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 .claude/settings.local.json
.vite/ .vite/
# Git worktrees (local isolation)
.worktrees/
# Logs # Logs
*.log *.log
npm-debug.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, resource and one timed action. M1 expands this into real resources, actions,
story nodes, automation unlocks, and prestige definitions. 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 ## State
`src/state/` owns environment coupling: `src/state/` owns environment coupling:
@@ -42,8 +48,72 @@ story nodes, automation unlocks, and prestige definitions.
## UI ## UI
`src/ui/` renders the view model and calls runtime commands. Components should `src/ui/` renders the view model and calls runtime commands. Components should
not implement gameplay rules. The M0 shell includes a resource readout, action not implement gameplay rules.
panel, progress bar, and event log.
### 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 ## 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'; import { content } from '../index';
describe('M1 stub content pack', () => { 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.resources).toHaveLength(2);
expect(content.actions.length).toBeGreaterThanOrEqual(4); expect(content.actions.length).toBeGreaterThanOrEqual(4);
expect(content.actions.length).toBeLessThanOrEqual(6);
const withCosts = content.actions.filter((a) => a.costs.length > 0); const withCosts = content.actions.filter((a) => a.costs.length > 0);
const withUnlocks = content.actions.filter((a) => a.unlock !== undefined); const withUnlocks = content.actions.filter((a) => a.unlock !== undefined);
expect(withCosts.length).toBeGreaterThanOrEqual(2); expect(withCosts.length).toBeGreaterThanOrEqual(2);
expect(withUnlocks.length).toBeGreaterThanOrEqual(1); 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 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) { 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); enqueueAction(state, content, trade.id);
tickGame(state, content, trade.durationMs); tickGame(state, content, trade.durationMs);
+78
View File
@@ -6,6 +6,7 @@ const validActions = [
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: { id: 'camp', label: 'Camp' },
durationMs: 3000, durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
}, },
@@ -28,6 +29,7 @@ describe('buildContent()', () => {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: { id: 'camp', label: 'Camp' },
durationMs: 3000, durationMs: 3000,
yields: [{ resourceId: 'ghost', amount: 1 }], yields: [{ resourceId: 'ghost', amount: 1 }],
}, },
@@ -48,6 +50,7 @@ describe('buildContent()', () => {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: { id: 'camp', label: 'Camp' },
durationMs: -1, durationMs: -1,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
}, },
@@ -66,6 +69,7 @@ describe('costs and multi-yield', () => {
{ {
id: 'craft', id: 'craft',
name: 'Craft', name: 'Craft',
group: { id: 'camp', label: 'Camp' },
durationMs: 1000, durationMs: 1000,
costs: [{ resourceId: 'wood', amount: 2 }], costs: [{ resourceId: 'wood', amount: 2 }],
yields: [ yields: [
@@ -84,6 +88,7 @@ describe('costs and multi-yield', () => {
{ {
id: 'craft', id: 'craft',
name: 'Craft', name: 'Craft',
group: { id: 'camp', label: 'Camp' },
durationMs: 1000, durationMs: 1000,
costs: [{ resourceId: 'ghost', amount: 1 }], costs: [{ resourceId: 'ghost', amount: 1 }],
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
@@ -104,6 +109,7 @@ describe('unlock conditions', () => {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: { id: 'camp', label: 'Camp' },
durationMs: 3000, durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
unlock: { unlock: {
@@ -131,6 +137,7 @@ describe('action narrative fields', () => {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: { id: 'camp', label: 'Camp' },
durationMs: 3000, durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
storyHint: 'Gather what the forest offers.', storyHint: 'Gather what the forest offers.',
@@ -148,3 +155,74 @@ describe('action narrative fields', () => {
expect(content.actionsById.forage.storyTooltip).toBeUndefined(); 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: { scout_path: {
id: 'scout_path', id: 'scout_path',
name: 'Scout', name: 'Scout',
kind: 'timed' as const,
group: { id: 'travel', label: 'Travel' },
durationMs: 1000, durationMs: 1000,
costs: [], costs: [],
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
+34 -1
View File
@@ -7,6 +7,8 @@ export const actionDefs = [
{ {
id: 'gather_supplies', id: 'gather_supplies',
name: 'Gather supplies', name: 'Gather supplies',
kind: 'timed',
group: { id: 'camp', label: 'Camp' },
durationMs: 3000, durationMs: 3000,
yields: [{ resourceId: 'supplies', amount: 2 }], yields: [{ resourceId: 'supplies', amount: 2 }],
storyHint: 'Basic camp labor.', storyHint: 'Basic camp labor.',
@@ -15,6 +17,8 @@ export const actionDefs = [
{ {
id: 'scout_path', id: 'scout_path',
name: 'Scout the path', name: 'Scout the path',
kind: 'timed',
group: { id: 'travel', label: 'Travel' },
durationMs: 5000, durationMs: 5000,
costs: [{ resourceId: 'supplies', amount: 2 }], costs: [{ resourceId: 'supplies', amount: 2 }],
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
@@ -24,6 +28,8 @@ export const actionDefs = [
{ {
id: 'trade_supplies', id: 'trade_supplies',
name: 'Trade at camp', name: 'Trade at camp',
kind: 'timed',
group: { id: 'camp', label: 'Camp' },
durationMs: 4000, durationMs: 4000,
costs: [{ resourceId: 'supplies', amount: 3 }], costs: [{ resourceId: 'supplies', amount: 3 }],
yields: [{ resourceId: 'coin', amount: 2 }], yields: [{ resourceId: 'coin', amount: 2 }],
@@ -34,6 +40,8 @@ export const actionDefs = [
{ {
id: 'fortify_camp', id: 'fortify_camp',
name: 'Fortify camp', name: 'Fortify camp',
kind: 'timed',
group: { id: 'camp', label: 'Camp' },
durationMs: 8000, durationMs: 8000,
costs: [ costs: [
{ resourceId: 'supplies', amount: 5 }, { resourceId: 'supplies', amount: 5 },
@@ -47,6 +55,8 @@ export const actionDefs = [
{ {
id: 'push_onward', id: 'push_onward',
name: 'Push onward', name: 'Push onward',
kind: 'timed',
group: { id: 'travel', label: 'Travel' },
durationMs: 6000, durationMs: 6000,
costs: [{ resourceId: 'supplies', amount: 2 }], costs: [{ resourceId: 'supplies', amount: 2 }],
yields: [{ resourceId: 'coin', amount: 3 }], yields: [{ resourceId: 'coin', amount: 3 }],
@@ -57,9 +67,32 @@ export const actionDefs = [
{ {
id: 'rest', id: 'rest',
name: 'Rest briefly', name: 'Rest briefly',
kind: 'loop',
group: { id: 'camp_loop', label: 'Camp activities' },
loopPriority: 0,
durationMs: 2000, durationMs: 2000,
yields: [{ resourceId: 'supplies', amount: 1 }], yields: [{ resourceId: 'supplies', amount: 1 }],
storyHint: 'Catch your breath.', 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 type GameContent = Content & StoryContent;
export const content: GameContent = { ...base, ...story }; export const content: GameContent = { ...base, ...story };
export type { ActionDef, Content, ResourceDef } from './schema'; export type { ActionDef, ActionGroup, ActionKind, Content, ResourceDef } from './schema';
+47 -4
View File
@@ -25,20 +25,63 @@ export const unlockDefSchema = z.object({
requireStoryFlags: z.array(z.string().min(1)).optional(), 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),
label: z.string().min(1),
});
export const actionDefSchema = z
.object({
id: z.string().min(1), id: z.string().min(1),
name: z.string().min(1), name: z.string().min(1),
durationMs: z.number().positive(), kind: actionKindSchema.default('timed'),
group: actionGroupSchema,
durationMs: z.number().positive().optional(),
loopPriority: z.number().int().nonnegative().optional(),
costs: z.array(resourceAmountSchema).default([]), costs: z.array(resourceAmountSchema).default([]),
yields: z.array(resourceAmountSchema).min(1), yields: z.array(resourceAmountSchema).default([]),
unlock: unlockDefSchema.optional(), unlock: unlockDefSchema.optional(),
storyHint: z.string().min(1).optional(), storyHint: z.string().min(1).optional(),
storyTooltip: 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 ResourceDef = z.infer<typeof resourceDefSchema>;
export type ResourceAmount = z.infer<typeof resourceAmountSchema>; export type ResourceAmount = z.infer<typeof resourceAmountSchema>;
export type UnlockDef = z.infer<typeof unlockDefSchema>; 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 type ActionDef = z.infer<typeof actionDefSchema>;
export interface Content { 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', id: 'forage',
name: 'Forage', name: 'Forage',
group: { id: 'test', label: 'Test' },
durationMs: 300, durationMs: 300,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
}, },
+235 -3
View File
@@ -1,14 +1,23 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { content as gameContent } from '../../content/index';
import { buildContent } from '../../content/schema'; import { buildContent } from '../../content/schema';
import { buildStoryContent } from '../../content/storySchema';
import { import {
cancelQueuedAction, cancelQueuedAction,
canUnlockAction, canUnlockAction,
clearQueue, clearQueue,
createGameState, createGameState,
enqueueAction, enqueueAction,
executeInstant,
maybeStartLoopAction,
performAction,
recordManualCompletion,
startAction, startAction,
tickGame, tickGame,
} from '../game'; } from '../game';
import { enterStoryNode } from '../story';
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
function testContent() { function testContent() {
return buildContent({ return buildContent({
@@ -17,6 +26,7 @@ function testContent() {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 300, durationMs: 300,
yields: [{ resourceId: 'gold', amount: 2 }], yields: [{ resourceId: 'gold', amount: 2 }],
}, },
@@ -28,9 +38,27 @@ function queueContent() {
return buildContent({ return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
actions: [ actions: [
{ id: 'a', name: 'A', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, {
{ id: 'b', name: 'B', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, id: 'a',
{ id: 'c', name: 'C', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] }, 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', id: 'gather',
name: 'Gather', name: 'Gather',
group: DEFAULT_GROUP,
durationMs: 300, durationMs: 300,
yields: [{ resourceId: 'supplies', amount: 2 }], yields: [{ resourceId: 'supplies', amount: 2 }],
}, },
{ {
id: 'trade', id: 'trade',
name: 'Trade', name: 'Trade',
group: DEFAULT_GROUP,
durationMs: 300, durationMs: 300,
costs: [{ resourceId: 'supplies', amount: 5 }], costs: [{ resourceId: 'supplies', amount: 5 }],
yields: [{ resourceId: 'coin', amount: 3 }], yields: [{ resourceId: 'coin', amount: 3 }],
@@ -58,6 +88,7 @@ function costContent() {
{ {
id: 'scout', id: 'scout',
name: 'Scout', name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 300, durationMs: 300,
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
unlock: { minResources: { coin: 1 } }, unlock: { minResources: { coin: 1 } },
@@ -74,6 +105,11 @@ describe('createGameState()', () => {
expect(state.actionElapsedMs).toBe(0); expect(state.actionElapsedMs).toBe(0);
expect(state.actionQueue).toEqual([]); expect(state.actionQueue).toEqual([]);
}); });
it('initializes empty manual completion counts', () => {
const state = createGameState(testContent());
expect(state.manualCompletionCounts).toEqual({});
});
}); });
describe('createGameState() story fields', () => { describe('createGameState() story fields', () => {
@@ -204,6 +240,7 @@ describe('unlock conditions', () => {
{ {
id: 'secret', id: 'secret',
name: 'Secret', name: 'Secret',
group: DEFAULT_GROUP,
durationMs: 100, durationMs: 100,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
unlock: { requireStoryFlags: ['path_scouted'] }, unlock: { requireStoryFlags: ['path_scouted'] },
@@ -245,12 +282,14 @@ describe('completion advances queue', () => {
{ {
id: 'cheap', id: 'cheap',
name: 'Cheap', name: 'Cheap',
group: DEFAULT_GROUP,
durationMs: 100, durationMs: 100,
yields: [{ resourceId: 'supplies', amount: 1 }], yields: [{ resourceId: 'supplies', amount: 1 }],
}, },
{ {
id: 'dear', id: 'dear',
name: 'Dear', name: 'Dear',
group: DEFAULT_GROUP,
durationMs: 100, durationMs: 100,
costs: [{ resourceId: 'supplies', amount: 6 }], costs: [{ resourceId: 'supplies', amount: 6 }],
yields: [{ resourceId: 'supplies', amount: 1 }], yields: [{ resourceId: 'supplies', amount: 1 }],
@@ -258,6 +297,7 @@ describe('completion advances queue', () => {
{ {
id: 'free', id: 'free',
name: 'Free', name: 'Free',
group: DEFAULT_GROUP,
durationMs: 100, durationMs: 100,
yields: [{ resourceId: 'supplies', amount: 1 }], yields: [{ resourceId: 'supplies', amount: 1 }],
}, },
@@ -283,6 +323,7 @@ describe('completion advances queue', () => {
{ {
id: 'combo', id: 'combo',
name: 'Combo', name: 'Combo',
group: DEFAULT_GROUP,
durationMs: 100, durationMs: 100,
yields: [ yields: [
{ resourceId: 'a', amount: 2 }, { resourceId: 'a', amount: 2 },
@@ -316,6 +357,24 @@ describe('tickGame() completion result', () => {
const result = tickGame(state, content, 100); const result = tickGame(state, content, 100);
expect(result.completedActionIds).toEqual([]); 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()', () => { describe('tickGame()', () => {
@@ -356,3 +415,176 @@ describe('tickGame()', () => {
expect(state.activeActionId).toBeNull(); 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'; import { describe, expect, it } from 'vitest';
const ENGINE_DIR = join(import.meta.dirname, '..'); 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[]> { async function engineSourceFiles(): Promise<string[]> {
const entries = await readdir(ENGINE_DIR, { withFileTypes: true }); const entries = await readdir(ENGINE_DIR, { withFileTypes: true });
@@ -18,8 +51,8 @@ describe('engine purity', () => {
expect(files.length).toBeGreaterThan(0); expect(files.length).toBeGreaterThan(0);
for (const file of files) { for (const file of files) {
const source = await readFile(file, 'utf8'); const source = await readFile(file, 'utf8');
for (const pattern of FORBIDDEN) { for (const { pattern, label } of FORBIDDEN) {
expect(source, `${file} must stay free of ${pattern}`).not.toMatch(pattern); 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', id: 'forage',
name: 'Forage', name: 'Forage',
group: { id: 'test', label: 'Test' },
durationMs: 3000, durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
}, },
@@ -69,6 +70,31 @@ describe('createSave()', () => {
expect(save.state.currentStoryNodeId).toBe('route_a_beat'); expect(save.state.currentStoryNodeId).toBe('route_a_beat');
expect(save.state.seenStoryNodeIds).toHaveLength(2); 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', () => { describe('serialize / deserialize round-trip', () => {
@@ -83,6 +109,54 @@ describe('serialize / deserialize round-trip', () => {
const restored = fromExportString(toExportString(save)); const restored = fromExportString(toExportString(save));
expect(restored).toEqual(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', () => { 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()', () => { describe('applyOfflineProgress()', () => {
it('credits whole ticks of elapsed time to the active action', () => { it('credits whole ticks of elapsed time to the active action', () => {
const content = testContent(); const content = testContent();
+7
View File
@@ -13,6 +13,8 @@ import {
type StoryEvent, type StoryEvent,
} from '../story'; } from '../story';
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
function gameContent() { function gameContent() {
const base = buildContent({ const base = buildContent({
resources: [ resources: [
@@ -23,6 +25,7 @@ function gameContent() {
{ {
id: 'scout_path', id: 'scout_path',
name: 'Scout', name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000, durationMs: 1000,
costs: [], costs: [],
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
@@ -54,6 +57,7 @@ function gameContentWithActionTrigger() {
{ {
id: 'scout_path', id: 'scout_path',
name: 'Scout', name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000, durationMs: 1000,
costs: [], costs: [],
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
@@ -91,6 +95,7 @@ function gameContentWithThreshold() {
{ {
id: 'scout_path', id: 'scout_path',
name: 'Scout', name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000, durationMs: 1000,
costs: [], costs: [],
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
@@ -132,6 +137,7 @@ function gameContentWithFork() {
{ {
id: 'scout_path', id: 'scout_path',
name: 'Scout', name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000, durationMs: 1000,
costs: [], costs: [],
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
@@ -189,6 +195,7 @@ function gameContentWithGatedChoice() {
{ {
id: 'scout_path', id: 'scout_path',
name: 'Scout', name: 'Scout',
group: DEFAULT_GROUP,
durationMs: 1000, durationMs: 1000,
costs: [], costs: [],
yields: [{ resourceId: 'coin', amount: 1 }], 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;
}
}
}
+133 -1
View File
@@ -1,4 +1,9 @@
import type { Content } from '../content/schema'; 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. * Core game state and per-tick simulation.
@@ -22,6 +27,12 @@ export interface GameState {
currentStoryNodeId: string; currentStoryNodeId: string;
/** Story node ids the player has already seen. */ /** Story node ids the player has already seen. */
seenStoryNodeIds: string[]; 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 { export function createGameState(content: Content): GameState {
@@ -37,6 +48,9 @@ export function createGameState(content: Content): GameState {
storyFlags: {}, storyFlags: {},
currentStoryNodeId: '', currentStoryNodeId: '',
seenStoryNodeIds: [], 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); assertKnownAction(content, actionId);
deductCosts(state, content, actionId); deductCosts(state, content, actionId);
state.activeActionId = actionId; state.activeActionId = actionId;
@@ -110,6 +129,7 @@ function startNextFromQueue(state: GameState, content: Content): void {
} }
state.activeActionId = null; state.activeActionId = null;
state.actionElapsedMs = 0; state.actionElapsedMs = 0;
maybeRunAutomation(state, content);
} }
export interface TickResult { export interface TickResult {
@@ -156,23 +176,135 @@ export function clearQueue(state: GameState): void {
state.actionQueue.length = 0; 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 * Advance the active action by `tickMs`. On completion, grants yields and
* advances the queue actions do not auto-repeat when the queue is empty. * advances the queue actions do not auto-repeat when the queue is empty.
*/ */
export function tickGame(state: GameState, content: Content, tickMs: number): TickResult { export function tickGame(state: GameState, content: Content, tickMs: number): TickResult {
const completedActionIds: string[] = []; const completedActionIds: string[] = [];
if (!state.activeActionId) {
maybeRunAutomation(state, content);
if (!state.activeActionId) return { completedActionIds }; if (!state.activeActionId) return { completedActionIds };
}
state.actionElapsedMs += tickMs; state.actionElapsedMs += tickMs;
while (state.activeActionId) { while (state.activeActionId) {
const actionId = state.activeActionId; const actionId = state.activeActionId;
const action = content.actionsById[actionId]; const action = content.actionsById[actionId];
if (!action) return { completedActionIds }; if (!action) return { completedActionIds };
if (action.durationMs === undefined) return { completedActionIds };
if (state.actionElapsedMs < action.durationMs) return { completedActionIds }; if (state.actionElapsedMs < action.durationMs) return { completedActionIds };
state.actionElapsedMs -= action.durationMs; state.actionElapsedMs -= action.durationMs;
grantYields(state, content, actionId); grantYields(state, content, actionId);
recordManualCompletion(state, content, actionId);
completedActionIds.push(actionId); completedActionIds.push(actionId);
startNextFromQueue(state, content); 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({}), storyFlags: z.record(z.string(), z.boolean()).default({}),
currentStoryNodeId: z.string().default(''), currentStoryNodeId: z.string().default(''),
seenStoryNodeIds: z.array(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({ export const saveSchema = z.object({
@@ -55,6 +58,9 @@ export function createSave(state: GameState, now: number): SaveData {
storyFlags: { ...state.storyFlags }, storyFlags: { ...state.storyFlags },
currentStoryNodeId: state.currentStoryNodeId, currentStoryNodeId: state.currentStoryNodeId,
seenStoryNodeIds: [...state.seenStoryNodeIds], 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)); 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( export function applyChoice(
state: GameState, state: GameState,
content: GameContent, content: GameContent,
+16
View File
@@ -10,3 +10,19 @@ body {
min-height: 100dvh; min-height: 100dvh;
background-color: #020617; /* slate-950 */ 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 { createSave, serializeSave } from '../../engine/save';
import { createMemoryBackend, loadGame, saveGame } from '../persistence'; import { createMemoryBackend, loadGame, saveGame } from '../persistence';
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
function testContent() { function testContent() {
return buildContent({ return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
@@ -11,6 +13,7 @@ function testContent() {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 3000, durationMs: 3000,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
}, },
@@ -22,8 +25,20 @@ function queueTestContent() {
return buildContent({ return buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }], resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
actions: [ 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: {}, storyFlags: {},
currentStoryNodeId: '', currentStoryNodeId: '',
seenStoryNodeIds: [], seenStoryNodeIds: [],
enabledLoopActionIds: {},
manualCompletionCounts: {},
automationQueue: [],
}, },
1000, 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'; import { getPrefs, setPrefs } from '../prefs';
describe('prefs', () => { describe('prefs', () => {
@@ -14,12 +14,65 @@ describe('prefs', () => {
}); });
}); });
afterEach(() => {
vi.unstubAllGlobals();
});
it('returns defaults when localStorage empty', () => { 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', () => { it('round-trips updated prefs', () => {
setPrefs({ storyOpenMode: 'manual', actionDetailMode: 'hover' }); setPrefs({ storyOpenMode: 'manual', actionDetailMode: 'hover' });
expect(getPrefs().storyOpenMode).toBe('manual'); 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 { describe, expect, it } from 'vitest';
import { content } from '../../content/index';
import { buildContent } from '../../content/schema'; import { buildContent } from '../../content/schema';
import { buildStoryContent } from '../../content/storySchema'; 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 { enterStoryNode } from '../../engine/story';
import { formatOfflineDuration, toView } from '../viewModel'; import { formatOfflineDuration, toView } from '../viewModel';
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
function testContent() { function testContent() {
const base = buildContent({ const base = buildContent({
resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }], resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }],
@@ -12,6 +15,7 @@ function testContent() {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 200, durationMs: 200,
yields: [{ resourceId: 'gold', amount: 1 }], yields: [{ resourceId: 'gold', amount: 1 }],
}, },
@@ -87,8 +91,20 @@ describe('toView()', () => {
const content = contentWithActions( const content = contentWithActions(
[{ id: 'gold', name: 'Gold' }], [{ 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); const state = createGameState(content);
@@ -108,6 +124,7 @@ describe('toView() action availability', () => {
{ {
id: 'locked', id: 'locked',
name: 'Locked', name: 'Locked',
group: DEFAULT_GROUP,
durationMs: 1000, durationMs: 1000,
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
unlock: { requireStoryFlags: ['route_a'] }, unlock: { requireStoryFlags: ['route_a'] },
@@ -127,6 +144,7 @@ describe('toView() action availability', () => {
{ {
id: 'forage', id: 'forage',
name: 'Forage', name: 'Forage',
group: DEFAULT_GROUP,
durationMs: 1000, durationMs: 1000,
yields: [{ resourceId: 'coin', amount: 1 }], yields: [{ resourceId: 'coin', amount: 1 }],
}, },
@@ -188,3 +206,161 @@ describe('formatOfflineDuration()', () => {
expect(formatOfflineDuration(7_200_000)).toBe('2h'); 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 }, storyFlags: { ...save.state.storyFlags },
currentStoryNodeId: save.state.currentStoryNodeId ?? '', currentStoryNodeId: save.state.currentStoryNodeId ?? '',
seenStoryNodeIds: [...(save.state.seenStoryNodeIds ?? [])], seenStoryNodeIds: [...(save.state.seenStoryNodeIds ?? [])],
enabledLoopActionIds: { ...(save.state.enabledLoopActionIds ?? {}) },
manualCompletionCounts: { ...(save.state.manualCompletionCounts ?? {}) },
automationQueue: [...(save.state.automationQueue ?? [])],
}; };
savedAt = save.savedAt; savedAt = save.savedAt;
} catch { } 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 StoryOpenMode = 'auto' | 'choices-only' | 'manual';
export type ActionDetailMode = 'inline' | 'hover' | 'info-button'; export type ActionDetailMode = 'inline' | 'hover' | 'info-button';
@@ -6,18 +6,35 @@ export type ActionDetailMode = 'inline' | 'hover' | 'info-button';
export interface GamePrefs { export interface GamePrefs {
storyOpenMode: StoryOpenMode; storyOpenMode: StoryOpenMode;
actionDetailMode: ActionDetailMode; actionDetailMode: ActionDetailMode;
collapsedActionGroups: Record<string, boolean>;
showEventLog: boolean;
} }
const DEFAULTS: GamePrefs = { const DEFAULTS: GamePrefs = {
storyOpenMode: 'auto', storyOpenMode: 'auto',
actionDetailMode: 'inline', actionDetailMode: 'inline',
collapsedActionGroups: {},
showEventLog: true,
}; };
export function getPrefs(): GamePrefs { export function getPrefs(): GamePrefs {
if (typeof localStorage === 'undefined') return { ...DEFAULTS }; if (typeof localStorage === 'undefined') return { ...DEFAULTS };
try { try {
const raw = localStorage.getItem(PREFS_KEY); 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) }; return { ...DEFAULTS, ...JSON.parse(raw) };
} catch { } catch {
return { ...DEFAULTS }; return { ...DEFAULTS };
+145 -47
View File
@@ -1,20 +1,26 @@
import { content } from '../content'; import { content } from '../content';
import {
addToAutomationQueue,
maybeRunAutomation,
removeFromAutomationQueue,
} from '../engine/automation';
import { import {
cancelQueuedAction as engineCancelQueuedAction, cancelQueuedAction as engineCancelQueuedAction,
enqueueAction as engineEnqueueAction,
type GameState, type GameState,
isActionAvailable, maybeStartLoopAction,
performAction as performActionEngine,
tickGame, tickGame,
} from '../engine/game'; } from '../engine/game';
import { exportRecipe, importRecipe } from '../engine/recipe';
import { applyChoice as engineApplyChoice, enterStoryNode, initStory } from '../engine/story'; import { applyChoice as engineApplyChoice, enterStoryNode, initStory } from '../engine/story';
import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop'; import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop';
import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence'; import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence';
import { getPrefs } from './prefs'; import { getPrefs } from './prefs';
import { useGameStore } from './store'; import { type ActivePanel, useGameStore } from './store';
import { import {
processStoryTriggers, processStoryTriggers,
type StoryUiEffect, type StoryUiEffect,
shouldAutoOpenPanel, shouldAutoNavigateToStory,
storyEventsToLogEntries, storyEventsToLogEntries,
} from './storyOrchestration'; } from './storyOrchestration';
import { formatOfflineDuration, toView } from './viewModel'; import { formatOfflineDuration, toView } from './viewModel';
@@ -32,7 +38,7 @@ import { formatOfflineDuration, toView } from './viewModel';
const PUBLISH_INTERVAL_MS = 100; // ~10 fps view refresh const PUBLISH_INTERVAL_MS = 100; // ~10 fps view refresh
const AUTOSAVE_INTERVAL_MS = 10_000; const AUTOSAVE_INTERVAL_MS = 10_000;
class GameRuntime { export class GameRuntime {
private state: GameState | null = null; private state: GameState | null = null;
private readonly loop: TickLoop = createTickLoop(); private readonly loop: TickLoop = createTickLoop();
private readonly backend: SaveBackend = createDefaultBackend(); private readonly backend: SaveBackend = createDefaultBackend();
@@ -41,6 +47,16 @@ class GameRuntime {
private lastSaveAt = 0; private lastSaveAt = 0;
private booted = false; 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> { async boot(): Promise<void> {
if (this.booted) { if (this.booted) {
return; return;
@@ -75,49 +91,133 @@ class GameRuntime {
cancelAnimationFrame(this.rafId); cancelAnimationFrame(this.rafId);
this.rafId = null; 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 { enqueueAction(actionId: string): void {
this.performAction(actionId);
}
toggleAutomation(actionId: string): void {
const state = this.state; const state = this.state;
if (!state) { if (!state) return;
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 { try {
engineEnqueueAction(state, content, actionId); const existingIndex = state.automationQueue.indexOf(actionId);
const action = content.actionsById[actionId]; if (existingIndex >= 0) {
if (action) { removeFromAutomationQueue(state, existingIndex);
const verb = state.actionQueue.includes(actionId) ? 'Queued' : 'Started'; } else {
useGameStore.getState().appendLog(`${verb}: ${action.name}.`); addToAutomationQueue(state, content, actionId);
} maybeRunAutomation(state, content);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Cannot start action';
useGameStore.getState().appendLog(msg);
} }
this.publish(); 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);
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 { applyStoryChoice(choiceId: string): void {
const action = content.actions.find((a) => a.storyChoiceId === choiceId);
if (action) {
this.performAction(action.id);
} else {
const state = this.state; const state = this.state;
if (!state) return; if (!state) return;
try { try {
const events = engineApplyChoice(state, content, choiceId); const events = engineApplyChoice(state, content, choiceId);
const entries = storyEventsToLogEntries(events); const entries = storyEventsToLogEntries(events);
const store = useGameStore.getState(); 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); for (const entry of entries) store.appendStoryLog(entry);
const choiceLabel = entries.at(-1)?.choiceLabel; const choiceLabel = entries.at(-1)?.choiceLabel;
if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`); if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`);
store.setStoryPanelOpen(false);
this.runPublishTriggers(); this.runPublishTriggers();
this.publish(); this.publish();
} catch (err) { } catch (err) {
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Choice failed'); useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Choice failed');
} }
} }
}
cancelQueuedAction(index: number): void { cancelQueuedAction(index: number): void {
const state = this.state; const state = this.state;
@@ -131,15 +231,6 @@ class GameRuntime {
} }
} }
openStoryPanel(): void {
useGameStore.getState().setStoryPanelOpen(true);
useGameStore.getState().setStoryHasUnread(false);
}
closeStoryPanel(): void {
useGameStore.getState().setStoryPanelOpen(false);
}
continueStory(): void { continueStory(): void {
const state = this.state; const state = this.state;
if (!state) return; if (!state) return;
@@ -149,30 +240,36 @@ class GameRuntime {
const store = useGameStore.getState(); const store = useGameStore.getState();
for (const entry of entries) store.appendStoryLog(entry); for (const entry of entries) store.appendStoryLog(entry);
for (const entry of entries) { 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; const prefs = store.prefs;
if (shouldAutoOpenPanel(prefs, 'fork_choice', content)) { if (shouldAutoNavigateToStory(prefs, 'fork_choice', content)) {
store.setStoryPanelOpen(true); this.setActivePanel('story');
store.setStoryHasUnread(false);
} else { } else {
store.setStoryHasUnread(true); store.setStoryHasUnread(true);
store.setStoryPanelOpen(false);
} }
this.publish(); this.publish();
return; return;
} }
this.closeStoryPanel(); this.setActivePanel('play');
this.publish(); this.publish();
} }
private applyStoryUiEffect(effect: StoryUiEffect): void { private applyStoryUiEffect(effect: StoryUiEffect): void {
const store = useGameStore.getState(); const store = useGameStore.getState();
const prefs = store.prefs;
for (const entry of effect.logEntries) store.appendStoryLog(entry); for (const entry of effect.logEntries) store.appendStoryLog(entry);
for (const line of effect.eventLogLines) store.appendLog(line); for (const line of effect.eventLogLines) store.appendLog(line);
if (effect.shouldOpenPanel) { if (effect.shouldOpenPanel) {
store.setStoryPanelOpen(true); if (prefs.storyOpenMode === 'auto') {
store.setActivePanel('story');
store.setStoryHasUnread(false); store.setStoryHasUnread(false);
} else {
store.setStoryHasUnread(true);
}
} else if (effect.enteredNodeIds.length > 0) { } else if (effect.enteredNodeIds.length > 0) {
store.setStoryHasUnread(true); store.setStoryHasUnread(true);
} }
@@ -201,6 +298,7 @@ class GameRuntime {
); );
this.applyStoryUiEffect(effect); this.applyStoryUiEffect(effect);
} }
maybeStartLoopAction(state, content);
}); });
if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) { if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) {
@@ -234,15 +332,15 @@ class GameRuntime {
} }
private installLifecycleHooks(): void { private installLifecycleHooks(): void {
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', this.visibilityChangeListener);
if (document.visibilityState === 'hidden') { window.addEventListener('beforeunload', this.beforeUnloadListener);
void this.save();
}
});
window.addEventListener('beforeunload', () => {
void this.save();
});
} }
} }
export const gameRuntime = new GameRuntime(); 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; choiceLabel?: string;
} }
export type ActivePanel = 'play' | 'story' | 'settings' | 'about';
export interface GameStoreState extends GameView { export interface GameStoreState extends GameView {
log: string[]; log: string[];
storyPanelOpen: boolean;
storyHasUnread: boolean; storyHasUnread: boolean;
storyLog: StoryLogEntry[]; storyLog: StoryLogEntry[];
prefs: GamePrefs; prefs: GamePrefs;
settingsOpen: boolean; activePanel: ActivePanel;
selectedStoryNodeId: string | null;
setView: (view: GameView) => void; setView: (view: GameView) => void;
appendLog: (line: string) => void; appendLog: (line: string) => void;
appendStoryLog: (entry: StoryLogEntry) => void; appendStoryLog: (entry: StoryLogEntry) => void;
setStoryPanelOpen: (open: boolean) => void;
setStoryHasUnread: (unread: boolean) => void; setStoryHasUnread: (unread: boolean) => void;
setPrefs: (partial: Partial<GamePrefs>) => 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: [], resources: [],
activeActionId: null, activeActionId: null,
actionName: null, actionName: null,
actionProgress: 0, actionProgress: 0,
queuedActionIds: [], queuedActionIds: [],
queuedActionNames: [], queuedActionNames: [],
automationQueueIds: [],
automationQueueNames: [],
actions: [], actions: [],
story: { currentProse: null, choices: [] }, story: { currentProse: null, atBootIntro: false, choices: [], tree: [] },
actionColumns: [],
log: [], log: [],
storyPanelOpen: false,
storyHasUnread: false, storyHasUnread: false,
storyLog: [], storyLog: [],
prefs: getPrefs(), prefs: getPrefs(),
settingsOpen: false, activePanel: 'play',
selectedStoryNodeId: null,
setView: (view) => set((state) => ({ ...state, ...view })), setView: (view) => set((state) => ({ ...state, ...view })),
appendLog: (line) => set((state) => ({ log: [...state.log, line].slice(-MAX_LOG_LINES) })), appendLog: (line) => set((state) => ({ log: [...state.log, line].slice(-MAX_LOG_LINES) })),
appendStoryLog: (entry) => set((state) => ({ storyLog: [...state.storyLog, entry] })), appendStoryLog: (entry) => set((state) => ({ storyLog: [...state.storyLog, entry] })),
setStoryPanelOpen: (open) => set({ storyPanelOpen: open }),
setStoryHasUnread: (unread) => set({ storyHasUnread: unread }), setStoryHasUnread: (unread) => set({ storyHasUnread: unread }),
setPrefs: (partial) => { setPrefs: (partial) => {
const prefs = persistPrefs(partial); const prefs = persistPrefs(partial);
set({ prefs }); 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 });
},
})); }));
+9 -5
View File
@@ -17,7 +17,7 @@ export function storyEventsToLogEntries(events: StoryEvent[]): StoryLogEntry[] {
.map((e) => ({ nodeId: e.nodeId, prose: e.prose, choiceLabel: e.choiceLabel })); .map((e) => ({ nodeId: e.nodeId, prose: e.prose, choiceLabel: e.choiceLabel }));
} }
export function shouldAutoOpenPanel( export function shouldAutoNavigateToStory(
prefs: GamePrefs, prefs: GamePrefs,
nodeId: string, nodeId: string,
content: GameContent, content: GameContent,
@@ -39,11 +39,15 @@ export function processStoryTriggers(
const logEntries = storyEventsToLogEntries(events); const logEntries = storyEventsToLogEntries(events);
const shouldOpenPanel = const shouldOpenPanel =
enteredNodeIds.length > 0 && enteredNodeIds.length > 0 &&
enteredNodeIds.some((id) => shouldAutoOpenPanel(prefs, id, content)); enteredNodeIds.some((id) => shouldAutoNavigateToStory(prefs, id, content));
const eventLogLines = logEntries.map((e) => const customLogs = events.filter((e) => e.kind === 'log').map((e) => e.prose);
const eventLogLines = [
...customLogs,
...logEntries.map((e) =>
e.choiceLabel e.choiceLabel
? `Story: ${e.choiceLabel}` ? `Story: ${e.choiceLabel}`
: `Story: ${content.storyNodesById[e.nodeId]?.prose.slice(0, 40)}`, : `Story: ${(content.storyNodesById[e.nodeId]?.prose ?? '').slice(0, 40)}`,
); ),
];
return { enteredNodeIds, logEntries, shouldOpenPanel, eventLogLines }; return { enteredNodeIds, logEntries, shouldOpenPanel, eventLogLines };
} }
+142 -6
View File
@@ -1,11 +1,12 @@
import type { GameContent } from '../content/index'; import type { GameContent } from '../content/index';
import { isAutomationUnlocked } from '../engine/automation';
import { import {
canAffordAction, canAffordAction,
canUnlockAction, canUnlockAction,
type GameState, type GameState,
isActionAvailable, isActionAvailable,
} from '../engine/game'; } 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. * Pure mapping from engine state to the view model the React shell renders.
@@ -18,6 +19,17 @@ export interface ResourceView {
amount: number; 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 { export interface ActionView {
id: string; id: string;
name: string; name: string;
@@ -27,6 +39,22 @@ export interface ActionView {
storyTooltip?: string; storyTooltip?: string;
costsSummary: string | null; costsSummary: string | null;
yieldsSummary: 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 { export interface StoryChoiceView {
@@ -36,9 +64,19 @@ export interface StoryChoiceView {
disabledReason: string | null; disabledReason: string | null;
} }
export interface StoryTreeNodeView {
id: string;
label: string;
seen: boolean;
active: boolean;
children: StoryTreeNodeView[];
}
export interface StoryView { export interface StoryView {
currentProse: string | null; currentProse: string | null;
atBootIntro: boolean;
choices: StoryChoiceView[]; choices: StoryChoiceView[];
tree: StoryTreeNodeView[];
} }
export interface GameView { export interface GameView {
@@ -49,8 +87,11 @@ export interface GameView {
actionProgress: number; actionProgress: number;
queuedActionIds: string[]; queuedActionIds: string[];
queuedActionNames: string[]; queuedActionNames: string[];
automationQueueIds: string[];
automationQueueNames: string[];
actions: ActionView[]; actions: ActionView[];
story: StoryView; story: StoryView;
actionColumns: ActionColumnView[];
} }
function actionDisabledReason( function actionDisabledReason(
@@ -72,6 +113,38 @@ function formatResourceList(
.join(', '); .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 { export function toView(state: GameState, content: GameContent): GameView {
const resources: ResourceView[] = content.resources.map((resource) => ({ const resources: ResourceView[] = content.resources.map((resource) => ({
id: resource.id, 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 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 queuedActionIds = [...state.actionQueue];
const queuedActionNames = queuedActionIds.map((id) => content.actionsById[id]?.name ?? id); 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) => ({ // 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, id: a.id,
name: a.name, name: a.name,
available: isActionAvailable(state, content, a.id), available,
disabledReason: actionDisabledReason(state, content, a.id), disabledReason: isStory ? null : actionDisabledReason(state, content, a.id),
storyHint: a.storyHint, storyHint: a.storyHint,
storyTooltip: a.storyTooltip, storyTooltip: a.storyTooltip,
costsSummary: a.costs.length ? formatResourceList(a.costs, content) : null, costsSummary: a.costs.length ? formatResourceList(a.costs, content) : null,
yieldsSummary: formatResourceList(a.yields, content), 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 node = getCurrentNode(state, content);
const availableChoices = getAvailableChoices(state, content); const availableChoices = getAvailableChoices(state, content);
const allChoices = node?.choices ?? []; 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 = { const story: StoryView = {
currentProse: node?.prose ?? null, currentProse: node?.prose ?? null,
atBootIntro,
choices: allChoices.map((choice) => { choices: allChoices.map((choice) => {
const available = availableChoices.some((c) => c.id === choice.id); const available = availableChoices.some((c) => c.id === choice.id);
return { return {
@@ -110,6 +242,7 @@ export function toView(state: GameState, content: GameContent): GameView {
disabledReason: available ? null : 'Requirements not met', disabledReason: available ? null : 'Requirements not met',
}; };
}), }),
tree: buildStoryTree(state, content),
}; };
return { return {
@@ -119,8 +252,11 @@ export function toView(state: GameState, content: GameContent): GameView {
actionProgress, actionProgress,
queuedActionIds, queuedActionIds,
queuedActionNames, queuedActionNames,
automationQueueIds,
automationQueueNames,
actions, actions,
story, 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>
);
}
+27 -47
View File
@@ -1,65 +1,45 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { gameRuntime } from '../state/runtime'; import { gameRuntime } from '../state/runtime';
import { useGameStore } from '../state/store'; import { useGameStore } from '../state/store';
import { ActionPanel } from './ActionPanel'; import { AboutPanel } from './AboutPanel';
import { EventLog } from './EventLog'; import { AppShell } from './AppShell';
import { ResourceBar } from './ResourceBar'; import { NavRail } from './NavRail';
import { SettingsDrawer } from './SettingsDrawer'; import { PlayPanel } from './PlayPanel';
import { StoryPanel } from './StoryPanel'; import { RightRail } from './RightRail';
import { SettingsPanel } from './SettingsPanel';
import { StoryView } from './StoryView';
/** /**
* M1 playable-loop shell. Boots the runtime once on mount; everything else * M1 playable-loop shell. Boots the runtime once on mount; everything else
* renders from the Zustand store the runtime feeds. * renders from the Zustand store the runtime feeds.
*/ */
export function App() { export function App() {
const storyHasUnread = useGameStore((s) => s.storyHasUnread); const activePanel = useGameStore((s) => s.activePanel);
const settingsOpen = useGameStore((s) => s.settingsOpen);
const setSettingsOpen = useGameStore((s) => s.setSettingsOpen);
useEffect(() => { useEffect(() => {
void gameRuntime.boot(); 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 ( return (
<> <AppShell
<StoryPanel /> nav={<NavRail />}
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col gap-5 px-4 py-8 text-slate-100"> center={renderActivePanel()}
<header className="flex flex-col gap-2"> right={activePanel === 'play' ? <RightRail /> : undefined}
<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>
</>
); );
} }
+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>
);
}