Merge pull request 'docs(m1): PR3 shell UI spec and implementation plans' (#15) from feat/docs-pr3-shell-spec into main
CI / verify (push) Successful in 1m0s
CI / verify (push) Successful in 1m0s
This commit was merged in pull request #15.
This commit is contained in:
@@ -16,6 +16,9 @@ coverage/
|
|||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.vite/
|
.vite/
|
||||||
|
|
||||||
|
# Git worktrees (local isolation)
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
@@ -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<string, boolean>`, `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`.*
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,694 @@
|
|||||||
|
# M1 PR3 T3.1 — Universal Automation & Recipes Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add universal automation — any action kind becomes automatable after first manual completion — with a separate automation queue, runner precedence (manual > automation > loop), and shareable text recipe export/import referencing content action ids.
|
||||||
|
|
||||||
|
**Architecture:** Pure engine modules `automation.ts` and `recipe.ts` own queue execution and text serialization; `manualCompletionCounts` gates unlock; runtime exposes automation UI commands; save v1 schema extended with defaults.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript strict, Vitest, Zod 4, lz-string (optional compressed recipes), Biome, pnpm, React 19, Zustand.
|
||||||
|
|
||||||
|
**Parent spec:** `docs/superpowers/specs/2026-06-11-m1-pr3-shell-ui-design.md` §Automation
|
||||||
|
|
||||||
|
**Prerequisite plan:** `docs/superpowers/plans/2026-06-11-m1-pr3-t30-shell-ui.md` (T3.0 shell must merge first)
|
||||||
|
|
||||||
|
**Branch:** `feat/m1-progression` (continues after T3.0)
|
||||||
|
|
||||||
|
**Closes:** Gitea #7 (Automation unlock)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `src/engine/game.ts` | `manualCompletionCounts`; hook completion recording in tick/performAction |
|
||||||
|
| `src/engine/automation.ts` | **Create** — automation queue CRUD, runner, unlock checks |
|
||||||
|
| `src/engine/recipe.ts` | **Create** — multi-line + single-line parse/serialize/validate |
|
||||||
|
| `src/engine/__tests__/automation.test.ts` | **Create** — queue, unlock, precedence tests |
|
||||||
|
| `src/engine/__tests__/recipe.test.ts` | **Create** — round-trip, reject unknown/locked ids |
|
||||||
|
| `src/engine/save.ts` | Persist `manualCompletionCounts`, `automationQueue` |
|
||||||
|
| `src/state/viewModel.ts` | `automationUnlocked`, `automationQueueNames` on ActionView |
|
||||||
|
| `src/state/runtime.ts` | `addToAutomation`, `removeFromAutomation`, `importRecipe`, `exportRecipe` |
|
||||||
|
| `src/ui/AutomationBar.tsx` | **Create** — queue list, export/import textarea |
|
||||||
|
| `src/ui/PlayPanel.tsx` | Mount AutomationBar below columns |
|
||||||
|
| `src/ui/ActionCard.tsx` | Auto toggle when unlocked |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: manualCompletionCounts + record on completion
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/engine/game.ts`
|
||||||
|
- Modify: `src/engine/save.ts`
|
||||||
|
- Modify: `src/engine/__tests__/game.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('manualCompletionCounts', () => {
|
||||||
|
it('increments when a timed action completes', () => {
|
||||||
|
const content = testContentWithGroup(); // timed action with group/kind
|
||||||
|
const state = createGameState(content);
|
||||||
|
enqueueAction(state, content, 'gather_supplies');
|
||||||
|
tickGame(state, content, 3000);
|
||||||
|
expect(state.manualCompletionCounts.gather_supplies).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pnpm test src/engine/__tests__/game.test.ts -t "manualCompletionCounts"`
|
||||||
|
|
||||||
|
Expected: FAIL — property undefined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement recording**
|
||||||
|
|
||||||
|
Add to `GameState`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
manualCompletionCounts: Record<string, number>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Default `{}` in `createGameState`.
|
||||||
|
|
||||||
|
Add helper:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function recordManualCompletion(state: GameState, content: Content, actionId: string): void {
|
||||||
|
if (!content.actionsById[actionId]) return;
|
||||||
|
state.manualCompletionCounts[actionId] = (state.manualCompletionCounts[actionId] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Call from:
|
||||||
|
- `tickGame` when pushing to `completedActionIds`
|
||||||
|
- `executeInstant` after yields
|
||||||
|
- `executeStoryAction` after `applyChoice`
|
||||||
|
|
||||||
|
Update `save.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
manualCompletionCounts: z.record(z.string(), z.number()).default({}),
|
||||||
|
automationQueue: z.array(z.string()).default([]),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `pnpm test src/engine/__tests__/game.test.ts src/engine/__tests__/save.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/engine/game.ts src/engine/save.ts src/engine/__tests__/game.test.ts src/engine/__tests__/save.test.ts
|
||||||
|
git commit -m "feat(engine): track manualCompletionCounts on action complete"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Automation unlock check
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/engine/automation.ts`
|
||||||
|
- Create: `src/engine/__tests__/automation.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildContent } from '../../content/schema';
|
||||||
|
import { createGameState } from '../game';
|
||||||
|
import { isAutomationUnlocked, automationUnlockThreshold } from '../automation';
|
||||||
|
|
||||||
|
describe('isAutomationUnlocked()', () => {
|
||||||
|
const content = buildContent({
|
||||||
|
resources: [{ id: 'supplies', name: 'Supplies' }],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 'gather_supplies',
|
||||||
|
name: 'Gather',
|
||||||
|
kind: 'timed',
|
||||||
|
group: { id: 'camp', label: 'Camp' },
|
||||||
|
durationMs: 1000,
|
||||||
|
yields: [{ resourceId: 'supplies', amount: 1 }],
|
||||||
|
automation: { unlockAfterManualCompletions: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false before first manual completion', () => {
|
||||||
|
const state = createGameState(content);
|
||||||
|
expect(isAutomationUnlocked(state, content, 'gather_supplies')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true after threshold met', () => {
|
||||||
|
const state = createGameState(content);
|
||||||
|
state.manualCompletionCounts.gather_supplies = 1;
|
||||||
|
expect(isAutomationUnlocked(state, content, 'gather_supplies')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pnpm test src/engine/__tests__/automation.test.ts`
|
||||||
|
|
||||||
|
Expected: FAIL — module not found.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement unlock helpers**
|
||||||
|
|
||||||
|
Create `src/engine/automation.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Content } from '../content/schema';
|
||||||
|
import type { GameState } from './game';
|
||||||
|
|
||||||
|
export function automationUnlockThreshold(content: Content, actionId: string): number {
|
||||||
|
return content.actionsById[actionId]?.automation?.unlockAfterManualCompletions ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAutomationUnlocked(state: GameState, content: Content, actionId: string): boolean {
|
||||||
|
const threshold = automationUnlockThreshold(content, actionId);
|
||||||
|
return (state.manualCompletionCounts[actionId] ?? 0) >= threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addToAutomationQueue(state: GameState, content: Content, actionId: string): void {
|
||||||
|
if (!content.actionsById[actionId]) {
|
||||||
|
throw new Error(`Unknown action "${actionId}"`);
|
||||||
|
}
|
||||||
|
if (!isAutomationUnlocked(state, content, actionId)) {
|
||||||
|
throw new Error(`Action "${actionId}" is not automation-unlocked`);
|
||||||
|
}
|
||||||
|
if (!state.automationQueue.includes(actionId)) {
|
||||||
|
state.automationQueue.push(actionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeFromAutomationQueue(state: GameState, index: number): void {
|
||||||
|
if (index < 0 || index >= state.automationQueue.length) {
|
||||||
|
throw new RangeError(`Automation queue index ${index} is out of range`);
|
||||||
|
}
|
||||||
|
state.automationQueue.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAutomationQueue(state: GameState): void {
|
||||||
|
state.automationQueue.length = 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `automationQueue: string[]` to `GameState` (default `[]`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `pnpm test src/engine/__tests__/automation.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/engine/automation.ts src/engine/game.ts src/engine/__tests__/automation.test.ts
|
||||||
|
git commit -m "feat(engine): automation unlock and queue CRUD"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Automation runner + precedence
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/engine/automation.ts`
|
||||||
|
- Modify: `src/engine/game.ts`
|
||||||
|
- Modify: `src/engine/__tests__/automation.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('maybeRunAutomation()', () => {
|
||||||
|
it('starts first affordable automation action when manual queue idle', () => {
|
||||||
|
const state = createGameState(content);
|
||||||
|
state.manualCompletionCounts.gather_supplies = 1;
|
||||||
|
state.automationQueue = ['gather_supplies'];
|
||||||
|
maybeRunAutomation(state, content);
|
||||||
|
expect(state.activeActionId).toBe('gather_supplies');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not run when manual queue has items', () => {
|
||||||
|
const state = createGameState(content);
|
||||||
|
state.actionQueue = ['gather_supplies'];
|
||||||
|
state.automationQueue = ['gather_supplies'];
|
||||||
|
state.manualCompletionCounts.gather_supplies = 1;
|
||||||
|
maybeRunAutomation(state, content);
|
||||||
|
expect(state.activeActionId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pnpm test src/engine/__tests__/automation.test.ts -t "maybeRunAutomation"`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement maybeRunAutomation**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { isActionAvailable, type GameState } from './game';
|
||||||
|
|
||||||
|
export function maybeRunAutomation(state: GameState, content: Content): void {
|
||||||
|
if (state.activeActionId !== null || state.actionQueue.length > 0) return;
|
||||||
|
|
||||||
|
for (const actionId of state.automationQueue) {
|
||||||
|
if (!isAutomationUnlocked(state, content, actionId)) continue;
|
||||||
|
if (!isActionAvailable(state, content, actionId)) continue;
|
||||||
|
|
||||||
|
const action = content.actionsById[actionId];
|
||||||
|
if (!action) continue;
|
||||||
|
|
||||||
|
switch (action.kind) {
|
||||||
|
case 'instant':
|
||||||
|
// execute inline without queue
|
||||||
|
break;
|
||||||
|
case 'timed':
|
||||||
|
case 'loop':
|
||||||
|
beginAction(state, content, actionId); // export beginAction or duplicate
|
||||||
|
return;
|
||||||
|
case 'story':
|
||||||
|
executeStoryAction(state, content, actionId);
|
||||||
|
return;
|
||||||
|
case 'context':
|
||||||
|
continue;
|
||||||
|
default:
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Precedence wiring in `game.ts` `startNextFromQueue`:**
|
||||||
|
|
||||||
|
After manual queue exhausts and sets idle:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
maybeRunAutomation(state, content);
|
||||||
|
maybeStartLoopAction(state, content);
|
||||||
|
```
|
||||||
|
|
||||||
|
Order: **manual timed completes → automation → loop**.
|
||||||
|
|
||||||
|
Refactor `beginAction` to export if needed (or add `startActionById` internal export).
|
||||||
|
|
||||||
|
For automation instant actions: execute inline in runner, re-advance automation index.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run automation + game tests**
|
||||||
|
|
||||||
|
Run: `pnpm test src/engine`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/engine/automation.ts src/engine/game.ts src/engine/__tests__/automation.test.ts
|
||||||
|
git commit -m "feat(engine): automation runner with manual-first precedence"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Recipe export/import
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/engine/recipe.ts`
|
||||||
|
- Create: `src/engine/__tests__/recipe.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildContent } from '../../content/schema';
|
||||||
|
import { createGameState } from '../game';
|
||||||
|
import { exportRecipe, importRecipe, RECIPE_HEADER_V1 } from '../recipe';
|
||||||
|
|
||||||
|
describe('recipe export/import', () => {
|
||||||
|
const content = buildContent({
|
||||||
|
resources: [{ id: 'supplies', name: 'Supplies' }],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 'gather_supplies',
|
||||||
|
name: 'Gather',
|
||||||
|
kind: 'timed',
|
||||||
|
group: { id: 'camp', label: 'Camp' },
|
||||||
|
durationMs: 1000,
|
||||||
|
yields: [{ resourceId: 'supplies', amount: 1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rest',
|
||||||
|
name: 'Rest',
|
||||||
|
kind: 'loop',
|
||||||
|
group: { id: 'camp_loop', label: 'Camp' },
|
||||||
|
durationMs: 1000,
|
||||||
|
yields: [{ resourceId: 'supplies', amount: 1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips multi-line format', () => {
|
||||||
|
const state = createGameState(content);
|
||||||
|
state.manualCompletionCounts = { gather_supplies: 1, rest: 1 };
|
||||||
|
state.automationQueue = ['gather_supplies', 'rest'];
|
||||||
|
const text = exportRecipe(state, content, { name: 'Camp loop' });
|
||||||
|
expect(text).toContain(RECIPE_HEADER_V1);
|
||||||
|
expect(text).toContain('gather_supplies');
|
||||||
|
|
||||||
|
const fresh = createGameState(content);
|
||||||
|
fresh.manualCompletionCounts = { gather_supplies: 1, rest: 1 };
|
||||||
|
importRecipe(fresh, content, text);
|
||||||
|
expect(fresh.automationQueue).toEqual(['gather_supplies', 'rest']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown action ids', () => {
|
||||||
|
const state = createGameState(content);
|
||||||
|
expect(() =>
|
||||||
|
importRecipe(state, content, `${RECIPE_HEADER_V1}\nnot_real`),
|
||||||
|
).toThrow(/unknown/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects locked action ids', () => {
|
||||||
|
const state = createGameState(content);
|
||||||
|
const text = `${RECIPE_HEADER_V1}\ngather_supplies`;
|
||||||
|
expect(() => importRecipe(state, content, text)).toThrow(/locked/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses single-line alias', () => {
|
||||||
|
const state = createGameState(content);
|
||||||
|
state.manualCompletionCounts.gather_supplies = 1;
|
||||||
|
importRecipe(state, content, `${RECIPE_HEADER_V1}:gather_supplies`);
|
||||||
|
expect(state.automationQueue).toEqual(['gather_supplies']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pnpm test src/engine/__tests__/recipe.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement recipe.ts**
|
||||||
|
|
||||||
|
Create `src/engine/recipe.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Content } from '../content/schema';
|
||||||
|
import type { GameState } from './game';
|
||||||
|
import { clearAutomationQueue, isAutomationUnlocked, addToAutomationQueue } from './automation';
|
||||||
|
|
||||||
|
export const RECIPE_HEADER_V1 = 'idlegame-recipe/v1';
|
||||||
|
const ACTION_ID_RE = /^[a-z][a-z0-9_]*$/;
|
||||||
|
|
||||||
|
export interface RecipeMeta {
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportRecipe(state: GameState, content: Content, meta?: RecipeMeta): string {
|
||||||
|
const lines = [RECIPE_HEADER_V1];
|
||||||
|
if (meta?.name) lines.push(`# name: ${meta.name}`);
|
||||||
|
for (const id of state.automationQueue) {
|
||||||
|
if (content.actionsById[id]) lines.push(id);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRecipeLines(text: string): { name?: string; actionIds: string[] } {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.startsWith(`${RECIPE_HEADER_V1}:`)) {
|
||||||
|
const ids = trimmed.slice(RECIPE_HEADER_V1.length + 1).split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
return { actionIds: ids };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = trimmed.split(/\r?\n/);
|
||||||
|
if (lines[0]?.trim() !== RECIPE_HEADER_V1) {
|
||||||
|
throw new Error(`Invalid recipe header; expected "${RECIPE_HEADER_V1}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let name: string | undefined;
|
||||||
|
const actionIds: string[] = [];
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i]?.trim() ?? '';
|
||||||
|
if (!line) continue;
|
||||||
|
if (line.startsWith('# name:')) {
|
||||||
|
name = line.slice('# name:'.length).trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('#')) continue;
|
||||||
|
if (!ACTION_ID_RE.test(line)) {
|
||||||
|
throw new Error(`Invalid action id "${line}"`);
|
||||||
|
}
|
||||||
|
actionIds.push(line);
|
||||||
|
}
|
||||||
|
return { name, actionIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importRecipe(state: GameState, content: Content, text: string): { name?: string } {
|
||||||
|
const { name, actionIds } = parseRecipeLines(text);
|
||||||
|
const unknown = actionIds.filter((id) => !content.actionsById[id]);
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
throw new Error(`Unknown action ids: ${unknown.join(', ')}`);
|
||||||
|
}
|
||||||
|
const locked = actionIds.filter((id) => !isAutomationUnlocked(state, content, id));
|
||||||
|
if (locked.length > 0) {
|
||||||
|
throw new Error(`Automation locked for: ${locked.join(', ')}`);
|
||||||
|
}
|
||||||
|
clearAutomationQueue(state);
|
||||||
|
for (const id of actionIds) {
|
||||||
|
addToAutomationQueue(state, content, id);
|
||||||
|
}
|
||||||
|
return { name };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `pnpm test src/engine/__tests__/recipe.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/engine/recipe.ts src/engine/__tests__/recipe.test.ts
|
||||||
|
git commit -m "feat(engine): automation recipe export and import"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: View model automation fields
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/state/viewModel.ts`
|
||||||
|
- Modify: `src/state/__tests__/viewModel.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
it('marks automationUnlocked on actions after manual completion', () => {
|
||||||
|
state.manualCompletionCounts.gather_supplies = 1;
|
||||||
|
const view = toView(state, content);
|
||||||
|
const action = view.actionColumns
|
||||||
|
.flatMap((c) => c.groups)
|
||||||
|
.flatMap((g) => g.actions)
|
||||||
|
.find((a) => a.id === 'gather_supplies');
|
||||||
|
expect(action?.automationUnlocked).toBe(true);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `pnpm test src/state/__tests__/viewModel.test.ts -t "automationUnlocked"`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Extend ActionView**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ActionView {
|
||||||
|
// ...existing
|
||||||
|
automationUnlocked: boolean;
|
||||||
|
inAutomationQueue: boolean;
|
||||||
|
loopEnabled: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Map using `isAutomationUnlocked`, `state.automationQueue.includes(id)`, `state.enabledLoopActionIds[id]`.
|
||||||
|
|
||||||
|
Add to `GameView`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
automationQueueIds: string[];
|
||||||
|
automationQueueNames: string[];
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `pnpm test src/state/__tests__/viewModel.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/state/viewModel.ts src/state/__tests__/viewModel.test.ts
|
||||||
|
git commit -m "feat(state): automation fields on action view model"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Runtime automation commands
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/state/runtime.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add runtime methods**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
toggleAutomation(actionId: string): void {
|
||||||
|
const state = this.state;
|
||||||
|
if (!state) return;
|
||||||
|
try {
|
||||||
|
if (state.automationQueue.includes(actionId)) {
|
||||||
|
const idx = state.automationQueue.indexOf(actionId);
|
||||||
|
removeFromAutomationQueue(state, idx);
|
||||||
|
} else {
|
||||||
|
addToAutomationQueue(state, content, actionId);
|
||||||
|
}
|
||||||
|
this.publish();
|
||||||
|
} catch (err) {
|
||||||
|
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Automation failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exportAutomationRecipe(): string {
|
||||||
|
const state = this.state;
|
||||||
|
if (!state) return '';
|
||||||
|
return exportRecipe(state, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
importAutomationRecipe(text: string): void {
|
||||||
|
const state = this.state;
|
||||||
|
if (!state) return;
|
||||||
|
try {
|
||||||
|
const meta = importRecipe(state, content, text);
|
||||||
|
useGameStore.getState().appendLog(meta.name ? `Imported recipe: ${meta.name}` : 'Imported recipe.');
|
||||||
|
this.publish();
|
||||||
|
} catch (err) {
|
||||||
|
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Import failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/state/runtime.ts
|
||||||
|
git commit -m "feat(state): runtime automation and recipe commands"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: AutomationBar UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ui/AutomationBar.tsx`
|
||||||
|
- Modify: `src/ui/PlayPanel.tsx`
|
||||||
|
- Modify: `src/ui/ActionCard.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create AutomationBar**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function AutomationBar() {
|
||||||
|
const names = useGameStore((s) => s.automationQueueNames);
|
||||||
|
const [importText, setImportText] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Automation" className="mt-4 rounded-lg border border-slate-700 p-3">
|
||||||
|
<h2 className="mb-2 font-medium text-slate-300 text-sm uppercase tracking-wide">Automation</h2>
|
||||||
|
{names.length > 0 ? (
|
||||||
|
<ol className="mb-2 text-slate-300 text-sm">
|
||||||
|
{names.map((name, i) => (
|
||||||
|
<li key={name}>{i + 1}. {name}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
) : (
|
||||||
|
<p className="mb-2 text-slate-500 text-sm">No automated processes.</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => navigator.clipboard.writeText(gameRuntime.exportAutomationRecipe())}>
|
||||||
|
Copy recipe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
className="mt-2 w-full rounded border border-slate-600 bg-slate-900 p-2 text-slate-200 text-xs"
|
||||||
|
rows={4}
|
||||||
|
placeholder="Paste recipe text…"
|
||||||
|
value={importText}
|
||||||
|
onChange={(e) => setImportText(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="button" className="mt-1" onClick={() => { gameRuntime.importAutomationRecipe(importText); setImportText(''); }}>
|
||||||
|
Import recipe
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: ActionCard auto toggle**
|
||||||
|
|
||||||
|
When `action.automationUnlocked`, show small "Auto" button calling `gameRuntime.toggleAutomation(action.id)`; highlight when `action.inAutomationQueue`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Mount in PlayPanel**
|
||||||
|
|
||||||
|
Add `<AutomationBar />` below column grid.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Browser smoke**
|
||||||
|
|
||||||
|
1. Complete gather manually once → Auto button appears.
|
||||||
|
2. Add to automation → runs when idle.
|
||||||
|
3. Export recipe → paste in fresh session (with unlocks) → import restores queue.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ui/AutomationBar.tsx src/ui/PlayPanel.tsx src/ui/ActionCard.tsx
|
||||||
|
git commit -m "feat(ui): automation bar with recipe export/import"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Integration verify
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run pre-PR chain**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm lint
|
||||||
|
pnpm test:coverage
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all green; engine ≥80%.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit if doc touch needed**
|
||||||
|
|
||||||
|
Update `docs/architecture.md` §Automation with recipe format example.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/architecture.md
|
||||||
|
git commit -m "docs: document automation queue and recipe format"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spec coverage self-review
|
||||||
|
|
||||||
|
| Spec requirement | Task |
|
||||||
|
|---|---|
|
||||||
|
| Universal automation after first manual completion | Tasks 1–2 |
|
||||||
|
| Story actions automatable after first perform | Task 1 records story completions |
|
||||||
|
| Separate automation queue | Tasks 2–3 |
|
||||||
|
| Precedence manual > automation > loop | Task 3 |
|
||||||
|
| Multi-line recipe format | Task 4 |
|
||||||
|
| Single-line alias | Task 4 |
|
||||||
|
| Reject unknown/locked ids on import | Task 4 |
|
||||||
|
| Export/import UI | Task 7 |
|
||||||
|
| Offline automation | Task 3 runner uses same tickGame path |
|
||||||
|
| Compressed recipes | Optional — defer unless queue >20 steps |
|
||||||
|
|
||||||
|
## Placeholder scan
|
||||||
|
|
||||||
|
No TBD/TODO. All steps include paths and code.
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
# M1 PR2 — Playable Loop Design Spec
|
||||||
|
|
||||||
|
> **Status:** Approved by ginnoir (brainstorm 2026-06-11).
|
||||||
|
> **Branch:** `feat/m1-playable-loop` off `main`.
|
||||||
|
> **Closes:** Gitea #5 (Action queue UI), #6 (Story graph and first branch).
|
||||||
|
> **Parent plan:** `docs/plans/2026-06-11-m1-vertical-slice.md` §PR2.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
PR2 turns the PR1 queue engine into the first **playable loop**: a hybrid story graph (choices, action-complete beats, boot, resource thresholds), a full-window visual-novel story panel with running story log, upgraded action queue UI with narrative context (subtitles/tooltips), and player preferences for story-open and action-detail modes. Stub prose and placeholder names prove branch mechanics; real content replaces them in PR3.
|
||||||
|
|
||||||
|
## What we decided (brainstorm)
|
||||||
|
|
||||||
|
| Topic | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Story pacing | **Hybrid** — manual choices, action-complete beats, boot intro, resource thresholds |
|
||||||
|
| Threshold checks | On **action completion** and on **publish** (~10 fps) |
|
||||||
|
| Stub A/B branch | **Flags + resources + route-exclusive action unlocks** |
|
||||||
|
| Action context | `storyHint` subtitle + `storyTooltip` with mechanics and consequences |
|
||||||
|
| Story UI | **Full-window overlay** (VN layout + running story log), not inline strip |
|
||||||
|
| Story open behavior | **Player-configurable**, default **auto on new beats**; modes: auto / choices-only / manual |
|
||||||
|
| Action detail display | **Inline subtitle default**; hover on desktop; configurable (inline / hover / info-button) |
|
||||||
|
| PR2 scope | **Mechanics + minimal Settings drawer**; full mobile layout pass deferred to PR4 (#10) |
|
||||||
|
| Architecture | **Approach 1** — pure engine triggers + thin UI/prefs in state layer |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/engine/story.ts traverse, applyChoice, evaluateTriggers, outcomes
|
||||||
|
src/engine/game.ts + storyFlags, currentStoryNodeId, seenStoryNodeIds
|
||||||
|
src/engine/save.ts persist story fields in SAVE_VERSION=1 (.default())
|
||||||
|
src/content/storySchema.ts Zod story node / choice / trigger / outcome defs
|
||||||
|
src/content/story.ts stub ~6-node graph
|
||||||
|
src/content/schema.ts + action.storyHint, action.storyTooltip
|
||||||
|
src/state/storyOrchestration.ts evaluateTriggers after boot, publish, actionComplete
|
||||||
|
src/state/prefs.ts localStorage: storyOpenMode, actionDetailMode
|
||||||
|
src/state/viewModel.ts + story view, action availability, hints
|
||||||
|
src/state/runtime.ts wire orchestration, applyChoice, cancelQueue
|
||||||
|
src/ui/StoryPanel.tsx full-screen overlay, VN passage, choices, story log
|
||||||
|
src/ui/ActionPanel.tsx queue list, cancel, disabled, subtitles/tooltips
|
||||||
|
src/ui/SettingsDrawer.tsx two preference selects
|
||||||
|
```
|
||||||
|
|
||||||
|
**Boundaries (unchanged from M0):**
|
||||||
|
|
||||||
|
- `src/engine/` — pure TS; no React, DOM, localStorage, wall clock.
|
||||||
|
- `src/state/` — orchestration, prefs, RAF, persistence.
|
||||||
|
- `src/ui/` — renders view model; calls runtime commands only.
|
||||||
|
|
||||||
|
## Story schema
|
||||||
|
|
||||||
|
### StoryNode
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
StoryNode {
|
||||||
|
id: string
|
||||||
|
prose: string
|
||||||
|
choices?: StoryChoice[]
|
||||||
|
triggers?: StoryTrigger[]
|
||||||
|
enterOutcomes?: StoryOutcome[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### StoryChoice
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
StoryChoice {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
requirements?: {
|
||||||
|
minResources?: Record<string, number>
|
||||||
|
requireStoryFlags?: string[]
|
||||||
|
excludeStoryFlags?: string[]
|
||||||
|
}
|
||||||
|
outcomes: StoryOutcome[]
|
||||||
|
targetNodeId: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### StoryTrigger
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
StoryTrigger {
|
||||||
|
type: 'boot' | 'actionComplete' | 'minResources'
|
||||||
|
actionId?: string
|
||||||
|
minResources?: Record<string, number>
|
||||||
|
targetNodeId: string
|
||||||
|
once?: boolean // default true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### StoryOutcome
|
||||||
|
|
||||||
|
| Type | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `setFlag` | `storyFlags[id] = true` |
|
||||||
|
| `clearFlag` | `storyFlags[id] = false` |
|
||||||
|
| `grantResource` | increase resource |
|
||||||
|
| `consumeResource` | decrease resource (validated) |
|
||||||
|
| `log` | narrative line appended to story log (no state change) |
|
||||||
|
|
||||||
|
Outcomes on a choice or `enterOutcomes` apply atomically before advancing.
|
||||||
|
|
||||||
|
### Action narrative fields
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
action.storyHint?: string // one-line subtitle (inline mode)
|
||||||
|
action.storyTooltip?: string // longer detail: yields, costs, consequences
|
||||||
|
```
|
||||||
|
|
||||||
|
Tooltip copy may reference flags/choices textually (authoring); engine does not parse natural language.
|
||||||
|
|
||||||
|
### Content validation
|
||||||
|
|
||||||
|
At `buildContent` / story load:
|
||||||
|
|
||||||
|
- All `targetNodeId` references resolve.
|
||||||
|
- Trigger `actionId` values exist in `actionsById`.
|
||||||
|
- Resource ids in outcomes/triggers exist.
|
||||||
|
- Graph has exactly one boot trigger entry point.
|
||||||
|
- Invalid graphs throw at load (dev-time), matching action schema behavior.
|
||||||
|
|
||||||
|
## GameState extensions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface GameState {
|
||||||
|
// existing: resources, activeActionId, actionElapsedMs, actionQueue
|
||||||
|
storyFlags: Record<string, boolean>
|
||||||
|
currentStoryNodeId: string
|
||||||
|
seenStoryNodeIds: string[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Save v1** (no version bump; PR4 ships formal v1→v2 migration):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
storyFlags: z.record(z.string(), z.boolean()).default({})
|
||||||
|
currentStoryNodeId: z.string().default('') // hydrated to entry on first boot if empty
|
||||||
|
seenStoryNodeIds: z.array(z.string()).default([])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Engine API (`src/engine/story.ts`)
|
||||||
|
|
||||||
|
| Function | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `initStory(state, content)` | Resolve entry node from boot trigger; set `currentStoryNodeId` |
|
||||||
|
| `evaluateTriggers(state, content, ctx)` | ctx: `{ reason: 'boot' \| 'publish' \| 'actionComplete', actionId? }`; returns `{ enteredNodes: string[], events: StoryEvent[] }` |
|
||||||
|
| `applyChoice(state, content, choiceId)` | Validate requirements, apply outcomes, advance, append to `seenStoryNodeIds` |
|
||||||
|
| `getAvailableChoices(state, content, nodeId)` | Filter choices by requirements |
|
||||||
|
| `getCurrentNode(state, content)` | Lookup helper |
|
||||||
|
|
||||||
|
**Trigger evaluation rules:**
|
||||||
|
|
||||||
|
1. **`boot`** — fires once on `initStory` if target not seen (when `once !== false`).
|
||||||
|
2. **`actionComplete`** — fires when `ctx.reason === 'actionComplete'` and `ctx.actionId` matches.
|
||||||
|
3. **`minResources`** — fires when all thresholds met; checked on `publish` and after `actionComplete`.
|
||||||
|
4. A trigger does not re-fire if `targetNodeId` is in `seenStoryNodeIds` and `once !== false`.
|
||||||
|
5. Entering a node applies `enterOutcomes`, adds id to `seenStoryNodeIds`, emits events.
|
||||||
|
|
||||||
|
**Integration with actions:** `game.ts` `isActionAvailable` receives `state.storyFlags` (remove PR1 default `{}` placeholder at call sites).
|
||||||
|
|
||||||
|
## Stub story graph
|
||||||
|
|
||||||
|
~6 nodes, one A/B fork, placeholder prose:
|
||||||
|
|
||||||
|
```text
|
||||||
|
boot_intro
|
||||||
|
└─ fork_choice ─┬─ route_a_beat → unlocks fortify_camp (requireStoryFlags: route_a)
|
||||||
|
└─ route_b_beat → unlocks push_onward stub action (requireStoryFlags: route_b)
|
||||||
|
threshold_beat (minResources coin ≥ 3) → merchant_flavor
|
||||||
|
actionComplete(scout_path) → scout_aftermath
|
||||||
|
```
|
||||||
|
|
||||||
|
**Route outcomes:**
|
||||||
|
|
||||||
|
- Route A: `setFlag route_a`, grant supplies, unlock `fortify_camp`.
|
||||||
|
- Route B: `setFlag route_b`, grant coin, unlock new stub action `push_onward`.
|
||||||
|
|
||||||
|
Add `push_onward` to `definitions.ts` with `requireStoryFlags: ['route_b']`.
|
||||||
|
|
||||||
|
## Runtime orchestration
|
||||||
|
|
||||||
|
```text
|
||||||
|
boot → loadGame → initStory → evaluateTriggers(boot) → publish
|
||||||
|
|
||||||
|
each frame:
|
||||||
|
tickGame → [if action completed this tick] evaluateTriggers(actionComplete)
|
||||||
|
publish (≥10fps) → evaluateTriggers(publish)
|
||||||
|
if new node:
|
||||||
|
appendStoryLog
|
||||||
|
open StoryPanel per prefs.storyOpenMode
|
||||||
|
optionally mirror one line to event log
|
||||||
|
|
||||||
|
applyChoice → engine → appendStoryLog → publish → close/minimize panel
|
||||||
|
cancelQueuedAction → engine → publish
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/state/storyOrchestration.ts` owns the trigger call sequence; runtime invokes it.
|
||||||
|
|
||||||
|
## Player preferences (`src/state/prefs.ts`)
|
||||||
|
|
||||||
|
Stored in `localStorage` (not save v1):
|
||||||
|
|
||||||
|
| Key | Values | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| `storyOpenMode` | `auto` \| `choices-only` \| `manual` | `auto` |
|
||||||
|
| `actionDetailMode` | `inline` \| `hover` \| `info-button` | `inline` |
|
||||||
|
|
||||||
|
**SettingsDrawer** — gear icon in header; two `<select>` controls; changes apply immediately.
|
||||||
|
|
||||||
|
### Story open modes
|
||||||
|
|
||||||
|
| Mode | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| `auto` | Open overlay on every new node |
|
||||||
|
| `choices-only` | Auto-open only when node has choices; else unread badge |
|
||||||
|
| `manual` | Never auto-open; **Story** button shows unread badge |
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
### App shell
|
||||||
|
|
||||||
|
Order unchanged: header (title + settings gear + story button) → ResourceBar → ActionPanel → EventLog.
|
||||||
|
|
||||||
|
### StoryPanel (overlay)
|
||||||
|
|
||||||
|
- `fixed inset-0 z-50`, dark backdrop.
|
||||||
|
- **Main:** current prose; choice buttons; "Continue" on passage-only nodes.
|
||||||
|
- **Story log:** collapsible sidebar (desktop) or bottom sheet (mobile) — append-only `{ nodeId, prose, choiceLabel? }`.
|
||||||
|
- Close/minimize respects open mode; choices require selection before dismiss when auto-opened for a choice node.
|
||||||
|
|
||||||
|
### ActionPanel
|
||||||
|
|
||||||
|
- Button per action with progress bar on active (existing).
|
||||||
|
- **Queue list:** ordered names, cancel (✕) per index.
|
||||||
|
- **Disabled** when locked or unaffordable; click logs reason (no enqueue).
|
||||||
|
- **Inline:** `storyHint` under action name.
|
||||||
|
- **Hover (desktop):** tooltip with `storyTooltip`.
|
||||||
|
- **Info-button:** ⓘ opens tooltip popover.
|
||||||
|
|
||||||
|
Availability derived from view model (`available`, `disabledReason`).
|
||||||
|
|
||||||
|
### View model additions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
actions: {
|
||||||
|
id, name, available, disabledReason,
|
||||||
|
storyHint?, storyTooltip?,
|
||||||
|
costsSummary?, yieldsSummary?
|
||||||
|
}[]
|
||||||
|
|
||||||
|
story: {
|
||||||
|
isOpen, hasUnread,
|
||||||
|
currentProse, choices: { id, label, disabled, disabledReason }[],
|
||||||
|
log: { nodeId, prose, choiceLabel? }[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
| Failure | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Invalid story content at load | Throw during `buildContent` (dev-time) |
|
||||||
|
| Unknown node/choice id in engine API | Throw (dev-time) |
|
||||||
|
| Choice requirements not met | `applyChoice` throws; UI disables button + shows reason |
|
||||||
|
| Unaffordable enqueue | Existing pattern — catch in runtime, log to event log |
|
||||||
|
| Missing prefs in localStorage | Use defaults |
|
||||||
|
| Save missing story fields | `.default()` on schema — backward compatible with PR1 saves |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Engine (required, ≥80% coverage on `src/engine/`)
|
||||||
|
|
||||||
|
- Linear node traversal and `seenStoryNodeIds`.
|
||||||
|
- Branch by choice; requirements gate choices.
|
||||||
|
- Each trigger type: boot, actionComplete, minResources.
|
||||||
|
- Threshold fires on resource change via outcomes.
|
||||||
|
- Outcomes: flags, grant/consume resources.
|
||||||
|
- `once: false` vs default once-only triggers.
|
||||||
|
- Invalid graph rejected at content load.
|
||||||
|
|
||||||
|
### Content schema
|
||||||
|
|
||||||
|
- Valid stub graph builds.
|
||||||
|
- Dangling `targetNodeId` throws.
|
||||||
|
- Unknown action id in trigger throws.
|
||||||
|
|
||||||
|
### State
|
||||||
|
|
||||||
|
- Orchestration fires boot trigger on load.
|
||||||
|
- `storyFlags` wired into action availability in view model.
|
||||||
|
- Prefs round-trip localStorage.
|
||||||
|
|
||||||
|
### UI (browser smoke — Antigravity or manual)
|
||||||
|
|
||||||
|
- Queue 2+ actions, cancel one, order respected.
|
||||||
|
- Route A vs B → different flags, resources, unlocked actions.
|
||||||
|
- Story panel opens per default auto mode.
|
||||||
|
- Settings change story-open mode behavior.
|
||||||
|
- Reload preserves story state + queue.
|
||||||
|
|
||||||
|
### Pre-PR verification chain
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm lint
|
||||||
|
pnpm test:coverage
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Out of scope (PR2)
|
||||||
|
|
||||||
|
- Real opening-arc prose (PR3 / Phase 0 gate).
|
||||||
|
- Automation unlock (PR3 #7).
|
||||||
|
- Prestige reset (PR3 #8).
|
||||||
|
- Save v2 migration and export/import UX (PR4 #9).
|
||||||
|
- Full mobile layout pass (PR4 #10) — basic overlay responsiveness only.
|
||||||
|
- Playwright E2E.
|
||||||
|
- Additional prefs beyond the two PR2 toggles.
|
||||||
|
|
||||||
|
## AI tool routing (from parent plan)
|
||||||
|
|
||||||
|
| Task | Tool |
|
||||||
|
|---|---|
|
||||||
|
| T2.1–T2.2 Story engine + schema | Codex |
|
||||||
|
| T2.3–T2.5 UI + stub graph + browser verify | Antigravity |
|
||||||
|
| Ginnoir review / tweaks | Cursor |
|
||||||
|
|
||||||
|
## Success criteria (PR2 merge)
|
||||||
|
|
||||||
|
1. Player can queue/cancel actions with visible queue and disabled states.
|
||||||
|
2. Full-window story panel shows stub VN passage and choices.
|
||||||
|
3. Completing route A vs B produces different flags, resources, and unlocked actions.
|
||||||
|
4. Action subtitles/tooltips show narrative + mechanical hints.
|
||||||
|
5. Settings drawer toggles story-open and action-detail modes.
|
||||||
|
6. Reload preserves story position, flags, and queue.
|
||||||
|
7. CI green; engine coverage ≥80%.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Brainstorm approved 2026-06-11. Next step: invoke `writing-plans` for task-level implementation plan.*
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
# M1 PR3 — Shell UI & Action Column Design Spec
|
||||||
|
|
||||||
|
> **Status:** Approved by ginnoir (brainstorm 2026-06-11).
|
||||||
|
> **Branch:** `feat/m1-progression` off `main` (first chunk before automation/prestige/content).
|
||||||
|
> **Supersedes (partially):** PR2 story overlay UX in `docs/superpowers/specs/2026-06-11-m1-pr2-playable-loop-design.md` §Story UI.
|
||||||
|
> **Parent plan:** `docs/plans/2026-06-11-m1-vertical-slice.md` §PR3.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
PR3 opens with a **shell refactor** that fixes PR2 UX debt before progression/content land: replace the full-screen story overlay with a three-region layout (nav rail / center panel / right rail), reorganize Play into **behavior-type action columns** with collapsible theme groups, move **all story forks to actions** (Story tab is read-only prose + branching tree), and establish **universal automation** with **shareable text recipes** referencing content action ids.
|
||||||
|
|
||||||
|
## What we decided (brainstorm)
|
||||||
|
|
||||||
|
| Topic | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Timing | **First chunk of PR3** — shell lands at start of `feat/m1-progression`, then T3.1 automation, T3.2 prestige, T3.3 content |
|
||||||
|
| Story choices | **Actions only** — Story tab has no choice buttons |
|
||||||
|
| Story tab | **Split pane:** branching tree (larger, ~60%) + long-form prose log (~40%); tree left, log right |
|
||||||
|
| App shell | **Left nav** (Play, Story, Settings, About) → **center** active panel → **right rail** on Play (resources + inventory + optional event log) |
|
||||||
|
| Action layout | **Columns by behavior kind**, collapsible **theme/purpose groups** inside each column |
|
||||||
|
| Column order | **Instant → Loop → Timed → Story → Context** (Timed centered as primary column) |
|
||||||
|
| Loop vs automation | **Loop** = idle filler while playing (queue empty, nothing timed active). **Automation** = hands-off repeat queue including offline |
|
||||||
|
| Automation scope | **Universal** — instant, loop, timed, story, context all automatable **after first manual completion** on that action |
|
||||||
|
| Story automation | Same rule — first time at a fork is conscious; after performing a branch once, that story action can be automated on repeat runs |
|
||||||
|
| Automation sharing | Recipes **export/import as text**; steps reference stable **content action ids** |
|
||||||
|
| Event log | **Optional** on Play right rail (collapsible + pref to hide; default visible) |
|
||||||
|
| Mobile | Column stacking + responsive Story split deferred to PR4 (#10) |
|
||||||
|
| Approach | **Shell first, typed columns second** within PR3 opener (T3.0) |
|
||||||
|
|
||||||
|
## Problem statement (PR2 debt)
|
||||||
|
|
||||||
|
1. **Readability** — full-screen story overlay (`bg-black/80`) ghosts underlying UI; hard to read.
|
||||||
|
2. **Redundant choice paths** — story panel choice buttons and Play actions both visible; unclear authority.
|
||||||
|
3. **Structural mismatch** — single centered column does not match intended Chronicle-style shell (nav / main / sidebar).
|
||||||
|
4. **Flat action list** — no distinction between instant, idle loop, timed queue, story decisions, or context switches.
|
||||||
|
|
||||||
|
## App shell
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┬─────────────────────────────┬──────────────┐
|
||||||
|
│ Nav rail │ Center (active panel) │ Right rail │
|
||||||
|
│ │ │ (Play only) │
|
||||||
|
│ ● Play │ Play → action columns │ Resources │
|
||||||
|
│ Story │ Story → tree │ prose log │ Inventory * │
|
||||||
|
│ Settings │ Event log † │
|
||||||
|
│ About │ Settings / About → content │ │
|
||||||
|
└──────────┴─────────────────────────────┴──────────────┘
|
||||||
|
|
||||||
|
* Inventory placeholder = resource list until item system exists
|
||||||
|
† Collapsible; pref to hide (default: visible)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nav rail
|
||||||
|
|
||||||
|
- Fixed left column; icons + labels on desktop, icons-only acceptable on narrow widths until PR4.
|
||||||
|
- **Story unread badge** when new beats arrive (replaces overlay auto-open as primary signal).
|
||||||
|
- `storyOpenMode` pref remapped: `auto` switches to Story nav or pulses badge instead of opening overlay.
|
||||||
|
|
||||||
|
### Center panel
|
||||||
|
|
||||||
|
Swaps content by active nav item. No full-screen modal for story.
|
||||||
|
|
||||||
|
### Right rail (Play only)
|
||||||
|
|
||||||
|
- **Resources** — always visible.
|
||||||
|
- **Inventory** — resource list placeholder in M1; item grid later.
|
||||||
|
- **Event log** — collapsible section; `showEventLog` pref (default `true`).
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `StoryPanel` full-screen overlay with choice buttons.
|
||||||
|
- Header Story / Settings buttons replaced by nav rail (Settings may retain drawer or become full center panel — implementer picks cleaner fit).
|
||||||
|
|
||||||
|
## Play panel — action columns
|
||||||
|
|
||||||
|
### Column order (left → right)
|
||||||
|
|
||||||
|
| # | Column | `kind` | Role |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Instant | `instant` | One-shot interactions: buy, sell, trade |
|
||||||
|
| 2 | Loop | `loop` | Idle upkeep between tasks (rest, passive recovery) |
|
||||||
|
| 3 | **Timed** | `timed` | Primary gameplay; manual queue + progress bars |
|
||||||
|
| 4 | Story | `story` | Narrative forks and hard decisions |
|
||||||
|
| 5 | Context | `context` | Area changes, combat entry, context switches |
|
||||||
|
|
||||||
|
Timed column is visually central. Narrow viewports: horizontal scroll with Timed near viewport center.
|
||||||
|
|
||||||
|
### Groups within columns
|
||||||
|
|
||||||
|
Each action belongs to a **group** (content-defined theme/purpose):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
group: {
|
||||||
|
id: string // e.g. 'camp', 'tavern', 'buy'
|
||||||
|
label: string // e.g. 'Camp activities', 'Buy'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Groups render as **collapsible sections** inside their kind column.
|
||||||
|
- Examples:
|
||||||
|
- **Instant:** Buy, Sell, Trade
|
||||||
|
- **Loop:** Camp activities, Travel activities
|
||||||
|
- **Timed:** Library, Tavern, Camp (location-themed)
|
||||||
|
- **Story:** Arc-specific decision groups
|
||||||
|
- **Context:** Region / combat entry groups
|
||||||
|
- Collapse state stored in `GamePrefs.collapsedActionGroups: Record<string, boolean>`.
|
||||||
|
|
||||||
|
### Action card UX (unchanged from PR2 where applicable)
|
||||||
|
|
||||||
|
- `storyHint` inline subtitle (default) / hover / info-button per pref.
|
||||||
|
- `storyTooltip` for mechanics and fork consequences.
|
||||||
|
- **Story-kind actions** get distinct styling (amber fork badge, border) so diverging paths are obvious in Play.
|
||||||
|
|
||||||
|
## Action kinds — engine behavior
|
||||||
|
|
||||||
|
### `instant`
|
||||||
|
|
||||||
|
- Click → validate afford/unlock → apply costs/yields immediately.
|
||||||
|
- No queue slot, no duration.
|
||||||
|
|
||||||
|
### `timed`
|
||||||
|
|
||||||
|
- Current PR1/PR2 behavior: `durationMs`, manual `actionQueue`, progress bar.
|
||||||
|
- Primary manual play column.
|
||||||
|
|
||||||
|
### `loop`
|
||||||
|
|
||||||
|
- Runs only when **`activeActionId` is null** and **manual `actionQueue` is empty**.
|
||||||
|
- Player enables/disables per loop action (toggle); priority order when multiple enabled (content `loopPriority` number, lower first).
|
||||||
|
- On completion while still idle, immediately repeats same loop action.
|
||||||
|
- Respects costs; stops if unaffordable.
|
||||||
|
- **Does not run offline** — idle-present behavior only.
|
||||||
|
|
||||||
|
### `story`
|
||||||
|
|
||||||
|
- Represents one graph choice. Content field `storyChoiceId` links to `StoryChoice.id`.
|
||||||
|
- On execute → `applyChoice(state, content, storyChoiceId)` → sibling story actions hide via flags/requirements.
|
||||||
|
- First visit: only available choices shown; player picks consciously.
|
||||||
|
- After first manual completion of that action id: eligible for automation queue.
|
||||||
|
|
||||||
|
### `context`
|
||||||
|
|
||||||
|
- Switches active game context (location, combat scene, etc.).
|
||||||
|
- M1: stub until opening arc requires it; schema + column chrome ship in T3.0.
|
||||||
|
|
||||||
|
## Automation (T3.1 — universal)
|
||||||
|
|
||||||
|
### Philosophy
|
||||||
|
|
||||||
|
Once the player has **completed an action manually at least once**, they may add it to an **automation queue** that repeats hands-off, **including offline** (within existing offline cap).
|
||||||
|
|
||||||
|
Applies to **all kinds** including `story` (after that specific branch was taken once). New/unseen branches remain manual until first completion.
|
||||||
|
|
||||||
|
Prestige/knowledge catch-up (T3.2) treats previously cleared routes as already completed for automation unlock purposes — same knowledge model as story fast-forward.
|
||||||
|
|
||||||
|
### Queues
|
||||||
|
|
||||||
|
| Queue | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `actionQueue` | Manual timed queue (player actively planning) |
|
||||||
|
| `automationQueue` | Hands-off repeat pipeline |
|
||||||
|
|
||||||
|
Automation runner executes when manual queue is idle (exact precedence documented in engine; automation fills gaps like a background worker, distinct from loop-kind idle actions — both may need priority rules: **manual > automation > loop**).
|
||||||
|
|
||||||
|
### Automation recipe export/import
|
||||||
|
|
||||||
|
Recipes are **shareable text** referencing stable **content action ids** (not display names).
|
||||||
|
|
||||||
|
**Canonical multi-line format (v1):**
|
||||||
|
|
||||||
|
```text
|
||||||
|
idlegame-recipe/v1
|
||||||
|
# name: Early camp grind
|
||||||
|
gather_supplies
|
||||||
|
scout_path
|
||||||
|
rest_briefly
|
||||||
|
```
|
||||||
|
|
||||||
|
- Line 1: magic header with version (`idlegame-recipe/v1`).
|
||||||
|
- Optional `# name:` comment for human label (ignored by parser if malformed).
|
||||||
|
- One action id per non-empty, non-comment line.
|
||||||
|
- Lines must match `/^[a-z][a-z0-9_]*$/` (same id convention as content defs).
|
||||||
|
|
||||||
|
**Single-line alias (optional parser support):**
|
||||||
|
|
||||||
|
```text
|
||||||
|
idlegame-recipe/v1:gather_supplies,scout_path,rest_briefly
|
||||||
|
```
|
||||||
|
|
||||||
|
**Compressed variant (long recipes):** same payload JSON + `lz-string` URI encoding as saves (`toRecipeExportString` / `fromRecipeExportString`) — optional in T3.1 if recipes exceed ~20 steps; multi-line text remains the default share format.
|
||||||
|
|
||||||
|
**Import validation:**
|
||||||
|
|
||||||
|
1. Parse version; reject unknown versions explicitly.
|
||||||
|
2. Every id must exist in `content.actionsById`.
|
||||||
|
3. Every id must be **automation-unlocked** for the current player state (`manualCompletionCounts[id] >= threshold`).
|
||||||
|
4. On failure: user-visible error listing unknown or locked ids — no partial apply unless ginnoir opts in later.
|
||||||
|
|
||||||
|
**Export:** serializes current `automationQueue` (or named saved recipe slot if added later) to multi-line text; copy-to-clipboard in Settings or Play automation UI.
|
||||||
|
|
||||||
|
### State fields
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
manualCompletionCounts: Record<string, number> // default {}; gates automation
|
||||||
|
automationQueue: string[] // action ids
|
||||||
|
enabledLoopActionIds: Record<string, boolean> // loop toggles
|
||||||
|
```
|
||||||
|
|
||||||
|
Persist in save payload (PR3 adds fields; PR4 may bump `SAVE_VERSION` to 2 with migration).
|
||||||
|
|
||||||
|
## Story tab
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
Split pane, resizable on desktop:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────┬─────────────────────┐
|
||||||
|
│ Branching tree (~60%) │ Prose log (~40%) │
|
||||||
|
│ │ │
|
||||||
|
│ ○ boot_intro │ [Full passage text │
|
||||||
|
│ ├─● route_a │ for selected or │
|
||||||
|
│ │ └─ merchant │ latest node] │
|
||||||
|
│ └─○ route_b (dimmed) │ │
|
||||||
|
│ │ Scrollable history │
|
||||||
|
└──────────────────────────┴─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Tree (primary surface):** built from story graph + `seenStoryNodeIds` / `storyFlags`. Taken paths emphasized; untaken branches dimmed. Click node → prose log scrolls to that beat. Larger pane because the tree needs manipulation room.
|
||||||
|
- **Prose log (secondary):** full passage text, not truncated. Choice labels inline: `[You chose: Take the high road]`.
|
||||||
|
- **No choice buttons.**
|
||||||
|
|
||||||
|
### Story map data
|
||||||
|
|
||||||
|
- Tree nodes mirror `storyNodesById` edges via choices + triggers.
|
||||||
|
- Visibility: seen nodes solid; future/locked dimmed or hidden per design.
|
||||||
|
- M1 T3.0 may ship minimal tree (indented list) before polish; structure must support real graph.
|
||||||
|
|
||||||
|
### Unread / navigation
|
||||||
|
|
||||||
|
- New story beats → Story nav badge.
|
||||||
|
- `storyOpenMode: auto` → switch to Story tab or highlight badge (no overlay).
|
||||||
|
|
||||||
|
## Content schema changes
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
actionKindSchema = z.enum(['instant', 'loop', 'timed', 'story', 'context'])
|
||||||
|
|
||||||
|
actionGroupSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
label: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
actionDefSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
kind: actionKindSchema.default('timed'),
|
||||||
|
group: actionGroupSchema,
|
||||||
|
durationMs: z.number().positive().optional(), // required for timed + loop
|
||||||
|
loopPriority: z.number().int().nonnegative().optional(),
|
||||||
|
costs: ...,
|
||||||
|
yields: ...,
|
||||||
|
unlock: ...,
|
||||||
|
storyHint: ...,
|
||||||
|
storyTooltip: ...,
|
||||||
|
storyChoiceId: z.string().min(1).optional(), // required when kind === 'story'
|
||||||
|
contextId: z.string().min(1).optional(), // required when kind === 'context'
|
||||||
|
automation: z.object({
|
||||||
|
unlockAfterManualCompletions: z.number().int().positive().default(1),
|
||||||
|
}).optional(),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation rules:**
|
||||||
|
|
||||||
|
- `timed` and `loop` require `durationMs`.
|
||||||
|
- `instant`, `story`, `context` must not require duration for execution (engine enforces).
|
||||||
|
- `story` requires valid `storyChoiceId` referencing a choice in the loaded story graph.
|
||||||
|
- Column placement derived from `kind` (not a separate column field).
|
||||||
|
|
||||||
|
### Stub content migration (T3.0)
|
||||||
|
|
||||||
|
| Current action | New kind | Group example |
|
||||||
|
|---|---|---|
|
||||||
|
| gather_supplies | timed | Camp |
|
||||||
|
| scout_path | timed | Travel |
|
||||||
|
| trade_at_camp | timed | Camp |
|
||||||
|
| fortify_camp | timed | Camp (route A) |
|
||||||
|
| push_onward | timed | Travel (route B) |
|
||||||
|
| rest_briefly | loop | Camp activities |
|
||||||
|
| *(new)* pick_high_road | story | Fork |
|
||||||
|
| *(new)* follow_river | story | Fork |
|
||||||
|
|
||||||
|
Remove parallel choice buttons from story graph UI; fork choices exist only as story-kind actions.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/ui/AppShell.tsx nav rail + center + right rail layout
|
||||||
|
src/ui/NavRail.tsx
|
||||||
|
src/ui/PlayPanel.tsx action column grid
|
||||||
|
src/ui/ActionColumn.tsx one kind column
|
||||||
|
src/ui/ActionGroup.tsx collapsible group
|
||||||
|
src/ui/StoryView.tsx split tree + prose log (replaces overlay StoryPanel)
|
||||||
|
src/ui/StoryTree.tsx
|
||||||
|
src/ui/StoryProseLog.tsx
|
||||||
|
src/ui/RightRail.tsx resources, inventory placeholder, event log
|
||||||
|
src/ui/AutomationBar.tsx automation queue UI + export/import (T3.1)
|
||||||
|
|
||||||
|
src/content/schema.ts + kind, group, storyChoiceId, contextId, automation
|
||||||
|
src/engine/game.ts instant execute, loop idle runner, completion counts
|
||||||
|
src/engine/automation.ts automation queue runner, recipe parse/serialize (new)
|
||||||
|
src/engine/recipe.ts recipe export/import validation (new)
|
||||||
|
src/state/viewModel.ts column/group projection, automation eligibility
|
||||||
|
src/state/runtime.ts nav commands, remove overlay; story action → applyChoice
|
||||||
|
src/state/prefs.ts + collapsedActionGroups, showEventLog
|
||||||
|
```
|
||||||
|
|
||||||
|
**Boundaries unchanged:** engine pure TS; UI calls runtime only.
|
||||||
|
|
||||||
|
## PR3 task order (revised opener)
|
||||||
|
|
||||||
|
| Task | Deliverable | Gitea |
|
||||||
|
|---|---|---|
|
||||||
|
| **T3.0** | App shell, action columns, story-via-actions, Story tab split, remove overlay | UI gate |
|
||||||
|
| **T3.1** | Universal automation + automation queue + recipe export/import | #7 |
|
||||||
|
| **T3.2** | Prestige + knowledge catch-up for automation/story | #8 |
|
||||||
|
| **T3.3** | Opening arc content on new shell | #11 |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Engine
|
||||||
|
|
||||||
|
- Instant action applies costs/yields without queue.
|
||||||
|
- Loop runs when manual queue idle; stops when timed work queued.
|
||||||
|
- Priority: manual timed > automation > loop (document exact rules in tests).
|
||||||
|
- Story action executes `applyChoice`; siblings become unavailable.
|
||||||
|
- `manualCompletionCounts` gates automation; story actions unlock after first perform.
|
||||||
|
- Recipe round-trip: export → import → identical queue; reject unknown ids; reject locked ids.
|
||||||
|
|
||||||
|
### UI / view model
|
||||||
|
|
||||||
|
- Actions projected into correct column and group.
|
||||||
|
- Group collapse prefs persist.
|
||||||
|
- Story tab: tree click selects prose; no choice buttons rendered.
|
||||||
|
- Nav badge on unread story.
|
||||||
|
|
||||||
|
### Regression
|
||||||
|
|
||||||
|
- Remove/update PR2 overlay tests and story choice button tests.
|
||||||
|
- Existing queue/tick/story graph tests adapted for action-kind story forks.
|
||||||
|
|
||||||
|
## Out of scope (T3.0)
|
||||||
|
|
||||||
|
- Polished graph visualization (minimal tree OK).
|
||||||
|
- Item inventory (resource list placeholder).
|
||||||
|
- Combat mechanics (context column stub).
|
||||||
|
- Mobile responsive column stack (PR4 #10).
|
||||||
|
- Save v2 migration (PR4 #9) — PR3 adds new state fields with defaults in v1 payload until bump.
|
||||||
|
|
||||||
|
## PR2 supersession note
|
||||||
|
|
||||||
|
PR2 spec locked full-window overlay with in-panel choices. This spec **intentionally replaces** that UX. Engine story graph (`applyChoice`, triggers, flags) remains; only presentation and choice surface move to Play actions + Story read-only tab.
|
||||||
|
|
||||||
|
## Open questions (none blocking)
|
||||||
|
|
||||||
|
All brainstorm decisions resolved 2026-06-11.
|
||||||
Reference in New Issue
Block a user