+ );
+}
+```
+
+**Step 13 — `src/ui/garden/SeedPicker.test.tsx`** — Vitest + @testing-library/react:
+
+- Initial render returns null (not visible).
+- Emitting `tile-clicked-coords` via `eventBus.emit('tile-clicked-coords', {tileIdx: 0, screenX: 100, screenY: 100})` makes the picker visible.
+- With `unlockedPlantTypes=['rosemary']`, exactly one plant button renders ("Rosemary").
+- Clicking the button enqueues `{kind: 'plantSeed', tileIdx: 0, plantTypeId: 'rosemary'}` into `pendingCommands` (verify via `appStore.getState().pendingCommands`).
+- After button click, the picker dismisses (visibility=false, returns null).
+
+**Step 14 — `src/ui/garden/index.ts`** + `src/ui/index.ts`:
+```typescript
+// src/ui/garden/index.ts
+export { SeedPicker } from './SeedPicker';
+
+// src/ui/index.ts
+export * from './begin';
+export * from './garden';
+```
+
+**Step 15 — Update `src/App.tsx`** to mount overlays as siblings:
+
+```typescript
+import { useRef } from 'react';
+import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx';
+import { BeginScreen } from './ui/begin';
+import { SeedPicker } from './ui/garden';
+
+function App() {
+ const phaserRef = useRef(null);
+
+ return (
+
+
+
+
+ {/* Plan 02-03 mounts: , */}
+ {/* Plan 02-04 mounts: */}
+ {/* Plan 02-05 mounts: , , */}
+
+ );
+}
+
+export default App;
+```
+
+**Step 16 — Update `src/PhaserGame.tsx`** to:
+- Initialize the SimState in the store (set `unlockedPlantTypes=['rosemary']` for first run; later plans read from save).
+- Install the first-interaction gesture handler when no Begin screen will show (i.e., when a save exists; for Phase 2 Wave 1, simplification: install always; the handler is a one-shot).
+
+```typescript
+import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } from 'react';
+import StartGame from './game/main.ts';
+import type * as Phaser from 'phaser';
+import { eventBus } from './game/event-bus';
+import { appStore } from './store';
+import { installFirstInteractionGestureHandler } from './ui/begin';
+
+export interface IRefPhaserGame {
+ game: Phaser.Game | null;
+ scene: Phaser.Scene | null;
+}
+
+interface IProps {
+ currentActiveScene?: (sceneInstance: Phaser.Scene) => void;
+}
+
+export const PhaserGame = forwardRef(function PhaserGame(props, ref) {
+ const game = useRef(null);
+ const sceneRef = useRef(null);
+
+ useLayoutEffect(() => {
+ if (game.current === null) {
+ // Bootstrap initial state (Plan 02-05 will replace with save-load path).
+ const initial = appStore.getState();
+ if (initial.unlockedPlantTypes.length === 0) {
+ appStore.setState({ unlockedPlantTypes: ['rosemary'] });
+ }
+
+ game.current = StartGame('game-container');
+
+ if (typeof ref === 'function') {
+ ref({ game: game.current, scene: null });
+ } else if (ref) {
+ ref.current = { game: game.current, scene: null };
+ }
+ }
+ return () => {
+ if (game.current) {
+ game.current.destroy(true);
+ game.current = null;
+ }
+ };
+ }, [ref]);
+
+ useEffect(() => {
+ const onSceneReady = (scene: Phaser.Scene) => {
+ sceneRef.current = scene;
+ props.currentActiveScene?.(scene);
+ };
+ eventBus.on('scene-ready', onSceneReady);
+ // Install gesture handler unconditionally — it's a one-shot that bootstraps audio
+ // on first interaction whether the Begin screen handled it or not (D-22 fallback).
+ installFirstInteractionGestureHandler();
+ return () => { eventBus.off('scene-ready', onSceneReady); };
+ }, [props]);
+
+ useImperativeHandle(ref, () => ({
+ game: game.current,
+ scene: sceneRef.current,
+ }));
+
+ return ;
+});
+```
+
+(Plan 02-05 wires the real save-lifecycle hook + clock-selection logic here.)
+
+**Commit:** `feat(02-02): begin screen + seed picker + ui-strings + lazy content split`. Run `npm run ci` before committing.
+
+**Manual smoke test:** `npm run dev`, visit `http://localhost:5173`. Should see Begin screen → click Begin → garden tiles visible → click empty tile → seed picker appears positioned over the tile → click "Rosemary" → primitive sprout appears in the tile. Wait ~2 minutes (use a Vitest-style FakeClock injection if desired, or wait for real-time test). Plant transitions sprout → mature → ready with the alpha pulse. Confirm visually.
+
+
+ - `grep -q "title: \"The Last Garden\"" content/seasons/01-soil/ui-strings.yaml`
+ - `grep -q "UiStringsSchema" src/content/schemas/ui-strings.ts`
+ - `grep -q "loadSeasonFragments" src/content/loader.ts` (PIPE-02 lazy split wired)
+ - `grep -q "uiStrings" src/content/index.ts` (barrel re-export)
+ - `grep -q "bootstrapAudioContext" src/ui/begin/use-audio-bootstrap.ts`
+ - `grep -q "installFirstInteractionGestureHandler" src/ui/begin/use-audio-bootstrap.ts`
+ - `grep -q "void bootstrapAudioContext()" src/ui/begin/BeginScreen.tsx` (sync-inside-click — Pitfall 5 mitigation)
+ - `grep -q "tile-clicked-coords" src/ui/garden/SeedPicker.tsx`
+ - `grep -q "" src/App.tsx` and `grep -q "" src/App.tsx`
+ - `grep -q "installFirstInteractionGestureHandler" src/PhaserGame.tsx`
+ - `! test -f content/seasons/00-demo/fragments.yaml` (the demo fragment was deleted)
+ - `test -f content/seasons/01-soil/fragments.yaml` (Phase 2 placeholder fragment file exists)
+ - No player-visible English strings hardcoded outside `/content/`: `grep -E "(Begin|Sow|Rosemary|Yarrow|Winter-rose)" src/ui/begin/BeginScreen.tsx src/ui/garden/SeedPicker.tsx | grep -v "uiStrings\|fallbackName\|aria-label\|comment" | wc -l` is 0 (the strings come from uiStrings, not literals)
+ - `npx vitest run src/ui/begin/ src/ui/garden/` exits 0 with all tests green
+ - `npm run ci` exits 0
+
+
+ npm run lint && npx vitest run src/ui/begin/ src/ui/garden/ src/content/ && npm run ci
+
+
+ BeginScreen + SeedPicker land. Audio bootstrap fires synchronously inside click handlers. UI strings externalized to /content/seasons/01-soil/ui-strings.yaml; Zod-validated. PIPE-02 lazy-fragment glob added (Plan 02-03 will populate Season 1). 00-demo deleted; 01-soil placeholder fragments.yaml exists. App.tsx mounts overlays. PhaserGame.tsx wires EventBus + initial unlocks + gesture handler. `npm run ci` green; manual smoke test confirms full Begin → Plant → Grow vertical slice runs end-to-end.
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Phaser canvas ↔ React DOM overlay | Tile pointerdown → EventBus → React popover → store command. EventBus payload is internal (no user-supplied data). |
+| AudioContext lazy-create boundary | Created synchronously inside click handler; defends iOS Safari Pitfall 5. |
+| Content schema boundary | ui-strings.yaml is authored content (repo-controlled); Zod-validated at module-eval. No user-supplied content path. |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-02-02-01 | Tampering | URL devtime flag exposed in production | mitigate | Garden scene reads window.__tlgClock if present; Plan 02-05 production-guards via import.meta.env.PROD check at boot. Phase 2 ships the hook; Phase 8 verifies no leakage. |
+| T-02-02-02 | Tampering | User pastes malicious uiStrings via Base64 import | mitigate | UiStringsSchema validates structure; React renders strings as text (no dangerouslySetInnerHTML). Even if tampered, no XSS surface. |
+| T-02-02-03 | Information disclosure | AudioContext failure leaking error details | accept | bootstrapAudioContext catches all errors and returns null; no error message surfaces to UI. |
+| T-02-02-04 | Denial-of-service | Seed picker spawn-on-every-pixel-click | accept | Click on empty tile only; one popover at a time; click-outside dismisses. No spam vector. |
+| T-02-02-05 | Tampering | Plant a seed for a locked plant type | mitigate | plantSeed command validates against state.unlockedPlantTypes — silent no-op if not unlocked. |
+
+No `high` severity threats. Phase 2 vertical-slice surface is small.
+
+
+
+
+After all 3 tasks committed:
+
+1. **Linter:** `npm run lint` exits 0 (sim-purity rule from Plan 02-01 catches Date.now leaks in src/sim/garden/).
+2. **Tests:** `npx vitest run` exits 0; new test files: `src/sim/garden/growth.test.ts`, `src/sim/garden/commands.test.ts`, `src/ui/begin/BeginScreen.test.tsx`, `src/ui/garden/SeedPicker.test.tsx`. Combined Phase-1+Phase-2 test count grows to ~130.
+3. **Build:** `npm run build` exits 0.
+4. **Full CI:** `npm run ci` exits 0.
+5. **Schema lock:** `grep -q "loadSeasonFragments" src/content/loader.ts` confirms PIPE-02 lazy wiring landed even though Plan 02-03 will populate the actual content.
+6. **Manual smoke** (executor performs once during Task 3): `npm run dev`, visit `http://localhost:5173`. Verify Begin → Plant → Grow loop works on Phaser primitives; tile hover state visible; seed picker positions over the clicked tile; primitive sprout appears; (optionally) wait for stage transitions.
+
+
+
+
+
+Plan 02-02 is complete when:
+
+- [ ] All 3 tasks committed.
+- [ ] `npm run ci` exits 0.
+- [ ] First-run player flow works end-to-end: Begin screen → tap → audio resumes → garden visible → click empty tile → seed picker → tap Rosemary → primitive sprout appears → grows through stages over ~2 minutes → ready-state pulse visible.
+- [ ] D-21, D-22, D-26, D-27, D-01, D-02, D-06 all visibly satisfied in the dev build.
+- [ ] AEST-07 satisfied: `bootstrapAudioContext` is called inside the click handler (Pitfall 5 + 9 mitigations in place).
+- [ ] UX-01 satisfied: Begin screen has no clutter; tile hover state subtle; no Phase-3 polish creep.
+- [ ] CORE-02 satisfied: scheduler drives sim ticks; src/sim/garden/ has zero `Date.now()` calls (ESLint rule confirms).
+- [ ] All player-visible strings in `/content/seasons/01-soil/ui-strings.yaml`.
+- [ ] PIPE-02 lazy split wired (loadSeasonFragments exists; Plan 02-03 populates real content).
+- [ ] Plan 02-03 (Harvest + Journal) and Plan 02-04 (Lura) can build on the sim/garden + render/garden + ui/garden surfaces shipped here.
+
+
+
+
diff --git a/.planning/phases/02-season-1-vertical-slice-soil/02-03-harvest-journal-fragments-PLAN.md b/.planning/phases/02-season-1-vertical-slice-soil/02-03-harvest-journal-fragments-PLAN.md
new file mode 100644
index 0000000..0726bb2
--- /dev/null
+++ b/.planning/phases/02-season-1-vertical-slice-soil/02-03-harvest-journal-fragments-PLAN.md
@@ -0,0 +1,1278 @@
+---
+phase: 02
+plan: 03
+type: execute
+wave: 1
+depends_on: [02-01]
+files_modified:
+ - src/sim/memory/selector.ts
+ - src/sim/memory/selector.test.ts
+ - src/sim/memory/pool.ts
+ - src/sim/memory/index.ts
+ - src/sim/garden/commands.ts
+ - src/sim/garden/commands.test.ts
+ - src/ui/journal/Journal.tsx
+ - src/ui/journal/Journal.test.tsx
+ - src/ui/journal/FragmentRevealModal.tsx
+ - src/ui/journal/FragmentRevealModal.test.tsx
+ - src/ui/journal/journal-icon.tsx
+ - src/ui/journal/index.ts
+ - src/ui/index.ts
+ - src/App.tsx
+ - content/seasons/01-soil/fragments.yaml
+ - content/seasons/01-soil/fragments/lura-first-letter.md
+ - content/seasons/01-soil/fragments/winter-rose-night.md
+ - scripts/check-bundle-split.mjs
+ - scripts/check-bundle-split.test.mjs
+ - package.json
+autonomous: true
+requirements: [GARD-03, GARD-04, MEMR-01, MEMR-02, MEMR-03, MEMR-04, MEMR-05, MEMR-06, PIPE-02, UX-01]
+tags: [vertical-slice, harvest, journal, fragments, content-authoring, lazy-load, mvp]
+
+must_haves:
+ truths:
+ - "Player clicks a ready-stage tile → harvest command enqueues → next sim tick selects exactly one fragment from the gated pool, appends to harvestedFragmentIds, empties the tile (GARD-03, MEMR-01)"
+ - "Fragment selector is deterministic (same inputs → same fragment), respects Season + plant-type gating, and never duplicates a fragment within a playthrough until the gated pool is exhausted (MEMR-06)"
+ - "When the gated pool is exhausted, selector returns the documented sentinel fragment (e.g., 'season1.soil.gardener-knows-this-one-already') OR repeats the most-recently-harvested fragment (Pitfall 8). Behavior chosen + documented."
+ - "Player clicks an immature plant → compost command enqueues → tile empties → an Ink-authored single-line tonal acknowledgement plays (GARD-04, D-07, RESEARCH Open Question 2). Phase 2 ships acknowledgements as a small Ink file under /content/dialogue/season1/compost-acknowledgements.ink — Plan 02-04 owns ink runtime; Plan 02-03 ships the AUTHORED CONTENT and the placeholder text-snippet UX (with TODO comment) so Plan 02-04 can swap to Ink without reworking."
+ - "Newly harvested fragments in active play surface in a full-text reveal modal (D-25); dismissing files into the journal under their Season"
+ - "Journal icon is invisible until the first harvest, then persistent (D-23). Journal opens on icon click as a full-screen modal (D-24); fragments grouped by Season; text is selectable + copy-pasteable DOM (MEMR-05)"
+ - "Season 1 ships ≥10 authored fragments under /content/seasons/01-soil/ — enough to comfortably exceed the 8th-harvest Lura threshold + plant-type unlocks per RESEARCH Pitfall 8 + Assumption A8"
+ - "Plant-type unlock thresholds: yarrow unlocks at 3 harvests (rosemary-pool); winter-rose unlocks at 6 harvests (yarrow-pool exhausted or near-exhausted). Specific values are Claude's discretion within reason (D-05); document chosen values in SUMMARY.md"
+ - "Compost returns the tile to empty immediately (D-07); no resource refund (D-04 = infinite seeds, no cost-recovery)"
+ - "PIPE-02 lazy loader actually loads Season-1 fragments via loadSeasonFragments(1); structural assertion via scripts/check-bundle-split.mjs proves Vite emits a separate Season-1 chunk after `npm run build`"
+ - "All authored fragment IDs match the regex /^season1\\.[a-z0-9._-]+$/ (MEMR-03 stable string ID rule)"
+ - "Fragment text matches bible voice (CLAUDE.md Tone) — short, specific, intermittent, sometimes funny, sometimes devastating"
+ - "npm run ci is green; the new scripts/check-bundle-split.mjs runs as part of `ci` and exits 0"
+ artifacts:
+ - path: src/sim/memory/selector.ts
+ provides: "selectFragment(state, currentSeason, plantTypeId, allFragments) → Fragment | null — pure deterministic selector with gating + no-dup + exhaustion fallback (MEMR-06, RESEARCH Pitfall 8)"
+ exports: ["selectFragment", "EXHAUSTION_FALLBACK_ID"]
+ - path: src/sim/memory/pool.ts
+ provides: "filterPool(allFragments, season, plantTypeId, alreadyHarvestedIds) — pure filter helper"
+ exports: ["filterPool"]
+ - path: src/sim/garden/commands.ts
+ provides: "(extended) harvest(state, tileIdx, currentTick), compost(state, tileIdx, currentTick) — pure commands. simulateOneTick branches on harvest/compost"
+ exports: ["plantSeed", "harvest", "compost", "simulateOneTick", "tileGrowthStage"]
+ - path: src/ui/journal/Journal.tsx
+ provides: "Full-screen modal listing all harvested fragments grouped by Season; selectable DOM text per MEMR-05"
+ exports: ["Journal"]
+ - path: src/ui/journal/FragmentRevealModal.tsx
+ provides: "Active-play reveal modal (D-25) — surfaces just-harvested fragment in full text"
+ exports: ["FragmentRevealModal"]
+ - path: src/ui/journal/journal-icon.tsx
+ provides: "Corner icon button (D-23/D-29). Hidden pre-first-harvest; opens Journal modal on click"
+ exports: ["JournalIcon"]
+ - path: content/seasons/01-soil/fragments.yaml
+ provides: "≥8 short Season-1 fragments authored in voice (the bulk pool that Lura's beats + plant-unlock thresholds draw from)"
+ - path: content/seasons/01-soil/fragments/*.md
+ provides: "≥2 long-form per-file Season-1 fragments (Markdown + frontmatter); proves the Markdown loader path on Season 1 too"
+ - path: scripts/check-bundle-split.mjs
+ provides: "PIPE-02 structural verification: after `npm run build`, asserts that dist/assets/ contains a chunk specifically named to include 'season1' or 'fragments' (Vite default chunk-naming based on the dynamic-import path)"
+ key_links:
+ - from: src/sim/garden/commands.ts
+ to: src/sim/memory/selector.ts
+ via: "harvest() invokes selectFragment to pick exactly one fragment"
+ pattern: "selectFragment"
+ - from: src/ui/journal/Journal.tsx
+ to: src/store/index.ts
+ via: "useAppStore(s => s.harvestedFragmentIds) — DOM render of fragments by Season"
+ pattern: "useAppStore"
+ - from: src/ui/journal/FragmentRevealModal.tsx
+ to: src/store/index.ts
+ via: "useAppStore(s => s.fragmentRevealId) — opens when set; clears on dismiss"
+ pattern: "fragmentRevealId"
+ - from: src/sim/memory/selector.ts
+ to: src/content/index.ts
+ via: "selector takes the loaded `fragments` array as an argument; pool is INJECTED so selector stays pure (no module-load coupling to Vite glob)"
+ pattern: "Fragment\\[\\]"
+ - from: package.json scripts.ci
+ to: scripts/check-bundle-split.mjs
+ via: "ci runs `npm run build` then `node scripts/check-bundle-split.mjs` to assert PIPE-02 chunk split"
+ pattern: "check:bundle-split"
+---
+
+
+**Wave 1 vertical slice. Depends on Plan 02-01 (foundations).**
+
+Runs in parallel with Plan 02-02 (Begin + Plant + Grow). Both depend only on 02-01. The shared surface is `src/sim/garden/types.ts` (locked by Plan 02-02 Task 1) and `src/sim/garden/commands.ts` (Plan 02-02 ships plantSeed; Plan 02-03 ADDS harvest + compost branches via merge). Coordinate the merge moment — both plans edit `simulateOneTick`'s switch.
+
+3 tasks. Estimated context cost ~50%.
+
+
+
+Ship the Harvest → Journal → Fragment-reveal vertical slice end-to-end. Player clicks a ready plant → harvest fires → exactly one Season-1 fragment is selected from the authored pool (deterministic, gated, no-dup) → reveal modal pops with the fragment's full text (selectable, copy-pasteable DOM) → dismissing the reveal files the fragment into the Memory Journal under Season 1 → a journal icon (hidden pre-first-harvest) reveals in the corner → clicking opens the Journal modal listing all collected fragments grouped by Season.
+
+Also ships compost → tile-empties + tonal acknowledgement, the actual Season-1 authored content (≥10 fragments matching bible voice), the plant-type unlock thresholds (yarrow at 3 harvests, winter-rose at 6 — Claude's discretion within D-05), and the PIPE-02 structural verification script proving Vite emits a separate Season-1 chunk after build.
+
+Purpose: Completes the second half of the player's first session (the first half — Begin → Plant → Grow — lands in Plan 02-02). After this plan ships, a player can run the full active-play loop end-to-end on real authored content. Plan 02-04 layers Lura's beats on top; Plan 02-05 layers offline catch-up + the letter on top.
+
+Output: Complete sim/memory module (selector + pool), extended sim/garden/commands.ts (harvest + compost branches), DOM-rendered Journal + FragmentRevealModal + journal-icon, ≥10 authored Season-1 fragments under /content/seasons/01-soil/, PIPE-02 structural test script, all green under `npm run ci`.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@CLAUDE.md
+@.planning/anti-fomo-doctrine.md
+@.planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md
+@.planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md
+@.planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md
+@.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-SUMMARY.md
+@.planning/phases/02-season-1-vertical-slice-soil/02-02-begin-plant-grow-SUMMARY.md
+@.planning/phases/01-foundations-and-doctrine/01-04-SUMMARY.md
+@content/README.md
+
+
+
+
+From src/sim/garden/index.ts (Plan 02-02):
+```typescript
+export type { Tile, PlantInstance, PlantType, PlantTypeId, GrowthStage } from './types';
+export { GRID_SIZE, GRID_ROWS, GRID_COLS, tileIdx, tileCoords } from './types';
+export { PLANT_TYPES, getPlantType } from './plants';
+export { advanceGrowth, GROWTH_THRESHOLDS } from './growth';
+export { plantSeed, simulateOneTick, tileGrowthStage } from './commands';
+// ^^^^^^^^^ Plan 02-03 EXTENDS commands.ts with harvest + compost; simulateOneTick branches on those kinds.
+```
+
+From src/store/index.ts (Plan 02-01) — already exposes:
+```typescript
+fragmentRevealId: string | null;
+setFragmentRevealId(id: string | null);
+harvestedFragmentIds: string[];
+setHarvested(ids: string[]);
+```
+
+From src/content/index.ts (Plan 02-02 extension):
+```typescript
+export const fragments: Fragment[]; // eager (legacy)
+export function loadSeasonFragments(seasonId: number): Promise; // PIPE-02 lazy
+export const uiStrings: Record;
+export type Fragment = { id: string; season: number; body: string };
+```
+
+Fragment ID regex (FragmentSchema): `/^season\d+\.[a-z0-9._-]+$/`. Examples: `season1.soil.first-bloom`, `season1.soil.lura.greeting` (dots and dashes both allowed).
+
+Existing src/App.tsx after Plan 02-02 (mount BeginScreen + SeedPicker; this plan adds Journal + FragmentRevealModal + JournalIcon):
+
+```typescript
+
+
+
+
+ {/* Plan 02-03: , , */}
+
+```
+
+From src/sim/state.ts (Plan 02-01):
+```typescript
+export interface SimState {
+ garden: { tiles: unknown[] };
+ plants: unknown[];
+ harvestedFragmentIds: string[];
+ lastTickAt: number;
+ unlockedPlantTypes: string[];
+ luraBeatProgress: { ... };
+ offlineEvents: unknown | null;
+ settings: { ...; persistenceToastShown: boolean };
+}
+```
+
+Mulberry32 seeded PRNG (RESEARCH line 1013, ~10 LoC pure):
+```typescript
+function mulberry32(a: number): () => number {
+ return function() {
+ let t = a += 0x6D2B79F5;
+ t = Math.imul(t ^ t >>> 15, t | 1);
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
+ }
+}
+```
+
+
+
+
+
+
+ Task 1: Author ≥10 Season-1 fragments + sim/memory selector + extend sim/garden/commands with harvest + compost
+
+ - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pitfall 8 lines 1102-1108 fragment exhaustion, Pitfall 10 lines 1118-1124 unlock off-by-one, Open Question 1 lines 1225-1229 plant identity)
+ - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group D lines 274-310, Group C lines 226-272 for sim/garden command pattern)
+ - .planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md (D-03 plant types, D-05 unlocks, D-07 post-harvest beat, D-14 Lura thresholds — gives a sense of how many harvests Phase 2 expects)
+ - CLAUDE.md (Tone — bible voice for fragment text)
+ - content/README.md (fragment authoring conventions)
+ - content/seasons/01-soil/fragments.yaml (Plan 02-02 placeholder — REPLACE with real content)
+ - src/sim/garden/commands.ts (Plan 02-02 — extend the simulateOneTick switch)
+ - src/sim/garden/commands.test.ts (Plan 02-02 — extend with harvest + compost cases)
+
+
+ content/seasons/01-soil/fragments.yaml,
+ content/seasons/01-soil/fragments/lura-first-letter.md,
+ content/seasons/01-soil/fragments/winter-rose-night.md,
+ src/sim/memory/selector.ts,
+ src/sim/memory/selector.test.ts,
+ src/sim/memory/pool.ts,
+ src/sim/memory/index.ts,
+ src/sim/garden/commands.ts,
+ src/sim/garden/commands.test.ts
+
+
+**Step 1 — Author Season-1 fragments.**
+
+Replace `content/seasons/01-soil/fragments.yaml` (currently a Plan-02-02 placeholder) with ≥8 short fragments authored in voice. Each fragment:
+- Has stable string ID matching `/^season1\.[a-z0-9._-]+$/`.
+- Belongs to one of the three plant types' tonal registers (warm / contemplative / heavy) via the `tags` field (a Phase-2 extension to FragmentSchema — see Step 2).
+- 2–6 sentences max. Bible voice: warm, specific, intermittent, sometimes funny, sometimes devastating.
+
+Author at least 8 fragments in fragments.yaml + 2 long-form Markdown fragments in `content/seasons/01-soil/fragments/*.md`. Total ≥10. The exhaustion fallback fragment (`season1.soil.gardener-knows-this-one-already`) is the 11th and may live in either yaml or md; document its role in a comment.
+
+**The fragment file MUST also include a 12th sentinel ID `season1.soil._exhaustion`** as the no-fragment-pool fallback per RESEARCH Pitfall 8.
+
+**Step 2 — Extend FragmentSchema with optional `tags` field** for plant-type gating (MEMR-06):
+
+Edit `src/content/schemas/fragment.ts`:
+```typescript
+export const FragmentSchema = z.object({
+ id: z.string().regex(/^season\d+\.[a-z0-9._-]+$/),
+ season: z.number().int().min(0).max(7),
+ body: z.string().min(1),
+ tags: z.array(z.string().min(1)).optional(), // Phase 2 extension for MEMR-06 gating
+});
+```
+
+This is backward-compatible (optional field). Existing tests still pass.
+
+**Sample fragments** (executor adapts; all matched to bible voice):
+
+```yaml
+# content/seasons/01-soil/fragments.yaml
+fragments:
+ # ----- WARM tonal register (rosemary pool) -----
+ - id: season1.soil.first-bloom
+ season: 1
+ tags: [warm]
+ body: |
+ The first thing that grew was rosemary. The shape of it didn't matter
+ so much as the smell — sharp, the kind of green that means the air
+ will warm up by afternoon.
+
+ - id: season1.soil.bread-was-easy
+ season: 1
+ tags: [warm]
+ body: |
+ Someone, in the place this came from, was very good at bread. There
+ isn't a name attached. There is the shape of an oven door, and a
+ towel folded a particular way.
+
+ - id: season1.soil.the-cat
+ season: 1
+ tags: [warm]
+ body: |
+ The cat is missing now too. It used to walk along the wall at dusk.
+ It would not come when called. It came anyway, in its own time. Most
+ good things were like that.
+
+ # ----- CONTEMPLATIVE tonal register (yarrow pool) -----
+ - id: season1.soil.what-the-wind-was-for
+ season: 1
+ tags: [contemplative]
+ body: |
+ The wind used to mean something specific in spring — a person putting
+ sheets out to dry, the line across two posts, the way it would crack
+ like a small flag. That meaning has gone soft. The wind still blows.
+
+ - id: season1.soil.the-letter-not-sent
+ season: 1
+ tags: [contemplative]
+ body: |
+ There was a letter someone meant to send. The address is gone, the
+ ink is gone, the reason is gone. What remains is the silence on the
+ other side of it — a room, somewhere, that never received the news.
+
+ - id: season1.soil.numbers-in-the-margin
+ season: 1
+ tags: [contemplative]
+ body: |
+ A book had a number written in the margin: 47. Whose age, whose page,
+ whose count of something — gone. The 47 sits very calmly on the
+ paper. Numbers are the last to forget. They will outlast all of us.
+
+ # ----- HEAVY tonal register (winter-rose pool) -----
+ - id: season1.soil.the-name-she-used
+ season: 1
+ tags: [heavy]
+ body: |
+ She had a name for him that wasn't his name. He had stopped objecting
+ to it long before the end. After, the name kept arriving — at the
+ door, in the post, in the mouths of people who had heard it once and
+ never been corrected. The garden does not say it. The garden only
+ grows.
+
+ - id: season1.soil.what-the-snow-took
+ season: 1
+ tags: [heavy]
+ body: |
+ Snow took the orchard one March. The trees were already old. The
+ orchard had been someone's grandfather's, then someone's father's,
+ then a row of stumps and a few unrooted sticks pretending. Pretending
+ is also a kind of remembering, until one day it isn't.
+
+ # ----- EXHAUSTION FALLBACK (returned when gated pool is empty per Pitfall 8) -----
+ - id: season1.soil._exhaustion
+ season: 1
+ tags: [_meta]
+ body: |
+ The garden knows this one already. The light comes in the same way it
+ came yesterday. There will be a new thing tomorrow. There is also
+ this — the steady part, that does not need re-learning.
+```
+
+```markdown
+
+---
+id: season1.soil.lura-first-letter
+season: 1
+tags: [warm]
+---
+Lura wrote you a letter once, and never sent it. It was about a recipe — the
+proportions of vinegar to honey, and how long to let the onions sit. Most of
+the letter is the recipe. Two paragraphs at the bottom are about something
+else: a bee in a kitchen window, a song you didn't recognize, the shape your
+hand made on a glass.
+
+She left the letter in a drawer, decided it sounded too much. Then there was
+no drawer, and no letter. The recipe is real. You could find it again, if you
+asked.
+```
+
+```markdown
+
+---
+id: season1.soil.winter-rose-night
+season: 1
+tags: [heavy]
+---
+Winter-rose blooms at night. This is, technically, slander — the rose blooms
+when it blooms, and the night is when most people are asleep, and so the
+night is when most people fail to see things bloom. But the slander stuck.
+A flower for the people who couldn't sleep.
+
+Someone, in this place, used to set a chair by the window in February and
+wait. The wait was the thing. The flower would bloom in its own time. Most
+good things were like that, until they weren't.
+```
+
+(Total: 9 in yaml + 2 in md + 1 sentinel = 12 fragments. Exceeds RESEARCH Assumption A8's "≥10" target with margin. Tags distribute: 4 warm, 3 contemplative, 3 heavy, 1 _meta = 11 plant-tagged + 1 sentinel; comfortably feeds 8th-harvest Lura threshold + plant-type unlocks.)
+
+**Step 3 — `src/sim/memory/pool.ts`** (PATTERNS Group D filter pattern):
+
+```typescript
+import type { Fragment } from '../../content';
+import type { PlantTypeId } from '../garden/types';
+import { PLANT_TYPES } from '../garden/plants';
+
+/**
+ * Filter the loaded fragments down to the gated, not-yet-harvested pool
+ * for a given (season, plantTypeId) at the moment of harvest.
+ *
+ * Per MEMR-06: respects authored gating (Season + plantType.fragmentTags
+ * intersection) and avoids duplicates within a playthrough.
+ *
+ * Per RESEARCH Pitfall 8: callers MUST handle the case where the returned
+ * pool is empty by falling back to the exhaustion sentinel
+ * (EXHAUSTION_FALLBACK_ID in selector.ts).
+ *
+ * Pure. No DOM. No Date.now.
+ */
+export function filterPool(
+ allFragments: readonly Fragment[],
+ season: number,
+ plantTypeId: PlantTypeId,
+ alreadyHarvestedIds: readonly string[],
+): Fragment[] {
+ const type = PLANT_TYPES[plantTypeId];
+ if (!type) return [];
+ const tagSet = new Set(type.fragmentTags);
+ const harvestedSet = new Set(alreadyHarvestedIds);
+ return allFragments.filter((f) => {
+ if (f.season !== season) return false;
+ if (harvestedSet.has(f.id)) return false;
+ // MEMR-06 plant-type gating: fragment must share at least one tag with the plant type's tonal register
+ if (!f.tags || !f.tags.some((t) => tagSet.has(t))) return false;
+ // Exclude the exhaustion sentinel from the pool — it's reserved for the fallback
+ if (f.tags.includes('_meta')) return false;
+ return true;
+ });
+}
+```
+
+**Step 4 — `src/sim/memory/selector.ts`** (RESEARCH Don't Hand-Roll line 1013 + PATTERNS Group D):
+
+```typescript
+import type { Fragment } from '../../content';
+import type { PlantTypeId } from '../garden/types';
+import { filterPool } from './pool';
+
+/**
+ * MEMR-06 deterministic fragment selector.
+ *
+ * Inputs are pure: (allFragments, currentSeason, plantTypeId, alreadyHarvestedIds, seedHash).
+ * Same inputs → same output. No Date.now, no Math.random — the seed is
+ * derived from `(harvestedFragmentIds.length, plantedAtTick)` in the
+ * caller (sim/garden/commands.ts) so the player's actions advance the
+ * stream without leaking wall-clock state into sim modules.
+ *
+ * Per RESEARCH Pitfall 8 (exhaustion):
+ * - If the gated pool is non-empty: return the seeded selection.
+ * - If the gated pool is empty: return the EXHAUSTION_FALLBACK_ID sentinel
+ * fragment (authored at content/seasons/01-soil/fragments.yaml as
+ * `season1.soil._exhaustion`).
+ * - If even the sentinel is missing (degenerate test fixture):
+ * return null and let the caller treat it as a no-op harvest.
+ */
+export const EXHAUSTION_FALLBACK_ID = 'season1.soil._exhaustion';
+
+function mulberry32(a: number): () => number {
+ return function() {
+ let t = (a += 0x6D2B79F5);
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+export function selectFragment(
+ allFragments: readonly Fragment[],
+ currentSeason: number,
+ plantTypeId: PlantTypeId,
+ alreadyHarvestedIds: readonly string[],
+ seedHash: number,
+): Fragment | null {
+ const pool = filterPool(allFragments, currentSeason, plantTypeId, alreadyHarvestedIds);
+ if (pool.length === 0) {
+ return allFragments.find((f) => f.id === EXHAUSTION_FALLBACK_ID) ?? null;
+ }
+ const rng = mulberry32(seedHash);
+ const idx = Math.floor(rng() * pool.length);
+ return pool[idx] ?? null;
+}
+```
+
+**Step 5 — `src/sim/memory/selector.test.ts`** — exhaustive Vitest:
+
+- Empty pool + sentinel present → returns sentinel.
+- Empty pool + no sentinel → returns null.
+- Pool with one fragment → always returns that fragment regardless of seed.
+- Pool with three fragments — same `seedHash` returns same fragment; different `seedHash` may return different.
+- Pool gating: `selectFragment([{id, season=1, tags:['warm']}, {id, season=1, tags:['heavy']}], 1, 'rosemary', [], 0)` returns only the warm-tagged one (rosemary tonal register).
+- No-dup: passing a fragment's id in `alreadyHarvestedIds` excludes it from the pool.
+- Season gating: fragment with `season=2` is never selected when `currentSeason=1`.
+- Sentinel exclusion: a fragment tagged `['_meta']` is NEVER returned via the normal-pool branch (only via the exhaustion fallback).
+
+**Step 6 — `src/sim/memory/index.ts`:**
+```typescript
+export { selectFragment, EXHAUSTION_FALLBACK_ID } from './selector';
+export { filterPool } from './pool';
+```
+
+Also add `export * from './memory'` to `src/sim/index.ts`.
+
+**Step 7 — Extend `src/sim/garden/commands.ts`** with `harvest` and `compost`. Add a `MemoryRegistry` injection point so the sim stays decoupled from `import.meta.glob` Vite magic:
+
+```typescript
+// add at top of commands.ts
+import { selectFragment, EXHAUSTION_FALLBACK_ID } from '../memory/selector';
+import type { Fragment } from '../../content';
+
+/**
+ * The fragment pool injected into simulateOneTick. The application
+ * layer (Phaser scene) loads fragments via loadSeasonFragments(1) and
+ * passes the array in. Sim modules stay decoupled from import.meta.glob.
+ */
+export interface SimContext {
+ fragments: readonly Fragment[];
+ currentSeason: number;
+}
+
+/**
+ * harvest(state, tileIdx, currentTick, ctx) → state'
+ *
+ * Pure. Picks exactly ONE fragment via the deterministic selector,
+ * empties the tile, and appends to harvestedFragmentIds. The seed
+ * derives from (harvestCount + plantedAtTick) — pure of all wall-clock.
+ *
+ * Per GARD-03 + MEMR-01 + MEMR-06.
+ *
+ * Returns the original state unchanged if the tile is empty or not ready.
+ */
+export function harvest(state: SimState, tileIdx: number, currentTick: number, ctx: SimContext): SimState {
+ if (tileIdx < 0 || tileIdx >= GRID_SIZE) return state;
+ const tiles = state.garden.tiles as Tile[];
+ const tile = tiles[tileIdx];
+ if (!tile?.plant) return state;
+ const type = PLANT_TYPES[tile.plant.plantTypeId];
+ if (!type) return state;
+ const stage = advanceGrowth(tile.plant, type, currentTick);
+ if (stage !== 'ready') return state; // refuse to harvest immature plants
+
+ const seedHash = state.harvestedFragmentIds.length * 2654435761 + tile.plant.plantedAtTick;
+ const fragment = selectFragment(
+ ctx.fragments,
+ ctx.currentSeason,
+ tile.plant.plantTypeId,
+ state.harvestedFragmentIds,
+ seedHash,
+ );
+ if (!fragment) return state; // degenerate: no fragment AND no sentinel — refuse to harvest
+
+ const nextTiles: Tile[] = tiles.map((t, i) => i === tileIdx ? { idx: i, plant: null } : t);
+ const harvestedIds = [...state.harvestedFragmentIds, fragment.id];
+
+ // D-05 plant-type unlock thresholds (Claude's discretion within reason):
+ // yarrow unlocks at 3 harvests
+ // winter-rose unlocks at 6 harvests
+ // Defended in selector.test.ts boundary cases. Document final values in SUMMARY.md.
+ const unlockedPlantTypes = computePlantUnlocks(harvestedIds.length);
+
+ return {
+ ...state,
+ garden: { tiles: nextTiles },
+ harvestedFragmentIds: harvestedIds,
+ unlockedPlantTypes,
+ };
+}
+
+const PLANT_UNLOCK_THRESHOLDS: Array<{ count: number; plantTypeId: PlantTypeId }> = [
+ { count: 0, plantTypeId: 'rosemary' }, // available from start
+ { count: 3, plantTypeId: 'yarrow' }, // unlocks at 3rd harvest (Pitfall 10: check AFTER harvest commit)
+ { count: 6, plantTypeId: 'winter-rose' }, // unlocks at 6th harvest
+];
+
+function computePlantUnlocks(harvestCount: number): string[] {
+ return PLANT_UNLOCK_THRESHOLDS
+ .filter((t) => harvestCount >= t.count)
+ .map((t) => t.plantTypeId);
+}
+
+/**
+ * compost(state, tileIdx, currentTick) → state'
+ *
+ * Pure. Empties the tile regardless of growth stage. No fragment yield.
+ * No resource refund (D-04 = infinite seeds).
+ *
+ * The "tonal beat" (D-07 + GARD-04) is a UI concern — Plan 02-04's Ink
+ * runtime renders compost-acknowledgements.ink lines via the dialogue
+ * overlay. Phase 2 Plan 02-03 ships the AUTHORED CONTENT; the React
+ * surface fires the beat by setting a flag; Plan 02-04 wires the Ink
+ * playback (placeholder DOM text in this plan, swap to ink later).
+ */
+export function compost(state: SimState, tileIdx: number, _currentTick: number): SimState {
+ if (tileIdx < 0 || tileIdx >= GRID_SIZE) return state;
+ const tiles = state.garden.tiles as Tile[];
+ const tile = tiles[tileIdx];
+ if (!tile?.plant) return state;
+ const nextTiles: Tile[] = tiles.map((t, i) => i === tileIdx ? { idx: i, plant: null } : t);
+ return { ...state, garden: { tiles: nextTiles } };
+}
+```
+
+**Update `simulateOneTick`** to dispatch on `harvest` and `compost`:
+
+```typescript
+export function simulateOneTick(
+ state: SimState,
+ currentTick: number,
+ commands: GardenCommand[],
+ ctx: SimContext,
+): SimState {
+ let next = state;
+ for (const cmd of commands) {
+ if (cmd.kind === 'plantSeed' && cmd.plantTypeId) {
+ next = plantSeed(next, cmd.tileIdx, cmd.plantTypeId as PlantTypeId, currentTick);
+ } else if (cmd.kind === 'harvest') {
+ next = harvest(next, cmd.tileIdx, currentTick, ctx);
+ } else if (cmd.kind === 'compost') {
+ next = compost(next, cmd.tileIdx, currentTick);
+ }
+ }
+ return { ...next, lastTickAt: currentTick };
+}
+```
+
+**Note:** simulateOneTick now takes a `ctx: SimContext` 4th argument. Update Plan 02-02's Garden scene to pass `{fragments: , currentSeason: 1}` — the executor edits `src/game/scenes/Garden.ts` to load fragments and pass through. The Garden scene's `update()` becomes:
+
+```typescript
+const result = drainTicks(simStateNow, this.accumulatorMs, (s, _dtMs, _silent) => {
+ const next = simulateOneTick(s, this.currentTick + 1, commands, this.simContext);
+ this.currentTick++;
+ return next;
+});
+```
+
+with `this.simContext` initialized in `create()` via `await loadSeasonFragments(1)`. Use `this.events.once('create')` or chain via `.then` since `create()` is sync but we need fragments early — practical approach: call `loadSeasonFragments(1)` in `init()` then `this.simContext = { fragments: [], currentSeason: 1 }` until resolved, then assign. (Or load eagerly via the existing `fragments` export from Plan 01-04 — for Phase 2 this is simpler and Plan 02-04+ can swap to lazy when content grows.)
+
+**Simpler approach (executor's preference allowed):** import the eager `fragments` export and filter for `season === 1` in the Garden scene's `create()`:
+```typescript
+import { fragments as allFragments } from '../../content';
+this.simContext = { fragments: allFragments, currentSeason: 1 };
+```
+PIPE-02's lazy split is structurally verified by `scripts/check-bundle-split.mjs` (Task 3 of this plan); the runtime can use the eager pool until Phase 4 grows beyond Season 1. **Document this trade-off in SUMMARY.md.**
+
+**Step 8 — Extend `src/sim/garden/commands.test.ts`** with harvest + compost cases:
+
+- Harvest a ready plant → returns state with tile cleared and exactly ONE new entry in harvestedFragmentIds.
+- Harvest the same tile after harvesting → returns state unchanged (tile is empty).
+- Harvest an immature plant → returns state unchanged.
+- Harvest with empty fragment context → returns state unchanged (no fragment selected).
+- Determinism: two calls to `harvest` on identical state produce identical results.
+- Plant-type unlocks: plant 3 rosemary, harvest each → after 3rd harvest, `unlockedPlantTypes` includes 'yarrow'.
+- Plant-type unlocks Pitfall 10 (off-by-one): after 2 harvests, `unlockedPlantTypes` does NOT include 'yarrow'; after 3, it does.
+- Compost a sprout → tile clears.
+- Compost an empty tile → state unchanged.
+- Compost does not change harvestedFragmentIds.
+- Compost does not change unlockedPlantTypes (no-fragment path).
+
+**Commit:** `feat(02-03): Season-1 fragments + sim/memory selector + harvest/compost commands`. Run `npm run lint && npx vitest run src/sim/ src/content/ && npm run build` before committing (npm run build proves the new fragments parse).
+
+
+ - `grep -c "^ - id: season1\\." content/seasons/01-soil/fragments.yaml` returns ≥9
+ - `ls content/seasons/01-soil/fragments/*.md | wc -l` returns ≥2
+ - `grep -q "season1.soil._exhaustion" content/seasons/01-soil/fragments.yaml`
+ - `grep -q "tags: \\[warm\\]\\|tags: \\[contemplative\\]\\|tags: \\[heavy\\]" content/seasons/01-soil/fragments.yaml` (multiple)
+ - `grep -q "tags: z.array(z.string()" src/content/schemas/fragment.ts` (schema extended)
+ - `grep -q "EXHAUSTION_FALLBACK_ID = 'season1.soil._exhaustion'" src/sim/memory/selector.ts`
+ - `grep -q "function mulberry32" src/sim/memory/selector.ts`
+ - `grep -q "export function harvest" src/sim/garden/commands.ts`
+ - `grep -q "export function compost" src/sim/garden/commands.ts`
+ - `grep -q "PLANT_UNLOCK_THRESHOLDS" src/sim/garden/commands.ts`
+ - `grep -L "Date.now" src/sim/memory/selector.ts src/sim/memory/pool.ts` (sim purity)
+ - `npx vitest run src/sim/memory/ src/sim/garden/ src/content/` exits 0 with all tests green; harvest/compost coverage ≥6 new cases
+ - `npm run build` succeeds — Vite parses all new fragments without schema violation
+ - `npm run lint` exits 0
+
+
+ npm run lint && npx vitest run src/sim/memory/ src/sim/garden/ src/content/ && npm run build
+
+
+ ≥10 Season-1 fragments authored under /content/seasons/01-soil/ (≥8 yaml + ≥2 md + 1 sentinel). Bible voice maintained. FragmentSchema extended with optional tags. Deterministic selector with gating + no-dup + exhaustion fallback ships under sim/memory/. harvest + compost commands extend sim/garden/commands.ts; simulateOneTick takes a SimContext. Garden scene wired to pass real fragment context. ≥6 new Vitest cases green.
+
+
+
+
+ Task 2: Memory Journal UI (Journal modal + FragmentRevealModal + JournalIcon) + App.tsx wiring + harvest event flow
+
+ - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Memory Journal section + Architectural Responsibility Map row "Memory Journal")
+ - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group I lines 471-518)
+ - .planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md (D-23 reveal-after-first-harvest, D-24 full-screen modal, D-25 immediate-reveal-modal)
+ - src/store/memory-slice.ts (Plan 02-01 — fragmentRevealId state slot)
+ - src/store/garden-slice.ts (Plan 02-01 — enqueueCommand)
+ - src/ui/begin/BeginScreen.tsx (Plan 02-02 — pattern for full-screen DOM overlay)
+ - src/App.tsx (Plan 02-02 — extend mount list)
+ - src/game/event-bus.ts (Plan 02-01 — fragment-revealed event)
+ - src/game/scenes/Garden.ts (Plan 02-02 — wire harvest pointerdown + emit fragment-revealed)
+
+
+ src/ui/journal/Journal.tsx,
+ src/ui/journal/Journal.test.tsx,
+ src/ui/journal/FragmentRevealModal.tsx,
+ src/ui/journal/FragmentRevealModal.test.tsx,
+ src/ui/journal/journal-icon.tsx,
+ src/ui/journal/index.ts,
+ src/ui/index.ts,
+ src/App.tsx,
+ src/game/scenes/Garden.ts
+
+
+**Step 1 — `src/ui/journal/Journal.tsx`** — full-screen modal (D-24):
+
+```typescript
+import { useState } from 'react';
+import { useAppStore } from '../../store';
+import { fragments as allFragments, uiStrings } from '../../content';
+
+/**
+ * D-24 — full-screen Memory Journal modal. DOM-rendered text per MEMR-05
+ * (selectable, copy-pasteable). Fragments grouped by Season; each fragment
+ * shown in full body text.
+ *
+ * Visibility is local state, opened by JournalIcon onClick. Phase 2 has
+ * only Season 1 — Phase 4+ Journal will need pagination / collapse.
+ */
+export function Journal({ open, onClose }: { open: boolean; onClose: () => void }): JSX.Element | null {
+ const harvested = useAppStore((s) => s.harvestedFragmentIds);
+ const strings = uiStrings[1]?.journal;
+ if (!open || !strings) return null;
+
+ // Resolve fragment objects in the order the player harvested them
+ const harvestedFragments = harvested
+ .map((id) => allFragments.find((f) => f.id === id))
+ .filter((f): f is NonNullable => f !== undefined);
+
+ // Group by season for D-24 "fragments grouped by Season" requirement
+ const bySeason = new Map();
+ for (const f of harvestedFragments) {
+ if (!bySeason.has(f.season)) bySeason.set(f.season, []);
+ bySeason.get(f.season)!.push(f);
+ }
+
+ return (
+
+ );
+}
+```
+
+**Step 3 — `src/ui/journal/journal-icon.tsx`** (D-23 + D-29):
+
+```typescript
+import { useState } from 'react';
+import { useAppStore, selectJournalRevealed } from '../../store';
+import { Journal } from './Journal';
+
+/**
+ * D-23 — journal affordance reveals after first harvest, then is persistent.
+ * D-29 — corner icon access pattern.
+ *
+ * Pre-first-harvest, returns null. Post-first-harvest, renders a small
+ * fixed-position icon button that opens the Journal modal.
+ */
+export function JournalIcon(): JSX.Element | null {
+ const revealed = useAppStore(selectJournalRevealed);
+ const [open, setOpen] = useState(false);
+
+ if (!revealed) return null;
+
+ return (
+ <>
+
+ setOpen(false)} />
+ >
+ );
+}
+```
+
+(The `✎` glyph is allowed — it's a typographic affordance, not localized copy. If the user prefers a SVG icon, swap; surfacing in SUMMARY.md.)
+
+**Step 4 — `src/ui/journal/Journal.test.tsx`** — Vitest + @testing-library/react:
+
+- Initial render with `harvestedFragmentIds: []` shows the empty-state copy from `uiStrings[1].journal.empty_state`.
+- With `harvestedFragmentIds: ['season1.soil.first-bloom']`, the Journal renders the full body of that fragment.
+- The fragment body is inside an element with `userSelect: 'text'` (selectable per MEMR-05) — assert via computed style on a found element.
+- The body text includes the actual sentence "The first thing that grew was rosemary" (selectable text content, not innerHTML — confirms DOM rendering, not canvas).
+- Fragments grouped by Season — `
Season 1
` is rendered.
+- Close button click invokes `onClose` callback once.
+
+**Step 5 — `src/ui/journal/FragmentRevealModal.test.tsx`** — Vitest:
+
+- With `fragmentRevealId: null`, returns null (not visible).
+- With `fragmentRevealId: 'season1.soil.first-bloom'`, the fragment body renders.
+- Click on the modal background dismisses (sets fragmentRevealId=null in the store).
+- Click on the article body does NOT dismiss (event.stopPropagation works).
+- Click on the inner Close button dismisses.
+
+**Step 6 — `src/ui/journal/index.ts`:**
+```typescript
+export { Journal } from './Journal';
+export { FragmentRevealModal } from './FragmentRevealModal';
+export { JournalIcon } from './journal-icon';
+```
+
+Update `src/ui/index.ts`:
+```typescript
+export * from './begin';
+export * from './garden';
+export * from './journal';
+```
+
+**Step 7 — Update `src/App.tsx`** to mount the new overlays:
+
+```typescript
+import { useRef } from 'react';
+import { PhaserGame, type IRefPhaserGame } from './PhaserGame.tsx';
+import { BeginScreen } from './ui/begin';
+import { SeedPicker } from './ui/garden';
+import { JournalIcon, FragmentRevealModal } from './ui/journal';
+
+function App() {
+ const phaserRef = useRef(null);
+ return (
+
+
+
+
+
+
+ {/* Plan 02-04: */}
+ {/* Plan 02-05: , , */}
+
+ );
+}
+
+export default App;
+```
+
+**Step 8 — Update `src/game/scenes/Garden.ts`** to:
+
+(a) Wire pointerdown on a ready-plant tile to enqueue a `harvest` command.
+(b) Detect when a new fragment was harvested in a sim tick (new id appended to `harvestedFragmentIds`) and set `fragmentRevealId` via `simAdapter` (extend simAdapter with `applyHarvestedFragmentsAndReveal` if needed; or do it inline by reading the previous count vs new count).
+
+In `Garden.ts`'s `update()` method, after the scheduler call, compare prev vs next `harvestedFragmentIds.length`:
+
+```typescript
+const prevCount = appStore.getState().harvestedFragmentIds.length;
+// ... drainTicks ...
+if (result.ticksApplied > 0) {
+ // Apply garden + memory state
+ simAdapter.applyTilesAndUnlocks(result.state.garden.tiles, result.state.unlockedPlantTypes);
+ if (result.state.harvestedFragmentIds.length > prevCount) {
+ // A new fragment was harvested in this tick — reveal it (D-25)
+ const newId = result.state.harvestedFragmentIds[result.state.harvestedFragmentIds.length - 1];
+ simAdapter.applyHarvestedFragments(result.state.harvestedFragmentIds);
+ appStore.getState().setFragmentRevealId(newId);
+ }
+}
+```
+
+In the pointerdown handler:
+
+```typescript
+private handleTilePointerDown(idx: number): void {
+ const tiles = appStore.getState().tiles as Tile[];
+ const tile = tiles[idx];
+ if (!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;
+ }
+ // Has plant — check growth stage.
+ const stage = tileGrowthStage(tile, this.currentTick);
+ if (stage === 'ready') {
+ appStore.getState().enqueueCommand({ kind: 'harvest', tileIdx: idx });
+ } else {
+ // Immature — compost (Plan 02-04 may add a confirmation prompt; Phase 2 ships immediate compost)
+ appStore.getState().enqueueCommand({ kind: 'compost', tileIdx: idx });
+ }
+}
+```
+
+**Note on compost beat:** The tonal acknowledgement (D-07 + GARD-04) should fire after compost. Plan 02-04 wires the Ink playback for the line. Plan 02-03 ships a TODO comment in Garden.ts (or a tiny placeholder DOM toast) so the affordance is visible:
+
+```typescript
+// TODO Plan 02-04: replace this placeholder with the Ink-authored compost beat
+// rendered through the dialogue overlay (compost-acknowledgements.ink).
+```
+
+Plan 02-04's authored content will land the actual lines.
+
+**Commit:** `feat(02-03): journal + reveal modal + harvest pointer wiring`. Run `npm run ci` before committing. Manual smoke test: harvest a ready plant in dev → reveal modal pops → close → journal icon appears in corner → click → modal lists fragment.
+
+
+ - `grep -q "Memory Journal" src/ui/journal/Journal.tsx` (aria-label)
+ - `grep -q "userSelect: 'text'" src/ui/journal/Journal.tsx` (MEMR-05 selectable)
+ - `grep -q "userSelect: 'text'" src/ui/journal/FragmentRevealModal.tsx`
+ - `grep -q "selectJournalRevealed" src/ui/journal/journal-icon.tsx` (D-23 first-harvest reveal gate)
+ - `grep -q "" src/App.tsx`
+ - `grep -q "" src/App.tsx`
+ - `grep -q "kind: 'harvest'" src/game/scenes/Garden.ts`
+ - `grep -q "kind: 'compost'" src/game/scenes/Garden.ts`
+ - `grep -q "setFragmentRevealId" src/game/scenes/Garden.ts` (reveal flow wired)
+ - `npx vitest run src/ui/journal/` exits 0 with all tests green (≥10 cases across 2 files)
+ - `npm run ci` exits 0
+
+
+ npm run lint && npx vitest run src/ui/journal/ && npm run ci
+
+
+ Journal + FragmentRevealModal + JournalIcon land. App.tsx mounts them. Garden.ts wires harvest/compost pointer events + reveal flow. Manual smoke test confirms: harvest ready plant → reveal pops → close → journal icon appears → opens journal modal listing fragment. Selectable text confirmed via Vitest.
+
+
+
+
+ Task 3: PIPE-02 structural verification — scripts/check-bundle-split.mjs and CI integration
+
+ - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 8 lines 906-940 PIPE-02 lazy loading, Open Question 4 lines 1240-1244)
+ - scripts/validate-assets.mjs (Phase 1 — analog for Node ESM build script)
+ - package.json scripts (current `ci` chain)
+ - src/content/loader.ts (Plan 02-02 — loadSeasonFragments lazy glob already wired)
+
+
+ scripts/check-bundle-split.mjs,
+ scripts/check-bundle-split.test.mjs,
+ package.json
+
+
+**Step 1 — `scripts/check-bundle-split.mjs`** — structural assertion that Vite emits a separate chunk for Season-1 fragments after `npm run build`:
+
+```javascript
+#!/usr/bin/env node
+// Phase 2 Plan 02-03 — PIPE-02 structural verification.
+//
+// After `npm run build`, Vite splits each lazy `import.meta.glob` target
+// into its own chunk. Phase 2 has only Season 1; the wiring is structural
+// so Phase 4 (Season 2) inherits without rework.
+//
+// This script asserts that `dist/assets/` contains at least one chunk
+// whose name reflects the lazy-imported Season-1 fragment paths
+// (Vite's default chunk name uses the module path slug; for
+// `/content/seasons/01-soil/fragments.yaml` the chunk name typically
+// includes `fragments` and may include `01-soil`).
+//
+// If the assertion is too tight, the script prints the chunk listing
+// for the dev to inspect and exits non-zero with guidance.
+
+import { readdirSync, existsSync, readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+const distAssets = resolve(process.cwd(), 'dist/assets');
+const distIndex = resolve(process.cwd(), 'dist/index.html');
+
+if (!existsSync(distAssets)) {
+ console.error('[check-bundle-split] dist/assets/ not found — run `npm run build` first');
+ process.exit(2);
+}
+
+const files = readdirSync(distAssets);
+
+// PIPE-02 looks for at least ONE chunk that references Season-1 fragment paths.
+// Vite hashes filenames; the source path is preserved as a comment in the
+// generated JS, but Vite typically also includes path slugs in chunk names
+// for dynamically-imported resources.
+//
+// We check three places:
+// 1. Any .js file in dist/assets/ whose NAME contains 'fragments' or 'season1' or '01-soil'.
+// 2. Any .js file whose CONTENTS reference '/content/seasons/01-soil/' (raw `?raw` imports
+// may inline the fragment YAML into a chunk).
+// 3. A non-empty fragments.yaml inlined as a string literal in some chunk.
+
+const chunkNameMatch = files.some((f) =>
+ f.endsWith('.js') && (f.includes('fragments') || f.includes('season1') || f.includes('01-soil'))
+);
+
+let chunkContentMatch = false;
+for (const f of files) {
+ if (!f.endsWith('.js')) continue;
+ const contents = readFileSync(resolve(distAssets, f), 'utf8');
+ if (contents.includes('/content/seasons/01-soil/') || contents.includes('season1.soil.first-bloom')) {
+ chunkContentMatch = true;
+ break;
+ }
+}
+
+if (chunkNameMatch || chunkContentMatch) {
+ console.log('[check-bundle-split] PIPE-02 OK — Season-1 content reachable via build output');
+ console.log(` chunkNameMatch=${chunkNameMatch}, chunkContentMatch=${chunkContentMatch}`);
+ console.log(` files: ${files.filter((f) => f.endsWith('.js')).join(', ')}`);
+ process.exit(0);
+}
+
+console.error('[check-bundle-split] FAIL — no chunk references /content/seasons/01-soil/');
+console.error(` dist/assets contained: ${files.join(', ')}`);
+console.error(' Expected: a chunk filename or content containing "fragments" / "season1" / "01-soil"');
+console.error(' See RESEARCH.md Pattern 8 (Per-Season Lazy Loading) for context.');
+process.exit(1);
+```
+
+**Step 2 — `scripts/check-bundle-split.test.mjs`** — Vitest unit test that exercises the script in two synthetic-fixture modes:
+
+Actually, since this script reads from disk after a real `npm run build`, the most pragmatic test is to:
+- Verify the script exists, has shebang, and is syntactically valid Node ESM.
+- Provide a Vitest test that mocks `dist/assets/` via a temp directory (use `node:fs/promises` and `mkdtemp`) and runs the script's main logic against the mock.
+
+For Phase 2 we ship a SIMPLER test: assert the script's existence and that it runs against the real `dist/` (which the CI's `npm run build` step will have populated).
+
+```javascript
+// scripts/check-bundle-split.test.mjs — vitest config includes scripts/**/*.test.mjs
+import { describe, it, expect } from 'vitest';
+import { existsSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+describe('scripts/check-bundle-split.mjs', () => {
+ it('exists and is non-empty', () => {
+ const path = resolve(process.cwd(), 'scripts/check-bundle-split.mjs');
+ expect(existsSync(path)).toBe(true);
+ });
+
+ // The actual structural assertion fires during `npm run ci` after `npm run build`
+ // populates dist/. Running it standalone in Vitest would either skip (no dist/)
+ // or duplicate the CI assertion. The script is exit-code-asserted via the ci chain.
+ it('is syntactically valid Node ESM (parses without error)', async () => {
+ // Smoke: importing it should not throw at parse time
+ await expect(import(resolve(process.cwd(), 'scripts/check-bundle-split.mjs'))).resolves.toBeTruthy();
+ });
+});
+```
+
+**Note:** The script has a `process.exit()` at the top level — importing it in Vitest will terminate the test process. To avoid that, wrap the script body in a `runCheck()` function exported via ESM AND only call it when `import.meta.url === \`file://${process.argv[1]}\`` (CLI mode). Refactor the script accordingly:
+
+```javascript
+#!/usr/bin/env node
+import { readdirSync, existsSync, readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+export function runCheck() {
+ // ... all the body logic above ...
+ // Return { ok: boolean, message: string } instead of calling process.exit
+}
+
+// CLI invocation
+if (import.meta.url === `file://${process.argv[1]}`) {
+ const result = runCheck();
+ console.log(result.message);
+ process.exit(result.ok ? 0 : 1);
+}
+```
+
+The Vitest test imports `runCheck` and asserts the structure (skipping the actual filesystem check if `dist/` is absent at test time).
+
+**Step 3 — Update `package.json`:**
+
+Add to scripts:
+```json
+"check:bundle-split": "node scripts/check-bundle-split.mjs",
+"ci": "npm run lint && npm run test && npm run validate:assets && npm run build && npm run check:bundle-split"
+```
+
+This places `check:bundle-split` AFTER `build` in the CI chain so dist/ is populated before the assertion runs.
+
+**Step 4 — Verify the script works on a fresh build:**
+
+Run from repo root:
+```
+rm -rf dist
+npm run build
+node scripts/check-bundle-split.mjs
+```
+Expect exit code 0 with the success message. If it fails, inspect dist/assets/ output and adjust the matching heuristic in `runCheck()`.
+
+**Defended option:** If the heuristic is fragile (e.g., Vite renames chunks differently in production builds), document in SUMMARY.md and consider adding `vite.config.ts` `build.rollupOptions.output.manualChunks` to force a `season1` chunk name. Don't auto-add this configuration; surface as Plan 02-05 follow-up.
+
+**Commit:** `chore(02-03): scripts/check-bundle-split.mjs (PIPE-02 structural verification)`. Run `npm run ci` before committing.
+
+
+ - `test -f scripts/check-bundle-split.mjs`
+ - `grep -q "runCheck" scripts/check-bundle-split.mjs` (refactored to allow Vitest import)
+ - `grep -q "check:bundle-split" package.json`
+ - `grep -q "npm run check:bundle-split" package.json` (in scripts.ci)
+ - Running `node scripts/check-bundle-split.mjs` after `npm run build` exits 0 with success message
+ - `npx vitest run scripts/check-bundle-split.test.mjs` exits 0
+ - `npm run ci` exits 0 end-to-end
+
+
+ npm run lint && npm run build && node scripts/check-bundle-split.mjs && npx vitest run scripts/check-bundle-split.test.mjs && npm run ci
+
+
+ PIPE-02 structural verification script exists, integrated into CI chain. `npm run ci` exits 0 with the new step in place. If the heuristic needs tuning post-build, surface in SUMMARY.md.
+
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Authored content boundary | Fragment body strings are repo-controlled (not user-supplied); Zod-validated at module-eval. React renders as text, no dangerouslySetInnerHTML. |
+| Sim ↔ content boundary | sim/memory imports the Fragment[] via injected SimContext; no module-load coupling between sim and Vite's import.meta.glob. |
+| Selector seed boundary | mulberry32 seed derives from sim state (harvestCount + plantedAtTick); no wall-clock leak. |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-02-03-01 | Tampering | Player edits harvestedFragmentIds via DevTools | accept | Single-player; CRC-32 detects accidental save corruption only (per Phase 1 doctrine). |
+| T-02-03-02 | Tampering | Numeric / non-stable fragment ID injected via authoring | mitigate | FragmentSchema regex `/^season\d+\.[a-z0-9._-]+$/` enforced at module-eval (Phase 1 PIPE-01); `npm run build` fails on schema violation. |
+| T-02-03-03 | Information disclosure | Fragment body XSS via Markdown / YAML | mitigate | gray-matter + yaml parsers handle content; React renders inside `
` with text content (not HTML); `userSelect: 'text'` doesn't change escape semantics. No dangerouslySetInnerHTML in Journal or RevealModal. |
+| T-02-03-04 | Tampering | Selector returns same fragment via seed manipulation | accept | Seed is pure function of sim state; even if a player manipulates state, no-dup logic ensures progression. |
+| T-02-03-05 | Denial-of-service | Massive fragment file slows initial load | mitigate | PIPE-02 lazy split keeps Season-2-7 out of initial bundle. Phase 2 only ships Season 1 (~12 fragments, <10KB). check-bundle-split.mjs verifies the lazy structure. |
+
+No `high` severity threats. The selector + content surface is small and well-bounded.
+
+
+
+
+After all 3 tasks committed:
+
+1. **Linter:** `npm run lint` exits 0.
+2. **Tests:** `npx vitest run` exits 0; new tests: `src/sim/memory/selector.test.ts` (≥8 cases), `src/sim/memory/pool.test.ts` (optional), `src/sim/garden/commands.test.ts` extended with harvest/compost (≥6 new cases), `src/ui/journal/Journal.test.tsx` (≥6 cases), `src/ui/journal/FragmentRevealModal.test.tsx` (≥5 cases), `scripts/check-bundle-split.test.mjs` (≥2 cases). Combined Phase-1+Phase-2 test count ≥150.
+3. **Build:** `npm run build` exits 0; ≥10 fragments in `/content/seasons/01-soil/` parse without schema violation.
+4. **PIPE-02 structural verify:** `node scripts/check-bundle-split.mjs` exits 0 after build.
+5. **Full CI:** `npm run ci` exits 0 (now includes `check:bundle-split` step).
+6. **Manual smoke** (executor performs once): `npm run dev`, plant rosemary on tile 0, wait 2 minutes (or use FakeClock injection from Plan 02-05's URL flag if landed), click ready plant → reveal modal pops with the selected Season-1 fragment → close → journal icon appears in corner → click icon → journal modal shows the fragment. Plant another rosemary, harvest, then a third — confirm `unlockedPlantTypes` now includes 'yarrow' (visible in the seed picker as a new selectable option).
+
+
+
+
+
+Plan 02-03 is complete when:
+
+- [ ] All 3 tasks committed.
+- [ ] `npm run ci` exits 0 (now with `check:bundle-split` integrated).
+- [ ] Active-play harvest loop works end-to-end: ready plant → click → reveal modal → close → journal icon → journal modal.
+- [ ] ≥10 Season-1 fragments authored under /content/seasons/01-soil/, all matching bible voice + stable string ID rule.
+- [ ] Plant-type unlock thresholds (yarrow at 3 / winter-rose at 6) take effect (Pitfall 10 boundary tested).
+- [ ] Compost works (immature plant → tile clears).
+- [ ] PIPE-02 structurally verified.
+- [ ] MEMR-05 satisfied: Journal text is selectable + copy-pasteable DOM (Vitest covers, manual confirms via browser DevTools).
+- [ ] D-23, D-24, D-25 all visibly satisfied in dev build.
+- [ ] Plan 02-04 (Lura's Ink dialogue) and Plan 02-05 (offline + letter + e2e) can build on this.
+
+
+
+
diff --git a/.planning/phases/02-season-1-vertical-slice-soil/02-04-lura-gate-beats-PLAN.md b/.planning/phases/02-season-1-vertical-slice-soil/02-04-lura-gate-beats-PLAN.md
new file mode 100644
index 0000000..9785a00
--- /dev/null
+++ b/.planning/phases/02-season-1-vertical-slice-soil/02-04-lura-gate-beats-PLAN.md
@@ -0,0 +1,1367 @@
+---
+phase: 02
+plan: 04
+type: execute
+wave: 2
+depends_on: [02-01, 02-02, 02-03]
+files_modified:
+ - scripts/compile-ink.mjs
+ - scripts/compile-ink.test.mjs
+ - package.json
+ - .gitignore
+ - content/dialogue/season1/lura-arrival.ink
+ - content/dialogue/season1/lura-mid.ink
+ - content/dialogue/season1/lura-farewell.ink
+ - content/dialogue/season1/compost-acknowledgements.ink
+ - content/dialogue/season1/lura-greeting-template.ink
+ - src/sim/narrative/lura-gate.ts
+ - src/sim/narrative/lura-gate.test.ts
+ - src/sim/narrative/beat-queue.ts
+ - src/sim/narrative/index.ts
+ - src/sim/garden/commands.ts
+ - src/sim/garden/commands.test.ts
+ - src/content/ink-loader.ts
+ - src/content/ink-loader.test.ts
+ - src/content/index.ts
+ - src/ui/dialogue/LuraDialogue.tsx
+ - src/ui/dialogue/LuraDialogue.test.tsx
+ - src/ui/dialogue/ink-renderer.tsx
+ - src/ui/dialogue/ink-runtime.ts
+ - src/ui/dialogue/ink-runtime.test.ts
+ - src/ui/dialogue/index.ts
+ - src/ui/index.ts
+ - src/render/garden/gate-renderer.ts
+ - src/render/garden/index.ts
+ - src/game/scenes/Garden.ts
+ - src/App.tsx
+autonomous: true
+requirements: [STRY-01, STRY-06, STRY-07, STRY-10]
+tags: [vertical-slice, lura, ink, dialogue-overlay, narrative-gating, mvp]
+
+must_haves:
+ truths:
+ - "All Lura dialogue is authored in Ink (.ink) under /content/dialogue/season1/; compiled at build time to JSON via `npm run compile:ink` invoking inklecate (STRY-06)"
+ - "Beat 1 (arrival) fires when state.harvestedFragmentIds.length transitions from 0 to 1 (1st harvest); beat 2 (mid) at 4th harvest; beat 3 (farewell) at 8th harvest. Counts come from sim state — STRY-10."
+ - "STRY-10: FakeClock advance alone (without harvest events) does NOT advance Lura beats. Tested in lura-gate.test.ts."
+ - "When a beat fires, sim sets state.luraBeatProgress.pending = beatId; the gate visual (in Phaser) shows a soft glow indicator (D-15). Player clicks the gate → React DOM dialogue overlay opens (D-15)."
+ - "Dialogue overlay uses inkjs Story to drive lines; text-message-cadence renders one line at a time with a tunable delay (RESEARCH p.800: 800ms × line length / 40 chars or simpler fixed 1500ms)"
+ - "Lura's Ink branches read sim state via story.variablesState — at minimum: fragment_count, last_plant_type, last_fragment_title (slot vocabulary documented in PATTERNS.md row 'Group J')"
+ - "After dismissing a beat, sim sets the beat's progress flag to true and clears `pending`; subsequent harvests advance toward the next threshold"
+ - "Compost acknowledgements (D-07 + GARD-04) ship as a small Ink file (compost-acknowledgements.ink) with 3–5 short lines; sim sets a beat flag for compost; the Lura dialogue overlay (or a thinner toast variant) plays the line"
+ - "All player-visible Ink content matches bible voice: warm + specific + intermittent; Lura is the warmth anchor, not a co-griever"
+ - "STRY-07: vacuously satisfied — Phase 2 ships zero Keeper-spoken lines (no Ink file says 'Keeper says...'); documented in SUMMARY"
+ - "Sim does NOT import inkjs (Architectural Responsibility Map line 40: Ink runtime lives in UI tier); narrative gating is pure-state"
+ - "compile-ink.mjs runs cleanly on Windows + macOS + Linux (RESEARCH Assumption A6 verification — first real inklecate invocation in the project)"
+ - "Compiled .ink.json output lives in src/content/compiled-ink/ and is .gitignore'd; the build pipeline regenerates on every `npm run build`"
+ - "npm run ci is green; the new compile-ink.mjs + compiled-ink path participate"
+ artifacts:
+ - path: scripts/compile-ink.mjs
+ provides: "Build-time inklecate runner — walks /content/dialogue/**/*.ink, emits to src/content/compiled-ink//.ink.json"
+ - path: content/dialogue/season1/lura-arrival.ink
+ provides: "Authored Ink for Lura's first beat (after 1st harvest); reads `fragment_count`, `last_plant_type`"
+ - path: content/dialogue/season1/lura-mid.ink
+ provides: "Authored Ink for Lura's mid beat (after 4th harvest); reads same variables"
+ - path: content/dialogue/season1/lura-farewell.ink
+ provides: "Authored Ink for Lura's farewell beat (after 8th harvest)"
+ - path: content/dialogue/season1/compost-acknowledgements.ink
+ provides: "3–5 short lines in voice for the compost tonal beat (GARD-04, D-07, replaces Plan 02-03's TODO)"
+ - path: src/sim/narrative/lura-gate.ts
+ provides: "Pure tick-count gate — checks harvestedFragmentIds.length against {1, 4, 8} thresholds; returns next pending beat id (D-14 + STRY-10)"
+ exports: ["LURA_BEAT_THRESHOLDS", "checkLuraBeatGate", "advanceLuraBeatProgress"]
+ - path: src/sim/narrative/beat-queue.ts
+ provides: "Beat queue type contracts mirroring V1Payload.luraBeatProgress shape"
+ exports: ["LuraBeatId", "LuraBeatProgress"]
+ - path: src/content/ink-loader.ts
+ provides: "Lazy runtime loader for compiled Ink JSON; instantiates inkjs Story with story.variablesState bound from store"
+ exports: ["loadInkStory", "bindGardenStateToInk", "INK_VARIABLE_MAP"]
+ - path: src/ui/dialogue/LuraDialogue.tsx
+ provides: "DOM dialogue overlay (D-15); text-message-cadence drip; opens when narrative.dialogueOverlayOpen=true"
+ exports: ["LuraDialogue"]
+ - path: src/ui/dialogue/ink-runtime.ts
+ provides: "Thin wrapper around inkjs Story.Continue() + currentChoices; binds variables from store snapshot before first Continue"
+ exports: ["InkRuntime", "createInkRuntime"]
+ - path: src/render/garden/gate-renderer.ts
+ provides: "Phaser primitive gate visual + indicator on luraBeatProgress.pending != null"
+ exports: ["drawGate", "updateGateIndicator"]
+ key_links:
+ - from: src/sim/garden/commands.ts
+ to: src/sim/narrative/lura-gate.ts
+ via: "harvest() calls advanceLuraBeatProgress to update state.luraBeatProgress.pending after appending to harvestedFragmentIds"
+ pattern: "advanceLuraBeatProgress"
+ - from: src/ui/dialogue/LuraDialogue.tsx
+ to: src/content/ink-loader.ts
+ via: "loadInkStory(beatId) returns inkjs Story; LuraDialogue drives Continue/choices via ink-runtime"
+ pattern: "loadInkStory"
+ - from: src/render/garden/gate-renderer.ts
+ to: src/store/index.ts
+ via: "Garden scene reads narrativeSlice.luraBeatProgress.pending; updates gate indicator visibility"
+ pattern: "luraBeatProgress.pending"
+ - from: src/game/scenes/Garden.ts
+ to: src/store/index.ts
+ via: "Garden scene's gate pointerdown calls setDialogueOverlayOpen(true) when a beat is pending"
+ pattern: "setDialogueOverlayOpen"
+ - from: src/ui/dialogue/ink-runtime.ts
+ to: "inkjs Story.variablesState"
+ via: "story.variablesState['fragment_count'] = snapshot.harvestedFragmentIds.length (Pitfall 4: snake_case mandatory)"
+ pattern: "variablesState"
+---
+
+
+**Wave 2 vertical slice. Depends on Plans 02-01, 02-02, 02-03.**
+
+This plan ships Lura's three Ink-authored beats end-to-end: from sim-state harvest count → narrative gating → gate indicator on the canvas → React DOM dialogue overlay reading and rendering Ink.
+
+Runs in parallel with Plan 02-05 (Letter + Settings + e2e). Plan 02-05 depends on this plan's `lura_was_here` slot output but only structurally; the merge moment is small.
+
+3 tasks. Estimated context cost ~50%. The first task is the load-bearing inklecate verification (RESEARCH Assumption A6, MEDIUM risk) — if compile-ink.mjs doesn't work on Windows, the executor must surface in SUMMARY.md and adjust before authoring further content.
+
+
+
+Land Lura's three Ink-authored Season 1 beats: arrival (after 1st harvest), mid (after 4th harvest), farewell (after 8th harvest), gated on sim-state harvest count (STRY-10 — system clock manipulation cannot fast-forward beats). Replace the no-op `compile:ink` script with a real inklecate runner; author the four Ink files (3 Lura beats + compost acknowledgements); ship the runtime path (`inkjs.Story` instantiation + variable binding + drip cadence DOM rendering); add a soft gate indicator in the Phaser canvas; wire the player-initiated visit (player clicks gate → React DOM dialogue overlay opens).
+
+Purpose: First real player-narrative integration in the project. Validates the entire Ink stack (inklecate compile → JSON → inkjs runtime → variable wiring → React rendering) end-to-end, on real authored content. Phase 4+ (Roots, Canopy, Storm, etc.) inherits this pipeline without rework. Lura is the warmth anchor for the whole arc — Phase 2 is where her voice goes on the record.
+
+Output: Working narrative-state plumbing where harvesting cadence drives Lura's appearances, three short authored beats reading in voice, the Ink → JSON → runtime pipeline structurally verified, and the foundation for Phase 4+'s longer arcs.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@CLAUDE.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/02-season-1-vertical-slice-soil/02-03-harvest-journal-fragments-SUMMARY.md
+
+
+
+
+From src/store/index.ts (Plan 02-01):
+```typescript
+luraBeatProgress: {
+ arrived: boolean; mid: boolean; farewell: boolean;
+ pending: 'arrival' | 'mid' | 'farewell' | null;
+};
+dialogueOverlayOpen: boolean;
+setLuraBeatProgress(p): void;
+setDialogueOverlayOpen(open: boolean): void;
+type LuraBeatId = 'arrival' | 'mid' | 'farewell';
+```
+
+From src/sim/garden/commands.ts (Plan 02-03):
+```typescript
+export function harvest(state: SimState, tileIdx: number, currentTick: number, ctx: SimContext): SimState;
+// ^^ extend to ALSO call advanceLuraBeatProgress on the new harvest count
+```
+
+From src/sim/state.ts (Plan 02-01):
+```typescript
+export interface SimState {
+ ...
+ luraBeatProgress: { arrived: boolean; mid: boolean; farewell: boolean; pending: ... | null };
+ harvestedFragmentIds: string[];
+ ...
+}
+```
+
+From src/content/index.ts (Plan 02-02):
+```typescript
+export const fragments: Fragment[]; // for last_fragment_title slot
+export const uiStrings: Record;
+```
+
+From src/game/event-bus.ts (Plan 02-01):
+```typescript
+export const eventBus: Phaser.Events.EventEmitter;
+// Events Phase 2 emits:
+// 'gate-clicked' (Phaser → React; emitted when player clicks gate visual)
+```
+
+From inkjs (installed v2.4.0; verified via node_modules/inkjs/ink.d.mts):
+```typescript
+import { Story } from 'inkjs';
+const story = new Story(jsonString);
+story.variablesState['fragment_count'] = 5; // SNAKE_CASE per Ink convention; Pitfall 4 says casing must match
+const line = story.Continue(); // returns next text line
+const choices = story.currentChoices; // array of Choice objects (with text, index)
+story.ChooseChoiceIndex(0); // advances on chosen choice
+const canContinue = story.canContinue; // bool
+```
+
+From inklecate (installed v1.8.1; verified via package.json + node_modules/inklecate/):
+The npm wrapper exposes the inklecate binary. RESEARCH Assumption A6 flags that the Windows binary path needs to work; first real run is THIS plan. Use the wrapper's exported function rather than direct binary path:
+```javascript
+import inklecate from 'inklecate';
+// API surface to confirm: inklecate({ inputFilepath, outputFilepath })
+// — readme + actual API surface verified during Task 1
+```
+
+From .gitignore (current — extend):
+```
+# (existing entries) — Plan 02-04 ADDS:
+# Compiled Ink output (regenerated by `npm run compile:ink`)
+src/content/compiled-ink/
+```
+
+
+
+
+
+
+ Task 1: Author all 4 Ink files (3 Lura beats + compost) + scripts/compile-ink.mjs + ink-loader runtime + RESEARCH A6 inklecate verification
+
+ - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 5 lines 741-800, Pattern 6 lines 802-840, Assumption A6 line 1213, Pitfall 4 lines 1057-1074)
+ - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group J lines 521-554)
+ - node_modules/inklecate/package.json + node_modules/inklecate/README.md (verify exact API)
+ - node_modules/inkjs/ink.d.mts (Story + variablesState API)
+ - CLAUDE.md (Tone — Lura voice; warmth anchor)
+ - .planning/anti-fomo-doctrine.md (the dialogue must comply: no nag, no FOMO, contemplative)
+
+
+ scripts/compile-ink.mjs,
+ scripts/compile-ink.test.mjs,
+ package.json,
+ .gitignore,
+ content/dialogue/season1/lura-arrival.ink,
+ content/dialogue/season1/lura-mid.ink,
+ content/dialogue/season1/lura-farewell.ink,
+ content/dialogue/season1/compost-acknowledgements.ink,
+ src/content/ink-loader.ts,
+ src/content/ink-loader.test.ts,
+ src/content/index.ts
+
+
+**Step 1 — Verify inklecate API (Assumption A6, MEDIUM risk).**
+
+Read `node_modules/inklecate/README.md` and `node_modules/inklecate/package.json` to confirm:
+- The default export's call signature.
+- The Windows binary path (e.g., `node_modules/inklecate/inklecate-windows/inklecate.exe`).
+
+If the package wrapper exposes a function like `inklecate({ inputFilepath, outputFilepath })`, use it. If it only exposes a CLI binary path, use Node's `child_process.execFileSync` to invoke the platform-appropriate binary. **Document the chosen approach in compile-ink.mjs's leading comment.**
+
+**Step 2 — `scripts/compile-ink.mjs`** — build-time Ink compiler:
+
+```javascript
+#!/usr/bin/env node
+// Phase 2 Plan 02-04 — compile content/dialogue/**/*.ink → src/content/compiled-ink/**/*.ink.json
+//
+// Per RESEARCH Pattern 5 + Assumption A6 (verified on this run).
+//
+// API note: this script invokes the inklecate npm wrapper. If the wrapper
+// API differs at runtime, fall back to invoking the platform binary via
+// child_process.execFileSync — the wrapper's bin/ directory contains
+// inklecate-windows/, inklecate-linux/, inklecate-macos/ subdirectories.
+
+import { mkdirSync, existsSync, readdirSync, statSync, rmSync } from 'node:fs';
+import { dirname, join, relative, resolve } from 'node:path';
+import { execFileSync } from 'node:child_process';
+
+const INK_ROOT = resolve(process.cwd(), 'content/dialogue');
+const OUT_ROOT = resolve(process.cwd(), 'src/content/compiled-ink');
+
+function findInkFiles(root) {
+ const out = [];
+ if (!existsSync(root)) return out;
+ for (const entry of readdirSync(root)) {
+ const full = join(root, entry);
+ const st = statSync(full);
+ if (st.isDirectory()) out.push(...findInkFiles(full));
+ else if (entry.endsWith('.ink')) out.push(full);
+ }
+ return out;
+}
+
+function inklecateBinary() {
+ // Verified at Task 1 — node_modules/inklecate/ ships per-platform binaries.
+ // The wrapper module exports a programmatic API; if it does not, fall through here.
+ const platform = process.platform;
+ const root = resolve(process.cwd(), 'node_modules/inklecate');
+ if (platform === 'win32') return resolve(root, 'inklecate-windows/inklecate.exe');
+ if (platform === 'darwin') return resolve(root, 'inklecate-mac/inklecate');
+ return resolve(root, 'inklecate-linux/inklecate');
+}
+
+export async function compileAllInk() {
+ const files = findInkFiles(INK_ROOT);
+ if (files.length === 0) {
+ console.log('[compile:ink] no .ink files under content/dialogue/ — skipping');
+ return { compiled: 0 };
+ }
+
+ // Wipe stale output (regenerated every run; .gitignore'd)
+ if (existsSync(OUT_ROOT)) rmSync(OUT_ROOT, { recursive: true, force: true });
+
+ let compiled = 0;
+ // Try the wrapper API first (verified on Task 1 first run); fall back to binary.
+ let wrapper = null;
+ try {
+ wrapper = (await import('inklecate')).default;
+ } catch {
+ wrapper = null;
+ }
+
+ const binary = inklecateBinary();
+
+ for (const inkPath of files) {
+ const rel = relative(INK_ROOT, inkPath);
+ const outPath = resolve(OUT_ROOT, rel.replace(/\.ink$/, '.ink.json'));
+ mkdirSync(dirname(outPath), { recursive: true });
+
+ let didCompile = false;
+ if (wrapper && typeof wrapper === 'function') {
+ try {
+ await wrapper({ inputFilepath: inkPath, outputFilepath: outPath, countAllVisits: false });
+ didCompile = true;
+ } catch (err) {
+ console.warn(`[compile:ink] wrapper failed for ${inkPath} (${(err)?.message ?? err}); falling back to binary`);
+ }
+ }
+ if (!didCompile) {
+ // Inklecate CLI shape: inklecate -o
+ execFileSync(binary, ['-o', outPath, inkPath], { stdio: 'inherit' });
+ didCompile = true;
+ }
+ compiled++;
+ console.log(`[compile:ink] ${rel} → ${relative(process.cwd(), outPath)}`);
+ }
+ console.log(`[compile:ink] compiled ${compiled} files`);
+ return { compiled };
+}
+
+// CLI invocation
+if (import.meta.url === `file://${process.argv[1]}`) {
+ compileAllInk().catch((err) => {
+ console.error('[compile:ink] FAILED:', err);
+ process.exit(1);
+ });
+}
+```
+
+(If the executor finds the inklecate wrapper has a different API after reading the package, ADJUST. The key contract is: produces .ink.json output for each .ink input. Surface deviations in SUMMARY.md.)
+
+**Step 3 — `scripts/compile-ink.test.mjs`** — Vitest sanity test:
+
+```javascript
+import { describe, it, expect } from 'vitest';
+import { existsSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { compileAllInk } from './compile-ink.mjs';
+
+describe('scripts/compile-ink.mjs', () => {
+ it('exports compileAllInk', () => {
+ expect(typeof compileAllInk).toBe('function');
+ });
+
+ it('compiles all .ink files in content/dialogue and emits .ink.json under src/content/compiled-ink/', async () => {
+ const result = await compileAllInk();
+ expect(result.compiled).toBeGreaterThanOrEqual(4); // 3 Lura + compost
+ expect(existsSync(resolve(process.cwd(), 'src/content/compiled-ink/season1/lura-arrival.ink.json'))).toBe(true);
+ expect(existsSync(resolve(process.cwd(), 'src/content/compiled-ink/season1/compost-acknowledgements.ink.json'))).toBe(true);
+ });
+});
+```
+
+**Step 4 — Update `package.json`:**
+
+```json
+"compile:ink": "node scripts/compile-ink.mjs",
+"build": "npm run compile:ink && tsc -b && vite build",
+"ci": "npm run lint && npm run test && npm run validate:assets && npm run build && npm run check:bundle-split"
+```
+
+(`npm run build` now precompiles Ink before the TS+Vite build so the `import.meta.glob('/src/content/compiled-ink/**/*.ink.json')` glob can resolve.)
+
+**Step 5 — Update `.gitignore`** to exclude generated Ink JSON:
+
+```
+# Compiled Ink output — regenerated on every build by `npm run compile:ink`
+src/content/compiled-ink/
+```
+
+**Step 6 — Author Ink files.**
+
+`content/dialogue/season1/lura-arrival.ink` — beat 1 (after 1st harvest):
+
+```ink
+// Lura, arrival beat. After the player's first harvest.
+// Variables read from sim:
+// fragment_count - number of harvested fragments at the moment Lura arrives
+// last_plant_type - 'rosemary' | 'yarrow' | 'winter-rose'
+//
+// Per Pitfall 4: snake_case mandatory.
+// Per CLAUDE.md Tone: Lura is the warmth anchor. Not a co-griever.
+// Specific, intermittent, sometimes funny.
+
+VAR fragment_count = 0
+VAR last_plant_type = ""
+
+== arrival ==
+
+You're already here. I thought it might take you longer.
+
+{ last_plant_type == "rosemary":
+ Rosemary, of all things. My grandmother's whole apron, when she got too close to the pot.
+- else:
+ { last_plant_type == "yarrow":
+ Yarrow. There used to be a saying about yarrow but I can't remember it. That's the joke I think.
+ - else:
+ { last_plant_type == "winter-rose":
+ Winter-rose. You don't mess around. Most people start small.
+ - else:
+ Something grew. That's a start.
+ }
+ }
+}
+
+I won't stay long. I just wanted to know that the wall held.
+
+-> END
+```
+
+`content/dialogue/season1/lura-mid.ink` — beat 2 (after 4th harvest):
+
+```ink
+VAR fragment_count = 0
+VAR last_plant_type = ""
+
+== mid ==
+
+Four. That feels like a real number.
+
+I tried to do this once. The garden, I mean. It was a balcony. I had three pots and one of them was already broken when I bought it. The basil died first. The rosemary survived. I think the rosemary survives most things.
+
+You're keeping at it. Most people don't.
+
+I have something I should be doing. I'll come back when there's more.
+
+-> END
+```
+
+`content/dialogue/season1/lura-farewell.ink` — beat 3 (after 8th harvest):
+
+```ink
+VAR fragment_count = 0
+VAR last_plant_type = ""
+
+== farewell ==
+
+Eight is enough for now.
+
+I think we both know what this part is. I'm going to go for a while. There's something I've been putting off, and I think you're far enough along that I can stop pretending I'm here for the small reasons.
+
+You'll know when there's more to say. You don't need me at the gate every day.
+
+The garden persists. Some of it is mine. Most of it is yours now.
+
+-> END
+```
+
+`content/dialogue/season1/compost-acknowledgements.ink` — Plan 02-03's TODO replacement (D-07 + GARD-04):
+
+```ink
+// Compost acknowledgements — short tonal beats fired when the player
+// composts an immature plant. One line per call (the renderer picks
+// randomly via fragment_count seed for variety).
+
+VAR fragment_count = 0
+
+== compost ==
+
+{ fragment_count == 0:
+ Sometimes the soil needs a turn.
+- else:
+ {
+ - fragment_count % 4 == 0:
+ It wasn't ready. That's not the same as failing.
+ - fragment_count % 3 == 0:
+ Some things are easier to begin again than to finish.
+ - fragment_count % 2 == 0:
+ The earth keeps the part that was useful.
+ - else:
+ Letting go is a kind of tending.
+ }
+}
+
+-> END
+```
+
+(Lines designed to match bible voice. User reviews before merge.)
+
+**Step 7 — `src/content/ink-loader.ts`** — runtime loader (RESEARCH Pattern 5):
+
+```typescript
+import { Story } from 'inkjs';
+import type { AppStoreShape } from '../store';
+import { fragments as allFragments } from './loader';
+
+/**
+ * Runtime Ink loader — instantiates an inkjs Story from the compiled
+ * JSON for a given beat id, and binds variables from a store snapshot.
+ *
+ * Per RESEARCH Pattern 5 + Pitfall 4 (snake_case mandatory).
+ */
+
+const luraStories = import.meta.glob('/src/content/compiled-ink/season1/lura-*.ink.json', {
+ query: '?raw',
+ import: 'default',
+});
+
+const compostStory = import.meta.glob('/src/content/compiled-ink/season1/compost-acknowledgements.ink.json', {
+ query: '?raw',
+ import: 'default',
+});
+
+/**
+ * The variable map binds Ink VAR names (snake_case) to functions that
+ * read the current store snapshot. Centralized here per Pitfall 4 — keys
+ * here MUST match VAR declarations in the .ink files.
+ */
+export const INK_VARIABLE_MAP = {
+ fragment_count: (s: AppStoreShape) => s.harvestedFragmentIds.length,
+ last_plant_type: (s: AppStoreShape) => {
+ // Phase 2: derive from most-recent harvest's plant type. The
+ // harvestedFragmentIds list is fragment IDs, not plant types — we
+ // map back via the fragment's `tags` field (warm/contemplative/heavy)
+ // → a plant type. The most-recent fragment's tag is the simplest proxy.
+ const lastId = s.harvestedFragmentIds[s.harvestedFragmentIds.length - 1];
+ if (!lastId) return '';
+ const frag = allFragments.find((f) => f.id === lastId);
+ if (!frag?.tags) return '';
+ if (frag.tags.includes('warm')) return 'rosemary';
+ if (frag.tags.includes('contemplative')) return 'yarrow';
+ if (frag.tags.includes('heavy')) return 'winter-rose';
+ return '';
+ },
+} as const;
+
+export async function loadInkStory(name: 'lura-arrival' | 'lura-mid' | 'lura-farewell' | 'compost-acknowledgements'): Promise {
+ const path = name === 'compost-acknowledgements'
+ ? '/src/content/compiled-ink/season1/compost-acknowledgements.ink.json'
+ : `/src/content/compiled-ink/season1/${name}.ink.json`;
+ const loader = name === 'compost-acknowledgements'
+ ? compostStory[path]
+ : luraStories[path];
+ if (!loader) {
+ throw new Error(`[ink-loader] No compiled story at ${path}. Did npm run compile:ink succeed?`);
+ }
+ const json = (await loader()) as string;
+ return new Story(json);
+}
+
+/**
+ * Set Ink variables from the current store snapshot. Call BEFORE the
+ * first story.Continue(). Per Pitfall 4: variable names are snake_case
+ * AND case-sensitive — typos do NOT throw, they silently leave the var
+ * at its declared default.
+ */
+export function bindGardenStateToInk(story: Story, snapshot: AppStoreShape): void {
+ for (const [varName, getter] of Object.entries(INK_VARIABLE_MAP)) {
+ const value = (getter as (s: AppStoreShape) => string | number | boolean)(snapshot);
+ try {
+ story.variablesState[varName] = value;
+ } catch {
+ // Ink throws if the variable doesn't exist in the story — log and continue.
+ console.warn(`[ink-loader] variable ${varName} not declared in this Ink story (silently skipped)`);
+ }
+ }
+}
+```
+
+**Step 8 — `src/content/ink-loader.test.ts`** — Vitest:
+
+- `loadInkStory('lura-arrival')` returns a `Story` instance (smoke).
+- `loadInkStory('compost-acknowledgements')` returns a Story.
+- `bindGardenStateToInk(story, snapshot)` sets `story.variablesState['fragment_count']` to `snapshot.harvestedFragmentIds.length`.
+- `bindGardenStateToInk` does not throw on a story missing a declared var (the warn is silent).
+- Variable casing test (Pitfall 4): every key in `INK_VARIABLE_MAP` is snake_case. Programmatic assertion: `Object.keys(INK_VARIABLE_MAP).every(k => /^[a-z_]+$/.test(k))`.
+
+This test requires Ink JSON to be present, which requires `npm run compile:ink` to have run. Add a `beforeAll` that runs the compile script:
+
+```typescript
+import { beforeAll } from 'vitest';
+import { compileAllInk } from '../../scripts/compile-ink.mjs';
+beforeAll(async () => { await compileAllInk(); });
+```
+
+**Step 9 — Update `src/content/index.ts`** to re-export ink-loader:
+```typescript
+export { loadInkStory, bindGardenStateToInk, INK_VARIABLE_MAP } from './ink-loader';
+```
+
+**Verification before commit:**
+
+Run from repo root:
+```
+npm run compile:ink
+ls src/content/compiled-ink/season1/ # Should list 4 .ink.json files
+npm run lint
+npx vitest run src/content/ink-loader.test.ts scripts/compile-ink.test.mjs
+npm run build # Should compile Ink + TS + Vite all green
+```
+
+If `compile:ink` fails on Windows (Assumption A6 risk), DOCUMENT in SUMMARY.md and adjust the `inklecateBinary()` resolution. Try `npx inklecate` as a last fallback.
+
+**Commit:** `feat(02-04): ink compilation pipeline + 4 authored Season-1 Ink files + runtime loader`. Run `npm run ci` before committing.
+
+
+ - `test -f scripts/compile-ink.mjs && grep -q "compileAllInk" scripts/compile-ink.mjs`
+ - `test -f content/dialogue/season1/lura-arrival.ink`
+ - `test -f content/dialogue/season1/lura-mid.ink`
+ - `test -f content/dialogue/season1/lura-farewell.ink`
+ - `test -f content/dialogue/season1/compost-acknowledgements.ink`
+ - `grep -q "VAR fragment_count" content/dialogue/season1/lura-arrival.ink`
+ - `grep -q "compile:ink" package.json && grep -q "node scripts/compile-ink.mjs" package.json`
+ - `grep -q "src/content/compiled-ink/" .gitignore`
+ - After `npm run compile:ink`: `ls src/content/compiled-ink/season1/*.ink.json | wc -l` returns ≥4
+ - `grep -q "INK_VARIABLE_MAP" src/content/ink-loader.ts`
+ - `grep -q "snake_case\\|fragment_count\\|last_plant_type" src/content/ink-loader.ts`
+ - `grep -q "loadInkStory" src/content/index.ts`
+ - `npm run ci` exits 0 (now compiles Ink as part of the build chain)
+
+
+ npm run compile:ink && npm run lint && npx vitest run src/content/ink-loader.test.ts scripts/compile-ink.test.mjs && npm run ci
+
+
+ Ink compile pipeline lands. 4 Season-1 .ink files authored in voice. compile:ink runs cleanly on the dev machine (Assumption A6 verified). Runtime loader instantiates inkjs Story + binds variables. Ink JSON output gitignored. `npm run ci` passes end-to-end.
+
+
+
+
+ Task 2: sim/narrative — Lura beat gating (1st/4th/8th harvest, STRY-10) + harvest-command integration
+
+ - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Lura Beat Gating section + Validation Architecture row STRY-10)
+ - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group E lines 312-346)
+ - src/sim/garden/commands.ts (Plan 02-03 — extend `harvest` to call advanceLuraBeatProgress)
+ - src/sim/garden/commands.test.ts (extend with STRY-10 test)
+ - src/sim/state.ts (luraBeatProgress shape)
+
+
+ src/sim/narrative/lura-gate.ts,
+ src/sim/narrative/lura-gate.test.ts,
+ src/sim/narrative/beat-queue.ts,
+ src/sim/narrative/index.ts,
+ src/sim/garden/commands.ts,
+ src/sim/garden/commands.test.ts,
+ src/sim/index.ts
+
+
+**Step 1 — `src/sim/narrative/beat-queue.ts`** — type contracts:
+
+```typescript
+/**
+ * Lura beat type contracts. Shape mirrors V1Payload.luraBeatProgress
+ * declared in src/save/migrations.ts (Plan 02-01 D-34 extension).
+ */
+
+export type LuraBeatId = 'arrival' | 'mid' | 'farewell';
+
+export interface LuraBeatProgress {
+ arrived: boolean;
+ mid: boolean;
+ farewell: boolean;
+ pending: LuraBeatId | null;
+}
+
+export const INITIAL_LURA_BEAT_PROGRESS: LuraBeatProgress = Object.freeze({
+ arrived: false,
+ mid: false,
+ farewell: false,
+ pending: null,
+});
+```
+
+**Step 2 — `src/sim/narrative/lura-gate.ts`** — pure tick-count gate (PATTERNS Group E):
+
+```typescript
+import type { LuraBeatId, LuraBeatProgress } from './beat-queue';
+
+/**
+ * Lura beat thresholds (CONTEXT D-14). Gate fires when harvestedFragmentIds.length
+ * reaches each threshold value (Pitfall 10: check AFTER the harvest commit).
+ *
+ * Per STRY-10: gates on tick count (harvest events), NOT wall time. A
+ * player who manipulates their system clock cannot fast-forward Lura's
+ * beats — only harvesting does. The harvest function in
+ * src/sim/garden/commands.ts calls advanceLuraBeatProgress with the
+ * post-commit harvestedFragmentIds.length.
+ */
+export const LURA_BEAT_THRESHOLDS: Readonly> = Object.freeze({
+ 1: 'arrival',
+ 4: 'mid',
+ 8: 'farewell',
+});
+
+/**
+ * Given the current LuraBeatProgress and a new harvest count, returns
+ * the (possibly-updated) LuraBeatProgress. Sets `pending` if a threshold
+ * was just crossed AND the corresponding flag is not already set.
+ *
+ * Pure. No side effects.
+ */
+export function advanceLuraBeatProgress(
+ progress: LuraBeatProgress,
+ harvestCount: number,
+): LuraBeatProgress {
+ // If a beat is already pending, don't replace it (player must visit before next fires)
+ if (progress.pending !== null) return progress;
+
+ for (const [threshold, beatId] of Object.entries(LURA_BEAT_THRESHOLDS)) {
+ const t = Number(threshold);
+ if (harvestCount === t) {
+ // Has the corresponding flag already been resolved?
+ const flagKey = beatId === 'arrival' ? 'arrived' : (beatId === 'mid' ? 'mid' : 'farewell');
+ if (progress[flagKey]) continue; // already visited; never re-fire (D-13: 3 beats total)
+ return { ...progress, pending: beatId };
+ }
+ }
+ return progress;
+}
+
+/**
+ * Called when the player closes a Lura dialogue overlay. Marks the
+ * pending beat as visited and clears `pending`.
+ */
+export function resolvePendingLuraBeat(progress: LuraBeatProgress): LuraBeatProgress {
+ if (!progress.pending) return progress;
+ if (progress.pending === 'arrival') return { ...progress, arrived: true, pending: null };
+ if (progress.pending === 'mid') return { ...progress, mid: true, pending: null };
+ if (progress.pending === 'farewell') return { ...progress, farewell: true, pending: null };
+ return progress;
+}
+
+/**
+ * Has any beat fired and is awaiting visit? Used by the gate-renderer
+ * (Phaser) to decide whether to draw the indicator (D-15).
+ */
+export function isLuraBeatPending(progress: LuraBeatProgress): boolean {
+ return progress.pending !== null;
+}
+```
+
+**Step 3 — `src/sim/narrative/lura-gate.test.ts`** — Vitest, esp. STRY-10 case:
+
+```typescript
+import { describe, it, expect } from 'vitest';
+import { FakeClock } from '../scheduler';
+import { advanceLuraBeatProgress, resolvePendingLuraBeat, isLuraBeatPending, LURA_BEAT_THRESHOLDS } from './lura-gate';
+import { INITIAL_LURA_BEAT_PROGRESS } from './beat-queue';
+
+describe('advanceLuraBeatProgress (STRY-10, D-14)', () => {
+ it('sets pending=arrival on the 1st harvest', () => {
+ const next = advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 1);
+ expect(next.pending).toBe('arrival');
+ expect(next.arrived).toBe(false); // not yet visited
+ });
+
+ it('does NOT set pending at harvest count 0', () => {
+ const next = advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 0);
+ expect(next.pending).toBeNull();
+ });
+
+ it('does NOT set pending at counts between thresholds (2, 3, 5, 6, 7)', () => {
+ [2, 3, 5, 6, 7].forEach((c) => {
+ const next = advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, c);
+ expect(next.pending).toBeNull();
+ });
+ });
+
+ it('Pitfall 10 (off-by-one boundary): threshold 4 fires AT 4, not 3 or 5', () => {
+ expect(advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 3).pending).toBeNull();
+ expect(advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 4).pending).toBe('mid');
+ expect(advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 5).pending).toBeNull();
+ });
+
+ it('does NOT replace a pending beat with a different one (player must visit first)', () => {
+ let p = advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 1); // pending=arrival
+ p = advanceLuraBeatProgress(p, 4);
+ expect(p.pending).toBe('arrival'); // unchanged
+ });
+
+ it('does NOT re-fire an already-visited beat', () => {
+ let p: any = { ...INITIAL_LURA_BEAT_PROGRESS, arrived: true };
+ p = advanceLuraBeatProgress(p, 1);
+ expect(p.pending).toBeNull();
+ });
+
+ it('STRY-10: FakeClock advance does NOT advance Lura beats without harvest events', () => {
+ const clock = new FakeClock(0);
+ const initialProgress = INITIAL_LURA_BEAT_PROGRESS;
+ clock.advance(60 * 60 * 1000); // 1 hour of "wall time"
+ // No harvests fired — the gate function is invoked with harvestCount=0
+ const after = advanceLuraBeatProgress(initialProgress, 0);
+ expect(after).toEqual(INITIAL_LURA_BEAT_PROGRESS);
+ });
+});
+
+describe('resolvePendingLuraBeat', () => {
+ it('marks arrival as resolved and clears pending', () => {
+ const p = { ...INITIAL_LURA_BEAT_PROGRESS, pending: 'arrival' as const };
+ const next = resolvePendingLuraBeat(p);
+ expect(next.arrived).toBe(true);
+ expect(next.pending).toBeNull();
+ });
+
+ it('marks mid + farewell similarly', () => {
+ const m = resolvePendingLuraBeat({ ...INITIAL_LURA_BEAT_PROGRESS, pending: 'mid' });
+ expect(m.mid).toBe(true);
+ const f = resolvePendingLuraBeat({ ...INITIAL_LURA_BEAT_PROGRESS, pending: 'farewell' });
+ expect(f.farewell).toBe(true);
+ });
+
+ it('is a no-op when pending=null', () => {
+ expect(resolvePendingLuraBeat(INITIAL_LURA_BEAT_PROGRESS)).toEqual(INITIAL_LURA_BEAT_PROGRESS);
+ });
+});
+
+describe('isLuraBeatPending', () => {
+ it('returns true when pending is set', () => {
+ expect(isLuraBeatPending({ ...INITIAL_LURA_BEAT_PROGRESS, pending: 'arrival' })).toBe(true);
+ });
+ it('returns false when no beat pending', () => {
+ expect(isLuraBeatPending(INITIAL_LURA_BEAT_PROGRESS)).toBe(false);
+ });
+});
+```
+
+**Step 4 — `src/sim/narrative/index.ts`:**
+```typescript
+export { LURA_BEAT_THRESHOLDS, advanceLuraBeatProgress, resolvePendingLuraBeat, isLuraBeatPending } from './lura-gate';
+export type { LuraBeatId, LuraBeatProgress } from './beat-queue';
+export { INITIAL_LURA_BEAT_PROGRESS } from './beat-queue';
+```
+
+Add `export * from './narrative'` to `src/sim/index.ts`.
+
+**Step 5 — Extend `src/sim/garden/commands.ts`** — `harvest` now updates `luraBeatProgress`:
+
+In the harvest function, after the `harvestedIds = [...]` line and before computing `unlockedPlantTypes`:
+
+```typescript
+import { advanceLuraBeatProgress } from '../narrative/lura-gate';
+
+// ... inside harvest():
+const luraBeatProgress = advanceLuraBeatProgress(state.luraBeatProgress, harvestedIds.length);
+
+return {
+ ...state,
+ garden: { tiles: nextTiles },
+ harvestedFragmentIds: harvestedIds,
+ unlockedPlantTypes,
+ luraBeatProgress,
+};
+```
+
+**Step 6 — Extend `src/sim/garden/commands.test.ts`** with integration tests:
+
+- After harvesting 1 ready plant, `state.luraBeatProgress.pending` is `'arrival'`.
+- After harvesting 4 ready plants (with `arrived=true` set after the 1st), `state.luraBeatProgress.pending` is `'mid'`.
+- Harvest count 5 with `pending='mid'` (player hasn't visited yet) leaves `pending='mid'`.
+- After 8 harvests with 1+4 already visited, `pending='farewell'`.
+
+**Commit:** `feat(02-04): sim/narrative — Lura beat gating (1/4/8 harvest, STRY-10)`. Run `npm run lint && npx vitest run src/sim/narrative/ src/sim/garden/` before committing.
+
+
+ - `grep -q "LURA_BEAT_THRESHOLDS" src/sim/narrative/lura-gate.ts`
+ - `grep -q "1: 'arrival'" src/sim/narrative/lura-gate.ts`
+ - `grep -q "4: 'mid'" src/sim/narrative/lura-gate.ts`
+ - `grep -q "8: 'farewell'" src/sim/narrative/lura-gate.ts`
+ - `grep -q "advanceLuraBeatProgress" src/sim/garden/commands.ts` (harvest integration)
+ - `grep -L "Date.now\\|setInterval" src/sim/narrative/lura-gate.ts src/sim/narrative/beat-queue.ts` (sim purity)
+ - `grep -q "FakeClock" src/sim/narrative/lura-gate.test.ts` (STRY-10 test exists)
+ - `npx vitest run src/sim/narrative/ src/sim/garden/` exits 0; ≥10 new test cases green; STRY-10 case present
+ - `npm run lint && npm run build` exits 0
+
+
+ npm run lint && npx vitest run src/sim/narrative/ src/sim/garden/ && npm run build
+
+
+ sim/narrative module ships pure tick-count Lura gate. STRY-10 test case proves FakeClock alone does not advance beats. harvest() in commands.ts updates state.luraBeatProgress on threshold crossings (Pitfall 10 boundary tested). All Phase-2 sim modules pass sim-purity ESLint rule.
+
+
+
+
+ Task 3: ui/dialogue (LuraDialogue + ink-renderer + ink-runtime) + render/garden gate-renderer + Garden scene integration + App.tsx mount
+
+ - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 5 lines 776-800 drip cadence, Architectural Responsibility Map row "Ink runtime bridge")
+ - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group I lines 471-518 React mounting; Group H lines 426-468 render layer)
+ - src/store/index.ts (narrativeSlice: dialogueOverlayOpen, luraBeatProgress, setDialogueOverlayOpen, setLuraBeatProgress)
+ - src/game/scenes/Garden.ts (Plan 02-02 + 02-03 — extend with gate object + pointerdown)
+ - src/render/garden/tile-coords.ts (Plan 02-02 — gate sits in canvas alongside grid; reuse layout constants)
+ - src/App.tsx (Plan 02-03 — extend mount list)
+ - src/ui/journal/Journal.tsx (analog DOM full-screen overlay)
+
+
+ src/ui/dialogue/LuraDialogue.tsx,
+ src/ui/dialogue/LuraDialogue.test.tsx,
+ src/ui/dialogue/ink-renderer.tsx,
+ src/ui/dialogue/ink-runtime.ts,
+ src/ui/dialogue/ink-runtime.test.ts,
+ src/ui/dialogue/index.ts,
+ src/ui/index.ts,
+ src/render/garden/gate-renderer.ts,
+ src/render/garden/index.ts,
+ src/game/scenes/Garden.ts,
+ src/App.tsx
+
+
+**Step 1 — `src/ui/dialogue/ink-runtime.ts`** — thin wrapper around inkjs (RESEARCH p.776):
+
+```typescript
+import type { Story } from 'inkjs';
+
+/**
+ * InkRuntime — thin wrapper around inkjs Story that yields lines one at
+ * a time with a tunable cadence delay. Used by LuraDialogue.
+ *
+ * Phase 2: fixed delay per line (1500ms or proportional to line length).
+ * Phase 8: reduced-motion (UX-05) will short-circuit the delay.
+ */
+export interface InkRuntime {
+ /** Pull the next available line; resolves after the cadence delay. */
+ nextLine(): Promise;
+ /** Are there more lines or choices available? */
+ canContinue(): boolean;
+ /** Current choices, if the story has paused on a choice point. */
+ currentChoices(): { index: number; text: string }[];
+ /** Pick a choice and resume. */
+ chooseChoice(index: number): void;
+ /** Skip the cadence delay (e.g., player tap-to-advance). */
+ skipDelay(): void;
+}
+
+const DEFAULT_DELAY_MS = 1500;
+const PER_CHAR_MS = 20;
+const MAX_DELAY_MS = 4000;
+
+export function createInkRuntime(story: Story): InkRuntime {
+ let skipNext = false;
+ return {
+ async nextLine() {
+ if (!story.canContinue) return null;
+ const line = story.Continue();
+ const delay = skipNext ? 0 : Math.min(MAX_DELAY_MS, DEFAULT_DELAY_MS + line.length * PER_CHAR_MS);
+ skipNext = false;
+ if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
+ return line;
+ },
+ canContinue: () => story.canContinue,
+ currentChoices: () => story.currentChoices.map((c, i) => ({ index: i, text: c.text })),
+ chooseChoice: (index: number) => story.ChooseChoiceIndex(index),
+ skipDelay: () => { skipNext = true; },
+ };
+}
+```
+
+**Step 2 — `src/ui/dialogue/ink-runtime.test.ts`** — Vitest:
+
+- Given a 2-line story, `nextLine()` returns each line in order; after second, returns null.
+- `skipDelay()` makes the next `nextLine()` resolve nearly-instantly (timing assertion: <100ms).
+- `canContinue()` returns true at start and false after exhaustion.
+- `currentChoices()` returns choice array when story pauses on choices.
+- `chooseChoice(0)` advances past the choice point.
+
+(For Vitest, use `vi.useFakeTimers()` to assert delay logic without real waits.)
+
+**Step 3 — `src/ui/dialogue/ink-renderer.tsx`** — drip rendering of accumulated Ink lines:
+
+```typescript
+import { useEffect, useRef, useState } from 'react';
+import type { InkRuntime } from './ink-runtime';
+
+/**
+ * Drives an InkRuntime, drips lines into the DOM with text-message
+ * cadence. Used by LuraDialogue (full-screen overlay) and may be reused
+ * for compost acknowledgements (smaller toast variant — Plan 02-04 Task 3).
+ */
+export function InkRenderer({ runtime, onComplete }: { runtime: InkRuntime; onComplete?: () => void }): JSX.Element {
+ const [lines, setLines] = useState([]);
+ const [choices, setChoices] = useState<{ index: number; text: string }[]>([]);
+ const [done, setDone] = useState(false);
+ const cancelled = useRef(false);
+
+ useEffect(() => {
+ cancelled.current = false;
+ (async () => {
+ while (!cancelled.current) {
+ const line = await runtime.nextLine();
+ if (cancelled.current) return;
+ if (line === null) break;
+ if (line.trim().length > 0) {
+ setLines((prev) => [...prev, line.trim()]);
+ }
+ }
+ const cs = runtime.currentChoices();
+ if (cs.length > 0) {
+ setChoices(cs);
+ return;
+ }
+ setDone(true);
+ onComplete?.();
+ })();
+ return () => { cancelled.current = true; };
+ }, [runtime, onComplete]);
+
+ const onChoice = (index: number) => {
+ runtime.chooseChoice(index);
+ setChoices([]);
+ setLines((prev) => [...prev]); // trigger re-render; loop will pick up
+ };
+
+ return (
+