- 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>
34 KiB
phase, plan, subsystem, tags, requires, provides, affects, tech-stack, key-files, key-decisions, patterns-established, requirements-completed, duration, completed
| phase | plan | subsystem | tags | requires | provides | affects | tech-stack | key-files | key-decisions | patterns-established | requirements-completed | duration | completed | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-season-1-vertical-slice-soil | 04 | lura-gate-beats |
|
|
|
|
|
|
|
|
|
24min | 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.jscallschild_process.spawnand 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:
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 = 11500mscapped 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.inkrewritten 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):
- 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.
- 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.
- 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 lintexits 0 (zero ESLint sim-purity violations; sim/narrative imports zero inkjs surfaces).npm run compile:inkemits 4 deterministic .ink.json files at src/content/compiled-ink/season1/.npm run buildexits 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 ciexits 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:
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:
- Direct binary invocation for compile:ink (wrapper API too opaque for build pipeline).
- compileAllInk wipe-toggle so the test path doesn't race with the loader test under parallel Vitest.
- BLOCKER 4 — uses
node_modules/inklecate/bin/inklecate{.exe}, not stale per-platform-folder strings. - Compost-beat UI deferred to Plan 02-05 (folded into persistence-toast surface).
- STRY-07 vacuously satisfied — zero Keeper-spoken lines in Phase 2.
- Lura voice review was internal during authoring; user reviews at next merge.
- Cadence constants: 1500ms base + 20ms/char + 4000ms cap (tunable in playtest).
- 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
executableHandlerswallows non-zero exit codes silently, the wrapper'sinklecate({...})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 readserr.stderr.toString()anderr.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
wipeoption 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. Eachrender()call accumulated a fresh dialog overlay;screen.getByRole('button', { name: 'Close' })then matched multiple elements. - Fix: Imported
cleanupfrom @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 ofstate. WhenChooseChoiceIndex(i)mutatedstate.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
chosenthat 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-anyreferences 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 anywithas unknown as Story(using the inkjs Story type imported at the top). No actualanyusage remains in the test file. - Files modified: src/ui/dialogue/ink-runtime.test.ts
- Commit:
661f990
Tightenings (within plan author's discretion)
- 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.
- 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 1–3.
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:
.inkunder /content/dialogue/, compile vianpm run compile:ink, runtime vialoadInkStory + 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.
- The letter Ink file (per CONTEXT D-17/D-18) authors via the same pipeline:
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 ingit log --oneline -5 - Commit
7b79d11(Task 2): FOUND ingit log --oneline -5 - Commit
661f990(Task 3): FOUND ingit log --oneline -5 npm run ciexits 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