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/.
This commit is contained in:
1 parent
57f037b62b
commit
203478268c
8 files changed
+6399
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,694 @@
|
||||
# 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.
|
||||
Reference in new issue
Block a user