Two distinct fields with strict separation:
- lastTickAt: wall-clock milliseconds. Written ONLY at saveSync time by
the application layer. The sim NEVER writes this field.
computeOfflineCatchup uses it as the wall-clock anchor.
- tickCount: monotonic sim-internal counter (one per simulate() call).
Used for STRY-10 narrative gating that must be immune to wall-clock
manipulation. The sim writes this field; the application layer reads
it via simAdapter.applyTickCount.
Changes:
02-01: SimState + V1Payload gain `tickCount: number`; migrations[1]
defaults to 0; GardenSlice exposes tickCount + lastTickAt + setters;
simAdapter exposes applyTickCount; tests assert the round-trip.
02-02: simulateOneTick increments next.tickCount + 1 (not lastTickAt:
currentTick); Garden scene's SimState snapshot reads lastTickAt
through from store and writes tickCount: this.currentTick locally;
acceptance_criteria forbids `lastTickAt: this.*` in the sim and scene.
02-05: buildPayloadFromStore now persists tickCount (from store);
hydrateStoreFromPayload restores it via state.setTickCount.
This unblocks the offline-catchup math: computeOfflineCatchup(payload.lastTickAt,
nowMs) now reliably reads wall-clock ms because the sim never overwrites it
with a tick counter.
70 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | tags | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02 | 02 | execute | 1 |
|
|
true |
|
|
|
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.
Returning players (save exists) skip the Begin screen entirely (D-22); a first-interaction gesture handler bootstraps audio on the first click/touchstart/keydown.
Purpose: This is the load-bearing vertical slice — the first feature commit ever, on top of the foundations Plan 02-01 lands. It validates that the Phaser ↔ React Zustand bridge works in real code (RESEARCH Pattern 3), that the ESLint sim-purity rule holds when actual sim code is written, that the inline seed picker DOM-popover-over-canvas pattern works under Phaser.Scale.FIT (RESEARCH Assumption A5 — verify here, MEDIUM-risk), and that all player-visible strings live in /content/.
Output: A running game where Begin → Plant → Grow is fully operational on placeholder Phaser primitives. Plan 02-03 lands Harvest → Journal on top of this. Plan 02-05's Playwright e2e exercises the full loop end-to-end.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @CLAUDE.md @.planning/anti-fomo-doctrine.md @.planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md @.planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md @.planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md @.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-SUMMARY.md @.planning/phases/01-foundations-and-doctrine/01-04-SUMMARY.mdFrom src/sim/scheduler/index.ts (Plan 02-01):
export type { Clock } from './clock';
export { wallClock, FakeClock } from './clock';
export const TICK_MS: number; // 200 (5Hz)
export const MAX_OFFLINE_MS: number;
export function drainTicks<S>(
state: S,
accumulatorMs: number,
simulate: (state: S, dtMs: number, silent: boolean) => S,
silent?: boolean,
): { state: S; remainderMs: number; ticksApplied: number };
From src/store/index.ts (Plan 02-01):
export const appStore: ZustandStore<AppStoreShape>;
export function useAppStore<T>(selector: (s: AppStoreShape) => T): T;
export const simAdapter: { drainCommands(): GardenCommand[]; applyTilesAndUnlocks(...); applyHarvestedFragments(...); applyLuraProgress(...) };
export interface GardenCommand { kind: 'plantSeed' | 'harvest' | 'compost'; tileIdx: number; plantTypeId?: string }
From src/game/event-bus.ts (Plan 02-01):
export const eventBus: Phaser.Events.EventEmitter;
// Sample events:
// 'scene-ready' (Phaser → React)
// 'tile-clicked-coords' (Phaser → React) {tileIdx, screenX, screenY}
// 'fragment-revealed' (Phaser → React) (Plan 02-03)
From src/save/index.ts (Phase 1 + Plan 02-01 extension):
export interface V1Payload { /* Phase-2-extended; see migrations.ts */ }
export function migrate(payload: unknown, fromVersion: number): { payload: unknown; toVersion: number };
export function openSaveDB(): Promise<SaveDB>;
export function registerSaveLifecycleHooks(config: { saveSync: () => void }): { detach(): void };
From src/content/index.ts (Phase 1):
export const fragments: Fragment[];
export { FragmentSchema, type Fragment, SeasonContentSchema, type SeasonContent } from './schemas/index';
Existing src/App.tsx (Phase 1 — to be expanded by this plan):
function App() {
const phaserRef = useRef<IRefPhaserGame | null>(null);
return (
<div id="app">
<PhaserGame ref={phaserRef} />
</div>
);
}
Existing src/game/main.ts scene config:
scene: [Boot], // Plan 02-02 changes to: scene: [Boot, Garden]
Existing Boot.create():
create(): void {
// Phase 2 will start the preloader from here.
}
// Plan 02-02 changes to:
create(): void { this.scene.start('Garden'); }
For SeedPicker positioning (RESEARCH Assumption A5): Phaser uses Phaser.Scale.FIT (src/game/main.ts:16). Pointer event coordinates from a Phaser scene's pointerdown handler are in canvas pixel space; getBoundingClientRect of #game-container may need to be added to translate to viewport coords. Verify on a non-fullscreen window during Task 2.
/**
* 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.
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<Record<PlantTypeId, PlantType>> = Object.freeze({
rosemary: {
id: 'rosemary',
fallbackName: 'Rosemary',
durationTicks: 600,
tints: { sprout: 0x8aa17a, mature: 0x5d7651, ready: 0xb6c7a8 },
fragmentTags: ['warm'],
},
yarrow: {
id: 'yarrow',
fallbackName: 'Yarrow',
durationTicks: 900,
tints: { sprout: 0xc8b89a, mature: 0xa39777, ready: 0xe8d8b6 },
fragmentTags: ['contemplative'],
},
'winter-rose': {
id: 'winter-rose',
fallbackName: 'Winter-rose',
durationTicks: 1500,
tints: { sprout: 0xa9a3b1, mature: 0x7d758a, ready: 0xc7bdd3 },
fragmentTags: ['heavy'],
},
});
export function getPlantType(id: PlantTypeId): PlantType {
const type = PLANT_TYPES[id];
if (!type) throw new Error(`Unknown plant type: ${id}`);
return type;
}
Step 3 — src/sim/garden/growth.ts:
import type { PlantInstance, PlantType, GrowthStage } from './types';
/**
* Sprout (0%) → Mature (33%) → Ready (≥100%). Per CONTEXT D-08/D-09.
*
* Pure function of (plantedAtTick, currentTick, durationTicks). Sim safety:
* no Date.now(), no DOM. The tick scheduler injects currentTick.
*/
export const GROWTH_THRESHOLDS = Object.freeze({
matureFraction: 0.33,
readyFraction: 1.0,
});
export function advanceGrowth(plant: PlantInstance, plantType: PlantType, currentTick: number): GrowthStage {
const ticksSincePlant = Math.max(0, currentTick - plant.plantedAtTick);
const progress = ticksSincePlant / plantType.durationTicks;
if (progress >= GROWTH_THRESHOLDS.readyFraction) return 'ready';
if (progress >= GROWTH_THRESHOLDS.matureFraction) return 'mature';
return 'sprout';
}
Step 4 — src/sim/garden/growth.test.ts — exhaustive boundary tests:
advanceGrowth({plantedAtTick: 0}, rosemary, 0)→'sprout'.advanceGrowth({plantedAtTick: 0}, rosemary, 197)→'sprout'(just-below 33%).advanceGrowth({plantedAtTick: 0}, rosemary, 198)→'mature'(≥33%; 600 * 0.33 = 198).advanceGrowth({plantedAtTick: 0}, rosemary, 599)→'mature'.advanceGrowth({plantedAtTick: 0}, rosemary, 600)→'ready'.advanceGrowth({plantedAtTick: 100}, rosemary, 100)→'sprout'(just planted).advanceGrowth({plantedAtTick: 100}, rosemary, 50)→'sprout'(negative delta clamped via Math.max — defends Pitfall 1).advanceGrowth({plantedAtTick: 0}, rosemary, 100000)→'ready'(overgrowth stays 'ready', no overflow stage).
Step 5 — src/sim/garden/commands.ts:
import type { SimState } from '../state';
import type { GardenCommand } from '../../store/garden-slice'; // type-only import; runtime store not loaded by sim
import { PLANT_TYPES, getPlantType } from './plants';
import type { PlantInstance, PlantTypeId, Tile } from './types';
import { GRID_SIZE } from './types';
import { advanceGrowth } from './growth';
/**
* Pure command applications. Each returns a NEW SimState — no mutation.
* Time is INJECTED via currentTick. Per CORE-02 + sim-purity ESLint rule.
*
* Phase 2 wires plantSeed here; harvest + compost ship in Plan 02-03.
*/
export function plantSeed(state: SimState, tileIdx: number, plantTypeId: PlantTypeId, currentTick: number): SimState {
if (tileIdx < 0 || tileIdx >= GRID_SIZE) throw new Error(`Bad tile index: ${tileIdx}`);
const tiles = state.garden.tiles as Tile[];
const target = tiles[tileIdx];
if (target?.plant !== null && target?.plant !== undefined) {
return state; // tile occupied — silent no-op (player tap on occupied tile is a render-tier path; sim refuses)
}
// Plant type must be unlocked (D-05 fragment-count thresholds; defaults to ['rosemary'] at game start)
if (!state.unlockedPlantTypes.includes(plantTypeId)) {
return state;
}
const plant: PlantInstance = { plantTypeId, plantedAtTick: currentTick };
const nextTiles: Tile[] = tiles.map((t, i) =>
i === tileIdx ? { idx: i, plant } : t,
);
return { ...state, garden: { tiles: nextTiles } };
}
/**
* Pure single-tick simulation. Drains pending commands, advances all plants.
* Per CORE-02 — fixed-timestep, deterministic from inputs.
*
* Phase 2 Plan 02-02 implements plantSeed only; harvest + compost arrive
* in Plan 02-03 (extended via the kind switch below).
*/
export function simulateOneTick(state: SimState, currentTick: number, commands: GardenCommand[]): SimState {
let next = state;
// Drain commands FIRST so state effects of new commands participate in this tick.
for (const cmd of commands) {
if (cmd.kind === 'plantSeed' && cmd.plantTypeId) {
next = plantSeed(next, cmd.tileIdx, cmd.plantTypeId as PlantTypeId, currentTick);
}
// Plan 02-03 will add 'harvest' and 'compost' branches here.
}
// BLOCKER 3 invariant: the sim writes tickCount (sim-internal counter for
// STRY-10), NEVER lastTickAt. lastTickAt is wall-clock ms owned by the
// application layer's saveSync (src/PhaserGame.tsx).
return { ...next, tickCount: next.tickCount + 1 };
}
/**
* Helper for renderers (read-only): given a Tile, what stage is its plant in?
* Pure; called from src/render/garden/plant-renderer.ts via injected currentTick.
*/
export function tileGrowthStage(tile: Tile, currentTick: number): GrowthStage | null {
if (!tile.plant) return null;
const type = PLANT_TYPES[tile.plant.plantTypeId];
if (!type) return null;
return advanceGrowth(tile.plant, type, currentTick);
}
import type { GrowthStage } from './types';
(Order the import type { GrowthStage } near the top of the file, not at the bottom — ESLint will complain otherwise. Keep all imports at file head.)
Step 6 — src/sim/garden/commands.test.ts — exhaustive Vitest cases:
- Empty initial state has 16 null tiles (constructed via helper).
plantSeed(state, 0, 'rosemary', 100)withunlockedPlantTypes=['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)withunlockedPlantTypes=['rosemary'](yarrow locked) returns state unchanged.plantSeed(state, 0, 'rosemary', 100)thenplantSeed(state', 0, 'rosemary', 200)— second call returns state' unchanged (tile occupied; silent no-op).plantSeed(state, 16, ...)throws (out-of-range tileIdx).simulateOneTickwith one plantSeed command applies it AND incrementstickCountby 1 (BLOCKER 3 — sim writes tickCount, not lastTickAt).simulateOneTickwith no commands still incrementstickCount(the sim ticked, even with no player commands).simulateOneTickdoes NOT modifylastTickAt(BLOCKER 3 — saveSync owns that field).tileGrowthStagereturns null for empty tile, returns the correct stage for a plant.
Step 7 — src/sim/garden/index.ts — barrel:
export type { Tile, PlantInstance, PlantType, PlantTypeId, GrowthStage } from './types';
export { GRID_ROWS, GRID_COLS, GRID_SIZE, tileIdx, tileCoords } from './types';
export { PLANT_TYPES, getPlantType } from './plants';
export { advanceGrowth, GROWTH_THRESHOLDS } from './growth';
export { plantSeed, simulateOneTick, tileGrowthStage } from './commands';
Also extend src/sim/index.ts to re-export * from './garden' (or specific symbols).
Commit: feat(02-02): sim/garden — types, plants table, growth state machine, plantSeed. Run npm run lint && npx vitest run src/sim/garden/ before committing.
<acceptance_criteria>
- grep -q "GRID_ROWS = 4" src/sim/garden/types.ts and grep -q "GRID_COLS = 4" src/sim/garden/types.ts
- grep -q "PlantTypeId = 'rosemary' | 'yarrow' | 'winter-rose'" src/sim/garden/types.ts
- grep -q "durationTicks: 600" src/sim/garden/plants.ts (rosemary)
- grep -q "durationTicks: 1500" src/sim/garden/plants.ts (winter-rose)
- grep -L "Date.now" src/sim/garden/types.ts src/sim/garden/plants.ts src/sim/garden/growth.ts src/sim/garden/commands.ts (none of these may contain Date.now per the ESLint rule)
- ! grep -E "lastTickAt:\\s*(this|currentTick)" src/sim/garden/commands.ts (BLOCKER 3 — sim must NEVER write lastTickAt; saveSync owns it)
- grep -q "tickCount: next.tickCount" src/sim/garden/commands.ts (BLOCKER 3 — simulateOneTick increments the sim-internal counter)
- ! grep -E "lastTickAt:\\s*this\\." src/game/scenes/Garden.ts (BLOCKER 3 — Garden scene snapshot must read lastTickAt from store, not write a tick counter into it)
- npx vitest run src/sim/garden/ exits 0 with ≥15 passing tests
- npm run lint exits 0 (the sim-purity rule from Plan 02-01 catches Date.now leaks here)
- npm run build exits 0
</acceptance_criteria>
npm run lint && npx vitest run src/sim/garden/ && npm run build
sim/garden core lands with 3 plant types, growth state machine, plantSeed command, simulateOneTick orchestrator. Pure functions throughout. ESLint sim-purity rule confirms no Date.now calls. ≥15 Vitest tests green.
import * as Phaser from 'phaser';
import { GRID_ROWS, GRID_COLS, GRID_SIZE } from '../../sim/garden/types';
/**
* 4×4 garden layout in canvas pixel coordinates. Centered in the
* 1024×768 game area declared in src/game/main.ts.
*
* Tile size + spacing chosen so the grid sits comfortably with margins
* for Phase-3 watercolor frames. Phase 2 ships placeholder primitives
* inside these bounds.
*/
export const GRID_LAYOUT = Object.freeze({
tileSize: 96, // px
tileGap: 16, // px between tiles
gridOriginX: 240, // top-left of grid in canvas px (centered: (1024 - (4*96 + 3*16))/2 = 248 ≈ 240)
gridOriginY: 144, // top-left of grid in canvas px (centered: (768 - (4*96 + 3*16))/2 = 168 ≈ 144)
});
export function tileTopLeftCanvas(idx: number): { x: number; y: number } {
if (idx < 0 || idx >= GRID_SIZE) throw new Error(`Bad tile idx: ${idx}`);
const row = Math.floor(idx / GRID_COLS);
const col = idx % GRID_COLS;
const x = GRID_LAYOUT.gridOriginX + col * (GRID_LAYOUT.tileSize + GRID_LAYOUT.tileGap);
const y = GRID_LAYOUT.gridOriginY + row * (GRID_LAYOUT.tileSize + GRID_LAYOUT.tileGap);
return { x, y };
}
export function tileCenterCanvas(idx: number): { x: number; y: number } {
const tl = tileTopLeftCanvas(idx);
return { x: tl.x + GRID_LAYOUT.tileSize / 2, y: tl.y + GRID_LAYOUT.tileSize / 2 };
}
/**
* Convert a tile center from canvas pixel space to viewport DOM coordinates.
* The seed picker (DOM popover) uses this to mount itself in absolute-position
* over the canvas (RESEARCH Pattern 4 + Assumption A5).
*
* Phaser.Scale.FIT scales + letterboxes; we need the actual canvas DOMRect
* to translate canvas-space → CSS pixel space.
*/
export function tileCenterToDom(scene: Phaser.Scene, idx: number): { x: number; y: number } {
const center = tileCenterCanvas(idx);
const canvas = scene.game.canvas;
const rect = canvas.getBoundingClientRect();
const scaleX = rect.width / scene.game.scale.width;
const scaleY = rect.height / scene.game.scale.height;
return {
x: rect.left + center.x * scaleX,
y: rect.top + center.y * scaleY,
};
}
Step 2 — src/render/garden/tile-renderer.ts — primitive draws (D-06):
import * as Phaser from 'phaser';
import { GRID_SIZE } from '../../sim/garden/types';
import { tileTopLeftCanvas, GRID_LAYOUT } from './tile-coords';
/**
* Empty-tile look: faint outlined rounded rectangle with subtle hover.
* Per CONTEXT D-06; Phase 3 paints the watercolor treatment.
*/
const OUTLINE_COLOR = 0x4d4d52;
const OUTLINE_HOVER = 0x6e6e75;
const OUTLINE_ALPHA = 0.6;
export interface TileGameObjects {
/** Hit-area rectangle (interactive). */
hit: Phaser.GameObjects.Rectangle;
/** Outline graphic. */
outline: Phaser.GameObjects.Graphics;
}
export function drawTiles(scene: Phaser.Scene): TileGameObjects[] {
const tiles: TileGameObjects[] = [];
for (let i = 0; i < GRID_SIZE; i++) {
const tl = tileTopLeftCanvas(i);
const cx = tl.x + GRID_LAYOUT.tileSize / 2;
const cy = tl.y + GRID_LAYOUT.tileSize / 2;
// Outline graphic
const g = scene.add.graphics();
g.lineStyle(2, OUTLINE_COLOR, OUTLINE_ALPHA);
g.strokeRoundedRect(tl.x, tl.y, GRID_LAYOUT.tileSize, GRID_LAYOUT.tileSize, 6);
// Hit rectangle (transparent, interactive)
const hit = scene.add.rectangle(cx, cy, GRID_LAYOUT.tileSize, GRID_LAYOUT.tileSize, 0xffffff, 0);
hit.setInteractive({ useHandCursor: true });
hit.on('pointerover', () => {
g.clear();
g.lineStyle(2, OUTLINE_HOVER, OUTLINE_ALPHA);
g.strokeRoundedRect(tl.x, tl.y, GRID_LAYOUT.tileSize, GRID_LAYOUT.tileSize, 6);
});
hit.on('pointerout', () => {
g.clear();
g.lineStyle(2, OUTLINE_COLOR, OUTLINE_ALPHA);
g.strokeRoundedRect(tl.x, tl.y, GRID_LAYOUT.tileSize, GRID_LAYOUT.tileSize, 6);
});
// Tag the hit object with its index for handler dispatch
hit.setData('tileIdx', i);
tiles.push({ hit, outline: g });
}
return tiles;
}
Step 3 — src/render/garden/plant-renderer.ts — primitive shapes per stage (D-26):
import * as Phaser from 'phaser';
import type { Tile, GrowthStage, PlantTypeId } from '../../sim/garden/types';
import { PLANT_TYPES } from '../../sim/garden/plants';
import { tileCenterCanvas, GRID_LAYOUT } from './tile-coords';
/**
* Plant primitives per CONTEXT D-26.
* sprout = small dot (radius 6)
* mature = stem rectangle (width 4, height 24)
* ready = bloom shape (small filled circle, radius 18)
*
* Tinted by plant type (PLANT_TYPES[plantTypeId].tints[stage]).
* Phase 3 swaps in painted sprites without touching this signature.
*/
export interface PlantGameObject {
shape: Phaser.GameObjects.Shape;
stage: GrowthStage;
}
export function drawPlant(scene: Phaser.Scene, tileIdx: number, tile: Tile, stage: GrowthStage): PlantGameObject | null {
if (!tile.plant) return null;
const type = PLANT_TYPES[tile.plant.plantTypeId];
if (!type) return null;
const center = tileCenterCanvas(tileIdx);
const tint = type.tints[stage];
let shape: Phaser.GameObjects.Shape;
if (stage === 'sprout') {
shape = scene.add.circle(center.x, center.y + GRID_LAYOUT.tileSize / 4, 6, tint);
} else if (stage === 'mature') {
shape = scene.add.rectangle(center.x, center.y, 4, 24, tint);
} else {
shape = scene.add.circle(center.x, center.y, 18, tint);
}
return { shape, stage };
}
export function destroyPlant(obj: PlantGameObject | null): void {
obj?.shape.destroy();
}
Step 4 — src/render/garden/ready-pulse.ts — alpha-cycle pulse (D-27):
import * as Phaser from 'phaser';
/**
* Subtle alpha pulse on ready-stage plants. Per CONTEXT D-27. Phase 3
* paints over with a warmer light treatment.
*
* Returns the tween so the scene can stop it when the plant is harvested.
*/
export function applyReadyPulse(scene: Phaser.Scene, target: Phaser.GameObjects.GameObject): Phaser.Tweens.Tween {
return scene.tweens.add({
targets: target,
alpha: { from: 0.7, to: 1.0 },
duration: 1200,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1,
});
}
Step 5 — src/render/garden/index.ts — barrel:
export { drawTiles } from './tile-renderer';
export type { TileGameObjects } from './tile-renderer';
export { drawPlant, destroyPlant } from './plant-renderer';
export type { PlantGameObject } from './plant-renderer';
export { applyReadyPulse } from './ready-pulse';
export { tileTopLeftCanvas, tileCenterCanvas, tileCenterToDom, GRID_LAYOUT } from './tile-coords';
Step 6 — src/render/index.ts — top-level render barrel:
export * from './garden';
Step 7 — src/game/scenes/Garden.ts — the Phaser scene that wires it all together:
import * as Phaser from 'phaser';
import { eventBus } from '../event-bus';
import { drainTicks, TICK_MS, wallClock, type Clock } from '../../sim/scheduler';
import type { SimState } from '../../sim/state';
import { simulateOneTick, tileGrowthStage } from '../../sim/garden';
import type { Tile } from '../../sim/garden/types';
import { drawTiles, drawPlant, destroyPlant, applyReadyPulse, tileCenterToDom, type TileGameObjects, type PlantGameObject } from '../../render/garden';
import { appStore, simAdapter } from '../../store';
/**
* The 4×4 garden scene (CONTEXT D-01). Wires the tick scheduler into
* Phaser's update() loop, draws tiles, dispatches pointer events to
* the EventBus + store, and re-renders plants on store changes.
*
* The Garden scene is the ONLY place where sim + store + render meet.
* It stays thin (RESEARCH Pattern 3 line 660): subscribe, dispatch.
*/
export class Garden extends Phaser.Scene {
private accumulatorMs = 0;
private lastFrameMs = 0;
private clock: Clock = wallClock;
private currentTick = 0;
private tileObjs: TileGameObjects[] = [];
private plantObjs: Map<number, PlantGameObject> = new Map();
private readyTweens: Map<number, Phaser.Tweens.Tween> = new Map();
private storeUnsubscribe: (() => void) | null = null;
constructor() {
super('Garden');
}
create(): void {
// Allow URL ?devtime=fake to swap in a FakeClock for Playwright (Plan 02-05).
// Production-guarded via import.meta.env.PROD in src/PhaserGame.tsx; the
// Garden scene reads the chosen clock from a window-scoped slot.
if ((window as unknown as { __tlgClock?: Clock }).__tlgClock) {
this.clock = (window as unknown as { __tlgClock: Clock }).__tlgClock;
}
this.tileObjs = drawTiles(this);
this.tileObjs.forEach((t, idx) => {
t.hit.on('pointerdown', () => this.handleTilePointerDown(idx));
});
this.lastFrameMs = this.clock.now();
// Re-render plants when tiles change in the store (Pitfall 6 mitigation:
// subscribe rather than read once in create()).
this.storeUnsubscribe = appStore.subscribe((state) => {
this.repaintPlants(state.tiles as Tile[]);
});
this.repaintPlants(appStore.getState().tiles as Tile[]);
eventBus.emit('scene-ready', this);
}
update(_time: number, _delta: number): void {
const now = this.clock.now();
const deltaMs = now - this.lastFrameMs;
this.lastFrameMs = now;
if (deltaMs > 0) this.accumulatorMs += deltaMs;
// Build current SimState snapshot from the store + drain commands.
const storeState = appStore.getState();
const commands = simAdapter.drainCommands();
// BLOCKER 3 fix — DO NOT seed lastTickAt with this.currentTick. lastTickAt
// is wall-clock ms owned by saveSync. The Garden scene's snapshot copies
// the value already in the store (which was hydrated from the save and
// hasn't been touched since). tickCount is the sim's own counter and is
// similarly read-through.
const simStateNow: SimState = {
garden: { tiles: storeState.tiles },
plants: [],
harvestedFragmentIds: storeState.harvestedFragmentIds,
lastTickAt: storeState.lastTickAt ?? 0, // read-through from store; sim never writes
tickCount: this.currentTick, // local sim counter — sim writes this field
unlockedPlantTypes: storeState.unlockedPlantTypes,
luraBeatProgress: storeState.luraBeatProgress,
offlineEvents: null,
settings: { musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8, persistenceToastShown: storeState.persistenceToastShown },
};
const result = drainTicks(
simStateNow,
this.accumulatorMs,
(s, _dtMs, _silent) => {
const next = simulateOneTick(s, this.currentTick + 1, commands);
this.currentTick++;
return next;
},
);
this.accumulatorMs = result.remainderMs;
// Apply tile state back to the store (other slices unchanged).
if (result.ticksApplied > 0) {
simAdapter.applyTilesAndUnlocks(
result.state.garden.tiles,
result.state.unlockedPlantTypes,
);
}
}
private handleTilePointerDown(idx: number): void {
const tiles = appStore.getState().tiles as Tile[];
const tile = tiles[idx];
if (!tile || !tile.plant) {
// Empty tile — emit event for the React seed picker.
const dom = tileCenterToDom(this, idx);
eventBus.emit('tile-clicked-coords', { tileIdx: idx, screenX: dom.x, screenY: dom.y });
return;
}
// Plan 02-03 wires harvest/compost on plant click.
}
private repaintPlants(tiles: Tile[]): void {
for (let idx = 0; idx < tiles.length; idx++) {
const tile = tiles[idx];
const stage = tile?.plant ? tileGrowthStage(tile, this.currentTick) : null;
const existing = this.plantObjs.get(idx);
if (!stage || !tile?.plant) {
if (existing) {
destroyPlant(existing);
this.plantObjs.delete(idx);
this.readyTweens.get(idx)?.stop();
this.readyTweens.delete(idx);
}
continue;
}
// Repaint if missing or stage changed.
if (!existing || existing.stage !== stage) {
if (existing) destroyPlant(existing);
const next = drawPlant(this, idx, tile, stage);
if (next) {
this.plantObjs.set(idx, next);
if (stage === 'ready') {
this.readyTweens.get(idx)?.stop();
this.readyTweens.set(idx, applyReadyPulse(this, next.shape));
}
}
}
}
}
destroy(): void {
this.storeUnsubscribe?.();
this.readyTweens.forEach((t) => t.stop());
this.readyTweens.clear();
this.plantObjs.forEach((p) => destroyPlant(p));
this.plantObjs.clear();
}
}
Step 8 — Update src/game/main.ts:
import * as Phaser from 'phaser';
import { Boot } from './scenes/Boot.ts';
import { Garden } from './scenes/Garden.ts';
const config: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO,
width: 1024,
height: 768,
parent: 'game-container',
backgroundColor: '#1a1a1a',
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
scene: [Boot, Garden],
};
const StartGame = (parent: string): Phaser.Game => {
return new Phaser.Game({ ...config, parent });
};
export default StartGame;
Step 9 — Update src/game/scenes/Boot.ts:
import * as Phaser from 'phaser';
/**
* Phase 2: Boot scene transitions to Garden once Phaser is up.
* No assets to load in Phase 2 (D-26 = Phaser primitives only).
*/
export class Boot extends Phaser.Scene {
constructor() {
super('Boot');
}
preload(): void {}
create(): void {
this.scene.start('Garden');
}
}
Manual smoke (the executor SHOULD do this once during the task to verify Assumption A5): npm run dev, open http://localhost:5173, click the page once (so the in-progress Begin gate doesn't block; Task 3 of this plan will gate Begin properly). Confirm 16 outlined tiles render in a 4×4 grid centered on the canvas. Resize the browser to a non-fullscreen window — the tiles should remain inside the canvas (Phaser.Scale.FIT). The seed picker in Task 3 will use tileCenterToDom to position itself; verify visually then.
Commit: feat(02-02): render layer + Garden scene + scheduler integration. Run npm run lint && npm run build before committing (Vitest tests for render-tier are minimal — Phaser scenes need a real canvas; rely on the Playwright e2e in Plan 02-05 for behavioral coverage).
<acceptance_criteria>
- grep -q "scene: \[Boot, Garden\]" src/game/main.ts
- grep -q "this.scene.start('Garden')" src/game/scenes/Boot.ts
- grep -q "export class Garden extends Phaser.Scene" src/game/scenes/Garden.ts
- grep -q "drainTicks" src/game/scenes/Garden.ts (scheduler wired into update loop)
- grep -q "appStore.subscribe" src/game/scenes/Garden.ts (Pitfall 6 mitigation: subscribe, don't read-once)
- grep -q "tile-clicked-coords" src/game/scenes/Garden.ts (EventBus emission)
- grep -q "tileCenterToDom" src/render/garden/tile-coords.ts
- grep -L "Date.now" src/render/garden/tile-renderer.ts src/render/garden/plant-renderer.ts src/render/garden/ready-pulse.ts src/render/garden/tile-coords.ts (render is sim-adjacent — should not need wall clock; clock comes from scene)
- npm run lint exits 0
- npm run build exits 0
</acceptance_criteria>
npm run lint && npm run build
Garden scene wires scheduler + EventBus + store + render. 4×4 tile grid renders with hover state. Plants repaint reactively when store changes. Tile pointerdown emits coords for the React seed picker. main.ts/Boot.ts updated. Manual smoke confirms tiles visible at npm run dev.
Step 2 — Author content/seasons/01-soil/ui-strings.yaml (player-visible Phase 2 copy; tone matches the bible voice — see CLAUDE.md "Tone" + anti-fomo-doctrine.md):
# Player-visible Phase 2 UI copy. Externalized per CLAUDE.md.
# Reviewed against bible voice + anti-FOMO doctrine.
season: 1
begin:
title: "The Last Garden"
subtitle: "tend"
cta: "Begin"
seed_picker:
title: "Sow"
cancel: "Not yet"
post_harvest_beat:
- "The earth remembers."
- "Something stayed."
- "It rests where it grew."
journal:
empty_state: "Nothing yet. Plant something."
back: "Close"
settings:
title: "Settings"
export: "Save to a copy"
import: "Restore from a copy"
restore_snapshot: "Earlier garden"
persistence_denied_toast: "The garden may forget, if your browser asks it to."
# Plant display names — sourced here so the writer can adjust without
# touching src/sim/garden/plants.ts (which carries fallbackName for tests).
plants:
rosemary: "Rosemary"
yarrow: "Yarrow"
winter-rose: "Winter-rose"
(Copy is a starting draft; user reviews. Bible voice: short, specific, intermittent, sometimes funny, sometimes devastating.)
Step 3 — src/content/schemas/ui-strings.ts:
import { z } from 'zod';
export const UiStringsSchema = z.object({
season: z.number().int().min(0).max(7),
begin: z.object({
title: z.string().min(1),
subtitle: z.string().min(1),
cta: z.string().min(1),
}),
seed_picker: z.object({
title: z.string().min(1),
cancel: z.string().min(1),
}),
post_harvest_beat: z.array(z.string().min(1)).min(1),
journal: z.object({
empty_state: z.string().min(1),
back: z.string().min(1),
}),
settings: z.object({
title: z.string().min(1),
export: z.string().min(1),
import: z.string().min(1),
restore_snapshot: z.string().min(1),
persistence_denied_toast: z.string().min(1),
}),
plants: z.record(z.string(), z.string().min(1)),
});
export type UiStrings = z.infer<typeof UiStringsSchema>;
Step 4 — Update src/content/schemas/index.ts:
export { FragmentSchema, type Fragment } from './fragment.ts';
export { SeasonContentSchema, type SeasonContent } from './season.ts';
export { UiStringsSchema, type UiStrings } from './ui-strings.ts';
Step 5 — Extend src/content/loader.ts with PIPE-02 lazy split for season fragments AND a synchronous load for ui-strings.yaml.
Rules:
- Keep the existing
yamlFilesandmdFilesglobs working (do not break Phase 1'sloader.test.ts). - Add a NEW eager glob for
ui-strings.yamlthat loads synchronously at module-eval (the Begin screen reads it on first paint — no time to await). - Add a NEW lazy glob
loadSeasonFragments(seasonId)for PIPE-02. The eagerfragmentsexport stays for now (Plan 02-03 may switch the consuming code to lazy).
// (top of file — keep existing imports; add UiStringsSchema)
import { SeasonContentSchema, FragmentSchema, UiStringsSchema, type Fragment, type UiStrings } from './schemas/index.ts';
// (existing yamlFiles, mdFiles, loadYamlFragments, loadMdFragments stay UNCHANGED — Plan 02-03 may switch later)
/**
* UI strings for the active Season. Loaded eagerly so first paint can
* reference any string without await. Per CLAUDE.md externalized-strings rule.
*/
const uiStringFiles = import.meta.glob('/content/seasons/*/ui-strings.yaml', {
eager: true,
query: '?raw',
import: 'default',
}) as Record<string, string>;
function loadUiStrings(): Record<number, UiStrings> {
const result: Record<number, UiStrings> = {};
for (const [path, raw] of Object.entries(uiStringFiles)) {
const data = parseYAML(raw);
const parsed = UiStringsSchema.safeParse(data);
if (!parsed.success) {
throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
}
result[parsed.data.season] = parsed.data;
}
return result;
}
export const uiStrings: Record<number, UiStrings> = loadUiStrings();
/**
* PIPE-02 — per-Season lazy chunk. Phase 2 has only Season 1; the wiring
* is here so Phase 4 (Season 2) inherits without rework. RESEARCH Pattern 8.
*/
const lazyYamlFragments = import.meta.glob('/content/seasons/*/fragments.yaml', {
query: '?raw',
import: 'default',
});
const lazyMdFragments = import.meta.glob('/content/seasons/*/fragments/*.md', {
query: '?raw',
import: 'default',
});
function pad2(n: number): string { return n.toString().padStart(2, '0'); }
export async function loadSeasonFragments(seasonId: number): Promise<Fragment[]> {
const yamlMatch = Object.entries(lazyYamlFragments).filter(([p]) => p.includes(`/${pad2(seasonId)}-`));
const mdMatch = Object.entries(lazyMdFragments).filter(([p]) => p.includes(`/${pad2(seasonId)}-`));
const yamlOut: Fragment[] = [];
for (const [path, loader] of yamlMatch) {
const raw = (await loader()) as string;
const parsed = SeasonContentSchema.safeParse(parseYAML(raw));
if (!parsed.success) throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
yamlOut.push(...parsed.data.fragments);
}
const mdOut: Fragment[] = [];
for (const [path, loader] of mdMatch) {
const raw = (await loader()) as string;
const { data, content } = grayMatter(raw);
const merged = { ...data, body: content.trim() };
const parsed = FragmentSchema.safeParse(merged);
if (!parsed.success) throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
mdOut.push(parsed.data);
}
return [...yamlOut, ...mdOut];
}
Step 6 — Update src/content/index.ts:
export { fragments, loadFragmentsFromGlob, loadSeasonFragments, uiStrings } from './loader.ts';
export { FragmentSchema, type Fragment, SeasonContentSchema, type SeasonContent, UiStringsSchema, type UiStrings } from './schemas/index.ts';
Step 7 — Delete content/seasons/00-demo/fragments.yaml per CONTEXT canonical_refs ("Phase 2 removes this file when Season 1 is authored"). Replace with a placeholder Season-1 fragments.yaml so the existing fragment loader still works:
# content/seasons/01-soil/fragments.yaml — Phase 2 placeholder. Plan 02-03
# replaces with ≥10 real Season-1 fragments authored in voice. The single
# placeholder fragment here keeps the eager loader green during Plan 02-02
# (Plan 02-03 expands).
fragments:
- id: season1.soil.placeholder
season: 1
body: "(placeholder — Plan 02-03 ships authored fragments)"
(Plan 02-03 owns the real Season-1 content authoring. Plan 02-02 ships the structural placeholder so the eager loader sees ≥1 valid Season-1 fragment.)
Step 8 — src/ui/begin/use-audio-bootstrap.ts — copy RESEARCH Pattern 9 lines 949-987 verbatim:
let _ctx: AudioContext | null = null;
let _resumed = false;
/**
* Lazy-create + resume AudioContext (AEST-07 + RESEARCH Pattern 9).
* MUST be called synchronously inside a click handler (Pitfall 5: iOS
* Safari requires the context to be CREATED inside the gesture, not
* just resumed).
*/
export async function bootstrapAudioContext(): Promise<AudioContext | null> {
if (_resumed && _ctx) return _ctx;
if (!_ctx) {
try {
const Ctor = typeof AudioContext !== 'undefined'
? AudioContext
: (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctor) return null;
_ctx = new Ctor();
} catch {
return null;
}
}
try {
await _ctx.resume();
_resumed = true;
return _ctx;
} catch {
return null;
}
}
/**
* For returning players (D-22): no Begin screen, but the next click /
* touch / keypress must bootstrap audio.
*/
export function installFirstInteractionGestureHandler(): void {
const handler = () => {
void bootstrapAudioContext();
document.removeEventListener('click', handler);
document.removeEventListener('touchstart', handler);
document.removeEventListener('keydown', handler);
};
document.addEventListener('click', handler);
document.addEventListener('touchstart', handler);
document.addEventListener('keydown', handler);
}
/** Test-only: reset module-level state between tests. */
export function __resetAudioBootstrapForTest(): void {
_ctx = null;
_resumed = false;
}
Step 9 — src/ui/begin/BeginScreen.tsx:
import { useAppStore } from '../../store';
import { uiStrings } from '../../content';
import { bootstrapAudioContext } from './use-audio-bootstrap';
/**
* D-21 + AEST-07: tasteful typographic Begin screen. Phase 3 swaps in
* the painted gesture-gate without changing this file's behavior.
*
* D-22: shown on first run only — gated by session.beginGateDismissed.
*/
export function BeginScreen(): JSX.Element | null {
const dismissed = useAppStore((s) => s.beginGateDismissed);
const dismissBeginGate = useAppStore((s) => s.dismissBeginGate);
if (dismissed) return null;
const strings = uiStrings[1]?.begin;
if (!strings) return null;
const onBegin = () => {
void bootstrapAudioContext(); // synchronous-inside-click; MUST not be inside useEffect (Pitfall 5)
dismissBeginGate();
};
return (
<div
style={{
position: 'fixed', inset: 0, zIndex: 100,
background: '#1a1a1a',
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
color: '#e8e0d0',
fontFamily: 'serif',
}}
role="dialog"
aria-label={strings.title}
>
<h1 style={{ fontSize: '3rem', margin: 0, fontWeight: 300 }}>{strings.title}</h1>
<p style={{ marginTop: '1rem', opacity: 0.7, letterSpacing: '0.2em' }}>{strings.subtitle}</p>
<button
onClick={onBegin}
style={{
marginTop: '4rem', padding: '0.6rem 2.4rem',
fontSize: '1.1rem', background: 'transparent',
color: '#e8e0d0', border: '1px solid #e8e0d0',
cursor: 'pointer', fontFamily: 'serif',
}}
>
{strings.cta}
</button>
</div>
);
}
Step 10 — src/ui/begin/BeginScreen.test.tsx — Vitest + happy-dom + @testing-library/react:
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { BeginScreen } from './BeginScreen';
import { appStore } from '../../store';
import { __resetAudioBootstrapForTest } from './use-audio-bootstrap';
describe('BeginScreen (AEST-07, D-21, D-22)', () => {
beforeEach(() => {
appStore.setState({ beginGateDismissed: false });
__resetAudioBootstrapForTest();
});
it('renders the title and Begin CTA when not dismissed', () => {
render(<BeginScreen />);
expect(screen.getByText('The Last Garden')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Begin' })).toBeTruthy();
});
it('renders nothing when beginGateDismissed=true (D-22 returning-player skip)', () => {
appStore.setState({ beginGateDismissed: true });
const { container } = render(<BeginScreen />);
expect(container.firstChild).toBeNull();
});
it('dismisses the gate and triggers audio bootstrap on click', async () => {
// happy-dom does not implement AudioContext; spy on the module-level
// function via dynamic import of use-audio-bootstrap to assert the call.
const audio = await import('./use-audio-bootstrap');
const spy = vi.spyOn(audio, 'bootstrapAudioContext').mockResolvedValue(null);
render(<BeginScreen />);
fireEvent.click(screen.getByRole('button', { name: 'Begin' }));
expect(spy).toHaveBeenCalledTimes(1);
expect(appStore.getState().beginGateDismissed).toBe(true);
spy.mockRestore();
});
});
Step 11 — src/ui/begin/index.ts — barrel:
export { BeginScreen } from './BeginScreen';
export { bootstrapAudioContext, installFirstInteractionGestureHandler } from './use-audio-bootstrap';
Step 12 — src/ui/garden/SeedPicker.tsx:
import { useEffect, useState } from 'react';
import { eventBus } from '../../game/event-bus';
import { useAppStore } from '../../store';
import { uiStrings } from '../../content';
import { PLANT_TYPES } from '../../sim/garden';
import type { PlantTypeId } from '../../sim/garden/types';
interface PickerState {
visible: boolean;
tileIdx: number;
x: number;
y: number;
}
/**
* D-02 — inline DOM popover positioned over the Phaser canvas.
* Listens for `tile-clicked-coords` from the Garden scene; mounts itself
* absolutely-positioned at those screen coords. Click outside dismisses.
*/
export function SeedPicker(): JSX.Element | null {
const [picker, setPicker] = useState<PickerState>({ visible: false, tileIdx: -1, x: 0, y: 0 });
const unlocked = useAppStore((s) => s.unlockedPlantTypes);
const enqueueCommand = useAppStore((s) => s.enqueueCommand);
const strings = uiStrings[1]?.seed_picker;
const plantStrings = uiStrings[1]?.plants ?? {};
useEffect(() => {
const onCoords = (payload: { tileIdx: number; screenX: number; screenY: number }) => {
setPicker({ visible: true, tileIdx: payload.tileIdx, x: payload.screenX, y: payload.screenY });
};
eventBus.on('tile-clicked-coords', onCoords);
return () => { eventBus.off('tile-clicked-coords', onCoords); };
}, []);
useEffect(() => {
if (!picker.visible) return;
// Defer so the click that opened the picker doesn't dismiss it.
const t = setTimeout(() => {
const onClick = () => setPicker((p) => ({ ...p, visible: false }));
document.addEventListener('click', onClick, { once: true });
return () => document.removeEventListener('click', onClick);
}, 0);
return () => clearTimeout(t);
}, [picker.visible]);
if (!picker.visible || !strings) return null;
const onSelect = (plantTypeId: PlantTypeId) => {
enqueueCommand({ kind: 'plantSeed', tileIdx: picker.tileIdx, plantTypeId });
setPicker((p) => ({ ...p, visible: false }));
};
// Translate screen coords to picker top-left (centered above tile).
const left = picker.x - 80;
const top = picker.y - 120;
return (
<div
data-testid="seed-picker"
onClick={(e) => e.stopPropagation()}
style={{
position: 'fixed', left, top, zIndex: 50,
background: '#2a2a2e', color: '#e8e0d0',
padding: '0.6rem 0.8rem', borderRadius: 4,
boxShadow: '0 6px 18px rgba(0,0,0,0.4)',
fontFamily: 'serif',
minWidth: 160,
}}
>
<div style={{ fontSize: '0.85rem', marginBottom: '0.5rem', opacity: 0.7 }}>{strings.title}</div>
{unlocked.length === 0 && (
<div style={{ fontSize: '0.85rem', fontStyle: 'italic', opacity: 0.6 }}>—</div>
)}
{unlocked.map((id) => {
const type = PLANT_TYPES[id as PlantTypeId];
if (!type) return null;
const display = plantStrings[id] ?? type.fallbackName;
return (
<button
key={id}
onClick={() => onSelect(id as PlantTypeId)}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '0.4rem 0.6rem', margin: '0.1rem 0',
background: 'transparent', color: '#e8e0d0',
border: '1px solid transparent', cursor: 'pointer',
fontFamily: 'serif',
}}
>
{display}
</button>
);
})}
</div>
);
}
Step 13 — src/ui/garden/SeedPicker.test.tsx — Vitest + @testing-library/react:
- Initial render returns null (not visible).
- Emitting
tile-clicked-coordsviaeventBus.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'}intopendingCommands(verify viaappStore.getState().pendingCommands). - After button click, the picker dismisses (visibility=false, returns null).
Step 14 — src/ui/garden/index.ts + src/ui/index.ts:
// src/ui/garden/index.ts
export { SeedPicker } from './SeedPicker';
// src/ui/index.ts
export * from './begin';
export * from './garden';
Step 15 — Update src/App.tsx to mount overlays as siblings:
import { useRef } from 'react';
import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx';
import { BeginScreen } from './ui/begin';
import { SeedPicker } from './ui/garden';
function App() {
const phaserRef = useRef<IRefPhaserGame | null>(null);
return (
<div id="app">
<PhaserGame ref={phaserRef} />
<BeginScreen />
<SeedPicker />
{/* Plan 02-03 mounts: <Journal />, <FragmentRevealModal /> */}
{/* Plan 02-04 mounts: <LuraDialogue /> */}
{/* Plan 02-05 mounts: <Letter />, <Settings />, <PersistenceToast /> */}
</div>
);
}
export default App;
Step 16 — Update src/PhaserGame.tsx to:
- Initialize the SimState in the store (set
unlockedPlantTypes=['rosemary']for first run; later plans read from save). - Install the first-interaction gesture handler when no Begin screen will show (i.e., when a save exists; for Phase 2 Wave 1, simplification: install always; the handler is a one-shot).
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } from 'react';
import StartGame from './game/main.ts';
import type * as Phaser from 'phaser';
import { eventBus } from './game/event-bus';
import { appStore } from './store';
import { installFirstInteractionGestureHandler } from './ui/begin';
export interface IRefPhaserGame {
game: Phaser.Game | null;
scene: Phaser.Scene | null;
}
interface IProps {
currentActiveScene?: (sceneInstance: Phaser.Scene) => void;
}
export const PhaserGame = forwardRef<IRefPhaserGame, IProps>(function PhaserGame(props, ref) {
const game = useRef<Phaser.Game | null>(null);
const sceneRef = useRef<Phaser.Scene | null>(null);
useLayoutEffect(() => {
if (game.current === null) {
// Bootstrap initial state (Plan 02-05 will replace with save-load path).
const initial = appStore.getState();
if (initial.unlockedPlantTypes.length === 0) {
appStore.setState({ unlockedPlantTypes: ['rosemary'] });
}
game.current = StartGame('game-container');
if (typeof ref === 'function') {
ref({ game: game.current, scene: null });
} else if (ref) {
ref.current = { game: game.current, scene: null };
}
}
return () => {
if (game.current) {
game.current.destroy(true);
game.current = null;
}
};
}, [ref]);
useEffect(() => {
const onSceneReady = (scene: Phaser.Scene) => {
sceneRef.current = scene;
props.currentActiveScene?.(scene);
};
eventBus.on('scene-ready', onSceneReady);
// Install gesture handler unconditionally — it's a one-shot that bootstraps audio
// on first interaction whether the Begin screen handled it or not (D-22 fallback).
installFirstInteractionGestureHandler();
return () => { eventBus.off('scene-ready', onSceneReady); };
}, [props]);
useImperativeHandle(ref, () => ({
game: game.current,
scene: sceneRef.current,
}));
return <div id="game-container" />;
});
(Plan 02-05 wires the real save-lifecycle hook + clock-selection logic here.)
Commit: feat(02-02): begin screen + seed picker + ui-strings + lazy content split. Run npm run ci before committing.
Manual smoke test: npm run dev, visit http://localhost:5173. Should see Begin screen → click Begin → garden tiles visible → click empty tile → seed picker appears positioned over the tile → click "Rosemary" → primitive sprout appears in the tile. Wait ~2 minutes (use a Vitest-style FakeClock injection if desired, or wait for real-time test). Plant transitions sprout → mature → ready with the alpha pulse. Confirm visually.
<acceptance_criteria>
- grep -q "title: \"The Last Garden\"" content/seasons/01-soil/ui-strings.yaml
- grep -q "UiStringsSchema" src/content/schemas/ui-strings.ts
- grep -q "loadSeasonFragments" src/content/loader.ts (PIPE-02 lazy split wired)
- grep -q "uiStrings" src/content/index.ts (barrel re-export)
- grep -q "bootstrapAudioContext" src/ui/begin/use-audio-bootstrap.ts
- grep -q "installFirstInteractionGestureHandler" src/ui/begin/use-audio-bootstrap.ts
- grep -q "void bootstrapAudioContext()" src/ui/begin/BeginScreen.tsx (sync-inside-click — Pitfall 5 mitigation)
- grep -q "tile-clicked-coords" src/ui/garden/SeedPicker.tsx
- grep -q "<BeginScreen />" src/App.tsx and grep -q "<SeedPicker />" src/App.tsx
- grep -q "installFirstInteractionGestureHandler" src/PhaserGame.tsx
- ! test -f content/seasons/00-demo/fragments.yaml (the demo fragment was deleted)
- test -f content/seasons/01-soil/fragments.yaml (Phase 2 placeholder fragment file exists)
- No player-visible English strings hardcoded outside /content/: grep -E "(Begin|Sow|Rosemary|Yarrow|Winter-rose)" src/ui/begin/BeginScreen.tsx src/ui/garden/SeedPicker.tsx | grep -v "uiStrings\|fallbackName\|aria-label\|comment" | wc -l is 0 (the strings come from uiStrings, not literals)
- npx vitest run src/ui/begin/ src/ui/garden/ exits 0 with all tests green
- npm run ci exits 0
</acceptance_criteria>
npm run lint && npx vitest run src/ui/begin/ src/ui/garden/ src/content/ && npm run ci
BeginScreen + SeedPicker land. Audio bootstrap fires synchronously inside click handlers. UI strings externalized to /content/seasons/01-soil/ui-strings.yaml; Zod-validated. PIPE-02 lazy-fragment glob added (Plan 02-03 will populate Season 1). 00-demo deleted; 01-soil placeholder fragments.yaml exists. App.tsx mounts overlays. PhaserGame.tsx wires EventBus + initial unlocks + gesture handler. npm run ci green; manual smoke test confirms full Begin → Plant → Grow vertical slice runs end-to-end.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Phaser canvas ↔ React DOM overlay | Tile pointerdown → EventBus → React popover → store command. EventBus payload is internal (no user-supplied data). |
| AudioContext lazy-create boundary | Created synchronously inside click handler; defends iOS Safari Pitfall 5. |
| Content schema boundary | ui-strings.yaml is authored content (repo-controlled); Zod-validated at module-eval. No user-supplied content path. |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-02-02-01 | Tampering | URL devtime flag exposed in production | mitigate | Garden scene reads window.__tlgClock if present; Plan 02-05 production-guards via import.meta.env.PROD check at boot. Phase 2 ships the hook; Phase 8 verifies no leakage. |
| T-02-02-02 | Tampering | User pastes malicious uiStrings via Base64 import | mitigate | UiStringsSchema validates structure; React renders strings as text (no dangerouslySetInnerHTML). Even if tampered, no XSS surface. |
| T-02-02-03 | Information disclosure | AudioContext failure leaking error details | accept | bootstrapAudioContext catches all errors and returns null; no error message surfaces to UI. |
| T-02-02-04 | Denial-of-service | Seed picker spawn-on-every-pixel-click | accept | Click on empty tile only; one popover at a time; click-outside dismisses. No spam vector. |
| T-02-02-05 | Tampering | Plant a seed for a locked plant type | mitigate | plantSeed command validates against state.unlockedPlantTypes — silent no-op if not unlocked. |
No high severity threats. Phase 2 vertical-slice surface is small.
</threat_model>
After all 3 tasks committed:
- Linter:
npm run lintexits 0 (sim-purity rule from Plan 02-01 catches Date.now leaks in src/sim/garden/). - Tests:
npx vitest runexits 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. - Build:
npm run buildexits 0. - Full CI:
npm run ciexits 0. - Schema lock:
grep -q "loadSeasonFragments" src/content/loader.tsconfirms PIPE-02 lazy wiring landed even though Plan 02-03 will populate the actual content. - Manual smoke (executor performs once during Task 3):
npm run dev, visithttp://localhost:5173. Verify Begin → Plant → Grow loop works on Phaser primitives; tile hover state visible; seed picker positions over the clicked tile; primitive sprout appears; (optionally) wait for stage transitions.
<success_criteria>
Plan 02-02 is complete when:
- All 3 tasks committed.
npm run ciexits 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:
bootstrapAudioContextis called inside the click handler (Pitfall 5 + 9 mitigations in place). - UX-01 satisfied: Begin screen has no clutter; tile hover state subtle; no Phase-3 polish creep.
- CORE-02 satisfied: scheduler drives sim ticks; src/sim/garden/ has zero
Date.now()calls (ESLint rule confirms). - All player-visible strings in
/content/seasons/01-soil/ui-strings.yaml. - PIPE-02 lazy split wired (loadSeasonFragments exists; Plan 02-03 populates real content).
- Plan 02-03 (Harvest + Journal) and Plan 02-04 (Lura) can build on the sim/garden + render/garden + ui/garden surfaces shipped here.
</success_criteria>
Create `.planning/phases/02-season-1-vertical-slice-soil/02-02-begin-plant-grow-SUMMARY.md` per template. Document: - Per-plant duration values shipped (rosemary 600 / yarrow 900 / winter-rose 1500 ticks). - Whether `tileCenterToDom` worked under `Phaser.Scale.FIT` without modification (RESEARCH Assumption A5 — verified). - Manual smoke test confirmation (date / browser / observed behavior). - Any deviations from locked content (e.g., user copy edits to ui-strings.yaml during review).