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/.
695 lines
20 KiB
Markdown
695 lines
20 KiB
Markdown
# 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 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.
|