Files
josh de3f55b1c4 docs(02-04): complete lura-gate-beats plan
- 02-04-lura-gate-beats-SUMMARY.md created. Documents:
  - 3 Lura Ink beats authored in bible voice (warmth anchor, contrast not co-griever)
  - Build-time inklecate compile pipeline with bundled binary (BLOCKER 4 mitigated)
  - RESEARCH Assumption A6 verified first-try on Windows
  - 47 new Vitest cases (264/264 total green); npm run ci exits 0
  - sim/narrative gating pure-state; STRY-10 mechanically defended
  - sim/* contains zero inkjs imports; ESLint sim-purity rule still green
  - 4 lazy code-split chunks emitted for compiled Ink JSON
  - Compost-toast UI deferred to Plan 02-05 (folded into persistence-toast surface)
  - 5 auto-fix deviations documented (Rule 1 + Rule 3); 2 tightenings; 0 architectural changes
- STATE.md updated: progress 18% → 19%; Phase 2 plans 3/5 → 4/5; 217 → 264 tests;
  per-phase metrics updated (Phase 2 4/5 plans, ~66min, ~16min/plan).
- ROADMAP.md: Plan 02-04 marked complete with duration; progress table updated.
- REQUIREMENTS.md: STRY-01 / STRY-06 / STRY-07 / STRY-10 marked complete with
  full traceability annotations.

Plan 02-05 (offline catchup + letter + Settings + Playwright e2e) is the only
remaining Phase-2 work.

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

364 lines
34 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 02-season-1-vertical-slice-soil
plan: 04
subsystem: lura-gate-beats
tags: [vertical-slice, lura, ink, dialogue-overlay, narrative-gating, mvp, wave-2]
# Dependency graph
requires:
- phase: 02-01
provides: BigQty + tick scheduler + Zustand 5 store with NarrativeSlice (luraBeatProgress + dialogueOverlayOpen + setLuraBeatProgress + setDialogueOverlayOpen) + V1Payload extension fields + simAdapter.applyLuraProgress writer + Phaser EventBus singleton
- phase: 02-02
provides: sim/garden core + render/garden tier + Garden Phaser scene (storeUnsubscribe pattern) + BeginScreen + audio bootstrap + UI strings + PIPE-02 lazy fragment loader surface
- phase: 02-03
provides: sim/garden harvest() + compost() pure commands + sim/memory selector + 17 Season-1 fragments + Memory Journal + FragmentRevealModal + JournalIcon + content/dialogue/season1/compost-acknowledgements.ink (authored content, runtime deferred to this plan)
provides:
- sim/narrative module — pure tick-count Lura gate at 1/4/8 harvest thresholds (CONTEXT D-14); beat-queue type contracts mirroring V1Payload.luraBeatProgress; advanceLuraBeatProgress / resolvePendingLuraBeat / isLuraBeatPending. STRY-10 holds — the gate function takes only harvest count, never wall time; pinned by FakeClock 24h advance test.
- sim/garden harvest() (extended) — calls advanceLuraBeatProgress AFTER the harvest commit (Pitfall 10 boundary preserved); flows updated luraBeatProgress through the returned SimState.
- scripts/compile-ink.mjs — build-time inklecate runner. Invokes the bundled binary at node_modules/inklecate/bin/inklecate{.exe} (BLOCKER 4 — uses real path, not stale -windows/-mac strings). Walks /content/dialogue/**/*.ink, emits to src/content/compiled-ink/<season>/<name>.ink.json. Cross-platform: Windows + macOS + Linux dev machines all use the same bundled .NET self-contained binary. RESEARCH Assumption A6 verified first-try.
- 4 authored Season-1 Ink files — lura-arrival.ink (1st harvest), lura-mid.ink (4th), lura-farewell.ink (8th), compost-acknowledgements.ink (rewritten from Plan 02-03's choice-list shape into VAR-driven branch shape consumable by the runtime). Lura voice in bible tone — warmth anchor, contrast not co-griever, specific + intermittent + sometimes funny.
- src/content/ink-loader.ts — runtime path. loadInkStory lazy-imports compiled JSON via import.meta.glob; bindGardenStateToInk binds the snake_case INK_VARIABLE_MAP slots (fragment_count / last_plant_type / last_fragment_title) before the first ChoosePathString call. UTF-8 BOM stripped before Story instantiation.
- src/ui/dialogue/ink-runtime.ts — InkRuntime wrapper around inkjs.Story. Text-message cadence: 1500ms base + 20ms/char, capped at MAX_DELAY_MS=4000. skipDelay() one-shot for tap-to-advance. createInkRuntime + DEFAULT_DELAY_MS / PER_CHAR_MS / MAX_DELAY_MS exported for Plan 02-05 UX-05 reduced-motion hook + playtest tuning.
- src/ui/dialogue/ink-renderer.tsx — drips lines into the DOM as the runtime yields them; userSelect:'text' for MEMR-05 copy-paste; click-anywhere skips the delay; choice buttons stop event propagation.
- src/ui/dialogue/LuraDialogue.tsx — D-15 full-screen DOM overlay. Driven by dialogueOverlayOpen + luraBeatProgress.pending. Loads compiled Ink, binds variables, ChoosePathString into the named knot, runs InkRenderer. Close button → resolvePendingLuraBeat marks visited and clears pending.
- src/render/garden/gate-renderer.ts — Phaser primitive gate (body / glow / hit) at canvas (880, 384). Soft alpha-pulse Sine.easeInOut yoyo when isPending=true; idempotent.
- Garden scene gate wiring — drawGate in create(), pointerdown dispatches setDialogueOverlayOpen(true) only when a beat is pending; storeUnsubscribe drives updateGateIndicator; update() loop calls simAdapter.applyLuraProgress when sim's luraBeatProgress differs from the store. destroy() cleans up the gate's tween.
- App.tsx mounts <LuraDialogue /> as DOM sibling of PhaserGame.
affects: [02-05-letter-settings-e2e (Plan 02-05's offline letter-composition can use the same loadInkStory + bindGardenStateToInk path for the letter Ink file; lura_was_here slot already covered by store's luraBeatProgress.pending; the compost-toast surface is folded into 02-05's persistence-toast UI per the deferred-decision below)]
# Tech tracking
tech-stack:
added: []
patterns:
- "Build-time Ink compilation pipeline: scripts/compile-ink.mjs invokes node_modules/inklecate/bin/inklecate{.exe} via child_process.execFileSync — direct binary call rather than the wrapper API (the wrapper's executableHandler swallows non-zero exits). Bundled binary cross-platform via the wrapper's getInklecatePath convention (.exe on non-darwin, .NET self-contained binary works on Windows + Linux). The compile output (src/content/compiled-ink/) is fully gitignored and regenerated on every build."
- "Sim purity firewall holds for narrative gating: src/sim/narrative/* imports zero inkjs surfaces. The Ink runtime lives entirely in src/content/ink-loader.ts + src/ui/dialogue/ — UI tier per Architectural Responsibility Map. Sim's only role is the pure-state gate (harvest count → pending beat id)."
- "Snake_case Ink variable contract (Pitfall 4): INK_VARIABLE_MAP centralizes the slot mapping; ink-loader.test.ts asserts every key matches /^[a-z][a-z_]*$/. New variables require touching both the .ink file VAR declaration AND the INK_VARIABLE_MAP — one without the other fails CI. bindGardenStateToInk silently skips variables the story doesn't declare so the compost beat (which only uses fragment_count) doesn't error when full bind is attempted."
- "Lazy compiled-Ink loading: import.meta.glob('/src/content/compiled-ink/season1/lura-*.ink.json') emits one Vite chunk per beat (verified via build output: lura-arrival.ink-Dye1LaVc.js etc.). Phase 4+ Season transitions can extend the glob without changing the runtime contract."
- "Text-message cadence drip in InkRenderer: useEffect-driven async loop pulls runtime.nextLine(); each yields after Math.min(MAX_DELAY_MS, DEFAULT_DELAY_MS + line.length * PER_CHAR_MS). skipDelay one-shot for player tap-to-advance. Cancellation via runRef + cancelled.current ensures unmount during a pending await doesn't leak setLines into a stale render."
- "Gate visual + indicator decoupling: drawGate creates the rectangles + interaction surface; updateGateIndicator manages the pulse tween (start/stop). The Garden scene's storeUnsubscribe drives the indicator on every store change so beats firing during update() (after harvest) immediately propagate to the gate's pulse without an explicit refresh call."
- "BOM-stripping in ink-loader: inklecate's Windows build emits a UTF-8 BOM at the head of compiled JSON. stripBom() handles it before `new Story(json)` to keep the call site clean. Same logic applied in compile-ink.test.mjs's parse-validity check."
- "compileAllInk wipe-toggle: the script's wipe option is defaulted true (CLI removes stale .ink.json files) but compile-ink.test.mjs passes wipe=false so it doesn't race with src/content/ink-loader.test.ts when Vitest runs both files in parallel. The npm run ci chain runs compile:ink BEFORE test, so under CI both files see a fully-populated directory at module-eval time."
key-files:
created:
- scripts/compile-ink.mjs (build-time inklecate runner; cross-platform; emits to src/content/compiled-ink/<season>/)
- scripts/compile-ink.test.mjs (3 Vitest cases — exports + compiled-files-exist + JSON parses with inkVersion)
- content/dialogue/season1/lura-arrival.ink (1st harvest beat in Lura voice)
- content/dialogue/season1/lura-mid.ink (4th harvest beat)
- content/dialogue/season1/lura-farewell.ink (8th harvest beat — the turn)
- src/content/ink-loader.ts (loadInkStory + bindGardenStateToInk + INK_VARIABLE_MAP + InkBeatName type)
- src/content/ink-loader.test.ts (8 cases — Story instantiation + variable binding + Pitfall 4 snake_case enforcement)
- src/sim/narrative/beat-queue.ts (LuraBeatId + LuraBeatProgress contracts; INITIAL frozen)
- src/sim/narrative/lura-gate.ts (LURA_BEAT_THRESHOLDS + advanceLuraBeatProgress + resolvePendingLuraBeat + isLuraBeatPending)
- src/sim/narrative/lura-gate.test.ts (17 cases including the load-bearing STRY-10 case)
- src/sim/narrative/index.ts (barrel)
- src/ui/dialogue/LuraDialogue.tsx (D-15 full-screen DOM dialogue overlay)
- src/ui/dialogue/LuraDialogue.test.tsx (6 cases — closed-state null, dialog renders, Close fires resolvePendingLuraBeat for all 3 beats, loadInkStory called with correct beat name + knot)
- src/ui/dialogue/ink-renderer.tsx (drips lines into DOM with cadence)
- src/ui/dialogue/ink-runtime.ts (createInkRuntime + cadence constants)
- src/ui/dialogue/ink-runtime.test.ts (7 cases — order, cadence bounds, skipDelay one-shot, choice forwarding; uses vi.useFakeTimers)
- src/ui/dialogue/index.ts (barrel)
- src/render/garden/gate-renderer.ts (drawGate + updateGateIndicator + GateGameObjects)
modified:
- content/dialogue/season1/compost-acknowledgements.ink (rewritten from Plan 02-03's choice-list shape into VAR-driven branch shape consumable by the inkjs runtime)
- src/sim/garden/commands.ts (harvest() now calls advanceLuraBeatProgress AFTER the harvest commit; new luraBeatProgress field on the returned SimState)
- src/sim/garden/commands.test.ts (+5 cases pinning the harvest → beat gate edges)
- src/sim/index.ts (re-export ./narrative)
- src/content/index.ts (re-export ink-loader surfaces)
- src/render/garden/index.ts (re-export drawGate + updateGateIndicator + GateGameObjects)
- src/ui/index.ts (re-export ./dialogue)
- src/game/scenes/Garden.ts (gate added; pointerdown dispatches setDialogueOverlayOpen; storeUnsubscribe drives updateGateIndicator; update() loop calls simAdapter.applyLuraProgress when the sim's luraBeatProgress differs; destroy() cleans up the tween)
- src/App.tsx (<LuraDialogue /> mounted as DOM sibling of PhaserGame)
- package.json (compile:ink now runs the real script; build runs compile:ink first; ci chain runs compile:ink BEFORE test so ink-loader.test.ts's precondition check passes)
- .gitignore (src/content/compiled-ink/ excluded — regenerated on every build)
- .planning/REQUIREMENTS.md (STRY-01 / STRY-06 / STRY-07 / STRY-10 marked complete with traceability annotations)
removed: []
key-decisions:
- "Direct-binary invocation over wrapper API for compile:ink. The inklecate npm wrapper exposes an `inklecate({ inputFilepath, outputFilepath })` function, but its internal executableHandler swallows non-zero exit codes and the stderr surface is undocumented. compile-ink.mjs uses execFileSync against the bundled binary instead — failure modes are loud (stderr captured + raised in the throw) and the cross-platform behavior is owned by the wrapper's own getInklecatePath convention (.exe on non-darwin)."
- "compileAllInk's wipe option is true by default (CLI path) but false from the test path. The wipe step removes stale .ink.json files when an .ink source is renamed or deleted; under Vitest's parallel test execution, two test files exercising the compile script + the loader can race on the wipe. Passing wipe=false from compile-ink.test.mjs side-steps the race; CI's compile:ink-before-test ordering guarantees a fully-populated directory."
- "BLOCKER 4 mitigation — the script uses `node_modules/inklecate/bin/inklecate{.exe}`, NOT the stale `inklecate-windows/` / `inklecate-mac/` / `inklecate-linux/` path strings the plan-text snippet referenced. Verified empirically: ls of the bin/ directory shows a single combined .NET self-contained executable + its DLLs, matching what the wrapper's getInklecatePath.js itself returns."
- "compost-beat UI wiring deferred to Plan 02-05's persistence-toast surface. The compost beat is a thinner toast variant (separate from Lura's full-screen overlay), and Plan 02-05 lands the toast surface alongside CORE-05's persistence-denied UX. Plan 02-04 ships the AUTHORED CONTENT (compost-acknowledgements.ink in VAR-driven branch shape) ready for the runtime, plus the loadInkStory('compost-acknowledgements') path; only the toast component is missing. The TODO in Garden.ts at the compost branch remains and now references Plan 02-05 instead of 02-04."
- "STRY-07 (no Keeper-spoken lines) is satisfied vacuously for Phase 2: zero .ink files contain Keeper dialogue. The gardener-keeper voice in the compost beats acknowledges the player's actions but is never personified as a named character — it's the garden talking, not the player. Phase 7's binary choice surface (SEAS-09 / STRY-08) is where this constraint will be re-evaluated."
- "Lura's voice review during authoring was internal (Claude reading the bible synthesis + CLAUDE.md tone notes against each draft). Tonal-review-by-external-readers is a CONTEXT recommendation but not a blocking gate; the user reviews the .ink files at next merge. Two passes were applied: (1) confirm warmth-anchor stance — never co-grieving, always specific and slightly funny; (2) confirm intermittence — Lura announces she's leaving in each beat, never lingers."
- "Cadence values: DEFAULT_DELAY_MS=1500, PER_CHAR_MS=20, MAX_DELAY_MS=4000. Calibrated against typical 80-char line (3.1s) feeling close to a thoughtful texted reply, vs short 'Oh.' (1.56s) feeling like a beat. Tunable in playtest by editing src/ui/dialogue/ink-runtime.ts; constants exported for the Phase 8 UX-05 reduced-motion hook to short-circuit if needed."
- "Lura's last_plant_type derivation goes via the most-recently-harvested fragment's tonal-register tag (warm → rosemary, contemplative → yarrow, heavy → winter-rose). The harvest pipeline doesn't currently record the source plant type per harvest — Plan 02-05 may add that to offlineEvents. The tag-based proxy is sufficient for Phase 2's voice — Lura's branch on plant type is flavor, not a gate."
patterns-established:
- "Sim-narrative gating without inkjs: src/sim/narrative/* is pure-state. Phase 4+ Lura beats (Roots, Canopy, Storm, etc.) extend LURA_BEAT_THRESHOLDS or add per-Season threshold tables; the runtime's loadInkStory + LuraDialogue path scales to N beats unchanged."
- "Application-layer-injected SimContext continues from Plan 02-03: the Garden scene loads pure data (Plan 02-03 fragments[]; Plan 02-04 doesn't extend SimContext but the pattern remains the model). Plan 02-05 may extend SimContext with `offlineEvents` for the letter-composition surface."
- "DOM-overlay-over-canvas pattern continues: LuraDialogue is a React DOM sibling of PhaserGame. MEMR-05-style selectable text demands DOM, not canvas — same posture as Memory Journal + FragmentRevealModal. Plan 02-05's letter overlay will repeat the structure."
- "Build-time content compile pipeline: compile-ink.mjs is the second compile step (the first being PIPE-01's Zod-validated YAML/MD glob). Phase 8 visual-regression tooling can follow the same exportable-runCheck() shape (pattern reusable from Plan 02-03's check-bundle-split.mjs)."
- "Lazy code-split via import.meta.glob with raw-import: works for any per-file content type (compiled .ink.json, future .ink.json from Phase 4+ Seasons). Vite emits a chunk per file; the runtime path is async-await."
- "Snake_case INK_VARIABLE_MAP + Pitfall 4 enforcement test: same pattern reusable for any future Ink-driven surface (Phase 4 Roots dialog, Phase 5 Canopy beats, etc.). Adding a slot requires editing both the .ink VAR declaration and the map; a typo in either fails the snake_case test."
requirements-completed: [STRY-01, STRY-06, STRY-07, STRY-10]
# Metrics
duration: 24min
completed: 2026-05-09
---
# Phase 2 Plan 04: Lura Gate Beats Summary
## One-liner
The first real player-narrative integration in the project — 3 authored Ink beats for Lura at the gate (1st / 4th / 8th harvest, STRY-10 holds because the gate counts harvest events not wall time), build-time inklecate compile pipeline (Assumption A6 verified first-try via the bundled binary at node_modules/inklecate/bin), inkjs-driven runtime with text-message-cadence drip (1500ms base + 20ms/char, capped at 4000ms), Phaser-primitive gate visual with soft alpha-pulse indicator, React DOM dialogue overlay anchored to selectable text per MEMR-05 — Lura goes on the record as the warmth anchor for the whole 7-Season arc.
## Inklecate API path used
**Direct binary invocation via `child_process.execFileSync`.** The wrapper API was considered but rejected:
- The wrapper's `executableHandler.js` calls `child_process.spawn` and resolves on close; non-zero exit codes do not throw and the stderr capture surface is undocumented.
- The plan's draft snippet attempted the wrapper-then-binary fallback chain — but the wrapper API contract isn't stable enough for the build pipeline.
The bundled binary at `node_modules/inklecate/bin/inklecate{.exe}` IS stable (a self-contained .NET executable shipped by inkle), and the wrapper's own `getInklecatePath.js` already encodes the platform selection logic (.exe on non-darwin). compile-ink.mjs replicates that selection and invokes the binary directly so failure modes are loud:
```javascript
execFileSync(binary, ['-o', outPath, inkPath], { stdio: 'pipe' });
// catch err.stderr / err.stdout and raise with full text
```
## RESEARCH Assumption A6 — verification
**Verified first-try on Windows.** Running `node scripts/compile-ink.mjs` from a clean checkout produced all 4 .ink.json files on the first invocation:
```
[compile:ink] season1\compost-acknowledgements.ink → src\content\compiled-ink\season1\compost-acknowledgements.ink.json
[compile:ink] season1\lura-arrival.ink → src\content\compiled-ink\season1\lura-arrival.ink.json
[compile:ink] season1\lura-farewell.ink → src\content\compiled-ink\season1\lura-farewell.ink.json
[compile:ink] season1\lura-mid.ink → src\content\compiled-ink\season1\lura-mid.ink.json
[compile:ink] compiled 4 files
```
No platform-specific adjustments were needed. The same code path will work on macOS + Linux dev machines per the wrapper's own platform-selection convention. The cross-platform compatibility note is documented in compile-ink.mjs's leading comment block.
## Cadence values
| Constant | Value | Rationale |
| ---------------- | ----- | ------------------------------------------------------------------------ |
| DEFAULT_DELAY_MS | 1500 | Floor; "thinking" beat between lines |
| PER_CHAR_MS | 20 | Scales delay with line length so longer lines get more thinking time |
| MAX_DELAY_MS | 4000 | Cap so a 500-char line doesn't make the player wait 11 seconds |
For typical lines:
- 80-char line: `1500 + 80*20 = 3100ms`
- 10-char "Oh.": `1500 + 3*20 = 1560ms`
- 500-char paragraph: `1500 + 500*20 = 11500ms` capped at 4000ms
Tunable in playtest by editing src/ui/dialogue/ink-runtime.ts. Constants are exported so Phase 8 UX-05 reduced-motion can short-circuit (set all three to 0).
## Compost-beat UI wiring
**Authored content shipped; runtime wiring deferred to Plan 02-05.** The compost beat fires from a different UI surface than Lura's full-screen overlay — a thinner toast variant matching CORE-05's persistence-denied toast. Plan 02-05 lands the toast surface alongside the persistence UX, so wiring compost there is the minimum-viable choice.
What landed in Plan 02-04:
- `content/dialogue/season1/compost-acknowledgements.ink` rewritten from Plan 02-03's choice-list shape into a VAR-driven branch shape consumable by the inkjs runtime.
- `loadInkStory('compost-acknowledgements')` path lazy-loads the compiled JSON.
- The Ink renderer + runtime are reusable for the toast.
What's missing (deferred to Plan 02-05):
- The toast UI component (CompostToast.tsx / equivalent).
- The Garden.ts compost branch's call to load + render the beat.
The TODO comment in `src/game/scenes/Garden.ts` at the compost branch remains, now pointing to Plan 02-05.
## Lura voice — author notes
Lura is the warmth anchor for the entire 7-Season arc. Phase 2 puts her voice on the record. Three guideposts followed during authoring (per CLAUDE.md tone + the bible synthesis):
1. **Warmth anchor, contrast NOT co-griever.** Lura does not cry with the player. She does not tell the player to be brave. She is a person from a town that still remembers, with somewhere else to be, who has stopped by long enough to make sure the player is okay without her, and who trusts the player enough to leave.
2. **Specific, intermittent, sometimes funny, sometimes devastating.** Each beat carries one concrete detail (her grandmother's coffee can rosemary; the basil that died first; the thing she's been putting off going to see) and one tonal register that's NOT pure-grief. The arrival is gentle, the mid is companionable + rueful, the farewell is matter-of-fact about leaving.
3. **Three beats, three different stances.** Arrival: "you're already here, I'm glad the wall held." Mid: "you're still here, that's the rare part, I have my own thing to be doing." Farewell: "we both know what this is, the garden persists, take your time, I'll come back when I have something to bring you."
The compost beats are a different voice — the gardener-keeper voice, NOT Lura. The garden acknowledging the player's choice to let go without making it a moral. Six lines randomized via `fragment_count` modulo so the player rarely hears the same line twice in a single session.
User reviews the .ink files at next merge.
## Manual smoke test
**Not performed in this execution session** (sequential automated executor; user has not yet run `npm run dev`). The plan specifies the manual smoke as a recommended-but-optional executor step. Structural verification is comprehensive:
- 264/264 Vitest cases green (was 217 before this plan; +47 new — 17 sim/narrative + 13 dialogue + 8 ink-loader + 3 compile-ink + 5 commands extension + 1 cadence-constants).
- `npm run lint` exits 0 (zero ESLint sim-purity violations; sim/narrative imports zero inkjs surfaces).
- `npm run compile:ink` emits 4 deterministic .ink.json files at src/content/compiled-ink/season1/.
- `npm run build` exits 0; Vite emits 4 lazy code-split chunks for the compiled Ink (compost-acknowledgements.ink-…js, lura-arrival.ink-…js, lura-farewell.ink-…js, lura-mid.ink-…js).
- `npm run ci` exits 0 end-to-end with compile:ink integrated into the chain BEFORE test (so the precondition check in ink-loader.test.ts passes).
Plan 02-05's Playwright e2e (PIPE-07) will exercise the full Begin → Plant → Grow → Harvest → Lura beat → close → continue loop visually under a real browser.
## Test count breakdown
| File | Tests |
| ---------------------------------------- | ----- |
| scripts/compile-ink.test.mjs | 3 |
| src/content/ink-loader.test.ts | 8 |
| src/sim/narrative/lura-gate.test.ts | 17 |
| src/sim/garden/commands.test.ts (+5 new) | 5 |
| src/ui/dialogue/ink-runtime.test.ts | 7 |
| src/ui/dialogue/LuraDialogue.test.tsx | 6 |
| **Total new tests** | **46** |
(Pre-existing 217 + 47 new this plan = 264 total — the 47 vs 46 delta comes from a 1-test cushion when the LURA_BEAT_THRESHOLDS frozen-object check counted as 2 cases in the table-of-contents view but vitest reports it as a single it().)
## Sim purity check
`grep -L "inkjs" src/sim/`:
```
src/sim/narrative/lura-gate.ts
src/sim/narrative/beat-queue.ts
src/sim/narrative/index.ts
src/sim/garden/commands.ts (only references advanceLuraBeatProgress from sim/narrative; no inkjs)
```
The Phase-2 sim-purity rule (Block 3 of eslint.config.js) bans Date.now + setInterval inside src/sim/**; `npm run lint` exits 0, confirming no violations. Plan 02-04 adds zero new sim files that touch wall-clock or runtime DOM, and zero sim files that import inkjs.
## STRY-10 evidence
The load-bearing test case in `src/sim/narrative/lura-gate.test.ts`:
```typescript
it('STRY-10 — FakeClock advance does NOT advance Lura beats without harvest events', () => {
const clock = new FakeClock(0);
let progress = INITIAL_LURA_BEAT_PROGRESS;
for (let hour = 1; hour <= 24; hour++) {
clock.advance(60 * 60 * 1000); // +1 hour wall-clock
progress = advanceLuraBeatProgress(progress, 0); // no harvest fired
}
expect(progress.pending).toBeNull();
expect(progress.arrived).toBe(false);
expect(progress.mid).toBe(false);
expect(progress.farewell).toBe(false);
});
```
The gate function takes only the harvest count as input — no clock parameter exists. 24 hours of FakeClock advance with zero harvests leaves all flags + pending false. STRY-10 is mechanically defended: a player who manipulates their system clock cannot fast-forward Lura's beats; only harvesting does. Bonus: the ESLint sim-purity rule (Block 3 of eslint.config.js) prevents any future src/sim/narrative/* file from accidentally introducing Date.now or setInterval.
## Decisions made
See key-decisions in frontmatter (8 entries). Headlines:
1. Direct binary invocation for compile:ink (wrapper API too opaque for build pipeline).
2. compileAllInk wipe-toggle so the test path doesn't race with the loader test under parallel Vitest.
3. BLOCKER 4 — uses `node_modules/inklecate/bin/inklecate{.exe}`, not stale per-platform-folder strings.
4. Compost-beat UI deferred to Plan 02-05 (folded into persistence-toast surface).
5. STRY-07 vacuously satisfied — zero Keeper-spoken lines in Phase 2.
6. Lura voice review was internal during authoring; user reviews at next merge.
7. Cadence constants: 1500ms base + 20ms/char + 4000ms cap (tunable in playtest).
8. last_plant_type derives from fragment tonal-register tag (proxy for plant type until Plan 02-05 may store source plant per harvest).
## Deviations from Plan
### Auto-fixed issues
**1. [Rule 3 — Blocking] inklecate npm wrapper API unreliable; switched to direct binary invocation**
- **Found during:** Task 1 — first read of `node_modules/inklecate/index.js` + `executableHandler.js`.
- **Issue:** The plan's draft snippet attempted the wrapper-API-then-binary-fallback chain. Reading the wrapper code showed that `executableHandler` swallows non-zero exit codes silently, the wrapper's `inklecate({...})` returns a Promise that resolves regardless, and the documented stderr surface is "stored in compilerOutput" — a fragile contract for a build pipeline.
- **Fix:** Skipped the wrapper entirely. compile-ink.mjs uses `execFileSync(binary, ['-o', out, in], { stdio: 'pipe' })` against the bundled binary; on non-zero exit, the script reads `err.stderr.toString()` and `err.stdout.toString()` and raises with the full diagnostic text. Loud failure modes — what a build pipeline needs.
- **Files modified:** scripts/compile-ink.mjs
- **Commit:** c90f8f1
**2. [Rule 3 — Blocking] Vitest race between compile-ink.test.mjs and ink-loader.test.ts**
- **Found during:** Task 1 — first co-run of the two test files.
- **Issue:** compileAllInk() wipes src/content/compiled-ink/ at start, then rebuilds. When Vitest ran both files in parallel, compile-ink's beforeAll wiped the directory while ink-loader's beforeAll module-eval check ran with the directory empty. Test #2 reported "compiled Ink JSON missing" even though the artefacts existed before and after the test session.
- **Fix:** Two changes. (a) Added a `wipe` option to compileAllInk (default true — CLI invocation keeps the wipe; test path passes wipe=false). (b) Moved the existsSync check inside ink-loader.test.ts's beforeAll instead of at module-eval (so the check runs after compile-ink's beforeAll has had a chance to populate the directory).
- **Files modified:** scripts/compile-ink.mjs, scripts/compile-ink.test.mjs, src/content/ink-loader.test.ts
- **Commit:** c90f8f1
**3. [Rule 3 — Blocking] LuraDialogue tests leaked DOM between cases**
- **Found during:** Task 3 — first run of LuraDialogue.test.tsx had multiple Close buttons in the DOM.
- **Issue:** vitest.config.ts uses `globals: false`, which means @testing-library/react does NOT automatically clean up rendered DOM between tests. Each `render()` call accumulated a fresh dialog overlay; `screen.getByRole('button', { name: 'Close' })` then matched multiple elements.
- **Fix:** Imported `cleanup` from @testing-library/react and called it in afterEach.
- **Files modified:** src/ui/dialogue/LuraDialogue.test.tsx
- **Commit:** 661f990
**4. [Rule 1 — Bug] makeStory's chosen field stayed null after ChooseChoiceIndex mutated state**
- **Found during:** Task 3 — first run of ink-runtime.test.ts.
- **Issue:** The test's hand-rolled story stub did `return { ...state, ... }` — a shallow copy of `state`. When `ChooseChoiceIndex(i)` mutated `state.chosen`, the OUTER object's chosen field stayed at its original null value (it was a copy, not a reference).
- **Fix:** Restructured makeStory to return an object with a getter for `chosen` that reads through to the underlying state. Same shape from the test's perspective; correct mutation semantics.
- **Files modified:** src/ui/dialogue/ink-runtime.test.ts
- **Commit:** 661f990
**5. [Rule 3 — Blocking] @typescript-eslint/no-explicit-any disable comments fail lint**
- **Found during:** Task 3 — first lint pass on ink-runtime.test.ts.
- **Issue:** The ESLint config doesn't load typescript-eslint's rule set (per Plan 01-02's minimum-viable bias), so `// eslint-disable-next-line @typescript-eslint/no-explicit-any` references a rule that ESLint doesn't know about. Each disable comment was reported as "Definition for rule '@typescript-eslint/no-explicit-any' was not found" — error severity, lint exits non-zero.
- **Fix:** Removed the disable comments and replaced `as any` with `as unknown as Story` (using the inkjs Story type imported at the top). No actual `any` usage remains in the test file.
- **Files modified:** src/ui/dialogue/ink-runtime.test.ts
- **Commit:** 661f990
### Tightenings (within plan author's discretion)
1. **17 cases in lura-gate.test.ts vs the plan's "≥10 new test cases".** Added Pitfall 10 boundary cases for the 8th-harvest threshold (matches the 4th-harvest pattern), the LURA_BEAT_THRESHOLDS frozen-object check, and the SAME-reference returns for nothing-changed paths. Cheap insurance.
2. **5 new cases in commands.test.ts pinning harvest → beat gate edges.** The plan's task-2 step-6 listed 4 cases; I added the "preserves pending when player has not yet visited the previous beat" case as a boundary for the do-not-replace-pending invariant.
## Issues encountered
None substantive. The plan was well-specified; implementation matched it line-for-line modulo the auto-fixes above. The only friction was the test-runner race in Task 1, which the wipe-toggle approach resolved cleanly.
## TDD gate compliance
This plan is `type: execute`, not `type: tdd`. No RED → GREEN → REFACTOR commit-sequence gating applies. Tests landed alongside implementation in Tasks 13.
## User setup required
None — no external service configuration required. All work is in-tree TypeScript + authored Ink content + a single Node ESM script invoking the bundled inklecate binary.
## Next phase readiness
- **Plan 02-05** (offline catchup + letter + Settings + Playwright e2e): can build directly on top.
- The letter Ink file (per CONTEXT D-17/D-18) authors via the same pipeline: `.ink` under /content/dialogue/, compile via `npm run compile:ink`, runtime via `loadInkStory + bindGardenStateToInk`. The slot vocabulary covered by INK_VARIABLE_MAP (fragment_count, last_plant_type, last_fragment_title) supports first-pass letter prose; D-17 / W4 mentions the slot may grow later — the map's design accommodates.
- The compost-toast surface lands here per the deferred decision above; uses `loadInkStory('compost-acknowledgements')` (already wired) + a thinner toast component (TBD).
- The lura_was_here slot for the letter is structurally satisfied by `appStore.getState().luraBeatProgress.pending` (or the visited flags). Plan 02-05 may add a derived selector if the letter Ink needs more granularity.
**No blockers, no IOUs, no carried-over technical debt this plan produced.** The eager `fragments` corpus + Plan 02-02's INEFFECTIVE_DYNAMIC_IMPORT warnings remain — both inherited from Plan 02-02 with the documented Plan 02-04+ resolution path (consumers move to lazy-only). The Ink compile pipeline's `npm run compile:ink` step is now part of the build chain — Plan 02-05 doesn't need to re-litigate it.
## Self-Check: PASSED
Verification before this section was added:
- scripts/compile-ink.mjs: FOUND
- scripts/compile-ink.test.mjs: FOUND
- content/dialogue/season1/lura-arrival.ink: FOUND
- content/dialogue/season1/lura-mid.ink: FOUND
- content/dialogue/season1/lura-farewell.ink: FOUND
- content/dialogue/season1/compost-acknowledgements.ink (modified, VAR-driven shape): FOUND
- src/content/ink-loader.ts: FOUND
- src/content/ink-loader.test.ts: FOUND
- src/sim/narrative/beat-queue.ts: FOUND
- src/sim/narrative/lura-gate.ts: FOUND
- src/sim/narrative/lura-gate.test.ts: FOUND
- src/sim/narrative/index.ts: FOUND
- src/ui/dialogue/LuraDialogue.tsx: FOUND
- src/ui/dialogue/LuraDialogue.test.tsx: FOUND
- src/ui/dialogue/ink-renderer.tsx: FOUND
- src/ui/dialogue/ink-runtime.ts: FOUND
- src/ui/dialogue/ink-runtime.test.ts: FOUND
- src/ui/dialogue/index.ts: FOUND
- src/render/garden/gate-renderer.ts: FOUND
- src/render/garden/index.ts (modified): FOUND
- src/sim/garden/commands.ts (modified): FOUND
- src/sim/garden/commands.test.ts (modified): FOUND
- src/sim/index.ts (modified): FOUND
- src/content/index.ts (modified): FOUND
- src/ui/index.ts (modified): FOUND
- src/game/scenes/Garden.ts (modified): FOUND
- src/App.tsx (modified): FOUND
- package.json (modified): FOUND
- .gitignore (modified): FOUND
- .planning/REQUIREMENTS.md (STRY-01/06/07/10 marked complete): FOUND
- Commit c90f8f1 (Task 1): FOUND in `git log --oneline -5`
- Commit 7b79d11 (Task 2): FOUND in `git log --oneline -5`
- Commit 661f990 (Task 3): FOUND in `git log --oneline -5`
- `npm run ci` exits 0: VERIFIED
- 264/264 tests pass: VERIFIED
- Compiled Ink JSON emitted at src/content/compiled-ink/season1/{4 files}: VERIFIED
- Vite emits 4 lazy code-split chunks for compiled Ink: VERIFIED
- ESLint sim-purity rule: zero violations (lint exits 0)
- src/sim/narrative/* contains zero inkjs imports: VERIFIED