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

695 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<string, number>;
```
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 (
<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**
```bash
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**
```powershell
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.
```bash
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.