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

53 KiB

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.tsfoo.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):

// 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):

/**
 * 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):

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:

/**
 * 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):

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:

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):

/**
 * 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):

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):

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):

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):

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):

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:

// 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):

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:

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):

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):

/**
 * 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):

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):

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):

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>:

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):

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):

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:

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):

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):

scene: [Boot],

becomes:

scene: [Boot, Garden],     // or [Boot, Preloader, Garden]

Boot transition (src/game/scenes/Boot.ts:16-18):

create(): void {
  // Phase 2 will start the preloader from here.
}

becomes:

create(): void {
  this.scene.start('Garden');
}

EventBus singleton — RESEARCH Pattern 3 lines 681-694:

// 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):

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:

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):

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:

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.


Analog: eslint.config.js itself — Phase 2 extends the firewall block.

Recommended addition (RESEARCH Pitfall 1, line 1037):

{
  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):

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:

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):

/**
 * 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.tssrc/save/checksum.test.ts; src/content/loader.tssrc/content/loader.test.ts.

Apply to: every Phase-2 production module gets a colocated test file. Naming: foo.tsfoo.test.ts; Foo.tsxFoo.test.tsx.

Vitest config already includes both (vitest.config.ts:14):

include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'scripts/**/*.test.mjs', 'scripts/**/*.test.ts'],

Concrete imports (src/save/checksum.test.ts:1-2):

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):

/**
 * 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):

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:

'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):

'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):

// 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):

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.