Files
idlegame/docs/superpowers/plans/2026-06-11-m1-pr3-t31-automation.md
T
ginnoir 203478268c
CI / verify (push) Successful in 1m31s
CI / verify (pull_request) Successful in 1m2s
docs(m1): add PR3 shell UI spec and implementation plans
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/.
2026-06-11 20:01:30 -05:00

20 KiB
Raw Blame History

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:

  • tickGame when pushing to completedActionIds
  • executeInstant after yields
  • executeStoryAction after applyChoice

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
  1. Complete gather manually once → Auto button appears.
  2. Add to automation → runs when idle.
  3. 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 12
Story actions automatable after first perform Task 1 records story completions
Separate automation queue Tasks 23
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.