# 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.