Capture the PR3 UX brainstorm: three-region shell, action columns by kind, story-via-actions, Story tab tree+log, and universal automation with shareable recipes. Includes T3.0/T3.1 implementation plans and M1 vertical slice plan. Also ignore local .worktrees/.
20 KiB
M1 PR3 T3.1 — Universal Automation & Recipes Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add universal automation — any action kind becomes automatable after first manual completion — with a separate automation queue, runner precedence (manual > automation > loop), and shareable text recipe export/import referencing content action ids.
Architecture: Pure engine modules automation.ts and recipe.ts own queue execution and text serialization; manualCompletionCounts gates unlock; runtime exposes automation UI commands; save v1 schema extended with defaults.
Tech Stack: TypeScript strict, Vitest, Zod 4, lz-string (optional compressed recipes), Biome, pnpm, React 19, Zustand.
Parent spec: docs/superpowers/specs/2026-06-11-m1-pr3-shell-ui-design.md §Automation
Prerequisite plan: docs/superpowers/plans/2026-06-11-m1-pr3-t30-shell-ui.md (T3.0 shell must merge first)
Branch: feat/m1-progression (continues after T3.0)
Closes: Gitea #7 (Automation unlock)
File map
| File | Responsibility |
|---|---|
src/engine/game.ts |
manualCompletionCounts; hook completion recording in tick/performAction |
src/engine/automation.ts |
Create — automation queue CRUD, runner, unlock checks |
src/engine/recipe.ts |
Create — multi-line + single-line parse/serialize/validate |
src/engine/__tests__/automation.test.ts |
Create — queue, unlock, precedence tests |
src/engine/__tests__/recipe.test.ts |
Create — round-trip, reject unknown/locked ids |
src/engine/save.ts |
Persist manualCompletionCounts, automationQueue |
src/state/viewModel.ts |
automationUnlocked, automationQueueNames on ActionView |
src/state/runtime.ts |
addToAutomation, removeFromAutomation, importRecipe, exportRecipe |
src/ui/AutomationBar.tsx |
Create — queue list, export/import textarea |
src/ui/PlayPanel.tsx |
Mount AutomationBar below columns |
src/ui/ActionCard.tsx |
Auto toggle when unlocked |
Task 1: manualCompletionCounts + record on completion
Files:
-
Modify:
src/engine/game.ts -
Modify:
src/engine/save.ts -
Modify:
src/engine/__tests__/game.test.ts -
Step 1: Write the failing test
describe('manualCompletionCounts', () => {
it('increments when a timed action completes', () => {
const content = testContentWithGroup(); // timed action with group/kind
const state = createGameState(content);
enqueueAction(state, content, 'gather_supplies');
tickGame(state, content, 3000);
expect(state.manualCompletionCounts.gather_supplies).toBe(1);
});
});
- Step 2: Run test to verify it fails
Run: pnpm test src/engine/__tests__/game.test.ts -t "manualCompletionCounts"
Expected: FAIL — property undefined.
- Step 3: Implement recording
Add to GameState:
manualCompletionCounts: Record<string, number>;
Default {} in createGameState.
Add helper:
export function recordManualCompletion(state: GameState, content: Content, actionId: string): void {
if (!content.actionsById[actionId]) return;
state.manualCompletionCounts[actionId] = (state.manualCompletionCounts[actionId] ?? 0) + 1;
}
Call from:
tickGamewhen pushing tocompletedActionIdsexecuteInstantafter yieldsexecuteStoryActionafterapplyChoice
Update save.ts:
manualCompletionCounts: z.record(z.string(), z.number()).default({}),
automationQueue: z.array(z.string()).default([]),
- Step 4: Run tests
Run: pnpm test src/engine/__tests__/game.test.ts src/engine/__tests__/save.test.ts
- Step 5: Commit
git add src/engine/game.ts src/engine/save.ts src/engine/__tests__/game.test.ts src/engine/__tests__/save.test.ts
git commit -m "feat(engine): track manualCompletionCounts on action complete"
Task 2: Automation unlock check
Files:
-
Create:
src/engine/automation.ts -
Create:
src/engine/__tests__/automation.test.ts -
Step 1: Write the failing test
import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema';
import { createGameState } from '../game';
import { isAutomationUnlocked, automationUnlockThreshold } from '../automation';
describe('isAutomationUnlocked()', () => {
const content = buildContent({
resources: [{ id: 'supplies', name: 'Supplies' }],
actions: [
{
id: 'gather_supplies',
name: 'Gather',
kind: 'timed',
group: { id: 'camp', label: 'Camp' },
durationMs: 1000,
yields: [{ resourceId: 'supplies', amount: 1 }],
automation: { unlockAfterManualCompletions: 1 },
},
],
});
it('is false before first manual completion', () => {
const state = createGameState(content);
expect(isAutomationUnlocked(state, content, 'gather_supplies')).toBe(false);
});
it('is true after threshold met', () => {
const state = createGameState(content);
state.manualCompletionCounts.gather_supplies = 1;
expect(isAutomationUnlocked(state, content, 'gather_supplies')).toBe(true);
});
});
- Step 2: Run test to verify it fails
Run: pnpm test src/engine/__tests__/automation.test.ts
Expected: FAIL — module not found.
- Step 3: Implement unlock helpers
Create src/engine/automation.ts:
import type { Content } from '../content/schema';
import type { GameState } from './game';
export function automationUnlockThreshold(content: Content, actionId: string): number {
return content.actionsById[actionId]?.automation?.unlockAfterManualCompletions ?? 1;
}
export function isAutomationUnlocked(state: GameState, content: Content, actionId: string): boolean {
const threshold = automationUnlockThreshold(content, actionId);
return (state.manualCompletionCounts[actionId] ?? 0) >= threshold;
}
export function addToAutomationQueue(state: GameState, content: Content, actionId: string): void {
if (!content.actionsById[actionId]) {
throw new Error(`Unknown action "${actionId}"`);
}
if (!isAutomationUnlocked(state, content, actionId)) {
throw new Error(`Action "${actionId}" is not automation-unlocked`);
}
if (!state.automationQueue.includes(actionId)) {
state.automationQueue.push(actionId);
}
}
export function removeFromAutomationQueue(state: GameState, index: number): void {
if (index < 0 || index >= state.automationQueue.length) {
throw new RangeError(`Automation queue index ${index} is out of range`);
}
state.automationQueue.splice(index, 1);
}
export function clearAutomationQueue(state: GameState): void {
state.automationQueue.length = 0;
}
Add automationQueue: string[] to GameState (default []).
- Step 4: Run tests
Run: pnpm test src/engine/__tests__/automation.test.ts
- Step 5: Commit
git add src/engine/automation.ts src/engine/game.ts src/engine/__tests__/automation.test.ts
git commit -m "feat(engine): automation unlock and queue CRUD"
Task 3: Automation runner + precedence
Files:
-
Modify:
src/engine/automation.ts -
Modify:
src/engine/game.ts -
Modify:
src/engine/__tests__/automation.test.ts -
Step 1: Write the failing test
describe('maybeRunAutomation()', () => {
it('starts first affordable automation action when manual queue idle', () => {
const state = createGameState(content);
state.manualCompletionCounts.gather_supplies = 1;
state.automationQueue = ['gather_supplies'];
maybeRunAutomation(state, content);
expect(state.activeActionId).toBe('gather_supplies');
});
it('does not run when manual queue has items', () => {
const state = createGameState(content);
state.actionQueue = ['gather_supplies'];
state.automationQueue = ['gather_supplies'];
state.manualCompletionCounts.gather_supplies = 1;
maybeRunAutomation(state, content);
expect(state.activeActionId).toBeNull();
});
});
- Step 2: Run test to verify it fails
Run: pnpm test src/engine/__tests__/automation.test.ts -t "maybeRunAutomation"
- Step 3: Implement maybeRunAutomation
import { isActionAvailable, type GameState } from './game';
export function maybeRunAutomation(state: GameState, content: Content): void {
if (state.activeActionId !== null || state.actionQueue.length > 0) return;
for (const actionId of state.automationQueue) {
if (!isAutomationUnlocked(state, content, actionId)) continue;
if (!isActionAvailable(state, content, actionId)) continue;
const action = content.actionsById[actionId];
if (!action) continue;
switch (action.kind) {
case 'instant':
// execute inline without queue
break;
case 'timed':
case 'loop':
beginAction(state, content, actionId); // export beginAction or duplicate
return;
case 'story':
executeStoryAction(state, content, actionId);
return;
case 'context':
continue;
default:
continue;
}
}
}
Precedence wiring in game.ts startNextFromQueue:
After manual queue exhausts and sets idle:
maybeRunAutomation(state, content);
maybeStartLoopAction(state, content);
Order: manual timed completes → automation → loop.
Refactor beginAction to export if needed (or add startActionById internal export).
For automation instant actions: execute inline in runner, re-advance automation index.
- Step 4: Run automation + game tests
Run: pnpm test src/engine
- Step 5: Commit
git add src/engine/automation.ts src/engine/game.ts src/engine/__tests__/automation.test.ts
git commit -m "feat(engine): automation runner with manual-first precedence"
Task 4: Recipe export/import
Files:
-
Create:
src/engine/recipe.ts -
Create:
src/engine/__tests__/recipe.test.ts -
Step 1: Write the failing test
import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema';
import { createGameState } from '../game';
import { exportRecipe, importRecipe, RECIPE_HEADER_V1 } from '../recipe';
describe('recipe export/import', () => {
const content = buildContent({
resources: [{ id: 'supplies', name: 'Supplies' }],
actions: [
{
id: 'gather_supplies',
name: 'Gather',
kind: 'timed',
group: { id: 'camp', label: 'Camp' },
durationMs: 1000,
yields: [{ resourceId: 'supplies', amount: 1 }],
},
{
id: 'rest',
name: 'Rest',
kind: 'loop',
group: { id: 'camp_loop', label: 'Camp' },
durationMs: 1000,
yields: [{ resourceId: 'supplies', amount: 1 }],
},
],
});
it('round-trips multi-line format', () => {
const state = createGameState(content);
state.manualCompletionCounts = { gather_supplies: 1, rest: 1 };
state.automationQueue = ['gather_supplies', 'rest'];
const text = exportRecipe(state, content, { name: 'Camp loop' });
expect(text).toContain(RECIPE_HEADER_V1);
expect(text).toContain('gather_supplies');
const fresh = createGameState(content);
fresh.manualCompletionCounts = { gather_supplies: 1, rest: 1 };
importRecipe(fresh, content, text);
expect(fresh.automationQueue).toEqual(['gather_supplies', 'rest']);
});
it('rejects unknown action ids', () => {
const state = createGameState(content);
expect(() =>
importRecipe(state, content, `${RECIPE_HEADER_V1}\nnot_real`),
).toThrow(/unknown/i);
});
it('rejects locked action ids', () => {
const state = createGameState(content);
const text = `${RECIPE_HEADER_V1}\ngather_supplies`;
expect(() => importRecipe(state, content, text)).toThrow(/locked/i);
});
it('parses single-line alias', () => {
const state = createGameState(content);
state.manualCompletionCounts.gather_supplies = 1;
importRecipe(state, content, `${RECIPE_HEADER_V1}:gather_supplies`);
expect(state.automationQueue).toEqual(['gather_supplies']);
});
});
- Step 2: Run test to verify it fails
Run: pnpm test src/engine/__tests__/recipe.test.ts
- Step 3: Implement recipe.ts
Create src/engine/recipe.ts:
import type { Content } from '../content/schema';
import type { GameState } from './game';
import { clearAutomationQueue, isAutomationUnlocked, addToAutomationQueue } from './automation';
export const RECIPE_HEADER_V1 = 'idlegame-recipe/v1';
const ACTION_ID_RE = /^[a-z][a-z0-9_]*$/;
export interface RecipeMeta {
name?: string;
}
export function exportRecipe(state: GameState, content: Content, meta?: RecipeMeta): string {
const lines = [RECIPE_HEADER_V1];
if (meta?.name) lines.push(`# name: ${meta.name}`);
for (const id of state.automationQueue) {
if (content.actionsById[id]) lines.push(id);
}
return lines.join('\n');
}
export function parseRecipeLines(text: string): { name?: string; actionIds: string[] } {
const trimmed = text.trim();
if (trimmed.startsWith(`${RECIPE_HEADER_V1}:`)) {
const ids = trimmed.slice(RECIPE_HEADER_V1.length + 1).split(',').map((s) => s.trim()).filter(Boolean);
return { actionIds: ids };
}
const lines = trimmed.split(/\r?\n/);
if (lines[0]?.trim() !== RECIPE_HEADER_V1) {
throw new Error(`Invalid recipe header; expected "${RECIPE_HEADER_V1}"`);
}
let name: string | undefined;
const actionIds: string[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i]?.trim() ?? '';
if (!line) continue;
if (line.startsWith('# name:')) {
name = line.slice('# name:'.length).trim();
continue;
}
if (line.startsWith('#')) continue;
if (!ACTION_ID_RE.test(line)) {
throw new Error(`Invalid action id "${line}"`);
}
actionIds.push(line);
}
return { name, actionIds };
}
export function importRecipe(state: GameState, content: Content, text: string): { name?: string } {
const { name, actionIds } = parseRecipeLines(text);
const unknown = actionIds.filter((id) => !content.actionsById[id]);
if (unknown.length > 0) {
throw new Error(`Unknown action ids: ${unknown.join(', ')}`);
}
const locked = actionIds.filter((id) => !isAutomationUnlocked(state, content, id));
if (locked.length > 0) {
throw new Error(`Automation locked for: ${locked.join(', ')}`);
}
clearAutomationQueue(state);
for (const id of actionIds) {
addToAutomationQueue(state, content, id);
}
return { name };
}
- Step 4: Run tests
Run: pnpm test src/engine/__tests__/recipe.test.ts
- Step 5: Commit
git add src/engine/recipe.ts src/engine/__tests__/recipe.test.ts
git commit -m "feat(engine): automation recipe export and import"
Task 5: View model automation fields
Files:
-
Modify:
src/state/viewModel.ts -
Modify:
src/state/__tests__/viewModel.test.ts -
Step 1: Write the failing test
it('marks automationUnlocked on actions after manual completion', () => {
state.manualCompletionCounts.gather_supplies = 1;
const view = toView(state, content);
const action = view.actionColumns
.flatMap((c) => c.groups)
.flatMap((g) => g.actions)
.find((a) => a.id === 'gather_supplies');
expect(action?.automationUnlocked).toBe(true);
});
- Step 2: Run test to verify it fails
Run: pnpm test src/state/__tests__/viewModel.test.ts -t "automationUnlocked"
- Step 3: Extend ActionView
export interface ActionView {
// ...existing
automationUnlocked: boolean;
inAutomationQueue: boolean;
loopEnabled: boolean;
}
Map using isAutomationUnlocked, state.automationQueue.includes(id), state.enabledLoopActionIds[id].
Add to GameView:
automationQueueIds: string[];
automationQueueNames: string[];
- Step 4: Run tests
Run: pnpm test src/state/__tests__/viewModel.test.ts
- Step 5: Commit
git add src/state/viewModel.ts src/state/__tests__/viewModel.test.ts
git commit -m "feat(state): automation fields on action view model"
Task 6: Runtime automation commands
Files:
-
Modify:
src/state/runtime.ts -
Step 1: Add runtime methods
toggleAutomation(actionId: string): void {
const state = this.state;
if (!state) return;
try {
if (state.automationQueue.includes(actionId)) {
const idx = state.automationQueue.indexOf(actionId);
removeFromAutomationQueue(state, idx);
} else {
addToAutomationQueue(state, content, actionId);
}
this.publish();
} catch (err) {
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Automation failed');
}
}
exportAutomationRecipe(): string {
const state = this.state;
if (!state) return '';
return exportRecipe(state, content);
}
importAutomationRecipe(text: string): void {
const state = this.state;
if (!state) return;
try {
const meta = importRecipe(state, content, text);
useGameStore.getState().appendLog(meta.name ? `Imported recipe: ${meta.name}` : 'Imported recipe.');
this.publish();
} catch (err) {
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Import failed');
}
}
- Step 2: Commit
git add src/state/runtime.ts
git commit -m "feat(state): runtime automation and recipe commands"
Task 7: AutomationBar UI
Files:
-
Create:
src/ui/AutomationBar.tsx -
Modify:
src/ui/PlayPanel.tsx -
Modify:
src/ui/ActionCard.tsx -
Step 1: Create AutomationBar
export function AutomationBar() {
const names = useGameStore((s) => s.automationQueueNames);
const [importText, setImportText] = useState('');
return (
<section aria-label="Automation" className="mt-4 rounded-lg border border-slate-700 p-3">
<h2 className="mb-2 font-medium text-slate-300 text-sm uppercase tracking-wide">Automation</h2>
{names.length > 0 ? (
<ol className="mb-2 text-slate-300 text-sm">
{names.map((name, i) => (
<li key={name}>{i + 1}. {name}</li>
))}
</ol>
) : (
<p className="mb-2 text-slate-500 text-sm">No automated processes.</p>
)}
<div className="flex gap-2">
<button type="button" onClick={() => navigator.clipboard.writeText(gameRuntime.exportAutomationRecipe())}>
Copy recipe
</button>
</div>
<textarea
className="mt-2 w-full rounded border border-slate-600 bg-slate-900 p-2 text-slate-200 text-xs"
rows={4}
placeholder="Paste recipe text…"
value={importText}
onChange={(e) => setImportText(e.target.value)}
/>
<button type="button" className="mt-1" onClick={() => { gameRuntime.importAutomationRecipe(importText); setImportText(''); }}>
Import recipe
</button>
</section>
);
}
- Step 2: ActionCard auto toggle
When action.automationUnlocked, show small "Auto" button calling gameRuntime.toggleAutomation(action.id); highlight when action.inAutomationQueue.
- Step 3: Mount in PlayPanel
Add <AutomationBar /> below column grid.
- Step 4: Browser smoke
- Complete gather manually once → Auto button appears.
- Add to automation → runs when idle.
- Export recipe → paste in fresh session (with unlocks) → import restores queue.
- Step 5: Commit
git add src/ui/AutomationBar.tsx src/ui/PlayPanel.tsx src/ui/ActionCard.tsx
git commit -m "feat(ui): automation bar with recipe export/import"
Task 8: Integration verify
- Step 1: Run pre-PR chain
pnpm typecheck
pnpm lint
pnpm test:coverage
pnpm build
Expected: all green; engine ≥80%.
- Step 2: Commit if doc touch needed
Update docs/architecture.md §Automation with recipe format example.
git add docs/architecture.md
git commit -m "docs: document automation queue and recipe format"
Spec coverage self-review
| Spec requirement | Task |
|---|---|
| Universal automation after first manual completion | Tasks 1–2 |
| Story actions automatable after first perform | Task 1 records story completions |
| Separate automation queue | Tasks 2–3 |
| Precedence manual > automation > loop | Task 3 |
| Multi-line recipe format | Task 4 |
| Single-line alias | Task 4 |
| Reject unknown/locked ids on import | Task 4 |
| Export/import UI | Task 7 |
| Offline automation | Task 3 runner uses same tickGame path |
| Compressed recipes | Optional — defer unless queue >20 steps |
Placeholder scan
No TBD/TODO. All steps include paths and code.