diff --git a/.gitignore b/.gitignore index 962cb5d..19a68af 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ coverage/ .claude/settings.local.json .vite/ +# Git worktrees (local isolation) +.worktrees/ + # Logs *.log npm-debug.log* diff --git a/docs/plans/2026-06-11-m1-vertical-slice.md b/docs/plans/2026-06-11-m1-vertical-slice.md new file mode 100644 index 0000000..2a68efc --- /dev/null +++ b/docs/plans/2026-06-11-m1-vertical-slice.md @@ -0,0 +1,342 @@ +# Idlegame — M1 Vertical Slice Plan (handoff artifact for superpowers) + +> **How to use this file:** the M1 brainstorm is complete; everything below is locked (approved by ginnoir 2026-06-11). Hand this plan to superpowers for execution. Tasks are grouped into **four phased PR batches** plus a **Phase 0 vault gate** for story content. Each phase ends at a browser-playable checkpoint. One tool per branch; everything lands via PR. +> +> **Canonical location:** `docs/plans/2026-06-11-m1-vertical-slice.md` in the working dir. + +## Context + +M0 is complete (2026-06-11). The repo ships a walking skeleton: one resource, one timed action, fixed-timestep tick loop with offline catch-up, versioned saves (v1, reject-on-mismatch), Zustand bridge, minimal React shell, CI + internal playtest deploy at `https://idlegame.ginnoir.com/`. Vault notes exist: `Idlegame/GDD.md`, `Idlegame/Decisions.md`, `Idlegame/Story/Outline.md` (beat skeleton placeholder — prose TBD). + +**M1 scope:** turn the skeleton into the first playable vertical slice — one short story arc through the first prestige reset, with a meaningful branching choice, one automation unlock, offline progress, and save/load (including migration scaffolding). Ten Gitea issues (#3–#12) under milestone **M1 Vertical Slice** map to this plan. + +**Execution mode (locked):** phased PR batches with playable checkpoints after each merge — not one mega-run, not one-PR-per-issue. + +## M1 success criteria + +A player on the internal playtest build can: + +1. Queue multiple timed actions and watch resources accrue with costs/unlocks respected. +2. Read story passages and make a branching choice where routes A and B produce visibly different outcomes (flags, resources, or event-log entries). +3. Unlock automation for at least one action after completing it manually once. +4. Finish the opening arc and trigger the first prestige reset, retaining knowledge for catch-up on the next run. +5. Reload, export/import a save, and play comfortably on a phone viewport. + +## Locked M1 decisions (brainstorm, 2026-06-11) + +| Decision | Choice | +|---|---| +| Phase structure | **Approach 1 — playable-first ladder:** 4 PRs + Phase 0 vault work | +| Story timing | Phase 0 outline runs **in parallel** with PR1–2; stub prose proves branch mechanics; **ginnoir review gate** before PR3 merges real content | +| Stub content | Placeholder names/prose allowed in PR1–2; replaced entirely in PR3 | +| Prestige depth | First reset only — within-layer catch-up (knowledge flags, fast-forward); **no new mechanics between layers** (post-M1) | +| Save version | Bump to v2 in PR4 when story/queue/automation/prestige fields land; v1→v2 migration required | +| Balance | Tunable constants in content defs; notes filed in vault during T4.3 | + +Design pillars unchanged from M0 — see `Idlegame/GDD.md` and `docs/plans/2026-06-11-m0-bootstrap.md` §Locked design pillars. + +## Starting codebase state + +``` +src/engine/ game.ts (single active action, no queue) + tickLoop.ts (accumulator + maxTicks cap) + save.ts (SAVE_VERSION=1, reject mismatch) + num.ts +src/content/ one resource (gold), one action (forage) +src/state/ runtime (RAF loop), persistence (idb + localStorage) +src/ui/ ResourceBar, ActionPanel, EventLog stubs +``` + +Architecture boundaries unchanged — see `docs/architecture.md`. + +## Phase map + +| Phase | Branch | Closes Gitea | Playable checkpoint | +|---|---|---|---| +| **0** | *(vault, no PR)* | prep for #11 | Opening arc beats outlined; prose drafted for ginnoir review | +| **1** | `feat/m1-foundations` | #3, #4 | Queue 3+ actions; multi-resource costs/unlocks work | +| **2** | `feat/m1-playable-loop` | #5, #6 | Stub story branch — pick A vs B, outcomes differ | +| **3** | `feat/m1-progression` | #7, #8, #11 | Full arc → prestige; second run catches up | +| **4** | `feat/m1-polish` | #9, #10, #12 | Mobile-ready; save export/import UX; balance tuned; M1 closed | + +## Dependency graph + +```text +Phase 0 (outline) ──────────────────────────────┐ + │ gate before PR3 content +PR1 foundations ──► PR2 story+queue UI ──► PR3 progression ──► PR4 polish + │ │ │ + └─ stub content └─ stub story graph └─ real content replaces stubs +``` + +PR1 must merge before PR2 (story graph assumes expanded game state + queue). PR3 requires Phase 0 approval for T3.3. PR4 assumes all gameplay systems exist. + +--- + +## Phase 0 — Story outline (vault, parallel with PR1–2) + +**Tool:** Claude Code (+ ginnoir review in chat/Cursor) + +**Work:** + +1. Fill `Idlegame/Story/Outline.md` §M1 arc beat skeleton: Hook → First loop → Branch (routes A/B) → Automation beat → Prestige climax. +2. Draft passage prose for each beat; ginnoir reviews/edits in vault. +3. Record any new design decisions in `Idlegame/Decisions.md`. + +**Gate:** ginnoir approves the outline before PR3 branch `feat/m1-progression` merges. Does **not** block PR1 or PR2. + +**Verify:** vault note has all five beats populated; ginnoir sign-off recorded in session log or Decisions. + +--- + +## PR1 — Foundations (`feat/m1-foundations`) + +**Primary tool:** Codex (GPT-5.5-Codex medium). Alt: Cursor agent (Composer 2.5). + +**Gitea closes:** #3 Engine tick and offline hardening, #4 Resource and action definition set. + +### T1.1 — Engine tick and offline hardening (#3) + +- Expand determinism regression tests: same elapsed wall time → same tick count and game state regardless of `advance()` chunking. +- Document offline behavior: tick loop `maxTicks` batching vs save-layer `DEFAULT_MAX_OFFLINE_MS` clamp (already in `save.ts`). +- Add/extend engine purity guard: no React/DOM imports under `src/engine/` (test or lint rule if lightweight). +- Keep `tickGame` pure; queue logic lands in T1.3. + +*Verify:* `pnpm test:coverage` green; engine ≥80%; determinism test passes. + +### T1.2 — Content schema expansion (#4) + +- Extend Zod schemas in `src/content/`: action **costs** (deduct on **start**; reject enqueue/start if unaffordable), **unlock conditions** (resource thresholds and/or story flags placeholder field for PR2), optional multi-yield. Record in `Idlegame/Decisions.md` as D-0010 if not already covered. +- Validation tests for malformed defs. + +*Verify:* schema tests green; invalid content rejected at load boundary. + +### T1.3 — Action queue engine + +- Extend `GameState`: `actionQueue: string[]` (ordered action ids), retain `activeActionId` + `actionElapsedMs`. +- API: `enqueueAction`, `cancelQueuedAction(index)`, `clearQueue` (if scoped); on action completion, auto-start next queued action. +- `tickGame` unchanged semantics for active action; completion handler dequeues. +- Unit tests: queue ordering, cancel, auto-advance, empty queue idle. + +*Verify:* queue tests green; no UI required yet. + +### T1.4 — Stub content pack + +- Replace walking-skeleton defs with M1-shaped placeholder set: **2 resources**, **4–5 timed actions** with costs/unlocks (generic names like "Supplies", "Scout the path"). +- Keep human-readable durations (seconds-scale for dev; balance in PR4). + +*Verify:* action completion + cost/unlock accrual tests green. + +### PR1 integration verify + +```powershell +pnpm typecheck +pnpm lint +pnpm test:coverage +pnpm build +``` + +Manual playtest: enqueue 3 actions → they run sequentially → resources reflect costs/yields → reload preserves queue state. + +**PR:** conventional commits, link `Closes #3`, `Closes #4` in merge commit or PR body. + +--- + +## PR2 — Playable loop (`feat/m1-playable-loop`) + +**Primary tools:** Codex (T2.1–T2.2 engine), Antigravity (T2.3–T2.5 UI + browser verify). + +**Gitea closes:** #5 Action queue UI, #6 Story graph and first branch. + +### T2.1 — Story graph engine (#6) + +- New module `src/engine/story.ts` (or `storyGraph.ts`): traverse nodes, evaluate choice requirements, apply outcomes (set flags, grant/consume resources, route to next node). +- Extend `GameState`: `storyFlags: Record`, `currentStoryNodeId: string`, `seenStoryNodeIds: string[]` (for prestige catch-up in PR3). +- Emit story events for the event log (pure data — UI renders). +- Unit tests: linear traversal, branch by choice, gated node blocked until requirements met. + +*Verify:* story traversal tests green; engine stays React-free. + +### T2.2 — Story content schema + +- Zod-validated story node defs in `src/content/`: id, prose, choices (label, requirements, outcomes, target node), auto-advance nodes optional. +- Wire into content loader alongside resources/actions. + +*Verify:* schema validation tests; invalid graph (dangling node id) fails at load. + +### T2.3 — Action queue UI (#5) + +- Upgrade `ActionPanel`: enqueue button per unlocked action, visible queue list, cancel queued item, active action progress bar (extend M0 pattern). +- Disabled state when action locked or unaffordable. + +*Verify:* browser — queue 2+ actions, cancel one, watch order respected. + +### T2.4 — Story panel UI + +- New component: current passage prose, choice buttons when node has choices, integrate with runtime commands. +- Event log receives story transition entries. + +*Verify:* browser — story panel renders; choice click advances node. + +### T2.5 — Stub story graph + +- ~6 nodes, **one branching choice** (routes A/B), placeholder prose proving mechanics. +- Hook stub graph to stub actions (e.g., branch unlocks different actions). + +*Verify:* browser — complete route A vs route B → different flags/resources/log entries. + +### PR2 integration verify + +Full pre-PR chain + desktop browser smoke. Stub prose is explicitly temporary. + +**PR:** link `Closes #5`, `Closes #6`. + +--- + +## PR3 — Progression (`feat/m1-progression`) + +**Gate:** Phase 0 outline approved by ginnoir. + +**Primary tools:** Claude Code (T3.3 content encode + prose fidelity), Codex (T3.1–T3.2 engine). + +**Gitea closes:** #7 Automation unlock, #8 First prestige reset, #11 Opening arc content. + +### T3.1 — Automation unlock (#7) + +- Track per-action manual completion count in `GameState`. +- After first manual completion, action becomes **automatable** (toggle or auto-enqueue repeat — pick enqueue-repeat for M1 simplicity). +- Automation respects costs; stops if unaffordable (document behavior). +- In-fiction story beat references automation (content in T3.3). + +*Verify:* tests — locked before first completion, unlocked after; automated repeats fire. + +### T3.2 — First prestige reset (#8) + +- Prestige trigger: story node or dedicated action at arc end. +- Reset: clear run resources, queue, active action; **retain** `prestigeLayer`, `knowledgeFlags` / `seenStoryNodeIds`, automation unlocks. +- Catch-up: known story nodes fast-forward (skip prose or abbreviated passage — implement minimal fast-forward for M1). +- `prestigeCount` increment; new prestige/story fields added to runtime + save payload in PR3 (still `SAVE_VERSION=1` until PR4); PR4 bumps version and ships v1→v2 migration. + +*Verify:* tests — reset clears run state, retains knowledge; second run skips/fast-forwards seen nodes; browser full arc → prestige → new run starts faster. + +### T3.3 — Opening arc content (#11) + +- Replace stub resources/actions/story graph with vault-approved opening arc from `Idlegame/Story/Outline.md`. +- Encode prose into `src/content/` story defs; link vault note in PR description. +- Branch choice must **matter** (different content, not flavor-only — per GDD). + +*Verify:* content schema tests; ginnoir in-app text review; vault cross-link present. + +### PR3 integration verify + +Full pre-PR chain. Playtest: start → branch → automation unlock → prestige → second run with catch-up. File brief playtest notes in vault. + +**PR:** link `Closes #7`, `Closes #8`, `Closes #11`. + +--- + +## PR4 — Polish & close (`feat/m1-polish`) + +**Primary tools:** Codex (T4.1 save), Antigravity (T4.2 mobile), Claude Code + ginnoir (T4.3 balance). + +**Gitea closes:** #9 Save migrations and import/export UX, #10 Mobile layout pass, #12 Balance pass. + +### T4.1 — Save migrations and import/export UX (#9) + +- Bump `SAVE_VERSION` to 2; implement v1→v2 migration (add new fields with sane defaults). +- Migration registry pattern for future versions. +- Settings UI: export save string (copy), import with validation + explicit confirm (overwrite). +- Tampered import rejected with user-visible error. + +*Verify:* migration unit tests; tamper rejection; browser export → clear storage → import → state restored. + +### T4.2 — Mobile layout pass (#10) + +- 375px viewport: no clipping/overlap; touch targets adequate; story panel + queue usable one-handed. +- Desktop unchanged unless fixes apply globally. + +*Verify:* Antigravity screenshots desktop + mobile; core flow works on mobile viewport. + +### T4.3 — Balance pass (#12) + +- Tune action durations, resource rates, choice cadence, time-to-first-prestige for semi-active pacing (~few-minute choice cadence target from GDD). +- Document tuning rationale in vault (`Idlegame/Decisions.md` or new `Idlegame/Balance/M1.md`). + +*Verify:* timed playtest observations recorded; ginnoir sign-off on feel. + +### T4.4 — Milestone close + +- Update `Idlegame/_Claude.md` session log; GDD milestone map (M1 complete, M2 TBD). +- Close Gitea milestone **M1 Vertical Slice**; ensure #3–#12 closed. +- README current-scope blurb → M1 complete. + +*Verify:* vault updated; Gitea milestone closed; `main` deploy green. + +### PR4 integration verify + +Full pre-PR chain. Final playtest on `idlegame.ginnoir.com`. + +**PR:** link `Closes #9`, `Closes #10`, `Closes #12`. + +--- + +## AI tool routing (M1) + +One tool per branch at a time. Default: phase owner merges before next phase starts. + +| Phase / task | Tool | Model tier | Why | +|---|---|---|---| +| Phase 0 outline + prose | Claude Code | Opus 4.8 | Story/design lane | +| PR1 T1.x | Codex | GPT-5.5-Codex medium | Scoped engine + TDD | +| PR2 T2.1–T2.2 | Codex | GPT-5.5-Codex medium | Story graph engine | +| PR2 T2.3–T2.5 | Antigravity | Gemini 3 Pro | Browser verification | +| PR3 T3.1–T3.2 | Codex | GPT-5.5-Codex highest | Prestige = state integrity surface | +| PR3 T3.3 | Claude Code | Opus 4.8 | Vault prose → content defs | +| PR4 T4.1 | Codex | GPT-5.5-Codex medium | Save migration discipline | +| PR4 T4.2 | Antigravity | Gemini 3 Pro | Mobile screenshots | +| PR4 T4.3 | Claude Code + ginnoir | Opus 4.8 / chat | Balance + playtest feel | +| Ginnoir review gates | Cursor / chat | Composer 2.5 | Phase 0 prose, balance sign-off | + +Cursor is the interactive lane for scoped tweaks anytime ginnoir is driving. + +## Error handling expectations + +- Invalid saves, content, or import strings: **fail explicitly** at boundary with clear errors (existing M0 pattern). +- Unknown action/story node ids: throw in engine API (dev-time content errors, not player-facing soft-fail). +- Migration failure: reject load, preserve prior save if possible, surface message in UI. + +## Testing expectations + +- New engine behavior: unit tests required; maintain ≥80% coverage on `src/engine/`. +- UI phases: browser smoke required (Antigravity or manual); Playwright deferred post-M1. +- Each PR runs the full pre-PR verification chain before merge. + +## Out of scope (M1) + +- Playwright E2E suite +- Second prestige layer or new mechanics between layers +- Cloud saves / accounts +- Public playtest (internal-only Caddy remains) +- Art pipeline, battle screen, i18n +- Steam/Tauri / Capacitor wraps + +## Gitea issue index + +| Issue | Title | Phase | +|---|---|---| +| #3 | Engine tick and offline hardening | PR1 | +| #4 | Resource and action definition set | PR1 | +| #5 | Action queue UI | PR2 | +| #6 | Story graph and first branch | PR2 | +| #7 | Automation unlock | PR3 | +| #8 | First prestige reset | PR3 | +| #11 | Opening arc content | PR3 | +| #9 | Save migrations and import/export UX | PR4 | +| #10 | Mobile layout pass | PR4 | +| #12 | Balance pass | PR4 | + +Plane epics (Vertical Slice, Game Design) link these issues by URL — no Plane task duplication required during execution. + +--- + +*Brainstorm approved by ginnoir 2026-06-11. Next step after spec review: invoke `writing-plans` for task-level implementation plans per PR if desired, or begin Phase 0 / PR1 directly via `executing-plans`.* diff --git a/docs/superpowers/plans/2026-06-11-m1-pr1-foundations.md b/docs/superpowers/plans/2026-06-11-m1-pr1-foundations.md new file mode 100644 index 0000000..3709f99 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-m1-pr1-foundations.md @@ -0,0 +1,1342 @@ +# M1 PR1 — Foundations 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:** Ship PR1 (`feat/m1-foundations`): hardened tick/offline determinism, expanded content schemas (costs, unlocks, multi-yield), action queue engine, stub M1 content pack, and save persistence for the queue — closing Gitea #3 and #4. + +**Architecture:** Extend the pure `src/engine/game.ts` state machine with `actionQueue` and completion-driven advancement (actions no longer auto-repeat; queue drains on completion). Content validation stays in `src/content/schema.ts` via Zod; costs deduct on **start** per D-0012. Save v1 schema grows to include `actionQueue` (formal v1→v2 migration waits for PR4). Runtime exposes `enqueueAction` for manual playtest; full queue UI is PR2. + +**Tech Stack:** TypeScript strict, Vitest, Zod 4, Biome, pnpm. No React in engine. + +**Parent spec:** `docs/plans/2026-06-11-m1-vertical-slice.md` (PR1 section). + +**Branch:** `feat/m1-foundations` off `main`. + +--- + +## File map + +| File | Responsibility | +|---|---| +| `src/engine/game.ts` | `GameState` + queue, affordability, unlock checks, `enqueueAction`, completion handler | +| `src/engine/__tests__/game.test.ts` | Queue, costs, unlocks, completion advancement | +| `src/engine/__tests__/determinism.test.ts` | **Create** — tick loop + game integration determinism | +| `src/engine/__tests__/purity.test.ts` | **Create** — engine must not import React/DOM | +| `src/engine/save.ts` | Extend `gameStateSchema` with `actionQueue` | +| `src/engine/__tests__/save.test.ts` | Round-trip queue in saves | +| `src/content/schema.ts` | Costs, unlocks, multi-yield yields | +| `src/content/__tests__/schema.test.ts` | Schema validation cases | +| `src/content/definitions.ts` | Stub M1 content (2 resources, 4–5 actions) | +| `src/state/persistence.ts` | Hydrate `actionQueue` on load | +| `src/state/__tests__/persistence.test.ts` | Queue survives save/load | +| `src/state/runtime.ts` | `enqueueAction` command (replaces direct `startAction` use) | +| `src/state/viewModel.ts` | Expose `queuedActionIds` for minimal visibility | +| `src/state/__tests__/viewModel.test.ts` | View model queue field | +| `docs/architecture.md` | Note queue + completion semantics | + +--- + +### Task 1: Determinism integration test + +**Files:** +- Create: `src/engine/__tests__/determinism.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +import { describe, expect, it } from 'vitest'; +import { buildContent } from '../../content/schema'; +import { createGameState, enqueueAction } from '../game'; +import { advance, createTickLoop, TICK_MS } from '../tickLoop'; + +function testContent() { + return buildContent({ + resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], + actions: [ + { + id: 'forage', + name: 'Forage', + durationMs: 300, + yields: [{ resourceId: 'gold', amount: 1 }], + }, + ], + }); +} + +function simulate(elapsedMs: number, chunkMs: number) { + const content = testContent(); + const state = createGameState(content); + enqueueAction(state, content, 'forage'); + + const loop = createTickLoop({ tickMs: TICK_MS, startNow: 0 }); + let now = 0; + while (now < elapsedMs) { + const next = Math.min(now + chunkMs, elapsedMs); + advance(loop, next, () => { + // tickGame imported once Task 6 lands; for now this file won't compile — that's the red step. + }); + now = next; + } + return { tickCount: loop.tickCount, gold: state.resources.gold }; +} + +describe('determinism integration', () => { + it('produces identical tick counts and game state regardless of advance chunking', () => { + const whole = simulate(5000, 5000); + const chunked = simulate(5000, 37); + expect(chunked.tickCount).toBe(whole.tickCount); + expect(chunked.gold).toBe(whole.gold); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test src/engine/__tests__/determinism.test.ts` + +Expected: FAIL — `enqueueAction` not exported / `tickGame` not wired. + +- [ ] **Step 3: Wire `tickGame` into the test (minimal fix for this task)** + +Replace the `advance` callback body with: + +```typescript +import { tickGame } from '../game'; + +// inside advance callback: +tickGame(state, content, TICK_MS); +``` + +Re-run after Task 6 exports `enqueueAction`. **This task completes when the determinism test passes after Task 6–7 land.** If implementing in order, leave this test file created but skipped (`it.skip`) until Task 7, then unskip. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm test src/engine/__tests__/determinism.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/__tests__/determinism.test.ts +git commit -m "test(engine): add tick loop + game determinism integration test" +``` + +--- + +### Task 2: Engine purity guard + +**Files:** +- Create: `src/engine/__tests__/purity.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const ENGINE_DIR = join(import.meta.dirname, '..'); +const FORBIDDEN = [/from\s+['"]react/, /from\s+['"]react-dom/, /from\s+['"]zustand/]; + +async function engineSourceFiles(): Promise { + const entries = await readdir(ENGINE_DIR, { withFileTypes: true }); + return entries + .filter((e) => e.isFile() && e.name.endsWith('.ts') && !e.name.endsWith('.test.ts')) + .map((e) => join(ENGINE_DIR, e.name)); +} + +describe('engine purity', () => { + it('does not import React, react-dom, or Zustand', async () => { + const files = await engineSourceFiles(); + expect(files.length).toBeGreaterThan(0); + for (const file of files) { + const source = await readFile(file, 'utf8'); + for (const pattern of FORBIDDEN) { + expect(source, `${file} must stay free of ${pattern}`).not.toMatch(pattern); + } + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it passes immediately** + +Run: `pnpm test src/engine/__tests__/purity.test.ts` + +Expected: PASS (M0 engine is already pure) + +- [ ] **Step 3: Commit** + +```bash +git add src/engine/__tests__/purity.test.ts +git commit -m "test(engine): guard against React/DOM imports in engine" +``` + +--- + +### Task 3: Document offline behavior + +**Files:** +- Modify: `src/engine/tickLoop.ts` (module doc comment) +- Modify: `src/engine/save.ts` (module doc comment) + +- [ ] **Step 1: Extend tickLoop module doc** + +At the top of `src/engine/tickLoop.ts`, ensure the module comment includes: + +```typescript +/** + * ... + * Offline catch-up uses this same path: a large `now` delta owes many ticks. + * `maxTicks` caps work per `advance()` call; the accumulator retains the + * remainder so no ticks are lost across calls. + * + * The save layer (`save.ts`) separately clamps how much *wall-clock* elapsed + * time is credited on load via `DEFAULT_MAX_OFFLINE_MS`. Tick loop batching + * and offline credit clamping are independent concerns. + */ +``` + +- [ ] **Step 2: Extend save module doc** + +At the top of `src/engine/save.ts`, add after the existing paragraph: + +```typescript +/** + * ... + * Offline credit: `applyOfflineProgress` floors elapsed ms to whole ticks + * (same `TICK_MS` as the live loop) and runs `tickGame` that many times. + * Elapsed beyond `DEFAULT_MAX_OFFLINE_MS` is not credited. + */ +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/engine/tickLoop.ts src/engine/save.ts +git commit -m "docs(engine): clarify offline tick batching vs save-layer clamp" +``` + +--- + +### Task 4: Content schema — multi-yield and costs + +**Files:** +- Modify: `src/content/schema.ts` +- Modify: `src/content/__tests__/schema.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Add to `src/content/__tests__/schema.test.ts`: + +```typescript +describe('costs and multi-yield', () => { + it('accepts optional costs and multiple yields', () => { + const content = buildContent({ + resources: [ + { id: 'supplies', name: 'Supplies', startAmount: 10 }, + { id: 'coin', name: 'Coin' }, + ], + actions: [ + { + id: 'trade', + name: 'Trade', + durationMs: 4000, + costs: [{ resourceId: 'supplies', amount: 3 }], + yields: [ + { resourceId: 'coin', amount: 2 }, + { resourceId: 'supplies', amount: 1 }, + ], + }, + ], + }); + expect(content.actionsById.trade.costs).toHaveLength(1); + expect(content.actionsById.trade.yields).toHaveLength(2); + }); + + it('rejects a cost referencing an unknown resource', () => { + expect(() => + buildContent({ + resources: [{ id: 'gold', name: 'Gold' }], + actions: [ + { + id: 'buy', + name: 'Buy', + durationMs: 1000, + costs: [{ resourceId: 'ghost', amount: 1 }], + yields: [{ resourceId: 'gold', amount: 1 }], + }, + ], + }), + ).toThrow(/unknown resource/i); + }); + + it('defaults costs to an empty array and yields to at least one entry', () => { + const content = buildContent({ + resources: [{ id: 'gold', name: 'Gold' }], + actions: [{ id: 'forage', name: 'Forage', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] }], + }); + expect(content.actionsById.forage.costs).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test src/content/__tests__/schema.test.ts` + +Expected: FAIL — `costs` / array `yields` not in schema. + +- [ ] **Step 3: Implement schema changes** + +Replace `actionDefSchema` and `buildContent` validation in `src/content/schema.ts`: + +```typescript +export const resourceAmountSchema = z.object({ + resourceId: z.string().min(1), + amount: z.number().positive(), +}); + +export const actionDefSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + durationMs: z.number().positive(), + costs: z.array(resourceAmountSchema).default([]), + yields: z.array(resourceAmountSchema).min(1), +}); + +export type ResourceAmount = z.infer; +``` + +In `buildContent`, after the yields resource check, add costs check: + +```typescript +for (const action of actions) { + for (const cost of action.costs) { + if (!resourcesById[cost.resourceId]) { + throw new Error( + `Action "${action.id}" costs unknown resource "${cost.resourceId}"`, + ); + } + } + for (const y of action.yields) { + if (!resourcesById[y.resourceId]) { + throw new Error( + `Action "${action.id}" yields unknown resource "${y.resourceId}"`, + ); + } + } +} +``` + +Remove the old single-object `yields` schema. + +- [ ] **Step 4: Update existing tests and definitions to array yields** + +In `src/content/__tests__/schema.test.ts`, change `validActions` yields to array form: + +```typescript +const validActions = [ + { + id: 'forage', + name: 'Forage', + durationMs: 3000, + yields: [{ resourceId: 'gold', amount: 1 }], + }, +]; +``` + +In `src/content/definitions.ts`: + +```typescript +export const actionDefs = [ + { + id: 'forage', + name: 'Forage for coin', + durationMs: 3000, + yields: [{ resourceId: 'gold', amount: 1 }], + }, +]; +``` + +Update every `buildContent` call in engine tests similarly (grep `yields:`). + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `pnpm test src/content/__tests__/schema.test.ts` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/content/schema.ts src/content/__tests__/schema.test.ts src/content/definitions.ts +git commit -m "feat(content): add action costs and multi-yield schema" +``` + +--- + +### Task 5: Content schema — unlock conditions + +**Files:** +- Modify: `src/content/schema.ts` +- Modify: `src/content/__tests__/schema.test.ts` + +- [ ] **Step 1: Write the failing tests** + +```typescript +describe('unlock conditions', () => { + it('accepts optional minResources and requireStoryFlags', () => { + const content = buildContent({ + resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], + actions: [ + { + id: 'scout', + name: 'Scout', + durationMs: 5000, + yields: [{ resourceId: 'gold', amount: 1 }], + unlock: { + minResources: { gold: 5 }, + requireStoryFlags: ['path_scouted'], + }, + }, + ], + }); + expect(content.actionsById.scout.unlock?.minResources?.gold).toBe(5); + expect(content.actionsById.scout.unlock?.requireStoryFlags).toEqual(['path_scouted']); + }); + + it('defaults unlock to undefined when omitted', () => { + const content = buildContent({ + resources: [{ id: 'gold', name: 'Gold' }], + actions: [ + { id: 'forage', name: 'Forage', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] }, + ], + }); + expect(content.actionsById.forage.unlock).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test src/content/__tests__/schema.test.ts -t "unlock"` + +Expected: FAIL + +- [ ] **Step 3: Implement unlock schema** + +Add to `src/content/schema.ts`: + +```typescript +export const unlockDefSchema = z.object({ + minResources: z.record(z.string(), z.number().nonnegative()).optional(), + requireStoryFlags: z.array(z.string().min(1)).optional(), +}); + +export const actionDefSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + durationMs: z.number().positive(), + costs: z.array(resourceAmountSchema).default([]), + yields: z.array(resourceAmountSchema).min(1), + unlock: unlockDefSchema.optional(), +}); + +export type UnlockDef = z.infer; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm test src/content/__tests__/schema.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/content/schema.ts src/content/__tests__/schema.test.ts +git commit -m "feat(content): add action unlock condition schema" +``` + +--- + +### Task 6: GameState queue field and enqueue API + +**Files:** +- Modify: `src/engine/game.ts` +- Modify: `src/engine/__tests__/game.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Add to `src/engine/__tests__/game.test.ts` (update `testContent` yields to array form first): + +```typescript +import { cancelQueuedAction, clearQueue, enqueueAction } from '../game'; + +function queueContent() { + return buildContent({ + resources: [{ id: 'gold', name: 'Gold', startAmount: 10 }], + actions: [ + { id: 'a', name: 'A', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, + { id: 'b', name: 'B', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, + { id: 'c', name: 'C', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, + ], + }); +} + +describe('enqueueAction()', () => { + it('starts immediately when idle', () => { + const content = queueContent(); + const state = createGameState(content); + enqueueAction(state, content, 'a'); + expect(state.activeActionId).toBe('a'); + expect(state.actionQueue).toEqual([]); + }); + + it('queues when another action is active', () => { + const content = queueContent(); + const state = createGameState(content); + enqueueAction(state, content, 'a'); + enqueueAction(state, content, 'b'); + enqueueAction(state, content, 'c'); + expect(state.activeActionId).toBe('a'); + expect(state.actionQueue).toEqual(['b', 'c']); + }); + + it('throws on unknown action id', () => { + const content = queueContent(); + const state = createGameState(content); + expect(() => enqueueAction(state, content, 'nope')).toThrow(/unknown action/i); + }); +}); + +describe('cancelQueuedAction()', () => { + it('removes a queued action by index', () => { + const content = queueContent(); + const state = createGameState(content); + enqueueAction(state, content, 'a'); + enqueueAction(state, content, 'b'); + enqueueAction(state, content, 'c'); + cancelQueuedAction(state, 0); + expect(state.actionQueue).toEqual(['c']); + }); +}); + +describe('clearQueue()', () => { + it('empties the queue without stopping the active action', () => { + const content = queueContent(); + const state = createGameState(content); + enqueueAction(state, content, 'a'); + enqueueAction(state, content, 'b'); + clearQueue(state); + expect(state.activeActionId).toBe('a'); + expect(state.actionQueue).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test src/engine/__tests__/game.test.ts -t "enqueueAction|cancelQueuedAction|clearQueue"` + +Expected: FAIL — exports missing. + +- [ ] **Step 3: Implement queue fields and APIs** + +In `src/engine/game.ts`: + +```typescript +export interface GameState { + resources: Record; + activeActionId: string | null; + actionElapsedMs: number; + actionQueue: string[]; +} + +export function createGameState(content: Content): GameState { + const resources: Record = {}; + for (const resource of content.resources) { + resources[resource.id] = resource.startAmount; + } + return { resources, activeActionId: null, actionElapsedMs: 0, actionQueue: [] }; +} + +function assertKnownAction(content: Content, actionId: string) { + if (!content.actionsById[actionId]) { + throw new Error(`Unknown action "${actionId}"`); + } +} + +/** @deprecated Prefer enqueueAction — kept for tests migrating incrementally */ +export function startAction(state: GameState, content: Content, actionId: string): void { + assertKnownAction(content, actionId); + state.activeActionId = actionId; + state.actionElapsedMs = 0; +} + +export function enqueueAction(state: GameState, content: Content, actionId: string): void { + assertKnownAction(content, actionId); + if (!state.activeActionId) { + state.activeActionId = actionId; + state.actionElapsedMs = 0; + } else { + state.actionQueue.push(actionId); + } +} + +export function cancelQueuedAction(state: GameState, index: number): void { + if (index < 0 || index >= state.actionQueue.length) { + throw new RangeError(`Queue index out of range: ${index}`); + } + state.actionQueue.splice(index, 1); +} + +export function clearQueue(state: GameState): void { + state.actionQueue = []; +} +``` + +Update `createGameState` tests to expect `actionQueue: []`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm test src/engine/__tests__/game.test.ts -t "enqueueAction|cancelQueuedAction|clearQueue"` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/game.ts src/engine/__tests__/game.test.ts +git commit -m "feat(engine): add action queue state and enqueue APIs" +``` + +--- + +### Task 7: Costs, unlocks, and completion-driven queue advancement + +**Files:** +- Modify: `src/engine/game.ts` +- Modify: `src/engine/__tests__/game.test.ts` +- Modify: `src/engine/__tests__/determinism.test.ts` (unskip if skipped) + +- [ ] **Step 1: Write the failing tests** + +```typescript +import { + canAffordAction, + canUnlockAction, + enqueueAction, + isActionAvailable, + tickGame, +} from '../game'; + +function costContent() { + return buildContent({ + resources: [ + { id: 'supplies', name: 'Supplies', startAmount: 10 }, + { id: 'coin', name: 'Coin', startAmount: 0 }, + ], + actions: [ + { + id: 'gather', + name: 'Gather', + durationMs: 300, + yields: [{ resourceId: 'supplies', amount: 2 }], + }, + { + id: 'trade', + name: 'Trade', + durationMs: 300, + costs: [{ resourceId: 'supplies', amount: 5 }], + yields: [{ resourceId: 'coin', amount: 3 }], + }, + { + id: 'scout', + name: 'Scout', + durationMs: 300, + yields: [{ resourceId: 'coin', amount: 1 }], + unlock: { minResources: { coin: 1 } }, + }, + ], + }); +} + +describe('costs on start (D-0012)', () => { + it('deducts costs when an action becomes active', () => { + const content = costContent(); + const state = createGameState(content); + enqueueAction(state, content, 'trade'); + expect(state.resources.supplies).toBe(5); + }); + + it('rejects enqueue when unaffordable', () => { + const content = costContent(); + const state = createGameState(content); + state.resources.supplies = 2; + expect(() => enqueueAction(state, content, 'trade')).toThrow(/cannot enqueue/i); + }); +}); + +describe('unlock conditions', () => { + it('rejects locked actions', () => { + const content = costContent(); + const state = createGameState(content); + expect(() => enqueueAction(state, content, 'scout')).toThrow(/cannot enqueue/i); + }); + + it('allows actions once unlock thresholds are met', () => { + const content = costContent(); + const state = createGameState(content); + state.resources.coin = 1; + enqueueAction(state, content, 'scout'); + expect(state.activeActionId).toBe('scout'); + }); +}); + +describe('completion advances queue', () => { + it('starts the next queued action after the active one completes', () => { + const content = costContent(); + const state = createGameState(content); + enqueueAction(state, content, 'gather'); + enqueueAction(state, content, 'gather'); + tickGame(state, content, 300); + expect(state.activeActionId).toBe('gather'); + expect(state.actionQueue).toEqual([]); + expect(state.resources.supplies).toBe(12); + }); + + it('goes idle when the queue is empty after completion', () => { + const content = costContent(); + const state = createGameState(content); + enqueueAction(state, content, 'gather'); + tickGame(state, content, 300); + expect(state.activeActionId).toBeNull(); + expect(state.actionElapsedMs).toBe(0); + }); + + it('grants all yields on completion', () => { + const content = buildContent({ + resources: [ + { id: 'a', name: 'A', startAmount: 0 }, + { id: 'b', name: 'B', startAmount: 0 }, + ], + actions: [ + { + id: 'combo', + name: 'Combo', + durationMs: 100, + yields: [ + { resourceId: 'a', amount: 2 }, + { resourceId: 'b', amount: 3 }, + ], + }, + ], + }); + const state = createGameState(content); + enqueueAction(state, content, 'combo'); + tickGame(state, content, 100); + expect(state.resources.a).toBe(2); + expect(state.resources.b).toBe(3); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test src/engine/__tests__/game.test.ts -t "costs on start|unlock|completion advances"` + +Expected: FAIL + +- [ ] **Step 3: Implement affordability, unlock, and new tickGame completion semantics** + +Replace `tickGame` and add helpers in `src/engine/game.ts`: + +```typescript +export function canAffordAction(state: GameState, content: Content, actionId: string): boolean { + const action = content.actionsById[actionId]; + if (!action) return false; + return action.costs.every((cost) => (state.resources[cost.resourceId] ?? 0) >= cost.amount); +} + +/** Story flags land in PR2; placeholder field keeps unlock schema honest. */ +export function canUnlockAction( + state: GameState, + content: Content, + actionId: string, + storyFlags: Record = {}, +): boolean { + const action = content.actionsById[actionId]; + if (!action) return false; + const unlock = action.unlock; + if (!unlock) return true; + if (unlock.minResources) { + for (const [resourceId, min] of Object.entries(unlock.minResources)) { + if ((state.resources[resourceId] ?? 0) < min) return false; + } + } + if (unlock.requireStoryFlags) { + for (const flag of unlock.requireStoryFlags) { + if (!storyFlags[flag]) return false; + } + } + return true; +} + +export function isActionAvailable( + state: GameState, + content: Content, + actionId: string, + storyFlags: Record = {}, +): boolean { + return canAffordAction(state, content, actionId) && canUnlockAction(state, content, actionId, storyFlags); +} + +function deductCosts(state: GameState, content: Content, actionId: string): void { + const action = content.actionsById[actionId]; + if (!action) return; + for (const cost of action.costs) { + state.resources[cost.resourceId] -= cost.amount; + } +} + +function grantYields(state: GameState, content: Content, actionId: string): void { + const action = content.actionsById[actionId]; + if (!action) return; + for (const y of action.yields) { + state.resources[y.resourceId] = (state.resources[y.resourceId] ?? 0) + y.amount; + } +} + +function beginAction(state: GameState, content: Content, actionId: string): void { + assertKnownAction(content, actionId); + deductCosts(state, content, actionId); + state.activeActionId = actionId; + state.actionElapsedMs = 0; +} + +function startNextFromQueue(state: GameState, content: Content): void { + while (state.actionQueue.length > 0) { + const nextId = state.actionQueue.shift()!; + if (isActionAvailable(state, content, nextId)) { + beginAction(state, content, nextId); + return; + } + } + state.activeActionId = null; + state.actionElapsedMs = 0; +} + +function completeActiveAction(state: GameState, content: Content): void { + const actionId = state.activeActionId; + if (!actionId) return; + grantYields(state, content, actionId); + startNextFromQueue(state, content); +} + +export function enqueueAction(state: GameState, content: Content, actionId: string): void { + assertKnownAction(content, actionId); + if (!isActionAvailable(state, content, actionId)) { + throw new Error(`Cannot enqueue action "${actionId}"`); + } + if (!state.activeActionId) { + beginAction(state, content, actionId); + } else { + state.actionQueue.push(actionId); + } +} + +export function tickGame(state: GameState, content: Content, tickMs: number): void { + if (!state.activeActionId) return; + const action = content.actionsById[state.activeActionId]; + if (!action) return; + + state.actionElapsedMs += tickMs; + while (state.actionElapsedMs >= action.durationMs) { + state.actionElapsedMs -= action.durationMs; + completeActiveAction(state, content); + if (!state.activeActionId) return; + const next = content.actionsById[state.activeActionId]; + if (!next) return; + if (state.actionElapsedMs < next.durationMs) return; + } +} +``` + +Update existing `tickGame` tests: they used auto-repeat — change expectations so one completion goes idle unless re-enqueued. + +Example fix for `'grants the yield on completion and repeats'` test — rename to `'grants the yield on completion then goes idle'`: + +```typescript +tickGame(state, content, 300); +expect(state.resources.gold).toBe(7); +expect(state.activeActionId).toBeNull(); +``` + +Remove or rewrite the multi-completion-in-one-tick test to enqueue once then tick large: + +```typescript +enqueueAction(state, content, 'forage'); +tickGame(state, content, 1000); +expect(state.resources.gold).toBe(7); // one completion only +expect(state.activeActionId).toBeNull(); +``` + +- [ ] **Step 4: Run all engine tests** + +Run: `pnpm test src/engine/__tests__/game.test.ts src/engine/__tests__/determinism.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/game.ts src/engine/__tests__/game.test.ts src/engine/__tests__/determinism.test.ts +git commit -m "feat(engine): costs on start, unlock checks, queue completion advancement" +``` + +--- + +### Task 8: Save schema — persist actionQueue + +**Files:** +- Modify: `src/engine/save.ts` +- Modify: `src/engine/__tests__/save.test.ts` +- Modify: `src/state/persistence.ts` +- Modify: `src/state/__tests__/persistence.test.ts` + +- [ ] **Step 1: Write the failing tests** + +In `src/engine/__tests__/save.test.ts`, update `sampleState` and add: + +```typescript +it('snapshots actionQueue in the save payload', () => { + const state = sampleState(); + state.actionQueue = ['b', 'c']; + const save = createSave(state, 1700); + expect(save.state.actionQueue).toEqual(['b', 'c']); +}); +``` + +In `src/state/__tests__/persistence.test.ts`, add: + +```typescript +it('restores actionQueue on load', async () => { + const content = buildContent({ + resources: [{ id: 'gold', name: 'Gold' }], + actions: [ + { id: 'a', name: 'A', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, + { id: 'b', name: 'B', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, + ], + }); + const backend = createMemoryBackend(); + const state = createGameState(content); + enqueueAction(state, content, 'a'); + enqueueAction(state, content, 'b'); + await saveGame(state, backend, 1000); + const loaded = await loadGame(content, backend, 1000); + expect(loaded.state.actionQueue).toEqual(['b']); + expect(loaded.state.activeActionId).toBe('a'); +}); +``` + +Import `enqueueAction` from `../engine/game` in persistence test. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test src/engine/__tests__/save.test.ts src/state/__tests__/persistence.test.ts -t "actionQueue"` + +Expected: FAIL + +- [ ] **Step 3: Extend save schema and persistence hydrate** + +In `src/engine/save.ts`: + +```typescript +export const gameStateSchema = z.object({ + resources: z.record(z.string(), z.number()), + activeActionId: z.string().nullable(), + actionElapsedMs: z.number().nonnegative(), + actionQueue: z.array(z.string()).default([]), +}); +``` + +In `createSave`: + +```typescript +state: { + resources: { ...state.resources }, + activeActionId: state.activeActionId, + actionElapsedMs: state.actionElapsedMs, + actionQueue: [...state.actionQueue], +}, +``` + +In `src/state/persistence.ts` `loadGame`, extend hydration: + +```typescript +state = { + resources: { ...base.resources, ...save.state.resources }, + activeActionId, + actionElapsedMs: save.state.actionElapsedMs, + actionQueue: [...(save.state.actionQueue ?? [])], +}; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm test src/engine/__tests__/save.test.ts src/state/__tests__/persistence.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/save.ts src/engine/__tests__/save.test.ts src/state/persistence.ts src/state/__tests__/persistence.test.ts +git commit -m "feat(save): persist action queue in v1 save payload" +``` + +--- + +### Task 9: Stub M1 content pack + +**Files:** +- Modify: `src/content/definitions.ts` +- Create: `src/content/__tests__/definitions.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +import { describe, expect, it } from 'vitest'; +import { content } from '../index'; + +describe('M1 stub content pack', () => { + it('defines two resources and four to five actions with costs and unlocks', () => { + expect(content.resources).toHaveLength(2); + expect(content.actions.length).toBeGreaterThanOrEqual(4); + expect(content.actions.length).toBeLessThanOrEqual(5); + const withCosts = content.actions.filter((a) => a.costs.length > 0); + const withUnlocks = content.actions.filter((a) => a.unlock !== undefined); + expect(withCosts.length).toBeGreaterThanOrEqual(2); + expect(withUnlocks.length).toBeGreaterThanOrEqual(1); + }); + + it('can simulate a costed action without throwing', () => { + const { createGameState, enqueueAction, tickGame } = await import('../../engine/game'); + const state = createGameState(content); + const trade = content.actions.find((a) => a.costs.length > 0); + expect(trade).toBeDefined(); + enqueueAction(state, content, trade!.id); + tickGame(state, content, trade!.durationMs); + expect(state.activeActionId).toBeNull(); + }); +}); +``` + +Fix: use static import, not dynamic: + +```typescript +import { createGameState, enqueueAction, tickGame } from '../../engine/game'; +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test src/content/__tests__/definitions.test.ts` + +Expected: FAIL — only one resource/action today. + +- [ ] **Step 3: Replace definitions with stub pack** + +In `src/content/definitions.ts`: + +```typescript +export const resourceDefs = [ + { id: 'supplies', name: 'Supplies', startAmount: 10 }, + { id: 'coin', name: 'Coin', startAmount: 0 }, +]; + +export const actionDefs = [ + { + id: 'gather_supplies', + name: 'Gather supplies', + durationMs: 3000, + yields: [{ resourceId: 'supplies', amount: 2 }], + }, + { + id: 'scout_path', + name: 'Scout the path', + durationMs: 5000, + costs: [{ resourceId: 'supplies', amount: 2 }], + yields: [{ resourceId: 'coin', amount: 1 }], + }, + { + id: 'trade_supplies', + name: 'Trade at camp', + durationMs: 4000, + costs: [{ resourceId: 'supplies', amount: 3 }], + yields: [{ resourceId: 'coin', amount: 2 }], + unlock: { minResources: { coin: 1 } }, + }, + { + id: 'fortify_camp', + name: 'Fortify camp', + durationMs: 8000, + costs: [ + { resourceId: 'supplies', amount: 5 }, + { resourceId: 'coin', amount: 2 }, + ], + yields: [{ resourceId: 'supplies', amount: 4 }], + unlock: { minResources: { supplies: 8 } }, + }, + { + id: 'rest', + name: 'Rest briefly', + durationMs: 2000, + yields: [{ resourceId: 'supplies', amount: 1 }], + }, +]; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm test src/content/__tests__/definitions.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/content/definitions.ts src/content/__tests__/definitions.test.ts +git commit -m "feat(content): add M1 stub resource and action pack" +``` + +--- + +### Task 10: Runtime enqueue bridge and view model + +**Files:** +- Modify: `src/state/runtime.ts` +- Modify: `src/state/viewModel.ts` +- Modify: `src/state/__tests__/viewModel.test.ts` +- Modify: `src/ui/ActionPanel.tsx` (minimal — call enqueue instead of start) + +- [ ] **Step 1: Write the failing view model test** + +In `src/state/__tests__/viewModel.test.ts`: + +```typescript +it('includes queued action ids in order', () => { + const content = buildContent({ + resources: [{ id: 'gold', name: 'Gold' }], + actions: [ + { id: 'a', name: 'Alpha', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, + { id: 'b', name: 'Bravo', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, + ], + }); + const state = createGameState(content); + enqueueAction(state, content, 'a'); + enqueueAction(state, content, 'b'); + const view = toView(state, content); + expect(view.queuedActionIds).toEqual(['b']); + expect(view.queuedActionNames).toEqual(['Bravo']); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test src/state/__tests__/viewModel.test.ts -t "queued"` + +Expected: FAIL + +- [ ] **Step 3: Implement view model + runtime + minimal UI** + +In `src/state/viewModel.ts`: + +```typescript +export interface GameView { + resources: ResourceView[]; + activeActionId: string | null; + actionName: string | null; + actionProgress: number; + queuedActionIds: string[]; + queuedActionNames: string[]; +} + +export function toView(state: GameState, content: Content): GameView { + // ...existing fields... + const queuedActionIds = [...state.actionQueue]; + const queuedActionNames = queuedActionIds.map( + (id) => content.actionsById[id]?.name ?? id, + ); + return { + resources, + activeActionId: state.activeActionId, + actionName: action ? action.name : null, + actionProgress, + queuedActionIds, + queuedActionNames, + }; +} +``` + +In `src/state/runtime.ts`, replace `startAction` with: + +```typescript +import { enqueueAction as engineEnqueueAction } from '../engine/game'; + +enqueueAction(actionId: string): void { + const state = this.state; + if (!state) return; + try { + engineEnqueueAction(state, content, actionId); + const action = content.actionsById[actionId]; + if (action) { + const verb = state.actionQueue.includes(actionId) ? 'Queued' : 'Started'; + useGameStore.getState().appendLog(`${verb}: ${action.name}.`); + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Cannot start action'; + useGameStore.getState().appendLog(msg); + } + this.publish(); +} +``` + +Rename public method from `startAction` to `enqueueAction` and update `ActionPanel.tsx`: + +```typescript +onClick={() => gameRuntime.enqueueAction(action.id)} +``` + +- [ ] **Step 4: Run tests and typecheck** + +Run: `pnpm test src/state/__tests__/viewModel.test.ts && pnpm typecheck` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/state/runtime.ts src/state/viewModel.ts src/state/__tests__/viewModel.test.ts src/ui/ActionPanel.tsx +git commit -m "feat(state): wire enqueueAction through runtime and view model" +``` + +--- + +### Task 11: Architecture note and coverage gate + +**Files:** +- Modify: `docs/architecture.md` + +- [ ] **Step 1: Update architecture doc** + +Under `game.ts` bullet in `docs/architecture.md`: + +```markdown +- `game.ts`: core game state, action queue, costs/unlocks on start, completion-driven + queue advancement (actions do not auto-repeat when the queue is empty). +``` + +- [ ] **Step 2: Run full pre-PR verification chain** + +```powershell +pnpm typecheck +pnpm lint +pnpm test:coverage +pnpm build +``` + +Expected: all green; `src/engine/` coverage ≥ 80%. + +- [ ] **Step 3: Manual playtest checklist** + +1. `pnpm dev` — open app. +2. Click **Gather supplies** three times quickly → event log shows Started + 2× Queued. +3. Watch resources: costs deduct on start for costed actions; yields apply on completion. +4. Reload → queue and resources preserved. +5. Resize to mobile width — no regressions (full mobile pass is PR4). + +- [ ] **Step 4: Commit** + +```bash +git add docs/architecture.md +git commit -m "docs: document action queue completion semantics" +``` + +--- + +### Task 12: Open PR + +**Files:** none (git + Gitea) + +- [ ] **Step 1: Push branch** + +```bash +git push -u origin feat/m1-foundations +``` + +- [ ] **Step 2: Create PR** + +Title: `feat(m1): foundations — queue engine, costs, stub content` + +Body: + +```markdown +## Summary +- Hardens engine determinism and purity guards (#3) +- Expands content schemas with costs, unlocks, multi-yield (#4) +- Adds action queue with completion-driven advancement and save persistence +- Ships stub M1 content pack (2 resources, 5 actions) + +## Test plan +- [x] `pnpm typecheck && pnpm lint && pnpm test:coverage && pnpm build` +- [x] Enqueue 3 actions sequentially in dev UI +- [x] Reload preserves queue + resources +- [x] Engine coverage ≥ 80% + +Closes #3 +Closes #4 +``` + +- [ ] **Step 3: Verify CI green on PR branch** + +Expected: Gitea Actions `CI / verify` passes. + +--- + +## Self-review + +**Spec coverage (PR1 from parent plan):** + +| Requirement | Task | +|---|---| +| T1.1 determinism regression tests | Task 1 | +| T1.1 offline behavior documented | Task 3 | +| T1.1 engine purity guard | Task 2 | +| T1.2 costs on start (D-0012) | Tasks 4, 7 | +| T1.2 unlock conditions + story flag placeholder | Task 5, 7 | +| T1.2 multi-yield | Task 4 | +| T1.3 action queue engine | Tasks 6, 7 | +| T1.4 stub content pack | Task 9 | +| PR1 integration verify | Task 11 | +| Closes #3, #4 | Task 12 | + +**Placeholder scan:** none — all steps include concrete code and commands. + +**Type consistency:** `GameState.actionQueue`, `GameView.queuedActionIds`, `save.state.actionQueue`, and `enqueueAction` signatures align across tasks. + +**Out of scope (deferred to later PRs):** story flags on `GameState` (PR2), queue UI polish (PR2 #5), save v2 migration (PR4 #9), automation (PR3 #7). + +--- + +## Execution handoff + +Plan complete and saved to `docs/superpowers/plans/2026-06-11-m1-pr1-foundations.md`. Two execution options: + +**1. Subagent-Driven (recommended)** — dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints + +**Which approach, senpai?** diff --git a/docs/superpowers/plans/2026-06-11-m1-pr2-playable-loop.md b/docs/superpowers/plans/2026-06-11-m1-pr2-playable-loop.md new file mode 100644 index 0000000..5d8ba17 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-m1-pr2-playable-loop.md @@ -0,0 +1,2081 @@ +# M1 PR2 — Playable Loop 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:** Ship PR2 (`feat/m1-playable-loop`): hybrid story graph engine, story content schema, full-window StoryPanel, upgraded ActionPanel (queue/cancel/disabled/hints), player prefs, stub A/B branch — closing Gitea #5 and #6. + +**Architecture:** Pure `src/engine/story.ts` handles triggers, choices, and outcomes; `GameState` gains story fields persisted in save v1. Runtime calls `evaluateTriggers` after boot, publish, and action completion (via `tickGame` return value). UI reads an expanded view model; prefs live in `localStorage` via `src/state/prefs.ts`. + +**Tech Stack:** TypeScript strict, Vitest, Zod 4, Biome, pnpm, React 19, Zustand, Tailwind 4. + +**Parent spec:** `docs/superpowers/specs/2026-06-11-m1-pr2-playable-loop-design.md` + +**Branch:** `feat/m1-playable-loop` off `main`. + +--- + +## File map + +| File | Responsibility | +|---|---| +| `src/engine/game.ts` | `GameState` story fields; `isActionAvailable` uses `state.storyFlags`; `tickGame` returns completed action ids | +| `src/engine/story.ts` | **Create** — initStory, evaluateTriggers, applyChoice, outcomes | +| `src/engine/__tests__/story.test.ts` | **Create** — story engine tests | +| `src/engine/save.ts` | Persist story fields in v1 schema | +| `src/engine/__tests__/save.test.ts` | Story field round-trip | +| `src/content/storySchema.ts` | **Create** — Zod story defs + `buildStoryContent` | +| `src/content/__tests__/storySchema.test.ts` | **Create** — schema validation | +| `src/content/schema.ts` | `storyHint`, `storyTooltip` on actions | +| `src/content/story.ts` | **Create** — stub ~6-node graph | +| `src/content/definitions.ts` | `push_onward`, route-gated unlocks, hints/tooltips | +| `src/content/index.ts` | Export merged `GameContent` | +| `src/state/prefs.ts` | **Create** — localStorage prefs | +| `src/state/__tests__/prefs.test.ts` | **Create** — prefs round-trip | +| `src/state/storyOrchestration.ts` | **Create** — trigger evaluation wrapper | +| `src/state/__tests__/storyOrchestration.test.ts` | **Create** — boot trigger smoke | +| `src/state/viewModel.ts` | Action availability, story view fields | +| `src/state/__tests__/viewModel.test.ts` | Extended view model tests | +| `src/state/store.ts` | Story UI state (open, unread, story log) | +| `src/state/runtime.ts` | Orchestration hooks, applyChoice, cancelQueue | +| `src/state/persistence.ts` | Hydrate story fields on load | +| `src/ui/ActionPanel.tsx` | Queue list, cancel, disabled, hints/tooltips | +| `src/ui/StoryPanel.tsx` | **Create** — full-screen VN overlay | +| `src/ui/SettingsDrawer.tsx` | **Create** — two pref selects | +| `src/ui/App.tsx` | Header chrome, StoryPanel, SettingsDrawer | +| `docs/architecture.md` | Story engine + prefs notes | + +--- + +### Task 1: GameState story fields + +**Files:** +- Modify: `src/engine/game.ts` +- Modify: `src/engine/__tests__/game.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add to `src/engine/__tests__/game.test.ts`: + +```typescript +describe('createGameState() story fields', () => { + it('initializes empty story state', () => { + const content = testContent(); + const state = createGameState(content); + expect(state.storyFlags).toEqual({}); + expect(state.currentStoryNodeId).toBe(''); + expect(state.seenStoryNodeIds).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test src/engine/__tests__/game.test.ts -t "story fields"` + +Expected: FAIL — properties undefined. + +- [ ] **Step 3: Extend GameState and createGameState** + +In `src/engine/game.ts`: + +```typescript +export interface GameState { + resources: Record; + activeActionId: string | null; + actionElapsedMs: number; + actionQueue: string[]; + storyFlags: Record; + currentStoryNodeId: string; + seenStoryNodeIds: string[]; +} + +export function createGameState(content: Content): GameState { + const resources: Record = {}; + for (const resource of content.resources) { + resources[resource.id] = resource.startAmount; + } + return { + resources, + activeActionId: null, + actionElapsedMs: 0, + actionQueue: [], + storyFlags: {}, + currentStoryNodeId: '', + seenStoryNodeIds: [], + }; +} +``` + +Update `isActionAvailable` / `canUnlockAction` call sites inside `game.ts` to pass `state.storyFlags` instead of default `{}`: + +```typescript +if (isActionAvailable(state, content, nextId)) { +``` + +and: + +```typescript +if (!isActionAvailable(state, content, actionId)) { +``` + +Remove the default `storyFlags = {}` parameter from exported `canUnlockAction` and `isActionAvailable` — always require explicit flags from callers (tests pass `state.storyFlags`). + +- [ ] **Step 4: Run tests** + +Run: `pnpm test src/engine/__tests__/game.test.ts` + +Expected: PASS (fix any test `createGameState` expectations). + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/game.ts src/engine/__tests__/game.test.ts +git commit -m "feat(engine): add story fields to GameState" +``` + +--- + +### Task 2: tickGame reports completed actions + +**Files:** +- Modify: `src/engine/game.ts` +- Modify: `src/engine/__tests__/game.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +describe('tickGame() completion result', () => { + it('returns the id of each action that completed this tick', () => { + const content = testContent(); + const state = createGameState(content); + enqueueAction(state, content, 'forage'); + const result = tickGame(state, content, 300); + expect(result.completedActionIds).toEqual(['forage']); + expect(state.activeActionId).toBeNull(); + }); + + it('returns an empty array when nothing completes', () => { + const content = testContent(); + const state = createGameState(content); + enqueueAction(state, content, 'forage'); + const result = tickGame(state, content, 100); + expect(result.completedActionIds).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test src/engine/__tests__/game.test.ts -t "completion result"` + +Expected: FAIL — no return value / wrong shape. + +- [ ] **Step 3: Implement return value** + +Change signature and track completions in `tickGame`: + +```typescript +export interface TickResult { + completedActionIds: string[]; +} + +export function tickGame(state: GameState, content: Content, tickMs: number): TickResult { + const completedActionIds: string[] = []; + if (!state.activeActionId) return { completedActionIds }; + + state.actionElapsedMs += tickMs; + while (state.activeActionId) { + const actionId = state.activeActionId; + const action = content.actionsById[actionId]; + if (!action) return { completedActionIds }; + if (state.actionElapsedMs < action.durationMs) return { completedActionIds }; + + state.actionElapsedMs -= action.durationMs; + grantYields(state, content, actionId); + completedActionIds.push(actionId); + startNextFromQueue(state, content); + } + return { completedActionIds }; +} +``` + +Refactor: inline `completeActiveAction` body into the loop above (or have `completeActiveAction` return the completed id). Update `applyOfflineProgress` in `save.ts` to ignore the return value: + +```typescript +tickGame(state, content, TICK_MS); +``` + +- [ ] **Step 4: Run engine tests** + +Run: `pnpm test src/engine/__tests__/game.test.ts src/engine/__tests__/save.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/game.ts src/engine/__tests__/game.test.ts src/engine/save.ts +git commit -m "feat(engine): tickGame returns completed action ids for story triggers" +``` + +--- + +### Task 3: Story content schema + +**Files:** +- Create: `src/content/storySchema.ts` +- Create: `src/content/__tests__/storySchema.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `src/content/__tests__/storySchema.test.ts`: + +```typescript +import { describe, expect, it } from 'vitest'; +import { buildStoryContent } from '../storySchema'; + +const resourcesById = { + supplies: { id: 'supplies', name: 'Supplies', startAmount: 0 }, + coin: { id: 'coin', name: 'Coin', startAmount: 0 }, +}; +const actionsById = { + scout_path: { id: 'scout_path', name: 'Scout', durationMs: 1000, costs: [], yields: [{ resourceId: 'coin', amount: 1 }] }, +}; + +const validNodes = [ + { + id: 'boot_intro', + prose: 'You wake at the crossroads.', + triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }], + }, + { + id: 'fork_choice', + prose: 'Which way?', + choices: [ + { + id: 'pick_a', + label: 'High road', + outcomes: [{ type: 'setFlag', flag: 'route_a' }], + targetNodeId: 'route_a_beat', + }, + { + id: 'pick_b', + label: 'Low road', + outcomes: [{ type: 'setFlag', flag: 'route_b' }], + targetNodeId: 'route_b_beat', + }, + ], + }, + { id: 'route_a_beat', prose: 'The high road.' }, + { id: 'route_b_beat', prose: 'The low road.' }, +]; + +describe('buildStoryContent()', () => { + it('indexes nodes and finds boot trigger', () => { + const story = buildStoryContent(validNodes, actionsById, resourcesById); + expect(story.storyNodesById.fork_choice.prose).toContain('Which way'); + expect(story.bootTargetNodeId).toBe('boot_intro'); + }); + + it('rejects dangling targetNodeId on choices', () => { + expect(() => + buildStoryContent( + [{ id: 'n', prose: 'x', choices: [{ id: 'c', label: 'y', outcomes: [], targetNodeId: 'missing' }] }], + actionsById, + resourcesById, + ), + ).toThrow(/unknown story node/i); + }); + + it('rejects unknown actionId in actionComplete trigger', () => { + expect(() => + buildStoryContent( + [ + { + id: 't', + prose: '', + triggers: [{ type: 'actionComplete', actionId: 'ghost', targetNodeId: 'boot_intro' }], + }, + { id: 'boot_intro', prose: 'hi' }, + ], + actionsById, + resourcesById, + ), + ).toThrow(/unknown action/i); + }); + + it('requires exactly one boot trigger', () => { + expect(() => + buildStoryContent([{ id: 'n', prose: 'no boot' }], actionsById, resourcesById), + ).toThrow(/boot trigger/i); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test src/content/__tests__/storySchema.test.ts` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement storySchema.ts** + +Create `src/content/storySchema.ts`: + +```typescript +import { z } from 'zod'; +import type { ActionDef } from './schema'; + +export const storyOutcomeSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('setFlag'), flag: z.string().min(1) }), + z.object({ type: z.literal('clearFlag'), flag: z.string().min(1) }), + z.object({ type: z.literal('grantResource'), resourceId: z.string().min(1), amount: z.number().positive() }), + z.object({ type: z.literal('consumeResource'), resourceId: z.string().min(1), amount: z.number().positive() }), + z.object({ type: z.literal('log'), text: z.string().min(1) }), +]); + +export const choiceRequirementsSchema = z.object({ + minResources: z.record(z.string(), z.number().nonnegative()).optional(), + requireStoryFlags: z.array(z.string().min(1)).optional(), + excludeStoryFlags: z.array(z.string().min(1)).optional(), +}); + +export const storyChoiceSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), + requirements: choiceRequirementsSchema.optional(), + outcomes: z.array(storyOutcomeSchema).default([]), + targetNodeId: z.string().min(1), +}); + +export const storyTriggerSchema = z.object({ + type: z.enum(['boot', 'actionComplete', 'minResources']), + actionId: z.string().min(1).optional(), + minResources: z.record(z.string(), z.number().nonnegative()).optional(), + targetNodeId: z.string().min(1), + once: z.boolean().default(true), +}); + +export const storyNodeSchema = z.object({ + id: z.string().min(1), + prose: z.string().min(1), + choices: z.array(storyChoiceSchema).optional(), + triggers: z.array(storyTriggerSchema).optional(), + enterOutcomes: z.array(storyOutcomeSchema).optional(), +}); + +export type StoryOutcome = z.infer; +export type StoryChoice = z.infer; +export type StoryTrigger = z.infer; +export type StoryNode = z.infer; + +export interface StoryContent { + storyNodes: StoryNode[]; + storyNodesById: Record; + bootTargetNodeId: string; +} + +function indexStoryNodes(nodes: StoryNode[]): Record { + const byId: Record = {}; + for (const node of nodes) { + if (byId[node.id]) throw new Error(`Duplicate story node id "${node.id}"`); + byId[node.id] = node; + } + return byId; +} + +export function buildStoryContent( + rawNodes: unknown[], + actionsById: Record, + resourcesById: Record, +): StoryContent { + const storyNodes = rawNodes.map((n) => storyNodeSchema.parse(n)); + const storyNodesById = indexStoryNodes(storyNodes); + + let bootTargetNodeId: string | null = null; + for (const node of storyNodes) { + for (const trigger of node.triggers ?? []) { + if (trigger.type === 'boot') { + if (bootTargetNodeId !== null) { + throw new Error('Story graph must have exactly one boot trigger'); + } + bootTargetNodeId = trigger.targetNodeId; + } + if (trigger.type === 'actionComplete' && !actionsById[trigger.actionId ?? '']) { + throw new Error(`Story trigger references unknown action "${trigger.actionId}"`); + } + if (trigger.minResources) { + for (const resourceId of Object.keys(trigger.minResources)) { + if (!resourcesById[resourceId]) { + throw new Error(`Story trigger references unknown resource "${resourceId}"`); + } + } + } + if (!storyNodesById[trigger.targetNodeId]) { + throw new Error(`Story trigger references unknown story node "${trigger.targetNodeId}"`); + } + } + for (const choice of node.choices ?? []) { + if (!storyNodesById[choice.targetNodeId]) { + throw new Error(`Story choice references unknown story node "${choice.targetNodeId}"`); + } + for (const outcome of choice.outcomes) { + if (outcome.type === 'grantResource' || outcome.type === 'consumeResource') { + if (!resourcesById[outcome.resourceId]) { + throw new Error(`Story outcome references unknown resource "${outcome.resourceId}"`); + } + } + } + } + for (const outcome of node.enterOutcomes ?? []) { + if (outcome.type === 'grantResource' || outcome.type === 'consumeResource') { + if (!resourcesById[outcome.resourceId]) { + throw new Error(`Story enterOutcome references unknown resource "${outcome.resourceId}"`); + } + } + } + } + + if (bootTargetNodeId === null) { + throw new Error('Story graph must have exactly one boot trigger'); + } + + return { storyNodes, storyNodesById, bootTargetNodeId }; +} +``` + +- [ ] **Step 4: Run tests** + +Run: `pnpm test src/content/__tests__/storySchema.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/content/storySchema.ts src/content/__tests__/storySchema.test.ts +git commit -m "feat(content): add Zod story graph schema and validation" +``` + +--- + +### Task 4: Action narrative fields + merged GameContent + +**Files:** +- Modify: `src/content/schema.ts` +- Modify: `src/content/index.ts` +- Modify: `src/content/__tests__/schema.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add to `src/content/__tests__/schema.test.ts`: + +```typescript +it('accepts optional storyHint and storyTooltip on actions', () => { + const content = buildContent({ + resources: [{ id: 'gold', name: 'Gold' }], + actions: [ + { + id: 'forage', + name: 'Forage', + durationMs: 3000, + yields: [{ resourceId: 'gold', amount: 1 }], + storyHint: 'Pick herbs along the trail.', + storyTooltip: 'Yields +1 Gold. Safe choice.', + }, + ], + }); + expect(content.actionsById.forage.storyHint).toBe('Pick herbs along the trail.'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test src/content/__tests__/schema.test.ts -t "storyHint"` + +Expected: FAIL + +- [ ] **Step 3: Extend action schema and GameContent export** + +In `schema.ts`: + +```typescript +export const actionDefSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + durationMs: z.number().positive(), + costs: z.array(resourceAmountSchema).default([]), + yields: z.array(resourceAmountSchema).min(1), + unlock: unlockDefSchema.optional(), + storyHint: z.string().min(1).optional(), + storyTooltip: z.string().min(1).optional(), +}); +``` + +In `index.ts`: + +```typescript +import { actionDefs, resourceDefs } from './definitions'; +import { buildContent, type Content } from './schema'; +import { buildStoryContent, type StoryContent } from './storySchema'; +import { storyNodeDefs } from './story'; + +const base = buildContent({ resources: resourceDefs, actions: actionDefs }); +const story = buildStoryContent(storyNodeDefs, base.actionsById, base.resourcesById); + +export type GameContent = Content & StoryContent; +export const content: GameContent = { ...base, ...story }; +``` + +Create a minimal placeholder `src/content/story.ts` for now (Task 18 replaces with full stub): + +```typescript +export const storyNodeDefs = [ + { + id: 'boot_intro', + prose: 'Placeholder boot.', + triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }], + }, +]; +``` + +Update engine/content imports: change `Content` to `GameContent` where story APIs need story nodes — or export `GameContent` as the runtime content type. For minimal churn, add `GameContent` in `schema.ts` re-export from index and use `GameContent` in `story.ts` engine module. + +- [ ] **Step 4: Run tests** + +Run: `pnpm test src/content/__tests__/schema.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/content/schema.ts src/content/index.ts src/content/story.ts src/content/__tests__/schema.test.ts +git commit -m "feat(content): add action story hints and merge GameContent" +``` + +--- + +### Task 5: Story engine — outcomes and enterNode + +**Files:** +- Create: `src/engine/story.ts` +- Create: `src/engine/__tests__/story.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `src/engine/__tests__/story.test.ts` with helpers: + +```typescript +import { describe, expect, it } from 'vitest'; +import { buildContent } from '../../content/schema'; +import { buildStoryContent } from '../../content/storySchema'; +import { createGameState } from '../game'; +import { applyOutcomes, enterStoryNode, initStory } from '../story'; + +function gameContent() { + const base = buildContent({ + resources: [ + { id: 'supplies', name: 'Supplies', startAmount: 10 }, + { id: 'coin', name: 'Coin', startAmount: 0 }, + ], + actions: [ + { id: 'scout_path', name: 'Scout', durationMs: 1000, costs: [], yields: [{ resourceId: 'coin', amount: 1 }] }, + ], + }); + const story = buildStoryContent( + [ + { + id: 'boot_intro', + prose: 'Boot.', + triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }], + enterOutcomes: [{ type: 'grantResource', resourceId: 'coin', amount: 1 }], + }, + ], + base.actionsById, + base.resourcesById, + ); + return { ...base, ...story }; +} + +describe('enterStoryNode()', () => { + it('sets current node, marks seen, applies enterOutcomes', () => { + const content = gameContent(); + const state = createGameState(content); + const events = enterStoryNode(state, content, 'boot_intro'); + expect(state.currentStoryNodeId).toBe('boot_intro'); + expect(state.seenStoryNodeIds).toContain('boot_intro'); + expect(state.resources.coin).toBe(1); + expect(events.length).toBeGreaterThan(0); + }); +}); + +describe('initStory()', () => { + it('leaves currentStoryNodeId empty until triggers run', () => { + const content = gameContent(); + const state = createGameState(content); + initStory(state, content); + expect(state.currentStoryNodeId).toBe(''); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test src/engine/__tests__/story.test.ts` + +Expected: FAIL + +- [ ] **Step 3: Implement outcomes + enterStoryNode + initStory** + +Create `src/engine/story.ts`: + +```typescript +import type { GameContent } from '../content/index'; +import type { StoryOutcome } from '../content/storySchema'; +import type { GameState } from './game'; + +export interface StoryEvent { + kind: 'enter' | 'log'; + nodeId: string; + prose: string; + choiceLabel?: string; +} + +export function applyOutcomes( + state: GameState, + content: GameContent, + outcomes: StoryOutcome[], + events: StoryEvent[], +): void { + for (const outcome of outcomes) { + switch (outcome.type) { + case 'setFlag': + state.storyFlags[outcome.flag] = true; + break; + case 'clearFlag': + state.storyFlags[outcome.flag] = false; + break; + case 'grantResource': + state.resources[outcome.resourceId] = (state.resources[outcome.resourceId] ?? 0) + outcome.amount; + break; + case 'consumeResource': { + const current = state.resources[outcome.resourceId] ?? 0; + if (current < outcome.amount) { + throw new Error(`Cannot consume ${outcome.amount} ${outcome.resourceId} (have ${current})`); + } + state.resources[outcome.resourceId] = current - outcome.amount; + break; + } + case 'log': + events.push({ kind: 'log', nodeId: state.currentStoryNodeId, prose: outcome.text }); + break; + } + } +} + +export function enterStoryNode(state: GameState, content: GameContent, nodeId: string): StoryEvent[] { + const node = content.storyNodesById[nodeId]; + if (!node) throw new Error(`Unknown story node "${nodeId}"`); + const events: StoryEvent[] = []; + if (!state.seenStoryNodeIds.includes(nodeId)) { + state.seenStoryNodeIds.push(nodeId); + } + state.currentStoryNodeId = nodeId; + applyOutcomes(state, content, node.enterOutcomes ?? [], events); + events.unshift({ kind: 'enter', nodeId, prose: node.prose }); + return events; +} + +export function initStory(state: GameState, _content: GameContent): void { + state.storyFlags = state.storyFlags ?? {}; + state.seenStoryNodeIds = state.seenStoryNodeIds ?? []; + if (!state.currentStoryNodeId) { + state.currentStoryNodeId = ''; + } +} +``` + +Use a type-only import pattern if circular — `GameContent` from `content/index` is fine in engine tests; engine `story.ts` should import `StoryContent & Content` as: + +```typescript +import type { GameContent } from '../content/index'; +``` + +If Biome/lint complains about content importing engine, define `StoryContent` + `Content` intersection locally: + +```typescript +import type { Content } from '../content/schema'; +import type { StoryContent } from '../content/storySchema'; +type GameContent = Content & StoryContent; +``` + +- [ ] **Step 4: Run tests** + +Run: `pnpm test src/engine/__tests__/story.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/story.ts src/engine/__tests__/story.test.ts +git commit -m "feat(engine): story outcomes and enterStoryNode" +``` + +--- + +### Task 6: Story engine — evaluateTriggers + +**Files:** +- Modify: `src/engine/story.ts` +- Modify: `src/engine/__tests__/story.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Add to `story.test.ts`: + +```typescript +import { evaluateTriggers } from '../story'; + +// extend gameContent() with extra nodes/triggers for trigger tests + +describe('evaluateTriggers()', () => { + it('fires boot trigger on boot reason', () => { + const content = gameContent(); + const state = createGameState(content); + initStory(state, content); + const { enteredNodeIds } = evaluateTriggers(state, content, { reason: 'boot' }); + expect(enteredNodeIds).toEqual(['boot_intro']); + expect(state.currentStoryNodeId).toBe('boot_intro'); + }); + + it('fires actionComplete when scout_path finishes', () => { + const content = gameContentWithActionTrigger(); // helper defined in test file + const state = createGameState(content); + const { enteredNodeIds } = evaluateTriggers(state, content, { + reason: 'actionComplete', + actionId: 'scout_path', + }); + expect(enteredNodeIds).toEqual(['scout_aftermath']); + }); + + it('fires minResources on publish when thresholds met', () => { + const content = gameContentWithThreshold(); + const state = createGameState(content); + state.resources.coin = 3; + const { enteredNodeIds } = evaluateTriggers(state, content, { reason: 'publish' }); + expect(enteredNodeIds).toEqual(['merchant_flavor']); + }); + + it('does not re-fire once-only triggers for seen targets', () => { + const content = gameContent(); + const state = createGameState(content); + evaluateTriggers(state, content, { reason: 'boot' }); + const second = evaluateTriggers(state, content, { reason: 'boot' }); + expect(second.enteredNodeIds).toEqual([]); + }); +}); +``` + +Implement helpers `gameContentWithActionTrigger` and `gameContentWithThreshold` inline in the test file with minimal node sets. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test src/engine/__tests__/story.test.ts -t "evaluateTriggers"` + +Expected: FAIL + +- [ ] **Step 3: Implement evaluateTriggers** + +Add to `story.ts`: + +```typescript +export type TriggerContext = + | { reason: 'boot' } + | { reason: 'publish' } + | { reason: 'actionComplete'; actionId: string }; + +export interface TriggerResult { + enteredNodeIds: string[]; + events: StoryEvent[]; +} + +function meetsMinResources(state: GameState, minResources: Record): boolean { + return Object.entries(minResources).every( + ([id, min]) => (state.resources[id] ?? 0) >= min, + ); +} + +function shouldSkipTrigger(state: GameState, trigger: { targetNodeId: string; once: boolean }): boolean { + return trigger.once !== false && state.seenStoryNodeIds.includes(trigger.targetNodeId); +} + +export function evaluateTriggers( + state: GameState, + content: GameContent, + ctx: TriggerContext, +): TriggerResult { + const enteredNodeIds: string[] = []; + const events: StoryEvent[] = []; + + for (const node of content.storyNodes) { + for (const trigger of node.triggers ?? []) { + if (shouldSkipTrigger(state, trigger)) continue; + + let matches = false; + if (ctx.reason === 'boot' && trigger.type === 'boot') matches = true; + if (ctx.reason === 'actionComplete' && trigger.type === 'actionComplete' && trigger.actionId === ctx.actionId) { + matches = true; + } + if ( + (ctx.reason === 'publish' || ctx.reason === 'actionComplete') && + trigger.type === 'minResources' && + trigger.minResources && + meetsMinResources(state, trigger.minResources) + ) { + matches = true; + } + + if (matches) { + enteredNodeIds.push(trigger.targetNodeId); + events.push(...enterStoryNode(state, content, trigger.targetNodeId)); + } + } + } + + return { enteredNodeIds, events }; +} +``` + +- [ ] **Step 4: Run tests** + +Run: `pnpm test src/engine/__tests__/story.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/story.ts src/engine/__tests__/story.test.ts +git commit -m "feat(engine): evaluateTriggers for boot, actionComplete, minResources" +``` + +--- + +### Task 7: Story engine — applyChoice and getAvailableChoices + +**Files:** +- Modify: `src/engine/story.ts` +- Modify: `src/engine/__tests__/story.test.ts` + +- [ ] **Step 1: Write the failing tests** + +```typescript +import { applyChoice, getAvailableChoices, getCurrentNode } from '../story'; + +describe('applyChoice()', () => { + it('applies outcomes and advances on fork', () => { + const content = gameContentWithFork(); // boot + fork_choice + route nodes + const state = createGameState(content); + enterStoryNode(state, content, 'fork_choice'); + applyChoice(state, content, 'pick_a'); + expect(state.storyFlags.route_a).toBe(true); + expect(state.currentStoryNodeId).toBe('route_a_beat'); + }); + + it('throws when requirements not met', () => { + const content = gameContentWithGatedChoice(); + const state = createGameState(content); + enterStoryNode(state, content, 'gated'); + expect(() => applyChoice(state, content, 'needs_coin')).toThrow(/requirements/i); + }); +}); + +describe('getAvailableChoices()', () => { + it('hides choices blocked by excludeStoryFlags', () => { + const content = gameContentWithFork(); + const state = createGameState(content); + state.storyFlags.route_a = true; + enterStoryNode(state, content, 'fork_choice'); + const choices = getAvailableChoices(state, content); + expect(choices.map((c) => c.id)).not.toContain('pick_b_if_excluded'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Expected: FAIL + +- [ ] **Step 3: Implement choice APIs** + +```typescript +import type { StoryChoice } from '../content/storySchema'; + +function meetsChoiceRequirements(state: GameState, requirements: StoryChoice['requirements']): boolean { + if (!requirements) return true; + if (requirements.minResources) { + for (const [id, min] of Object.entries(requirements.minResources)) { + if ((state.resources[id] ?? 0) < min) return false; + } + } + if (requirements.requireStoryFlags) { + for (const flag of requirements.requireStoryFlags) { + if (!state.storyFlags[flag]) return false; + } + } + if (requirements.excludeStoryFlags) { + for (const flag of requirements.excludeStoryFlags) { + if (state.storyFlags[flag]) return false; + } + } + return true; +} + +export function getCurrentNode(state: GameState, content: GameContent) { + return content.storyNodesById[state.currentStoryNodeId] ?? null; +} + +export function getAvailableChoices(state: GameState, content: GameContent): StoryChoice[] { + const node = getCurrentNode(state, content); + if (!node?.choices) return []; + return node.choices.filter((c) => meetsChoiceRequirements(state, c.requirements)); +} + +export function applyChoice(state: GameState, content: GameContent, choiceId: string): StoryEvent[] { + const node = getCurrentNode(state, content); + if (!node?.choices) throw new Error(`Node "${node?.id}" has no choices`); + const choice = node.choices.find((c) => c.id === choiceId); + if (!choice) throw new Error(`Unknown choice "${choiceId}"`); + if (!meetsChoiceRequirements(state, choice.requirements)) { + throw new Error(`Choice "${choiceId}" requirements not met`); + } + const events: StoryEvent[] = []; + applyOutcomes(state, content, choice.outcomes, events); + events.push(...enterStoryNode(state, content, choice.targetNodeId)); + const last = events.find((e) => e.kind === 'enter'); + if (last) last.choiceLabel = choice.label; + return events; +} +``` + +- [ ] **Step 4: Run tests** + +Run: `pnpm test src/engine/__tests__/story.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/story.ts src/engine/__tests__/story.test.ts +git commit -m "feat(engine): applyChoice and gated choice filtering" +``` + +--- + +### Task 8: Save persistence for story fields + +**Files:** +- Modify: `src/engine/save.ts` +- Modify: `src/engine/__tests__/save.test.ts` +- Modify: `src/state/persistence.ts` +- Modify: `src/state/__tests__/persistence.test.ts` + +- [ ] **Step 1: Write the failing tests** + +In `save.test.ts`: + +```typescript +it('snapshots story fields in the save payload', () => { + const state = sampleState(); + state.storyFlags = { route_a: true }; + state.currentStoryNodeId = 'route_a_beat'; + state.seenStoryNodeIds = ['boot_intro', 'route_a_beat']; + const save = createSave(state, 1700); + expect(save.state.storyFlags).toEqual({ route_a: true }); + expect(save.state.currentStoryNodeId).toBe('route_a_beat'); + expect(save.state.seenStoryNodeIds).toHaveLength(2); +}); +``` + +In `persistence.test.ts`: + +```typescript +it('restores story fields on load', async () => { + const content = buildTestGameContent(); // minimal content helper + const backend = createMemoryBackend(); + const state = createGameState(content); + state.storyFlags = { route_b: true }; + state.currentStoryNodeId = 'fork_choice'; + state.seenStoryNodeIds = ['boot_intro']; + await saveGame(state, backend, 1000); + const loaded = await loadGame(content, backend, 1000); + expect(loaded.state.storyFlags.route_b).toBe(true); + expect(loaded.state.currentStoryNodeId).toBe('fork_choice'); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Expected: FAIL + +- [ ] **Step 3: Extend save schema and persistence** + +In `save.ts`: + +```typescript +export const gameStateSchema = z.object({ + resources: z.record(z.string(), z.number()), + activeActionId: z.string().nullable(), + actionElapsedMs: z.number().nonnegative(), + actionQueue: z.array(z.string()).default([]), + storyFlags: z.record(z.string(), z.boolean()).default({}), + currentStoryNodeId: z.string().default(''), + seenStoryNodeIds: z.array(z.string()).default([]), +}); +``` + +Update `createSave` to copy story fields. + +In `persistence.ts` hydration: + +```typescript +state = { + resources: { ...base.resources, ...save.state.resources }, + activeActionId, + actionElapsedMs: save.state.actionElapsedMs, + actionQueue: [...(save.state.actionQueue ?? [])], + storyFlags: { ...save.state.storyFlags }, + currentStoryNodeId: save.state.currentStoryNodeId ?? '', + seenStoryNodeIds: [...(save.state.seenStoryNodeIds ?? [])], +}; +``` + +- [ ] **Step 4: Run tests** + +Run: `pnpm test src/engine/__tests__/save.test.ts src/state/__tests__/persistence.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/save.ts src/engine/__tests__/save.test.ts src/state/persistence.ts src/state/__tests__/persistence.test.ts +git commit -m "feat(save): persist story flags and node progress in v1" +``` + +--- + +### Task 9: Player preferences module + +**Files:** +- Create: `src/state/prefs.ts` +- Create: `src/state/__tests__/prefs.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getPrefs, setPrefs, type GamePrefs } from '../prefs'; + +describe('prefs', () => { + beforeEach(() => { + vi.stubGlobal('localStorage', { + store: {} as Record, + getItem(key: string) { return this.store[key] ?? null; }, + setItem(key: string, value: string) { this.store[key] = value; }, + }); + }); + + it('returns defaults when localStorage empty', () => { + expect(getPrefs()).toEqual({ storyOpenMode: 'auto', actionDetailMode: 'inline' }); + }); + + it('round-trips updated prefs', () => { + setPrefs({ storyOpenMode: 'manual', actionDetailMode: 'hover' }); + expect(getPrefs().storyOpenMode).toBe('manual'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Expected: FAIL + +- [ ] **Step 3: Implement prefs.ts** + +```typescript +const PREFS_KEY = 'idlegame:prefs:v1'; + +export type StoryOpenMode = 'auto' | 'choices-only' | 'manual'; +export type ActionDetailMode = 'inline' | 'hover' | 'info-button'; + +export interface GamePrefs { + storyOpenMode: StoryOpenMode; + actionDetailMode: ActionDetailMode; +} + +const DEFAULTS: GamePrefs = { + storyOpenMode: 'auto', + actionDetailMode: 'inline', +}; + +export function getPrefs(): GamePrefs { + if (typeof localStorage === 'undefined') return { ...DEFAULTS }; + try { + const raw = localStorage.getItem(PREFS_KEY); + if (!raw) return { ...DEFAULTS }; + return { ...DEFAULTS, ...JSON.parse(raw) }; + } catch { + return { ...DEFAULTS }; + } +} + +export function setPrefs(partial: Partial): GamePrefs { + const next = { ...getPrefs(), ...partial }; + if (typeof localStorage !== 'undefined') { + localStorage.setItem(PREFS_KEY, JSON.stringify(next)); + } + return next; +} +``` + +- [ ] **Step 4: Run test** + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/state/prefs.ts src/state/__tests__/prefs.test.ts +git commit -m "feat(state): localStorage player prefs for story and action display" +``` + +--- + +### Task 10: View model — action availability and story fields + +**Files:** +- Modify: `src/state/viewModel.ts` +- Modify: `src/state/__tests__/viewModel.test.ts` + +- [ ] **Step 1: Write the failing tests** + +```typescript +import { getAvailableChoices } from '../../engine/story'; + +describe('toView() action availability', () => { + it('marks locked actions unavailable with reason', () => { + const content = buildContent({ + resources: [{ id: 'coin', name: 'Coin', startAmount: 0 }], + actions: [ + { + id: 'locked', + name: 'Locked', + durationMs: 1000, + yields: [{ resourceId: 'coin', amount: 1 }], + unlock: { requireStoryFlags: ['route_a'] }, + }, + ], + }); + const state = createGameState(content); + const view = toView(state, content); + expect(view.actions[0].available).toBe(false); + expect(view.actions[0].disabledReason).toMatch(/locked/i); + }); + + it('includes story passage and choices from current node', () => { + const content = /* GameContent with fork node */; + const state = createGameState(content); + enterStoryNode(state, content, 'fork_choice'); + const view = toView(state, content); + expect(view.story.currentProse).toContain('Which way'); + expect(view.story.choices.length).toBe(2); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Expected: FAIL + +- [ ] **Step 3: Extend view model** + +Update `viewModel.ts`: + +```typescript +import { canAffordAction, canUnlockAction, isActionAvailable } from '../engine/game'; +import { getAvailableChoices, getCurrentNode } from '../engine/story'; +import type { GameContent } from '../content/index'; + +export interface ActionView { + id: string; + name: string; + available: boolean; + disabledReason: string | null; + storyHint?: string; + storyTooltip?: string; + costsSummary: string | null; + yieldsSummary: string | null; +} + +export interface StoryChoiceView { + id: string; + label: string; + disabled: boolean; + disabledReason: string | null; +} + +export interface StoryView { + currentProse: string | null; + choices: StoryChoiceView[]; +} + +export interface GameView { + resources: ResourceView[]; + activeActionId: string | null; + actionName: string | null; + actionProgress: number; + queuedActionIds: string[]; + queuedActionNames: string[]; + actions: ActionView[]; + story: StoryView; +} + +function disabledReason(state: GameState, content: GameContent, actionId: string): string | null { + if (!canUnlockAction(state, content, actionId, state.storyFlags)) return 'Locked'; + if (!canAffordAction(state, content, actionId)) return 'Not enough resources'; + return null; +} + +function formatResourceList(items: { resourceId: string; amount: number }[], content: GameContent): string { + return items.map((i) => `${i.amount} ${content.resourcesById[i.resourceId]?.name ?? i.resourceId}`).join(', '); +} + +export function toView(state: GameState, content: GameContent): GameView { + // ... existing resource/progress/queue mapping ... + + const actions: ActionView[] = content.actions.map((action) => ({ + id: action.id, + name: action.name, + available: isActionAvailable(state, content, action.id), + disabledReason: disabledReason(state, content, action.id), + storyHint: action.storyHint, + storyTooltip: action.storyTooltip, + costsSummary: action.costs.length ? formatResourceList(action.costs, content) : null, + yieldsSummary: formatResourceList(action.yields, content), + })); + + const node = getCurrentNode(state, content); + const availableChoices = getAvailableChoices(state, content); + const allChoices = node?.choices ?? []; + + const story: StoryView = { + currentProse: node?.prose ?? null, + choices: allChoices.map((choice) => { + const available = availableChoices.some((c) => c.id === choice.id); + return { + id: choice.id, + label: choice.label, + disabled: !available, + disabledReason: available ? null : 'Requirements not met', + }; + }), + }; + + return { /* existing fields */, actions, story }; +} +``` + +Fix `isActionAvailable` in `game.ts` to use `state.storyFlags` internally (update signature in Task 1 if not done). + +- [ ] **Step 4: Run tests** + +Run: `pnpm test src/state/__tests__/viewModel.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/state/viewModel.ts src/state/__tests__/viewModel.test.ts src/engine/game.ts +git commit -m "feat(state): view model action availability and story passage" +``` + +--- + +### Task 11: Store story UI state + +**Files:** +- Modify: `src/state/store.ts` + +- [ ] **Step 1: Extend store interface** + +```typescript +export interface StoryLogEntry { + nodeId: string; + prose: string; + choiceLabel?: string; +} + +export interface GameStoreState extends GameView { + log: string[]; + storyPanelOpen: boolean; + storyHasUnread: boolean; + storyLog: StoryLogEntry[]; + prefs: GamePrefs; + setView: (view: GameView) => void; + appendLog: (line: string) => void; + appendStoryLog: (entry: StoryLogEntry) => void; + setStoryPanelOpen: (open: boolean) => void; + setStoryHasUnread: (unread: boolean) => void; + setPrefs: (partial: Partial) => void; +} +``` + +Wire defaults: `storyPanelOpen: false`, `storyHasUnread: false`, `storyLog: []`, `prefs: getPrefs()`. + +- [ ] **Step 2: Run typecheck** + +Run: `pnpm typecheck` + +Expected: FAIL until runtime/UI updated — implement store methods first. + +- [ ] **Step 3: Implement store actions** + +```typescript +import { getPrefs, setPrefs as persistPrefs } from './prefs'; + +appendStoryLog: (entry) => + set((state) => ({ storyLog: [...state.storyLog, entry] })), +setStoryPanelOpen: (open) => set({ storyPanelOpen: open }), +setStoryHasUnread: (unread) => set({ storyHasUnread: unread }), +setPrefs: (partial) => { + const prefs = persistPrefs(partial); + set({ prefs }); +}, +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/state/store.ts +git commit -m "feat(state): store story panel and prefs UI state" +``` + +--- + +### Task 12: Story orchestration module + +**Files:** +- Create: `src/state/storyOrchestration.ts` +- Create: `src/state/__tests__/storyOrchestration.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +import { describe, expect, it } from 'vitest'; +import { content } from '../../content'; +import { createGameState } from '../../engine/game'; +import { initStory } from '../../engine/story'; +import { processStoryTriggers } from '../storyOrchestration'; + +describe('processStoryTriggers()', () => { + it('returns entered nodes on boot', () => { + const state = createGameState(content); + initStory(state, content); + const result = processStoryTriggers(state, content, { reason: 'boot' }); + expect(result.enteredNodeIds.length).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Expected: FAIL + +- [ ] **Step 3: Implement storyOrchestration.ts** + +```typescript +import type { GameContent } from '../content/index'; +import type { GameState } from '../engine/game'; +import { evaluateTriggers, type StoryEvent, type TriggerContext } from '../engine/story'; +import type { StoryLogEntry } from './store'; +import type { GamePrefs } from './prefs'; + +export interface StoryUiEffect { + enteredNodeIds: string[]; + logEntries: StoryLogEntry[]; + shouldOpenPanel: boolean; + eventLogLines: string[]; +} + +export function storyEventsToLogEntries(events: StoryEvent[]): StoryLogEntry[] { + return events + .filter((e) => e.kind === 'enter') + .map((e) => ({ nodeId: e.nodeId, prose: e.prose, choiceLabel: e.choiceLabel })); +} + +export function shouldAutoOpenPanel( + prefs: GamePrefs, + nodeId: string, + content: GameContent, +): boolean { + const node = content.storyNodesById[nodeId]; + if (!node) return false; + if (prefs.storyOpenMode === 'manual') return false; + if (prefs.storyOpenMode === 'auto') return true; + // choices-only + return (node.choices?.length ?? 0) > 0; +} + +export function processStoryTriggers( + state: GameState, + content: GameContent, + ctx: TriggerContext, + prefs: GamePrefs, +): StoryUiEffect { + const { enteredNodeIds, events } = evaluateTriggers(state, content, ctx); + const logEntries = storyEventsToLogEntries(events); + const shouldOpenPanel = + enteredNodeIds.length > 0 && + enteredNodeIds.some((id) => shouldAutoOpenPanel(prefs, id, content)); + const eventLogLines = logEntries.map((e) => + e.choiceLabel ? `Story: ${e.choiceLabel}` : `Story: ${content.storyNodesById[e.nodeId]?.prose.slice(0, 40)}…`, + ); + return { enteredNodeIds, logEntries, shouldOpenPanel, eventLogLines }; +} +``` + +- [ ] **Step 4: Run test** + +Expected: PASS (requires stub story in content from Task 18 — if boot node only, still passes). + +- [ ] **Step 5: Commit** + +```bash +git add src/state/storyOrchestration.ts src/state/__tests__/storyOrchestration.test.ts +git commit -m "feat(state): story trigger orchestration and auto-open rules" +``` + +--- + +### Task 13: Runtime wiring + +**Files:** +- Modify: `src/state/runtime.ts` + +- [ ] **Step 1: Wire boot story sequence in `boot()`** + +After `loadGame`: + +```typescript +import { initStory, applyChoice as engineApplyChoice, enterStoryNode } from '../engine/story'; +import { getPrefs } from './prefs'; +import { processStoryTriggers, storyEventsToLogEntries, shouldAutoOpenPanel } from './storyOrchestration'; + +// inside boot(), after state assigned: +initStory(this.state, content); +const prefs = getPrefs(); +useGameStore.getState().setPrefs(prefs); +const bootEffect = processStoryTriggers(this.state, content, { reason: 'boot' }, prefs); +this.applyStoryUiEffect(bootEffect); +this.publish(); +``` + +Add private helpers to `GameRuntime`: + +```typescript +private applyStoryUiEffect(effect: StoryUiEffect): void { + const store = useGameStore.getState(); + for (const entry of effect.logEntries) store.appendStoryLog(entry); + for (const line of effect.eventLogLines) store.appendLog(line); + if (effect.shouldOpenPanel) { + store.setStoryPanelOpen(true); + store.setStoryHasUnread(false); + } else if (effect.enteredNodeIds.length > 0) { + store.setStoryHasUnread(true); + } +} + +private runPublishTriggers(): void { + const state = this.state; + if (!state) return; + const prefs = useGameStore.getState().prefs; + const effect = processStoryTriggers(state, content, { reason: 'publish' }, prefs); + this.applyStoryUiEffect(effect); +} +``` + +- [ ] **Step 2: Wire tick completion triggers** + +In `frame`: + +```typescript +const result = tickGame(state, content, TICK_MS); +for (const actionId of result.completedActionIds) { + const prefs = useGameStore.getState().prefs; + const effect = processStoryTriggers(state, content, { reason: 'actionComplete', actionId }, prefs); + this.applyStoryUiEffect(effect); +} +// before or after publish interval: +if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) { + this.runPublishTriggers(); + this.publish(); +} +``` + +- [ ] **Step 3: Add public runtime commands** + +```typescript +applyStoryChoice(choiceId: string): void { + const state = this.state; + if (!state) return; + try { + const events = engineApplyChoice(state, content, choiceId); + const entries = storyEventsToLogEntries(events); + const store = useGameStore.getState(); + for (const entry of entries) store.appendStoryLog(entry); + const choiceLabel = entries.at(-1)?.choiceLabel; + if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`); + store.setStoryPanelOpen(false); + this.runPublishTriggers(); + this.publish(); + } catch (err) { + useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Choice failed'); + } +} + +cancelQueuedAction(index: number): void { + const state = this.state; + if (!state) return; + try { + cancelQueuedAction(state, index); + useGameStore.getState().appendLog('Removed queued action.'); + this.publish(); + } catch (err) { + useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Cancel failed'); + } +} + +openStoryPanel(): void { + useGameStore.getState().setStoryPanelOpen(true); + useGameStore.getState().setStoryHasUnread(false); +} + +closeStoryPanel(): void { + useGameStore.getState().setStoryPanelOpen(false); +} + +continueStory(): void { + // passage-only node dismiss + this.closeStoryPanel(); +} +``` + +Update `enqueueAction` to use `isActionAvailable` with story flags and log disabled reason: + +```typescript +if (!isActionAvailable(state, content, actionId)) { + const reason = /* from view model helper or inline */; + useGameStore.getState().appendLog(reason ?? 'Cannot enqueue'); + return; +} +``` + +- [ ] **Step 4: Run typecheck and tests** + +Run: `pnpm typecheck && pnpm test` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/state/runtime.ts +git commit -m "feat(state): wire story triggers and choices through runtime" +``` + +--- + +### Task 14: ActionPanel — queue, cancel, disabled, hints + +**Files:** +- Modify: `src/ui/ActionPanel.tsx` + +- [ ] **Step 1: Rewrite ActionPanel to consume view.actions and view queue** + +```tsx +import { content } from '../content'; +import { gameRuntime } from '../state/runtime'; +import { useGameStore } from '../state/store'; + +export function ActionPanel() { + const actions = useGameStore((s) => s.actions); + const activeActionId = useGameStore((s) => s.activeActionId); + const actionProgress = useGameStore((s) => s.actionProgress); + const queuedActionIds = useGameStore((s) => s.queuedActionIds); + const queuedActionNames = useGameStore((s) => s.queuedActionNames); + const prefs = useGameStore((s) => s.prefs); + + return ( +
+

Actions

+ {actions.map((action) => { + const isActive = action.id === activeActionId; + return ( +
+ + {prefs.actionDetailMode === 'info-button' && action.storyTooltip ? ( + + ) : null} +
+ ); + })} + {queuedActionNames.length > 0 ? ( +
    + {queuedActionNames.map((name, index) => ( +
  1. + {name} + +
  2. + ))} +
+ ) : null} +
+ ); +} +``` + +- [ ] **Step 2: Manual smoke** + +Run: `pnpm dev` — verify queue list, cancel, disabled styling, inline hints. + +- [ ] **Step 3: Commit** + +```bash +git add src/ui/ActionPanel.tsx +git commit -m "feat(ui): action queue list, cancel, disabled states, and hints" +``` + +--- + +### Task 15: StoryPanel overlay + +**Files:** +- Create: `src/ui/StoryPanel.tsx` + +- [ ] **Step 1: Implement StoryPanel** + +```tsx +import { gameRuntime } from '../state/runtime'; +import { useGameStore } from '../state/store'; + +export function StoryPanel() { + const open = useGameStore((s) => s.storyPanelOpen); + const story = useGameStore((s) => s.story); + const storyLog = useGameStore((s) => s.storyLog); + + if (!open) return null; + + const hasChoices = story.choices.length > 0; + + return ( +
+
+ +
+

{story.currentProse}

+ {hasChoices ? ( +
+ {story.choices.map((choice) => ( + + ))} +
+ ) : ( + + )} + +
+
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/ui/StoryPanel.tsx +git commit -m "feat(ui): full-screen story panel with VN layout and story log" +``` + +--- + +### Task 16: SettingsDrawer and App header + +**Files:** +- Create: `src/ui/SettingsDrawer.tsx` +- Modify: `src/ui/App.tsx` + +- [ ] **Step 1: Implement SettingsDrawer** + +```tsx +import { useGameStore } from '../state/store'; +import type { ActionDetailMode, StoryOpenMode } from '../state/prefs'; + +export function SettingsDrawer() { + const open = useGameStore((s) => s.settingsOpen); // add settingsOpen to store OR use local useState in App + const prefs = useGameStore((s) => s.prefs); + const setPrefs = useGameStore((s) => s.setPrefs); + if (!open) return null; + return ( +
+ + +
+ ); +} +``` + +- [ ] **Step 2: Update App.tsx header** + +```tsx +import { StoryPanel } from './StoryPanel'; +import { SettingsDrawer } from './SettingsDrawer'; + +// header: title, Story button (shows badge if storyHasUnread), gear toggle +// subtitle: "M1 playable loop — PR2" + +``` + +Wire Story button: `onClick={() => gameRuntime.openStoryPanel()}` with unread dot when `storyHasUnread`. + +- [ ] **Step 3: Commit** + +```bash +git add src/ui/SettingsDrawer.tsx src/ui/App.tsx src/state/store.ts +git commit -m "feat(ui): settings drawer and story button in app shell" +``` + +--- + +### Task 17: Stub story graph and content pack + +**Files:** +- Modify: `src/content/story.ts` +- Modify: `src/content/definitions.ts` +- Create: `src/content/__tests__/story.test.ts` + +- [ ] **Step 1: Write integration test for stub branch divergence** + +```typescript +import { describe, expect, it } from 'vitest'; +import { content } from '../index'; +import { createGameState, isActionAvailable } from '../../engine/game'; +import { applyChoice, enterStoryNode, evaluateTriggers, initStory } from '../../engine/story'; + +describe('stub story graph', () => { + it('route A unlocks fortify_camp but not push_onward', () => { + const state = createGameState(content); + initStory(state, content); + evaluateTriggers(state, content, { reason: 'boot' }); + enterStoryNode(state, content, 'fork_choice'); + applyChoice(state, content, 'pick_a'); + expect(state.storyFlags.route_a).toBe(true); + expect(isActionAvailable(state, content, 'fortify_camp')).toBe(true); + expect(isActionAvailable(state, content, 'push_onward')).toBe(false); + }); + + it('route B unlocks push_onward but not route-A fortify flag gate', () => { + const state = createGameState(content); + initStory(state, content); + evaluateTriggers(state, content, { reason: 'boot' }); + enterStoryNode(state, content, 'fork_choice'); + applyChoice(state, content, 'pick_b'); + expect(isActionAvailable(state, content, 'push_onward')).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Replace story.ts with full stub graph** + +```typescript +export const storyNodeDefs = [ + { + id: 'boot_intro', + prose: '[Stub] You wake at a crossroads camp. Smoke rises from a cold fire pit.', + triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }], + }, + { + id: 'fork_choice', + prose: '[Stub] Tracks split. The high road climbs; the low road bends toward the river.', + choices: [ + { + id: 'pick_a', + label: 'Take the high road', + outcomes: [ + { type: 'setFlag', flag: 'route_a' }, + { type: 'grantResource', resourceId: 'supplies', amount: 3 }, + ], + targetNodeId: 'route_a_beat', + }, + { + id: 'pick_b', + label: 'Follow the river', + outcomes: [ + { type: 'setFlag', flag: 'route_b' }, + { type: 'grantResource', resourceId: 'coin', amount: 2 }, + ], + targetNodeId: 'route_b_beat', + }, + ], + }, + { id: 'route_a_beat', prose: '[Stub] Route A: high ground, extra supplies.' }, + { id: 'route_b_beat', prose: '[Stub] Route B: river trade, extra coin.' }, + { + id: 'threshold_listener', + prose: '', + triggers: [{ type: 'minResources', minResources: { coin: 3 }, targetNodeId: 'merchant_flavor' }], + }, + { id: 'merchant_flavor', prose: '[Stub] A merchant remembers your face.' }, + { + id: 'scout_listener', + prose: '', + triggers: [{ type: 'actionComplete', actionId: 'scout_path', targetNodeId: 'scout_aftermath' }], + }, + { id: 'scout_aftermath', prose: '[Stub] The path is mapped.' }, +]; +``` + +Boot flow: after boot_intro fires, runtime needs to advance player to fork — add enterOutcomes or second boot step. **Fix:** boot trigger targets `boot_intro`; on Continue player stays until we add auto-chain OR boot_intro has no choices and `continueStory` calls `enterStoryNode(state, content, 'fork_choice')` from runtime. Simpler: boot trigger targets `fork_choice` directly OR boot_intro choices empty with `enterOutcomes` that don't auto-advance — **simplest stub fix:** set boot `targetNodeId: 'fork_choice'` and move intro prose to fork's enterOutcomes log, OR chain: boot_intro prose only, player clicks Continue → runtime advances to fork_choice via `continueStory` calling `enterStoryNode(state, content, 'fork_choice')`. + +Plan explicit behavior in `continueStory`: + +```typescript +continueStory(): void { + const state = this.state; + if (!state) return; + if (state.currentStoryNodeId === 'boot_intro') { + enterStoryNode(state, content, 'fork_choice'); + // apply UI effect... + } + this.closeStoryPanel(); + this.publish(); +} +``` + +- [ ] **Step 3: Update definitions.ts** + +Add `push_onward`: + +```typescript +{ + id: 'push_onward', + name: 'Push onward', + durationMs: 6000, + costs: [{ resourceId: 'supplies', amount: 2 }], + yields: [{ resourceId: 'coin', amount: 3 }], + unlock: { requireStoryFlags: ['route_b'] }, + storyHint: 'Follow the river route.', + storyTooltip: 'Costs 2 Supplies. Yields 3 Coin. Route B only.', +}, +``` + +Update `fortify_camp`: + +```typescript +unlock: { minResources: { supplies: 8 }, requireStoryFlags: ['route_a'] }, +storyHint: 'Walls for the high road camp.', +storyTooltip: 'Route A only. Costs supplies and coin.', +``` + +Add hints to other actions similarly. + +- [ ] **Step 4: Run tests** + +Run: `pnpm test src/content/__tests__/story.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/content/story.ts src/content/definitions.ts src/content/__tests__/story.test.ts src/state/runtime.ts +git commit -m "feat(content): stub story graph with A/B branch and route-gated actions" +``` + +--- + +### Task 18: Architecture doc and verification gate + +**Files:** +- Modify: `docs/architecture.md` + +- [ ] **Step 1: Update architecture.md** + +Add bullets: + +```markdown +- `story.ts`: story graph traversal, triggers (boot, actionComplete, minResources), choices, outcomes. +- `storyOrchestration.ts`: evaluates triggers after boot/publish/action completion; maps prefs to panel auto-open. +- Player prefs (`prefs.ts`) in localStorage — not in save v1. +``` + +- [ ] **Step 2: Run full pre-PR chain** + +```powershell +pnpm typecheck +pnpm lint +pnpm test:coverage +pnpm build +``` + +Expected: all green; `src/engine/` coverage ≥ 80%. + +- [ ] **Step 3: Manual playtest checklist** + +1. `pnpm dev` — boot story auto-opens (default pref). +2. Continue from boot → fork → pick A vs B → different resources and unlocked actions. +3. Queue 3 actions → cancel middle → order respected. +4. Scout path completes → scout aftermath story fires. +5. Earn 3 coin → merchant flavor fires. +6. Settings → manual story mode → new beats badge only. +7. Reload → story flags, node, queue preserved. + +- [ ] **Step 4: Commit** + +```bash +git add docs/architecture.md +git commit -m "docs: document story engine and PR2 playable loop" +``` + +--- + +### Task 19: Open PR + +**Files:** none (git + Gitea) + +- [ ] **Step 1: Push branch** + +```bash +git checkout -b feat/m1-playable-loop +git push -u origin feat/m1-playable-loop +``` + +- [ ] **Step 2: Create PR** + +Title: `feat(m1): playable loop — story graph, story panel, queue UI` + +Body: + +```markdown +## Summary +- Story graph engine with hybrid triggers and A/B branch (#6) +- Full-window StoryPanel with VN layout and story log +- ActionPanel queue list, cancel, disabled states, hints/tooltips (#5) +- Player prefs: story-open mode and action-detail mode +- Stub story graph proving route-exclusive actions + +## Test plan +- [x] `pnpm typecheck && pnpm lint && pnpm test:coverage && pnpm build` +- [x] Route A vs B → different flags, resources, unlocked actions +- [x] Queue cancel works +- [x] Reload preserves story + queue +- [x] Engine coverage ≥ 80% + +Closes #5 +Closes #6 +``` + +- [ ] **Step 3: Verify CI green on PR branch** + +Expected: Gitea Actions `CI / verify` passes. + +--- + +## Self-review + +**Spec coverage:** + +| Requirement | Task | +|---|---| +| Hybrid triggers (boot, actionComplete, minResources) | Tasks 6, 12, 13 | +| Threshold on publish + actionComplete | Tasks 6, 13 | +| Story schema + validation | Task 3 | +| GameState story fields | Task 1 | +| applyChoice + gated choices | Task 7 | +| Save v1 story persistence | Task 8 | +| Action hints/tooltips | Tasks 4, 14, 17 | +| Full-window StoryPanel + log | Task 15 | +| Queue UI cancel/disabled | Task 14 | +| Player prefs + Settings | Tasks 9, 16 | +| Stub A/B branch + route actions | Task 17 | +| storyFlags wired to unlocks | Tasks 1, 10, 17 | +| Architecture docs | Task 18 | +| Closes #5, #6 | Task 19 | + +**Placeholder scan:** none. + +**Type consistency:** `GameContent` used in engine story module and view model; `TickResult.completedActionIds` consumed in runtime; store `GameView` extended consistently in Task 10–11. + +--- + +## Execution handoff + +Plan complete and saved to `docs/superpowers/plans/2026-06-11-m1-pr2-playable-loop.md`. Two execution options: + +**1. Subagent-Driven (recommended)** — dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints + +Which approach, senpai? diff --git a/docs/superpowers/plans/2026-06-11-m1-pr3-t30-shell-ui.md b/docs/superpowers/plans/2026-06-11-m1-pr3-t30-shell-ui.md new file mode 100644 index 0000000..d35deae --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-m1-pr3-t30-shell-ui.md @@ -0,0 +1,1229 @@ +# M1 PR3 T3.0 — Shell UI 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:** Replace the PR2 single-column overlay UX with a three-region app shell (nav / center / right rail), action columns by behavior kind with collapsible groups, story forks via Play actions only, and a Story tab with branching tree + prose log. + +**Architecture:** Extend content schema with `kind` + `group`; add `performAction` dispatcher in pure engine (`instant`, `timed`, `loop`, `story`, `context`); view model projects actions into columns/groups; store tracks `activePanel` instead of overlay open state; runtime routes story beats to nav badge/auto-switch. + +**Tech Stack:** TypeScript strict, Vitest, Zod 4, Biome, pnpm, React 19, Zustand, Tailwind 4. + +**Parent spec:** `docs/superpowers/specs/2026-06-11-m1-pr3-shell-ui-design.md` + +**Follow-on plan:** `docs/superpowers/plans/2026-06-11-m1-pr3-t31-automation.md` (T3.1 automation + recipes) + +**Branch:** `feat/m1-progression` off `main`. + +**Worktree:** Create/use isolated worktree before starting (`superpowers:using-git-worktrees`). + +--- + +## File map + +| File | Responsibility | +|---|---| +| `src/content/schema.ts` | `kind`, `group`, optional `durationMs`, `storyChoiceId`, `loopPriority`; relaxed yields validation | +| `src/content/__tests__/schema.test.ts` | Schema tests for kinds/groups/storyChoiceId | +| `src/content/definitions.ts` | Migrate stub actions; add `pick_high_road`, `follow_river` story actions | +| `src/content/storySchema.ts` | Export choice id index helper for buildContent cross-check | +| `src/engine/game.ts` | `performAction`, `executeInstant`, loop idle runner, `enabledLoopActionIds` state | +| `src/engine/__tests__/game.test.ts` | instant/loop/story dispatch tests | +| `src/engine/story.ts` | `isStoryChoiceActionAvailable` helper | +| `src/engine/save.ts` | Persist `enabledLoopActionIds` | +| `src/state/viewModel.ts` | Column/group projection, story tree nodes, remove choice buttons from StoryView | +| `src/state/__tests__/viewModel.test.ts` | Column projection + story tree tests | +| `src/state/store.ts` | `activePanel`, remove `storyPanelOpen`; keep `storyHasUnread` | +| `src/state/prefs.ts` | `collapsedActionGroups`, `showEventLog`; bump prefs key to v2 | +| `src/state/storyOrchestration.ts` | Remap auto-open → nav switch signal | +| `src/state/runtime.ts` | `performAction`, `setActivePanel`, story action wiring, loop post-tick | +| `src/ui/AppShell.tsx` | **Create** — grid layout | +| `src/ui/NavRail.tsx` | **Create** — Play/Story/Settings/About | +| `src/ui/PlayPanel.tsx` | **Create** — column grid wrapper | +| `src/ui/ActionColumn.tsx` | **Create** — one kind column | +| `src/ui/ActionGroup.tsx` | **Create** — collapsible group + cards | +| `src/ui/ActionCard.tsx` | **Create** — extract card from ActionPanel | +| `src/ui/StoryView.tsx` | **Create** — 60/40 split | +| `src/ui/StoryTree.tsx` | **Create** — indented tree from graph | +| `src/ui/StoryProseLog.tsx` | **Create** — scrollable prose | +| `src/ui/RightRail.tsx` | **Create** — resources, inventory placeholder, event log | +| `src/ui/SettingsPanel.tsx` | **Create** — move SettingsDrawer content | +| `src/ui/AboutPanel.tsx` | **Create** — static stub | +| `src/ui/App.tsx` | Wire AppShell; remove overlay | +| `src/ui/StoryPanel.tsx` | **Delete** | +| `src/ui/ActionPanel.tsx` | **Delete** after extraction | +| `src/ui/ResourceBar.tsx` | **Delete** — absorbed by RightRail | +| `src/ui/EventLog.tsx` | **Delete** — absorbed by RightRail | +| `docs/architecture.md` | Shell + action kinds section | + +--- + +### Task 1: Action kind schema + +**Files:** +- Modify: `src/content/schema.ts` +- Modify: `src/content/__tests__/schema.test.ts` + +- [ ] **Step 1: Write the failing test** + +Add to `src/content/__tests__/schema.test.ts`: + +```typescript +describe('action kind schema', () => { + it('accepts kind, group, and storyChoiceId', () => { + const content = buildContent({ + resources: [{ id: 'supplies', name: 'Supplies' }], + actions: [ + { + id: 'pick_high_road', + name: 'Take the high road', + kind: 'story', + group: { id: 'fork', label: 'Crossroads' }, + storyChoiceId: 'pick_a', + yields: [], + }, + ], + }); + expect(content.actionsById.pick_high_road.kind).toBe('story'); + expect(content.actionsById.pick_high_road.group.label).toBe('Crossroads'); + }); + + it('defaults kind to timed and requires durationMs for timed actions', () => { + expect(() => + buildContent({ + resources: [{ id: 'supplies', name: 'Supplies' }], + actions: [ + { + id: 'broken', + name: 'Broken', + kind: 'timed', + group: { id: 'camp', label: 'Camp' }, + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + ], + }), + ).toThrow(); + }); + + it('requires durationMs for loop actions', () => { + const content = buildContent({ + resources: [{ id: 'supplies', name: 'Supplies' }], + actions: [ + { + id: 'rest', + name: 'Rest', + kind: 'loop', + group: { id: 'camp_loop', label: 'Camp activities' }, + durationMs: 2000, + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + ], + }); + expect(content.actionsById.rest.kind).toBe('loop'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test src/content/__tests__/schema.test.ts -t "action kind"` + +Expected: FAIL — unknown keys / missing kind. + +- [ ] **Step 3: Implement schema** + +Replace `actionDefSchema` in `src/content/schema.ts`: + +```typescript +export const actionKindSchema = z.enum(['instant', 'loop', 'timed', 'story', 'context']); + +export const actionGroupSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), +}); + +export const actionDefSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + kind: actionKindSchema.default('timed'), + group: actionGroupSchema, + durationMs: z.number().positive().optional(), + loopPriority: z.number().int().nonnegative().optional(), + costs: z.array(resourceAmountSchema).default([]), + yields: z.array(resourceAmountSchema).default([]), + unlock: unlockDefSchema.optional(), + storyHint: z.string().min(1).optional(), + storyTooltip: z.string().min(1).optional(), + storyChoiceId: z.string().min(1).optional(), + contextId: z.string().min(1).optional(), + automation: z + .object({ + unlockAfterManualCompletions: z.number().int().positive().default(1), + }) + .optional(), + }) + .superRefine((action, ctx) => { + if ((action.kind === 'timed' || action.kind === 'loop') && action.durationMs === undefined) { + ctx.addIssue({ + code: 'custom', + message: `${action.kind} actions require durationMs`, + path: ['durationMs'], + }); + } + if (action.kind === 'story' && !action.storyChoiceId) { + ctx.addIssue({ + code: 'custom', + message: 'story actions require storyChoiceId', + path: ['storyChoiceId'], + }); + } + if (action.kind === 'timed' && action.yields.length === 0) { + ctx.addIssue({ + code: 'custom', + message: 'timed actions require at least one yield', + path: ['yields'], + }); + } + }); + +export type ActionKind = z.infer; +export type ActionGroup = z.infer; +``` + +Export `ActionKind`, `ActionGroup` from `src/content/index.ts`. + +- [ ] **Step 4: Run tests** + +Run: `pnpm test src/content/__tests__/schema.test.ts` + +Expected: PASS (update any existing tests that omit `group` / `kind`). + +- [ ] **Step 5: Commit** + +```bash +git add src/content/schema.ts src/content/__tests__/schema.test.ts src/content/index.ts +git commit -m "feat(content): add action kind and group schema" +``` + +--- + +### Task 2: Migrate stub action definitions + +**Files:** +- Modify: `src/content/definitions.ts` +- Modify: `src/content/__tests__/definitions.test.ts` + +- [ ] **Step 1: Update all action defs with kind + group** + +Replace `src/content/definitions.ts` action entries (keep resource defs): + +```typescript +export const actionDefs = [ + { + id: 'gather_supplies', + name: 'Gather supplies', + kind: 'timed', + group: { id: 'camp', label: 'Camp' }, + durationMs: 3000, + yields: [{ resourceId: 'supplies', amount: 2 }], + storyHint: 'Basic camp labor.', + storyTooltip: 'Yields 2 Supplies. No cost.', + }, + { + id: 'scout_path', + name: 'Scout the path', + kind: 'timed', + group: { id: 'travel', label: 'Travel' }, + durationMs: 5000, + costs: [{ resourceId: 'supplies', amount: 2 }], + yields: [{ resourceId: 'coin', amount: 1 }], + storyHint: 'Map the crossing.', + storyTooltip: 'Costs 2 Supplies. Yields 1 Coin. Triggers scout aftermath story.', + }, + { + id: 'trade_supplies', + name: 'Trade at camp', + kind: 'timed', + group: { id: 'camp', label: 'Camp' }, + durationMs: 4000, + costs: [{ resourceId: 'supplies', amount: 3 }], + yields: [{ resourceId: 'coin', amount: 2 }], + unlock: { minResources: { coin: 1 } }, + storyHint: 'Barter with travelers.', + storyTooltip: 'Costs 3 Supplies. Yields 2 Coin. Unlocks at 1 Coin.', + }, + { + id: 'fortify_camp', + name: 'Fortify camp', + kind: 'timed', + group: { id: 'camp', label: 'Camp' }, + durationMs: 8000, + costs: [ + { resourceId: 'supplies', amount: 5 }, + { resourceId: 'coin', amount: 2 }, + ], + yields: [{ resourceId: 'supplies', amount: 4 }], + unlock: { minResources: { supplies: 8 }, requireStoryFlags: ['route_a'] }, + storyHint: 'Walls for the high road camp.', + storyTooltip: 'Route A only. Costs supplies and coin.', + }, + { + id: 'push_onward', + name: 'Push onward', + kind: 'timed', + group: { id: 'travel', label: 'Travel' }, + durationMs: 6000, + costs: [{ resourceId: 'supplies', amount: 2 }], + yields: [{ resourceId: 'coin', amount: 3 }], + unlock: { requireStoryFlags: ['route_b'] }, + storyHint: 'Follow the river route.', + storyTooltip: 'Costs 2 Supplies. Yields 3 Coin. Route B only.', + }, + { + id: 'rest', + name: 'Rest briefly', + kind: 'loop', + group: { id: 'camp_loop', label: 'Camp activities' }, + loopPriority: 0, + durationMs: 2000, + yields: [{ resourceId: 'supplies', amount: 1 }], + storyHint: 'Catch your breath.', + storyTooltip: 'Idle upkeep — runs when nothing else is queued.', + }, + { + id: 'pick_high_road', + name: 'Take the high road', + kind: 'story', + group: { id: 'fork', label: 'Crossroads' }, + storyChoiceId: 'pick_a', + unlock: { requireStoryFlags: [] }, + storyHint: 'Route A — high ground and supplies.', + storyTooltip: 'Story fork: grants route A flag and resources. Hides river path.', + yields: [], + }, + { + id: 'follow_river', + name: 'Follow the river', + kind: 'story', + group: { id: 'fork', label: 'Crossroads' }, + storyChoiceId: 'pick_b', + storyHint: 'Route B — river trade and coin.', + storyTooltip: 'Story fork: grants route B flag. Hides high road path.', + yields: [], + }, +]; +``` + +Add story action unlock: use custom unlock in view model (Task 6) — for content, add optional `unlock.requireAtStoryNodeId: 'fork_choice'` to unlock schema in Task 6. + +- [ ] **Step 2: Fix failing content tests** + +Run: `pnpm test src/content/__tests__/definitions.test.ts` + +Update tests to use `performAction` or `enqueueAction` on timed ids only. + +- [ ] **Step 3: Commit** + +```bash +git add src/content/definitions.ts src/content/__tests__/definitions.test.ts +git commit -m "feat(content): migrate stub actions to kind/group model" +``` + +--- + +### Task 3: Instant action execution + +**Files:** +- Modify: `src/engine/game.ts` +- Modify: `src/engine/__tests__/game.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +describe('executeInstant()', () => { + it('applies costs and yields immediately without queueing', () => { + const content = buildContent({ + resources: [{ id: 'coin', name: 'Coin', startAmount: 5 }], + actions: [ + { + id: 'buy_supply', + name: 'Buy supply', + kind: 'instant', + group: { id: 'buy', label: 'Buy' }, + costs: [{ resourceId: 'coin', amount: 2 }], + yields: [{ resourceId: 'coin', amount: 0 }], + }, + ], + }); + // fix yields - instant needs at least one yield or allow empty; use supplies: + const state = createGameState(content); + // ... use proper test fixture from Task 1 pattern + }); +}); +``` + +Use a minimal inline fixture: + +```typescript +describe('executeInstant()', () => { + const instantContent = buildContent({ + resources: [ + { id: 'supplies', name: 'Supplies', startAmount: 0 }, + { id: 'coin', name: 'Coin', startAmount: 5 }, + ], + actions: [ + { + id: 'buy_supply', + name: 'Buy supply', + kind: 'instant', + group: { id: 'buy', label: 'Buy' }, + costs: [{ resourceId: 'coin', amount: 2 }], + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + ], + }); + + it('applies costs and yields immediately without queueing', () => { + const state = createGameState(instantContent); + executeInstant(state, instantContent, 'buy_supply'); + expect(state.resources.coin).toBe(3); + expect(state.resources.supplies).toBe(1); + expect(state.activeActionId).toBeNull(); + expect(state.actionQueue).toEqual([]); + }); + + it('throws when unaffordable', () => { + const state = createGameState(instantContent); + state.resources.coin = 0; + expect(() => executeInstant(state, instantContent, 'buy_supply')).toThrow(/Cannot/); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test src/engine/__tests__/game.test.ts -t "executeInstant"` + +Expected: FAIL — `executeInstant` not defined. + +- [ ] **Step 3: Implement executeInstant** + +Add to `src/engine/game.ts`: + +```typescript +export function executeInstant(state: GameState, content: Content, actionId: string): void { + const action = content.actionsById[actionId]; + if (!action || action.kind !== 'instant') { + throw new Error(`Action "${actionId}" is not instant`); + } + if (!isActionAvailable(state, content, actionId)) { + throw new Error(`Cannot perform instant action "${actionId}"`); + } + deductCosts(state, content, actionId); + grantYields(state, content, actionId); +} +``` + +- [ ] **Step 4: Run tests** + +Run: `pnpm test src/engine/__tests__/game.test.ts -t "executeInstant"` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/game.ts src/engine/__tests__/game.test.ts +git commit -m "feat(engine): add instant action execution" +``` + +--- + +### Task 4: Story action execution + +**Files:** +- Modify: `src/engine/story.ts` +- Modify: `src/engine/game.ts` +- Create: `src/engine/__tests__/storyActions.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/engine/__tests__/storyActions.test.ts`: + +```typescript +import { describe, expect, it } from 'vitest'; +import { buildContent } from '../../content/schema'; +import { buildStoryContent } from '../../content/storySchema'; +import { storyNodeDefs } from '../../content/story'; +import { createGameState } from '../game'; +import { enterStoryNode } from '../story'; +import { executeStoryAction } from '../game'; + +const story = buildStoryContent(storyNodeDefs, []); +const base = buildContent({ + resources: [ + { id: 'supplies', name: 'Supplies', startAmount: 10 }, + { id: 'coin', name: 'Coin', startAmount: 0 }, + ], + actions: [ + { + id: 'pick_high_road', + name: 'Take the high road', + kind: 'story', + group: { id: 'fork', label: 'Fork' }, + storyChoiceId: 'pick_a', + yields: [], + }, + { + id: 'follow_river', + name: 'Follow the river', + kind: 'story', + group: { id: 'fork', label: 'Fork' }, + storyChoiceId: 'pick_b', + yields: [], + }, + ], +}); +const content = { ...base, ...story }; + +describe('executeStoryAction()', () => { + it('applies the linked story choice', () => { + const state = createGameState(content); + enterStoryNode(state, content, 'fork_choice'); + executeStoryAction(state, content, 'pick_high_road'); + expect(state.storyFlags.route_a).toBe(true); + expect(state.currentStoryNodeId).toBe('route_a_beat'); + }); + + it('throws when choice is not available', () => { + const state = createGameState(content); + enterStoryNode(state, content, 'fork_choice'); + executeStoryAction(state, content, 'pick_high_road'); + expect(() => executeStoryAction(state, content, 'follow_river')).toThrow(); + }); +}); +``` + +Add helper in `src/engine/story.ts`: + +```typescript +export function isStoryChoiceAvailable( + state: GameState, + content: GameContent, + storyChoiceId: string, +): boolean { + const node = getCurrentNode(state, content); + if (!node?.choices) return false; + const choice = node.choices.find((c) => c.id === storyChoiceId); + if (!choice) return false; + return meetsChoiceRequirements(state, choice.requirements); +} +``` + +Export `meetsChoiceRequirements` or wrap it — if private, duplicate check via `getAvailableChoices`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test src/engine/__tests__/storyActions.test.ts` + +Expected: FAIL + +- [ ] **Step 3: Implement executeStoryAction** + +In `src/engine/game.ts`: + +```typescript +import { applyChoice, isStoryChoiceAvailable } from './story'; +import type { GameContent } from '../content/index'; + +export function executeStoryAction(state: GameState, content: GameContent, actionId: string): void { + const action = content.actionsById[actionId]; + if (!action || action.kind !== 'story' || !action.storyChoiceId) { + throw new Error(`Action "${actionId}" is not a story action`); + } + if (!isStoryChoiceAvailable(state, content, action.storyChoiceId)) { + throw new Error(`Story choice "${action.storyChoiceId}" is not available`); + } + applyChoice(state, content, action.storyChoiceId); +} +``` + +Note: `game.ts` currently imports `Content` only — switch story execution imports to use a minimal interface or import `GameContent` from content index. **Engine purity test** allows importing from `content/schema` and `content/storySchema` but not `content/index` if it pulls React — check `purity.test.ts`; if blocked, pass choice availability check via injected callback. Current codebase imports `Content` from schema in game.ts — use `GameContent` type from a types-only re-export or duplicate the intersection in story.ts. + +Preferred: add `import type { GameContent } from '../content/index'` — verify purity test still passes (index is data-only). + +- [ ] **Step 4: Run tests** + +Run: `pnpm test src/engine/__tests__/storyActions.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/game.ts src/engine/story.ts src/engine/__tests__/storyActions.test.ts +git commit -m "feat(engine): execute story actions via storyChoiceId" +``` + +--- + +### Task 5: Loop idle runner + enabledLoopActionIds + +**Files:** +- Modify: `src/engine/game.ts` +- Modify: `src/engine/save.ts` +- Modify: `src/engine/__tests__/game.test.ts` + +- [ ] **Step 1: Write the failing test** + +Extend `GameState` test + loop runner: + +```typescript +describe('loop idle runner', () => { + const loopContent = buildContent({ + resources: [{ id: 'supplies', name: 'Supplies', startAmount: 0 }], + actions: [ + { + id: 'rest', + name: 'Rest', + kind: 'loop', + group: { id: 'camp_loop', label: 'Camp' }, + durationMs: 1000, + loopPriority: 0, + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + ], + }); + + it('starts enabled loop action when idle', () => { + const state = createGameState(loopContent); + state.enabledLoopActionIds = { rest: true }; + maybeStartLoopAction(state, loopContent); + expect(state.activeActionId).toBe('rest'); + }); + + it('does not start loop when queue has items', () => { + const state = createGameState(loopContent); + state.enabledLoopActionIds = { rest: true }; + state.actionQueue.push('rest'); + maybeStartLoopAction(state, loopContent); + expect(state.activeActionId).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test src/engine/__tests__/game.test.ts -t "loop idle"` + +Expected: FAIL + +- [ ] **Step 3: Extend GameState and implement maybeStartLoopAction** + +```typescript +export interface GameState { + // ...existing fields + enabledLoopActionIds: Record; +} + +// in createGameState: +enabledLoopActionIds: {}, + +export function maybeStartLoopAction(state: GameState, content: Content): void { + if (state.activeActionId !== null || state.actionQueue.length > 0) return; + + const candidates = content.actions + .filter((a) => a.kind === 'loop' && state.enabledLoopActionIds[a.id]) + .sort((a, b) => (a.loopPriority ?? 0) - (b.loopPriority ?? 0)); + + for (const action of candidates) { + if (isActionAvailable(state, content, action.id)) { + beginAction(state, content, action.id); + return; + } + } +} +``` + +After `startNextFromQueue` leaves idle, call `maybeStartLoopAction`. When loop action completes in `tickGame`, if still idle, call `maybeStartLoopAction` again for repeat. + +Update `save.ts` `gameStateSchema`: + +```typescript +enabledLoopActionIds: z.record(z.string(), z.boolean()).default({}), +``` + +- [ ] **Step 4: Run tests + save tests** + +Run: `pnpm test src/engine` + +Expected: PASS (update save round-trip test) + +- [ ] **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): loop idle runner with enabledLoopActionIds" +``` + +--- + +### Task 6: performAction dispatcher + +**Files:** +- Modify: `src/engine/game.ts` +- Modify: `src/engine/__tests__/game.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +describe('performAction()', () => { + it('dispatches timed actions through enqueueAction', () => { + const content = testContent(); // existing timed fixture — add group/kind + const state = createGameState(content); + performAction(state, content, 'forage'); + expect(state.activeActionId).toBe('forage'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test src/engine/__tests__/game.test.ts -t "performAction"` + +- [ ] **Step 3: Implement performAction** + +```typescript +export function performAction(state: GameState, content: GameContent, actionId: string): void { + const action = content.actionsById[actionId]; + if (!action) throw new Error(`Unknown action "${actionId}"`); + + switch (action.kind) { + case 'instant': + executeInstant(state, content, actionId); + break; + case 'timed': + enqueueAction(state, content, actionId); + break; + case 'loop': { + if (!isActionAvailable(state, content, actionId)) { + throw new Error(`Cannot enable loop action "${actionId}"`); + } + state.enabledLoopActionIds[actionId] = !state.enabledLoopActionIds[actionId]; + if (state.enabledLoopActionIds[actionId]) { + maybeStartLoopAction(state, content); + } + break; + } + case 'story': + executeStoryAction(state, content, actionId); + break; + case 'context': + throw new Error(`Context action "${actionId}" is not implemented`); + default: + throw new Error(`Unknown action kind`); + } +} +``` + +- [ ] **Step 4: Run full engine tests** + +Run: `pnpm test src/engine` + +- [ ] **Step 5: Commit** + +```bash +git add src/engine/game.ts src/engine/__tests__/game.test.ts +git commit -m "feat(engine): add performAction dispatcher by kind" +``` + +--- + +### Task 7: View model column projection + +**Files:** +- Modify: `src/state/viewModel.ts` +- Modify: `src/state/__tests__/viewModel.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +describe('action columns projection', () => { + it('groups actions by kind column and group', () => { + const view = toView(stateWithMixedActions, content); + const kinds = view.actionColumns.map((c) => c.kind); + expect(kinds).toEqual(['instant', 'loop', 'timed', 'story', 'context']); + const timed = view.actionColumns.find((c) => c.kind === 'timed'); + expect(timed?.groups.some((g) => g.id === 'camp')).toBe(true); + }); + + it('hides unavailable story actions after fork taken', () => { + // after pick_a, follow_river action.available === false + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test src/state/__tests__/viewModel.test.ts -t "action columns"` + +- [ ] **Step 3: Implement projection** + +Add types: + +```typescript +export const ACTION_COLUMN_ORDER = ['instant', 'loop', 'timed', 'story', 'context'] as const; + +export interface ActionGroupView { + id: string; + label: string; + actions: ActionView[]; +} + +export interface ActionColumnView { + kind: (typeof ACTION_COLUMN_ORDER)[number]; + label: string; + groups: ActionGroupView[]; +} + +export interface StoryTreeNodeView { + id: string; + label: string; + seen: boolean; + active: boolean; + children: StoryTreeNodeView[]; +} + +export interface StoryView { + currentProse: string | null; + selectedNodeId: string | null; + tree: StoryTreeNodeView[]; + log: StoryLogEntryView[]; +} +``` + +Add `kind` to `ActionView`. Build columns by filtering `content.actions` into kind buckets, then group by `action.group.id`. + +Story action availability: use `isStoryChoiceAvailable` when `kind === 'story'`. + +Remove `choices` from `StoryView`. + +Implement `buildStoryTree(content, state)` — minimal: start at `boot_intro`, recurse choices where `seenStoryNodeIds` includes target or flag set on taken branch. + +- [ ] **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): project actions into columns and story tree" +``` + +--- + +### Task 8: Store nav panel + prefs + +**Files:** +- Modify: `src/state/store.ts` +- Modify: `src/state/prefs.ts` +- Modify: `src/state/__tests__/prefs.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +describe('expanded prefs', () => { + it('defaults collapsedActionGroups and showEventLog', () => { + const prefs = getPrefs(); + expect(prefs.collapsedActionGroups).toEqual({}); + expect(prefs.showEventLog).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm test src/state/__tests__/prefs.test.ts -t "expanded prefs"` + +- [ ] **Step 3: Update prefs and store** + +`src/state/prefs.ts`: + +```typescript +const PREFS_KEY = 'idlegame:prefs:v2'; + +export type ActivePanel = 'play' | 'story' | 'settings' | 'about'; + +export interface GamePrefs { + storyOpenMode: StoryOpenMode; + actionDetailMode: ActionDetailMode; + collapsedActionGroups: Record; + showEventLog: boolean; +} + +const DEFAULTS: GamePrefs = { + storyOpenMode: 'auto', + actionDetailMode: 'inline', + collapsedActionGroups: {}, + showEventLog: true, +}; +``` + +`src/state/store.ts` — replace overlay state: + +```typescript +activePanel: ActivePanel; +selectedStoryNodeId: string | null; +// remove: storyPanelOpen, settingsOpen +setActivePanel: (panel: ActivePanel) => void; +setSelectedStoryNodeId: (id: string | null) => void; +toggleActionGroupCollapsed: (groupKey: string) => void; +``` + +`groupKey` format: `${kind}:${groupId}`. + +- [ ] **Step 4: Run tests** + +Run: `pnpm test src/state/__tests__/prefs.test.ts` + +- [ ] **Step 5: Commit** + +```bash +git add src/state/store.ts src/state/prefs.ts src/state/__tests__/prefs.test.ts +git commit -m "feat(state): nav panel store and expanded prefs" +``` + +--- + +### Task 9: Runtime — performAction + nav story signals + +**Files:** +- Modify: `src/state/runtime.ts` +- Modify: `src/state/storyOrchestration.ts` + +- [ ] **Step 1: Replace enqueueAction/applyStoryChoice with performAction** + +In `runtime.ts`: + +```typescript +performAction(actionId: string): void { + const state = this.state; + if (!state) return; + try { + const action = content.actionsById[actionId]; + if (!action) return; + const wasStory = action.kind === 'story'; + performActionEngine(state, content, actionId); + if (wasStory) { + const events = /* last story events from applyChoice — refactor applyChoice to return events already */; + // reuse storyEventsToLogEntries + appendStoryLog + this.runPublishTriggers(); + } + // log line + this.publish(); + } catch (err) { + useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Action failed'); + } +} + +setActivePanel(panel: ActivePanel): void { + const store = useGameStore.getState(); + store.setActivePanel(panel); + if (panel === 'story') store.setStoryHasUnread(false); +} +``` + +Remove: `openStoryPanel`, `closeStoryPanel`, `applyStoryChoice`, `continueStory` choice path. + +Update `applyStoryUiEffect`: + +```typescript +if (effect.shouldOpenPanel) { + if (prefs.storyOpenMode === 'auto') { + store.setActivePanel('story'); + store.setStoryHasUnread(false); + } else { + store.setStoryHasUnread(true); + } +} +``` + +Remap `shouldAutoOpenPanel` in `storyOrchestration.ts` — rename to `shouldAutoNavigateToStory` (same logic, semantic change). + +After `tickGame` completions, call `maybeStartLoopAction(state, content)`. + +Wire `boot_intro` auto-advance: on `continueStory` equivalent, use `enterStoryNode` to `fork_choice` when user opens Story tab on first beat — or auto-enter on boot via existing trigger (no Continue button needed if prose shows in log). + +- [ ] **Step 2: Run state tests** + +Run: `pnpm test src/state` + +- [ ] **Step 3: Commit** + +```bash +git add src/state/runtime.ts src/state/storyOrchestration.ts +git commit -m "feat(state): runtime performAction and nav story signals" +``` + +--- + +### Task 10: AppShell + NavRail + +**Files:** +- Create: `src/ui/AppShell.tsx` +- Create: `src/ui/NavRail.tsx` +- Modify: `src/ui/App.tsx` + +- [ ] **Step 1: Create AppShell layout** + +`src/ui/AppShell.tsx`: + +```tsx +import type { ReactNode } from 'react'; + +interface AppShellProps { + nav: ReactNode; + center: ReactNode; + right?: ReactNode; +} + +export function AppShell({ nav, center, right }: AppShellProps) { + return ( +
+
{nav}
+
{center}
+ {right ? ( + + ) : null} +
+ ); +} +``` + +`src/ui/NavRail.tsx` — buttons call `gameRuntime.setActivePanel`, Story shows badge when `storyHasUnread`. + +- [ ] **Step 2: Wire App.tsx** + +```tsx +export function App() { + const activePanel = useGameStore((s) => s.activePanel); + useEffect(() => { void gameRuntime.boot(); }, []); + + return ( + } + center={ + activePanel === 'play' ? : + activePanel === 'story' ? : + activePanel === 'settings' ? : + + } + right={activePanel === 'play' ? : undefined} + /> + ); +} +``` + +- [ ] **Step 3: Manual smoke** + +Run: `pnpm dev` — verify nav switches panels, no overlay. + +- [ ] **Step 4: Commit** + +```bash +git add src/ui/AppShell.tsx src/ui/NavRail.tsx src/ui/App.tsx +git commit -m "feat(ui): app shell with nav rail" +``` + +--- + +### Task 11: PlayPanel action columns + +**Files:** +- Create: `src/ui/PlayPanel.tsx` +- Create: `src/ui/ActionColumn.tsx` +- Create: `src/ui/ActionGroup.tsx` +- Create: `src/ui/ActionCard.tsx` + +- [ ] **Step 1: Build column grid** + +`PlayPanel.tsx`: + +```tsx +const columns = useGameStore((s) => s.actionColumns); +const COLUMN_LABELS: Record = { + instant: 'Instant', + loop: 'Loop', + timed: 'Timed', + story: 'Story', + context: 'Context', +}; + +return ( +
+ {columns.map((col) => ( + + ))} +
+); +``` + +`ActionGroup.tsx` — collapse toggle calls `gameRuntime.toggleActionGroupCollapsed(`${kind}:${groupId}`)`. + +`ActionCard.tsx` — migrate from `ActionPanel.tsx`; story kind gets `border-amber-500/50` + fork badge; loop kind shows enabled toggle state from `enabledLoopActionIds` via view model field `loopEnabled?: boolean`. + +- [ ] **Step 2: Delete old ActionPanel** + +Remove `src/ui/ActionPanel.tsx` after migration. + +- [ ] **Step 3: Commit** + +```bash +git add src/ui/PlayPanel.tsx src/ui/ActionColumn.tsx src/ui/ActionGroup.tsx src/ui/ActionCard.tsx +git rm src/ui/ActionPanel.tsx +git commit -m "feat(ui): action columns with collapsible groups" +``` + +--- + +### Task 12: StoryView split pane + +**Files:** +- Create: `src/ui/StoryView.tsx` +- Create: `src/ui/StoryTree.tsx` +- Create: `src/ui/StoryProseLog.tsx` +- Delete: `src/ui/StoryPanel.tsx` + +- [ ] **Step 1: Build 60/40 split** + +`StoryView.tsx`: + +```tsx +export function StoryView() { + const tree = useGameStore((s) => s.story.tree); + const log = useGameStore((s) => s.story.log); + const selectedId = useGameStore((s) => s.selectedStoryNodeId); + + return ( +
+ gameRuntime.selectStoryNode(id)} /> + +
+ ); +} +``` + +`StoryTree.tsx` — recursive `
    ` with indentation; dim `seen: false` nodes. + +`StoryProseLog.tsx` — render full prose; **no buttons**. + +- [ ] **Step 2: Remove StoryPanel and runtime overlay methods** + +```bash +git rm src/ui/StoryPanel.tsx +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/ui/StoryView.tsx src/ui/StoryTree.tsx src/ui/StoryProseLog.tsx src/state/runtime.ts +git commit -m "feat(ui): story tab with tree and prose log" +``` + +--- + +### Task 13: RightRail + +**Files:** +- Create: `src/ui/RightRail.tsx` +- Create: `src/ui/SettingsPanel.tsx` +- Create: `src/ui/AboutPanel.tsx` +- Delete: `src/ui/ResourceBar.tsx`, `src/ui/EventLog.tsx`, `src/ui/SettingsDrawer.tsx` + +- [ ] **Step 1: Implement RightRail** + +Sections: Resources (from `view.resources`), Inventory placeholder (duplicate resource list with "Items coming soon"), Event log (collapsible, hidden when `!prefs.showEventLog`). + +- [ ] **Step 2: SettingsPanel** + +Move `SettingsDrawer` selects + add `showEventLog` checkbox. + +- [ ] **Step 3: AboutPanel** + +Static stub: "Idlegame — M1 vertical slice." + +- [ ] **Step 4: Commit** + +```bash +git add src/ui/RightRail.tsx src/ui/SettingsPanel.tsx src/ui/AboutPanel.tsx +git rm src/ui/ResourceBar.tsx src/ui/EventLog.tsx src/ui/SettingsDrawer.tsx +git commit -m "feat(ui): right rail and settings/about panels" +``` + +--- + +### Task 14: Integration verify + docs + +**Files:** +- Modify: `docs/architecture.md` + +- [ ] **Step 1: Run pre-PR chain** + +```powershell +pnpm typecheck +pnpm lint +pnpm test:coverage +pnpm build +``` + +Expected: all green; engine ≥80% coverage. + +- [ ] **Step 2: Browser smoke checklist** + +1. Boot → nav Play visible; no overlay. +2. Continue boot story via Story tab prose (fork appears in tree). +3. Fork only in Story **column** as actions — pick one; sibling hides. +4. Timed actions queue in center column; loop rest toggles and runs when idle. +5. Story tab: tree click updates prose; 60/40 split readable. +6. Right rail: resources + log; log collapsible. +7. Reload preserves state. + +- [ ] **Step 3: Update architecture.md** + +Add §Shell layout and §Action kinds referencing spec. + +- [ ] **Step 4: Commit** + +```bash +git add docs/architecture.md +git commit -m "docs: document PR3 shell UI and action kinds" +``` + +--- + +## Spec coverage self-review + +| Spec requirement | Task | +|---|---| +| Three-region shell | Task 10 | +| Column order Instant→Loop→Timed→Story→Context | Task 7, 11 | +| Collapsible groups | Task 8, 11 | +| Story forks via actions only | Task 4, 6, 9, 11 | +| Story tab 60/40 tree+log | Task 12 | +| No overlay | Task 9, 10, 12 | +| Right rail resources/inventory/log | Task 13 | +| Optional event log pref | Task 8, 13 | +| Instant/loop/timed/story/context kinds | Tasks 1, 3–6 | +| Loop idle behavior | Task 5 | +| Context column stub | Task 6 throws | +| Remove PR2 overlay | Task 12 | +| Automation/recipes | **T3.1 plan** | +| Mobile responsive | PR4 — out of scope | +| manualCompletionCounts | **T3.1 plan** | + +## Placeholder scan + +No TBD/TODO steps. All tasks include file paths and code snippets. diff --git a/docs/superpowers/plans/2026-06-11-m1-pr3-t31-automation.md b/docs/superpowers/plans/2026-06-11-m1-pr3-t31-automation.md new file mode 100644 index 0000000..d88c3bf --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-m1-pr3-t31-automation.md @@ -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; +``` + +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 ( +
    +

    Automation

    + {names.length > 0 ? ( +
      + {names.map((name, i) => ( +
    1. {i + 1}. {name}
    2. + ))} +
    + ) : ( +

    No automated processes.

    + )} +
    + +
    +