Files
TheLastGarden/.planning/phases/02-season-1-vertical-slice-soil/02-02-begin-plant-grow-PLAN.md
T
josh e5c55b0aae revise(02): BLOCKER 3 — split lastTickAt (wall-clock) from tickCount (sim counter)
Two distinct fields with strict separation:
  - lastTickAt: wall-clock milliseconds. Written ONLY at saveSync time by
    the application layer. The sim NEVER writes this field.
    computeOfflineCatchup uses it as the wall-clock anchor.
  - tickCount: monotonic sim-internal counter (one per simulate() call).
    Used for STRY-10 narrative gating that must be immune to wall-clock
    manipulation. The sim writes this field; the application layer reads
    it via simAdapter.applyTickCount.

Changes:
  02-01: SimState + V1Payload gain `tickCount: number`; migrations[1]
  defaults to 0; GardenSlice exposes tickCount + lastTickAt + setters;
  simAdapter exposes applyTickCount; tests assert the round-trip.
  02-02: simulateOneTick increments next.tickCount + 1 (not lastTickAt:
  currentTick); Garden scene's SimState snapshot reads lastTickAt
  through from store and writes tickCount: this.currentTick locally;
  acceptance_criteria forbids `lastTickAt: this.*` in the sim and scene.
  02-05: buildPayloadFromStore now persists tickCount (from store);
  hydrateStoreFromPayload restores it via state.setTickCount.

This unblocks the offline-catchup math: computeOfflineCatchup(payload.lastTickAt,
nowMs) now reliably reads wall-clock ms because the sim never overwrites it
with a tick counter.
2026-05-09 03:04:45 -04:00

70 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
phase plan type wave depends_on files_modified autonomous requirements tags must_haves
02 02 execute 1
02-01
src/sim/garden/types.ts
src/sim/garden/plants.ts
src/sim/garden/growth.ts
src/sim/garden/growth.test.ts
src/sim/garden/commands.ts
src/sim/garden/commands.test.ts
src/sim/garden/index.ts
src/render/garden/tile-renderer.ts
src/render/garden/plant-renderer.ts
src/render/garden/ready-pulse.ts
src/render/garden/tile-coords.ts
src/render/garden/index.ts
src/render/index.ts
src/ui/begin/BeginScreen.tsx
src/ui/begin/BeginScreen.test.tsx
src/ui/begin/use-audio-bootstrap.ts
src/ui/begin/index.ts
src/ui/garden/SeedPicker.tsx
src/ui/garden/SeedPicker.test.tsx
src/ui/garden/index.ts
src/ui/index.ts
src/game/scenes/Garden.ts
src/game/main.ts
src/game/scenes/Boot.ts
src/PhaserGame.tsx
src/App.tsx
content/seasons/01-soil/ui-strings.yaml
src/content/schemas/ui-strings.ts
src/content/schemas/index.ts
src/content/loader.ts
src/content/index.ts
content/seasons/00-demo/fragments.yaml
true
GARD-01
GARD-02
AEST-07
UX-01
CORE-02
vertical-slice
garden
begin-screen
plant
grow
audio-bootstrap
mvp
truths artifacts key_links
Player loads the page with no save → sees a typographic 'Tend the garden / Begin' screen with no other UI clutter; tap calls audioContext.resume() and dismisses the screen (AEST-07, D-21, UX-01)
Player loads with an existing save → Begin screen is skipped; AudioContext bootstraps on first interaction via the click+touchstart+keydown gesture handler (D-22)
Player clicks an empty tile → seed picker DOM popover appears positioned at the tile's screen coords; popover lists currently-unlocked plant types; click outside dismisses (D-02)
Player selects a plant type → command enqueues into the store; next sim tick applies plantSeed; tile state moves from empty → sprout (GARD-01)
Plant advances sprout → mature → ready over its growth duration (per-plant duration in 25min band, D-08/D-09); state machine is a pure function of (plantedAtTick, currentTick, growthDurationTicks)
Empty tile renders as faint outline + subtle hover state (D-06); plant primitives render distinct shapes per stage tinted by plant type (D-26); ready tiles pulse via alpha cycle (D-27)
Sim is pure — no Date.now() in src/sim/garden/; all time threaded as injected ticks (CORE-02)
Phaser Garden scene's update() loop reads from the store, calls scheduler.drainTicks with the simulate function, writes results back via simAdapter — no React re-renders trigger render-tier draws
First-interaction gesture handler installed on returning-player loads succeeds at audioContext.resume() on the first click/touchstart/keydown (Pitfall 5 mitigation)
All player-visible Begin-screen and seed-picker copy lives in /content/seasons/01-soil/ui-strings.yaml; nothing player-visible hardcoded in TS (CLAUDE.md externalized-strings rule)
npm run ci is green; sim-purity ESLint rule (Plan 02-01 Task 3) catches any Date.now() leak
path provides exports
src/sim/garden/types.ts Tile, PlantInstance, PlantType, PlantTypeId, GrowthStage interfaces; tileIdx(row,col) and tileCoords(idx) helpers (Pitfall 2: row*4+col canonical encoding)
Tile
PlantInstance
PlantType
PlantTypeId
GrowthStage
tileIdx
tileCoords
path provides exports
src/sim/garden/plants.ts Static PlantType table — 3 Season-1 plants with distinct durations + tonal-identity slugs
PLANT_TYPES
getPlantType
path provides exports
src/sim/garden/growth.ts advanceGrowth(plant, currentTick) → GrowthStage — pure function of plantedAtTick + currentTick + plantType.durationTicks
advanceGrowth
GROWTH_THRESHOLDS
path provides exports
src/sim/garden/commands.ts plantSeed(state, args), simulateOneTick(state, currentTick) — pure command applications. Phase 2 wires plantSeed; harvest/compost added in Plan 02-03
plantSeed
simulateOneTick
path provides exports
src/render/garden/tile-renderer.ts drawTiles(scene, tiles) — Phaser primitive draw of 16-tile grid with hover state (D-06)
drawTiles
path provides exports
src/render/garden/plant-renderer.ts drawPlant(scene, tileIdx, plant) — primitive shapes per growth stage tinted by plantType (D-26)
drawPlant
path provides exports
src/render/garden/ready-pulse.ts applyReadyPulse(scene, tileIdx) — alpha tween for ready cue (D-27)
applyReadyPulse
path provides exports
src/render/garden/tile-coords.ts tileToScreenCoords(scene, tileIdx) — helper for seed picker positioning (RESEARCH Pattern 4)
tileToScreenCoords
GRID_LAYOUT
path provides exports
src/ui/begin/BeginScreen.tsx Tasteful typographic Begin screen — title + Begin button (D-21); calls bootstrapAudioContext on tap, dismisses via session slice (D-22)
BeginScreen
path provides exports
src/ui/begin/use-audio-bootstrap.ts bootstrapAudioContext() — lazy AudioContext creation + resume; installFirstInteractionGestureHandler() for returning players (RESEARCH Pattern 9)
bootstrapAudioContext
installFirstInteractionGestureHandler
path provides exports
src/ui/garden/SeedPicker.tsx Inline DOM popover positioned over Phaser canvas; lists unlocked plant types; commits via store.enqueueCommand (D-02)
SeedPicker
path provides exports
src/game/scenes/Garden.ts Phaser Garden scene — 4×4 tile grid, pointerdown handlers, scheduler integration, EventBus emissions
Garden
path provides
content/seasons/01-soil/ui-strings.yaml Player-visible Phase 2 UI copy (Begin screen, seed picker, post-harvest beat) — externalized per CLAUDE.md
path provides exports
src/content/schemas/ui-strings.ts UiStringsSchema (Zod) for ui-strings.yaml validation
UiStringsSchema
UiStrings
from to via pattern
src/ui/begin/BeginScreen.tsx src/ui/begin/use-audio-bootstrap.ts onClick handler calls bootstrapAudioContext() synchronously inside the click event bootstrapAudioContext
from to via pattern
src/game/scenes/Garden.ts src/sim/garden/commands.ts scheduler drains store commands and applies them via simulateOneTick(state, tick) simulateOneTick
from to via pattern
src/ui/garden/SeedPicker.tsx src/store/index.ts useAppStore + enqueueCommand({kind: 'plantSeed', tileIdx, plantTypeId}) enqueueCommand
from to via pattern
src/game/scenes/Garden.ts src/game/event-bus.ts tile pointerdown emits 'tile-clicked-coords' with {tileIdx, screenX, screenY} tile-clicked-coords
from to via pattern
src/render/garden/plant-renderer.ts src/sim/garden/types.ts imports PlantType / GrowthStage types only — no behavioral coupling (render reads from store, not from sim modules) import type
**Wave 1 vertical slice. Depends on Plan 02-01 (foundations).**

This plan ships the first end-to-end vertical slice: a player can launch the game, press Begin, click an empty tile, choose a plant from the inline picker, and watch it grow on the Phaser canvas. The slice touches every architectural tier (sim → store → render → ui), proving the firewall holds and the foundations work in production-shaped code paths.

Runs in parallel with Plan 02-03 (Harvest + Journal). Both depend only on 02-01; they share src/sim/garden/types.ts (created here), so the integration moment is small.

3 tasks. Estimated context cost ~50%. /clear between tasks if needed.

Ship the Begin → Plant → Grow vertical slice end-to-end. Player loads the page, sees the typographic Begin screen (D-21, AEST-07), taps it (AudioContext.resume() fires synchronously inside the click handler — Pitfall 5 mitigation), the Begin screen dismisses, the Garden scene activates, the player clicks an empty tile, the inline seed picker pops up positioned over the tile (D-02), the player taps a plant type, the command flows through the store → scheduler → sim → store → render, and the player watches a primitive sprout shape appear and grow across the 25min duration (D-08, D-09) tinted to the plant type (D-26) with a ready-state pulse (D-27).

Returning players (save exists) skip the Begin screen entirely (D-22); a first-interaction gesture handler bootstraps audio on the first click/touchstart/keydown.

Purpose: This is the load-bearing vertical slice — the first feature commit ever, on top of the foundations Plan 02-01 lands. It validates that the Phaser ↔ React Zustand bridge works in real code (RESEARCH Pattern 3), that the ESLint sim-purity rule holds when actual sim code is written, that the inline seed picker DOM-popover-over-canvas pattern works under Phaser.Scale.FIT (RESEARCH Assumption A5 — verify here, MEDIUM-risk), and that all player-visible strings live in /content/.

Output: A running game where Begin → Plant → Grow is fully operational on placeholder Phaser primitives. Plan 02-03 lands Harvest → Journal on top of this. Plan 02-05's Playwright e2e exercises the full loop end-to-end.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @CLAUDE.md @.planning/anti-fomo-doctrine.md @.planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md @.planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md @.planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md @.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-SUMMARY.md @.planning/phases/01-foundations-and-doctrine/01-04-SUMMARY.md

From src/sim/scheduler/index.ts (Plan 02-01):

export type { Clock } from './clock';
export { wallClock, FakeClock } from './clock';
export const TICK_MS: number;          // 200 (5Hz)
export const MAX_OFFLINE_MS: number;
export function drainTicks<S>(
  state: S,
  accumulatorMs: number,
  simulate: (state: S, dtMs: number, silent: boolean) => S,
  silent?: boolean,
): { state: S; remainderMs: number; ticksApplied: number };

From src/store/index.ts (Plan 02-01):

export const appStore: ZustandStore<AppStoreShape>;
export function useAppStore<T>(selector: (s: AppStoreShape) => T): T;
export const simAdapter: { drainCommands(): GardenCommand[]; applyTilesAndUnlocks(...); applyHarvestedFragments(...); applyLuraProgress(...) };
export interface GardenCommand { kind: 'plantSeed' | 'harvest' | 'compost'; tileIdx: number; plantTypeId?: string }

From src/game/event-bus.ts (Plan 02-01):

export const eventBus: Phaser.Events.EventEmitter;
// Sample events:
//   'scene-ready'             (Phaser → React)
//   'tile-clicked-coords'     (Phaser → React) {tileIdx, screenX, screenY}
//   'fragment-revealed'       (Phaser → React) (Plan 02-03)

From src/save/index.ts (Phase 1 + Plan 02-01 extension):

export interface V1Payload { /* Phase-2-extended; see migrations.ts */ }
export function migrate(payload: unknown, fromVersion: number): { payload: unknown; toVersion: number };
export function openSaveDB(): Promise<SaveDB>;
export function registerSaveLifecycleHooks(config: { saveSync: () => void }): { detach(): void };

From src/content/index.ts (Phase 1):

export const fragments: Fragment[];
export { FragmentSchema, type Fragment, SeasonContentSchema, type SeasonContent } from './schemas/index';

Existing src/App.tsx (Phase 1 — to be expanded by this plan):

function App() {
  const phaserRef = useRef<IRefPhaserGame | null>(null);
  return (
    <div id="app">
      <PhaserGame ref={phaserRef} />
    </div>
  );
}

Existing src/game/main.ts scene config:

scene: [Boot],   // Plan 02-02 changes to: scene: [Boot, Garden]

Existing Boot.create():

create(): void {
  // Phase 2 will start the preloader from here.
}
// Plan 02-02 changes to:
create(): void { this.scene.start('Garden'); }

For SeedPicker positioning (RESEARCH Assumption A5): Phaser uses Phaser.Scale.FIT (src/game/main.ts:16). Pointer event coordinates from a Phaser scene's pointerdown handler are in canvas pixel space; getBoundingClientRect of #game-container may need to be added to translate to viewport coords. Verify on a non-fullscreen window during Task 2.

Task 1: sim/garden core (types + plants table + growth state machine + plantSeed command + simulateOneTick) - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 1 lines 434-540, Pitfall 2 lines 1042-1048, Pitfall 10 lines 1118-1124) - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group C lines 226-272) - .planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md (D-01 through D-09) - src/save/migrations.ts (V1Payload — sim/garden types must be structurally compatible) - src/sim/state.ts (Plan 02-01 SimState root shape) - src/sim/scheduler/index.ts (TICK_MS, drainTicks signature) src/sim/garden/types.ts, src/sim/garden/plants.ts, src/sim/garden/growth.ts, src/sim/garden/growth.test.ts, src/sim/garden/commands.ts, src/sim/garden/commands.test.ts, src/sim/garden/index.ts **Step 1 — `src/sim/garden/types.ts`:**
/**
 * Garden state shapes (CONTEXT D-01: 4×4 fixed grid; D-26: primitive shapes).
 * Pure data; sim mutates these via pure-function commands. Per CORE-10
 * firewall, this module is sim — no DOM, no React, no Phaser, no Date.now.
 *
 * Tile coordinate convention (RESEARCH Pitfall 2): canonical encoding
 *   tileIdx = row * GRID_COLS + col
 * Always use the helpers; never inline the arithmetic.
 */

export const GRID_ROWS = 4;
export const GRID_COLS = 4;
export const GRID_SIZE = GRID_ROWS * GRID_COLS; // 16

export type GrowthStage = 'sprout' | 'mature' | 'ready';

export type PlantTypeId = 'rosemary' | 'yarrow' | 'winter-rose'; // 3 Season-1 plants per D-03

export interface PlantInstance {
  plantTypeId: PlantTypeId;
  plantedAtTick: number;          // tick number, NOT wall time — per CORE-02
}

export interface Tile {
  idx: number;                    // 0..15
  plant: PlantInstance | null;    // null = empty
}

export interface PlantType {
  id: PlantTypeId;
  /** Display name (player-visible) — sourced from /content/seasons/01-soil/ui-strings.yaml at runtime; this string here is a fallback for build-only test fixtures. */
  fallbackName: string;
  /** Growth duration in ticks (TICK_MS=200; 1500 ticks = 5 min). Per D-08/D-09. */
  durationTicks: number;
  /** Phaser tint hex per growth stage (D-26). */
  tints: { sprout: number; mature: number; ready: number };
  /** Fragment pool subset filter for MEMR-06 (Plan 02-03 wires this). */
  fragmentTags: readonly string[];
}

export function tileIdx(row: number, col: number): number {
  if (row < 0 || row >= GRID_ROWS || col < 0 || col >= GRID_COLS) {
    throw new Error(`Tile out of range: row=${row} col=${col}`);
  }
  return row * GRID_COLS + col;
}

export function tileCoords(idx: number): { row: number; col: number } {
  if (idx < 0 || idx >= GRID_SIZE) {
    throw new Error(`Tile index out of range: ${idx}`);
  }
  return { row: Math.floor(idx / GRID_COLS), col: idx % GRID_COLS };
}

Step 2 — src/sim/garden/plants.ts — 3 Season-1 plant types (D-03), distinct durations within 25min band (D-09). At TICK_MS=200, 600 ticks = 2min, 900 ticks = 3min, 1500 ticks = 5min.

import type { PlantType, PlantTypeId } from './types';

/**
 * Three Season-1 plants with tonal identity per the bible's
 * "real species, slightly wrong" rule (CLAUDE.md "Tone").
 *
 * Names are placeholder pending user review (RESEARCH Open Question 1).
 * Tonal register: rosemary (warm) / yarrow (contemplative) / winter-rose (heavy).
 *
 * Per D-08/D-09: durations vary within a 25min active-play band.
 *   rosemary    →  600 ticks ≈ 2 min  (the warm short one)
 *   yarrow      →  900 ticks ≈ 3 min  (medium contemplative)
 *   winter-rose → 1500 ticks ≈ 5 min  (the heavy slow one)
 *
 * Tints are placeholders — Phase 3 swaps watercolor textures over these.
 */
export const PLANT_TYPES: Readonly<Record<PlantTypeId, PlantType>> = Object.freeze({
  rosemary: {
    id: 'rosemary',
    fallbackName: 'Rosemary',
    durationTicks: 600,
    tints: { sprout: 0x8aa17a, mature: 0x5d7651, ready: 0xb6c7a8 },
    fragmentTags: ['warm'],
  },
  yarrow: {
    id: 'yarrow',
    fallbackName: 'Yarrow',
    durationTicks: 900,
    tints: { sprout: 0xc8b89a, mature: 0xa39777, ready: 0xe8d8b6 },
    fragmentTags: ['contemplative'],
  },
  'winter-rose': {
    id: 'winter-rose',
    fallbackName: 'Winter-rose',
    durationTicks: 1500,
    tints: { sprout: 0xa9a3b1, mature: 0x7d758a, ready: 0xc7bdd3 },
    fragmentTags: ['heavy'],
  },
});

export function getPlantType(id: PlantTypeId): PlantType {
  const type = PLANT_TYPES[id];
  if (!type) throw new Error(`Unknown plant type: ${id}`);
  return type;
}

Step 3 — src/sim/garden/growth.ts:

import type { PlantInstance, PlantType, GrowthStage } from './types';

/**
 * Sprout (0%) → Mature (33%) → Ready (≥100%). Per CONTEXT D-08/D-09.
 *
 * Pure function of (plantedAtTick, currentTick, durationTicks). Sim safety:
 * no Date.now(), no DOM. The tick scheduler injects currentTick.
 */
export const GROWTH_THRESHOLDS = Object.freeze({
  matureFraction: 0.33,
  readyFraction: 1.0,
});

export function advanceGrowth(plant: PlantInstance, plantType: PlantType, currentTick: number): GrowthStage {
  const ticksSincePlant = Math.max(0, currentTick - plant.plantedAtTick);
  const progress = ticksSincePlant / plantType.durationTicks;
  if (progress >= GROWTH_THRESHOLDS.readyFraction) return 'ready';
  if (progress >= GROWTH_THRESHOLDS.matureFraction) return 'mature';
  return 'sprout';
}

Step 4 — src/sim/garden/growth.test.ts — exhaustive boundary tests:

  • advanceGrowth({plantedAtTick: 0}, rosemary, 0)'sprout'.
  • advanceGrowth({plantedAtTick: 0}, rosemary, 197)'sprout' (just-below 33%).
  • advanceGrowth({plantedAtTick: 0}, rosemary, 198)'mature' (≥33%; 600 * 0.33 = 198).
  • advanceGrowth({plantedAtTick: 0}, rosemary, 599)'mature'.
  • advanceGrowth({plantedAtTick: 0}, rosemary, 600)'ready'.
  • advanceGrowth({plantedAtTick: 100}, rosemary, 100)'sprout' (just planted).
  • advanceGrowth({plantedAtTick: 100}, rosemary, 50)'sprout' (negative delta clamped via Math.max — defends Pitfall 1).
  • advanceGrowth({plantedAtTick: 0}, rosemary, 100000)'ready' (overgrowth stays 'ready', no overflow stage).

Step 5 — src/sim/garden/commands.ts:

import type { SimState } from '../state';
import type { GardenCommand } from '../../store/garden-slice';   // type-only import; runtime store not loaded by sim
import { PLANT_TYPES, getPlantType } from './plants';
import type { PlantInstance, PlantTypeId, Tile } from './types';
import { GRID_SIZE } from './types';
import { advanceGrowth } from './growth';

/**
 * Pure command applications. Each returns a NEW SimState — no mutation.
 * Time is INJECTED via currentTick. Per CORE-02 + sim-purity ESLint rule.
 *
 * Phase 2 wires plantSeed here; harvest + compost ship in Plan 02-03.
 */

export function plantSeed(state: SimState, tileIdx: number, plantTypeId: PlantTypeId, currentTick: number): SimState {
  if (tileIdx < 0 || tileIdx >= GRID_SIZE) throw new Error(`Bad tile index: ${tileIdx}`);
  const tiles = state.garden.tiles as Tile[];
  const target = tiles[tileIdx];
  if (target?.plant !== null && target?.plant !== undefined) {
    return state; // tile occupied — silent no-op (player tap on occupied tile is a render-tier path; sim refuses)
  }
  // Plant type must be unlocked (D-05 fragment-count thresholds; defaults to ['rosemary'] at game start)
  if (!state.unlockedPlantTypes.includes(plantTypeId)) {
    return state;
  }
  const plant: PlantInstance = { plantTypeId, plantedAtTick: currentTick };
  const nextTiles: Tile[] = tiles.map((t, i) =>
    i === tileIdx ? { idx: i, plant } : t,
  );
  return { ...state, garden: { tiles: nextTiles } };
}

/**
 * Pure single-tick simulation. Drains pending commands, advances all plants.
 * Per CORE-02 — fixed-timestep, deterministic from inputs.
 *
 * Phase 2 Plan 02-02 implements plantSeed only; harvest + compost arrive
 * in Plan 02-03 (extended via the kind switch below).
 */
export function simulateOneTick(state: SimState, currentTick: number, commands: GardenCommand[]): SimState {
  let next = state;
  // Drain commands FIRST so state effects of new commands participate in this tick.
  for (const cmd of commands) {
    if (cmd.kind === 'plantSeed' && cmd.plantTypeId) {
      next = plantSeed(next, cmd.tileIdx, cmd.plantTypeId as PlantTypeId, currentTick);
    }
    // Plan 02-03 will add 'harvest' and 'compost' branches here.
  }
  // BLOCKER 3 invariant: the sim writes tickCount (sim-internal counter for
  // STRY-10), NEVER lastTickAt. lastTickAt is wall-clock ms owned by the
  // application layer's saveSync (src/PhaserGame.tsx).
  return { ...next, tickCount: next.tickCount + 1 };
}

/**
 * Helper for renderers (read-only): given a Tile, what stage is its plant in?
 * Pure; called from src/render/garden/plant-renderer.ts via injected currentTick.
 */
export function tileGrowthStage(tile: Tile, currentTick: number): GrowthStage | null {
  if (!tile.plant) return null;
  const type = PLANT_TYPES[tile.plant.plantTypeId];
  if (!type) return null;
  return advanceGrowth(tile.plant, type, currentTick);
}

import type { GrowthStage } from './types';

(Order the import type { GrowthStage } near the top of the file, not at the bottom — ESLint will complain otherwise. Keep all imports at file head.)

Step 6 — src/sim/garden/commands.test.ts — exhaustive Vitest cases:

  • Empty initial state has 16 null tiles (constructed via helper).
  • plantSeed(state, 0, 'rosemary', 100) with unlockedPlantTypes=['rosemary'] returns new state with tile[0].plant = {plantTypeId: 'rosemary', plantedAtTick: 100}; original state.garden.tiles[0] still null (immutability).
  • plantSeed(state, 0, 'yarrow', 100) with unlockedPlantTypes=['rosemary'] (yarrow locked) returns state unchanged.
  • plantSeed(state, 0, 'rosemary', 100) then plantSeed(state', 0, 'rosemary', 200) — second call returns state' unchanged (tile occupied; silent no-op).
  • plantSeed(state, 16, ...) throws (out-of-range tileIdx).
  • simulateOneTick with one plantSeed command applies it AND increments tickCount by 1 (BLOCKER 3 — sim writes tickCount, not lastTickAt).
  • simulateOneTick with no commands still increments tickCount (the sim ticked, even with no player commands).
  • simulateOneTick does NOT modify lastTickAt (BLOCKER 3 — saveSync owns that field).
  • tileGrowthStage returns null for empty tile, returns the correct stage for a plant.

Step 7 — src/sim/garden/index.ts — barrel:

export type { Tile, PlantInstance, PlantType, PlantTypeId, GrowthStage } from './types';
export { GRID_ROWS, GRID_COLS, GRID_SIZE, tileIdx, tileCoords } from './types';
export { PLANT_TYPES, getPlantType } from './plants';
export { advanceGrowth, GROWTH_THRESHOLDS } from './growth';
export { plantSeed, simulateOneTick, tileGrowthStage } from './commands';

Also extend src/sim/index.ts to re-export * from './garden' (or specific symbols).

Commit: feat(02-02): sim/garden — types, plants table, growth state machine, plantSeed. Run npm run lint && npx vitest run src/sim/garden/ before committing. <acceptance_criteria> - grep -q "GRID_ROWS = 4" src/sim/garden/types.ts and grep -q "GRID_COLS = 4" src/sim/garden/types.ts - grep -q "PlantTypeId = 'rosemary' | 'yarrow' | 'winter-rose'" src/sim/garden/types.ts - grep -q "durationTicks: 600" src/sim/garden/plants.ts (rosemary) - grep -q "durationTicks: 1500" src/sim/garden/plants.ts (winter-rose) - grep -L "Date.now" src/sim/garden/types.ts src/sim/garden/plants.ts src/sim/garden/growth.ts src/sim/garden/commands.ts (none of these may contain Date.now per the ESLint rule) - ! grep -E "lastTickAt:\\s*(this|currentTick)" src/sim/garden/commands.ts (BLOCKER 3 — sim must NEVER write lastTickAt; saveSync owns it) - grep -q "tickCount: next.tickCount" src/sim/garden/commands.ts (BLOCKER 3 — simulateOneTick increments the sim-internal counter) - ! grep -E "lastTickAt:\\s*this\\." src/game/scenes/Garden.ts (BLOCKER 3 — Garden scene snapshot must read lastTickAt from store, not write a tick counter into it) - npx vitest run src/sim/garden/ exits 0 with ≥15 passing tests - npm run lint exits 0 (the sim-purity rule from Plan 02-01 catches Date.now leaks here) - npm run build exits 0 </acceptance_criteria> npm run lint && npx vitest run src/sim/garden/ && npm run build sim/garden core lands with 3 plant types, growth state machine, plantSeed command, simulateOneTick orchestrator. Pure functions throughout. ESLint sim-purity rule confirms no Date.now calls. ≥15 Vitest tests green.

Task 2: Render layer (Phaser Garden scene + tile/plant/ready-pulse renderers + tile-coords helper) and main.ts/Boot.ts wiring - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 4 lines 698-740 inline seed picker, Pitfall 6 lines 1086-1092 stale-closure subscribe pattern, Assumption A5 lines 1212-1213) - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group H lines 426-468, Group L lines 621-660) - src/game/main.ts (current Phaser config — must add Garden scene) - src/game/scenes/Boot.ts (current empty create() — must transition to Garden) - src/PhaserGame.tsx (Phaser-React bridge — Phase 2 wires save lifecycle hooks here in Task 3) src/render/garden/tile-renderer.ts, src/render/garden/plant-renderer.ts, src/render/garden/ready-pulse.ts, src/render/garden/tile-coords.ts, src/render/garden/index.ts, src/render/index.ts, src/game/scenes/Garden.ts, src/game/main.ts, src/game/scenes/Boot.ts **Step 1 — `src/render/garden/tile-coords.ts`** — the load-bearing helper for seed picker positioning. Defends Assumption A5 (MEDIUM risk per RESEARCH).
import * as Phaser from 'phaser';
import { GRID_ROWS, GRID_COLS, GRID_SIZE } from '../../sim/garden/types';

/**
 * 4×4 garden layout in canvas pixel coordinates. Centered in the
 * 1024×768 game area declared in src/game/main.ts.
 *
 * Tile size + spacing chosen so the grid sits comfortably with margins
 * for Phase-3 watercolor frames. Phase 2 ships placeholder primitives
 * inside these bounds.
 */
export const GRID_LAYOUT = Object.freeze({
  tileSize: 96,        // px
  tileGap: 16,         // px between tiles
  gridOriginX: 240,    // top-left of grid in canvas px (centered: (1024 - (4*96 + 3*16))/2 = 248 ≈ 240)
  gridOriginY: 144,    // top-left of grid in canvas px (centered: (768 - (4*96 + 3*16))/2 = 168 ≈ 144)
});

export function tileTopLeftCanvas(idx: number): { x: number; y: number } {
  if (idx < 0 || idx >= GRID_SIZE) throw new Error(`Bad tile idx: ${idx}`);
  const row = Math.floor(idx / GRID_COLS);
  const col = idx % GRID_COLS;
  const x = GRID_LAYOUT.gridOriginX + col * (GRID_LAYOUT.tileSize + GRID_LAYOUT.tileGap);
  const y = GRID_LAYOUT.gridOriginY + row * (GRID_LAYOUT.tileSize + GRID_LAYOUT.tileGap);
  return { x, y };
}

export function tileCenterCanvas(idx: number): { x: number; y: number } {
  const tl = tileTopLeftCanvas(idx);
  return { x: tl.x + GRID_LAYOUT.tileSize / 2, y: tl.y + GRID_LAYOUT.tileSize / 2 };
}

/**
 * Convert a tile center from canvas pixel space to viewport DOM coordinates.
 * The seed picker (DOM popover) uses this to mount itself in absolute-position
 * over the canvas (RESEARCH Pattern 4 + Assumption A5).
 *
 * Phaser.Scale.FIT scales + letterboxes; we need the actual canvas DOMRect
 * to translate canvas-space → CSS pixel space.
 */
export function tileCenterToDom(scene: Phaser.Scene, idx: number): { x: number; y: number } {
  const center = tileCenterCanvas(idx);
  const canvas = scene.game.canvas;
  const rect = canvas.getBoundingClientRect();
  const scaleX = rect.width / scene.game.scale.width;
  const scaleY = rect.height / scene.game.scale.height;
  return {
    x: rect.left + center.x * scaleX,
    y: rect.top + center.y * scaleY,
  };
}

Step 2 — src/render/garden/tile-renderer.ts — primitive draws (D-06):

import * as Phaser from 'phaser';
import { GRID_SIZE } from '../../sim/garden/types';
import { tileTopLeftCanvas, GRID_LAYOUT } from './tile-coords';

/**
 * Empty-tile look: faint outlined rounded rectangle with subtle hover.
 * Per CONTEXT D-06; Phase 3 paints the watercolor treatment.
 */
const OUTLINE_COLOR = 0x4d4d52;
const OUTLINE_HOVER = 0x6e6e75;
const OUTLINE_ALPHA = 0.6;

export interface TileGameObjects {
  /** Hit-area rectangle (interactive). */
  hit: Phaser.GameObjects.Rectangle;
  /** Outline graphic. */
  outline: Phaser.GameObjects.Graphics;
}

export function drawTiles(scene: Phaser.Scene): TileGameObjects[] {
  const tiles: TileGameObjects[] = [];
  for (let i = 0; i < GRID_SIZE; i++) {
    const tl = tileTopLeftCanvas(i);
    const cx = tl.x + GRID_LAYOUT.tileSize / 2;
    const cy = tl.y + GRID_LAYOUT.tileSize / 2;

    // Outline graphic
    const g = scene.add.graphics();
    g.lineStyle(2, OUTLINE_COLOR, OUTLINE_ALPHA);
    g.strokeRoundedRect(tl.x, tl.y, GRID_LAYOUT.tileSize, GRID_LAYOUT.tileSize, 6);

    // Hit rectangle (transparent, interactive)
    const hit = scene.add.rectangle(cx, cy, GRID_LAYOUT.tileSize, GRID_LAYOUT.tileSize, 0xffffff, 0);
    hit.setInteractive({ useHandCursor: true });
    hit.on('pointerover', () => {
      g.clear();
      g.lineStyle(2, OUTLINE_HOVER, OUTLINE_ALPHA);
      g.strokeRoundedRect(tl.x, tl.y, GRID_LAYOUT.tileSize, GRID_LAYOUT.tileSize, 6);
    });
    hit.on('pointerout', () => {
      g.clear();
      g.lineStyle(2, OUTLINE_COLOR, OUTLINE_ALPHA);
      g.strokeRoundedRect(tl.x, tl.y, GRID_LAYOUT.tileSize, GRID_LAYOUT.tileSize, 6);
    });

    // Tag the hit object with its index for handler dispatch
    hit.setData('tileIdx', i);

    tiles.push({ hit, outline: g });
  }
  return tiles;
}

Step 3 — src/render/garden/plant-renderer.ts — primitive shapes per stage (D-26):

import * as Phaser from 'phaser';
import type { Tile, GrowthStage, PlantTypeId } from '../../sim/garden/types';
import { PLANT_TYPES } from '../../sim/garden/plants';
import { tileCenterCanvas, GRID_LAYOUT } from './tile-coords';

/**
 * Plant primitives per CONTEXT D-26.
 *   sprout = small dot (radius 6)
 *   mature = stem rectangle (width 4, height 24)
 *   ready  = bloom shape (small filled circle, radius 18)
 *
 * Tinted by plant type (PLANT_TYPES[plantTypeId].tints[stage]).
 * Phase 3 swaps in painted sprites without touching this signature.
 */

export interface PlantGameObject {
  shape: Phaser.GameObjects.Shape;
  stage: GrowthStage;
}

export function drawPlant(scene: Phaser.Scene, tileIdx: number, tile: Tile, stage: GrowthStage): PlantGameObject | null {
  if (!tile.plant) return null;
  const type = PLANT_TYPES[tile.plant.plantTypeId];
  if (!type) return null;
  const center = tileCenterCanvas(tileIdx);
  const tint = type.tints[stage];

  let shape: Phaser.GameObjects.Shape;
  if (stage === 'sprout') {
    shape = scene.add.circle(center.x, center.y + GRID_LAYOUT.tileSize / 4, 6, tint);
  } else if (stage === 'mature') {
    shape = scene.add.rectangle(center.x, center.y, 4, 24, tint);
  } else {
    shape = scene.add.circle(center.x, center.y, 18, tint);
  }
  return { shape, stage };
}

export function destroyPlant(obj: PlantGameObject | null): void {
  obj?.shape.destroy();
}

Step 4 — src/render/garden/ready-pulse.ts — alpha-cycle pulse (D-27):

import * as Phaser from 'phaser';

/**
 * Subtle alpha pulse on ready-stage plants. Per CONTEXT D-27. Phase 3
 * paints over with a warmer light treatment.
 *
 * Returns the tween so the scene can stop it when the plant is harvested.
 */
export function applyReadyPulse(scene: Phaser.Scene, target: Phaser.GameObjects.GameObject): Phaser.Tweens.Tween {
  return scene.tweens.add({
    targets: target,
    alpha: { from: 0.7, to: 1.0 },
    duration: 1200,
    ease: 'Sine.easeInOut',
    yoyo: true,
    repeat: -1,
  });
}

Step 5 — src/render/garden/index.ts — barrel:

export { drawTiles } from './tile-renderer';
export type { TileGameObjects } from './tile-renderer';
export { drawPlant, destroyPlant } from './plant-renderer';
export type { PlantGameObject } from './plant-renderer';
export { applyReadyPulse } from './ready-pulse';
export { tileTopLeftCanvas, tileCenterCanvas, tileCenterToDom, GRID_LAYOUT } from './tile-coords';

Step 6 — src/render/index.ts — top-level render barrel:

export * from './garden';

Step 7 — src/game/scenes/Garden.ts — the Phaser scene that wires it all together:

import * as Phaser from 'phaser';
import { eventBus } from '../event-bus';
import { drainTicks, TICK_MS, wallClock, type Clock } from '../../sim/scheduler';
import type { SimState } from '../../sim/state';
import { simulateOneTick, tileGrowthStage } from '../../sim/garden';
import type { Tile } from '../../sim/garden/types';
import { drawTiles, drawPlant, destroyPlant, applyReadyPulse, tileCenterToDom, type TileGameObjects, type PlantGameObject } from '../../render/garden';
import { appStore, simAdapter } from '../../store';

/**
 * The 4×4 garden scene (CONTEXT D-01). Wires the tick scheduler into
 * Phaser's update() loop, draws tiles, dispatches pointer events to
 * the EventBus + store, and re-renders plants on store changes.
 *
 * The Garden scene is the ONLY place where sim + store + render meet.
 * It stays thin (RESEARCH Pattern 3 line 660): subscribe, dispatch.
 */
export class Garden extends Phaser.Scene {
  private accumulatorMs = 0;
  private lastFrameMs = 0;
  private clock: Clock = wallClock;
  private currentTick = 0;
  private tileObjs: TileGameObjects[] = [];
  private plantObjs: Map<number, PlantGameObject> = new Map();
  private readyTweens: Map<number, Phaser.Tweens.Tween> = new Map();
  private storeUnsubscribe: (() => void) | null = null;

  constructor() {
    super('Garden');
  }

  create(): void {
    // Allow URL ?devtime=fake to swap in a FakeClock for Playwright (Plan 02-05).
    // Production-guarded via import.meta.env.PROD in src/PhaserGame.tsx; the
    // Garden scene reads the chosen clock from a window-scoped slot.
    if ((window as unknown as { __tlgClock?: Clock }).__tlgClock) {
      this.clock = (window as unknown as { __tlgClock: Clock }).__tlgClock;
    }

    this.tileObjs = drawTiles(this);
    this.tileObjs.forEach((t, idx) => {
      t.hit.on('pointerdown', () => this.handleTilePointerDown(idx));
    });

    this.lastFrameMs = this.clock.now();

    // Re-render plants when tiles change in the store (Pitfall 6 mitigation:
    // subscribe rather than read once in create()).
    this.storeUnsubscribe = appStore.subscribe((state) => {
      this.repaintPlants(state.tiles as Tile[]);
    });
    this.repaintPlants(appStore.getState().tiles as Tile[]);

    eventBus.emit('scene-ready', this);
  }

  update(_time: number, _delta: number): void {
    const now = this.clock.now();
    const deltaMs = now - this.lastFrameMs;
    this.lastFrameMs = now;
    if (deltaMs > 0) this.accumulatorMs += deltaMs;

    // Build current SimState snapshot from the store + drain commands.
    const storeState = appStore.getState();
    const commands = simAdapter.drainCommands();
    // BLOCKER 3 fix — DO NOT seed lastTickAt with this.currentTick. lastTickAt
    // is wall-clock ms owned by saveSync. The Garden scene's snapshot copies
    // the value already in the store (which was hydrated from the save and
    // hasn't been touched since). tickCount is the sim's own counter and is
    // similarly read-through.
    const simStateNow: SimState = {
      garden: { tiles: storeState.tiles },
      plants: [],
      harvestedFragmentIds: storeState.harvestedFragmentIds,
      lastTickAt: storeState.lastTickAt ?? 0,    // read-through from store; sim never writes
      tickCount: this.currentTick,                // local sim counter — sim writes this field
      unlockedPlantTypes: storeState.unlockedPlantTypes,
      luraBeatProgress: storeState.luraBeatProgress,
      offlineEvents: null,
      settings: { musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8, persistenceToastShown: storeState.persistenceToastShown },
    };

    const result = drainTicks(
      simStateNow,
      this.accumulatorMs,
      (s, _dtMs, _silent) => {
        const next = simulateOneTick(s, this.currentTick + 1, commands);
        this.currentTick++;
        return next;
      },
    );
    this.accumulatorMs = result.remainderMs;

    // Apply tile state back to the store (other slices unchanged).
    if (result.ticksApplied > 0) {
      simAdapter.applyTilesAndUnlocks(
        result.state.garden.tiles,
        result.state.unlockedPlantTypes,
      );
    }
  }

  private handleTilePointerDown(idx: number): void {
    const tiles = appStore.getState().tiles as Tile[];
    const tile = tiles[idx];
    if (!tile || !tile.plant) {
      // Empty tile — emit event for the React seed picker.
      const dom = tileCenterToDom(this, idx);
      eventBus.emit('tile-clicked-coords', { tileIdx: idx, screenX: dom.x, screenY: dom.y });
      return;
    }
    // Plan 02-03 wires harvest/compost on plant click.
  }

  private repaintPlants(tiles: Tile[]): void {
    for (let idx = 0; idx < tiles.length; idx++) {
      const tile = tiles[idx];
      const stage = tile?.plant ? tileGrowthStage(tile, this.currentTick) : null;
      const existing = this.plantObjs.get(idx);
      if (!stage || !tile?.plant) {
        if (existing) {
          destroyPlant(existing);
          this.plantObjs.delete(idx);
          this.readyTweens.get(idx)?.stop();
          this.readyTweens.delete(idx);
        }
        continue;
      }
      // Repaint if missing or stage changed.
      if (!existing || existing.stage !== stage) {
        if (existing) destroyPlant(existing);
        const next = drawPlant(this, idx, tile, stage);
        if (next) {
          this.plantObjs.set(idx, next);
          if (stage === 'ready') {
            this.readyTweens.get(idx)?.stop();
            this.readyTweens.set(idx, applyReadyPulse(this, next.shape));
          }
        }
      }
    }
  }

  destroy(): void {
    this.storeUnsubscribe?.();
    this.readyTweens.forEach((t) => t.stop());
    this.readyTweens.clear();
    this.plantObjs.forEach((p) => destroyPlant(p));
    this.plantObjs.clear();
  }
}

Step 8 — Update src/game/main.ts:

import * as Phaser from 'phaser';
import { Boot } from './scenes/Boot.ts';
import { Garden } from './scenes/Garden.ts';

const config: Phaser.Types.Core.GameConfig = {
  type: Phaser.AUTO,
  width: 1024,
  height: 768,
  parent: 'game-container',
  backgroundColor: '#1a1a1a',
  scale: {
    mode: Phaser.Scale.FIT,
    autoCenter: Phaser.Scale.CENTER_BOTH,
  },
  scene: [Boot, Garden],
};

const StartGame = (parent: string): Phaser.Game => {
  return new Phaser.Game({ ...config, parent });
};

export default StartGame;

Step 9 — Update src/game/scenes/Boot.ts:

import * as Phaser from 'phaser';

/**
 * Phase 2: Boot scene transitions to Garden once Phaser is up.
 * No assets to load in Phase 2 (D-26 = Phaser primitives only).
 */
export class Boot extends Phaser.Scene {
  constructor() {
    super('Boot');
  }

  preload(): void {}

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

Manual smoke (the executor SHOULD do this once during the task to verify Assumption A5): npm run dev, open http://localhost:5173, click the page once (so the in-progress Begin gate doesn't block; Task 3 of this plan will gate Begin properly). Confirm 16 outlined tiles render in a 4×4 grid centered on the canvas. Resize the browser to a non-fullscreen window — the tiles should remain inside the canvas (Phaser.Scale.FIT). The seed picker in Task 3 will use tileCenterToDom to position itself; verify visually then.

Commit: feat(02-02): render layer + Garden scene + scheduler integration. Run npm run lint && npm run build before committing (Vitest tests for render-tier are minimal — Phaser scenes need a real canvas; rely on the Playwright e2e in Plan 02-05 for behavioral coverage). <acceptance_criteria> - grep -q "scene: \[Boot, Garden\]" src/game/main.ts - grep -q "this.scene.start('Garden')" src/game/scenes/Boot.ts - grep -q "export class Garden extends Phaser.Scene" src/game/scenes/Garden.ts - grep -q "drainTicks" src/game/scenes/Garden.ts (scheduler wired into update loop) - grep -q "appStore.subscribe" src/game/scenes/Garden.ts (Pitfall 6 mitigation: subscribe, don't read-once) - grep -q "tile-clicked-coords" src/game/scenes/Garden.ts (EventBus emission) - grep -q "tileCenterToDom" src/render/garden/tile-coords.ts - grep -L "Date.now" src/render/garden/tile-renderer.ts src/render/garden/plant-renderer.ts src/render/garden/ready-pulse.ts src/render/garden/tile-coords.ts (render is sim-adjacent — should not need wall clock; clock comes from scene) - npm run lint exits 0 - npm run build exits 0 </acceptance_criteria> npm run lint && npm run build Garden scene wires scheduler + EventBus + store + render. 4×4 tile grid renders with hover state. Plants repaint reactively when store changes. Tile pointerdown emits coords for the React seed picker. main.ts/Boot.ts updated. Manual smoke confirms tiles visible at npm run dev.

Task 3: BeginScreen + audio bootstrap + SeedPicker + UI strings + lazy-content schema + App.tsx wiring - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 9 lines 942-992 audio bootstrap, Pattern 4 lines 698-740 seed picker, Pitfall 5 lines 1076-1084 iOS lazy-create) - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group I lines 471-518) - src/App.tsx (current — extend with overlays) - src/PhaserGame.tsx (current — wire EventBus subscription + save lifecycle) - src/content/loader.ts (Phase 1 — extend for ui-strings.yaml) - src/content/schemas/index.ts (Phase 1 — add UiStringsSchema export) - .planning/anti-fomo-doctrine.md (Begin copy must comply: no nag, no FOMO, contemplative tone) src/ui/begin/BeginScreen.tsx, src/ui/begin/BeginScreen.test.tsx, src/ui/begin/use-audio-bootstrap.ts, src/ui/begin/index.ts, src/ui/garden/SeedPicker.tsx, src/ui/garden/SeedPicker.test.tsx, src/ui/garden/index.ts, src/ui/index.ts, content/seasons/01-soil/ui-strings.yaml, src/content/schemas/ui-strings.ts, src/content/schemas/index.ts, src/content/loader.ts, src/content/index.ts, content/seasons/00-demo/fragments.yaml, src/PhaserGame.tsx, src/App.tsx **Step 1 — Install `@testing-library/react`** for component tests: ``` npm install -D @testing-library/react @testing-library/dom ``` (If already installed by Plan 02-01 Task 2, this is a no-op confirm.)

Step 2 — Author content/seasons/01-soil/ui-strings.yaml (player-visible Phase 2 copy; tone matches the bible voice — see CLAUDE.md "Tone" + anti-fomo-doctrine.md):

# Player-visible Phase 2 UI copy. Externalized per CLAUDE.md.
# Reviewed against bible voice + anti-FOMO doctrine.
season: 1

begin:
  title: "The Last Garden"
  subtitle: "tend"
  cta: "Begin"

seed_picker:
  title: "Sow"
  cancel: "Not yet"

post_harvest_beat:
  - "The earth remembers."
  - "Something stayed."
  - "It rests where it grew."

journal:
  empty_state: "Nothing yet. Plant something."
  back: "Close"

settings:
  title: "Settings"
  export: "Save to a copy"
  import: "Restore from a copy"
  restore_snapshot: "Earlier garden"
  persistence_denied_toast: "The garden may forget, if your browser asks it to."

# Plant display names — sourced here so the writer can adjust without
# touching src/sim/garden/plants.ts (which carries fallbackName for tests).
plants:
  rosemary: "Rosemary"
  yarrow: "Yarrow"
  winter-rose: "Winter-rose"

(Copy is a starting draft; user reviews. Bible voice: short, specific, intermittent, sometimes funny, sometimes devastating.)

Step 3 — src/content/schemas/ui-strings.ts:

import { z } from 'zod';

export const UiStringsSchema = z.object({
  season: z.number().int().min(0).max(7),
  begin: z.object({
    title: z.string().min(1),
    subtitle: z.string().min(1),
    cta: z.string().min(1),
  }),
  seed_picker: z.object({
    title: z.string().min(1),
    cancel: z.string().min(1),
  }),
  post_harvest_beat: z.array(z.string().min(1)).min(1),
  journal: z.object({
    empty_state: z.string().min(1),
    back: z.string().min(1),
  }),
  settings: z.object({
    title: z.string().min(1),
    export: z.string().min(1),
    import: z.string().min(1),
    restore_snapshot: z.string().min(1),
    persistence_denied_toast: z.string().min(1),
  }),
  plants: z.record(z.string(), z.string().min(1)),
});

export type UiStrings = z.infer<typeof UiStringsSchema>;

Step 4 — Update src/content/schemas/index.ts:

export { FragmentSchema, type Fragment } from './fragment.ts';
export { SeasonContentSchema, type SeasonContent } from './season.ts';
export { UiStringsSchema, type UiStrings } from './ui-strings.ts';

Step 5 — Extend src/content/loader.ts with PIPE-02 lazy split for season fragments AND a synchronous load for ui-strings.yaml.

Rules:

  1. Keep the existing yamlFiles and mdFiles globs working (do not break Phase 1's loader.test.ts).
  2. Add a NEW eager glob for ui-strings.yaml that loads synchronously at module-eval (the Begin screen reads it on first paint — no time to await).
  3. Add a NEW lazy glob loadSeasonFragments(seasonId) for PIPE-02. The eager fragments export stays for now (Plan 02-03 may switch the consuming code to lazy).
// (top of file — keep existing imports; add UiStringsSchema)
import { SeasonContentSchema, FragmentSchema, UiStringsSchema, type Fragment, type UiStrings } from './schemas/index.ts';

// (existing yamlFiles, mdFiles, loadYamlFragments, loadMdFragments stay UNCHANGED — Plan 02-03 may switch later)

/**
 * UI strings for the active Season. Loaded eagerly so first paint can
 * reference any string without await. Per CLAUDE.md externalized-strings rule.
 */
const uiStringFiles = import.meta.glob('/content/seasons/*/ui-strings.yaml', {
  eager: true,
  query: '?raw',
  import: 'default',
}) as Record<string, string>;

function loadUiStrings(): Record<number, UiStrings> {
  const result: Record<number, UiStrings> = {};
  for (const [path, raw] of Object.entries(uiStringFiles)) {
    const data = parseYAML(raw);
    const parsed = UiStringsSchema.safeParse(data);
    if (!parsed.success) {
      throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
    }
    result[parsed.data.season] = parsed.data;
  }
  return result;
}

export const uiStrings: Record<number, UiStrings> = loadUiStrings();

/**
 * PIPE-02 — per-Season lazy chunk. Phase 2 has only Season 1; the wiring
 * is here so Phase 4 (Season 2) inherits without rework. RESEARCH Pattern 8.
 */
const lazyYamlFragments = import.meta.glob('/content/seasons/*/fragments.yaml', {
  query: '?raw',
  import: 'default',
});

const lazyMdFragments = import.meta.glob('/content/seasons/*/fragments/*.md', {
  query: '?raw',
  import: 'default',
});

function pad2(n: number): string { return n.toString().padStart(2, '0'); }

export async function loadSeasonFragments(seasonId: number): Promise<Fragment[]> {
  const yamlMatch = Object.entries(lazyYamlFragments).filter(([p]) => p.includes(`/${pad2(seasonId)}-`));
  const mdMatch = Object.entries(lazyMdFragments).filter(([p]) => p.includes(`/${pad2(seasonId)}-`));
  const yamlOut: Fragment[] = [];
  for (const [path, loader] of yamlMatch) {
    const raw = (await loader()) as string;
    const parsed = SeasonContentSchema.safeParse(parseYAML(raw));
    if (!parsed.success) throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
    yamlOut.push(...parsed.data.fragments);
  }
  const mdOut: Fragment[] = [];
  for (const [path, loader] of mdMatch) {
    const raw = (await loader()) as string;
    const { data, content } = grayMatter(raw);
    const merged = { ...data, body: content.trim() };
    const parsed = FragmentSchema.safeParse(merged);
    if (!parsed.success) throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
    mdOut.push(parsed.data);
  }
  return [...yamlOut, ...mdOut];
}

Step 6 — Update src/content/index.ts:

export { fragments, loadFragmentsFromGlob, loadSeasonFragments, uiStrings } from './loader.ts';
export { FragmentSchema, type Fragment, SeasonContentSchema, type SeasonContent, UiStringsSchema, type UiStrings } from './schemas/index.ts';

Step 7 — Delete content/seasons/00-demo/fragments.yaml per CONTEXT canonical_refs ("Phase 2 removes this file when Season 1 is authored"). Replace with a placeholder Season-1 fragments.yaml so the existing fragment loader still works:

# content/seasons/01-soil/fragments.yaml — Phase 2 placeholder. Plan 02-03
# replaces with ≥10 real Season-1 fragments authored in voice. The single
# placeholder fragment here keeps the eager loader green during Plan 02-02
# (Plan 02-03 expands).
fragments:
  - id: season1.soil.placeholder
    season: 1
    body: "(placeholder — Plan 02-03 ships authored fragments)"

(Plan 02-03 owns the real Season-1 content authoring. Plan 02-02 ships the structural placeholder so the eager loader sees ≥1 valid Season-1 fragment.)

Step 8 — src/ui/begin/use-audio-bootstrap.ts — copy RESEARCH Pattern 9 lines 949-987 verbatim:

let _ctx: AudioContext | null = null;
let _resumed = false;

/**
 * Lazy-create + resume AudioContext (AEST-07 + RESEARCH Pattern 9).
 * MUST be called synchronously inside a click handler (Pitfall 5: iOS
 * Safari requires the context to be CREATED inside the gesture, not
 * just resumed).
 */
export async function bootstrapAudioContext(): Promise<AudioContext | null> {
  if (_resumed && _ctx) return _ctx;
  if (!_ctx) {
    try {
      const Ctor = typeof AudioContext !== 'undefined'
        ? AudioContext
        : (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
      if (!Ctor) return null;
      _ctx = new Ctor();
    } catch {
      return null;
    }
  }
  try {
    await _ctx.resume();
    _resumed = true;
    return _ctx;
  } catch {
    return null;
  }
}

/**
 * For returning players (D-22): no Begin screen, but the next click /
 * touch / keypress must bootstrap audio.
 */
export function installFirstInteractionGestureHandler(): void {
  const handler = () => {
    void bootstrapAudioContext();
    document.removeEventListener('click', handler);
    document.removeEventListener('touchstart', handler);
    document.removeEventListener('keydown', handler);
  };
  document.addEventListener('click', handler);
  document.addEventListener('touchstart', handler);
  document.addEventListener('keydown', handler);
}

/** Test-only: reset module-level state between tests. */
export function __resetAudioBootstrapForTest(): void {
  _ctx = null;
  _resumed = false;
}

Step 9 — src/ui/begin/BeginScreen.tsx:

import { useAppStore } from '../../store';
import { uiStrings } from '../../content';
import { bootstrapAudioContext } from './use-audio-bootstrap';

/**
 * D-21 + AEST-07: tasteful typographic Begin screen. Phase 3 swaps in
 * the painted gesture-gate without changing this file's behavior.
 *
 * D-22: shown on first run only — gated by session.beginGateDismissed.
 */
export function BeginScreen(): JSX.Element | null {
  const dismissed = useAppStore((s) => s.beginGateDismissed);
  const dismissBeginGate = useAppStore((s) => s.dismissBeginGate);

  if (dismissed) return null;

  const strings = uiStrings[1]?.begin;
  if (!strings) return null;

  const onBegin = () => {
    void bootstrapAudioContext();   // synchronous-inside-click; MUST not be inside useEffect (Pitfall 5)
    dismissBeginGate();
  };

  return (
    <div
      style={{
        position: 'fixed', inset: 0, zIndex: 100,
        background: '#1a1a1a',
        display: 'flex', flexDirection: 'column',
        alignItems: 'center', justifyContent: 'center',
        color: '#e8e0d0',
        fontFamily: 'serif',
      }}
      role="dialog"
      aria-label={strings.title}
    >
      <h1 style={{ fontSize: '3rem', margin: 0, fontWeight: 300 }}>{strings.title}</h1>
      <p style={{ marginTop: '1rem', opacity: 0.7, letterSpacing: '0.2em' }}>{strings.subtitle}</p>
      <button
        onClick={onBegin}
        style={{
          marginTop: '4rem', padding: '0.6rem 2.4rem',
          fontSize: '1.1rem', background: 'transparent',
          color: '#e8e0d0', border: '1px solid #e8e0d0',
          cursor: 'pointer', fontFamily: 'serif',
        }}
      >
        {strings.cta}
      </button>
    </div>
  );
}

Step 10 — src/ui/begin/BeginScreen.test.tsx — Vitest + happy-dom + @testing-library/react:

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { BeginScreen } from './BeginScreen';
import { appStore } from '../../store';
import { __resetAudioBootstrapForTest } from './use-audio-bootstrap';

describe('BeginScreen (AEST-07, D-21, D-22)', () => {
  beforeEach(() => {
    appStore.setState({ beginGateDismissed: false });
    __resetAudioBootstrapForTest();
  });

  it('renders the title and Begin CTA when not dismissed', () => {
    render(<BeginScreen />);
    expect(screen.getByText('The Last Garden')).toBeTruthy();
    expect(screen.getByRole('button', { name: 'Begin' })).toBeTruthy();
  });

  it('renders nothing when beginGateDismissed=true (D-22 returning-player skip)', () => {
    appStore.setState({ beginGateDismissed: true });
    const { container } = render(<BeginScreen />);
    expect(container.firstChild).toBeNull();
  });

  it('dismisses the gate and triggers audio bootstrap on click', async () => {
    // happy-dom does not implement AudioContext; spy on the module-level
    // function via dynamic import of use-audio-bootstrap to assert the call.
    const audio = await import('./use-audio-bootstrap');
    const spy = vi.spyOn(audio, 'bootstrapAudioContext').mockResolvedValue(null);
    render(<BeginScreen />);
    fireEvent.click(screen.getByRole('button', { name: 'Begin' }));
    expect(spy).toHaveBeenCalledTimes(1);
    expect(appStore.getState().beginGateDismissed).toBe(true);
    spy.mockRestore();
  });
});

Step 11 — src/ui/begin/index.ts — barrel:

export { BeginScreen } from './BeginScreen';
export { bootstrapAudioContext, installFirstInteractionGestureHandler } from './use-audio-bootstrap';

Step 12 — src/ui/garden/SeedPicker.tsx:

import { useEffect, useState } from 'react';
import { eventBus } from '../../game/event-bus';
import { useAppStore } from '../../store';
import { uiStrings } from '../../content';
import { PLANT_TYPES } from '../../sim/garden';
import type { PlantTypeId } from '../../sim/garden/types';

interface PickerState {
  visible: boolean;
  tileIdx: number;
  x: number;
  y: number;
}

/**
 * D-02 — inline DOM popover positioned over the Phaser canvas.
 * Listens for `tile-clicked-coords` from the Garden scene; mounts itself
 * absolutely-positioned at those screen coords. Click outside dismisses.
 */
export function SeedPicker(): JSX.Element | null {
  const [picker, setPicker] = useState<PickerState>({ visible: false, tileIdx: -1, x: 0, y: 0 });
  const unlocked = useAppStore((s) => s.unlockedPlantTypes);
  const enqueueCommand = useAppStore((s) => s.enqueueCommand);
  const strings = uiStrings[1]?.seed_picker;
  const plantStrings = uiStrings[1]?.plants ?? {};

  useEffect(() => {
    const onCoords = (payload: { tileIdx: number; screenX: number; screenY: number }) => {
      setPicker({ visible: true, tileIdx: payload.tileIdx, x: payload.screenX, y: payload.screenY });
    };
    eventBus.on('tile-clicked-coords', onCoords);
    return () => { eventBus.off('tile-clicked-coords', onCoords); };
  }, []);

  useEffect(() => {
    if (!picker.visible) return;
    // Defer so the click that opened the picker doesn't dismiss it.
    const t = setTimeout(() => {
      const onClick = () => setPicker((p) => ({ ...p, visible: false }));
      document.addEventListener('click', onClick, { once: true });
      return () => document.removeEventListener('click', onClick);
    }, 0);
    return () => clearTimeout(t);
  }, [picker.visible]);

  if (!picker.visible || !strings) return null;

  const onSelect = (plantTypeId: PlantTypeId) => {
    enqueueCommand({ kind: 'plantSeed', tileIdx: picker.tileIdx, plantTypeId });
    setPicker((p) => ({ ...p, visible: false }));
  };

  // Translate screen coords to picker top-left (centered above tile).
  const left = picker.x - 80;
  const top = picker.y - 120;

  return (
    <div
      data-testid="seed-picker"
      onClick={(e) => e.stopPropagation()}
      style={{
        position: 'fixed', left, top, zIndex: 50,
        background: '#2a2a2e', color: '#e8e0d0',
        padding: '0.6rem 0.8rem', borderRadius: 4,
        boxShadow: '0 6px 18px rgba(0,0,0,0.4)',
        fontFamily: 'serif',
        minWidth: 160,
      }}
    >
      <div style={{ fontSize: '0.85rem', marginBottom: '0.5rem', opacity: 0.7 }}>{strings.title}</div>
      {unlocked.length === 0 && (
        <div style={{ fontSize: '0.85rem', fontStyle: 'italic', opacity: 0.6 }}></div>
      )}
      {unlocked.map((id) => {
        const type = PLANT_TYPES[id as PlantTypeId];
        if (!type) return null;
        const display = plantStrings[id] ?? type.fallbackName;
        return (
          <button
            key={id}
            onClick={() => onSelect(id as PlantTypeId)}
            style={{
              display: 'block', width: '100%', textAlign: 'left',
              padding: '0.4rem 0.6rem', margin: '0.1rem 0',
              background: 'transparent', color: '#e8e0d0',
              border: '1px solid transparent', cursor: 'pointer',
              fontFamily: 'serif',
            }}
          >
            {display}
          </button>
        );
      })}
    </div>
  );
}

Step 13 — src/ui/garden/SeedPicker.test.tsx — Vitest + @testing-library/react:

  • Initial render returns null (not visible).
  • Emitting tile-clicked-coords via eventBus.emit('tile-clicked-coords', {tileIdx: 0, screenX: 100, screenY: 100}) makes the picker visible.
  • With unlockedPlantTypes=['rosemary'], exactly one plant button renders ("Rosemary").
  • Clicking the button enqueues {kind: 'plantSeed', tileIdx: 0, plantTypeId: 'rosemary'} into pendingCommands (verify via appStore.getState().pendingCommands).
  • After button click, the picker dismisses (visibility=false, returns null).

Step 14 — src/ui/garden/index.ts + src/ui/index.ts:

// src/ui/garden/index.ts
export { SeedPicker } from './SeedPicker';

// src/ui/index.ts
export * from './begin';
export * from './garden';

Step 15 — Update src/App.tsx to mount overlays as siblings:

import { useRef } from 'react';
import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx';
import { BeginScreen } from './ui/begin';
import { SeedPicker } from './ui/garden';

function App() {
  const phaserRef = useRef<IRefPhaserGame | null>(null);

  return (
    <div id="app">
      <PhaserGame ref={phaserRef} />
      <BeginScreen />
      <SeedPicker />
      {/* Plan 02-03 mounts: <Journal />, <FragmentRevealModal /> */}
      {/* Plan 02-04 mounts: <LuraDialogue /> */}
      {/* Plan 02-05 mounts: <Letter />, <Settings />, <PersistenceToast /> */}
    </div>
  );
}

export default App;

Step 16 — Update src/PhaserGame.tsx to:

  • Initialize the SimState in the store (set unlockedPlantTypes=['rosemary'] for first run; later plans read from save).
  • Install the first-interaction gesture handler when no Begin screen will show (i.e., when a save exists; for Phase 2 Wave 1, simplification: install always; the handler is a one-shot).
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } from 'react';
import StartGame from './game/main.ts';
import type * as Phaser from 'phaser';
import { eventBus } from './game/event-bus';
import { appStore } from './store';
import { installFirstInteractionGestureHandler } from './ui/begin';

export interface IRefPhaserGame {
  game: Phaser.Game | null;
  scene: Phaser.Scene | null;
}

interface IProps {
  currentActiveScene?: (sceneInstance: Phaser.Scene) => void;
}

export const PhaserGame = forwardRef<IRefPhaserGame, IProps>(function PhaserGame(props, ref) {
  const game = useRef<Phaser.Game | null>(null);
  const sceneRef = useRef<Phaser.Scene | null>(null);

  useLayoutEffect(() => {
    if (game.current === null) {
      // Bootstrap initial state (Plan 02-05 will replace with save-load path).
      const initial = appStore.getState();
      if (initial.unlockedPlantTypes.length === 0) {
        appStore.setState({ unlockedPlantTypes: ['rosemary'] });
      }

      game.current = StartGame('game-container');

      if (typeof ref === 'function') {
        ref({ game: game.current, scene: null });
      } else if (ref) {
        ref.current = { game: game.current, scene: null };
      }
    }
    return () => {
      if (game.current) {
        game.current.destroy(true);
        game.current = null;
      }
    };
  }, [ref]);

  useEffect(() => {
    const onSceneReady = (scene: Phaser.Scene) => {
      sceneRef.current = scene;
      props.currentActiveScene?.(scene);
    };
    eventBus.on('scene-ready', onSceneReady);
    // Install gesture handler unconditionally — it's a one-shot that bootstraps audio
    // on first interaction whether the Begin screen handled it or not (D-22 fallback).
    installFirstInteractionGestureHandler();
    return () => { eventBus.off('scene-ready', onSceneReady); };
  }, [props]);

  useImperativeHandle(ref, () => ({
    game: game.current,
    scene: sceneRef.current,
  }));

  return <div id="game-container" />;
});

(Plan 02-05 wires the real save-lifecycle hook + clock-selection logic here.)

Commit: feat(02-02): begin screen + seed picker + ui-strings + lazy content split. Run npm run ci before committing.

Manual smoke test: npm run dev, visit http://localhost:5173. Should see Begin screen → click Begin → garden tiles visible → click empty tile → seed picker appears positioned over the tile → click "Rosemary" → primitive sprout appears in the tile. Wait ~2 minutes (use a Vitest-style FakeClock injection if desired, or wait for real-time test). Plant transitions sprout → mature → ready with the alpha pulse. Confirm visually. <acceptance_criteria> - grep -q "title: \"The Last Garden\"" content/seasons/01-soil/ui-strings.yaml - grep -q "UiStringsSchema" src/content/schemas/ui-strings.ts - grep -q "loadSeasonFragments" src/content/loader.ts (PIPE-02 lazy split wired) - grep -q "uiStrings" src/content/index.ts (barrel re-export) - grep -q "bootstrapAudioContext" src/ui/begin/use-audio-bootstrap.ts - grep -q "installFirstInteractionGestureHandler" src/ui/begin/use-audio-bootstrap.ts - grep -q "void bootstrapAudioContext()" src/ui/begin/BeginScreen.tsx (sync-inside-click — Pitfall 5 mitigation) - grep -q "tile-clicked-coords" src/ui/garden/SeedPicker.tsx - grep -q "<BeginScreen />" src/App.tsx and grep -q "<SeedPicker />" src/App.tsx - grep -q "installFirstInteractionGestureHandler" src/PhaserGame.tsx - ! test -f content/seasons/00-demo/fragments.yaml (the demo fragment was deleted) - test -f content/seasons/01-soil/fragments.yaml (Phase 2 placeholder fragment file exists) - No player-visible English strings hardcoded outside /content/: grep -E "(Begin|Sow|Rosemary|Yarrow|Winter-rose)" src/ui/begin/BeginScreen.tsx src/ui/garden/SeedPicker.tsx | grep -v "uiStrings\|fallbackName\|aria-label\|comment" | wc -l is 0 (the strings come from uiStrings, not literals) - npx vitest run src/ui/begin/ src/ui/garden/ exits 0 with all tests green - npm run ci exits 0 </acceptance_criteria> npm run lint && npx vitest run src/ui/begin/ src/ui/garden/ src/content/ && npm run ci BeginScreen + SeedPicker land. Audio bootstrap fires synchronously inside click handlers. UI strings externalized to /content/seasons/01-soil/ui-strings.yaml; Zod-validated. PIPE-02 lazy-fragment glob added (Plan 02-03 will populate Season 1). 00-demo deleted; 01-soil placeholder fragments.yaml exists. App.tsx mounts overlays. PhaserGame.tsx wires EventBus + initial unlocks + gesture handler. npm run ci green; manual smoke test confirms full Begin → Plant → Grow vertical slice runs end-to-end.

<threat_model>

Trust Boundaries

Boundary Description
Phaser canvas ↔ React DOM overlay Tile pointerdown → EventBus → React popover → store command. EventBus payload is internal (no user-supplied data).
AudioContext lazy-create boundary Created synchronously inside click handler; defends iOS Safari Pitfall 5.
Content schema boundary ui-strings.yaml is authored content (repo-controlled); Zod-validated at module-eval. No user-supplied content path.

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-02-02-01 Tampering URL devtime flag exposed in production mitigate Garden scene reads window.__tlgClock if present; Plan 02-05 production-guards via import.meta.env.PROD check at boot. Phase 2 ships the hook; Phase 8 verifies no leakage.
T-02-02-02 Tampering User pastes malicious uiStrings via Base64 import mitigate UiStringsSchema validates structure; React renders strings as text (no dangerouslySetInnerHTML). Even if tampered, no XSS surface.
T-02-02-03 Information disclosure AudioContext failure leaking error details accept bootstrapAudioContext catches all errors and returns null; no error message surfaces to UI.
T-02-02-04 Denial-of-service Seed picker spawn-on-every-pixel-click accept Click on empty tile only; one popover at a time; click-outside dismisses. No spam vector.
T-02-02-05 Tampering Plant a seed for a locked plant type mitigate plantSeed command validates against state.unlockedPlantTypes — silent no-op if not unlocked.

No high severity threats. Phase 2 vertical-slice surface is small. </threat_model>

After all 3 tasks committed:

  1. Linter: npm run lint exits 0 (sim-purity rule from Plan 02-01 catches Date.now leaks in src/sim/garden/).
  2. Tests: npx vitest run exits 0; new test files: src/sim/garden/growth.test.ts, src/sim/garden/commands.test.ts, src/ui/begin/BeginScreen.test.tsx, src/ui/garden/SeedPicker.test.tsx. Combined Phase-1+Phase-2 test count grows to ~130.
  3. Build: npm run build exits 0.
  4. Full CI: npm run ci exits 0.
  5. Schema lock: grep -q "loadSeasonFragments" src/content/loader.ts confirms PIPE-02 lazy wiring landed even though Plan 02-03 will populate the actual content.
  6. Manual smoke (executor performs once during Task 3): npm run dev, visit http://localhost:5173. Verify Begin → Plant → Grow loop works on Phaser primitives; tile hover state visible; seed picker positions over the clicked tile; primitive sprout appears; (optionally) wait for stage transitions.

<success_criteria>

Plan 02-02 is complete when:

  • All 3 tasks committed.
  • npm run ci exits 0.
  • First-run player flow works end-to-end: Begin screen → tap → audio resumes → garden visible → click empty tile → seed picker → tap Rosemary → primitive sprout appears → grows through stages over ~2 minutes → ready-state pulse visible.
  • D-21, D-22, D-26, D-27, D-01, D-02, D-06 all visibly satisfied in the dev build.
  • AEST-07 satisfied: bootstrapAudioContext is called inside the click handler (Pitfall 5 + 9 mitigations in place).
  • UX-01 satisfied: Begin screen has no clutter; tile hover state subtle; no Phase-3 polish creep.
  • CORE-02 satisfied: scheduler drives sim ticks; src/sim/garden/ has zero Date.now() calls (ESLint rule confirms).
  • All player-visible strings in /content/seasons/01-soil/ui-strings.yaml.
  • PIPE-02 lazy split wired (loadSeasonFragments exists; Plan 02-03 populates real content).
  • Plan 02-03 (Harvest + Journal) and Plan 02-04 (Lura) can build on the sim/garden + render/garden + ui/garden surfaces shipped here.

</success_criteria>

Create `.planning/phases/02-season-1-vertical-slice-soil/02-02-begin-plant-grow-SUMMARY.md` per template. Document: - Per-plant duration values shipped (rosemary 600 / yarrow 900 / winter-rose 1500 ticks). - Whether `tileCenterToDom` worked under `Phaser.Scale.FIT` without modification (RESEARCH Assumption A5 — verified). - Manual smoke test confirmation (date / browser / observed behavior). - Any deviations from locked content (e.g., user copy edits to ui-strings.yaml during review).