diff --git a/.planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md b/.planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md new file mode 100644 index 0000000..fba2c8c --- /dev/null +++ b/.planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md @@ -0,0 +1,1026 @@ +# Phase 2: Season 1 Vertical Slice (Soil) — Pattern Map + +**Mapped:** 2026-05-09 +**Files analyzed:** 49 new + 5 modified (54 total) +**Analogs found:** 47 / 54 (87%) + +## Reading order for the planner + +1. **Architectural firewall is non-negotiable.** ESLint enforces `src/sim/` cannot import from `src/render/` or `src/ui/`. Every plan's first acceptance criterion is `npm run ci` green. The firewall test under `src/sim/__test_violation__/` is the proof-of-rule; don't break it. +2. **Phase 1 already shipped the heavy infrastructure.** Save layer (`src/save/`) and content pipeline (`src/content/`) are *frozen public barrels* — Phase 2 imports from `src/save/index.ts` and `src/content/index.ts`, never from internal modules. The only edit inside `src/save/` is to `migrations.ts` (extend `V1Payload`); the only addition inside `src/content/` is `ink-loader.ts`. +3. **`V1Payload` is extended, not migrated.** `CURRENT_SCHEMA_VERSION` stays at `1`. No new `migrations[2]` entry. Phase 4 owns `migrations[2]`. +4. **Sim is pure.** No `Date.now()`, no `setInterval`, no DOM, no fetch. Time is *injected* via the scheduler. The scheduler at `src/sim/scheduler/clock.ts` is the only file in the project allowed to call `Date.now()`. +5. **Tests live next to the file under test.** `foo.ts` ↔ `foo.test.ts`. Vitest config already includes `src/**/*.test.ts` and `src/**/*.test.tsx`. + +## File Classification + +### NEW files (49) + +| New file | Role | Data flow | Closest analog | Match quality | +|----------|------|-----------|----------------|---------------| +| `src/sim/numbers/big-qty.ts` | sim/utility | transform (immutable value class) | `src/save/checksum.ts` (small pure utility wrapping a 3rd-party lib) | role-match | +| `src/sim/numbers/big-qty.test.ts` | test | unit | `src/save/checksum.test.ts` | exact | +| `src/sim/numbers/format.ts` | sim/utility | transform (number → display string) | `src/save/checksum.ts` (canonicalJSON) | role-match | +| `src/sim/numbers/format.test.ts` | test | unit | `src/save/checksum.test.ts` (canonicalJSON cases) | exact | +| `src/sim/numbers/index.ts` | sim/barrel | re-export | `src/save/index.ts` | exact | +| `src/sim/scheduler/clock.ts` | sim/service | wall-time injection point | none — first sim module | no analog | +| `src/sim/scheduler/clock.test.ts` | test | unit | `src/save/checksum.test.ts` | role-match | +| `src/sim/scheduler/tick.ts` | sim/service | accumulator drain (pure) | none — first scheduler | no analog | +| `src/sim/scheduler/tick.test.ts` | test | unit | `src/save/migrations.test.ts` (pure-function pipeline) | role-match | +| `src/sim/scheduler/catchup.ts` | sim/service | offline replay (pure) | none — first catch-up | no analog | +| `src/sim/scheduler/catchup.test.ts` | test | unit | `src/save/migrations.test.ts` | role-match | +| `src/sim/scheduler/index.ts` | sim/barrel | re-export | `src/save/index.ts` | exact | +| `src/sim/garden/types.ts` | sim/model | data shape declarations | `src/save/migrations.ts` (V1Payload interface) | role-match | +| `src/sim/garden/plants.ts` | sim/data | static plant-type table | `src/content/schemas/fragment.ts` (static schema declaration) | role-match | +| `src/sim/garden/growth.ts` | sim/service | state machine (pure) | none — first state machine | no analog | +| `src/sim/garden/growth.test.ts` | test | unit | `src/save/migrations.test.ts` | role-match | +| `src/sim/garden/commands.ts` | sim/service | command application (pure) | none — first command set | no analog | +| `src/sim/garden/commands.test.ts` | test | unit | `src/save/migrations.test.ts` | role-match | +| `src/sim/garden/auto-harvest.ts` | sim/service | offline event aggregation | none — first offline | no analog | +| `src/sim/garden/auto-harvest.test.ts` | test | unit | `src/save/migrations.test.ts` | role-match | +| `src/sim/garden/index.ts` | sim/barrel | re-export | `src/save/index.ts` | exact | +| `src/sim/memory/selector.ts` | sim/service | deterministic pool selection (pure) | `src/content/loader.ts` (filter+validate over loaded data) | role-match | +| `src/sim/memory/selector.test.ts` | test | unit | `src/content/loader.test.ts` (gates+rejects) | exact | +| `src/sim/memory/pool.ts` | sim/service | content adapter | `src/content/loader.ts` | role-match | +| `src/sim/memory/index.ts` | sim/barrel | re-export | `src/save/index.ts` | exact | +| `src/sim/narrative/lura-gate.ts` | sim/service | tick-count gate (pure) | `src/save/migrations.ts` (registry-keyed dispatch) | partial | +| `src/sim/narrative/lura-gate.test.ts` | test | unit | `src/save/migrations.test.ts` | role-match | +| `src/sim/narrative/beat-queue.ts` | sim/model | queue shape | `src/save/migrations.ts` (V1Payload contract) | role-match | +| `src/sim/narrative/index.ts` | sim/barrel | re-export | `src/save/index.ts` | exact | +| `src/sim/offline/events.ts` | sim/model+validator | Zod schema + aggregator | `src/content/schemas/fragment.ts` | role-match | +| `src/sim/offline/events.test.ts` | test | unit | `src/content/loader.test.ts` (zod rejection cases) | role-match | +| `src/sim/offline/index.ts` | sim/barrel | re-export | `src/save/index.ts` | exact | +| `src/sim/state.ts` | sim/model | root SimState shape | `src/save/migrations.ts` (V1Payload root) | exact | +| `src/sim/index.ts` | sim/barrel | re-export | `src/save/index.ts` | exact | +| `src/store/store.ts` | store/composition | Zustand vanilla store + slices | none — first store | no analog | +| `src/store/garden-slice.ts` | store/slice | command queue + tile state | none — first slice | no analog | +| `src/store/memory-slice.ts` | store/slice | fragment + reveal-modal state | none — first slice | no analog | +| `src/store/narrative-slice.ts` | store/slice | beat queue + dialogue state | none — first slice | no analog | +| `src/store/session-slice.ts` | store/slice | begin gate + toast state | none — first slice | no analog | +| `src/store/selectors.ts` | store/utility | derived view selectors | none — first selectors | no analog | +| `src/store/store.test.ts` | test | integration | `src/save/round-trip.test.ts` (composition) | partial | +| `src/store/index.ts` | store/barrel | re-export | `src/save/index.ts` | exact | +| `src/render/garden/tile-renderer.ts` | render/phaser | canvas primitive draw | none — first render | no analog | +| `src/render/garden/plant-renderer.ts` | render/phaser | canvas primitive draw | none — first render | no analog | +| `src/render/garden/ready-pulse.ts` | render/phaser | canvas alpha animation | none — first render | no analog | +| `src/render/garden/gate-renderer.ts` | render/phaser | canvas primitive draw | none — first render | no analog | +| `src/render/garden/tile-coords.ts` | render/utility | tile↔screen coordinate map | none — first coord helper | no analog | +| `src/render/garden/index.ts` | render/barrel | re-export | `src/save/index.ts` | exact | +| `src/render/index.ts` | render/barrel | re-export | `src/save/index.ts` | exact | +| `src/ui/begin/BeginScreen.tsx` | ui/component | DOM full-screen modal + gesture | `src/App.tsx` (root JSX layout) | partial | +| `src/ui/begin/BeginScreen.test.tsx` | test | component | `src/content/loader.test.ts` | partial | +| `src/ui/begin/use-audio-bootstrap.ts` | ui/hook | gesture-gated AudioContext | none — first audio hook | no analog | +| `src/ui/begin/index.ts` | ui/barrel | re-export | `src/save/index.ts` | exact | +| `src/ui/journal/Journal.tsx` | ui/component | DOM full-screen modal | `src/App.tsx` | partial | +| `src/ui/journal/Journal.test.tsx` | test | component | `src/content/loader.test.ts` | partial | +| `src/ui/journal/FragmentRevealModal.tsx` | ui/component | DOM modal | `src/App.tsx` | partial | +| `src/ui/journal/FragmentRevealModal.test.tsx` | test | component | `src/content/loader.test.ts` | partial | +| `src/ui/journal/journal-icon.tsx` | ui/component | DOM icon button | `src/App.tsx` | partial | +| `src/ui/journal/index.ts` | ui/barrel | re-export | `src/save/index.ts` | exact | +| `src/ui/letter/Letter.tsx` | ui/component | DOM full-screen modal | `src/App.tsx` | partial | +| `src/ui/letter/Letter.test.tsx` | test | component | `src/content/loader.test.ts` | partial | +| `src/ui/letter/letter-renderer.ts` | ui/utility | inkjs Story driver | none — first ink runtime | no analog | +| `src/ui/letter/letter-renderer.test.ts` | test | unit | `src/content/loader.test.ts` | partial | +| `src/ui/letter/index.ts` | ui/barrel | re-export | `src/save/index.ts` | exact | +| `src/ui/dialogue/LuraDialogue.tsx` | ui/component | DOM dialogue overlay | `src/App.tsx` | partial | +| `src/ui/dialogue/LuraDialogue.test.tsx` | test | component | `src/content/loader.test.ts` | partial | +| `src/ui/dialogue/ink-renderer.tsx` | ui/component | text-cadence drip | none — first cadence | no analog | +| `src/ui/dialogue/ink-runtime.ts` | ui/utility | inkjs Story instantiation | none — first ink runtime | no analog | +| `src/ui/dialogue/index.ts` | ui/barrel | re-export | `src/save/index.ts` | exact | +| `src/ui/settings/Settings.tsx` | ui/component | DOM modal (Export/Import/Restore) | `src/App.tsx` | partial | +| `src/ui/settings/Settings.test.tsx` | test | component | `src/save/round-trip.test.ts` (export/import flows) | partial | +| `src/ui/settings/persistence-toast.tsx` | ui/component | DOM transient toast | none — first toast | no analog | +| `src/ui/settings/index.ts` | ui/barrel | re-export | `src/save/index.ts` | exact | +| `src/ui/garden/SeedPicker.tsx` | ui/component | DOM popover over canvas | none — first popover | no analog | +| `src/ui/garden/SeedPicker.test.tsx` | test | component | `src/content/loader.test.ts` | partial | +| `src/ui/garden/index.ts` | ui/barrel | re-export | `src/save/index.ts` | exact | +| `src/ui/index.ts` | ui/barrel | re-export | `src/save/index.ts` | exact | +| `src/game/event-bus.ts` | game/singleton | Phaser EventEmitter singleton | none — first event bus | no analog | +| `src/game/scenes/Garden.ts` | game/scene | canvas scene with input + tick drive | `src/game/scenes/Boot.ts` | exact | +| `src/game/scenes/Preloader.ts` (optional) | game/scene | asset load | `src/game/scenes/Boot.ts` | exact | +| `src/content/ink-loader.ts` | content/loader | runtime JSON load + variable bind | `src/content/loader.ts` | role-match | +| `src/content/ink-loader.test.ts` | test | unit | `src/content/loader.test.ts` | exact | +| `scripts/compile-ink.mjs` | build/script | inklecate batch compile | `scripts/validate-assets.mjs` | role-match | +| `scripts/check-bundle-split.mjs` | build/script | structural assertion on dist/ | `scripts/validate-assets.mjs` | role-match | +| `tests/e2e/season1-loop.spec.ts` | test/e2e | Playwright full-loop smoke | none — first e2e | no analog (only `playwright.config.ts` exists) | + +### MODIFIED files (5) + +| Modified file | Edit kind | Existing role | +|---------------|-----------|---------------| +| `src/save/migrations.ts` | extend `V1Payload` interface + extend `migrations[1]` defaults | save/model | +| `src/save/migrations.test.ts` | add cases asserting new fields default correctly | test | +| `src/content/loader.ts` | swap to `eager: false` lazy variant for Season-1 (PIPE-02) | content/loader | +| `src/game/main.ts` | add `Garden` (and optional `Preloader`) to `scene: []` | game/config | +| `src/game/scenes/Boot.ts` | replace placeholder `create()` body with `this.scene.start('Garden')` | game/scene | +| `src/PhaserGame.tsx` | subscribe to event-bus; wire save lifecycle hooks (visibilitychange/beforeunload) | app/bridge | +| `src/App.tsx` | mount Begin/Journal/Settings/Letter/Dialogue/SeedPicker as siblings to `` | app/root | +| `package.json` | replace `compile:ink` no-op with `node scripts/compile-ink.mjs`; add `check:bundle-split` to `ci` | config | +| `eslint.config.js` | (recommended) add `no-restricted-syntax` rule banning `Date.now()` inside `src/sim/**` except `src/sim/scheduler/clock.ts` | config | +| `content/seasons/00-demo/fragments.yaml` | DELETE (replaced by Season 1) | content | + +## Pattern Assignments + +### Group A — sim/numbers (BigQty + format) [Wave 0, Plan 02-01] + +**Analog:** `src/save/checksum.ts` — a small, pure, immutable, third-party-wrapping module with colocated test. + +**Imports pattern** (`src/save/checksum.ts:1-1` + `src/save/checksum.test.ts:1-2`): +```typescript +// production module +import CRC32 from 'crc-32'; +// test +import { describe, it, expect } from 'vitest'; +import { crc32hex, canonicalJSON } from './checksum'; +``` + +**Comment-style discipline** (`src/save/checksum.ts:3-8`): +```typescript +/** + * 8-char lowercase hex CRC-32 of the input string. + * crc-32 returns a signed 32-bit integer; we mask to unsigned and pad. + * Used by envelope.wrap/unwrap to detect save corruption (lossy storage, + * partial writes, browser-eviction truncation). + */ +``` +Phase 1 modules ship a leading docblock that names the requirement (CORE-XX, Pitfall N, etc.) the function defends. Every Phase-2 sim module should do the same — link to the relevant CONTEXT decision (D-31, D-33, …) or RESEARCH pattern (Pattern 1, Pattern 2, …). + +**Test pattern** (`src/save/checksum.test.ts:7-19`): +```typescript +describe('crc32hex', () => { + it('is deterministic — same input always returns same output', () => { + expect(crc32hex('hello')).toBe(crc32hex('hello')); + }); + + it('returns 8-char lowercase hex', () => { + expect(crc32hex('hello')).toMatch(/^[0-9a-f]{8}$/); + }); + + it('differs for different inputs', () => { + expect(crc32hex('hello')).not.toBe(crc32hex('world')); + }); +}); +``` +Tests are colocated, use one `describe` per exported symbol, and each `it` asserts one invariant. The Phase-2 BigQty test file should follow exactly this layout (one `describe` for `BigQty`, one each for `add` / `mul` / `eq` / `toJSON` / `fromJSON` / `format`). + +**Concrete code prescription** for `big-qty.ts` and `format.ts` is in RESEARCH.md Pattern 2 (lines 547-600). Copy the class skeleton from there; copy the docblock-with-citation style from `checksum.ts`. + +**Boundary callout:** `src/sim/numbers/` is sim — ESLint will reject any import of `src/render/`, `src/ui/`. Imports allowed: `break_eternity.js` only. + +--- + +### Group B — sim/scheduler (clock + tick + catchup) [Wave 0, Plan 02-01] + +**Analog:** `src/save/checksum.ts` (pure utilities) for shape; **no behavioral analog** — first scheduler in the project. + +**Imports pattern** is the same as Group A. The `clock.ts` module is the *only* file in the entire project allowed to import `Date.now()`. Add the following docblock at the top: +```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 recommended ESLint no-restricted-syntax rule + * (RESEARCH Pitfall 1, line 1037) excludes this file specifically. + */ +``` + +**Production class skeleton** (RESEARCH lines 502-521): +```typescript +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; } +} +``` + +**Tick / catchup skeleton** (RESEARCH lines 455-493): copy the `drainTicks()` accumulator pattern. Implements CORE-02 + CORE-03 + CORE-11 (refuses negative; clamps 24h). + +**Test pattern** for catchup mirrors `src/save/migrations.test.ts:45-51`: +```typescript +it('throws when fromVersion is in the future (no migration registered)', () => { + expect(() => migrate({}, 99)).toThrow(); +}); + +it('throws when fromVersion is negative', () => { + expect(() => migrate({}, -1)).toThrow(); +}); +``` +For Phase 2's catchup: assert `drainTicks(state, -1)` returns the original state with `ticksApplied: 0`, and `drainTicks(state, 25 * 3600 * 1000)` clamps to exactly `Math.floor(24 * 3600 * 1000 / TICK_MS)` ticks. + +**Boundary callout:** `src/sim/scheduler/` is sim. The `Clock` interface is consumed by every other `src/sim/` module via dependency injection — never import `wallClock` directly from sim modules; the scheduler injects the chosen clock at the boundary in `src/store/` or `src/game/scenes/Garden.ts`. + +--- + +### Group C — sim/garden (types + plants + growth + commands + auto-harvest) [Wave 1, Plan 02-02] + +**Analog:** `src/save/migrations.ts` (V1Payload model + pure transforms). + +**Type-declaration pattern** (`src/save/migrations.ts:23-38`): +```typescript +/** + * The minimal v1 save shape per CONTEXT D-04: garden tiles, plant growth + * data placeholder, harvested fragment IDs, last tick timestamp, settings. + * Phase 2 fleshes the contents; Phase 1 just locks the field set. + */ +export interface V1Payload { + garden: { tiles: unknown[] }; + plants: unknown[]; + harvestedFragmentIds: string[]; + lastTickAt: number; + settings: { + musicVolume: number; + ambientVolume: number; + sfxVolume: number; + }; +} +``` +Phase 2's `src/sim/garden/types.ts` exports `Tile`, `PlantInstance`, `PlantType`, `PlantTypeId`, `GardenCommand` with the same shape — small, plain TypeScript interfaces, no methods, no validation. Validation lives separately (Group F: `src/sim/offline/events.ts` ships the Zod schema for `OfflineEventBlock`; sim types stay structural). + +**Pure-function pattern** (`src/save/migrations.ts:48-63` — the registry of pure transforms): +```typescript +export const migrations: Record = { + 1: (s: unknown): V1Payload => { + const v0 = (s ?? {}) as V0Payload; + return { + garden: { tiles: v0.garden ?? [] }, + plants: [], + harvestedFragmentIds: [], + lastTickAt: Date.now(), + settings: { ... }, + }; + }, +}; +``` +Phase 2's commands (`plantSeed`, `harvest`, `compost`) follow the same shape: `(state, args) => state`. Pure. No `Date.now()` (the time arg is *injected* by the scheduler). No store reads. + +**Tile coordinate convention** — RESEARCH Pitfall 2 (line 1042): canonical encoding `index = row * 4 + col`; export `tileIdx(row, col)` and `tileCoords(idx)` helpers; ban raw arithmetic at call sites. + +**Boundary callout:** `src/sim/garden/` is sim. Cannot import `src/render/`, `src/ui/`, `src/store/`, `src/game/`. Can import `src/sim/scheduler/` (for `Clock` type only — never call `clock.now()` from inside garden code; the application layer threads `now` through as an argument). + +--- + +### Group D — sim/memory (fragment selector) [Wave 1, Plan 02-03] + +**Analog:** `src/content/loader.ts` (filtered, validated traversal of authored content). + +**Filter + validate over loaded data** (`src/content/loader.ts:31-40`): +```typescript +function loadYamlFragments(): Fragment[] { + return Object.entries(yamlFiles).flatMap(([path, raw]) => { + const data = parseYAML(raw); + const parsed = SeasonContentSchema.safeParse(data); + if (!parsed.success) { + throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`); + } + return parsed.data.fragments; + }); +} +``` +The selector at `src/sim/memory/selector.ts` does the same shape: filter the fragment pool by `(season, plantType, alreadyHarvested)`, validate the result is non-empty, return one entry. **But** — sim cannot import `src/content/` directly without going through the public barrel `src/content/index.ts`. RESEARCH Don't Hand-Roll table line 1013: deterministic + no-dup with mulberry32 PRNG (~10 LoC pure). + +**Test pattern with rejection cases** (`src/content/loader.test.ts:40-50`): +```typescript +it('THROWS on a numeric-id violation (stable-string-ID rule)', () => { + const yamlGlob = { + '/content/seasons/01-soil/fragments.yaml': ` +fragments: + - id: 42 + season: 1 + body: "..." +`, + }; + expect(() => loadFragmentsFromGlob(yamlGlob)).toThrow(/\[content\] schema violation/); +}); +``` +Phase 2's `selector.test.ts` should run the same shape: pose a degenerate input (empty pool, exhausted pool, locked plant type), assert the function returns the documented sentinel or throws. + +**Boundary callout:** `src/sim/memory/` is sim. Imports `src/content/index.ts` (barrel only — never `src/content/loader.ts` directly). Does NOT import `src/render/`, `src/ui/`, `src/store/`. + +--- + +### Group E — sim/narrative (Lura beat gating) [Wave 2, Plan 02-04] + +**Analog:** `src/save/migrations.ts:48-63` (registry-keyed dispatch on a known set of integer keys). + +**Registry pattern** (the migrations registry is the closest shape to a beat dispatcher): +```typescript +export const migrations: Record = { + 1: (s: unknown): V1Payload => { /* ... */ }, +}; +``` +Phase 2's `src/sim/narrative/lura-gate.ts` exports a similar registry keyed on `harvestCount` thresholds (1, 4, 8 per D-14): +```typescript +const LURA_BEAT_THRESHOLDS = { + 1: 'arrival', + 4: 'mid', + 8: 'farewell', +} as const; +``` +The gate function takes `(prevHarvestCount, nextHarvestCount, beatProgress)` and returns the next pending beat or `null`. **Pure**; no Ink imports (RESEARCH line 27: "reads sim state's harvest count; does NOT load Ink content"). Ink runtime lives in `src/ui/dialogue/`. + +**STRY-10 test pattern** — tick-count semantics, not wall-time: +```typescript +// Modeled after src/save/migrations.test.ts:53-63 +it('FakeClock advance does NOT advance Lura beats without harvest events', () => { + const clock = new FakeClock(0); + let state = initialSimState(); + clock.advance(60 * 60 * 1000); // 1 hour + // No harvests fired — beats must remain at 0 + expect(luraBeatProgress(state)).toEqual({ arrived: false, mid: false, farewell: false, pending: null }); +}); +``` + +**Boundary callout:** `src/sim/narrative/` is sim. Cannot import `inkjs` (Ink runtime is UI tier per Architectural Responsibility Map line 40). Cannot import `src/render/`, `src/ui/`, `src/store/`. + +--- + +### Group F — sim/offline (event aggregator + Zod schema) [Wave 2, Plan 02-05] + +**Analog:** `src/content/schemas/fragment.ts` (Zod schema) + `src/content/loader.ts` (aggregator). + +**Zod schema pattern** (`src/content/schemas/fragment.ts:14-18`): +```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), +}); + +export type Fragment = z.infer; +``` +Phase 2's `OfflineEventBlock` (D-19) follows exactly: +```typescript +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; +``` + +**Boundary callout:** `src/sim/offline/` is sim. Imports `zod` only. + +--- + +### Group G — store (Zustand vanilla + slices + selectors) [Wave 0, Plan 02-01] + +**Analog:** none — first store. Closest shape is the *barrel pattern* of `src/save/index.ts`. + +**Composition skeleton** — copy directly from RESEARCH.md Pattern 3 (lines 624-661): +```typescript +import { createStore } from 'zustand/vanilla'; +import { useStore } from 'zustand'; + +export type AppStoreShape = GardenSlice & MemorySlice & NarrativeSlice & SessionSlice; + +export const appStore = createStore()((set, get) => ({ + ...createGardenSlice(set, get), + ...createMemorySlice(set, get), + ...createNarrativeSlice(set, get), + ...createSessionSlice(set, get), +})); + +export function useAppStore(selector: (s: AppStoreShape) => T): T { + return useStore(appStore, selector); +} +``` + +**Barrel pattern** (`src/save/index.ts:1-38`): +```typescript +/** + * Public surface of the save layer. Phase 2's tick scheduler + Zustand + * store are the first consumers — they should ONLY import from this + * file, never from the individual modules underneath. + */ + +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'; +// … +``` +`src/store/index.ts` re-exports `appStore`, `useAppStore`, slice types, and the `simAdapter` interface only — never internal slice creators. + +**Boundary callout:** `src/store/` is its own ESLint element type (declared in `eslint.config.js:87`). The default rule is `allow`, so: +- `src/sim/` cannot import `src/store/` directly (sim is pure; it returns new state, the application layer applies it via `simAdapter`). +- `src/render/`, `src/ui/`, `src/game/` MAY import `src/store/index.ts` to read state and enqueue commands. + +The `simAdapter` lives in `src/store/` (not in `src/sim/`) so the sim never imports the store. RESEARCH lines 651-661. + +--- + +### Group H — render/garden (Phaser primitive draw) [Wave 1, Plan 02-02] + +**Analog:** `src/game/scenes/Boot.ts` (current scene shape; expand for tile/plant draws). + +**Scene shape** (`src/game/scenes/Boot.ts:7-19`): +```typescript +export class Boot extends Phaser.Scene { + constructor() { + super('Boot'); + } + + preload(): void { + // No assets in Phase 1. + } + + create(): void { + // Phase 2 will start the preloader from here. + } +} +``` +Phase 2's `src/game/scenes/Garden.ts` follows. Renderers in `src/render/garden/*.ts` are *not* scenes — they're modules called by the `Garden` scene's `create()` and `update()`. They take a `Phaser.Scene` reference and draw onto it; they don't extend `Phaser.Scene`. + +**Phaser config registration** (`src/game/main.ts:9-20`): +```typescript +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], +}; +``` +Phase 2 modifies this to `scene: [Boot, Garden]` (or `[Boot, Preloader, Garden]`). Boot's `create()` becomes `this.scene.start('Garden')`. + +**Tile-coords helper** — RESEARCH lines 702-715. The tile coordinate translation is **purely a render concern** — it lives in `src/render/garden/tile-coords.ts`, not in `src/sim/garden/`. The seed picker (UI tier) uses this helper *via* an EventBus event payload, so it never imports `src/render/` directly. + +**Boundary callout:** `src/render/garden/` is render. ESLint allows `src/render/` to import anywhere by default. But: do not import `src/sim/garden/` from rendering code — the scene reads tile state from the *store* (Zustand), not from sim modules directly. This keeps render independent of sim's internal evolution. (The store types are sufficient.) + +--- + +### Group I — ui/* (React DOM overlays: Begin / Journal / Letter / Dialogue / Settings / SeedPicker) [Waves 1+2] + +**Analog:** `src/App.tsx` (only existing React component) — but it's a thin shell. UI components are first of their kind. + +**Mounting pattern** (`src/App.tsx:1-15`): +```typescript +import { useRef } from 'react'; +import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx'; + +function App() { + const phaserRef = useRef(null); + + return ( +
+ +
+ ); +} + +export default App; +``` +Phase 2 expands `App.tsx` to mount overlays as siblings to ``: +```typescript +return ( +
+ + + + + + + + + +
+); +``` +Each overlay is self-gating (reads its own visibility flag from the store via `useAppStore`); `App.tsx` does not branch. + +**TSX file shape** — there is no Phase-1 React component beyond `App.tsx` and `PhaserGame.tsx`, so the planner should follow the standard React 19 pattern: function components, named export, colocated `*.test.tsx` using `@testing-library/react` (will need to be added to devDependencies — RESEARCH line 207 lists this as a Phase-2 install). + +**MEMR-05 contract:** Journal text MUST render as DOM (not Phaser canvas) so it is selectable and copy-pasteable. Anti-pattern at RESEARCH line 1003. + +**Boundary callout:** `src/ui/` is ui. Allowed imports: `react`, `inkjs`, `src/store/index.ts`, `src/save/index.ts`, `src/content/index.ts`. **Forbidden** by ESLint: `src/sim/` (the firewall is `sim cannot import ui`; ui importing sim is also a smell — go through the store). UI components NEVER import `src/render/` or `src/game/` directly — cross-tier signaling goes via the EventBus + store. + +**Audio bootstrap hook** at `src/ui/begin/use-audio-bootstrap.ts` — copy the exact code from RESEARCH lines 953-987 (Pattern 9). The function MUST be called *synchronously inside* a click handler (RESEARCH Pitfall 5, line 1076) — never inside `useEffect`. + +--- + +### Group J — content/ink-loader + scripts/compile-ink.mjs [Wave 2] + +**Analog:** `src/content/loader.ts` (Vite-native glob with Zod validation) + `scripts/validate-assets.mjs` (CI-runnable Node script). + +**Vite glob pattern** (`src/content/loader.ts:19-29`): +```typescript +const yamlFiles = import.meta.glob('/content/seasons/*/fragments.yaml', { + eager: true, + query: '?raw', + import: 'default', +}) as Record; + +const mdFiles = import.meta.glob('/content/seasons/*/fragments/*.md', { + eager: true, + query: '?raw', + import: 'default', +}) as Record; +``` +Phase 2's `src/content/ink-loader.ts` adds a third path for compiled-Ink JSON. Compiled JSON lives under `src/content/compiled-ink/` (gitignored, generated). Use a separate `import.meta.glob` for `'/src/content/compiled-ink/**/*.ink.json'` with `query: 'json'` (or default JSON import — Vite handles `.json` natively). Per RESEARCH line 365. + +**Schema-violation throw** (`src/content/loader.ts:35-37`): +```typescript +if (!parsed.success) { + throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`); +} +``` +The Ink loader follows the same shape: throw at module-eval if the compiled JSON is missing/malformed. The throw bubbles up through Vite, exits `npm run build` non-zero — same PIPE-01 contract. + +**Build-script pattern** (`scripts/validate-assets.mjs` — already exists; structural analog only). `scripts/compile-ink.mjs` is a Node ESM module that shells `inklecate` over each `.ink` file under `/content/dialogue/season1/`. RESEARCH lines 743-800 detail the invocation; Assumption A6 (RESEARCH line 1213+1562) flags that the inklecate Windows-binary invocation needs verification on first run. + +**Lazy-loading switch** (PIPE-02): RESEARCH Pattern 8 (lines 906-940) details the `eager: false` shift. **Critical:** Phase 1's existing `loader.test.ts` MUST continue to pass after the switch. The test exercises `loadFragmentsFromGlob` (the test-only helper accepting mocked globs); the real `import.meta.glob` is replaced. Add a new test case asserting Season-1 fragments load via the lazy `loadSeasonFragments(1)` path. + +**Boundary callout:** `src/content/` is content. Imports `gray-matter`, `yaml`, `zod`, `inkjs` (the new ink-loader needs `inkjs.Story`). + +--- + +### Group K — Save extension (`src/save/migrations.ts`) [Wave 0, Plan 02-01] + +**Analog:** ITSELF — Phase 2 edits the existing file in place. + +**Concrete edit** (RESEARCH Pattern 7, lines 847-895). Copy the new `V1Payload` interface verbatim: +```typescript +export interface V1Payload { + garden: { tiles: TileSlot[] }; + plants: PlantInstance[]; + harvestedFragmentIds: string[]; + lastTickAt: number; + + // NEW Phase 2 fields: + unlockedPlantTypes: PlantTypeId[]; + luraBeatProgress: { + arrived: boolean; + mid: boolean; + farewell: boolean; + pending: 'arrival' | 'mid' | 'farewell' | null; + }; + offlineEvents: OfflineEventBlock | null; + + settings: { + musicVolume: number; + ambientVolume: number; + sfxVolume: number; + persistenceToastShown: boolean; + }; +} +``` + +**Migration update** (`src/save/migrations.ts:48-63` becomes RESEARCH lines 874-895): +```typescript +export const migrations: Record = { + 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`.** No `migrations[2]`. RESEARCH line 897. + +**Test edit** (`src/save/migrations.test.ts:14-43`): the existing v0→v1 case becomes more thorough — assert the new fields default correctly. Add a new case verifying the v1→v1 no-op (line 32-43) preserves the new fields when supplied. Use `expect.objectContaining` for forward-compatibility — don't lock to exact equality on a moving shape. + +**Critical doctrine** (CONTEXT specifics, line 187): "Phase 1's V1Payload has not shipped any production saves; Phase 2 extends the v1 payload shape rather than adding `migrate_v1_to_v2`. The first real migration lands in Phase 4 (per Phase 1 D-04). The synthetic v0→v1 demo migration in `migrations[1]` continues to work as the proof-of-chain." + +**Boundary callout:** `src/save/` is save. The migration must NOT import from `src/sim/garden/types.ts` — that would create a save → sim dependency. Instead, declare local mirrored interfaces (`TileSlot`, `PlantInstance`, `PlantTypeId`, `OfflineEventBlock`) in `migrations.ts` and rely on TypeScript structural typing at the application boundary. Alternatively, move the shared types to a "primitives" module that both sim and save can import (e.g., `src/save/v1-types.ts` re-exported, with sim mirroring its own copy). RESEARCH does not pre-decide; planner picks one. + +--- + +### Group L — game/event-bus + Garden scene + main.ts edits [Wave 1, Plan 02-02] + +**Analog:** `src/game/main.ts` and `src/game/scenes/Boot.ts`. + +**Scene-list edit** (`src/game/main.ts:19`): +```typescript +scene: [Boot], +``` +becomes: +```typescript +scene: [Boot, Garden], // or [Boot, Preloader, Garden] +``` + +**Boot transition** (`src/game/scenes/Boot.ts:16-18`): +```typescript +create(): void { + // Phase 2 will start the preloader from here. +} +``` +becomes: +```typescript +create(): void { + this.scene.start('Garden'); +} +``` + +**EventBus singleton** — RESEARCH Pattern 3 lines 681-694: +```typescript +// src/game/event-bus.ts +import * as Phaser from 'phaser'; + +/** Single shared emitter — the Phaser 4 React-template pattern. */ +export const eventBus = new Phaser.Events.EventEmitter(); +``` + +**Garden scene shape** mirrors `Boot.ts:7-19` (constructor + preload + create + update). The scene's `update(_time, _delta)` calls `scheduler.advanceLive(clock.now())` once per frame — RESEARCH line 523. + +**Boundary callout:** `src/game/` is game. ESLint default-allows; the scene MAY import `src/render/garden/` (renderers) and `src/store/index.ts` (read commands, write state via `simAdapter`). The Garden scene is the **only** place where sim + store + render meet — keep it thin (subscribe-and-dispatch). + +--- + +### Group M — App.tsx + PhaserGame.tsx edits [Waves 1+2] + +**Analog:** themselves — incremental edits to existing Phase-1 files. + +**App.tsx pattern** — see Group I above. + +**PhaserGame.tsx hook addition** (current `useEffect` at `src/PhaserGame.tsx:36-39` is a placeholder): +```typescript +useEffect(() => { + // Phase 2+: subscribe to scene-ready events here and surface the active scene + // through `currentActiveScene` so React can talk to Phaser. +}, []); +``` +Phase 2 wires this up: subscribe to `eventBus.on('scene-ready', ...)`, surface the active scene to React via the `currentActiveScene` callback. RESEARCH Pattern 3 line 694. + +**Save lifecycle hooks** — UX-10. Add `visibilitychange`, `beforeunload` listeners in a new `useEffect` that calls into `src/save/index.ts`: +```typescript +useEffect(() => { + const onHide = () => { /* serialize via src/save/wrap + db.put */ }; + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') onHide(); + }); + window.addEventListener('beforeunload', onHide); + // …cleanup on unmount +}, []); +``` +RESEARCH Pitfall 7 (line 1094): save fires AFTER React unmounts on `beforeunload`. Mitigation: keep the save-write code path synchronous inside the `beforeunload` handler — no `await`. Use `LocalStorageDBAdapter` synchronously here (already shipped at `src/save/db-localstorage-adapter.ts`); IndexedDB writes can lag. + +**Boundary callout:** `src/PhaserGame.tsx` and `src/App.tsx` are typed as element `app` in `eslint.config.js:88`. They sit at the cross-tier boundary and may import all tiers — they're the binding code. + +--- + +### Group N — Playwright e2e [Wave 2, Plan 02-05] + +**Analog:** none — `playwright.config.ts` exists but no specs ship in Phase 1. Comment at `playwright.config.ts:5-6`: "First spec lands in Phase 2 (PIPE-07)." + +**Config (already shipped)** (`playwright.config.ts:7-16`): +```typescript +export default defineConfig({ + testDir: 'tests/e2e', + use: { baseURL: 'http://localhost:5173' }, + webServer: { + command: 'npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: true, + timeout: 30_000, + }, +}); +``` + +**Spec skeleton** — RESEARCH lines 1377-1387 detail the URL-flag + FakeClock fast-forward. The spec at `tests/e2e/season1-loop.spec.ts` boots with `?devtime=fake`, advances `window.__tlgFakeClock.advance(...)` between assertions: +```typescript +import { test, expect } from '@playwright/test'; + +test('Season 1 full loop: Begin → Plant → Harvest → Journal → Reload → Persist', async ({ page }) => { + await page.goto('/?devtime=fake'); + // ... assertions per the smoke flow + await page.evaluate(() => window.__tlgFakeClock.advance(5 * 60 * 1000)); + // ... harvest, journal, reload, persist +}); +``` + +**Production guard** — RESEARCH line 1387: "in `import.meta.env.PROD` builds, the URL flag is silently ignored". Implement in the boot path of `src/PhaserGame.tsx` or wherever the Clock is selected. + +**Boundary callout:** `tests/e2e/` is outside `src/`. ESLint's `src/**/*` glob doesn't apply. + +--- + +### Group O — ESLint config edit (recommended, optional) [Wave 0, Plan 02-01] + +**Analog:** `eslint.config.js` itself — Phase 2 *extends* the firewall block. + +**Recommended addition** (RESEARCH Pitfall 1, line 1037): +```javascript +{ + 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()', + }, { + selector: "CallExpression[callee.name='setInterval']", + message: 'src/sim/** must not use setInterval; the scheduler drives ticks via the Phaser game loop', + }], + }, +}, +``` + +**Existing analog** (`eslint.config.js:115-126`): +```javascript +rules: { + 'boundaries/element-types': ['error', { + default: 'allow', + rules: [ + { from: ['sim'], disallow: ['render', 'ui'] }, + ], + }], +}, +``` + +**Test analog** for the new rule — model on `src/sim/__test_violation__/lint-firewall.test.ts:19-49`: +```typescript +describe('CORE-10: src/sim/ cannot import from src/render/ or src/ui/', () => { + it('eslint-plugin-boundaries flags a sim → render import as an error', async () => { + const eslint = new ESLint({ + overrideConfigFile: resolve(process.cwd(), 'eslint.config.js'), + ignore: false, + }); + const fixturePath = resolve(process.cwd(), 'src/sim/__test_violation__/violator.ts'); + const results = await eslint.lintFiles([fixturePath]); + // ... assert ruleId === 'boundaries/element-types' + }); +}); +``` +Phase 2 adds an analogous fixture (e.g., `src/sim/__test_violation__/date-now-violator.ts`) and an analogous test asserting `ruleId === 'no-restricted-syntax'` fires. Keep the fixture excluded via the existing `ignores` block at `eslint.config.js:43-49`. + +**User pushback warning:** the user's CLAUDE.md auto-memory flags "feedback_avoid_overengineering — solo user prefers minimum-viable shape; no ceremonial workflows unless asked". The ESLint extension is a *defense* against Pitfall 1 (line 1029-1041), but the planner should expose it as a *defended option* in PLAN.md — not a locked task — and let the user decide whether to add it now or rely on code review. + +--- + +## Shared Patterns + +### Pattern: Per-layer public barrel + +**Source:** `src/save/index.ts` (38 lines), `src/content/index.ts` (8 lines). + +**Apply to:** every new layer — `src/sim/numbers/index.ts`, `src/sim/scheduler/index.ts`, `src/sim/garden/index.ts`, `src/sim/memory/index.ts`, `src/sim/narrative/index.ts`, `src/sim/offline/index.ts`, `src/sim/index.ts`, `src/store/index.ts`, `src/render/garden/index.ts`, `src/render/index.ts`, `src/ui/begin/index.ts`, `src/ui/journal/index.ts`, `src/ui/letter/index.ts`, `src/ui/dialogue/index.ts`, `src/ui/settings/index.ts`, `src/ui/garden/index.ts`, `src/ui/index.ts`. + +**Concrete excerpt** (`src/save/index.ts:1-12`): +```typescript +/** + * Public surface of the save layer. Phase 2's tick scheduler + Zustand + * store are the first consumers — they should ONLY import from this + * file, never from the individual modules underneath. The internal + * shape is allowed to change between phases; this barrel is the + * stability contract. + */ + +export { wrap, unwrap, SaveCorruptError, SaveEnvelopeSchema } from './envelope'; +export type { SaveEnvelope } from './envelope'; + +export { migrate, CURRENT_SCHEMA_VERSION, migrations } from './migrations'; +``` + +**Rule:** consumers cross layer boundaries through `index.ts` only. Internal modules are private. + +--- + +### Pattern: Vitest test colocation with `*.test.ts(x)` suffix + +**Source:** `src/save/checksum.ts` ↔ `src/save/checksum.test.ts`; `src/content/loader.ts` ↔ `src/content/loader.test.ts`. + +**Apply to:** every Phase-2 production module gets a colocated test file. Naming: `foo.ts` ↔ `foo.test.ts`; `Foo.tsx` ↔ `Foo.test.tsx`. + +**Vitest config already includes both** (`vitest.config.ts:14`): +```typescript +include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'scripts/**/*.test.mjs', 'scripts/**/*.test.ts'], +``` + +**Concrete imports** (`src/save/checksum.test.ts:1-2`): +```typescript +import { describe, it, expect } from 'vitest'; +import { crc32hex, canonicalJSON } from './checksum'; +``` + +**Rule:** never put production code and test in the same file; never put tests under a separate `tests/unit/` tree (only Playwright `tests/e2e/`). + +--- + +### Pattern: docblock-with-citation at the top of every module + +**Source:** `src/save/checksum.ts:3-8`, `src/save/migrations.ts:1-13`, `src/save/envelope.ts:4-11`, every Phase-1 module. + +**Apply to:** every new Phase-2 module gets a leading docblock that names: +1. The phase + plan that introduced it. +2. The CONTEXT decision(s) it implements (`D-XX`) AND/OR the RESEARCH pattern it implements (Pattern N). +3. The REQ-ID(s) it satisfies. +4. Any non-obvious constraint or pitfall it defends against. + +**Concrete excerpt** (`src/save/migrations.ts:1-13`): +```typescript +/** + * Forward-only save migration registry. + * + * Each entry `migrations[N]` is the function that migrates payload from + * schema version N-1 to schema version N. Phase 1 ships migrations[1] + * (the synthetic v0 → v1 demo per CONTEXT D-05); Phase 4 will land + * migrations[2] when prestige / Roothold state lands. + * + * The v1 shape (from CONTEXT D-04) is intentionally minimal: only what + * Phase 2's first feature commit will write. Authoring it now lets us + * prove the migration chain end-to-end without speculating about future + * Season 5+ structures. + */ +``` + +**Rule:** future readers (the verifier, the next phase's planner, the user-as-reviewer) read the docblock first. Make it count. + +--- + +### Pattern: Zod schema + safeParse + bubble-up throw + +**Source:** `src/content/loader.ts:31-40`, `src/save/codec.ts:51-75`, `src/content/schemas/fragment.ts:14-18`. + +**Apply to:** every new Zod schema (Phase 2: `OfflineEventBlockSchema` in `src/sim/offline/events.ts`). + +**Concrete excerpt** (`src/content/loader.ts:31-40`): +```typescript +function loadYamlFragments(): Fragment[] { + return Object.entries(yamlFiles).flatMap(([path, raw]) => { + const data = parseYAML(raw); + const parsed = SeasonContentSchema.safeParse(data); + if (!parsed.success) { + throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`); + } + return parsed.data.fragments; + }); +} +``` + +**Rule:** always `safeParse` (never `parse`); always include the source path / context in the throw; let the throw bubble up — don't swallow. + +--- + +### Pattern: ESLint boundary classification + +**Source:** `eslint.config.js:80-90`. + +**Apply to:** every new directory created under `src/`. The classification table at `eslint.config.js:80-90` lists nine element types: +```javascript +'boundaries/elements': [ + { type: 'sim', pattern: 'src/sim/**' }, + { type: 'render', pattern: 'src/render/**' }, + { type: 'ui', pattern: 'src/ui/**' }, + { type: 'save', pattern: 'src/save/**' }, + { type: 'content', pattern: 'src/content/**' }, + { type: 'audio', pattern: 'src/audio/**' }, + { type: 'store', pattern: 'src/store/**' }, + { type: 'app', pattern: 'src/{main,App,PhaserGame}.{ts,tsx}' }, + { type: 'game', pattern: 'src/game/**' }, +], +``` + +**Rule:** Phase 2 introduces no NEW element types. Every new file falls under an existing pattern. If the planner finds a file that doesn't fit, **stop and ask** — don't add a new element type without explicit approval. + +**Critical firewall rule** (`eslint.config.js:120-125`): +```javascript +'boundaries/element-types': ['error', { + default: 'allow', + rules: [ + { from: ['sim'], disallow: ['render', 'ui'] }, + ], +}], +``` + +--- + +### Pattern: Phaser ↔ React communication via Zustand store + EventBus singleton + +**Source:** RESEARCH Pattern 3 (lines 612-696). No Phase-1 analog ships yet, but `src/PhaserGame.tsx:36-39` is shaped for it. + +**Apply to:** every render-tier ↔ ui-tier signal in Phase 2. + +**Two channels:** +1. **Persistent state → Zustand store** (`src/store/`). Tile contents, plant growth, harvested fragments, beat progress, settings, queued commands. +2. **Transient signals → Phaser EventBus** (`src/game/event-bus.ts`). `scene-ready`, `tile-clicked-coords` (carries seed-picker mount position), `fragment-revealed` (one-shot to fire the reveal modal). + +**Concrete excerpt** (RESEARCH lines 681-694): +```typescript +// src/game/event-bus.ts +import * as Phaser from 'phaser'; +export const eventBus = new Phaser.Events.EventEmitter(); + +// Sample events Phase 2 will emit/listen for: +// 'scene-ready' (Phaser → React) signals scene tree is live +// 'tile-clicked-coords' (Phaser → React) {tileIdx, screenX, screenY} for seed picker +// 'request-active-scene' (React → Phaser) one-shot +// 'fragment-revealed' (Phaser → React) one-shot for D-25 reveal modal +``` + +**Anti-pattern:** routing user-input intents through the EventBus (RESEARCH line 696). User intents = commands → store. Transient signals → EventBus. + +--- + +### Pattern: Pure-function sim with injected time + +**Source:** RESEARCH Pattern 1 (lines 434-540) + CLAUDE.md Code Style. + +**Apply to:** every function in `src/sim/garden/`, `src/sim/memory/`, `src/sim/narrative/`, `src/sim/offline/`. + +**Rule:** sim functions take time as an argument: `simulate(state, dtTicks, commands, { now })`. They never call `Date.now()`, `performance.now()`, `setInterval`, `setTimeout` (with a non-zero arg), or `requestAnimationFrame`. Timers are the scheduler's responsibility. + +**Anti-pattern excerpt** (`src/save/migrations.ts:55`): +```typescript +lastTickAt: Date.now(), +``` +This `Date.now()` call inside `migrations[1]` is intentional — it's the boundary at save time, not sim time. The migration runs once at boot, in the application layer, not inside the sim loop. **Sim modules under `src/sim/` will not have this pattern.** + +--- + +### Pattern: Save lifecycle hooks via React useEffect + browser events + +**Source:** RESEARCH Pitfall 7 (line 1094) + CONTEXT D-32 wiring. + +**Apply to:** `src/PhaserGame.tsx` (only). + +**Rule:** save-on-hide and save-on-unload listeners attach in `useEffect` inside `PhaserGame.tsx`. The `beforeunload` handler MUST be synchronous (no `await`) because React unmounts asynchronously. Use the synchronous `LocalStorageDBAdapter` write path; the `idb`-backed write may lose the last few hundred ms of state on `beforeunload`, which is acceptable per the multi-layer save model. + +--- + +## No Analog Found + +Files with no Phase-1 analog. Planner uses RESEARCH.md patterns directly: + +| File | Role | Reason | RESEARCH reference | +|------|------|--------|--------------------| +| `src/sim/scheduler/clock.ts`, `tick.ts`, `catchup.ts` | sim/scheduler | First wall-clock owner in the project | Pattern 1 (lines 434-540) | +| `src/store/store.ts` + slice files | store | First Zustand store; first ESLint `store` element population | Pattern 3 (lines 612-676) | +| `src/render/garden/*.ts` | render/phaser | First population of `src/render/` (only `__firewall_target__.ts` exists today) | Pattern 4 (lines 698-740) for tile coords | +| `src/ui/garden/SeedPicker.tsx` | ui/popover | First DOM popover over Phaser canvas | Pattern 4 (lines 698-740) | +| `src/ui/begin/use-audio-bootstrap.ts` | ui/audio | First AudioContext bootstrap | Pattern 9 (lines 942-992) | +| `src/ui/dialogue/ink-runtime.ts`, `src/ui/letter/letter-renderer.ts` | ui/ink | First inkjs runtime integration | Pattern 5 (lines 741-801) + Pattern 6 (lines 802-840) | +| `src/game/event-bus.ts` | game/singleton | First Phaser.Events.EventEmitter singleton | Pattern 3 (lines 681-694) | +| `tests/e2e/season1-loop.spec.ts` | test/e2e | First Playwright spec | RESEARCH Sim-Clock Injection (lines 1377-1387) | +| `scripts/compile-ink.mjs` | build/script | First inklecate batch | RESEARCH Pattern 5 (lines 743-800), Assumption A6 | + +**Common thread:** these all live at integration boundaries that Phase 1 deliberately deferred to Phase 2. The RESEARCH patterns are the substitute for an existing analog — copy the skeleton verbatim. + +--- + +## Phase 2 Boundary Callout Cheat-Sheet + +For the planner: every PLAN.md task that creates a new file MUST include a one-line statement of which ESLint element it falls under and what it can/cannot import. Use this table: + +| New file lives in | ESLint element | Can import | CANNOT import | +|-------------------|----------------|------------|---------------| +| `src/sim/**` | `sim` | `src/sim/**` (within), 3rd-party libs only | `src/render/**`, `src/ui/**` (firewall, error severity) | +| `src/store/**` | `store` | anywhere (default-allow) | n/a | +| `src/render/**` | `render` | `src/store/**` via barrel, 3rd-party (Phaser) | sim modules directly (smell, not lint-enforced) | +| `src/ui/**` | `ui` | `src/store/**`, `src/save/**`, `src/content/**` via barrels, 3rd-party (React, inkjs) | `src/render/**`, `src/game/**`, `src/sim/**` directly | +| `src/game/**` | `game` | anywhere (default-allow); especially `src/render/**` and `src/store/**` | smell to import `src/sim/**` directly — go through scheduler + store | +| `src/content/**` | `content` | 3rd-party (zod, gray-matter, yaml, inkjs) | n/a | +| `src/save/**` | `save` | 3rd-party (idb, lz-string, crc-32, zod) | sim/render/ui (save is a leaf utility) | +| `src/{main,App,PhaserGame}.tsx` | `app` | anywhere | n/a (this is the binding layer) | +| `tests/e2e/**` | (outside `src/`) | anywhere | n/a | + +--- + +## Metadata + +**Analog search scope:** `src/**/*.{ts,tsx}` (32 files), `eslint.config.js`, `vitest.config.ts`, `playwright.config.ts`, `package.json`, `content/**`, `scripts/**`. +**Files scanned (read in full):** `src/save/index.ts`, `src/save/migrations.ts`, `src/save/envelope.ts`, `src/save/persist.ts`, `src/save/snapshots.ts`, `src/save/codec.ts`, `src/save/checksum.ts`, `src/save/checksum.test.ts`, `src/save/migrations.test.ts`, `src/content/index.ts`, `src/content/loader.ts`, `src/content/loader.test.ts`, `src/content/schemas/fragment.ts`, `src/content/schemas/index.ts`, `src/content/schemas/season.ts`, `src/game/main.ts`, `src/game/scenes/Boot.ts`, `src/App.tsx`, `src/PhaserGame.tsx`, `src/main.tsx`, `src/sim/__test_violation__/lint-firewall.test.ts`, `src/sim/__test_violation__/violator.ts`, `src/render/__firewall_target__.ts`, `eslint.config.js`, `vitest.config.ts`, `playwright.config.ts`, `package.json`, `content/README.md`, `content/seasons/00-demo/fragments.yaml`. RESEARCH.md and CONTEXT.md read as required-reading inputs (RESEARCH read in three targeted sections: Pattern map header, Pattern 7 (save extension), Phase Requirements → Test Map). +**Files analyzed for classification:** 54 (49 NEW + 5 MODIFIED). +**Pattern extraction date:** 2026-05-09. +**Doctrine carried forward:** +- Architectural firewall is non-negotiable (CORE-10, ESLint-enforced). +- BigQty wrapper from day one of feature code (CLAUDE.md Code Style). +- Sim is pure; clock injects time (CLAUDE.md + RESEARCH Pattern 1). +- Save extension, not migration (CONTEXT D-34, RESEARCH Pattern 7). +- Single public barrel per layer (`index.ts`). +- Stable string fragment IDs (regex enforced by `FragmentSchema`). +- Zod safeParse + bubble-up throw on schema violations. +- User flagged "minimum-viable shape; no ceremonial workflows" — apply to every plan task; defended options surfaced, not auto-locked.