Files
josh 5bc98ba4ac docs(02): map phase 2 file targets to existing analogs
54 file targets classified (49 new, 5 modified) with 87% analog coverage.
Key patterns: V1Payload extension (not v1→v2 migration), per-layer
public barrel pattern, test colocation, Zustand vanilla store + Phaser
EventBus singleton as the dual sim↔React bridge, ESLint sim-purity rule
proposed as a defended option (not auto-locked, per minimum-viable bias).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:11:01 -04:00

1027 lines
53 KiB
Markdown

# 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 `<PhaserGame>` | 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<number, Migration> = {
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<number, Migration> = {
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<typeof FragmentSchema>;
```
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<typeof OfflineEventBlockSchema>;
```
**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<AppStoreShape>()((set, get) => ({
...createGardenSlice(set, get),
...createMemorySlice(set, get),
...createNarrativeSlice(set, get),
...createSessionSlice(set, get),
}));
export function useAppStore<T>(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<IRefPhaserGame | null>(null);
return (
<div id="app">
<PhaserGame ref={phaserRef} />
</div>
);
}
export default App;
```
Phase 2 expands `App.tsx` to mount overlays as siblings to `<PhaserGame>`:
```typescript
return (
<div id="app">
<PhaserGame ref={phaserRef} />
<BeginScreen />
<SeedPicker />
<Journal />
<FragmentRevealModal />
<LuraDialogue />
<Letter />
<Settings />
<PersistenceToast />
</div>
);
```
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<string, string>;
const mdFiles = import.meta.glob('/content/seasons/*/fragments/*.md', {
eager: true,
query: '?raw',
import: 'default',
}) as Record<string, string>;
```
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<number, Migration> = {
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.