63d2d8d5f7
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>
1644 lines
68 KiB
Markdown
1644 lines
68 KiB
Markdown
---
|
||
phase: 02
|
||
plan: 02
|
||
type: execute
|
||
wave: 1
|
||
depends_on: [02-01]
|
||
files_modified:
|
||
- src/sim/garden/types.ts
|
||
- src/sim/garden/plants.ts
|
||
- src/sim/garden/growth.ts
|
||
- src/sim/garden/growth.test.ts
|
||
- src/sim/garden/commands.ts
|
||
- src/sim/garden/commands.test.ts
|
||
- src/sim/garden/index.ts
|
||
- src/render/garden/tile-renderer.ts
|
||
- src/render/garden/plant-renderer.ts
|
||
- src/render/garden/ready-pulse.ts
|
||
- src/render/garden/tile-coords.ts
|
||
- src/render/garden/index.ts
|
||
- src/render/index.ts
|
||
- src/ui/begin/BeginScreen.tsx
|
||
- src/ui/begin/BeginScreen.test.tsx
|
||
- src/ui/begin/use-audio-bootstrap.ts
|
||
- src/ui/begin/index.ts
|
||
- src/ui/garden/SeedPicker.tsx
|
||
- src/ui/garden/SeedPicker.test.tsx
|
||
- src/ui/garden/index.ts
|
||
- src/ui/index.ts
|
||
- src/game/scenes/Garden.ts
|
||
- src/game/main.ts
|
||
- src/game/scenes/Boot.ts
|
||
- src/PhaserGame.tsx
|
||
- src/App.tsx
|
||
- content/seasons/01-soil/ui-strings.yaml
|
||
- src/content/schemas/ui-strings.ts
|
||
- src/content/schemas/index.ts
|
||
- src/content/loader.ts
|
||
- src/content/index.ts
|
||
- content/seasons/00-demo/fragments.yaml
|
||
autonomous: true
|
||
requirements: [GARD-01, GARD-02, AEST-07, UX-01, CORE-02]
|
||
tags: [vertical-slice, garden, begin-screen, plant, grow, audio-bootstrap, mvp]
|
||
|
||
must_haves:
|
||
truths:
|
||
- "Player loads the page with no save → sees a typographic 'Tend the garden / Begin' screen with no other UI clutter; tap calls audioContext.resume() and dismisses the screen (AEST-07, D-21, UX-01)"
|
||
- "Player loads with an existing save → Begin screen is skipped; AudioContext bootstraps on first interaction via the click+touchstart+keydown gesture handler (D-22)"
|
||
- "Player clicks an empty tile → seed picker DOM popover appears positioned at the tile's screen coords; popover lists currently-unlocked plant types; click outside dismisses (D-02)"
|
||
- "Player selects a plant type → command enqueues into the store; next sim tick applies plantSeed; tile state moves from empty → sprout (GARD-01)"
|
||
- "Plant advances sprout → mature → ready over its growth duration (per-plant duration in 2–5min band, D-08/D-09); state machine is a pure function of (plantedAtTick, currentTick, growthDurationTicks)"
|
||
- "Empty tile renders as faint outline + subtle hover state (D-06); plant primitives render distinct shapes per stage tinted by plant type (D-26); ready tiles pulse via alpha cycle (D-27)"
|
||
- "Sim is pure — no Date.now() in src/sim/garden/; all time threaded as injected ticks (CORE-02)"
|
||
- "Phaser Garden scene's update() loop reads from the store, calls scheduler.drainTicks with the simulate function, writes results back via simAdapter — no React re-renders trigger render-tier draws"
|
||
- "First-interaction gesture handler installed on returning-player loads succeeds at audioContext.resume() on the first click/touchstart/keydown (Pitfall 5 mitigation)"
|
||
- "All player-visible Begin-screen and seed-picker copy lives in /content/seasons/01-soil/ui-strings.yaml; nothing player-visible hardcoded in TS (CLAUDE.md externalized-strings rule)"
|
||
- "npm run ci is green; sim-purity ESLint rule (Plan 02-01 Task 3) catches any Date.now() leak"
|
||
artifacts:
|
||
- path: src/sim/garden/types.ts
|
||
provides: "Tile, PlantInstance, PlantType, PlantTypeId, GrowthStage interfaces; tileIdx(row,col) and tileCoords(idx) helpers (Pitfall 2: row*4+col canonical encoding)"
|
||
exports: ["Tile", "PlantInstance", "PlantType", "PlantTypeId", "GrowthStage", "tileIdx", "tileCoords"]
|
||
- path: src/sim/garden/plants.ts
|
||
provides: "Static PlantType table — 3 Season-1 plants with distinct durations + tonal-identity slugs"
|
||
exports: ["PLANT_TYPES", "getPlantType"]
|
||
- path: src/sim/garden/growth.ts
|
||
provides: "advanceGrowth(plant, currentTick) → GrowthStage — pure function of plantedAtTick + currentTick + plantType.durationTicks"
|
||
exports: ["advanceGrowth", "GROWTH_THRESHOLDS"]
|
||
- path: src/sim/garden/commands.ts
|
||
provides: "plantSeed(state, args), simulateOneTick(state, currentTick) — pure command applications. Phase 2 wires plantSeed; harvest/compost added in Plan 02-03"
|
||
exports: ["plantSeed", "simulateOneTick"]
|
||
- path: src/render/garden/tile-renderer.ts
|
||
provides: "drawTiles(scene, tiles) — Phaser primitive draw of 16-tile grid with hover state (D-06)"
|
||
exports: ["drawTiles"]
|
||
- path: src/render/garden/plant-renderer.ts
|
||
provides: "drawPlant(scene, tileIdx, plant) — primitive shapes per growth stage tinted by plantType (D-26)"
|
||
exports: ["drawPlant"]
|
||
- path: src/render/garden/ready-pulse.ts
|
||
provides: "applyReadyPulse(scene, tileIdx) — alpha tween for ready cue (D-27)"
|
||
exports: ["applyReadyPulse"]
|
||
- path: src/render/garden/tile-coords.ts
|
||
provides: "tileToScreenCoords(scene, tileIdx) — helper for seed picker positioning (RESEARCH Pattern 4)"
|
||
exports: ["tileToScreenCoords", "GRID_LAYOUT"]
|
||
- path: src/ui/begin/BeginScreen.tsx
|
||
provides: "Tasteful typographic Begin screen — title + Begin button (D-21); calls bootstrapAudioContext on tap, dismisses via session slice (D-22)"
|
||
exports: ["BeginScreen"]
|
||
- path: src/ui/begin/use-audio-bootstrap.ts
|
||
provides: "bootstrapAudioContext() — lazy AudioContext creation + resume; installFirstInteractionGestureHandler() for returning players (RESEARCH Pattern 9)"
|
||
exports: ["bootstrapAudioContext", "installFirstInteractionGestureHandler"]
|
||
- path: src/ui/garden/SeedPicker.tsx
|
||
provides: "Inline DOM popover positioned over Phaser canvas; lists unlocked plant types; commits via store.enqueueCommand (D-02)"
|
||
exports: ["SeedPicker"]
|
||
- path: src/game/scenes/Garden.ts
|
||
provides: "Phaser Garden scene — 4×4 tile grid, pointerdown handlers, scheduler integration, EventBus emissions"
|
||
exports: ["Garden"]
|
||
- path: content/seasons/01-soil/ui-strings.yaml
|
||
provides: "Player-visible Phase 2 UI copy (Begin screen, seed picker, post-harvest beat) — externalized per CLAUDE.md"
|
||
- path: src/content/schemas/ui-strings.ts
|
||
provides: "UiStringsSchema (Zod) for ui-strings.yaml validation"
|
||
exports: ["UiStringsSchema", "UiStrings"]
|
||
key_links:
|
||
- from: src/ui/begin/BeginScreen.tsx
|
||
to: src/ui/begin/use-audio-bootstrap.ts
|
||
via: "onClick handler calls bootstrapAudioContext() synchronously inside the click event"
|
||
pattern: "bootstrapAudioContext"
|
||
- from: src/game/scenes/Garden.ts
|
||
to: src/sim/garden/commands.ts
|
||
via: "scheduler drains store commands and applies them via simulateOneTick(state, tick)"
|
||
pattern: "simulateOneTick"
|
||
- from: src/ui/garden/SeedPicker.tsx
|
||
to: src/store/index.ts
|
||
via: "useAppStore + enqueueCommand({kind: 'plantSeed', tileIdx, plantTypeId})"
|
||
pattern: "enqueueCommand"
|
||
- from: src/game/scenes/Garden.ts
|
||
to: src/game/event-bus.ts
|
||
via: "tile pointerdown emits 'tile-clicked-coords' with {tileIdx, screenX, screenY}"
|
||
pattern: "tile-clicked-coords"
|
||
- from: src/render/garden/plant-renderer.ts
|
||
to: src/sim/garden/types.ts
|
||
via: "imports PlantType / GrowthStage types only — no behavioral coupling (render reads from store, not from sim modules)"
|
||
pattern: "import type"
|
||
---
|
||
|
||
<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 2–5min duration (D-08, D-09) tinted to the plant type (D-26) with a ready-state pulse (D-27).
|
||
|
||
Returning players (save exists) skip the Begin screen entirely (D-22); a first-interaction gesture handler bootstraps audio on the first click/touchstart/keydown.
|
||
|
||
Purpose: This is the load-bearing vertical slice — the first feature commit ever, on top of the foundations Plan 02-01 lands. It validates that the Phaser ↔ React Zustand bridge works in real code (RESEARCH Pattern 3), that the ESLint sim-purity rule holds when actual sim code is written, that the inline seed picker DOM-popover-over-canvas pattern works under `Phaser.Scale.FIT` (RESEARCH Assumption A5 — verify here, MEDIUM-risk), and that all player-visible strings live in `/content/`.
|
||
|
||
Output: A running game where Begin → Plant → Grow is fully operational on placeholder Phaser primitives. Plan 02-03 lands Harvest → Journal on top of this. Plan 02-05's Playwright e2e exercises the full loop end-to-end.
|
||
</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 2–5min band (D-09). At TICK_MS=200, 600 ticks = 2min, 900 ticks = 3min, 1500 ticks = 5min.
|
||
|
||
```typescript
|
||
import type { PlantType, PlantTypeId } from './types';
|
||
|
||
/**
|
||
* Three Season-1 plants with tonal identity per the bible's
|
||
* "real species, slightly wrong" rule (CLAUDE.md "Tone").
|
||
*
|
||
* Names are placeholder pending user review (RESEARCH Open Question 1).
|
||
* Tonal register: rosemary (warm) / yarrow (contemplative) / winter-rose (heavy).
|
||
*
|
||
* Per D-08/D-09: durations vary within a 2–5min active-play band.
|
||
* rosemary → 600 ticks ≈ 2 min (the warm short one)
|
||
* yarrow → 900 ticks ≈ 3 min (medium contemplative)
|
||
* winter-rose → 1500 ticks ≈ 5 min (the heavy slow one)
|
||
*
|
||
* Tints are placeholders — Phase 3 swaps watercolor textures over these.
|
||
*/
|
||
export const PLANT_TYPES: Readonly<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>
|