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:
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user