Files
TheLastGarden/.planning/phases/02-season-1-vertical-slice-soil/02-04-lura-gate-beats-PLAN.md
T
josh 5ddaabcdc1
ci / lint + test + validate-assets + build (push) Successful in 9m39s
docs(02): cite D-12, D-16, D-32 in plan must_haves + record planning complete
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>
2026-05-09 03:19:44 -04:00

1393 lines
60 KiB
Markdown
Raw 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
plan: 04
type: execute
wave: 2
depends_on: [02-01, 02-02, 02-03]
files_modified:
- scripts/compile-ink.mjs
- scripts/compile-ink.test.mjs
- package.json
- .gitignore
- content/dialogue/season1/lura-arrival.ink
- content/dialogue/season1/lura-mid.ink
- content/dialogue/season1/lura-farewell.ink
- content/dialogue/season1/compost-acknowledgements.ink
- src/sim/narrative/lura-gate.ts
- src/sim/narrative/lura-gate.test.ts
- src/sim/narrative/beat-queue.ts
- src/sim/narrative/index.ts
- src/sim/garden/commands.ts
- src/sim/garden/commands.test.ts
- src/content/ink-loader.ts
- src/content/ink-loader.test.ts
- src/content/index.ts
- src/ui/dialogue/LuraDialogue.tsx
- src/ui/dialogue/LuraDialogue.test.tsx
- src/ui/dialogue/ink-renderer.tsx
- src/ui/dialogue/ink-runtime.ts
- src/ui/dialogue/ink-runtime.test.ts
- src/ui/dialogue/index.ts
- src/ui/index.ts
- src/render/garden/gate-renderer.ts
- src/render/garden/index.ts
- src/game/scenes/Garden.ts
- src/App.tsx
autonomous: true
requirements: [STRY-01, STRY-06, STRY-07, STRY-10]
tags: [vertical-slice, lura, ink, dialogue-overlay, narrative-gating, mvp]
must_haves:
truths:
- "Lura is present as discrete gate visits — not a persistent chat thread (D-12). 3 beats this Season: arrival, mid, farewell."
- "All Lura dialogue is authored in Ink (.ink) under /content/dialogue/season1/; compiled at build time to JSON via `npm run compile:ink` invoking inklecate; runtime-loaded via inkjs (STRY-06, D-16)"
- "Beat 1 (arrival) fires when state.harvestedFragmentIds.length transitions from 0 to 1 (1st harvest); beat 2 (mid) at 4th harvest; beat 3 (farewell) at 8th harvest. Counts come from sim state — STRY-10."
- "STRY-10: FakeClock advance alone (without harvest events) does NOT advance Lura beats. Tested in lura-gate.test.ts."
- "When a beat fires, sim sets state.luraBeatProgress.pending = beatId; the gate visual (in Phaser) shows a soft glow indicator (D-15). Player clicks the gate → React DOM dialogue overlay opens (D-15)."
- "Dialogue overlay uses inkjs Story to drive lines; text-message-cadence renders one line at a time with a tunable delay (RESEARCH p.800: 800ms × line length / 40 chars or simpler fixed 1500ms)"
- "Lura's Ink branches read sim state via story.variablesState — at minimum: fragment_count, last_plant_type, last_fragment_title (slot vocabulary documented in PATTERNS.md row 'Group J')"
- "After dismissing a beat, sim sets the beat's progress flag to true and clears `pending`; subsequent harvests advance toward the next threshold"
- "Compost acknowledgements (D-07 + GARD-04) ship as a small Ink file (compost-acknowledgements.ink) with 35 short lines; sim sets a beat flag for compost; the Lura dialogue overlay (or a thinner toast variant) plays the line"
- "All player-visible Ink content matches bible voice: warm + specific + intermittent; Lura is the warmth anchor, not a co-griever"
- "STRY-07: vacuously satisfied — Phase 2 ships zero Keeper-spoken lines (no Ink file says 'Keeper says...'); documented in SUMMARY"
- "Sim does NOT import inkjs (Architectural Responsibility Map line 40: Ink runtime lives in UI tier); narrative gating is pure-state"
- "compile-ink.mjs runs cleanly on Windows + macOS + Linux (RESEARCH Assumption A6 verification — first real inklecate invocation in the project)"
- "Compiled .ink.json output lives in src/content/compiled-ink/ and is .gitignore'd; the build pipeline regenerates on every `npm run build`"
- "npm run ci is green; the new compile-ink.mjs + compiled-ink path participate"
artifacts:
- path: scripts/compile-ink.mjs
provides: "Build-time inklecate runner — walks /content/dialogue/**/*.ink, emits to src/content/compiled-ink/<season>/<name>.ink.json"
- path: content/dialogue/season1/lura-arrival.ink
provides: "Authored Ink for Lura's first beat (after 1st harvest); reads `fragment_count`, `last_plant_type`"
- path: content/dialogue/season1/lura-mid.ink
provides: "Authored Ink for Lura's mid beat (after 4th harvest); reads same variables"
- path: content/dialogue/season1/lura-farewell.ink
provides: "Authored Ink for Lura's farewell beat (after 8th harvest)"
- path: content/dialogue/season1/compost-acknowledgements.ink
provides: "35 short lines in voice for the compost tonal beat (GARD-04, D-07, replaces Plan 02-03's TODO)"
- path: src/sim/narrative/lura-gate.ts
provides: "Pure tick-count gate — checks harvestedFragmentIds.length against {1, 4, 8} thresholds; returns next pending beat id (D-14 + STRY-10)"
exports: ["LURA_BEAT_THRESHOLDS", "checkLuraBeatGate", "advanceLuraBeatProgress"]
- path: src/sim/narrative/beat-queue.ts
provides: "Beat queue type contracts mirroring V1Payload.luraBeatProgress shape"
exports: ["LuraBeatId", "LuraBeatProgress"]
- path: src/content/ink-loader.ts
provides: "Lazy runtime loader for compiled Ink JSON; instantiates inkjs Story with story.variablesState bound from store"
exports: ["loadInkStory", "bindGardenStateToInk", "INK_VARIABLE_MAP"]
- path: src/ui/dialogue/LuraDialogue.tsx
provides: "DOM dialogue overlay (D-15); text-message-cadence drip; opens when narrative.dialogueOverlayOpen=true"
exports: ["LuraDialogue"]
- path: src/ui/dialogue/ink-runtime.ts
provides: "Thin wrapper around inkjs Story.Continue() + currentChoices; binds variables from store snapshot before first Continue"
exports: ["InkRuntime", "createInkRuntime"]
- path: src/render/garden/gate-renderer.ts
provides: "Phaser primitive gate visual + indicator on luraBeatProgress.pending != null"
exports: ["drawGate", "updateGateIndicator"]
key_links:
- from: src/sim/garden/commands.ts
to: src/sim/narrative/lura-gate.ts
via: "harvest() calls advanceLuraBeatProgress to update state.luraBeatProgress.pending after appending to harvestedFragmentIds"
pattern: "advanceLuraBeatProgress"
- from: src/ui/dialogue/LuraDialogue.tsx
to: src/content/ink-loader.ts
via: "loadInkStory(beatId) returns inkjs Story; LuraDialogue drives Continue/choices via ink-runtime"
pattern: "loadInkStory"
- from: src/render/garden/gate-renderer.ts
to: src/store/index.ts
via: "Garden scene reads narrativeSlice.luraBeatProgress.pending; updates gate indicator visibility"
pattern: "luraBeatProgress.pending"
- from: src/game/scenes/Garden.ts
to: src/store/index.ts
via: "Garden scene's gate pointerdown calls setDialogueOverlayOpen(true) when a beat is pending"
pattern: "setDialogueOverlayOpen"
- from: src/ui/dialogue/ink-runtime.ts
to: "inkjs Story.variablesState"
via: "story.variablesState['fragment_count'] = snapshot.harvestedFragmentIds.length (Pitfall 4: snake_case mandatory)"
pattern: "variablesState"
---
<note>
**Wave 2 vertical slice. Depends on Plans 02-01, 02-02, 02-03.**
This plan ships Lura's three Ink-authored beats end-to-end: from sim-state harvest count → narrative gating → gate indicator on the canvas → React DOM dialogue overlay reading and rendering Ink.
Runs in parallel with Plan 02-05 (Letter + Settings + e2e). Plan 02-05 depends on this plan's `lura_was_here` slot output but only structurally; the merge moment is small.
3 tasks. Estimated context cost ~50%. The first task is the load-bearing inklecate verification (RESEARCH Assumption A6, MEDIUM risk) — if compile-ink.mjs doesn't work on Windows, the executor must surface in SUMMARY.md and adjust before authoring further content.
</note>
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@CLAUDE.md
@.planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md
@.planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md
@.planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md
@.planning/phases/02-season-1-vertical-slice-soil/02-01-foundations-SUMMARY.md
@.planning/phases/02-season-1-vertical-slice-soil/02-03-harvest-journal-fragments-SUMMARY.md
<interfaces>
<!-- Types and exports the executor needs from prior Phase 2 plans. -->
From src/store/index.ts (Plan 02-01):
```typescript
luraBeatProgress: {
arrived: boolean; mid: boolean; farewell: boolean;
pending: 'arrival' | 'mid' | 'farewell' | null;
};
dialogueOverlayOpen: boolean;
setLuraBeatProgress(p): void;
setDialogueOverlayOpen(open: boolean): void;
type LuraBeatId = 'arrival' | 'mid' | 'farewell';
```
From src/sim/garden/commands.ts (Plan 02-03):
```typescript
export function harvest(state: SimState, tileIdx: number, currentTick: number, ctx: SimContext): SimState;
// ^^ extend to ALSO call advanceLuraBeatProgress on the new harvest count
```
From src/sim/state.ts (Plan 02-01):
```typescript
export interface SimState {
...
luraBeatProgress: { arrived: boolean; mid: boolean; farewell: boolean; pending: ... | null };
harvestedFragmentIds: string[];
...
}
```
From src/content/index.ts (Plan 02-02):
```typescript
export const fragments: Fragment[]; // for last_fragment_title slot
export const uiStrings: Record<number, UiStrings>;
```
From src/game/event-bus.ts (Plan 02-01):
```typescript
export const eventBus: Phaser.Events.EventEmitter;
// Events Phase 2 emits:
// 'gate-clicked' (Phaser → React; emitted when player clicks gate visual)
```
From inkjs (installed v2.4.0; verified via node_modules/inkjs/ink.d.mts):
```typescript
import { Story } from 'inkjs';
const story = new Story(jsonString);
story.variablesState['fragment_count'] = 5; // SNAKE_CASE per Ink convention; Pitfall 4 says casing must match
const line = story.Continue(); // returns next text line
const choices = story.currentChoices; // array of Choice objects (with text, index)
story.ChooseChoiceIndex(0); // advances on chosen choice
const canContinue = story.canContinue; // bool
```
From inklecate (installed v1.8.1; verified via package.json + node_modules/inklecate/):
The npm wrapper exposes the inklecate binary. RESEARCH Assumption A6 flags that the Windows binary path needs to work; first real run is THIS plan. Use the wrapper's exported function rather than direct binary path:
```javascript
import inklecate from 'inklecate';
// API surface to confirm: inklecate({ inputFilepath, outputFilepath })
// — readme + actual API surface verified during Task 1
```
From .gitignore (current — extend):
```
# (existing entries) — Plan 02-04 ADDS:
# Compiled Ink output (regenerated by `npm run compile:ink`)
src/content/compiled-ink/
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Author all 4 Ink files (3 Lura beats + compost) + scripts/compile-ink.mjs + ink-loader runtime + RESEARCH A6 inklecate verification</name>
<read_first>
- .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 5 lines 741-800, Pattern 6 lines 802-840, Assumption A6 line 1213, Pitfall 4 lines 1057-1074)
- .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group J lines 521-554)
- node_modules/inklecate/package.json + node_modules/inklecate/README.md (verify exact API)
- node_modules/inkjs/ink.d.mts (Story + variablesState API)
- CLAUDE.md (Tone — Lura voice; warmth anchor)
- .planning/anti-fomo-doctrine.md (the dialogue must comply: no nag, no FOMO, contemplative)
</read_first>
<files>
scripts/compile-ink.mjs,
scripts/compile-ink.test.mjs,
package.json,
.gitignore,
content/dialogue/season1/lura-arrival.ink,
content/dialogue/season1/lura-mid.ink,
content/dialogue/season1/lura-farewell.ink,
content/dialogue/season1/compost-acknowledgements.ink,
src/content/ink-loader.ts,
src/content/ink-loader.test.ts,
src/content/index.ts
</files>
<action>
**Step 1 — Verify inklecate API (Assumption A6, MEDIUM risk).**
Read `node_modules/inklecate/README.md` and `node_modules/inklecate/package.json` to confirm:
- The default export's call signature.
- The Windows binary path (e.g., `node_modules/inklecate/inklecate-windows/inklecate.exe`).
If the package wrapper exposes a function like `inklecate({ inputFilepath, outputFilepath })`, use it. If it only exposes a CLI binary path, use Node's `child_process.execFileSync` to invoke the platform-appropriate binary. **Document the chosen approach in compile-ink.mjs's leading comment.**
**Step 2 — `scripts/compile-ink.mjs`** — build-time Ink compiler:
```javascript
#!/usr/bin/env node
// Phase 2 Plan 02-04 — compile content/dialogue/**/*.ink → src/content/compiled-ink/**/*.ink.json
//
// Per RESEARCH Pattern 5 + Assumption A6 (verified on this run).
//
// API note: this script invokes the inklecate npm wrapper. If the wrapper
// API differs at runtime, fall back to invoking the platform binary via
// child_process.execFileSync — the wrapper's bin/ directory contains
// inklecate-windows/, inklecate-linux/, inklecate-macos/ subdirectories.
import { mkdirSync, existsSync, readdirSync, statSync, rmSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { execFileSync } from 'node:child_process';
const INK_ROOT = resolve(process.cwd(), 'content/dialogue');
const OUT_ROOT = resolve(process.cwd(), 'src/content/compiled-ink');
function findInkFiles(root) {
const out = [];
if (!existsSync(root)) return out;
for (const entry of readdirSync(root)) {
const full = join(root, entry);
const st = statSync(full);
if (st.isDirectory()) out.push(...findInkFiles(full));
else if (entry.endsWith('.ink')) out.push(full);
}
return out;
}
function inklecateBinary() {
// 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:
```javascript
import { describe, it, expect } from 'vitest';
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { compileAllInk } from './compile-ink.mjs';
describe('scripts/compile-ink.mjs', () => {
it('exports compileAllInk', () => {
expect(typeof compileAllInk).toBe('function');
});
it('compiles all .ink files in content/dialogue and emits .ink.json under src/content/compiled-ink/', async () => {
const result = await compileAllInk();
expect(result.compiled).toBeGreaterThanOrEqual(4); // 3 Lura + compost
expect(existsSync(resolve(process.cwd(), 'src/content/compiled-ink/season1/lura-arrival.ink.json'))).toBe(true);
expect(existsSync(resolve(process.cwd(), 'src/content/compiled-ink/season1/compost-acknowledgements.ink.json'))).toBe(true);
});
});
```
**Step 4 — Update `package.json`:**
```json
"compile:ink": "node scripts/compile-ink.mjs",
"build": "npm run compile:ink && tsc -b && vite build",
"ci": "npm run lint && npm run test && npm run validate:assets && npm run build && npm run check:bundle-split"
```
(`npm run build` now precompiles Ink before the TS+Vite build so the `import.meta.glob('/src/content/compiled-ink/**/*.ink.json')` glob can resolve.)
**Step 5 — Update `.gitignore`** to exclude generated Ink JSON:
```
# Compiled Ink output — regenerated on every build by `npm run compile:ink`
src/content/compiled-ink/
```
**Step 6 — Author Ink files.**
`content/dialogue/season1/lura-arrival.ink` — beat 1 (after 1st harvest):
```ink
// Lura, arrival beat. After the player's first harvest.
// Variables read from sim:
// fragment_count - number of harvested fragments at the moment Lura arrives
// last_plant_type - 'rosemary' | 'yarrow' | 'winter-rose'
//
// Per Pitfall 4: snake_case mandatory.
// Per CLAUDE.md Tone: Lura is the warmth anchor. Not a co-griever.
// Specific, intermittent, sometimes funny.
VAR fragment_count = 0
VAR last_plant_type = ""
== arrival ==
You're already here. I thought it might take you longer.
{ last_plant_type == "rosemary":
Rosemary, of all things. My grandmother's whole apron, when she got too close to the pot.
- else:
{ last_plant_type == "yarrow":
Yarrow. There used to be a saying about yarrow but I can't remember it. That's the joke I think.
- else:
{ last_plant_type == "winter-rose":
Winter-rose. You don't mess around. Most people start small.
- else:
Something grew. That's a start.
}
}
}
I won't stay long. I just wanted to know that the wall held.
-> END
```
`content/dialogue/season1/lura-mid.ink` — beat 2 (after 4th harvest):
```ink
VAR fragment_count = 0
VAR last_plant_type = ""
== mid ==
Four. That feels like a real number.
I tried to do this once. The garden, I mean. It was a balcony. I had three pots and one of them was already broken when I bought it. The basil died first. The rosemary survived. I think the rosemary survives most things.
You're keeping at it. Most people don't.
I have something I should be doing. I'll come back when there's more.
-> END
```
`content/dialogue/season1/lura-farewell.ink` — beat 3 (after 8th harvest):
```ink
VAR fragment_count = 0
VAR last_plant_type = ""
== farewell ==
Eight is enough for now.
I think we both know what this part is. I'm going to go for a while. There's something I've been putting off, and I think you're far enough along that I can stop pretending I'm here for the small reasons.
You'll know when there's more to say. You don't need me at the gate every day.
The garden persists. Some of it is mine. Most of it is yours now.
-> END
```
`content/dialogue/season1/compost-acknowledgements.ink` — Plan 02-03's TODO replacement (D-07 + GARD-04):
```ink
// Compost acknowledgements — short tonal beats fired when the player
// composts an immature plant. One line per call (the renderer picks
// randomly via fragment_count seed for variety).
VAR fragment_count = 0
== compost ==
{ fragment_count == 0:
Sometimes the soil needs a turn.
- else:
{
- fragment_count % 4 == 0:
It wasn't ready. That's not the same as failing.
- fragment_count % 3 == 0:
Some things are easier to begin again than to finish.
- fragment_count % 2 == 0:
The earth keeps the part that was useful.
- else:
Letting go is a kind of tending.
}
}
-> END
```
(Lines designed to match bible voice. User reviews before merge.)
**Step 7 — `src/content/ink-loader.ts`** — runtime loader (RESEARCH Pattern 5):
```typescript
import { Story } from 'inkjs';
import type { AppStoreShape } from '../store';
import { fragments as allFragments } from './loader';
/**
* Runtime Ink loader — instantiates an inkjs Story from the compiled
* JSON for a given beat id, and binds variables from a store snapshot.
*
* Per RESEARCH Pattern 5 + Pitfall 4 (snake_case mandatory).
*/
const luraStories = import.meta.glob('/src/content/compiled-ink/season1/lura-*.ink.json', {
query: '?raw',
import: 'default',
});
const compostStory = import.meta.glob('/src/content/compiled-ink/season1/compost-acknowledgements.ink.json', {
query: '?raw',
import: 'default',
});
/**
* The variable map binds Ink VAR names (snake_case) to functions that
* read the current store snapshot. Centralized here per Pitfall 4 — keys
* here MUST match VAR declarations in the .ink files.
*/
export const INK_VARIABLE_MAP = {
fragment_count: (s: AppStoreShape) => s.harvestedFragmentIds.length,
last_plant_type: (s: AppStoreShape) => {
// Phase 2: derive from most-recent harvest's plant type. The
// harvestedFragmentIds list is fragment IDs, not plant types — we
// map back via the fragment's `tags` field (warm/contemplative/heavy)
// → a plant type. The most-recent fragment's tag is the simplest proxy.
const lastId = s.harvestedFragmentIds[s.harvestedFragmentIds.length - 1];
if (!lastId) return '';
const frag = allFragments.find((f) => f.id === lastId);
if (!frag?.tags) return '';
if (frag.tags.includes('warm')) return 'rosemary';
if (frag.tags.includes('contemplative')) return 'yarrow';
if (frag.tags.includes('heavy')) return 'winter-rose';
return '';
},
// 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 a `Story` instance (smoke).
- `loadInkStory('compost-acknowledgements')` returns a Story.
- `bindGardenStateToInk(story, snapshot)` sets `story.variablesState['fragment_count']` to `snapshot.harvestedFragmentIds.length`.
- `bindGardenStateToInk` does not throw on a story missing a declared var (the warn is silent).
- Variable casing test (Pitfall 4): every key in `INK_VARIABLE_MAP` is snake_case. Programmatic assertion: `Object.keys(INK_VARIABLE_MAP).every(k => /^[a-z_]+$/.test(k))`.
This test requires Ink JSON to be present, which requires `npm run compile:ink` to have run 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:
```typescript
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:
```typescript
export { loadInkStory, bindGardenStateToInk, INK_VARIABLE_MAP } from './ink-loader';
```
**Verification before commit:**
Run from repo root:
```
npm run compile:ink
ls src/content/compiled-ink/season1/ # Should list 4 .ink.json files
npm run lint
npx vitest run src/content/ink-loader.test.ts scripts/compile-ink.test.mjs
npm run build # Should compile Ink + TS + Vite all green
```
If `compile:ink` fails on Windows (Assumption A6 risk), DOCUMENT in SUMMARY.md and adjust the `inklecateBinary()` resolution. Try `npx inklecate` as a last fallback.
**Commit:** `feat(02-04): ink compilation pipeline + 4 authored Season-1 Ink files + runtime loader`. Run `npm run ci` before committing.
</action>
<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>
<verify>
<automated>npm run compile:ink && npm run lint && npx vitest run src/content/ink-loader.test.ts scripts/compile-ink.test.mjs && npm run ci</automated>
</verify>
<done>
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.
</done>
</task>
<task type="auto">
<name>Task 2: sim/narrative — Lura beat gating (1st/4th/8th harvest, STRY-10) + harvest-command integration</name>
<read_first>
- .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Lura Beat Gating section + Validation Architecture row STRY-10)
- .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group E lines 312-346)
- src/sim/garden/commands.ts (Plan 02-03 — extend `harvest` to call advanceLuraBeatProgress)
- src/sim/garden/commands.test.ts (extend with STRY-10 test)
- src/sim/state.ts (luraBeatProgress shape)
</read_first>
<files>
src/sim/narrative/lura-gate.ts,
src/sim/narrative/lura-gate.test.ts,
src/sim/narrative/beat-queue.ts,
src/sim/narrative/index.ts,
src/sim/garden/commands.ts,
src/sim/garden/commands.test.ts,
src/sim/index.ts
</files>
<action>
**Step 1 — `src/sim/narrative/beat-queue.ts`** — type contracts:
```typescript
/**
* Lura beat type contracts. Shape mirrors V1Payload.luraBeatProgress
* declared in src/save/migrations.ts (Plan 02-01 D-34 extension).
*/
export type LuraBeatId = 'arrival' | 'mid' | 'farewell';
export interface LuraBeatProgress {
arrived: boolean;
mid: boolean;
farewell: boolean;
pending: LuraBeatId | null;
}
export const INITIAL_LURA_BEAT_PROGRESS: LuraBeatProgress = Object.freeze({
arrived: false,
mid: false,
farewell: false,
pending: null,
});
```
**Step 2 — `src/sim/narrative/lura-gate.ts`** — pure tick-count gate (PATTERNS Group E):
```typescript
import type { LuraBeatId, LuraBeatProgress } from './beat-queue';
/**
* Lura beat thresholds (CONTEXT D-14). Gate fires when harvestedFragmentIds.length
* reaches each threshold value (Pitfall 10: check AFTER the harvest commit).
*
* Per STRY-10: gates on tick count (harvest events), NOT wall time. A
* player who manipulates their system clock cannot fast-forward Lura's
* beats — only harvesting does. The harvest function in
* src/sim/garden/commands.ts calls advanceLuraBeatProgress with the
* post-commit harvestedFragmentIds.length.
*/
export const LURA_BEAT_THRESHOLDS: Readonly<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:
```typescript
import { describe, it, expect } from 'vitest';
import { FakeClock } from '../scheduler';
import { advanceLuraBeatProgress, resolvePendingLuraBeat, isLuraBeatPending, LURA_BEAT_THRESHOLDS } from './lura-gate';
import { INITIAL_LURA_BEAT_PROGRESS } from './beat-queue';
describe('advanceLuraBeatProgress (STRY-10, D-14)', () => {
it('sets pending=arrival on the 1st harvest', () => {
const next = advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 1);
expect(next.pending).toBe('arrival');
expect(next.arrived).toBe(false); // not yet visited
});
it('does NOT set pending at harvest count 0', () => {
const next = advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 0);
expect(next.pending).toBeNull();
});
it('does NOT set pending at counts between thresholds (2, 3, 5, 6, 7)', () => {
[2, 3, 5, 6, 7].forEach((c) => {
const next = advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, c);
expect(next.pending).toBeNull();
});
});
it('Pitfall 10 (off-by-one boundary): threshold 4 fires AT 4, not 3 or 5', () => {
expect(advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 3).pending).toBeNull();
expect(advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 4).pending).toBe('mid');
expect(advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 5).pending).toBeNull();
});
it('does NOT replace a pending beat with a different one (player must visit first)', () => {
let p = advanceLuraBeatProgress(INITIAL_LURA_BEAT_PROGRESS, 1); // pending=arrival
p = advanceLuraBeatProgress(p, 4);
expect(p.pending).toBe('arrival'); // unchanged
});
it('does NOT re-fire an already-visited beat', () => {
let p: any = { ...INITIAL_LURA_BEAT_PROGRESS, arrived: true };
p = advanceLuraBeatProgress(p, 1);
expect(p.pending).toBeNull();
});
it('STRY-10: FakeClock advance does NOT advance Lura beats without harvest events', () => {
const clock = new FakeClock(0);
const initialProgress = INITIAL_LURA_BEAT_PROGRESS;
clock.advance(60 * 60 * 1000); // 1 hour of "wall time"
// No harvests fired — the gate function is invoked with harvestCount=0
const after = advanceLuraBeatProgress(initialProgress, 0);
expect(after).toEqual(INITIAL_LURA_BEAT_PROGRESS);
});
});
describe('resolvePendingLuraBeat', () => {
it('marks arrival as resolved and clears pending', () => {
const p = { ...INITIAL_LURA_BEAT_PROGRESS, pending: 'arrival' as const };
const next = resolvePendingLuraBeat(p);
expect(next.arrived).toBe(true);
expect(next.pending).toBeNull();
});
it('marks mid + farewell similarly', () => {
const m = resolvePendingLuraBeat({ ...INITIAL_LURA_BEAT_PROGRESS, pending: 'mid' });
expect(m.mid).toBe(true);
const f = resolvePendingLuraBeat({ ...INITIAL_LURA_BEAT_PROGRESS, pending: 'farewell' });
expect(f.farewell).toBe(true);
});
it('is a no-op when pending=null', () => {
expect(resolvePendingLuraBeat(INITIAL_LURA_BEAT_PROGRESS)).toEqual(INITIAL_LURA_BEAT_PROGRESS);
});
});
describe('isLuraBeatPending', () => {
it('returns true when pending is set', () => {
expect(isLuraBeatPending({ ...INITIAL_LURA_BEAT_PROGRESS, pending: 'arrival' })).toBe(true);
});
it('returns false when no beat pending', () => {
expect(isLuraBeatPending(INITIAL_LURA_BEAT_PROGRESS)).toBe(false);
});
});
```
**Step 4 — `src/sim/narrative/index.ts`:**
```typescript
export { LURA_BEAT_THRESHOLDS, advanceLuraBeatProgress, resolvePendingLuraBeat, isLuraBeatPending } from './lura-gate';
export type { LuraBeatId, LuraBeatProgress } from './beat-queue';
export { INITIAL_LURA_BEAT_PROGRESS } from './beat-queue';
```
Add `export * from './narrative'` to `src/sim/index.ts`.
**Step 5 — Extend `src/sim/garden/commands.ts`**`harvest` now updates `luraBeatProgress`:
In the harvest function, after the `harvestedIds = [...]` line and before computing `unlockedPlantTypes`:
```typescript
import { advanceLuraBeatProgress } from '../narrative/lura-gate';
// ... inside harvest():
const luraBeatProgress = advanceLuraBeatProgress(state.luraBeatProgress, harvestedIds.length);
return {
...state,
garden: { tiles: nextTiles },
harvestedFragmentIds: harvestedIds,
unlockedPlantTypes,
luraBeatProgress,
};
```
**Step 6 — Extend `src/sim/garden/commands.test.ts`** with integration tests:
- After harvesting 1 ready plant, `state.luraBeatProgress.pending` is `'arrival'`.
- After harvesting 4 ready plants (with `arrived=true` set after the 1st), `state.luraBeatProgress.pending` is `'mid'`.
- Harvest count 5 with `pending='mid'` (player hasn't visited yet) leaves `pending='mid'`.
- After 8 harvests with 1+4 already visited, `pending='farewell'`.
**Commit:** `feat(02-04): sim/narrative — Lura beat gating (1/4/8 harvest, STRY-10)`. Run `npm run lint && npx vitest run src/sim/narrative/ src/sim/garden/` before committing.
</action>
<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>
<verify>
<automated>npm run lint && npx vitest run src/sim/narrative/ src/sim/garden/ && npm run build</automated>
</verify>
<done>
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.
</done>
</task>
<task type="auto">
<name>Task 3: ui/dialogue (LuraDialogue + ink-renderer + ink-runtime) + render/garden gate-renderer + Garden scene integration + App.tsx mount</name>
<read_first>
- .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 5 lines 776-800 drip cadence, Architectural Responsibility Map row "Ink runtime bridge")
- .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group I lines 471-518 React mounting; Group H lines 426-468 render layer)
- src/store/index.ts (narrativeSlice: dialogueOverlayOpen, luraBeatProgress, setDialogueOverlayOpen, setLuraBeatProgress)
- src/game/scenes/Garden.ts (Plan 02-02 + 02-03 — extend with gate object + pointerdown)
- src/render/garden/tile-coords.ts (Plan 02-02 — gate sits in canvas alongside grid; reuse layout constants)
- src/App.tsx (Plan 02-03 — extend mount list)
- src/ui/journal/Journal.tsx (analog DOM full-screen overlay)
</read_first>
<files>
src/ui/dialogue/LuraDialogue.tsx,
src/ui/dialogue/LuraDialogue.test.tsx,
src/ui/dialogue/ink-renderer.tsx,
src/ui/dialogue/ink-runtime.ts,
src/ui/dialogue/ink-runtime.test.ts,
src/ui/dialogue/index.ts,
src/ui/index.ts,
src/render/garden/gate-renderer.ts,
src/render/garden/index.ts,
src/game/scenes/Garden.ts,
src/App.tsx
</files>
<action>
**Step 1 — `src/ui/dialogue/ink-runtime.ts`** — thin wrapper around inkjs (RESEARCH p.776):
```typescript
import type { Story } from 'inkjs';
/**
* InkRuntime — thin wrapper around inkjs Story that yields lines one at
* a time with a tunable cadence delay. Used by LuraDialogue.
*
* Phase 2: fixed delay per line (1500ms or proportional to line length).
* Phase 8: reduced-motion (UX-05) will short-circuit the delay.
*/
export interface InkRuntime {
/** Pull the next available line; resolves after the cadence delay. */
nextLine(): Promise<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 next `nextLine()` resolve nearly-instantly (timing assertion: <100ms).
- `canContinue()` returns true at start and false after exhaustion.
- `currentChoices()` returns choice array when story pauses on choices.
- `chooseChoice(0)` advances past the choice point.
(For Vitest, use `vi.useFakeTimers()` to assert delay logic without real waits.)
**Step 3 — `src/ui/dialogue/ink-renderer.tsx`** — drip rendering of accumulated Ink lines:
```typescript
import { useEffect, useRef, useState } from 'react';
import type { InkRuntime } from './ink-runtime';
/**
* Drives an InkRuntime, drips lines into the DOM with text-message
* cadence. Used by LuraDialogue (full-screen overlay) and may be reused
* for compost acknowledgements (smaller toast variant — Plan 02-04 Task 3).
*/
export function InkRenderer({ runtime, onComplete }: { runtime: InkRuntime; onComplete?: () => void }): JSX.Element {
const [lines, setLines] = useState<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`:**
```typescript
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: true` and `pending: null`, returns null (no beat to render).
- With `dialogueOverlayOpen: true` and `pending: 'arrival'`, mounts the dialog (text "Lura at the gate" via aria-label).
- Close button click: dispatches `setDialogueOverlayOpen(false)` AND advances `luraBeatProgress.arrived` to 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`:
```typescript
// 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):
```typescript
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`:
```typescript
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).
```typescript
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`:**
```typescript
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.
</action>
<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>
<verify>
<automated>npm run lint && npx vitest run src/ui/dialogue/ src/render/ && npm run ci</automated>
</verify>
<done>
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.
</done>
</task>
</tasks>
<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>
<verification>
After all 3 tasks committed:
1. **Linter:** `npm run lint` exits 0.
2. **Tests:** `npx vitest run` exits 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.
3. **Ink compile:** `npm run compile:ink` produces `src/content/compiled-ink/season1/{lura-arrival,lura-mid,lura-farewell,compost-acknowledgements}.ink.json`.
4. **Build:** `npm run build` exits 0 — `compile:ink` runs as part of build, then tsc + vite.
5. **PIPE-02 verify:** `node scripts/check-bundle-split.mjs` after build exits 0 (the compiled Ink JSON participates in a chunk; should not break the season1 chunk assertion).
6. **Full CI:** `npm run ci` exits 0.
7. **STRY-10 evidence:** lura-gate.test.ts has a test case proving FakeClock advance alone doesn't trigger beats (visible in test output).
8. **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.
</verification>
<success_criteria>
Plan 02-04 is complete when:
- [ ] All 3 tasks committed.
- [ ] `npm run ci` exits 0.
- [ ] Compile pipeline: `.ink` source → `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 `inkjs` import, no `Date.now`, no `setInterval`.
- [ ] STRY-07 vacuously satisfied (no Keeper-spoken lines in Phase 2).
- [ ] Plan 02-05 (Letter + e2e) can build on the `lura_was_here` slot output (already covered by store's `luraBeatProgress.pending`).
</success_criteria>
<output>
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.
</output>