Decision-coverage gate found three CONTEXT.md decisions structurally implemented but not literally cited by their D-NN tags. Added one-line must_haves entries citing each: - D-12 (Lura as discrete gate visits, 3 beats this Season) → 02-04 - D-16 (all Lura dialogue authored in Ink, runtime via inkjs) → 02-04 - D-32 (Zustand 5 store as the Phaser↔React bridge; sim never imports store, CORE-10 enforced) → 02-01 STATE.md flipped from in_progress (context gathered) to ready_to_execute with the planning summary in stopped_at. All 24 REQ-IDs + 34 D-XX decisions now covered. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
60 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | tags | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02 | 04 | execute | 2 |
|
|
true |
|
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @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.mdFrom src/store/index.ts (Plan 02-01):
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):
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):
export interface SimState {
...
luraBeatProgress: { arrived: boolean; mid: boolean; farewell: boolean; pending: ... | null };
harvestedFragmentIds: string[];
...
}
From src/content/index.ts (Plan 02-02):
export const fragments: Fragment[]; // for last_fragment_title slot
export const uiStrings: Record<number, UiStrings>;
From src/game/event-bus.ts (Plan 02-01):
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):
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:
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/
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:
#!/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() {
// The wrapper API is tried first in compileAllInk; this is the fallback.
// Verified Task 1: node_modules/inklecate/bin/{inklecate,inklecate.exe} —
// a single bin/ directory holds both .NET binaries (Windows + POSIX). The
// wrapper handles platform selection internally.
const ext = process.platform === 'win32' ? '.exe' : '';
return resolve(process.cwd(), 'node_modules/inklecate/bin/inklecate' + ext);
}
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 <out> <in>
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:
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:
"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):
// 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):
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):
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):
// 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):
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 '';
},
// Per W4 — first sentence of the most-recently-harvested fragment's body, for letter prose.
last_fragment_title: (s: AppStoreShape) => {
const lastId = s.harvestedFragmentIds[s.harvestedFragmentIds.length - 1];
if (!lastId) return '';
const frag = allFragments.find((f) => f.id === lastId);
if (!frag) return '';
return frag.body.split(/[.!?]/)[0]?.trim() ?? '';
},
} as const;
export async function loadInkStory(name: 'lura-arrival' | 'lura-mid' | 'lura-farewell' | 'compost-acknowledgements'): Promise<Story> {
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 aStoryinstance (smoke).loadInkStory('compost-acknowledgements')returns a Story.bindGardenStateToInk(story, snapshot)setsstory.variablesState['fragment_count']tosnapshot.harvestedFragmentIds.length.bindGardenStateToInkdoes not throw on a story missing a declared var (the warn is silent).- Variable casing test (Pitfall 4): every key in
INK_VARIABLE_MAPis 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 BEFORE the suite. Per W9, do NOT call compileAllInk() from inside the test — invoking the compiler concurrently with other tests creates a filesystem race on src/content/compiled-ink/ (the script wipes the directory at start). Instead, add a precondition check that fails loudly with a fix-it message:
import { beforeAll } from 'vitest';
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
const compiledExists = existsSync(
resolve(process.cwd(), 'src/content/compiled-ink/season1/lura-arrival.ink.json'),
);
beforeAll(() => {
if (!compiledExists) {
throw new Error(
'ink-loader.test.ts: compiled Ink JSON missing. Run `npm run compile:ink` (or `npm run build`) before this suite.',
);
}
});
The npm run ci chain already runs compile:ink as part of npm run build, so the local + CI flow is:
npm run compile:ink → npx vitest run (the test file's beforeAll just verifies the artefact exists).
If you prefer Vitest's globalSetup, the equivalent lives in vitest.config.ts — but the precondition check above is simpler, faster, and surfaces a clearer error when the artefact is missing.
Step 9 — Update src/content/index.ts to re-export ink-loader:
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.
<acceptance_criteria>
- test -f scripts/compile-ink.mjs && grep -q "compileAllInk" scripts/compile-ink.mjs
- grep -q "node_modules/inklecate/bin" scripts/compile-ink.mjs (BLOCKER 4: real binary path; not the non-existent inklecate-windows/ etc.)
- ! grep -q "inklecate-windows\|inklecate-mac\|inklecate-linux" scripts/compile-ink.mjs (negative: stale path strings absent)
- 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)
</acceptance_criteria>
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.
/**
* 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):
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<Record<number, LuraBeatId>> = 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:
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:
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:
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.pendingis'arrival'. - After harvesting 4 ready plants (with
arrived=trueset after the 1st),state.luraBeatProgress.pendingis'mid'. - Harvest count 5 with
pending='mid'(player hasn't visited yet) leavespending='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.
<acceptance_criteria>
- 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
</acceptance_criteria>
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.
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<string | null>;
/** 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 nextnextLine()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:
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<string[]>([]);
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 (
<div onClick={() => runtime.skipDelay()} style={{ cursor: 'pointer' }}>
{lines.map((line, i) => (
<p
key={i}
style={{
margin: '0.6rem 0',
fontSize: '1.05rem',
lineHeight: 1.6,
userSelect: 'text',
}}
>{line}</p>
))}
{choices.length > 0 && (
<div style={{ marginTop: '1rem' }}>
{choices.map((c) => (
<button
key={c.index}
onClick={() => onChoice(c.index)}
style={{
display: 'block', margin: '0.4rem 0',
background: 'transparent', color: '#e8e0d0',
border: '1px solid #4d4d52', padding: '0.4rem 0.8rem',
cursor: 'pointer', fontFamily: 'serif', textAlign: 'left',
}}
>
{c.text}
</button>
))}
</div>
)}
</div>
);
}
Step 4 — src/ui/dialogue/LuraDialogue.tsx:
import { useEffect, useState } from 'react';
import { useAppStore } from '../../store';
import { loadInkStory, bindGardenStateToInk } from '../../content';
import { createInkRuntime, type InkRuntime } from './ink-runtime';
import { InkRenderer } from './ink-renderer';
import { resolvePendingLuraBeat } from '../../sim/narrative';
/**
* D-15 — React DOM dialogue overlay. Opens when player clicks the gate
* with a pending Lura beat. Loads the corresponding compiled Ink, binds
* variables from the store snapshot, drives the InkRenderer.
*
* On dismiss: resolves the pending beat in the store (which clears `pending`
* and sets the corresponding visited flag).
*/
export function LuraDialogue(): JSX.Element | null {
const open = useAppStore((s) => s.dialogueOverlayOpen);
const pending = useAppStore((s) => s.luraBeatProgress.pending);
const setDialogueOverlayOpen = useAppStore((s) => s.setDialogueOverlayOpen);
const setLuraBeatProgress = useAppStore((s) => s.setLuraBeatProgress);
const [runtime, setRuntime] = useState<InkRuntime | null>(null);
useEffect(() => {
if (!open || !pending) {
setRuntime(null);
return;
}
let cancelled = false;
(async () => {
try {
const beatName = `lura-${pending}` as 'lura-arrival' | 'lura-mid' | 'lura-farewell';
const story = await loadInkStory(beatName);
if (cancelled) return;
bindGardenStateToInk(story, useAppStore.getState());
// The story's knot has the same name as the beat — call the entry
const knot = pending; // Ink files use `== arrival ==`, `== mid ==`, `== farewell ==`
story.ChoosePathString(knot);
setRuntime(createInkRuntime(story));
} catch (err) {
console.error('[LuraDialogue] failed to load beat', pending, err);
// Fail soft — close overlay
setDialogueOverlayOpen(false);
}
})();
return () => { cancelled = true; };
}, [open, pending, setDialogueOverlayOpen]);
if (!open) return null;
const onClose = () => {
setDialogueOverlayOpen(false);
// Resolve the pending beat in the store
setLuraBeatProgress(resolvePendingLuraBeat(useAppStore.getState().luraBeatProgress));
};
return (
<div
role="dialog"
aria-label="Lura at the gate"
style={{
position: 'fixed', inset: 0, zIndex: 85,
background: '#1a1a1aee',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#e8e0d0',
fontFamily: 'serif',
}}
>
<div style={{ maxWidth: 600, padding: '2rem' }}>
{runtime ? <InkRenderer runtime={runtime} onComplete={() => {}} /> : <p style={{ opacity: 0.5 }}>...</p>}
<button
onClick={onClose}
style={{
marginTop: '2rem', padding: '0.5rem 1.4rem',
background: 'transparent', color: '#e8e0d0',
border: '1px solid #e8e0d0', cursor: 'pointer',
fontFamily: 'serif',
}}
>
Close
</button>
</div>
</div>
);
}
Step 5 — src/ui/dialogue/LuraDialogue.test.tsx — Vitest with mocked Ink runtime (since happy-dom can run inkjs but the cadence makes assertions slow):
- With
dialogueOverlayOpen: false, returns null. - With
dialogueOverlayOpen: trueandpending: null, returns null (no beat to render). - With
dialogueOverlayOpen: trueandpending: 'arrival', mounts the dialog (text "Lura at the gate" via aria-label). - Close button click: dispatches
setDialogueOverlayOpen(false)AND advancesluraBeatProgress.arrivedto true. - (Skip the actual Ink rendering assertion in unit test — Plan 02-05 e2e covers the integration.)
Step 6 — src/ui/dialogue/index.ts + src/ui/index.ts:
// src/ui/dialogue/index.ts
export { LuraDialogue } from './LuraDialogue';
export { InkRenderer } from './ink-renderer';
export { createInkRuntime } from './ink-runtime';
export type { InkRuntime } from './ink-runtime';
// src/ui/index.ts (extend)
export * from './begin';
export * from './garden';
export * from './journal';
export * from './dialogue';
Step 7 — src/render/garden/gate-renderer.ts — Phaser primitive gate visual + indicator (D-15):
import * as Phaser from 'phaser';
/**
* Phaser primitive gate visual. Sits at the edge of the 4×4 garden. When
* a Lura beat is pending (luraBeatProgress.pending != null), the gate
* glows softly via alpha pulse (D-15).
*
* Phase 3 paints over with the watercolor gate. The hit/glow shape stays.
*/
const GATE_X = 880; // canvas px — right side, near the grid
const GATE_Y = 384; // vertical center
const GATE_COLOR = 0x6e6e75;
const GATE_GLOW_COLOR = 0xe8d8b6;
const GATE_HIT_W = 80;
const GATE_HIT_H = 120;
export interface GateGameObjects {
hit: Phaser.GameObjects.Rectangle;
body: Phaser.GameObjects.Rectangle;
glow: Phaser.GameObjects.Rectangle;
glowTween: Phaser.Tweens.Tween | null;
}
export function drawGate(scene: Phaser.Scene): GateGameObjects {
const body = scene.add.rectangle(GATE_X, GATE_Y, GATE_HIT_W * 0.7, GATE_HIT_H, GATE_COLOR);
const glow = scene.add.rectangle(GATE_X, GATE_Y, GATE_HIT_W * 0.9, GATE_HIT_H * 1.05, GATE_GLOW_COLOR, 0);
glow.setBlendMode(Phaser.BlendModes.ADD);
const hit = scene.add.rectangle(GATE_X, GATE_Y, GATE_HIT_W, GATE_HIT_H, 0xffffff, 0);
hit.setInteractive({ useHandCursor: true });
hit.setData('isGate', true);
return { hit, body, glow, glowTween: null };
}
export function updateGateIndicator(scene: Phaser.Scene, gate: GateGameObjects, isPending: boolean): void {
if (isPending && !gate.glowTween) {
gate.glowTween = scene.tweens.add({
targets: gate.glow,
alpha: { from: 0.0, to: 0.4 },
duration: 1200,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1,
});
} else if (!isPending && gate.glowTween) {
gate.glowTween.stop();
gate.glowTween = null;
gate.glow.setAlpha(0);
}
}
Update src/render/garden/index.ts:
export { drawGate, updateGateIndicator } from './gate-renderer';
export type { GateGameObjects } from './gate-renderer';
Step 8 — Update src/game/scenes/Garden.ts:
(a) In create(), draw the gate; subscribe to store and call updateGateIndicator on changes.
(b) Wire gate pointerdown → setDialogueOverlayOpen(true) (only if a beat is pending).
import { drawGate, updateGateIndicator, type GateGameObjects } from '../../render/garden';
// In Garden class:
private gate: GateGameObjects | null = null;
// In create():
this.gate = drawGate(this);
this.gate.hit.on('pointerdown', () => {
const pending = appStore.getState().luraBeatProgress.pending;
if (pending) {
appStore.getState().setDialogueOverlayOpen(true);
}
});
// Add to the store-subscribe block:
this.storeUnsubscribe = appStore.subscribe((state) => {
this.repaintPlants(state.tiles as Tile[]);
if (this.gate) {
updateGateIndicator(this, this.gate, state.luraBeatProgress.pending !== null);
}
});
// Initial paint:
if (this.gate) {
updateGateIndicator(this, this.gate, appStore.getState().luraBeatProgress.pending !== null);
}
(Plan 02-03 already wired the harvest pointerdown; this plan adds the gate pointerdown without conflicting.)
Step 9 — Update src/App.tsx:
import { LuraDialogue } from './ui/dialogue';
// Inside <div id="app">:
<LuraDialogue />
{/* (other overlays from prior plans) */}
Compost-beat wiring (resolves Plan 02-03 TODO):
The compost line plays via the same dialogue overlay (smaller / shorter); for Phase 2 minimum-viable: just fire setDialogueOverlayOpen(true) with a synthetic pending: 'compost' flag. But to keep the data model clean, INSTEAD: Plan 02-04 ships compost lines as a small toast variant. Surface this as a Plan 02-05 follow-up if it requires non-trivial UI work; for Phase 2 Wave 2, document the compost lines exist (compost-acknowledgements.ink compiles green), and Plan 02-05 wires the actual compost-toast UI alongside the persistence-denied toast (similar shape).
(Update SUMMARY.md: "Compost Ink content authored; runtime wiring deferred to Plan 02-05's persistence-toast surface, per minimum-viable bias.")
Manual smoke test: npm run dev, plant + harvest a rosemary (~2 min) → gate begins glowing → click gate → Lura arrival dialogue overlay appears → text drips line by line in voice → close → gate stops glowing → harvest 3 more (rosemary unlocks yarrow at 3) → fragment-count 4 → gate glows again → click → Lura mid beat plays.
Commit: feat(02-04): Lura dialogue overlay + Ink runtime + gate visual + Garden scene wiring. Run npm run ci before committing.
<acceptance_criteria>
- grep -q "createInkRuntime" src/ui/dialogue/ink-runtime.ts
- grep -q "story.Continue()" src/ui/dialogue/ink-runtime.ts
- grep -q "ChoosePathString(knot)" src/ui/dialogue/LuraDialogue.tsx
- grep -q "loadInkStory" src/ui/dialogue/LuraDialogue.tsx
- grep -q "resolvePendingLuraBeat" src/ui/dialogue/LuraDialogue.tsx
- grep -q "drawGate" src/render/garden/gate-renderer.ts
- grep -q "updateGateIndicator" src/render/garden/gate-renderer.ts
- grep -q "this.gate" src/game/scenes/Garden.ts (gate instance held by Garden scene)
- grep -q "setDialogueOverlayOpen" src/game/scenes/Garden.ts
- grep -q "<LuraDialogue />" src/App.tsx
- npx vitest run src/ui/dialogue/ exits 0; ≥6 cases green
- npm run ci exits 0
</acceptance_criteria>
npm run lint && npx vitest run src/ui/dialogue/ src/render/ && npm run ci
LuraDialogue overlay renders Ink with text-message cadence. Gate visual glows when a beat is pending; click opens overlay; close resolves the beat in the store. App.tsx mounts LuraDialogue. Manual smoke test confirms 1st/4th harvest triggers Lura's arrival/mid beats end-to-end.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Ink content boundary | .ink files are repo-controlled; inklecate produces JSON; React renders strings (no dangerouslySetInnerHTML); inkjs evaluates story logic in-memory only. |
| sim ↔ inkjs boundary | Sim never imports inkjs (Architectural Responsibility Map line 40). Narrative gating is pure-state; runtime lives in UI tier. |
| Build-time boundary | inklecate is a Node ESM script invoked during npm run build; produces only repo-local JSON files; no network. |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-02-04-01 | Tampering | System-clock manipulation skips Lura beats | mitigate | STRY-10 — beats gate on harvestedFragmentIds.length (sim-state, not wall-clock). lura-gate.test.ts asserts FakeClock advance alone doesn't progress beats. |
| T-02-04-02 | Tampering | Player edits luraBeatProgress in DevTools | accept | Single-player; CRC-32 detects accidental save corruption. |
| T-02-04-03 | Information disclosure | Ink content XSS via JSON injection | mitigate | inkjs renders strings; React renders strings; no dangerouslySetInnerHTML. Ink JSON is build-time generated from repo-controlled .ink. |
| T-02-04-04 | Tampering | Compiled Ink JSON checked into repo and modified maliciously | accept | Compiled JSON is gitignored. Source-of-truth is the .ink file. CI regenerates JSON from source on every build. |
| T-02-04-05 | Denial-of-service | Ink story infinite loop blocking the runtime | accept | Inkjs handles loops at story logic; the runtime's setTimeout-based delay is bounded. Phase 2's authored content has no loops. |
| T-02-04-06 | Tampering | Ink variable name typo silently leaves variable unset (Pitfall 4) | mitigate | INK_VARIABLE_MAP is the centralized snake_case mapping; ink-loader.test.ts asserts every key is snake_case. Adding a new variable requires editing both the .ink file AND INK_VARIABLE_MAP — one fails CI without the other. |
No high severity threats.
</threat_model>
After all 3 tasks committed:
- Linter:
npm run lintexits 0. - Tests:
npx vitest runexits 0; new test files:scripts/compile-ink.test.mjs,src/content/ink-loader.test.ts,src/sim/narrative/lura-gate.test.ts,src/ui/dialogue/ink-runtime.test.ts,src/ui/dialogue/LuraDialogue.test.tsx. Combined Phase-1+Phase-2 test count ≥175. - Ink compile:
npm run compile:inkproducessrc/content/compiled-ink/season1/{lura-arrival,lura-mid,lura-farewell,compost-acknowledgements}.ink.json. - Build:
npm run buildexits 0 —compile:inkruns as part of build, then tsc + vite. - PIPE-02 verify:
node scripts/check-bundle-split.mjsafter build exits 0 (the compiled Ink JSON participates in a chunk; should not break the season1 chunk assertion). - Full CI:
npm run ciexits 0. - STRY-10 evidence: lura-gate.test.ts has a test case proving FakeClock advance alone doesn't trigger beats (visible in test output).
- Manual smoke (executor performs once):
npm run dev, plant 1 rosemary, harvest at ready → gate glows → click gate → Lura arrival overlay → text drips → close → gate stops glowing → harvestedFragmentIds.length=1, luraBeatProgress.arrived=true.
<success_criteria>
Plan 02-04 is complete when:
- All 3 tasks committed.
npm run ciexits 0.- Compile pipeline:
.inksource →inklecate→.ink.json→inkjs.Story→ React DOM works end-to-end. - 4 authored Ink files (3 Lura beats + compost acknowledgements) match bible voice + anti-FOMO doctrine.
- Lura beats fire at harvest counts 1, 4, 8 (D-14); STRY-10 verified (FakeClock alone doesn't advance).
- Gate visual glows when a beat pends; click opens DOM dialogue overlay; close resolves the beat.
- sim/narrative is pure: no
inkjsimport, noDate.now, nosetInterval. - STRY-07 vacuously satisfied (no Keeper-spoken lines in Phase 2).
- Plan 02-05 (Letter + e2e) can build on the
lura_was_hereslot output (already covered by store'sluraBeatProgress.pending).
</success_criteria>
Create `.planning/phases/02-season-1-vertical-slice-soil/02-04-lura-gate-beats-SUMMARY.md` per template. Document: - Inklecate API path used (wrapper function vs CLI binary; RESEARCH Assumption A6 verification). - Whether compile:ink ran first-try on the dev machine OR needed adjustments. - Final cadence values (DEFAULT_DELAY_MS, PER_CHAR_MS, MAX_DELAY_MS) — playtest may adjust. - Whether the compost-beat UI was wired here or deferred to Plan 02-05's toast surface. - Manual smoke test confirmation (date / browser / observed Lura beat at 1st harvest). - Any tonal review notes from the user on Lura's authored copy. - Confirmation that no Phase-2 sim module imports inkjs.