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