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:
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