Files
josh d065922cad revise(02): mark Open Questions RESOLVED + tidy GrowthStage import order
- W1: 02-RESEARCH.md Open Questions section now flagged (RESOLVED) and each
  Recommendation prefixed with RESOLVED + a pointer to the artifact that
  codified the resolution.
- W8: 02-02 Plan example moves `import type { GrowthStage }` to the top of
  commands.ts (alongside the other type-only imports) and drops the trailing
  parenthetical apology — the executor doesn't need to fix anything.
2026-05-09 03:05:51 -04:00

1617 lines
125 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 2: Season 1 Vertical Slice (Soil) - Research
**Researched:** 2026-05-09
**Domain:** Browser narrative idle game vertical slice (Phaser 4 canvas + React 19 DOM overlay + pure-sim core + Ink narrative + extended IndexedDB save)
**Confidence:** HIGH on stack/architecture (entire stack already locked + scaffolded in Phase 1, code-surface verified file-by-file). HIGH on tick scheduler / BigQty / Zustand-bridge patterns (Phaser 4's React EventBus pattern is the documented integration; idle-game tick-scheduler shape is canonical per `.planning/research/ARCHITECTURE.md`). MEDIUM on Ink↔Zustand bridge specifics (inkjs API verified in installed module; integration shape is ours to design). MEDIUM on Playwright fast-forward strategy (multiple viable mechanisms; the choice is partly ergonomic).
## Summary
Phase 2 is a vertical slice on top of an already-laid foundation. Phase 1 shipped all the retrofit-hostile infrastructure: the save layer (IndexedDB + LocalStorage + CRC-32 + lz-string + Base64 + migration chain), the Vite-native content pipeline (Markdown+frontmatter / YAML, Zod-validated), the ESLint firewall (sim cannot import render or ui — `eslint-plugin-boundaries` flat config in `eslint.config.js`), the Phaser 4 + React 19 + Vite + TypeScript scaffold (`src/game/main.ts`, `src/PhaserGame.tsx`, `src/App.tsx`), the Vitest + Playwright test rig, the asset provenance gate, the doctrine docs, and `npm run ci` as the gate. Phase 2 lands the three deferred "day-one of feature code" foundations in a single phase (`BigQty`, the Zustand 5 store, the tick scheduler), then builds the vertical slice on top: 4×4 garden, 23 plant types, click+inline seed picker, sprout→mature→ready growth, harvest yields exactly one fragment, full-screen Memory Journal, three Ink-authored Lura gate visits gated on 1st/4th/8th harvest, authored Ink letter-from-the-garden, save-management Settings, Playwright e2e proving the full loop end-to-end.
The phase's load-bearing technical decisions are: (1) the **tick scheduler** is the only owner of wall-clock access — sim modules stay pure, time is injected, negative deltas refused, single offline catch-up clamped to 24h, with the Phaser update loop driving the scheduler from `src/game/scenes/`; (2) the **BigQty** wrapper around `break_eternity.js` lands in `src/sim/numbers/` and is the type all economic values flow through, even though Season 1's actual numbers never need it (per CLAUDE.md "from day one of feature code" + Pitfall 7); (3) the **Phaser↔React bridge** uses Phaser 4's official template `EventBus` pattern (a `Phaser.Events.EventEmitter` instance accessible to both worlds) plus a Zustand 5 vanilla `createStore` — the sim writes to the store via a slim adapter (sim cannot import `src/store/` directly, since the store imports from `src/ui/` selectors), and React reads from it via hooks; (4) the **save-schema extension** updates `V1Payload` in-place per CONTEXT D-34 — Phase 1's v1 has shipped no production saves, so adding fields is type-extension, not a `migrate_v1_to_v2`, which keeps `migrations[1]`'s synthetic v0→v1 demo green; (5) the **fragment selector** is a pure function in `src/sim/memory/` taking `(state, currentSeason, gardenContext, fragmentPool, seed) → fragmentId`, gating on Season + plant type + harvest count and tracking exhaustion via the existing `harvestedFragmentIds` array; (6) **Ink integration** uses `inklecate` (installed) compiled at build via an npm script that emits `*.ink.json` into `src/content/compiled-ink/`, with `inkjs` (installed, v2.4.0) loading the JSON at runtime; Lura's branches read game state by setting Ink variables from Zustand snapshots before `Continue()` is called.
**Primary recommendation:** Plan Phase 2 as **5 vertical-slice plans** delivered in 3 waves: Wave 0 lands the three foundations (BigQty + Zustand store + tick scheduler) as a single non-feature plan that unblocks everything; Wave 1 lands two parallel vertical slices (the planting-and-growth slice + the journal-and-fragments slice); Wave 2 lands two more parallel slices (Lura gate visits + the offline-letter loop) and the Playwright e2e smoke. Each Wave-1+ plan must pass `npm run ci` standalone.
## Architectural Responsibility Map
Each capability of the Phase 2 vertical slice is owned by a primary architectural tier, with the firewall enforced by `eslint.config.js`. Cross-tier communication goes through the Zustand store and the Phaser EventBus.
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Garden tile state (16 tiles, plant data, plantedAtTick) | `src/sim/garden/` | — | Pure data; sim mutates; rendering reads via store. |
| Plant growth state machine (sprout → mature → ready) | `src/sim/garden/` | — | Deterministic from `(plantedAtTick, currentTick, plantType.duration)`. No DOM. |
| Tick scheduler / monotonic clock | `src/sim/scheduler/` | `src/game/scenes/` (drives via Phaser update) | Only owner of wall-clock. Sim's `simulate(state, dtTicks)` is pure; scheduler injects time. |
| Offline catch-up (24h cap, refuse negative dt) | `src/sim/scheduler/` | — | Pure function over saved state + delta. Fires `silent: true` to suppress UI side effects. |
| BigQty wrapper around break_eternity.js | `src/sim/numbers/` | — | Pure math; serialization helpers; no DOM. |
| Fragment selector (deterministic, gating, no-dup) | `src/sim/memory/` | — | Pure function over `(state, fragments, seed)`. |
| Lura beat gating (1st/4th/8th harvest count) | `src/sim/narrative/` | — | Reads sim state's harvest count; enqueues `beatId` events. Does NOT load Ink content. |
| Auto-harvest during offline (D-10) | `src/sim/garden/` | — | Same simulate loop, with auto-harvest branch when player is absent (state flag). |
| Garden tile rendering (Phaser primitives) | `src/render/garden/` | — | Empty-tile outline + plant stage shape + ready-pulse via Phaser primitives. No DOM. |
| Begin screen (gesture gate + AudioContext.resume) | `src/ui/begin/` | — | Full-screen React DOM modal; first-run only per D-22; tap calls `audioContext.resume()`. |
| Memory Journal (full-screen modal) | `src/ui/journal/` | — | DOM per MEMR-05 (selectable / copy-pasteable); reveals after 1st harvest per D-23. |
| Lura dialogue overlay | `src/ui/dialogue/` | `src/sim/narrative/` (gates), Ink runtime (renders) | DOM-rendered per D-15 (selectable text from day one). |
| Letter-from-the-garden (full-screen modal) | `src/ui/letter/` | Ink runtime (templates) | DOM full-screen per D-20 (≥5min absence threshold). |
| Settings (Export/Import/Restore) | `src/ui/settings/` | `src/save/` (already shipped) | DOM modal; corner icon + hotkey per D-29. |
| Inline seed picker (popover over clicked tile) | `src/ui/garden/` | `src/render/garden/` (provides tile screen position) | DOM popover positioned over Phaser canvas via tile→screen coord conversion. |
| Persistence-result toast (D-30) | `src/ui/toast/` | `src/save/persist.ts` (data) | One-time soft toast in voice on first save if denied. |
| Save persistence (already shipped) | `src/save/` | — | Phase 1's complete layer. Phase 2 imports only via `src/save/index.ts`. |
| Save lifecycle hooks (visibilitychange, beforeunload, Season transition) | `src/save/` (existing) + `src/PhaserGame.tsx` (subscribes) | — | Browser event listeners live with the React shell; serialize calls go through the save barrel. |
| Content loader (already shipped) | `src/content/` | — | Phase 2 drops `/content/seasons/01-soil/*.{md,yaml}` and `/content/dialogue/season1/*.ink`. |
| Ink runtime bridge | `src/sim/narrative/` (gates only) + `src/ui/dialogue/` (rendering) | — | inkjs `Story` instances live in UI tier; sim never imports inkjs. |
| Phaser ↔ React EventBus | `src/PhaserGame.tsx` (mounts) + scenes + UI | Zustand store (alternate channel) | Per Phaser 4 official template. Used for one-shot scene-ready / scene-event signals; persistent state goes through Zustand. |
**Architectural firewall reminder (CORE-10, ESLint-enforced):** `src/sim/` cannot import from `src/render/` or `src/ui/`. The store sits in `src/store/` and is allowed both directions (sim writes; UI reads). The Phaser scene (`src/game/`) reads from the store and writes commands; it does NOT import sim modules directly — it dispatches commands via the store, and the scheduler picks them up.
## User Constraints (from CONTEXT.md)
### Locked Decisions
(Copied verbatim from `02-CONTEXT.md`. Numbering preserved.)
**Garden Geometry & Input**
- **D-01:** Garden is a **4×4 fixed grid (16 tiles)**.
- **D-02:** Seed placement is **click-empty-tile → inline seed picker** (small popover anchored to the clicked tile, listing currently-unlocked plant types; single tap commits). No persistent seed sidebar.
- **D-03:** **23 plant types** ship in Season 1. Each plant has its own growth duration and fragment pool.
- **D-04:** **Infinite seed supply from start** — anti-FOMO. Meaningful constraint is *time*, not seed inventory.
- **D-05:** **First plant available from start; remaining 12 unlock by fragment-count thresholds** harvested.
- **D-06:** Empty tile look = **faint outlined tile + subtle hover state** (Phaser primitive draw).
- **D-07:** Post-harvest tile **returns immediately to empty** with a brief acknowledgement beat.
**Time Density (Growth + Offline)**
- **D-08:** First-plant active-play growth = **~25 minutes** (sprout → ready).
- **D-09:** **Per-plant durations vary** (short / medium / longer) within the ~25min band.
- **D-10:** **Auto-harvest during offline; manual in active play.** Auto-harvested plants queue into the offline event log; the *letter* narrates them.
- **D-11:** **24h offline cap is surfaced silently in the letter's voice** — no numeric "you were gone for 28 hours" copy.
**Lura's Season 1 Arc**
- **D-12:** Lura is present as **discrete visits at the gate** — not a persistent chat thread.
- **D-13:** **3 beats in Season 1: arrival · mid · farewell.**
- **D-14:** Beats gated by fragment-count thresholds — beat 1 after **1st** harvest; mid after **4th**; farewell after **8th**. Counts come from sim ticks, not wall time (STRY-10).
- **D-15:** Beat-fire UX = **subtle gate indicator + player-initiated visit.** Conversation opens as a **React DOM dialogue overlay**.
- **D-16:** All Lura dialogue is authored in **Ink (`.ink`) under `/content/dialogue/`**.
**Letter-from-the-Garden (UX-02)**
- **D-17:** Letter composed from **authored skeleton + templated insertions**. Hand-authored Ink passages with named variable slots; specifics flow in from the offline event log.
- **D-18:** Letter authoring lives in **Ink (`.ink` files in `/content/dialogue/`)**.
- **D-19:** Save schema gains a small **`offlineEvents`** block: per-plant counts of plants bloomed, list of auto-harvested fragment IDs, flag for any newly-unlocked Lura beat queued. Phase-2 schema *extension*, not a migration.
- **D-20:** Letter triggers when **absence ≥ 5 minutes**. Below threshold, no letter. Full-screen DOM overlay; one tap dismisses.
**Begin Screen (AEST-07 + UX-01)**
- **D-21:** **Tasteful placeholder; Phase 3 paints.** Typographic title + a single "Begin" affordance, calls `AudioContext.resume()` on tap.
- **D-22:** **Begin screen shows on first run only.** Subsequent loads skip directly to the live garden; AudioContext enables on the first interaction. "First run" = no save exists.
**Memory Journal (MEMR-04, MEMR-05)**
- **D-23:** Journal affordance **reveals after the player's first harvest**, then is persistent.
- **D-24:** Journal layout = **full-screen modal overlay**, fragments grouped by Season; back affordance returns to garden. DOM-rendered per MEMR-05.
- **D-25:** Newly harvested fragments (in active play) **surface immediately in a full-text reveal modal**; dismissing files into the journal. Offline auto-harvested fragments are surfaced via the *letter* and re-readable in the journal.
**Visual Placeholders (Phase 2 only)**
- **D-26:** Plants render as **simple Phaser-primitive shapes per growth stage, tinted by plant type**. No PNG asset work in Phase 2.
- **D-27:** Ready-state cue = **subtle glow / pulse on ready tiles**.
**Phase 2 Settings UI Scope**
- **D-28:** Settings menu in Phase 2 ships **save-management surfaces only** — Export, Import, Restore. Persistence-result toast folds in.
- **D-29:** Settings access = **small icon in a corner of the main view + keyboard shortcut**. Persistent.
- **D-30:** `navigator.storage.persist()` outcome surfaced as a **one-time soft toast in voice on first save if denied; nothing if granted**.
**Foundations That Must Land in Phase 2 (per CLAUDE.md)**
- **D-31:** **`BigQty` wrapper around `break_eternity.js`** is Phase 2's first task. Lands in `src/sim/numbers/`.
- **D-32:** **Zustand 5 store** is the bridge between Phaser scene and React UI. Sim writes to the store; React reads. Sim never imports the store directly.
- **D-33:** **Tick scheduler / monotonic clock** is the *only* owner of wall-clock access. Tick rate is Claude's discretion (likely 410Hz). Negative deltas refused; offline catch-up clamps to 24h.
- **D-34:** **Save extension for Phase 2** updates `V1Payload` (in `src/save/migrations.ts`) — Phase-2-scope schema *extension* (not a v1→v2 migration).
### Claude's Discretion
- Specific growth-duration values per plant type within the 25min band (D-08 / D-09).
- Exact fragment-count threshold values for plant-type unlocks (D-05) and Lura beats (1st/4th/8th may shift ±12 during playtest).
- Form of the post-harvest acknowledgement beat (D-07).
- Form of the gate indicator when Lura's beat unlocks (D-15).
- Tick rate / sim cadence (likely 410Hz).
- Internal shape of the Zustand store slices.
- Internal shape of the scene/state machine inside Phaser (Boot → Preloader → Garden, or simpler).
- Specific copy of the Begin screen, the persistence-denied toast, and the post-harvest acknowledgement (all reviewed by user).
- Choice of e2e test fast-forward mechanism (hidden dev hotkey vs URL flag vs sim-clock injection).
- Specific copy of the Memory Journal "no fragments yet" empty state.
- Whether the offline letter's slot vocabulary is finalized in this phase or expanded incrementally.
### Deferred Ideas (OUT OF SCOPE)
- Hybrid Lura presence (gate visits + ambient text-message drip) — rejected for Phase 2 in favor of pure discrete gate visits (D-12). May be reconsidered Phase 4+.
- Plant-type unlocks tied to specific authored fragments — rejected for Phase 2 in favor of fragment-count thresholds (D-05). Phase 4+ may explore narrative-keyed unlocks.
- Fully procedural letter from event-log templates — rejected (D-17). Phase 2 commits to authored skeleton + slots.
- Audio sliders (UX-04), keyboard nav (UX-06), browser-zoom guarantees (UX-07), color-redundant icons (UX-08), tab-title bloom (UX-09), Lura-not-numbers UX (UX-12) — all confirmed for **Phase 8**.
- Visual regression for asset library (PIPE-04) — Phase 8.
- Roothold prestige currency, Season transitions, die-off, finite-ceiling enforcement — Phase 4. Phase 2 plants nothing in the save schema for Roothold.
- Cross-pollination, Memory Storms, place-memory vignettes, ecosystem planting, the Below, the Loom, the Archivist, Lura's full multi-Season arc, the Nameless Man — Phase 47.
- Watercolor post-process, painted plants, painted Begin screen, solo cello + ambient buses + crossfade, reduced-motion toggle (UX-05) — Phase 3.
- Real production-volume AI assets + locked north-star reference set — Phase 5 follow-up. Phase 2 ships zero AI-generated assets (D-26 = primitive shapes).
- Real `migrate_v1_to_v2` — Phase 4. Phase 2 only extends `V1Payload` shape (D-34).
- Per-plant duration variance via dynamic content authoring — out of scope.
- Compost yielding seeds back — rejected (D-04 = infinite seeds).
- Persistent Settings element on Begin screen — rejected (D-29).
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| CORE-02 | Deterministic, fixed-timestep sim advancing by elapsed real time. | Tick Scheduler section + Pattern: Fixed-Timestep Accumulator. |
| CORE-03 | Closed-and-returned garden progresses by elapsed time, capped at 24h. | Tick Scheduler + Offline Catch-up section. |
| CORE-11 | Sim refuses negative deltas + caps any single offline progression at 24h. | Tick Scheduler boundary checks + tests. |
| GARD-01 | Plant a seed into an unoccupied tile. | Garden Sim section + Inline Seed Picker section + sim/garden module. |
| GARD-02 | Plant has visible growth state, updates from save data on load, advances over time. | Plant State Machine section + render/garden + tick scheduler. |
| GARD-03 | Harvest mature plant → exactly one fragment; tile empties. | Harvest Logic section + Fragment Selector section. |
| GARD-04 | Compost an immature plant → tonal beat acknowledging the choice to let go. | Compost Logic section + UI overlay. |
| MEMR-01 | Each harvest yields exactly one memory fragment from authored pool gated by Season + progression. | Fragment Selector section. |
| MEMR-02 | Fragments authored in plain text (Markdown + frontmatter) under `/content/`, compiled per-Season at build time. | Phase 1 already shipped; Phase 2 drops Season 1 files. |
| MEMR-03 | Stable string IDs (`season1.soil.first-bloom`). | FragmentSchema regex already enforces; Season 1 IDs follow convention. |
| MEMR-04 | Memory Journal (React DOM panel) listing every collected fragment, organized by Season. | Memory Journal section. |
| MEMR-05 | Player can read any collected fragment in full, selectable + copy-pasteable. | Memory Journal section (DOM, not canvas). |
| MEMR-06 | Deterministic selector respecting authored gating + no-duplicates within playthrough until pool exhausted. | Fragment Selector section. |
| STRY-01 | Lura appears at gate during Season 1 with text-message-cadence dialogue authored in Ink. | Lura's Season 1 Arc section + Ink Integration section. |
| STRY-06 | All authored dialogue uses Ink (`.ink` → JSON for runtime via inkjs). | Ink Compilation Pipeline section. |
| STRY-07 | Keeper has no name, no backstory, no dialogue beyond final binary choice (Phase 7). | Vacuously satisfied — Phase 2 authors no Keeper-spoken lines. |
| STRY-10 | Story progression gates on tick count, not wall time. | Lura Beat Gating section (counts harvest events = sim ticks). |
| AEST-07 | First screen is "Tend the garden / Begin" gesture gate that calls `AudioContext.resume()`. | Begin Screen section + AudioContext Bootstrap section. |
| UX-01 | Single, clean "Begin" screen; UI grows as player progresses (A Dark Room rule). | Begin Screen section + Memory Journal reveal logic. |
| UX-02 | Returning-player letter from the garden — written in voice, not a stat dump. | Letter-from-the-Garden section. |
| UX-10 | Save on `visibilitychange` to hidden, on `beforeunload`, on Season transitions. | Save Lifecycle Hooks section. |
| UX-11 | Numbers display in human-readable formats (1.2K, 4.5M, scientific past threshold). | BigQty section (`format()` method). |
| PIPE-02 | Player loads only current Season at runtime; future Seasons not in initial bundle. | Per-Season Lazy Loading section. |
| PIPE-07 | E2E smoke (Playwright) loads game, plants seed, harvests fragment, verifies persistence across reload. | Playwright E2E section + Sim-Clock Injection section. |
## Standard Stack
### Core (already locked + installed; verified against `package.json` 2026-05-09)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `phaser` | `^4.1.0` (installed: 4.1.0) [VERIFIED: package.json + npm view] | 2D game framework: scenes, input, rendering, time, tweens, events. Phase 2 uses scene tree, input system, basic graphics primitives, ready-pulse via tween. | Already locked in Phase 1; vertical-slice owner. |
| `react` / `react-dom` | `^19.2.6` (installed: 19.2.6) [VERIFIED] | DOM overlay shell: Begin, Journal, Letter, Lura dialogue, Settings, seed picker. | Locked Phase 1. |
| `zustand` | `^5.0.0` (npm view: 5.0.13 — needs install in Phase 2) [VERIFIED: npm registry] | Phaser↔React state bridge. | Locked Phase 1; install is Phase 2's first task per D-32. |
| `break_eternity.js` | `^2.1.3` (npm view: 2.1.3 — needs install) [VERIFIED: npm registry] | Big-number library wrapped by `BigQty`. | Locked Phase 1; install is Phase 2's first task per D-31. |
| `inkjs` | `^2.4.0` (installed: 2.4.0) [VERIFIED: package.json] | Runtime for compiled Ink stories. ESM imports `Story` from `inkjs` per `node_modules/inkjs/ink.d.mts`. | Locked Phase 1; runtime usage begins Phase 2. |
| `inklecate` (devDep) | `^1.8.1` (installed: 1.8.1) [VERIFIED: package.json] | Build-time Ink compiler. CLI invoked from `npm run compile:ink`. | Locked Phase 1; first real usage Phase 2. |
| `idb` | `^8.0.3` (installed) [VERIFIED] | Already wired by `src/save/`. | — |
| `lz-string` | `^1.5.0` (installed) [VERIFIED] | Already wired by `src/save/codec.ts`. | — |
| `crc-32` | `^1.2.2` (installed) [VERIFIED] | Already wired by `src/save/checksum.ts`. | — |
| `zod` | `^4.4.3` (installed) [VERIFIED] | Already used by `src/content/schemas/` and `src/save/envelope.ts`. Phase 2 uses for `OfflineEvent` schema in V1Payload extension. | — |
| `gray-matter` | `^4.0.3` (installed) [VERIFIED] | Already wired in `src/content/loader.ts` for Markdown frontmatter. | — |
| `yaml` | `^2.8.4` (installed) [VERIFIED] | Already wired. | — |
### Supporting (already in devDeps)
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `vitest` | `^4.1.5` [VERIFIED: package.json] | Unit + integration tests for sim modules, BigQty, scheduler, fragment selector, save migrations, Zustand slices. | All Phase 2 plans add tests. |
| `@playwright/test` | `^1.59.1` [VERIFIED] | E2E smoke (PIPE-07). | Wave 2 plan. |
| `happy-dom` | `^20.9.0` [VERIFIED] | Vitest DOM env (already configured in `vitest.config.ts`). | UI component tests. |
| `fake-indexeddb` | `^6.2.5` [VERIFIED] | Used by `src/save/db.test.ts`; Phase 2's Vitest tests for the extended save flow continue to use it. | All save-touching unit tests. |
### Howler.js — Phase 2 Stub Strategy
Howler.js is the locked audio library per `.planning/research/STACK.md` and CLAUDE.md, but Phase 3 owns audio bus setup. **Phase 2 does NOT install or use Howler.** It still must satisfy AEST-07's "calls `AudioContext.resume()`". The minimum-viable approach: Phase 2's Begin screen creates a fresh `AudioContext` (or accesses an existing one if Phase 3 has shipped) and calls `.resume()`. No buffers are loaded, no sounds are played. Phase 3 will rewire the Begin screen to also boot Howler's master gain.
Concretely: `src/ui/begin/use-audio-bootstrap.ts` exports `bootstrapAudioContext()` returning `Promise<AudioContext | null>`. The function:
1. Lazily creates `new AudioContext()` (with `webkitAudioContext` fallback for Safari).
2. Calls `audioContext.resume()` and awaits it.
3. Returns the context (Phase 3 will retrieve it via the Zustand store and feed Howler).
4. On any error or unsupported browser, returns `null` and logs once. Phase 2's loop does not depend on audio working.
[CITED: developer.chrome.com/blog/web-audio-autoplay] — Web Audio gesture requirement and `resume()` pattern.
### Installation (additions Phase 2 makes)
```bash
npm install zustand@^5.0.0
npm install break_eternity.js@^2.1.3
```
That is the entire dependency-installation surface for Phase 2. Everything else is already on disk from Phase 1.
**Version verification (2026-05-09):**
- `npm view zustand version``5.0.13` [VERIFIED 2026-05-09]
- `npm view break_eternity.js version``2.1.3` [VERIFIED 2026-05-09]
- `npm view phaser version``4.1.0` (already installed) [VERIFIED 2026-05-09]
- `npm view inkjs version``2.4.0` (already installed) [VERIFIED 2026-05-09]
- `npm view inklecate version``1.8.1` (already installed) [VERIFIED 2026-05-09]
- `npm view howler version``2.2.4` (NOT installed in Phase 2; deferred to Phase 3) [VERIFIED 2026-05-09]
### Alternatives Considered (and rejected for Phase 2)
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Phaser 4 EventBus + Zustand | Mitt-only event bus | Mitt is a fine event emitter, but Phaser's official template already provides an `EventBus` (Phaser.Events.EventEmitter) — using it removes a dependency and matches the documented Phaser+React pattern. Persistent state still needs Zustand; events alone do not survive reloads. |
| Zustand 5 react binding | Vanilla `createStore` only | Vanilla works fine for the sim side, but the React side needs the hook-style binding (`useStore`) for clean re-renders. Use Zustand 5's pattern: `createStore` from `zustand/vanilla` for the underlying store + `useStore` from `zustand` for React subscriptions. [CITED: zustand.docs.pmnd.rs/reference/apis/create-store] |
| `BigQty` as direct re-export of `Decimal` | Hand-roll wrapper class | The wrapper is non-negotiable per CLAUDE.md ("Never raw `Decimal` values in app code"). Re-export-only would let raw Decimals leak into call sites. |
| Inline R3F-style React-Phaser bridge | Phaser 4 official EventBus + `<canvas ref>` | The official `phaser/template-react-ts` shape is what `src/PhaserGame.tsx` already implements. Replacing this would touch Phase-1 code unnecessarily. |
| 60Hz sim tick | 410Hz sim tick | A 410Hz sim tick is the canonical idle-game rate per `.planning/research/ARCHITECTURE.md` Pattern 2 (5Hz example) and gives `24h × 3600 × 5 = 432,000` ticks per offline cap, comfortably catchable in <100ms. 60Hz would need 5.18M ticks for the same offline window — pointless cost when growth resolution is in minutes. |
| Phaser scene tree Boot → Preloader → Garden | Single Garden scene | Phase 2 has near-zero asset loading (no PNG plants — D-26 says primitives only). A single `Garden` scene plus a tiny `Boot` is enough; Preloader becomes meaningful in Phase 3 when watercolor textures arrive. |
## Architecture Patterns
### System Architecture Diagram
```
BROWSER TAB
┌─────────────────────────────────────────────────────────────────────┐
│ React 19 DOM Overlay (z-index above canvas) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Begin │ │ Journal │ │ Letter │ │ Dialogue│ │ Settings │ │
│ │ Screen │ │ (modal) │ │ (modal) │ │ (overlay)│ │ (modal) │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │ │
│ │ └────┬───────┴────┬───────┴────┬───────┘ │
│ ┌────┴────────────────┴────┐ ┌─────┴────────────┴─────┐ │
│ │ Inline Seed Picker │ │ HUD chrome (corner │ │
│ │ (DOM popover positioned │ │ icons + journal │ │
│ │ over Phaser tile) │ │ icon, optional) │ │
│ └────┬─────────────────────┘ └────────────┬───────────┘ │
│ │ │ │
│ │ read state, dispatch commands │ │
│ ↓ ↓ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Zustand 5 Store (createStore from zustand/vanilla,│ │
│ │ read in React via useStore) │ │
│ │ ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌─────────┐ │ │
│ │ │ garden │ │ memory │ │ narrative│ │ session │ │ │
│ │ │ slice │ │ slice │ │ slice │ │ slice │ │ │
│ │ └─────────┘ └─────────┘ └──────────┘ └─────────┘ │ │
│ └─────────┬────────────────────────────────────┬─────┘ │
│ │ command queue (intent only) │ │
│ ↓ ↑ │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
│ │ Phaser 4 Scene Tree │ │ Pure Sim (src/sim/) │ │
│ │ (src/game/, src/render/)│ read │ ┌──────────────────┐ │ │
│ │ ┌─────────────────┐ │ state │ │ scheduler │ │ │
│ │ │ Boot → Garden │←────┼─────────┼─│ (only owner of │ │ │
│ │ │ scene │ │ │ │ wall-clock) │ │ │
│ │ └─────────────────┘ │ │ └────────┬─────────┘ │ │
│ │ • tile rendering │ │ │ injects t │ │
│ │ • plant primitives │ write │ ↓ │ │
│ │ • ready-pulse tween │ state │ ┌────────────────┐ │ │
│ │ • pointerdown handler │←────────┼─│ simulate(s,dt) │ │ │
│ │ • Phaser EventBus │ events │ │ garden.tick │ │ │
│ │ (scene-ready, etc.) │ │ │ growth.advance│ │ │
│ └─────────────────────────┘ │ │ fragment.sel │ │ │
│ │ │ narrative.gate│ │ │
│ ┌──────────────────────────────────┐│ │ numbers/BigQty│ │ │
│ │ Browser event listeners ││ └────────────────┘ │ │
│ │ visibilitychange → save │└──────────┬──────────────┘ │
│ │ beforeunload → save │ │ │
│ │ click → AudioContext.resume() │ │ │
│ └──────────────────────────────────┘ │ │
│ │ │
│ ┌──────────────────────────────────────────────┴──────────────┐ │
│ │ src/save/ (Phase 1, frozen barrel) │ │
│ │ wrap/unwrap, snapshot, requestPersistence, │ │
│ │ exportToBase64/importFromBase64, openSaveDB, │ │
│ │ migrate, V1Payload (extended Phase 2) │ │
│ └────────────────────────────┬────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────┐ │
│ │ IndexedDB (primary) + │ │
│ │ LocalStorage fallback │ │
│ │ Stores: saves, save_snapshots │ │
│ └──────────────────────────────────┘ │
│ │
│ Build time: /content/seasons/01-soil/*.{md,yaml} │
│ /content/dialogue/season1/*.ink │
│ ── Vite import.meta.glob (per-Season lazy from Phase 2) ── │
│ ── inklecate compile → src/content/compiled-ink/*.ink.json ── │
└─────────────────────────────────────────────────────────────────────┘
```
**Data flow summary:**
1. **Boot:** `main.tsx` mounts `App`; `App` mounts `<PhaserGame>`; `PhaserGame.tsx` creates Phaser; Boot scene transitions to Garden scene; Garden scene wires the tick scheduler into its `update()` callback.
2. **Resume from save:** `src/save/index.ts` is read; if a save exists, the envelope is unwrapped, migrated (still v1), and committed to the Zustand store; the scheduler computes elapsed-since-`lastTickAt`, clamps to 24h, runs the catch-up loop, and emits an `offlineEvents` aggregate that triggers the letter overlay if absence ≥ 5min.
3. **First run:** No save → Begin screen mounts; tap calls `AudioContext.resume()` and dismisses the Begin screen; Garden scene activates.
4. **Live tick:** Garden scene's `update(time, delta)` calls `scheduler.tick(now)`. Scheduler accumulates wall-clock `delta`, drains in fixed-size `TICK_MS` chunks, calls `simulate(state, dt, commands)`, applies the result to the Zustand store. Render reads from the store.
5. **Plant a seed:** Player clicks empty tile in Phaser → React seed picker pops up (positioned via tile→screen coord conversion provided by `src/render/garden/`) → click commits → command dispatched into the store → next sim tick the command is drained and applied.
6. **Harvest:** Player clicks ready tile → `harvest` command queued → simulate runs `harvestPlant` → fragment selector picks one fragment id → `harvestedFragmentIds` array gets a new entry → store updates → React renders fragment-reveal modal.
7. **Lura beat:** `narrative.gate` checks `harvestedFragmentIds.length` after each harvest; on count ∈ {1, 4, 8} (config-tunable), enqueues a `pendingLuraBeat: 'arrival'|'mid'|'farewell'` flag; the gate icon glows; player taps gate → React loads compiled `season1/lura-arrival.ink.json` into a fresh inkjs `Story`, sets variables from the store snapshot, drives the dialogue overlay.
8. **Save on visibility hidden / beforeunload:** Listener invokes the save layer's `wrap` + IndexedDB write path. Save is small (<10KB) so synchronous serialization completes well within `beforeunload`'s tight window.
### Recommended Project Structure (Phase 2 additions)
```
src/
├── sim/ (NEW Phase 2 — was empty firewall dir)
│ ├── numbers/
│ │ ├── big-qty.ts D-31; wrapper around break_eternity.js
│ │ ├── big-qty.test.ts
│ │ └── format.ts UX-11 human-readable display (1.2K, 4.5M, …)
│ ├── scheduler/
│ │ ├── clock.ts D-33; the only owner of Date.now()
│ │ ├── tick.ts fixed-timestep accumulator + simulate dispatcher
│ │ ├── catchup.ts CORE-03/CORE-11; refuse negative dt; clamp 24h
│ │ └── *.test.ts
│ ├── garden/
│ │ ├── types.ts Tile + PlantInstance + PlantType
│ │ ├── plants.ts 23 PlantType definitions (durations, fragmentPool)
│ │ ├── growth.ts sprout → mature → ready state machine
│ │ ├── commands.ts plantSeed / harvest / compost (pure)
│ │ ├── auto-harvest.ts D-10 offline auto-harvest branch
│ │ └── *.test.ts
│ ├── memory/
│ │ ├── selector.ts MEMR-06 deterministic, gating, no-dup
│ │ ├── selector.test.ts
│ │ └── pool.ts reads from src/content/, applies Season + plant gate
│ ├── narrative/
│ │ ├── lura-gate.ts D-14 1st/4th/8th harvest count → pending beat
│ │ ├── lura-gate.test.ts
│ │ └── beat-queue.ts store-shape contract for pending Lura beats
│ ├── offline/
│ │ ├── events.ts OfflineEvent schema (zod) + aggregator
│ │ └── events.test.ts
│ ├── state.ts SimState root shape + serialization (matches V1Payload)
│ └── index.ts barrel; the application layer imports from here
├── store/ (NEW Phase 2)
│ ├── garden-slice.ts 16 tiles + plant-type unlocks + commands
│ ├── memory-slice.ts harvested fragment ids + reveal modal state
│ ├── narrative-slice.ts Lura beat queue + dialogue overlay state
│ ├── session-slice.ts Begin gate state + persistence-result toast
│ ├── store.ts composes slices with createStore from zustand/vanilla
│ ├── selectors.ts React-friendly selectors for components
│ └── index.ts
├── save/ (FROZEN Phase 1 barrel; Phase 2 only edits migrations.ts)
│ └── migrations.ts extends V1Payload per D-34
├── content/ (FROZEN Phase 1 barrel; Phase 2 adds Season-1 lazy split)
│ ├── ink-loader.ts (NEW) runtime loader for compiled-ink JSON
│ ├── compiled-ink/ (GENERATED, gitignored) by `npm run compile:ink`
│ │ └── season1/
│ │ ├── lura-arrival.ink.json
│ │ ├── lura-mid.ink.json
│ │ ├── lura-farewell.ink.json
│ │ └── letter-from-the-garden.ink.json
│ └── (existing Phase 1 modules unchanged)
├── render/
│ └── garden/
│ ├── tile-renderer.ts D-06 outlined tile, hover state
│ ├── plant-renderer.ts D-26 primitive shapes per growth stage
│ ├── ready-pulse.ts D-27 alpha cycle / shader pulse
│ ├── gate-renderer.ts gate visual + indicator on pending Lura beat (D-15)
│ └── tile-coords.ts tile↔screen coord conversion (used by seed picker)
├── ui/
│ ├── begin/
│ │ ├── BeginScreen.tsx D-21 typographic placeholder
│ │ └── use-audio-bootstrap.ts AudioContext.resume() on tap
│ ├── journal/
│ │ ├── Journal.tsx D-24 full-screen modal
│ │ ├── FragmentRevealModal.tsx D-25 active-play harvest reveal
│ │ └── journal-icon.tsx D-23 reveals after 1st harvest
│ ├── letter/
│ │ ├── Letter.tsx D-20 full-screen, ≥5min absence
│ │ └── letter-renderer.ts drives inkjs Story + variable bindings
│ ├── dialogue/
│ │ ├── LuraDialogue.tsx D-15 React DOM overlay
│ │ ├── ink-renderer.tsx text-message-cadence drip
│ │ └── ink-runtime.ts inkjs Story instantiation + variable wiring
│ ├── settings/
│ │ ├── Settings.tsx D-28 Export/Import/Restore
│ │ └── persistence-toast.tsx D-30 in-voice soft toast
│ └── garden/
│ └── SeedPicker.tsx D-02 inline popover positioned over canvas
├── game/
│ ├── main.ts EXPAND scene list to include Garden
│ ├── event-bus.ts (NEW) Phaser.Events.EventEmitter singleton (per Phaser 4 template)
│ └── scenes/
│ ├── Boot.ts EXPAND: transition to Garden after `MainScene.create`
│ └── Garden.ts (NEW) the canvas scene; wires tick scheduler + tile input
└── PhaserGame.tsx EXPAND: subscribe to event-bus, expose audio context, register lifecycle save hooks
```
```
content/
├── seasons/
│ ├── 00-demo/ REMOVED Phase 2 (per CONTEXT canonical_refs)
│ └── 01-soil/ NEW
│ ├── fragments.yaml bulk Season-1 fragments (≥8 to satisfy 8th-harvest threshold)
│ └── fragments/
│ └── *.md long-form fragments (one-per-file, frontmatter)
└── dialogue/ NEW (was empty)
└── season1/
├── lura-arrival.ink
├── lura-mid.ink
├── lura-farewell.ink
└── letter-from-the-garden.ink
```
```
tests/
└── e2e/ NEW Phase 2
└── season1-loop.spec.ts PIPE-07 smoke
```
### Pattern 1: Tick Scheduler / Monotonic Clock
**What:** A scheduler module in `src/sim/scheduler/` that owns all wall-clock access (`Date.now()` / `performance.now()`), accumulates real-time deltas, drains them in fixed-size `TICK_MS` chunks, and calls `simulate(state, dtTicks, commands) → state'`. It is the single boundary point between wall time and sim time.
**When to use:** Always for the live tick. Same module also handles offline catch-up.
**Recommended tick rate:** 5 Hz (`TICK_MS = 200`). Rationale:
- A 24h offline catch-up = `24 × 3600 × 5 = 432,000` ticks. Even at 1µs per tick, that catches up in ~0.4s — well within "boot to playable".
- Plant growth in the 25min band has natural granularity (a 5-min plant ticks 1500 times sprout→ready; plenty of resolution for sprout/mature/ready transitions).
- Render runs at `requestAnimationFrame` (60Hz on most displays); sim runs at 5Hz independently. They're decoupled by design.
- 5Hz matches the example in `.planning/research/ARCHITECTURE.md` Pattern 2 — keeping the documented rate keeps everyone on the same page.
**Example:**
```typescript
// Source: .planning/research/ARCHITECTURE.md Pattern 2 + CONTEXT D-33
// src/sim/scheduler/tick.ts — pure, no I/O. Time is INJECTED.
import type { SimState } from '../state';
import { simulate } from '../simulate';
export const TICK_MS = 200; // 5Hz
export const MAX_OFFLINE_MS = 24 * 3600 * 1000;
export interface TickResult {
state: SimState;
ticksApplied: number;
silent: boolean;
}
/**
* Drain the accumulator. Called by the Phaser scene's update() loop OR by
* the catch-up function during boot. Returns the new state and how many
* ticks were actually applied.
*
* REFUSES negative deltas (CORE-11); CLAMPS at MAX_OFFLINE_MS (CORE-03).
*/
export function drainTicks(
state: SimState,
accumulatorMs: number,
silent = false,
): { state: SimState; remainderMs: number; ticksApplied: number } {
if (accumulatorMs < 0) {
// System-clock cheat or save-corruption case. Sim refuses to advance.
// The application layer logs and resets the accumulator.
return { state, remainderMs: 0, ticksApplied: 0 };
}
const cappedMs = Math.min(accumulatorMs, MAX_OFFLINE_MS);
const ticks = Math.floor(cappedMs / TICK_MS);
let next = state;
for (let i = 0; i < ticks; i++) {
next = simulate(next, TICK_MS, /* commands */ [], { silent });
}
return {
state: next,
remainderMs: cappedMs - ticks * TICK_MS,
ticksApplied: ticks,
};
}
```
```typescript
// src/sim/scheduler/clock.ts — the ONLY allowed Date.now() in the project.
export interface Clock {
now(): number; // monotonic-enough wall clock in ms
}
/** Production clock. Reads Date.now(). */
export const wallClock: Clock = {
now: () => Date.now(),
};
/** Test clock. Lets Vitest tests advance time deterministically.
* Also used by Playwright's fast-forward URL flag (?devtime=fake). */
export class FakeClock implements Clock {
private t: number;
constructor(start = 0) {
this.t = start;
}
now(): number {
return this.t;
}
advance(ms: number): void {
this.t += ms;
}
}
```
The Phaser `Garden` scene's `update(_time, _delta)` calls `scheduler.advanceLive(clock.now())` once per frame; the scheduler owns the `lastFrameTime` and the accumulator. Note: Phaser's `time` and `delta` arguments are also wall-derived, but we ignore them and call `clock.now()` ourselves so a `FakeClock` injection is the *only* knob. [CITED: gafferongames.com/post/fix_your_timestep — Fix Your Timestep accumulator pattern, the canonical reference.]
**Boot path (catch-up from save):**
```typescript
// Application layer — src/app/boot.ts (or inlined in PhaserGame.tsx).
// 1. Read save (src/save/).
// 2. Migrate (still v1).
// 3. Compute offlineMs = clock.now() - savedPayload.lastTickAt.
// 4. drainTicks(state, offlineMs, /* silent */ true) → aggregates events.
// 5. If offlineMs >= 5*60*1000 (D-20 threshold), pop the Letter overlay.
// 6. Commit final state to the Zustand store; live tick begins.
```
**Anti-patterns to avoid:**
- `setInterval(tick, 200)` — breaks under tab throttling (`.planning/research/PITFALLS.md` #12).
- Calling `Date.now()` anywhere in `src/sim/garden/`, `src/sim/memory/`, `src/sim/narrative/` — would silently bypass the FakeClock. The ESLint `no-restricted-syntax` rule should be added for `CallExpression[callee.object.name='Date'][callee.property.name='now']` inside `src/sim/**` except `src/sim/scheduler/clock.ts`.
### Pattern 2: BigQty wrapper around break_eternity.js
**What:** A typed wrapper around `Decimal` (the class exported from `break_eternity.js`) that all economic values flow through. Phase 2 lands this even though Season 1 numbers never exceed `Number.MAX_SAFE_INTEGER` — per CLAUDE.md "BigNumbers go through the typed BigQty wrapper around break_eternity.js. Never raw Decimal values in app code."
**Minimum-viable surface:**
```typescript
// Source: .planning/research/PITFALLS.md #7 + CLAUDE.md "Code Style"
// src/sim/numbers/big-qty.ts
import Decimal from 'break_eternity.js'; // ships index.d.ts; no @types needed
export class BigQty {
private constructor(private readonly d: Decimal) {}
// Constructors
static fromNumber(n: number): BigQty { return new BigQty(new Decimal(n)); }
static fromString(s: string): BigQty { return new BigQty(new Decimal(s)); }
static zero(): BigQty { return BigQty.fromNumber(0); }
static one(): BigQty { return BigQty.fromNumber(1); }
// Arithmetic (returns NEW BigQty — immutable)
add(b: BigQty): BigQty { return new BigQty(this.d.add(b.d)); }
sub(b: BigQty): BigQty { return new BigQty(this.d.sub(b.d)); }
mul(b: BigQty): BigQty { return new BigQty(this.d.mul(b.d)); }
div(b: BigQty): BigQty { return new BigQty(this.d.div(b.d)); }
// Comparison
eq(b: BigQty): boolean { return this.d.eq(b.d); }
gte(b: BigQty): boolean { return this.d.gte(b.d); }
gt(b: BigQty): boolean { return this.d.gt(b.d); }
lt(b: BigQty): boolean { return this.d.lt(b.d); }
lte(b: BigQty): boolean { return this.d.lte(b.d); }
// Display (UX-11)
format(): string { return formatHumanReadable(this.d); }
toNumberSaturating(): number {
// Phase 2: returns safe-int saturating value for UI bookkeeping.
if (this.d.gte(Number.MAX_SAFE_INTEGER)) return Number.MAX_SAFE_INTEGER;
return this.d.toNumber();
}
// Serialization (round-trips through Save)
toJSON(): string { return this.d.toString(); }
static fromJSON(s: string): BigQty { return BigQty.fromString(s); }
}
// src/sim/numbers/format.ts — UX-11
export function formatHumanReadable(d: Decimal): string {
// Phase 2 minimum: K/M/B/T then scientific past 1e15.
const n = d.toNumber();
if (Number.isFinite(n) && Math.abs(n) < 1000) return n.toFixed(0);
if (Math.abs(n) < 1e6) return `${(n / 1e3).toFixed(1)}K`;
if (Math.abs(n) < 1e9) return `${(n / 1e6).toFixed(1)}M`;
if (Math.abs(n) < 1e12) return `${(n / 1e9).toFixed(1)}B`;
if (Math.abs(n) < 1e15) return `${(n / 1e12).toFixed(1)}T`;
// break_eternity-only territory: scientific via Decimal#toExponential
return d.toExponential(2);
}
```
**Why an immutable wrapper:** every operation returns a fresh `BigQty`, so the sim's state shape is purely value-oriented. This composes with Zustand 5's structural-equality detection (no accidental mutation tripping subscriber loops) and with snapshot persistence (the save layer just calls `JSON.stringify` on values that include `BigQty` instances — `toJSON()` is invoked automatically).
**Save round-trip:** `BigQty.toJSON()` returns the canonical Decimal string. On load, `BigQty.fromJSON(s)` reconstructs. This must round-trip in all migration tests; add a Vitest case in `migrations.test.ts` once Phase 4 starts using BigQty fields.
**Phase 2 actual usage sites:** Phase 2's economy is intentionally minimal — the only BigQty-typed values are likely:
- `harvestCount` (could remain plain `number`; promoting to BigQty here is the discipline-tax that buys retrofit safety later).
- The (deferred-to-Phase-4) Roothold value — Phase 2 does NOT add it to V1Payload, but the wrapper is ready when it does.
[CITED: github.com/Patashu/break_eternity.js — TypeScript-native via `index.d.ts`; v2.1.3.]
### Pattern 3: Zustand 5 + Phaser EventBus Bridge
**What:** Two communication channels between Phaser scenes and React UI:
1. **Persistent state** lives in a Zustand 5 store created via `createStore` from `zustand/vanilla` (works without React). The sim writes to it via a slim adapter; React components read from it via the `useStore` hook.
2. **Transient events** ("scene-ready", "tile-clicked-with-coords") flow through a `Phaser.Events.EventEmitter` singleton — the official Phaser 4 React-template `EventBus` pattern. [CITED: phaser.io/news/2025/05/bringing-our-phaserjs-templates-into-the-future]
**Why both:** Persistent state needs structural-equality reactivity for component re-renders (Zustand). One-shot signals (e.g., "active scene is now Garden", or "popover should appear at screen X,Y" because Phaser knows where the tile is on screen) don't need to live in state — they're transients. Routing transients through Zustand pollutes the store.
**Slice composition:**
```typescript
// Source: zustand.docs.pmnd.rs/reference/apis/create-store
// src/store/store.ts
import { createStore } from 'zustand/vanilla';
import { useStore } from 'zustand';
import type { GardenSlice } from './garden-slice';
import type { MemorySlice } from './memory-slice';
import type { NarrativeSlice } from './narrative-slice';
import type { SessionSlice } from './session-slice';
export type AppStoreShape = GardenSlice & MemorySlice & NarrativeSlice & SessionSlice;
export const appStore = createStore<AppStoreShape>()((set, get) => ({
...createGardenSlice(set, get),
...createMemorySlice(set, get),
...createNarrativeSlice(set, get),
...createSessionSlice(set, get),
}));
// React-side hook
export function useAppStore<T>(selector: (s: AppStoreShape) => T): T {
return useStore(appStore, selector);
}
// Sim-side adapter (sim cannot import this directly per CORE-10 firewall;
// the adapter sits in src/store/ and exposes a write-only interface that
// the Phaser scene injects into the scheduler call site).
export const simAdapter = {
applySimResult: (next: SimState, events: SimEvent[]) => {
appStore.setState({ /* derive slice updates from next + events */ });
},
drainCommands: () => {
const cmds = appStore.getState().pendingCommands;
appStore.setState({ pendingCommands: [] });
return cmds;
},
};
```
**Slice example:**
```typescript
// src/store/garden-slice.ts
export interface GardenSlice {
tiles: Tile[]; // length 16, per D-01
unlockedPlantTypes: PlantTypeId[];
pendingCommands: GardenCommand[];
enqueuePlant: (tileIdx: number, plantTypeId: PlantTypeId) => void;
enqueueHarvest: (tileIdx: number) => void;
enqueueCompost: (tileIdx: number) => void;
}
```
**Phaser EventBus singleton:**
```typescript
// Source: phaser.io/news/2025/05/bringing-our-phaserjs-templates-into-the-future
// src/game/event-bus.ts
import * as Phaser from 'phaser';
/** Single shared emitter — the Phaser 4 React-template pattern. */
export const eventBus = new Phaser.Events.EventEmitter();
// Sample events Phase 2 will emit/listen for:
// 'scene-ready' (Phaser → React) signals scene tree is live
// 'tile-clicked-coords' (Phaser → React) {tileIdx, screenX, screenY} for seed picker
// 'request-active-scene' (React → Phaser) one-shot
// 'fragment-revealed' (Phaser → React) one-shot for D-25 reveal modal
```
The Phaser scene calls `eventBus.emit('scene-ready', this)`; `PhaserGame.tsx` listens, surfaces the active scene to React. This is exactly what `src/PhaserGame.tsx`'s Phase-1 placeholder is shaped for (the `currentActiveScene` callback comment).
**Anti-pattern:** routing user-input intents through the EventBus instead of the store. User intents are commands (durable, replayable, save-able if needed); transient signals are not. Tile-clicks → store. Scene transitions → EventBus.
### Pattern 4: Inline Seed Picker (DOM popover over Phaser canvas)
**What:** D-02's "click empty tile → inline seed picker" with no persistent sidebar. Implementation: pointerdown on a Phaser tile fires an EventBus event with `{tileIdx, screenX, screenY}`; the React `<SeedPicker>` listens, mounts itself absolutely-positioned to those screen coords with a CSS arrow pointing at the tile.
```typescript
// src/render/garden/tile-coords.ts
export function tileToScreenCoords(
scene: Phaser.Scene,
tileIdx: number,
): { x: number; y: number } {
const tile = /* Phaser GameObject for tileIdx */;
// Account for the Scale.FIT center offset and DPI.
const camera = scene.cameras.main;
return {
x: camera.x + tile.x * camera.zoom,
y: camera.y + tile.y * camera.zoom,
};
}
// In Garden scene's create():
this.tileObjects.forEach((t, idx) => {
t.setInteractive();
t.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
if (state.tiles[idx].plant === null) {
eventBus.emit('tile-clicked-coords', {
tileIdx: idx,
screenX: pointer.x, // pointer event coords are in canvas space
screenY: pointer.y,
});
} else if (state.tiles[idx].plant.stage === 'ready') {
appStore.getState().enqueueHarvest(idx);
}
// Mature-but-not-ready tile click → no-op or compost confirmation.
});
});
```
[CITED: docs.phaser.io/phaser/concepts/input — Phaser input system; pointerdown on interactive game objects.]
**Position translation:** `pointer.x/y` from a Phaser pointer event are in the canvas's CSS-pixel coordinate space, which matches DOM coordinates. The `<SeedPicker>` mounts at `position: absolute; left: ${pointer.x}px; top: ${pointer.y}px;` directly. (No need to `getBoundingClientRect` the canvas; the canvas's CSS layout starts at viewport origin in this app.)
**Click outside → dismiss:** the popover registers a one-shot document `click` listener that fires after the next tick (avoids capturing the same click that opened it).
### Pattern 5: Ink Compilation + Runtime
**Build-time:** `inklecate` (installed as devDep) compiles `*.ink``*.ink.json`. The current `package.json scripts.compile:ink` is a no-op stub from Phase 1; Phase 2 replaces it:
```json
"compile:ink": "node scripts/compile-ink.mjs"
```
```javascript
// scripts/compile-ink.mjs (NEW Phase 2)
// Walks /content/dialogue/**/*.ink, calls inklecate per file,
// emits to src/content/compiled-ink/<season>/<name>.ink.json.
// Adds --countAllVisits=false (default) so visit counts are minimal.
import { inklecate } from 'inklecate';
import { glob } from 'glob';
import path from 'node:path';
const inkFiles = await glob('content/dialogue/**/*.ink');
for (const inkPath of inkFiles) {
const rel = path.relative('content/dialogue', inkPath);
const outPath = path
.join('src/content/compiled-ink', rel)
.replace(/\.ink$/, '.ink.json');
await inklecate({
inputFilepath: inkPath,
outputFilepath: outPath,
});
}
```
This script runs as part of `npm run build` (and explicitly via `npm run compile:ink` so dev iteration is fast). The output directory is `.gitignore`'d; the Vite dev server serves the compiled JSON via standard import.
**Runtime:** `inkjs` v2.4.0 exports `Story` from the package root (verified in `node_modules/inkjs/ink.d.mts`). Usage:
```typescript
// Source: github.com/y-lohse/inkjs README; node_modules/inkjs/README.md
// src/ui/dialogue/ink-runtime.ts
import { Story } from 'inkjs';
// Lazy import the compiled JSON so Season 2-7 stays out of the Phase-2 bundle.
const luraStories = import.meta.glob('/src/content/compiled-ink/season1/lura-*.ink.json', {
import: 'default',
});
export async function loadLuraBeat(beatId: 'arrival' | 'mid' | 'farewell'): Promise<Story> {
const path = `/src/content/compiled-ink/season1/lura-${beatId}.ink.json`;
const json = await luraStories[path]();
return new Story(json);
}
// Variable wiring — Lura's branches can read game state.
export function bindGardenStateToInk(story: Story, snapshot: AppStoreShape): void {
// Map from store keys to Ink variable names declared in the .ink files.
story.variablesState['fragment_count'] = snapshot.harvestedFragmentIds.length;
story.variablesState['last_plant_type'] = snapshot.lastPlantedType ?? '';
// ... add more as the writer adds Ink variables
}
```
**Drip-fed text-message-cadence rendering (STRY-01 "text-message-cadence dialogue"):** Phase 2's React `<LuraDialogue>` component calls `story.Continue()` to fetch the next line, awaits a tunable delay (e.g., 800ms × line length / 40 characters), then renders. Choices are rendered as buttons after `story.currentChoices` resolves. The reduced-motion preference (deferred to Phase 8) will eventually short-circuit the delay; Phase 2 ships a fixed cadence.
### Pattern 6: Letter-from-the-Garden as Ink Template
**What:** D-17 says the letter is an authored Ink skeleton with named variable slots. Ink supports variables via `VAR varName = value` syntax and string interpolation `{varName}` in passages. The application sets the variables via `story.variablesState['name'] = value` BEFORE calling `Continue()`.
**Skeleton example (`content/dialogue/season1/letter-from-the-garden.ink`):**
```ink
VAR plants_bloomed = 0
VAR fragment_titles = ""
VAR lura_was_here = false
The garden held its breath while you were gone.
{plants_bloomed > 1:
{plants_bloomed} blooms came and went, leaves heavy with their leaving.
- else:
{plants_bloomed == 1:
One bloom came and went.
- else:
Nothing bloomed. The wind carried something else, and the garden remembered it.
}
}
{ fragment_titles ? "Among what stayed: {fragment_titles}." }
{ lura_was_here:
Lura came by once. She did not knock. She left a folded leaf on the gate post.
}
```
**Why Ink variables, not Ink EXTERNAL functions:** EXTERNAL functions are for callbacks ("game, please tell me X"); they're heavier and require runtime registration. Simple string-substitution slots map cleanly to `variablesState[name] = value`. Phase 2 ships variable-substitution only. If Phase 4+ needs more, the `EXTERNAL function get_lura_quote() = "..."` pattern is available without re-architecting.
**Slot vocabulary for Phase 2's letter** (from D-19's `offlineEvents` block):
- `plants_bloomed: number` — total auto-harvested plants during absence
- `fragment_titles: string` — comma-joined human-readable list of just-collected fragments (or `""` if none)
- `lura_was_here: boolean` — whether a beat ticked while the player was away (the gate indicator was queued)
The writer expands the slot list as new Ink expressions need state; Phase 2 may grow from these three to ~56 by end of phase. CLAUDE.md "voice anchor" rule means the Ink reads as authored fiction; the slots are filled at runtime.
### Pattern 7: Save-Schema Extension (NOT a migration)
**What:** Per CONTEXT D-34 and `.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md` D-04, Phase 1's v1 envelope has shipped no production saves. Phase 2 *extends* `V1Payload` in place — adds new fields with sensible defaults — and the existing `migrations[1]` synthetic v0→v1 demo continues to work because v0 → v1 already sets up the new defaults via the same factory function.
**Concrete edit to `src/save/migrations.ts`:**
```typescript
// Phase-2 V1Payload shape. EXTENSION of Phase-1's shape (compatible).
export interface V1Payload {
garden: { tiles: TileSlot[] };
plants: PlantInstance[]; // shape filled in Phase 2
harvestedFragmentIds: string[];
lastTickAt: number;
// NEW Phase 2 fields:
unlockedPlantTypes: PlantTypeId[]; // D-05
luraBeatProgress: {
arrived: boolean;
mid: boolean;
farewell: boolean;
pending: 'arrival' | 'mid' | 'farewell' | null;
}; // D-13/D-14
offlineEvents: OfflineEventBlock | null; // D-19; nulled after letter dismissed
settings: {
musicVolume: number;
ambientVolume: number;
sfxVolume: number;
persistenceToastShown: boolean; // D-30 one-time
};
}
// migrations[1] becomes:
export const migrations: Record<number, Migration> = {
1: (s: unknown): V1Payload => {
const v0 = (s ?? {}) as V0Payload;
return {
garden: { tiles: v0.garden ?? [] },
plants: [],
harvestedFragmentIds: [],
lastTickAt: Date.now(),
// NEW defaults:
unlockedPlantTypes: [], // populated by sim init when first run
luraBeatProgress: { arrived: false, mid: false, farewell: false, pending: null },
offlineEvents: null,
settings: {
musicVolume: 0.7,
ambientVolume: 0.5,
sfxVolume: 0.8,
persistenceToastShown: false,
},
};
},
};
```
The `migrations.test.ts` tests need updating for the new fields, but `CURRENT_SCHEMA_VERSION` stays at `1` and no `migrations[2]` is added (per `.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md` D-04: "The first *real* migration is v1 → v2 in Phase 4").
**Why this is safe:** No production saves under v1 exist. Adding fields to `V1Payload` is type-extension, not data migration. The synthetic v0 → v1 chain is updated to populate the new defaults; the chain remains exercised end-to-end.
**Anti-patterns to avoid:**
- Adding a `migrations[2]` for what is fundamentally a Phase-2 init concern. Phase 4 owns `migrations[2]` per Phase 1's locked decision.
- Reading `lastTickAt` directly from save without going through the scheduler — this would let `Date.now()` leak into sim modules.
- Storing the offline letter's *body text* in the save. Only the structured `offlineEvents` block is saved; the Ink template renders it on demand from `/content/dialogue/season1/letter-from-the-garden.ink.json`.
### Pattern 8: Per-Season Lazy Loading (PIPE-02)
**What:** `import.meta.glob('/content/seasons/*/fragments/*.md', { eager: false, … })` returns functions that resolve to dynamic imports — Vite splits each into its own chunk. [CITED: vite.dev/guide/features — `import.meta.glob` is "lazy-loaded via dynamic import" by default when `eager` is not set or is `false`.]
The current `src/content/loader.ts` uses `eager: true` for Phase 1's single-Season demo. Phase 2 splits the Season-1 path into a lazy variant:
```typescript
// src/content/loader.ts (Phase 2 evolution)
// Eager bootstrap: only the season manifest (lightweight) is in the initial bundle.
const seasonManifests = import.meta.glob('/content/seasons/*/manifest.yaml', {
eager: true,
query: '?raw',
import: 'default',
}) as Record<string, string>;
// Per-Season lazy: actual fragment files load only when that Season is active.
const lazyYamlFragments = import.meta.glob('/content/seasons/*/fragments.yaml', {
query: '?raw',
import: 'default',
});
const lazyMdFragments = import.meta.glob('/content/seasons/*/fragments/*.md', {
query: '?raw',
import: 'default',
});
export async function loadSeasonFragments(seasonId: number): Promise<Fragment[]> {
const yamlPath = `/content/seasons/${pad2(seasonId)}-${slug(seasonId)}/fragments.yaml`;
const fragmentPaths = Object.keys(lazyMdFragments).filter(p => p.includes(`/${pad2(seasonId)}-`));
// …load + validate each via FragmentSchema as today; throw on schema violation.
}
```
For Phase 2 (Season 1 only is authored), Vite produces two chunks: the main bundle (no fragment text) and a `season1` chunk (~few KB of Markdown + YAML). When Phase 4 adds Season 2, it ships an additional chunk; Season 2-7 are never in the initial download until the player progresses.
**Initial-load budget impact:** Phase 1's initial bundle is dominated by Phaser 4 (~700KB minified) and React 19 (~50KB). Phase 2 adds Zustand (~3KB), `break_eternity.js` (~30KB), Season-1 Ink JSON (~few KB), and roughly 200 lines of Phase-2 sim code. The Season-1 fragment chunk lazy-loads after the Begin gesture, so the *gesture-to-painted-garden* path stays under CORE-01's 5s budget.
### Pattern 9: AudioContext.resume() Bootstrap
**Per CONTEXT D-21/D-22:**
- First-run (no save exists): Begin screen mounts; tap calls `audioContext.resume()`.
- Subsequent loads: Begin screen is skipped; AudioContext starts in `suspended` state and resumes on the player's first interaction (any tile click, gate click, or button click counts as a user gesture per browser autoplay policy).
```typescript
// src/ui/begin/use-audio-bootstrap.ts
let _ctx: AudioContext | null = null;
let _resumed = false;
export async function bootstrapAudioContext(): Promise<AudioContext | null> {
if (_resumed && _ctx) return _ctx;
if (!_ctx) {
try {
const Ctor =
typeof AudioContext !== 'undefined'
? AudioContext
: (window as any).webkitAudioContext;
if (!Ctor) return null;
_ctx = new Ctor();
} catch {
return null;
}
}
try {
await _ctx.resume();
_resumed = true;
return _ctx;
} catch {
return null;
}
}
// First-interaction handler installed on the live garden when no Begin shows.
export function installFirstInteractionGestureHandler(): void {
const handler = () => {
bootstrapAudioContext();
document.removeEventListener('click', handler);
document.removeEventListener('touchstart', handler);
document.removeEventListener('keydown', handler);
};
document.addEventListener('click', handler, { once: false });
document.addEventListener('touchstart', handler, { once: false });
document.addEventListener('keydown', handler, { once: false });
}
```
[CITED: developer.chrome.com/blog/web-audio-autoplay — explicit `context.resume()` after gesture is the canonical fix.]
**Phase 3 will:** retrieve the resumed `AudioContext` from this module (or pass it through the Zustand store) and feed it to Howler's master gain.
### Anti-Patterns to Avoid
- **`setInterval(tick, 200)` for sim progression** — breaks under tab throttling; the elapsed-time clock model is the documented fix (`.planning/research/PITFALLS.md` #12).
- **Mutating store state from sim modules directly** — sim cannot import `src/store/`. Instead, the application layer (Phaser scene + scheduler) is the bridge: scene calls `scheduler.tick(state)`, gets back `next state`, calls `simAdapter.applySimResult(next, events)` which lives in `src/store/`.
- **Naive offline catch-up** (`fragments += rate * elapsedSeconds`) — misses non-linear interactions and Lura beat unlocks. **Run the actual `simulate()` loop** with `silent: true` (`.planning/research/PITFALLS.md` Anti-Pattern 7).
- **`if (!state.luraBeatProgress) state.luraBeatProgress = {...}` at read sites** — that's a hidden migration. Instead, fix the migration once in `migrations[1]` (Phase 2) and require `V1Payload` to be fully populated.
- **Storing player-visible strings in TypeScript** — every line of Lura's dialogue, the letter, the Begin screen copy, the post-harvest acknowledgement, the persistence-denied toast lives in `/content/`. The "no fragments yet" empty-state copy lives in `/content/seasons/01-soil/ui-strings.yaml` (a small additional content file Phase 2 adds), validated by an additional Zod schema.
- **Numeric fragment IDs** — already prevented by `FragmentSchema`'s regex. Don't loosen the regex for "convenience."
- **Calling `audioContext.resume()` in `useEffect` without a user gesture** — fires before gesture, fails silently; the resume must be inside an actual click handler.
- **Rendering Memory Journal text inside Phaser** — MEMR-05 demands selectable, copy-pasteable DOM text. Phaser canvas text fails this requirement.
- **Lura's beats as `setTimeout` triggers** — would let system-clock manipulation skip beats. STRY-10 demands tick-count gating. The fragment-count-based trigger satisfies this naturally because harvest events advance during sim ticks only.
- **Reading `Date.now()` inside a React render** — would re-fire on every render. Time is in the store; React just reads it.
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Big-number math for prestige economy | Custom BigDecimal | `break_eternity.js` (wrapped by `BigQty`) | Native JS numbers overflow at 1e308; precision loss at 1e15. `break_eternity.js` is the standard for incremental games (Pitfall 7). |
| Branching dialogue with state-aware choices | Custom dialogue tree | `inkjs` + `inklecate` | Already locked + installed. Ink is purpose-built for narrative scripting; `inkjs` runtime is battle-tested across browser deployments. (`.planning/research/STACK.md`) |
| Deterministic fragment selection with gating + no-dup | Random `Math.random()` rolling | Pure function with seeded PRNG over a filtered, exhaustion-tracking pool | MEMR-06 demands "deterministic". Use a small seeded PRNG (e.g., mulberry32, ~10 LoC pure function) seeded from a stable hash of `(harvestedFragmentIds.length, lastTickAt)` so replays are reproducible. |
| State management between Phaser and React | Custom event bus + manual state sync | Zustand 5 (createStore + useStore) + Phaser EventBus | Zustand is locked + standard. The Phaser `EventBus` pattern is the official template-react integration. |
| Idle game tick loop with fixed timestep + offline catch-up | `setInterval` + ad-hoc math | Accumulator pattern owning wall-clock at one boundary | Tab throttling, system-clock cheat, save-load sync — all solved by the documented pattern (Architecture Pattern 2). |
| Save versioning + checksum + Base64 + IndexedDB + LocalStorage fallback | Anything | `src/save/` (Phase 1) | Already shipped. Phase 2 imports only from `src/save/index.ts`. |
| Markdown frontmatter parsing | Custom regex | `gray-matter` (already installed + wired) | Standard. Phase 1 already integrated it. |
| YAML parsing | Custom YAML | `yaml` package (already installed + wired) | Standard. Phase 1 already integrated it. |
| CRC-32 checksum | Custom CRC | `crc-32` package (already installed + wired) | Standard. Phase 1 already integrated it. |
| Schema validation | Custom validator | `zod` (already installed + wired) | Standard. Phase 1 uses it for content + envelope; Phase 2 uses for `OfflineEventBlock`. |
| Audio crossfading | Custom Web Audio gain ramps | `Howler.js` (Phase 3) — Phase 2 stubs only | Howler abstracts iOS Safari quirks. Phase 2 doesn't need it; Phase 3 wires it. |
| Ink dialogue rendering with text-cadence | Custom drip animator | Compose `setTimeout` cadence around `inkjs.Story.Continue()` (~30 LoC) | The drip is genuinely small custom code; the dialogue *engine* is Ink. |
| Per-Season lazy loading | Custom dynamic-import scheme | `import.meta.glob({ eager: false })` — Vite native | Vite splits chunks automatically. (`.planning/research/STACK.md` + Vite docs) |
**Key insight:** Phase 1 already paid for the heavy infrastructure. Phase 2's "don't hand-roll" list is mostly "use what Phase 1 built" — the temptation will be to bypass the existing barrel and import from internal modules under deadline pressure. Don't.
## Common Pitfalls
### Pitfall 1: Sim modules calling `Date.now()` for "convenience"
**What goes wrong:** A sim module reads `Date.now()` to "stamp" a plant's plantedAt. The Vitest test passes (because `Date.now()` exists in node). The Playwright fast-forward fails because the FakeClock injection is bypassed.
**Why it happens:** It's one line of "obvious" code. Authors don't see the architecture.
**How to avoid:**
- The clock is an injected parameter — sim functions take `(state, dt, now)` not `(state, dt)`.
- Add an ESLint rule: `'no-restricted-syntax': ['error', { selector: "CallExpression[callee.object.name='Date'][callee.property.name='now']", message: 'src/sim/** must inject time; only src/sim/scheduler/clock.ts may read Date.now()' }]` scoped to `src/sim/**` excluding `src/sim/scheduler/clock.ts`.
- Add a Vitest test that runs through the sim with a FakeClock and asserts no real-time elapses (i.e., real-`Date.now()` and the result are unchanged).
**Warning signs:** A test passes only when run quickly; a Playwright fast-forward changes results.
### Pitfall 2: 4×4 garden state confusion (tile coords vs index)
**What goes wrong:** Tile is at `(row=2, col=3)`. Code uses `index = row * 4 + col` in some places and `index = col * 4 + row` in others. Save loads the wrong tile.
**How to avoid:** Pick one canonical encoding (`index = row * 4 + col`) in `src/sim/garden/types.ts` with an exported helper `tileIdx(row, col)` and `tileCoords(idx)`. Ban raw arithmetic at call sites.
**Warning signs:** Plant appears in wrong tile after refresh; save round-trip test fails on tile order.
### Pitfall 3: Begin-screen skip logic misclassifies returning players
**What goes wrong:** D-22 says "first run = no save exists". An IndexedDB read fails (private mode, blocked) → save layer falls through to LocalStorage → LocalStorage read returns `null` (genuinely missing) vs `null` (silently failed). Returning player sees Begin screen again.
**How to avoid:** `openSaveDB()` already wraps the IDB-or-LocalStorage decision; the SaveDB interface exposes both. Phase 2's "first run?" check is `(await db.get('saves', 'main')) === undefined`. The SaveDB abstraction makes IDB-vs-LS transparent. Test both paths in Vitest.
**Warning signs:** Returning player on iOS Safari sees Begin screen on every launch.
### Pitfall 4: Ink variable casing mismatch (case-sensitive)
**What goes wrong:** Ink declares `VAR plants_bloomed = 0`. JS sets `story.variablesState['plantsBloomed'] = 5`. No error fires; the variable stays at 0; the letter renders the wrong branch.
**How to avoid:** Use `snake_case` for all Ink variables. Centralize the mapping table in `src/ui/letter/letter-renderer.ts`:
```typescript
const INK_VARIABLE_MAP = {
plants_bloomed: (s: AppStoreShape) => s.offlineEvents?.plantsBloomedCount ?? 0,
fragment_titles: (s: AppStoreShape) => s.offlineEvents?.harvestedFragmentTitles.join(', ') ?? '',
lura_was_here: (s: AppStoreShape) => s.offlineEvents?.luraBeatPending ?? false,
} as const;
```
A test asserts every key in `INK_VARIABLE_MAP` exists in the compiled `.ink.json` (parsed for `VAR` declarations).
**Warning signs:** Letter renders an unexpected branch; "fragment_titles" shows literal text.
### Pitfall 5: AudioContext spec disagreement on `resume()` timing
**What goes wrong:** Mobile Safari requires `AudioContext` to be *created* synchronously inside the gesture handler — not just `resumed` inside it. Creating in `useEffect` and calling `resume()` in click fails on iOS.
**How to avoid:** Lazy-create the `AudioContext` inside `bootstrapAudioContext()` which is called from the click handler. Don't pre-create on mount.
**Warning signs:** Audio works on Chrome desktop, fails silently on iOS.
[CITED: developer.mozilla.org/Web/Media/Guides/Autoplay — autoplay policy details for cross-browser behavior.]
### Pitfall 6: Phaser scene stale closure over Zustand state
**What goes wrong:** Phaser scene's `create()` does `const tiles = appStore.getState().tiles` once; later updates don't propagate to the scene's render. Plants never appear after planting.
**How to avoid:** Inside Phaser scenes, *subscribe* to the store: `appStore.subscribe(state => this.renderTiles(state.tiles))`. Or — simpler for Phase 2 — re-read `appStore.getState()` inside the scene's `update()` loop (it runs at 60Hz; reading is cheap). The latter is the minimum-viable choice.
**Warning signs:** Plant placed via React popover does not appear in canvas until refresh.
### Pitfall 7: Save fires AFTER React unmounts on `beforeunload`
**What goes wrong:** `beforeunload` listener calls `await saveAsync()`. Browser doesn't await async work in unload handlers. Save races and may not flush.
**How to avoid:** Use a synchronous-ish save path on `beforeunload`: serialize the envelope (synchronous), write to LocalStorage (synchronous), and *only* attempt IndexedDB best-effort. The next-load path checks both stores; whichever has the more recent `lastTickAt` wins.
**Warning signs:** Closing the tab and reopening loses 110 seconds of progress.
### Pitfall 8: Fragment selector returns duplicates after exhaustion
**What goes wrong:** MEMR-06 says "no duplicates within a single playthrough until the pool is exhausted." Once exhausted, the spec is silent. If the selector throws, harvests after exhaustion break.
**How to avoid:** When all fragments in the gated pool have been harvested, the selector falls back to a "memory-of-a-memory" pool (a single fragment authored as the exhaustion marker, e.g., `season1.soil.gardener-knows-this-one-already`) OR repeats the most recently harvested one (least bad option for UX). Phase 2 should ship enough Season-1 fragments that exhaustion is unlikely (target ≥10 to comfortably exceed the 8th-harvest Lura threshold + plant-type unlocks). Document the chosen behavior in PLAN.md.
**Warning signs:** Player reaches a state where harvest does nothing; or duplicate fragments appear unexpectedly.
### Pitfall 9: Letter overlay swallows tap-to-resume gesture
**What goes wrong:** Player returns after >5min. Letter overlay mounts. Player taps to dismiss → letter dismisses but `AudioContext.resume()` is not called because no `bootstrapAudioContext()` handler is registered on the letter's dismiss button.
**How to avoid:** The letter's dismiss button calls `bootstrapAudioContext()` exactly like the Begin button. Same for any first-interaction surface a returning player might hit before the live garden.
**Warning signs:** Audio fails for returning players who land directly in the letter.
### Pitfall 10: Plant-type unlock thresholds vs fragment-count off-by-one
**What goes wrong:** D-05 says "remaining 12 unlock by fragment-count thresholds." Player has 5 harvests. Threshold for plant 2 is `5`. Off-by-one bug: unlock fires *before* the 5th harvest is committed to the store.
**How to avoid:** Always check thresholds *after* the harvest commit, in the same simulate-step's post-step hook. Test the boundary (4 harvests = locked, 5 harvests = unlocked) explicitly.
**Warning signs:** Race condition between unlock and reveal modal.
## Code Examples
### Verified pattern: Phaser scene → React EventBus signaling scene-ready
```typescript
// Source: phaser.io/news/2025/05/bringing-our-phaserjs-templates-into-the-future
// src/game/scenes/Garden.ts
import * as Phaser from 'phaser';
import { eventBus } from '../event-bus';
export class Garden extends Phaser.Scene {
constructor() { super('Garden'); }
create(): void {
// ... build tile objects, plant primitives, gate ...
eventBus.emit('scene-ready', this);
}
update(_time: number, _delta: number): void {
// Re-read from store on each frame; render.
// The scheduler is invoked on a fixed cadence inside this loop —
// see src/sim/scheduler/tick.ts for the accumulator drain.
}
}
```
### Verified pattern: Zustand 5 vanilla store with React hook
```typescript
// Source: zustand.docs.pmnd.rs/reference/apis/create-store
// src/store/store.ts
import { createStore } from 'zustand/vanilla';
import { useStore } from 'zustand';
export const appStore = createStore<AppStoreShape>()((set) => ({
// … slice spreads
}));
// React side
export const useAppStore = <T>(selector: (s: AppStoreShape) => T): T =>
useStore(appStore, selector);
```
### Verified pattern: Tick scheduler accumulator (idle-game canonical)
```typescript
// Source: gafferongames.com/post/fix_your_timestep + .planning/research/ARCHITECTURE.md Pattern 2
// src/sim/scheduler/tick.ts (excerpt — full version above)
const TICK_MS = 200;
const MAX_OFFLINE_MS = 24 * 3600 * 1000;
// drainTicks refuses negative dt (CORE-11) and clamps offline to 24h (CORE-03).
```
### Verified pattern: inkjs Story instantiation
```typescript
// Source: github.com/y-lohse/inkjs README; node_modules/inkjs/ink.d.mts
// src/ui/dialogue/ink-runtime.ts (excerpt)
import { Story } from 'inkjs';
const story = new Story(compiledJson);
story.variablesState['fragment_count'] = snapshot.harvestedFragmentIds.length;
const line = story.Continue();
```
## State of the Art
| Old Approach | Current Approach (2026) | When Changed | Impact |
|--------------|-------------------------|--------------|--------|
| `setInterval` for idle tick | Elapsed-time-based progression with `requestAnimationFrame`-driven scheduler | Chrome 88+ background-tab timer throttling (Jan 2021) | Mandatory for any idle game; `.planning/research/PITFALLS.md` #12. |
| Phaser 3 with custom React shell | Phaser 4 official `template-react-ts` with `EventBus` singleton | Phaser 4 release (April 2026) + template refresh (May 2025 announcement) | Removes ~6 months of custom integration; what `src/PhaserGame.tsx` already implements. |
| Zustand 4 with React-only API | Zustand 5 with `zustand/vanilla` `createStore` | Zustand 5 release | Lets sim-side adapters and Phaser scenes participate without React-fiber overhead. |
| Ink with hand-rolled drip rendering | `inkjs` v2.4 + ESM `import { Story } from 'inkjs'` | inkjs 2.x ESM exports landed | TypeScript-friendly imports; compiled output stays portable. |
| `@types/howler` always required | Howler.js 2.2.4 ships compatible types via `@types/howler` (DefinitelyTyped) | — | Phase 3 concern. |
**Deprecated/outdated (avoid):**
- `setInterval`-driven game logic (Pitfall 12).
- Storing entitlements client-side (Pitfall 4 in `.planning/research/ARCHITECTURE.md`'s Anti-Patterns) — moot for Phase 2 (no entitlements yet, but worth knowing for v2).
- `process.env.NODE_ENV` in browser code — Vite uses `import.meta.env` (already used by Phase 1).
- `phaser/types/Phaser` deep imports — Phaser 4 exports types from the package root.
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | The Phaser 4 `EventBus` pattern (Phaser.Events.EventEmitter singleton) is the documented Phaser-React integration in Phaser 4's official template, just as it was in Phaser 3's template. | Pattern 3 + System Architecture Diagram | LOW — verified via [phaser.io/news/2025/05/bringing-our-phaserjs-templates-into-the-future]; the template's EventBus pattern explicitly says the May 2025 update prepared the template for Phaser 4. If Phaser 4's release shifted this, Phase 2 falls back to `mitt` which is already research-confirmed in `.planning/research/STACK.md`. |
| A2 | A 5Hz sim tick rate (TICK_MS=200) is appropriate for 25 minute plant growth. | Pattern 1 | LOW — derived from `.planning/research/ARCHITECTURE.md` Pattern 2's worked example. If playtest shows visible stepping in growth animation, Phase 2 can bump to 10Hz (TICK_MS=100) without changing any sim code (only the constant). |
| A3 | A 24h offline catch-up at 5Hz (=432K ticks) catches up in well under 1s on a modern device. | Pattern 1 | LOW — the per-tick work is small (state mutation + accumulator drain); but if the simulate function ends up doing string allocations per tick, this could degrade. Vitest benchmark in scheduler tests should assert ≤500ms for 432K ticks. |
| A4 | Ink variables (`VAR plants_bloomed = 0`) set via `story.variablesState['plants_bloomed'] = N` work for letter templating in inkjs 2.4.0. | Pattern 6 | LOW — verified via [github.com/y-lohse/inkjs README "Differences with the C# API: Getting and setting ink variables"]; this is the documented mechanism. |
| A5 | Phaser pointer event coordinates (`pointer.x`, `pointer.y`) are in CSS-pixel space and can be used directly as DOM `position: absolute` coords for the seed picker without `getBoundingClientRect` adjustments. | Pattern 4 | MEDIUM — depends on the canvas's CSS layout. Phase 1's scaffold uses `Phaser.Scale.FIT`, which adds letterboxing. The popover may need to `getBoundingClientRect` the `#game-container` and add its `left/top` offset. Plan 2 should test on a non-fullscreen window. |
| A6 | `inklecate` (the npm wrapper around the .NET binary) runs cleanly on Windows + macOS + Linux CI. | Pattern 5 | MEDIUM — `inklecate.exe` ships in `node_modules/inklecate/bin/`; the wrapper picks the right binary per platform. Phase 2's `compile-ink.mjs` script must not assume a specific binary path; it should call the `inklecate` package's exported function. The package was installed in Phase 1 but never invoked beyond the no-op stub — Plan 2 should confirm with one real compile on Windows-the-dev-machine before authoring all Lura content. |
| A7 | Phase 2's letter rendering uses Ink variable substitution + conditional branches, not `EXTERNAL` functions. | Pattern 6 | LOW — variable substitution is sufficient for D-19's slot vocabulary; flagged here so reviewers know we explicitly chose simpler over more powerful. |
| A8 | Authoring "≥10 Season-1 fragments" is sufficient to satisfy 1st/4th/8th Lura thresholds + 12 plant-type unlocks + buffer. | Pitfall 8 | LOW — covers the worst case (8 harvests + 1 plant-type unlock margin). User reviews specific count during planning. |
| A9 | The first-interaction gesture handler installed on returning-player loads (Pattern 9) is reliable across browsers (no edge case where audio fails silently). | Pattern 9 | LOW — the `click`+`touchstart`+`keydown` triple-listener is the documented pattern. iOS quirks were the historical bug; modern Safari respects `resume()` after any user gesture. Phase 8 e2e test on iOS Safari is the right place to verify. |
| A10 | Per-Season lazy loading via `import.meta.glob({ eager: false })` produces real chunk splits for Phase 2 even with only one Season authored. | Pattern 8 | LOW — Vite always splits each lazy import; the chunk happens to be a single file in Phase 2, but the wiring is correct for Phase 4+. Verify with a `npm run build && ls dist/assets/` showing a `season1`-named chunk. |
**If this table is empty:** All claims in this research were verified or cited — no user confirmation needed.
This table has 10 entries; all are `LOW` risk except A5 and A6 (`MEDIUM`). Plan 2 should explicitly verify A5 (canvas-to-DOM coord mapping under FIT scale) and A6 (inklecate wrapper invocation) early.
## Open Questions (RESOLVED)
> All five open questions resolved during /gsd-discuss-phase 2 + planning. Resolutions are codified in CONTEXT.md decisions and the per-plan PLANs. Recommendations below are ratified, not pending.
1. **Plant-type identity per the 23 plant types in Season 1 (per D-03 + D-09).**
- What we know: 23 plants, varying durations within 25min, distinct fragment pools, distinct visual primitive (different tint per plant), tonal identity per plant. First plant from start; remaining unlock at fragment-count thresholds.
- What's unclear: the actual identities (e.g., "rosemary vs. yarrow vs. winter-rose"?). Bible says "real flora, slightly wrong" but Phase 2 doesn't need painted flora — names matter for fragment authoring.
- RESOLVED: Plan 2's content authoring step proposes 3 names tied to 3 fragment-pool tonal registers (warm / contemplative / heavy). Locked names: rosemary (warm), yarrow (contemplative), winter-rose (heavy). See Plan 02-02 PLANT_TYPES.
2. **Compost UX shape (GARD-04 "tonal beat acknowledging the choice to let go").**
- What we know: composting is a Phase 2 mechanic; tonal beat must "acknowledge the choice to let go". Bible voice is warm + specific.
- What's unclear: is the beat a small text snippet, a particle effect, a sound? D-07 leaves form to Claude's discretion *for harvest*; GARD-04 is the *compost* analog and isn't in CONTEXT.
- RESOLVED: a single Ink-authored line per compost (`content/dialogue/season1/compost-acknowledgements.ink`, ~35 short lines, randomly selected). Authored in Plan 02-04. UI-wiring deferral acknowledged in 02-04 SUMMARY → 02-05 toast surface.
3. **Save trigger on Season transitions (UX-10).**
- What we know: UX-10 specifies "saves on visibilitychange to hidden, on beforeunload, AND on Season transitions."
- What's unclear: Phase 2 has no Season transitions (Season 1 only). The save-on-transition hook is dormant until Phase 4.
- RESOLVED: implement the save hook as a pure function callable by future-phase code (`saveOnSeasonTransition(state)` exported from `src/save/index.ts`). Phase 2 verifies it via unit test only; first real call is Phase 4. See Plan 02-01 src/save/lifecycle.ts.
4. **PIPE-02 lazy-loading scope when only Season 1 is authored.**
- What we know: PIPE-02 says "future Seasons are not in the initial bundle."
- What's unclear: with only Season 1 authored, the lazy split is a wiring exercise — Phase 2's actual *initial* bundle contains everything because there's nothing else to lazy-load. Is the requirement satisfied "vacuously" (no Seasons 2-7 exist), or does the pattern need to demonstrably work?
- RESOLVED: ship the lazy pattern (`{ eager: false }`) so Phase 4 inherits it without rework. Plan 02-03 ships scripts/check-bundle-split.mjs; PIPE-02 verification is structural via that script + a Vitest case. PIPE-07 e2e covers the runtime loop.
5. **Should the offline letter ALSO surface the just-unlocked Lura beat indicator, or are they decoupled?**
- What we know: D-19 says `offlineEvents` includes "a flag for any newly-unlocked Lura beat queued for first-visit." D-15 says the beat-fire UX is "a soft cue at the gate." D-20 says the letter is full-screen.
- What's unclear: when a beat unlocks during absence, does the letter mention Lura? Or does the player dismiss the letter into the live garden where the gate glows?
- RESOLVED: the letter mentions Lura's presence in voice (Ink slot `lura_was_here`), the gate also indicates the queued beat. Both surfaces, complementing each other. See Plan 02-05 letter-from-the-garden.ink + Plan 02-04 gate-renderer.ts.
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node.js | Build, test, CI | ✓ | (Phase 1 ran on Node 22 per `.github/workflows/ci.yml`) | — |
| `npm` | Install + scripts | ✓ | (bundled with Node) | — |
| Phaser 4 | Canvas rendering | ✓ | 4.1.0 | — |
| React 19 | UI overlays | ✓ | 19.2.6 | — |
| Vite | Dev server + bundler | ✓ | 8.0.11 | — |
| TypeScript | Source compilation | ✓ | 6.0.3 | — |
| Vitest | Unit + integration tests | ✓ | 4.1.5 | — |
| Playwright | E2E smoke | ✓ | 1.59.1 | — |
| `idb` | Save layer | ✓ | 8.0.3 | LocalStorage adapter (already shipped in `src/save/`) |
| `lz-string` | Save compression | ✓ | 1.5.0 | — |
| `crc-32` | Save checksum | ✓ | 1.2.2 | — |
| `zod` | Schema validation | ✓ | 4.4.3 | — |
| `gray-matter` | Markdown frontmatter | ✓ | 4.0.3 | — |
| `yaml` | YAML parsing | ✓ | 2.8.4 | — |
| `inkjs` | Ink runtime | ✓ | 2.4.0 | — |
| `inklecate` | Ink build-time compilation | ✓ | 1.8.1 (binaries shipped per platform) | — |
| `happy-dom` | Vitest DOM env | ✓ | 20.9.0 | — |
| `fake-indexeddb` | IDB tests | ✓ | 6.2.5 | — |
| `eslint` + `eslint-plugin-boundaries` | CORE-10 firewall | ✓ | 9.39.4 + 6.0.2 | — |
| **`zustand`** | State bridge (D-32) | ✗ | — | **Install in Phase 2** (`npm install zustand@^5.0.0`) |
| **`break_eternity.js`** | BigQty wrapper (D-31) | ✗ | — | **Install in Phase 2** (`npm install break_eternity.js@^2.1.3`) |
| `howler` | Audio (Phase 3 only) | ✗ | — | Phase 2 stubs `AudioContext.resume()` only (D-21); Phase 3 installs and wires Howler. |
| AudioContext API | Begin gesture (AEST-07) | ✓ (browser) | All evergreen + iOS Safari | None — handled gracefully in `bootstrapAudioContext()` (returns `null`). |
| `navigator.storage.persist()` API | CORE-05 (already shipped) | ✓ in modern browsers; ✗ in iOS Safari | — | Phase 1's `requestPersistence()` returns `apiAvailable: false` and the toast doesn't fire. |
| IndexedDB | Save primary (already shipped) | ✓ in all browsers; ✗ in private mode (some) | — | Phase 1's `LocalStorageDBAdapter` fallback. |
**Missing dependencies with no fallback:** None.
**Missing dependencies with fallback:** None blocking — Howler.js is intentionally Phase-3 (D-21).
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Vitest 4.1.5 + Playwright 1.59.1 |
| Config files | `vitest.config.ts` (env: happy-dom; pre-existing) + `playwright.config.ts` (pre-existing — first specs land Phase 2) |
| Quick run command | `npm test` (= `vitest run --passWithNoTests=false`) |
| Full suite command | `npm run ci` (= `lint + test + validate:assets + build`) |
| E2E run command | `npx playwright test` |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| CORE-02 | Sim runs deterministic fixed-timestep loop, advancing by elapsed real time | unit | `vitest run src/sim/scheduler/tick.test.ts` | Wave 0 |
| CORE-03 | Closing tab + returning advances by elapsed time, capped at 24h | unit | `vitest run src/sim/scheduler/catchup.test.ts` | Wave 0 |
| CORE-11 | Sim refuses negative deltas + clamps offline at 24h | unit | `vitest run src/sim/scheduler/catchup.test.ts -t 'refuses negative'` | Wave 0 |
| CORE-11 | Sim refuses negative deltas (integration with FakeClock) | integration | `vitest run src/sim/scheduler/clock.test.ts` | Wave 0 |
| GARD-01 | Plant a seed into unoccupied tile | unit | `vitest run src/sim/garden/commands.test.ts -t 'plantSeed'` | Wave 1A |
| GARD-01 | Click empty tile → seed picker appears at correct screen coords (DOM) | integration | `vitest run src/ui/garden/SeedPicker.test.tsx` | Wave 1A |
| GARD-02 | Plant advances sprout → mature → ready over time | unit | `vitest run src/sim/garden/growth.test.ts` | Wave 1A |
| GARD-02 | Growth state survives save round-trip + load | integration | `vitest run src/save/round-trip.test.ts -t 'plant growth state'` | Wave 1A (extends existing test) |
| GARD-03 | Harvest mature plant yields exactly one fragment, empties tile | unit | `vitest run src/sim/garden/commands.test.ts -t 'harvest'` | Wave 1B |
| GARD-04 | Compost immature plant + tonal beat | unit + UI | `vitest run src/sim/garden/commands.test.ts -t 'compost'` + `src/ui/garden/CompostBeat.test.tsx` | Wave 1B |
| MEMR-01 | Each harvest yields exactly one fragment from authored pool gated by Season + progression | unit | `vitest run src/sim/memory/selector.test.ts -t 'one fragment per harvest'` | Wave 1B |
| MEMR-02 | Fragments authored in `/content/seasons/01-soil/`, compiled at build | manual + CI | `npm run build` succeeds with Season-1 content present; loader test green | Wave 1B (extends `src/content/loader.test.ts`) |
| MEMR-03 | Stable string IDs (regex enforced) | unit | `vitest run src/content/loader.test.ts -t 'rejects numeric id'` (existing) | ✅ |
| MEMR-04 | Memory Journal lists every collected fragment, organized by Season | integration | `vitest run src/ui/journal/Journal.test.tsx` | Wave 1B |
| MEMR-05 | Player can read fragment in full; text is selectable + copy-pasteable (DOM, not canvas) | integration | `vitest run src/ui/journal/Journal.test.tsx -t 'selectable text'` | Wave 1B |
| MEMR-06 | Selector deterministic; respects gating; no duplicates within playthrough until exhausted | unit | `vitest run src/sim/memory/selector.test.ts -t 'no duplicates' + 'gating' + 'deterministic'` | Wave 1B |
| STRY-01 | Lura appears at gate during Season 1 with text-message-cadence dialogue authored in Ink | integration + e2e | `vitest run src/ui/dialogue/LuraDialogue.test.tsx` + Playwright e2e | Wave 2A |
| STRY-06 | All dialogue is `.ink` compiled to JSON | manual + CI | `npm run compile:ink` produces `src/content/compiled-ink/season1/lura-*.ink.json` | Wave 2A |
| STRY-07 | Keeper has no dialogue beyond final binary choice (Phase 7) | manual | Vacuously true; document in PLAN.md verification | ✅ (vacuous) |
| STRY-10 | Story progression gates on tick count, not wall time | unit | `vitest run src/sim/narrative/lura-gate.test.ts -t 'gates on harvest count'` + clock-cheat test (FakeClock advance ≠ beat advance) | Wave 2A |
| AEST-07 | First screen is "Begin" gesture gate that calls `AudioContext.resume()` | integration + e2e | `vitest run src/ui/begin/BeginScreen.test.tsx -t 'resume on tap'` + Playwright e2e | Wave 1A |
| UX-01 | Single Begin screen with no UI clutter; UI grows progressively | manual + integration | A Dark Room rule manual verification + journal-icon-hidden-pre-harvest test | Wave 1A + Wave 1B |
| UX-02 | Returning-player letter from the garden, in voice | integration | `vitest run src/ui/letter/Letter.test.tsx` | Wave 2B |
| UX-10 | Save on `visibilitychange` hidden, on `beforeunload`, on Season transitions | integration | `vitest run src/save/lifecycle.test.ts` (NEW) | Wave 0 (or extends existing round-trip) |
| UX-11 | Numbers display as 1.2K / 4.5M / scientific past threshold | unit | `vitest run src/sim/numbers/format.test.ts` | Wave 0 |
| PIPE-02 | Initial bundle excludes Seasons 2-7; per-Season lazy chunks | structural | `npm run build` + filesystem assertion (new `scripts/check-bundle-split.mjs`) | Wave 1B |
| PIPE-07 | E2E smoke: load → begin → plant → fast-forward → harvest → journal-shows-fragment → reload → fragment-persists | e2e | `npx playwright test tests/e2e/season1-loop.spec.ts` | Wave 2C |
### Sampling Rate
- **Per task commit:** `npm test` (Vitest unit + integration only; Playwright excluded for speed; current p50 ≈ ~5s based on Phase 1's 53-tests-in-12-files rate)
- **Per wave merge:** `npm run ci` (lint + test + validate:assets + build); manual `npx playwright test` for Wave 2C
- **Phase gate:** `npm run ci` green AND `npx playwright test` green before `/gsd-verify-work`
### Wave 0 Gaps
Wave 0 (the foundations plan) is the only plan that adds testing infrastructure beyond what Phase 1 shipped:
- [ ] `src/sim/scheduler/clock.test.ts` — FakeClock fixture + monotonic invariants
- [ ] `src/sim/scheduler/tick.test.ts` — accumulator drain math + tick rate
- [ ] `src/sim/scheduler/catchup.test.ts` — 24h cap + negative-delta refusal
- [ ] `src/sim/numbers/big-qty.test.ts` — arithmetic + comparison + serialization round-trip
- [ ] `src/sim/numbers/format.test.ts` — UX-11 thresholds (1.2K, 4.5M, etc.)
- [ ] `src/save/lifecycle.test.ts` — visibilitychange + beforeunload + Season transition save firing (UX-10)
- [ ] `src/store/store.test.ts` — slice composition + selectors
- [ ] `src/store/garden-slice.test.ts` — command queueing + apply-result semantics
Wave 1A (planting + growth + Begin) gaps:
- [ ] `src/sim/garden/commands.test.ts` — plantSeed, harvest, compost (pure functions)
- [ ] `src/sim/garden/growth.test.ts` — state machine
- [ ] `src/sim/garden/auto-harvest.test.ts` — D-10 offline branch
- [ ] `src/ui/begin/BeginScreen.test.tsx` — tap calls bootstrapAudioContext
- [ ] `src/ui/garden/SeedPicker.test.tsx` — popover positioning + dismiss
Wave 1B (memory + journal + fragments + content) gaps:
- [ ] `src/sim/memory/selector.test.ts` — deterministic + gating + no-dup + exhaustion
- [ ] `src/ui/journal/Journal.test.tsx` — list + select + copy
- [ ] `src/ui/journal/FragmentRevealModal.test.tsx` — D-25 reveal flow
- [ ] `src/content/loader.test.ts` extension — Season-1 fragments load via lazy path
- [ ] `scripts/check-bundle-split.mjs` — PIPE-02 structural test
Wave 2A (Lura beats) gaps:
- [ ] `src/sim/narrative/lura-gate.test.ts` — 1st/4th/8th gating + STRY-10 tick-count semantics
- [ ] `src/ui/dialogue/LuraDialogue.test.tsx` — Ink runtime integration + drip cadence
- [ ] `src/ui/dialogue/ink-runtime.test.ts` — variable wiring
Wave 2B (offline letter) gaps:
- [ ] `src/sim/offline/events.test.ts``OfflineEventBlock` schema + aggregator
- [ ] `src/ui/letter/Letter.test.tsx` — Ink template render + dismiss
- [ ] `src/ui/letter/letter-renderer.test.ts` — variable mapping table coverage
Wave 2C (e2e) gaps:
- [ ] `tests/e2e/season1-loop.spec.ts` — full loop smoke
- [ ] Playwright fast-forward mechanism (URL flag `?devtime=fake`, recommended) — implementation in `src/sim/scheduler/clock.ts` + boot wiring
**No framework install needed** — Vitest, Playwright, fake-indexeddb, happy-dom are all already on disk from Phase 1.
### Sim-Clock Injection for Playwright Fast-Forward
The Phase 2 e2e (PIPE-07) needs to fast-forward growth without waiting 25 minutes per plant. Three viable mechanisms:
| Mechanism | How | Pros | Cons |
|-----------|-----|------|------|
| **URL flag (`?devtime=fake`) + FakeClock injection** ⭐ | Boot reads `URLSearchParams`; if `devtime=fake`, the scheduler binds to a `FakeClock` instance exposed on `window.__tlgFakeClock`. Playwright calls `await page.evaluate(() => window.__tlgFakeClock.advance(5 * 60 * 1000))` to fast-forward 5 minutes. | Cleanly reusable in Vitest tests via the same FakeClock; no production code path differs; cleanly excluded from prod builds via build-time check. | URL flag must be respected only in dev/test contexts (Vite's `import.meta.env.DEV`). |
| Hidden dev hotkey | `?+fast` keyboard combo advances FakeClock | Useful for manual playtest. | Doesn't compose with Playwright; still need the URL flag. |
| Sim-tick-count manipulation | E2E test directly mutates `appStore.getState().lastTickAt` | Avoids the FakeClock entirely. | Bypasses the whole scheduler boundary; risk of false-positive test results if scheduler has bugs. |
**Recommendation:** Implement the URL flag + FakeClock injection (option ⭐). The FakeClock already exists in `src/sim/scheduler/clock.ts` for Vitest tests; the URL flag merely chooses the production wallClock vs the dev FakeClock at boot. Phase 2's PIPE-07 spec ships with this mechanism. Add a guard: in `import.meta.env.PROD` builds, the URL flag is silently ignored (the wallClock is always used).
## Security Domain
`security_enforcement` is not explicitly false in `.planning/config.json`, so this section is included.
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | no | No accounts in v1 (single-player local). |
| V3 Session Management | no | Same. |
| V4 Access Control | no | Same. |
| V5 Input Validation | yes | All save imports go through `SaveEnvelopeSchema` (Zod) and the 50MB cap (already shipped Phase 1). All Ink-runtime variable injection is type-checked at the JS boundary; no string concatenation into Ink source at runtime. |
| V6 Cryptography | partial (integrity only) | CRC-32 checksum on save envelopes (already shipped Phase 1) detects corruption, NOT adversarial tampering. Phase 2 inherits this policy — single-player save tampering is by-design acceptable per `src/save/envelope.ts` `SaveCorruptError` doc comment. |
| V7 Error Handling and Logging | partial | Toast surfaces persistence-result respectfully (D-30); no PII in logs (none collected in v1). |
| V8 Data Protection | yes | No telemetry in Phase 2; saves are local-only; Base64 export is user-initiated and contains no secrets. |
| V12 Files and Resources | yes | `validate-assets.mjs` already shipped Phase 1; no new asset surfaces in Phase 2 (D-26 = primitives only). |
### Known Threat Patterns for Phase 2 Stack
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Save tampering (player edits Roothold via DevTools) | Tampering | **Accepted** — single-player; CRC-32 detects accidental corruption only. Documented in `src/save/envelope.ts` (already Phase 1). |
| Malformed Base64 import (DoS via giant inflated string) | Denial-of-service | 50MB cap before lz-string decompression (already Phase 1). |
| System-clock manipulation to skip Lura beats | Tampering | Beats gate on tick count (harvest events), not wall time (STRY-10). FakeClock advance ≠ beat advance unless harvests also fire. |
| `Date.now()` returns negative delta (clock-rewind cheat) | Tampering | Scheduler refuses negative deltas (CORE-11); state does not advance; logged once. |
| AudioContext blocked → muted experience that misleads about audio failures | Information disclosure (sort of) / UX | Bootstrap function returns `null` gracefully; Phase 2 has no audio anyway. |
| Cross-origin script injection via Ink content | XSS | Ink content is repo-controlled (no user-authored Ink); inkjs renders to string and React renders strings, not HTML. No `dangerouslySetInnerHTML`. |
| Storage eviction silently wiping save | Tampering / data loss | `navigator.storage.persist()` request (CORE-05, already Phase 1) + LocalStorage fallback + Base64 export; soft toast respects user agency (D-30). |
## Project Constraints (from CLAUDE.md)
These directives have the same authority as locked decisions and constrain Phase 2 plans:
- **Stack is locked, do not re-litigate:** Phaser 4, React 19, Zustand 5, break_eternity.js (via BigQty), Ink + inkjs, Howler.js, IndexedDB + LocalStorage + lz-string + versioned schema + Base64 export, Markdown+frontmatter / YAML / .ink in `/content/`, Vitest + Playwright. Phase 2 plans must use these; alternatives are out of scope unless explicitly requested.
- **Architectural firewall (load-bearing):** Phaser owns the canvas; React 19 owns the UI shell; Zustand bridges them; simulation core (`src/sim/`) does not import from `src/render/` or `src/ui/`. Enforced by `eslint.config.js` `boundaries/element-types` rule. Plans that introduce sim→render or sim→ui edges fail CI.
- **TypeScript strict; no `any` in production code.** Phase 2 sim code is fully typed; Zod schemas produce inferred types throughout.
- **Player-visible strings are externalized in `/content/`, never hardcoded.** Lura's lines, the letter, the Begin copy, the post-harvest acknowledgement, the persistence-denied toast, the journal empty-state — all in `/content/`.
- **Memory fragment IDs are stable strings (`season3.canopy.lura_07.vignette`), never numeric.** Already enforced by `FragmentSchema` regex.
- **Simulation modules are pure** — no `Date.now()`, no `setInterval`, no DOM, no fetch. **Inject time as a parameter; the tick scheduler owns wall-clock access.** This is exactly D-33 + Pattern 1.
- **BigNumbers go through the typed `BigQty` wrapper around `break_eternity.js`. Never raw `Decimal` values in app code.** This is exactly D-31 + Pattern 2.
- **Save format always carries `{schemaVersion, payload, checksum}`. Never serialize raw state.** Already shipped by `src/save/envelope.ts` Phase 1.
- **New AI-generated assets must carry full provenance metadata and pass the curation gate.** Phase 2 ships zero AI-generated assets (D-26 = primitives), so the gate is not exercised; remains green.
- **Anti-FOMO doctrine** must be consulted at every UX decision. Phase 2's UX decisions all comply: no daily login bonus, no streaks, no limited-time content, no nag notifications, no loss-aversion copy, no countdown timers in core UI, persistence-denied is a soft in-voice toast (not nag).
- **Banner concerns 110** (CLAUDE.md): all carry forward. Banner concern #1 (story ends but loop doesn't) is constrained by Phase 2's growth/economy choices not foreclosing Season 7's finite ceiling — confirmed by Pattern 7 (`V1Payload` extension, no Roothold pre-allocation; Phase 4 owns the prestige machinery).
- **Tone:** Player-facing copy "warm, specific, intermittent, sometimes funny, sometimes devastating." Lura is the warmth anchor — write her as the contrast, not a co-griever.
- **GSD config:** Mode=YOLO (auto-approve gates), Granularity=Standard (58 phases), Plans run in parallel within a phase, Quality model profile, research+plan-check+verifier all on, `nyquist_validation: true`. Plans use the Validation Architecture section above.
## MVP Slice Proposal
Phase 2's mode is `mvp` (vertical-slice planning). Each plan should deliver an end-to-end thin slice rather than a horizontal layer. Recommended **5 plans across 3 waves**:
### Wave 0: Foundations (1 plan, blocks everything else)
**Plan 02-01 — Foundations: BigQty + Zustand store + Tick scheduler**
- Install `zustand@^5` and `break_eternity.js@^2.1.3`.
- Author `src/sim/numbers/big-qty.ts` + `format.ts` with full Vitest coverage (UX-11 surface).
- Author `src/sim/scheduler/clock.ts`, `tick.ts`, `catchup.ts` with Vitest coverage (CORE-02, CORE-03, CORE-11). Add the no-`Date.now`-in-sim ESLint rule.
- Author `src/store/store.ts` + four slices (garden, memory, narrative, session) with Vitest tests of slice composition + selectors.
- Extend `V1Payload` per D-34 (D-19's `offlineEvents`, `unlockedPlantTypes`, `luraBeatProgress`, `settings.persistenceToastShown`).
- Add `src/save/lifecycle.test.ts` covering UX-10 trigger points.
- Wire the scheduler into `Garden` scene (tiny placeholder scene) so the live tick boots end-to-end.
- Adds `src/game/event-bus.ts` for the Phaser EventBus singleton (per Phaser 4 template pattern).
- Verifies `npm run ci` green.
**Output: A running scaffold where the sim ticks, the store updates, the save schema is extended, and the firewall holds.** Nothing player-visible yet — but Wave 1 plans can build features without re-running Wave 0's infrastructure.
### Wave 1: Two parallel vertical slices (2 plans)
**Plan 02-02 — Vertical slice: Begin + Plant + Grow**
- Authors Season-1 plant types (23) in `src/sim/garden/plants.ts` with durations + tonal identity.
- Implements `src/sim/garden/{types,growth,commands}.ts` (plantSeed + grow).
- Implements `src/render/garden/{tile-renderer,plant-renderer,ready-pulse,tile-coords}.ts`.
- Implements `src/ui/begin/{BeginScreen,use-audio-bootstrap}.ts` (D-21, D-22, AEST-07).
- Implements `src/ui/garden/SeedPicker.tsx` (D-02 inline popover).
- Wires Phaser's tile pointerdown → EventBus → React popover → command dispatch.
- Implements first-interaction gesture handler for returning players (D-22).
- Tests: vitest unit + integration (Begin tap calls resume; tile click positions popover; plant grows through stages).
- E2E manual smoke: launch → press Begin → click empty tile → seed picker → place seed → watch sprout primitive appear → fast-forward via FakeClock.
- Satisfies: AEST-07, UX-01, GARD-01, GARD-02 (partial — full save round-trip in Plan 02-04).
**Plan 02-03 — Vertical slice: Harvest + Journal + Compost + Fragments**
- Author Season-1 fragments (≥10) in `/content/seasons/01-soil/fragments.yaml` and a few long-form `.md` per the existing convention.
- Implements `src/sim/memory/{selector,pool}.ts` (MEMR-06).
- Implements `src/sim/garden/commands.ts` `harvest` + `compost` (GARD-03, GARD-04).
- Implements `src/ui/journal/{Journal,FragmentRevealModal,journal-icon}.tsx` (MEMR-04, MEMR-05, D-23, D-24, D-25).
- Implements GARD-04 compost tonal beat (Ink-authored line per D-07 model — see Open Question 2).
- Implements PIPE-02 lazy-loading wiring in `src/content/loader.ts` and `scripts/check-bundle-split.mjs`.
- Tests: vitest unit (selector deterministic / gated / no-dup) + integration (Journal renders fragments; selectable text; reveal modal).
- E2E manual smoke: launch → plant → fast-forward → harvest → reveal modal → close → journal shows it.
- Satisfies: GARD-03, GARD-04, MEMR-01, MEMR-02, MEMR-04, MEMR-05, MEMR-06, PIPE-02, UX-01 (Journal-icon-on-first-harvest reveal), UX-11 (number formatting in journal counts).
**Wave 1 plans run in parallel.** Plan 02-02 and Plan 02-03 share `src/sim/garden/types.ts` (small surface, lock in Wave 0) and Plan 02-03 depends on Plan 02-02 only for the harvest action wiring; both can be drafted simultaneously and merged with a small integration moment.
### Wave 2: Two parallel vertical slices + e2e (3 plans)
**Plan 02-04 — Vertical slice: Lura's Three Beats**
- Replaces `npm run compile:ink` no-op stub with `scripts/compile-ink.mjs` invoking the `inklecate` package.
- Authors `/content/dialogue/season1/lura-{arrival,mid,farewell}.ink` (Ink files).
- Implements `src/sim/narrative/{lura-gate,beat-queue}.ts` (D-13, D-14, STRY-10).
- Implements `src/ui/dialogue/{LuraDialogue,ink-runtime,ink-renderer}.tsx` (D-15, D-16, STRY-01, STRY-06).
- Implements `src/render/garden/gate-renderer.ts` (gate visual + indicator on pending beat).
- Tests: vitest unit (gate fires at 1st/4th/8th harvest; doesn't fire on FakeClock advance alone — STRY-10) + integration (Ink runtime drives drip cadence).
- E2E manual smoke: harvest 1st fragment → gate glows → click gate → Lura arrival dialogue → continue.
- Satisfies: STRY-01, STRY-06, STRY-07 (vacuous), STRY-10.
**Plan 02-05 — Vertical slice: Letter from the Garden + Settings + Save Lifecycle Hooks + Playwright E2E (PIPE-07)**
- Authors `/content/dialogue/season1/letter-from-the-garden.ink` (D-17, D-18) with the slot vocabulary from D-19.
- Implements `src/sim/offline/events.ts` (`OfflineEventBlock` zod schema + aggregator from sim's silent-mode events).
- Implements `src/ui/letter/{Letter,letter-renderer}.tsx` (D-20).
- Implements `src/ui/settings/{Settings,persistence-toast}.tsx` (D-28, D-29, D-30).
- Wires save-lifecycle hooks (visibilitychange, beforeunload, Season transition stub) per UX-10.
- Implements `src/sim/garden/auto-harvest.ts` (D-10).
- Implements URL-flag `?devtime=fake` FakeClock injection at boot for Playwright.
- Authors `tests/e2e/season1-loop.spec.ts` (PIPE-07 full smoke).
- Tests: vitest unit + integration (letter renders Ink slots; settings export round-trips; save fires on visibility change; URL-flag injects FakeClock); Playwright e2e green.
- Satisfies: UX-02, UX-10, PIPE-07 + finishes any GARD-02-style save-survival assertions left dangling.
**Wave 2 plans 02-04 and 02-05 run in parallel.** Plan 02-05 depends on plan 02-04's `src/sim/narrative/` only for the `lura_was_here` slot; plan 02-04 doesn't depend on 02-05. Both depend on Wave 1 work being merged. Plan 02-05 owns Playwright PIPE-07.
### Why this shape
- **Wave 0 lands the three foundations together** because they're tightly coupled (the scheduler updates the store; BigQty values flow through state; the firewall rule enforces the boundary). Splitting them risks circular blocking.
- **Wave 1's two slices each touch sim + store + render + ui** — true vertical slices, not horizontal layers. Plan 02-02's smoke test (plant a seed, see it grow) is end-to-end. Plan 02-03's smoke test (harvest, see fragment in journal) is end-to-end.
- **Wave 2 isolates the heaviest authored content (Lura's Ink + the letter) into the latest plans** so the writer's work integrates against working mechanics, not against speculative shapes.
- **PIPE-07 lives in the last plan** because it asserts the full loop. Earlier-plan e2e tests would assert partial loops — fragile.
- **Each plan ships `npm run ci` green standalone.** No plan leaves CI red mid-phase.
## Sources
### Primary (HIGH confidence)
- `.planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md` — User decisions D-01 through D-34.
- `.planning/REQUIREMENTS.md` — 24 Phase 2 REQ-IDs verbatim.
- `.planning/PROJECT.md` — story bible synthesis, hard thematic constraints.
- `.planning/ROADMAP.md` — Phase 2 success criteria (5).
- `.planning/STATE.md` — Phase 1 verification table (16/16 PASS).
- `.planning/anti-fomo-doctrine.md` — 17 banned UX patterns + review checklist.
- `.planning/season-7-end-state.md` — principle-level rest-state contract.
- `.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md` — Phase 1 D-01..D-12 (save format, content pipeline, firewall locks).
- `.planning/research/STACK.md` — locked stack rationale, version compatibility, content pipeline shape.
- `.planning/research/ARCHITECTURE.md` — three-layer firewall, tick scheduler shape, six architectural patterns.
- `.planning/research/PITFALLS.md` — 14 critical pitfalls (esp. #1, #4, #6, #7, #11, #12).
- `CLAUDE.md` — stack lock, architectural firewall, banner concerns 110, code style.
- `src/save/index.ts`, `src/save/migrations.ts`, `src/save/envelope.ts`, `src/save/codec.ts`, `src/save/db.ts`, `src/save/db-localstorage-adapter.ts`, `src/save/persist.ts`, `src/save/snapshots.ts` — Phase 1 save layer (frozen).
- `src/content/index.ts`, `src/content/loader.ts`, `src/content/schemas/{fragment,season,index}.ts` — Phase 1 content pipeline.
- `src/game/main.ts`, `src/game/scenes/Boot.ts` — Phaser entry.
- `src/App.tsx`, `src/PhaserGame.tsx` — React shell + Phaser bridge.
- `eslint.config.js` — flat config + `eslint-plugin-boundaries` element types.
- `package.json` — exact dependencies + scripts (verified 2026-05-09).
- `vitest.config.ts`, `playwright.config.ts`, `vite.config.ts` — test + build infrastructure.
- `content/README.md` — content authoring conventions.
- `content/seasons/00-demo/fragments.yaml` — demo to be removed in Phase 2.
- `node_modules/inkjs/ink.d.mts` + `node_modules/inkjs/README.md` — inkjs API verification.
- `node_modules/inklecate/README.md` + `node_modules/inklecate/package.json` — inklecate npm wrapper API.
### Secondary (MEDIUM confidence — verified via WebSearch + cross-referenced with Primary)
- [Phaser 4 React template EventBus pattern (May 2025 announcement, prepared for Phaser 4)](https://phaser.io/news/2025/05/bringing-our-phaserjs-templates-into-the-future) — cross-referenced with `src/PhaserGame.tsx`'s template structure.
- [Phaser input documentation — pointerdown on interactive game objects](https://docs.phaser.io/phaser/concepts/input)
- [Phaser EventEmitter](https://docs.phaser.io/api-documentation/class/events-eventemitter)
- [Zustand 5 createStore (vanilla)](https://zustand.docs.pmnd.rs/reference/apis/create-store)
- [Zustand vanilla store usage outside React](https://github.com/pmndrs/zustand/discussions/1866)
- [Vite import.meta.glob lazy code-splitting](https://vite.dev/guide/features) — confirmed default-lazy when `eager` is omitted/false.
- [Web Audio Autoplay Policy + AudioContext.resume()](https://developer.chrome.com/blog/web-audio-autoplay)
- [MDN Autoplay guide](https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Autoplay)
- [Fix Your Timestep — Gaffer On Games](https://gafferongames.com/post/fix_your_timestep/)
- [break_eternity.js (Patashu)](https://github.com/Patashu/break_eternity.js) — TypeScript-native, ships `index.d.ts`.
- [inkjs README — getting/setting variables](https://github.com/y-lohse/inkjs)
- npm registry verification (`npm view <pkg> version`) for phaser, zustand, break_eternity.js, inkjs, howler, inklecate (2026-05-09).
### Tertiary (LOW confidence — flagged for verification during planning)
- The exact tile→DOM coordinate mapping under `Phaser.Scale.FIT` letterboxing (Assumption A5) — verify in Plan 02-02.
- The `inklecate` npm wrapper's behavior on Windows with the `inputFilepath`/`outputFilepath` argument shape — verify with one real compile in Plan 02-04 (Assumption A6).
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — entire stack already locked + installed + verified.
- Architecture: HIGH — patterns are documented in `.planning/research/ARCHITECTURE.md` and align with the existing scaffolded structure in `eslint.config.js`.
- Tick scheduler: HIGH — canonical pattern from Gaffer On Games + research doc; one degree of design freedom (tick rate) is justified at 5Hz.
- BigQty: HIGH — `break_eternity.js` is TypeScript-native + drop-in; the wrapper surface is small and obvious.
- Zustand-Phaser bridge: HIGH — exact pattern is the official template's; `src/PhaserGame.tsx` is already shaped for it.
- Ink integration: MEDIUM — `inkjs` and `inklecate` are installed and documented; the variable-substitution pattern is verified, but the inklecate Windows-binary invocation needs one real run in Plan 02-04.
- Letter slot vocabulary: MEDIUM — three slots are ample for D-19's contract, but playtest may want more (acknowledged in Open Question 5 and Claude's Discretion).
- Pitfalls: HIGH — derived from `.planning/research/PITFALLS.md` (14 documented + 2 phase-specific surfacings) plus implementation specifics.
- Validation Architecture: HIGH — every requirement maps to a concrete test file; Wave-0 gaps explicit.
**Research date:** 2026-05-09
**Valid until:** 2026-06-09 (30 days; stack is stable, no rapidly-evolving deps in Phase 2 surface)
## REQ-ID Coverage Map
| REQ-ID | Requirement | Sections in this RESEARCH.md addressing it |
|--------|-------------|-------------------------------------------|
| CORE-02 | Deterministic, fixed-timestep sim advancing by elapsed real time | Pattern 1 (Tick Scheduler / Monotonic Clock); Architectural Responsibility Map row "Tick scheduler"; Validation Architecture row CORE-02 |
| CORE-03 | Closed/returned game progresses by elapsed time, capped at 24h | Pattern 1; Boot path subsection; Validation Architecture row CORE-03 |
| CORE-11 | Sim refuses negative deltas + caps offline at 24h | Pattern 1 (`drainTicks` rejects `accumulatorMs < 0`); Pitfall 1 + Common Pitfall 1; Security Domain row "system-clock manipulation"; Validation Architecture row CORE-11 |
| GARD-01 | Plant a seed into unoccupied tile | Pattern 4 (Inline Seed Picker); Architectural Responsibility Map; MVP Slice Plan 02-02; Validation Architecture row GARD-01 |
| GARD-02 | Visible growth state, advances over time, persists across save | Pattern 1 (sim ticks growth); Pattern 7 (V1Payload extension stores plant state); Architectural Responsibility Map; MVP Slice Plan 02-02 |
| GARD-03 | Harvest mature plant → exactly one fragment; tile empties | MVP Slice Plan 02-03; Validation Architecture row GARD-03 |
| GARD-04 | Compost immature plant + tonal beat | MVP Slice Plan 02-03; Open Question 2; Validation Architecture row GARD-04 |
| MEMR-01 | Each harvest yields exactly one fragment from gated authored pool | Pattern (Fragment Selector — sim/memory module); MVP Slice Plan 02-03 |
| MEMR-02 | Fragments authored in `/content/`, compiled per-Season at build | Already shipped Phase 1; Phase 2 drops Season-1 files (Pattern 8 lazy split); MVP Slice Plan 02-03 |
| MEMR-03 | Stable string IDs (regex enforced) | Already enforced by `FragmentSchema`; Common Pitfall — numeric IDs; MVP Slice Plan 02-03 |
| MEMR-04 | Memory Journal lists fragments by Season | Architectural Responsibility Map row "Memory Journal"; MVP Slice Plan 02-03 |
| MEMR-05 | Selectable + copy-pasteable fragment text (DOM, not canvas) | Architectural Responsibility Map; Anti-Patterns ("Rendering Memory Journal text inside Phaser"); MVP Slice Plan 02-03 |
| MEMR-06 | Deterministic selector with gating + no-dup until exhaustion | Pattern (Fragment Selector); Common Pitfall 8 (exhaustion); MVP Slice Plan 02-03 |
| STRY-01 | Lura at gate during S1 with text-message-cadence Ink dialogue | Pattern 5 (Ink Compilation + Runtime); Architectural Responsibility Map; MVP Slice Plan 02-04 |
| STRY-06 | All dialogue is `.ink` compiled to JSON | Pattern 5; MVP Slice Plan 02-04 (compile-ink.mjs) |
| STRY-07 | Keeper has no dialogue beyond final binary choice (Phase 7) | Vacuously satisfied; documented in Phase Requirements table + Validation Architecture |
| STRY-10 | Story progression gates on tick count, not wall time | Pattern 1 + Lura beat gating in `src/sim/narrative/`; Common Pitfall (tick-count gating); MVP Slice Plan 02-04 |
| AEST-07 | First screen "Begin" gesture gate, calls `AudioContext.resume()` | Pattern 9 (AudioContext.resume() Bootstrap); Common Pitfall 5 (iOS Safari); Common Pitfall 9 (letter dismiss); MVP Slice Plan 02-02 |
| UX-01 | Single Begin screen, no clutter; UI grows progressively | D-21, D-22, D-23 in User Constraints; A Dark Room rule applied across slices; MVP Slice Plan 02-02 (Begin) + Plan 02-03 (Journal reveals) |
| UX-02 | Returning-player letter from the garden, in voice | Pattern 6 (Letter as Ink Template); MVP Slice Plan 02-05 |
| UX-10 | Save on visibilitychange/beforeunload/Season transition | Pattern 7 (V1Payload extension); Common Pitfall 7 (beforeunload synchronous path); Open Question 3 (Season-transition hook is dormant Phase 2); MVP Slice Plan 02-05 |
| UX-11 | Numbers as 1.2K / 4.5M / scientific past threshold | Pattern 2 (BigQty `format()` + `formatHumanReadable`); MVP Slice Plan 02-01 |
| PIPE-02 | Player loads only current Season; future Seasons not in initial bundle | Pattern 8 (Per-Season Lazy Loading); MVP Slice Plan 02-03 |
| PIPE-07 | Playwright e2e smoke covering full loop | Validation Architecture (Sim-Clock Injection); MVP Slice Plan 02-05 |
**All 24 REQ-IDs are addressed.** Each maps to at least one architectural pattern, one or more files in the proposed structure, and at least one test in the Validation Architecture.
---
*Research date: 2026-05-09*
*Phase: 2 — Season 1 Vertical Slice (Soil)*