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
+6
View File
@@ -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';
+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.
}
}
}