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
+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');
});
});