63d2d8d5f7
Phase 2 (Season 1 Vertical Slice — Soil) plan set: - 02-01 (Wave 0): foundations (BigQty + Zustand 5 store + tick scheduler + V1Payload extension + save lifecycle hooks + Phaser EventBus + ESLint sim-purity rule) - 02-02 (Wave 1, parallel): Begin → Plant → Grow vertical slice - 02-03 (Wave 1, parallel): Harvest → Journal → Compost + Season 1 fragments + PIPE-02 verification - 02-04 (Wave 2, parallel): Lura's 3 Ink-authored gate beats (1st/4th/8th harvest, STRY-10) - 02-05 (Wave 2, parallel): Letter + Settings + boot-path save lifecycle + Playwright PIPE-07 e2e All 24 Phase-2 REQ-IDs covered across the plan set. VALIDATION.md per-task verification map filled (15 tasks); nyquist_compliant: true. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1368 lines
58 KiB
Markdown
1368 lines
58 KiB
Markdown
---
|
||
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
|
||
- content/dialogue/season1/lura-greeting-template.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:
|
||
- "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 (STRY-06)"
|
||
- "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 3–5 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: "3–5 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() {
|
||
// Verified at Task 1 — node_modules/inklecate/ ships per-platform binaries.
|
||
// The wrapper module exports a programmatic API; if it does not, fall through here.
|
||
const platform = process.platform;
|
||
const root = resolve(process.cwd(), 'node_modules/inklecate');
|
||
if (platform === 'win32') return resolve(root, 'inklecate-windows/inklecate.exe');
|
||
if (platform === 'darwin') return resolve(root, 'inklecate-mac/inklecate');
|
||
return resolve(root, 'inklecate-linux/inklecate');
|
||
}
|
||
|
||
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 '';
|
||
},
|
||
} 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. Add a `beforeAll` that runs the compile script:
|
||
|
||
```typescript
|
||
import { beforeAll } from 'vitest';
|
||
import { compileAllInk } from '../../scripts/compile-ink.mjs';
|
||
beforeAll(async () => { await compileAllInk(); });
|
||
```
|
||
|
||
**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`
|
||
- `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>
|