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