Author SHA1 Message Date
ginnoir c1db07d997 Merge pull request 'feat(m1): PR3.1 automation unlock and recipes' (#19) from feat/m1-progression into main
CI / verify (push) Successful in 49s
Deploy Internal Playtest / deploy (push) Successful in 45s
2026-06-12 01:26:50 -05:00
ginnoir b8c98a9b42 docs: document automation queue and recipe format
CI / verify (push) Successful in 1m7s
CI / verify (pull_request) Successful in 45s
2026-06-12 00:58:53 -05:00
ginnoir ddbf9c0045 feat(ui): automation bar with recipe export import 2026-06-12 00:57:42 -05:00
ginnoir 29bc4d4bc5 feat(state): runtime automation and recipe commands 2026-06-12 00:51:17 -05:00
ginnoir 4b5372cdf9 feat(state): automation fields on action view model 2026-06-12 00:49:09 -05:00
ginnoir e2a44bec7d feat(engine): automation recipe export and import 2026-06-12 00:47:19 -05:00
ginnoir a7ecf2225b feat(engine): automation runner with manual-first precedence 2026-06-12 00:45:27 -05:00
ginnoir 79f1251cad test(engine): harden automation queue coverage 2026-06-12 00:42:20 -05:00
ginnoir 4a85bf85ae feat(engine): automation unlock and queue CRUD 2026-06-12 00:29:32 -05:00
ginnoir d625216018 feat(engine): track manualCompletionCounts on action complete 2026-06-12 00:19:24 -05:00
ginnoir 4b7adf718d Merge pull request 'feat(m1): PR3 T3.0 — shell UI, action kinds, story tab' (#16) from feat/m1-progression into main
CI / verify (push) Successful in 1m27s
Deploy Internal Playtest / deploy (push) Successful in 1m3s
2026-06-11 22:52:28 -05:00
ginnoir f7ad9e7ecd test(engine): guard offline-loop safety and harden purity test
CI / verify (push) Successful in 1m13s
CI / verify (pull_request) Successful in 58s
- save.test.ts: add regression test proving loop actions do NOT start
  or yield during offline catch-up (applyOfflineProgress replays
  tickGame directly; maybeStartLoopAction is never called, so an
  enabled loop with no active action stays idle and yields 0).

- purity.test.ts: extend FORBIDDEN list to catch environment APIs
  (Date.now(), localStorage., indexedDB., idb-keyval import, document.,
  window., requestAnimationFrame()) in addition to the existing
  React/react-dom/zustand import guards. Patterns are scoped to call
  sites and member-access forms so prose comments (e.g. save.ts's
  "Date scheduling" doc comment) do not false-positive. lz-string is
  intentionally omitted — it is a pure compression library.

- NavRail.tsx: add sr-only "New story" span alongside the aria-hidden
  pulse dot so screen readers can perceive the unread-story badge.
2026-06-11 22:40:38 -05:00
ginnoir 977dd8e878 docs: document PR3 shell UI and action kinds 2026-06-11 22:33:48 -05:00
ginnoir 4d508ae77c refactor(ui): link event-log disclosure with aria-controls
Add aria-controls to the right-rail event-log collapse toggle pointing at
the log region, and title-case the settings label for consistency.
2026-06-11 22:31:24 -05:00
ginnoir 10a43729c5 feat(ui): right rail inventory placeholder and optional event log
- RightRail: add Inventory section (placeholder "Items coming soon") between
  Resources and Event Log, matching the existing heading style
- RightRail: gate Event Log section on prefs.showEventLog; add local-state
  collapse toggle with aria-expanded and chevron indicator
- SettingsPanel: add "Show event log" checkbox row bound to prefs.showEventLog
  via setPrefs, matching existing card styling
- PlayPanel: gate mobile EventLog behind prefs.showEventLog for consistency
  with desktop; mobile ResourceBar unchanged
2026-06-11 22:28:42 -05:00
ginnoir 880abe5888 fix(ui): restore boot-intro continue and fix story tree a11y
Finding 1 (critical): atBootIntro was derived from tree.length === 0 in
StoryView.tsx, but buildStoryTree always returns all topology-root nodes,
so tree.length is always > 0 and the Continue button never rendered.
Fix: add atBootIntro: boolean to StoryView (viewModel), computed by
finding the boot-trigger node id without hardcoding the literal string,
then comparing to the current node. StoryView.tsx reads it from the store.
Also add a viewModel test covering both true and false cases.

Finding 2: onSelect in StoryView.tsx called useGameStore.getState()
directly, bypassing the runtime command layer. Fix: add
GameRuntime.selectStoryNode(id) and wire onSelect through gameRuntime.

Finding 3: aria-pressed on story tree nodes wrongly signals toggle-button
semantics. Fix: replace with aria-current={isSelected ? 'true' : undefined}.

Optional: hoist h-[calc(100dvh-6rem)] to SHELL_HEIGHT const in StoryView.tsx.
2026-06-11 22:25:51 -05:00
ginnoir 91348ed42f refactor(ui): finalize review fixes for action cards and view model
- Guard ActionCard click-outside listener behind open state
- Add aria-expanded to ActionGroup collapse toggle
- Use optional chaining and drop non-null assertions in column projection
2026-06-11 22:16:40 -05:00
ginnoir e9f2514308 feat(ui): story tab with tree and prose log 2026-06-11 22:11:01 -05:00
ginnoir 75273a76de refactor(ui): improve action card accessibility, flex layout and scroll styling 2026-06-11 21:34:46 -05:00
ginnoir bbded8a266 feat(ui): action columns with collapsible groups 2026-06-11 21:32:13 -05:00
ginnoir 08d186f42e refactor: clean up unused panel overlay state and methods 2026-06-11 21:29:44 -05:00
ginnoir 232b84299c refactor(ui): resolve mobile layout and HMR findings for app shell and nav rail 2026-06-11 21:27:41 -05:00
ginnoir ee79872d4d feat(ui): app shell with nav rail 2026-06-11 21:24:42 -05:00
ginnoir ed9607e9e8 refactor(state): address HMR and defensive slicing recommendations for runtime 2026-06-11 21:23:18 -05:00
ginnoir b29b17c6cb refactor(state): fix code quality findings for runtime and story log 2026-06-11 21:20:52 -05:00
ginnoir 239b2506ec feat(state): runtime performAction and nav story signals 2026-06-11 21:16:27 -05:00
ginnoir adf730702d refactor(state): address review recommendations for store and prefs 2026-06-11 21:14:04 -05:00
ginnoir 09e2d06b87 refactor(state): fix code quality findings for nav panel store and prefs 2026-06-11 21:12:20 -05:00
ginnoir 9b2793b264 feat(state): nav panel store and expanded prefs 2026-06-11 21:10:07 -05:00
ginnoir 73d836fe96 feat(state): project actions into columns and story tree 2026-06-11 21:01:42 -05:00
ginnoir 163f710f4c feat(engine): add performAction dispatcher by kind 2026-06-11 20:53:55 -05:00
ginnoir 0de61d3e41 feat(engine): loop idle runner with enabledLoopActionIds 2026-06-11 20:44:20 -05:00
ginnoir 8d9484171f feat(engine): execute story actions via storyChoiceId 2026-06-11 20:36:07 -05:00
ginnoir b3940774c4 feat(engine): add instant action execution
Implements executeInstant, which applies an instant action's costs and
yields immediately with no queue slot and no duration requirement.
2026-06-11 20:29:28 -05:00
ginnoir b54805637c feat(content): migrate stub actions to kind/group model 2026-06-11 20:23:49 -05:00
ginnoir c1b38e36aa fix(content): guard optional durationMs and cover loop validation 2026-06-11 20:17:50 -05:00
ginnoir c48e53cc7b feat(content): add action kind and group schema 2026-06-11 20:12:21 -05:00
ginnoir c76b2d1231 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
2026-06-11 20:05:13 -05:00
ginnoir 203478268c docs(m1): add PR3 shell UI spec and implementation plans
CI / verify (push) Successful in 1m31s
CI / verify (pull_request) Successful in 1m2s
Capture the PR3 UX brainstorm: three-region shell, action columns by kind, story-via-actions, Story tab tree+log, and universal automation with shareable recipes. Includes T3.0/T3.1 implementation plans and M1 vertical slice plan. Also ignore local .worktrees/.
2026-06-11 20:01:30 -05:00
ginnoir 57f037b62b Merge pull request 'feat(m1): playable loop — story graph, story panel, queue UI' (#14) from feat/m1-playable-loop into main
CI / verify (push) Successful in 1m33s
Deploy Internal Playtest / deploy (push) Successful in 1m13s
2026-06-11 19:26:53 -05:00
57 changed files with 9683 additions and 398 deletions
+3
View File
@@ -16,6 +16,9 @@ coverage/
.claude/settings.local.json
.vite/
# Git worktrees (local isolation)
.worktrees/
# Logs
*.log
npm-debug.log*
+72 -2
View File
@@ -25,6 +25,12 @@ storage, requestAnimationFrame, or Date scheduling.
resource and one timed action. M1 expands this into real resources, actions,
story nodes, automation unlocks, and prestige definitions.
Each action carries a behavior `kind` (`instant`, `loop`, `timed`, `story`,
`context`) and a `group` (`{ id, label }`) used for UI layout. The schema
enforces kind-specific invariants: `timed`/`loop` require `durationMs`, `timed`
requires at least one yield, and `story` requires a `storyChoiceId` linking the
action to a choice on the current story node.
## State
`src/state/` owns environment coupling:
@@ -42,8 +48,72 @@ story nodes, automation unlocks, and prestige definitions.
## UI
`src/ui/` renders the view model and calls runtime commands. Components should
not implement gameplay rules. The M0 shell includes a resource readout, action
panel, progress bar, and event log.
not implement gameplay rules.
### Shell layout
PR3 replaces the M0/PR2 single-column overlay with a three-region shell
(`AppShell.tsx`): a left **nav rail** (Play / Story / Settings / About), a
**center** panel for the active tab, and a **right rail** shown on Play
(resources, an inventory placeholder, and an optional event log gated by the
`showEventLog` pref). `store.activePanel` selects the center panel; there is no
modal overlay. New story beats raise a nav badge (`storyHasUnread`), and
`storyOpenMode: auto` switches to the Story tab instead of opening an overlay.
### Action kinds
Play organizes actions into **columns by behavior kind**, ordered
`instant → loop → timed → story → context`, with collapsible theme **groups**
inside each column (collapse state persists in `prefs.collapsedActionGroups`).
The pure engine dispatches each kind through `performAction` (`game.ts`):
`instant` applies costs/yields immediately, `timed` enqueues, `loop` toggles an
entry in `enabledLoopActionIds` (an idle runner starts the highest-priority
affordable loop only when the queue is empty and only during live ticks, never
offline), and `story` applies the linked choice. Story forks are taken **only**
through Story-kind actions; the Story tab itself is read-only.
### Story tab
`StoryView.tsx` is a read-only 60/40 split: a branching `StoryTree` built from
the story graph's choice/trigger edges (seen paths emphasized, unseen dimmed) and
a scrollable `StoryProseLog` with no choice buttons. The boot intro shows a
single Continue affordance that advances `boot_intro → fork_choice`; thereafter
progression is driven by Story-kind actions.
The full design lives in `docs/superpowers/specs/2026-06-11-m1-pr3-shell-ui-design.md`.
### Automation
Automation is universal across action kinds once an action has at least its
configured number of successful completions (`automation.unlockAfterManualCompletions`,
default `1`). The engine stores those counts in
`GameState.manualCompletionCounts` and stores configured automation in
`GameState.automationQueue`.
`src/engine/automation.ts` owns unlock checks, queue mutation, and the runner.
The runner preserves precedence: manual active/queued actions first,
automation second, loop-idle actions last. `tickGame` can start automation when
the manual queue exhausts, including during offline catch-up; loop actions still
start only from the live runtime path.
`src/engine/recipe.ts` serializes automation queues as shareable text using
content action ids. The v1 multiline format is:
```text
idlegame-recipe/v1
# name: Camp loop
gather_supplies
rest
```
The single-line alias is:
```text
idlegame-recipe/v1:gather_supplies,rest
```
Import rejects unknown action ids and actions that are not automation-unlocked
for the current save.
## Verification
+342
View File
@@ -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 PR12; stub prose proves branch mechanics; **ginnoir review gate** before PR3 merges real content |
| Stub content | Placeholder names/prose allowed in PR12; 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 PR12)
**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**, **45 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.1T2.2 engine), Antigravity (T2.3T2.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.1T3.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.1T2.2 | Codex | GPT-5.5-Codex medium | Story graph engine |
| PR2 T2.3T2.5 | Antigravity | Gemini 3 Pro | Browser verification |
| PR3 T3.1T3.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 12 |
| Story actions automatable after first perform | Task 1 records story completions |
| Separate automation queue | Tasks 23 |
| 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.1T2.2 Story engine + schema | Codex |
| T2.3T2.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.
+12 -5
View File
@@ -3,21 +3,28 @@ import { createGameState, enqueueAction, tickGame } from '../../engine/game';
import { content } from '../index';
describe('M1 stub content pack', () => {
it('defines two resources and four to five actions with costs and unlocks', () => {
it('defines two resources and multiple actions with costs, unlocks, and varied kinds', () => {
expect(content.resources).toHaveLength(2);
expect(content.actions.length).toBeGreaterThanOrEqual(4);
expect(content.actions.length).toBeLessThanOrEqual(6);
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);
// verify the new kinds are present
const kinds = new Set(content.actions.map((a) => a.kind));
expect(kinds.has('timed')).toBe(true);
expect(kinds.has('loop')).toBe(true);
expect(kinds.has('story')).toBe(true);
});
it('can simulate a costed action without throwing', () => {
it('can simulate a costed timed action without throwing', () => {
const state = createGameState(content);
const trade = content.actions.find((a) => a.costs.length > 0);
const trade = content.actions.find((a) => a.costs.length > 0 && a.kind === 'timed');
if (!trade) {
throw new Error('expected at least one costed action');
throw new Error('expected at least one costed timed action');
}
if (trade.durationMs === undefined) {
throw new Error('expected costed timed action to have durationMs');
}
enqueueAction(state, content, trade.id);
tickGame(state, content, trade.durationMs);
+78
View File
@@ -6,6 +6,7 @@ const validActions = [
{
id: 'forage',
name: 'Forage',
group: { id: 'camp', label: 'Camp' },
durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }],
},
@@ -28,6 +29,7 @@ describe('buildContent()', () => {
{
id: 'forage',
name: 'Forage',
group: { id: 'camp', label: 'Camp' },
durationMs: 3000,
yields: [{ resourceId: 'ghost', amount: 1 }],
},
@@ -48,6 +50,7 @@ describe('buildContent()', () => {
{
id: 'forage',
name: 'Forage',
group: { id: 'camp', label: 'Camp' },
durationMs: -1,
yields: [{ resourceId: 'gold', amount: 1 }],
},
@@ -66,6 +69,7 @@ describe('costs and multi-yield', () => {
{
id: 'craft',
name: 'Craft',
group: { id: 'camp', label: 'Camp' },
durationMs: 1000,
costs: [{ resourceId: 'wood', amount: 2 }],
yields: [
@@ -84,6 +88,7 @@ describe('costs and multi-yield', () => {
{
id: 'craft',
name: 'Craft',
group: { id: 'camp', label: 'Camp' },
durationMs: 1000,
costs: [{ resourceId: 'ghost', amount: 1 }],
yields: [{ resourceId: 'gold', amount: 1 }],
@@ -104,6 +109,7 @@ describe('unlock conditions', () => {
{
id: 'forage',
name: 'Forage',
group: { id: 'camp', label: 'Camp' },
durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }],
unlock: {
@@ -131,6 +137,7 @@ describe('action narrative fields', () => {
{
id: 'forage',
name: 'Forage',
group: { id: 'camp', label: 'Camp' },
durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }],
storyHint: 'Gather what the forest offers.',
@@ -148,3 +155,74 @@ describe('action narrative fields', () => {
expect(content.actionsById.forage.storyTooltip).toBeUndefined();
});
});
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');
});
it('rejects loop action without durationMs', () => {
expect(() =>
buildContent({
resources: [{ id: 'supplies', name: 'Supplies' }],
actions: [
{
id: 'broken_loop',
name: 'Broken loop',
kind: 'loop',
group: { id: 'camp', label: 'Camp' },
yields: [],
},
],
}),
).toThrow(/durationMs/);
});
});
@@ -9,6 +9,8 @@ const actionsById = {
scout_path: {
id: 'scout_path',
name: 'Scout',
kind: 'timed' as const,
group: { id: 'travel', label: 'Travel' },
durationMs: 1000,
costs: [],
yields: [{ resourceId: 'coin', amount: 1 }],
+34 -1
View File
@@ -7,6 +7,8 @@ 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.',
@@ -15,6 +17,8 @@ export const actionDefs = [
{
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 }],
@@ -24,6 +28,8 @@ export const actionDefs = [
{
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 }],
@@ -34,6 +40,8 @@ export const actionDefs = [
{
id: 'fortify_camp',
name: 'Fortify camp',
kind: 'timed',
group: { id: 'camp', label: 'Camp' },
durationMs: 8000,
costs: [
{ resourceId: 'supplies', amount: 5 },
@@ -47,6 +55,8 @@ export const actionDefs = [
{
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 }],
@@ -57,9 +67,32 @@ export const actionDefs = [
{
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: 'Yields 1 Supply. Quick recovery.',
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',
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: [],
},
];
+1 -1
View File
@@ -9,4 +9,4 @@ const story = buildStoryContent(storyNodeDefs, base.actionsById, base.resourcesB
export type GameContent = Content & StoryContent;
export const content: GameContent = { ...base, ...story };
export type { ActionDef, Content, ResourceDef } from './schema';
export type { ActionDef, ActionGroup, ActionKind, Content, ResourceDef } from './schema';
+51 -8
View File
@@ -25,20 +25,63 @@ export const unlockDefSchema = z.object({
requireStoryFlags: z.array(z.string().min(1)).optional(),
});
export const actionDefSchema = z.object({
export const actionKindSchema = z.enum(['instant', 'loop', 'timed', 'story', 'context']);
export const actionGroupSchema = 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(),
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 === undefined) {
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 ResourceDef = z.infer<typeof resourceDefSchema>;
export type ResourceAmount = z.infer<typeof resourceAmountSchema>;
export type UnlockDef = z.infer<typeof unlockDefSchema>;
export type ActionKind = z.infer<typeof actionKindSchema>;
export type ActionGroup = z.infer<typeof actionGroupSchema>;
export type ActionDef = z.infer<typeof actionDefSchema>;
export interface Content {
+242
View File
@@ -0,0 +1,242 @@
import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema';
import {
addToAutomationQueue,
automationUnlockThreshold,
clearAutomationQueue,
isAutomationUnlocked,
maybeRunAutomation,
removeFromAutomationQueue,
} from '../automation';
import { createGameState, enqueueAction, tickGame } from '../game';
const DEFAULT_GROUP = { id: 'automation', label: 'Automation' };
function automationContent() {
return buildContent({
resources: [
{ id: 'supplies', name: 'Supplies', startAmount: 0 },
{ id: 'renown', name: 'Renown', startAmount: 0 },
],
actions: [
{
id: 'forage',
name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 100,
yields: [{ resourceId: 'supplies', amount: 1 }],
automation: { unlockAfterManualCompletions: 1 },
},
{
id: 'survey',
name: 'Survey',
group: DEFAULT_GROUP,
durationMs: 100,
yields: [{ resourceId: 'renown', amount: 1 }],
},
{
id: 'train',
name: 'Train',
group: DEFAULT_GROUP,
durationMs: 100,
yields: [{ resourceId: 'renown', amount: 1 }],
automation: { unlockAfterManualCompletions: 2 },
},
],
});
}
describe('automationUnlockThreshold()', () => {
it('returns the action-specific automation threshold when configured', () => {
const content = automationContent();
expect(automationUnlockThreshold(content, 'train')).toBe(2);
});
it('defaults to one manual completion when automation config is absent', () => {
const content = automationContent();
expect(automationUnlockThreshold(content, 'survey')).toBe(1);
});
});
describe('isAutomationUnlocked()', () => {
it('returns false before the first manual completion when an action unlocks after one', () => {
const content = automationContent();
const state = createGameState(content);
expect(isAutomationUnlocked(state, content, 'forage')).toBe(false);
});
it('returns true once the manual completion threshold is met', () => {
const content = automationContent();
const state = createGameState(content);
state.manualCompletionCounts.forage = 1;
expect(isAutomationUnlocked(state, content, 'forage')).toBe(true);
});
});
describe('automation queue CRUD', () => {
it('throws for unknown action ids', () => {
const content = automationContent();
const state = createGameState(content);
expect(() => addToAutomationQueue(state, content, 'missing')).toThrow(
'Unknown action "missing"',
);
});
it('throws for locked action ids', () => {
const content = automationContent();
const state = createGameState(content);
expect(() => addToAutomationQueue(state, content, 'forage')).toThrow(/automation.*locked/i);
});
it('appends an unlocked action id', () => {
const content = automationContent();
const state = createGameState(content);
state.manualCompletionCounts.forage = 1;
addToAutomationQueue(state, content, 'forage');
expect(state.automationQueue).toEqual(['forage']);
});
it('does not duplicate an action already in the queue', () => {
const content = automationContent();
const state = createGameState(content);
state.manualCompletionCounts.forage = 1;
addToAutomationQueue(state, content, 'forage');
addToAutomationQueue(state, content, 'forage');
expect(state.automationQueue).toEqual(['forage']);
});
it('removes by index', () => {
const content = automationContent();
const state = createGameState(content);
state.automationQueue = ['forage', 'survey'];
removeFromAutomationQueue(state, 0);
expect(state.automationQueue).toEqual(['survey']);
});
it('throws RangeError when removing an out-of-range index', () => {
const content = automationContent();
const state = createGameState(content);
state.automationQueue = ['forage'];
expect(() => removeFromAutomationQueue(state, 1)).toThrow(RangeError);
});
it('throws RangeError when removing a non-integer index', () => {
const content = automationContent();
const state = createGameState(content);
state.automationQueue = ['forage'];
expect(() => removeFromAutomationQueue(state, 0.5)).toThrow(RangeError);
expect(state.automationQueue).toEqual(['forage']);
});
it('empties the queue', () => {
const content = automationContent();
const state = createGameState(content);
state.automationQueue = ['forage', 'survey'];
clearAutomationQueue(state);
expect(state.automationQueue).toEqual([]);
});
});
function runnerContent() {
return buildContent({
resources: [
{ id: 'supplies', name: 'Supplies', startAmount: 0 },
{ id: 'coin', name: 'Coin', startAmount: 2 },
],
actions: [
{
id: 'gather',
name: 'Gather',
group: DEFAULT_GROUP,
durationMs: 100,
yields: [{ resourceId: 'supplies', amount: 1 }],
},
{
id: 'buy',
name: 'Buy',
kind: 'instant',
group: DEFAULT_GROUP,
costs: [{ resourceId: 'coin', amount: 1 }],
yields: [{ resourceId: 'supplies', amount: 1 }],
},
{
id: 'rest',
name: 'Rest',
kind: 'loop',
group: DEFAULT_GROUP,
durationMs: 100,
loopPriority: 0,
yields: [{ resourceId: 'supplies', amount: 1 }],
},
],
});
}
describe('maybeRunAutomation()', () => {
it('starts the first affordable automation action when manual queue is idle', () => {
const content = runnerContent();
const state = createGameState(content);
state.manualCompletionCounts.gather = 1;
state.automationQueue = ['gather'];
maybeRunAutomation(state, content);
expect(state.activeActionId).toBe('gather');
});
it('does not run when the manual queue has items', () => {
const content = runnerContent();
const state = createGameState(content);
state.actionQueue = ['gather'];
state.manualCompletionCounts.gather = 1;
state.automationQueue = ['gather'];
maybeRunAutomation(state, content);
expect(state.activeActionId).toBeNull();
});
it('executes affordable instant automation and advances to the next candidate', () => {
const content = runnerContent();
const state = createGameState(content);
state.manualCompletionCounts.buy = 1;
state.manualCompletionCounts.gather = 1;
state.automationQueue = ['buy', 'gather'];
maybeRunAutomation(state, content);
expect(state.resources.coin).toBe(1);
expect(state.resources.supplies).toBe(1);
expect(state.activeActionId).toBe('gather');
});
it('starts automation before enabled loop actions after manual queue exhausts', () => {
const content = runnerContent();
const state = createGameState(content);
state.manualCompletionCounts.gather = 1;
state.automationQueue = ['gather'];
state.enabledLoopActionIds.rest = true;
enqueueAction(state, content, 'gather');
tickGame(state, content, 100);
expect(state.activeActionId).toBe('gather');
expect(state.enabledLoopActionIds.rest).toBe(true);
});
});
+1
View File
@@ -10,6 +10,7 @@ function testContent() {
{
id: 'forage',
name: 'Forage',
group: { id: 'test', label: 'Test' },
durationMs: 300,
yields: [{ resourceId: 'gold', amount: 1 }],
},
+235 -3
View File
@@ -1,14 +1,23 @@
import { describe, expect, it } from 'vitest';
import { content as gameContent } from '../../content/index';
import { buildContent } from '../../content/schema';
import { buildStoryContent } from '../../content/storySchema';
import {
cancelQueuedAction,
canUnlockAction,
clearQueue,
createGameState,
enqueueAction,
executeInstant,
maybeStartLoopAction,
performAction,
recordManualCompletion,
startAction,
tickGame,
} from '../game';
import { enterStoryNode } from '../story';
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
function testContent() {
return buildContent({
@@ -17,6 +26,7 @@ function testContent() {
{
id: 'forage',
name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 300,
yields: [{ resourceId: 'gold', amount: 2 }],
},
@@ -28,9 +38,27 @@ function queueContent() {
return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
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 }] },
{
id: 'a',
name: 'A',
group: DEFAULT_GROUP,
durationMs: 1000,
yields: [{ resourceId: 'gold', amount: 1 }],
},
{
id: 'b',
name: 'B',
group: DEFAULT_GROUP,
durationMs: 1000,
yields: [{ resourceId: 'gold', amount: 1 }],
},
{
id: 'c',
name: 'C',
group: DEFAULT_GROUP,
durationMs: 1000,
yields: [{ resourceId: 'gold', amount: 1 }],
},
],
});
}
@@ -45,12 +73,14 @@ function costContent() {
{
id: 'gather',
name: 'Gather',
group: DEFAULT_GROUP,
durationMs: 300,
yields: [{ resourceId: 'supplies', amount: 2 }],
},
{
id: 'trade',
name: 'Trade',
group: DEFAULT_GROUP,
durationMs: 300,
costs: [{ resourceId: 'supplies', amount: 5 }],
yields: [{ resourceId: 'coin', amount: 3 }],
@@ -58,6 +88,7 @@ function costContent() {
{
id: 'scout',
name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 300,
yields: [{ resourceId: 'coin', amount: 1 }],
unlock: { minResources: { coin: 1 } },
@@ -74,6 +105,11 @@ describe('createGameState()', () => {
expect(state.actionElapsedMs).toBe(0);
expect(state.actionQueue).toEqual([]);
});
it('initializes empty manual completion counts', () => {
const state = createGameState(testContent());
expect(state.manualCompletionCounts).toEqual({});
});
});
describe('createGameState() story fields', () => {
@@ -204,6 +240,7 @@ describe('unlock conditions', () => {
{
id: 'secret',
name: 'Secret',
group: DEFAULT_GROUP,
durationMs: 100,
yields: [{ resourceId: 'gold', amount: 1 }],
unlock: { requireStoryFlags: ['path_scouted'] },
@@ -245,12 +282,14 @@ describe('completion advances queue', () => {
{
id: 'cheap',
name: 'Cheap',
group: DEFAULT_GROUP,
durationMs: 100,
yields: [{ resourceId: 'supplies', amount: 1 }],
},
{
id: 'dear',
name: 'Dear',
group: DEFAULT_GROUP,
durationMs: 100,
costs: [{ resourceId: 'supplies', amount: 6 }],
yields: [{ resourceId: 'supplies', amount: 1 }],
@@ -258,6 +297,7 @@ describe('completion advances queue', () => {
{
id: 'free',
name: 'Free',
group: DEFAULT_GROUP,
durationMs: 100,
yields: [{ resourceId: 'supplies', amount: 1 }],
},
@@ -283,6 +323,7 @@ describe('completion advances queue', () => {
{
id: 'combo',
name: 'Combo',
group: DEFAULT_GROUP,
durationMs: 100,
yields: [
{ resourceId: 'a', amount: 2 },
@@ -316,6 +357,24 @@ describe('tickGame() completion result', () => {
const result = tickGame(state, content, 100);
expect(result.completedActionIds).toEqual([]);
});
it('increments manualCompletionCounts when a timed action completes', () => {
const content = testContent();
const state = createGameState(content);
enqueueAction(state, content, 'forage');
tickGame(state, content, 300);
expect(state.manualCompletionCounts.forage).toBe(1);
});
});
describe('recordManualCompletion()', () => {
it('increments known content actions only', () => {
const content = testContent();
const state = createGameState(content);
recordManualCompletion(state, content, 'forage');
recordManualCompletion(state, content, 'missing');
expect(state.manualCompletionCounts).toEqual({ forage: 1 });
});
});
describe('tickGame()', () => {
@@ -356,3 +415,176 @@ describe('tickGame()', () => {
expect(state.activeActionId).toBeNull();
});
});
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([]);
expect(state.manualCompletionCounts.buy_supply).toBe(1);
});
it('throws when unaffordable', () => {
const state = createGameState(instantContent);
state.resources.coin = 0;
expect(() => executeInstant(state, instantContent, 'buy_supply')).toThrow(/Cannot/);
});
});
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('defaults enabledLoopActionIds to empty', () => {
const state = createGameState(loopContent);
expect(state.enabledLoopActionIds).toEqual({});
});
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();
});
it('does not start a disabled loop action', () => {
const state = createGameState(loopContent);
maybeStartLoopAction(state, loopContent);
expect(state.activeActionId).toBeNull();
});
});
describe('performAction()', () => {
it('dispatches timed actions through enqueueAction and returns empty array', () => {
const state = createGameState(gameContent);
const events = performAction(state, gameContent, 'gather_supplies');
expect(state.activeActionId).toBe('gather_supplies');
expect(events).toEqual([]);
});
it('toggles loop actions, starts them when idle, and returns empty array', () => {
const state = createGameState(gameContent);
const events1 = performAction(state, gameContent, 'rest'); // enable
expect(state.enabledLoopActionIds.rest).toBe(true);
expect(state.activeActionId).toBe('rest'); // started because idle + available
expect(events1).toEqual([]);
const events2 = performAction(state, gameContent, 'rest'); // disable
expect(state.enabledLoopActionIds.rest).toBe(false);
expect(events2).toEqual([]);
});
it('dispatches story actions through executeStoryAction and returns events', () => {
const state = createGameState(gameContent);
enterStoryNode(state, gameContent, 'fork_choice');
const events = performAction(state, gameContent, 'pick_high_road');
expect(state.storyFlags.route_a).toBe(true);
expect(state.manualCompletionCounts.pick_high_road).toBe(1);
expect(events.length).toBeGreaterThan(0);
expect(events[0].kind).toBe('enter');
});
it('executes instant actions immediately', () => {
const base = 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 }],
},
{
id: 'enter_cave',
name: 'Enter cave',
kind: 'context',
group: { id: 'travel', label: 'Travel' },
contextId: 'cave',
},
],
});
const story = buildStoryContent(
[{ id: 'boot', prose: 'x', triggers: [{ type: 'boot', targetNodeId: 'boot' }] }],
base.actionsById,
base.resourcesById,
);
const fixture = { ...base, ...story };
const state = createGameState(fixture);
performAction(state, fixture, 'buy_supply');
expect(state.resources.coin).toBe(3);
expect(state.resources.supplies).toBe(1);
expect(state.activeActionId).toBeNull();
});
it('throws for context actions (not implemented in M1)', () => {
const base = buildContent({
resources: [{ id: 'supplies', name: 'Supplies', startAmount: 0 }],
actions: [
{
id: 'enter_cave',
name: 'Enter cave',
kind: 'context',
group: { id: 'travel', label: 'Travel' },
contextId: 'cave',
},
],
});
const story = buildStoryContent(
[{ id: 'boot', prose: 'x', triggers: [{ type: 'boot', targetNodeId: 'boot' }] }],
base.actionsById,
base.resourcesById,
);
const fixture = { ...base, ...story };
const state = createGameState(fixture);
expect(() => performAction(state, fixture, 'enter_cave')).toThrow(/not implemented/i);
});
it('throws for unknown actions', () => {
const state = createGameState(gameContent);
expect(() => performAction(state, gameContent, 'nope')).toThrow(/[Uu]nknown/);
});
});
+36 -3
View File
@@ -3,7 +3,40 @@ 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/];
/**
* Patterns that must NOT appear in engine source files.
*
* React / state-manager imports match the import statement so plain
* string occurrences in comments are not flagged.
*
* Environment / browser / wall-clock APIs matched as usage tokens
* (property-access or call-site forms) to avoid false-positives on
* prose comments that name these APIs without using them. In
* particular:
* - `Date\.now\(` catches the call site; a comment saying "Date
* scheduling" does not contain "Date.now(" so it passes.
* - `localStorage\.` / `indexedDB\.` catch member-access, not the
* bare words that appear in save.ts's module-doc comment.
* - `from\s+['"]idb-keyval` catches the package import.
* - `\bdocument\.` / `\bwindow\.` catch DOM member-access.
* - `requestAnimationFrame\(` catches the call site.
*
* lz-string is a pure compression library used by save.ts it is
* intentionally NOT in this list.
*/
const FORBIDDEN: { pattern: RegExp; label: string }[] = [
{ pattern: /from\s+['"]react['"]/, label: 'react import' },
{ pattern: /from\s+['"]react-dom['"]/, label: 'react-dom import' },
{ pattern: /from\s+['"]zustand['"]/, label: 'zustand import' },
{ pattern: /Date\.now\(/, label: 'Date.now() call (wall-clock)' },
{ pattern: /localStorage\./, label: 'localStorage access (storage API)' },
{ pattern: /indexedDB\./, label: 'indexedDB access (storage API)' },
{ pattern: /from\s+['"]idb-keyval['"]/, label: 'idb-keyval import (storage API)' },
{ pattern: /\bdocument\./, label: 'document access (DOM API)' },
{ pattern: /\bwindow\./, label: 'window access (browser global)' },
{ pattern: /requestAnimationFrame\(/, label: 'requestAnimationFrame call (scheduling API)' },
];
async function engineSourceFiles(): Promise<string[]> {
const entries = await readdir(ENGINE_DIR, { withFileTypes: true });
@@ -18,8 +51,8 @@ describe('engine purity', () => {
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);
for (const { pattern, label } of FORBIDDEN) {
expect(source, `${file} must not use ${label}`).not.toMatch(pattern);
}
}
});
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { buildContent } from '../../content/schema';
import { createGameState } from '../game';
import { exportRecipe, importRecipe, RECIPE_HEADER_V1 } from '../recipe';
const DEFAULT_GROUP = { id: 'camp', label: 'Camp' };
function recipeContent() {
return buildContent({
resources: [{ id: 'supplies', name: 'Supplies' }],
actions: [
{
id: 'gather_supplies',
name: 'Gather',
kind: 'timed',
group: DEFAULT_GROUP,
durationMs: 1000,
yields: [{ resourceId: 'supplies', amount: 1 }],
},
{
id: 'rest',
name: 'Rest',
kind: 'loop',
group: DEFAULT_GROUP,
durationMs: 1000,
yields: [{ resourceId: 'supplies', amount: 1 }],
},
],
});
}
describe('recipe export/import', () => {
it('round-trips multi-line format', () => {
const content = recipeContent();
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 content = recipeContent();
const state = createGameState(content);
expect(() => importRecipe(state, content, `${RECIPE_HEADER_V1}\nnot_real`)).toThrow(/unknown/i);
});
it('rejects locked action ids', () => {
const content = recipeContent();
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 content = recipeContent();
const state = createGameState(content);
state.manualCompletionCounts.gather_supplies = 1;
importRecipe(state, content, `${RECIPE_HEADER_V1}:gather_supplies`);
expect(state.automationQueue).toEqual(['gather_supplies']);
});
});
+103
View File
@@ -18,6 +18,7 @@ function testContent() {
{
id: 'forage',
name: 'Forage',
group: { id: 'test', label: 'Test' },
durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }],
},
@@ -69,6 +70,31 @@ describe('createSave()', () => {
expect(save.state.currentStoryNodeId).toBe('route_a_beat');
expect(save.state.seenStoryNodeIds).toHaveLength(2);
});
it('snapshots enabledLoopActionIds in the save payload and isolates from mutation', () => {
const state = sampleState();
state.enabledLoopActionIds = { rest: true };
const save = createSave(state, 1700);
expect(save.state.enabledLoopActionIds).toEqual({ rest: true });
state.enabledLoopActionIds.rest = false;
expect(save.state.enabledLoopActionIds).toEqual({ rest: true });
});
it('snapshots manualCompletionCounts and automationQueue in the save payload', () => {
const state = sampleState() as ReturnType<typeof sampleState> & {
manualCompletionCounts: Record<string, number>;
automationQueue: string[];
};
state.manualCompletionCounts = { forage: 2 };
state.automationQueue = ['forage'];
const save = createSave(state, 1700);
expect(save.state.manualCompletionCounts).toEqual({ forage: 2 });
expect(save.state.automationQueue).toEqual(['forage']);
state.manualCompletionCounts.forage = 3;
state.automationQueue.push('forage');
expect(save.state.manualCompletionCounts).toEqual({ forage: 2 });
expect(save.state.automationQueue).toEqual(['forage']);
});
});
describe('serialize / deserialize round-trip', () => {
@@ -83,6 +109,54 @@ describe('serialize / deserialize round-trip', () => {
const restored = fromExportString(toExportString(save));
expect(restored).toEqual(save);
});
it('preserves enabledLoopActionIds through a round-trip', () => {
const state = sampleState();
state.enabledLoopActionIds = { rest: true, patrol: false };
const restored = deserializeSave(serializeSave(createSave(state, 1700)));
expect(restored.state.enabledLoopActionIds).toEqual({ rest: true, patrol: false });
});
it('preserves manualCompletionCounts and automationQueue through a round-trip', () => {
const state = sampleState() as ReturnType<typeof sampleState> & {
manualCompletionCounts: Record<string, number>;
automationQueue: string[];
};
state.manualCompletionCounts = { forage: 4 };
state.automationQueue = ['forage'];
const restored = deserializeSave(serializeSave(createSave(state, 1700)));
expect(restored.state.manualCompletionCounts).toEqual({ forage: 4 });
expect(restored.state.automationQueue).toEqual(['forage']);
});
it('defaults enabledLoopActionIds to {} when absent from save JSON', () => {
const json = JSON.stringify({
version: 1,
savedAt: 1700,
state: {
resources: { gold: 0 },
activeActionId: null,
actionElapsedMs: 0,
},
});
const restored = deserializeSave(json);
expect(restored.state.enabledLoopActionIds).toEqual({});
});
it('defaults manualCompletionCounts and automationQueue when absent from save JSON', () => {
const json = JSON.stringify({
version: 1,
savedAt: 1700,
state: {
resources: { gold: 0 },
activeActionId: null,
actionElapsedMs: 0,
},
});
const restored = deserializeSave(json);
expect(restored.state.manualCompletionCounts).toEqual({});
expect(restored.state.automationQueue).toEqual([]);
});
});
describe('invalid / tampered saves', () => {
@@ -104,6 +178,35 @@ describe('invalid / tampered saves', () => {
});
});
describe('applyOfflineProgress() — loop invariant', () => {
it('does NOT start a loop action during offline catch-up even when it is enabled', () => {
// Hardest invariant: maybeStartLoopAction is called by the runtime AFTER each live
// tick, never from tickGame itself. Offline catch-up replays tickGame directly, so
// loop actions must never start (and therefore never yield) during catch-up.
const content = buildContent({
resources: [{ id: 'wood', name: 'Wood', startAmount: 0 }],
actions: [
{
id: 'chop',
name: 'Chop Wood',
kind: 'loop',
group: { id: 'test', label: 'Test' },
durationMs: 1000,
yields: [{ resourceId: 'wood', amount: 1 }],
},
],
});
const state = createGameState(content);
// Enable the loop — player has toggled it on — but do NOT make it active.
state.enabledLoopActionIds.chop = true;
// Simulate coming back online after 10 seconds (10 full loop durations).
applyOfflineProgress(state, content, 0, 10_000);
// The loop must NOT have started or yielded during offline catch-up.
expect(state.activeActionId).toBeNull();
expect(state.resources.wood).toBe(0);
});
});
describe('applyOfflineProgress()', () => {
it('credits whole ticks of elapsed time to the active action', () => {
const content = testContent();
+7
View File
@@ -13,6 +13,8 @@ import {
type StoryEvent,
} from '../story';
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
function gameContent() {
const base = buildContent({
resources: [
@@ -23,6 +25,7 @@ function gameContent() {
{
id: 'scout_path',
name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000,
costs: [],
yields: [{ resourceId: 'coin', amount: 1 }],
@@ -54,6 +57,7 @@ function gameContentWithActionTrigger() {
{
id: 'scout_path',
name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000,
costs: [],
yields: [{ resourceId: 'coin', amount: 1 }],
@@ -91,6 +95,7 @@ function gameContentWithThreshold() {
{
id: 'scout_path',
name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000,
costs: [],
yields: [{ resourceId: 'coin', amount: 1 }],
@@ -132,6 +137,7 @@ function gameContentWithFork() {
{
id: 'scout_path',
name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000,
costs: [],
yields: [{ resourceId: 'coin', amount: 1 }],
@@ -189,6 +195,7 @@ function gameContentWithGatedChoice() {
{
id: 'scout_path',
name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000,
costs: [],
yields: [{ resourceId: 'coin', amount: 1 }],
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { content } from '../../content/index';
import { createGameState, executeStoryAction } from '../game';
import { enterStoryNode } from '../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 the choice is not available', () => {
const state = createGameState(content);
enterStoryNode(state, content, 'fork_choice');
executeStoryAction(state, content, 'pick_high_road');
// after taking route A, the current node has no choices, so follow_river (pick_b) is unavailable
expect(() => executeStoryAction(state, content, 'follow_river')).toThrow();
});
});
+86
View File
@@ -0,0 +1,86 @@
import type { Content } from '../content/schema';
import type { StoryContent } from '../content/storySchema';
import {
beginAction,
executeInstant,
executeStoryAction,
type GameState,
isActionAvailable,
} from './game';
type GameContent = Content & StoryContent;
function assertKnownAction(content: Content, actionId: string): void {
if (!content.actionsById[actionId]) {
throw new Error(`Unknown action "${actionId}"`);
}
}
export function automationUnlockThreshold(content: Content, actionId: string): number {
const action = content.actionsById[actionId];
if (!action) {
throw new Error(`Unknown action "${actionId}"`);
}
return action.automation?.unlockAfterManualCompletions ?? 1;
}
export function isAutomationUnlocked(
state: GameState,
content: Content,
actionId: string,
): boolean {
assertKnownAction(content, actionId);
return (
(state.manualCompletionCounts[actionId] ?? 0) >= automationUnlockThreshold(content, actionId)
);
}
export function addToAutomationQueue(state: GameState, content: Content, actionId: string): void {
assertKnownAction(content, actionId);
if (!isAutomationUnlocked(state, content, actionId)) {
throw new Error(`Automation for action "${actionId}" is locked`);
}
if (!state.automationQueue.includes(actionId)) {
state.automationQueue.push(actionId);
}
}
export function removeFromAutomationQueue(state: GameState, index: number): void {
if (!Number.isInteger(index) || 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;
}
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':
executeInstant(state, content, actionId);
continue;
case 'timed':
case 'loop':
beginAction(state, content, actionId);
return;
case 'story':
executeStoryAction(state, content as GameContent, actionId);
return;
case 'context':
continue;
default:
continue;
}
}
}
+134 -2
View File
@@ -1,4 +1,9 @@
import type { Content } from '../content/schema';
import type { StoryContent } from '../content/storySchema';
import { maybeRunAutomation } from './automation';
import { applyChoice, isStoryChoiceAvailable, type StoryEvent } from './story';
type GameContent = Content & StoryContent;
/**
* Core game state and per-tick simulation.
@@ -22,6 +27,12 @@ export interface GameState {
currentStoryNodeId: string;
/** Story node ids the player has already seen. */
seenStoryNodeIds: string[];
/** loop-kind action id -> whether the player has enabled it for idle running. */
enabledLoopActionIds: Record<string, boolean>;
/** action id -> number of successful player-enabled completions. */
manualCompletionCounts: Record<string, number>;
/** Action ids configured for future automation repeat. */
automationQueue: string[];
}
export function createGameState(content: Content): GameState {
@@ -37,6 +48,9 @@ export function createGameState(content: Content): GameState {
storyFlags: {},
currentStoryNodeId: '',
seenStoryNodeIds: [],
enabledLoopActionIds: {},
manualCompletionCounts: {},
automationQueue: [],
};
}
@@ -90,7 +104,12 @@ function grantYields(state: GameState, content: Content, actionId: string): void
}
}
function beginAction(state: GameState, content: Content, actionId: string): void {
export function recordManualCompletion(state: GameState, content: Content, actionId: string): void {
if (!content.actionsById[actionId]) return;
state.manualCompletionCounts[actionId] = (state.manualCompletionCounts[actionId] ?? 0) + 1;
}
export function beginAction(state: GameState, content: Content, actionId: string): void {
assertKnownAction(content, actionId);
deductCosts(state, content, actionId);
state.activeActionId = actionId;
@@ -110,6 +129,7 @@ function startNextFromQueue(state: GameState, content: Content): void {
}
state.activeActionId = null;
state.actionElapsedMs = 0;
maybeRunAutomation(state, content);
}
export interface TickResult {
@@ -156,23 +176,135 @@ export function clearQueue(state: GameState): void {
state.actionQueue.length = 0;
}
/**
* Execute an instant action immediately, deducting costs and granting yields
* without occupying a queue slot or requiring a duration.
* Throws if the action is not of kind 'instant' or is unavailable.
*/
export function executeInstant(state: GameState, content: Content, actionId: string): void {
const action = content.actionsById[actionId];
if (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);
recordManualCompletion(state, content, actionId);
}
/**
* Execute a story action by resolving its storyChoiceId and applying it.
* Throws if the action is not of kind 'story', has no storyChoiceId, or the
* choice is not currently available.
*/
export function executeStoryAction(
state: GameState,
content: GameContent,
actionId: string,
): StoryEvent[] {
const action = content.actionsById[actionId];
if (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`);
}
const events = applyChoice(state, content, action.storyChoiceId);
recordManualCompletion(state, content, actionId);
return events;
}
/**
* Start the highest-priority enabled, available loop action when the game is idle.
* Invoked by the runtime AFTER each live tick never from `tickGame`, so loop
* actions do not run during offline catch-up (which replays `tickGame` directly).
*/
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;
}
}
}
/**
* Single entry point for all player-initiated action dispatch.
*
* Dispatches by action.kind to the appropriate per-kind function:
* - instant: execute immediately (costs/yields, no queue slot)
* - timed: enqueue (start if idle, queue otherwise)
* - loop: toggle player enable preference; start runner if just enabled
* - story: resolve storyChoiceId and apply the choice
* - context: not implemented in M1 throws
*
* Note on loop toggle: disabling is always allowed, even when the action is
* currently unaffordable. Affordability is the runner's concern (maybeStartLoopAction
* re-checks each tick). Throwing on unaffordable before toggling would wrongly
* block the player from DISABLING an active but now-unaffordable loop.
*/
export function performAction(
state: GameState,
content: GameContent,
actionId: string,
): StoryEvent[] {
const action = content.actionsById[actionId];
if (!action) throw new Error(`Unknown action "${actionId}"`);
switch (action.kind) {
case 'instant':
executeInstant(state, content, actionId);
return [];
case 'timed':
enqueueAction(state, content, actionId);
return [];
case 'loop': {
const willEnable = !state.enabledLoopActionIds[actionId];
state.enabledLoopActionIds[actionId] = willEnable;
if (willEnable) {
maybeStartLoopAction(state, content);
}
return [];
}
case 'story':
return executeStoryAction(state, content, actionId);
case 'context':
throw new Error(`Context action "${actionId}" is not implemented`);
default:
throw new Error(`Unknown action kind "${(action as { kind: string }).kind}"`);
}
}
/**
* Advance the active action by `tickMs`. On completion, grants yields and
* advances the queue actions do not auto-repeat when the queue is empty.
*/
export function tickGame(state: GameState, content: Content, tickMs: number): TickResult {
const completedActionIds: string[] = [];
if (!state.activeActionId) return { completedActionIds };
if (!state.activeActionId) {
maybeRunAutomation(state, content);
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 (action.durationMs === undefined) return { completedActionIds };
if (state.actionElapsedMs < action.durationMs) return { completedActionIds };
state.actionElapsedMs -= action.durationMs;
grantYields(state, content, actionId);
recordManualCompletion(state, content, actionId);
completedActionIds.push(actionId);
startNextFromQueue(state, content);
}
+79
View File
@@ -0,0 +1,79 @@
import type { Content } from '../content/schema';
import { addToAutomationQueue, clearAutomationQueue, isAutomationUnlocked } from './automation';
import type { GameState } from './game';
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 actionId of state.automationQueue) {
if (content.actionsById[actionId]) {
lines.push(actionId);
}
}
return lines.join('\n');
}
export function parseRecipeLines(text: string): { name?: string; actionIds: string[] } {
const trimmed = text.trim();
if (trimmed.startsWith(`${RECIPE_HEADER_V1}:`)) {
const actionIds = trimmed
.slice(RECIPE_HEADER_V1.length + 1)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return { actionIds };
}
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 += 1) {
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 actionId of actionIds) {
addToAutomationQueue(state, content, actionId);
}
return { name };
}
+6
View File
@@ -32,6 +32,9 @@ export const gameStateSchema = z.object({
storyFlags: z.record(z.string(), z.boolean()).default({}),
currentStoryNodeId: z.string().default(''),
seenStoryNodeIds: z.array(z.string()).default([]),
enabledLoopActionIds: z.record(z.string(), z.boolean()).default({}),
manualCompletionCounts: z.record(z.string(), z.number()).default({}),
automationQueue: z.array(z.string()).default([]),
});
export const saveSchema = z.object({
@@ -55,6 +58,9 @@ export function createSave(state: GameState, now: number): SaveData {
storyFlags: { ...state.storyFlags },
currentStoryNodeId: state.currentStoryNodeId,
seenStoryNodeIds: [...state.seenStoryNodeIds],
enabledLoopActionIds: { ...state.enabledLoopActionIds },
manualCompletionCounts: { ...state.manualCompletionCounts },
automationQueue: [...state.automationQueue],
},
};
}
+12
View File
@@ -165,6 +165,18 @@ export function getAvailableChoices(state: GameState, content: GameContent): Sto
return node.choices.filter((c) => meetsChoiceRequirements(state, c.requirements));
}
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 function applyChoice(
state: GameState,
content: GameContent,
+16
View File
@@ -10,3 +10,19 @@ body {
min-height: 100dvh;
background-color: #020617; /* slate-950 */
}
/* Custom Scrollbar for action columns */
.overflow-x-auto::-webkit-scrollbar {
height: 6px;
}
.overflow-x-auto::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.3);
border-radius: 9999px;
}
.overflow-x-auto::-webkit-scrollbar-thumb {
background: rgba(100, 116, 139, 0.4);
border-radius: 9999px;
}
.overflow-x-auto::-webkit-scrollbar-thumb:hover {
background: rgba(245, 158, 11, 0.5);
}
+20 -2
View File
@@ -4,6 +4,8 @@ import { createGameState, enqueueAction, startAction } from '../../engine/game';
import { createSave, serializeSave } from '../../engine/save';
import { createMemoryBackend, loadGame, saveGame } from '../persistence';
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
function testContent() {
return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
@@ -11,6 +13,7 @@ function testContent() {
{
id: 'forage',
name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }],
},
@@ -22,8 +25,20 @@ function queueTestContent() {
return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
actions: [
{ id: 'a', name: 'A', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] },
{ id: 'b', name: 'B', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] },
{
id: 'a',
name: 'A',
group: DEFAULT_GROUP,
durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }],
},
{
id: 'b',
name: 'B',
group: DEFAULT_GROUP,
durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }],
},
],
});
}
@@ -95,6 +110,9 @@ describe('loadGame()', () => {
storyFlags: {},
currentStoryNodeId: '',
seenStoryNodeIds: [],
enabledLoopActionIds: {},
manualCompletionCounts: {},
automationQueue: [],
},
1000,
),
+55 -2
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getPrefs, setPrefs } from '../prefs';
describe('prefs', () => {
@@ -14,12 +14,65 @@ describe('prefs', () => {
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('returns defaults when localStorage empty', () => {
expect(getPrefs()).toEqual({ storyOpenMode: 'auto', actionDetailMode: 'inline' });
expect(getPrefs()).toEqual({
storyOpenMode: 'auto',
actionDetailMode: 'inline',
collapsedActionGroups: {},
showEventLog: true,
});
});
it('round-trips updated prefs', () => {
setPrefs({ storyOpenMode: 'manual', actionDetailMode: 'hover' });
expect(getPrefs().storyOpenMode).toBe('manual');
});
it('migrates v1 prefs to v2 if v2 does not exist', () => {
const v1Prefs = {
storyOpenMode: 'manual',
actionDetailMode: 'hover',
collapsedActionGroups: { 'some-group': true },
};
localStorage.setItem('idlegame:prefs:v1', JSON.stringify(v1Prefs));
const migratedPrefs = getPrefs();
expect(migratedPrefs).toEqual({
storyOpenMode: 'manual',
actionDetailMode: 'hover',
collapsedActionGroups: { 'some-group': true },
showEventLog: true,
});
const rawV2 = localStorage.getItem('idlegame:prefs:v2');
expect(rawV2).not.toBeNull();
expect(JSON.parse(rawV2 ?? 'null')).toEqual(migratedPrefs);
});
it('returns defaults and does not migrate if v1 prefs is invalid JSON', () => {
localStorage.setItem('idlegame:prefs:v1', '{invalid-json}');
const prefs = getPrefs();
expect(prefs).toEqual({
storyOpenMode: 'auto',
actionDetailMode: 'inline',
collapsedActionGroups: {},
showEventLog: true,
});
expect(localStorage.getItem('idlegame:prefs:v2')).toBeNull();
});
describe('expanded prefs', () => {
it('defaults collapsedActionGroups and showEventLog', () => {
const prefs = getPrefs();
expect(prefs.collapsedActionGroups).toEqual({});
expect(prefs.showEventLog).toBe(true);
});
});
});
+312
View File
@@ -0,0 +1,312 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { content } from '../../content';
import type { GameState } from '../../engine/game';
import { RECIPE_HEADER_V1 } from '../../engine/recipe';
import { getPrefs } from '../prefs';
import { GameRuntime } from '../runtime';
import { useGameStore } from '../store';
function runtimeState(runtime: GameRuntime): GameState {
return (runtime as unknown as { state: GameState }).state;
}
describe('GameRuntime', () => {
let runtime: GameRuntime;
beforeEach(async () => {
// Stub localStorage
const storage: Record<string, string> = {};
vi.stubGlobal('localStorage', {
getItem(key: string) {
return storage[key] ?? null;
},
setItem(key: string, value: string) {
storage[key] = value;
},
removeItem(key: string) {
delete storage[key];
},
clear() {
for (const k of Object.keys(storage)) {
delete storage[k];
}
},
});
// Stub requestAnimationFrame
vi.stubGlobal('requestAnimationFrame', vi.fn().mockReturnValue(1));
vi.stubGlobal('cancelAnimationFrame', vi.fn());
vi.stubGlobal('indexedDB', undefined);
// Stub visibilityState and document.addEventListener
vi.stubGlobal('document', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
visibilityState: 'visible',
});
// Stub window.addEventListener
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
// Setup clean store
useGameStore.setState({
log: [],
storyHasUnread: false,
storyLog: [],
prefs: getPrefs(),
activePanel: 'play',
});
runtime = new GameRuntime();
// Boot the runtime to initialize state
await runtime.boot();
});
afterEach(() => {
runtime.stop();
vi.unstubAllGlobals();
});
it('performs timed action', () => {
runtime.performAction('gather_supplies');
const store = useGameStore.getState();
expect(store.log).toContain('Started: Gather supplies.');
});
it('performs loop action', () => {
// Loop actions toggle enable state
runtime.performAction('rest');
const view = useGameStore.getState();
// Verify it is enabled
expect(view.actions.find((a) => a.id === 'rest')?.loopEnabled).toBe(true);
});
it('toggles an unlocked action in and out of the automation queue', () => {
const state = runtimeState(runtime);
state.manualCompletionCounts.gather_supplies = 1;
runtime.toggleAutomation('gather_supplies');
expect(useGameStore.getState().automationQueueIds).toEqual(['gather_supplies']);
runtime.toggleAutomation('gather_supplies');
expect(useGameStore.getState().automationQueueIds).toEqual([]);
});
it('logs automation toggle errors for locked actions', () => {
runtime.toggleAutomation('gather_supplies');
expect(useGameStore.getState().log.at(-1)).toMatch(/automation.*locked/i);
});
it('exports the automation recipe text', () => {
const state = runtimeState(runtime);
state.manualCompletionCounts.gather_supplies = 1;
state.automationQueue = ['gather_supplies'];
const text = runtime.exportAutomationRecipe();
expect(text).toContain(RECIPE_HEADER_V1);
expect(text).toContain('gather_supplies');
});
it('imports an automation recipe and publishes the queue', () => {
const state = runtimeState(runtime);
state.manualCompletionCounts.gather_supplies = 1;
runtime.importAutomationRecipe(`${RECIPE_HEADER_V1}:gather_supplies`);
expect(useGameStore.getState().automationQueueIds).toEqual(['gather_supplies']);
expect(useGameStore.getState().log.at(-1)).toMatch(/imported recipe/i);
});
it('performs story action and appends story log', () => {
// Let's first move state to fork_choice node where story choices are available
runtime.continueStory();
// Pick the high road
runtime.performAction('pick_high_road');
const store = useGameStore.getState();
// The story log should have the node we entered: route_a_beat
expect(store.storyLog.some((entry) => entry.nodeId === 'route_a_beat')).toBe(true);
// Should append choice label to normal log
expect(store.log).toContain('Story: Take the high road');
});
it('performs story action and appends custom log outcomes', () => {
// Save original fork_choice node
const originalNode = content.storyNodesById.fork_choice;
if (!originalNode) {
throw new Error('fork_choice node not found in content');
}
const originalNodes = [...content.storyNodes];
// Create a modified fork_choice node with a log outcome on pick_a
const choices = originalNode.choices ?? [];
const firstChoice = choices[0];
if (!firstChoice) {
throw new Error('first choice not found on fork_choice');
}
const modifiedChoice = {
...firstChoice,
outcomes: [
...(firstChoice.outcomes ?? []),
{ type: 'log' as const, text: 'Custom log from story action!' },
],
};
const modifiedNode = {
...originalNode,
choices: [modifiedChoice, ...choices.slice(1)],
};
// Mutate content
content.storyNodesById.fork_choice = modifiedNode;
content.storyNodes = content.storyNodes.map((n) => (n.id === 'fork_choice' ? modifiedNode : n));
try {
// Move to fork_choice
runtime.continueStory();
// Clear logs to check cleanly
useGameStore.setState({ log: [], storyLog: [] });
// Perform the story action
runtime.performAction('pick_high_road');
const store = useGameStore.getState();
expect(store.log).toContain('Custom log from story action!');
} finally {
// Restore content
content.storyNodesById.fork_choice = originalNode;
content.storyNodes = originalNodes;
}
});
it('setActivePanel does not auto-advance boot_intro to fork_choice', () => {
// Initially we boot into boot_intro.
// If we call setActivePanel('story'), it should NOT trigger the auto-advance logic
// from 'boot_intro' to 'fork_choice'.
runtime.setActivePanel('story');
const store = useGameStore.getState();
expect(store.storyLog.some((entry) => entry.nodeId === 'fork_choice')).toBe(false);
});
it('continueStory() advances boot_intro to fork_choice and opens panel when storyOpenMode is auto', () => {
const store = useGameStore.getState();
store.setPrefs({ ...store.prefs, storyOpenMode: 'auto' });
// Force play panel and closed/no unread story
store.setActivePanel('play');
store.setStoryHasUnread(false);
runtime.continueStory();
const updated = useGameStore.getState();
// Verifies it advances to fork_choice
expect(updated.storyLog.some((entry) => entry.nodeId === 'fork_choice')).toBe(true);
// Verifies it opens panel and does not set unread (since it's open)
expect(updated.activePanel).toBe('story');
expect(updated.storyHasUnread).toBe(false);
});
it('continueStory() advances boot_intro to fork_choice and sets unread when storyOpenMode is manual', () => {
const store = useGameStore.getState();
store.setPrefs({ ...store.prefs, storyOpenMode: 'manual' });
// Force play panel and closed/no unread story
store.setActivePanel('play');
store.setStoryHasUnread(false);
runtime.continueStory();
const updated = useGameStore.getState();
// Verifies it advances to fork_choice
expect(updated.storyLog.some((entry) => entry.nodeId === 'fork_choice')).toBe(true);
// Verifies it does NOT open panel and sets unread flag to true
expect(updated.activePanel).toBe('play');
expect(updated.storyHasUnread).toBe(true);
});
it('applies story choice with an action mapping and processes log outcomes', () => {
// Move to fork_choice
runtime.continueStory();
// Apply choice 'pick_a' (which maps to 'pick_high_road' action)
runtime.applyStoryChoice('pick_a');
const store = useGameStore.getState();
expect(store.storyLog.some((entry) => entry.nodeId === 'route_a_beat')).toBe(true);
expect(store.log).toContain('Story: Take the high road');
});
it('applies story choice without an action mapping and appends custom log outcomes', () => {
// Save original fork_choice node
const originalNode = content.storyNodesById.fork_choice;
if (!originalNode) {
throw new Error('fork_choice node not found in content');
}
const originalNodes = [...content.storyNodes];
// Create a modified fork_choice node with a custom choice that has a 'log' outcome
const customChoice = {
id: 'custom_choice_no_action',
label: 'Perform custom choice',
outcomes: [{ type: 'log' as const, text: 'This is a custom log outcome!' }],
targetNodeId: 'route_a_beat',
};
const modifiedNode = {
...originalNode,
choices: [...(originalNode.choices ?? []), customChoice],
};
// Mutate content
content.storyNodesById.fork_choice = modifiedNode;
content.storyNodes = content.storyNodes.map((n) => (n.id === 'fork_choice' ? modifiedNode : n));
try {
// Move to fork_choice
runtime.continueStory();
// Clear logs to check cleanly
useGameStore.setState({ log: [], storyLog: [] });
// Apply choice
runtime.applyStoryChoice('custom_choice_no_action');
const store = useGameStore.getState();
expect(store.log).toContain('This is a custom log outcome!');
expect(store.storyLog.some((entry) => entry.nodeId === 'route_a_beat')).toBe(true);
} finally {
// Restore content
content.storyNodesById.fork_choice = originalNode;
content.storyNodes = originalNodes;
}
});
it('registers lifecycle listeners on boot and removes them on stop', () => {
const addSpyDoc = vi.spyOn(document, 'addEventListener');
const removeSpyDoc = vi.spyOn(document, 'removeEventListener');
const addSpyWin = vi.spyOn(window, 'addEventListener');
const removeSpyWin = vi.spyOn(window, 'removeEventListener');
const testRuntime = new GameRuntime();
testRuntime.boot();
expect(addSpyDoc).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
expect(addSpyWin).toHaveBeenCalledWith('beforeunload', expect.any(Function));
testRuntime.stop();
expect(removeSpyDoc).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
expect(removeSpyWin).toHaveBeenCalledWith('beforeunload', expect.any(Function));
});
});
+55
View File
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getPrefs } from '../prefs';
import { useGameStore } from '../store';
describe('game store nav and prefs', () => {
beforeEach(() => {
const store: Record<string, string> = {};
vi.stubGlobal('localStorage', {
getItem(key: string) {
return store[key] ?? null;
},
setItem(key: string, value: string) {
store[key] = value;
},
});
useGameStore.setState({
activePanel: 'play',
selectedStoryNodeId: null,
prefs: getPrefs(),
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('has default activePanel and selectedStoryNodeId', () => {
const state = useGameStore.getState();
expect(state.activePanel).toBe('play');
expect(state.selectedStoryNodeId).toBeNull();
});
it('updates activePanel via setActivePanel', () => {
const state = useGameStore.getState();
state.setActivePanel('story');
expect(useGameStore.getState().activePanel).toBe('story');
});
it('updates selectedStoryNodeId via setSelectedStoryNodeId', () => {
const state = useGameStore.getState();
state.setSelectedStoryNodeId('node-1');
expect(useGameStore.getState().selectedStoryNodeId).toBe('node-1');
});
it('toggles collapsed action groups in prefs', () => {
const state = useGameStore.getState();
expect(state.prefs.collapsedActionGroups['skills:gather']).toBeUndefined();
state.toggleActionGroupCollapsed('skills:gather');
expect(useGameStore.getState().prefs.collapsedActionGroups['skills:gather']).toBe(true);
useGameStore.getState().toggleActionGroupCollapsed('skills:gather');
expect(useGameStore.getState().prefs.collapsedActionGroups['skills:gather']).toBe(false);
});
});
+179 -3
View File
@@ -1,10 +1,13 @@
import { describe, expect, it } from 'vitest';
import { content } from '../../content/index';
import { buildContent } from '../../content/schema';
import { buildStoryContent } from '../../content/storySchema';
import { createGameState, enqueueAction, startAction } from '../../engine/game';
import { createGameState, enqueueAction, performAction, startAction } from '../../engine/game';
import { enterStoryNode } from '../../engine/story';
import { formatOfflineDuration, toView } from '../viewModel';
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
function testContent() {
const base = buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }],
@@ -12,6 +15,7 @@ function testContent() {
{
id: 'forage',
name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 200,
yields: [{ resourceId: 'gold', amount: 1 }],
},
@@ -87,8 +91,20 @@ describe('toView()', () => {
const content = contentWithActions(
[{ id: 'gold', name: 'Gold' }],
[
{ id: 'a', name: 'Alpha', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] },
{ id: 'b', name: 'Bravo', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] },
{
id: 'a',
name: 'Alpha',
group: DEFAULT_GROUP,
durationMs: 1000,
yields: [{ resourceId: 'gold', amount: 1 }],
},
{
id: 'b',
name: 'Bravo',
group: DEFAULT_GROUP,
durationMs: 1000,
yields: [{ resourceId: 'gold', amount: 1 }],
},
],
);
const state = createGameState(content);
@@ -108,6 +124,7 @@ describe('toView() action availability', () => {
{
id: 'locked',
name: 'Locked',
group: DEFAULT_GROUP,
durationMs: 1000,
yields: [{ resourceId: 'coin', amount: 1 }],
unlock: { requireStoryFlags: ['route_a'] },
@@ -127,6 +144,7 @@ describe('toView() action availability', () => {
{
id: 'forage',
name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 1000,
yields: [{ resourceId: 'coin', amount: 1 }],
},
@@ -188,3 +206,161 @@ describe('formatOfflineDuration()', () => {
expect(formatOfflineDuration(7_200_000)).toBe('2h');
});
});
describe('action columns projection', () => {
it('produces all five kind columns in fixed order', () => {
const state = createGameState(content);
const view = toView(state, content);
expect(view.actionColumns.map((c) => c.kind)).toEqual([
'instant',
'loop',
'timed',
'story',
'context',
]);
});
it('groups timed actions by their content group', () => {
const state = createGameState(content);
const view = toView(state, content);
const timed = view.actionColumns.find((c) => c.kind === 'timed');
expect(timed?.groups.some((g) => g.id === 'camp')).toBe(true);
expect(timed?.groups.some((g) => g.id === 'travel')).toBe(true);
});
it('marks loopEnabled from enabledLoopActionIds', () => {
const state = createGameState(content);
state.enabledLoopActionIds = { rest: true };
const view = toView(state, content);
const rest = view.actionColumns
.flatMap((c) => c.groups)
.flatMap((g) => g.actions)
.find((a) => a.id === 'rest');
expect(rest?.loopEnabled).toBe(true);
});
it('marks automationUnlocked on actions after manual completion', () => {
const state = createGameState(content);
state.manualCompletionCounts.gather_supplies = 1;
const view = toView(state, content);
const gather = view.actionColumns
.flatMap((c) => c.groups)
.flatMap((g) => g.actions)
.find((a) => a.id === 'gather_supplies');
expect(gather?.automationUnlocked).toBe(true);
});
it('marks actions already in the automation queue', () => {
const state = createGameState(content);
state.automationQueue = ['gather_supplies'];
const view = toView(state, content);
const gather = view.actionColumns
.flatMap((c) => c.groups)
.flatMap((g) => g.actions)
.find((a) => a.id === 'gather_supplies');
expect(gather?.inAutomationQueue).toBe(true);
});
it('projects automation queue ids and display names', () => {
const state = createGameState(content);
state.automationQueue = ['gather_supplies', 'missing_action'];
const view = toView(state, content);
expect(view.automationQueueIds).toEqual(['gather_supplies', 'missing_action']);
expect(view.automationQueueNames).toEqual(['Gather supplies', 'missing_action']);
});
it('shows story actions only when their choice is available, hiding siblings after a fork is taken', () => {
const state = createGameState(content);
// before reaching the fork, story actions are hidden
let storyCol = toView(state, content).actionColumns.find((c) => c.kind === 'story');
expect(storyCol?.groups.flatMap((g) => g.actions)).toHaveLength(0);
// at the fork, both story actions appear
enterStoryNode(state, content, 'fork_choice');
storyCol = toView(state, content).actionColumns.find((c) => c.kind === 'story');
const idsAtFork = storyCol?.groups.flatMap((g) => g.actions).map((a) => a.id) ?? [];
expect(idsAtFork).toEqual(expect.arrayContaining(['pick_high_road', 'follow_river']));
// after taking route A, the sibling hides
performAction(state, content, 'pick_high_road');
storyCol = toView(state, content).actionColumns.find((c) => c.kind === 'story');
expect(storyCol?.groups.flatMap((g) => g.actions)).toHaveLength(0);
});
});
describe('story.atBootIntro', () => {
it('is true at the boot-entry node and false after entering a different node', () => {
const base = buildContent({
resources: [{ id: 'coin', name: 'Coin', startAmount: 0 }],
actions: [
{
id: 'forage',
name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 1000,
yields: [{ resourceId: 'coin', amount: 1 }],
},
],
});
const story = buildStoryContent(
[
{
id: 'boot_intro',
prose: 'Boot.',
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: 'route_a_beat', prose: 'The high road.' },
],
base.actionsById,
base.resourcesById,
);
const c = { ...base, ...story };
const state = createGameState(c);
// Simulate boot: enter the boot-entry node
enterStoryNode(state, c, 'boot_intro');
expect(toView(state, c).story.atBootIntro).toBe(true);
// After moving past boot into fork_choice, atBootIntro must be false
enterStoryNode(state, c, 'fork_choice');
expect(toView(state, c).story.atBootIntro).toBe(false);
});
});
describe('story tree projection', () => {
it('builds a tree marking seen and active nodes', () => {
const state = createGameState(content);
enterStoryNode(state, content, 'fork_choice');
const view = toView(state, content);
// fork_choice should be a node in the tree, marked active+seen, with route children
const findNode = (
nodes: typeof view.story.tree,
id: string,
): (typeof nodes)[number] | undefined => {
for (const n of nodes) {
if (n.id === id) return n;
const deeper = findNode(n.children, id);
if (deeper) return deeper;
}
return undefined;
};
const fork = findNode(view.story.tree, 'fork_choice');
expect(fork).toBeDefined();
expect(fork?.active).toBe(true);
expect(fork?.seen).toBe(true);
expect(fork?.children.map((c) => c.id)).toEqual(
expect.arrayContaining(['route_a_beat', 'route_b_beat']),
);
});
});
+3
View File
@@ -106,6 +106,9 @@ export async function loadGame(
storyFlags: { ...save.state.storyFlags },
currentStoryNodeId: save.state.currentStoryNodeId ?? '',
seenStoryNodeIds: [...(save.state.seenStoryNodeIds ?? [])],
enabledLoopActionIds: { ...(save.state.enabledLoopActionIds ?? {}) },
manualCompletionCounts: { ...(save.state.manualCompletionCounts ?? {}) },
automationQueue: [...(save.state.automationQueue ?? [])],
};
savedAt = save.savedAt;
} catch {
+19 -2
View File
@@ -1,4 +1,4 @@
const PREFS_KEY = 'idlegame:prefs:v1';
const PREFS_KEY = 'idlegame:prefs:v2';
export type StoryOpenMode = 'auto' | 'choices-only' | 'manual';
export type ActionDetailMode = 'inline' | 'hover' | 'info-button';
@@ -6,18 +6,35 @@ export type ActionDetailMode = 'inline' | 'hover' | 'info-button';
export interface GamePrefs {
storyOpenMode: StoryOpenMode;
actionDetailMode: ActionDetailMode;
collapsedActionGroups: Record<string, boolean>;
showEventLog: boolean;
}
const DEFAULTS: GamePrefs = {
storyOpenMode: 'auto',
actionDetailMode: 'inline',
collapsedActionGroups: {},
showEventLog: true,
};
export function getPrefs(): GamePrefs {
if (typeof localStorage === 'undefined') return { ...DEFAULTS };
try {
const raw = localStorage.getItem(PREFS_KEY);
if (!raw) return { ...DEFAULTS };
if (!raw) {
const rawV1 = localStorage.getItem('idlegame:prefs:v1');
if (rawV1) {
try {
const parsedV1 = JSON.parse(rawV1);
const migrated = { ...DEFAULTS, ...parsedV1 };
localStorage.setItem(PREFS_KEY, JSON.stringify(migrated));
return migrated;
} catch {
return { ...DEFAULTS };
}
}
return { ...DEFAULTS };
}
return { ...DEFAULTS, ...JSON.parse(raw) };
} catch {
return { ...DEFAULTS };
+159 -61
View File
@@ -1,20 +1,26 @@
import { content } from '../content';
import {
addToAutomationQueue,
maybeRunAutomation,
removeFromAutomationQueue,
} from '../engine/automation';
import {
cancelQueuedAction as engineCancelQueuedAction,
enqueueAction as engineEnqueueAction,
type GameState,
isActionAvailable,
maybeStartLoopAction,
performAction as performActionEngine,
tickGame,
} from '../engine/game';
import { exportRecipe, importRecipe } from '../engine/recipe';
import { applyChoice as engineApplyChoice, enterStoryNode, initStory } from '../engine/story';
import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop';
import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence';
import { getPrefs } from './prefs';
import { useGameStore } from './store';
import { type ActivePanel, useGameStore } from './store';
import {
processStoryTriggers,
type StoryUiEffect,
shouldAutoOpenPanel,
shouldAutoNavigateToStory,
storyEventsToLogEntries,
} from './storyOrchestration';
import { formatOfflineDuration, toView } from './viewModel';
@@ -32,7 +38,7 @@ import { formatOfflineDuration, toView } from './viewModel';
const PUBLISH_INTERVAL_MS = 100; // ~10 fps view refresh
const AUTOSAVE_INTERVAL_MS = 10_000;
class GameRuntime {
export class GameRuntime {
private state: GameState | null = null;
private readonly loop: TickLoop = createTickLoop();
private readonly backend: SaveBackend = createDefaultBackend();
@@ -41,6 +47,16 @@ class GameRuntime {
private lastSaveAt = 0;
private booted = false;
private readonly visibilityChangeListener = (): void => {
if (document.visibilityState === 'hidden') {
void this.save();
}
};
private readonly beforeUnloadListener = (): void => {
void this.save();
};
async boot(): Promise<void> {
if (this.booted) {
return;
@@ -75,47 +91,131 @@ class GameRuntime {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
document.removeEventListener('visibilitychange', this.visibilityChangeListener);
window.removeEventListener('beforeunload', this.beforeUnloadListener);
this.booted = false;
}
performAction(actionId: string): void {
const state = this.state;
if (!state) return;
const action = content.actionsById[actionId];
if (!action) return;
try {
const isStory = action.kind === 'story';
const events = performActionEngine(state, content, actionId);
const store = useGameStore.getState();
// Append any custom log outcomes
for (const event of events.filter((e) => e.kind === 'log')) {
store.appendLog(event.prose);
}
if (isStory) {
const entries = storyEventsToLogEntries(events);
for (const entry of entries) {
store.appendStoryLog(entry);
}
const choiceLabel = entries.at(-1)?.choiceLabel;
if (choiceLabel) {
store.appendLog(`Story: ${choiceLabel}`);
}
this.runPublishTriggers();
} else if (action.kind === 'timed') {
const verb = state.actionQueue.includes(actionId) ? 'Queued' : 'Started';
store.appendLog(`${verb}: ${action.name}.`);
}
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);
}
}
selectStoryNode(id: string): void {
useGameStore.getState().setSelectedStoryNodeId(id);
}
toggleActionGroupCollapsed(groupKey: string): void {
useGameStore.getState().toggleActionGroupCollapsed(groupKey);
}
enqueueAction(actionId: string): void {
const state = this.state;
if (!state) {
return;
}
if (!isActionAvailable(state, content, actionId)) {
const actionView = toView(state, content).actions.find((a) => a.id === actionId);
useGameStore.getState().appendLog(actionView?.disabledReason ?? 'Cannot enqueue');
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();
this.performAction(actionId);
}
applyStoryChoice(choiceId: string): void {
toggleAutomation(actionId: 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();
const existingIndex = state.automationQueue.indexOf(actionId);
if (existingIndex >= 0) {
removeFromAutomationQueue(state, existingIndex);
} else {
addToAutomationQueue(state, content, actionId);
maybeRunAutomation(state, content);
}
this.publish();
} catch (err) {
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Choice failed');
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);
maybeRunAutomation(state, content);
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');
}
}
applyStoryChoice(choiceId: string): void {
const action = content.actions.find((a) => a.storyChoiceId === choiceId);
if (action) {
this.performAction(action.id);
} else {
const state = this.state;
if (!state) return;
try {
const events = engineApplyChoice(state, content, choiceId);
const entries = storyEventsToLogEntries(events);
const store = useGameStore.getState();
// Append any custom log outcomes
for (const event of events.filter((e) => e.kind === 'log')) {
store.appendLog(event.prose);
}
for (const entry of entries) store.appendStoryLog(entry);
const choiceLabel = entries.at(-1)?.choiceLabel;
if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`);
this.runPublishTriggers();
this.publish();
} catch (err) {
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Choice failed');
}
}
}
@@ -131,15 +231,6 @@ class GameRuntime {
}
}
openStoryPanel(): void {
useGameStore.getState().setStoryPanelOpen(true);
useGameStore.getState().setStoryHasUnread(false);
}
closeStoryPanel(): void {
useGameStore.getState().setStoryPanelOpen(false);
}
continueStory(): void {
const state = this.state;
if (!state) return;
@@ -149,30 +240,36 @@ class GameRuntime {
const store = useGameStore.getState();
for (const entry of entries) store.appendStoryLog(entry);
for (const entry of entries) {
store.appendLog(`Story: ${content.storyNodesById[entry.nodeId]?.prose.slice(0, 40)}`);
store.appendLog(
`Story: ${(content.storyNodesById[entry.nodeId]?.prose ?? '').slice(0, 40)}`,
);
}
const prefs = store.prefs;
if (shouldAutoOpenPanel(prefs, 'fork_choice', content)) {
store.setStoryPanelOpen(true);
store.setStoryHasUnread(false);
if (shouldAutoNavigateToStory(prefs, 'fork_choice', content)) {
this.setActivePanel('story');
} else {
store.setStoryHasUnread(true);
store.setStoryPanelOpen(false);
}
this.publish();
return;
}
this.closeStoryPanel();
this.setActivePanel('play');
this.publish();
}
private applyStoryUiEffect(effect: StoryUiEffect): void {
const store = useGameStore.getState();
const prefs = store.prefs;
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);
if (prefs.storyOpenMode === 'auto') {
store.setActivePanel('story');
store.setStoryHasUnread(false);
} else {
store.setStoryHasUnread(true);
}
} else if (effect.enteredNodeIds.length > 0) {
store.setStoryHasUnread(true);
}
@@ -201,6 +298,7 @@ class GameRuntime {
);
this.applyStoryUiEffect(effect);
}
maybeStartLoopAction(state, content);
});
if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) {
@@ -234,15 +332,15 @@ class GameRuntime {
}
private installLifecycleHooks(): void {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
void this.save();
}
});
window.addEventListener('beforeunload', () => {
void this.save();
});
document.addEventListener('visibilitychange', this.visibilityChangeListener);
window.addEventListener('beforeunload', this.beforeUnloadListener);
}
}
export const gameRuntime = new GameRuntime();
if (import.meta.hot) {
import.meta.hot.dispose(() => {
gameRuntime.stop();
});
}
+25 -10
View File
@@ -16,45 +16,60 @@ export interface StoryLogEntry {
choiceLabel?: string;
}
export type ActivePanel = 'play' | 'story' | 'settings' | 'about';
export interface GameStoreState extends GameView {
log: string[];
storyPanelOpen: boolean;
storyHasUnread: boolean;
storyLog: StoryLogEntry[];
prefs: GamePrefs;
settingsOpen: boolean;
activePanel: ActivePanel;
selectedStoryNodeId: string | null;
setView: (view: GameView) => void;
appendLog: (line: string) => void;
appendStoryLog: (entry: StoryLogEntry) => void;
setStoryPanelOpen: (open: boolean) => void;
setStoryHasUnread: (unread: boolean) => void;
setPrefs: (partial: Partial<GamePrefs>) => void;
setSettingsOpen: (open: boolean) => void;
setActivePanel: (panel: ActivePanel) => void;
setSelectedStoryNodeId: (id: string | null) => void;
toggleActionGroupCollapsed: (groupKey: string) => void;
}
export const useGameStore = create<GameStoreState>((set) => ({
export const useGameStore = create<GameStoreState>((set, get) => ({
resources: [],
activeActionId: null,
actionName: null,
actionProgress: 0,
queuedActionIds: [],
queuedActionNames: [],
automationQueueIds: [],
automationQueueNames: [],
actions: [],
story: { currentProse: null, choices: [] },
story: { currentProse: null, atBootIntro: false, choices: [], tree: [] },
actionColumns: [],
log: [],
storyPanelOpen: false,
storyHasUnread: false,
storyLog: [],
prefs: getPrefs(),
settingsOpen: false,
activePanel: 'play',
selectedStoryNodeId: null,
setView: (view) => set((state) => ({ ...state, ...view })),
appendLog: (line) => set((state) => ({ log: [...state.log, line].slice(-MAX_LOG_LINES) })),
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 });
},
setSettingsOpen: (open) => set({ settingsOpen: open }),
setActivePanel: (panel) => set({ activePanel: panel }),
setSelectedStoryNodeId: (id) => set({ selectedStoryNodeId: id }),
toggleActionGroupCollapsed: (groupKey) => {
const currentPrefs = get().prefs;
const nextCollapsed = {
...currentPrefs.collapsedActionGroups,
[groupKey]: !currentPrefs.collapsedActionGroups[groupKey],
};
const prefs = persistPrefs({ collapsedActionGroups: nextCollapsed });
set({ prefs });
},
}));
+11 -7
View File
@@ -17,7 +17,7 @@ export function storyEventsToLogEntries(events: StoryEvent[]): StoryLogEntry[] {
.map((e) => ({ nodeId: e.nodeId, prose: e.prose, choiceLabel: e.choiceLabel }));
}
export function shouldAutoOpenPanel(
export function shouldAutoNavigateToStory(
prefs: GamePrefs,
nodeId: string,
content: GameContent,
@@ -39,11 +39,15 @@ export function processStoryTriggers(
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)}`,
);
enteredNodeIds.some((id) => shouldAutoNavigateToStory(prefs, id, content));
const customLogs = events.filter((e) => e.kind === 'log').map((e) => e.prose);
const eventLogLines = [
...customLogs,
...logEntries.map((e) =>
e.choiceLabel
? `Story: ${e.choiceLabel}`
: `Story: ${(content.storyNodesById[e.nodeId]?.prose ?? '').slice(0, 40)}`,
),
];
return { enteredNodeIds, logEntries, shouldOpenPanel, eventLogLines };
}
+148 -12
View File
@@ -1,11 +1,12 @@
import type { GameContent } from '../content/index';
import { isAutomationUnlocked } from '../engine/automation';
import {
canAffordAction,
canUnlockAction,
type GameState,
isActionAvailable,
} from '../engine/game';
import { getAvailableChoices, getCurrentNode } from '../engine/story';
import { getAvailableChoices, getCurrentNode, isStoryChoiceAvailable } from '../engine/story';
/**
* Pure mapping from engine state to the view model the React shell renders.
@@ -18,6 +19,17 @@ export interface ResourceView {
amount: number;
}
export const ACTION_COLUMN_ORDER = ['instant', 'loop', 'timed', 'story', 'context'] as const;
export type ActionColumnKind = (typeof ACTION_COLUMN_ORDER)[number];
const COLUMN_LABELS: Record<ActionColumnKind, string> = {
instant: 'Instant',
loop: 'Loop',
timed: 'Timed',
story: 'Story',
context: 'Context',
};
export interface ActionView {
id: string;
name: string;
@@ -27,6 +39,22 @@ export interface ActionView {
storyTooltip?: string;
costsSummary: string | null;
yieldsSummary: string | null;
kind: ActionColumnKind;
loopEnabled: boolean;
automationUnlocked: boolean;
inAutomationQueue: boolean;
}
export interface ActionGroupView {
id: string;
label: string;
actions: ActionView[];
}
export interface ActionColumnView {
kind: ActionColumnKind;
label: string;
groups: ActionGroupView[];
}
export interface StoryChoiceView {
@@ -36,9 +64,19 @@ export interface StoryChoiceView {
disabledReason: string | null;
}
export interface StoryTreeNodeView {
id: string;
label: string;
seen: boolean;
active: boolean;
children: StoryTreeNodeView[];
}
export interface StoryView {
currentProse: string | null;
atBootIntro: boolean;
choices: StoryChoiceView[];
tree: StoryTreeNodeView[];
}
export interface GameView {
@@ -49,8 +87,11 @@ export interface GameView {
actionProgress: number;
queuedActionIds: string[];
queuedActionNames: string[];
automationQueueIds: string[];
automationQueueNames: string[];
actions: ActionView[];
story: StoryView;
actionColumns: ActionColumnView[];
}
function actionDisabledReason(
@@ -72,6 +113,38 @@ function formatResourceList(
.join(', ');
}
function buildStoryTree(state: GameState, content: GameContent): StoryTreeNodeView[] {
// Edges come from choice targets and trigger targets, skipping self-edges.
const childIds = new Set<string>();
const childrenOf = new Map<string, string[]>();
for (const node of content.storyNodes) {
const targets: string[] = [];
for (const choice of node.choices ?? []) {
if (choice.targetNodeId !== node.id) targets.push(choice.targetNodeId);
}
for (const trigger of node.triggers ?? []) {
if (trigger.targetNodeId !== node.id) targets.push(trigger.targetNodeId);
}
childrenOf.set(node.id, targets);
for (const t of targets) childIds.add(t);
}
const build = (id: string, seenOnPath: Set<string>): StoryTreeNodeView => {
const children = seenOnPath.has(id)
? []
: (childrenOf.get(id) ?? []).map((c) => build(c, new Set(seenOnPath).add(id)));
return {
id,
label: id, // minimal label per spec (T3.0 ships a minimal tree)
seen: state.seenStoryNodeIds.includes(id),
active: state.currentStoryNodeId === id,
children,
};
};
return content.storyNodes
.filter((n) => !childIds.has(n.id))
.map((n) => build(n.id, new Set<string>()));
}
export function toView(state: GameState, content: GameContent): GameView {
const resources: ResourceView[] = content.resources.map((resource) => ({
id: resource.id,
@@ -80,27 +153,86 @@ export function toView(state: GameState, content: GameContent): GameView {
}));
const action = state.activeActionId ? content.actionsById[state.activeActionId] : undefined;
const actionProgress = action ? Math.min(1, state.actionElapsedMs / action.durationMs) : 0;
const actionProgress = action?.durationMs
? Math.min(1, state.actionElapsedMs / action.durationMs)
: 0;
const queuedActionIds = [...state.actionQueue];
const queuedActionNames = queuedActionIds.map((id) => content.actionsById[id]?.name ?? id);
const automationQueueIds = [...state.automationQueue];
const automationQueueNames = automationQueueIds.map((id) => content.actionsById[id]?.name ?? id);
const actions: ActionView[] = content.actions.map((a) => ({
id: a.id,
name: a.name,
available: isActionAvailable(state, content, a.id),
disabledReason: actionDisabledReason(state, content, a.id),
storyHint: a.storyHint,
storyTooltip: a.storyTooltip,
costsSummary: a.costs.length ? formatResourceList(a.costs, content) : null,
yieldsSummary: formatResourceList(a.yields, content),
}));
// Build a map of ActionView by id for column assembly
const actionViewMap = new Map<string, ActionView>();
const actions: ActionView[] = content.actions.map((a) => {
const isStory = a.kind === 'story';
const available = isStory
? isStoryChoiceAvailable(state, content, a.storyChoiceId ?? '')
: isActionAvailable(state, content, a.id);
const view: ActionView = {
id: a.id,
name: a.name,
available,
disabledReason: isStory ? null : actionDisabledReason(state, content, a.id),
storyHint: a.storyHint,
storyTooltip: a.storyTooltip,
costsSummary: a.costs.length ? formatResourceList(a.costs, content) : null,
yieldsSummary: formatResourceList(a.yields, content),
kind: a.kind,
loopEnabled: !!state.enabledLoopActionIds[a.id],
automationUnlocked: isAutomationUnlocked(state, content, a.id),
inAutomationQueue: state.automationQueue.includes(a.id),
};
actionViewMap.set(a.id, view);
return view;
});
// Build action columns: one per kind in fixed order
const actionColumns: ActionColumnView[] = ACTION_COLUMN_ORDER.map((kind) => {
// Collect actions of this kind
const kindActions = content.actions.filter((a) => a.kind === kind);
// For story kind: only include available actions (hides siblings after fork)
const includedActions =
kind === 'story'
? kindActions.filter((a) => actionViewMap.get(a.id)?.available === true)
: kindActions;
// Group by action.group, preserving first-seen order
const groupOrder: string[] = [];
const groupMap = new Map<string, { id: string; label: string; actions: ActionView[] }>();
for (const a of includedActions) {
const view = actionViewMap.get(a.id);
if (!view) continue;
if (!groupMap.has(a.group.id)) {
groupOrder.push(a.group.id);
groupMap.set(a.group.id, { id: a.group.id, label: a.group.label, actions: [] });
}
groupMap.get(a.group.id)?.actions.push(view);
}
const groups: ActionGroupView[] = groupOrder
.map((gid) => groupMap.get(gid))
.filter((g): g is ActionGroupView => g !== undefined);
return {
kind,
label: COLUMN_LABELS[kind],
groups,
};
});
const node = getCurrentNode(state, content);
const availableChoices = getAvailableChoices(state, content);
const allChoices = node?.choices ?? [];
const bootEntryNodeId = content.storyNodes.find((n) =>
n.triggers?.some((t) => t.type === 'boot'),
)?.id;
const atBootIntro = node != null && node.id === bootEntryNodeId;
const story: StoryView = {
currentProse: node?.prose ?? null,
atBootIntro,
choices: allChoices.map((choice) => {
const available = availableChoices.some((c) => c.id === choice.id);
return {
@@ -110,6 +242,7 @@ export function toView(state: GameState, content: GameContent): GameView {
disabledReason: available ? null : 'Requirements not met',
};
}),
tree: buildStoryTree(state, content),
};
return {
@@ -119,8 +252,11 @@ export function toView(state: GameState, content: GameContent): GameView {
actionProgress,
queuedActionIds,
queuedActionNames,
automationQueueIds,
automationQueueNames,
actions,
story,
actionColumns,
};
}
+53
View File
@@ -0,0 +1,53 @@
export function AboutPanel() {
return (
<div className="max-w-2xl space-y-6">
<div>
<h2 className="font-bold text-slate-100 text-xl tracking-tight">About Idlegame</h2>
<p className="text-slate-400 text-sm">A text-fantasy RPG incremental experience.</p>
</div>
<div className="space-y-6 text-slate-300">
<section className="space-y-2">
<h3 className="font-semibold text-slate-200 text-sm tracking-wide uppercase">
How to Play
</h3>
<p className="text-sm leading-relaxed text-slate-400">
Idlegame is driven by actions and choices. Select actions from the{' '}
<strong className="text-amber-400">Play</strong> screen to execute them. Some actions
are timed and can be queued. Once completed, they reward you with resources, unlock new
deeds, or advance the chronicle.
</p>
</section>
<section className="space-y-2">
<h3 className="font-semibold text-slate-200 text-sm tracking-wide uppercase">
The Chronicle
</h3>
<p className="text-sm leading-relaxed text-slate-400">
As you perform actions, you will unlock narrative points of interest. Head to the{' '}
<strong className="text-amber-400">Story</strong> tab to make crucial choices and read
the history of your journey.
</p>
</section>
<section className="space-y-2">
<h3 className="font-semibold text-slate-200 text-sm tracking-wide uppercase">
Technical Specs
</h3>
<div className="rounded-xl border border-slate-800 bg-slate-900/40 p-4">
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
<dt className="text-slate-500">Version</dt>
<dd className="font-mono text-slate-300">0.1.0 (M1 Milestone)</dd>
<dt className="text-slate-500">Engine</dt>
<dd className="text-slate-300">Pure TypeScript State Machine</dd>
<dt className="text-slate-500">Framework</dt>
<dd className="text-slate-300">React + Zustand + Tailwind CSS</dd>
<dt className="text-slate-500">Target Platform</dt>
<dd className="text-slate-300">Responsive Web & PWA</dd>
</dl>
</div>
</section>
</div>
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
import { useEffect, useRef, useState } from 'react';
import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store';
import type { ActionView } from '../state/viewModel';
interface ActionCardProps {
action: ActionView;
}
export function ActionCard({ action }: ActionCardProps) {
const activeActionId = useGameStore((s) => s.activeActionId);
const actionProgress = useGameStore((s) => s.actionProgress);
const prefs = useGameStore((s) => s.prefs);
const [openInfoId, setOpenInfoId] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const isOpen = openInfoId === action.id;
useEffect(() => {
if (!isOpen) return;
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setOpenInfoId(null);
}
}
document.addEventListener('click', handleClickOutside);
return () => {
document.removeEventListener('click', handleClickOutside);
};
}, [isOpen]);
const isActive = action.id === activeActionId;
const isDisabled = !action.available && !isActive;
const summaryParts = [
action.costsSummary ? `Cost: ${action.costsSummary}` : null,
action.yieldsSummary ? `Yield: ${action.yieldsSummary}` : null,
].filter(Boolean);
let borderBgClass = '';
if (isDisabled) {
borderBgClass = 'border-slate-800 bg-slate-900/40 opacity-50 cursor-not-allowed';
} else {
borderBgClass =
'border-slate-700 bg-slate-800/70 hover:border-amber-500/60 hover:bg-slate-800 cursor-pointer';
if (action.kind === 'story') {
borderBgClass =
'border-amber-500/50 bg-slate-800/70 hover:border-amber-500/80 hover:bg-slate-800 cursor-pointer';
} else if (action.kind === 'loop' && action.loopEnabled) {
borderBgClass =
'border-amber-500 bg-amber-500/10 shadow-[0_0_8px_rgba(245,158,11,0.15)] hover:border-amber-400 hover:bg-amber-500/15 cursor-pointer';
}
}
const tooltipId = `tooltip-${action.id}`;
return (
<div ref={containerRef} className="relative flex gap-1 w-full">
<button
type="button"
disabled={isDisabled}
aria-pressed={action.kind === 'loop' ? action.loopEnabled : undefined}
title={prefs.actionDetailMode === 'hover' ? action.storyTooltip : undefined}
onClick={() => gameRuntime.performAction(action.id)}
className={`relative flex-1 overflow-hidden rounded-lg border px-4 py-3 text-left transition-all duration-200 ${borderBgClass}`}
>
{isActive ? (
<div
className="absolute inset-y-0 left-0 bg-amber-500/15 transition-all duration-100 ease-linear pointer-events-none"
style={{ width: `${Math.round(actionProgress * 100)}%` }}
aria-hidden="true"
/>
) : null}
<div className="relative flex flex-col gap-1 w-full">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-slate-100 flex items-center gap-2">
{action.kind === 'loop' && (
<span
className={`h-4 w-4 rounded border flex items-center justify-center transition-colors shrink-0 ${
action.loopEnabled
? 'border-amber-500 bg-amber-500 text-slate-950'
: 'border-slate-600 bg-slate-900/50'
}`}
aria-hidden="true"
>
{action.loopEnabled && (
<svg
className="h-2.5 w-2.5 stroke-slate-950 stroke-[3] fill-none"
viewBox="0 0 24 24"
>
<title>Loop enabled</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
)}
</span>
)}
{action.name}
</span>
<div className="flex items-center gap-1.5">
{action.kind === 'story' && (
<span className="rounded bg-amber-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-amber-300 uppercase tracking-wider">
Story
</span>
)}
<span className="text-slate-400 text-xs shrink-0">
{isActive
? 'running…'
: isDisabled
? (action.disabledReason ?? 'unavailable')
: 'start'}
</span>
</div>
</div>
{summaryParts.length > 0 ? (
<p className="text-slate-500 text-xs">{summaryParts.join(' · ')}</p>
) : null}
{prefs.actionDetailMode === 'inline' && action.storyHint ? (
<p className="text-slate-400 text-xs mt-0.5">{action.storyHint}</p>
) : null}
</div>
</button>
{prefs.actionDetailMode === 'info-button' && action.storyTooltip ? (
<div className="relative shrink-0 flex">
<button
type="button"
aria-label={`Details for ${action.name}`}
aria-expanded={openInfoId === action.id}
aria-describedby={openInfoId === action.id ? tooltipId : undefined}
onClick={() => setOpenInfoId(openInfoId === action.id ? null : action.id)}
className="flex items-center rounded-lg border border-slate-700 bg-slate-800/70 px-2 text-slate-400 transition-colors hover:border-amber-500/60 hover:text-slate-200"
>
</button>
{openInfoId === action.id ? (
<div
id={tooltipId}
role="tooltip"
className="absolute top-full right-0 z-10 mt-1 w-64 rounded-lg border border-slate-600 bg-slate-900 p-3 text-slate-300 text-xs shadow-lg"
>
{action.storyTooltip}
</div>
) : null}
</div>
) : null}
{action.automationUnlocked ? (
<button
type="button"
aria-label={`${action.inAutomationQueue ? 'Disable' : 'Enable'} automation for ${action.name}`}
aria-pressed={action.inAutomationQueue}
title={`${action.inAutomationQueue ? 'Disable' : 'Enable'} automation for ${action.name}`}
onClick={() => gameRuntime.toggleAutomation(action.id)}
className={`shrink-0 rounded-lg border px-2 text-xs font-semibold transition-colors ${
action.inAutomationQueue
? 'border-amber-500 bg-amber-500/15 text-amber-300 hover:bg-amber-500/20'
: 'border-slate-700 bg-slate-800/70 text-slate-400 hover:border-amber-500/60 hover:text-amber-300'
}`}
>
Auto
</button>
) : null}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { ActionGroupView } from '../state/viewModel';
import { ActionGroup } from './ActionGroup';
interface ActionColumnProps {
label: string;
groups: ActionGroupView[];
actionKind: string;
}
export function ActionColumn({ label, groups, actionKind }: ActionColumnProps) {
return (
<div className="w-80 min-w-80 shrink-0 flex flex-col gap-4 bg-slate-900/40 rounded-xl p-4 border border-slate-800/60">
<h2 className="font-bold text-slate-200 text-sm uppercase tracking-widest border-b border-slate-800 pb-2 select-none">
{label}
</h2>
<div className="flex flex-col gap-3">
{groups.map((group) => (
<ActionGroup key={group.id} group={group} actionKind={actionKind} />
))}
</div>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store';
import type { ActionGroupView } from '../state/viewModel';
import { ActionCard } from './ActionCard';
interface ActionGroupProps {
group: ActionGroupView;
actionKind: string;
}
export function ActionGroup({ group, actionKind }: ActionGroupProps) {
const collapsed = useGameStore(
(s) => !!s.prefs.collapsedActionGroups[`${actionKind}:${group.id}`],
);
return (
<div className="flex flex-col gap-1 w-full">
<button
type="button"
aria-expanded={!collapsed}
onClick={() => gameRuntime.toggleActionGroupCollapsed(`${actionKind}:${group.id}`)}
className="flex w-full items-center justify-between py-1.5 text-left text-slate-400 hover:text-slate-200 transition-colors focus:outline-none"
>
<span className="font-semibold text-xs uppercase tracking-wider">{group.label}</span>
<span className="text-slate-500 text-xs shrink-0 select-none">{collapsed ? '▶' : '▼'}</span>
</button>
{!collapsed && (
<div className="flex flex-col gap-2 pl-1">
{group.actions.map((action) => (
<ActionCard key={action.id} action={action} />
))}
</div>
)}
</div>
);
}
-112
View File
@@ -1,112 +0,0 @@
import { useState } from 'react';
import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store';
/** Action list with queue, cancel, disabled states, and story hints/tooltips. */
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);
const [openInfoId, setOpenInfoId] = useState<string | null>(null);
return (
<section aria-label="Actions" className="flex flex-col gap-2">
<h2 className="font-medium text-slate-300 text-sm uppercase tracking-wide">Actions</h2>
{actions.map((action) => {
const isActive = action.id === activeActionId;
const isDisabled = !action.available && !isActive;
const summaryParts = [
action.costsSummary ? `Cost: ${action.costsSummary}` : null,
action.yieldsSummary ? `Yield: ${action.yieldsSummary}` : null,
].filter(Boolean);
return (
<div key={action.id} className="relative flex gap-1">
<button
type="button"
disabled={isDisabled}
title={prefs.actionDetailMode === 'hover' ? action.storyTooltip : undefined}
onClick={() => gameRuntime.enqueueAction(action.id)}
className={`relative w-full overflow-hidden rounded-lg border px-4 py-3 text-left transition-colors ${
isDisabled
? 'cursor-not-allowed border-slate-800 bg-slate-900/40 opacity-50'
: 'border-slate-700 bg-slate-800/70 hover:border-amber-500/60 hover:bg-slate-800'
}`}
>
{isActive ? (
<div
className="absolute inset-y-0 left-0 bg-amber-500/15"
style={{ width: `${Math.round(actionProgress * 100)}%` }}
aria-hidden="true"
/>
) : null}
<div className="relative flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="font-medium text-slate-100">{action.name}</span>
<span className="text-slate-400 text-xs">
{isActive
? 'running…'
: isDisabled
? (action.disabledReason ?? 'unavailable')
: 'start'}
</span>
</div>
{summaryParts.length > 0 ? (
<p className="text-slate-500 text-xs">{summaryParts.join(' · ')}</p>
) : null}
{prefs.actionDetailMode === 'inline' && action.storyHint ? (
<p className="text-slate-400 text-xs">{action.storyHint}</p>
) : null}
</div>
</button>
{prefs.actionDetailMode === 'info-button' && action.storyTooltip ? (
<div className="relative shrink-0">
<button
type="button"
aria-label={`Details for ${action.name}`}
aria-expanded={openInfoId === action.id}
onClick={() => setOpenInfoId(openInfoId === action.id ? null : action.id)}
className="flex h-full items-center rounded-lg border border-slate-700 bg-slate-800/70 px-2 text-slate-400 transition-colors hover:border-amber-500/60 hover:text-slate-200"
title={action.storyTooltip}
>
</button>
{openInfoId === action.id ? (
<div
role="tooltip"
className="absolute top-full right-0 z-10 mt-1 max-w-xs rounded-lg border border-slate-600 bg-slate-900 p-3 text-slate-300 text-xs shadow-lg"
>
{action.storyTooltip}
</div>
) : null}
</div>
) : null}
</div>
);
})}
{queuedActionNames.length > 0 ? (
<ol aria-label="Action queue" className="mt-2 flex flex-col gap-1">
{queuedActionNames.map((name, index) => (
<li
key={queuedActionIds[index]}
className="flex items-center justify-between rounded border border-slate-700 bg-slate-900/60 px-3 py-2 text-sm"
>
<span className="text-slate-300">{name}</span>
<button
type="button"
aria-label={`Cancel ${name}`}
onClick={() => gameRuntime.cancelQueuedAction(index)}
className="rounded px-2 py-0.5 text-slate-400 transition-colors hover:bg-slate-800 hover:text-amber-400"
>
</button>
</li>
))}
</ol>
) : null}
</section>
);
}
+28 -48
View File
@@ -1,65 +1,45 @@
import { useEffect } from 'react';
import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store';
import { ActionPanel } from './ActionPanel';
import { EventLog } from './EventLog';
import { ResourceBar } from './ResourceBar';
import { SettingsDrawer } from './SettingsDrawer';
import { StoryPanel } from './StoryPanel';
import { AboutPanel } from './AboutPanel';
import { AppShell } from './AppShell';
import { NavRail } from './NavRail';
import { PlayPanel } from './PlayPanel';
import { RightRail } from './RightRail';
import { SettingsPanel } from './SettingsPanel';
import { StoryView } from './StoryView';
/**
* M1 playable-loop shell. Boots the runtime once on mount; everything else
* renders from the Zustand store the runtime feeds.
*/
export function App() {
const storyHasUnread = useGameStore((s) => s.storyHasUnread);
const settingsOpen = useGameStore((s) => s.settingsOpen);
const setSettingsOpen = useGameStore((s) => s.setSettingsOpen);
const activePanel = useGameStore((s) => s.activePanel);
useEffect(() => {
void gameRuntime.boot();
}, []);
const renderActivePanel = () => {
switch (activePanel) {
case 'play':
return <PlayPanel />;
case 'story':
return <StoryView />;
case 'settings':
return <SettingsPanel />;
case 'about':
return <AboutPanel />;
default:
return <PlayPanel />;
}
};
return (
<>
<StoryPanel />
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col gap-5 px-4 py-8 text-slate-100">
<header className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-4">
<h1 className="font-bold text-2xl tracking-tight">Idlegame</h1>
<div className="flex items-center gap-2">
<button
type="button"
aria-label={storyHasUnread ? 'Story, unread' : 'Story'}
onClick={() => gameRuntime.openStoryPanel()}
className="relative rounded-lg border border-slate-700 bg-slate-800/70 px-3 py-1.5 text-sm transition-colors hover:border-amber-500/60"
>
Story
{storyHasUnread ? (
<span
className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-amber-500"
aria-hidden
/>
) : null}
</button>
<button
type="button"
aria-label="Settings"
aria-expanded={settingsOpen}
onClick={() => setSettingsOpen(!settingsOpen)}
className="rounded-lg border border-slate-700 bg-slate-800/70 px-3 py-1.5 text-sm transition-colors hover:border-amber-500/60"
>
</button>
</div>
</div>
<p className="text-slate-500 text-sm">M1 playable loop</p>
<SettingsDrawer />
</header>
<ResourceBar />
<ActionPanel />
<EventLog />
</main>
</>
<AppShell
nav={<NavRail />}
center={renderActivePanel()}
right={activePanel === 'play' ? <RightRail /> : undefined}
/>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { ReactNode } from 'react';
interface AppShellProps {
nav: ReactNode;
center: ReactNode;
right?: ReactNode;
}
export function AppShell({ nav, center, right }: AppShellProps) {
return (
<div
className={`grid min-h-dvh text-slate-100 ${
right
? 'grid-cols-[auto_1fr] md:grid-cols-[14rem_1fr_auto]'
: 'grid-cols-[auto_1fr] md:grid-cols-[14rem_1fr]'
}`}
>
<div className="border-slate-800 border-r bg-slate-950">{nav}</div>
<main className="overflow-y-auto p-6">{center}</main>
{right ? (
<aside className="hidden w-72 border-slate-800 border-l bg-slate-950 p-6 md:block">
{right}
</aside>
) : null}
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { useState } from 'react';
import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store';
export function AutomationBar() {
const automationQueueIds = useGameStore((s) => s.automationQueueIds);
const automationQueueNames = useGameStore((s) => s.automationQueueNames);
const [importText, setImportText] = useState('');
async function copyRecipe() {
const text = gameRuntime.exportAutomationRecipe();
try {
await navigator.clipboard?.writeText(text);
return;
} catch {
// Fall through to the legacy selection path.
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.append(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
}
function importRecipeText() {
gameRuntime.importAutomationRecipe(importText);
setImportText('');
}
return (
<section
aria-label="Automation"
className="rounded-xl border border-slate-800 bg-slate-900/30 p-4"
>
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="font-semibold text-slate-400 text-xs uppercase tracking-wider">
Automation
</h3>
<button
type="button"
onClick={copyRecipe}
className="rounded border border-slate-700 px-2.5 py-1 text-slate-300 text-xs transition-colors hover:border-amber-500/60 hover:text-amber-300"
>
Copy recipe
</button>
</div>
{automationQueueNames.length > 0 ? (
<ol aria-label="Automation queue" className="mb-3 flex flex-col gap-1.5">
{automationQueueNames.map((name, index) => (
<li
// biome-ignore lint/suspicious/noArrayIndexKey: indices distinguish repeated automation entries in display order
key={`${automationQueueIds[index]}-${index}`}
className="rounded-lg border border-slate-700 bg-slate-900/60 px-3 py-2 text-slate-300 text-sm"
>
{index + 1}. {name}
</li>
))}
</ol>
) : (
<p className="mb-3 text-slate-500 text-sm">No automated processes.</p>
)}
<div className="flex flex-col gap-2">
<textarea
aria-label="Recipe text"
className="min-h-24 w-full resize-y rounded-lg border border-slate-700 bg-slate-950/70 p-2 text-slate-200 text-xs outline-none transition-colors placeholder:text-slate-600 focus:border-amber-500/70"
placeholder="Paste recipe text..."
value={importText}
onChange={(event) => setImportText(event.target.value)}
/>
<button
type="button"
disabled={importText.trim().length === 0}
onClick={importRecipeText}
className="self-start rounded border border-slate-700 px-3 py-1.5 text-slate-300 text-xs transition-colors hover:border-amber-500/60 hover:text-amber-300 disabled:cursor-not-allowed disabled:opacity-40"
>
Import recipe
</button>
</div>
</section>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { gameRuntime } from '../state/runtime';
import { type ActivePanel, useGameStore } from '../state/store';
export function NavRail() {
const activePanel = useGameStore((s) => s.activePanel);
const storyHasUnread = useGameStore((s) => s.storyHasUnread);
const navItems: { id: ActivePanel; label: string; icon: React.ReactNode }[] = [
{
id: 'play',
label: 'Play',
icon: (
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"
/>
</svg>
),
},
{
id: 'story',
label: 'Story',
icon: (
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
),
},
{
id: 'settings',
label: 'Settings',
icon: (
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
),
},
{
id: 'about',
label: 'About',
icon: (
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
},
];
return (
<div className="flex h-full flex-col p-2 md:p-4">
<div className="mb-8 hidden items-center justify-between px-2 md:flex">
<span className="bg-gradient-to-r from-amber-400 via-amber-200 to-amber-500 bg-clip-text font-extrabold text-lg text-transparent tracking-wider">
IDLEGAME
</span>
<span className="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] font-semibold text-slate-400 tracking-wide">
M1
</span>
</div>
<nav className="flex flex-1 flex-col gap-1.5">
{navItems.map((item) => {
const isActive = activePanel === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => gameRuntime.setActivePanel(item.id)}
className={`relative flex cursor-pointer items-center justify-center md:justify-start gap-3 rounded-lg px-2.5 py-2.5 md:px-3 text-sm font-medium transition-all duration-200 ${
isActive
? 'border border-amber-500/30 bg-amber-500/10 text-amber-400'
: 'border border-transparent text-slate-400 hover:bg-slate-900/60 hover:text-slate-200'
}`}
>
{item.icon}
<span className="hidden md:inline">{item.label}</span>
{item.id === 'story' && storyHasUnread ? (
<>
<span
className="absolute top-1.5 right-1.5 md:top-1/2 md:right-3 h-2 w-2 md:-translate-y-1/2 animate-pulse rounded-full bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.6)]"
aria-hidden="true"
/>
<span className="sr-only">New story</span>
</>
) : null}
</button>
);
})}
</nav>
<div className="mt-auto hidden border-slate-800/85 border-t pt-4 text-center md:block">
<span className="text-[11px] text-slate-600">senpai edition</span>
</div>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store';
import { ActionColumn } from './ActionColumn';
import { AutomationBar } from './AutomationBar';
import { EventLog } from './EventLog';
import { ResourceBar } from './ResourceBar';
export function PlayPanel() {
const showEventLog = useGameStore((s) => s.prefs.showEventLog);
const columns = useGameStore((s) => s.actionColumns).filter(
(col) => col.groups.length > 0 && col.groups.some((g) => g.actions.length > 0),
);
const queuedActionIds = useGameStore((s) => s.queuedActionIds);
const queuedActionNames = useGameStore((s) => s.queuedActionNames);
return (
<div className="space-y-6">
<div className="block md:hidden">
<ResourceBar />
</div>
<section aria-label="Actions" className="flex gap-4 overflow-x-auto pb-4">
{columns.map((col) => (
<ActionColumn
key={col.kind}
label={col.label}
groups={col.groups}
actionKind={col.kind}
/>
))}
</section>
<AutomationBar />
{queuedActionNames.length > 0 ? (
<div className="border border-slate-800 bg-slate-900/30 rounded-xl p-4">
<h3 className="font-semibold text-slate-400 text-xs uppercase tracking-wider mb-2">
Action Queue
</h3>
<ol aria-label="Action queue" className="flex flex-col gap-1.5">
{queuedActionNames.map((name, index) => (
<li
// biome-ignore lint/suspicious/noArrayIndexKey: indices are stable and identify individual queue items
key={`${queuedActionIds[index]}-${index}`}
className="flex items-center justify-between rounded-lg border border-slate-700 bg-slate-900/60 px-3 py-2 text-sm"
>
<span className="text-slate-300">{name}</span>
<button
type="button"
aria-label={`Cancel ${name}`}
onClick={() => gameRuntime.cancelQueuedAction(index)}
className="rounded px-2 py-0.5 text-slate-400 transition-colors hover:bg-slate-800 hover:text-amber-400"
>
</button>
</li>
))}
</ol>
</div>
) : null}
{showEventLog && (
<div className="block md:hidden">
<EventLog />
</div>
)}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { useState } from 'react';
import { useGameStore } from '../state/store';
import { EventLog } from './EventLog';
import { ResourceBar } from './ResourceBar';
export function RightRail() {
const showEventLog = useGameStore((s) => s.prefs.showEventLog);
const [logOpen, setLogOpen] = useState(true);
return (
<div className="flex h-full flex-col gap-6">
{/* Resources */}
<div>
<h2 className="mb-3 font-semibold text-slate-400 text-xs tracking-wider uppercase">
Resources
</h2>
<ResourceBar />
</div>
{/* Inventory placeholder */}
<div className="border-slate-900 border-t pt-4">
<h2 className="mb-3 font-semibold text-slate-400 text-xs tracking-wider uppercase">
Inventory
</h2>
<p className="text-slate-600 text-xs">Items coming soon.</p>
</div>
{/* Event log — gated by pref, collapsible via local state */}
{showEventLog && (
<div className="flex-1 border-slate-900 border-t pt-4">
<button
type="button"
aria-expanded={logOpen}
aria-controls="right-rail-event-log"
onClick={() => setLogOpen((prev) => !prev)}
className="mb-3 flex w-full items-center justify-between font-semibold text-slate-400 text-xs tracking-wider uppercase hover:text-slate-300"
>
<span>Event Log</span>
<span aria-hidden="true" className="text-slate-500">
{logOpen ? '▾' : '▸'}
</span>
</button>
<div id="right-rail-event-log">{logOpen && <EventLog />}</div>
</div>
)}
</div>
);
}
-40
View File
@@ -1,40 +0,0 @@
import type { ActionDetailMode, StoryOpenMode } from '../state/prefs';
import { useGameStore } from '../state/store';
/** Preference panel toggled from the header gear; changes persist immediately. */
export function SettingsDrawer() {
const open = useGameStore((s) => s.settingsOpen);
const prefs = useGameStore((s) => s.prefs);
const setPrefs = useGameStore((s) => s.setPrefs);
if (!open) return null;
return (
<div className="rounded-lg border border-slate-700 bg-slate-900 p-4">
<label className="flex flex-col gap-1 text-sm">
Story opens
<select
value={prefs.storyOpenMode}
onChange={(e) => setPrefs({ storyOpenMode: e.target.value as StoryOpenMode })}
className="rounded border border-slate-600 bg-slate-800 px-2 py-1 text-slate-100"
>
<option value="auto">Automatically</option>
<option value="choices-only">Choices only</option>
<option value="manual">Manually</option>
</select>
</label>
<label className="mt-3 flex flex-col gap-1 text-sm">
Action details
<select
value={prefs.actionDetailMode}
onChange={(e) => setPrefs({ actionDetailMode: e.target.value as ActionDetailMode })}
className="rounded border border-slate-600 bg-slate-800 px-2 py-1 text-slate-100"
>
<option value="inline">Inline subtitles</option>
<option value="hover">Hover tooltips</option>
<option value="info-button">Info button</option>
</select>
</label>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import type { ActionDetailMode, StoryOpenMode } from '../state/prefs';
import { useGameStore } from '../state/store';
export function SettingsPanel() {
const prefs = useGameStore((s) => s.prefs);
const setPrefs = useGameStore((s) => s.setPrefs);
return (
<div className="max-w-xl space-y-6">
<div>
<h2 className="font-bold text-slate-100 text-xl tracking-tight">Game Settings</h2>
<p className="text-slate-400 text-sm">Configure your gameplay and UI preferences.</p>
</div>
<div className="space-y-4">
{/* Story Open Mode */}
<div className="flex flex-col gap-2 rounded-xl border border-slate-800 bg-slate-900/40 p-5">
<div className="flex flex-col">
<span className="font-semibold text-slate-200 text-sm">Story Navigation</span>
<span className="text-slate-400 text-xs">
How should the game navigate when story events are unlocked?
</span>
</div>
<div className="mt-2 grid grid-cols-3 gap-2">
{(['auto', 'choices-only', 'manual'] as StoryOpenMode[]).map((mode) => {
const labels: Record<StoryOpenMode, string> = {
auto: 'Automatically',
'choices-only': 'Choices Only',
manual: 'Manually',
};
const isActive = prefs.storyOpenMode === mode;
return (
<button
key={mode}
type="button"
onClick={() => setPrefs({ storyOpenMode: mode })}
className={`cursor-pointer rounded-lg border px-3 py-2 text-xs font-medium transition-all duration-200 ${
isActive
? 'border-amber-500/40 bg-amber-500/10 text-amber-400'
: 'border-slate-800 bg-slate-950/20 text-slate-400 hover:border-slate-700 hover:text-slate-200'
}`}
>
{labels[mode]}
</button>
);
})}
</div>
</div>
{/* Action Detail Mode */}
<div className="flex flex-col gap-2 rounded-xl border border-slate-800 bg-slate-900/40 p-5">
<div className="flex flex-col">
<span className="font-semibold text-slate-200 text-sm">Action Details</span>
<span className="text-slate-400 text-xs">
Where should action requirements and details be shown?
</span>
</div>
<div className="mt-2 grid grid-cols-3 gap-2">
{(['inline', 'hover', 'info-button'] as ActionDetailMode[]).map((mode) => {
const labels: Record<ActionDetailMode, string> = {
inline: 'Inline',
hover: 'Hover Tooltips',
'info-button': 'Info Button',
};
const isActive = prefs.actionDetailMode === mode;
return (
<button
key={mode}
type="button"
onClick={() => setPrefs({ actionDetailMode: mode })}
className={`cursor-pointer rounded-lg border px-3 py-2 text-xs font-medium transition-all duration-200 ${
isActive
? 'border-amber-500/40 bg-amber-500/10 text-amber-400'
: 'border-slate-800 bg-slate-950/20 text-slate-400 hover:border-slate-700 hover:text-slate-200'
}`}
>
{labels[mode]}
</button>
);
})}
</div>
</div>
{/* Show event log */}
<div className="flex flex-col gap-2 rounded-xl border border-slate-800 bg-slate-900/40 p-5">
<label className="flex cursor-pointer items-center justify-between gap-4">
<div className="flex flex-col">
<span className="font-semibold text-slate-200 text-sm">Show Event Log</span>
<span className="text-slate-400 text-xs">
Display the scrollable event log in the right rail and on mobile.
</span>
</div>
<input
type="checkbox"
checked={prefs.showEventLog}
onChange={(e) => setPrefs({ showEventLog: e.target.checked })}
className="h-4 w-4 cursor-pointer accent-amber-500"
/>
</label>
</div>
</div>
</div>
);
}
-74
View File
@@ -1,74 +0,0 @@
import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store';
/** Full-screen VN overlay with prose, choices, and desktop story log sidebar. */
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 (
<div
className="fixed inset-0 z-50 flex bg-black/80"
role="dialog"
aria-modal="true"
aria-label="Story"
>
<div className="flex flex-1 flex-col md:flex-row">
<aside className="hidden max-h-full w-full overflow-y-auto border-slate-700 border-r p-4 md:block md:w-1/3">
<h3 className="mb-2 font-medium text-slate-400 text-xs uppercase tracking-wide">
Story log
</h3>
<ul className="flex flex-col gap-2 text-slate-300 text-sm">
{storyLog.map((entry) => (
<li key={`${entry.nodeId}:${entry.choiceLabel ?? ''}:${entry.prose}`}>
{entry.choiceLabel ? (
<span className="text-amber-400/80">[{entry.choiceLabel}] </span>
) : null}
{entry.prose}
</li>
))}
</ul>
</aside>
<main className="flex flex-1 flex-col gap-4 p-6">
<p className="flex-1 text-lg text-slate-100 leading-relaxed">{story.currentProse}</p>
{hasChoices ? (
<div className="flex flex-col gap-2">
{story.choices.map((choice) => (
<button
key={choice.id}
type="button"
disabled={choice.disabled}
title={choice.disabled ? (choice.disabledReason ?? undefined) : undefined}
onClick={() => gameRuntime.applyStoryChoice(choice.id)}
className="rounded-lg border border-slate-600 bg-slate-900/60 px-4 py-3 text-left transition-colors hover:border-amber-500/60 disabled:cursor-not-allowed disabled:opacity-40"
>
{choice.label}
</button>
))}
</div>
) : (
<button
type="button"
onClick={() => gameRuntime.continueStory()}
className="self-start rounded-lg bg-amber-600 px-4 py-2 transition-colors hover:bg-amber-500"
>
Continue
</button>
)}
<button
type="button"
onClick={() => gameRuntime.closeStoryPanel()}
className="self-end text-slate-400 text-sm transition-colors hover:text-slate-200"
>
Close
</button>
</main>
</div>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useRef } from 'react';
import type { StoryLogEntry } from '../state/store';
interface StoryProseLogProps {
log: StoryLogEntry[];
selectedId: string | null;
}
export function StoryProseLog({ log, selectedId }: StoryProseLogProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const selectedEntryRef = useRef<HTMLLIElement>(null);
const prevLogCountRef = useRef(log.length);
// Auto-scroll to bottom when new entries arrive
useEffect(() => {
if (log.length > prevLogCountRef.current && !selectedId && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
prevLogCountRef.current = log.length;
}, [log.length, selectedId]);
// Scroll to selected entry when selectedId changes
useEffect(() => {
if (selectedId && selectedEntryRef.current) {
selectedEntryRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [selectedId]);
if (log.length === 0) {
return (
<div className="flex h-full items-center justify-center rounded-xl border border-slate-800/60 bg-slate-900/40 shadow-inner">
<p className="text-sm text-slate-500 italic">The chronicle is empty</p>
</div>
);
}
return (
<div
ref={scrollRef}
className="overflow-y-auto rounded-xl border border-slate-800/60 bg-slate-900/40 p-4 shadow-inner"
>
<h2 className="mb-3 text-xs font-semibold tracking-wider text-slate-400 uppercase">
Prose Log
</h2>
<ul className="space-y-3">
{log.map((entry, i) => {
const isHighlighted = selectedId != null && entry.nodeId === selectedId;
// Find first matching entry for the scroll-into-view ref
const isFirstMatch = isHighlighted && log.findIndex((e) => e.nodeId === selectedId) === i;
return (
<li
key={`${entry.nodeId}:${String(i)}`}
ref={isFirstMatch ? selectedEntryRef : undefined}
className={`rounded-lg border px-3 py-2 text-sm leading-relaxed transition-colors duration-200 ${
isHighlighted
? 'border-amber-500/30 bg-amber-500/10 text-slate-100'
: 'border-transparent text-slate-300'
}`}
>
{entry.choiceLabel ? (
<span className="mb-1 mr-2 inline-block rounded bg-amber-500/20 px-1.5 py-0.5 text-xs font-medium text-amber-300">
{entry.choiceLabel}
</span>
) : null}
<span className="whitespace-pre-wrap">{entry.prose}</span>
</li>
);
})}
</ul>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import type { StoryTreeNodeView } from '../state/viewModel';
interface StoryTreeProps {
nodes: StoryTreeNodeView[];
selectedId: string | null;
onSelect: (id: string) => void;
}
interface StoryTreeNodeProps {
node: StoryTreeNodeView;
depth: number;
selectedId: string | null;
onSelect: (id: string) => void;
}
function StoryTreeNode({ node, depth, selectedId, onSelect }: StoryTreeNodeProps) {
const isSelected = node.id === selectedId;
let labelClasses =
'w-full cursor-pointer rounded px-2 py-1 text-left text-sm transition-colors duration-150';
if (isSelected) {
labelClasses += ' bg-amber-500/15 border border-amber-500/40 text-amber-200';
} else if (node.active) {
labelClasses += ' text-amber-400 hover:bg-slate-800/60 border border-transparent';
} else if (!node.seen) {
labelClasses += ' text-slate-600 hover:bg-slate-800/40 border border-transparent';
} else {
labelClasses += ' text-slate-300 hover:bg-slate-800/60 border border-transparent';
}
return (
<li>
<button
type="button"
className={labelClasses}
style={{ marginLeft: `${depth * 1}rem` }}
onClick={() => onSelect(node.id)}
aria-current={isSelected ? 'true' : undefined}
>
<span className="flex items-center gap-1.5">
{node.active && (
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-amber-400" />
)}
{node.label}
</span>
</button>
{node.children.length > 0 && (
<ul className="mt-0.5 space-y-0.5">
{node.children.map((child) => (
<StoryTreeNode
key={child.id}
node={child}
depth={depth + 1}
selectedId={selectedId}
onSelect={onSelect}
/>
))}
</ul>
)}
</li>
);
}
export function StoryTree({ nodes, selectedId, onSelect }: StoryTreeProps) {
if (nodes.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-slate-500 italic">No story branches yet</p>
</div>
);
}
return (
<div className="overflow-y-auto rounded-xl border border-slate-800/60 bg-slate-900/40 p-4 shadow-inner">
<h2 className="mb-3 text-xs font-semibold tracking-wider text-slate-400 uppercase">
Story Tree
</h2>
<ul className="space-y-0.5">
{nodes.map((node) => (
<StoryTreeNode
key={node.id}
node={node}
depth={0}
selectedId={selectedId}
onSelect={onSelect}
/>
))}
</ul>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store';
import { StoryProseLog } from './StoryProseLog';
import { StoryTree } from './StoryTree';
const SHELL_HEIGHT = 'h-[calc(100dvh-6rem)]';
export function StoryView() {
const tree = useGameStore((s) => s.story.tree);
const log = useGameStore((s) => s.storyLog);
const selectedId = useGameStore((s) => s.selectedStoryNodeId);
const currentProse = useGameStore((s) => s.story.currentProse);
const isBootIntro = useGameStore((s) => s.story.atBootIntro);
if (isBootIntro) {
return (
<div className={`flex ${SHELL_HEIGHT} flex-col items-center justify-center gap-6`}>
<p className="max-w-lg text-center text-lg leading-relaxed text-slate-100 whitespace-pre-wrap">
{currentProse}
</p>
<button
type="button"
onClick={() => gameRuntime.continueStory()}
className="cursor-pointer rounded-lg bg-amber-600 px-6 py-2.5 font-medium text-slate-100 transition-colors hover:bg-amber-500"
>
Continue
</button>
</div>
);
}
return (
<div className={`grid ${SHELL_HEIGHT} grid-cols-[3fr_2fr] gap-4`}>
<StoryTree
nodes={tree}
selectedId={selectedId}
onSelect={(id) => gameRuntime.selectStoryNode(id)}
/>
<StoryProseLog log={log} selectedId={selectedId} />
</div>
);
}