--- phase: 02 plan: 02 type: execute wave: 1 depends_on: [02-01] files_modified: - 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 autonomous: true requirements: [GARD-01, GARD-02, AEST-07, UX-01, CORE-02] tags: [vertical-slice, garden, begin-screen, plant, grow, audio-bootstrap, mvp] must_haves: truths: - "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 2–5min 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" artifacts: - path: src/sim/garden/types.ts provides: "Tile, PlantInstance, PlantType, PlantTypeId, GrowthStage interfaces; tileIdx(row,col) and tileCoords(idx) helpers (Pitfall 2: row*4+col canonical encoding)" exports: ["Tile", "PlantInstance", "PlantType", "PlantTypeId", "GrowthStage", "tileIdx", "tileCoords"] - path: src/sim/garden/plants.ts provides: "Static PlantType table — 3 Season-1 plants with distinct durations + tonal-identity slugs" exports: ["PLANT_TYPES", "getPlantType"] - path: src/sim/garden/growth.ts provides: "advanceGrowth(plant, currentTick) → GrowthStage — pure function of plantedAtTick + currentTick + plantType.durationTicks" exports: ["advanceGrowth", "GROWTH_THRESHOLDS"] - path: src/sim/garden/commands.ts provides: "plantSeed(state, args), simulateOneTick(state, currentTick) — pure command applications. Phase 2 wires plantSeed; harvest/compost added in Plan 02-03" exports: ["plantSeed", "simulateOneTick"] - path: src/render/garden/tile-renderer.ts provides: "drawTiles(scene, tiles) — Phaser primitive draw of 16-tile grid with hover state (D-06)" exports: ["drawTiles"] - path: src/render/garden/plant-renderer.ts provides: "drawPlant(scene, tileIdx, plant) — primitive shapes per growth stage tinted by plantType (D-26)" exports: ["drawPlant"] - path: src/render/garden/ready-pulse.ts provides: "applyReadyPulse(scene, tileIdx) — alpha tween for ready cue (D-27)" exports: ["applyReadyPulse"] - path: src/render/garden/tile-coords.ts provides: "tileToScreenCoords(scene, tileIdx) — helper for seed picker positioning (RESEARCH Pattern 4)" exports: ["tileToScreenCoords", "GRID_LAYOUT"] - path: src/ui/begin/BeginScreen.tsx provides: "Tasteful typographic Begin screen — title + Begin button (D-21); calls bootstrapAudioContext on tap, dismisses via session slice (D-22)" exports: ["BeginScreen"] - path: src/ui/begin/use-audio-bootstrap.ts provides: "bootstrapAudioContext() — lazy AudioContext creation + resume; installFirstInteractionGestureHandler() for returning players (RESEARCH Pattern 9)" exports: ["bootstrapAudioContext", "installFirstInteractionGestureHandler"] - path: src/ui/garden/SeedPicker.tsx provides: "Inline DOM popover positioned over Phaser canvas; lists unlocked plant types; commits via store.enqueueCommand (D-02)" exports: ["SeedPicker"] - path: src/game/scenes/Garden.ts provides: "Phaser Garden scene — 4×4 tile grid, pointerdown handlers, scheduler integration, EventBus emissions" exports: ["Garden"] - path: content/seasons/01-soil/ui-strings.yaml provides: "Player-visible Phase 2 UI copy (Begin screen, seed picker, post-harvest beat) — externalized per CLAUDE.md" - path: src/content/schemas/ui-strings.ts provides: "UiStringsSchema (Zod) for ui-strings.yaml validation" exports: ["UiStringsSchema", "UiStrings"] key_links: - from: src/ui/begin/BeginScreen.tsx to: src/ui/begin/use-audio-bootstrap.ts via: "onClick handler calls bootstrapAudioContext() synchronously inside the click event" pattern: "bootstrapAudioContext" - from: src/game/scenes/Garden.ts to: src/sim/garden/commands.ts via: "scheduler drains store commands and applies them via simulateOneTick(state, tick)" pattern: "simulateOneTick" - from: src/ui/garden/SeedPicker.tsx to: src/store/index.ts via: "useAppStore + enqueueCommand({kind: 'plantSeed', tileIdx, plantTypeId})" pattern: "enqueueCommand" - from: src/game/scenes/Garden.ts to: src/game/event-bus.ts via: "tile pointerdown emits 'tile-clicked-coords' with {tileIdx, screenX, screenY}" pattern: "tile-clicked-coords" - from: src/render/garden/plant-renderer.ts to: src/sim/garden/types.ts via: "imports PlantType / GrowthStage types only — no behavioral coupling (render reads from store, not from sim modules)" pattern: "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 2–5min 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. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.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): ```typescript 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( 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): ```typescript export const appStore: ZustandStore; export function useAppStore(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): ```typescript 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): ```typescript export interface V1Payload { /* Phase-2-extended; see migrations.ts */ } export function migrate(payload: unknown, fromVersion: number): { payload: unknown; toVersion: number }; export function openSaveDB(): Promise; export function registerSaveLifecycleHooks(config: { saveSync: () => void }): { detach(): void }; ``` From src/content/index.ts (Phase 1): ```typescript 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): ```typescript function App() { const phaserRef = useRef(null); return (
); } ``` Existing src/game/main.ts scene config: ```typescript scene: [Boot], // Plan 02-02 changes to: scene: [Boot, Garden] ``` Existing Boot.create(): ```typescript 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`:** ```typescript /** * 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 2–5min band (D-09). At TICK_MS=200, 600 ticks = 2min, 900 ticks = 3min, 1500 ticks = 5min. ```typescript 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 2–5min 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> = 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`:** ```typescript 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`:** ```typescript 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. } return { ...next, lastTickAt: currentTick }; } /** * 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 updates `lastTickAt: currentTick`. - `simulateOneTick` with no commands updates only `lastTickAt`. - `tileGrowthStage` returns null for empty tile, returns the correct stage for a plant. **Step 7 — `src/sim/garden/index.ts`** — barrel: ```typescript 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. - `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) - `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 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). ```typescript 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): ```typescript 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): ```typescript 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): ```typescript 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: ```typescript 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: ```typescript export * from './garden'; ``` **Step 7 — `src/game/scenes/Garden.ts`** — the Phaser scene that wires it all together: ```typescript 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 = new Map(); private readyTweens: Map = 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(); const simStateNow: SimState = { garden: { tiles: storeState.tiles }, plants: [], harvestedFragmentIds: storeState.harvestedFragmentIds, lastTickAt: this.currentTick, 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`:** ```typescript 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`:** ```typescript 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). - `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 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): ```yaml # 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`:** ```typescript 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; ``` **Step 4 — Update `src/content/schemas/index.ts`:** ```typescript 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). ```typescript // (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; function loadUiStrings(): Record { const result: Record = {}; 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 = 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 { 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`:** ```typescript 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: ```yaml # 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: ```typescript 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 { 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`:** ```typescript 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 (

{strings.title}

{strings.subtitle}

); } ``` **Step 10 — `src/ui/begin/BeginScreen.test.tsx`** — Vitest + happy-dom + @testing-library/react: ```typescript 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(); 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(); 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(); 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: ```typescript export { BeginScreen } from './BeginScreen'; export { bootstrapAudioContext, installFirstInteractionGestureHandler } from './use-audio-bootstrap'; ``` **Step 12 — `src/ui/garden/SeedPicker.tsx`:** ```typescript 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({ 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 (
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, }} >
{strings.title}
{unlocked.length === 0 && (
)} {unlocked.map((id) => { const type = PLANT_TYPES[id as PlantTypeId]; if (!type) return null; const display = plantStrings[id] ?? type.fallbackName; return ( ); })}
); } ``` **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`: ```typescript // 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: ```typescript 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(null); return (
{/* Plan 02-03 mounts: , */} {/* Plan 02-04 mounts: */} {/* Plan 02-05 mounts: , , */}
); } 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). ```typescript 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(function PhaserGame(props, ref) { const game = useRef(null); const sceneRef = useRef(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
; }); ``` (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. - `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 "" src/App.tsx` and `grep -q "" 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 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. ## 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. 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. 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. 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).