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:
@@ -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