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

60 KiB
Raw Blame History

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
02-01
02-02
02-03
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
true
STRY-01
STRY-06
STRY-07
STRY-10
vertical-slice
lura
ink
dialogue-overlay
narrative-gating
mvp
truths artifacts key_links
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
path provides
scripts/compile-ink.mjs Build-time inklecate runner — walks /content/dialogue/**/*.ink, emits to src/content/compiled-ink/<season>/<name>.ink.json
path provides
content/dialogue/season1/lura-arrival.ink Authored Ink for Lura's first beat (after 1st harvest); reads `fragment_count`, `last_plant_type`
path provides
content/dialogue/season1/lura-mid.ink Authored Ink for Lura's mid beat (after 4th harvest); reads same variables
path provides
content/dialogue/season1/lura-farewell.ink Authored Ink for Lura's farewell beat (after 8th harvest)
path provides
content/dialogue/season1/compost-acknowledgements.ink 35 short lines in voice for the compost tonal beat (GARD-04, D-07, replaces Plan 02-03's TODO)
path provides exports
src/sim/narrative/lura-gate.ts Pure tick-count gate — checks harvestedFragmentIds.length against {1, 4, 8} thresholds; returns next pending beat id (D-14 + STRY-10)
LURA_BEAT_THRESHOLDS
checkLuraBeatGate
advanceLuraBeatProgress
path provides exports
src/sim/narrative/beat-queue.ts Beat queue type contracts mirroring V1Payload.luraBeatProgress shape
LuraBeatId
LuraBeatProgress
path provides exports
src/content/ink-loader.ts Lazy runtime loader for compiled Ink JSON; instantiates inkjs Story with story.variablesState bound from store
loadInkStory
bindGardenStateToInk
INK_VARIABLE_MAP
path provides exports
src/ui/dialogue/LuraDialogue.tsx DOM dialogue overlay (D-15); text-message-cadence drip; opens when narrative.dialogueOverlayOpen=true
LuraDialogue
path provides exports
src/ui/dialogue/ink-runtime.ts Thin wrapper around inkjs Story.Continue() + currentChoices; binds variables from store snapshot before first Continue
InkRuntime
createInkRuntime
path provides exports
src/render/garden/gate-renderer.ts Phaser primitive gate visual + indicator on luraBeatProgress.pending != null
drawGate
updateGateIndicator
from to via pattern
src/sim/garden/commands.ts src/sim/narrative/lura-gate.ts harvest() calls advanceLuraBeatProgress to update state.luraBeatProgress.pending after appending to harvestedFragmentIds advanceLuraBeatProgress
from to via pattern
src/ui/dialogue/LuraDialogue.tsx src/content/ink-loader.ts loadInkStory(beatId) returns inkjs Story; LuraDialogue drives Continue/choices via ink-runtime loadInkStory
from to via pattern
src/render/garden/gate-renderer.ts src/store/index.ts Garden scene reads narrativeSlice.luraBeatProgress.pending; updates gate indicator visibility luraBeatProgress.pending
from to via pattern
src/game/scenes/Garden.ts src/store/index.ts Garden scene's gate pointerdown calls setDialogueOverlayOpen(true) when a beat is pending setDialogueOverlayOpen
from to via pattern
src/ui/dialogue/ink-runtime.ts inkjs Story.variablesState story.variablesState['fragment_count'] = snapshot.harvestedFragmentIds.length (Pitfall 4: snake_case mandatory) variablesState
**Wave 2 vertical slice. Depends on Plans 02-01, 02-02, 02-03.**

This plan ships Lura's three Ink-authored beats end-to-end: from sim-state harvest count → narrative gating → gate indicator on the canvas → React DOM dialogue overlay reading and rendering Ink.

Runs in parallel with Plan 02-05 (Letter + Settings + e2e). Plan 02-05 depends on this plan's lura_was_here slot output but only structurally; the merge moment is small.

3 tasks. Estimated context cost ~50%. The first task is the load-bearing inklecate verification (RESEARCH Assumption A6, MEDIUM risk) — if compile-ink.mjs doesn't work on Windows, the executor must surface in SUMMARY.md and adjust before authoring further content.

Land Lura's three Ink-authored Season 1 beats: arrival (after 1st harvest), mid (after 4th harvest), farewell (after 8th harvest), gated on sim-state harvest count (STRY-10 — system clock manipulation cannot fast-forward beats). Replace the no-op `compile:ink` script with a real inklecate runner; author the four Ink files (3 Lura beats + compost acknowledgements); ship the runtime path (`inkjs.Story` instantiation + variable binding + drip cadence DOM rendering); add a soft gate indicator in the Phaser canvas; wire the player-initiated visit (player clicks gate → React DOM dialogue overlay opens).

Purpose: First real player-narrative integration in the project. Validates the entire Ink stack (inklecate compile → JSON → inkjs runtime → variable wiring → React rendering) end-to-end, on real authored content. Phase 4+ (Roots, Canopy, Storm, etc.) inherits this pipeline without rework. Lura is the warmth anchor for the whole arc — Phase 2 is where her voice goes on the record.

Output: Working narrative-state plumbing where harvesting cadence drives Lura's appearances, three short authored beats reading in voice, the Ink → JSON → runtime pipeline structurally verified, and the foundation for Phase 4+'s longer arcs.

<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.md

From 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/
Task 1: Author all 4 Ink files (3 Lura beats + compost) + scripts/compile-ink.mjs + ink-loader runtime + RESEARCH A6 inklecate verification - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 5 lines 741-800, Pattern 6 lines 802-840, Assumption A6 line 1213, Pitfall 4 lines 1057-1074) - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group J lines 521-554) - node_modules/inklecate/package.json + node_modules/inklecate/README.md (verify exact API) - node_modules/inkjs/ink.d.mts (Story + variablesState API) - CLAUDE.md (Tone — Lura voice; warmth anchor) - .planning/anti-fomo-doctrine.md (the dialogue must comply: no nag, no FOMO, contemplative) scripts/compile-ink.mjs, scripts/compile-ink.test.mjs, package.json, .gitignore, content/dialogue/season1/lura-arrival.ink, content/dialogue/season1/lura-mid.ink, content/dialogue/season1/lura-farewell.ink, content/dialogue/season1/compost-acknowledgements.ink, src/content/ink-loader.ts, src/content/ink-loader.test.ts, src/content/index.ts **Step 1 — Verify inklecate API (Assumption A6, MEDIUM risk).**

Read node_modules/inklecate/README.md and node_modules/inklecate/package.json to confirm:

  • The default export's call signature.
  • The Windows binary path (e.g., node_modules/inklecate/inklecate-windows/inklecate.exe).

If the package wrapper exposes a function like inklecate({ inputFilepath, outputFilepath }), use it. If it only exposes a CLI binary path, use Node's child_process.execFileSync to invoke the platform-appropriate binary. Document the chosen approach in compile-ink.mjs's leading comment.

Step 2 — scripts/compile-ink.mjs — build-time Ink compiler:

#!/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 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:

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:inknpx 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.

Task 2: sim/narrative — Lura beat gating (1st/4th/8th harvest, STRY-10) + harvest-command integration - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Lura Beat Gating section + Validation Architecture row STRY-10) - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group E lines 312-346) - src/sim/garden/commands.ts (Plan 02-03 — extend `harvest` to call advanceLuraBeatProgress) - src/sim/garden/commands.test.ts (extend with STRY-10 test) - src/sim/state.ts (luraBeatProgress shape) src/sim/narrative/lura-gate.ts, src/sim/narrative/lura-gate.test.ts, src/sim/narrative/beat-queue.ts, src/sim/narrative/index.ts, src/sim/garden/commands.ts, src/sim/garden/commands.test.ts, src/sim/index.ts **Step 1 — `src/sim/narrative/beat-queue.ts`** — type contracts:
/**
 * 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.tsharvest 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.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. <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.

Task 3: ui/dialogue (LuraDialogue + ink-renderer + ink-runtime) + render/garden gate-renderer + Garden scene integration + App.tsx mount - .planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md (Pattern 5 lines 776-800 drip cadence, Architectural Responsibility Map row "Ink runtime bridge") - .planning/phases/02-season-1-vertical-slice-soil/02-PATTERNS.md (Group I lines 471-518 React mounting; Group H lines 426-468 render layer) - src/store/index.ts (narrativeSlice: dialogueOverlayOpen, luraBeatProgress, setDialogueOverlayOpen, setLuraBeatProgress) - src/game/scenes/Garden.ts (Plan 02-02 + 02-03 — extend with gate object + pointerdown) - src/render/garden/tile-coords.ts (Plan 02-02 — gate sits in canvas alongside grid; reuse layout constants) - src/App.tsx (Plan 02-03 — extend mount list) - src/ui/journal/Journal.tsx (analog DOM full-screen overlay) src/ui/dialogue/LuraDialogue.tsx, src/ui/dialogue/LuraDialogue.test.tsx, src/ui/dialogue/ink-renderer.tsx, src/ui/dialogue/ink-runtime.ts, src/ui/dialogue/ink-runtime.test.ts, src/ui/dialogue/index.ts, src/ui/index.ts, src/render/garden/gate-renderer.ts, src/render/garden/index.ts, src/game/scenes/Garden.ts, src/App.tsx **Step 1 — `src/ui/dialogue/ink-runtime.ts`** — thin wrapper around inkjs (RESEARCH p.776):
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:

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: 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:

// 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:

  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.

<success_criteria>

Plan 02-04 is complete when:

  • All 3 tasks committed.
  • npm run ci exits 0.
  • Compile pipeline: .ink source → inklecate.ink.jsoninkjs.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>

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.