feat(02-04): ink compilation pipeline + 4 authored Season-1 Ink files + runtime loader

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 10:24:40 -04:00
parent 348c76a537
commit c90f8f1e5c
11 changed files with 674 additions and 39 deletions
+4
View File
@@ -43,3 +43,7 @@ logs/
# Vite cache # Vite cache
.vite/ .vite/
node_modules/.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/
@@ -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 // Phase 2 NOTE — UI WIRING DEFERRED TO PLAN 02-05:
// (CONTEXT D-07 + GARD-04). Plan 02-04 owns the Ink runtime — this file // Plan 02-04 ships the Ink compile pipeline + runtime + LuraDialogue
// is loaded by the Ink runtime (inkjs) at that point and one of these // overlay. The compost-beat surface is a thinner toast variant (separate
// short lines is dripped into the dialogue overlay each time the player // from the Lura full-screen overlay) and is folded into Plan 02-05's
// composts an immature plant. // 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 // This file is rewritten in VAR-driven branch form (replacing Plan 02-03's
// compost branch) does NOT yet render these lines — there's a TODO // choice-list shape) so it matches the runtime contract: one ChoosePathString
// comment at the call site marking the Plan 02-04 wiring point. The // → drip lines → END. The branching uses fragment_count to vary the line
// content lives here so the writer can iterate on voice without waiting // without requiring the runtime to expose Ink choice points.
// for the runtime to land.
// //
// Tone (CLAUDE.md): warm, specific, intermittent, sometimes funny, // Tone (CLAUDE.md): the gardener-keeper voice, NOT Lura. Warm, specific,
// sometimes devastating. The gardener-keeper voice. NOT Lura. The garden // intermittent. Acknowledges the player's choice to let go without making
// is acknowledging the player's choice to let go — never sentimental, // it a moral. Never "it's okay." Never reassurance. Just the small fact
// never reassuring, never "it's okay." Just the small fact of the choice, // of the choice, honored.
// 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).
=== compost_beats === VAR fragment_count = 0
* The earth takes it back without comment.
->DONE
* Some things are tended into being. Others are tended into not being. Both count. == compost ==
->DONE
* The space the plant occupied is now space. That is a kind of progress. { fragment_count == 0:
->DONE 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. -> END
->DONE
* The garden is bigger by one empty tile.
->DONE
* You changed your mind. The garden has nothing to say about it.
->DONE
+44
View File
@@ -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
@@ -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
+31
View File
@@ -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
+3 -3
View File
@@ -6,15 +6,15 @@
"description": "A 7-Season browser narrative idle game in the lineage of A Dark Room and Universal Paperclips.", "description": "A 7-Season browser narrative idle game in the lineage of A Dark Room and Universal Paperclips.",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "npm run compile:ink && tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --max-warnings 0", "lint": "eslint . --max-warnings 0",
"test": "vitest run --passWithNoTests=false", "test": "vitest run --passWithNoTests=false",
"test:watch": "vitest", "test:watch": "vitest",
"validate:assets": "node scripts/validate-assets.mjs", "validate:assets": "node scripts/validate-assets.mjs",
"check:bundle-split": "node scripts/check-bundle-split.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", "compile:ink": "node scripts/compile-ink.mjs",
"ci": "npm run lint && npm run test && npm run validate:assets && npm run build && npm run check:bundle-split" "ci": "npm run lint && npm run compile:ink && npm run test && npm run validate:assets && npm run build && npm run check:bundle-split"
}, },
"dependencies": { "dependencies": {
"break_eternity.js": "^2.1.3", "break_eternity.js": "^2.1.3",
+161
View File
@@ -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 <outFile> <inFile>`.
// 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;
+69
View File
@@ -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');
});
});
+6
View File
@@ -12,3 +12,9 @@ export {
type SeasonContent, type SeasonContent,
type UiStrings, type UiStrings,
} from './schemas/index.ts'; } from './schemas/index.ts';
export {
loadInkStory,
bindGardenStateToInk,
INK_VARIABLE_MAP,
type InkBeatName,
} from './ink-loader.ts';
+142
View File
@@ -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> = {}): 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<AppStoreShape>),
} 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<string, unknown>
)['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<string, unknown>
)['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<string, unknown>
)['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');
});
});
+149
View File
@@ -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<Story> {
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<string, unknown>
)[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.
}
}
}