Files
TheLastGarden/.planning/phases/02-season-1-vertical-slice-soil/02-02-begin-plant-grow-PLAN.md
T
josh 63d2d8d5f7 docs(02): create phase 2 plan — 5 plans across 3 waves
Phase 2 (Season 1 Vertical Slice — Soil) plan set:
- 02-01 (Wave 0): foundations (BigQty + Zustand 5 store + tick scheduler + V1Payload extension + save lifecycle hooks + Phaser EventBus + ESLint sim-purity rule)
- 02-02 (Wave 1, parallel): Begin → Plant → Grow vertical slice
- 02-03 (Wave 1, parallel): Harvest → Journal → Compost + Season 1 fragments + PIPE-02 verification
- 02-04 (Wave 2, parallel): Lura's 3 Ink-authored gate beats (1st/4th/8th harvest, STRY-10)
- 02-05 (Wave 2, parallel): Letter + Settings + boot-path save lifecycle + Playwright PIPE-07 e2e

All 24 Phase-2 REQ-IDs covered across the plan set. VALIDATION.md per-task verification map filled (15 tasks); nyquist_compliant: true.

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

1644 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 25min band, D-08/D-09); state machine is a pure function of (plantedAtTick, currentTick, growthDurationTicks)"
- "Empty tile renders as faint outline + subtle hover state (D-06); plant primitives render distinct shapes per stage tinted by plant type (D-26); ready tiles pulse via alpha cycle (D-27)"
- "Sim is pure — no Date.now() in src/sim/garden/; all time threaded as injected ticks (CORE-02)"
- "Phaser Garden scene's update() loop reads from the store, calls scheduler.drainTicks with the simulate function, writes results back via simAdapter — no React re-renders trigger render-tier draws"
- "First-interaction gesture handler installed on returning-player loads succeeds at audioContext.resume() on the first click/touchstart/keydown (Pitfall 5 mitigation)"
- "All player-visible Begin-screen and seed-picker copy lives in /content/seasons/01-soil/ui-strings.yaml; nothing player-visible hardcoded in TS (CLAUDE.md externalized-strings rule)"
- "npm run ci is green; sim-purity ESLint rule (Plan 02-01 Task 3) catches any Date.now() leak"
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"
---
<note>
**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.
</note>
<objective>
Ship the Begin → Plant → Grow vertical slice end-to-end. Player loads the page, sees the typographic Begin screen (D-21, AEST-07), taps it (AudioContext.resume() fires synchronously inside the click handler — Pitfall 5 mitigation), the Begin screen dismisses, the Garden scene activates, the player clicks an empty tile, the inline seed picker pops up positioned over the tile (D-02), the player taps a plant type, the command flows through the store → scheduler → sim → store → render, and the player watches a primitive sprout shape appear and grow across the 25min duration (D-08, D-09) tinted to the plant type (D-26) with a ready-state pulse (D-27).
Returning players (save exists) skip the Begin screen entirely (D-22); a first-interaction gesture handler bootstraps audio on the first click/touchstart/keydown.
Purpose: This is the load-bearing vertical slice — the first feature commit ever, on top of the foundations Plan 02-01 lands. It validates that the Phaser ↔ React Zustand bridge works in real code (RESEARCH Pattern 3), that the ESLint sim-purity rule holds when actual sim code is written, that the inline seed picker DOM-popover-over-canvas pattern works under `Phaser.Scale.FIT` (RESEARCH Assumption A5 — verify here, MEDIUM-risk), and that all player-visible strings live in `/content/`.
Output: A running game where Begin → Plant → Grow is fully operational on placeholder Phaser primitives. Plan 02-03 lands Harvest → Journal on top of this. Plan 02-05's Playwright e2e exercises the full loop end-to-end.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@CLAUDE.md
@.planning/anti-fomo-doctrine.md
@.planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md
@.planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md
@.planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md
@.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-SUMMARY.md
@.planning/phases/01-foundations-and-doctrine/01-04-SUMMARY.md
<interfaces>
<!-- Types and exports the executor needs from Plan 02-01 (Wave 0). -->
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<S>(
state: S,
accumulatorMs: number,
simulate: (state: S, dtMs: number, silent: boolean) => S,
silent?: boolean,
): { state: S; remainderMs: number; ticksApplied: number };
```
From src/store/index.ts (Plan 02-01):
```typescript
export const appStore: ZustandStore<AppStoreShape>;
export function useAppStore<T>(selector: (s: AppStoreShape) => T): T;
export const simAdapter: { drainCommands(): GardenCommand[]; applyTilesAndUnlocks(...); applyHarvestedFragments(...); applyLuraProgress(...) };
export interface GardenCommand { kind: 'plantSeed' | 'harvest' | 'compost'; tileIdx: number; plantTypeId?: string }
```
From src/game/event-bus.ts (Plan 02-01):
```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<SaveDB>;
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<IRefPhaserGame | null>(null);
return (
<div id="app">
<PhaserGame ref={phaserRef} />
</div>
);
}
```
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.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: sim/garden core (types + plants table + growth state machine + plantSeed command + simulateOneTick)</name>
<read_first>
- .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)
</read_first>
<files>
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
</files>
<action>
**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 25min 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 25min active-play band.
* rosemary → 600 ticks ≈ 2 min (the warm short one)
* yarrow → 900 ticks ≈ 3 min (medium contemplative)
* winter-rose → 1500 ticks ≈ 5 min (the heavy slow one)
*
* Tints are placeholders — Phase 3 swaps watercolor textures over these.
*/
export const PLANT_TYPES: Readonly<Record<PlantTypeId, PlantType>> = Object.freeze({
rosemary: {
id: 'rosemary',
fallbackName: 'Rosemary',
durationTicks: 600,
tints: { sprout: 0x8aa17a, mature: 0x5d7651, ready: 0xb6c7a8 },
fragmentTags: ['warm'],
},
yarrow: {
id: 'yarrow',
fallbackName: 'Yarrow',
durationTicks: 900,
tints: { sprout: 0xc8b89a, mature: 0xa39777, ready: 0xe8d8b6 },
fragmentTags: ['contemplative'],
},
'winter-rose': {
id: 'winter-rose',
fallbackName: 'Winter-rose',
durationTicks: 1500,
tints: { sprout: 0xa9a3b1, mature: 0x7d758a, ready: 0xc7bdd3 },
fragmentTags: ['heavy'],
},
});
export function getPlantType(id: PlantTypeId): PlantType {
const type = PLANT_TYPES[id];
if (!type) throw new Error(`Unknown plant type: ${id}`);
return type;
}
```
**Step 3 — `src/sim/garden/growth.ts`:**
```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.
</action>
<acceptance_criteria>
- `grep -q "GRID_ROWS = 4" src/sim/garden/types.ts` and `grep -q "GRID_COLS = 4" src/sim/garden/types.ts`
- `grep -q "PlantTypeId = 'rosemary' | 'yarrow' | 'winter-rose'" src/sim/garden/types.ts`
- `grep -q "durationTicks: 600" src/sim/garden/plants.ts` (rosemary)
- `grep -q "durationTicks: 1500" src/sim/garden/plants.ts` (winter-rose)
- `grep -L "Date.now" src/sim/garden/types.ts src/sim/garden/plants.ts src/sim/garden/growth.ts src/sim/garden/commands.ts` (none of these may contain Date.now per the ESLint rule)
- `npx vitest run src/sim/garden/` exits 0 with ≥15 passing tests
- `npm run lint` exits 0 (the sim-purity rule from Plan 02-01 catches Date.now leaks here)
- `npm run build` exits 0
</acceptance_criteria>
<verify>
<automated>npm run lint && npx vitest run src/sim/garden/ && npm run build</automated>
</verify>
<done>
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.
</done>
</task>
<task type="auto">
<name>Task 2: Render layer (Phaser Garden scene + tile/plant/ready-pulse renderers + tile-coords helper) and main.ts/Boot.ts wiring</name>
<read_first>
- .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)
</read_first>
<files>
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
</files>
<action>
**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<number, PlantGameObject> = new Map();
private readyTweens: Map<number, Phaser.Tweens.Tween> = new Map();
private storeUnsubscribe: (() => void) | null = null;
constructor() {
super('Garden');
}
create(): void {
// Allow URL ?devtime=fake to swap in a FakeClock for Playwright (Plan 02-05).
// Production-guarded via import.meta.env.PROD in src/PhaserGame.tsx; the
// Garden scene reads the chosen clock from a window-scoped slot.
if ((window as unknown as { __tlgClock?: Clock }).__tlgClock) {
this.clock = (window as unknown as { __tlgClock: Clock }).__tlgClock;
}
this.tileObjs = drawTiles(this);
this.tileObjs.forEach((t, idx) => {
t.hit.on('pointerdown', () => this.handleTilePointerDown(idx));
});
this.lastFrameMs = this.clock.now();
// Re-render plants when tiles change in the store (Pitfall 6 mitigation:
// subscribe rather than read once in create()).
this.storeUnsubscribe = appStore.subscribe((state) => {
this.repaintPlants(state.tiles as Tile[]);
});
this.repaintPlants(appStore.getState().tiles as Tile[]);
eventBus.emit('scene-ready', this);
}
update(_time: number, _delta: number): void {
const now = this.clock.now();
const deltaMs = now - this.lastFrameMs;
this.lastFrameMs = now;
if (deltaMs > 0) this.accumulatorMs += deltaMs;
// Build current SimState snapshot from the store + drain commands.
const storeState = appStore.getState();
const commands = simAdapter.drainCommands();
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).
</action>
<acceptance_criteria>
- `grep -q "scene: \[Boot, Garden\]" src/game/main.ts`
- `grep -q "this.scene.start('Garden')" src/game/scenes/Boot.ts`
- `grep -q "export class Garden extends Phaser.Scene" src/game/scenes/Garden.ts`
- `grep -q "drainTicks" src/game/scenes/Garden.ts` (scheduler wired into update loop)
- `grep -q "appStore.subscribe" src/game/scenes/Garden.ts` (Pitfall 6 mitigation: subscribe, don't read-once)
- `grep -q "tile-clicked-coords" src/game/scenes/Garden.ts` (EventBus emission)
- `grep -q "tileCenterToDom" src/render/garden/tile-coords.ts`
- `grep -L "Date.now" src/render/garden/tile-renderer.ts src/render/garden/plant-renderer.ts src/render/garden/ready-pulse.ts src/render/garden/tile-coords.ts` (render is sim-adjacent — should not need wall clock; clock comes from scene)
- `npm run lint` exits 0
- `npm run build` exits 0
</acceptance_criteria>
<verify>
<automated>npm run lint && npm run build</automated>
</verify>
<done>
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`.
</done>
</task>
<task type="auto">
<name>Task 3: BeginScreen + audio bootstrap + SeedPicker + UI strings + lazy-content schema + App.tsx wiring</name>
<read_first>
- .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)
</read_first>
<files>
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
</files>
<action>
**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<typeof UiStringsSchema>;
```
**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<string, string>;
function loadUiStrings(): Record<number, UiStrings> {
const result: Record<number, UiStrings> = {};
for (const [path, raw] of Object.entries(uiStringFiles)) {
const data = parseYAML(raw);
const parsed = UiStringsSchema.safeParse(data);
if (!parsed.success) {
throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
}
result[parsed.data.season] = parsed.data;
}
return result;
}
export const uiStrings: Record<number, UiStrings> = loadUiStrings();
/**
* PIPE-02 — per-Season lazy chunk. Phase 2 has only Season 1; the wiring
* is here so Phase 4 (Season 2) inherits without rework. RESEARCH Pattern 8.
*/
const lazyYamlFragments = import.meta.glob('/content/seasons/*/fragments.yaml', {
query: '?raw',
import: 'default',
});
const lazyMdFragments = import.meta.glob('/content/seasons/*/fragments/*.md', {
query: '?raw',
import: 'default',
});
function pad2(n: number): string { return n.toString().padStart(2, '0'); }
export async function loadSeasonFragments(seasonId: number): Promise<Fragment[]> {
const yamlMatch = Object.entries(lazyYamlFragments).filter(([p]) => p.includes(`/${pad2(seasonId)}-`));
const mdMatch = Object.entries(lazyMdFragments).filter(([p]) => p.includes(`/${pad2(seasonId)}-`));
const yamlOut: Fragment[] = [];
for (const [path, loader] of yamlMatch) {
const raw = (await loader()) as string;
const parsed = SeasonContentSchema.safeParse(parseYAML(raw));
if (!parsed.success) throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
yamlOut.push(...parsed.data.fragments);
}
const mdOut: Fragment[] = [];
for (const [path, loader] of mdMatch) {
const raw = (await loader()) as string;
const { data, content } = grayMatter(raw);
const merged = { ...data, body: content.trim() };
const parsed = FragmentSchema.safeParse(merged);
if (!parsed.success) throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
mdOut.push(parsed.data);
}
return [...yamlOut, ...mdOut];
}
```
**Step 6 — Update `src/content/index.ts`:**
```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<AudioContext | null> {
if (_resumed && _ctx) return _ctx;
if (!_ctx) {
try {
const Ctor = typeof AudioContext !== 'undefined'
? AudioContext
: (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctor) return null;
_ctx = new Ctor();
} catch {
return null;
}
}
try {
await _ctx.resume();
_resumed = true;
return _ctx;
} catch {
return null;
}
}
/**
* For returning players (D-22): no Begin screen, but the next click /
* touch / keypress must bootstrap audio.
*/
export function installFirstInteractionGestureHandler(): void {
const handler = () => {
void bootstrapAudioContext();
document.removeEventListener('click', handler);
document.removeEventListener('touchstart', handler);
document.removeEventListener('keydown', handler);
};
document.addEventListener('click', handler);
document.addEventListener('touchstart', handler);
document.addEventListener('keydown', handler);
}
/** Test-only: reset module-level state between tests. */
export function __resetAudioBootstrapForTest(): void {
_ctx = null;
_resumed = false;
}
```
**Step 9 — `src/ui/begin/BeginScreen.tsx`:**
```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 (
<div
style={{
position: 'fixed', inset: 0, zIndex: 100,
background: '#1a1a1a',
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
color: '#e8e0d0',
fontFamily: 'serif',
}}
role="dialog"
aria-label={strings.title}
>
<h1 style={{ fontSize: '3rem', margin: 0, fontWeight: 300 }}>{strings.title}</h1>
<p style={{ marginTop: '1rem', opacity: 0.7, letterSpacing: '0.2em' }}>{strings.subtitle}</p>
<button
onClick={onBegin}
style={{
marginTop: '4rem', padding: '0.6rem 2.4rem',
fontSize: '1.1rem', background: 'transparent',
color: '#e8e0d0', border: '1px solid #e8e0d0',
cursor: 'pointer', fontFamily: 'serif',
}}
>
{strings.cta}
</button>
</div>
);
}
```
**Step 10 — `src/ui/begin/BeginScreen.test.tsx`** — Vitest + happy-dom + @testing-library/react:
```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(<BeginScreen />);
expect(screen.getByText('The Last Garden')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Begin' })).toBeTruthy();
});
it('renders nothing when beginGateDismissed=true (D-22 returning-player skip)', () => {
appStore.setState({ beginGateDismissed: true });
const { container } = render(<BeginScreen />);
expect(container.firstChild).toBeNull();
});
it('dismisses the gate and triggers audio bootstrap on click', async () => {
// happy-dom does not implement AudioContext; spy on the module-level
// function via dynamic import of use-audio-bootstrap to assert the call.
const audio = await import('./use-audio-bootstrap');
const spy = vi.spyOn(audio, 'bootstrapAudioContext').mockResolvedValue(null);
render(<BeginScreen />);
fireEvent.click(screen.getByRole('button', { name: 'Begin' }));
expect(spy).toHaveBeenCalledTimes(1);
expect(appStore.getState().beginGateDismissed).toBe(true);
spy.mockRestore();
});
});
```
**Step 11 — `src/ui/begin/index.ts`** — barrel:
```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<PickerState>({ visible: false, tileIdx: -1, x: 0, y: 0 });
const unlocked = useAppStore((s) => s.unlockedPlantTypes);
const enqueueCommand = useAppStore((s) => s.enqueueCommand);
const strings = uiStrings[1]?.seed_picker;
const plantStrings = uiStrings[1]?.plants ?? {};
useEffect(() => {
const onCoords = (payload: { tileIdx: number; screenX: number; screenY: number }) => {
setPicker({ visible: true, tileIdx: payload.tileIdx, x: payload.screenX, y: payload.screenY });
};
eventBus.on('tile-clicked-coords', onCoords);
return () => { eventBus.off('tile-clicked-coords', onCoords); };
}, []);
useEffect(() => {
if (!picker.visible) return;
// Defer so the click that opened the picker doesn't dismiss it.
const t = setTimeout(() => {
const onClick = () => setPicker((p) => ({ ...p, visible: false }));
document.addEventListener('click', onClick, { once: true });
return () => document.removeEventListener('click', onClick);
}, 0);
return () => clearTimeout(t);
}, [picker.visible]);
if (!picker.visible || !strings) return null;
const onSelect = (plantTypeId: PlantTypeId) => {
enqueueCommand({ kind: 'plantSeed', tileIdx: picker.tileIdx, plantTypeId });
setPicker((p) => ({ ...p, visible: false }));
};
// Translate screen coords to picker top-left (centered above tile).
const left = picker.x - 80;
const top = picker.y - 120;
return (
<div
data-testid="seed-picker"
onClick={(e) => e.stopPropagation()}
style={{
position: 'fixed', left, top, zIndex: 50,
background: '#2a2a2e', color: '#e8e0d0',
padding: '0.6rem 0.8rem', borderRadius: 4,
boxShadow: '0 6px 18px rgba(0,0,0,0.4)',
fontFamily: 'serif',
minWidth: 160,
}}
>
<div style={{ fontSize: '0.85rem', marginBottom: '0.5rem', opacity: 0.7 }}>{strings.title}</div>
{unlocked.length === 0 && (
<div style={{ fontSize: '0.85rem', fontStyle: 'italic', opacity: 0.6 }}></div>
)}
{unlocked.map((id) => {
const type = PLANT_TYPES[id as PlantTypeId];
if (!type) return null;
const display = plantStrings[id] ?? type.fallbackName;
return (
<button
key={id}
onClick={() => onSelect(id as PlantTypeId)}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '0.4rem 0.6rem', margin: '0.1rem 0',
background: 'transparent', color: '#e8e0d0',
border: '1px solid transparent', cursor: 'pointer',
fontFamily: 'serif',
}}
>
{display}
</button>
);
})}
</div>
);
}
```
**Step 13 — `src/ui/garden/SeedPicker.test.tsx`** — Vitest + @testing-library/react:
- Initial render returns null (not visible).
- Emitting `tile-clicked-coords` via `eventBus.emit('tile-clicked-coords', {tileIdx: 0, screenX: 100, screenY: 100})` makes the picker visible.
- With `unlockedPlantTypes=['rosemary']`, exactly one plant button renders ("Rosemary").
- Clicking the button enqueues `{kind: 'plantSeed', tileIdx: 0, plantTypeId: 'rosemary'}` into `pendingCommands` (verify via `appStore.getState().pendingCommands`).
- After button click, the picker dismisses (visibility=false, returns null).
**Step 14 — `src/ui/garden/index.ts`** + `src/ui/index.ts`:
```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<IRefPhaserGame | null>(null);
return (
<div id="app">
<PhaserGame ref={phaserRef} />
<BeginScreen />
<SeedPicker />
{/* Plan 02-03 mounts: <Journal />, <FragmentRevealModal /> */}
{/* Plan 02-04 mounts: <LuraDialogue /> */}
{/* Plan 02-05 mounts: <Letter />, <Settings />, <PersistenceToast /> */}
</div>
);
}
export default App;
```
**Step 16 — Update `src/PhaserGame.tsx`** to:
- Initialize the SimState in the store (set `unlockedPlantTypes=['rosemary']` for first run; later plans read from save).
- Install the first-interaction gesture handler when no Begin screen will show (i.e., when a save exists; for Phase 2 Wave 1, simplification: install always; the handler is a one-shot).
```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<IRefPhaserGame, IProps>(function PhaserGame(props, ref) {
const game = useRef<Phaser.Game | null>(null);
const sceneRef = useRef<Phaser.Scene | null>(null);
useLayoutEffect(() => {
if (game.current === null) {
// Bootstrap initial state (Plan 02-05 will replace with save-load path).
const initial = appStore.getState();
if (initial.unlockedPlantTypes.length === 0) {
appStore.setState({ unlockedPlantTypes: ['rosemary'] });
}
game.current = StartGame('game-container');
if (typeof ref === 'function') {
ref({ game: game.current, scene: null });
} else if (ref) {
ref.current = { game: game.current, scene: null };
}
}
return () => {
if (game.current) {
game.current.destroy(true);
game.current = null;
}
};
}, [ref]);
useEffect(() => {
const onSceneReady = (scene: Phaser.Scene) => {
sceneRef.current = scene;
props.currentActiveScene?.(scene);
};
eventBus.on('scene-ready', onSceneReady);
// Install gesture handler unconditionally — it's a one-shot that bootstraps audio
// on first interaction whether the Begin screen handled it or not (D-22 fallback).
installFirstInteractionGestureHandler();
return () => { eventBus.off('scene-ready', onSceneReady); };
}, [props]);
useImperativeHandle(ref, () => ({
game: game.current,
scene: sceneRef.current,
}));
return <div id="game-container" />;
});
```
(Plan 02-05 wires the real save-lifecycle hook + clock-selection logic here.)
**Commit:** `feat(02-02): begin screen + seed picker + ui-strings + lazy content split`. Run `npm run ci` before committing.
**Manual smoke test:** `npm run dev`, visit `http://localhost:5173`. Should see Begin screen → click Begin → garden tiles visible → click empty tile → seed picker appears positioned over the tile → click "Rosemary" → primitive sprout appears in the tile. Wait ~2 minutes (use a Vitest-style FakeClock injection if desired, or wait for real-time test). Plant transitions sprout → mature → ready with the alpha pulse. Confirm visually.
</action>
<acceptance_criteria>
- `grep -q "title: \"The Last Garden\"" content/seasons/01-soil/ui-strings.yaml`
- `grep -q "UiStringsSchema" src/content/schemas/ui-strings.ts`
- `grep -q "loadSeasonFragments" src/content/loader.ts` (PIPE-02 lazy split wired)
- `grep -q "uiStrings" src/content/index.ts` (barrel re-export)
- `grep -q "bootstrapAudioContext" src/ui/begin/use-audio-bootstrap.ts`
- `grep -q "installFirstInteractionGestureHandler" src/ui/begin/use-audio-bootstrap.ts`
- `grep -q "void bootstrapAudioContext()" src/ui/begin/BeginScreen.tsx` (sync-inside-click — Pitfall 5 mitigation)
- `grep -q "tile-clicked-coords" src/ui/garden/SeedPicker.tsx`
- `grep -q "<BeginScreen />" src/App.tsx` and `grep -q "<SeedPicker />" src/App.tsx`
- `grep -q "installFirstInteractionGestureHandler" src/PhaserGame.tsx`
- `! test -f content/seasons/00-demo/fragments.yaml` (the demo fragment was deleted)
- `test -f content/seasons/01-soil/fragments.yaml` (Phase 2 placeholder fragment file exists)
- No player-visible English strings hardcoded outside `/content/`: `grep -E "(Begin|Sow|Rosemary|Yarrow|Winter-rose)" src/ui/begin/BeginScreen.tsx src/ui/garden/SeedPicker.tsx | grep -v "uiStrings\|fallbackName\|aria-label\|comment" | wc -l` is 0 (the strings come from uiStrings, not literals)
- `npx vitest run src/ui/begin/ src/ui/garden/` exits 0 with all tests green
- `npm run ci` exits 0
</acceptance_criteria>
<verify>
<automated>npm run lint && npx vitest run src/ui/begin/ src/ui/garden/ src/content/ && npm run ci</automated>
</verify>
<done>
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.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Phaser canvas ↔ React DOM overlay | Tile pointerdown → EventBus → React popover → store command. EventBus payload is internal (no user-supplied data). |
| AudioContext lazy-create boundary | Created synchronously inside click handler; defends iOS Safari Pitfall 5. |
| Content schema boundary | ui-strings.yaml is authored content (repo-controlled); Zod-validated at module-eval. No user-supplied content path. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-02-01 | Tampering | URL devtime flag exposed in production | mitigate | Garden scene reads window.__tlgClock if present; Plan 02-05 production-guards via import.meta.env.PROD check at boot. Phase 2 ships the hook; Phase 8 verifies no leakage. |
| T-02-02-02 | Tampering | User pastes malicious uiStrings via Base64 import | mitigate | UiStringsSchema validates structure; React renders strings as text (no dangerouslySetInnerHTML). Even if tampered, no XSS surface. |
| T-02-02-03 | Information disclosure | AudioContext failure leaking error details | accept | bootstrapAudioContext catches all errors and returns null; no error message surfaces to UI. |
| T-02-02-04 | Denial-of-service | Seed picker spawn-on-every-pixel-click | accept | Click on empty tile only; one popover at a time; click-outside dismisses. No spam vector. |
| T-02-02-05 | Tampering | Plant a seed for a locked plant type | mitigate | plantSeed command validates against state.unlockedPlantTypes — silent no-op if not unlocked. |
No `high` severity threats. Phase 2 vertical-slice surface is small.
</threat_model>
<verification>
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.
</verification>
<success_criteria>
Plan 02-02 is complete when:
- [ ] All 3 tasks committed.
- [ ] `npm run ci` exits 0.
- [ ] First-run player flow works end-to-end: Begin screen → tap → audio resumes → garden visible → click empty tile → seed picker → tap Rosemary → primitive sprout appears → grows through stages over ~2 minutes → ready-state pulse visible.
- [ ] D-21, D-22, D-26, D-27, D-01, D-02, D-06 all visibly satisfied in the dev build.
- [ ] AEST-07 satisfied: `bootstrapAudioContext` is called inside the click handler (Pitfall 5 + 9 mitigations in place).
- [ ] UX-01 satisfied: Begin screen has no clutter; tile hover state subtle; no Phase-3 polish creep.
- [ ] CORE-02 satisfied: scheduler drives sim ticks; src/sim/garden/ has zero `Date.now()` calls (ESLint rule confirms).
- [ ] All player-visible strings in `/content/seasons/01-soil/ui-strings.yaml`.
- [ ] PIPE-02 lazy split wired (loadSeasonFragments exists; Plan 02-03 populates real content).
- [ ] Plan 02-03 (Harvest + Journal) and Plan 02-04 (Lura) can build on the sim/garden + render/garden + ui/garden surfaces shipped here.
</success_criteria>
<output>
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).
</output>