Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1db07d997 | ||
|
|
b8c98a9b42 | ||
|
|
ddbf9c0045 | ||
|
|
29bc4d4bc5 | ||
|
|
4b5372cdf9 | ||
|
|
e2a44bec7d | ||
|
|
a7ecf2225b | ||
|
|
79f1251cad | ||
|
|
4a85bf85ae | ||
|
|
d625216018 | ||
|
|
4b7adf718d | ||
|
|
f7ad9e7ecd | ||
|
|
977dd8e878 | ||
|
|
4d508ae77c | ||
|
|
10a43729c5 | ||
|
|
880abe5888 | ||
|
|
91348ed42f | ||
|
|
e9f2514308 | ||
|
|
75273a76de | ||
|
|
bbded8a266 | ||
|
|
08d186f42e | ||
|
|
232b84299c | ||
|
|
ee79872d4d | ||
|
|
ed9607e9e8 | ||
|
|
b29b17c6cb | ||
|
|
239b2506ec | ||
|
|
adf730702d | ||
|
|
09e2d06b87 | ||
|
|
9b2793b264 | ||
|
|
73d836fe96 | ||
|
|
163f710f4c | ||
|
|
0de61d3e41 | ||
|
|
8d9484171f | ||
|
|
b3940774c4 | ||
|
|
b54805637c | ||
|
|
c1b38e36aa | ||
|
|
c48e53cc7b | ||
|
|
c76b2d1231 | ||
|
|
203478268c | ||
|
|
57f037b62b |
@@ -16,6 +16,9 @@ coverage/
|
||||
.claude/settings.local.json
|
||||
.vite/
|
||||
|
||||
# Git worktrees (local isolation)
|
||||
.worktrees/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
+72
-2
@@ -25,6 +25,12 @@ storage, requestAnimationFrame, or Date scheduling.
|
||||
resource and one timed action. M1 expands this into real resources, actions,
|
||||
story nodes, automation unlocks, and prestige definitions.
|
||||
|
||||
Each action carries a behavior `kind` (`instant`, `loop`, `timed`, `story`,
|
||||
`context`) and a `group` (`{ id, label }`) used for UI layout. The schema
|
||||
enforces kind-specific invariants: `timed`/`loop` require `durationMs`, `timed`
|
||||
requires at least one yield, and `story` requires a `storyChoiceId` linking the
|
||||
action to a choice on the current story node.
|
||||
|
||||
## State
|
||||
|
||||
`src/state/` owns environment coupling:
|
||||
@@ -42,8 +48,72 @@ story nodes, automation unlocks, and prestige definitions.
|
||||
## UI
|
||||
|
||||
`src/ui/` renders the view model and calls runtime commands. Components should
|
||||
not implement gameplay rules. The M0 shell includes a resource readout, action
|
||||
panel, progress bar, and event log.
|
||||
not implement gameplay rules.
|
||||
|
||||
### Shell layout
|
||||
|
||||
PR3 replaces the M0/PR2 single-column overlay with a three-region shell
|
||||
(`AppShell.tsx`): a left **nav rail** (Play / Story / Settings / About), a
|
||||
**center** panel for the active tab, and a **right rail** shown on Play
|
||||
(resources, an inventory placeholder, and an optional event log gated by the
|
||||
`showEventLog` pref). `store.activePanel` selects the center panel; there is no
|
||||
modal overlay. New story beats raise a nav badge (`storyHasUnread`), and
|
||||
`storyOpenMode: auto` switches to the Story tab instead of opening an overlay.
|
||||
|
||||
### Action kinds
|
||||
|
||||
Play organizes actions into **columns by behavior kind**, ordered
|
||||
`instant → loop → timed → story → context`, with collapsible theme **groups**
|
||||
inside each column (collapse state persists in `prefs.collapsedActionGroups`).
|
||||
The pure engine dispatches each kind through `performAction` (`game.ts`):
|
||||
`instant` applies costs/yields immediately, `timed` enqueues, `loop` toggles an
|
||||
entry in `enabledLoopActionIds` (an idle runner starts the highest-priority
|
||||
affordable loop only when the queue is empty and only during live ticks, never
|
||||
offline), and `story` applies the linked choice. Story forks are taken **only**
|
||||
through Story-kind actions; the Story tab itself is read-only.
|
||||
|
||||
### Story tab
|
||||
|
||||
`StoryView.tsx` is a read-only 60/40 split: a branching `StoryTree` built from
|
||||
the story graph's choice/trigger edges (seen paths emphasized, unseen dimmed) and
|
||||
a scrollable `StoryProseLog` with no choice buttons. The boot intro shows a
|
||||
single Continue affordance that advances `boot_intro → fork_choice`; thereafter
|
||||
progression is driven by Story-kind actions.
|
||||
|
||||
The full design lives in `docs/superpowers/specs/2026-06-11-m1-pr3-shell-ui-design.md`.
|
||||
|
||||
### Automation
|
||||
|
||||
Automation is universal across action kinds once an action has at least its
|
||||
configured number of successful completions (`automation.unlockAfterManualCompletions`,
|
||||
default `1`). The engine stores those counts in
|
||||
`GameState.manualCompletionCounts` and stores configured automation in
|
||||
`GameState.automationQueue`.
|
||||
|
||||
`src/engine/automation.ts` owns unlock checks, queue mutation, and the runner.
|
||||
The runner preserves precedence: manual active/queued actions first,
|
||||
automation second, loop-idle actions last. `tickGame` can start automation when
|
||||
the manual queue exhausts, including during offline catch-up; loop actions still
|
||||
start only from the live runtime path.
|
||||
|
||||
`src/engine/recipe.ts` serializes automation queues as shareable text using
|
||||
content action ids. The v1 multiline format is:
|
||||
|
||||
```text
|
||||
idlegame-recipe/v1
|
||||
# name: Camp loop
|
||||
gather_supplies
|
||||
rest
|
||||
```
|
||||
|
||||
The single-line alias is:
|
||||
|
||||
```text
|
||||
idlegame-recipe/v1:gather_supplies,rest
|
||||
```
|
||||
|
||||
Import rejects unknown action ids and actions that are not automation-unlocked
|
||||
for the current save.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
# Idlegame — M1 Vertical Slice Plan (handoff artifact for superpowers)
|
||||
|
||||
> **How to use this file:** the M1 brainstorm is complete; everything below is locked (approved by ginnoir 2026-06-11). Hand this plan to superpowers for execution. Tasks are grouped into **four phased PR batches** plus a **Phase 0 vault gate** for story content. Each phase ends at a browser-playable checkpoint. One tool per branch; everything lands via PR.
|
||||
>
|
||||
> **Canonical location:** `docs/plans/2026-06-11-m1-vertical-slice.md` in the working dir.
|
||||
|
||||
## Context
|
||||
|
||||
M0 is complete (2026-06-11). The repo ships a walking skeleton: one resource, one timed action, fixed-timestep tick loop with offline catch-up, versioned saves (v1, reject-on-mismatch), Zustand bridge, minimal React shell, CI + internal playtest deploy at `https://idlegame.ginnoir.com/`. Vault notes exist: `Idlegame/GDD.md`, `Idlegame/Decisions.md`, `Idlegame/Story/Outline.md` (beat skeleton placeholder — prose TBD).
|
||||
|
||||
**M1 scope:** turn the skeleton into the first playable vertical slice — one short story arc through the first prestige reset, with a meaningful branching choice, one automation unlock, offline progress, and save/load (including migration scaffolding). Ten Gitea issues (#3–#12) under milestone **M1 Vertical Slice** map to this plan.
|
||||
|
||||
**Execution mode (locked):** phased PR batches with playable checkpoints after each merge — not one mega-run, not one-PR-per-issue.
|
||||
|
||||
## M1 success criteria
|
||||
|
||||
A player on the internal playtest build can:
|
||||
|
||||
1. Queue multiple timed actions and watch resources accrue with costs/unlocks respected.
|
||||
2. Read story passages and make a branching choice where routes A and B produce visibly different outcomes (flags, resources, or event-log entries).
|
||||
3. Unlock automation for at least one action after completing it manually once.
|
||||
4. Finish the opening arc and trigger the first prestige reset, retaining knowledge for catch-up on the next run.
|
||||
5. Reload, export/import a save, and play comfortably on a phone viewport.
|
||||
|
||||
## Locked M1 decisions (brainstorm, 2026-06-11)
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Phase structure | **Approach 1 — playable-first ladder:** 4 PRs + Phase 0 vault work |
|
||||
| Story timing | Phase 0 outline runs **in parallel** with PR1–2; stub prose proves branch mechanics; **ginnoir review gate** before PR3 merges real content |
|
||||
| Stub content | Placeholder names/prose allowed in PR1–2; replaced entirely in PR3 |
|
||||
| Prestige depth | First reset only — within-layer catch-up (knowledge flags, fast-forward); **no new mechanics between layers** (post-M1) |
|
||||
| Save version | Bump to v2 in PR4 when story/queue/automation/prestige fields land; v1→v2 migration required |
|
||||
| Balance | Tunable constants in content defs; notes filed in vault during T4.3 |
|
||||
|
||||
Design pillars unchanged from M0 — see `Idlegame/GDD.md` and `docs/plans/2026-06-11-m0-bootstrap.md` §Locked design pillars.
|
||||
|
||||
## Starting codebase state
|
||||
|
||||
```
|
||||
src/engine/ game.ts (single active action, no queue)
|
||||
tickLoop.ts (accumulator + maxTicks cap)
|
||||
save.ts (SAVE_VERSION=1, reject mismatch)
|
||||
num.ts
|
||||
src/content/ one resource (gold), one action (forage)
|
||||
src/state/ runtime (RAF loop), persistence (idb + localStorage)
|
||||
src/ui/ ResourceBar, ActionPanel, EventLog stubs
|
||||
```
|
||||
|
||||
Architecture boundaries unchanged — see `docs/architecture.md`.
|
||||
|
||||
## Phase map
|
||||
|
||||
| Phase | Branch | Closes Gitea | Playable checkpoint |
|
||||
|---|---|---|---|
|
||||
| **0** | *(vault, no PR)* | prep for #11 | Opening arc beats outlined; prose drafted for ginnoir review |
|
||||
| **1** | `feat/m1-foundations` | #3, #4 | Queue 3+ actions; multi-resource costs/unlocks work |
|
||||
| **2** | `feat/m1-playable-loop` | #5, #6 | Stub story branch — pick A vs B, outcomes differ |
|
||||
| **3** | `feat/m1-progression` | #7, #8, #11 | Full arc → prestige; second run catches up |
|
||||
| **4** | `feat/m1-polish` | #9, #10, #12 | Mobile-ready; save export/import UX; balance tuned; M1 closed |
|
||||
|
||||
## Dependency graph
|
||||
|
||||
```text
|
||||
Phase 0 (outline) ──────────────────────────────┐
|
||||
│ gate before PR3 content
|
||||
PR1 foundations ──► PR2 story+queue UI ──► PR3 progression ──► PR4 polish
|
||||
│ │ │
|
||||
└─ stub content └─ stub story graph └─ real content replaces stubs
|
||||
```
|
||||
|
||||
PR1 must merge before PR2 (story graph assumes expanded game state + queue). PR3 requires Phase 0 approval for T3.3. PR4 assumes all gameplay systems exist.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Story outline (vault, parallel with PR1–2)
|
||||
|
||||
**Tool:** Claude Code (+ ginnoir review in chat/Cursor)
|
||||
|
||||
**Work:**
|
||||
|
||||
1. Fill `Idlegame/Story/Outline.md` §M1 arc beat skeleton: Hook → First loop → Branch (routes A/B) → Automation beat → Prestige climax.
|
||||
2. Draft passage prose for each beat; ginnoir reviews/edits in vault.
|
||||
3. Record any new design decisions in `Idlegame/Decisions.md`.
|
||||
|
||||
**Gate:** ginnoir approves the outline before PR3 branch `feat/m1-progression` merges. Does **not** block PR1 or PR2.
|
||||
|
||||
**Verify:** vault note has all five beats populated; ginnoir sign-off recorded in session log or Decisions.
|
||||
|
||||
---
|
||||
|
||||
## PR1 — Foundations (`feat/m1-foundations`)
|
||||
|
||||
**Primary tool:** Codex (GPT-5.5-Codex medium). Alt: Cursor agent (Composer 2.5).
|
||||
|
||||
**Gitea closes:** #3 Engine tick and offline hardening, #4 Resource and action definition set.
|
||||
|
||||
### T1.1 — Engine tick and offline hardening (#3)
|
||||
|
||||
- Expand determinism regression tests: same elapsed wall time → same tick count and game state regardless of `advance()` chunking.
|
||||
- Document offline behavior: tick loop `maxTicks` batching vs save-layer `DEFAULT_MAX_OFFLINE_MS` clamp (already in `save.ts`).
|
||||
- Add/extend engine purity guard: no React/DOM imports under `src/engine/` (test or lint rule if lightweight).
|
||||
- Keep `tickGame` pure; queue logic lands in T1.3.
|
||||
|
||||
*Verify:* `pnpm test:coverage` green; engine ≥80%; determinism test passes.
|
||||
|
||||
### T1.2 — Content schema expansion (#4)
|
||||
|
||||
- Extend Zod schemas in `src/content/`: action **costs** (deduct on **start**; reject enqueue/start if unaffordable), **unlock conditions** (resource thresholds and/or story flags placeholder field for PR2), optional multi-yield. Record in `Idlegame/Decisions.md` as D-0010 if not already covered.
|
||||
- Validation tests for malformed defs.
|
||||
|
||||
*Verify:* schema tests green; invalid content rejected at load boundary.
|
||||
|
||||
### T1.3 — Action queue engine
|
||||
|
||||
- Extend `GameState`: `actionQueue: string[]` (ordered action ids), retain `activeActionId` + `actionElapsedMs`.
|
||||
- API: `enqueueAction`, `cancelQueuedAction(index)`, `clearQueue` (if scoped); on action completion, auto-start next queued action.
|
||||
- `tickGame` unchanged semantics for active action; completion handler dequeues.
|
||||
- Unit tests: queue ordering, cancel, auto-advance, empty queue idle.
|
||||
|
||||
*Verify:* queue tests green; no UI required yet.
|
||||
|
||||
### T1.4 — Stub content pack
|
||||
|
||||
- Replace walking-skeleton defs with M1-shaped placeholder set: **2 resources**, **4–5 timed actions** with costs/unlocks (generic names like "Supplies", "Scout the path").
|
||||
- Keep human-readable durations (seconds-scale for dev; balance in PR4).
|
||||
|
||||
*Verify:* action completion + cost/unlock accrual tests green.
|
||||
|
||||
### PR1 integration verify
|
||||
|
||||
```powershell
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm test:coverage
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Manual playtest: enqueue 3 actions → they run sequentially → resources reflect costs/yields → reload preserves queue state.
|
||||
|
||||
**PR:** conventional commits, link `Closes #3`, `Closes #4` in merge commit or PR body.
|
||||
|
||||
---
|
||||
|
||||
## PR2 — Playable loop (`feat/m1-playable-loop`)
|
||||
|
||||
**Primary tools:** Codex (T2.1–T2.2 engine), Antigravity (T2.3–T2.5 UI + browser verify).
|
||||
|
||||
**Gitea closes:** #5 Action queue UI, #6 Story graph and first branch.
|
||||
|
||||
### T2.1 — Story graph engine (#6)
|
||||
|
||||
- New module `src/engine/story.ts` (or `storyGraph.ts`): traverse nodes, evaluate choice requirements, apply outcomes (set flags, grant/consume resources, route to next node).
|
||||
- Extend `GameState`: `storyFlags: Record<string, boolean>`, `currentStoryNodeId: string`, `seenStoryNodeIds: string[]` (for prestige catch-up in PR3).
|
||||
- Emit story events for the event log (pure data — UI renders).
|
||||
- Unit tests: linear traversal, branch by choice, gated node blocked until requirements met.
|
||||
|
||||
*Verify:* story traversal tests green; engine stays React-free.
|
||||
|
||||
### T2.2 — Story content schema
|
||||
|
||||
- Zod-validated story node defs in `src/content/`: id, prose, choices (label, requirements, outcomes, target node), auto-advance nodes optional.
|
||||
- Wire into content loader alongside resources/actions.
|
||||
|
||||
*Verify:* schema validation tests; invalid graph (dangling node id) fails at load.
|
||||
|
||||
### T2.3 — Action queue UI (#5)
|
||||
|
||||
- Upgrade `ActionPanel`: enqueue button per unlocked action, visible queue list, cancel queued item, active action progress bar (extend M0 pattern).
|
||||
- Disabled state when action locked or unaffordable.
|
||||
|
||||
*Verify:* browser — queue 2+ actions, cancel one, watch order respected.
|
||||
|
||||
### T2.4 — Story panel UI
|
||||
|
||||
- New component: current passage prose, choice buttons when node has choices, integrate with runtime commands.
|
||||
- Event log receives story transition entries.
|
||||
|
||||
*Verify:* browser — story panel renders; choice click advances node.
|
||||
|
||||
### T2.5 — Stub story graph
|
||||
|
||||
- ~6 nodes, **one branching choice** (routes A/B), placeholder prose proving mechanics.
|
||||
- Hook stub graph to stub actions (e.g., branch unlocks different actions).
|
||||
|
||||
*Verify:* browser — complete route A vs route B → different flags/resources/log entries.
|
||||
|
||||
### PR2 integration verify
|
||||
|
||||
Full pre-PR chain + desktop browser smoke. Stub prose is explicitly temporary.
|
||||
|
||||
**PR:** link `Closes #5`, `Closes #6`.
|
||||
|
||||
---
|
||||
|
||||
## PR3 — Progression (`feat/m1-progression`)
|
||||
|
||||
**Gate:** Phase 0 outline approved by ginnoir.
|
||||
|
||||
**Primary tools:** Claude Code (T3.3 content encode + prose fidelity), Codex (T3.1–T3.2 engine).
|
||||
|
||||
**Gitea closes:** #7 Automation unlock, #8 First prestige reset, #11 Opening arc content.
|
||||
|
||||
### T3.1 — Automation unlock (#7)
|
||||
|
||||
- Track per-action manual completion count in `GameState`.
|
||||
- After first manual completion, action becomes **automatable** (toggle or auto-enqueue repeat — pick enqueue-repeat for M1 simplicity).
|
||||
- Automation respects costs; stops if unaffordable (document behavior).
|
||||
- In-fiction story beat references automation (content in T3.3).
|
||||
|
||||
*Verify:* tests — locked before first completion, unlocked after; automated repeats fire.
|
||||
|
||||
### T3.2 — First prestige reset (#8)
|
||||
|
||||
- Prestige trigger: story node or dedicated action at arc end.
|
||||
- Reset: clear run resources, queue, active action; **retain** `prestigeLayer`, `knowledgeFlags` / `seenStoryNodeIds`, automation unlocks.
|
||||
- Catch-up: known story nodes fast-forward (skip prose or abbreviated passage — implement minimal fast-forward for M1).
|
||||
- `prestigeCount` increment; new prestige/story fields added to runtime + save payload in PR3 (still `SAVE_VERSION=1` until PR4); PR4 bumps version and ships v1→v2 migration.
|
||||
|
||||
*Verify:* tests — reset clears run state, retains knowledge; second run skips/fast-forwards seen nodes; browser full arc → prestige → new run starts faster.
|
||||
|
||||
### T3.3 — Opening arc content (#11)
|
||||
|
||||
- Replace stub resources/actions/story graph with vault-approved opening arc from `Idlegame/Story/Outline.md`.
|
||||
- Encode prose into `src/content/` story defs; link vault note in PR description.
|
||||
- Branch choice must **matter** (different content, not flavor-only — per GDD).
|
||||
|
||||
*Verify:* content schema tests; ginnoir in-app text review; vault cross-link present.
|
||||
|
||||
### PR3 integration verify
|
||||
|
||||
Full pre-PR chain. Playtest: start → branch → automation unlock → prestige → second run with catch-up. File brief playtest notes in vault.
|
||||
|
||||
**PR:** link `Closes #7`, `Closes #8`, `Closes #11`.
|
||||
|
||||
---
|
||||
|
||||
## PR4 — Polish & close (`feat/m1-polish`)
|
||||
|
||||
**Primary tools:** Codex (T4.1 save), Antigravity (T4.2 mobile), Claude Code + ginnoir (T4.3 balance).
|
||||
|
||||
**Gitea closes:** #9 Save migrations and import/export UX, #10 Mobile layout pass, #12 Balance pass.
|
||||
|
||||
### T4.1 — Save migrations and import/export UX (#9)
|
||||
|
||||
- Bump `SAVE_VERSION` to 2; implement v1→v2 migration (add new fields with sane defaults).
|
||||
- Migration registry pattern for future versions.
|
||||
- Settings UI: export save string (copy), import with validation + explicit confirm (overwrite).
|
||||
- Tampered import rejected with user-visible error.
|
||||
|
||||
*Verify:* migration unit tests; tamper rejection; browser export → clear storage → import → state restored.
|
||||
|
||||
### T4.2 — Mobile layout pass (#10)
|
||||
|
||||
- 375px viewport: no clipping/overlap; touch targets adequate; story panel + queue usable one-handed.
|
||||
- Desktop unchanged unless fixes apply globally.
|
||||
|
||||
*Verify:* Antigravity screenshots desktop + mobile; core flow works on mobile viewport.
|
||||
|
||||
### T4.3 — Balance pass (#12)
|
||||
|
||||
- Tune action durations, resource rates, choice cadence, time-to-first-prestige for semi-active pacing (~few-minute choice cadence target from GDD).
|
||||
- Document tuning rationale in vault (`Idlegame/Decisions.md` or new `Idlegame/Balance/M1.md`).
|
||||
|
||||
*Verify:* timed playtest observations recorded; ginnoir sign-off on feel.
|
||||
|
||||
### T4.4 — Milestone close
|
||||
|
||||
- Update `Idlegame/_Claude.md` session log; GDD milestone map (M1 complete, M2 TBD).
|
||||
- Close Gitea milestone **M1 Vertical Slice**; ensure #3–#12 closed.
|
||||
- README current-scope blurb → M1 complete.
|
||||
|
||||
*Verify:* vault updated; Gitea milestone closed; `main` deploy green.
|
||||
|
||||
### PR4 integration verify
|
||||
|
||||
Full pre-PR chain. Final playtest on `idlegame.ginnoir.com`.
|
||||
|
||||
**PR:** link `Closes #9`, `Closes #10`, `Closes #12`.
|
||||
|
||||
---
|
||||
|
||||
## AI tool routing (M1)
|
||||
|
||||
One tool per branch at a time. Default: phase owner merges before next phase starts.
|
||||
|
||||
| Phase / task | Tool | Model tier | Why |
|
||||
|---|---|---|---|
|
||||
| Phase 0 outline + prose | Claude Code | Opus 4.8 | Story/design lane |
|
||||
| PR1 T1.x | Codex | GPT-5.5-Codex medium | Scoped engine + TDD |
|
||||
| PR2 T2.1–T2.2 | Codex | GPT-5.5-Codex medium | Story graph engine |
|
||||
| PR2 T2.3–T2.5 | Antigravity | Gemini 3 Pro | Browser verification |
|
||||
| PR3 T3.1–T3.2 | Codex | GPT-5.5-Codex highest | Prestige = state integrity surface |
|
||||
| PR3 T3.3 | Claude Code | Opus 4.8 | Vault prose → content defs |
|
||||
| PR4 T4.1 | Codex | GPT-5.5-Codex medium | Save migration discipline |
|
||||
| PR4 T4.2 | Antigravity | Gemini 3 Pro | Mobile screenshots |
|
||||
| PR4 T4.3 | Claude Code + ginnoir | Opus 4.8 / chat | Balance + playtest feel |
|
||||
| Ginnoir review gates | Cursor / chat | Composer 2.5 | Phase 0 prose, balance sign-off |
|
||||
|
||||
Cursor is the interactive lane for scoped tweaks anytime ginnoir is driving.
|
||||
|
||||
## Error handling expectations
|
||||
|
||||
- Invalid saves, content, or import strings: **fail explicitly** at boundary with clear errors (existing M0 pattern).
|
||||
- Unknown action/story node ids: throw in engine API (dev-time content errors, not player-facing soft-fail).
|
||||
- Migration failure: reject load, preserve prior save if possible, surface message in UI.
|
||||
|
||||
## Testing expectations
|
||||
|
||||
- New engine behavior: unit tests required; maintain ≥80% coverage on `src/engine/`.
|
||||
- UI phases: browser smoke required (Antigravity or manual); Playwright deferred post-M1.
|
||||
- Each PR runs the full pre-PR verification chain before merge.
|
||||
|
||||
## Out of scope (M1)
|
||||
|
||||
- Playwright E2E suite
|
||||
- Second prestige layer or new mechanics between layers
|
||||
- Cloud saves / accounts
|
||||
- Public playtest (internal-only Caddy remains)
|
||||
- Art pipeline, battle screen, i18n
|
||||
- Steam/Tauri / Capacitor wraps
|
||||
|
||||
## Gitea issue index
|
||||
|
||||
| Issue | Title | Phase |
|
||||
|---|---|---|
|
||||
| #3 | Engine tick and offline hardening | PR1 |
|
||||
| #4 | Resource and action definition set | PR1 |
|
||||
| #5 | Action queue UI | PR2 |
|
||||
| #6 | Story graph and first branch | PR2 |
|
||||
| #7 | Automation unlock | PR3 |
|
||||
| #8 | First prestige reset | PR3 |
|
||||
| #11 | Opening arc content | PR3 |
|
||||
| #9 | Save migrations and import/export UX | PR4 |
|
||||
| #10 | Mobile layout pass | PR4 |
|
||||
| #12 | Balance pass | PR4 |
|
||||
|
||||
Plane epics (Vertical Slice, Game Design) link these issues by URL — no Plane task duplication required during execution.
|
||||
|
||||
---
|
||||
|
||||
*Brainstorm approved by ginnoir 2026-06-11. Next step after spec review: invoke `writing-plans` for task-level implementation plans per PR if desired, or begin Phase 0 / PR1 directly via `executing-plans`.*
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,694 @@
|
||||
# M1 PR3 T3.1 — Universal Automation & Recipes Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add universal automation — any action kind becomes automatable after first manual completion — with a separate automation queue, runner precedence (manual > automation > loop), and shareable text recipe export/import referencing content action ids.
|
||||
|
||||
**Architecture:** Pure engine modules `automation.ts` and `recipe.ts` own queue execution and text serialization; `manualCompletionCounts` gates unlock; runtime exposes automation UI commands; save v1 schema extended with defaults.
|
||||
|
||||
**Tech Stack:** TypeScript strict, Vitest, Zod 4, lz-string (optional compressed recipes), Biome, pnpm, React 19, Zustand.
|
||||
|
||||
**Parent spec:** `docs/superpowers/specs/2026-06-11-m1-pr3-shell-ui-design.md` §Automation
|
||||
|
||||
**Prerequisite plan:** `docs/superpowers/plans/2026-06-11-m1-pr3-t30-shell-ui.md` (T3.0 shell must merge first)
|
||||
|
||||
**Branch:** `feat/m1-progression` (continues after T3.0)
|
||||
|
||||
**Closes:** Gitea #7 (Automation unlock)
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `src/engine/game.ts` | `manualCompletionCounts`; hook completion recording in tick/performAction |
|
||||
| `src/engine/automation.ts` | **Create** — automation queue CRUD, runner, unlock checks |
|
||||
| `src/engine/recipe.ts` | **Create** — multi-line + single-line parse/serialize/validate |
|
||||
| `src/engine/__tests__/automation.test.ts` | **Create** — queue, unlock, precedence tests |
|
||||
| `src/engine/__tests__/recipe.test.ts` | **Create** — round-trip, reject unknown/locked ids |
|
||||
| `src/engine/save.ts` | Persist `manualCompletionCounts`, `automationQueue` |
|
||||
| `src/state/viewModel.ts` | `automationUnlocked`, `automationQueueNames` on ActionView |
|
||||
| `src/state/runtime.ts` | `addToAutomation`, `removeFromAutomation`, `importRecipe`, `exportRecipe` |
|
||||
| `src/ui/AutomationBar.tsx` | **Create** — queue list, export/import textarea |
|
||||
| `src/ui/PlayPanel.tsx` | Mount AutomationBar below columns |
|
||||
| `src/ui/ActionCard.tsx` | Auto toggle when unlocked |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: manualCompletionCounts + record on completion
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/engine/game.ts`
|
||||
- Modify: `src/engine/save.ts`
|
||||
- Modify: `src/engine/__tests__/game.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```typescript
|
||||
describe('manualCompletionCounts', () => {
|
||||
it('increments when a timed action completes', () => {
|
||||
const content = testContentWithGroup(); // timed action with group/kind
|
||||
const state = createGameState(content);
|
||||
enqueueAction(state, content, 'gather_supplies');
|
||||
tickGame(state, content, 3000);
|
||||
expect(state.manualCompletionCounts.gather_supplies).toBe(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pnpm test src/engine/__tests__/game.test.ts -t "manualCompletionCounts"`
|
||||
|
||||
Expected: FAIL — property undefined.
|
||||
|
||||
- [ ] **Step 3: Implement recording**
|
||||
|
||||
Add to `GameState`:
|
||||
|
||||
```typescript
|
||||
manualCompletionCounts: Record<string, number>;
|
||||
```
|
||||
|
||||
Default `{}` in `createGameState`.
|
||||
|
||||
Add helper:
|
||||
|
||||
```typescript
|
||||
export function recordManualCompletion(state: GameState, content: Content, actionId: string): void {
|
||||
if (!content.actionsById[actionId]) return;
|
||||
state.manualCompletionCounts[actionId] = (state.manualCompletionCounts[actionId] ?? 0) + 1;
|
||||
}
|
||||
```
|
||||
|
||||
Call from:
|
||||
- `tickGame` when pushing to `completedActionIds`
|
||||
- `executeInstant` after yields
|
||||
- `executeStoryAction` after `applyChoice`
|
||||
|
||||
Update `save.ts`:
|
||||
|
||||
```typescript
|
||||
manualCompletionCounts: z.record(z.string(), z.number()).default({}),
|
||||
automationQueue: z.array(z.string()).default([]),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `pnpm test src/engine/__tests__/game.test.ts src/engine/__tests__/save.test.ts`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/engine/game.ts src/engine/save.ts src/engine/__tests__/game.test.ts src/engine/__tests__/save.test.ts
|
||||
git commit -m "feat(engine): track manualCompletionCounts on action complete"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Automation unlock check
|
||||
|
||||
**Files:**
|
||||
- Create: `src/engine/automation.ts`
|
||||
- Create: `src/engine/__tests__/automation.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildContent } from '../../content/schema';
|
||||
import { createGameState } from '../game';
|
||||
import { isAutomationUnlocked, automationUnlockThreshold } from '../automation';
|
||||
|
||||
describe('isAutomationUnlocked()', () => {
|
||||
const content = buildContent({
|
||||
resources: [{ id: 'supplies', name: 'Supplies' }],
|
||||
actions: [
|
||||
{
|
||||
id: 'gather_supplies',
|
||||
name: 'Gather',
|
||||
kind: 'timed',
|
||||
group: { id: 'camp', label: 'Camp' },
|
||||
durationMs: 1000,
|
||||
yields: [{ resourceId: 'supplies', amount: 1 }],
|
||||
automation: { unlockAfterManualCompletions: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it('is false before first manual completion', () => {
|
||||
const state = createGameState(content);
|
||||
expect(isAutomationUnlocked(state, content, 'gather_supplies')).toBe(false);
|
||||
});
|
||||
|
||||
it('is true after threshold met', () => {
|
||||
const state = createGameState(content);
|
||||
state.manualCompletionCounts.gather_supplies = 1;
|
||||
expect(isAutomationUnlocked(state, content, 'gather_supplies')).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pnpm test src/engine/__tests__/automation.test.ts`
|
||||
|
||||
Expected: FAIL — module not found.
|
||||
|
||||
- [ ] **Step 3: Implement unlock helpers**
|
||||
|
||||
Create `src/engine/automation.ts`:
|
||||
|
||||
```typescript
|
||||
import type { Content } from '../content/schema';
|
||||
import type { GameState } from './game';
|
||||
|
||||
export function automationUnlockThreshold(content: Content, actionId: string): number {
|
||||
return content.actionsById[actionId]?.automation?.unlockAfterManualCompletions ?? 1;
|
||||
}
|
||||
|
||||
export function isAutomationUnlocked(state: GameState, content: Content, actionId: string): boolean {
|
||||
const threshold = automationUnlockThreshold(content, actionId);
|
||||
return (state.manualCompletionCounts[actionId] ?? 0) >= threshold;
|
||||
}
|
||||
|
||||
export function addToAutomationQueue(state: GameState, content: Content, actionId: string): void {
|
||||
if (!content.actionsById[actionId]) {
|
||||
throw new Error(`Unknown action "${actionId}"`);
|
||||
}
|
||||
if (!isAutomationUnlocked(state, content, actionId)) {
|
||||
throw new Error(`Action "${actionId}" is not automation-unlocked`);
|
||||
}
|
||||
if (!state.automationQueue.includes(actionId)) {
|
||||
state.automationQueue.push(actionId);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeFromAutomationQueue(state: GameState, index: number): void {
|
||||
if (index < 0 || index >= state.automationQueue.length) {
|
||||
throw new RangeError(`Automation queue index ${index} is out of range`);
|
||||
}
|
||||
state.automationQueue.splice(index, 1);
|
||||
}
|
||||
|
||||
export function clearAutomationQueue(state: GameState): void {
|
||||
state.automationQueue.length = 0;
|
||||
}
|
||||
```
|
||||
|
||||
Add `automationQueue: string[]` to `GameState` (default `[]`).
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `pnpm test src/engine/__tests__/automation.test.ts`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/engine/automation.ts src/engine/game.ts src/engine/__tests__/automation.test.ts
|
||||
git commit -m "feat(engine): automation unlock and queue CRUD"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Automation runner + precedence
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/engine/automation.ts`
|
||||
- Modify: `src/engine/game.ts`
|
||||
- Modify: `src/engine/__tests__/automation.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```typescript
|
||||
describe('maybeRunAutomation()', () => {
|
||||
it('starts first affordable automation action when manual queue idle', () => {
|
||||
const state = createGameState(content);
|
||||
state.manualCompletionCounts.gather_supplies = 1;
|
||||
state.automationQueue = ['gather_supplies'];
|
||||
maybeRunAutomation(state, content);
|
||||
expect(state.activeActionId).toBe('gather_supplies');
|
||||
});
|
||||
|
||||
it('does not run when manual queue has items', () => {
|
||||
const state = createGameState(content);
|
||||
state.actionQueue = ['gather_supplies'];
|
||||
state.automationQueue = ['gather_supplies'];
|
||||
state.manualCompletionCounts.gather_supplies = 1;
|
||||
maybeRunAutomation(state, content);
|
||||
expect(state.activeActionId).toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pnpm test src/engine/__tests__/automation.test.ts -t "maybeRunAutomation"`
|
||||
|
||||
- [ ] **Step 3: Implement maybeRunAutomation**
|
||||
|
||||
```typescript
|
||||
import { isActionAvailable, type GameState } from './game';
|
||||
|
||||
export function maybeRunAutomation(state: GameState, content: Content): void {
|
||||
if (state.activeActionId !== null || state.actionQueue.length > 0) return;
|
||||
|
||||
for (const actionId of state.automationQueue) {
|
||||
if (!isAutomationUnlocked(state, content, actionId)) continue;
|
||||
if (!isActionAvailable(state, content, actionId)) continue;
|
||||
|
||||
const action = content.actionsById[actionId];
|
||||
if (!action) continue;
|
||||
|
||||
switch (action.kind) {
|
||||
case 'instant':
|
||||
// execute inline without queue
|
||||
break;
|
||||
case 'timed':
|
||||
case 'loop':
|
||||
beginAction(state, content, actionId); // export beginAction or duplicate
|
||||
return;
|
||||
case 'story':
|
||||
executeStoryAction(state, content, actionId);
|
||||
return;
|
||||
case 'context':
|
||||
continue;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Precedence wiring in `game.ts` `startNextFromQueue`:**
|
||||
|
||||
After manual queue exhausts and sets idle:
|
||||
|
||||
```typescript
|
||||
maybeRunAutomation(state, content);
|
||||
maybeStartLoopAction(state, content);
|
||||
```
|
||||
|
||||
Order: **manual timed completes → automation → loop**.
|
||||
|
||||
Refactor `beginAction` to export if needed (or add `startActionById` internal export).
|
||||
|
||||
For automation instant actions: execute inline in runner, re-advance automation index.
|
||||
|
||||
- [ ] **Step 4: Run automation + game tests**
|
||||
|
||||
Run: `pnpm test src/engine`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/engine/automation.ts src/engine/game.ts src/engine/__tests__/automation.test.ts
|
||||
git commit -m "feat(engine): automation runner with manual-first precedence"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Recipe export/import
|
||||
|
||||
**Files:**
|
||||
- Create: `src/engine/recipe.ts`
|
||||
- Create: `src/engine/__tests__/recipe.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildContent } from '../../content/schema';
|
||||
import { createGameState } from '../game';
|
||||
import { exportRecipe, importRecipe, RECIPE_HEADER_V1 } from '../recipe';
|
||||
|
||||
describe('recipe export/import', () => {
|
||||
const content = buildContent({
|
||||
resources: [{ id: 'supplies', name: 'Supplies' }],
|
||||
actions: [
|
||||
{
|
||||
id: 'gather_supplies',
|
||||
name: 'Gather',
|
||||
kind: 'timed',
|
||||
group: { id: 'camp', label: 'Camp' },
|
||||
durationMs: 1000,
|
||||
yields: [{ resourceId: 'supplies', amount: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'rest',
|
||||
name: 'Rest',
|
||||
kind: 'loop',
|
||||
group: { id: 'camp_loop', label: 'Camp' },
|
||||
durationMs: 1000,
|
||||
yields: [{ resourceId: 'supplies', amount: 1 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it('round-trips multi-line format', () => {
|
||||
const state = createGameState(content);
|
||||
state.manualCompletionCounts = { gather_supplies: 1, rest: 1 };
|
||||
state.automationQueue = ['gather_supplies', 'rest'];
|
||||
const text = exportRecipe(state, content, { name: 'Camp loop' });
|
||||
expect(text).toContain(RECIPE_HEADER_V1);
|
||||
expect(text).toContain('gather_supplies');
|
||||
|
||||
const fresh = createGameState(content);
|
||||
fresh.manualCompletionCounts = { gather_supplies: 1, rest: 1 };
|
||||
importRecipe(fresh, content, text);
|
||||
expect(fresh.automationQueue).toEqual(['gather_supplies', 'rest']);
|
||||
});
|
||||
|
||||
it('rejects unknown action ids', () => {
|
||||
const state = createGameState(content);
|
||||
expect(() =>
|
||||
importRecipe(state, content, `${RECIPE_HEADER_V1}\nnot_real`),
|
||||
).toThrow(/unknown/i);
|
||||
});
|
||||
|
||||
it('rejects locked action ids', () => {
|
||||
const state = createGameState(content);
|
||||
const text = `${RECIPE_HEADER_V1}\ngather_supplies`;
|
||||
expect(() => importRecipe(state, content, text)).toThrow(/locked/i);
|
||||
});
|
||||
|
||||
it('parses single-line alias', () => {
|
||||
const state = createGameState(content);
|
||||
state.manualCompletionCounts.gather_supplies = 1;
|
||||
importRecipe(state, content, `${RECIPE_HEADER_V1}:gather_supplies`);
|
||||
expect(state.automationQueue).toEqual(['gather_supplies']);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pnpm test src/engine/__tests__/recipe.test.ts`
|
||||
|
||||
- [ ] **Step 3: Implement recipe.ts**
|
||||
|
||||
Create `src/engine/recipe.ts`:
|
||||
|
||||
```typescript
|
||||
import type { Content } from '../content/schema';
|
||||
import type { GameState } from './game';
|
||||
import { clearAutomationQueue, isAutomationUnlocked, addToAutomationQueue } from './automation';
|
||||
|
||||
export const RECIPE_HEADER_V1 = 'idlegame-recipe/v1';
|
||||
const ACTION_ID_RE = /^[a-z][a-z0-9_]*$/;
|
||||
|
||||
export interface RecipeMeta {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export function exportRecipe(state: GameState, content: Content, meta?: RecipeMeta): string {
|
||||
const lines = [RECIPE_HEADER_V1];
|
||||
if (meta?.name) lines.push(`# name: ${meta.name}`);
|
||||
for (const id of state.automationQueue) {
|
||||
if (content.actionsById[id]) lines.push(id);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function parseRecipeLines(text: string): { name?: string; actionIds: string[] } {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith(`${RECIPE_HEADER_V1}:`)) {
|
||||
const ids = trimmed.slice(RECIPE_HEADER_V1.length + 1).split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return { actionIds: ids };
|
||||
}
|
||||
|
||||
const lines = trimmed.split(/\r?\n/);
|
||||
if (lines[0]?.trim() !== RECIPE_HEADER_V1) {
|
||||
throw new Error(`Invalid recipe header; expected "${RECIPE_HEADER_V1}"`);
|
||||
}
|
||||
|
||||
let name: string | undefined;
|
||||
const actionIds: string[] = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i]?.trim() ?? '';
|
||||
if (!line) continue;
|
||||
if (line.startsWith('# name:')) {
|
||||
name = line.slice('# name:'.length).trim();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('#')) continue;
|
||||
if (!ACTION_ID_RE.test(line)) {
|
||||
throw new Error(`Invalid action id "${line}"`);
|
||||
}
|
||||
actionIds.push(line);
|
||||
}
|
||||
return { name, actionIds };
|
||||
}
|
||||
|
||||
export function importRecipe(state: GameState, content: Content, text: string): { name?: string } {
|
||||
const { name, actionIds } = parseRecipeLines(text);
|
||||
const unknown = actionIds.filter((id) => !content.actionsById[id]);
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`Unknown action ids: ${unknown.join(', ')}`);
|
||||
}
|
||||
const locked = actionIds.filter((id) => !isAutomationUnlocked(state, content, id));
|
||||
if (locked.length > 0) {
|
||||
throw new Error(`Automation locked for: ${locked.join(', ')}`);
|
||||
}
|
||||
clearAutomationQueue(state);
|
||||
for (const id of actionIds) {
|
||||
addToAutomationQueue(state, content, id);
|
||||
}
|
||||
return { name };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `pnpm test src/engine/__tests__/recipe.test.ts`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/engine/recipe.ts src/engine/__tests__/recipe.test.ts
|
||||
git commit -m "feat(engine): automation recipe export and import"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: View model automation fields
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/state/viewModel.ts`
|
||||
- Modify: `src/state/__tests__/viewModel.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```typescript
|
||||
it('marks automationUnlocked on actions after manual completion', () => {
|
||||
state.manualCompletionCounts.gather_supplies = 1;
|
||||
const view = toView(state, content);
|
||||
const action = view.actionColumns
|
||||
.flatMap((c) => c.groups)
|
||||
.flatMap((g) => g.actions)
|
||||
.find((a) => a.id === 'gather_supplies');
|
||||
expect(action?.automationUnlocked).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pnpm test src/state/__tests__/viewModel.test.ts -t "automationUnlocked"`
|
||||
|
||||
- [ ] **Step 3: Extend ActionView**
|
||||
|
||||
```typescript
|
||||
export interface ActionView {
|
||||
// ...existing
|
||||
automationUnlocked: boolean;
|
||||
inAutomationQueue: boolean;
|
||||
loopEnabled: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Map using `isAutomationUnlocked`, `state.automationQueue.includes(id)`, `state.enabledLoopActionIds[id]`.
|
||||
|
||||
Add to `GameView`:
|
||||
|
||||
```typescript
|
||||
automationQueueIds: string[];
|
||||
automationQueueNames: string[];
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `pnpm test src/state/__tests__/viewModel.test.ts`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/state/viewModel.ts src/state/__tests__/viewModel.test.ts
|
||||
git commit -m "feat(state): automation fields on action view model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Runtime automation commands
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/state/runtime.ts`
|
||||
|
||||
- [ ] **Step 1: Add runtime methods**
|
||||
|
||||
```typescript
|
||||
toggleAutomation(actionId: string): void {
|
||||
const state = this.state;
|
||||
if (!state) return;
|
||||
try {
|
||||
if (state.automationQueue.includes(actionId)) {
|
||||
const idx = state.automationQueue.indexOf(actionId);
|
||||
removeFromAutomationQueue(state, idx);
|
||||
} else {
|
||||
addToAutomationQueue(state, content, actionId);
|
||||
}
|
||||
this.publish();
|
||||
} catch (err) {
|
||||
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Automation failed');
|
||||
}
|
||||
}
|
||||
|
||||
exportAutomationRecipe(): string {
|
||||
const state = this.state;
|
||||
if (!state) return '';
|
||||
return exportRecipe(state, content);
|
||||
}
|
||||
|
||||
importAutomationRecipe(text: string): void {
|
||||
const state = this.state;
|
||||
if (!state) return;
|
||||
try {
|
||||
const meta = importRecipe(state, content, text);
|
||||
useGameStore.getState().appendLog(meta.name ? `Imported recipe: ${meta.name}` : 'Imported recipe.');
|
||||
this.publish();
|
||||
} catch (err) {
|
||||
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Import failed');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/state/runtime.ts
|
||||
git commit -m "feat(state): runtime automation and recipe commands"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: AutomationBar UI
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ui/AutomationBar.tsx`
|
||||
- Modify: `src/ui/PlayPanel.tsx`
|
||||
- Modify: `src/ui/ActionCard.tsx`
|
||||
|
||||
- [ ] **Step 1: Create AutomationBar**
|
||||
|
||||
```tsx
|
||||
export function AutomationBar() {
|
||||
const names = useGameStore((s) => s.automationQueueNames);
|
||||
const [importText, setImportText] = useState('');
|
||||
|
||||
return (
|
||||
<section aria-label="Automation" className="mt-4 rounded-lg border border-slate-700 p-3">
|
||||
<h2 className="mb-2 font-medium text-slate-300 text-sm uppercase tracking-wide">Automation</h2>
|
||||
{names.length > 0 ? (
|
||||
<ol className="mb-2 text-slate-300 text-sm">
|
||||
{names.map((name, i) => (
|
||||
<li key={name}>{i + 1}. {name}</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="mb-2 text-slate-500 text-sm">No automated processes.</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => navigator.clipboard.writeText(gameRuntime.exportAutomationRecipe())}>
|
||||
Copy recipe
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="mt-2 w-full rounded border border-slate-600 bg-slate-900 p-2 text-slate-200 text-xs"
|
||||
rows={4}
|
||||
placeholder="Paste recipe text…"
|
||||
value={importText}
|
||||
onChange={(e) => setImportText(e.target.value)}
|
||||
/>
|
||||
<button type="button" className="mt-1" onClick={() => { gameRuntime.importAutomationRecipe(importText); setImportText(''); }}>
|
||||
Import recipe
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: ActionCard auto toggle**
|
||||
|
||||
When `action.automationUnlocked`, show small "Auto" button calling `gameRuntime.toggleAutomation(action.id)`; highlight when `action.inAutomationQueue`.
|
||||
|
||||
- [ ] **Step 3: Mount in PlayPanel**
|
||||
|
||||
Add `<AutomationBar />` below column grid.
|
||||
|
||||
- [ ] **Step 4: Browser smoke**
|
||||
|
||||
1. Complete gather manually once → Auto button appears.
|
||||
2. Add to automation → runs when idle.
|
||||
3. Export recipe → paste in fresh session (with unlocks) → import restores queue.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ui/AutomationBar.tsx src/ui/PlayPanel.tsx src/ui/ActionCard.tsx
|
||||
git commit -m "feat(ui): automation bar with recipe export/import"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Integration verify
|
||||
|
||||
- [ ] **Step 1: Run pre-PR chain**
|
||||
|
||||
```powershell
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm test:coverage
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Expected: all green; engine ≥80%.
|
||||
|
||||
- [ ] **Step 2: Commit if doc touch needed**
|
||||
|
||||
Update `docs/architecture.md` §Automation with recipe format example.
|
||||
|
||||
```bash
|
||||
git add docs/architecture.md
|
||||
git commit -m "docs: document automation queue and recipe format"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage self-review
|
||||
|
||||
| Spec requirement | Task |
|
||||
|---|---|
|
||||
| Universal automation after first manual completion | Tasks 1–2 |
|
||||
| Story actions automatable after first perform | Task 1 records story completions |
|
||||
| Separate automation queue | Tasks 2–3 |
|
||||
| Precedence manual > automation > loop | Task 3 |
|
||||
| Multi-line recipe format | Task 4 |
|
||||
| Single-line alias | Task 4 |
|
||||
| Reject unknown/locked ids on import | Task 4 |
|
||||
| Export/import UI | Task 7 |
|
||||
| Offline automation | Task 3 runner uses same tickGame path |
|
||||
| Compressed recipes | Optional — defer unless queue >20 steps |
|
||||
|
||||
## Placeholder scan
|
||||
|
||||
No TBD/TODO. All steps include paths and code.
|
||||
@@ -0,0 +1,341 @@
|
||||
# M1 PR2 — Playable Loop Design Spec
|
||||
|
||||
> **Status:** Approved by ginnoir (brainstorm 2026-06-11).
|
||||
> **Branch:** `feat/m1-playable-loop` off `main`.
|
||||
> **Closes:** Gitea #5 (Action queue UI), #6 (Story graph and first branch).
|
||||
> **Parent plan:** `docs/plans/2026-06-11-m1-vertical-slice.md` §PR2.
|
||||
|
||||
## Summary
|
||||
|
||||
PR2 turns the PR1 queue engine into the first **playable loop**: a hybrid story graph (choices, action-complete beats, boot, resource thresholds), a full-window visual-novel story panel with running story log, upgraded action queue UI with narrative context (subtitles/tooltips), and player preferences for story-open and action-detail modes. Stub prose and placeholder names prove branch mechanics; real content replaces them in PR3.
|
||||
|
||||
## What we decided (brainstorm)
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Story pacing | **Hybrid** — manual choices, action-complete beats, boot intro, resource thresholds |
|
||||
| Threshold checks | On **action completion** and on **publish** (~10 fps) |
|
||||
| Stub A/B branch | **Flags + resources + route-exclusive action unlocks** |
|
||||
| Action context | `storyHint` subtitle + `storyTooltip` with mechanics and consequences |
|
||||
| Story UI | **Full-window overlay** (VN layout + running story log), not inline strip |
|
||||
| Story open behavior | **Player-configurable**, default **auto on new beats**; modes: auto / choices-only / manual |
|
||||
| Action detail display | **Inline subtitle default**; hover on desktop; configurable (inline / hover / info-button) |
|
||||
| PR2 scope | **Mechanics + minimal Settings drawer**; full mobile layout pass deferred to PR4 (#10) |
|
||||
| Architecture | **Approach 1** — pure engine triggers + thin UI/prefs in state layer |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/engine/story.ts traverse, applyChoice, evaluateTriggers, outcomes
|
||||
src/engine/game.ts + storyFlags, currentStoryNodeId, seenStoryNodeIds
|
||||
src/engine/save.ts persist story fields in SAVE_VERSION=1 (.default())
|
||||
src/content/storySchema.ts Zod story node / choice / trigger / outcome defs
|
||||
src/content/story.ts stub ~6-node graph
|
||||
src/content/schema.ts + action.storyHint, action.storyTooltip
|
||||
src/state/storyOrchestration.ts evaluateTriggers after boot, publish, actionComplete
|
||||
src/state/prefs.ts localStorage: storyOpenMode, actionDetailMode
|
||||
src/state/viewModel.ts + story view, action availability, hints
|
||||
src/state/runtime.ts wire orchestration, applyChoice, cancelQueue
|
||||
src/ui/StoryPanel.tsx full-screen overlay, VN passage, choices, story log
|
||||
src/ui/ActionPanel.tsx queue list, cancel, disabled, subtitles/tooltips
|
||||
src/ui/SettingsDrawer.tsx two preference selects
|
||||
```
|
||||
|
||||
**Boundaries (unchanged from M0):**
|
||||
|
||||
- `src/engine/` — pure TS; no React, DOM, localStorage, wall clock.
|
||||
- `src/state/` — orchestration, prefs, RAF, persistence.
|
||||
- `src/ui/` — renders view model; calls runtime commands only.
|
||||
|
||||
## Story schema
|
||||
|
||||
### StoryNode
|
||||
|
||||
```typescript
|
||||
StoryNode {
|
||||
id: string
|
||||
prose: string
|
||||
choices?: StoryChoice[]
|
||||
triggers?: StoryTrigger[]
|
||||
enterOutcomes?: StoryOutcome[]
|
||||
}
|
||||
```
|
||||
|
||||
### StoryChoice
|
||||
|
||||
```typescript
|
||||
StoryChoice {
|
||||
id: string
|
||||
label: string
|
||||
requirements?: {
|
||||
minResources?: Record<string, number>
|
||||
requireStoryFlags?: string[]
|
||||
excludeStoryFlags?: string[]
|
||||
}
|
||||
outcomes: StoryOutcome[]
|
||||
targetNodeId: string
|
||||
}
|
||||
```
|
||||
|
||||
### StoryTrigger
|
||||
|
||||
```typescript
|
||||
StoryTrigger {
|
||||
type: 'boot' | 'actionComplete' | 'minResources'
|
||||
actionId?: string
|
||||
minResources?: Record<string, number>
|
||||
targetNodeId: string
|
||||
once?: boolean // default true
|
||||
}
|
||||
```
|
||||
|
||||
### StoryOutcome
|
||||
|
||||
| Type | Effect |
|
||||
|---|---|
|
||||
| `setFlag` | `storyFlags[id] = true` |
|
||||
| `clearFlag` | `storyFlags[id] = false` |
|
||||
| `grantResource` | increase resource |
|
||||
| `consumeResource` | decrease resource (validated) |
|
||||
| `log` | narrative line appended to story log (no state change) |
|
||||
|
||||
Outcomes on a choice or `enterOutcomes` apply atomically before advancing.
|
||||
|
||||
### Action narrative fields
|
||||
|
||||
```typescript
|
||||
action.storyHint?: string // one-line subtitle (inline mode)
|
||||
action.storyTooltip?: string // longer detail: yields, costs, consequences
|
||||
```
|
||||
|
||||
Tooltip copy may reference flags/choices textually (authoring); engine does not parse natural language.
|
||||
|
||||
### Content validation
|
||||
|
||||
At `buildContent` / story load:
|
||||
|
||||
- All `targetNodeId` references resolve.
|
||||
- Trigger `actionId` values exist in `actionsById`.
|
||||
- Resource ids in outcomes/triggers exist.
|
||||
- Graph has exactly one boot trigger entry point.
|
||||
- Invalid graphs throw at load (dev-time), matching action schema behavior.
|
||||
|
||||
## GameState extensions
|
||||
|
||||
```typescript
|
||||
interface GameState {
|
||||
// existing: resources, activeActionId, actionElapsedMs, actionQueue
|
||||
storyFlags: Record<string, boolean>
|
||||
currentStoryNodeId: string
|
||||
seenStoryNodeIds: string[]
|
||||
}
|
||||
```
|
||||
|
||||
**Save v1** (no version bump; PR4 ships formal v1→v2 migration):
|
||||
|
||||
```typescript
|
||||
storyFlags: z.record(z.string(), z.boolean()).default({})
|
||||
currentStoryNodeId: z.string().default('') // hydrated to entry on first boot if empty
|
||||
seenStoryNodeIds: z.array(z.string()).default([])
|
||||
```
|
||||
|
||||
## Engine API (`src/engine/story.ts`)
|
||||
|
||||
| Function | Purpose |
|
||||
|---|---|
|
||||
| `initStory(state, content)` | Resolve entry node from boot trigger; set `currentStoryNodeId` |
|
||||
| `evaluateTriggers(state, content, ctx)` | ctx: `{ reason: 'boot' \| 'publish' \| 'actionComplete', actionId? }`; returns `{ enteredNodes: string[], events: StoryEvent[] }` |
|
||||
| `applyChoice(state, content, choiceId)` | Validate requirements, apply outcomes, advance, append to `seenStoryNodeIds` |
|
||||
| `getAvailableChoices(state, content, nodeId)` | Filter choices by requirements |
|
||||
| `getCurrentNode(state, content)` | Lookup helper |
|
||||
|
||||
**Trigger evaluation rules:**
|
||||
|
||||
1. **`boot`** — fires once on `initStory` if target not seen (when `once !== false`).
|
||||
2. **`actionComplete`** — fires when `ctx.reason === 'actionComplete'` and `ctx.actionId` matches.
|
||||
3. **`minResources`** — fires when all thresholds met; checked on `publish` and after `actionComplete`.
|
||||
4. A trigger does not re-fire if `targetNodeId` is in `seenStoryNodeIds` and `once !== false`.
|
||||
5. Entering a node applies `enterOutcomes`, adds id to `seenStoryNodeIds`, emits events.
|
||||
|
||||
**Integration with actions:** `game.ts` `isActionAvailable` receives `state.storyFlags` (remove PR1 default `{}` placeholder at call sites).
|
||||
|
||||
## Stub story graph
|
||||
|
||||
~6 nodes, one A/B fork, placeholder prose:
|
||||
|
||||
```text
|
||||
boot_intro
|
||||
└─ fork_choice ─┬─ route_a_beat → unlocks fortify_camp (requireStoryFlags: route_a)
|
||||
└─ route_b_beat → unlocks push_onward stub action (requireStoryFlags: route_b)
|
||||
threshold_beat (minResources coin ≥ 3) → merchant_flavor
|
||||
actionComplete(scout_path) → scout_aftermath
|
||||
```
|
||||
|
||||
**Route outcomes:**
|
||||
|
||||
- Route A: `setFlag route_a`, grant supplies, unlock `fortify_camp`.
|
||||
- Route B: `setFlag route_b`, grant coin, unlock new stub action `push_onward`.
|
||||
|
||||
Add `push_onward` to `definitions.ts` with `requireStoryFlags: ['route_b']`.
|
||||
|
||||
## Runtime orchestration
|
||||
|
||||
```text
|
||||
boot → loadGame → initStory → evaluateTriggers(boot) → publish
|
||||
|
||||
each frame:
|
||||
tickGame → [if action completed this tick] evaluateTriggers(actionComplete)
|
||||
publish (≥10fps) → evaluateTriggers(publish)
|
||||
if new node:
|
||||
appendStoryLog
|
||||
open StoryPanel per prefs.storyOpenMode
|
||||
optionally mirror one line to event log
|
||||
|
||||
applyChoice → engine → appendStoryLog → publish → close/minimize panel
|
||||
cancelQueuedAction → engine → publish
|
||||
```
|
||||
|
||||
`src/state/storyOrchestration.ts` owns the trigger call sequence; runtime invokes it.
|
||||
|
||||
## Player preferences (`src/state/prefs.ts`)
|
||||
|
||||
Stored in `localStorage` (not save v1):
|
||||
|
||||
| Key | Values | Default |
|
||||
|---|---|---|
|
||||
| `storyOpenMode` | `auto` \| `choices-only` \| `manual` | `auto` |
|
||||
| `actionDetailMode` | `inline` \| `hover` \| `info-button` | `inline` |
|
||||
|
||||
**SettingsDrawer** — gear icon in header; two `<select>` controls; changes apply immediately.
|
||||
|
||||
### Story open modes
|
||||
|
||||
| Mode | Behavior |
|
||||
|---|---|
|
||||
| `auto` | Open overlay on every new node |
|
||||
| `choices-only` | Auto-open only when node has choices; else unread badge |
|
||||
| `manual` | Never auto-open; **Story** button shows unread badge |
|
||||
|
||||
## UI
|
||||
|
||||
### App shell
|
||||
|
||||
Order unchanged: header (title + settings gear + story button) → ResourceBar → ActionPanel → EventLog.
|
||||
|
||||
### StoryPanel (overlay)
|
||||
|
||||
- `fixed inset-0 z-50`, dark backdrop.
|
||||
- **Main:** current prose; choice buttons; "Continue" on passage-only nodes.
|
||||
- **Story log:** collapsible sidebar (desktop) or bottom sheet (mobile) — append-only `{ nodeId, prose, choiceLabel? }`.
|
||||
- Close/minimize respects open mode; choices require selection before dismiss when auto-opened for a choice node.
|
||||
|
||||
### ActionPanel
|
||||
|
||||
- Button per action with progress bar on active (existing).
|
||||
- **Queue list:** ordered names, cancel (✕) per index.
|
||||
- **Disabled** when locked or unaffordable; click logs reason (no enqueue).
|
||||
- **Inline:** `storyHint` under action name.
|
||||
- **Hover (desktop):** tooltip with `storyTooltip`.
|
||||
- **Info-button:** ⓘ opens tooltip popover.
|
||||
|
||||
Availability derived from view model (`available`, `disabledReason`).
|
||||
|
||||
### View model additions
|
||||
|
||||
```typescript
|
||||
actions: {
|
||||
id, name, available, disabledReason,
|
||||
storyHint?, storyTooltip?,
|
||||
costsSummary?, yieldsSummary?
|
||||
}[]
|
||||
|
||||
story: {
|
||||
isOpen, hasUnread,
|
||||
currentProse, choices: { id, label, disabled, disabledReason }[],
|
||||
log: { nodeId, prose, choiceLabel? }[]
|
||||
}
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
| Failure | Behavior |
|
||||
|---|---|
|
||||
| Invalid story content at load | Throw during `buildContent` (dev-time) |
|
||||
| Unknown node/choice id in engine API | Throw (dev-time) |
|
||||
| Choice requirements not met | `applyChoice` throws; UI disables button + shows reason |
|
||||
| Unaffordable enqueue | Existing pattern — catch in runtime, log to event log |
|
||||
| Missing prefs in localStorage | Use defaults |
|
||||
| Save missing story fields | `.default()` on schema — backward compatible with PR1 saves |
|
||||
|
||||
## Testing
|
||||
|
||||
### Engine (required, ≥80% coverage on `src/engine/`)
|
||||
|
||||
- Linear node traversal and `seenStoryNodeIds`.
|
||||
- Branch by choice; requirements gate choices.
|
||||
- Each trigger type: boot, actionComplete, minResources.
|
||||
- Threshold fires on resource change via outcomes.
|
||||
- Outcomes: flags, grant/consume resources.
|
||||
- `once: false` vs default once-only triggers.
|
||||
- Invalid graph rejected at content load.
|
||||
|
||||
### Content schema
|
||||
|
||||
- Valid stub graph builds.
|
||||
- Dangling `targetNodeId` throws.
|
||||
- Unknown action id in trigger throws.
|
||||
|
||||
### State
|
||||
|
||||
- Orchestration fires boot trigger on load.
|
||||
- `storyFlags` wired into action availability in view model.
|
||||
- Prefs round-trip localStorage.
|
||||
|
||||
### UI (browser smoke — Antigravity or manual)
|
||||
|
||||
- Queue 2+ actions, cancel one, order respected.
|
||||
- Route A vs B → different flags, resources, unlocked actions.
|
||||
- Story panel opens per default auto mode.
|
||||
- Settings change story-open mode behavior.
|
||||
- Reload preserves story state + queue.
|
||||
|
||||
### Pre-PR verification chain
|
||||
|
||||
```powershell
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm test:coverage
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Out of scope (PR2)
|
||||
|
||||
- Real opening-arc prose (PR3 / Phase 0 gate).
|
||||
- Automation unlock (PR3 #7).
|
||||
- Prestige reset (PR3 #8).
|
||||
- Save v2 migration and export/import UX (PR4 #9).
|
||||
- Full mobile layout pass (PR4 #10) — basic overlay responsiveness only.
|
||||
- Playwright E2E.
|
||||
- Additional prefs beyond the two PR2 toggles.
|
||||
|
||||
## AI tool routing (from parent plan)
|
||||
|
||||
| Task | Tool |
|
||||
|---|---|
|
||||
| T2.1–T2.2 Story engine + schema | Codex |
|
||||
| T2.3–T2.5 UI + stub graph + browser verify | Antigravity |
|
||||
| Ginnoir review / tweaks | Cursor |
|
||||
|
||||
## Success criteria (PR2 merge)
|
||||
|
||||
1. Player can queue/cancel actions with visible queue and disabled states.
|
||||
2. Full-window story panel shows stub VN passage and choices.
|
||||
3. Completing route A vs B produces different flags, resources, and unlocked actions.
|
||||
4. Action subtitles/tooltips show narrative + mechanical hints.
|
||||
5. Settings drawer toggles story-open and action-detail modes.
|
||||
6. Reload preserves story position, flags, and queue.
|
||||
7. CI green; engine coverage ≥80%.
|
||||
|
||||
---
|
||||
|
||||
*Brainstorm approved 2026-06-11. Next step: invoke `writing-plans` for task-level implementation plan.*
|
||||
@@ -0,0 +1,367 @@
|
||||
# M1 PR3 — Shell UI & Action Column Design Spec
|
||||
|
||||
> **Status:** Approved by ginnoir (brainstorm 2026-06-11).
|
||||
> **Branch:** `feat/m1-progression` off `main` (first chunk before automation/prestige/content).
|
||||
> **Supersedes (partially):** PR2 story overlay UX in `docs/superpowers/specs/2026-06-11-m1-pr2-playable-loop-design.md` §Story UI.
|
||||
> **Parent plan:** `docs/plans/2026-06-11-m1-vertical-slice.md` §PR3.
|
||||
|
||||
## Summary
|
||||
|
||||
PR3 opens with a **shell refactor** that fixes PR2 UX debt before progression/content land: replace the full-screen story overlay with a three-region layout (nav rail / center panel / right rail), reorganize Play into **behavior-type action columns** with collapsible theme groups, move **all story forks to actions** (Story tab is read-only prose + branching tree), and establish **universal automation** with **shareable text recipes** referencing content action ids.
|
||||
|
||||
## What we decided (brainstorm)
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Timing | **First chunk of PR3** — shell lands at start of `feat/m1-progression`, then T3.1 automation, T3.2 prestige, T3.3 content |
|
||||
| Story choices | **Actions only** — Story tab has no choice buttons |
|
||||
| Story tab | **Split pane:** branching tree (larger, ~60%) + long-form prose log (~40%); tree left, log right |
|
||||
| App shell | **Left nav** (Play, Story, Settings, About) → **center** active panel → **right rail** on Play (resources + inventory + optional event log) |
|
||||
| Action layout | **Columns by behavior kind**, collapsible **theme/purpose groups** inside each column |
|
||||
| Column order | **Instant → Loop → Timed → Story → Context** (Timed centered as primary column) |
|
||||
| Loop vs automation | **Loop** = idle filler while playing (queue empty, nothing timed active). **Automation** = hands-off repeat queue including offline |
|
||||
| Automation scope | **Universal** — instant, loop, timed, story, context all automatable **after first manual completion** on that action |
|
||||
| Story automation | Same rule — first time at a fork is conscious; after performing a branch once, that story action can be automated on repeat runs |
|
||||
| Automation sharing | Recipes **export/import as text**; steps reference stable **content action ids** |
|
||||
| Event log | **Optional** on Play right rail (collapsible + pref to hide; default visible) |
|
||||
| Mobile | Column stacking + responsive Story split deferred to PR4 (#10) |
|
||||
| Approach | **Shell first, typed columns second** within PR3 opener (T3.0) |
|
||||
|
||||
## Problem statement (PR2 debt)
|
||||
|
||||
1. **Readability** — full-screen story overlay (`bg-black/80`) ghosts underlying UI; hard to read.
|
||||
2. **Redundant choice paths** — story panel choice buttons and Play actions both visible; unclear authority.
|
||||
3. **Structural mismatch** — single centered column does not match intended Chronicle-style shell (nav / main / sidebar).
|
||||
4. **Flat action list** — no distinction between instant, idle loop, timed queue, story decisions, or context switches.
|
||||
|
||||
## App shell
|
||||
|
||||
```
|
||||
┌──────────┬─────────────────────────────┬──────────────┐
|
||||
│ Nav rail │ Center (active panel) │ Right rail │
|
||||
│ │ │ (Play only) │
|
||||
│ ● Play │ Play → action columns │ Resources │
|
||||
│ Story │ Story → tree │ prose log │ Inventory * │
|
||||
│ Settings │ Event log † │
|
||||
│ About │ Settings / About → content │ │
|
||||
└──────────┴─────────────────────────────┴──────────────┘
|
||||
|
||||
* Inventory placeholder = resource list until item system exists
|
||||
† Collapsible; pref to hide (default: visible)
|
||||
```
|
||||
|
||||
### Nav rail
|
||||
|
||||
- Fixed left column; icons + labels on desktop, icons-only acceptable on narrow widths until PR4.
|
||||
- **Story unread badge** when new beats arrive (replaces overlay auto-open as primary signal).
|
||||
- `storyOpenMode` pref remapped: `auto` switches to Story nav or pulses badge instead of opening overlay.
|
||||
|
||||
### Center panel
|
||||
|
||||
Swaps content by active nav item. No full-screen modal for story.
|
||||
|
||||
### Right rail (Play only)
|
||||
|
||||
- **Resources** — always visible.
|
||||
- **Inventory** — resource list placeholder in M1; item grid later.
|
||||
- **Event log** — collapsible section; `showEventLog` pref (default `true`).
|
||||
|
||||
### Removed
|
||||
|
||||
- `StoryPanel` full-screen overlay with choice buttons.
|
||||
- Header Story / Settings buttons replaced by nav rail (Settings may retain drawer or become full center panel — implementer picks cleaner fit).
|
||||
|
||||
## Play panel — action columns
|
||||
|
||||
### Column order (left → right)
|
||||
|
||||
| # | Column | `kind` | Role |
|
||||
|---|---|---|---|
|
||||
| 1 | Instant | `instant` | One-shot interactions: buy, sell, trade |
|
||||
| 2 | Loop | `loop` | Idle upkeep between tasks (rest, passive recovery) |
|
||||
| 3 | **Timed** | `timed` | Primary gameplay; manual queue + progress bars |
|
||||
| 4 | Story | `story` | Narrative forks and hard decisions |
|
||||
| 5 | Context | `context` | Area changes, combat entry, context switches |
|
||||
|
||||
Timed column is visually central. Narrow viewports: horizontal scroll with Timed near viewport center.
|
||||
|
||||
### Groups within columns
|
||||
|
||||
Each action belongs to a **group** (content-defined theme/purpose):
|
||||
|
||||
```typescript
|
||||
group: {
|
||||
id: string // e.g. 'camp', 'tavern', 'buy'
|
||||
label: string // e.g. 'Camp activities', 'Buy'
|
||||
}
|
||||
```
|
||||
|
||||
- Groups render as **collapsible sections** inside their kind column.
|
||||
- Examples:
|
||||
- **Instant:** Buy, Sell, Trade
|
||||
- **Loop:** Camp activities, Travel activities
|
||||
- **Timed:** Library, Tavern, Camp (location-themed)
|
||||
- **Story:** Arc-specific decision groups
|
||||
- **Context:** Region / combat entry groups
|
||||
- Collapse state stored in `GamePrefs.collapsedActionGroups: Record<string, boolean>`.
|
||||
|
||||
### Action card UX (unchanged from PR2 where applicable)
|
||||
|
||||
- `storyHint` inline subtitle (default) / hover / info-button per pref.
|
||||
- `storyTooltip` for mechanics and fork consequences.
|
||||
- **Story-kind actions** get distinct styling (amber fork badge, border) so diverging paths are obvious in Play.
|
||||
|
||||
## Action kinds — engine behavior
|
||||
|
||||
### `instant`
|
||||
|
||||
- Click → validate afford/unlock → apply costs/yields immediately.
|
||||
- No queue slot, no duration.
|
||||
|
||||
### `timed`
|
||||
|
||||
- Current PR1/PR2 behavior: `durationMs`, manual `actionQueue`, progress bar.
|
||||
- Primary manual play column.
|
||||
|
||||
### `loop`
|
||||
|
||||
- Runs only when **`activeActionId` is null** and **manual `actionQueue` is empty**.
|
||||
- Player enables/disables per loop action (toggle); priority order when multiple enabled (content `loopPriority` number, lower first).
|
||||
- On completion while still idle, immediately repeats same loop action.
|
||||
- Respects costs; stops if unaffordable.
|
||||
- **Does not run offline** — idle-present behavior only.
|
||||
|
||||
### `story`
|
||||
|
||||
- Represents one graph choice. Content field `storyChoiceId` links to `StoryChoice.id`.
|
||||
- On execute → `applyChoice(state, content, storyChoiceId)` → sibling story actions hide via flags/requirements.
|
||||
- First visit: only available choices shown; player picks consciously.
|
||||
- After first manual completion of that action id: eligible for automation queue.
|
||||
|
||||
### `context`
|
||||
|
||||
- Switches active game context (location, combat scene, etc.).
|
||||
- M1: stub until opening arc requires it; schema + column chrome ship in T3.0.
|
||||
|
||||
## Automation (T3.1 — universal)
|
||||
|
||||
### Philosophy
|
||||
|
||||
Once the player has **completed an action manually at least once**, they may add it to an **automation queue** that repeats hands-off, **including offline** (within existing offline cap).
|
||||
|
||||
Applies to **all kinds** including `story` (after that specific branch was taken once). New/unseen branches remain manual until first completion.
|
||||
|
||||
Prestige/knowledge catch-up (T3.2) treats previously cleared routes as already completed for automation unlock purposes — same knowledge model as story fast-forward.
|
||||
|
||||
### Queues
|
||||
|
||||
| Queue | Purpose |
|
||||
|---|---|
|
||||
| `actionQueue` | Manual timed queue (player actively planning) |
|
||||
| `automationQueue` | Hands-off repeat pipeline |
|
||||
|
||||
Automation runner executes when manual queue is idle (exact precedence documented in engine; automation fills gaps like a background worker, distinct from loop-kind idle actions — both may need priority rules: **manual > automation > loop**).
|
||||
|
||||
### Automation recipe export/import
|
||||
|
||||
Recipes are **shareable text** referencing stable **content action ids** (not display names).
|
||||
|
||||
**Canonical multi-line format (v1):**
|
||||
|
||||
```text
|
||||
idlegame-recipe/v1
|
||||
# name: Early camp grind
|
||||
gather_supplies
|
||||
scout_path
|
||||
rest_briefly
|
||||
```
|
||||
|
||||
- Line 1: magic header with version (`idlegame-recipe/v1`).
|
||||
- Optional `# name:` comment for human label (ignored by parser if malformed).
|
||||
- One action id per non-empty, non-comment line.
|
||||
- Lines must match `/^[a-z][a-z0-9_]*$/` (same id convention as content defs).
|
||||
|
||||
**Single-line alias (optional parser support):**
|
||||
|
||||
```text
|
||||
idlegame-recipe/v1:gather_supplies,scout_path,rest_briefly
|
||||
```
|
||||
|
||||
**Compressed variant (long recipes):** same payload JSON + `lz-string` URI encoding as saves (`toRecipeExportString` / `fromRecipeExportString`) — optional in T3.1 if recipes exceed ~20 steps; multi-line text remains the default share format.
|
||||
|
||||
**Import validation:**
|
||||
|
||||
1. Parse version; reject unknown versions explicitly.
|
||||
2. Every id must exist in `content.actionsById`.
|
||||
3. Every id must be **automation-unlocked** for the current player state (`manualCompletionCounts[id] >= threshold`).
|
||||
4. On failure: user-visible error listing unknown or locked ids — no partial apply unless ginnoir opts in later.
|
||||
|
||||
**Export:** serializes current `automationQueue` (or named saved recipe slot if added later) to multi-line text; copy-to-clipboard in Settings or Play automation UI.
|
||||
|
||||
### State fields
|
||||
|
||||
```typescript
|
||||
manualCompletionCounts: Record<string, number> // default {}; gates automation
|
||||
automationQueue: string[] // action ids
|
||||
enabledLoopActionIds: Record<string, boolean> // loop toggles
|
||||
```
|
||||
|
||||
Persist in save payload (PR3 adds fields; PR4 may bump `SAVE_VERSION` to 2 with migration).
|
||||
|
||||
## Story tab
|
||||
|
||||
### Layout
|
||||
|
||||
Split pane, resizable on desktop:
|
||||
|
||||
```
|
||||
┌──────────────────────────┬─────────────────────┐
|
||||
│ Branching tree (~60%) │ Prose log (~40%) │
|
||||
│ │ │
|
||||
│ ○ boot_intro │ [Full passage text │
|
||||
│ ├─● route_a │ for selected or │
|
||||
│ │ └─ merchant │ latest node] │
|
||||
│ └─○ route_b (dimmed) │ │
|
||||
│ │ Scrollable history │
|
||||
└──────────────────────────┴─────────────────────┘
|
||||
```
|
||||
|
||||
- **Tree (primary surface):** built from story graph + `seenStoryNodeIds` / `storyFlags`. Taken paths emphasized; untaken branches dimmed. Click node → prose log scrolls to that beat. Larger pane because the tree needs manipulation room.
|
||||
- **Prose log (secondary):** full passage text, not truncated. Choice labels inline: `[You chose: Take the high road]`.
|
||||
- **No choice buttons.**
|
||||
|
||||
### Story map data
|
||||
|
||||
- Tree nodes mirror `storyNodesById` edges via choices + triggers.
|
||||
- Visibility: seen nodes solid; future/locked dimmed or hidden per design.
|
||||
- M1 T3.0 may ship minimal tree (indented list) before polish; structure must support real graph.
|
||||
|
||||
### Unread / navigation
|
||||
|
||||
- New story beats → Story nav badge.
|
||||
- `storyOpenMode: auto` → switch to Story tab or highlight badge (no overlay).
|
||||
|
||||
## Content schema changes
|
||||
|
||||
```typescript
|
||||
actionKindSchema = z.enum(['instant', 'loop', 'timed', 'story', 'context'])
|
||||
|
||||
actionGroupSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
})
|
||||
|
||||
actionDefSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
kind: actionKindSchema.default('timed'),
|
||||
group: actionGroupSchema,
|
||||
durationMs: z.number().positive().optional(), // required for timed + loop
|
||||
loopPriority: z.number().int().nonnegative().optional(),
|
||||
costs: ...,
|
||||
yields: ...,
|
||||
unlock: ...,
|
||||
storyHint: ...,
|
||||
storyTooltip: ...,
|
||||
storyChoiceId: z.string().min(1).optional(), // required when kind === 'story'
|
||||
contextId: z.string().min(1).optional(), // required when kind === 'context'
|
||||
automation: z.object({
|
||||
unlockAfterManualCompletions: z.number().int().positive().default(1),
|
||||
}).optional(),
|
||||
})
|
||||
```
|
||||
|
||||
**Validation rules:**
|
||||
|
||||
- `timed` and `loop` require `durationMs`.
|
||||
- `instant`, `story`, `context` must not require duration for execution (engine enforces).
|
||||
- `story` requires valid `storyChoiceId` referencing a choice in the loaded story graph.
|
||||
- Column placement derived from `kind` (not a separate column field).
|
||||
|
||||
### Stub content migration (T3.0)
|
||||
|
||||
| Current action | New kind | Group example |
|
||||
|---|---|---|
|
||||
| gather_supplies | timed | Camp |
|
||||
| scout_path | timed | Travel |
|
||||
| trade_at_camp | timed | Camp |
|
||||
| fortify_camp | timed | Camp (route A) |
|
||||
| push_onward | timed | Travel (route B) |
|
||||
| rest_briefly | loop | Camp activities |
|
||||
| *(new)* pick_high_road | story | Fork |
|
||||
| *(new)* follow_river | story | Fork |
|
||||
|
||||
Remove parallel choice buttons from story graph UI; fork choices exist only as story-kind actions.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/ui/AppShell.tsx nav rail + center + right rail layout
|
||||
src/ui/NavRail.tsx
|
||||
src/ui/PlayPanel.tsx action column grid
|
||||
src/ui/ActionColumn.tsx one kind column
|
||||
src/ui/ActionGroup.tsx collapsible group
|
||||
src/ui/StoryView.tsx split tree + prose log (replaces overlay StoryPanel)
|
||||
src/ui/StoryTree.tsx
|
||||
src/ui/StoryProseLog.tsx
|
||||
src/ui/RightRail.tsx resources, inventory placeholder, event log
|
||||
src/ui/AutomationBar.tsx automation queue UI + export/import (T3.1)
|
||||
|
||||
src/content/schema.ts + kind, group, storyChoiceId, contextId, automation
|
||||
src/engine/game.ts instant execute, loop idle runner, completion counts
|
||||
src/engine/automation.ts automation queue runner, recipe parse/serialize (new)
|
||||
src/engine/recipe.ts recipe export/import validation (new)
|
||||
src/state/viewModel.ts column/group projection, automation eligibility
|
||||
src/state/runtime.ts nav commands, remove overlay; story action → applyChoice
|
||||
src/state/prefs.ts + collapsedActionGroups, showEventLog
|
||||
```
|
||||
|
||||
**Boundaries unchanged:** engine pure TS; UI calls runtime only.
|
||||
|
||||
## PR3 task order (revised opener)
|
||||
|
||||
| Task | Deliverable | Gitea |
|
||||
|---|---|---|
|
||||
| **T3.0** | App shell, action columns, story-via-actions, Story tab split, remove overlay | UI gate |
|
||||
| **T3.1** | Universal automation + automation queue + recipe export/import | #7 |
|
||||
| **T3.2** | Prestige + knowledge catch-up for automation/story | #8 |
|
||||
| **T3.3** | Opening arc content on new shell | #11 |
|
||||
|
||||
## Testing
|
||||
|
||||
### Engine
|
||||
|
||||
- Instant action applies costs/yields without queue.
|
||||
- Loop runs when manual queue idle; stops when timed work queued.
|
||||
- Priority: manual timed > automation > loop (document exact rules in tests).
|
||||
- Story action executes `applyChoice`; siblings become unavailable.
|
||||
- `manualCompletionCounts` gates automation; story actions unlock after first perform.
|
||||
- Recipe round-trip: export → import → identical queue; reject unknown ids; reject locked ids.
|
||||
|
||||
### UI / view model
|
||||
|
||||
- Actions projected into correct column and group.
|
||||
- Group collapse prefs persist.
|
||||
- Story tab: tree click selects prose; no choice buttons rendered.
|
||||
- Nav badge on unread story.
|
||||
|
||||
### Regression
|
||||
|
||||
- Remove/update PR2 overlay tests and story choice button tests.
|
||||
- Existing queue/tick/story graph tests adapted for action-kind story forks.
|
||||
|
||||
## Out of scope (T3.0)
|
||||
|
||||
- Polished graph visualization (minimal tree OK).
|
||||
- Item inventory (resource list placeholder).
|
||||
- Combat mechanics (context column stub).
|
||||
- Mobile responsive column stack (PR4 #10).
|
||||
- Save v2 migration (PR4 #9) — PR3 adds new state fields with defaults in v1 payload until bump.
|
||||
|
||||
## PR2 supersession note
|
||||
|
||||
PR2 spec locked full-window overlay with in-panel choices. This spec **intentionally replaces** that UX. Engine story graph (`applyChoice`, triggers, flags) remains; only presentation and choice surface move to Play actions + Story read-only tab.
|
||||
|
||||
## Open questions (none blocking)
|
||||
|
||||
All brainstorm decisions resolved 2026-06-11.
|
||||
@@ -3,21 +3,28 @@ import { createGameState, enqueueAction, tickGame } from '../../engine/game';
|
||||
import { content } from '../index';
|
||||
|
||||
describe('M1 stub content pack', () => {
|
||||
it('defines two resources and four to five actions with costs and unlocks', () => {
|
||||
it('defines two resources and multiple actions with costs, unlocks, and varied kinds', () => {
|
||||
expect(content.resources).toHaveLength(2);
|
||||
expect(content.actions.length).toBeGreaterThanOrEqual(4);
|
||||
expect(content.actions.length).toBeLessThanOrEqual(6);
|
||||
const withCosts = content.actions.filter((a) => a.costs.length > 0);
|
||||
const withUnlocks = content.actions.filter((a) => a.unlock !== undefined);
|
||||
expect(withCosts.length).toBeGreaterThanOrEqual(2);
|
||||
expect(withUnlocks.length).toBeGreaterThanOrEqual(1);
|
||||
// verify the new kinds are present
|
||||
const kinds = new Set(content.actions.map((a) => a.kind));
|
||||
expect(kinds.has('timed')).toBe(true);
|
||||
expect(kinds.has('loop')).toBe(true);
|
||||
expect(kinds.has('story')).toBe(true);
|
||||
});
|
||||
|
||||
it('can simulate a costed action without throwing', () => {
|
||||
it('can simulate a costed timed action without throwing', () => {
|
||||
const state = createGameState(content);
|
||||
const trade = content.actions.find((a) => a.costs.length > 0);
|
||||
const trade = content.actions.find((a) => a.costs.length > 0 && a.kind === 'timed');
|
||||
if (!trade) {
|
||||
throw new Error('expected at least one costed action');
|
||||
throw new Error('expected at least one costed timed action');
|
||||
}
|
||||
if (trade.durationMs === undefined) {
|
||||
throw new Error('expected costed timed action to have durationMs');
|
||||
}
|
||||
enqueueAction(state, content, trade.id);
|
||||
tickGame(state, content, trade.durationMs);
|
||||
|
||||
@@ -6,6 +6,7 @@ const validActions = [
|
||||
{
|
||||
id: 'forage',
|
||||
name: 'Forage',
|
||||
group: { id: 'camp', label: 'Camp' },
|
||||
durationMs: 3000,
|
||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||
},
|
||||
@@ -28,6 +29,7 @@ describe('buildContent()', () => {
|
||||
{
|
||||
id: 'forage',
|
||||
name: 'Forage',
|
||||
group: { id: 'camp', label: 'Camp' },
|
||||
durationMs: 3000,
|
||||
yields: [{ resourceId: 'ghost', amount: 1 }],
|
||||
},
|
||||
@@ -48,6 +50,7 @@ describe('buildContent()', () => {
|
||||
{
|
||||
id: 'forage',
|
||||
name: 'Forage',
|
||||
group: { id: 'camp', label: 'Camp' },
|
||||
durationMs: -1,
|
||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||
},
|
||||
@@ -66,6 +69,7 @@ describe('costs and multi-yield', () => {
|
||||
{
|
||||
id: 'craft',
|
||||
name: 'Craft',
|
||||
group: { id: 'camp', label: 'Camp' },
|
||||
durationMs: 1000,
|
||||
costs: [{ resourceId: 'wood', amount: 2 }],
|
||||
yields: [
|
||||
@@ -84,6 +88,7 @@ describe('costs and multi-yield', () => {
|
||||
{
|
||||
id: 'craft',
|
||||
name: 'Craft',
|
||||
group: { id: 'camp', label: 'Camp' },
|
||||
durationMs: 1000,
|
||||
costs: [{ resourceId: 'ghost', amount: 1 }],
|
||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||
@@ -104,6 +109,7 @@ describe('unlock conditions', () => {
|
||||
{
|
||||
id: 'forage',
|
||||
name: 'Forage',
|
||||
group: { id: 'camp', label: 'Camp' },
|
||||
durationMs: 3000,
|
||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||
unlock: {
|
||||
@@ -131,6 +137,7 @@ describe('action narrative fields', () => {
|
||||
{
|
||||
id: 'forage',
|
||||
name: 'Forage',
|
||||
group: { id: 'camp', label: 'Camp' },
|
||||
durationMs: 3000,
|
||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||
storyHint: 'Gather what the forest offers.',
|
||||
@@ -148,3 +155,74 @@ describe('action narrative fields', () => {
|
||||
expect(content.actionsById.forage.storyTooltip).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('action kind schema', () => {
|
||||
it('accepts kind, group, and storyChoiceId', () => {
|
||||
const content = buildContent({
|
||||
resources: [{ id: 'supplies', name: 'Supplies' }],
|
||||
actions: [
|
||||
{
|
||||
id: 'pick_high_road',
|
||||
name: 'Take the high road',
|
||||
kind: 'story',
|
||||
group: { id: 'fork', label: 'Crossroads' },
|
||||
storyChoiceId: 'pick_a',
|
||||
yields: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(content.actionsById.pick_high_road.kind).toBe('story');
|
||||
expect(content.actionsById.pick_high_road.group.label).toBe('Crossroads');
|
||||
});
|
||||
|
||||
it('defaults kind to timed and requires durationMs for timed actions', () => {
|
||||
expect(() =>
|
||||
buildContent({
|
||||
resources: [{ id: 'supplies', name: 'Supplies' }],
|
||||
actions: [
|
||||
{
|
||||
id: 'broken',
|
||||
name: 'Broken',
|
||||
kind: 'timed',
|
||||
group: { id: 'camp', label: 'Camp' },
|
||||
yields: [{ resourceId: 'supplies', amount: 1 }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('requires durationMs for loop actions', () => {
|
||||
const content = buildContent({
|
||||
resources: [{ id: 'supplies', name: 'Supplies' }],
|
||||
actions: [
|
||||
{
|
||||
id: 'rest',
|
||||
name: 'Rest',
|
||||
kind: 'loop',
|
||||
group: { id: 'camp_loop', label: 'Camp activities' },
|
||||
durationMs: 2000,
|
||||
yields: [{ resourceId: 'supplies', amount: 1 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(content.actionsById.rest.kind).toBe('loop');
|
||||
});
|
||||
|
||||
it('rejects loop action without durationMs', () => {
|
||||
expect(() =>
|
||||
buildContent({
|
||||
resources: [{ id: 'supplies', name: 'Supplies' }],
|
||||
actions: [
|
||||
{
|
||||
id: 'broken_loop',
|
||||
name: 'Broken loop',
|
||||
kind: 'loop',
|
||||
group: { id: 'camp', label: 'Camp' },
|
||||
yields: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow(/durationMs/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ const actionsById = {
|
||||
scout_path: {
|
||||
id: 'scout_path',
|
||||
name: 'Scout',
|
||||
kind: 'timed' as const,
|
||||
group: { id: 'travel', label: 'Travel' },
|
||||
durationMs: 1000,
|
||||
costs: [],
|
||||
yields: [{ resourceId: 'coin', amount: 1 }],
|
||||
|
||||
@@ -7,6 +7,8 @@ export const actionDefs = [
|
||||
{
|
||||
id: 'gather_supplies',
|
||||
name: 'Gather supplies',
|
||||
kind: 'timed',
|
||||
group: { id: 'camp', label: 'Camp' },
|
||||
durationMs: 3000,
|
||||
yields: [{ resourceId: 'supplies', amount: 2 }],
|
||||
storyHint: 'Basic camp labor.',
|
||||
@@ -15,6 +17,8 @@ export const actionDefs = [
|
||||
{
|
||||
id: 'scout_path',
|
||||
name: 'Scout the path',
|
||||
kind: 'timed',
|
||||
group: { id: 'travel', label: 'Travel' },
|
||||
durationMs: 5000,
|
||||
costs: [{ resourceId: 'supplies', amount: 2 }],
|
||||
yields: [{ resourceId: 'coin', amount: 1 }],
|
||||
@@ -24,6 +28,8 @@ export const actionDefs = [
|
||||
{
|
||||
id: 'trade_supplies',
|
||||
name: 'Trade at camp',
|
||||
kind: 'timed',
|
||||
group: { id: 'camp', label: 'Camp' },
|
||||
durationMs: 4000,
|
||||
costs: [{ resourceId: 'supplies', amount: 3 }],
|
||||
yields: [{ resourceId: 'coin', amount: 2 }],
|
||||
@@ -34,6 +40,8 @@ export const actionDefs = [
|
||||
{
|
||||
id: 'fortify_camp',
|
||||
name: 'Fortify camp',
|
||||
kind: 'timed',
|
||||
group: { id: 'camp', label: 'Camp' },
|
||||
durationMs: 8000,
|
||||
costs: [
|
||||
{ resourceId: 'supplies', amount: 5 },
|
||||
@@ -47,6 +55,8 @@ export const actionDefs = [
|
||||
{
|
||||
id: 'push_onward',
|
||||
name: 'Push onward',
|
||||
kind: 'timed',
|
||||
group: { id: 'travel', label: 'Travel' },
|
||||
durationMs: 6000,
|
||||
costs: [{ resourceId: 'supplies', amount: 2 }],
|
||||
yields: [{ resourceId: 'coin', amount: 3 }],
|
||||
@@ -57,9 +67,32 @@ export const actionDefs = [
|
||||
{
|
||||
id: 'rest',
|
||||
name: 'Rest briefly',
|
||||
kind: 'loop',
|
||||
group: { id: 'camp_loop', label: 'Camp activities' },
|
||||
loopPriority: 0,
|
||||
durationMs: 2000,
|
||||
yields: [{ resourceId: 'supplies', amount: 1 }],
|
||||
storyHint: 'Catch your breath.',
|
||||
storyTooltip: 'Yields 1 Supply. Quick recovery.',
|
||||
storyTooltip: 'Idle upkeep — runs when nothing else is queued.',
|
||||
},
|
||||
{
|
||||
id: 'pick_high_road',
|
||||
name: 'Take the high road',
|
||||
kind: 'story',
|
||||
group: { id: 'fork', label: 'Crossroads' },
|
||||
storyChoiceId: 'pick_a',
|
||||
storyHint: 'Route A — high ground and supplies.',
|
||||
storyTooltip: 'Story fork: grants route A flag and resources. Hides river path.',
|
||||
yields: [],
|
||||
},
|
||||
{
|
||||
id: 'follow_river',
|
||||
name: 'Follow the river',
|
||||
kind: 'story',
|
||||
group: { id: 'fork', label: 'Crossroads' },
|
||||
storyChoiceId: 'pick_b',
|
||||
storyHint: 'Route B — river trade and coin.',
|
||||
storyTooltip: 'Story fork: grants route B flag. Hides high road path.',
|
||||
yields: [],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -9,4 +9,4 @@ const story = buildStoryContent(storyNodeDefs, base.actionsById, base.resourcesB
|
||||
export type GameContent = Content & StoryContent;
|
||||
export const content: GameContent = { ...base, ...story };
|
||||
|
||||
export type { ActionDef, Content, ResourceDef } from './schema';
|
||||
export type { ActionDef, ActionGroup, ActionKind, Content, ResourceDef } from './schema';
|
||||
|
||||
+46
-3
@@ -25,20 +25,63 @@ export const unlockDefSchema = z.object({
|
||||
requireStoryFlags: z.array(z.string().min(1)).optional(),
|
||||
});
|
||||
|
||||
export const actionDefSchema = z.object({
|
||||
export const actionKindSchema = z.enum(['instant', 'loop', 'timed', 'story', 'context']);
|
||||
|
||||
export const actionGroupSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
});
|
||||
|
||||
export const actionDefSchema = z
|
||||
.object({
|
||||
id: 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([]),
|
||||
yields: z.array(resourceAmountSchema).min(1),
|
||||
yields: z.array(resourceAmountSchema).default([]),
|
||||
unlock: unlockDefSchema.optional(),
|
||||
storyHint: z.string().min(1).optional(),
|
||||
storyTooltip: z.string().min(1).optional(),
|
||||
storyChoiceId: z.string().min(1).optional(),
|
||||
contextId: z.string().min(1).optional(),
|
||||
automation: z
|
||||
.object({
|
||||
unlockAfterManualCompletions: z.number().int().positive().default(1),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.superRefine((action, ctx) => {
|
||||
if ((action.kind === 'timed' || action.kind === 'loop') && action.durationMs === undefined) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `${action.kind} actions require durationMs`,
|
||||
path: ['durationMs'],
|
||||
});
|
||||
}
|
||||
if (action.kind === 'story' && action.storyChoiceId === undefined) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'story actions require storyChoiceId',
|
||||
path: ['storyChoiceId'],
|
||||
});
|
||||
}
|
||||
if (action.kind === 'timed' && action.yields.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'timed actions require at least one yield',
|
||||
path: ['yields'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type ResourceDef = z.infer<typeof resourceDefSchema>;
|
||||
export type ResourceAmount = z.infer<typeof resourceAmountSchema>;
|
||||
export type UnlockDef = z.infer<typeof unlockDefSchema>;
|
||||
export type ActionKind = z.infer<typeof actionKindSchema>;
|
||||
export type ActionGroup = z.infer<typeof actionGroupSchema>;
|
||||
export type ActionDef = z.infer<typeof actionDefSchema>;
|
||||
|
||||
export interface Content {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ function testContent() {
|
||||
{
|
||||
id: 'forage',
|
||||
name: 'Forage',
|
||||
group: { id: 'test', label: 'Test' },
|
||||
durationMs: 300,
|
||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||
},
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { content as gameContent } from '../../content/index';
|
||||
import { buildContent } from '../../content/schema';
|
||||
import { buildStoryContent } from '../../content/storySchema';
|
||||
import {
|
||||
cancelQueuedAction,
|
||||
canUnlockAction,
|
||||
clearQueue,
|
||||
createGameState,
|
||||
enqueueAction,
|
||||
executeInstant,
|
||||
maybeStartLoopAction,
|
||||
performAction,
|
||||
recordManualCompletion,
|
||||
startAction,
|
||||
tickGame,
|
||||
} from '../game';
|
||||
import { enterStoryNode } from '../story';
|
||||
|
||||
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
|
||||
|
||||
function testContent() {
|
||||
return buildContent({
|
||||
@@ -17,6 +26,7 @@ function testContent() {
|
||||
{
|
||||
id: 'forage',
|
||||
name: 'Forage',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 300,
|
||||
yields: [{ resourceId: 'gold', amount: 2 }],
|
||||
},
|
||||
@@ -28,9 +38,27 @@ function queueContent() {
|
||||
return buildContent({
|
||||
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
|
||||
actions: [
|
||||
{ id: 'a', name: 'A', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] },
|
||||
{ id: 'b', name: 'B', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] },
|
||||
{ id: 'c', name: 'C', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] },
|
||||
{
|
||||
id: 'a',
|
||||
name: 'A',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 1000,
|
||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
name: 'B',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 1000,
|
||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'c',
|
||||
name: 'C',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 1000,
|
||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -45,12 +73,14 @@ function costContent() {
|
||||
{
|
||||
id: 'gather',
|
||||
name: 'Gather',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 300,
|
||||
yields: [{ resourceId: 'supplies', amount: 2 }],
|
||||
},
|
||||
{
|
||||
id: 'trade',
|
||||
name: 'Trade',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 300,
|
||||
costs: [{ resourceId: 'supplies', amount: 5 }],
|
||||
yields: [{ resourceId: 'coin', amount: 3 }],
|
||||
@@ -58,6 +88,7 @@ function costContent() {
|
||||
{
|
||||
id: 'scout',
|
||||
name: 'Scout',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 300,
|
||||
yields: [{ resourceId: 'coin', amount: 1 }],
|
||||
unlock: { minResources: { coin: 1 } },
|
||||
@@ -74,6 +105,11 @@ describe('createGameState()', () => {
|
||||
expect(state.actionElapsedMs).toBe(0);
|
||||
expect(state.actionQueue).toEqual([]);
|
||||
});
|
||||
|
||||
it('initializes empty manual completion counts', () => {
|
||||
const state = createGameState(testContent());
|
||||
expect(state.manualCompletionCounts).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGameState() story fields', () => {
|
||||
@@ -204,6 +240,7 @@ describe('unlock conditions', () => {
|
||||
{
|
||||
id: 'secret',
|
||||
name: 'Secret',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 100,
|
||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||
unlock: { requireStoryFlags: ['path_scouted'] },
|
||||
@@ -245,12 +282,14 @@ describe('completion advances queue', () => {
|
||||
{
|
||||
id: 'cheap',
|
||||
name: 'Cheap',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 100,
|
||||
yields: [{ resourceId: 'supplies', amount: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'dear',
|
||||
name: 'Dear',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 100,
|
||||
costs: [{ resourceId: 'supplies', amount: 6 }],
|
||||
yields: [{ resourceId: 'supplies', amount: 1 }],
|
||||
@@ -258,6 +297,7 @@ describe('completion advances queue', () => {
|
||||
{
|
||||
id: 'free',
|
||||
name: 'Free',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 100,
|
||||
yields: [{ resourceId: 'supplies', amount: 1 }],
|
||||
},
|
||||
@@ -283,6 +323,7 @@ describe('completion advances queue', () => {
|
||||
{
|
||||
id: 'combo',
|
||||
name: 'Combo',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 100,
|
||||
yields: [
|
||||
{ resourceId: 'a', amount: 2 },
|
||||
@@ -316,6 +357,24 @@ describe('tickGame() completion result', () => {
|
||||
const result = tickGame(state, content, 100);
|
||||
expect(result.completedActionIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('increments manualCompletionCounts when a timed action completes', () => {
|
||||
const content = testContent();
|
||||
const state = createGameState(content);
|
||||
enqueueAction(state, content, 'forage');
|
||||
tickGame(state, content, 300);
|
||||
expect(state.manualCompletionCounts.forage).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordManualCompletion()', () => {
|
||||
it('increments known content actions only', () => {
|
||||
const content = testContent();
|
||||
const state = createGameState(content);
|
||||
recordManualCompletion(state, content, 'forage');
|
||||
recordManualCompletion(state, content, 'missing');
|
||||
expect(state.manualCompletionCounts).toEqual({ forage: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('tickGame()', () => {
|
||||
@@ -356,3 +415,176 @@ describe('tickGame()', () => {
|
||||
expect(state.activeActionId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeInstant()', () => {
|
||||
const instantContent = buildContent({
|
||||
resources: [
|
||||
{ id: 'supplies', name: 'Supplies', startAmount: 0 },
|
||||
{ id: 'coin', name: 'Coin', startAmount: 5 },
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: 'buy_supply',
|
||||
name: 'Buy supply',
|
||||
kind: 'instant',
|
||||
group: { id: 'buy', label: 'Buy' },
|
||||
costs: [{ resourceId: 'coin', amount: 2 }],
|
||||
yields: [{ resourceId: 'supplies', amount: 1 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it('applies costs and yields immediately without queueing', () => {
|
||||
const state = createGameState(instantContent);
|
||||
executeInstant(state, instantContent, 'buy_supply');
|
||||
expect(state.resources.coin).toBe(3);
|
||||
expect(state.resources.supplies).toBe(1);
|
||||
expect(state.activeActionId).toBeNull();
|
||||
expect(state.actionQueue).toEqual([]);
|
||||
expect(state.manualCompletionCounts.buy_supply).toBe(1);
|
||||
});
|
||||
|
||||
it('throws when unaffordable', () => {
|
||||
const state = createGameState(instantContent);
|
||||
state.resources.coin = 0;
|
||||
expect(() => executeInstant(state, instantContent, 'buy_supply')).toThrow(/Cannot/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loop idle runner', () => {
|
||||
const loopContent = buildContent({
|
||||
resources: [{ id: 'supplies', name: 'Supplies', startAmount: 0 }],
|
||||
actions: [
|
||||
{
|
||||
id: 'rest',
|
||||
name: 'Rest',
|
||||
kind: 'loop',
|
||||
group: { id: 'camp_loop', label: 'Camp' },
|
||||
durationMs: 1000,
|
||||
loopPriority: 0,
|
||||
yields: [{ resourceId: 'supplies', amount: 1 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it('defaults enabledLoopActionIds to empty', () => {
|
||||
const state = createGameState(loopContent);
|
||||
expect(state.enabledLoopActionIds).toEqual({});
|
||||
});
|
||||
|
||||
it('starts enabled loop action when idle', () => {
|
||||
const state = createGameState(loopContent);
|
||||
state.enabledLoopActionIds = { rest: true };
|
||||
maybeStartLoopAction(state, loopContent);
|
||||
expect(state.activeActionId).toBe('rest');
|
||||
});
|
||||
|
||||
it('does not start loop when queue has items', () => {
|
||||
const state = createGameState(loopContent);
|
||||
state.enabledLoopActionIds = { rest: true };
|
||||
state.actionQueue.push('rest');
|
||||
maybeStartLoopAction(state, loopContent);
|
||||
expect(state.activeActionId).toBeNull();
|
||||
});
|
||||
|
||||
it('does not start a disabled loop action', () => {
|
||||
const state = createGameState(loopContent);
|
||||
maybeStartLoopAction(state, loopContent);
|
||||
expect(state.activeActionId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('performAction()', () => {
|
||||
it('dispatches timed actions through enqueueAction and returns empty array', () => {
|
||||
const state = createGameState(gameContent);
|
||||
const events = performAction(state, gameContent, 'gather_supplies');
|
||||
expect(state.activeActionId).toBe('gather_supplies');
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it('toggles loop actions, starts them when idle, and returns empty array', () => {
|
||||
const state = createGameState(gameContent);
|
||||
const events1 = performAction(state, gameContent, 'rest'); // enable
|
||||
expect(state.enabledLoopActionIds.rest).toBe(true);
|
||||
expect(state.activeActionId).toBe('rest'); // started because idle + available
|
||||
expect(events1).toEqual([]);
|
||||
const events2 = performAction(state, gameContent, 'rest'); // disable
|
||||
expect(state.enabledLoopActionIds.rest).toBe(false);
|
||||
expect(events2).toEqual([]);
|
||||
});
|
||||
|
||||
it('dispatches story actions through executeStoryAction and returns events', () => {
|
||||
const state = createGameState(gameContent);
|
||||
enterStoryNode(state, gameContent, 'fork_choice');
|
||||
const events = performAction(state, gameContent, 'pick_high_road');
|
||||
expect(state.storyFlags.route_a).toBe(true);
|
||||
expect(state.manualCompletionCounts.pick_high_road).toBe(1);
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].kind).toBe('enter');
|
||||
});
|
||||
|
||||
it('executes instant actions immediately', () => {
|
||||
const base = buildContent({
|
||||
resources: [
|
||||
{ id: 'supplies', name: 'Supplies', startAmount: 0 },
|
||||
{ id: 'coin', name: 'Coin', startAmount: 5 },
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: 'buy_supply',
|
||||
name: 'Buy supply',
|
||||
kind: 'instant',
|
||||
group: { id: 'buy', label: 'Buy' },
|
||||
costs: [{ resourceId: 'coin', amount: 2 }],
|
||||
yields: [{ resourceId: 'supplies', amount: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'enter_cave',
|
||||
name: 'Enter cave',
|
||||
kind: 'context',
|
||||
group: { id: 'travel', label: 'Travel' },
|
||||
contextId: 'cave',
|
||||
},
|
||||
],
|
||||
});
|
||||
const story = buildStoryContent(
|
||||
[{ id: 'boot', prose: 'x', triggers: [{ type: 'boot', targetNodeId: 'boot' }] }],
|
||||
base.actionsById,
|
||||
base.resourcesById,
|
||||
);
|
||||
const fixture = { ...base, ...story };
|
||||
const state = createGameState(fixture);
|
||||
performAction(state, fixture, 'buy_supply');
|
||||
expect(state.resources.coin).toBe(3);
|
||||
expect(state.resources.supplies).toBe(1);
|
||||
expect(state.activeActionId).toBeNull();
|
||||
});
|
||||
|
||||
it('throws for context actions (not implemented in M1)', () => {
|
||||
const base = buildContent({
|
||||
resources: [{ id: 'supplies', name: 'Supplies', startAmount: 0 }],
|
||||
actions: [
|
||||
{
|
||||
id: 'enter_cave',
|
||||
name: 'Enter cave',
|
||||
kind: 'context',
|
||||
group: { id: 'travel', label: 'Travel' },
|
||||
contextId: 'cave',
|
||||
},
|
||||
],
|
||||
});
|
||||
const story = buildStoryContent(
|
||||
[{ id: 'boot', prose: 'x', triggers: [{ type: 'boot', targetNodeId: 'boot' }] }],
|
||||
base.actionsById,
|
||||
base.resourcesById,
|
||||
);
|
||||
const fixture = { ...base, ...story };
|
||||
const state = createGameState(fixture);
|
||||
expect(() => performAction(state, fixture, 'enter_cave')).toThrow(/not implemented/i);
|
||||
});
|
||||
|
||||
it('throws for unknown actions', () => {
|
||||
const state = createGameState(gameContent);
|
||||
expect(() => performAction(state, gameContent, 'nope')).toThrow(/[Uu]nknown/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,40 @@ import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ENGINE_DIR = join(import.meta.dirname, '..');
|
||||
const FORBIDDEN = [/from\s+['"]react/, /from\s+['"]react-dom/, /from\s+['"]zustand/];
|
||||
|
||||
/**
|
||||
* Patterns that must NOT appear in engine source files.
|
||||
*
|
||||
* React / state-manager imports — match the import statement so plain
|
||||
* string occurrences in comments are not flagged.
|
||||
*
|
||||
* Environment / browser / wall-clock APIs — matched as usage tokens
|
||||
* (property-access or call-site forms) to avoid false-positives on
|
||||
* prose comments that name these APIs without using them. In
|
||||
* particular:
|
||||
* - `Date\.now\(` catches the call site; a comment saying "Date
|
||||
* scheduling" does not contain "Date.now(" so it passes.
|
||||
* - `localStorage\.` / `indexedDB\.` catch member-access, not the
|
||||
* bare words that appear in save.ts's module-doc comment.
|
||||
* - `from\s+['"]idb-keyval` catches the package import.
|
||||
* - `\bdocument\.` / `\bwindow\.` catch DOM member-access.
|
||||
* - `requestAnimationFrame\(` catches the call site.
|
||||
*
|
||||
* lz-string is a pure compression library used by save.ts — it is
|
||||
* intentionally NOT in this list.
|
||||
*/
|
||||
const FORBIDDEN: { pattern: RegExp; label: string }[] = [
|
||||
{ pattern: /from\s+['"]react['"]/, label: 'react import' },
|
||||
{ pattern: /from\s+['"]react-dom['"]/, label: 'react-dom import' },
|
||||
{ pattern: /from\s+['"]zustand['"]/, label: 'zustand import' },
|
||||
{ pattern: /Date\.now\(/, label: 'Date.now() call (wall-clock)' },
|
||||
{ pattern: /localStorage\./, label: 'localStorage access (storage API)' },
|
||||
{ pattern: /indexedDB\./, label: 'indexedDB access (storage API)' },
|
||||
{ pattern: /from\s+['"]idb-keyval['"]/, label: 'idb-keyval import (storage API)' },
|
||||
{ pattern: /\bdocument\./, label: 'document access (DOM API)' },
|
||||
{ pattern: /\bwindow\./, label: 'window access (browser global)' },
|
||||
{ pattern: /requestAnimationFrame\(/, label: 'requestAnimationFrame call (scheduling API)' },
|
||||
];
|
||||
|
||||
async function engineSourceFiles(): Promise<string[]> {
|
||||
const entries = await readdir(ENGINE_DIR, { withFileTypes: true });
|
||||
@@ -18,8 +51,8 @@ describe('engine purity', () => {
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
for (const file of files) {
|
||||
const source = await readFile(file, 'utf8');
|
||||
for (const pattern of FORBIDDEN) {
|
||||
expect(source, `${file} must stay free of ${pattern}`).not.toMatch(pattern);
|
||||
for (const { pattern, label } of FORBIDDEN) {
|
||||
expect(source, `${file} must not use ${label}`).not.toMatch(pattern);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ function testContent() {
|
||||
{
|
||||
id: 'forage',
|
||||
name: 'Forage',
|
||||
group: { id: 'test', label: 'Test' },
|
||||
durationMs: 3000,
|
||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||
},
|
||||
@@ -69,6 +70,31 @@ describe('createSave()', () => {
|
||||
expect(save.state.currentStoryNodeId).toBe('route_a_beat');
|
||||
expect(save.state.seenStoryNodeIds).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('snapshots enabledLoopActionIds in the save payload and isolates from mutation', () => {
|
||||
const state = sampleState();
|
||||
state.enabledLoopActionIds = { rest: true };
|
||||
const save = createSave(state, 1700);
|
||||
expect(save.state.enabledLoopActionIds).toEqual({ rest: true });
|
||||
state.enabledLoopActionIds.rest = false;
|
||||
expect(save.state.enabledLoopActionIds).toEqual({ rest: true });
|
||||
});
|
||||
|
||||
it('snapshots manualCompletionCounts and automationQueue in the save payload', () => {
|
||||
const state = sampleState() as ReturnType<typeof sampleState> & {
|
||||
manualCompletionCounts: Record<string, number>;
|
||||
automationQueue: string[];
|
||||
};
|
||||
state.manualCompletionCounts = { forage: 2 };
|
||||
state.automationQueue = ['forage'];
|
||||
const save = createSave(state, 1700);
|
||||
expect(save.state.manualCompletionCounts).toEqual({ forage: 2 });
|
||||
expect(save.state.automationQueue).toEqual(['forage']);
|
||||
state.manualCompletionCounts.forage = 3;
|
||||
state.automationQueue.push('forage');
|
||||
expect(save.state.manualCompletionCounts).toEqual({ forage: 2 });
|
||||
expect(save.state.automationQueue).toEqual(['forage']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialize / deserialize round-trip', () => {
|
||||
@@ -83,6 +109,54 @@ describe('serialize / deserialize round-trip', () => {
|
||||
const restored = fromExportString(toExportString(save));
|
||||
expect(restored).toEqual(save);
|
||||
});
|
||||
|
||||
it('preserves enabledLoopActionIds through a round-trip', () => {
|
||||
const state = sampleState();
|
||||
state.enabledLoopActionIds = { rest: true, patrol: false };
|
||||
const restored = deserializeSave(serializeSave(createSave(state, 1700)));
|
||||
expect(restored.state.enabledLoopActionIds).toEqual({ rest: true, patrol: false });
|
||||
});
|
||||
|
||||
it('preserves manualCompletionCounts and automationQueue through a round-trip', () => {
|
||||
const state = sampleState() as ReturnType<typeof sampleState> & {
|
||||
manualCompletionCounts: Record<string, number>;
|
||||
automationQueue: string[];
|
||||
};
|
||||
state.manualCompletionCounts = { forage: 4 };
|
||||
state.automationQueue = ['forage'];
|
||||
const restored = deserializeSave(serializeSave(createSave(state, 1700)));
|
||||
expect(restored.state.manualCompletionCounts).toEqual({ forage: 4 });
|
||||
expect(restored.state.automationQueue).toEqual(['forage']);
|
||||
});
|
||||
|
||||
it('defaults enabledLoopActionIds to {} when absent from save JSON', () => {
|
||||
const json = JSON.stringify({
|
||||
version: 1,
|
||||
savedAt: 1700,
|
||||
state: {
|
||||
resources: { gold: 0 },
|
||||
activeActionId: null,
|
||||
actionElapsedMs: 0,
|
||||
},
|
||||
});
|
||||
const restored = deserializeSave(json);
|
||||
expect(restored.state.enabledLoopActionIds).toEqual({});
|
||||
});
|
||||
|
||||
it('defaults manualCompletionCounts and automationQueue when absent from save JSON', () => {
|
||||
const json = JSON.stringify({
|
||||
version: 1,
|
||||
savedAt: 1700,
|
||||
state: {
|
||||
resources: { gold: 0 },
|
||||
activeActionId: null,
|
||||
actionElapsedMs: 0,
|
||||
},
|
||||
});
|
||||
const restored = deserializeSave(json);
|
||||
expect(restored.state.manualCompletionCounts).toEqual({});
|
||||
expect(restored.state.automationQueue).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid / tampered saves', () => {
|
||||
@@ -104,6 +178,35 @@ describe('invalid / tampered saves', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyOfflineProgress() — loop invariant', () => {
|
||||
it('does NOT start a loop action during offline catch-up even when it is enabled', () => {
|
||||
// Hardest invariant: maybeStartLoopAction is called by the runtime AFTER each live
|
||||
// tick, never from tickGame itself. Offline catch-up replays tickGame directly, so
|
||||
// loop actions must never start (and therefore never yield) during catch-up.
|
||||
const content = buildContent({
|
||||
resources: [{ id: 'wood', name: 'Wood', startAmount: 0 }],
|
||||
actions: [
|
||||
{
|
||||
id: 'chop',
|
||||
name: 'Chop Wood',
|
||||
kind: 'loop',
|
||||
group: { id: 'test', label: 'Test' },
|
||||
durationMs: 1000,
|
||||
yields: [{ resourceId: 'wood', amount: 1 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const state = createGameState(content);
|
||||
// Enable the loop — player has toggled it on — but do NOT make it active.
|
||||
state.enabledLoopActionIds.chop = true;
|
||||
// Simulate coming back online after 10 seconds (10 full loop durations).
|
||||
applyOfflineProgress(state, content, 0, 10_000);
|
||||
// The loop must NOT have started or yielded during offline catch-up.
|
||||
expect(state.activeActionId).toBeNull();
|
||||
expect(state.resources.wood).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyOfflineProgress()', () => {
|
||||
it('credits whole ticks of elapsed time to the active action', () => {
|
||||
const content = testContent();
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
type StoryEvent,
|
||||
} from '../story';
|
||||
|
||||
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
|
||||
|
||||
function gameContent() {
|
||||
const base = buildContent({
|
||||
resources: [
|
||||
@@ -23,6 +25,7 @@ function gameContent() {
|
||||
{
|
||||
id: 'scout_path',
|
||||
name: 'Scout',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 1000,
|
||||
costs: [],
|
||||
yields: [{ resourceId: 'coin', amount: 1 }],
|
||||
@@ -54,6 +57,7 @@ function gameContentWithActionTrigger() {
|
||||
{
|
||||
id: 'scout_path',
|
||||
name: 'Scout',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 1000,
|
||||
costs: [],
|
||||
yields: [{ resourceId: 'coin', amount: 1 }],
|
||||
@@ -91,6 +95,7 @@ function gameContentWithThreshold() {
|
||||
{
|
||||
id: 'scout_path',
|
||||
name: 'Scout',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 1000,
|
||||
costs: [],
|
||||
yields: [{ resourceId: 'coin', amount: 1 }],
|
||||
@@ -132,6 +137,7 @@ function gameContentWithFork() {
|
||||
{
|
||||
id: 'scout_path',
|
||||
name: 'Scout',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 1000,
|
||||
costs: [],
|
||||
yields: [{ resourceId: 'coin', amount: 1 }],
|
||||
@@ -189,6 +195,7 @@ function gameContentWithGatedChoice() {
|
||||
{
|
||||
id: 'scout_path',
|
||||
name: 'Scout',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 1000,
|
||||
costs: [],
|
||||
yields: [{ resourceId: 'coin', amount: 1 }],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -1,4 +1,9 @@
|
||||
import type { Content } from '../content/schema';
|
||||
import type { StoryContent } from '../content/storySchema';
|
||||
import { maybeRunAutomation } from './automation';
|
||||
import { applyChoice, isStoryChoiceAvailable, type StoryEvent } from './story';
|
||||
|
||||
type GameContent = Content & StoryContent;
|
||||
|
||||
/**
|
||||
* Core game state and per-tick simulation.
|
||||
@@ -22,6 +27,12 @@ export interface GameState {
|
||||
currentStoryNodeId: string;
|
||||
/** Story node ids the player has already seen. */
|
||||
seenStoryNodeIds: string[];
|
||||
/** loop-kind action id -> whether the player has enabled it for idle running. */
|
||||
enabledLoopActionIds: Record<string, boolean>;
|
||||
/** action id -> number of successful player-enabled completions. */
|
||||
manualCompletionCounts: Record<string, number>;
|
||||
/** Action ids configured for future automation repeat. */
|
||||
automationQueue: string[];
|
||||
}
|
||||
|
||||
export function createGameState(content: Content): GameState {
|
||||
@@ -37,6 +48,9 @@ export function createGameState(content: Content): GameState {
|
||||
storyFlags: {},
|
||||
currentStoryNodeId: '',
|
||||
seenStoryNodeIds: [],
|
||||
enabledLoopActionIds: {},
|
||||
manualCompletionCounts: {},
|
||||
automationQueue: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,7 +104,12 @@ function grantYields(state: GameState, content: Content, actionId: string): void
|
||||
}
|
||||
}
|
||||
|
||||
function beginAction(state: GameState, content: Content, actionId: string): void {
|
||||
export function recordManualCompletion(state: GameState, content: Content, actionId: string): void {
|
||||
if (!content.actionsById[actionId]) return;
|
||||
state.manualCompletionCounts[actionId] = (state.manualCompletionCounts[actionId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
export function beginAction(state: GameState, content: Content, actionId: string): void {
|
||||
assertKnownAction(content, actionId);
|
||||
deductCosts(state, content, actionId);
|
||||
state.activeActionId = actionId;
|
||||
@@ -110,6 +129,7 @@ function startNextFromQueue(state: GameState, content: Content): void {
|
||||
}
|
||||
state.activeActionId = null;
|
||||
state.actionElapsedMs = 0;
|
||||
maybeRunAutomation(state, content);
|
||||
}
|
||||
|
||||
export interface TickResult {
|
||||
@@ -156,23 +176,135 @@ export function clearQueue(state: GameState): void {
|
||||
state.actionQueue.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an instant action immediately, deducting costs and granting yields
|
||||
* without occupying a queue slot or requiring a duration.
|
||||
* Throws if the action is not of kind 'instant' or is unavailable.
|
||||
*/
|
||||
export function executeInstant(state: GameState, content: Content, actionId: string): void {
|
||||
const action = content.actionsById[actionId];
|
||||
if (action?.kind !== 'instant') {
|
||||
throw new Error(`Action "${actionId}" is not instant`);
|
||||
}
|
||||
if (!isActionAvailable(state, content, actionId)) {
|
||||
throw new Error(`Cannot perform instant action "${actionId}"`);
|
||||
}
|
||||
deductCosts(state, content, actionId);
|
||||
grantYields(state, content, actionId);
|
||||
recordManualCompletion(state, content, actionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a story action by resolving its storyChoiceId and applying it.
|
||||
* Throws if the action is not of kind 'story', has no storyChoiceId, or the
|
||||
* choice is not currently available.
|
||||
*/
|
||||
export function executeStoryAction(
|
||||
state: GameState,
|
||||
content: GameContent,
|
||||
actionId: string,
|
||||
): StoryEvent[] {
|
||||
const action = content.actionsById[actionId];
|
||||
if (action?.kind !== 'story' || !action.storyChoiceId) {
|
||||
throw new Error(`Action "${actionId}" is not a story action`);
|
||||
}
|
||||
if (!isStoryChoiceAvailable(state, content, action.storyChoiceId)) {
|
||||
throw new Error(`Story choice "${action.storyChoiceId}" is not available`);
|
||||
}
|
||||
const events = applyChoice(state, content, action.storyChoiceId);
|
||||
recordManualCompletion(state, content, actionId);
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the highest-priority enabled, available loop action when the game is idle.
|
||||
* Invoked by the runtime AFTER each live tick — never from `tickGame`, so loop
|
||||
* actions do not run during offline catch-up (which replays `tickGame` directly).
|
||||
*/
|
||||
export function maybeStartLoopAction(state: GameState, content: Content): void {
|
||||
if (state.activeActionId !== null || state.actionQueue.length > 0) return;
|
||||
|
||||
const candidates = content.actions
|
||||
.filter((a) => a.kind === 'loop' && state.enabledLoopActionIds[a.id])
|
||||
.sort((a, b) => (a.loopPriority ?? 0) - (b.loopPriority ?? 0));
|
||||
|
||||
for (const action of candidates) {
|
||||
if (isActionAvailable(state, content, action.id)) {
|
||||
beginAction(state, content, action.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single entry point for all player-initiated action dispatch.
|
||||
*
|
||||
* Dispatches by action.kind to the appropriate per-kind function:
|
||||
* - instant: execute immediately (costs/yields, no queue slot)
|
||||
* - timed: enqueue (start if idle, queue otherwise)
|
||||
* - loop: toggle player enable preference; start runner if just enabled
|
||||
* - story: resolve storyChoiceId and apply the choice
|
||||
* - context: not implemented in M1 — throws
|
||||
*
|
||||
* Note on loop toggle: disabling is always allowed, even when the action is
|
||||
* currently unaffordable. Affordability is the runner's concern (maybeStartLoopAction
|
||||
* re-checks each tick). Throwing on unaffordable before toggling would wrongly
|
||||
* block the player from DISABLING an active but now-unaffordable loop.
|
||||
*/
|
||||
export function performAction(
|
||||
state: GameState,
|
||||
content: GameContent,
|
||||
actionId: string,
|
||||
): StoryEvent[] {
|
||||
const action = content.actionsById[actionId];
|
||||
if (!action) throw new Error(`Unknown action "${actionId}"`);
|
||||
|
||||
switch (action.kind) {
|
||||
case 'instant':
|
||||
executeInstant(state, content, actionId);
|
||||
return [];
|
||||
case 'timed':
|
||||
enqueueAction(state, content, actionId);
|
||||
return [];
|
||||
case 'loop': {
|
||||
const willEnable = !state.enabledLoopActionIds[actionId];
|
||||
state.enabledLoopActionIds[actionId] = willEnable;
|
||||
if (willEnable) {
|
||||
maybeStartLoopAction(state, content);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
case 'story':
|
||||
return executeStoryAction(state, content, actionId);
|
||||
case 'context':
|
||||
throw new Error(`Context action "${actionId}" is not implemented`);
|
||||
default:
|
||||
throw new Error(`Unknown action kind "${(action as { kind: string }).kind}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the active action by `tickMs`. On completion, grants yields and
|
||||
* advances the queue — actions do not auto-repeat when the queue is empty.
|
||||
*/
|
||||
export function tickGame(state: GameState, content: Content, tickMs: number): TickResult {
|
||||
const completedActionIds: string[] = [];
|
||||
if (!state.activeActionId) {
|
||||
maybeRunAutomation(state, content);
|
||||
if (!state.activeActionId) return { completedActionIds };
|
||||
}
|
||||
|
||||
state.actionElapsedMs += tickMs;
|
||||
while (state.activeActionId) {
|
||||
const actionId = state.activeActionId;
|
||||
const action = content.actionsById[actionId];
|
||||
if (!action) return { completedActionIds };
|
||||
if (action.durationMs === undefined) return { completedActionIds };
|
||||
if (state.actionElapsedMs < action.durationMs) return { completedActionIds };
|
||||
|
||||
state.actionElapsedMs -= action.durationMs;
|
||||
grantYields(state, content, actionId);
|
||||
recordManualCompletion(state, content, actionId);
|
||||
completedActionIds.push(actionId);
|
||||
startNextFromQueue(state, content);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -32,6 +32,9 @@ export const gameStateSchema = z.object({
|
||||
storyFlags: z.record(z.string(), z.boolean()).default({}),
|
||||
currentStoryNodeId: z.string().default(''),
|
||||
seenStoryNodeIds: z.array(z.string()).default([]),
|
||||
enabledLoopActionIds: z.record(z.string(), z.boolean()).default({}),
|
||||
manualCompletionCounts: z.record(z.string(), z.number()).default({}),
|
||||
automationQueue: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export const saveSchema = z.object({
|
||||
@@ -55,6 +58,9 @@ export function createSave(state: GameState, now: number): SaveData {
|
||||
storyFlags: { ...state.storyFlags },
|
||||
currentStoryNodeId: state.currentStoryNodeId,
|
||||
seenStoryNodeIds: [...state.seenStoryNodeIds],
|
||||
enabledLoopActionIds: { ...state.enabledLoopActionIds },
|
||||
manualCompletionCounts: { ...state.manualCompletionCounts },
|
||||
automationQueue: [...state.automationQueue],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,6 +165,18 @@ export function getAvailableChoices(state: GameState, content: GameContent): Sto
|
||||
return node.choices.filter((c) => meetsChoiceRequirements(state, c.requirements));
|
||||
}
|
||||
|
||||
export function isStoryChoiceAvailable(
|
||||
state: GameState,
|
||||
content: GameContent,
|
||||
storyChoiceId: string,
|
||||
): boolean {
|
||||
const node = getCurrentNode(state, content);
|
||||
if (!node?.choices) return false;
|
||||
const choice = node.choices.find((c) => c.id === storyChoiceId);
|
||||
if (!choice) return false;
|
||||
return meetsChoiceRequirements(state, choice.requirements);
|
||||
}
|
||||
|
||||
export function applyChoice(
|
||||
state: GameState,
|
||||
content: GameContent,
|
||||
|
||||
@@ -10,3 +10,19 @@ body {
|
||||
min-height: 100dvh;
|
||||
background-color: #020617; /* slate-950 */
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for action columns */
|
||||
.overflow-x-auto::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
.overflow-x-auto::-webkit-scrollbar-track {
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.overflow-x-auto::-webkit-scrollbar-thumb {
|
||||
background: rgba(100, 116, 139, 0.4);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.overflow-x-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(245, 158, 11, 0.5);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { createGameState, enqueueAction, startAction } from '../../engine/game';
|
||||
import { createSave, serializeSave } from '../../engine/save';
|
||||
import { createMemoryBackend, loadGame, saveGame } from '../persistence';
|
||||
|
||||
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
|
||||
|
||||
function testContent() {
|
||||
return buildContent({
|
||||
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
|
||||
@@ -11,6 +13,7 @@ function testContent() {
|
||||
{
|
||||
id: 'forage',
|
||||
name: 'Forage',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 3000,
|
||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||
},
|
||||
@@ -22,8 +25,20 @@ function queueTestContent() {
|
||||
return buildContent({
|
||||
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
|
||||
actions: [
|
||||
{ id: 'a', name: 'A', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] },
|
||||
{ id: 'b', name: 'B', durationMs: 3000, yields: [{ resourceId: 'gold', amount: 1 }] },
|
||||
{
|
||||
id: 'a',
|
||||
name: 'A',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 3000,
|
||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
name: 'B',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 3000,
|
||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -95,6 +110,9 @@ describe('loadGame()', () => {
|
||||
storyFlags: {},
|
||||
currentStoryNodeId: '',
|
||||
seenStoryNodeIds: [],
|
||||
enabledLoopActionIds: {},
|
||||
manualCompletionCounts: {},
|
||||
automationQueue: [],
|
||||
},
|
||||
1000,
|
||||
),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getPrefs, setPrefs } from '../prefs';
|
||||
|
||||
describe('prefs', () => {
|
||||
@@ -14,12 +14,65 @@ describe('prefs', () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns defaults when localStorage empty', () => {
|
||||
expect(getPrefs()).toEqual({ storyOpenMode: 'auto', actionDetailMode: 'inline' });
|
||||
expect(getPrefs()).toEqual({
|
||||
storyOpenMode: 'auto',
|
||||
actionDetailMode: 'inline',
|
||||
collapsedActionGroups: {},
|
||||
showEventLog: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips updated prefs', () => {
|
||||
setPrefs({ storyOpenMode: 'manual', actionDetailMode: 'hover' });
|
||||
expect(getPrefs().storyOpenMode).toBe('manual');
|
||||
});
|
||||
|
||||
it('migrates v1 prefs to v2 if v2 does not exist', () => {
|
||||
const v1Prefs = {
|
||||
storyOpenMode: 'manual',
|
||||
actionDetailMode: 'hover',
|
||||
collapsedActionGroups: { 'some-group': true },
|
||||
};
|
||||
localStorage.setItem('idlegame:prefs:v1', JSON.stringify(v1Prefs));
|
||||
|
||||
const migratedPrefs = getPrefs();
|
||||
|
||||
expect(migratedPrefs).toEqual({
|
||||
storyOpenMode: 'manual',
|
||||
actionDetailMode: 'hover',
|
||||
collapsedActionGroups: { 'some-group': true },
|
||||
showEventLog: true,
|
||||
});
|
||||
|
||||
const rawV2 = localStorage.getItem('idlegame:prefs:v2');
|
||||
expect(rawV2).not.toBeNull();
|
||||
expect(JSON.parse(rawV2 ?? 'null')).toEqual(migratedPrefs);
|
||||
});
|
||||
|
||||
it('returns defaults and does not migrate if v1 prefs is invalid JSON', () => {
|
||||
localStorage.setItem('idlegame:prefs:v1', '{invalid-json}');
|
||||
|
||||
const prefs = getPrefs();
|
||||
expect(prefs).toEqual({
|
||||
storyOpenMode: 'auto',
|
||||
actionDetailMode: 'inline',
|
||||
collapsedActionGroups: {},
|
||||
showEventLog: true,
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('idlegame:prefs:v2')).toBeNull();
|
||||
});
|
||||
|
||||
describe('expanded prefs', () => {
|
||||
it('defaults collapsedActionGroups and showEventLog', () => {
|
||||
const prefs = getPrefs();
|
||||
expect(prefs.collapsedActionGroups).toEqual({});
|
||||
expect(prefs.showEventLog).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { content } from '../../content/index';
|
||||
import { buildContent } from '../../content/schema';
|
||||
import { buildStoryContent } from '../../content/storySchema';
|
||||
import { createGameState, enqueueAction, startAction } from '../../engine/game';
|
||||
import { createGameState, enqueueAction, performAction, startAction } from '../../engine/game';
|
||||
import { enterStoryNode } from '../../engine/story';
|
||||
import { formatOfflineDuration, toView } from '../viewModel';
|
||||
|
||||
const DEFAULT_GROUP = { id: 'test', label: 'Test' };
|
||||
|
||||
function testContent() {
|
||||
const base = buildContent({
|
||||
resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }],
|
||||
@@ -12,6 +15,7 @@ function testContent() {
|
||||
{
|
||||
id: 'forage',
|
||||
name: 'Forage',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 200,
|
||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||
},
|
||||
@@ -87,8 +91,20 @@ describe('toView()', () => {
|
||||
const content = contentWithActions(
|
||||
[{ id: 'gold', name: 'Gold' }],
|
||||
[
|
||||
{ id: 'a', name: 'Alpha', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] },
|
||||
{ id: 'b', name: 'Bravo', durationMs: 1000, yields: [{ resourceId: 'gold', amount: 1 }] },
|
||||
{
|
||||
id: 'a',
|
||||
name: 'Alpha',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 1000,
|
||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
name: 'Bravo',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 1000,
|
||||
yields: [{ resourceId: 'gold', amount: 1 }],
|
||||
},
|
||||
],
|
||||
);
|
||||
const state = createGameState(content);
|
||||
@@ -108,6 +124,7 @@ describe('toView() action availability', () => {
|
||||
{
|
||||
id: 'locked',
|
||||
name: 'Locked',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 1000,
|
||||
yields: [{ resourceId: 'coin', amount: 1 }],
|
||||
unlock: { requireStoryFlags: ['route_a'] },
|
||||
@@ -127,6 +144,7 @@ describe('toView() action availability', () => {
|
||||
{
|
||||
id: 'forage',
|
||||
name: 'Forage',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 1000,
|
||||
yields: [{ resourceId: 'coin', amount: 1 }],
|
||||
},
|
||||
@@ -188,3 +206,161 @@ describe('formatOfflineDuration()', () => {
|
||||
expect(formatOfflineDuration(7_200_000)).toBe('2h');
|
||||
});
|
||||
});
|
||||
|
||||
describe('action columns projection', () => {
|
||||
it('produces all five kind columns in fixed order', () => {
|
||||
const state = createGameState(content);
|
||||
const view = toView(state, content);
|
||||
expect(view.actionColumns.map((c) => c.kind)).toEqual([
|
||||
'instant',
|
||||
'loop',
|
||||
'timed',
|
||||
'story',
|
||||
'context',
|
||||
]);
|
||||
});
|
||||
|
||||
it('groups timed actions by their content group', () => {
|
||||
const state = createGameState(content);
|
||||
const view = toView(state, content);
|
||||
const timed = view.actionColumns.find((c) => c.kind === 'timed');
|
||||
expect(timed?.groups.some((g) => g.id === 'camp')).toBe(true);
|
||||
expect(timed?.groups.some((g) => g.id === 'travel')).toBe(true);
|
||||
});
|
||||
|
||||
it('marks loopEnabled from enabledLoopActionIds', () => {
|
||||
const state = createGameState(content);
|
||||
state.enabledLoopActionIds = { rest: true };
|
||||
const view = toView(state, content);
|
||||
const rest = view.actionColumns
|
||||
.flatMap((c) => c.groups)
|
||||
.flatMap((g) => g.actions)
|
||||
.find((a) => a.id === 'rest');
|
||||
expect(rest?.loopEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('marks automationUnlocked on actions after manual completion', () => {
|
||||
const state = createGameState(content);
|
||||
state.manualCompletionCounts.gather_supplies = 1;
|
||||
const view = toView(state, content);
|
||||
const gather = view.actionColumns
|
||||
.flatMap((c) => c.groups)
|
||||
.flatMap((g) => g.actions)
|
||||
.find((a) => a.id === 'gather_supplies');
|
||||
|
||||
expect(gather?.automationUnlocked).toBe(true);
|
||||
});
|
||||
|
||||
it('marks actions already in the automation queue', () => {
|
||||
const state = createGameState(content);
|
||||
state.automationQueue = ['gather_supplies'];
|
||||
const view = toView(state, content);
|
||||
const gather = view.actionColumns
|
||||
.flatMap((c) => c.groups)
|
||||
.flatMap((g) => g.actions)
|
||||
.find((a) => a.id === 'gather_supplies');
|
||||
|
||||
expect(gather?.inAutomationQueue).toBe(true);
|
||||
});
|
||||
|
||||
it('projects automation queue ids and display names', () => {
|
||||
const state = createGameState(content);
|
||||
state.automationQueue = ['gather_supplies', 'missing_action'];
|
||||
const view = toView(state, content);
|
||||
|
||||
expect(view.automationQueueIds).toEqual(['gather_supplies', 'missing_action']);
|
||||
expect(view.automationQueueNames).toEqual(['Gather supplies', 'missing_action']);
|
||||
});
|
||||
|
||||
it('shows story actions only when their choice is available, hiding siblings after a fork is taken', () => {
|
||||
const state = createGameState(content);
|
||||
// before reaching the fork, story actions are hidden
|
||||
let storyCol = toView(state, content).actionColumns.find((c) => c.kind === 'story');
|
||||
expect(storyCol?.groups.flatMap((g) => g.actions)).toHaveLength(0);
|
||||
// at the fork, both story actions appear
|
||||
enterStoryNode(state, content, 'fork_choice');
|
||||
storyCol = toView(state, content).actionColumns.find((c) => c.kind === 'story');
|
||||
const idsAtFork = storyCol?.groups.flatMap((g) => g.actions).map((a) => a.id) ?? [];
|
||||
expect(idsAtFork).toEqual(expect.arrayContaining(['pick_high_road', 'follow_river']));
|
||||
// after taking route A, the sibling hides
|
||||
performAction(state, content, 'pick_high_road');
|
||||
storyCol = toView(state, content).actionColumns.find((c) => c.kind === 'story');
|
||||
expect(storyCol?.groups.flatMap((g) => g.actions)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('story.atBootIntro', () => {
|
||||
it('is true at the boot-entry node and false after entering a different node', () => {
|
||||
const base = buildContent({
|
||||
resources: [{ id: 'coin', name: 'Coin', startAmount: 0 }],
|
||||
actions: [
|
||||
{
|
||||
id: 'forage',
|
||||
name: 'Forage',
|
||||
group: DEFAULT_GROUP,
|
||||
durationMs: 1000,
|
||||
yields: [{ resourceId: 'coin', amount: 1 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const story = buildStoryContent(
|
||||
[
|
||||
{
|
||||
id: 'boot_intro',
|
||||
prose: 'Boot.',
|
||||
triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }],
|
||||
},
|
||||
{
|
||||
id: 'fork_choice',
|
||||
prose: 'Which way?',
|
||||
choices: [
|
||||
{
|
||||
id: 'pick_a',
|
||||
label: 'High road',
|
||||
outcomes: [{ type: 'setFlag', flag: 'route_a' }],
|
||||
targetNodeId: 'route_a_beat',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: 'route_a_beat', prose: 'The high road.' },
|
||||
],
|
||||
base.actionsById,
|
||||
base.resourcesById,
|
||||
);
|
||||
const c = { ...base, ...story };
|
||||
const state = createGameState(c);
|
||||
// Simulate boot: enter the boot-entry node
|
||||
enterStoryNode(state, c, 'boot_intro');
|
||||
expect(toView(state, c).story.atBootIntro).toBe(true);
|
||||
// After moving past boot into fork_choice, atBootIntro must be false
|
||||
enterStoryNode(state, c, 'fork_choice');
|
||||
expect(toView(state, c).story.atBootIntro).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('story tree projection', () => {
|
||||
it('builds a tree marking seen and active nodes', () => {
|
||||
const state = createGameState(content);
|
||||
enterStoryNode(state, content, 'fork_choice');
|
||||
const view = toView(state, content);
|
||||
// fork_choice should be a node in the tree, marked active+seen, with route children
|
||||
const findNode = (
|
||||
nodes: typeof view.story.tree,
|
||||
id: string,
|
||||
): (typeof nodes)[number] | undefined => {
|
||||
for (const n of nodes) {
|
||||
if (n.id === id) return n;
|
||||
const deeper = findNode(n.children, id);
|
||||
if (deeper) return deeper;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const fork = findNode(view.story.tree, 'fork_choice');
|
||||
expect(fork).toBeDefined();
|
||||
expect(fork?.active).toBe(true);
|
||||
expect(fork?.seen).toBe(true);
|
||||
expect(fork?.children.map((c) => c.id)).toEqual(
|
||||
expect.arrayContaining(['route_a_beat', 'route_b_beat']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,6 +106,9 @@ export async function loadGame(
|
||||
storyFlags: { ...save.state.storyFlags },
|
||||
currentStoryNodeId: save.state.currentStoryNodeId ?? '',
|
||||
seenStoryNodeIds: [...(save.state.seenStoryNodeIds ?? [])],
|
||||
enabledLoopActionIds: { ...(save.state.enabledLoopActionIds ?? {}) },
|
||||
manualCompletionCounts: { ...(save.state.manualCompletionCounts ?? {}) },
|
||||
automationQueue: [...(save.state.automationQueue ?? [])],
|
||||
};
|
||||
savedAt = save.savedAt;
|
||||
} catch {
|
||||
|
||||
+19
-2
@@ -1,4 +1,4 @@
|
||||
const PREFS_KEY = 'idlegame:prefs:v1';
|
||||
const PREFS_KEY = 'idlegame:prefs:v2';
|
||||
|
||||
export type StoryOpenMode = 'auto' | 'choices-only' | 'manual';
|
||||
export type ActionDetailMode = 'inline' | 'hover' | 'info-button';
|
||||
@@ -6,18 +6,35 @@ export type ActionDetailMode = 'inline' | 'hover' | 'info-button';
|
||||
export interface GamePrefs {
|
||||
storyOpenMode: StoryOpenMode;
|
||||
actionDetailMode: ActionDetailMode;
|
||||
collapsedActionGroups: Record<string, boolean>;
|
||||
showEventLog: boolean;
|
||||
}
|
||||
|
||||
const DEFAULTS: GamePrefs = {
|
||||
storyOpenMode: 'auto',
|
||||
actionDetailMode: 'inline',
|
||||
collapsedActionGroups: {},
|
||||
showEventLog: true,
|
||||
};
|
||||
|
||||
export function getPrefs(): GamePrefs {
|
||||
if (typeof localStorage === 'undefined') return { ...DEFAULTS };
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFS_KEY);
|
||||
if (!raw) return { ...DEFAULTS };
|
||||
if (!raw) {
|
||||
const rawV1 = localStorage.getItem('idlegame:prefs:v1');
|
||||
if (rawV1) {
|
||||
try {
|
||||
const parsedV1 = JSON.parse(rawV1);
|
||||
const migrated = { ...DEFAULTS, ...parsedV1 };
|
||||
localStorage.setItem(PREFS_KEY, JSON.stringify(migrated));
|
||||
return migrated;
|
||||
} catch {
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
}
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
return { ...DEFAULTS, ...JSON.parse(raw) };
|
||||
} catch {
|
||||
return { ...DEFAULTS };
|
||||
|
||||
+145
-47
@@ -1,20 +1,26 @@
|
||||
import { content } from '../content';
|
||||
import {
|
||||
addToAutomationQueue,
|
||||
maybeRunAutomation,
|
||||
removeFromAutomationQueue,
|
||||
} from '../engine/automation';
|
||||
import {
|
||||
cancelQueuedAction as engineCancelQueuedAction,
|
||||
enqueueAction as engineEnqueueAction,
|
||||
type GameState,
|
||||
isActionAvailable,
|
||||
maybeStartLoopAction,
|
||||
performAction as performActionEngine,
|
||||
tickGame,
|
||||
} from '../engine/game';
|
||||
import { exportRecipe, importRecipe } from '../engine/recipe';
|
||||
import { applyChoice as engineApplyChoice, enterStoryNode, initStory } from '../engine/story';
|
||||
import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop';
|
||||
import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence';
|
||||
import { getPrefs } from './prefs';
|
||||
import { useGameStore } from './store';
|
||||
import { type ActivePanel, useGameStore } from './store';
|
||||
import {
|
||||
processStoryTriggers,
|
||||
type StoryUiEffect,
|
||||
shouldAutoOpenPanel,
|
||||
shouldAutoNavigateToStory,
|
||||
storyEventsToLogEntries,
|
||||
} from './storyOrchestration';
|
||||
import { formatOfflineDuration, toView } from './viewModel';
|
||||
@@ -32,7 +38,7 @@ import { formatOfflineDuration, toView } from './viewModel';
|
||||
const PUBLISH_INTERVAL_MS = 100; // ~10 fps view refresh
|
||||
const AUTOSAVE_INTERVAL_MS = 10_000;
|
||||
|
||||
class GameRuntime {
|
||||
export class GameRuntime {
|
||||
private state: GameState | null = null;
|
||||
private readonly loop: TickLoop = createTickLoop();
|
||||
private readonly backend: SaveBackend = createDefaultBackend();
|
||||
@@ -41,6 +47,16 @@ class GameRuntime {
|
||||
private lastSaveAt = 0;
|
||||
private booted = false;
|
||||
|
||||
private readonly visibilityChangeListener = (): void => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
void this.save();
|
||||
}
|
||||
};
|
||||
|
||||
private readonly beforeUnloadListener = (): void => {
|
||||
void this.save();
|
||||
};
|
||||
|
||||
async boot(): Promise<void> {
|
||||
if (this.booted) {
|
||||
return;
|
||||
@@ -75,49 +91,133 @@ class GameRuntime {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
document.removeEventListener('visibilitychange', this.visibilityChangeListener);
|
||||
window.removeEventListener('beforeunload', this.beforeUnloadListener);
|
||||
this.booted = false;
|
||||
}
|
||||
|
||||
performAction(actionId: string): void {
|
||||
const state = this.state;
|
||||
if (!state) return;
|
||||
const action = content.actionsById[actionId];
|
||||
if (!action) return;
|
||||
|
||||
try {
|
||||
const isStory = action.kind === 'story';
|
||||
const events = performActionEngine(state, content, actionId);
|
||||
const store = useGameStore.getState();
|
||||
|
||||
// Append any custom log outcomes
|
||||
for (const event of events.filter((e) => e.kind === 'log')) {
|
||||
store.appendLog(event.prose);
|
||||
}
|
||||
|
||||
if (isStory) {
|
||||
const entries = storyEventsToLogEntries(events);
|
||||
for (const entry of entries) {
|
||||
store.appendStoryLog(entry);
|
||||
}
|
||||
const choiceLabel = entries.at(-1)?.choiceLabel;
|
||||
if (choiceLabel) {
|
||||
store.appendLog(`Story: ${choiceLabel}`);
|
||||
}
|
||||
this.runPublishTriggers();
|
||||
} else if (action.kind === 'timed') {
|
||||
const verb = state.actionQueue.includes(actionId) ? 'Queued' : 'Started';
|
||||
store.appendLog(`${verb}: ${action.name}.`);
|
||||
}
|
||||
|
||||
this.publish();
|
||||
} catch (err) {
|
||||
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Action failed');
|
||||
}
|
||||
}
|
||||
|
||||
setActivePanel(panel: ActivePanel): void {
|
||||
const store = useGameStore.getState();
|
||||
store.setActivePanel(panel);
|
||||
if (panel === 'story') {
|
||||
store.setStoryHasUnread(false);
|
||||
}
|
||||
}
|
||||
|
||||
selectStoryNode(id: string): void {
|
||||
useGameStore.getState().setSelectedStoryNodeId(id);
|
||||
}
|
||||
|
||||
toggleActionGroupCollapsed(groupKey: string): void {
|
||||
useGameStore.getState().toggleActionGroupCollapsed(groupKey);
|
||||
}
|
||||
|
||||
enqueueAction(actionId: string): void {
|
||||
this.performAction(actionId);
|
||||
}
|
||||
|
||||
toggleAutomation(actionId: string): void {
|
||||
const state = this.state;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
if (!isActionAvailable(state, content, actionId)) {
|
||||
const actionView = toView(state, content).actions.find((a) => a.id === actionId);
|
||||
useGameStore.getState().appendLog(actionView?.disabledReason ?? 'Cannot enqueue');
|
||||
return;
|
||||
}
|
||||
if (!state) return;
|
||||
try {
|
||||
engineEnqueueAction(state, content, actionId);
|
||||
const action = content.actionsById[actionId];
|
||||
if (action) {
|
||||
const verb = state.actionQueue.includes(actionId) ? 'Queued' : 'Started';
|
||||
useGameStore.getState().appendLog(`${verb}: ${action.name}.`);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Cannot start action';
|
||||
useGameStore.getState().appendLog(msg);
|
||||
const existingIndex = state.automationQueue.indexOf(actionId);
|
||||
if (existingIndex >= 0) {
|
||||
removeFromAutomationQueue(state, existingIndex);
|
||||
} else {
|
||||
addToAutomationQueue(state, content, actionId);
|
||||
maybeRunAutomation(state, content);
|
||||
}
|
||||
this.publish();
|
||||
} catch (err) {
|
||||
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Automation failed');
|
||||
}
|
||||
}
|
||||
|
||||
exportAutomationRecipe(): string {
|
||||
const state = this.state;
|
||||
if (!state) return '';
|
||||
return exportRecipe(state, content);
|
||||
}
|
||||
|
||||
importAutomationRecipe(text: string): void {
|
||||
const state = this.state;
|
||||
if (!state) return;
|
||||
try {
|
||||
const meta = importRecipe(state, content, text);
|
||||
maybeRunAutomation(state, content);
|
||||
useGameStore
|
||||
.getState()
|
||||
.appendLog(meta.name ? `Imported recipe: ${meta.name}` : 'Imported recipe.');
|
||||
this.publish();
|
||||
} catch (err) {
|
||||
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Import failed');
|
||||
}
|
||||
}
|
||||
|
||||
applyStoryChoice(choiceId: string): void {
|
||||
const action = content.actions.find((a) => a.storyChoiceId === choiceId);
|
||||
if (action) {
|
||||
this.performAction(action.id);
|
||||
} else {
|
||||
const state = this.state;
|
||||
if (!state) return;
|
||||
try {
|
||||
const events = engineApplyChoice(state, content, choiceId);
|
||||
const entries = storyEventsToLogEntries(events);
|
||||
const store = useGameStore.getState();
|
||||
|
||||
// Append any custom log outcomes
|
||||
for (const event of events.filter((e) => e.kind === 'log')) {
|
||||
store.appendLog(event.prose);
|
||||
}
|
||||
|
||||
for (const entry of entries) store.appendStoryLog(entry);
|
||||
const choiceLabel = entries.at(-1)?.choiceLabel;
|
||||
if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`);
|
||||
store.setStoryPanelOpen(false);
|
||||
this.runPublishTriggers();
|
||||
this.publish();
|
||||
} catch (err) {
|
||||
useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Choice failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cancelQueuedAction(index: number): void {
|
||||
const state = this.state;
|
||||
@@ -131,15 +231,6 @@ class GameRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
openStoryPanel(): void {
|
||||
useGameStore.getState().setStoryPanelOpen(true);
|
||||
useGameStore.getState().setStoryHasUnread(false);
|
||||
}
|
||||
|
||||
closeStoryPanel(): void {
|
||||
useGameStore.getState().setStoryPanelOpen(false);
|
||||
}
|
||||
|
||||
continueStory(): void {
|
||||
const state = this.state;
|
||||
if (!state) return;
|
||||
@@ -149,30 +240,36 @@ class GameRuntime {
|
||||
const store = useGameStore.getState();
|
||||
for (const entry of entries) store.appendStoryLog(entry);
|
||||
for (const entry of entries) {
|
||||
store.appendLog(`Story: ${content.storyNodesById[entry.nodeId]?.prose.slice(0, 40)}…`);
|
||||
store.appendLog(
|
||||
`Story: ${(content.storyNodesById[entry.nodeId]?.prose ?? '').slice(0, 40)}…`,
|
||||
);
|
||||
}
|
||||
const prefs = store.prefs;
|
||||
if (shouldAutoOpenPanel(prefs, 'fork_choice', content)) {
|
||||
store.setStoryPanelOpen(true);
|
||||
store.setStoryHasUnread(false);
|
||||
if (shouldAutoNavigateToStory(prefs, 'fork_choice', content)) {
|
||||
this.setActivePanel('story');
|
||||
} else {
|
||||
store.setStoryHasUnread(true);
|
||||
store.setStoryPanelOpen(false);
|
||||
}
|
||||
this.publish();
|
||||
return;
|
||||
}
|
||||
this.closeStoryPanel();
|
||||
this.setActivePanel('play');
|
||||
this.publish();
|
||||
}
|
||||
|
||||
private applyStoryUiEffect(effect: StoryUiEffect): void {
|
||||
const store = useGameStore.getState();
|
||||
const prefs = store.prefs;
|
||||
for (const entry of effect.logEntries) store.appendStoryLog(entry);
|
||||
for (const line of effect.eventLogLines) store.appendLog(line);
|
||||
|
||||
if (effect.shouldOpenPanel) {
|
||||
store.setStoryPanelOpen(true);
|
||||
if (prefs.storyOpenMode === 'auto') {
|
||||
store.setActivePanel('story');
|
||||
store.setStoryHasUnread(false);
|
||||
} else {
|
||||
store.setStoryHasUnread(true);
|
||||
}
|
||||
} else if (effect.enteredNodeIds.length > 0) {
|
||||
store.setStoryHasUnread(true);
|
||||
}
|
||||
@@ -201,6 +298,7 @@ class GameRuntime {
|
||||
);
|
||||
this.applyStoryUiEffect(effect);
|
||||
}
|
||||
maybeStartLoopAction(state, content);
|
||||
});
|
||||
|
||||
if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) {
|
||||
@@ -234,15 +332,15 @@ class GameRuntime {
|
||||
}
|
||||
|
||||
private installLifecycleHooks(): void {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
void this.save();
|
||||
}
|
||||
});
|
||||
window.addEventListener('beforeunload', () => {
|
||||
void this.save();
|
||||
});
|
||||
document.addEventListener('visibilitychange', this.visibilityChangeListener);
|
||||
window.addEventListener('beforeunload', this.beforeUnloadListener);
|
||||
}
|
||||
}
|
||||
|
||||
export const gameRuntime = new GameRuntime();
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.dispose(() => {
|
||||
gameRuntime.stop();
|
||||
});
|
||||
}
|
||||
|
||||
+25
-10
@@ -16,45 +16,60 @@ export interface StoryLogEntry {
|
||||
choiceLabel?: string;
|
||||
}
|
||||
|
||||
export type ActivePanel = 'play' | 'story' | 'settings' | 'about';
|
||||
|
||||
export interface GameStoreState extends GameView {
|
||||
log: string[];
|
||||
storyPanelOpen: boolean;
|
||||
storyHasUnread: boolean;
|
||||
storyLog: StoryLogEntry[];
|
||||
prefs: GamePrefs;
|
||||
settingsOpen: boolean;
|
||||
activePanel: ActivePanel;
|
||||
selectedStoryNodeId: string | null;
|
||||
setView: (view: GameView) => void;
|
||||
appendLog: (line: string) => void;
|
||||
appendStoryLog: (entry: StoryLogEntry) => void;
|
||||
setStoryPanelOpen: (open: boolean) => void;
|
||||
setStoryHasUnread: (unread: boolean) => void;
|
||||
setPrefs: (partial: Partial<GamePrefs>) => void;
|
||||
setSettingsOpen: (open: boolean) => void;
|
||||
setActivePanel: (panel: ActivePanel) => void;
|
||||
setSelectedStoryNodeId: (id: string | null) => void;
|
||||
toggleActionGroupCollapsed: (groupKey: string) => void;
|
||||
}
|
||||
|
||||
export const useGameStore = create<GameStoreState>((set) => ({
|
||||
export const useGameStore = create<GameStoreState>((set, get) => ({
|
||||
resources: [],
|
||||
activeActionId: null,
|
||||
actionName: null,
|
||||
actionProgress: 0,
|
||||
queuedActionIds: [],
|
||||
queuedActionNames: [],
|
||||
automationQueueIds: [],
|
||||
automationQueueNames: [],
|
||||
actions: [],
|
||||
story: { currentProse: null, choices: [] },
|
||||
story: { currentProse: null, atBootIntro: false, choices: [], tree: [] },
|
||||
actionColumns: [],
|
||||
log: [],
|
||||
storyPanelOpen: false,
|
||||
storyHasUnread: false,
|
||||
storyLog: [],
|
||||
prefs: getPrefs(),
|
||||
settingsOpen: false,
|
||||
activePanel: 'play',
|
||||
selectedStoryNodeId: null,
|
||||
setView: (view) => set((state) => ({ ...state, ...view })),
|
||||
appendLog: (line) => set((state) => ({ log: [...state.log, line].slice(-MAX_LOG_LINES) })),
|
||||
appendStoryLog: (entry) => set((state) => ({ storyLog: [...state.storyLog, entry] })),
|
||||
setStoryPanelOpen: (open) => set({ storyPanelOpen: open }),
|
||||
setStoryHasUnread: (unread) => set({ storyHasUnread: unread }),
|
||||
setPrefs: (partial) => {
|
||||
const prefs = persistPrefs(partial);
|
||||
set({ prefs });
|
||||
},
|
||||
setSettingsOpen: (open) => set({ settingsOpen: open }),
|
||||
setActivePanel: (panel) => set({ activePanel: panel }),
|
||||
setSelectedStoryNodeId: (id) => set({ selectedStoryNodeId: id }),
|
||||
toggleActionGroupCollapsed: (groupKey) => {
|
||||
const currentPrefs = get().prefs;
|
||||
const nextCollapsed = {
|
||||
...currentPrefs.collapsedActionGroups,
|
||||
[groupKey]: !currentPrefs.collapsedActionGroups[groupKey],
|
||||
};
|
||||
const prefs = persistPrefs({ collapsedActionGroups: nextCollapsed });
|
||||
set({ prefs });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -17,7 +17,7 @@ export function storyEventsToLogEntries(events: StoryEvent[]): StoryLogEntry[] {
|
||||
.map((e) => ({ nodeId: e.nodeId, prose: e.prose, choiceLabel: e.choiceLabel }));
|
||||
}
|
||||
|
||||
export function shouldAutoOpenPanel(
|
||||
export function shouldAutoNavigateToStory(
|
||||
prefs: GamePrefs,
|
||||
nodeId: string,
|
||||
content: GameContent,
|
||||
@@ -39,11 +39,15 @@ export function processStoryTriggers(
|
||||
const logEntries = storyEventsToLogEntries(events);
|
||||
const shouldOpenPanel =
|
||||
enteredNodeIds.length > 0 &&
|
||||
enteredNodeIds.some((id) => shouldAutoOpenPanel(prefs, id, content));
|
||||
const eventLogLines = logEntries.map((e) =>
|
||||
enteredNodeIds.some((id) => shouldAutoNavigateToStory(prefs, id, content));
|
||||
const customLogs = events.filter((e) => e.kind === 'log').map((e) => e.prose);
|
||||
const eventLogLines = [
|
||||
...customLogs,
|
||||
...logEntries.map((e) =>
|
||||
e.choiceLabel
|
||||
? `Story: ${e.choiceLabel}`
|
||||
: `Story: ${content.storyNodesById[e.nodeId]?.prose.slice(0, 40)}…`,
|
||||
);
|
||||
: `Story: ${(content.storyNodesById[e.nodeId]?.prose ?? '').slice(0, 40)}…`,
|
||||
),
|
||||
];
|
||||
return { enteredNodeIds, logEntries, shouldOpenPanel, eventLogLines };
|
||||
}
|
||||
|
||||
+142
-6
@@ -1,11 +1,12 @@
|
||||
import type { GameContent } from '../content/index';
|
||||
import { isAutomationUnlocked } from '../engine/automation';
|
||||
import {
|
||||
canAffordAction,
|
||||
canUnlockAction,
|
||||
type GameState,
|
||||
isActionAvailable,
|
||||
} from '../engine/game';
|
||||
import { getAvailableChoices, getCurrentNode } from '../engine/story';
|
||||
import { getAvailableChoices, getCurrentNode, isStoryChoiceAvailable } from '../engine/story';
|
||||
|
||||
/**
|
||||
* Pure mapping from engine state to the view model the React shell renders.
|
||||
@@ -18,6 +19,17 @@ export interface ResourceView {
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export const ACTION_COLUMN_ORDER = ['instant', 'loop', 'timed', 'story', 'context'] as const;
|
||||
export type ActionColumnKind = (typeof ACTION_COLUMN_ORDER)[number];
|
||||
|
||||
const COLUMN_LABELS: Record<ActionColumnKind, string> = {
|
||||
instant: 'Instant',
|
||||
loop: 'Loop',
|
||||
timed: 'Timed',
|
||||
story: 'Story',
|
||||
context: 'Context',
|
||||
};
|
||||
|
||||
export interface ActionView {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -27,6 +39,22 @@ export interface ActionView {
|
||||
storyTooltip?: string;
|
||||
costsSummary: string | null;
|
||||
yieldsSummary: string | null;
|
||||
kind: ActionColumnKind;
|
||||
loopEnabled: boolean;
|
||||
automationUnlocked: boolean;
|
||||
inAutomationQueue: boolean;
|
||||
}
|
||||
|
||||
export interface ActionGroupView {
|
||||
id: string;
|
||||
label: string;
|
||||
actions: ActionView[];
|
||||
}
|
||||
|
||||
export interface ActionColumnView {
|
||||
kind: ActionColumnKind;
|
||||
label: string;
|
||||
groups: ActionGroupView[];
|
||||
}
|
||||
|
||||
export interface StoryChoiceView {
|
||||
@@ -36,9 +64,19 @@ export interface StoryChoiceView {
|
||||
disabledReason: string | null;
|
||||
}
|
||||
|
||||
export interface StoryTreeNodeView {
|
||||
id: string;
|
||||
label: string;
|
||||
seen: boolean;
|
||||
active: boolean;
|
||||
children: StoryTreeNodeView[];
|
||||
}
|
||||
|
||||
export interface StoryView {
|
||||
currentProse: string | null;
|
||||
atBootIntro: boolean;
|
||||
choices: StoryChoiceView[];
|
||||
tree: StoryTreeNodeView[];
|
||||
}
|
||||
|
||||
export interface GameView {
|
||||
@@ -49,8 +87,11 @@ export interface GameView {
|
||||
actionProgress: number;
|
||||
queuedActionIds: string[];
|
||||
queuedActionNames: string[];
|
||||
automationQueueIds: string[];
|
||||
automationQueueNames: string[];
|
||||
actions: ActionView[];
|
||||
story: StoryView;
|
||||
actionColumns: ActionColumnView[];
|
||||
}
|
||||
|
||||
function actionDisabledReason(
|
||||
@@ -72,6 +113,38 @@ function formatResourceList(
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function buildStoryTree(state: GameState, content: GameContent): StoryTreeNodeView[] {
|
||||
// Edges come from choice targets and trigger targets, skipping self-edges.
|
||||
const childIds = new Set<string>();
|
||||
const childrenOf = new Map<string, string[]>();
|
||||
for (const node of content.storyNodes) {
|
||||
const targets: string[] = [];
|
||||
for (const choice of node.choices ?? []) {
|
||||
if (choice.targetNodeId !== node.id) targets.push(choice.targetNodeId);
|
||||
}
|
||||
for (const trigger of node.triggers ?? []) {
|
||||
if (trigger.targetNodeId !== node.id) targets.push(trigger.targetNodeId);
|
||||
}
|
||||
childrenOf.set(node.id, targets);
|
||||
for (const t of targets) childIds.add(t);
|
||||
}
|
||||
const build = (id: string, seenOnPath: Set<string>): StoryTreeNodeView => {
|
||||
const children = seenOnPath.has(id)
|
||||
? []
|
||||
: (childrenOf.get(id) ?? []).map((c) => build(c, new Set(seenOnPath).add(id)));
|
||||
return {
|
||||
id,
|
||||
label: id, // minimal label per spec (T3.0 ships a minimal tree)
|
||||
seen: state.seenStoryNodeIds.includes(id),
|
||||
active: state.currentStoryNodeId === id,
|
||||
children,
|
||||
};
|
||||
};
|
||||
return content.storyNodes
|
||||
.filter((n) => !childIds.has(n.id))
|
||||
.map((n) => build(n.id, new Set<string>()));
|
||||
}
|
||||
|
||||
export function toView(state: GameState, content: GameContent): GameView {
|
||||
const resources: ResourceView[] = content.resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
@@ -80,27 +153,86 @@ export function toView(state: GameState, content: GameContent): GameView {
|
||||
}));
|
||||
|
||||
const action = state.activeActionId ? content.actionsById[state.activeActionId] : undefined;
|
||||
const actionProgress = action ? Math.min(1, state.actionElapsedMs / action.durationMs) : 0;
|
||||
const actionProgress = action?.durationMs
|
||||
? Math.min(1, state.actionElapsedMs / action.durationMs)
|
||||
: 0;
|
||||
const queuedActionIds = [...state.actionQueue];
|
||||
const queuedActionNames = queuedActionIds.map((id) => content.actionsById[id]?.name ?? id);
|
||||
const automationQueueIds = [...state.automationQueue];
|
||||
const automationQueueNames = automationQueueIds.map((id) => content.actionsById[id]?.name ?? id);
|
||||
|
||||
const actions: ActionView[] = content.actions.map((a) => ({
|
||||
// Build a map of ActionView by id for column assembly
|
||||
const actionViewMap = new Map<string, ActionView>();
|
||||
const actions: ActionView[] = content.actions.map((a) => {
|
||||
const isStory = a.kind === 'story';
|
||||
const available = isStory
|
||||
? isStoryChoiceAvailable(state, content, a.storyChoiceId ?? '')
|
||||
: isActionAvailable(state, content, a.id);
|
||||
const view: ActionView = {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
available: isActionAvailable(state, content, a.id),
|
||||
disabledReason: actionDisabledReason(state, content, a.id),
|
||||
available,
|
||||
disabledReason: isStory ? null : actionDisabledReason(state, content, a.id),
|
||||
storyHint: a.storyHint,
|
||||
storyTooltip: a.storyTooltip,
|
||||
costsSummary: a.costs.length ? formatResourceList(a.costs, content) : null,
|
||||
yieldsSummary: formatResourceList(a.yields, content),
|
||||
}));
|
||||
kind: a.kind,
|
||||
loopEnabled: !!state.enabledLoopActionIds[a.id],
|
||||
automationUnlocked: isAutomationUnlocked(state, content, a.id),
|
||||
inAutomationQueue: state.automationQueue.includes(a.id),
|
||||
};
|
||||
actionViewMap.set(a.id, view);
|
||||
return view;
|
||||
});
|
||||
|
||||
// Build action columns: one per kind in fixed order
|
||||
const actionColumns: ActionColumnView[] = ACTION_COLUMN_ORDER.map((kind) => {
|
||||
// Collect actions of this kind
|
||||
const kindActions = content.actions.filter((a) => a.kind === kind);
|
||||
|
||||
// For story kind: only include available actions (hides siblings after fork)
|
||||
const includedActions =
|
||||
kind === 'story'
|
||||
? kindActions.filter((a) => actionViewMap.get(a.id)?.available === true)
|
||||
: kindActions;
|
||||
|
||||
// Group by action.group, preserving first-seen order
|
||||
const groupOrder: string[] = [];
|
||||
const groupMap = new Map<string, { id: string; label: string; actions: ActionView[] }>();
|
||||
for (const a of includedActions) {
|
||||
const view = actionViewMap.get(a.id);
|
||||
if (!view) continue;
|
||||
if (!groupMap.has(a.group.id)) {
|
||||
groupOrder.push(a.group.id);
|
||||
groupMap.set(a.group.id, { id: a.group.id, label: a.group.label, actions: [] });
|
||||
}
|
||||
groupMap.get(a.group.id)?.actions.push(view);
|
||||
}
|
||||
|
||||
const groups: ActionGroupView[] = groupOrder
|
||||
.map((gid) => groupMap.get(gid))
|
||||
.filter((g): g is ActionGroupView => g !== undefined);
|
||||
|
||||
return {
|
||||
kind,
|
||||
label: COLUMN_LABELS[kind],
|
||||
groups,
|
||||
};
|
||||
});
|
||||
|
||||
const node = getCurrentNode(state, content);
|
||||
const availableChoices = getAvailableChoices(state, content);
|
||||
const allChoices = node?.choices ?? [];
|
||||
|
||||
const bootEntryNodeId = content.storyNodes.find((n) =>
|
||||
n.triggers?.some((t) => t.type === 'boot'),
|
||||
)?.id;
|
||||
const atBootIntro = node != null && node.id === bootEntryNodeId;
|
||||
|
||||
const story: StoryView = {
|
||||
currentProse: node?.prose ?? null,
|
||||
atBootIntro,
|
||||
choices: allChoices.map((choice) => {
|
||||
const available = availableChoices.some((c) => c.id === choice.id);
|
||||
return {
|
||||
@@ -110,6 +242,7 @@ export function toView(state: GameState, content: GameContent): GameView {
|
||||
disabledReason: available ? null : 'Requirements not met',
|
||||
};
|
||||
}),
|
||||
tree: buildStoryTree(state, content),
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -119,8 +252,11 @@ export function toView(state: GameState, content: GameContent): GameView {
|
||||
actionProgress,
|
||||
queuedActionIds,
|
||||
queuedActionNames,
|
||||
automationQueueIds,
|
||||
automationQueueNames,
|
||||
actions,
|
||||
story,
|
||||
actionColumns,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -1,65 +1,45 @@
|
||||
import { useEffect } from 'react';
|
||||
import { gameRuntime } from '../state/runtime';
|
||||
import { useGameStore } from '../state/store';
|
||||
import { ActionPanel } from './ActionPanel';
|
||||
import { EventLog } from './EventLog';
|
||||
import { ResourceBar } from './ResourceBar';
|
||||
import { SettingsDrawer } from './SettingsDrawer';
|
||||
import { StoryPanel } from './StoryPanel';
|
||||
import { AboutPanel } from './AboutPanel';
|
||||
import { AppShell } from './AppShell';
|
||||
import { NavRail } from './NavRail';
|
||||
import { PlayPanel } from './PlayPanel';
|
||||
import { RightRail } from './RightRail';
|
||||
import { SettingsPanel } from './SettingsPanel';
|
||||
import { StoryView } from './StoryView';
|
||||
|
||||
/**
|
||||
* M1 playable-loop shell. Boots the runtime once on mount; everything else
|
||||
* renders from the Zustand store the runtime feeds.
|
||||
*/
|
||||
export function App() {
|
||||
const storyHasUnread = useGameStore((s) => s.storyHasUnread);
|
||||
const settingsOpen = useGameStore((s) => s.settingsOpen);
|
||||
const setSettingsOpen = useGameStore((s) => s.setSettingsOpen);
|
||||
const activePanel = useGameStore((s) => s.activePanel);
|
||||
|
||||
useEffect(() => {
|
||||
void gameRuntime.boot();
|
||||
}, []);
|
||||
|
||||
const renderActivePanel = () => {
|
||||
switch (activePanel) {
|
||||
case 'play':
|
||||
return <PlayPanel />;
|
||||
case 'story':
|
||||
return <StoryView />;
|
||||
case 'settings':
|
||||
return <SettingsPanel />;
|
||||
case 'about':
|
||||
return <AboutPanel />;
|
||||
default:
|
||||
return <PlayPanel />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StoryPanel />
|
||||
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col gap-5 px-4 py-8 text-slate-100">
|
||||
<header className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="font-bold text-2xl tracking-tight">Idlegame</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={storyHasUnread ? 'Story, unread' : 'Story'}
|
||||
onClick={() => gameRuntime.openStoryPanel()}
|
||||
className="relative rounded-lg border border-slate-700 bg-slate-800/70 px-3 py-1.5 text-sm transition-colors hover:border-amber-500/60"
|
||||
>
|
||||
Story
|
||||
{storyHasUnread ? (
|
||||
<span
|
||||
className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-amber-500"
|
||||
aria-hidden
|
||||
<AppShell
|
||||
nav={<NavRail />}
|
||||
center={renderActivePanel()}
|
||||
right={activePanel === 'play' ? <RightRail /> : undefined}
|
||||
/>
|
||||
) : 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user