diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 07530e3..49ddcdd 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -55,7 +55,13 @@ Plans: 3. Player can compost an immature plant and receive a tonal beat acknowledging the choice to let go; the deterministic fragment selector never duplicates a fragment within a playthrough until the pool is exhausted, respects authored Season/story-state gating, and Lura appears at the garden gate with text-message-cadence dialogue authored in Ink and compiled to JSON. 4. Player who closes the tab and returns up to 24 hours later finds the garden has progressed by elapsed real time (not `setInterval` ticks), with the simulation refusing negative deltas and capping any single offline catch-up at 24 hours; the return screen is a *letter from the garden* (not a stat dump) describing what bloomed and what Lura said, and saves fire correctly on `visibilitychange` to hidden, on `beforeunload`, and on Season transitions. 5. A Playwright e2e smoke test passes: it loads the game, dismisses the begin gate, plants a seed, fast-forwards growth, harvests a fragment, verifies the fragment text appears in the journal, refreshes the page, and verifies the harvested fragment persists. Story progression gates on tick count (not wall time), so manipulating the system clock cannot fast-forward through Lura's authored beats. -**Plans**: TBD +**Plans:** 5 plans +Plans: +- [ ] 02-01-foundations-PLAN.md — BigQty + Zustand 5 store + tick scheduler + V1Payload extension + save lifecycle hooks + Phaser EventBus singleton + ESLint sim-purity rule (Wave 0; foundations every other Phase-2 plan depends on) +- [ ] 02-02-begin-plant-grow-PLAN.md — sim/garden core (4×4 grid, 3 plant types, growth state machine, plantSeed) + render layer (Phaser primitives, ready-pulse, tile-coords) + BeginScreen + audio bootstrap + SeedPicker + UI strings (Wave 1; AEST-07, UX-01, GARD-01, GARD-02) +- [ ] 02-03-harvest-journal-fragments-PLAN.md — Season-1 ≥10 authored fragments + sim/memory selector (deterministic, gated, no-dup, exhaustion) + harvest + compost + Memory Journal + FragmentRevealModal + JournalIcon + PIPE-02 structural verification (Wave 1; GARD-03, GARD-04, MEMR-01..06, PIPE-02) +- [ ] 02-04-lura-gate-beats-PLAN.md — inklecate compile pipeline + 4 authored .ink files (3 Lura beats + compost acknowledgements) + sim/narrative tick-count gate (1st/4th/8th harvest) + LuraDialogue overlay + InkRenderer drip + Phaser gate visual indicator (Wave 2; STRY-01, STRY-06, STRY-07 vacuous, STRY-10) +- [ ] 02-05-letter-settings-e2e-PLAN.md — sim/offline + auto-harvest + letter Ink + Letter overlay + Settings (Export/Import/Restore) + persistence-toast + boot-path save lifecycle wiring + URL-flag FakeClock injection + Playwright PIPE-07 e2e (Wave 2; UX-02, UX-10, CORE-03, CORE-11, PIPE-07) **UI hint**: yes ### Phase 3: Watercolor & Cello Aesthetic diff --git a/.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-PLAN.md b/.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-PLAN.md new file mode 100644 index 0000000..5139582 --- /dev/null +++ b/.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-PLAN.md @@ -0,0 +1,1144 @@ +--- +phase: 02 +plan: 01 +type: execute +wave: 0 +depends_on: [] +files_modified: + - package.json + - src/sim/numbers/big-qty.ts + - src/sim/numbers/big-qty.test.ts + - src/sim/numbers/format.ts + - src/sim/numbers/format.test.ts + - src/sim/numbers/index.ts + - src/sim/scheduler/clock.ts + - src/sim/scheduler/clock.test.ts + - src/sim/scheduler/tick.ts + - src/sim/scheduler/tick.test.ts + - src/sim/scheduler/catchup.ts + - src/sim/scheduler/catchup.test.ts + - src/sim/scheduler/index.ts + - src/sim/state.ts + - src/sim/index.ts + - src/store/garden-slice.ts + - src/store/memory-slice.ts + - src/store/narrative-slice.ts + - src/store/session-slice.ts + - src/store/store.ts + - src/store/store.test.ts + - src/store/selectors.ts + - src/store/sim-adapter.ts + - src/store/index.ts + - src/save/migrations.ts + - src/save/migrations.test.ts + - src/save/index.ts + - src/save/lifecycle.ts + - src/save/lifecycle.test.ts + - eslint.config.js + - src/sim/__test_violation__/date-now-violator.ts + - src/sim/__test_violation__/lint-firewall.test.ts + - src/game/event-bus.ts +autonomous: true +requirements: [CORE-02, CORE-03, CORE-11, UX-10, UX-11] +tags: [foundations, scheduler, big-qty, zustand, save-extension, mvp] + +must_haves: + truths: + - "BigQty wraps break_eternity.js — every arithmetic op returns a NEW BigQty (immutable); add/sub/mul/div/eq/gte/gt/lt/lte work; toJSON()/fromJSON(s) round-trip the canonical Decimal string" + - "formatHumanReadable produces '1.2K' / '4.5M' / '8.9B' / '1.0T' / scientific past 1e15 (UX-11)" + - "src/sim/scheduler/clock.ts is the ONLY file in the project that calls Date.now(); a FakeClock test fixture lets sim tests advance time deterministically" + - "drainTicks(state, accumulatorMs<0) returns the original state with ticksApplied=0 (CORE-11 negative-delta refusal)" + - "drainTicks(state, 25*3600*1000) clamps to floor(MAX_OFFLINE_MS / TICK_MS) ticks (CORE-03 24h cap)" + - "drainTicks at TICK_MS=200ms over 24h completes ≤500ms on a modern machine (Vitest benchmark)" + - "Zustand 5 vanilla createStore composes 4 slices (garden / memory / narrative / session); useAppStore hook re-renders on selector changes; getState() works without React" + - "simAdapter (in src/store/) exposes applySimResult(next, events) and drainCommands(); src/sim/ never imports src/store/" + - "V1Payload extension adds unlockedPlantTypes, luraBeatProgress, offlineEvents, settings.persistenceToastShown — CURRENT_SCHEMA_VERSION stays at 1; no migrations[2] entry exists" + - "migrations[1] (the v0→v1 demo) returns a fully-populated V1Payload including all new fields with sensible defaults" + - "save lifecycle hook fires synchronously on visibilitychange→hidden, on beforeunload, AND on saveOnSeasonTransition() invocation (UX-10); Vitest exercises all three triggers" + - "Phaser EventBus singleton (src/game/event-bus.ts) exports `eventBus = new Phaser.Events.EventEmitter()` per Phaser 4 official template" + - "ESLint extension: any new src/sim/** file calling Date.now() (except src/sim/scheduler/clock.ts) fails lint with rule id 'no-restricted-syntax'; deliberate-violation fixture proves it" + - "npm run ci is green at end of plan" + artifacts: + - path: src/sim/numbers/big-qty.ts + provides: "BigQty immutable wrapper around break_eternity.js Decimal (D-31)" + exports: ["BigQty"] + - path: src/sim/numbers/format.ts + provides: "formatHumanReadable(d) — UX-11 K/M/B/T/scientific" + exports: ["formatHumanReadable"] + - path: src/sim/numbers/index.ts + provides: "Public barrel for sim/numbers" + - path: src/sim/scheduler/clock.ts + provides: "Clock interface, wallClock implementation, FakeClock test fixture (D-33). The ONLY file in src/sim/ allowed to call Date.now()." + exports: ["Clock", "wallClock", "FakeClock"] + - path: src/sim/scheduler/tick.ts + provides: "TICK_MS=200, MAX_OFFLINE_MS, drainTicks(state, accumulatorMs, silent) — fixed-timestep accumulator (CORE-02)" + exports: ["TICK_MS", "MAX_OFFLINE_MS", "drainTicks"] + - path: src/sim/scheduler/catchup.ts + provides: "computeOfflineCatchup(savedLastTickAt, nowMs) — clamps to 24h, refuses negative (CORE-03, CORE-11)" + exports: ["computeOfflineCatchup"] + - path: src/sim/state.ts + provides: "SimState root shape — structural mirror of V1Payload" + exports: ["SimState"] + - path: src/store/store.ts + provides: "appStore (zustand/vanilla createStore) composing 4 slices, useAppStore hook" + exports: ["appStore", "useAppStore", "AppStoreShape"] + - path: src/store/sim-adapter.ts + provides: "simAdapter — applySimResult / drainCommands; sim never imports this" + exports: ["simAdapter"] + - path: src/save/migrations.ts + provides: "Phase-2-extended V1Payload (D-34) with unlockedPlantTypes, luraBeatProgress, offlineEvents, settings.persistenceToastShown" + exports: ["migrate", "migrations", "CURRENT_SCHEMA_VERSION", "V1Payload"] + - path: src/save/lifecycle.ts + provides: "registerSaveLifecycleHooks() — visibilitychange + beforeunload listeners; saveOnSeasonTransition(state) standalone callable (UX-10)" + exports: ["registerSaveLifecycleHooks", "saveOnSeasonTransition"] + - path: src/game/event-bus.ts + provides: "Phaser.Events.EventEmitter singleton per Phaser 4 React-template pattern" + exports: ["eventBus"] + key_links: + - from: src/sim/scheduler/tick.ts + to: src/sim/scheduler/clock.ts + via: "import type { Clock } from './clock' — tick takes time as injected argument; never reads Date.now itself" + pattern: "import type \\{ Clock \\}" + - from: src/store/sim-adapter.ts + to: src/store/store.ts + via: "appStore.setState({...}) and appStore.getState()" + pattern: "appStore\\.(setState|getState)" + - from: src/save/migrations.ts + to: "extended V1Payload" + via: "interface V1Payload includes unlockedPlantTypes, luraBeatProgress, offlineEvents, settings.persistenceToastShown" + pattern: "luraBeatProgress|offlineEvents|unlockedPlantTypes|persistenceToastShown" + - from: eslint.config.js + to: src/sim/scheduler/clock.ts + via: "no-restricted-syntax rule excludes clock.ts; bans Date.now() everywhere else under src/sim/**" + pattern: "no-restricted-syntax" +--- + + +**Wave 0. Foundations plan. Blocks every other Phase 2 plan.** + +This plan lands the three deferred "day-one of feature code" foundations from CLAUDE.md (BigQty, Zustand store, tick scheduler), extends the save schema in place per D-34, wires save lifecycle hooks (UX-10), and seeds the Phaser EventBus singleton. No vertical-slice features here — but every Wave-1+ plan can build on this without re-running infrastructure work. + +3 tasks. Estimated context cost ~45%. If executor context fills mid-plan, `/clear` is safe between tasks (each commits independently). + + + +Land the three Phase-2 foundations (BigQty wrapper around `break_eternity.js`, Zustand 5 vanilla store + 4 slice files + slim sim adapter, tick scheduler / monotonic clock with negative-delta refusal + 24h offline cap), extend `V1Payload` in place per D-34 (no `migrations[2]`), wire save lifecycle hooks for UX-10 (visibilitychange + beforeunload + Season transition), seed the Phaser EventBus singleton per the Phaser 4 React-template pattern, and (defended option) add an ESLint `no-restricted-syntax` rule banning `Date.now()` and `setInterval` inside `src/sim/**` except `src/sim/scheduler/clock.ts`. + +Purpose: Wave 1 + Wave 2 plans build vertical slices on top of these foundations. Splitting them risks circular blocking (the scheduler updates the store; BigQty values flow through state; the firewall rule enforces the boundary). All Phase-2 sim modules will inject the Clock; all economic values flow through BigQty; React UI reads via `useAppStore`; saves carry the new fields. + +Output: Running scaffold where the sim ticks (against a placeholder no-op simulate), the store updates, the save schema is extended with safe defaults, the firewall holds, the EventBus singleton is exported, and the ESLint sim-purity rule has a green deliberate-violation test. Nothing player-visible yet. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@CLAUDE.md +@.planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md +@.planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md +@.planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md +@.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md +@.planning/phases/01-foundations-and-doctrine/01-03-save-layer-SUMMARY.md +@.planning/phases/01-foundations-and-doctrine/01-02-eslint-firewall-SUMMARY.md + + + + +From src/save/index.ts (Phase 1, frozen barrel — Phase 2 imports ONLY from this file): +```typescript +export { wrap, unwrap, SaveCorruptError, SaveEnvelopeSchema } from './envelope'; +export type { SaveEnvelope } from './envelope'; +export { migrate, CURRENT_SCHEMA_VERSION, migrations } from './migrations'; +export type { V1Payload } from './migrations'; // <-- this plan extends it +export { snapshot, listSnapshots } from './snapshots'; +export type { SnapshotEntry } from './snapshots'; +export { requestPersistence } from './persist'; +export type { PersistResult } from './persist'; +export { exportToBase64, importFromBase64, MAX_IMPORT_BYTES } from './codec'; +export { openSaveDB, SAVE_DB_NAME } from './db'; +export type { SaveDB, SaveDBSchema, SavedRecord, SnapshotRecord, SaveStoreName, SaveObjectStore, SaveTransaction } from './db'; +export { LocalStorageDBAdapter } from './db-localstorage-adapter'; +export type { StoreName, RecordOf } from './db-localstorage-adapter'; +export { crc32hex, canonicalJSON } from './checksum'; +``` + +Current V1Payload (src/save/migrations.ts) — Phase 1 shape that Phase 2 extends in place: +```typescript +export interface V1Payload { + garden: { tiles: unknown[] }; + plants: unknown[]; + harvestedFragmentIds: string[]; + lastTickAt: number; + settings: { + musicVolume: number; + ambientVolume: number; + sfxVolume: number; + }; +} +``` + +Current migrations[1] body (Phase 2 extends to populate new field defaults): +```typescript +1: (s: unknown): V1Payload => { + const v0 = (s ?? {}) as V0Payload; + return { + garden: { tiles: v0.garden ?? [] }, + plants: [], + harvestedFragmentIds: [], + lastTickAt: Date.now(), + settings: { musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8 }, + }; +}, +``` + +ESLint firewall (eslint.config.js Phase 1) — boundaries/element-types rule already enforces: +``` +{ from: ['sim'], disallow: ['render', 'ui'] } +``` +Element types: sim, render, ui, save, content, audio, store, app, game. +The deliberate-violation fixture under src/sim/__test_violation__/ is excluded from default lint. + +Phaser version: ^4.1.0 (installed). Use `import * as Phaser from 'phaser'`. + +For test patterns, mirror src/save/checksum.test.ts: +```typescript +import { describe, it, expect } from 'vitest'; +import { crc32hex, canonicalJSON } from './checksum'; + +describe('crc32hex', () => { + it('is deterministic — same input always returns same output', () => { + expect(crc32hex('hello')).toBe(crc32hex('hello')); + }); + // ... one describe per exported symbol; one assertion per `it` +}); +``` + +For migrations.test.ts pattern, mirror existing v0→v1 cases (use expect.objectContaining for forward-compatibility — RESEARCH p.614). + + + + + + + Task 1: Install zustand + break_eternity.js, author BigQty + format, scheduler (clock + tick + catchup), and barrels + + - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 1 lines 434-540, Pattern 2 lines 541-610) + - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group A, Group B; reading-order section) + - src/save/checksum.ts + src/save/checksum.test.ts (analog for pure-utility test layout) + - package.json (current scripts.ci, current deps) + + + package.json, + src/sim/numbers/big-qty.ts, + src/sim/numbers/big-qty.test.ts, + src/sim/numbers/format.ts, + src/sim/numbers/format.test.ts, + src/sim/numbers/index.ts, + src/sim/scheduler/clock.ts, + src/sim/scheduler/clock.test.ts, + src/sim/scheduler/tick.ts, + src/sim/scheduler/tick.test.ts, + src/sim/scheduler/catchup.ts, + src/sim/scheduler/catchup.test.ts, + src/sim/scheduler/index.ts, + src/sim/state.ts, + src/sim/index.ts + + +**Step 1 — Install dependencies:** + +Run, from the repo root, in order: +``` +npm install zustand@^5.0.0 +npm install break_eternity.js@^2.1.3 +``` + +Verify both land in `dependencies` (NOT devDependencies) in `package.json` with the exact `^` ranges above. Lockfile updated. + +**Step 2 — `src/sim/numbers/big-qty.ts`** (copy from RESEARCH lines 547-600 verbatim, with leading docblock). + +Leading docblock MUST cite: "Per CLAUDE.md Code Style: 'BigNumbers go through the typed BigQty wrapper around break_eternity.js. Never raw Decimal values in app code.' Per CONTEXT D-31. Per RESEARCH Pattern 2." + +The class: +- Private constructor; public static factories `fromNumber(n)`, `fromString(s)`, `zero()`, `one()`. +- Immutable arithmetic: `add(b)`, `sub(b)`, `mul(b)`, `div(b)` — each returns NEW BigQty. +- Comparison: `eq`, `gte`, `gt`, `lt`, `lte`. +- Display: `format()` (delegates to `formatHumanReadable`), `toNumberSaturating()` (returns Number.MAX_SAFE_INTEGER if Decimal.gte(MAX_SAFE_INTEGER)). +- Serialization: `toJSON()` returns `this.d.toString()`; static `fromJSON(s)` → BigQty via fromString. + +Import from `break_eternity.js`: +```typescript +import Decimal from 'break_eternity.js'; +``` + +**Step 3 — `src/sim/numbers/format.ts`** (copy RESEARCH lines 588-599 — formatHumanReadable): + +```typescript +import Decimal from 'break_eternity.js'; + +export function formatHumanReadable(d: Decimal): string { + 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`; + return d.toExponential(2); +} +``` + +**Step 4 — `src/sim/numbers/big-qty.test.ts`** — Vitest, one `describe('BigQty', () => { ... })` outer, then nested `describe` per category: +- `add` / `sub` / `mul` / `div` — each: `BigQty.fromNumber(2).add(BigQty.fromNumber(3)).eq(BigQty.fromNumber(5))` returns true; immutability assertion (original instance unchanged after operation). +- `eq` / `gte` / `gt` / `lt` / `lte` — ordering correctness on small + large values. +- `toJSON` / `fromJSON` round-trip — fromString('1e100').toJSON() round-trips to a value that .eq() the original. +- `toNumberSaturating` — saturates at `Number.MAX_SAFE_INTEGER` for large Decimals. + +**Step 5 — `src/sim/numbers/format.test.ts`** — boundary cases for UX-11: +- `0` → `"0"`; `999` → `"999"`; `1000` → `"1.0K"`; `1499` → `"1.5K"`; `1500` → `"1.5K"`; `999999` → `"1000.0K"`; `1e6` → `"1.0M"`; `1e9` → `"1.0B"`; `1e12` → `"1.0T"`; `1e15` → scientific (matches `/^\d\.\d{2}e\+\d+$/`). +- Negative numbers: `-1500` → `"-1.5K"` (verify via `Math.abs` branch). + +**Step 6 — `src/sim/numbers/index.ts`** — barrel: +```typescript +export { BigQty } from './big-qty'; +export { formatHumanReadable } from './format'; +``` + +**Step 7 — `src/sim/scheduler/clock.ts`** (copy RESEARCH lines 495-521). + +Leading docblock MUST cite CLAUDE.md "Simulation modules are pure" rule, CONTEXT D-33, and the ESLint `no-restricted-syntax` exclusion that this file specifically claims (Task 3 of this plan adds the rule). + +```typescript +/** + * The single owner of wall-clock access in The Last Garden. + * + * Per CLAUDE.md "Code Style": "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." + * + * Per CONTEXT D-33: this module is the only place in src/sim/ that may + * read Date.now(). The ESLint no-restricted-syntax rule (Phase 2 Plan 02-01 + * Task 3) excludes this file specifically. + */ + +export interface Clock { + now(): number; +} + +export const wallClock: Clock = { + now: () => Date.now(), +}; + +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; } +} +``` + +**Step 8 — `src/sim/scheduler/clock.test.ts`** — Vitest: +- `wallClock.now()` returns a finite number; two consecutive calls satisfy `b >= a` (allow equal). +- `FakeClock` starts at 0 by default; advance(1000) returns 1000 from now(); advance is monotonic-by-construction; can be initialized with arbitrary start. + +**Step 9 — `src/sim/scheduler/tick.ts`** (copy RESEARCH lines 446-493). + +Define the no-op `simulate` import as a placeholder shape: +```typescript +import type { Clock } from './clock'; +import type { SimState } from '../state'; + +export const TICK_MS = 200; // 5Hz, per RESEARCH Pattern 1 line 440 +export const MAX_OFFLINE_MS = 24 * 3600 * 1000; + +export interface TickResult { + state: SimState; + remainderMs: number; + ticksApplied: number; +} + +/** + * Drain the accumulator. Pure. Time is INJECTED via accumulatorMs. + * REFUSES negative deltas (CORE-11). CLAMPS at MAX_OFFLINE_MS (CORE-03). + * + * The simulate function is passed in to keep this module pure (no static + * import from src/sim/garden/ — Wave-1 plans wire that in). + */ +export function drainTicks( + state: SimState, + accumulatorMs: number, + simulate: (state: SimState, dtMs: number, silent: boolean) => SimState, + silent = false, +): TickResult { + if (accumulatorMs < 0) { + 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, silent); + } + return { + state: next, + remainderMs: cappedMs - ticks * TICK_MS, + ticksApplied: ticks, + }; +} +``` + +**Step 10 — `src/sim/scheduler/tick.test.ts`** — Vitest: +- `drainTicks(s, -1, sim)` returns `{state: s, ticksApplied: 0, remainderMs: 0}` (CORE-11). +- `drainTicks(s, 25*3600*1000, sim)` clamps `ticksApplied` to `Math.floor(MAX_OFFLINE_MS / TICK_MS) === 432000` (CORE-03). +- `drainTicks(s, 1000, sim)` with TICK_MS=200 calls sim 5 times, returns remainderMs=0. +- `drainTicks(s, 1100, sim)` calls sim 5 times, remainderMs=100. +- Benchmark assertion: 432000 ticks complete within 500ms wall time on the test machine (use `performance.now()` in the test runner — happy-dom provides it). If this fails on CI, log and `expect.soft` rather than hard-fail (RESEARCH Assumption A3). + +**Step 11 — `src/sim/scheduler/catchup.ts`**: + +```typescript +import { TICK_MS, MAX_OFFLINE_MS } from './tick'; + +export interface OfflineCatchupSpec { + elapsedMs: number; // raw wall-clock delta (negative deltas are clamped to 0 here, NOT refused — refusal lives in drainTicks) + cappedMs: number; // min(elapsedMs, MAX_OFFLINE_MS); 0 if elapsedMs < 0 + willRunCatchup: boolean; // cappedMs >= TICK_MS + hitOfflineCap: boolean; // elapsedMs > MAX_OFFLINE_MS +} + +/** + * Pure descriptor of an offline-catchup boundary. The application layer + * uses this to decide: + * - whether to fire the letter overlay (cappedMs >= 5*60*1000 → Plan 02-05) + * - whether to log a 24h-cap-hit event silently (hitOfflineCap === true) + * Per CORE-03 + CORE-11. + */ +export function computeOfflineCatchup(savedLastTickAt: number, nowMs: number): OfflineCatchupSpec { + const raw = nowMs - savedLastTickAt; + const elapsedMs = raw; + const cappedMs = raw < 0 ? 0 : Math.min(raw, MAX_OFFLINE_MS); + return { + elapsedMs, + cappedMs, + willRunCatchup: cappedMs >= TICK_MS, + hitOfflineCap: raw > MAX_OFFLINE_MS, + }; +} +``` + +**Step 12 — `src/sim/scheduler/catchup.test.ts`** — Vitest: +- `computeOfflineCatchup(1000, 1100)` → `{elapsedMs: 100, cappedMs: 100, willRunCatchup: false, hitOfflineCap: false}` (below TICK_MS). +- `computeOfflineCatchup(0, 1000)` → `cappedMs: 1000, willRunCatchup: true`. +- Negative branch: `computeOfflineCatchup(2000, 1000)` → `cappedMs: 0, willRunCatchup: false` (system clock rewind cheat — CORE-11). +- Cap branch: `computeOfflineCatchup(0, 25*3600*1000)` → `cappedMs: MAX_OFFLINE_MS, hitOfflineCap: true`. + +**Step 13 — `src/sim/scheduler/index.ts`** — barrel: +```typescript +export type { Clock } from './clock'; +export { wallClock, FakeClock } from './clock'; +export { TICK_MS, MAX_OFFLINE_MS, drainTicks } from './tick'; +export type { TickResult } from './tick'; +export { computeOfflineCatchup } from './catchup'; +export type { OfflineCatchupSpec } from './catchup'; +``` + +**Step 14 — `src/sim/state.ts`** — root SimState mirrors V1Payload structurally (declared here so the scheduler can type-check without importing src/save/). Wave-1 plans flesh out tile/plant interior shapes; for now use minimal placeholder types and make the export forward-compatible: + +```typescript +/** + * SimState — root shape of the in-memory sim world. Structurally + * compatible with V1Payload from src/save/migrations.ts (a SimState + * round-trips to a V1Payload via the application layer). + * + * Wave 0 ships placeholder unknown[] for tiles/plants — Wave 1 (Plan 02-02) + * fleshes them out with real interfaces in src/sim/garden/types.ts. + */ +export interface SimState { + garden: { tiles: unknown[] }; + plants: unknown[]; + harvestedFragmentIds: string[]; + lastTickAt: number; + unlockedPlantTypes: string[]; + luraBeatProgress: { + arrived: boolean; + mid: boolean; + farewell: boolean; + pending: 'arrival' | 'mid' | 'farewell' | null; + }; + offlineEvents: unknown | null; + settings: { + musicVolume: number; + ambientVolume: number; + sfxVolume: number; + persistenceToastShown: boolean; + }; +} +``` + +**Step 15 — `src/sim/index.ts`** — top-level sim barrel re-exporting from sub-barrels (numbers, scheduler) and SimState type. + +**Commit:** `feat(02-01): BigQty + scheduler + sim foundations`. Run `npm run lint && npm test` and ensure both pass before committing. + + + - `package.json` `dependencies` field contains both `"zustand": "^5.0.0"` and `"break_eternity.js": "^2.1.3"` exactly. + - `grep -q "export class BigQty" src/sim/numbers/big-qty.ts` + - `grep -q "export function formatHumanReadable" src/sim/numbers/format.ts` + - `grep -q "export class FakeClock" src/sim/scheduler/clock.ts` + - `grep -c "Date.now" src/sim/scheduler/clock.ts` reports `1` exactly (the wallClock implementation; no other call site) + - `grep -L "Date.now" src/sim/numbers/big-qty.ts src/sim/numbers/format.ts src/sim/scheduler/tick.ts src/sim/scheduler/catchup.ts src/sim/state.ts` (all four files lack the call) + - `grep -q "export const TICK_MS = 200" src/sim/scheduler/tick.ts` + - `grep -q "export const MAX_OFFLINE_MS" src/sim/scheduler/tick.ts` + - `npx vitest run src/sim/numbers/ src/sim/scheduler/` exits 0 and reports ≥20 passing tests across `big-qty.test.ts`, `format.test.ts`, `clock.test.ts`, `tick.test.ts`, `catchup.test.ts` + - `npm run lint` exits 0 + - `npm run build` exits 0 (`tsc -b && vite build` — strict TS gate) + + + npm run lint && npx vitest run src/sim/numbers/ src/sim/scheduler/ && npm run build + + + BigQty + format land under src/sim/numbers/ with full coverage. Scheduler (clock + tick + catchup) lands under src/sim/scheduler/ with full coverage including CORE-03 + CORE-11 boundary tests. SimState type declared. Barrels exist. zustand and break_eternity.js installed. `npm run lint && npx vitest run src/sim/numbers/ src/sim/scheduler/ && npm run build` exits 0. + + + + + Task 2: Zustand store (4 slices + sim adapter + selectors), V1Payload extension, save lifecycle hooks, EventBus + + - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 3 lines 612-696, Pattern 7 lines 841-905, AudioContext bootstrap line 949 [for context only — Plan 02-02 owns it]) + - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group G store, Group K save extension, Group L event-bus, Pattern: Save lifecycle hooks) + - src/save/migrations.ts (current shape — Phase 2 extends in place) + - src/save/migrations.test.ts (existing test cases — extend to cover new fields) + - src/save/index.ts (frozen barrel — Phase 2 extends to export `lifecycle` exports) + - src/PhaserGame.tsx (where the lifecycle hook will eventually be called from in Plan 02-05; for Wave 0 just expose the function) + + + src/store/garden-slice.ts, + src/store/memory-slice.ts, + src/store/narrative-slice.ts, + src/store/session-slice.ts, + src/store/store.ts, + src/store/store.test.ts, + src/store/selectors.ts, + src/store/sim-adapter.ts, + src/store/index.ts, + src/save/migrations.ts, + src/save/migrations.test.ts, + src/save/lifecycle.ts, + src/save/lifecycle.test.ts, + src/save/index.ts, + src/game/event-bus.ts + + +**Step 1 — Extend `V1Payload` in `src/save/migrations.ts`** (per RESEARCH Pattern 7 lines 847-895 + PATTERNS Group K). + +Replace the `V1Payload` interface with: + +```typescript +/** + * v1 save shape — Phase-2-extended per CONTEXT D-34. + * + * NOTE: This is an EXTENSION, not a migration. Phase 1's v1 has shipped + * no production saves; Phase 2 adds fields with sensible defaults rather + * than introducing migrations[2]. The first real v1→v2 migration lands + * in Phase 4 (Roothold / prestige state). + * + * Cross-references: + * - unlockedPlantTypes → CONTEXT D-05 (plant-type unlocks via fragment count) + * - luraBeatProgress → CONTEXT D-13 / D-14 (3 beats: arrival / mid / farewell) + * - offlineEvents → CONTEXT D-19 (offline event log feeding the letter) + * - settings.persistenceToastShown → CONTEXT D-30 (one-time soft toast) + */ +export interface V1Payload { + garden: { tiles: unknown[] }; + plants: unknown[]; + harvestedFragmentIds: string[]; + lastTickAt: number; + + // NEW Phase 2 fields: + unlockedPlantTypes: string[]; + luraBeatProgress: { + arrived: boolean; + mid: boolean; + farewell: boolean; + pending: 'arrival' | 'mid' | 'farewell' | null; + }; + offlineEvents: OfflineEventBlock | null; + + settings: { + musicVolume: number; + ambientVolume: number; + sfxVolume: number; + persistenceToastShown: boolean; + }; +} + +/** + * Local mirror of the OfflineEventBlock shape — declared HERE rather + * than imported from src/sim/offline/ so the save layer remains a leaf + * with no upward dependency on sim. The Zod schema lives in src/sim/offline/ + * (Plan 02-05); structural compatibility is enforced via TypeScript at the + * application boundary (src/store/sim-adapter.ts). + */ +export interface OfflineEventBlock { + plantsBloomedCount: Record; + harvestedFragmentIds: string[]; + luraBeatPending: 'arrival' | 'mid' | 'farewell' | null; +} +``` + +Update `migrations[1]` body to populate the new defaults: + +```typescript +1: (s: unknown): V1Payload => { + const v0 = (s ?? {}) as V0Payload; + return { + garden: { tiles: v0.garden ?? [] }, + plants: [], + harvestedFragmentIds: [], + lastTickAt: Date.now(), + unlockedPlantTypes: [], + luraBeatProgress: { arrived: false, mid: false, farewell: false, pending: null }, + offlineEvents: null, + settings: { + musicVolume: 0.7, + ambientVolume: 0.5, + sfxVolume: 0.8, + persistenceToastShown: false, + }, + }; +}, +``` + +`CURRENT_SCHEMA_VERSION` stays at `1`. Do NOT add `migrations[2]`. + +**Step 2 — Update `src/save/migrations.test.ts`:** + +Existing v0→v1 test still asserts the migration runs; ADD assertions for new fields. Use `expect.objectContaining` or precise equality: +- `migrations[1]({garden: ['x']}).unlockedPlantTypes` deep-equals `[]`. +- `migrations[1]({...}).luraBeatProgress` deep-equals `{arrived: false, mid: false, farewell: false, pending: null}`. +- `migrations[1]({...}).offlineEvents` is `null`. +- `migrations[1]({...}).settings.persistenceToastShown` is `false`. +- `migrations[1]({...}).settings.musicVolume` is `0.7` (existing value preserved). + +Also add a regression-defense test: `expect(Object.keys(migrations).sort()).toEqual(['1'])` — proves no `migrations[2]` was sneakily added. + +**Step 3 — Author Zustand store + 4 slices.** + +Each slice file (`src/store/garden-slice.ts`, `memory-slice.ts`, `narrative-slice.ts`, `session-slice.ts`) exports: +- A `*Slice` interface (state fields + setter actions for queueing commands). +- A `create*Slice` factory that takes Zustand's `set` and `get` and returns the slice object. + +**`src/store/garden-slice.ts`** (placeholder shapes; Wave 1 plans extend): +```typescript +import type { StateCreator } from 'zustand'; + +/** + * GardenSlice — Phase 2 garden state surface (D-01 through D-07). + * The 16 tiles + unlocked plant types + queued commands. Wave-1 Plan 02-02 + * (Begin/Plant/Grow) and Plan 02-03 (Harvest/Journal) flesh out the tile + * data; Wave 0 ships the slice shape so React can subscribe immediately. + */ +export interface GardenCommand { + kind: 'plantSeed' | 'harvest' | 'compost'; + tileIdx: number; + plantTypeId?: string; // only for plantSeed +} + +export interface GardenSlice { + tiles: unknown[]; // length 16; Plan 02-02 fills with Tile interface + unlockedPlantTypes: string[]; + pendingCommands: GardenCommand[]; + enqueueCommand: (cmd: GardenCommand) => void; + drainCommands: () => GardenCommand[]; + applyTilesAndUnlocks: (tiles: unknown[], unlocked: string[]) => void; +} + +export const createGardenSlice: StateCreator = (set, get) => ({ + tiles: new Array(16).fill(null), + unlockedPlantTypes: [], + pendingCommands: [], + enqueueCommand: (cmd) => set((s) => ({ pendingCommands: [...s.pendingCommands, cmd] })), + drainCommands: () => { + const cmds = get().pendingCommands; + set({ pendingCommands: [] }); + return cmds; + }, + applyTilesAndUnlocks: (tiles, unlocked) => set({ tiles, unlockedPlantTypes: unlocked }), +}); +``` + +**`src/store/memory-slice.ts`**: +```typescript +import type { StateCreator } from 'zustand'; + +export interface MemorySlice { + harvestedFragmentIds: string[]; + // Reveal modal state — D-25 surfaces just-harvested fragment in active play + fragmentRevealId: string | null; + setHarvested: (ids: string[]) => void; + setFragmentRevealId: (id: string | null) => void; +} + +export const createMemorySlice: StateCreator = (set) => ({ + harvestedFragmentIds: [], + fragmentRevealId: null, + setHarvested: (ids) => set({ harvestedFragmentIds: ids }), + setFragmentRevealId: (id) => set({ fragmentRevealId: id }), +}); +``` + +**`src/store/narrative-slice.ts`**: +```typescript +import type { StateCreator } from 'zustand'; + +export type LuraBeatId = 'arrival' | 'mid' | 'farewell'; + +export interface NarrativeSlice { + luraBeatProgress: { arrived: boolean; mid: boolean; farewell: boolean; pending: LuraBeatId | null }; + dialogueOverlayOpen: boolean; + setLuraBeatProgress: (p: NarrativeSlice['luraBeatProgress']) => void; + setDialogueOverlayOpen: (open: boolean) => void; +} + +export const createNarrativeSlice: StateCreator = (set) => ({ + luraBeatProgress: { arrived: false, mid: false, farewell: false, pending: null }, + dialogueOverlayOpen: false, + setLuraBeatProgress: (p) => set({ luraBeatProgress: p }), + setDialogueOverlayOpen: (open) => set({ dialogueOverlayOpen: open }), +}); +``` + +**`src/store/session-slice.ts`**: +```typescript +import type { StateCreator } from 'zustand'; + +export interface SessionSlice { + beginGateDismissed: boolean; + persistenceToastShown: boolean; + letterOverlayOpen: boolean; + pendingLetterEventBlock: unknown | null; // OfflineEventBlock; typed in Plan 02-05 + dismissBeginGate: () => void; + setPersistenceToastShown: (v: boolean) => void; + openLetter: (block: unknown) => void; + dismissLetter: () => void; +} + +export const createSessionSlice: StateCreator = (set) => ({ + beginGateDismissed: false, + persistenceToastShown: false, + letterOverlayOpen: false, + pendingLetterEventBlock: null, + dismissBeginGate: () => set({ beginGateDismissed: true }), + setPersistenceToastShown: (v) => set({ persistenceToastShown: v }), + openLetter: (block) => set({ letterOverlayOpen: true, pendingLetterEventBlock: block }), + dismissLetter: () => set({ letterOverlayOpen: false, pendingLetterEventBlock: null }), +}); +``` + +**`src/store/store.ts`** — composition (RESEARCH lines 624-661): +```typescript +import { createStore } from 'zustand/vanilla'; +import { useStore } from 'zustand'; +import { createGardenSlice, type GardenSlice } from './garden-slice'; +import { createMemorySlice, type MemorySlice } from './memory-slice'; +import { createNarrativeSlice, type NarrativeSlice } from './narrative-slice'; +import { createSessionSlice, type SessionSlice } from './session-slice'; + +export type AppStoreShape = GardenSlice & MemorySlice & NarrativeSlice & SessionSlice; + +export const appStore = createStore()((...a) => ({ + ...createGardenSlice(...a), + ...createMemorySlice(...a), + ...createNarrativeSlice(...a), + ...createSessionSlice(...a), +})); + +export function useAppStore(selector: (s: AppStoreShape) => T): T { + return useStore(appStore, selector); +} +``` + +**`src/store/sim-adapter.ts`** — the bridge between sim outputs and store updates (RESEARCH lines 651-661). Sim never imports this file. +```typescript +import { appStore } from './store'; +import type { GardenCommand } from './garden-slice'; + +/** + * simAdapter — the application-layer boundary between the pure sim and + * the Zustand store. The Phaser scene's update() loop calls these: + * 1. drainCommands() — pull pending commands the React UI enqueued + * 2. (run scheduler with those commands; receive next state + events) + * 3. applySimResult(next, events) — write the result back into the store + * + * src/sim/ MUST NOT import this file. The CORE-10 firewall (sim → ui) + * already prevents that; this comment is a reader-facing reminder. + */ +export const simAdapter = { + drainCommands(): GardenCommand[] { + return appStore.getState().drainCommands(); + }, + applyTilesAndUnlocks(tiles: unknown[], unlocked: string[]): void { + appStore.getState().applyTilesAndUnlocks(tiles, unlocked); + }, + applyHarvestedFragments(ids: string[]): void { + appStore.getState().setHarvested(ids); + }, + applyLuraProgress(p: { arrived: boolean; mid: boolean; farewell: boolean; pending: 'arrival' | 'mid' | 'farewell' | null }): void { + appStore.getState().setLuraBeatProgress(p); + }, +}; +``` + +**`src/store/selectors.ts`** — small named selectors React components can use: +```typescript +import type { AppStoreShape } from './store'; + +export const selectHarvestCount = (s: AppStoreShape): number => s.harvestedFragmentIds.length; +export const selectJournalRevealed = (s: AppStoreShape): boolean => s.harvestedFragmentIds.length > 0; +export const selectBeginGateActive = (s: AppStoreShape): boolean => !s.beginGateDismissed; +export const selectLuraPending = (s: AppStoreShape) => s.luraBeatProgress.pending; +``` + +**`src/store/index.ts`** — barrel: +```typescript +export { appStore, useAppStore } from './store'; +export type { AppStoreShape } from './store'; +export { simAdapter } from './sim-adapter'; +export type { GardenSlice, GardenCommand } from './garden-slice'; +export type { MemorySlice } from './memory-slice'; +export type { NarrativeSlice, LuraBeatId } from './narrative-slice'; +export type { SessionSlice } from './session-slice'; +export * from './selectors'; +``` + +**Step 4 — `src/store/store.test.ts`** — Vitest: +- Slice composition: `appStore.getState()` has all four slice keys (`pendingCommands`, `harvestedFragmentIds`, `luraBeatProgress`, `beginGateDismissed`). +- Command enqueue+drain semantics: `enqueueCommand({kind: 'plantSeed', tileIdx: 0, plantTypeId: 'rosemary'})` then `drainCommands()` returns the command and leaves `pendingCommands === []`. +- React hook surface: `renderHook(() => useAppStore(s => s.harvestedFragmentIds.length))` from `@testing-library/react` re-renders when `setHarvested(['season1.soil.x'])` fires. NOTE: `@testing-library/react` is NOT installed yet — install it as a devDep before writing this part of the test (`npm install -D @testing-library/react`). Confirm `package.json` reflects the install. +- Selector check: `selectJournalRevealed({...initial, harvestedFragmentIds: ['x']})` returns `true`. + +**Step 5 — `src/save/lifecycle.ts`** — UX-10 hook implementation (RESEARCH Pitfall 7 lines 1094-1100): + +```typescript +/** + * Save lifecycle hooks (UX-10). + * + * Saves fire on: + * 1. visibilitychange → hidden + * 2. beforeunload + * 3. saveOnSeasonTransition() (callable from Phase 4+; Phase 2 verifies via unit test only) + * + * The visibilitychange + beforeunload handlers MUST be synchronous (no + * `await`) — RESEARCH Pitfall 7 line 1094: React unmounts asynchronously + * and `beforeunload` will not await. The synchronous LocalStorageDBAdapter + * write path is used here; idb writes are best-effort. + */ + +export interface LifecycleHooksHandle { + /** Detach all listeners. Call from a useEffect cleanup function. */ + detach(): void; +} + +export interface LifecycleHooksConfig { + /** Synchronous serializer that writes to LocalStorage and best-effort to IDB. */ + saveSync: () => void; +} + +export function registerSaveLifecycleHooks(config: LifecycleHooksConfig): LifecycleHooksHandle { + const onVisibility = () => { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { + config.saveSync(); + } + }; + const onBeforeUnload = () => { + config.saveSync(); + }; + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', onVisibility); + } + if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', onBeforeUnload); + } + return { + detach() { + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', onVisibility); + } + if (typeof window !== 'undefined') { + window.removeEventListener('beforeunload', onBeforeUnload); + } + }, + }; +} + +/** + * Phase-4+ hook for Season transitions. Phase 2 has no transitions; this + * function is exported so Phase 4's prestige plan can call it directly + * (UX-10 third trigger). + */ +export function saveOnSeasonTransition(saveSync: () => void): void { + saveSync(); +} +``` + +**Step 6 — `src/save/lifecycle.test.ts`** — Vitest with happy-dom: +- A spy `saveSync` is invoked when `document.dispatchEvent(new Event('visibilitychange'))` fires AND `document.visibilityState === 'hidden'` (use `Object.defineProperty(document, 'visibilityState', {value: 'hidden', configurable: true})`). +- `saveSync` is invoked when `window.dispatchEvent(new Event('beforeunload'))` fires. +- `saveOnSeasonTransition(spy)` invokes `spy` exactly once. +- `handle.detach()` removes both listeners (subsequent dispatches do not invoke the spy). + +**Step 7 — `src/save/index.ts`** — extend the existing barrel: +```typescript +// (existing exports stay) +export { registerSaveLifecycleHooks, saveOnSeasonTransition } from './lifecycle'; +export type { LifecycleHooksHandle, LifecycleHooksConfig } from './lifecycle'; +export type { OfflineEventBlock } from './migrations'; +``` + +**Step 8 — `src/game/event-bus.ts`** — Phaser EventBus singleton (RESEARCH Pattern 3 lines 681-694): + +```typescript +import * as Phaser from 'phaser'; + +/** + * Single shared emitter — the Phaser 4 React-template pattern. + * Source: phaser.io/news/2025/05/bringing-our-phaserjs-templates-into-the-future + * + * Used for transient signals between Phaser scenes and React UI: + * 'scene-ready' (Phaser → React) signals scene tree is live + * 'tile-clicked-coords' (Phaser → React) {tileIdx, screenX, screenY} for seed picker (Plan 02-02) + * 'fragment-revealed' (Phaser → React) one-shot for D-25 reveal modal (Plan 02-03) + * + * Persistent state lives in src/store/, NOT here. Anti-pattern: routing + * user-input intents through this bus — those are commands, store-bound. + */ +export const eventBus = new Phaser.Events.EventEmitter(); +``` + +(No test for this — it's a single-line singleton; trivial verification via `import { eventBus } from './event-bus'` working in any other file's test suite.) + +**Commit:** `feat(02-01): Zustand store + V1Payload extension + save lifecycle hooks`. Run `npm run lint && npm test` before committing. + + + - `grep -q "OfflineEventBlock" src/save/migrations.ts` (new field type declared inline) + - `grep -q "luraBeatProgress" src/save/migrations.ts` + - `grep -q "persistenceToastShown" src/save/migrations.ts` + - `grep -c "^ [0-9]:" src/save/migrations.ts` reports `1` exactly (only `migrations[1]`; no `migrations[2]`) + - `grep -q "CURRENT_SCHEMA_VERSION = 1" src/save/migrations.ts` (version stays 1) + - `grep -q "import { createStore } from 'zustand/vanilla'" src/store/store.ts` + - `grep -q "export const appStore" src/store/store.ts` + - `grep -q "export const simAdapter" src/store/sim-adapter.ts` + - `grep -q "registerSaveLifecycleHooks" src/save/lifecycle.ts` + - `grep -q "saveOnSeasonTransition" src/save/lifecycle.ts` + - `grep -q "registerSaveLifecycleHooks" src/save/index.ts` (barrel re-export added) + - `grep -q "new Phaser.Events.EventEmitter" src/game/event-bus.ts` + - `npx vitest run src/store/ src/save/migrations.test.ts src/save/lifecycle.test.ts` exits 0 with all tests green + - `npm run ci` exits 0 + + + npm run lint && npx vitest run src/store/ src/save/migrations.test.ts src/save/lifecycle.test.ts && npm run ci + + + Zustand store with 4 slices + sim adapter + selectors lands. V1Payload extended in place per D-34 with full default population in migrations[1]; CURRENT_SCHEMA_VERSION stays at 1. Save lifecycle hooks (UX-10) ship with Vitest covering all three triggers. Phaser EventBus singleton seeded. `npm run ci` exits 0. + + + + + Task 3: ESLint sim-purity rule (Date.now + setInterval ban) with deliberate-violation fixture + + - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pitfall 1 lines 1029-1041, Group O lines 731-781 in PATTERNS.md) + - eslint.config.js (current Phase 1 firewall config) + - src/sim/__test_violation__/lint-firewall.test.ts (analog: programmatic ESLint test) + - src/sim/__test_violation__/violator.ts (analog: deliberate-violation fixture pattern) + + + eslint.config.js, + src/sim/__test_violation__/date-now-violator.ts, + src/sim/__test_violation__/lint-firewall.test.ts + + +**Defended option (per CONTEXT user-pushback flag):** PATTERNS.md Group O line 780 explicitly notes the user prefers minimum-viable shape. This task implements the rule because it directly defends Pitfall 1 (the highest-impact sim-purity defect we can prevent at lint time) and is ~30 lines of config + ~30 lines of test. If the executor finds this lands cleanly, ship it. If it conflicts with the existing flat-config layout in a way that requires non-trivial restructure, surface a one-line note in SUMMARY.md and defer to manual code review. Default: ship. + +**Step 1 — Edit `eslint.config.js`** to add a fourth config block (after the current ignores + firewall blocks). The block targets `src/sim/**` exclusively, ignores `src/sim/scheduler/clock.ts` (the one allowed wall-clock owner) and `src/sim/__test_violation__/**` (deliberate-violation fixtures): + +```javascript +// --------------------------------------------------------------------- +// 3. Phase-2 sim-purity rule (CONTEXT D-33, RESEARCH Pitfall 1). +// +// Bans Date.now() and setInterval() inside src/sim/** to enforce the +// "Sim modules are pure — no Date.now(), no setInterval" rule from +// CLAUDE.md Code Style. The single allowed wall-clock owner is +// src/sim/scheduler/clock.ts (which exports the Clock interface and +// the wallClock + FakeClock implementations). +// +// Severity is `error` so `npm run lint --max-warnings 0` fails on a +// violation. The deliberate-violation fixture under +// src/sim/__test_violation__/ is excluded; it exists ONLY to be lint- +// tested by Task 3's Vitest test (which runs ESLint programmatically +// with `ignore: false`). +// --------------------------------------------------------------------- +{ + files: ['src/sim/**/*.{ts,tsx}'], + ignores: ['src/sim/scheduler/clock.ts', 'src/sim/__test_violation__/**'], + rules: { + '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() (CONTEXT D-33).", + }, + { + selector: "CallExpression[callee.name='setInterval']", + message: "src/sim/** must not use setInterval; the scheduler drives ticks via the Phaser game loop (CORE-02).", + }, + ], + }, +}, +``` + +**Step 2 — `src/sim/__test_violation__/date-now-violator.ts`** — deliberate-violation fixture (mirrors `violator.ts`): + +```typescript +/** + * Deliberate violation of CONTEXT D-33 — `src/sim/` may NOT call Date.now(). + * This file exists ONLY to be lint-tested by date-now-violator.test.ts (or + * the existing lint-firewall.test.ts extension) and is excluded from the + * default lint glob via eslint.config.js Block 1's `ignores` array. + * + * The Vitest test runs ESLint programmatically with `ignore: false` against + * this file and asserts that `no-restricted-syntax` fires. + */ +export function violator(): number { + return Date.now(); // intentional violation +} +``` + +**Step 3 — Extend `src/sim/__test_violation__/lint-firewall.test.ts`** with a NEW `describe` block (do NOT modify the existing CORE-10 test): + +```typescript +import { describe, it, expect } from 'vitest'; +import { ESLint } from 'eslint'; +import { resolve } from 'node:path'; + +// (existing CORE-10 test stays as-is — DO NOT REMOVE) + +describe('Phase 2 sim-purity rule (CONTEXT D-33)', () => { + it('eslint flags Date.now() inside src/sim/** as no-restricted-syntax', async () => { + const eslint = new ESLint({ + overrideConfigFile: resolve(process.cwd(), 'eslint.config.js'), + ignore: false, + }); + const fixturePath = resolve(process.cwd(), 'src/sim/__test_violation__/date-now-violator.ts'); + const results = await eslint.lintFiles([fixturePath]); + + expect(results).toHaveLength(1); + const violations = results[0].messages.filter( + (m) => m.ruleId === 'no-restricted-syntax', + ); + expect(violations.length).toBeGreaterThanOrEqual(1); + expect(violations[0].message).toMatch(/inject time|D-33/); + }); + + it('does NOT flag Date.now() inside src/sim/scheduler/clock.ts (the one exception)', async () => { + const eslint = new ESLint({ + overrideConfigFile: resolve(process.cwd(), 'eslint.config.js'), + ignore: false, + }); + const clockPath = resolve(process.cwd(), 'src/sim/scheduler/clock.ts'); + const results = await eslint.lintFiles([clockPath]); + const noRestrictedViolations = results[0].messages.filter( + (m) => m.ruleId === 'no-restricted-syntax', + ); + expect(noRestrictedViolations).toHaveLength(0); + }); +}); +``` + +**Commit:** `chore(02-01): eslint sim-purity rule + Date.now violator fixture`. Run `npm run lint && npx vitest run src/sim/__test_violation__/` before committing. + + + - `grep -q "no-restricted-syntax" eslint.config.js` + - `grep -q "src/sim/scheduler/clock.ts" eslint.config.js` (in the new block's `ignores` array) + - `grep -q "Date.now" src/sim/__test_violation__/date-now-violator.ts` + - `npm run lint` exits 0 (the deliberate violator is excluded by Block 1) + - `npx vitest run src/sim/__test_violation__/lint-firewall.test.ts` exits 0 with the new test cases passing + - The existing CORE-10 firewall test remains green + + + npm run lint && npx vitest run src/sim/__test_violation__/ && npm run ci + + + ESLint sim-purity rule lands. Deliberate Date.now() violator fixture proves the rule fires. The clock.ts exception is verified by a positive test. `npm run ci` is green. (If the rule conflicts non-trivially with the existing config layout, this task may be deferred per the defended-option clause; surface in SUMMARY.md.) + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Save schema extension boundary | New V1Payload fields must round-trip through CRC-32 envelope without breaking checksum. Phase 1's CRC is over canonical JSON; new fields automatically participate. | +| Sim ↔ wall-clock boundary | The scheduler's clock module is the single trust boundary for time. All other sim modules MUST inject time. ESLint enforces (Task 3). | +| Store ↔ sim boundary | sim never imports the store; the store imports sim type signatures only. simAdapter is the bridge owned by store/. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-02-01-01 | Tampering | Save round-trip with new fields | mitigate | New fields participate in canonical JSON checksum automatically. The migrations.test.ts assertion that all new fields default correctly is the regression gate; round-trip test (already shipped in src/save/round-trip.test.ts) exercises wrap → unwrap with the extended payload via npm run ci. | +| T-02-01-02 | Tampering | System-clock rewind cheat (negative delta) | mitigate | drainTicks refuses negative accumulatorMs and returns state unchanged (CORE-11); computeOfflineCatchup reports cappedMs=0 for negative deltas. Vitest covers both paths. | +| T-02-01-03 | Tampering | 24h offline cap bypass | mitigate | drainTicks clamps at MAX_OFFLINE_MS; computeOfflineCatchup reports hitOfflineCap=true (CORE-03). The cap is not user-configurable. | +| T-02-01-04 | Tampering | Sim module silently calling Date.now() to bypass FakeClock | mitigate | ESLint no-restricted-syntax rule (Task 3) makes any `Date.now()` outside src/sim/scheduler/clock.ts an error-severity lint failure. Deliberate-violation fixture proves the rule fires. | +| T-02-01-05 | Denial-of-service | Offline catch-up loop hangs on absurd delta | mitigate | MAX_OFFLINE_MS clamp limits drainTicks to ≤432000 iterations regardless of input; benchmark assertion targets ≤500ms. | +| T-02-01-06 | Repudiation | n/a | accept | Single-player local game; no server-authoritative actions. | +| T-02-01-07 | Information disclosure | n/a | accept | No PII collected; no telemetry; saves are local-only. | +| T-02-01-08 | Elevation of privilege | n/a | accept | No privilege model in v1. | + +All Wave 0 threats are mitigate or accept. No `high` severity threats; no blocking issues for this plan. + + + + +After all 3 tasks committed: + +1. **Linter:** `npm run lint` exits 0. +2. **Tests:** `npx vitest run` exits 0 with the Phase-1 53 tests + the new Phase-2 Wave-0 additions all green. Expected new test count: BigQty (~12), format (~10), clock (~5), tick (~5), catchup (~5), store (~6), migrations.test additions (~5), lifecycle (~4), lint-firewall additions (~2). Total ≥54 new tests; combined ~107. +3. **Build:** `npm run build` exits 0 (`tsc -b && vite build` — strict TS gate). +4. **Full CI:** `npm run ci` exits 0. +5. **Firewall:** `grep -rL "Date.now" src/sim/numbers/ src/sim/garden/ src/sim/memory/ src/sim/narrative/ src/sim/offline/` (the directories Plan 02-01 doesn't fully populate but enforces the rule for) — none of these directories should contain Date.now() calls when Wave 1+ plans land. +6. **Schema lock:** `grep -c "^ [0-9]:" src/save/migrations.ts` returns `1` — confirms no `migrations[2]` was added. + + + + + +Plan 02-01 is complete when: + +- [ ] All 3 tasks committed with conventional-commit messages prefixed with `feat(02-01):` or `chore(02-01):`. +- [ ] `npm run ci` exits 0. +- [ ] BigQty wraps `break_eternity.js` and round-trips via toJSON/fromJSON. +- [ ] formatHumanReadable handles all UX-11 thresholds. +- [ ] FakeClock + wallClock + drainTicks + computeOfflineCatchup all behave per CORE-02 / CORE-03 / CORE-11. +- [ ] Zustand vanilla createStore composes 4 slices; useAppStore React hook works in Vitest. +- [ ] simAdapter exposes drainCommands + applyTilesAndUnlocks + applyHarvestedFragments + applyLuraProgress. +- [ ] V1Payload extended with unlockedPlantTypes, luraBeatProgress, offlineEvents, settings.persistenceToastShown; migrations[1] populates all new defaults; CURRENT_SCHEMA_VERSION stays at 1; no migrations[2]. +- [ ] registerSaveLifecycleHooks fires saveSync on visibilitychange→hidden, beforeunload, and saveOnSeasonTransition() (UX-10). +- [ ] Phaser EventBus singleton exported from src/game/event-bus.ts. +- [ ] ESLint sim-purity rule banning Date.now() and setInterval inside src/sim/** (except clock.ts) lands with deliberate-violation fixture proving the rule fires. +- [ ] All Wave 1 + Wave 2 plans can begin execution against this foundation. + + + + +After completion, create `.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-SUMMARY.md` per the standard summary template. Document: +- Final TICK_MS chosen (200 = 5Hz; flag if changed during implementation). +- Whether the ESLint sim-purity rule landed or was deferred per the defended-option clause. +- Any deviations from the locked task list (e.g., extra tests added; install adjustments). +- Final test count breakdown (per-file). + diff --git a/.planning/phases/02-season-1-vertical-slice-soil/02-02-begin-plant-grow-PLAN.md b/.planning/phases/02-season-1-vertical-slice-soil/02-02-begin-plant-grow-PLAN.md new file mode 100644 index 0000000..bb443de --- /dev/null +++ b/.planning/phases/02-season-1-vertical-slice-soil/02-02-begin-plant-grow-PLAN.md @@ -0,0 +1,1643 @@ +--- +phase: 02 +plan: 02 +type: execute +wave: 1 +depends_on: [02-01] +files_modified: + - src/sim/garden/types.ts + - src/sim/garden/plants.ts + - src/sim/garden/growth.ts + - src/sim/garden/growth.test.ts + - src/sim/garden/commands.ts + - src/sim/garden/commands.test.ts + - src/sim/garden/index.ts + - src/render/garden/tile-renderer.ts + - src/render/garden/plant-renderer.ts + - src/render/garden/ready-pulse.ts + - src/render/garden/tile-coords.ts + - src/render/garden/index.ts + - src/render/index.ts + - src/ui/begin/BeginScreen.tsx + - src/ui/begin/BeginScreen.test.tsx + - src/ui/begin/use-audio-bootstrap.ts + - src/ui/begin/index.ts + - src/ui/garden/SeedPicker.tsx + - src/ui/garden/SeedPicker.test.tsx + - src/ui/garden/index.ts + - src/ui/index.ts + - src/game/scenes/Garden.ts + - src/game/main.ts + - src/game/scenes/Boot.ts + - src/PhaserGame.tsx + - src/App.tsx + - content/seasons/01-soil/ui-strings.yaml + - src/content/schemas/ui-strings.ts + - src/content/schemas/index.ts + - src/content/loader.ts + - src/content/index.ts + - content/seasons/00-demo/fragments.yaml +autonomous: true +requirements: [GARD-01, GARD-02, AEST-07, UX-01, CORE-02] +tags: [vertical-slice, garden, begin-screen, plant, grow, audio-bootstrap, mvp] + +must_haves: + truths: + - "Player loads the page with no save → sees a typographic 'Tend the garden / Begin' screen with no other UI clutter; tap calls audioContext.resume() and dismisses the screen (AEST-07, D-21, UX-01)" + - "Player loads with an existing save → Begin screen is skipped; AudioContext bootstraps on first interaction via the click+touchstart+keydown gesture handler (D-22)" + - "Player clicks an empty tile → seed picker DOM popover appears positioned at the tile's screen coords; popover lists currently-unlocked plant types; click outside dismisses (D-02)" + - "Player selects a plant type → command enqueues into the store; next sim tick applies plantSeed; tile state moves from empty → sprout (GARD-01)" + - "Plant advances sprout → mature → ready over its growth duration (per-plant duration in 2–5min band, D-08/D-09); state machine is a pure function of (plantedAtTick, currentTick, growthDurationTicks)" + - "Empty tile renders as faint outline + subtle hover state (D-06); plant primitives render distinct shapes per stage tinted by plant type (D-26); ready tiles pulse via alpha cycle (D-27)" + - "Sim is pure — no Date.now() in src/sim/garden/; all time threaded as injected ticks (CORE-02)" + - "Phaser Garden scene's update() loop reads from the store, calls scheduler.drainTicks with the simulate function, writes results back via simAdapter — no React re-renders trigger render-tier draws" + - "First-interaction gesture handler installed on returning-player loads succeeds at audioContext.resume() on the first click/touchstart/keydown (Pitfall 5 mitigation)" + - "All player-visible Begin-screen and seed-picker copy lives in /content/seasons/01-soil/ui-strings.yaml; nothing player-visible hardcoded in TS (CLAUDE.md externalized-strings rule)" + - "npm run ci is green; sim-purity ESLint rule (Plan 02-01 Task 3) catches any Date.now() leak" + artifacts: + - path: src/sim/garden/types.ts + provides: "Tile, PlantInstance, PlantType, PlantTypeId, GrowthStage interfaces; tileIdx(row,col) and tileCoords(idx) helpers (Pitfall 2: row*4+col canonical encoding)" + exports: ["Tile", "PlantInstance", "PlantType", "PlantTypeId", "GrowthStage", "tileIdx", "tileCoords"] + - path: src/sim/garden/plants.ts + provides: "Static PlantType table — 3 Season-1 plants with distinct durations + tonal-identity slugs" + exports: ["PLANT_TYPES", "getPlantType"] + - path: src/sim/garden/growth.ts + provides: "advanceGrowth(plant, currentTick) → GrowthStage — pure function of plantedAtTick + currentTick + plantType.durationTicks" + exports: ["advanceGrowth", "GROWTH_THRESHOLDS"] + - path: src/sim/garden/commands.ts + provides: "plantSeed(state, args), simulateOneTick(state, currentTick) — pure command applications. Phase 2 wires plantSeed; harvest/compost added in Plan 02-03" + exports: ["plantSeed", "simulateOneTick"] + - path: src/render/garden/tile-renderer.ts + provides: "drawTiles(scene, tiles) — Phaser primitive draw of 16-tile grid with hover state (D-06)" + exports: ["drawTiles"] + - path: src/render/garden/plant-renderer.ts + provides: "drawPlant(scene, tileIdx, plant) — primitive shapes per growth stage tinted by plantType (D-26)" + exports: ["drawPlant"] + - path: src/render/garden/ready-pulse.ts + provides: "applyReadyPulse(scene, tileIdx) — alpha tween for ready cue (D-27)" + exports: ["applyReadyPulse"] + - path: src/render/garden/tile-coords.ts + provides: "tileToScreenCoords(scene, tileIdx) — helper for seed picker positioning (RESEARCH Pattern 4)" + exports: ["tileToScreenCoords", "GRID_LAYOUT"] + - path: src/ui/begin/BeginScreen.tsx + provides: "Tasteful typographic Begin screen — title + Begin button (D-21); calls bootstrapAudioContext on tap, dismisses via session slice (D-22)" + exports: ["BeginScreen"] + - path: src/ui/begin/use-audio-bootstrap.ts + provides: "bootstrapAudioContext() — lazy AudioContext creation + resume; installFirstInteractionGestureHandler() for returning players (RESEARCH Pattern 9)" + exports: ["bootstrapAudioContext", "installFirstInteractionGestureHandler"] + - path: src/ui/garden/SeedPicker.tsx + provides: "Inline DOM popover positioned over Phaser canvas; lists unlocked plant types; commits via store.enqueueCommand (D-02)" + exports: ["SeedPicker"] + - path: src/game/scenes/Garden.ts + provides: "Phaser Garden scene — 4×4 tile grid, pointerdown handlers, scheduler integration, EventBus emissions" + exports: ["Garden"] + - path: content/seasons/01-soil/ui-strings.yaml + provides: "Player-visible Phase 2 UI copy (Begin screen, seed picker, post-harvest beat) — externalized per CLAUDE.md" + - path: src/content/schemas/ui-strings.ts + provides: "UiStringsSchema (Zod) for ui-strings.yaml validation" + exports: ["UiStringsSchema", "UiStrings"] + key_links: + - from: src/ui/begin/BeginScreen.tsx + to: src/ui/begin/use-audio-bootstrap.ts + via: "onClick handler calls bootstrapAudioContext() synchronously inside the click event" + pattern: "bootstrapAudioContext" + - from: src/game/scenes/Garden.ts + to: src/sim/garden/commands.ts + via: "scheduler drains store commands and applies them via simulateOneTick(state, tick)" + pattern: "simulateOneTick" + - from: src/ui/garden/SeedPicker.tsx + to: src/store/index.ts + via: "useAppStore + enqueueCommand({kind: 'plantSeed', tileIdx, plantTypeId})" + pattern: "enqueueCommand" + - from: src/game/scenes/Garden.ts + to: src/game/event-bus.ts + via: "tile pointerdown emits 'tile-clicked-coords' with {tileIdx, screenX, screenY}" + pattern: "tile-clicked-coords" + - from: src/render/garden/plant-renderer.ts + to: src/sim/garden/types.ts + via: "imports PlantType / GrowthStage types only — no behavioral coupling (render reads from store, not from sim modules)" + pattern: "import type" +--- + + +**Wave 1 vertical slice. Depends on Plan 02-01 (foundations).** + +This plan ships the first end-to-end vertical slice: a player can launch the game, press Begin, click an empty tile, choose a plant from the inline picker, and watch it grow on the Phaser canvas. The slice touches every architectural tier (sim → store → render → ui), proving the firewall holds and the foundations work in production-shaped code paths. + +Runs in parallel with Plan 02-03 (Harvest + Journal). Both depend only on 02-01; they share `src/sim/garden/types.ts` (created here), so the integration moment is small. + +3 tasks. Estimated context cost ~50%. `/clear` between tasks if needed. + + + +Ship the Begin → Plant → Grow vertical slice end-to-end. Player loads the page, sees the typographic Begin screen (D-21, AEST-07), taps it (AudioContext.resume() fires synchronously inside the click handler — Pitfall 5 mitigation), the Begin screen dismisses, the Garden scene activates, the player clicks an empty tile, the inline seed picker pops up positioned over the tile (D-02), the player taps a plant type, the command flows through the store → scheduler → sim → store → render, and the player watches a primitive sprout shape appear and grow across the 2–5min duration (D-08, D-09) tinted to the plant type (D-26) with a ready-state pulse (D-27). + +Returning players (save exists) skip the Begin screen entirely (D-22); a first-interaction gesture handler bootstraps audio on the first click/touchstart/keydown. + +Purpose: This is the load-bearing vertical slice — the first feature commit ever, on top of the foundations Plan 02-01 lands. It validates that the Phaser ↔ React Zustand bridge works in real code (RESEARCH Pattern 3), that the ESLint sim-purity rule holds when actual sim code is written, that the inline seed picker DOM-popover-over-canvas pattern works under `Phaser.Scale.FIT` (RESEARCH Assumption A5 — verify here, MEDIUM-risk), and that all player-visible strings live in `/content/`. + +Output: A running game where Begin → Plant → Grow is fully operational on placeholder Phaser primitives. Plan 02-03 lands Harvest → Journal on top of this. Plan 02-05's Playwright e2e exercises the full loop end-to-end. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@CLAUDE.md +@.planning/anti-fomo-doctrine.md +@.planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md +@.planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md +@.planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md +@.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-SUMMARY.md +@.planning/phases/01-foundations-and-doctrine/01-04-SUMMARY.md + + + + +From src/sim/scheduler/index.ts (Plan 02-01): +```typescript +export type { Clock } from './clock'; +export { wallClock, FakeClock } from './clock'; +export const TICK_MS: number; // 200 (5Hz) +export const MAX_OFFLINE_MS: number; +export function drainTicks( + state: S, + accumulatorMs: number, + simulate: (state: S, dtMs: number, silent: boolean) => S, + silent?: boolean, +): { state: S; remainderMs: number; ticksApplied: number }; +``` + +From src/store/index.ts (Plan 02-01): +```typescript +export const appStore: ZustandStore; +export function useAppStore(selector: (s: AppStoreShape) => T): T; +export const simAdapter: { drainCommands(): GardenCommand[]; applyTilesAndUnlocks(...); applyHarvestedFragments(...); applyLuraProgress(...) }; +export interface GardenCommand { kind: 'plantSeed' | 'harvest' | 'compost'; tileIdx: number; plantTypeId?: string } +``` + +From src/game/event-bus.ts (Plan 02-01): +```typescript +export const eventBus: Phaser.Events.EventEmitter; +// Sample events: +// 'scene-ready' (Phaser → React) +// 'tile-clicked-coords' (Phaser → React) {tileIdx, screenX, screenY} +// 'fragment-revealed' (Phaser → React) (Plan 02-03) +``` + +From src/save/index.ts (Phase 1 + Plan 02-01 extension): +```typescript +export interface V1Payload { /* Phase-2-extended; see migrations.ts */ } +export function migrate(payload: unknown, fromVersion: number): { payload: unknown; toVersion: number }; +export function openSaveDB(): Promise; +export function registerSaveLifecycleHooks(config: { saveSync: () => void }): { detach(): void }; +``` + +From src/content/index.ts (Phase 1): +```typescript +export const fragments: Fragment[]; +export { FragmentSchema, type Fragment, SeasonContentSchema, type SeasonContent } from './schemas/index'; +``` + +Existing src/App.tsx (Phase 1 — to be expanded by this plan): +```typescript +function App() { + const phaserRef = useRef(null); + return ( +
+ +
+ ); +} +``` + +Existing src/game/main.ts scene config: +```typescript +scene: [Boot], // Plan 02-02 changes to: scene: [Boot, Garden] +``` + +Existing Boot.create(): +```typescript +create(): void { + // Phase 2 will start the preloader from here. +} +// Plan 02-02 changes to: +create(): void { this.scene.start('Garden'); } +``` + +For SeedPicker positioning (RESEARCH Assumption A5): Phaser uses `Phaser.Scale.FIT` (`src/game/main.ts:16`). Pointer event coordinates from a Phaser scene's pointerdown handler are in canvas pixel space; getBoundingClientRect of `#game-container` may need to be added to translate to viewport coords. Verify on a non-fullscreen window during Task 2. +
+
+ + + + + Task 1: sim/garden core (types + plants table + growth state machine + plantSeed command + simulateOneTick) + + - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 1 lines 434-540, Pitfall 2 lines 1042-1048, Pitfall 10 lines 1118-1124) + - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group C lines 226-272) + - .planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md (D-01 through D-09) + - src/save/migrations.ts (V1Payload — sim/garden types must be structurally compatible) + - src/sim/state.ts (Plan 02-01 SimState root shape) + - src/sim/scheduler/index.ts (TICK_MS, drainTicks signature) + + + src/sim/garden/types.ts, + src/sim/garden/plants.ts, + src/sim/garden/growth.ts, + src/sim/garden/growth.test.ts, + src/sim/garden/commands.ts, + src/sim/garden/commands.test.ts, + src/sim/garden/index.ts + + +**Step 1 — `src/sim/garden/types.ts`:** + +```typescript +/** + * Garden state shapes (CONTEXT D-01: 4×4 fixed grid; D-26: primitive shapes). + * Pure data; sim mutates these via pure-function commands. Per CORE-10 + * firewall, this module is sim — no DOM, no React, no Phaser, no Date.now. + * + * Tile coordinate convention (RESEARCH Pitfall 2): canonical encoding + * tileIdx = row * GRID_COLS + col + * Always use the helpers; never inline the arithmetic. + */ + +export const GRID_ROWS = 4; +export const GRID_COLS = 4; +export const GRID_SIZE = GRID_ROWS * GRID_COLS; // 16 + +export type GrowthStage = 'sprout' | 'mature' | 'ready'; + +export type PlantTypeId = 'rosemary' | 'yarrow' | 'winter-rose'; // 3 Season-1 plants per D-03 + +export interface PlantInstance { + plantTypeId: PlantTypeId; + plantedAtTick: number; // tick number, NOT wall time — per CORE-02 +} + +export interface Tile { + idx: number; // 0..15 + plant: PlantInstance | null; // null = empty +} + +export interface PlantType { + id: PlantTypeId; + /** Display name (player-visible) — sourced from /content/seasons/01-soil/ui-strings.yaml at runtime; this string here is a fallback for build-only test fixtures. */ + fallbackName: string; + /** Growth duration in ticks (TICK_MS=200; 1500 ticks = 5 min). Per D-08/D-09. */ + durationTicks: number; + /** Phaser tint hex per growth stage (D-26). */ + tints: { sprout: number; mature: number; ready: number }; + /** Fragment pool subset filter for MEMR-06 (Plan 02-03 wires this). */ + fragmentTags: readonly string[]; +} + +export function tileIdx(row: number, col: number): number { + if (row < 0 || row >= GRID_ROWS || col < 0 || col >= GRID_COLS) { + throw new Error(`Tile out of range: row=${row} col=${col}`); + } + return row * GRID_COLS + col; +} + +export function tileCoords(idx: number): { row: number; col: number } { + if (idx < 0 || idx >= GRID_SIZE) { + throw new Error(`Tile index out of range: ${idx}`); + } + return { row: Math.floor(idx / GRID_COLS), col: idx % GRID_COLS }; +} +``` + +**Step 2 — `src/sim/garden/plants.ts`** — 3 Season-1 plant types (D-03), distinct durations within 2–5min band (D-09). At TICK_MS=200, 600 ticks = 2min, 900 ticks = 3min, 1500 ticks = 5min. + +```typescript +import type { PlantType, PlantTypeId } from './types'; + +/** + * Three Season-1 plants with tonal identity per the bible's + * "real species, slightly wrong" rule (CLAUDE.md "Tone"). + * + * Names are placeholder pending user review (RESEARCH Open Question 1). + * Tonal register: rosemary (warm) / yarrow (contemplative) / winter-rose (heavy). + * + * Per D-08/D-09: durations vary within a 2–5min active-play band. + * rosemary → 600 ticks ≈ 2 min (the warm short one) + * yarrow → 900 ticks ≈ 3 min (medium contemplative) + * winter-rose → 1500 ticks ≈ 5 min (the heavy slow one) + * + * Tints are placeholders — Phase 3 swaps watercolor textures over these. + */ +export const PLANT_TYPES: Readonly> = Object.freeze({ + rosemary: { + id: 'rosemary', + fallbackName: 'Rosemary', + durationTicks: 600, + tints: { sprout: 0x8aa17a, mature: 0x5d7651, ready: 0xb6c7a8 }, + fragmentTags: ['warm'], + }, + yarrow: { + id: 'yarrow', + fallbackName: 'Yarrow', + durationTicks: 900, + tints: { sprout: 0xc8b89a, mature: 0xa39777, ready: 0xe8d8b6 }, + fragmentTags: ['contemplative'], + }, + 'winter-rose': { + id: 'winter-rose', + fallbackName: 'Winter-rose', + durationTicks: 1500, + tints: { sprout: 0xa9a3b1, mature: 0x7d758a, ready: 0xc7bdd3 }, + fragmentTags: ['heavy'], + }, +}); + +export function getPlantType(id: PlantTypeId): PlantType { + const type = PLANT_TYPES[id]; + if (!type) throw new Error(`Unknown plant type: ${id}`); + return type; +} +``` + +**Step 3 — `src/sim/garden/growth.ts`:** + +```typescript +import type { PlantInstance, PlantType, GrowthStage } from './types'; + +/** + * Sprout (0%) → Mature (33%) → Ready (≥100%). Per CONTEXT D-08/D-09. + * + * Pure function of (plantedAtTick, currentTick, durationTicks). Sim safety: + * no Date.now(), no DOM. The tick scheduler injects currentTick. + */ +export const GROWTH_THRESHOLDS = Object.freeze({ + matureFraction: 0.33, + readyFraction: 1.0, +}); + +export function advanceGrowth(plant: PlantInstance, plantType: PlantType, currentTick: number): GrowthStage { + const ticksSincePlant = Math.max(0, currentTick - plant.plantedAtTick); + const progress = ticksSincePlant / plantType.durationTicks; + if (progress >= GROWTH_THRESHOLDS.readyFraction) return 'ready'; + if (progress >= GROWTH_THRESHOLDS.matureFraction) return 'mature'; + return 'sprout'; +} +``` + +**Step 4 — `src/sim/garden/growth.test.ts`** — exhaustive boundary tests: + +- `advanceGrowth({plantedAtTick: 0}, rosemary, 0)` → `'sprout'`. +- `advanceGrowth({plantedAtTick: 0}, rosemary, 197)` → `'sprout'` (just-below 33%). +- `advanceGrowth({plantedAtTick: 0}, rosemary, 198)` → `'mature'` (≥33%; 600 * 0.33 = 198). +- `advanceGrowth({plantedAtTick: 0}, rosemary, 599)` → `'mature'`. +- `advanceGrowth({plantedAtTick: 0}, rosemary, 600)` → `'ready'`. +- `advanceGrowth({plantedAtTick: 100}, rosemary, 100)` → `'sprout'` (just planted). +- `advanceGrowth({plantedAtTick: 100}, rosemary, 50)` → `'sprout'` (negative delta clamped via Math.max — defends Pitfall 1). +- `advanceGrowth({plantedAtTick: 0}, rosemary, 100000)` → `'ready'` (overgrowth stays 'ready', no overflow stage). + +**Step 5 — `src/sim/garden/commands.ts`:** + +```typescript +import type { SimState } from '../state'; +import type { GardenCommand } from '../../store/garden-slice'; // type-only import; runtime store not loaded by sim +import { PLANT_TYPES, getPlantType } from './plants'; +import type { PlantInstance, PlantTypeId, Tile } from './types'; +import { GRID_SIZE } from './types'; +import { advanceGrowth } from './growth'; + +/** + * Pure command applications. Each returns a NEW SimState — no mutation. + * Time is INJECTED via currentTick. Per CORE-02 + sim-purity ESLint rule. + * + * Phase 2 wires plantSeed here; harvest + compost ship in Plan 02-03. + */ + +export function plantSeed(state: SimState, tileIdx: number, plantTypeId: PlantTypeId, currentTick: number): SimState { + if (tileIdx < 0 || tileIdx >= GRID_SIZE) throw new Error(`Bad tile index: ${tileIdx}`); + const tiles = state.garden.tiles as Tile[]; + const target = tiles[tileIdx]; + if (target?.plant !== null && target?.plant !== undefined) { + return state; // tile occupied — silent no-op (player tap on occupied tile is a render-tier path; sim refuses) + } + // Plant type must be unlocked (D-05 fragment-count thresholds; defaults to ['rosemary'] at game start) + if (!state.unlockedPlantTypes.includes(plantTypeId)) { + return state; + } + const plant: PlantInstance = { plantTypeId, plantedAtTick: currentTick }; + const nextTiles: Tile[] = tiles.map((t, i) => + i === tileIdx ? { idx: i, plant } : t, + ); + return { ...state, garden: { tiles: nextTiles } }; +} + +/** + * Pure single-tick simulation. Drains pending commands, advances all plants. + * Per CORE-02 — fixed-timestep, deterministic from inputs. + * + * Phase 2 Plan 02-02 implements plantSeed only; harvest + compost arrive + * in Plan 02-03 (extended via the kind switch below). + */ +export function simulateOneTick(state: SimState, currentTick: number, commands: GardenCommand[]): SimState { + let next = state; + // Drain commands FIRST so state effects of new commands participate in this tick. + for (const cmd of commands) { + if (cmd.kind === 'plantSeed' && cmd.plantTypeId) { + next = plantSeed(next, cmd.tileIdx, cmd.plantTypeId as PlantTypeId, currentTick); + } + // Plan 02-03 will add 'harvest' and 'compost' branches here. + } + return { ...next, lastTickAt: currentTick }; +} + +/** + * Helper for renderers (read-only): given a Tile, what stage is its plant in? + * Pure; called from src/render/garden/plant-renderer.ts via injected currentTick. + */ +export function tileGrowthStage(tile: Tile, currentTick: number): GrowthStage | null { + if (!tile.plant) return null; + const type = PLANT_TYPES[tile.plant.plantTypeId]; + if (!type) return null; + return advanceGrowth(tile.plant, type, currentTick); +} + +import type { GrowthStage } from './types'; +``` + +(Order the `import type { GrowthStage }` near the top of the file, not at the bottom — ESLint will complain otherwise. Keep all imports at file head.) + +**Step 6 — `src/sim/garden/commands.test.ts`** — exhaustive Vitest cases: + +- Empty initial state has 16 null tiles (constructed via helper). +- `plantSeed(state, 0, 'rosemary', 100)` with `unlockedPlantTypes=['rosemary']` returns new state with tile[0].plant = `{plantTypeId: 'rosemary', plantedAtTick: 100}`; original state.garden.tiles[0] still null (immutability). +- `plantSeed(state, 0, 'yarrow', 100)` with `unlockedPlantTypes=['rosemary']` (yarrow locked) returns state unchanged. +- `plantSeed(state, 0, 'rosemary', 100)` then `plantSeed(state', 0, 'rosemary', 200)` — second call returns state' unchanged (tile occupied; silent no-op). +- `plantSeed(state, 16, ...)` throws (out-of-range tileIdx). +- `simulateOneTick` with one plantSeed command applies it AND updates `lastTickAt: currentTick`. +- `simulateOneTick` with no commands updates only `lastTickAt`. +- `tileGrowthStage` returns null for empty tile, returns the correct stage for a plant. + +**Step 7 — `src/sim/garden/index.ts`** — barrel: +```typescript +export type { Tile, PlantInstance, PlantType, PlantTypeId, GrowthStage } from './types'; +export { GRID_ROWS, GRID_COLS, GRID_SIZE, tileIdx, tileCoords } from './types'; +export { PLANT_TYPES, getPlantType } from './plants'; +export { advanceGrowth, GROWTH_THRESHOLDS } from './growth'; +export { plantSeed, simulateOneTick, tileGrowthStage } from './commands'; +``` + +Also extend `src/sim/index.ts` to re-export `* from './garden'` (or specific symbols). + +**Commit:** `feat(02-02): sim/garden — types, plants table, growth state machine, plantSeed`. Run `npm run lint && npx vitest run src/sim/garden/` before committing. + + + - `grep -q "GRID_ROWS = 4" src/sim/garden/types.ts` and `grep -q "GRID_COLS = 4" src/sim/garden/types.ts` + - `grep -q "PlantTypeId = 'rosemary' | 'yarrow' | 'winter-rose'" src/sim/garden/types.ts` + - `grep -q "durationTicks: 600" src/sim/garden/plants.ts` (rosemary) + - `grep -q "durationTicks: 1500" src/sim/garden/plants.ts` (winter-rose) + - `grep -L "Date.now" src/sim/garden/types.ts src/sim/garden/plants.ts src/sim/garden/growth.ts src/sim/garden/commands.ts` (none of these may contain Date.now per the ESLint rule) + - `npx vitest run src/sim/garden/` exits 0 with ≥15 passing tests + - `npm run lint` exits 0 (the sim-purity rule from Plan 02-01 catches Date.now leaks here) + - `npm run build` exits 0 + + + npm run lint && npx vitest run src/sim/garden/ && npm run build + + + sim/garden core lands with 3 plant types, growth state machine, plantSeed command, simulateOneTick orchestrator. Pure functions throughout. ESLint sim-purity rule confirms no Date.now calls. ≥15 Vitest tests green. + + + + + Task 2: Render layer (Phaser Garden scene + tile/plant/ready-pulse renderers + tile-coords helper) and main.ts/Boot.ts wiring + + - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 4 lines 698-740 inline seed picker, Pitfall 6 lines 1086-1092 stale-closure subscribe pattern, Assumption A5 lines 1212-1213) + - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group H lines 426-468, Group L lines 621-660) + - src/game/main.ts (current Phaser config — must add Garden scene) + - src/game/scenes/Boot.ts (current empty create() — must transition to Garden) + - src/PhaserGame.tsx (Phaser-React bridge — Phase 2 wires save lifecycle hooks here in Task 3) + + + src/render/garden/tile-renderer.ts, + src/render/garden/plant-renderer.ts, + src/render/garden/ready-pulse.ts, + src/render/garden/tile-coords.ts, + src/render/garden/index.ts, + src/render/index.ts, + src/game/scenes/Garden.ts, + src/game/main.ts, + src/game/scenes/Boot.ts + + +**Step 1 — `src/render/garden/tile-coords.ts`** — the load-bearing helper for seed picker positioning. Defends Assumption A5 (MEDIUM risk per RESEARCH). + +```typescript +import * as Phaser from 'phaser'; +import { GRID_ROWS, GRID_COLS, GRID_SIZE } from '../../sim/garden/types'; + +/** + * 4×4 garden layout in canvas pixel coordinates. Centered in the + * 1024×768 game area declared in src/game/main.ts. + * + * Tile size + spacing chosen so the grid sits comfortably with margins + * for Phase-3 watercolor frames. Phase 2 ships placeholder primitives + * inside these bounds. + */ +export const GRID_LAYOUT = Object.freeze({ + tileSize: 96, // px + tileGap: 16, // px between tiles + gridOriginX: 240, // top-left of grid in canvas px (centered: (1024 - (4*96 + 3*16))/2 = 248 ≈ 240) + gridOriginY: 144, // top-left of grid in canvas px (centered: (768 - (4*96 + 3*16))/2 = 168 ≈ 144) +}); + +export function tileTopLeftCanvas(idx: number): { x: number; y: number } { + if (idx < 0 || idx >= GRID_SIZE) throw new Error(`Bad tile idx: ${idx}`); + const row = Math.floor(idx / GRID_COLS); + const col = idx % GRID_COLS; + const x = GRID_LAYOUT.gridOriginX + col * (GRID_LAYOUT.tileSize + GRID_LAYOUT.tileGap); + const y = GRID_LAYOUT.gridOriginY + row * (GRID_LAYOUT.tileSize + GRID_LAYOUT.tileGap); + return { x, y }; +} + +export function tileCenterCanvas(idx: number): { x: number; y: number } { + const tl = tileTopLeftCanvas(idx); + return { x: tl.x + GRID_LAYOUT.tileSize / 2, y: tl.y + GRID_LAYOUT.tileSize / 2 }; +} + +/** + * Convert a tile center from canvas pixel space to viewport DOM coordinates. + * The seed picker (DOM popover) uses this to mount itself in absolute-position + * over the canvas (RESEARCH Pattern 4 + Assumption A5). + * + * Phaser.Scale.FIT scales + letterboxes; we need the actual canvas DOMRect + * to translate canvas-space → CSS pixel space. + */ +export function tileCenterToDom(scene: Phaser.Scene, idx: number): { x: number; y: number } { + const center = tileCenterCanvas(idx); + const canvas = scene.game.canvas; + const rect = canvas.getBoundingClientRect(); + const scaleX = rect.width / scene.game.scale.width; + const scaleY = rect.height / scene.game.scale.height; + return { + x: rect.left + center.x * scaleX, + y: rect.top + center.y * scaleY, + }; +} +``` + +**Step 2 — `src/render/garden/tile-renderer.ts`** — primitive draws (D-06): + +```typescript +import * as Phaser from 'phaser'; +import { GRID_SIZE } from '../../sim/garden/types'; +import { tileTopLeftCanvas, GRID_LAYOUT } from './tile-coords'; + +/** + * Empty-tile look: faint outlined rounded rectangle with subtle hover. + * Per CONTEXT D-06; Phase 3 paints the watercolor treatment. + */ +const OUTLINE_COLOR = 0x4d4d52; +const OUTLINE_HOVER = 0x6e6e75; +const OUTLINE_ALPHA = 0.6; + +export interface TileGameObjects { + /** Hit-area rectangle (interactive). */ + hit: Phaser.GameObjects.Rectangle; + /** Outline graphic. */ + outline: Phaser.GameObjects.Graphics; +} + +export function drawTiles(scene: Phaser.Scene): TileGameObjects[] { + const tiles: TileGameObjects[] = []; + for (let i = 0; i < GRID_SIZE; i++) { + const tl = tileTopLeftCanvas(i); + const cx = tl.x + GRID_LAYOUT.tileSize / 2; + const cy = tl.y + GRID_LAYOUT.tileSize / 2; + + // Outline graphic + const g = scene.add.graphics(); + g.lineStyle(2, OUTLINE_COLOR, OUTLINE_ALPHA); + g.strokeRoundedRect(tl.x, tl.y, GRID_LAYOUT.tileSize, GRID_LAYOUT.tileSize, 6); + + // Hit rectangle (transparent, interactive) + const hit = scene.add.rectangle(cx, cy, GRID_LAYOUT.tileSize, GRID_LAYOUT.tileSize, 0xffffff, 0); + hit.setInteractive({ useHandCursor: true }); + hit.on('pointerover', () => { + g.clear(); + g.lineStyle(2, OUTLINE_HOVER, OUTLINE_ALPHA); + g.strokeRoundedRect(tl.x, tl.y, GRID_LAYOUT.tileSize, GRID_LAYOUT.tileSize, 6); + }); + hit.on('pointerout', () => { + g.clear(); + g.lineStyle(2, OUTLINE_COLOR, OUTLINE_ALPHA); + g.strokeRoundedRect(tl.x, tl.y, GRID_LAYOUT.tileSize, GRID_LAYOUT.tileSize, 6); + }); + + // Tag the hit object with its index for handler dispatch + hit.setData('tileIdx', i); + + tiles.push({ hit, outline: g }); + } + return tiles; +} +``` + +**Step 3 — `src/render/garden/plant-renderer.ts`** — primitive shapes per stage (D-26): + +```typescript +import * as Phaser from 'phaser'; +import type { Tile, GrowthStage, PlantTypeId } from '../../sim/garden/types'; +import { PLANT_TYPES } from '../../sim/garden/plants'; +import { tileCenterCanvas, GRID_LAYOUT } from './tile-coords'; + +/** + * Plant primitives per CONTEXT D-26. + * sprout = small dot (radius 6) + * mature = stem rectangle (width 4, height 24) + * ready = bloom shape (small filled circle, radius 18) + * + * Tinted by plant type (PLANT_TYPES[plantTypeId].tints[stage]). + * Phase 3 swaps in painted sprites without touching this signature. + */ + +export interface PlantGameObject { + shape: Phaser.GameObjects.Shape; + stage: GrowthStage; +} + +export function drawPlant(scene: Phaser.Scene, tileIdx: number, tile: Tile, stage: GrowthStage): PlantGameObject | null { + if (!tile.plant) return null; + const type = PLANT_TYPES[tile.plant.plantTypeId]; + if (!type) return null; + const center = tileCenterCanvas(tileIdx); + const tint = type.tints[stage]; + + let shape: Phaser.GameObjects.Shape; + if (stage === 'sprout') { + shape = scene.add.circle(center.x, center.y + GRID_LAYOUT.tileSize / 4, 6, tint); + } else if (stage === 'mature') { + shape = scene.add.rectangle(center.x, center.y, 4, 24, tint); + } else { + shape = scene.add.circle(center.x, center.y, 18, tint); + } + return { shape, stage }; +} + +export function destroyPlant(obj: PlantGameObject | null): void { + obj?.shape.destroy(); +} +``` + +**Step 4 — `src/render/garden/ready-pulse.ts`** — alpha-cycle pulse (D-27): + +```typescript +import * as Phaser from 'phaser'; + +/** + * Subtle alpha pulse on ready-stage plants. Per CONTEXT D-27. Phase 3 + * paints over with a warmer light treatment. + * + * Returns the tween so the scene can stop it when the plant is harvested. + */ +export function applyReadyPulse(scene: Phaser.Scene, target: Phaser.GameObjects.GameObject): Phaser.Tweens.Tween { + return scene.tweens.add({ + targets: target, + alpha: { from: 0.7, to: 1.0 }, + duration: 1200, + ease: 'Sine.easeInOut', + yoyo: true, + repeat: -1, + }); +} +``` + +**Step 5 — `src/render/garden/index.ts`** — barrel: +```typescript +export { drawTiles } from './tile-renderer'; +export type { TileGameObjects } from './tile-renderer'; +export { drawPlant, destroyPlant } from './plant-renderer'; +export type { PlantGameObject } from './plant-renderer'; +export { applyReadyPulse } from './ready-pulse'; +export { tileTopLeftCanvas, tileCenterCanvas, tileCenterToDom, GRID_LAYOUT } from './tile-coords'; +``` + +**Step 6 — `src/render/index.ts`** — top-level render barrel: +```typescript +export * from './garden'; +``` + +**Step 7 — `src/game/scenes/Garden.ts`** — the Phaser scene that wires it all together: + +```typescript +import * as Phaser from 'phaser'; +import { eventBus } from '../event-bus'; +import { drainTicks, TICK_MS, wallClock, type Clock } from '../../sim/scheduler'; +import type { SimState } from '../../sim/state'; +import { simulateOneTick, tileGrowthStage } from '../../sim/garden'; +import type { Tile } from '../../sim/garden/types'; +import { drawTiles, drawPlant, destroyPlant, applyReadyPulse, tileCenterToDom, type TileGameObjects, type PlantGameObject } from '../../render/garden'; +import { appStore, simAdapter } from '../../store'; + +/** + * The 4×4 garden scene (CONTEXT D-01). Wires the tick scheduler into + * Phaser's update() loop, draws tiles, dispatches pointer events to + * the EventBus + store, and re-renders plants on store changes. + * + * The Garden scene is the ONLY place where sim + store + render meet. + * It stays thin (RESEARCH Pattern 3 line 660): subscribe, dispatch. + */ +export class Garden extends Phaser.Scene { + private accumulatorMs = 0; + private lastFrameMs = 0; + private clock: Clock = wallClock; + private currentTick = 0; + private tileObjs: TileGameObjects[] = []; + private plantObjs: Map = new Map(); + private readyTweens: Map = new Map(); + private storeUnsubscribe: (() => void) | null = null; + + constructor() { + super('Garden'); + } + + create(): void { + // Allow URL ?devtime=fake to swap in a FakeClock for Playwright (Plan 02-05). + // Production-guarded via import.meta.env.PROD in src/PhaserGame.tsx; the + // Garden scene reads the chosen clock from a window-scoped slot. + if ((window as unknown as { __tlgClock?: Clock }).__tlgClock) { + this.clock = (window as unknown as { __tlgClock: Clock }).__tlgClock; + } + + this.tileObjs = drawTiles(this); + this.tileObjs.forEach((t, idx) => { + t.hit.on('pointerdown', () => this.handleTilePointerDown(idx)); + }); + + this.lastFrameMs = this.clock.now(); + + // Re-render plants when tiles change in the store (Pitfall 6 mitigation: + // subscribe rather than read once in create()). + this.storeUnsubscribe = appStore.subscribe((state) => { + this.repaintPlants(state.tiles as Tile[]); + }); + this.repaintPlants(appStore.getState().tiles as Tile[]); + + eventBus.emit('scene-ready', this); + } + + update(_time: number, _delta: number): void { + const now = this.clock.now(); + const deltaMs = now - this.lastFrameMs; + this.lastFrameMs = now; + if (deltaMs > 0) this.accumulatorMs += deltaMs; + + // Build current SimState snapshot from the store + drain commands. + const storeState = appStore.getState(); + const commands = simAdapter.drainCommands(); + const simStateNow: SimState = { + garden: { tiles: storeState.tiles }, + plants: [], + harvestedFragmentIds: storeState.harvestedFragmentIds, + lastTickAt: this.currentTick, + unlockedPlantTypes: storeState.unlockedPlantTypes, + luraBeatProgress: storeState.luraBeatProgress, + offlineEvents: null, + settings: { musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8, persistenceToastShown: storeState.persistenceToastShown }, + }; + + const result = drainTicks( + simStateNow, + this.accumulatorMs, + (s, _dtMs, _silent) => { + const next = simulateOneTick(s, this.currentTick + 1, commands); + this.currentTick++; + return next; + }, + ); + this.accumulatorMs = result.remainderMs; + + // Apply tile state back to the store (other slices unchanged). + if (result.ticksApplied > 0) { + simAdapter.applyTilesAndUnlocks( + result.state.garden.tiles, + result.state.unlockedPlantTypes, + ); + } + } + + private handleTilePointerDown(idx: number): void { + const tiles = appStore.getState().tiles as Tile[]; + const tile = tiles[idx]; + if (!tile || !tile.plant) { + // Empty tile — emit event for the React seed picker. + const dom = tileCenterToDom(this, idx); + eventBus.emit('tile-clicked-coords', { tileIdx: idx, screenX: dom.x, screenY: dom.y }); + return; + } + // Plan 02-03 wires harvest/compost on plant click. + } + + private repaintPlants(tiles: Tile[]): void { + for (let idx = 0; idx < tiles.length; idx++) { + const tile = tiles[idx]; + const stage = tile?.plant ? tileGrowthStage(tile, this.currentTick) : null; + const existing = this.plantObjs.get(idx); + if (!stage || !tile?.plant) { + if (existing) { + destroyPlant(existing); + this.plantObjs.delete(idx); + this.readyTweens.get(idx)?.stop(); + this.readyTweens.delete(idx); + } + continue; + } + // Repaint if missing or stage changed. + if (!existing || existing.stage !== stage) { + if (existing) destroyPlant(existing); + const next = drawPlant(this, idx, tile, stage); + if (next) { + this.plantObjs.set(idx, next); + if (stage === 'ready') { + this.readyTweens.get(idx)?.stop(); + this.readyTweens.set(idx, applyReadyPulse(this, next.shape)); + } + } + } + } + } + + destroy(): void { + this.storeUnsubscribe?.(); + this.readyTweens.forEach((t) => t.stop()); + this.readyTweens.clear(); + this.plantObjs.forEach((p) => destroyPlant(p)); + this.plantObjs.clear(); + } +} +``` + +**Step 8 — Update `src/game/main.ts`:** + +```typescript +import * as Phaser from 'phaser'; +import { Boot } from './scenes/Boot.ts'; +import { Garden } from './scenes/Garden.ts'; + +const config: Phaser.Types.Core.GameConfig = { + type: Phaser.AUTO, + width: 1024, + height: 768, + parent: 'game-container', + backgroundColor: '#1a1a1a', + scale: { + mode: Phaser.Scale.FIT, + autoCenter: Phaser.Scale.CENTER_BOTH, + }, + scene: [Boot, Garden], +}; + +const StartGame = (parent: string): Phaser.Game => { + return new Phaser.Game({ ...config, parent }); +}; + +export default StartGame; +``` + +**Step 9 — Update `src/game/scenes/Boot.ts`:** + +```typescript +import * as Phaser from 'phaser'; + +/** + * Phase 2: Boot scene transitions to Garden once Phaser is up. + * No assets to load in Phase 2 (D-26 = Phaser primitives only). + */ +export class Boot extends Phaser.Scene { + constructor() { + super('Boot'); + } + + preload(): void {} + + create(): void { + this.scene.start('Garden'); + } +} +``` + +**Manual smoke** (the executor SHOULD do this once during the task to verify Assumption A5): `npm run dev`, open `http://localhost:5173`, click the page once (so the in-progress Begin gate doesn't block; Task 3 of this plan will gate Begin properly). Confirm 16 outlined tiles render in a 4×4 grid centered on the canvas. Resize the browser to a non-fullscreen window — the tiles should remain inside the canvas (Phaser.Scale.FIT). The seed picker in Task 3 will use `tileCenterToDom` to position itself; verify visually then. + +**Commit:** `feat(02-02): render layer + Garden scene + scheduler integration`. Run `npm run lint && npm run build` before committing (Vitest tests for render-tier are minimal — Phaser scenes need a real canvas; rely on the Playwright e2e in Plan 02-05 for behavioral coverage). + + + - `grep -q "scene: \[Boot, Garden\]" src/game/main.ts` + - `grep -q "this.scene.start('Garden')" src/game/scenes/Boot.ts` + - `grep -q "export class Garden extends Phaser.Scene" src/game/scenes/Garden.ts` + - `grep -q "drainTicks" src/game/scenes/Garden.ts` (scheduler wired into update loop) + - `grep -q "appStore.subscribe" src/game/scenes/Garden.ts` (Pitfall 6 mitigation: subscribe, don't read-once) + - `grep -q "tile-clicked-coords" src/game/scenes/Garden.ts` (EventBus emission) + - `grep -q "tileCenterToDom" src/render/garden/tile-coords.ts` + - `grep -L "Date.now" src/render/garden/tile-renderer.ts src/render/garden/plant-renderer.ts src/render/garden/ready-pulse.ts src/render/garden/tile-coords.ts` (render is sim-adjacent — should not need wall clock; clock comes from scene) + - `npm run lint` exits 0 + - `npm run build` exits 0 + + + npm run lint && npm run build + + + Garden scene wires scheduler + EventBus + store + render. 4×4 tile grid renders with hover state. Plants repaint reactively when store changes. Tile pointerdown emits coords for the React seed picker. main.ts/Boot.ts updated. Manual smoke confirms tiles visible at `npm run dev`. + + + + + Task 3: BeginScreen + audio bootstrap + SeedPicker + UI strings + lazy-content schema + App.tsx wiring + + - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 9 lines 942-992 audio bootstrap, Pattern 4 lines 698-740 seed picker, Pitfall 5 lines 1076-1084 iOS lazy-create) + - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group I lines 471-518) + - src/App.tsx (current — extend with overlays) + - src/PhaserGame.tsx (current — wire EventBus subscription + save lifecycle) + - src/content/loader.ts (Phase 1 — extend for ui-strings.yaml) + - src/content/schemas/index.ts (Phase 1 — add UiStringsSchema export) + - .planning/anti-fomo-doctrine.md (Begin copy must comply: no nag, no FOMO, contemplative tone) + + + src/ui/begin/BeginScreen.tsx, + src/ui/begin/BeginScreen.test.tsx, + src/ui/begin/use-audio-bootstrap.ts, + src/ui/begin/index.ts, + src/ui/garden/SeedPicker.tsx, + src/ui/garden/SeedPicker.test.tsx, + src/ui/garden/index.ts, + src/ui/index.ts, + content/seasons/01-soil/ui-strings.yaml, + src/content/schemas/ui-strings.ts, + src/content/schemas/index.ts, + src/content/loader.ts, + src/content/index.ts, + content/seasons/00-demo/fragments.yaml, + src/PhaserGame.tsx, + src/App.tsx + + +**Step 1 — Install `@testing-library/react`** for component tests: +``` +npm install -D @testing-library/react @testing-library/dom +``` +(If already installed by Plan 02-01 Task 2, this is a no-op confirm.) + +**Step 2 — Author `content/seasons/01-soil/ui-strings.yaml`** (player-visible Phase 2 copy; tone matches the bible voice — see CLAUDE.md "Tone" + anti-fomo-doctrine.md): + +```yaml +# Player-visible Phase 2 UI copy. Externalized per CLAUDE.md. +# Reviewed against bible voice + anti-FOMO doctrine. +season: 1 + +begin: + title: "The Last Garden" + subtitle: "tend" + cta: "Begin" + +seed_picker: + title: "Sow" + cancel: "Not yet" + +post_harvest_beat: + - "The earth remembers." + - "Something stayed." + - "It rests where it grew." + +journal: + empty_state: "Nothing yet. Plant something." + back: "Close" + +settings: + title: "Settings" + export: "Save to a copy" + import: "Restore from a copy" + restore_snapshot: "Earlier garden" + persistence_denied_toast: "The garden may forget, if your browser asks it to." + +# Plant display names — sourced here so the writer can adjust without +# touching src/sim/garden/plants.ts (which carries fallbackName for tests). +plants: + rosemary: "Rosemary" + yarrow: "Yarrow" + winter-rose: "Winter-rose" +``` + +(Copy is a starting draft; user reviews. Bible voice: short, specific, intermittent, sometimes funny, sometimes devastating.) + +**Step 3 — `src/content/schemas/ui-strings.ts`:** + +```typescript +import { z } from 'zod'; + +export const UiStringsSchema = z.object({ + season: z.number().int().min(0).max(7), + begin: z.object({ + title: z.string().min(1), + subtitle: z.string().min(1), + cta: z.string().min(1), + }), + seed_picker: z.object({ + title: z.string().min(1), + cancel: z.string().min(1), + }), + post_harvest_beat: z.array(z.string().min(1)).min(1), + journal: z.object({ + empty_state: z.string().min(1), + back: z.string().min(1), + }), + settings: z.object({ + title: z.string().min(1), + export: z.string().min(1), + import: z.string().min(1), + restore_snapshot: z.string().min(1), + persistence_denied_toast: z.string().min(1), + }), + plants: z.record(z.string(), z.string().min(1)), +}); + +export type UiStrings = z.infer; +``` + +**Step 4 — Update `src/content/schemas/index.ts`:** + +```typescript +export { FragmentSchema, type Fragment } from './fragment.ts'; +export { SeasonContentSchema, type SeasonContent } from './season.ts'; +export { UiStringsSchema, type UiStrings } from './ui-strings.ts'; +``` + +**Step 5 — Extend `src/content/loader.ts`** with PIPE-02 lazy split for season fragments AND a synchronous load for ui-strings.yaml. + +Rules: +1. Keep the existing `yamlFiles` and `mdFiles` globs working (do not break Phase 1's `loader.test.ts`). +2. Add a NEW eager glob for `ui-strings.yaml` that loads synchronously at module-eval (the Begin screen reads it on first paint — no time to await). +3. Add a NEW lazy glob `loadSeasonFragments(seasonId)` for PIPE-02. The eager `fragments` export stays for now (Plan 02-03 may switch the consuming code to lazy). + +```typescript +// (top of file — keep existing imports; add UiStringsSchema) +import { SeasonContentSchema, FragmentSchema, UiStringsSchema, type Fragment, type UiStrings } from './schemas/index.ts'; + +// (existing yamlFiles, mdFiles, loadYamlFragments, loadMdFragments stay UNCHANGED — Plan 02-03 may switch later) + +/** + * UI strings for the active Season. Loaded eagerly so first paint can + * reference any string without await. Per CLAUDE.md externalized-strings rule. + */ +const uiStringFiles = import.meta.glob('/content/seasons/*/ui-strings.yaml', { + eager: true, + query: '?raw', + import: 'default', +}) as Record; + +function loadUiStrings(): Record { + const result: Record = {}; + for (const [path, raw] of Object.entries(uiStringFiles)) { + const data = parseYAML(raw); + const parsed = UiStringsSchema.safeParse(data); + if (!parsed.success) { + throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`); + } + result[parsed.data.season] = parsed.data; + } + return result; +} + +export const uiStrings: Record = loadUiStrings(); + +/** + * PIPE-02 — per-Season lazy chunk. Phase 2 has only Season 1; the wiring + * is here so Phase 4 (Season 2) inherits without rework. RESEARCH Pattern 8. + */ +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', +}); + +function pad2(n: number): string { return n.toString().padStart(2, '0'); } + +export async function loadSeasonFragments(seasonId: number): Promise { + const yamlMatch = Object.entries(lazyYamlFragments).filter(([p]) => p.includes(`/${pad2(seasonId)}-`)); + const mdMatch = Object.entries(lazyMdFragments).filter(([p]) => p.includes(`/${pad2(seasonId)}-`)); + const yamlOut: Fragment[] = []; + for (const [path, loader] of yamlMatch) { + const raw = (await loader()) as string; + const parsed = SeasonContentSchema.safeParse(parseYAML(raw)); + if (!parsed.success) throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`); + yamlOut.push(...parsed.data.fragments); + } + const mdOut: Fragment[] = []; + for (const [path, loader] of mdMatch) { + const raw = (await loader()) as string; + const { data, content } = grayMatter(raw); + const merged = { ...data, body: content.trim() }; + const parsed = FragmentSchema.safeParse(merged); + if (!parsed.success) throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`); + mdOut.push(parsed.data); + } + return [...yamlOut, ...mdOut]; +} +``` + +**Step 6 — Update `src/content/index.ts`:** + +```typescript +export { fragments, loadFragmentsFromGlob, loadSeasonFragments, uiStrings } from './loader.ts'; +export { FragmentSchema, type Fragment, SeasonContentSchema, type SeasonContent, UiStringsSchema, type UiStrings } from './schemas/index.ts'; +``` + +**Step 7 — Delete `content/seasons/00-demo/fragments.yaml`** per CONTEXT canonical_refs ("Phase 2 removes this file when Season 1 is authored"). Replace with a placeholder Season-1 fragments.yaml so the existing fragment loader still works: + +```yaml +# content/seasons/01-soil/fragments.yaml — Phase 2 placeholder. Plan 02-03 +# replaces with ≥10 real Season-1 fragments authored in voice. The single +# placeholder fragment here keeps the eager loader green during Plan 02-02 +# (Plan 02-03 expands). +fragments: + - id: season1.soil.placeholder + season: 1 + body: "(placeholder — Plan 02-03 ships authored fragments)" +``` + +(Plan 02-03 owns the real Season-1 content authoring. Plan 02-02 ships the structural placeholder so the eager loader sees ≥1 valid Season-1 fragment.) + +**Step 8 — `src/ui/begin/use-audio-bootstrap.ts`** — copy RESEARCH Pattern 9 lines 949-987 verbatim: + +```typescript +let _ctx: AudioContext | null = null; +let _resumed = false; + +/** + * Lazy-create + resume AudioContext (AEST-07 + RESEARCH Pattern 9). + * MUST be called synchronously inside a click handler (Pitfall 5: iOS + * Safari requires the context to be CREATED inside the gesture, not + * just resumed). + */ +export async function bootstrapAudioContext(): Promise { + if (_resumed && _ctx) return _ctx; + if (!_ctx) { + try { + const Ctor = typeof AudioContext !== 'undefined' + ? AudioContext + : (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!Ctor) return null; + _ctx = new Ctor(); + } catch { + return null; + } + } + try { + await _ctx.resume(); + _resumed = true; + return _ctx; + } catch { + return null; + } +} + +/** + * For returning players (D-22): no Begin screen, but the next click / + * touch / keypress must bootstrap audio. + */ +export function installFirstInteractionGestureHandler(): void { + const handler = () => { + void bootstrapAudioContext(); + document.removeEventListener('click', handler); + document.removeEventListener('touchstart', handler); + document.removeEventListener('keydown', handler); + }; + document.addEventListener('click', handler); + document.addEventListener('touchstart', handler); + document.addEventListener('keydown', handler); +} + +/** Test-only: reset module-level state between tests. */ +export function __resetAudioBootstrapForTest(): void { + _ctx = null; + _resumed = false; +} +``` + +**Step 9 — `src/ui/begin/BeginScreen.tsx`:** + +```typescript +import { useAppStore } from '../../store'; +import { uiStrings } from '../../content'; +import { bootstrapAudioContext } from './use-audio-bootstrap'; + +/** + * D-21 + AEST-07: tasteful typographic Begin screen. Phase 3 swaps in + * the painted gesture-gate without changing this file's behavior. + * + * D-22: shown on first run only — gated by session.beginGateDismissed. + */ +export function BeginScreen(): JSX.Element | null { + const dismissed = useAppStore((s) => s.beginGateDismissed); + const dismissBeginGate = useAppStore((s) => s.dismissBeginGate); + + if (dismissed) return null; + + const strings = uiStrings[1]?.begin; + if (!strings) return null; + + const onBegin = () => { + void bootstrapAudioContext(); // synchronous-inside-click; MUST not be inside useEffect (Pitfall 5) + dismissBeginGate(); + }; + + return ( +
+

{strings.title}

+

{strings.subtitle}

+ +
+ ); +} +``` + +**Step 10 — `src/ui/begin/BeginScreen.test.tsx`** — Vitest + happy-dom + @testing-library/react: + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { BeginScreen } from './BeginScreen'; +import { appStore } from '../../store'; +import { __resetAudioBootstrapForTest } from './use-audio-bootstrap'; + +describe('BeginScreen (AEST-07, D-21, D-22)', () => { + beforeEach(() => { + appStore.setState({ beginGateDismissed: false }); + __resetAudioBootstrapForTest(); + }); + + it('renders the title and Begin CTA when not dismissed', () => { + render(); + expect(screen.getByText('The Last Garden')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Begin' })).toBeTruthy(); + }); + + it('renders nothing when beginGateDismissed=true (D-22 returning-player skip)', () => { + appStore.setState({ beginGateDismissed: true }); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('dismisses the gate and triggers audio bootstrap on click', async () => { + // happy-dom does not implement AudioContext; spy on the module-level + // function via dynamic import of use-audio-bootstrap to assert the call. + const audio = await import('./use-audio-bootstrap'); + const spy = vi.spyOn(audio, 'bootstrapAudioContext').mockResolvedValue(null); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Begin' })); + expect(spy).toHaveBeenCalledTimes(1); + expect(appStore.getState().beginGateDismissed).toBe(true); + spy.mockRestore(); + }); +}); +``` + +**Step 11 — `src/ui/begin/index.ts`** — barrel: +```typescript +export { BeginScreen } from './BeginScreen'; +export { bootstrapAudioContext, installFirstInteractionGestureHandler } from './use-audio-bootstrap'; +``` + +**Step 12 — `src/ui/garden/SeedPicker.tsx`:** + +```typescript +import { useEffect, useState } from 'react'; +import { eventBus } from '../../game/event-bus'; +import { useAppStore } from '../../store'; +import { uiStrings } from '../../content'; +import { PLANT_TYPES } from '../../sim/garden'; +import type { PlantTypeId } from '../../sim/garden/types'; + +interface PickerState { + visible: boolean; + tileIdx: number; + x: number; + y: number; +} + +/** + * D-02 — inline DOM popover positioned over the Phaser canvas. + * Listens for `tile-clicked-coords` from the Garden scene; mounts itself + * absolutely-positioned at those screen coords. Click outside dismisses. + */ +export function SeedPicker(): JSX.Element | null { + const [picker, setPicker] = useState({ visible: false, tileIdx: -1, x: 0, y: 0 }); + const unlocked = useAppStore((s) => s.unlockedPlantTypes); + const enqueueCommand = useAppStore((s) => s.enqueueCommand); + const strings = uiStrings[1]?.seed_picker; + const plantStrings = uiStrings[1]?.plants ?? {}; + + useEffect(() => { + const onCoords = (payload: { tileIdx: number; screenX: number; screenY: number }) => { + setPicker({ visible: true, tileIdx: payload.tileIdx, x: payload.screenX, y: payload.screenY }); + }; + eventBus.on('tile-clicked-coords', onCoords); + return () => { eventBus.off('tile-clicked-coords', onCoords); }; + }, []); + + useEffect(() => { + if (!picker.visible) return; + // Defer so the click that opened the picker doesn't dismiss it. + const t = setTimeout(() => { + const onClick = () => setPicker((p) => ({ ...p, visible: false })); + document.addEventListener('click', onClick, { once: true }); + return () => document.removeEventListener('click', onClick); + }, 0); + return () => clearTimeout(t); + }, [picker.visible]); + + if (!picker.visible || !strings) return null; + + const onSelect = (plantTypeId: PlantTypeId) => { + enqueueCommand({ kind: 'plantSeed', tileIdx: picker.tileIdx, plantTypeId }); + setPicker((p) => ({ ...p, visible: false })); + }; + + // Translate screen coords to picker top-left (centered above tile). + const left = picker.x - 80; + const top = picker.y - 120; + + return ( +
e.stopPropagation()} + style={{ + position: 'fixed', left, top, zIndex: 50, + background: '#2a2a2e', color: '#e8e0d0', + padding: '0.6rem 0.8rem', borderRadius: 4, + boxShadow: '0 6px 18px rgba(0,0,0,0.4)', + fontFamily: 'serif', + minWidth: 160, + }} + > +
{strings.title}
+ {unlocked.length === 0 && ( +
+ )} + {unlocked.map((id) => { + const type = PLANT_TYPES[id as PlantTypeId]; + if (!type) return null; + const display = plantStrings[id] ?? type.fallbackName; + return ( + + ); + })} +
+ ); +} +``` + +**Step 13 — `src/ui/garden/SeedPicker.test.tsx`** — Vitest + @testing-library/react: + +- Initial render returns null (not visible). +- Emitting `tile-clicked-coords` via `eventBus.emit('tile-clicked-coords', {tileIdx: 0, screenX: 100, screenY: 100})` makes the picker visible. +- With `unlockedPlantTypes=['rosemary']`, exactly one plant button renders ("Rosemary"). +- Clicking the button enqueues `{kind: 'plantSeed', tileIdx: 0, plantTypeId: 'rosemary'}` into `pendingCommands` (verify via `appStore.getState().pendingCommands`). +- After button click, the picker dismisses (visibility=false, returns null). + +**Step 14 — `src/ui/garden/index.ts`** + `src/ui/index.ts`: +```typescript +// src/ui/garden/index.ts +export { SeedPicker } from './SeedPicker'; + +// src/ui/index.ts +export * from './begin'; +export * from './garden'; +``` + +**Step 15 — Update `src/App.tsx`** to mount overlays as siblings: + +```typescript +import { useRef } from 'react'; +import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx'; +import { BeginScreen } from './ui/begin'; +import { SeedPicker } from './ui/garden'; + +function App() { + const phaserRef = useRef(null); + + return ( +
+ + + + {/* Plan 02-03 mounts: , */} + {/* Plan 02-04 mounts: */} + {/* Plan 02-05 mounts: , , */} +
+ ); +} + +export default App; +``` + +**Step 16 — Update `src/PhaserGame.tsx`** to: +- Initialize the SimState in the store (set `unlockedPlantTypes=['rosemary']` for first run; later plans read from save). +- Install the first-interaction gesture handler when no Begin screen will show (i.e., when a save exists; for Phase 2 Wave 1, simplification: install always; the handler is a one-shot). + +```typescript +import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } from 'react'; +import StartGame from './game/main.ts'; +import type * as Phaser from 'phaser'; +import { eventBus } from './game/event-bus'; +import { appStore } from './store'; +import { installFirstInteractionGestureHandler } from './ui/begin'; + +export interface IRefPhaserGame { + game: Phaser.Game | null; + scene: Phaser.Scene | null; +} + +interface IProps { + currentActiveScene?: (sceneInstance: Phaser.Scene) => void; +} + +export const PhaserGame = forwardRef(function PhaserGame(props, ref) { + const game = useRef(null); + const sceneRef = useRef(null); + + useLayoutEffect(() => { + if (game.current === null) { + // Bootstrap initial state (Plan 02-05 will replace with save-load path). + const initial = appStore.getState(); + if (initial.unlockedPlantTypes.length === 0) { + appStore.setState({ unlockedPlantTypes: ['rosemary'] }); + } + + game.current = StartGame('game-container'); + + if (typeof ref === 'function') { + ref({ game: game.current, scene: null }); + } else if (ref) { + ref.current = { game: game.current, scene: null }; + } + } + return () => { + if (game.current) { + game.current.destroy(true); + game.current = null; + } + }; + }, [ref]); + + useEffect(() => { + const onSceneReady = (scene: Phaser.Scene) => { + sceneRef.current = scene; + props.currentActiveScene?.(scene); + }; + eventBus.on('scene-ready', onSceneReady); + // Install gesture handler unconditionally — it's a one-shot that bootstraps audio + // on first interaction whether the Begin screen handled it or not (D-22 fallback). + installFirstInteractionGestureHandler(); + return () => { eventBus.off('scene-ready', onSceneReady); }; + }, [props]); + + useImperativeHandle(ref, () => ({ + game: game.current, + scene: sceneRef.current, + })); + + return
; +}); +``` + +(Plan 02-05 wires the real save-lifecycle hook + clock-selection logic here.) + +**Commit:** `feat(02-02): begin screen + seed picker + ui-strings + lazy content split`. Run `npm run ci` before committing. + +**Manual smoke test:** `npm run dev`, visit `http://localhost:5173`. Should see Begin screen → click Begin → garden tiles visible → click empty tile → seed picker appears positioned over the tile → click "Rosemary" → primitive sprout appears in the tile. Wait ~2 minutes (use a Vitest-style FakeClock injection if desired, or wait for real-time test). Plant transitions sprout → mature → ready with the alpha pulse. Confirm visually. + + + - `grep -q "title: \"The Last Garden\"" content/seasons/01-soil/ui-strings.yaml` + - `grep -q "UiStringsSchema" src/content/schemas/ui-strings.ts` + - `grep -q "loadSeasonFragments" src/content/loader.ts` (PIPE-02 lazy split wired) + - `grep -q "uiStrings" src/content/index.ts` (barrel re-export) + - `grep -q "bootstrapAudioContext" src/ui/begin/use-audio-bootstrap.ts` + - `grep -q "installFirstInteractionGestureHandler" src/ui/begin/use-audio-bootstrap.ts` + - `grep -q "void bootstrapAudioContext()" src/ui/begin/BeginScreen.tsx` (sync-inside-click — Pitfall 5 mitigation) + - `grep -q "tile-clicked-coords" src/ui/garden/SeedPicker.tsx` + - `grep -q "" src/App.tsx` and `grep -q "" src/App.tsx` + - `grep -q "installFirstInteractionGestureHandler" src/PhaserGame.tsx` + - `! test -f content/seasons/00-demo/fragments.yaml` (the demo fragment was deleted) + - `test -f content/seasons/01-soil/fragments.yaml` (Phase 2 placeholder fragment file exists) + - No player-visible English strings hardcoded outside `/content/`: `grep -E "(Begin|Sow|Rosemary|Yarrow|Winter-rose)" src/ui/begin/BeginScreen.tsx src/ui/garden/SeedPicker.tsx | grep -v "uiStrings\|fallbackName\|aria-label\|comment" | wc -l` is 0 (the strings come from uiStrings, not literals) + - `npx vitest run src/ui/begin/ src/ui/garden/` exits 0 with all tests green + - `npm run ci` exits 0 + + + npm run lint && npx vitest run src/ui/begin/ src/ui/garden/ src/content/ && npm run ci + + + BeginScreen + SeedPicker land. Audio bootstrap fires synchronously inside click handlers. UI strings externalized to /content/seasons/01-soil/ui-strings.yaml; Zod-validated. PIPE-02 lazy-fragment glob added (Plan 02-03 will populate Season 1). 00-demo deleted; 01-soil placeholder fragments.yaml exists. App.tsx mounts overlays. PhaserGame.tsx wires EventBus + initial unlocks + gesture handler. `npm run ci` green; manual smoke test confirms full Begin → Plant → Grow vertical slice runs end-to-end. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Phaser canvas ↔ React DOM overlay | Tile pointerdown → EventBus → React popover → store command. EventBus payload is internal (no user-supplied data). | +| AudioContext lazy-create boundary | Created synchronously inside click handler; defends iOS Safari Pitfall 5. | +| Content schema boundary | ui-strings.yaml is authored content (repo-controlled); Zod-validated at module-eval. No user-supplied content path. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-02-02-01 | Tampering | URL devtime flag exposed in production | mitigate | Garden scene reads window.__tlgClock if present; Plan 02-05 production-guards via import.meta.env.PROD check at boot. Phase 2 ships the hook; Phase 8 verifies no leakage. | +| T-02-02-02 | Tampering | User pastes malicious uiStrings via Base64 import | mitigate | UiStringsSchema validates structure; React renders strings as text (no dangerouslySetInnerHTML). Even if tampered, no XSS surface. | +| T-02-02-03 | Information disclosure | AudioContext failure leaking error details | accept | bootstrapAudioContext catches all errors and returns null; no error message surfaces to UI. | +| T-02-02-04 | Denial-of-service | Seed picker spawn-on-every-pixel-click | accept | Click on empty tile only; one popover at a time; click-outside dismisses. No spam vector. | +| T-02-02-05 | Tampering | Plant a seed for a locked plant type | mitigate | plantSeed command validates against state.unlockedPlantTypes — silent no-op if not unlocked. | + +No `high` severity threats. Phase 2 vertical-slice surface is small. + + + + +After all 3 tasks committed: + +1. **Linter:** `npm run lint` exits 0 (sim-purity rule from Plan 02-01 catches Date.now leaks in src/sim/garden/). +2. **Tests:** `npx vitest run` exits 0; new test files: `src/sim/garden/growth.test.ts`, `src/sim/garden/commands.test.ts`, `src/ui/begin/BeginScreen.test.tsx`, `src/ui/garden/SeedPicker.test.tsx`. Combined Phase-1+Phase-2 test count grows to ~130. +3. **Build:** `npm run build` exits 0. +4. **Full CI:** `npm run ci` exits 0. +5. **Schema lock:** `grep -q "loadSeasonFragments" src/content/loader.ts` confirms PIPE-02 lazy wiring landed even though Plan 02-03 will populate the actual content. +6. **Manual smoke** (executor performs once during Task 3): `npm run dev`, visit `http://localhost:5173`. Verify Begin → Plant → Grow loop works on Phaser primitives; tile hover state visible; seed picker positions over the clicked tile; primitive sprout appears; (optionally) wait for stage transitions. + + + + + +Plan 02-02 is complete when: + +- [ ] All 3 tasks committed. +- [ ] `npm run ci` exits 0. +- [ ] First-run player flow works end-to-end: Begin screen → tap → audio resumes → garden visible → click empty tile → seed picker → tap Rosemary → primitive sprout appears → grows through stages over ~2 minutes → ready-state pulse visible. +- [ ] D-21, D-22, D-26, D-27, D-01, D-02, D-06 all visibly satisfied in the dev build. +- [ ] AEST-07 satisfied: `bootstrapAudioContext` is called inside the click handler (Pitfall 5 + 9 mitigations in place). +- [ ] UX-01 satisfied: Begin screen has no clutter; tile hover state subtle; no Phase-3 polish creep. +- [ ] CORE-02 satisfied: scheduler drives sim ticks; src/sim/garden/ has zero `Date.now()` calls (ESLint rule confirms). +- [ ] All player-visible strings in `/content/seasons/01-soil/ui-strings.yaml`. +- [ ] PIPE-02 lazy split wired (loadSeasonFragments exists; Plan 02-03 populates real content). +- [ ] Plan 02-03 (Harvest + Journal) and Plan 02-04 (Lura) can build on the sim/garden + render/garden + ui/garden surfaces shipped here. + + + + +Create `.planning/phases/02-season-1-vertical-slice-soil/02-02-begin-plant-grow-SUMMARY.md` per template. Document: +- Per-plant duration values shipped (rosemary 600 / yarrow 900 / winter-rose 1500 ticks). +- Whether `tileCenterToDom` worked under `Phaser.Scale.FIT` without modification (RESEARCH Assumption A5 — verified). +- Manual smoke test confirmation (date / browser / observed behavior). +- Any deviations from locked content (e.g., user copy edits to ui-strings.yaml during review). + diff --git a/.planning/phases/02-season-1-vertical-slice-soil/02-03-harvest-journal-fragments-PLAN.md b/.planning/phases/02-season-1-vertical-slice-soil/02-03-harvest-journal-fragments-PLAN.md new file mode 100644 index 0000000..0726bb2 --- /dev/null +++ b/.planning/phases/02-season-1-vertical-slice-soil/02-03-harvest-journal-fragments-PLAN.md @@ -0,0 +1,1278 @@ +--- +phase: 02 +plan: 03 +type: execute +wave: 1 +depends_on: [02-01] +files_modified: + - src/sim/memory/selector.ts + - src/sim/memory/selector.test.ts + - src/sim/memory/pool.ts + - src/sim/memory/index.ts + - src/sim/garden/commands.ts + - src/sim/garden/commands.test.ts + - src/ui/journal/Journal.tsx + - src/ui/journal/Journal.test.tsx + - src/ui/journal/FragmentRevealModal.tsx + - src/ui/journal/FragmentRevealModal.test.tsx + - src/ui/journal/journal-icon.tsx + - src/ui/journal/index.ts + - src/ui/index.ts + - src/App.tsx + - content/seasons/01-soil/fragments.yaml + - content/seasons/01-soil/fragments/lura-first-letter.md + - content/seasons/01-soil/fragments/winter-rose-night.md + - scripts/check-bundle-split.mjs + - scripts/check-bundle-split.test.mjs + - package.json +autonomous: true +requirements: [GARD-03, GARD-04, MEMR-01, MEMR-02, MEMR-03, MEMR-04, MEMR-05, MEMR-06, PIPE-02, UX-01] +tags: [vertical-slice, harvest, journal, fragments, content-authoring, lazy-load, mvp] + +must_haves: + truths: + - "Player clicks a ready-stage tile → harvest command enqueues → next sim tick selects exactly one fragment from the gated pool, appends to harvestedFragmentIds, empties the tile (GARD-03, MEMR-01)" + - "Fragment selector is deterministic (same inputs → same fragment), respects Season + plant-type gating, and never duplicates a fragment within a playthrough until the gated pool is exhausted (MEMR-06)" + - "When the gated pool is exhausted, selector returns the documented sentinel fragment (e.g., 'season1.soil.gardener-knows-this-one-already') OR repeats the most-recently-harvested fragment (Pitfall 8). Behavior chosen + documented." + - "Player clicks an immature plant → compost command enqueues → tile empties → an Ink-authored single-line tonal acknowledgement plays (GARD-04, D-07, RESEARCH Open Question 2). Phase 2 ships acknowledgements as a small Ink file under /content/dialogue/season1/compost-acknowledgements.ink — Plan 02-04 owns ink runtime; Plan 02-03 ships the AUTHORED CONTENT and the placeholder text-snippet UX (with TODO comment) so Plan 02-04 can swap to Ink without reworking." + - "Newly harvested fragments in active play surface in a full-text reveal modal (D-25); dismissing files into the journal under their Season" + - "Journal icon is invisible until the first harvest, then persistent (D-23). Journal opens on icon click as a full-screen modal (D-24); fragments grouped by Season; text is selectable + copy-pasteable DOM (MEMR-05)" + - "Season 1 ships ≥10 authored fragments under /content/seasons/01-soil/ — enough to comfortably exceed the 8th-harvest Lura threshold + plant-type unlocks per RESEARCH Pitfall 8 + Assumption A8" + - "Plant-type unlock thresholds: yarrow unlocks at 3 harvests (rosemary-pool); winter-rose unlocks at 6 harvests (yarrow-pool exhausted or near-exhausted). Specific values are Claude's discretion within reason (D-05); document chosen values in SUMMARY.md" + - "Compost returns the tile to empty immediately (D-07); no resource refund (D-04 = infinite seeds, no cost-recovery)" + - "PIPE-02 lazy loader actually loads Season-1 fragments via loadSeasonFragments(1); structural assertion via scripts/check-bundle-split.mjs proves Vite emits a separate Season-1 chunk after `npm run build`" + - "All authored fragment IDs match the regex /^season1\\.[a-z0-9._-]+$/ (MEMR-03 stable string ID rule)" + - "Fragment text matches bible voice (CLAUDE.md Tone) — short, specific, intermittent, sometimes funny, sometimes devastating" + - "npm run ci is green; the new scripts/check-bundle-split.mjs runs as part of `ci` and exits 0" + artifacts: + - path: src/sim/memory/selector.ts + provides: "selectFragment(state, currentSeason, plantTypeId, allFragments) → Fragment | null — pure deterministic selector with gating + no-dup + exhaustion fallback (MEMR-06, RESEARCH Pitfall 8)" + exports: ["selectFragment", "EXHAUSTION_FALLBACK_ID"] + - path: src/sim/memory/pool.ts + provides: "filterPool(allFragments, season, plantTypeId, alreadyHarvestedIds) — pure filter helper" + exports: ["filterPool"] + - path: src/sim/garden/commands.ts + provides: "(extended) harvest(state, tileIdx, currentTick), compost(state, tileIdx, currentTick) — pure commands. simulateOneTick branches on harvest/compost" + exports: ["plantSeed", "harvest", "compost", "simulateOneTick", "tileGrowthStage"] + - path: src/ui/journal/Journal.tsx + provides: "Full-screen modal listing all harvested fragments grouped by Season; selectable DOM text per MEMR-05" + exports: ["Journal"] + - path: src/ui/journal/FragmentRevealModal.tsx + provides: "Active-play reveal modal (D-25) — surfaces just-harvested fragment in full text" + exports: ["FragmentRevealModal"] + - path: src/ui/journal/journal-icon.tsx + provides: "Corner icon button (D-23/D-29). Hidden pre-first-harvest; opens Journal modal on click" + exports: ["JournalIcon"] + - path: content/seasons/01-soil/fragments.yaml + provides: "≥8 short Season-1 fragments authored in voice (the bulk pool that Lura's beats + plant-unlock thresholds draw from)" + - path: content/seasons/01-soil/fragments/*.md + provides: "≥2 long-form per-file Season-1 fragments (Markdown + frontmatter); proves the Markdown loader path on Season 1 too" + - path: scripts/check-bundle-split.mjs + provides: "PIPE-02 structural verification: after `npm run build`, asserts that dist/assets/ contains a chunk specifically named to include 'season1' or 'fragments' (Vite default chunk-naming based on the dynamic-import path)" + key_links: + - from: src/sim/garden/commands.ts + to: src/sim/memory/selector.ts + via: "harvest() invokes selectFragment to pick exactly one fragment" + pattern: "selectFragment" + - from: src/ui/journal/Journal.tsx + to: src/store/index.ts + via: "useAppStore(s => s.harvestedFragmentIds) — DOM render of fragments by Season" + pattern: "useAppStore" + - from: src/ui/journal/FragmentRevealModal.tsx + to: src/store/index.ts + via: "useAppStore(s => s.fragmentRevealId) — opens when set; clears on dismiss" + pattern: "fragmentRevealId" + - from: src/sim/memory/selector.ts + to: src/content/index.ts + via: "selector takes the loaded `fragments` array as an argument; pool is INJECTED so selector stays pure (no module-load coupling to Vite glob)" + pattern: "Fragment\\[\\]" + - from: package.json scripts.ci + to: scripts/check-bundle-split.mjs + via: "ci runs `npm run build` then `node scripts/check-bundle-split.mjs` to assert PIPE-02 chunk split" + pattern: "check:bundle-split" +--- + + +**Wave 1 vertical slice. Depends on Plan 02-01 (foundations).** + +Runs in parallel with Plan 02-02 (Begin + Plant + Grow). Both depend only on 02-01. The shared surface is `src/sim/garden/types.ts` (locked by Plan 02-02 Task 1) and `src/sim/garden/commands.ts` (Plan 02-02 ships plantSeed; Plan 02-03 ADDS harvest + compost branches via merge). Coordinate the merge moment — both plans edit `simulateOneTick`'s switch. + +3 tasks. Estimated context cost ~50%. + + + +Ship the Harvest → Journal → Fragment-reveal vertical slice end-to-end. Player clicks a ready plant → harvest fires → exactly one Season-1 fragment is selected from the authored pool (deterministic, gated, no-dup) → reveal modal pops with the fragment's full text (selectable, copy-pasteable DOM) → dismissing the reveal files the fragment into the Memory Journal under Season 1 → a journal icon (hidden pre-first-harvest) reveals in the corner → clicking opens the Journal modal listing all collected fragments grouped by Season. + +Also ships compost → tile-empties + tonal acknowledgement, the actual Season-1 authored content (≥10 fragments matching bible voice), the plant-type unlock thresholds (yarrow at 3 harvests, winter-rose at 6 — Claude's discretion within D-05), and the PIPE-02 structural verification script proving Vite emits a separate Season-1 chunk after build. + +Purpose: Completes the second half of the player's first session (the first half — Begin → Plant → Grow — lands in Plan 02-02). After this plan ships, a player can run the full active-play loop end-to-end on real authored content. Plan 02-04 layers Lura's beats on top; Plan 02-05 layers offline catch-up + the letter on top. + +Output: Complete sim/memory module (selector + pool), extended sim/garden/commands.ts (harvest + compost branches), DOM-rendered Journal + FragmentRevealModal + journal-icon, ≥10 authored Season-1 fragments under /content/seasons/01-soil/, PIPE-02 structural test script, all green under `npm run ci`. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@CLAUDE.md +@.planning/anti-fomo-doctrine.md +@.planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md +@.planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md +@.planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md +@.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-SUMMARY.md +@.planning/phases/02-season-1-vertical-slice-soil/02-02-begin-plant-grow-SUMMARY.md +@.planning/phases/01-foundations-and-doctrine/01-04-SUMMARY.md +@content/README.md + + + + +From src/sim/garden/index.ts (Plan 02-02): +```typescript +export type { Tile, PlantInstance, PlantType, PlantTypeId, GrowthStage } from './types'; +export { GRID_SIZE, GRID_ROWS, GRID_COLS, tileIdx, tileCoords } from './types'; +export { PLANT_TYPES, getPlantType } from './plants'; +export { advanceGrowth, GROWTH_THRESHOLDS } from './growth'; +export { plantSeed, simulateOneTick, tileGrowthStage } from './commands'; +// ^^^^^^^^^ Plan 02-03 EXTENDS commands.ts with harvest + compost; simulateOneTick branches on those kinds. +``` + +From src/store/index.ts (Plan 02-01) — already exposes: +```typescript +fragmentRevealId: string | null; +setFragmentRevealId(id: string | null); +harvestedFragmentIds: string[]; +setHarvested(ids: string[]); +``` + +From src/content/index.ts (Plan 02-02 extension): +```typescript +export const fragments: Fragment[]; // eager (legacy) +export function loadSeasonFragments(seasonId: number): Promise; // PIPE-02 lazy +export const uiStrings: Record; +export type Fragment = { id: string; season: number; body: string }; +``` + +Fragment ID regex (FragmentSchema): `/^season\d+\.[a-z0-9._-]+$/`. Examples: `season1.soil.first-bloom`, `season1.soil.lura.greeting` (dots and dashes both allowed). + +Existing src/App.tsx after Plan 02-02 (mount BeginScreen + SeedPicker; this plan adds Journal + FragmentRevealModal + JournalIcon): + +```typescript +
+ + + + {/* Plan 02-03: , , */} +
+``` + +From src/sim/state.ts (Plan 02-01): +```typescript +export interface SimState { + garden: { tiles: unknown[] }; + plants: unknown[]; + harvestedFragmentIds: string[]; + lastTickAt: number; + unlockedPlantTypes: string[]; + luraBeatProgress: { ... }; + offlineEvents: unknown | null; + settings: { ...; persistenceToastShown: boolean }; +} +``` + +Mulberry32 seeded PRNG (RESEARCH line 1013, ~10 LoC pure): +```typescript +function mulberry32(a: number): () => number { + return function() { + let t = a += 0x6D2B79F5; + t = Math.imul(t ^ t >>> 15, t | 1); + t ^= t + Math.imul(t ^ t >>> 7, t | 61); + return ((t ^ t >>> 14) >>> 0) / 4294967296; + } +} +``` +
+
+ + + + + Task 1: Author ≥10 Season-1 fragments + sim/memory selector + extend sim/garden/commands with harvest + compost + + - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pitfall 8 lines 1102-1108 fragment exhaustion, Pitfall 10 lines 1118-1124 unlock off-by-one, Open Question 1 lines 1225-1229 plant identity) + - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group D lines 274-310, Group C lines 226-272 for sim/garden command pattern) + - .planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md (D-03 plant types, D-05 unlocks, D-07 post-harvest beat, D-14 Lura thresholds — gives a sense of how many harvests Phase 2 expects) + - CLAUDE.md (Tone — bible voice for fragment text) + - content/README.md (fragment authoring conventions) + - content/seasons/01-soil/fragments.yaml (Plan 02-02 placeholder — REPLACE with real content) + - src/sim/garden/commands.ts (Plan 02-02 — extend the simulateOneTick switch) + - src/sim/garden/commands.test.ts (Plan 02-02 — extend with harvest + compost cases) + + + content/seasons/01-soil/fragments.yaml, + content/seasons/01-soil/fragments/lura-first-letter.md, + content/seasons/01-soil/fragments/winter-rose-night.md, + src/sim/memory/selector.ts, + src/sim/memory/selector.test.ts, + src/sim/memory/pool.ts, + src/sim/memory/index.ts, + src/sim/garden/commands.ts, + src/sim/garden/commands.test.ts + + +**Step 1 — Author Season-1 fragments.** + +Replace `content/seasons/01-soil/fragments.yaml` (currently a Plan-02-02 placeholder) with ≥8 short fragments authored in voice. Each fragment: +- Has stable string ID matching `/^season1\.[a-z0-9._-]+$/`. +- Belongs to one of the three plant types' tonal registers (warm / contemplative / heavy) via the `tags` field (a Phase-2 extension to FragmentSchema — see Step 2). +- 2–6 sentences max. Bible voice: warm, specific, intermittent, sometimes funny, sometimes devastating. + +Author at least 8 fragments in fragments.yaml + 2 long-form Markdown fragments in `content/seasons/01-soil/fragments/*.md`. Total ≥10. The exhaustion fallback fragment (`season1.soil.gardener-knows-this-one-already`) is the 11th and may live in either yaml or md; document its role in a comment. + +**The fragment file MUST also include a 12th sentinel ID `season1.soil._exhaustion`** as the no-fragment-pool fallback per RESEARCH Pitfall 8. + +**Step 2 — Extend FragmentSchema with optional `tags` field** for plant-type gating (MEMR-06): + +Edit `src/content/schemas/fragment.ts`: +```typescript +export const FragmentSchema = z.object({ + id: z.string().regex(/^season\d+\.[a-z0-9._-]+$/), + season: z.number().int().min(0).max(7), + body: z.string().min(1), + tags: z.array(z.string().min(1)).optional(), // Phase 2 extension for MEMR-06 gating +}); +``` + +This is backward-compatible (optional field). Existing tests still pass. + +**Sample fragments** (executor adapts; all matched to bible voice): + +```yaml +# content/seasons/01-soil/fragments.yaml +fragments: + # ----- WARM tonal register (rosemary pool) ----- + - id: season1.soil.first-bloom + season: 1 + tags: [warm] + body: | + The first thing that grew was rosemary. The shape of it didn't matter + so much as the smell — sharp, the kind of green that means the air + will warm up by afternoon. + + - id: season1.soil.bread-was-easy + season: 1 + tags: [warm] + body: | + Someone, in the place this came from, was very good at bread. There + isn't a name attached. There is the shape of an oven door, and a + towel folded a particular way. + + - id: season1.soil.the-cat + season: 1 + tags: [warm] + body: | + The cat is missing now too. It used to walk along the wall at dusk. + It would not come when called. It came anyway, in its own time. Most + good things were like that. + + # ----- CONTEMPLATIVE tonal register (yarrow pool) ----- + - id: season1.soil.what-the-wind-was-for + season: 1 + tags: [contemplative] + body: | + The wind used to mean something specific in spring — a person putting + sheets out to dry, the line across two posts, the way it would crack + like a small flag. That meaning has gone soft. The wind still blows. + + - id: season1.soil.the-letter-not-sent + season: 1 + tags: [contemplative] + body: | + There was a letter someone meant to send. The address is gone, the + ink is gone, the reason is gone. What remains is the silence on the + other side of it — a room, somewhere, that never received the news. + + - id: season1.soil.numbers-in-the-margin + season: 1 + tags: [contemplative] + body: | + A book had a number written in the margin: 47. Whose age, whose page, + whose count of something — gone. The 47 sits very calmly on the + paper. Numbers are the last to forget. They will outlast all of us. + + # ----- HEAVY tonal register (winter-rose pool) ----- + - id: season1.soil.the-name-she-used + season: 1 + tags: [heavy] + body: | + She had a name for him that wasn't his name. He had stopped objecting + to it long before the end. After, the name kept arriving — at the + door, in the post, in the mouths of people who had heard it once and + never been corrected. The garden does not say it. The garden only + grows. + + - id: season1.soil.what-the-snow-took + season: 1 + tags: [heavy] + body: | + Snow took the orchard one March. The trees were already old. The + orchard had been someone's grandfather's, then someone's father's, + then a row of stumps and a few unrooted sticks pretending. Pretending + is also a kind of remembering, until one day it isn't. + + # ----- EXHAUSTION FALLBACK (returned when gated pool is empty per Pitfall 8) ----- + - id: season1.soil._exhaustion + season: 1 + tags: [_meta] + body: | + The garden knows this one already. The light comes in the same way it + came yesterday. There will be a new thing tomorrow. There is also + this — the steady part, that does not need re-learning. +``` + +```markdown + +--- +id: season1.soil.lura-first-letter +season: 1 +tags: [warm] +--- +Lura wrote you a letter once, and never sent it. It was about a recipe — the +proportions of vinegar to honey, and how long to let the onions sit. Most of +the letter is the recipe. Two paragraphs at the bottom are about something +else: a bee in a kitchen window, a song you didn't recognize, the shape your +hand made on a glass. + +She left the letter in a drawer, decided it sounded too much. Then there was +no drawer, and no letter. The recipe is real. You could find it again, if you +asked. +``` + +```markdown + +--- +id: season1.soil.winter-rose-night +season: 1 +tags: [heavy] +--- +Winter-rose blooms at night. This is, technically, slander — the rose blooms +when it blooms, and the night is when most people are asleep, and so the +night is when most people fail to see things bloom. But the slander stuck. +A flower for the people who couldn't sleep. + +Someone, in this place, used to set a chair by the window in February and +wait. The wait was the thing. The flower would bloom in its own time. Most +good things were like that, until they weren't. +``` + +(Total: 9 in yaml + 2 in md + 1 sentinel = 12 fragments. Exceeds RESEARCH Assumption A8's "≥10" target with margin. Tags distribute: 4 warm, 3 contemplative, 3 heavy, 1 _meta = 11 plant-tagged + 1 sentinel; comfortably feeds 8th-harvest Lura threshold + plant-type unlocks.) + +**Step 3 — `src/sim/memory/pool.ts`** (PATTERNS Group D filter pattern): + +```typescript +import type { Fragment } from '../../content'; +import type { PlantTypeId } from '../garden/types'; +import { PLANT_TYPES } from '../garden/plants'; + +/** + * Filter the loaded fragments down to the gated, not-yet-harvested pool + * for a given (season, plantTypeId) at the moment of harvest. + * + * Per MEMR-06: respects authored gating (Season + plantType.fragmentTags + * intersection) and avoids duplicates within a playthrough. + * + * Per RESEARCH Pitfall 8: callers MUST handle the case where the returned + * pool is empty by falling back to the exhaustion sentinel + * (EXHAUSTION_FALLBACK_ID in selector.ts). + * + * Pure. No DOM. No Date.now. + */ +export function filterPool( + allFragments: readonly Fragment[], + season: number, + plantTypeId: PlantTypeId, + alreadyHarvestedIds: readonly string[], +): Fragment[] { + const type = PLANT_TYPES[plantTypeId]; + if (!type) return []; + const tagSet = new Set(type.fragmentTags); + const harvestedSet = new Set(alreadyHarvestedIds); + return allFragments.filter((f) => { + if (f.season !== season) return false; + if (harvestedSet.has(f.id)) return false; + // MEMR-06 plant-type gating: fragment must share at least one tag with the plant type's tonal register + if (!f.tags || !f.tags.some((t) => tagSet.has(t))) return false; + // Exclude the exhaustion sentinel from the pool — it's reserved for the fallback + if (f.tags.includes('_meta')) return false; + return true; + }); +} +``` + +**Step 4 — `src/sim/memory/selector.ts`** (RESEARCH Don't Hand-Roll line 1013 + PATTERNS Group D): + +```typescript +import type { Fragment } from '../../content'; +import type { PlantTypeId } from '../garden/types'; +import { filterPool } from './pool'; + +/** + * MEMR-06 deterministic fragment selector. + * + * Inputs are pure: (allFragments, currentSeason, plantTypeId, alreadyHarvestedIds, seedHash). + * Same inputs → same output. No Date.now, no Math.random — the seed is + * derived from `(harvestedFragmentIds.length, plantedAtTick)` in the + * caller (sim/garden/commands.ts) so the player's actions advance the + * stream without leaking wall-clock state into sim modules. + * + * Per RESEARCH Pitfall 8 (exhaustion): + * - If the gated pool is non-empty: return the seeded selection. + * - If the gated pool is empty: return the EXHAUSTION_FALLBACK_ID sentinel + * fragment (authored at content/seasons/01-soil/fragments.yaml as + * `season1.soil._exhaustion`). + * - If even the sentinel is missing (degenerate test fixture): + * return null and let the caller treat it as a no-op harvest. + */ +export const EXHAUSTION_FALLBACK_ID = 'season1.soil._exhaustion'; + +function mulberry32(a: number): () => number { + return function() { + let t = (a += 0x6D2B79F5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export function selectFragment( + allFragments: readonly Fragment[], + currentSeason: number, + plantTypeId: PlantTypeId, + alreadyHarvestedIds: readonly string[], + seedHash: number, +): Fragment | null { + const pool = filterPool(allFragments, currentSeason, plantTypeId, alreadyHarvestedIds); + if (pool.length === 0) { + return allFragments.find((f) => f.id === EXHAUSTION_FALLBACK_ID) ?? null; + } + const rng = mulberry32(seedHash); + const idx = Math.floor(rng() * pool.length); + return pool[idx] ?? null; +} +``` + +**Step 5 — `src/sim/memory/selector.test.ts`** — exhaustive Vitest: + +- Empty pool + sentinel present → returns sentinel. +- Empty pool + no sentinel → returns null. +- Pool with one fragment → always returns that fragment regardless of seed. +- Pool with three fragments — same `seedHash` returns same fragment; different `seedHash` may return different. +- Pool gating: `selectFragment([{id, season=1, tags:['warm']}, {id, season=1, tags:['heavy']}], 1, 'rosemary', [], 0)` returns only the warm-tagged one (rosemary tonal register). +- No-dup: passing a fragment's id in `alreadyHarvestedIds` excludes it from the pool. +- Season gating: fragment with `season=2` is never selected when `currentSeason=1`. +- Sentinel exclusion: a fragment tagged `['_meta']` is NEVER returned via the normal-pool branch (only via the exhaustion fallback). + +**Step 6 — `src/sim/memory/index.ts`:** +```typescript +export { selectFragment, EXHAUSTION_FALLBACK_ID } from './selector'; +export { filterPool } from './pool'; +``` + +Also add `export * from './memory'` to `src/sim/index.ts`. + +**Step 7 — Extend `src/sim/garden/commands.ts`** with `harvest` and `compost`. Add a `MemoryRegistry` injection point so the sim stays decoupled from `import.meta.glob` Vite magic: + +```typescript +// add at top of commands.ts +import { selectFragment, EXHAUSTION_FALLBACK_ID } from '../memory/selector'; +import type { Fragment } from '../../content'; + +/** + * The fragment pool injected into simulateOneTick. The application + * layer (Phaser scene) loads fragments via loadSeasonFragments(1) and + * passes the array in. Sim modules stay decoupled from import.meta.glob. + */ +export interface SimContext { + fragments: readonly Fragment[]; + currentSeason: number; +} + +/** + * harvest(state, tileIdx, currentTick, ctx) → state' + * + * Pure. Picks exactly ONE fragment via the deterministic selector, + * empties the tile, and appends to harvestedFragmentIds. The seed + * derives from (harvestCount + plantedAtTick) — pure of all wall-clock. + * + * Per GARD-03 + MEMR-01 + MEMR-06. + * + * Returns the original state unchanged if the tile is empty or not ready. + */ +export function harvest(state: SimState, tileIdx: number, currentTick: number, ctx: SimContext): SimState { + if (tileIdx < 0 || tileIdx >= GRID_SIZE) return state; + const tiles = state.garden.tiles as Tile[]; + const tile = tiles[tileIdx]; + if (!tile?.plant) return state; + const type = PLANT_TYPES[tile.plant.plantTypeId]; + if (!type) return state; + const stage = advanceGrowth(tile.plant, type, currentTick); + if (stage !== 'ready') return state; // refuse to harvest immature plants + + const seedHash = state.harvestedFragmentIds.length * 2654435761 + tile.plant.plantedAtTick; + const fragment = selectFragment( + ctx.fragments, + ctx.currentSeason, + tile.plant.plantTypeId, + state.harvestedFragmentIds, + seedHash, + ); + if (!fragment) return state; // degenerate: no fragment AND no sentinel — refuse to harvest + + const nextTiles: Tile[] = tiles.map((t, i) => i === tileIdx ? { idx: i, plant: null } : t); + const harvestedIds = [...state.harvestedFragmentIds, fragment.id]; + + // D-05 plant-type unlock thresholds (Claude's discretion within reason): + // yarrow unlocks at 3 harvests + // winter-rose unlocks at 6 harvests + // Defended in selector.test.ts boundary cases. Document final values in SUMMARY.md. + const unlockedPlantTypes = computePlantUnlocks(harvestedIds.length); + + return { + ...state, + garden: { tiles: nextTiles }, + harvestedFragmentIds: harvestedIds, + unlockedPlantTypes, + }; +} + +const PLANT_UNLOCK_THRESHOLDS: Array<{ count: number; plantTypeId: PlantTypeId }> = [ + { count: 0, plantTypeId: 'rosemary' }, // available from start + { count: 3, plantTypeId: 'yarrow' }, // unlocks at 3rd harvest (Pitfall 10: check AFTER harvest commit) + { count: 6, plantTypeId: 'winter-rose' }, // unlocks at 6th harvest +]; + +function computePlantUnlocks(harvestCount: number): string[] { + return PLANT_UNLOCK_THRESHOLDS + .filter((t) => harvestCount >= t.count) + .map((t) => t.plantTypeId); +} + +/** + * compost(state, tileIdx, currentTick) → state' + * + * Pure. Empties the tile regardless of growth stage. No fragment yield. + * No resource refund (D-04 = infinite seeds). + * + * The "tonal beat" (D-07 + GARD-04) is a UI concern — Plan 02-04's Ink + * runtime renders compost-acknowledgements.ink lines via the dialogue + * overlay. Phase 2 Plan 02-03 ships the AUTHORED CONTENT; the React + * surface fires the beat by setting a flag; Plan 02-04 wires the Ink + * playback (placeholder DOM text in this plan, swap to ink later). + */ +export function compost(state: SimState, tileIdx: number, _currentTick: number): SimState { + if (tileIdx < 0 || tileIdx >= GRID_SIZE) return state; + const tiles = state.garden.tiles as Tile[]; + const tile = tiles[tileIdx]; + if (!tile?.plant) return state; + const nextTiles: Tile[] = tiles.map((t, i) => i === tileIdx ? { idx: i, plant: null } : t); + return { ...state, garden: { tiles: nextTiles } }; +} +``` + +**Update `simulateOneTick`** to dispatch on `harvest` and `compost`: + +```typescript +export function simulateOneTick( + state: SimState, + currentTick: number, + commands: GardenCommand[], + ctx: SimContext, +): SimState { + let next = state; + for (const cmd of commands) { + if (cmd.kind === 'plantSeed' && cmd.plantTypeId) { + next = plantSeed(next, cmd.tileIdx, cmd.plantTypeId as PlantTypeId, currentTick); + } else if (cmd.kind === 'harvest') { + next = harvest(next, cmd.tileIdx, currentTick, ctx); + } else if (cmd.kind === 'compost') { + next = compost(next, cmd.tileIdx, currentTick); + } + } + return { ...next, lastTickAt: currentTick }; +} +``` + +**Note:** simulateOneTick now takes a `ctx: SimContext` 4th argument. Update Plan 02-02's Garden scene to pass `{fragments: , currentSeason: 1}` — the executor edits `src/game/scenes/Garden.ts` to load fragments and pass through. The Garden scene's `update()` becomes: + +```typescript +const result = drainTicks(simStateNow, this.accumulatorMs, (s, _dtMs, _silent) => { + const next = simulateOneTick(s, this.currentTick + 1, commands, this.simContext); + this.currentTick++; + return next; +}); +``` + +with `this.simContext` initialized in `create()` via `await loadSeasonFragments(1)`. Use `this.events.once('create')` or chain via `.then` since `create()` is sync but we need fragments early — practical approach: call `loadSeasonFragments(1)` in `init()` then `this.simContext = { fragments: [], currentSeason: 1 }` until resolved, then assign. (Or load eagerly via the existing `fragments` export from Plan 01-04 — for Phase 2 this is simpler and Plan 02-04+ can swap to lazy when content grows.) + +**Simpler approach (executor's preference allowed):** import the eager `fragments` export and filter for `season === 1` in the Garden scene's `create()`: +```typescript +import { fragments as allFragments } from '../../content'; +this.simContext = { fragments: allFragments, currentSeason: 1 }; +``` +PIPE-02's lazy split is structurally verified by `scripts/check-bundle-split.mjs` (Task 3 of this plan); the runtime can use the eager pool until Phase 4 grows beyond Season 1. **Document this trade-off in SUMMARY.md.** + +**Step 8 — Extend `src/sim/garden/commands.test.ts`** with harvest + compost cases: + +- Harvest a ready plant → returns state with tile cleared and exactly ONE new entry in harvestedFragmentIds. +- Harvest the same tile after harvesting → returns state unchanged (tile is empty). +- Harvest an immature plant → returns state unchanged. +- Harvest with empty fragment context → returns state unchanged (no fragment selected). +- Determinism: two calls to `harvest` on identical state produce identical results. +- Plant-type unlocks: plant 3 rosemary, harvest each → after 3rd harvest, `unlockedPlantTypes` includes 'yarrow'. +- Plant-type unlocks Pitfall 10 (off-by-one): after 2 harvests, `unlockedPlantTypes` does NOT include 'yarrow'; after 3, it does. +- Compost a sprout → tile clears. +- Compost an empty tile → state unchanged. +- Compost does not change harvestedFragmentIds. +- Compost does not change unlockedPlantTypes (no-fragment path). + +**Commit:** `feat(02-03): Season-1 fragments + sim/memory selector + harvest/compost commands`. Run `npm run lint && npx vitest run src/sim/ src/content/ && npm run build` before committing (npm run build proves the new fragments parse). + + + - `grep -c "^ - id: season1\\." content/seasons/01-soil/fragments.yaml` returns ≥9 + - `ls content/seasons/01-soil/fragments/*.md | wc -l` returns ≥2 + - `grep -q "season1.soil._exhaustion" content/seasons/01-soil/fragments.yaml` + - `grep -q "tags: \\[warm\\]\\|tags: \\[contemplative\\]\\|tags: \\[heavy\\]" content/seasons/01-soil/fragments.yaml` (multiple) + - `grep -q "tags: z.array(z.string()" src/content/schemas/fragment.ts` (schema extended) + - `grep -q "EXHAUSTION_FALLBACK_ID = 'season1.soil._exhaustion'" src/sim/memory/selector.ts` + - `grep -q "function mulberry32" src/sim/memory/selector.ts` + - `grep -q "export function harvest" src/sim/garden/commands.ts` + - `grep -q "export function compost" src/sim/garden/commands.ts` + - `grep -q "PLANT_UNLOCK_THRESHOLDS" src/sim/garden/commands.ts` + - `grep -L "Date.now" src/sim/memory/selector.ts src/sim/memory/pool.ts` (sim purity) + - `npx vitest run src/sim/memory/ src/sim/garden/ src/content/` exits 0 with all tests green; harvest/compost coverage ≥6 new cases + - `npm run build` succeeds — Vite parses all new fragments without schema violation + - `npm run lint` exits 0 + + + npm run lint && npx vitest run src/sim/memory/ src/sim/garden/ src/content/ && npm run build + + + ≥10 Season-1 fragments authored under /content/seasons/01-soil/ (≥8 yaml + ≥2 md + 1 sentinel). Bible voice maintained. FragmentSchema extended with optional tags. Deterministic selector with gating + no-dup + exhaustion fallback ships under sim/memory/. harvest + compost commands extend sim/garden/commands.ts; simulateOneTick takes a SimContext. Garden scene wired to pass real fragment context. ≥6 new Vitest cases green. + + + + + Task 2: Memory Journal UI (Journal modal + FragmentRevealModal + JournalIcon) + App.tsx wiring + harvest event flow + + - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Memory Journal section + Architectural Responsibility Map row "Memory Journal") + - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group I lines 471-518) + - .planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md (D-23 reveal-after-first-harvest, D-24 full-screen modal, D-25 immediate-reveal-modal) + - src/store/memory-slice.ts (Plan 02-01 — fragmentRevealId state slot) + - src/store/garden-slice.ts (Plan 02-01 — enqueueCommand) + - src/ui/begin/BeginScreen.tsx (Plan 02-02 — pattern for full-screen DOM overlay) + - src/App.tsx (Plan 02-02 — extend mount list) + - src/game/event-bus.ts (Plan 02-01 — fragment-revealed event) + - src/game/scenes/Garden.ts (Plan 02-02 — wire harvest pointerdown + emit fragment-revealed) + + + src/ui/journal/Journal.tsx, + src/ui/journal/Journal.test.tsx, + src/ui/journal/FragmentRevealModal.tsx, + src/ui/journal/FragmentRevealModal.test.tsx, + src/ui/journal/journal-icon.tsx, + src/ui/journal/index.ts, + src/ui/index.ts, + src/App.tsx, + src/game/scenes/Garden.ts + + +**Step 1 — `src/ui/journal/Journal.tsx`** — full-screen modal (D-24): + +```typescript +import { useState } from 'react'; +import { useAppStore } from '../../store'; +import { fragments as allFragments, uiStrings } from '../../content'; + +/** + * D-24 — full-screen Memory Journal modal. DOM-rendered text per MEMR-05 + * (selectable, copy-pasteable). Fragments grouped by Season; each fragment + * shown in full body text. + * + * Visibility is local state, opened by JournalIcon onClick. Phase 2 has + * only Season 1 — Phase 4+ Journal will need pagination / collapse. + */ +export function Journal({ open, onClose }: { open: boolean; onClose: () => void }): JSX.Element | null { + const harvested = useAppStore((s) => s.harvestedFragmentIds); + const strings = uiStrings[1]?.journal; + if (!open || !strings) return null; + + // Resolve fragment objects in the order the player harvested them + const harvestedFragments = harvested + .map((id) => allFragments.find((f) => f.id === id)) + .filter((f): f is NonNullable => f !== undefined); + + // Group by season for D-24 "fragments grouped by Season" requirement + const bySeason = new Map(); + for (const f of harvestedFragments) { + if (!bySeason.has(f.season)) bySeason.set(f.season, []); + bySeason.get(f.season)!.push(f); + } + + return ( +
+ +
+ {harvestedFragments.length === 0 && ( +

{strings.empty_state}

+ )} + {[...bySeason.entries()].sort(([a], [b]) => a - b).map(([season, frags]) => ( +
+

+ Season {season} +

+ {frags.map((f) => ( +
+
{f.body}
+
+ ))} +
+ ))} +
+
+ ); +} +``` + +**Step 2 — `src/ui/journal/FragmentRevealModal.tsx`** (D-25): + +```typescript +import { useAppStore } from '../../store'; +import { fragments as allFragments } from '../../content'; + +/** + * D-25 — fragment reveal modal in active play. Surfaces the just-harvested + * fragment in full text; dismissing files it into the Journal. + * + * Triggered by sim/garden/commands.ts harvest setting fragmentRevealId + * via the application layer (Garden scene's update loop on fragment- + * revealed event). Dismiss clears fragmentRevealId. + */ +export function FragmentRevealModal(): JSX.Element | null { + const fragmentRevealId = useAppStore((s) => s.fragmentRevealId); + const setFragmentRevealId = useAppStore((s) => s.setFragmentRevealId); + + if (!fragmentRevealId) return null; + + const fragment = allFragments.find((f) => f.id === fragmentRevealId); + if (!fragment) { + // Defensive: if the id doesn't resolve (degenerate), dismiss silently + setFragmentRevealId(null); + return null; + } + + const onDismiss = () => setFragmentRevealId(null); + + return ( +
+
e.stopPropagation()} + data-fragment-id={fragment.id} + style={{ + maxWidth: 600, padding: '3rem 2.4rem', + background: '#1f1f23', + borderRadius: 4, + cursor: 'default', + }} + > +
{fragment.body}
+ +
+
+ ); +} +``` + +**Step 3 — `src/ui/journal/journal-icon.tsx`** (D-23 + D-29): + +```typescript +import { useState } from 'react'; +import { useAppStore, selectJournalRevealed } from '../../store'; +import { Journal } from './Journal'; + +/** + * D-23 — journal affordance reveals after first harvest, then is persistent. + * D-29 — corner icon access pattern. + * + * Pre-first-harvest, returns null. Post-first-harvest, renders a small + * fixed-position icon button that opens the Journal modal. + */ +export function JournalIcon(): JSX.Element | null { + const revealed = useAppStore(selectJournalRevealed); + const [open, setOpen] = useState(false); + + if (!revealed) return null; + + return ( + <> + + setOpen(false)} /> + + ); +} +``` + +(The `✎` glyph is allowed — it's a typographic affordance, not localized copy. If the user prefers a SVG icon, swap; surfacing in SUMMARY.md.) + +**Step 4 — `src/ui/journal/Journal.test.tsx`** — Vitest + @testing-library/react: + +- Initial render with `harvestedFragmentIds: []` shows the empty-state copy from `uiStrings[1].journal.empty_state`. +- With `harvestedFragmentIds: ['season1.soil.first-bloom']`, the Journal renders the full body of that fragment. +- The fragment body is inside an element with `userSelect: 'text'` (selectable per MEMR-05) — assert via computed style on a found element. +- The body text includes the actual sentence "The first thing that grew was rosemary" (selectable text content, not innerHTML — confirms DOM rendering, not canvas). +- Fragments grouped by Season — `

Season 1

` is rendered. +- Close button click invokes `onClose` callback once. + +**Step 5 — `src/ui/journal/FragmentRevealModal.test.tsx`** — Vitest: + +- With `fragmentRevealId: null`, returns null (not visible). +- With `fragmentRevealId: 'season1.soil.first-bloom'`, the fragment body renders. +- Click on the modal background dismisses (sets fragmentRevealId=null in the store). +- Click on the article body does NOT dismiss (event.stopPropagation works). +- Click on the inner Close button dismisses. + +**Step 6 — `src/ui/journal/index.ts`:** +```typescript +export { Journal } from './Journal'; +export { FragmentRevealModal } from './FragmentRevealModal'; +export { JournalIcon } from './journal-icon'; +``` + +Update `src/ui/index.ts`: +```typescript +export * from './begin'; +export * from './garden'; +export * from './journal'; +``` + +**Step 7 — Update `src/App.tsx`** to mount the new overlays: + +```typescript +import { useRef } from 'react'; +import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx'; +import { BeginScreen } from './ui/begin'; +import { SeedPicker } from './ui/garden'; +import { JournalIcon, FragmentRevealModal } from './ui/journal'; + +function App() { + const phaserRef = useRef(null); + return ( +
+ + + + + + {/* Plan 02-04: */} + {/* Plan 02-05: , , */} +
+ ); +} + +export default App; +``` + +**Step 8 — Update `src/game/scenes/Garden.ts`** to: + +(a) Wire pointerdown on a ready-plant tile to enqueue a `harvest` command. +(b) Detect when a new fragment was harvested in a sim tick (new id appended to `harvestedFragmentIds`) and set `fragmentRevealId` via `simAdapter` (extend simAdapter with `applyHarvestedFragmentsAndReveal` if needed; or do it inline by reading the previous count vs new count). + +In `Garden.ts`'s `update()` method, after the scheduler call, compare prev vs next `harvestedFragmentIds.length`: + +```typescript +const prevCount = appStore.getState().harvestedFragmentIds.length; +// ... drainTicks ... +if (result.ticksApplied > 0) { + // Apply garden + memory state + simAdapter.applyTilesAndUnlocks(result.state.garden.tiles, result.state.unlockedPlantTypes); + if (result.state.harvestedFragmentIds.length > prevCount) { + // A new fragment was harvested in this tick — reveal it (D-25) + const newId = result.state.harvestedFragmentIds[result.state.harvestedFragmentIds.length - 1]; + simAdapter.applyHarvestedFragments(result.state.harvestedFragmentIds); + appStore.getState().setFragmentRevealId(newId); + } +} +``` + +In the pointerdown handler: + +```typescript +private handleTilePointerDown(idx: number): void { + const tiles = appStore.getState().tiles as Tile[]; + const tile = tiles[idx]; + if (!tile?.plant) { + // Empty tile — emit event for the React seed picker. + const dom = tileCenterToDom(this, idx); + eventBus.emit('tile-clicked-coords', { tileIdx: idx, screenX: dom.x, screenY: dom.y }); + return; + } + // Has plant — check growth stage. + const stage = tileGrowthStage(tile, this.currentTick); + if (stage === 'ready') { + appStore.getState().enqueueCommand({ kind: 'harvest', tileIdx: idx }); + } else { + // Immature — compost (Plan 02-04 may add a confirmation prompt; Phase 2 ships immediate compost) + appStore.getState().enqueueCommand({ kind: 'compost', tileIdx: idx }); + } +} +``` + +**Note on compost beat:** The tonal acknowledgement (D-07 + GARD-04) should fire after compost. Plan 02-04 wires the Ink playback for the line. Plan 02-03 ships a TODO comment in Garden.ts (or a tiny placeholder DOM toast) so the affordance is visible: + +```typescript +// TODO Plan 02-04: replace this placeholder with the Ink-authored compost beat +// rendered through the dialogue overlay (compost-acknowledgements.ink). +``` + +Plan 02-04's authored content will land the actual lines. + +**Commit:** `feat(02-03): journal + reveal modal + harvest pointer wiring`. Run `npm run ci` before committing. Manual smoke test: harvest a ready plant in dev → reveal modal pops → close → journal icon appears in corner → click → modal lists fragment. +
+ + - `grep -q "Memory Journal" src/ui/journal/Journal.tsx` (aria-label) + - `grep -q "userSelect: 'text'" src/ui/journal/Journal.tsx` (MEMR-05 selectable) + - `grep -q "userSelect: 'text'" src/ui/journal/FragmentRevealModal.tsx` + - `grep -q "selectJournalRevealed" src/ui/journal/journal-icon.tsx` (D-23 first-harvest reveal gate) + - `grep -q "" src/App.tsx` + - `grep -q "" src/App.tsx` + - `grep -q "kind: 'harvest'" src/game/scenes/Garden.ts` + - `grep -q "kind: 'compost'" src/game/scenes/Garden.ts` + - `grep -q "setFragmentRevealId" src/game/scenes/Garden.ts` (reveal flow wired) + - `npx vitest run src/ui/journal/` exits 0 with all tests green (≥10 cases across 2 files) + - `npm run ci` exits 0 + + + npm run lint && npx vitest run src/ui/journal/ && npm run ci + + + Journal + FragmentRevealModal + JournalIcon land. App.tsx mounts them. Garden.ts wires harvest/compost pointer events + reveal flow. Manual smoke test confirms: harvest ready plant → reveal pops → close → journal icon appears → opens journal modal listing fragment. Selectable text confirmed via Vitest. + +
+ + + Task 3: PIPE-02 structural verification — scripts/check-bundle-split.mjs and CI integration + + - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 8 lines 906-940 PIPE-02 lazy loading, Open Question 4 lines 1240-1244) + - scripts/validate-assets.mjs (Phase 1 — analog for Node ESM build script) + - package.json scripts (current `ci` chain) + - src/content/loader.ts (Plan 02-02 — loadSeasonFragments lazy glob already wired) + + + scripts/check-bundle-split.mjs, + scripts/check-bundle-split.test.mjs, + package.json + + +**Step 1 — `scripts/check-bundle-split.mjs`** — structural assertion that Vite emits a separate chunk for Season-1 fragments after `npm run build`: + +```javascript +#!/usr/bin/env node +// Phase 2 Plan 02-03 — PIPE-02 structural verification. +// +// After `npm run build`, Vite splits each lazy `import.meta.glob` target +// into its own chunk. Phase 2 has only Season 1; the wiring is structural +// so Phase 4 (Season 2) inherits without rework. +// +// This script asserts that `dist/assets/` contains at least one chunk +// whose name reflects the lazy-imported Season-1 fragment paths +// (Vite's default chunk name uses the module path slug; for +// `/content/seasons/01-soil/fragments.yaml` the chunk name typically +// includes `fragments` and may include `01-soil`). +// +// If the assertion is too tight, the script prints the chunk listing +// for the dev to inspect and exits non-zero with guidance. + +import { readdirSync, existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const distAssets = resolve(process.cwd(), 'dist/assets'); +const distIndex = resolve(process.cwd(), 'dist/index.html'); + +if (!existsSync(distAssets)) { + console.error('[check-bundle-split] dist/assets/ not found — run `npm run build` first'); + process.exit(2); +} + +const files = readdirSync(distAssets); + +// PIPE-02 looks for at least ONE chunk that references Season-1 fragment paths. +// Vite hashes filenames; the source path is preserved as a comment in the +// generated JS, but Vite typically also includes path slugs in chunk names +// for dynamically-imported resources. +// +// We check three places: +// 1. Any .js file in dist/assets/ whose NAME contains 'fragments' or 'season1' or '01-soil'. +// 2. Any .js file whose CONTENTS reference '/content/seasons/01-soil/' (raw `?raw` imports +// may inline the fragment YAML into a chunk). +// 3. A non-empty fragments.yaml inlined as a string literal in some chunk. + +const chunkNameMatch = files.some((f) => + f.endsWith('.js') && (f.includes('fragments') || f.includes('season1') || f.includes('01-soil')) +); + +let chunkContentMatch = false; +for (const f of files) { + if (!f.endsWith('.js')) continue; + const contents = readFileSync(resolve(distAssets, f), 'utf8'); + if (contents.includes('/content/seasons/01-soil/') || contents.includes('season1.soil.first-bloom')) { + chunkContentMatch = true; + break; + } +} + +if (chunkNameMatch || chunkContentMatch) { + console.log('[check-bundle-split] PIPE-02 OK — Season-1 content reachable via build output'); + console.log(` chunkNameMatch=${chunkNameMatch}, chunkContentMatch=${chunkContentMatch}`); + console.log(` files: ${files.filter((f) => f.endsWith('.js')).join(', ')}`); + process.exit(0); +} + +console.error('[check-bundle-split] FAIL — no chunk references /content/seasons/01-soil/'); +console.error(` dist/assets contained: ${files.join(', ')}`); +console.error(' Expected: a chunk filename or content containing "fragments" / "season1" / "01-soil"'); +console.error(' See RESEARCH.md Pattern 8 (Per-Season Lazy Loading) for context.'); +process.exit(1); +``` + +**Step 2 — `scripts/check-bundle-split.test.mjs`** — Vitest unit test that exercises the script in two synthetic-fixture modes: + +Actually, since this script reads from disk after a real `npm run build`, the most pragmatic test is to: +- Verify the script exists, has shebang, and is syntactically valid Node ESM. +- Provide a Vitest test that mocks `dist/assets/` via a temp directory (use `node:fs/promises` and `mkdtemp`) and runs the script's main logic against the mock. + +For Phase 2 we ship a SIMPLER test: assert the script's existence and that it runs against the real `dist/` (which the CI's `npm run build` step will have populated). + +```javascript +// scripts/check-bundle-split.test.mjs — vitest config includes scripts/**/*.test.mjs +import { describe, it, expect } from 'vitest'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('scripts/check-bundle-split.mjs', () => { + it('exists and is non-empty', () => { + const path = resolve(process.cwd(), 'scripts/check-bundle-split.mjs'); + expect(existsSync(path)).toBe(true); + }); + + // The actual structural assertion fires during `npm run ci` after `npm run build` + // populates dist/. Running it standalone in Vitest would either skip (no dist/) + // or duplicate the CI assertion. The script is exit-code-asserted via the ci chain. + it('is syntactically valid Node ESM (parses without error)', async () => { + // Smoke: importing it should not throw at parse time + await expect(import(resolve(process.cwd(), 'scripts/check-bundle-split.mjs'))).resolves.toBeTruthy(); + }); +}); +``` + +**Note:** The script has a `process.exit()` at the top level — importing it in Vitest will terminate the test process. To avoid that, wrap the script body in a `runCheck()` function exported via ESM AND only call it when `import.meta.url === \`file://${process.argv[1]}\`` (CLI mode). Refactor the script accordingly: + +```javascript +#!/usr/bin/env node +import { readdirSync, existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +export function runCheck() { + // ... all the body logic above ... + // Return { ok: boolean, message: string } instead of calling process.exit +} + +// CLI invocation +if (import.meta.url === `file://${process.argv[1]}`) { + const result = runCheck(); + console.log(result.message); + process.exit(result.ok ? 0 : 1); +} +``` + +The Vitest test imports `runCheck` and asserts the structure (skipping the actual filesystem check if `dist/` is absent at test time). + +**Step 3 — Update `package.json`:** + +Add to scripts: +```json +"check:bundle-split": "node scripts/check-bundle-split.mjs", +"ci": "npm run lint && npm run test && npm run validate:assets && npm run build && npm run check:bundle-split" +``` + +This places `check:bundle-split` AFTER `build` in the CI chain so dist/ is populated before the assertion runs. + +**Step 4 — Verify the script works on a fresh build:** + +Run from repo root: +``` +rm -rf dist +npm run build +node scripts/check-bundle-split.mjs +``` +Expect exit code 0 with the success message. If it fails, inspect dist/assets/ output and adjust the matching heuristic in `runCheck()`. + +**Defended option:** If the heuristic is fragile (e.g., Vite renames chunks differently in production builds), document in SUMMARY.md and consider adding `vite.config.ts` `build.rollupOptions.output.manualChunks` to force a `season1` chunk name. Don't auto-add this configuration; surface as Plan 02-05 follow-up. + +**Commit:** `chore(02-03): scripts/check-bundle-split.mjs (PIPE-02 structural verification)`. Run `npm run ci` before committing. + + + - `test -f scripts/check-bundle-split.mjs` + - `grep -q "runCheck" scripts/check-bundle-split.mjs` (refactored to allow Vitest import) + - `grep -q "check:bundle-split" package.json` + - `grep -q "npm run check:bundle-split" package.json` (in scripts.ci) + - Running `node scripts/check-bundle-split.mjs` after `npm run build` exits 0 with success message + - `npx vitest run scripts/check-bundle-split.test.mjs` exits 0 + - `npm run ci` exits 0 end-to-end + + + npm run lint && npm run build && node scripts/check-bundle-split.mjs && npx vitest run scripts/check-bundle-split.test.mjs && npm run ci + + + PIPE-02 structural verification script exists, integrated into CI chain. `npm run ci` exits 0 with the new step in place. If the heuristic needs tuning post-build, surface in SUMMARY.md. + + + +
+ + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Authored content boundary | Fragment body strings are repo-controlled (not user-supplied); Zod-validated at module-eval. React renders as text, no dangerouslySetInnerHTML. | +| Sim ↔ content boundary | sim/memory imports the Fragment[] via injected SimContext; no module-load coupling between sim and Vite's import.meta.glob. | +| Selector seed boundary | mulberry32 seed derives from sim state (harvestCount + plantedAtTick); no wall-clock leak. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-02-03-01 | Tampering | Player edits harvestedFragmentIds via DevTools | accept | Single-player; CRC-32 detects accidental save corruption only (per Phase 1 doctrine). | +| T-02-03-02 | Tampering | Numeric / non-stable fragment ID injected via authoring | mitigate | FragmentSchema regex `/^season\d+\.[a-z0-9._-]+$/` enforced at module-eval (Phase 1 PIPE-01); `npm run build` fails on schema violation. | +| T-02-03-03 | Information disclosure | Fragment body XSS via Markdown / YAML | mitigate | gray-matter + yaml parsers handle content; React renders inside `
` with text content (not HTML); `userSelect: 'text'` doesn't change escape semantics. No dangerouslySetInnerHTML in Journal or RevealModal. |
+| T-02-03-04 | Tampering | Selector returns same fragment via seed manipulation | accept | Seed is pure function of sim state; even if a player manipulates state, no-dup logic ensures progression. |
+| T-02-03-05 | Denial-of-service | Massive fragment file slows initial load | mitigate | PIPE-02 lazy split keeps Season-2-7 out of initial bundle. Phase 2 only ships Season 1 (~12 fragments, <10KB). check-bundle-split.mjs verifies the lazy structure. |
+
+No `high` severity threats. The selector + content surface is small and well-bounded.
+
+
+
+
+After all 3 tasks committed:
+
+1. **Linter:** `npm run lint` exits 0.
+2. **Tests:** `npx vitest run` exits 0; new tests: `src/sim/memory/selector.test.ts` (≥8 cases), `src/sim/memory/pool.test.ts` (optional), `src/sim/garden/commands.test.ts` extended with harvest/compost (≥6 new cases), `src/ui/journal/Journal.test.tsx` (≥6 cases), `src/ui/journal/FragmentRevealModal.test.tsx` (≥5 cases), `scripts/check-bundle-split.test.mjs` (≥2 cases). Combined Phase-1+Phase-2 test count ≥150.
+3. **Build:** `npm run build` exits 0; ≥10 fragments in `/content/seasons/01-soil/` parse without schema violation.
+4. **PIPE-02 structural verify:** `node scripts/check-bundle-split.mjs` exits 0 after build.
+5. **Full CI:** `npm run ci` exits 0 (now includes `check:bundle-split` step).
+6. **Manual smoke** (executor performs once): `npm run dev`, plant rosemary on tile 0, wait 2 minutes (or use FakeClock injection from Plan 02-05's URL flag if landed), click ready plant → reveal modal pops with the selected Season-1 fragment → close → journal icon appears in corner → click icon → journal modal shows the fragment. Plant another rosemary, harvest, then a third — confirm `unlockedPlantTypes` now includes 'yarrow' (visible in the seed picker as a new selectable option).
+
+
+
+
+
+Plan 02-03 is complete when:
+
+- [ ] All 3 tasks committed.
+- [ ] `npm run ci` exits 0 (now with `check:bundle-split` integrated).
+- [ ] Active-play harvest loop works end-to-end: ready plant → click → reveal modal → close → journal icon → journal modal.
+- [ ] ≥10 Season-1 fragments authored under /content/seasons/01-soil/, all matching bible voice + stable string ID rule.
+- [ ] Plant-type unlock thresholds (yarrow at 3 / winter-rose at 6) take effect (Pitfall 10 boundary tested).
+- [ ] Compost works (immature plant → tile clears).
+- [ ] PIPE-02 structurally verified.
+- [ ] MEMR-05 satisfied: Journal text is selectable + copy-pasteable DOM (Vitest covers, manual confirms via browser DevTools).
+- [ ] D-23, D-24, D-25 all visibly satisfied in dev build.
+- [ ] Plan 02-04 (Lura's Ink dialogue) and Plan 02-05 (offline + letter + e2e) can build on this.
+
+
+
+
+Create `.planning/phases/02-season-1-vertical-slice-soil/02-03-harvest-journal-fragments-SUMMARY.md` per template. Document:
+- Plant-type unlock thresholds finalized (yarrow=3, winter-rose=6 — adjust if playtest demands).
+- Total Season-1 fragment count (target ≥10; record actual).
+- Per-tag distribution (warm / contemplative / heavy counts).
+- Whether `scripts/check-bundle-split.mjs` heuristic worked first try or needed tuning.
+- Manual smoke test confirmation.
+- Any compost-acknowledgement Ink content authored ahead of Plan 02-04 (the executor MAY land the .ink file here as a head-start; Plan 02-04 wires the runtime).
+- Garden scene's chosen approach to fragment loading (eager `fragments` filter for Season 1 vs early `loadSeasonFragments(1)` await — both acceptable; document which).
+
diff --git a/.planning/phases/02-season-1-vertical-slice-soil/02-04-lura-gate-beats-PLAN.md b/.planning/phases/02-season-1-vertical-slice-soil/02-04-lura-gate-beats-PLAN.md
new file mode 100644
index 0000000..9785a00
--- /dev/null
+++ b/.planning/phases/02-season-1-vertical-slice-soil/02-04-lura-gate-beats-PLAN.md
@@ -0,0 +1,1367 @@
+---
+phase: 02
+plan: 04
+type: execute
+wave: 2
+depends_on: [02-01, 02-02, 02-03]
+files_modified:
+  - scripts/compile-ink.mjs
+  - scripts/compile-ink.test.mjs
+  - package.json
+  - .gitignore
+  - content/dialogue/season1/lura-arrival.ink
+  - content/dialogue/season1/lura-mid.ink
+  - content/dialogue/season1/lura-farewell.ink
+  - content/dialogue/season1/compost-acknowledgements.ink
+  - content/dialogue/season1/lura-greeting-template.ink
+  - src/sim/narrative/lura-gate.ts
+  - src/sim/narrative/lura-gate.test.ts
+  - src/sim/narrative/beat-queue.ts
+  - src/sim/narrative/index.ts
+  - src/sim/garden/commands.ts
+  - src/sim/garden/commands.test.ts
+  - src/content/ink-loader.ts
+  - src/content/ink-loader.test.ts
+  - src/content/index.ts
+  - src/ui/dialogue/LuraDialogue.tsx
+  - src/ui/dialogue/LuraDialogue.test.tsx
+  - src/ui/dialogue/ink-renderer.tsx
+  - src/ui/dialogue/ink-runtime.ts
+  - src/ui/dialogue/ink-runtime.test.ts
+  - src/ui/dialogue/index.ts
+  - src/ui/index.ts
+  - src/render/garden/gate-renderer.ts
+  - src/render/garden/index.ts
+  - src/game/scenes/Garden.ts
+  - src/App.tsx
+autonomous: true
+requirements: [STRY-01, STRY-06, STRY-07, STRY-10]
+tags: [vertical-slice, lura, ink, dialogue-overlay, narrative-gating, mvp]
+
+must_haves:
+  truths:
+    - "All Lura dialogue is authored in Ink (.ink) under /content/dialogue/season1/; compiled at build time to JSON via `npm run compile:ink` invoking inklecate (STRY-06)"
+    - "Beat 1 (arrival) fires when state.harvestedFragmentIds.length transitions from 0 to 1 (1st harvest); beat 2 (mid) at 4th harvest; beat 3 (farewell) at 8th harvest. Counts come from sim state — STRY-10."
+    - "STRY-10: FakeClock advance alone (without harvest events) does NOT advance Lura beats. Tested in lura-gate.test.ts."
+    - "When a beat fires, sim sets state.luraBeatProgress.pending = beatId; the gate visual (in Phaser) shows a soft glow indicator (D-15). Player clicks the gate → React DOM dialogue overlay opens (D-15)."
+    - "Dialogue overlay uses inkjs Story to drive lines; text-message-cadence renders one line at a time with a tunable delay (RESEARCH p.800: 800ms × line length / 40 chars or simpler fixed 1500ms)"
+    - "Lura's Ink branches read sim state via story.variablesState — at minimum: fragment_count, last_plant_type, last_fragment_title (slot vocabulary documented in PATTERNS.md row 'Group J')"
+    - "After dismissing a beat, sim sets the beat's progress flag to true and clears `pending`; subsequent harvests advance toward the next threshold"
+    - "Compost acknowledgements (D-07 + GARD-04) ship as a small Ink file (compost-acknowledgements.ink) with 3–5 short lines; sim sets a beat flag for compost; the Lura dialogue overlay (or a thinner toast variant) plays the line"
+    - "All player-visible Ink content matches bible voice: warm + specific + intermittent; Lura is the warmth anchor, not a co-griever"
+    - "STRY-07: vacuously satisfied — Phase 2 ships zero Keeper-spoken lines (no Ink file says 'Keeper says...'); documented in SUMMARY"
+    - "Sim does NOT import inkjs (Architectural Responsibility Map line 40: Ink runtime lives in UI tier); narrative gating is pure-state"
+    - "compile-ink.mjs runs cleanly on Windows + macOS + Linux (RESEARCH Assumption A6 verification — first real inklecate invocation in the project)"
+    - "Compiled .ink.json output lives in src/content/compiled-ink/ and is .gitignore'd; the build pipeline regenerates on every `npm run build`"
+    - "npm run ci is green; the new compile-ink.mjs + compiled-ink path participate"
+  artifacts:
+    - path: scripts/compile-ink.mjs
+      provides: "Build-time inklecate runner — walks /content/dialogue/**/*.ink, emits to src/content/compiled-ink//.ink.json"
+    - path: content/dialogue/season1/lura-arrival.ink
+      provides: "Authored Ink for Lura's first beat (after 1st harvest); reads `fragment_count`, `last_plant_type`"
+    - path: content/dialogue/season1/lura-mid.ink
+      provides: "Authored Ink for Lura's mid beat (after 4th harvest); reads same variables"
+    - path: content/dialogue/season1/lura-farewell.ink
+      provides: "Authored Ink for Lura's farewell beat (after 8th harvest)"
+    - path: content/dialogue/season1/compost-acknowledgements.ink
+      provides: "3–5 short lines in voice for the compost tonal beat (GARD-04, D-07, replaces Plan 02-03's TODO)"
+    - path: src/sim/narrative/lura-gate.ts
+      provides: "Pure tick-count gate — checks harvestedFragmentIds.length against {1, 4, 8} thresholds; returns next pending beat id (D-14 + STRY-10)"
+      exports: ["LURA_BEAT_THRESHOLDS", "checkLuraBeatGate", "advanceLuraBeatProgress"]
+    - path: src/sim/narrative/beat-queue.ts
+      provides: "Beat queue type contracts mirroring V1Payload.luraBeatProgress shape"
+      exports: ["LuraBeatId", "LuraBeatProgress"]
+    - path: src/content/ink-loader.ts
+      provides: "Lazy runtime loader for compiled Ink JSON; instantiates inkjs Story with story.variablesState bound from store"
+      exports: ["loadInkStory", "bindGardenStateToInk", "INK_VARIABLE_MAP"]
+    - path: src/ui/dialogue/LuraDialogue.tsx
+      provides: "DOM dialogue overlay (D-15); text-message-cadence drip; opens when narrative.dialogueOverlayOpen=true"
+      exports: ["LuraDialogue"]
+    - path: src/ui/dialogue/ink-runtime.ts
+      provides: "Thin wrapper around inkjs Story.Continue() + currentChoices; binds variables from store snapshot before first Continue"
+      exports: ["InkRuntime", "createInkRuntime"]
+    - path: src/render/garden/gate-renderer.ts
+      provides: "Phaser primitive gate visual + indicator on luraBeatProgress.pending != null"
+      exports: ["drawGate", "updateGateIndicator"]
+  key_links:
+    - from: src/sim/garden/commands.ts
+      to: src/sim/narrative/lura-gate.ts
+      via: "harvest() calls advanceLuraBeatProgress to update state.luraBeatProgress.pending after appending to harvestedFragmentIds"
+      pattern: "advanceLuraBeatProgress"
+    - from: src/ui/dialogue/LuraDialogue.tsx
+      to: src/content/ink-loader.ts
+      via: "loadInkStory(beatId) returns inkjs Story; LuraDialogue drives Continue/choices via ink-runtime"
+      pattern: "loadInkStory"
+    - from: src/render/garden/gate-renderer.ts
+      to: src/store/index.ts
+      via: "Garden scene reads narrativeSlice.luraBeatProgress.pending; updates gate indicator visibility"
+      pattern: "luraBeatProgress.pending"
+    - from: src/game/scenes/Garden.ts
+      to: src/store/index.ts
+      via: "Garden scene's gate pointerdown calls setDialogueOverlayOpen(true) when a beat is pending"
+      pattern: "setDialogueOverlayOpen"
+    - from: src/ui/dialogue/ink-runtime.ts
+      to: "inkjs Story.variablesState"
+      via: "story.variablesState['fragment_count'] = snapshot.harvestedFragmentIds.length (Pitfall 4: snake_case mandatory)"
+      pattern: "variablesState"
+---
+
+
+**Wave 2 vertical slice. Depends on Plans 02-01, 02-02, 02-03.**
+
+This plan ships Lura's three Ink-authored beats end-to-end: from sim-state harvest count → narrative gating → gate indicator on the canvas → React DOM dialogue overlay reading and rendering Ink.
+
+Runs in parallel with Plan 02-05 (Letter + Settings + e2e). Plan 02-05 depends on this plan's `lura_was_here` slot output but only structurally; the merge moment is small.
+
+3 tasks. Estimated context cost ~50%. The first task is the load-bearing inklecate verification (RESEARCH Assumption A6, MEDIUM risk) — if compile-ink.mjs doesn't work on Windows, the executor must surface in SUMMARY.md and adjust before authoring further content.
+
+
+
+Land Lura's three Ink-authored Season 1 beats: arrival (after 1st harvest), mid (after 4th harvest), farewell (after 8th harvest), gated on sim-state harvest count (STRY-10 — system clock manipulation cannot fast-forward beats). Replace the no-op `compile:ink` script with a real inklecate runner; author the four Ink files (3 Lura beats + compost acknowledgements); ship the runtime path (`inkjs.Story` instantiation + variable binding + drip cadence DOM rendering); add a soft gate indicator in the Phaser canvas; wire the player-initiated visit (player clicks gate → React DOM dialogue overlay opens).
+
+Purpose: First real player-narrative integration in the project. Validates the entire Ink stack (inklecate compile → JSON → inkjs runtime → variable wiring → React rendering) end-to-end, on real authored content. Phase 4+ (Roots, Canopy, Storm, etc.) inherits this pipeline without rework. Lura is the warmth anchor for the whole arc — Phase 2 is where her voice goes on the record.
+
+Output: Working narrative-state plumbing where harvesting cadence drives Lura's appearances, three short authored beats reading in voice, the Ink → JSON → runtime pipeline structurally verified, and the foundation for Phase 4+'s longer arcs.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@CLAUDE.md
+@.planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md
+@.planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md
+@.planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md
+@.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-SUMMARY.md
+@.planning/phases/02-season-1-vertical-slice-soil/02-03-harvest-journal-fragments-SUMMARY.md
+
+
+
+
+From src/store/index.ts (Plan 02-01):
+```typescript
+luraBeatProgress: {
+  arrived: boolean; mid: boolean; farewell: boolean;
+  pending: 'arrival' | 'mid' | 'farewell' | null;
+};
+dialogueOverlayOpen: boolean;
+setLuraBeatProgress(p): void;
+setDialogueOverlayOpen(open: boolean): void;
+type LuraBeatId = 'arrival' | 'mid' | 'farewell';
+```
+
+From src/sim/garden/commands.ts (Plan 02-03):
+```typescript
+export function harvest(state: SimState, tileIdx: number, currentTick: number, ctx: SimContext): SimState;
+//        ^^ extend to ALSO call advanceLuraBeatProgress on the new harvest count
+```
+
+From src/sim/state.ts (Plan 02-01):
+```typescript
+export interface SimState {
+  ...
+  luraBeatProgress: { arrived: boolean; mid: boolean; farewell: boolean; pending: ... | null };
+  harvestedFragmentIds: string[];
+  ...
+}
+```
+
+From src/content/index.ts (Plan 02-02):
+```typescript
+export const fragments: Fragment[];   // for last_fragment_title slot
+export const uiStrings: Record;
+```
+
+From src/game/event-bus.ts (Plan 02-01):
+```typescript
+export const eventBus: Phaser.Events.EventEmitter;
+// Events Phase 2 emits:
+//   'gate-clicked' (Phaser → React; emitted when player clicks gate visual)
+```
+
+From inkjs (installed v2.4.0; verified via node_modules/inkjs/ink.d.mts):
+```typescript
+import { Story } from 'inkjs';
+const story = new Story(jsonString);
+story.variablesState['fragment_count'] = 5;        // SNAKE_CASE per Ink convention; Pitfall 4 says casing must match
+const line = story.Continue();                     // returns next text line
+const choices = story.currentChoices;              // array of Choice objects (with text, index)
+story.ChooseChoiceIndex(0);                        // advances on chosen choice
+const canContinue = story.canContinue;             // bool
+```
+
+From inklecate (installed v1.8.1; verified via package.json + node_modules/inklecate/):
+The npm wrapper exposes the inklecate binary. RESEARCH Assumption A6 flags that the Windows binary path needs to work; first real run is THIS plan. Use the wrapper's exported function rather than direct binary path:
+```javascript
+import inklecate from 'inklecate';
+// API surface to confirm: inklecate({ inputFilepath, outputFilepath })
+// — readme + actual API surface verified during Task 1
+```
+
+From .gitignore (current — extend):
+```
+# (existing entries) — Plan 02-04 ADDS:
+# Compiled Ink output (regenerated by `npm run compile:ink`)
+src/content/compiled-ink/
+```
+
+
+
+
+
+
+  Task 1: Author all 4 Ink files (3 Lura beats + compost) + scripts/compile-ink.mjs + ink-loader runtime + RESEARCH A6 inklecate verification
+  
+    - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 5 lines 741-800, Pattern 6 lines 802-840, Assumption A6 line 1213, Pitfall 4 lines 1057-1074)
+    - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group J lines 521-554)
+    - node_modules/inklecate/package.json + node_modules/inklecate/README.md (verify exact API)
+    - node_modules/inkjs/ink.d.mts (Story + variablesState API)
+    - CLAUDE.md (Tone — Lura voice; warmth anchor)
+    - .planning/anti-fomo-doctrine.md (the dialogue must comply: no nag, no FOMO, contemplative)
+  
+  
+    scripts/compile-ink.mjs,
+    scripts/compile-ink.test.mjs,
+    package.json,
+    .gitignore,
+    content/dialogue/season1/lura-arrival.ink,
+    content/dialogue/season1/lura-mid.ink,
+    content/dialogue/season1/lura-farewell.ink,
+    content/dialogue/season1/compost-acknowledgements.ink,
+    src/content/ink-loader.ts,
+    src/content/ink-loader.test.ts,
+    src/content/index.ts
+  
+  
+**Step 1 — Verify inklecate API (Assumption A6, MEDIUM risk).**
+
+Read `node_modules/inklecate/README.md` and `node_modules/inklecate/package.json` to confirm:
+- The default export's call signature.
+- The Windows binary path (e.g., `node_modules/inklecate/inklecate-windows/inklecate.exe`).
+
+If the package wrapper exposes a function like `inklecate({ inputFilepath, outputFilepath })`, use it. If it only exposes a CLI binary path, use Node's `child_process.execFileSync` to invoke the platform-appropriate binary. **Document the chosen approach in compile-ink.mjs's leading comment.**
+
+**Step 2 — `scripts/compile-ink.mjs`** — build-time Ink compiler:
+
+```javascript
+#!/usr/bin/env node
+// Phase 2 Plan 02-04 — compile content/dialogue/**/*.ink → src/content/compiled-ink/**/*.ink.json
+//
+// Per RESEARCH Pattern 5 + Assumption A6 (verified on this run).
+//
+// API note: this script invokes the inklecate npm wrapper. If the wrapper
+// API differs at runtime, fall back to invoking the platform binary via
+// child_process.execFileSync — the wrapper's bin/ directory contains
+// inklecate-windows/, inklecate-linux/, inklecate-macos/ subdirectories.
+
+import { mkdirSync, existsSync, readdirSync, statSync, rmSync } from 'node:fs';
+import { dirname, join, relative, resolve } from 'node:path';
+import { execFileSync } from 'node:child_process';
+
+const INK_ROOT = resolve(process.cwd(), 'content/dialogue');
+const OUT_ROOT = resolve(process.cwd(), 'src/content/compiled-ink');
+
+function findInkFiles(root) {
+  const out = [];
+  if (!existsSync(root)) return out;
+  for (const entry of readdirSync(root)) {
+    const full = join(root, entry);
+    const st = statSync(full);
+    if (st.isDirectory()) out.push(...findInkFiles(full));
+    else if (entry.endsWith('.ink')) out.push(full);
+  }
+  return out;
+}
+
+function inklecateBinary() {
+  // Verified at Task 1 — node_modules/inklecate/ ships per-platform binaries.
+  // The wrapper module exports a programmatic API; if it does not, fall through here.
+  const platform = process.platform;
+  const root = resolve(process.cwd(), 'node_modules/inklecate');
+  if (platform === 'win32') return resolve(root, 'inklecate-windows/inklecate.exe');
+  if (platform === 'darwin') return resolve(root, 'inklecate-mac/inklecate');
+  return resolve(root, 'inklecate-linux/inklecate');
+}
+
+export async function compileAllInk() {
+  const files = findInkFiles(INK_ROOT);
+  if (files.length === 0) {
+    console.log('[compile:ink] no .ink files under content/dialogue/ — skipping');
+    return { compiled: 0 };
+  }
+
+  // Wipe stale output (regenerated every run; .gitignore'd)
+  if (existsSync(OUT_ROOT)) rmSync(OUT_ROOT, { recursive: true, force: true });
+
+  let compiled = 0;
+  // Try the wrapper API first (verified on Task 1 first run); fall back to binary.
+  let wrapper = null;
+  try {
+    wrapper = (await import('inklecate')).default;
+  } catch {
+    wrapper = null;
+  }
+
+  const binary = inklecateBinary();
+
+  for (const inkPath of files) {
+    const rel = relative(INK_ROOT, inkPath);
+    const outPath = resolve(OUT_ROOT, rel.replace(/\.ink$/, '.ink.json'));
+    mkdirSync(dirname(outPath), { recursive: true });
+
+    let didCompile = false;
+    if (wrapper && typeof wrapper === 'function') {
+      try {
+        await wrapper({ inputFilepath: inkPath, outputFilepath: outPath, countAllVisits: false });
+        didCompile = true;
+      } catch (err) {
+        console.warn(`[compile:ink] wrapper failed for ${inkPath} (${(err)?.message ?? err}); falling back to binary`);
+      }
+    }
+    if (!didCompile) {
+      // Inklecate CLI shape: inklecate -o  
+      execFileSync(binary, ['-o', outPath, inkPath], { stdio: 'inherit' });
+      didCompile = true;
+    }
+    compiled++;
+    console.log(`[compile:ink]   ${rel} → ${relative(process.cwd(), outPath)}`);
+  }
+  console.log(`[compile:ink] compiled ${compiled} files`);
+  return { compiled };
+}
+
+// CLI invocation
+if (import.meta.url === `file://${process.argv[1]}`) {
+  compileAllInk().catch((err) => {
+    console.error('[compile:ink] FAILED:', err);
+    process.exit(1);
+  });
+}
+```
+
+(If the executor finds the inklecate wrapper has a different API after reading the package, ADJUST. The key contract is: produces .ink.json output for each .ink input. Surface deviations in SUMMARY.md.)
+
+**Step 3 — `scripts/compile-ink.test.mjs`** — Vitest sanity test:
+
+```javascript
+import { describe, it, expect } from 'vitest';
+import { existsSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { compileAllInk } from './compile-ink.mjs';
+
+describe('scripts/compile-ink.mjs', () => {
+  it('exports compileAllInk', () => {
+    expect(typeof compileAllInk).toBe('function');
+  });
+
+  it('compiles all .ink files in content/dialogue and emits .ink.json under src/content/compiled-ink/', async () => {
+    const result = await compileAllInk();
+    expect(result.compiled).toBeGreaterThanOrEqual(4); // 3 Lura + compost
+    expect(existsSync(resolve(process.cwd(), 'src/content/compiled-ink/season1/lura-arrival.ink.json'))).toBe(true);
+    expect(existsSync(resolve(process.cwd(), 'src/content/compiled-ink/season1/compost-acknowledgements.ink.json'))).toBe(true);
+  });
+});
+```
+
+**Step 4 — Update `package.json`:**
+
+```json
+"compile:ink": "node scripts/compile-ink.mjs",
+"build": "npm run compile:ink && tsc -b && vite build",
+"ci": "npm run lint && npm run test && npm run validate:assets && npm run build && npm run check:bundle-split"
+```
+
+(`npm run build` now precompiles Ink before the TS+Vite build so the `import.meta.glob('/src/content/compiled-ink/**/*.ink.json')` glob can resolve.)
+
+**Step 5 — Update `.gitignore`** to exclude generated Ink JSON:
+
+```
+# Compiled Ink output — regenerated on every build by `npm run compile:ink`
+src/content/compiled-ink/
+```
+
+**Step 6 — Author Ink files.**
+
+`content/dialogue/season1/lura-arrival.ink` — beat 1 (after 1st harvest):
+
+```ink
+// Lura, arrival beat. After the player's first harvest.
+// Variables read from sim:
+//   fragment_count   - number of harvested fragments at the moment Lura arrives
+//   last_plant_type  - 'rosemary' | 'yarrow' | 'winter-rose'
+//
+// Per Pitfall 4: snake_case mandatory.
+// Per CLAUDE.md Tone: Lura is the warmth anchor. Not a co-griever.
+//                     Specific, intermittent, sometimes funny.
+
+VAR fragment_count = 0
+VAR last_plant_type = ""
+
+== arrival ==
+
+You're already here. I thought it might take you longer.
+
+{ last_plant_type == "rosemary":
+    Rosemary, of all things. My grandmother's whole apron, when she got too close to the pot.
+- else:
+  { last_plant_type == "yarrow":
+      Yarrow. There used to be a saying about yarrow but I can't remember it. That's the joke I think.
+  - else:
+    { last_plant_type == "winter-rose":
+        Winter-rose. You don't mess around. Most people start small.
+    - else:
+        Something grew. That's a start.
+    }
+  }
+}
+
+I won't stay long. I just wanted to know that the wall held.
+
+-> END
+```
+
+`content/dialogue/season1/lura-mid.ink` — beat 2 (after 4th harvest):
+
+```ink
+VAR fragment_count = 0
+VAR last_plant_type = ""
+
+== mid ==
+
+Four. That feels like a real number.
+
+I tried to do this once. The garden, I mean. It was a balcony. I had three pots and one of them was already broken when I bought it. The basil died first. The rosemary survived. I think the rosemary survives most things.
+
+You're keeping at it. Most people don't.
+
+I have something I should be doing. I'll come back when there's more.
+
+-> END
+```
+
+`content/dialogue/season1/lura-farewell.ink` — beat 3 (after 8th harvest):
+
+```ink
+VAR fragment_count = 0
+VAR last_plant_type = ""
+
+== farewell ==
+
+Eight is enough for now.
+
+I think we both know what this part is. I'm going to go for a while. There's something I've been putting off, and I think you're far enough along that I can stop pretending I'm here for the small reasons.
+
+You'll know when there's more to say. You don't need me at the gate every day.
+
+The garden persists. Some of it is mine. Most of it is yours now.
+
+-> END
+```
+
+`content/dialogue/season1/compost-acknowledgements.ink` — Plan 02-03's TODO replacement (D-07 + GARD-04):
+
+```ink
+// Compost acknowledgements — short tonal beats fired when the player
+// composts an immature plant. One line per call (the renderer picks
+// randomly via fragment_count seed for variety).
+
+VAR fragment_count = 0
+
+== compost ==
+
+{ fragment_count == 0:
+    Sometimes the soil needs a turn.
+- else:
+  {
+    - fragment_count % 4 == 0:
+        It wasn't ready. That's not the same as failing.
+    - fragment_count % 3 == 0:
+        Some things are easier to begin again than to finish.
+    - fragment_count % 2 == 0:
+        The earth keeps the part that was useful.
+    - else:
+        Letting go is a kind of tending.
+  }
+}
+
+-> END
+```
+
+(Lines designed to match bible voice. User reviews before merge.)
+
+**Step 7 — `src/content/ink-loader.ts`** — runtime loader (RESEARCH Pattern 5):
+
+```typescript
+import { Story } from 'inkjs';
+import type { AppStoreShape } from '../store';
+import { fragments as allFragments } from './loader';
+
+/**
+ * Runtime Ink loader — instantiates an inkjs Story from the compiled
+ * JSON for a given beat id, and binds variables from a store snapshot.
+ *
+ * Per RESEARCH Pattern 5 + Pitfall 4 (snake_case mandatory).
+ */
+
+const luraStories = import.meta.glob('/src/content/compiled-ink/season1/lura-*.ink.json', {
+  query: '?raw',
+  import: 'default',
+});
+
+const compostStory = import.meta.glob('/src/content/compiled-ink/season1/compost-acknowledgements.ink.json', {
+  query: '?raw',
+  import: 'default',
+});
+
+/**
+ * The variable map binds Ink VAR names (snake_case) to functions that
+ * read the current store snapshot. Centralized here per Pitfall 4 — keys
+ * here MUST match VAR declarations in the .ink files.
+ */
+export const INK_VARIABLE_MAP = {
+  fragment_count: (s: AppStoreShape) => s.harvestedFragmentIds.length,
+  last_plant_type: (s: AppStoreShape) => {
+    // Phase 2: derive from most-recent harvest's plant type. The
+    // harvestedFragmentIds list is fragment IDs, not plant types — we
+    // map back via the fragment's `tags` field (warm/contemplative/heavy)
+    // → a plant type. The most-recent fragment's tag is the simplest proxy.
+    const lastId = s.harvestedFragmentIds[s.harvestedFragmentIds.length - 1];
+    if (!lastId) return '';
+    const frag = allFragments.find((f) => f.id === lastId);
+    if (!frag?.tags) return '';
+    if (frag.tags.includes('warm')) return 'rosemary';
+    if (frag.tags.includes('contemplative')) return 'yarrow';
+    if (frag.tags.includes('heavy')) return 'winter-rose';
+    return '';
+  },
+} as const;
+
+export async function loadInkStory(name: 'lura-arrival' | 'lura-mid' | 'lura-farewell' | 'compost-acknowledgements'): Promise {
+  const path = name === 'compost-acknowledgements'
+    ? '/src/content/compiled-ink/season1/compost-acknowledgements.ink.json'
+    : `/src/content/compiled-ink/season1/${name}.ink.json`;
+  const loader = name === 'compost-acknowledgements'
+    ? compostStory[path]
+    : luraStories[path];
+  if (!loader) {
+    throw new Error(`[ink-loader] No compiled story at ${path}. Did npm run compile:ink succeed?`);
+  }
+  const json = (await loader()) as string;
+  return new Story(json);
+}
+
+/**
+ * Set Ink variables from the current store snapshot. Call BEFORE the
+ * first story.Continue(). Per Pitfall 4: variable names are snake_case
+ * AND case-sensitive — typos do NOT throw, they silently leave the var
+ * at its declared default.
+ */
+export function bindGardenStateToInk(story: Story, snapshot: AppStoreShape): void {
+  for (const [varName, getter] of Object.entries(INK_VARIABLE_MAP)) {
+    const value = (getter as (s: AppStoreShape) => string | number | boolean)(snapshot);
+    try {
+      story.variablesState[varName] = value;
+    } catch {
+      // Ink throws if the variable doesn't exist in the story — log and continue.
+      console.warn(`[ink-loader] variable ${varName} not declared in this Ink story (silently skipped)`);
+    }
+  }
+}
+```
+
+**Step 8 — `src/content/ink-loader.test.ts`** — Vitest:
+
+- `loadInkStory('lura-arrival')` returns a `Story` instance (smoke).
+- `loadInkStory('compost-acknowledgements')` returns a Story.
+- `bindGardenStateToInk(story, snapshot)` sets `story.variablesState['fragment_count']` to `snapshot.harvestedFragmentIds.length`.
+- `bindGardenStateToInk` does not throw on a story missing a declared var (the warn is silent).
+- Variable casing test (Pitfall 4): every key in `INK_VARIABLE_MAP` is snake_case. Programmatic assertion: `Object.keys(INK_VARIABLE_MAP).every(k => /^[a-z_]+$/.test(k))`.
+
+This test requires Ink JSON to be present, which requires `npm run compile:ink` to have run. Add a `beforeAll` that runs the compile script:
+
+```typescript
+import { beforeAll } from 'vitest';
+import { compileAllInk } from '../../scripts/compile-ink.mjs';
+beforeAll(async () => { await compileAllInk(); });
+```
+
+**Step 9 — Update `src/content/index.ts`** to re-export ink-loader:
+```typescript
+export { loadInkStory, bindGardenStateToInk, INK_VARIABLE_MAP } from './ink-loader';
+```
+
+**Verification before commit:**
+
+Run from repo root:
+```
+npm run compile:ink
+ls src/content/compiled-ink/season1/   # Should list 4 .ink.json files
+npm run lint
+npx vitest run src/content/ink-loader.test.ts scripts/compile-ink.test.mjs
+npm run build   # Should compile Ink + TS + Vite all green
+```
+
+If `compile:ink` fails on Windows (Assumption A6 risk), DOCUMENT in SUMMARY.md and adjust the `inklecateBinary()` resolution. Try `npx inklecate` as a last fallback.
+
+**Commit:** `feat(02-04): ink compilation pipeline + 4 authored Season-1 Ink files + runtime loader`. Run `npm run ci` before committing.
+  
+  
+    - `test -f scripts/compile-ink.mjs && grep -q "compileAllInk" scripts/compile-ink.mjs`
+    - `test -f content/dialogue/season1/lura-arrival.ink`
+    - `test -f content/dialogue/season1/lura-mid.ink`
+    - `test -f content/dialogue/season1/lura-farewell.ink`
+    - `test -f content/dialogue/season1/compost-acknowledgements.ink`
+    - `grep -q "VAR fragment_count" content/dialogue/season1/lura-arrival.ink`
+    - `grep -q "compile:ink" package.json && grep -q "node scripts/compile-ink.mjs" package.json`
+    - `grep -q "src/content/compiled-ink/" .gitignore`
+    - After `npm run compile:ink`: `ls src/content/compiled-ink/season1/*.ink.json | wc -l` returns ≥4
+    - `grep -q "INK_VARIABLE_MAP" src/content/ink-loader.ts`
+    - `grep -q "snake_case\\|fragment_count\\|last_plant_type" src/content/ink-loader.ts`
+    - `grep -q "loadInkStory" src/content/index.ts`
+    - `npm run ci` exits 0 (now compiles Ink as part of the build chain)
+  
+  
+    npm run compile:ink && npm run lint && npx vitest run src/content/ink-loader.test.ts scripts/compile-ink.test.mjs && npm run ci
+  
+  
+    Ink compile pipeline lands. 4 Season-1 .ink files authored in voice. compile:ink runs cleanly on the dev machine (Assumption A6 verified). Runtime loader instantiates inkjs Story + binds variables. Ink JSON output gitignored. `npm run ci` passes end-to-end.
+  
+
+
+
+  Task 2: sim/narrative — Lura beat gating (1st/4th/8th harvest, STRY-10) + harvest-command integration
+  
+    - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Lura Beat Gating section + Validation Architecture row STRY-10)
+    - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group E lines 312-346)
+    - src/sim/garden/commands.ts (Plan 02-03 — extend `harvest` to call advanceLuraBeatProgress)
+    - src/sim/garden/commands.test.ts (extend with STRY-10 test)
+    - src/sim/state.ts (luraBeatProgress shape)
+  
+  
+    src/sim/narrative/lura-gate.ts,
+    src/sim/narrative/lura-gate.test.ts,
+    src/sim/narrative/beat-queue.ts,
+    src/sim/narrative/index.ts,
+    src/sim/garden/commands.ts,
+    src/sim/garden/commands.test.ts,
+    src/sim/index.ts
+  
+  
+**Step 1 — `src/sim/narrative/beat-queue.ts`** — type contracts:
+
+```typescript
+/**
+ * Lura beat type contracts. Shape mirrors V1Payload.luraBeatProgress
+ * declared in src/save/migrations.ts (Plan 02-01 D-34 extension).
+ */
+
+export type LuraBeatId = 'arrival' | 'mid' | 'farewell';
+
+export interface LuraBeatProgress {
+  arrived: boolean;
+  mid: boolean;
+  farewell: boolean;
+  pending: LuraBeatId | null;
+}
+
+export const INITIAL_LURA_BEAT_PROGRESS: LuraBeatProgress = Object.freeze({
+  arrived: false,
+  mid: false,
+  farewell: false,
+  pending: null,
+});
+```
+
+**Step 2 — `src/sim/narrative/lura-gate.ts`** — pure tick-count gate (PATTERNS Group E):
+
+```typescript
+import type { LuraBeatId, LuraBeatProgress } from './beat-queue';
+
+/**
+ * Lura beat thresholds (CONTEXT D-14). Gate fires when harvestedFragmentIds.length
+ * reaches each threshold value (Pitfall 10: check AFTER the harvest commit).
+ *
+ * Per STRY-10: gates on tick count (harvest events), NOT wall time. A
+ * player who manipulates their system clock cannot fast-forward Lura's
+ * beats — only harvesting does. The harvest function in
+ * src/sim/garden/commands.ts calls advanceLuraBeatProgress with the
+ * post-commit harvestedFragmentIds.length.
+ */
+export const LURA_BEAT_THRESHOLDS: Readonly> = Object.freeze({
+  1: 'arrival',
+  4: 'mid',
+  8: 'farewell',
+});
+
+/**
+ * Given the current LuraBeatProgress and a new harvest count, returns
+ * the (possibly-updated) LuraBeatProgress. Sets `pending` if a threshold
+ * was just crossed AND the corresponding flag is not already set.
+ *
+ * Pure. No side effects.
+ */
+export function advanceLuraBeatProgress(
+  progress: LuraBeatProgress,
+  harvestCount: number,
+): LuraBeatProgress {
+  // If a beat is already pending, don't replace it (player must visit before next fires)
+  if (progress.pending !== null) return progress;
+
+  for (const [threshold, beatId] of Object.entries(LURA_BEAT_THRESHOLDS)) {
+    const t = Number(threshold);
+    if (harvestCount === t) {
+      // Has the corresponding flag already been resolved?
+      const flagKey = beatId === 'arrival' ? 'arrived' : (beatId === 'mid' ? 'mid' : 'farewell');
+      if (progress[flagKey]) continue; // already visited; never re-fire (D-13: 3 beats total)
+      return { ...progress, pending: beatId };
+    }
+  }
+  return progress;
+}
+
+/**
+ * Called when the player closes a Lura dialogue overlay. Marks the
+ * pending beat as visited and clears `pending`.
+ */
+export function resolvePendingLuraBeat(progress: LuraBeatProgress): LuraBeatProgress {
+  if (!progress.pending) return progress;
+  if (progress.pending === 'arrival') return { ...progress, arrived: true, pending: null };
+  if (progress.pending === 'mid') return { ...progress, mid: true, pending: null };
+  if (progress.pending === 'farewell') return { ...progress, farewell: true, pending: null };
+  return progress;
+}
+
+/**
+ * Has any beat fired and is awaiting visit? Used by the gate-renderer
+ * (Phaser) to decide whether to draw the indicator (D-15).
+ */
+export function isLuraBeatPending(progress: LuraBeatProgress): boolean {
+  return progress.pending !== null;
+}
+```
+
+**Step 3 — `src/sim/narrative/lura-gate.test.ts`** — Vitest, esp. STRY-10 case:
+
+```typescript
+import { describe, it, expect } from 'vitest';
+import { FakeClock } from '../scheduler';
+import { advanceLuraBeatProgress, resolvePendingLuraBeat, isLuraBeatPending, LURA_BEAT_THRESHOLDS } from './lura-gate';
+import { INITIAL_LURA_BEAT_PROGRESS } from './beat-queue';
+
+describe('advanceLuraBeatProgress (STRY-10, D-14)', () => {
+  it('sets pending=arrival on the 1st harvest', () => {
+    const next = advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 1);
+    expect(next.pending).toBe('arrival');
+    expect(next.arrived).toBe(false); // not yet visited
+  });
+
+  it('does NOT set pending at harvest count 0', () => {
+    const next = advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 0);
+    expect(next.pending).toBeNull();
+  });
+
+  it('does NOT set pending at counts between thresholds (2, 3, 5, 6, 7)', () => {
+    [2, 3, 5, 6, 7].forEach((c) => {
+      const next = advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, c);
+      expect(next.pending).toBeNull();
+    });
+  });
+
+  it('Pitfall 10 (off-by-one boundary): threshold 4 fires AT 4, not 3 or 5', () => {
+    expect(advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 3).pending).toBeNull();
+    expect(advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 4).pending).toBe('mid');
+    expect(advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 5).pending).toBeNull();
+  });
+
+  it('does NOT replace a pending beat with a different one (player must visit first)', () => {
+    let p = advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 1); // pending=arrival
+    p = advanceLuraBeatProgress(p, 4);
+    expect(p.pending).toBe('arrival'); // unchanged
+  });
+
+  it('does NOT re-fire an already-visited beat', () => {
+    let p: any = { ...INITIAL_LURA_BEAT_PROGRESS, arrived: true };
+    p = advanceLuraBeatProgress(p, 1);
+    expect(p.pending).toBeNull();
+  });
+
+  it('STRY-10: FakeClock advance does NOT advance Lura beats without harvest events', () => {
+    const clock = new FakeClock(0);
+    const initialProgress = INITIAL_LURA_BEAT_PROGRESS;
+    clock.advance(60 * 60 * 1000); // 1 hour of "wall time"
+    // No harvests fired — the gate function is invoked with harvestCount=0
+    const after = advanceLuraBeatProgress(initialProgress, 0);
+    expect(after).toEqual(INITIAL_LURA_BEAT_PROGRESS);
+  });
+});
+
+describe('resolvePendingLuraBeat', () => {
+  it('marks arrival as resolved and clears pending', () => {
+    const p = { ...INITIAL_LURA_BEAT_PROGRESS, pending: 'arrival' as const };
+    const next = resolvePendingLuraBeat(p);
+    expect(next.arrived).toBe(true);
+    expect(next.pending).toBeNull();
+  });
+
+  it('marks mid + farewell similarly', () => {
+    const m = resolvePendingLuraBeat({ ...INITIAL_LURA_BEAT_PROGRESS, pending: 'mid' });
+    expect(m.mid).toBe(true);
+    const f = resolvePendingLuraBeat({ ...INITIAL_LURA_BEAT_PROGRESS, pending: 'farewell' });
+    expect(f.farewell).toBe(true);
+  });
+
+  it('is a no-op when pending=null', () => {
+    expect(resolvePendingLuraBeat(INITIAL_LURA_BEAT_PROGRESS)).toEqual(INITIAL_LURA_BEAT_PROGRESS);
+  });
+});
+
+describe('isLuraBeatPending', () => {
+  it('returns true when pending is set', () => {
+    expect(isLuraBeatPending({ ...INITIAL_LURA_BEAT_PROGRESS, pending: 'arrival' })).toBe(true);
+  });
+  it('returns false when no beat pending', () => {
+    expect(isLuraBeatPending(INITIAL_LURA_BEAT_PROGRESS)).toBe(false);
+  });
+});
+```
+
+**Step 4 — `src/sim/narrative/index.ts`:**
+```typescript
+export { LURA_BEAT_THRESHOLDS, advanceLuraBeatProgress, resolvePendingLuraBeat, isLuraBeatPending } from './lura-gate';
+export type { LuraBeatId, LuraBeatProgress } from './beat-queue';
+export { INITIAL_LURA_BEAT_PROGRESS } from './beat-queue';
+```
+
+Add `export * from './narrative'` to `src/sim/index.ts`.
+
+**Step 5 — Extend `src/sim/garden/commands.ts`** — `harvest` now updates `luraBeatProgress`:
+
+In the harvest function, after the `harvestedIds = [...]` line and before computing `unlockedPlantTypes`:
+
+```typescript
+import { advanceLuraBeatProgress } from '../narrative/lura-gate';
+
+// ... inside harvest():
+const luraBeatProgress = advanceLuraBeatProgress(state.luraBeatProgress, harvestedIds.length);
+
+return {
+  ...state,
+  garden: { tiles: nextTiles },
+  harvestedFragmentIds: harvestedIds,
+  unlockedPlantTypes,
+  luraBeatProgress,
+};
+```
+
+**Step 6 — Extend `src/sim/garden/commands.test.ts`** with integration tests:
+
+- After harvesting 1 ready plant, `state.luraBeatProgress.pending` is `'arrival'`.
+- After harvesting 4 ready plants (with `arrived=true` set after the 1st), `state.luraBeatProgress.pending` is `'mid'`.
+- Harvest count 5 with `pending='mid'` (player hasn't visited yet) leaves `pending='mid'`.
+- After 8 harvests with 1+4 already visited, `pending='farewell'`.
+
+**Commit:** `feat(02-04): sim/narrative — Lura beat gating (1/4/8 harvest, STRY-10)`. Run `npm run lint && npx vitest run src/sim/narrative/ src/sim/garden/` before committing.
+  
+  
+    - `grep -q "LURA_BEAT_THRESHOLDS" src/sim/narrative/lura-gate.ts`
+    - `grep -q "1: 'arrival'" src/sim/narrative/lura-gate.ts`
+    - `grep -q "4: 'mid'" src/sim/narrative/lura-gate.ts`
+    - `grep -q "8: 'farewell'" src/sim/narrative/lura-gate.ts`
+    - `grep -q "advanceLuraBeatProgress" src/sim/garden/commands.ts` (harvest integration)
+    - `grep -L "Date.now\\|setInterval" src/sim/narrative/lura-gate.ts src/sim/narrative/beat-queue.ts` (sim purity)
+    - `grep -q "FakeClock" src/sim/narrative/lura-gate.test.ts` (STRY-10 test exists)
+    - `npx vitest run src/sim/narrative/ src/sim/garden/` exits 0; ≥10 new test cases green; STRY-10 case present
+    - `npm run lint && npm run build` exits 0
+  
+  
+    npm run lint && npx vitest run src/sim/narrative/ src/sim/garden/ && npm run build
+  
+  
+    sim/narrative module ships pure tick-count Lura gate. STRY-10 test case proves FakeClock alone does not advance beats. harvest() in commands.ts updates state.luraBeatProgress on threshold crossings (Pitfall 10 boundary tested). All Phase-2 sim modules pass sim-purity ESLint rule.
+  
+
+
+
+  Task 3: ui/dialogue (LuraDialogue + ink-renderer + ink-runtime) + render/garden gate-renderer + Garden scene integration + App.tsx mount
+  
+    - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 5 lines 776-800 drip cadence, Architectural Responsibility Map row "Ink runtime bridge")
+    - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group I lines 471-518 React mounting; Group H lines 426-468 render layer)
+    - src/store/index.ts (narrativeSlice: dialogueOverlayOpen, luraBeatProgress, setDialogueOverlayOpen, setLuraBeatProgress)
+    - src/game/scenes/Garden.ts (Plan 02-02 + 02-03 — extend with gate object + pointerdown)
+    - src/render/garden/tile-coords.ts (Plan 02-02 — gate sits in canvas alongside grid; reuse layout constants)
+    - src/App.tsx (Plan 02-03 — extend mount list)
+    - src/ui/journal/Journal.tsx (analog DOM full-screen overlay)
+  
+  
+    src/ui/dialogue/LuraDialogue.tsx,
+    src/ui/dialogue/LuraDialogue.test.tsx,
+    src/ui/dialogue/ink-renderer.tsx,
+    src/ui/dialogue/ink-runtime.ts,
+    src/ui/dialogue/ink-runtime.test.ts,
+    src/ui/dialogue/index.ts,
+    src/ui/index.ts,
+    src/render/garden/gate-renderer.ts,
+    src/render/garden/index.ts,
+    src/game/scenes/Garden.ts,
+    src/App.tsx
+  
+  
+**Step 1 — `src/ui/dialogue/ink-runtime.ts`** — thin wrapper around inkjs (RESEARCH p.776):
+
+```typescript
+import type { Story } from 'inkjs';
+
+/**
+ * InkRuntime — thin wrapper around inkjs Story that yields lines one at
+ * a time with a tunable cadence delay. Used by LuraDialogue.
+ *
+ * Phase 2: fixed delay per line (1500ms or proportional to line length).
+ * Phase 8: reduced-motion (UX-05) will short-circuit the delay.
+ */
+export interface InkRuntime {
+  /** Pull the next available line; resolves after the cadence delay. */
+  nextLine(): Promise;
+  /** Are there more lines or choices available? */
+  canContinue(): boolean;
+  /** Current choices, if the story has paused on a choice point. */
+  currentChoices(): { index: number; text: string }[];
+  /** Pick a choice and resume. */
+  chooseChoice(index: number): void;
+  /** Skip the cadence delay (e.g., player tap-to-advance). */
+  skipDelay(): void;
+}
+
+const DEFAULT_DELAY_MS = 1500;
+const PER_CHAR_MS = 20;
+const MAX_DELAY_MS = 4000;
+
+export function createInkRuntime(story: Story): InkRuntime {
+  let skipNext = false;
+  return {
+    async nextLine() {
+      if (!story.canContinue) return null;
+      const line = story.Continue();
+      const delay = skipNext ? 0 : Math.min(MAX_DELAY_MS, DEFAULT_DELAY_MS + line.length * PER_CHAR_MS);
+      skipNext = false;
+      if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
+      return line;
+    },
+    canContinue: () => story.canContinue,
+    currentChoices: () => story.currentChoices.map((c, i) => ({ index: i, text: c.text })),
+    chooseChoice: (index: number) => story.ChooseChoiceIndex(index),
+    skipDelay: () => { skipNext = true; },
+  };
+}
+```
+
+**Step 2 — `src/ui/dialogue/ink-runtime.test.ts`** — Vitest:
+
+- Given a 2-line story, `nextLine()` returns each line in order; after second, returns null.
+- `skipDelay()` makes the next `nextLine()` resolve nearly-instantly (timing assertion: <100ms).
+- `canContinue()` returns true at start and false after exhaustion.
+- `currentChoices()` returns choice array when story pauses on choices.
+- `chooseChoice(0)` advances past the choice point.
+
+(For Vitest, use `vi.useFakeTimers()` to assert delay logic without real waits.)
+
+**Step 3 — `src/ui/dialogue/ink-renderer.tsx`** — drip rendering of accumulated Ink lines:
+
+```typescript
+import { useEffect, useRef, useState } from 'react';
+import type { InkRuntime } from './ink-runtime';
+
+/**
+ * Drives an InkRuntime, drips lines into the DOM with text-message
+ * cadence. Used by LuraDialogue (full-screen overlay) and may be reused
+ * for compost acknowledgements (smaller toast variant — Plan 02-04 Task 3).
+ */
+export function InkRenderer({ runtime, onComplete }: { runtime: InkRuntime; onComplete?: () => void }): JSX.Element {
+  const [lines, setLines] = useState([]);
+  const [choices, setChoices] = useState<{ index: number; text: string }[]>([]);
+  const [done, setDone] = useState(false);
+  const cancelled = useRef(false);
+
+  useEffect(() => {
+    cancelled.current = false;
+    (async () => {
+      while (!cancelled.current) {
+        const line = await runtime.nextLine();
+        if (cancelled.current) return;
+        if (line === null) break;
+        if (line.trim().length > 0) {
+          setLines((prev) => [...prev, line.trim()]);
+        }
+      }
+      const cs = runtime.currentChoices();
+      if (cs.length > 0) {
+        setChoices(cs);
+        return;
+      }
+      setDone(true);
+      onComplete?.();
+    })();
+    return () => { cancelled.current = true; };
+  }, [runtime, onComplete]);
+
+  const onChoice = (index: number) => {
+    runtime.chooseChoice(index);
+    setChoices([]);
+    setLines((prev) => [...prev]); // trigger re-render; loop will pick up
+  };
+
+  return (
+    
runtime.skipDelay()} style={{ cursor: 'pointer' }}> + {lines.map((line, i) => ( +

{line}

+ ))} + {choices.length > 0 && ( +
+ {choices.map((c) => ( + + ))} +
+ )} +
+ ); +} +``` + +**Step 4 — `src/ui/dialogue/LuraDialogue.tsx`:** + +```typescript +import { useEffect, useState } from 'react'; +import { useAppStore } from '../../store'; +import { loadInkStory, bindGardenStateToInk } from '../../content'; +import { createInkRuntime, type InkRuntime } from './ink-runtime'; +import { InkRenderer } from './ink-renderer'; +import { resolvePendingLuraBeat } from '../../sim/narrative'; + +/** + * D-15 — React DOM dialogue overlay. Opens when player clicks the gate + * with a pending Lura beat. Loads the corresponding compiled Ink, binds + * variables from the store snapshot, drives the InkRenderer. + * + * On dismiss: resolves the pending beat in the store (which clears `pending` + * and sets the corresponding visited flag). + */ +export function LuraDialogue(): JSX.Element | null { + const open = useAppStore((s) => s.dialogueOverlayOpen); + const pending = useAppStore((s) => s.luraBeatProgress.pending); + const setDialogueOverlayOpen = useAppStore((s) => s.setDialogueOverlayOpen); + const setLuraBeatProgress = useAppStore((s) => s.setLuraBeatProgress); + const [runtime, setRuntime] = useState(null); + + useEffect(() => { + if (!open || !pending) { + setRuntime(null); + return; + } + let cancelled = false; + (async () => { + try { + const beatName = `lura-${pending}` as 'lura-arrival' | 'lura-mid' | 'lura-farewell'; + const story = await loadInkStory(beatName); + if (cancelled) return; + bindGardenStateToInk(story, useAppStore.getState()); + // The story's knot has the same name as the beat — call the entry + const knot = pending; // Ink files use `== arrival ==`, `== mid ==`, `== farewell ==` + story.ChoosePathString(knot); + setRuntime(createInkRuntime(story)); + } catch (err) { + console.error('[LuraDialogue] failed to load beat', pending, err); + // Fail soft — close overlay + setDialogueOverlayOpen(false); + } + })(); + return () => { cancelled = true; }; + }, [open, pending, setDialogueOverlayOpen]); + + if (!open) return null; + + const onClose = () => { + setDialogueOverlayOpen(false); + // Resolve the pending beat in the store + setLuraBeatProgress(resolvePendingLuraBeat(useAppStore.getState().luraBeatProgress)); + }; + + return ( +
+
+ {runtime ? {}} /> :

...

} + +
+
+ ); +} +``` + +**Step 5 — `src/ui/dialogue/LuraDialogue.test.tsx`** — Vitest with mocked Ink runtime (since happy-dom can run inkjs but the cadence makes assertions slow): + +- With `dialogueOverlayOpen: false`, returns null. +- With `dialogueOverlayOpen: true` and `pending: null`, returns null (no beat to render). +- With `dialogueOverlayOpen: true` and `pending: 'arrival'`, mounts the dialog (text "Lura at the gate" via aria-label). +- Close button click: dispatches `setDialogueOverlayOpen(false)` AND advances `luraBeatProgress.arrived` to true. +- (Skip the actual Ink rendering assertion in unit test — Plan 02-05 e2e covers the integration.) + +**Step 6 — `src/ui/dialogue/index.ts`** + `src/ui/index.ts`: +```typescript +// src/ui/dialogue/index.ts +export { LuraDialogue } from './LuraDialogue'; +export { InkRenderer } from './ink-renderer'; +export { createInkRuntime } from './ink-runtime'; +export type { InkRuntime } from './ink-runtime'; + +// src/ui/index.ts (extend) +export * from './begin'; +export * from './garden'; +export * from './journal'; +export * from './dialogue'; +``` + +**Step 7 — `src/render/garden/gate-renderer.ts`** — Phaser primitive gate visual + indicator (D-15): + +```typescript +import * as Phaser from 'phaser'; + +/** + * Phaser primitive gate visual. Sits at the edge of the 4×4 garden. When + * a Lura beat is pending (luraBeatProgress.pending != null), the gate + * glows softly via alpha pulse (D-15). + * + * Phase 3 paints over with the watercolor gate. The hit/glow shape stays. + */ + +const GATE_X = 880; // canvas px — right side, near the grid +const GATE_Y = 384; // vertical center +const GATE_COLOR = 0x6e6e75; +const GATE_GLOW_COLOR = 0xe8d8b6; +const GATE_HIT_W = 80; +const GATE_HIT_H = 120; + +export interface GateGameObjects { + hit: Phaser.GameObjects.Rectangle; + body: Phaser.GameObjects.Rectangle; + glow: Phaser.GameObjects.Rectangle; + glowTween: Phaser.Tweens.Tween | null; +} + +export function drawGate(scene: Phaser.Scene): GateGameObjects { + const body = scene.add.rectangle(GATE_X, GATE_Y, GATE_HIT_W * 0.7, GATE_HIT_H, GATE_COLOR); + const glow = scene.add.rectangle(GATE_X, GATE_Y, GATE_HIT_W * 0.9, GATE_HIT_H * 1.05, GATE_GLOW_COLOR, 0); + glow.setBlendMode(Phaser.BlendModes.ADD); + const hit = scene.add.rectangle(GATE_X, GATE_Y, GATE_HIT_W, GATE_HIT_H, 0xffffff, 0); + hit.setInteractive({ useHandCursor: true }); + hit.setData('isGate', true); + return { hit, body, glow, glowTween: null }; +} + +export function updateGateIndicator(scene: Phaser.Scene, gate: GateGameObjects, isPending: boolean): void { + if (isPending && !gate.glowTween) { + gate.glowTween = scene.tweens.add({ + targets: gate.glow, + alpha: { from: 0.0, to: 0.4 }, + duration: 1200, + ease: 'Sine.easeInOut', + yoyo: true, + repeat: -1, + }); + } else if (!isPending && gate.glowTween) { + gate.glowTween.stop(); + gate.glowTween = null; + gate.glow.setAlpha(0); + } +} +``` + +Update `src/render/garden/index.ts`: +```typescript +export { drawGate, updateGateIndicator } from './gate-renderer'; +export type { GateGameObjects } from './gate-renderer'; +``` + +**Step 8 — Update `src/game/scenes/Garden.ts`:** + +(a) In `create()`, draw the gate; subscribe to store and call `updateGateIndicator` on changes. +(b) Wire gate pointerdown → `setDialogueOverlayOpen(true)` (only if a beat is pending). + +```typescript +import { drawGate, updateGateIndicator, type GateGameObjects } from '../../render/garden'; + +// In Garden class: +private gate: GateGameObjects | null = null; + +// In create(): +this.gate = drawGate(this); +this.gate.hit.on('pointerdown', () => { + const pending = appStore.getState().luraBeatProgress.pending; + if (pending) { + appStore.getState().setDialogueOverlayOpen(true); + } +}); + +// Add to the store-subscribe block: +this.storeUnsubscribe = appStore.subscribe((state) => { + this.repaintPlants(state.tiles as Tile[]); + if (this.gate) { + updateGateIndicator(this, this.gate, state.luraBeatProgress.pending !== null); + } +}); +// Initial paint: +if (this.gate) { + updateGateIndicator(this, this.gate, appStore.getState().luraBeatProgress.pending !== null); +} +``` + +(Plan 02-03 already wired the harvest pointerdown; this plan adds the gate pointerdown without conflicting.) + +**Step 9 — Update `src/App.tsx`:** + +```typescript +import { LuraDialogue } from './ui/dialogue'; + +// Inside
: + +{/* (other overlays from prior plans) */} +``` + +**Compost-beat wiring (resolves Plan 02-03 TODO):** + +The compost line plays via the same dialogue overlay (smaller / shorter); for Phase 2 minimum-viable: just fire `setDialogueOverlayOpen(true)` with a synthetic `pending: 'compost'` flag. But to keep the data model clean, INSTEAD: Plan 02-04 ships compost lines as a small toast variant. Surface this as a Plan 02-05 follow-up if it requires non-trivial UI work; for Phase 2 Wave 2, document the compost lines exist (compost-acknowledgements.ink compiles green), and Plan 02-05 wires the actual compost-toast UI alongside the persistence-denied toast (similar shape). + +(Update SUMMARY.md: "Compost Ink content authored; runtime wiring deferred to Plan 02-05's persistence-toast surface, per minimum-viable bias.") + +**Manual smoke test:** `npm run dev`, plant + harvest a rosemary (~2 min) → gate begins glowing → click gate → Lura arrival dialogue overlay appears → text drips line by line in voice → close → gate stops glowing → harvest 3 more (rosemary unlocks yarrow at 3) → fragment-count 4 → gate glows again → click → Lura mid beat plays. + +**Commit:** `feat(02-04): Lura dialogue overlay + Ink runtime + gate visual + Garden scene wiring`. Run `npm run ci` before committing. + + + - `grep -q "createInkRuntime" src/ui/dialogue/ink-runtime.ts` + - `grep -q "story.Continue()" src/ui/dialogue/ink-runtime.ts` + - `grep -q "ChoosePathString(knot)" src/ui/dialogue/LuraDialogue.tsx` + - `grep -q "loadInkStory" src/ui/dialogue/LuraDialogue.tsx` + - `grep -q "resolvePendingLuraBeat" src/ui/dialogue/LuraDialogue.tsx` + - `grep -q "drawGate" src/render/garden/gate-renderer.ts` + - `grep -q "updateGateIndicator" src/render/garden/gate-renderer.ts` + - `grep -q "this.gate" src/game/scenes/Garden.ts` (gate instance held by Garden scene) + - `grep -q "setDialogueOverlayOpen" src/game/scenes/Garden.ts` + - `grep -q "" src/App.tsx` + - `npx vitest run src/ui/dialogue/` exits 0; ≥6 cases green + - `npm run ci` exits 0 + + + npm run lint && npx vitest run src/ui/dialogue/ src/render/ && npm run ci + + + LuraDialogue overlay renders Ink with text-message cadence. Gate visual glows when a beat is pending; click opens overlay; close resolves the beat in the store. App.tsx mounts LuraDialogue. Manual smoke test confirms 1st/4th harvest triggers Lura's arrival/mid beats end-to-end. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Ink content boundary | `.ink` files are repo-controlled; `inklecate` produces JSON; React renders strings (no dangerouslySetInnerHTML); inkjs evaluates story logic in-memory only. | +| sim ↔ inkjs boundary | Sim never imports inkjs (Architectural Responsibility Map line 40). Narrative gating is pure-state; runtime lives in UI tier. | +| Build-time boundary | inklecate is a Node ESM script invoked during `npm run build`; produces only repo-local JSON files; no network. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-02-04-01 | Tampering | System-clock manipulation skips Lura beats | mitigate | STRY-10 — beats gate on harvestedFragmentIds.length (sim-state, not wall-clock). lura-gate.test.ts asserts FakeClock advance alone doesn't progress beats. | +| T-02-04-02 | Tampering | Player edits luraBeatProgress in DevTools | accept | Single-player; CRC-32 detects accidental save corruption. | +| T-02-04-03 | Information disclosure | Ink content XSS via JSON injection | mitigate | inkjs renders strings; React renders strings; no dangerouslySetInnerHTML. Ink JSON is build-time generated from repo-controlled .ink. | +| T-02-04-04 | Tampering | Compiled Ink JSON checked into repo and modified maliciously | accept | Compiled JSON is gitignored. Source-of-truth is the .ink file. CI regenerates JSON from source on every build. | +| T-02-04-05 | Denial-of-service | Ink story infinite loop blocking the runtime | accept | Inkjs handles loops at story logic; the runtime's setTimeout-based delay is bounded. Phase 2's authored content has no loops. | +| T-02-04-06 | Tampering | Ink variable name typo silently leaves variable unset (Pitfall 4) | mitigate | INK_VARIABLE_MAP is the centralized snake_case mapping; ink-loader.test.ts asserts every key is snake_case. Adding a new variable requires editing both the .ink file AND INK_VARIABLE_MAP — one fails CI without the other. | + +No `high` severity threats. + + + + +After all 3 tasks committed: + +1. **Linter:** `npm run lint` exits 0. +2. **Tests:** `npx vitest run` exits 0; new test files: `scripts/compile-ink.test.mjs`, `src/content/ink-loader.test.ts`, `src/sim/narrative/lura-gate.test.ts`, `src/ui/dialogue/ink-runtime.test.ts`, `src/ui/dialogue/LuraDialogue.test.tsx`. Combined Phase-1+Phase-2 test count ≥175. +3. **Ink compile:** `npm run compile:ink` produces `src/content/compiled-ink/season1/{lura-arrival,lura-mid,lura-farewell,compost-acknowledgements}.ink.json`. +4. **Build:** `npm run build` exits 0 — `compile:ink` runs as part of build, then tsc + vite. +5. **PIPE-02 verify:** `node scripts/check-bundle-split.mjs` after build exits 0 (the compiled Ink JSON participates in a chunk; should not break the season1 chunk assertion). +6. **Full CI:** `npm run ci` exits 0. +7. **STRY-10 evidence:** lura-gate.test.ts has a test case proving FakeClock advance alone doesn't trigger beats (visible in test output). +8. **Manual smoke** (executor performs once): `npm run dev`, plant 1 rosemary, harvest at ready → gate glows → click gate → Lura arrival overlay → text drips → close → gate stops glowing → harvestedFragmentIds.length=1, luraBeatProgress.arrived=true. + + + + + +Plan 02-04 is complete when: + +- [ ] All 3 tasks committed. +- [ ] `npm run ci` exits 0. +- [ ] Compile pipeline: `.ink` source → `inklecate` → `.ink.json` → `inkjs.Story` → React DOM works end-to-end. +- [ ] 4 authored Ink files (3 Lura beats + compost acknowledgements) match bible voice + anti-FOMO doctrine. +- [ ] Lura beats fire at harvest counts 1, 4, 8 (D-14); STRY-10 verified (FakeClock alone doesn't advance). +- [ ] Gate visual glows when a beat pends; click opens DOM dialogue overlay; close resolves the beat. +- [ ] sim/narrative is pure: no `inkjs` import, no `Date.now`, no `setInterval`. +- [ ] STRY-07 vacuously satisfied (no Keeper-spoken lines in Phase 2). +- [ ] Plan 02-05 (Letter + e2e) can build on the `lura_was_here` slot output (already covered by store's `luraBeatProgress.pending`). + + + + +Create `.planning/phases/02-season-1-vertical-slice-soil/02-04-lura-gate-beats-SUMMARY.md` per template. Document: +- Inklecate API path used (wrapper function vs CLI binary; RESEARCH Assumption A6 verification). +- Whether compile:ink ran first-try on the dev machine OR needed adjustments. +- Final cadence values (DEFAULT_DELAY_MS, PER_CHAR_MS, MAX_DELAY_MS) — playtest may adjust. +- Whether the compost-beat UI was wired here or deferred to Plan 02-05's toast surface. +- Manual smoke test confirmation (date / browser / observed Lura beat at 1st harvest). +- Any tonal review notes from the user on Lura's authored copy. +- Confirmation that no Phase-2 sim module imports inkjs. + diff --git a/.planning/phases/02-season-1-vertical-slice-soil/02-05-letter-settings-e2e-PLAN.md b/.planning/phases/02-season-1-vertical-slice-soil/02-05-letter-settings-e2e-PLAN.md new file mode 100644 index 0000000..2770f57 --- /dev/null +++ b/.planning/phases/02-season-1-vertical-slice-soil/02-05-letter-settings-e2e-PLAN.md @@ -0,0 +1,1434 @@ +--- +phase: 02 +plan: 05 +type: execute +wave: 2 +depends_on: [02-01, 02-02, 02-03, 02-04] +files_modified: + - content/dialogue/season1/letter-from-the-garden.ink + - src/sim/offline/events.ts + - src/sim/offline/events.test.ts + - src/sim/offline/index.ts + - src/sim/garden/auto-harvest.ts + - src/sim/garden/auto-harvest.test.ts + - src/sim/garden/index.ts + - src/sim/garden/commands.ts + - src/save/migrations.ts + - src/save/index.ts + - src/ui/letter/Letter.tsx + - src/ui/letter/Letter.test.tsx + - src/ui/letter/letter-renderer.ts + - src/ui/letter/letter-renderer.test.ts + - src/ui/letter/index.ts + - src/ui/settings/Settings.tsx + - src/ui/settings/Settings.test.tsx + - src/ui/settings/persistence-toast.tsx + - src/ui/settings/index.ts + - src/ui/index.ts + - src/App.tsx + - src/PhaserGame.tsx + - src/game/scenes/Garden.ts + - tests/e2e/season1-loop.spec.ts + - playwright.config.ts +autonomous: true +requirements: [UX-02, UX-10, CORE-03, CORE-11, PIPE-07, GARD-02, GARD-04] +tags: [vertical-slice, letter, settings, save-lifecycle, offline-catchup, playwright-e2e, mvp] + +must_haves: + truths: + - "Player who closes the tab and returns ≥5 minutes later sees the full-screen letter overlay; <5 minutes sees no letter (D-20)" + - "Letter renders an authored Ink skeleton with templated insertions: plants_bloomed (count), fragment_titles (string), lura_was_here (bool) — populated from offlineEvents block in V1Payload (D-17, D-18, D-19)" + - "One tap dismisses letter to live garden (D-20). Dismiss button calls bootstrapAudioContext (Pitfall 9 mitigation)." + - "Auto-harvest during offline (D-10): the silent simulate path harvests every plant that ripened; offlineEvents records {plantsBloomedCount, harvestedFragmentIds, luraBeatPending}" + - "Auto-harvest in active play does NOT fire — player chooses when to harvest active plants" + - "Save lifecycle: visibilitychange→hidden, beforeunload, AND saveOnSeasonTransition() (UX-10) all serialize the current state via wrap+CRC32+IDB-or-LocalStorage" + - "Settings menu (D-28): Export to Base64 (CORE-09), Import from Base64 (CORE-09), Restore previous snapshot (CORE-08); no audio sliders, no keyboard nav (Phase 8)" + - "Settings access: corner icon + keyboard shortcut (D-29). Persistent." + - "Persistence-result toast (D-30): one-time soft toast in voice on first save if denied; nothing if granted. State remembered via settings.persistenceToastShown." + - "Boot path: on page load, read save → migrate (still v1) → compute offlineMs via computeOfflineCatchup → if elapsedMs >= 5*60*1000, run silent catch-up loop, fill offlineEvents, open letter overlay" + - "URL flag ?devtime=fake injects FakeClock for Playwright; production-guarded (import.meta.env.PROD ignores the flag)" + - "Playwright e2e (PIPE-07): load → dismiss begin → plant → fast-forward via window.__tlgFakeClock.advance → harvest → fragment-reveal → close → journal shows fragment → reload page → fragment persists" + - "24h offline cap surfaced silently in the letter's voice (D-11); no numeric '28h' copy in any code path" + - "compost tonal beat (Plan 02-04 deferral) wires here as a small toast variant or as a tiny one-line render via the dialogue overlay — implementation choice surfaced in SUMMARY" + - "All 24 Phase-2 REQ-IDs visibly satisfied across the 5 plans of this phase" + artifacts: + - path: content/dialogue/season1/letter-from-the-garden.ink + provides: "Authored Ink letter skeleton with VAR plants_bloomed, fragment_titles, lura_was_here (D-17, D-18)" + - path: src/sim/offline/events.ts + provides: "OfflineEventBlockSchema (Zod) + aggregateOfflineEvents(prev, next) — pure" + exports: ["OfflineEventBlockSchema", "OfflineEventBlock", "aggregateOfflineEvents"] + - path: src/sim/garden/auto-harvest.ts + provides: "autoHarvestReadyPlants(state, currentTick, ctx) — silent-mode harvest branch (D-10)" + exports: ["autoHarvestReadyPlants"] + - path: src/ui/letter/Letter.tsx + provides: "Full-screen letter overlay (D-20). Loads compiled letter Ink, binds slots from offlineEvents, renders one-tap-to-dismiss" + exports: ["Letter"] + - path: src/ui/letter/letter-renderer.ts + provides: "Pure template helper: buildLetterSlots(offlineEvents, fragments) → {plants_bloomed, fragment_titles, lura_was_here}" + exports: ["buildLetterSlots"] + - path: src/ui/settings/Settings.tsx + provides: "Settings modal (D-28): Export, Import, Restore. Save-management only — Phase 8 adds audio/a11y." + exports: ["Settings"] + - path: src/ui/settings/persistence-toast.tsx + provides: "PersistenceToast (D-30) — one-time soft toast" + exports: ["PersistenceToast"] + - path: tests/e2e/season1-loop.spec.ts + provides: "Playwright PIPE-07 full-loop smoke" + key_links: + - from: src/PhaserGame.tsx + to: src/save/index.ts + via: "Boot path: openSaveDB → unwrap → migrate → drainTicks(silent=true) → if absence>=5min set offlineEvents + openLetter" + pattern: "computeOfflineCatchup\\|drainTicks" + - from: src/PhaserGame.tsx + to: src/save/lifecycle.ts + via: "registerSaveLifecycleHooks({saveSync}) — wires visibilitychange + beforeunload" + pattern: "registerSaveLifecycleHooks" + - from: src/ui/letter/Letter.tsx + to: src/content/ink-loader.ts + via: "loadInkStory('letter-from-the-garden') + bindGardenStateToInk + buildLetterSlots" + pattern: "loadInkStory\\('letter" + - from: tests/e2e/season1-loop.spec.ts + to: src/sim/scheduler/clock.ts FakeClock + via: "page.goto('/?devtime=fake') → window.__tlgFakeClock.advance(...)" + pattern: "__tlgFakeClock" +--- + + +**Wave 2 closing plan. Depends on Plans 02-01, 02-02, 02-03, 02-04.** + +This is the integration plan: it ties offline catch-up (D-10), the letter (UX-02), save lifecycle hooks (UX-10), the Settings UI (D-28..D-30), and the Playwright e2e (PIPE-07) together — proving the full Season 1 vertical slice end-to-end. + +After this plan ships, Phase 2 is functionally complete: a player can launch, plant, grow, harvest, meet Lura, leave the tab for hours, and return to a letter from the garden — and the Playwright e2e proves it persists. + +3 tasks. Estimated context cost ~50%. The Playwright spec is the load-bearing closing artifact. + + + +Land the Letter-from-the-garden vertical slice + Settings UI + save lifecycle wiring + Playwright e2e (PIPE-07) — the final Phase-2 integration. After return-from-tab-close, the player sees an authored Ink letter (D-17, D-18, UX-02) with templated insertions describing what bloomed while away (auto-harvest per D-10), what Lura did (gate beat queued during absence), and what the wind brought; one tap dismisses to the live garden. Settings menu provides Export / Import / Restore (D-28) plus the in-voice persistence-result toast (D-30). Playwright e2e exercises the entire authored loop: load → begin → plant → fast-forward → harvest → reveal → close → journal-shows-fragment → reload → fragment-persists. + +Purpose: Closes Phase 2. Validates that the architecture firewall holds end-to-end on real authored content + real save round-trip + real fast-forward via FakeClock injection. The PIPE-07 Playwright spec becomes the canonical proof that Phase 2 ships — `/gsd-verify-work` runs after this plan. + +Output: A complete, working Phase-2-vertical-slice game that could plausibly ship as a free standalone Season 1 prologue. All 24 Phase-2 REQ-IDs structurally satisfied. `npm run ci && npx playwright test` exits 0. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@CLAUDE.md +@.planning/anti-fomo-doctrine.md +@.planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md +@.planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md +@.planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md +@.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-SUMMARY.md +@.planning/phases/02-season-1-vertical-slice-soil/02-02-begin-plant-grow-SUMMARY.md +@.planning/phases/02-season-1-vertical-slice-soil/02-03-harvest-journal-fragments-SUMMARY.md +@.planning/phases/02-season-1-vertical-slice-soil/02-04-lura-gate-beats-SUMMARY.md + + + + +From src/sim/scheduler/index.ts (Plan 02-01): +```typescript +export type { Clock } from './clock'; +export { wallClock, FakeClock } from './clock'; +export const TICK_MS: number; // 200 (5Hz) +export const MAX_OFFLINE_MS: number; // 24 * 3600 * 1000 +export function drainTicks(state: S, accumulatorMs: number, simulate, silent?: boolean): { state, remainderMs, ticksApplied }; +export function computeOfflineCatchup(savedLastTickAt: number, nowMs: number): { elapsedMs, cappedMs, willRunCatchup, hitOfflineCap }; +``` + +From src/save/index.ts (Plan 02-01 extended): +```typescript +export { wrap, unwrap } from './envelope'; +export { migrate, CURRENT_SCHEMA_VERSION } from './migrations'; +export type { V1Payload, OfflineEventBlock } from './migrations'; // OfflineEventBlock TYPE declared in migrations.ts (Plan 02-01); ZOD schema in src/sim/offline/ (this plan) +export { exportToBase64, importFromBase64, MAX_IMPORT_BYTES } from './codec'; +export { snapshot, listSnapshots } from './snapshots'; +export { requestPersistence } from './persist'; +export { openSaveDB, LocalStorageDBAdapter } from './db'; +export { registerSaveLifecycleHooks, saveOnSeasonTransition } from './lifecycle'; +``` + +From src/store/index.ts (Plan 02-01): +```typescript +session.letterOverlayOpen: boolean; +session.pendingLetterEventBlock: unknown | null; +openLetter(block: unknown): void; +dismissLetter(): void; +session.persistenceToastShown: boolean; +setPersistenceToastShown(v: boolean): void; +``` + +From src/sim/garden/commands.ts (Plan 02-03 + 02-04): +```typescript +export function harvest(state: SimState, tileIdx: number, currentTick: number, ctx: SimContext): SimState; +// ^^ this plan adds an `autoHarvest` branch to simulateOneTick when called with `silent: true` +``` + +V1Payload offlineEvents shape (Plan 02-01 declared inline): +```typescript +export interface OfflineEventBlock { + plantsBloomedCount: Record; + harvestedFragmentIds: string[]; + luraBeatPending: 'arrival' | 'mid' | 'farewell' | null; +} +``` + +From src/ui/dialogue (Plan 02-04): +```typescript +export const InkRenderer: React.FC<{ runtime: InkRuntime; onComplete?: () => void }>; +export function createInkRuntime(story: Story): InkRuntime; +``` + +From src/content/ink-loader.ts (Plan 02-04): +```typescript +export async function loadInkStory(name: 'lura-arrival' | 'lura-mid' | 'lura-farewell' | 'compost-acknowledgements'): Promise; +// ^^ this plan extends to also accept 'letter-from-the-garden' +export function bindGardenStateToInk(story: Story, snapshot: AppStoreShape): void; +``` + +From src/PhaserGame.tsx (Plan 02-02): +The boot path currently sets `unlockedPlantTypes: ['rosemary']` if empty, mounts Phaser, listens for scene-ready. THIS PLAN replaces that bootstrap with a real save-load path: read save (if present) → migrate → set initial store state from migrated V1Payload → compute offline → run silent catch-up → maybe open letter. + +From content/dialogue/season1/ (Plan 02-04 ships 4 .ink files): +- lura-arrival.ink, lura-mid.ink, lura-farewell.ink, compost-acknowledgements.ink. THIS PLAN adds: letter-from-the-garden.ink. + +playwright.config.ts (already shipped Phase 1): +```typescript +testDir: 'tests/e2e', +use: { baseURL: 'http://localhost:5173' }, +webServer: { command: 'npm run dev', url: 'http://localhost:5173', reuseExistingServer: true, timeout: 30000 }, +``` + + + + + + + Task 1: sim/offline + auto-harvest + extended ink-loader for letter + letter-from-the-garden.ink authoring + + - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 6 lines 802-840 letter Ink template, Pitfall 4 line 1057 snake_case, Pitfall 9 line 1110 letter dismiss audio) + - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group F lines 350-376 zod schema) + - .planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md (D-10 auto-harvest, D-11 silent 24h cap, D-17/D-18/D-19/D-20 letter) + - src/save/migrations.ts (OfflineEventBlock interface declared in Plan 02-01) + - src/sim/garden/commands.ts (Plans 02-03 + 02-04 — harvest + Lura integration) + - src/content/ink-loader.ts (Plan 02-04 — extend the union to accept 'letter-from-the-garden') + - CLAUDE.md (Tone — letter must read in voice, not stat dump) + + + src/sim/offline/events.ts, + src/sim/offline/events.test.ts, + src/sim/offline/index.ts, + src/sim/garden/auto-harvest.ts, + src/sim/garden/auto-harvest.test.ts, + src/sim/garden/commands.ts, + src/sim/garden/index.ts, + src/sim/index.ts, + content/dialogue/season1/letter-from-the-garden.ink, + src/content/ink-loader.ts, + src/ui/letter/letter-renderer.ts, + src/ui/letter/letter-renderer.test.ts + + +**Step 1 — `src/sim/offline/events.ts`** — Zod schema + aggregator: + +```typescript +import { z } from 'zod'; + +/** + * OfflineEventBlock — captures what happened while the player was away. + * Per CONTEXT D-19. Phase 2 ships the minimum slot vocabulary; + * Phase 4+ may add more if playtest demands. + * + * Structurally compatible with the OfflineEventBlock interface declared + * in src/save/migrations.ts (Plan 02-01); the Zod schema here is the + * runtime validator. + */ +export const OfflineEventBlockSchema = z.object({ + plantsBloomedCount: z.record(z.string(), z.number().int().nonnegative()), + harvestedFragmentIds: z.array(z.string().regex(/^season\d+\.[a-z0-9._-]+$/)), + luraBeatPending: z.enum(['arrival', 'mid', 'farewell']).nullable(), +}); + +export type OfflineEventBlock = z.infer; + +export const EMPTY_OFFLINE_EVENTS: OfflineEventBlock = Object.freeze({ + plantsBloomedCount: {}, + harvestedFragmentIds: [], + luraBeatPending: null, +}); + +/** + * Pure aggregator — combines a previous OfflineEventBlock with a new + * (plantTypeId, fragmentId, luraBeatPending?) tuple from a single + * silent-mode auto-harvest event. + */ +export function aggregateOfflineEvent( + prev: OfflineEventBlock, + plantTypeId: string, + fragmentId: string, + luraBeatPending: OfflineEventBlock['luraBeatPending'], +): OfflineEventBlock { + const counts = { ...prev.plantsBloomedCount }; + counts[plantTypeId] = (counts[plantTypeId] ?? 0) + 1; + return { + plantsBloomedCount: counts, + harvestedFragmentIds: [...prev.harvestedFragmentIds, fragmentId], + luraBeatPending: luraBeatPending ?? prev.luraBeatPending, + }; +} +``` + +**Step 2 — `src/sim/offline/events.test.ts`** — Vitest: + +- `OfflineEventBlockSchema.parse(EMPTY_OFFLINE_EVENTS)` succeeds. +- Schema rejects: missing field, wrong-type field, fragment id with bad regex. +- `aggregateOfflineEvent(EMPTY, 'rosemary', 'season1.soil.first-bloom', null)` returns block with plantsBloomedCount.rosemary=1. +- Two consecutive aggregates increment counts correctly. +- luraBeatPending overwrites only when newer is non-null AND prev was null. + +**Step 3 — `src/sim/offline/index.ts`:** +```typescript +export { OfflineEventBlockSchema, EMPTY_OFFLINE_EVENTS, aggregateOfflineEvent } from './events'; +export type { OfflineEventBlock } from './events'; +``` + +Add `export * from './offline'` to `src/sim/index.ts`. + +**Step 4 — `src/sim/garden/auto-harvest.ts`** — silent-mode harvest branch (D-10): + +```typescript +import type { SimState } from '../state'; +import type { Tile } from './types'; +import { PLANT_TYPES } from './plants'; +import { advanceGrowth } from './growth'; +import { harvest } from './commands'; +import type { SimContext } from './commands'; +import { aggregateOfflineEvent } from '../offline/events'; +import { EMPTY_OFFLINE_EVENTS } from '../offline/events'; + +/** + * D-10 — auto-harvest during offline. While the player is away, plants + * that ripen are auto-harvested, populating the offlineEvents block + * that the *letter* will narrate. + * + * Pure. Called inside drainTicks's silent-mode simulate function. + */ +export function autoHarvestReadyPlants( + state: SimState, + currentTick: number, + ctx: SimContext, +): SimState { + let next = state; + const tiles = state.garden.tiles as Tile[]; + for (let i = 0; i < tiles.length; i++) { + const tile = tiles[i]; + if (!tile?.plant) continue; + const type = PLANT_TYPES[tile.plant.plantTypeId]; + if (!type) continue; + const stage = advanceGrowth(tile.plant, type, currentTick); + if (stage !== 'ready') continue; + + // Snapshot fields we'll need to populate offlineEvents + const harvestedBefore = next.harvestedFragmentIds.length; + const plantTypeId = tile.plant.plantTypeId; + + // Reuse the standard harvest pipeline (selector + plant-unlock + Lura gate) + next = harvest(next, i, currentTick, ctx); + + // If a fragment was actually selected, append to offline events + if (next.harvestedFragmentIds.length > harvestedBefore) { + const newId = next.harvestedFragmentIds[next.harvestedFragmentIds.length - 1]; + const luraPending = next.luraBeatProgress.pending; + const prevEvents = (next.offlineEvents as ReturnType | null) ?? EMPTY_OFFLINE_EVENTS; + next = { + ...next, + offlineEvents: aggregateOfflineEvent(prevEvents, plantTypeId, newId, luraPending), + }; + } + } + return next; +} +``` + +**Step 5 — `src/sim/garden/auto-harvest.test.ts`** — Vitest: + +- A 4×4 garden with 2 ready rosemary plants → autoHarvestReadyPlants returns state with both tiles cleared, offlineEvents.plantsBloomedCount.rosemary=2, harvestedFragmentIds.length grew by 2. +- An immature plant is NOT auto-harvested. +- An empty tile is a no-op. +- After auto-harvest crosses the 1-fragment threshold, offlineEvents.luraBeatPending === 'arrival'. + +**Step 6 — Update `simulateOneTick`** in `src/sim/garden/commands.ts` to call `autoHarvestReadyPlants` when called in silent mode: + +In Plan 02-03, `simulateOneTick` accepted `(state, currentTick, commands, ctx)`. Add a 5th argument `silent: boolean` (or pass via ctx). Simpler: add `silent` to ctx: + +```typescript +export interface SimContext { + fragments: readonly Fragment[]; + currentSeason: number; + silent?: boolean; // when true, simulateOneTick auto-harvests ready plants (D-10) +} + +export function simulateOneTick(state, currentTick, commands, ctx): SimState { + let next = state; + for (const cmd of commands) { /* ... existing cases ... */ } + if (ctx.silent) { + next = autoHarvestReadyPlants(next, currentTick, ctx); + } + return { ...next, lastTickAt: currentTick }; +} +``` + +Update `src/sim/garden/index.ts`: +```typescript +export { autoHarvestReadyPlants } from './auto-harvest'; +``` + +**Step 7 — Author `content/dialogue/season1/letter-from-the-garden.ink`** (RESEARCH Pattern 6): + +```ink +// Letter from the garden — UX-02 + D-17 + D-18. +// Composed from authored skeleton + templated insertions per CONTEXT D-17. +// Slots populated at runtime from sim/offline/events.ts via the variable +// map in src/content/ink-loader.ts. +// +// Per Pitfall 4: variable names are snake_case AND case-sensitive. +// Per CONTEXT D-11: 24h offline cap is silent in voice — no numeric "28h" copy. +// +// The skeleton MUST read like authored fiction (CLAUDE.md Tone). The +// slots fill in the specifics. + +VAR plants_bloomed = 0 +VAR fragment_titles = "" +VAR lura_was_here = false +VAR fragment_count = 0 +VAR last_plant_type = "" + +== letter == + +The garden held its breath while you were gone. + +{ plants_bloomed > 1: + {plants_bloomed} blooms came and went, each leaving the soil a little quieter than they found it. +- else: + { plants_bloomed == 1: + One bloom came and went. The space it left feels generous, somehow. + - else: + Nothing bloomed. The wind carried something else, and the garden held that, too. + } +} + +{ 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 — you'll find it when you next walk past. +} + +The light is the same as when you left. The garden is older. + +-> END +``` + +**Step 8 — Update `src/content/ink-loader.ts`** to support the letter: + +Extend the `loadInkStory` union AND `INK_VARIABLE_MAP`: + +```typescript +const luraStories = import.meta.glob('/src/content/compiled-ink/season1/lura-*.ink.json', {...}); +const compostStory = import.meta.glob('/src/content/compiled-ink/season1/compost-acknowledgements.ink.json', {...}); +const letterStory = import.meta.glob('/src/content/compiled-ink/season1/letter-from-the-garden.ink.json', { + query: '?raw', import: 'default', +}); + +export const INK_VARIABLE_MAP = { + fragment_count: (s) => s.harvestedFragmentIds.length, + last_plant_type: (s) => { /* ... unchanged ... */ }, + // NEW for letter: + plants_bloomed: (s) => { + const counts = (s.pendingLetterEventBlock as { plantsBloomedCount?: Record } | null)?.plantsBloomedCount ?? {}; + return Object.values(counts).reduce((a, b) => a + b, 0); + }, + fragment_titles: (s) => { + const ids = (s.pendingLetterEventBlock as { harvestedFragmentIds?: string[] } | null)?.harvestedFragmentIds ?? []; + if (ids.length === 0) return ''; + // Convert IDs to a comma-joined human-friendly list. For Phase 2, slugify the ID's last segment. + return ids.map((id) => id.replace(/^season\d+\./, '').replace(/[._-]+/g, ' ')).join(', '); + }, + lura_was_here: (s) => Boolean((s.pendingLetterEventBlock as { luraBeatPending?: string | null } | null)?.luraBeatPending), +} as const; + +export async function loadInkStory( + name: 'lura-arrival' | 'lura-mid' | 'lura-farewell' | 'compost-acknowledgements' | 'letter-from-the-garden', +): Promise { + let path: string; let loader; + if (name === 'compost-acknowledgements') { + path = `/src/content/compiled-ink/season1/${name}.ink.json`; + loader = compostStory[path]; + } else if (name === 'letter-from-the-garden') { + path = `/src/content/compiled-ink/season1/${name}.ink.json`; + loader = letterStory[path]; + } else { + path = `/src/content/compiled-ink/season1/${name}.ink.json`; + loader = luraStories[path]; + } + if (!loader) throw new Error(`[ink-loader] No compiled story at ${path}.`); + const json = (await loader()) as string; + return new Story(json); +} +``` + +**Step 9 — `src/ui/letter/letter-renderer.ts`** — pure helper for slot building (separate from the React component for testability): + +```typescript +import type { OfflineEventBlock } from '../../sim/offline'; +import type { Fragment } from '../../content'; + +/** + * Build the variable slot values for letter-from-the-garden.ink from + * an OfflineEventBlock + the fragment pool (for human-readable titles). + * + * Pure. Used by Letter.tsx via INK_VARIABLE_MAP at bind time. + */ +export interface LetterSlots { + plants_bloomed: number; + fragment_titles: string; + lura_was_here: boolean; +} + +export function buildLetterSlots( + events: OfflineEventBlock | null, + allFragments: readonly Fragment[], +): LetterSlots { + if (!events) return { plants_bloomed: 0, fragment_titles: '', lura_was_here: false }; + const total = Object.values(events.plantsBloomedCount).reduce((a, b) => a + b, 0); + // For human-readable titles: use the fragment id's last segment, slugified to spaces + const titles = events.harvestedFragmentIds + .map((id) => { + const f = allFragments.find((x) => x.id === id); + // Prefer the fragment's first sentence (up to 60 chars) for tonal weight; fall back to id slug + if (f) { + const firstLine = f.body.split(/[.!?]/)[0]?.trim() ?? ''; + if (firstLine.length > 0 && firstLine.length <= 60) return firstLine.toLowerCase(); + } + return id.replace(/^season\d+\./, '').replace(/[._-]+/g, ' '); + }) + .filter((t) => t.length > 0); + return { + plants_bloomed: total, + fragment_titles: titles.join('; '), + lura_was_here: events.luraBeatPending !== null, + }; +} +``` + +**Step 10 — `src/ui/letter/letter-renderer.test.ts`** — Vitest: + +- Empty events → all zeros / empty / false. +- Single rosemary auto-harvest → plants_bloomed=1, fragment_titles uses the fragment's first-sentence slug. +- luraBeatPending='arrival' → lura_was_here=true. +- 24h cap edge case: 50 plants bloomed → plants_bloomed=50 (no truncation; the Ink template handles "many" copy). + +**Commit:** `feat(02-05): sim/offline + auto-harvest + letter Ink + letter-renderer`. Run `npm run lint && npm run compile:ink && npx vitest run src/sim/offline/ src/sim/garden/auto-harvest.test.ts src/ui/letter/letter-renderer.test.ts && npm run ci` before committing. + + + - `grep -q "OfflineEventBlockSchema" src/sim/offline/events.ts` + - `grep -q "aggregateOfflineEvent" src/sim/offline/events.ts` + - `grep -q "autoHarvestReadyPlants" src/sim/garden/auto-harvest.ts` + - `grep -q "ctx.silent" src/sim/garden/commands.ts` (silent mode triggers auto-harvest) + - `test -f content/dialogue/season1/letter-from-the-garden.ink` + - `grep -q "VAR plants_bloomed" content/dialogue/season1/letter-from-the-garden.ink` + - `grep -q "VAR lura_was_here" content/dialogue/season1/letter-from-the-garden.ink` + - `grep -q "letter-from-the-garden" src/content/ink-loader.ts` (loadInkStory accepts the union case) + - `grep -q "buildLetterSlots" src/ui/letter/letter-renderer.ts` + - `grep -L "Date.now\\|setInterval" src/sim/offline/events.ts src/sim/garden/auto-harvest.ts` (sim purity) + - `npm run compile:ink` produces `src/content/compiled-ink/season1/letter-from-the-garden.ink.json` + - `npx vitest run src/sim/offline/ src/sim/garden/auto-harvest.test.ts src/ui/letter/letter-renderer.test.ts` exits 0 + - `npm run ci` exits 0 + + + npm run compile:ink && npm run lint && npx vitest run src/sim/offline/ src/sim/garden/auto-harvest.test.ts src/ui/letter/letter-renderer.test.ts && npm run ci + + + sim/offline ships Zod schema + aggregator. autoHarvestReadyPlants extends silent-mode simulate. letter-from-the-garden.ink authored in voice. ink-loader supports the letter. letter-renderer builds slots purely. All sim modules sim-pure. `npm run ci` green. + + + + + Task 2: Letter overlay + Settings + persistence-toast UIs + boot-path save lifecycle wiring + URL-flag clock injection + + - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Sim-Clock Injection lines 1377-1387, Open Question 5 lines 1245-1248, AudioContext bootstrap Pattern 9, Pitfall 9 line 1110 letter dismiss audio) + - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group I React mounting, Group M PhaserGame.tsx hook addition lines 663-690) + - .planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md (D-20 letter UX, D-28 Settings scope, D-29 access pattern, D-30 toast) + - src/PhaserGame.tsx (Plan 02-02 — wire boot path here) + - src/save/index.ts (Plan 02-01 — barrel of all save APIs) + - src/store/session-slice.ts (Plan 02-01 — letterOverlayOpen, persistenceToastShown) + - src/ui/journal/Journal.tsx (analog full-screen modal pattern) + - src/ui/dialogue/LuraDialogue.tsx (analog Ink-driven overlay) + + + src/ui/letter/Letter.tsx, + src/ui/letter/Letter.test.tsx, + src/ui/letter/index.ts, + src/ui/settings/Settings.tsx, + src/ui/settings/Settings.test.tsx, + src/ui/settings/persistence-toast.tsx, + src/ui/settings/index.ts, + src/ui/index.ts, + src/save/migrations.ts, + src/PhaserGame.tsx, + src/game/scenes/Garden.ts, + src/App.tsx + + +**Step 1 — `src/ui/letter/Letter.tsx`** (D-20 + Pitfall 9): + +```typescript +import { useEffect, useState } from 'react'; +import { useAppStore } from '../../store'; +import { loadInkStory, fragments as allFragments } from '../../content'; +import { createInkRuntime, InkRenderer, type InkRuntime } from '../dialogue'; +import { bootstrapAudioContext } from '../begin'; +import { buildLetterSlots } from './letter-renderer'; +import type { OfflineEventBlock } from '../../sim/offline'; + +/** + * UX-02 + D-20 — Letter from the garden. Full-screen overlay; one tap + * dismisses to the live garden. Triggered when absence ≥ 5 minutes + * (the threshold check lives in the boot path in src/PhaserGame.tsx). + * + * Per Pitfall 9: dismiss must call bootstrapAudioContext too — a returning + * player who lands directly in the letter would otherwise have no audio + * gesture before reaching the live garden. + */ +export function Letter(): JSX.Element | null { + const open = useAppStore((s) => s.letterOverlayOpen); + const block = useAppStore((s) => s.pendingLetterEventBlock) as OfflineEventBlock | null; + const dismissLetter = useAppStore((s) => s.dismissLetter); + const [runtime, setRuntime] = useState(null); + + useEffect(() => { + if (!open) { + setRuntime(null); + return; + } + let cancelled = false; + (async () => { + try { + const story = await loadInkStory('letter-from-the-garden'); + if (cancelled) return; + const slots = buildLetterSlots(block, allFragments); + try { story.variablesState['plants_bloomed'] = slots.plants_bloomed; } catch {} + try { story.variablesState['fragment_titles'] = slots.fragment_titles; } catch {} + try { story.variablesState['lura_was_here'] = slots.lura_was_here; } catch {} + story.ChoosePathString('letter'); + setRuntime(createInkRuntime(story)); + } catch (err) { + console.error('[Letter] failed to load', err); + dismissLetter(); + } + })(); + return () => { cancelled = true; }; + }, [open, block, dismissLetter]); + + if (!open) return null; + + const onDismiss = () => { + void bootstrapAudioContext(); // Pitfall 9: returning player audio gesture + dismissLetter(); + }; + + return ( +
+
e.stopPropagation()} + style={{ + maxWidth: 620, padding: '3rem 2.6rem', + cursor: 'default', + }} + > + {runtime ? {}} /> :

...

} + +
+
+ ); +} +``` + +**Step 2 — `src/ui/letter/Letter.test.tsx`** — Vitest: + +- With `letterOverlayOpen: false`, returns null. +- With `open: true` and `pendingLetterEventBlock: null`, mounts the dialog (loading state). +- Dismiss button click dispatches `dismissLetter()` AND calls `bootstrapAudioContext` (spy). +- Click on backdrop dismisses; click on article does NOT. + +**Step 3 — `src/ui/letter/index.ts`:** +```typescript +export { Letter } from './Letter'; +export { buildLetterSlots } from './letter-renderer'; +export type { LetterSlots } from './letter-renderer'; +``` + +**Step 4 — `src/ui/settings/Settings.tsx`** (D-28 save-management only): + +```typescript +import { useState } from 'react'; +import { useAppStore } from '../../store'; +import { exportToBase64, importFromBase64, listSnapshots, snapshot, openSaveDB, wrap, unwrap, migrate, CURRENT_SCHEMA_VERSION, type V1Payload } from '../../save'; +import { uiStrings } from '../../content'; + +/** + * D-28 — Phase 2 Settings UI. Save-management surfaces only. + * Audio sliders + keyboard nav + a11y polish ship in Phase 8. + */ +export function Settings({ open, onClose }: { open: boolean; onClose: () => void }): JSX.Element | null { + const strings = uiStrings[1]?.settings; + const [base64Buf, setBase64Buf] = useState(''); + const [statusLine, setStatusLine] = useState(null); + if (!open || !strings) return null; + + const onExport = async () => { + try { + // Build a fresh save envelope from current store state. + // (Plan 02-05 wires the same payload-build path in src/PhaserGame.tsx for save lifecycle hooks.) + const state = useAppStore.getState(); + const payload: V1Payload = buildPayloadFromStore(state); + const env = wrap(payload, CURRENT_SCHEMA_VERSION); + const b64 = await exportToBase64(env); + navigator.clipboard?.writeText(b64).catch(() => {}); + setBase64Buf(b64); + setStatusLine('Saved to clipboard.'); + } catch (e) { + setStatusLine('Could not save.'); + } + }; + + const onImport = async () => { + try { + const env = await importFromBase64(base64Buf); + const { payload } = migrate(env.payload, env.schemaVersion); + const v1 = unwrap(env); + const restored = v1.payload as V1Payload; // post-migrate + // Apply to store + hydrateStoreFromPayload(restored); + setStatusLine('Restored.'); + } catch (e) { + setStatusLine('That doesn\'t look like one of yours.'); + } + }; + + const onRestoreSnapshot = async () => { + try { + const db = await openSaveDB(); + const snaps = await listSnapshots(db); + if (snaps.length === 0) { + setStatusLine('Nothing earlier to find.'); + return; + } + // Restore the most-recent snapshot (Phase 2 ships single-action restore; + // Phase 8 may add a list selector). + const last = snaps[snaps.length - 1]; + const payload = unwrap(last.envelope); + hydrateStoreFromPayload(payload as V1Payload); + setStatusLine('Earlier garden restored.'); + } catch (e) { + setStatusLine('Nothing earlier could be reached.'); + } + }; + + return ( +
+
+

{strings.title}

+ +