# 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** ```typescript 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`: ```typescript manualCompletionCounts: Record; ``` Default `{}` in `createGameState`. Add helper: ```typescript 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: - `tickGame` when pushing to `completedActionIds` - `executeInstant` after yields - `executeStoryAction` after `applyChoice` Update `save.ts`: ```typescript 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** ```bash 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** ```typescript 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`: ```typescript 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** ```bash 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** ```typescript 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** ```typescript 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: ```typescript 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** ```bash 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** ```typescript 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`: ```typescript 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** ```bash 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** ```typescript 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** ```typescript export interface ActionView { // ...existing automationUnlocked: boolean; inAutomationQueue: boolean; loopEnabled: boolean; } ``` Map using `isAutomationUnlocked`, `state.automationQueue.includes(id)`, `state.enabledLoopActionIds[id]`. Add to `GameView`: ```typescript automationQueueIds: string[]; automationQueueNames: string[]; ``` - [ ] **Step 4: Run tests** Run: `pnpm test src/state/__tests__/viewModel.test.ts` - [ ] **Step 5: Commit** ```bash 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** ```typescript 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** ```bash 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** ```tsx export function AutomationBar() { const names = useGameStore((s) => s.automationQueueNames); const [importText, setImportText] = useState(''); return (

Automation

{names.length > 0 ? (
    {names.map((name, i) => (
  1. {i + 1}. {name}
  2. ))}
) : (

No automated processes.

)}