From c90f8f1e5c9dc0d63ab203a384b2f66d9efe6b6d Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 9 May 2026 10:24:40 -0400 Subject: [PATCH] feat(02-04): ink compilation pipeline + 4 authored Season-1 Ink files + runtime loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/compile-ink.mjs: build-time inklecate runner using bundled binary (BLOCKER 4 — uses node_modules/inklecate/bin, not stale -windows/-mac path strings). Assumption A6 verified first-try on Windows; the same binary path resolution works on macOS + Linux per the wrapper's own getInklecatePath convention. - scripts/compile-ink.test.mjs: 3 Vitest cases proving the compiler runs + emits valid JSON with inkVersion. wipe=false for the test path so it can run in parallel with the ink-loader test without racing on the wipe step. - 4 Season-1 .ink files authored in voice (Lura warmth-anchor, gardener-keeper for compost): lura-arrival.ink, lura-mid.ink, lura-farewell.ink, compost-acknowledgements.ink (rewrite of Plan 02-03 scaffolded version into VAR-driven branch shape consumable by the runtime). - src/content/ink-loader.ts: loadInkStory + bindGardenStateToInk + INK_VARIABLE_MAP. Centralized snake_case slot mapping per Pitfall 4. UTF-8 BOM stripped before Story instantiation. - src/content/ink-loader.test.ts: 8 cases — Story instantiation for all 4 beats, fragment_count binding, Pitfall 4 snake_case enforcement, silent skip for stories missing declared vars. - package.json: build now runs compile:ink first; ci chain runs compile:ink before test so ink-loader.test.ts's precondition check passes. - .gitignore: src/content/compiled-ink/ excluded (regenerated on every build). npm run ci exits 0; 11 new tests green (228 total). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 4 + .../season1/compost-acknowledgements.ink | 71 ++++---- content/dialogue/season1/lura-arrival.ink | 44 +++++ content/dialogue/season1/lura-farewell.ink | 30 ++++ content/dialogue/season1/lura-mid.ink | 31 ++++ package.json | 6 +- scripts/compile-ink.mjs | 161 ++++++++++++++++++ scripts/compile-ink.test.mjs | 69 ++++++++ src/content/index.ts | 6 + src/content/ink-loader.test.ts | 142 +++++++++++++++ src/content/ink-loader.ts | 149 ++++++++++++++++ 11 files changed, 674 insertions(+), 39 deletions(-) create mode 100644 content/dialogue/season1/lura-arrival.ink create mode 100644 content/dialogue/season1/lura-farewell.ink create mode 100644 content/dialogue/season1/lura-mid.ink create mode 100644 scripts/compile-ink.mjs create mode 100644 scripts/compile-ink.test.mjs create mode 100644 src/content/ink-loader.test.ts create mode 100644 src/content/ink-loader.ts diff --git a/.gitignore b/.gitignore index 9f59995..29ff799 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,7 @@ logs/ # Vite cache .vite/ node_modules/.vite/ + +# Compiled Ink output — regenerated on every build by `npm run compile:ink` +# (Plan 02-04). Source-of-truth lives in /content/dialogue/**/*.ink. +src/content/compiled-ink/ diff --git a/content/dialogue/season1/compost-acknowledgements.ink b/content/dialogue/season1/compost-acknowledgements.ink index a0d0ac6..17d54d6 100644 --- a/content/dialogue/season1/compost-acknowledgements.ink +++ b/content/dialogue/season1/compost-acknowledgements.ink @@ -1,43 +1,42 @@ -// content/dialogue/season1/compost-acknowledgements.ink +// Compost acknowledgements — D-07 + GARD-04. Plan 02-03 authored content; +// Plan 02-04 ships the Ink runtime that consumes it. // -// Plan 02-03 ships the AUTHORED CONTENT for the compost tonal beat -// (CONTEXT D-07 + GARD-04). Plan 02-04 owns the Ink runtime — this file -// is loaded by the Ink runtime (inkjs) at that point and one of these -// short lines is dripped into the dialogue overlay each time the player -// composts an immature plant. +// Phase 2 NOTE — UI WIRING DEFERRED TO PLAN 02-05: +// Plan 02-04 ships the Ink compile pipeline + runtime + LuraDialogue +// overlay. The compost-beat surface is a thinner toast variant (separate +// from the Lura full-screen overlay) and is folded into Plan 02-05's +// persistence-toast UI surface for minimum-viable-bias reasons documented +// in 02-04-SUMMARY.md. // -// In Plan 02-03 the React surface (Garden.ts handleTilePointerDown's -// compost branch) does NOT yet render these lines — there's a TODO -// comment at the call site marking the Plan 02-04 wiring point. The -// content lives here so the writer can iterate on voice without waiting -// for the runtime to land. +// This file is rewritten in VAR-driven branch form (replacing Plan 02-03's +// choice-list shape) so it matches the runtime contract: one ChoosePathString +// → drip lines → END. The branching uses fragment_count to vary the line +// without requiring the runtime to expose Ink choice points. // -// Tone (CLAUDE.md): warm, specific, intermittent, sometimes funny, -// sometimes devastating. The gardener-keeper voice. NOT Lura. The garden -// is acknowledging the player's choice to let go — never sentimental, -// never reassuring, never "it's okay." Just the small fact of the choice, -// honored. -// -// Phase 2 ships ~6 short lines so the player rarely hears the same line -// twice in a single session. Plan 02-04 will randomize selection (via -// the same mulberry32 pattern as the fragment selector, or a simple -// weighted pick — implementer's choice). +// Tone (CLAUDE.md): the gardener-keeper voice, NOT Lura. Warm, specific, +// intermittent. Acknowledges the player's choice to let go without making +// it a moral. Never "it's okay." Never reassurance. Just the small fact +// of the choice, honored. -=== compost_beats === -* The earth takes it back without comment. -->DONE +VAR fragment_count = 0 -* Some things are tended into being. Others are tended into not being. Both count. -->DONE +== compost == -* The space the plant occupied is now space. That is a kind of progress. -->DONE +{ fragment_count == 0: + The earth takes it back without comment. +- else: + { + - fragment_count % 5 == 0: + Some things are tended into being. Others are tended into not being. Both count. + - fragment_count % 4 == 0: + It wasn't ready. That isn't the same as failing. + - fragment_count % 3 == 0: + The space the plant was in is now space. That's a kind of progress. + - fragment_count % 2 == 0: + It returns to the soil. Not poetry — just composting. Mostly. + - else: + You changed your mind. The garden has nothing to say about it. + } +} -* It returns to the soil it came from. Not poetry — just composting. Mostly. -->DONE - -* The garden is bigger by one empty tile. -->DONE - -* You changed your mind. The garden has nothing to say about it. -->DONE +-> END diff --git a/content/dialogue/season1/lura-arrival.ink b/content/dialogue/season1/lura-arrival.ink new file mode 100644 index 0000000..dff6f60 --- /dev/null +++ b/content/dialogue/season1/lura-arrival.ink @@ -0,0 +1,44 @@ +// Lura, arrival beat. After the player's first harvest. +// +// Variables read from sim (set via story.variablesState before the first +// Continue() — see src/content/ink-loader.ts INK_VARIABLE_MAP): +// fragment_count - number of harvested fragments at the moment Lura arrives +// last_plant_type - 'rosemary' | 'yarrow' | 'winter-rose' +// +// Per Pitfall 4: Ink VAR names MUST be snake_case AND match INK_VARIABLE_MAP +// keys exactly. Typos do NOT throw — the variable silently keeps its +// declared default. +// +// Per CLAUDE.md Tone — Lura is the warmth anchor for the arc, not a +// co-griever. Specific. Intermittent. Sometimes funny. She is the contrast +// to the gardener-keeper voice; she does not lament with the player. +// She brings news from outside the wall, on her own time. + +VAR fragment_count = 0 +VAR last_plant_type = "" + +== arrival == + +Oh. You're already here. + +I thought it'd take longer. The wall held, then. Good. + +{ last_plant_type == "rosemary": + Rosemary. Of course rosemary. My grandmother kept some in a coffee can on the porch and it outlived two of her dogs. +- else: + { last_plant_type == "yarrow": + Yarrow. There's an old saying about yarrow and I cannot for the life of me remember what it is. The forgetting is the joke, I think. + - else: + { last_plant_type == "winter-rose": + Winter-rose, on the first try. You don't mess around. Most people start small. + - else: + Something grew. That's a start. That's not nothing. + } + } +} + +I won't keep you. I just wanted to see it for myself. + +I'll come back when there's more to come back for. + +-> END diff --git a/content/dialogue/season1/lura-farewell.ink b/content/dialogue/season1/lura-farewell.ink new file mode 100644 index 0000000..d5333d6 --- /dev/null +++ b/content/dialogue/season1/lura-farewell.ink @@ -0,0 +1,30 @@ +// Lura, farewell beat. After the player's 8th harvest (CONTEXT D-14). +// +// This is the turn — the place where Lura tells you she's leaving and +// why, without explaining it. She is still the warmth anchor: she does +// NOT cry, she does NOT tell you to be brave, she does NOT make you the +// center of her grief. She is a person with somewhere else to be, who +// stopped by long enough to make sure you'd be okay without her, and +// who trusts you enough to leave. +// +// Phase 4+ Lura returns at later Seasons; the door this beat closes is +// "Lura at the gate every time you harvest," not Lura herself. + +VAR fragment_count = 0 +VAR last_plant_type = "" + +== farewell == + +Eight. That's enough. For now. + +I think we both know what this part is. + +I've been putting something off. I think you're far enough along now that I can stop pretending I'm here for the small reasons. There's a thing I have to go and see for myself, and I don't get to bring you with me, and I don't get to tell you about it before I know. + +You don't need me at the gate every day. You haven't for a while. + +The garden persists. Some of it is mine. Most of it is yours now. + +I'll come back when there's something to bring you. Take your time. + +-> END diff --git a/content/dialogue/season1/lura-mid.ink b/content/dialogue/season1/lura-mid.ink new file mode 100644 index 0000000..d8ca61a --- /dev/null +++ b/content/dialogue/season1/lura-mid.ink @@ -0,0 +1,31 @@ +// Lura, mid beat. After the player's 4th harvest (CONTEXT D-14). +// +// See lura-arrival.ink for variable contract + tone notes. Lura is the +// warmth anchor: specific, slightly funny, never sentimental. She knows +// something is happening to the world and she is choosing to be useful +// about it instead of mournful. + +VAR fragment_count = 0 +VAR last_plant_type = "" + +== mid == + +Four. That's a real number. + +I tried to do this once, you know. The garden, I mean. Not — not at this scale. A balcony. Three pots, one of them already broken when I bought it. The basil died first. The rosemary survived. The rosemary survives most things. + +You're keeping at it. Most people don't. + +{ last_plant_type == "winter-rose": + A winter-rose this time. They're harder. You can tell, can't you. They want a particular kind of attention. +- else: + { last_plant_type == "yarrow": + Yarrow keeps giving you yarrow. There's a lesson in that and I'm not going to spell it out, that's the kind of thing you ruin by saying. + - else: + I'm going to be honest, I lost track of which one it was this time. They look different in the wall. + } +} + +There's something I should be doing. I'll be back when there's more to bring you. + +-> END diff --git a/package.json b/package.json index 8bbdb84..dc78849 100644 --- a/package.json +++ b/package.json @@ -6,15 +6,15 @@ "description": "A 7-Season browser narrative idle game in the lineage of A Dark Room and Universal Paperclips.", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "npm run compile:ink && tsc -b && vite build", "preview": "vite preview", "lint": "eslint . --max-warnings 0", "test": "vitest run --passWithNoTests=false", "test:watch": "vitest", "validate:assets": "node scripts/validate-assets.mjs", "check:bundle-split": "node scripts/check-bundle-split.mjs", - "compile:ink": "echo \"[compile:ink] no .ink files yet — Phase 2 will populate /content/dialogue/\" && exit 0", - "ci": "npm run lint && npm run test && npm run validate:assets && npm run build && npm run check:bundle-split" + "compile:ink": "node scripts/compile-ink.mjs", + "ci": "npm run lint && npm run compile:ink && npm run test && npm run validate:assets && npm run build && npm run check:bundle-split" }, "dependencies": { "break_eternity.js": "^2.1.3", diff --git a/scripts/compile-ink.mjs b/scripts/compile-ink.mjs new file mode 100644 index 0000000..098bd0b --- /dev/null +++ b/scripts/compile-ink.mjs @@ -0,0 +1,161 @@ +#!/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). + * + * Approach (chosen after reading node_modules/inklecate/index.js + + * getInklecatePath.js + executableHandler.js): + * + * The npm wrapper for inklecate exposes a CommonJS module shape: + * `module.exports = { ArgsEnum, DEBUG, getBinDir, getCacheFilepath, + * getInklecatePath, inklecate }`. + * + * The wrapper's `inklecate` function spawns the inklecate.exe / inklecate + * binary under node_modules/inklecate/bin/ asynchronously and resolves + * when the child exits — but as of inklecate@1.8.1, the wrapper's + * `executableHandler` swallows non-zero exit codes silently and the + * API surface is undocumented for stderr. To keep failure modes loud + * AND to keep this script cross-platform, we invoke the binary + * DIRECTLY via `child_process.execFileSync`. The wrapper's bin/ folder + * is the canonical home for both Windows (inklecate.exe) and POSIX + * (inklecate) executables; the wrapper handles platform selection + * internally via `process.platform === 'darwin' ? 'inklecate' : + * 'inklecate.exe'` (see node_modules/inklecate/getInklecatePath.js). + * + * On Linux the same `inklecate` binary applies (it's a single .NET + * self-contained executable that ships alongside the .dll runtime), + * matching what `executableHandler` does internally. + */ + +import { + mkdirSync, + existsSync, + readdirSync, + statSync, + rmSync, + writeFileSync, +} 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; +} + +/** + * Resolve the bundled inklecate binary path. + * + * BLOCKER 4 mitigation — DO NOT use stale path strings like + * `node_modules/inklecate/inklecate-windows/inklecate.exe`. The wrapper + * ships a single `bin/` directory containing both inklecate (POSIX) and + * inklecate.exe (Windows). Verified during Plan 02-04 first run: + * ls node_modules/inklecate/bin/ + * ink-engine-runtime.dll inklecate.exe inklecate + * ink_compiler.dll libhostpolicy.so + * + * Platform selection follows the wrapper's own + * getInklecatePath.js convention: anything-not-darwin uses .exe — but + * that's a quirk of the .NET self-contained build. On Linux the .exe + * file is the actual ELF executable (Mono-style multi-platform .NET); + * on macOS the no-extension `inklecate` is used. We replicate that + * behavior here so this script works on Windows + macOS + Linux dev + * machines without modification (Assumption A6). + */ +function inklecateBinary() { + const binDir = resolve(process.cwd(), 'node_modules/inklecate/bin'); + // Match the wrapper's own platform-selection logic. + const name = process.platform === 'darwin' ? 'inklecate' : 'inklecate.exe'; + return join(binDir, name); +} + +export async function compileAllInk(options = {}) { + const { wipe = true } = options; + const files = findInkFiles(INK_ROOT); + if (files.length === 0) { + console.log('[compile:ink] no .ink files under content/dialogue/ — skipping'); + return { compiled: 0, files: [] }; + } + + // Optionally wipe stale output. The CLI path passes wipe=true (default) + // so deleted .ink files don't leave stale .ink.json files behind. The + // Vitest test passes wipe=false so it doesn't race with parallel test + // files (e.g., src/content/ink-loader.test.ts) reading the compiled + // artefacts. + if (wipe && existsSync(OUT_ROOT)) { + rmSync(OUT_ROOT, { recursive: true, force: true }); + } + + const binary = inklecateBinary(); + if (!existsSync(binary)) { + throw new Error( + `[compile:ink] inklecate binary not found at ${binary}. ` + + `Did 'npm install' run? Expected node_modules/inklecate/bin/{inklecate,inklecate.exe}.`, + ); + } + + const compiled = []; + 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 }); + + // Inklecate CLI shape: `inklecate -o `. + // The binary writes a JSON file at the given path. Stderr is captured + // and surfaced if the exit code is non-zero. + try { + execFileSync(binary, ['-o', outPath, inkPath], { stdio: 'pipe' }); + } catch (err) { + const stderr = err && err.stderr ? err.stderr.toString() : ''; + const stdout = err && err.stdout ? err.stdout.toString() : ''; + throw new Error( + `[compile:ink] FAILED compiling ${rel}\n` + + (stderr ? `stderr:\n${stderr}\n` : '') + + (stdout ? `stdout:\n${stdout}\n` : ''), + ); + } + + if (!existsSync(outPath)) { + throw new Error( + `[compile:ink] inklecate exit code 0 but no output at ${outPath} for input ${inkPath}`, + ); + } + compiled.push({ in: inkPath, out: outPath }); + console.log(`[compile:ink] ${rel} -> ${relative(process.cwd(), outPath)}`); + } + console.log(`[compile:ink] compiled ${compiled.length} files`); + return { compiled: compiled.length, files: compiled }; +} + +// CLI invocation (gated so Vitest can `import` this module without firing). +const isDirectCli = (() => { + try { + const argvUrl = `file://${resolve(process.argv[1] ?? '').replace(/\\/g, '/')}`; + return import.meta.url === argvUrl || import.meta.url.endsWith('/compile-ink.mjs') && process.argv[1]?.endsWith('compile-ink.mjs'); + } catch { + return false; + } +})(); + +if (isDirectCli) { + compileAllInk().catch((err) => { + console.error('[compile:ink] FAILED:', err && err.stack ? err.stack : err); + process.exit(1); + }); +} + +// Suppress unused-import lint for writeFileSync — kept available for +// future inline-write paths if the binary path approach ever needs to +// fall back to a wrapper-only-mode that returns JSON via stdout. +void writeFileSync; diff --git a/scripts/compile-ink.test.mjs b/scripts/compile-ink.test.mjs new file mode 100644 index 0000000..9a9ec1e --- /dev/null +++ b/scripts/compile-ink.test.mjs @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { compileAllInk } from './compile-ink.mjs'; + +/** + * Phase 2 Plan 02-04 Task 1 sanity test for the build-time Ink compiler. + * + * Imports compile-ink.mjs (the CLI guard prevents the auto-run path from + * firing under Vitest) and exercises compileAllInk() against the real + * /content/dialogue tree exactly once via beforeAll. Subsequent test + * cases inspect the resulting artefacts. + * + * W9 invariant: compileAllInk() wipes src/content/compiled-ink/ at start, + * so we MUST call it from a single beforeAll. Calling it inside multiple + * test cases — or concurrently with src/content/ink-loader.test.ts — + * creates a filesystem race. The npm run ci chain runs `compile:ink` + * BEFORE `test` so under CI both this file and ink-loader.test.ts see + * a fully-populated compiled-ink/ directory at module-eval time. This + * file's beforeAll is defensive belt-and-suspenders. + * + * Determinism guarantee: inklecate is deterministic from .ink content, + * so same inputs ALWAYS produce the same JSON output. + */ + +let compileResult = null; + +beforeAll(async () => { + // wipe=false to avoid racing with src/content/ink-loader.test.ts when + // Vitest runs test files in parallel. Production CLI invocation + // (`npm run compile:ink`) keeps wipe=true to clear deleted .ink files. + compileResult = await compileAllInk({ wipe: false }); +}); + +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/', () => { + expect(compileResult).not.toBeNull(); + // 3 Lura beats + 1 compost = 4 minimum. Phase 4+ will add more. + expect(compileResult.compiled).toBeGreaterThanOrEqual(4); + const expected = [ + 'src/content/compiled-ink/season1/lura-arrival.ink.json', + 'src/content/compiled-ink/season1/lura-mid.ink.json', + 'src/content/compiled-ink/season1/lura-farewell.ink.json', + 'src/content/compiled-ink/season1/compost-acknowledgements.ink.json', + ]; + for (const rel of expected) { + expect(existsSync(resolve(process.cwd(), rel))).toBe(true); + } + }); + + it('produces valid JSON output (parses without error)', () => { + const arrival = readFileSync( + resolve(process.cwd(), 'src/content/compiled-ink/season1/lura-arrival.ink.json'), + 'utf8', + ); + // inklecate emits a UTF-8 BOM header byte on some platforms; strip it + // before JSON.parse just like the runtime loader will. + const stripped = arrival.charCodeAt(0) === 0xfeff ? arrival.slice(1) : arrival; + expect(() => JSON.parse(stripped)).not.toThrow(); + const obj = JSON.parse(stripped); + expect(obj).toBeTypeOf('object'); + // inklecate v1.x stories carry an `inkVersion` property at the root. + expect(obj.inkVersion).toBeTypeOf('number'); + }); +}); diff --git a/src/content/index.ts b/src/content/index.ts index ffe501c..5df95f4 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -12,3 +12,9 @@ export { type SeasonContent, type UiStrings, } from './schemas/index.ts'; +export { + loadInkStory, + bindGardenStateToInk, + INK_VARIABLE_MAP, + type InkBeatName, +} from './ink-loader.ts'; diff --git a/src/content/ink-loader.test.ts b/src/content/ink-loader.test.ts new file mode 100644 index 0000000..c651841 --- /dev/null +++ b/src/content/ink-loader.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { Story } from 'inkjs'; +import { + loadInkStory, + bindGardenStateToInk, + INK_VARIABLE_MAP, +} from './ink-loader'; +import type { AppStoreShape } from '../store'; + +/** + * Phase 2 Plan 02-04 Task 1 sanity tests for the Ink runtime loader. + * + * Precondition (W9): the test file does NOT call compileAllInk() — + * concurrent invocations of the compile script would race on the + * src/content/compiled-ink/ wipe step. Instead, we assert the compiled + * artefacts exist and surface a clear fix-it message if they don't. The + * `npm run ci` chain runs `compile:ink` BEFORE `test`, so the artefact + * is always present in CI. + * + * The `compiledExists` check happens INSIDE beforeAll (not at module + * eval) because compile-ink.test.mjs may wipe + regenerate the + * compiled-ink/ directory at test-execution time. Reading existsSync at + * module eval would race with that test file's wipe step. + */ +beforeAll(() => { + const compiledExists = existsSync( + resolve(process.cwd(), 'src/content/compiled-ink/season1/lura-arrival.ink.json'), + ); + 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.', + ); + } +}); + +function emptySnapshot(overrides: Partial = {}): AppStoreShape { + return { + // GardenSlice + tiles: new Array(16).fill(null), + unlockedPlantTypes: ['rosemary'], + tickCount: 0, + lastTickAt: 0, + pendingCommands: [], + enqueueCommand: () => {}, + drainCommands: () => [], + applyTilesAndUnlocks: () => {}, + setTickCount: () => {}, + setLastTickAt: () => {}, + // MemorySlice + harvestedFragmentIds: [], + fragmentRevealId: null, + setHarvested: () => {}, + setFragmentRevealId: () => {}, + // NarrativeSlice + luraBeatProgress: { arrived: false, mid: false, farewell: false, pending: null }, + dialogueOverlayOpen: false, + setLuraBeatProgress: () => {}, + setDialogueOverlayOpen: () => {}, + // SessionSlice + beginGateDismissed: false, + persistenceToastShown: false, + letterOverlayOpen: false, + pendingLetterEventBlock: null, + dismissBeginGate: () => {}, + setPersistenceToastShown: () => {}, + openLetter: () => {}, + dismissLetter: () => {}, + ...(overrides as Partial), + } as AppStoreShape; +} + +describe('loadInkStory', () => { + it('returns an inkjs Story instance for lura-arrival', async () => { + const story = await loadInkStory('lura-arrival'); + expect(story).toBeInstanceOf(Story); + }); + + it('returns an inkjs Story instance for compost-acknowledgements', async () => { + const story = await loadInkStory('compost-acknowledgements'); + expect(story).toBeInstanceOf(Story); + }); + + it('returns an inkjs Story instance for lura-mid + lura-farewell', async () => { + const m = await loadInkStory('lura-mid'); + const f = await loadInkStory('lura-farewell'); + expect(m).toBeInstanceOf(Story); + expect(f).toBeInstanceOf(Story); + }); +}); + +describe('bindGardenStateToInk', () => { + it('sets fragment_count on a story that declares the VAR', async () => { + const story = await loadInkStory('lura-arrival'); + const snap = emptySnapshot({ harvestedFragmentIds: ['a', 'b', 'c'] }); + bindGardenStateToInk(story, snap); + // Read back via the same variablesState surface to confirm the bind landed. + const value = ( + story.variablesState as unknown as Record + )['fragment_count']; + expect(value).toBe(3); + }); + + it('does NOT throw when binding to a story missing some variables (compost has only fragment_count)', async () => { + const story = await loadInkStory('compost-acknowledgements'); + const snap = emptySnapshot({ harvestedFragmentIds: ['a', 'b'] }); + expect(() => bindGardenStateToInk(story, snap)).not.toThrow(); + // fragment_count was declared and should be set. + const fc = ( + story.variablesState as unknown as Record + )['fragment_count']; + expect(fc).toBe(2); + }); + + it('sets last_plant_type to empty string when there are no harvests', async () => { + const story = await loadInkStory('lura-arrival'); + const snap = emptySnapshot({ harvestedFragmentIds: [] }); + bindGardenStateToInk(story, snap); + const lpt = ( + story.variablesState as unknown as Record + )['last_plant_type']; + expect(lpt).toBe(''); + }); +}); + +describe('INK_VARIABLE_MAP (Pitfall 4 — snake_case mandatory)', () => { + it('every key is snake_case (lowercase letters + underscores only)', () => { + const keys = Object.keys(INK_VARIABLE_MAP); + expect(keys.length).toBeGreaterThan(0); + for (const key of keys) { + expect(key).toMatch(/^[a-z][a-z_]*$/); + } + }); + + it('declares the three Phase-2 slots', () => { + const keys = Object.keys(INK_VARIABLE_MAP); + expect(keys).toContain('fragment_count'); + expect(keys).toContain('last_plant_type'); + expect(keys).toContain('last_fragment_title'); + }); +}); diff --git a/src/content/ink-loader.ts b/src/content/ink-loader.ts new file mode 100644 index 0000000..b2d41a1 --- /dev/null +++ b/src/content/ink-loader.ts @@ -0,0 +1,149 @@ +import { Story } from 'inkjs'; +import type { AppStoreShape } from '../store'; +import { fragments as allFragments } from './loader'; + +/** + * Runtime Ink loader (Plan 02-04). Instantiates an inkjs `Story` from + * the compiled JSON for a given beat name, and binds variables from a + * store snapshot before the first Continue() / ChoosePathString() call. + * + * Per RESEARCH Pattern 5 — the Ink runtime sits in the UI tier (this + * module re-exported from src/content/ but consumed by src/ui/dialogue/); + * src/sim/ MUST NOT import this file (CORE-10 + Architectural + * Responsibility Map). Sim narrative gating is pure-state — see + * src/sim/narrative/lura-gate.ts. + * + * Per Pitfall 4 (snake_case mandatory): the keys in INK_VARIABLE_MAP + * must match the VAR declarations in the .ink files exactly. Typos do + * NOT throw — Ink silently leaves the variable at its declared default. + */ + +// Lazy globs — Vite emits each compiled .ink.json as a code-split chunk. +// The story files are tiny (~1KB each) but lazy-loading keeps the entry +// bundle minimal and matches the PIPE-02 lazy-content posture. +const luraStoryGlob = import.meta.glob( + '/src/content/compiled-ink/season1/lura-*.ink.json', + { query: '?raw', import: 'default' }, +); + +const compostStoryGlob = import.meta.glob( + '/src/content/compiled-ink/season1/compost-acknowledgements.ink.json', + { query: '?raw', import: 'default' }, +); + +export type InkBeatName = + | 'lura-arrival' + | 'lura-mid' + | 'lura-farewell' + | 'compost-acknowledgements'; + +/** + * INK_VARIABLE_MAP — the centralized snake_case mapping (Pitfall 4). + * + * Adding a new variable to a .ink file requires adding the same key + * here. The ink-loader.test.ts asserts every key is snake_case so a + * camelCase typo fails CI rather than silently leaving the variable + * unbound. + * + * Phase 2 ships these three slots — `last_fragment_title` is reserved + * for Plan 02-05's letter prose authoring (W4) but is exposed now so + * the Ink files can read it without a follow-up patch. + */ +export const INK_VARIABLE_MAP = { + fragment_count: (s: AppStoreShape) => s.harvestedFragmentIds.length, + last_plant_type: (s: AppStoreShape): string => { + // Phase 2 derivation: the most-recently-harvested fragment's + // tonal-register tag maps back to a plant type. The harvest + // pipeline doesn't currently store the source plant type per + // harvest — Plan 02-05 may add that to offlineEvents. For now, + // the 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 ''; + }, + last_fragment_title: (s: AppStoreShape): string => { + 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; + +function compiledInkPath(name: InkBeatName): string { + return `/src/content/compiled-ink/season1/${name}.ink.json`; +} + +/** + * Strip the UTF-8 BOM that some platforms' inklecate builds emit at the + * head of the JSON output. Without this, `new Story(json)` parses but + * a downstream `JSON.parse(json)` would throw on the leading 0xFEFF. + */ +function stripBom(s: string): string { + return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s; +} + +/** + * Load the compiled Ink JSON for a beat name and instantiate an + * `inkjs.Story`. The caller is responsible for binding variables and + * choosing the entry knot/path. Throws if the compiled artefact is + * missing — runs the diagnostic message past the cause: + * "Did `npm run compile:ink` succeed?" + */ +export async function loadInkStory(name: InkBeatName): Promise { + const path = compiledInkPath(name); + const loader = + name === 'compost-acknowledgements' + ? compostStoryGlob[path] + : luraStoryGlob[path]; + if (!loader) { + throw new Error( + `[ink-loader] No compiled story at ${path}. Did 'npm run compile:ink' succeed?`, + ); + } + const raw = (await loader()) as string; + return new Story(stripBom(raw)); +} + +/** + * Bind every INK_VARIABLE_MAP slot from the current store snapshot into + * the given Story's variablesState. Call BEFORE the first + * `story.Continue()` or `story.ChoosePathString(knot)`. + * + * Per Pitfall 4: variable names are case-sensitive AND snake_case. + * Setting a variable that the Ink story doesn't declare throws inside + * inkjs — we catch and warn rather than fail the whole dialogue, since + * not every story declares every variable (e.g., the compost beat only + * uses `fragment_count`). + */ +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 { + // inkjs's variablesState exposes a Proxy-like setter that throws + // when the var doesn't exist in the story. The cast tells + // TypeScript we know what we're doing — this is the documented + // inkjs API surface (Story.d.ts line ~150). + ( + story.variablesState as unknown as Record + )[varName] = value; + } catch { + // Story doesn't declare this variable; silent skip is the + // intended behavior. We don't `console.warn` in tests because it + // pollutes Vitest output for the compost beat (which only uses + // fragment_count) on every run. + } + } +}