orbit/dev/vignettes.test.mjs

355 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Vignettes model test (dev tool, run with Node — no browser):
*
* node dev/vignettes.test.mjs
*
* The pure rules of data/vignettes.json (js/vignettes/VignetteModel.js) —
* the planet dialogue scenes' contract:
* - the shipped config is VALID (enabled, the speakers registered with
* their planet cuts — planet-trestle.mp4 / planet-carol.mp4 — and the
* roster well-formed — empty until the first scene is authored);
* - normalizeLine / normalizeVignette drop what can't be shown (no
* speaker / no text / unknown speaker / no id / no location / no
* lines) and keep the rest (audio optional, once defaults true,
* the gate's quest+step pair normalizes);
* - vignettesForLocation returns the scenes bound to a world slot
* ('bar' | 'shop'), in config order;
* - vignetteAvailable is the availability rule: unplayed, the gate
* held (quest granted, unclaimed, the named step exists and is open)
* — a gate the scene couldn't read stays CLOSED, a `once: false`
* scene re-arms after it played;
* - asset keys are stable (per character for the planet clip, per file
* for the speech — a clip shared by several lines loads once) and
* resolve to the shipped files.
*/
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { readFileSync, existsSync } from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
// Silence the model's drop warnings during the negative tests (they are
// the EXPECTED behavior; the checks below verify the drops).
const origWarn = console.warn;
console.warn = () => {};
const {
vignettesEnabled,
charEntry,
normalizeLine,
normalizeVignette,
normalizeEffects,
listVignettes,
vignettesForLocation,
vignetteAvailable,
lineAssets,
vignetteAssets,
videoKeyFor,
videoUrlFor,
audioKeyFor,
} = await import(pathToFileURL(join(root, 'js/vignettes/VignetteModel.js')).href);
const cfg = JSON.parse(readFileSync(join(root, 'data/vignettes.json'), 'utf8'));
// ----------------------------------------------------------------------
// the shipped config (the contract the hub + window build on)
// ----------------------------------------------------------------------
check('vignettes.json is enabled', cfg.enabled === true);
check(
'the audio recipe ships (own volume + a per-sound music duck)',
typeof cfg.audio?.volume === 'number' && typeof cfg.audio?.musicDuck === 'number',
);
const trestle = charEntry(cfg, 'trestle');
check('Trestle is registered (label + role)', !!trestle && trestle.label === 'TRESTLE' && !!trestle.role);
const carol = charEntry(cfg, 'carol');
check('Carol is registered (label + role)', !!carol && carol.label === 'CAROL' && !!carol.role);
// The default planet cuts — the speakers' faces (the comms' space cuts
// are the <id>.mp4 pair; the planet cuts ride alongside).
check(
'the default planet cuts ship (trestle + carol)',
existsSync(join(root, 'assets/videos/characters/planet-trestle.mp4')) &&
existsSync(join(root, 'assets/videos/characters/planet-carol.mp4')),
);
// The roster ships its FIRST scene — the bar's Trestle + Carol meeting
// ('Special Delivery' step 1; the quest's check is the 'vignettePlayed'
// witness keyed on the scene's id).
const roster = listVignettes(cfg);
check('the roster: exactly one scene ships (the first meeting)', roster.length === 1);
const first = roster[0];
check(
'the first scene: the bar, gated on special_delivery step 0 (once)',
first?.id === 'special_delivery_bar_meeting' &&
first?.location === 'bar' &&
first?.quest === 'special_delivery' &&
first?.step === 0 &&
first?.once === true,
);
check(
'the first scene: five lines — Trestle opens, Carol carries the four beats',
first?.lines?.length === 5 &&
JSON.stringify(first?.lines.map((l) => l.from)) === JSON.stringify(['trestle', 'carol', 'carol', 'carol', 'carol']),
);
check(
'the first scene: every line carries its speech clip (the voices ship on disk)',
(first?.lines ?? []).every((l) => typeof l.audio === 'string' && l.audio.length > 0 && existsSync(join(root, l.audio))),
);
check(
'the first scene: its gate is the quest\u2019s bar step (vignettePlayed \u2192 this id)',
(() => {
const quests = JSON.parse(readFileSync(join(root, 'data/quests.json'), 'utf8'));
const q = (quests.quests ?? []).find((x) => x.id === first?.quest);
const c = (q?.checks ?? [])[first?.step];
return c?.type === 'vignettePlayed' && c?.id === first?.id;
})(),
);
check(
"the first scene: its DONE beat builds the hold out to 4 bays (effects.cargoHold — the ship starts at 0)",
first?.effects?.cargoHold === 4,
);
check(
"the first scene: the beat precedes the package (the quest's cargo step stands on the package aboard)",
(() => {
const quests = JSON.parse(readFileSync(join(root, 'data/quests.json'), 'utf8'));
const q = (quests.quests ?? []).find((x) => x.id === first?.quest);
const cargoStep = (q?.checks ?? []).find((c) => c.type === 'cargoUpgraded');
return cargoStep?.id === q?.packageGood;
})(),
);
check("location: the bar's scenes (the first one)", JSON.stringify(vignettesForLocation(cfg, 'bar').map((v) => v.id)) === JSON.stringify(['special_delivery_bar_meeting']));
check('location: the shop has no scenes yet', vignettesForLocation(cfg, 'shop').length === 0);
// The window's contract (the hub + window read it): the labels + the
// opening beat.
// The window's geometry contract (the user's ask): the speaker's face at
// FULL size (the 256×256 planet cut — never downscaled) + a text column
// wide enough that the shipped lines wrap comfortably.
const winCfg = cfg.window ?? {};
check('the window ships the full planet cut (videoSize 256 — the face is never downscaled)', winCfg.videoSize === 256);
check(
'the window is wide: the feed + a usable text column (≥320px of words — 502px ships)',
(() => {
const col = (winCfg.width ?? 0) - 2 * (winCfg.pad ?? 0) - (winCfg.videoSize ?? 0) - (winCfg.videoGap ?? 0);
return col >= 320;
})(),
);
check('the window ships NEXT + DONE labels', cfg.window?.button?.label === 'NEXT' && cfg.window?.button?.doneLabel === 'DONE');
check('the opening beat ships (a positive delay)', Number(cfg.window?.startDelayMs) >= 0);
check('the scrim ships (a dimming + its alpha)', !!cfg.window?.scrim?.color && Number(cfg.window?.scrim?.alpha) >= 0);
// ----------------------------------------------------------------------
// normalization
// ----------------------------------------------------------------------
const chars = { trestle: { label: 'TRESTLE' }, carol: { label: 'CAROL' } };
const mkCfg = (vignettes) => ({ enabled: true, characters: chars, vignettes });
check('a line: the words + the speaker (audio optional)', (() => {
const l = normalizeLine({ from: 'carol', text: 'Hey kid.', audio: 'assets/speech/carol/carol-q2-01.mp3' });
return l?.from === 'carol' && l?.text === 'Hey kid.' && l?.audio === 'assets/speech/carol/carol-q2-01.mp3';
})());
check('a line: a text-only line is valid', normalizeLine({ from: 'carol', text: 'Hey.' })?.audio === null);
check('a line: no speaker is dropped', normalizeLine({ text: 'Words.' }) === null);
check('a line: no words is dropped', normalizeLine({ from: 'carol' }) === null);
check('a line: a non-object is dropped', normalizeLine('carol: words.') === null);
const good = normalizeVignette(
{
id: 'bar-trestle-carol',
location: 'Bar', // case-insensitive
quest: 'special_delivery',
step: 1,
lines: [
{ from: 'trestle', text: 'One more stop before the nest.' },
{ from: 'carol', text: 'You made it to the capital, kid.' },
],
},
mkCfg([]),
);
check(
'a scene: id + location (lowercased) + the gate pair + the lines, in order',
!!good &&
good.id === 'bar-trestle-carol' &&
good.location === 'bar' &&
good.quest === 'special_delivery' &&
good.step === 1 &&
good.once === true &&
good.lines.length === 2 &&
good.lines[0].from === 'trestle' &&
good.lines[1].from === 'carol',
);
check('a scene: no gate = ungated (quest + step both null)', (() => {
const v = normalizeVignette({ id: 'chatter', location: 'bar', lines: [{ from: 'carol', text: 'Hi.' }] }, mkCfg([]));
return v?.quest === null && v?.step === null;
})());
check('a scene: `once: false` re-arms (the author’s repeat)', (() => {
const v = normalizeVignette(
{ id: 'repeat', location: 'shop', lines: [{ from: 'trestle', text: 'Back again.' }], once: false },
mkCfg([]),
);
return v?.once === false;
})());
check('a scene: a step without a quest is dropped (a gate needs its quest)', (() => {
const v = normalizeVignette({ id: 'orphan', location: 'bar', step: 2, lines: [{ from: 'carol', text: 'Hi.' }] }, mkCfg([]));
return v?.quest === null && v?.step === null;
})());
check('a scene: no id is dropped', normalizeVignette({ location: 'bar', lines: [{ from: 'carol', text: 'Hi.' }] }, mkCfg([])) === null);
check('a scene: no location is dropped', normalizeVignette({ id: 'x', lines: [{ from: 'carol', text: 'Hi.' }] }, mkCfg([])) === null);
check('a scene: no lines is dropped', normalizeVignette({ id: 'x', location: 'bar' }, mkCfg([])) === null);
// The story effects (the DONE beat — the model keeps the safe entries,
// GameScene.onVignetteComplete applies them).
check('effects: cargoHold keeps a positive bay count', normalizeEffects({ cargoHold: 4 })?.cargoHold === 4);
check('effects: a non-integer hold floors (4.7 → 4)', normalizeEffects({ cargoHold: 4.7 })?.cargoHold === 4);
check('effects: a zero/negative hold is dropped (no 0-bay build-out)', normalizeEffects({ cargoHold: 0 }) === null && normalizeEffects({ cargoHold: -2 }) === null);
check('effects: an unknown entry alone yields nothing', normalizeEffects({ anything: true }) === null);
check('effects: a missing/invalid block reads as none', normalizeEffects(null) === null && normalizeEffects('4') === null);
check('effects: a scene without the block ships none', normalizeVignette({ id: 'x', location: 'bar', lines: [{ from: 'carol', text: 'Hi.' }] }, mkCfg([]))?.effects === null);
check('a scene: an unknown speaker drops the whole scene', (() => {
const v = normalizeVignette(
{ id: 'ghost', location: 'bar', lines: [{ from: 'carol', text: 'Hi.' }, { from: 'no-one', text: '??' }] },
mkCfg([]),
);
return v === null;
})());
check('a scene: one bad line drops the whole scene (warned)', (() => {
const v = normalizeVignette(
{ id: 'broken', location: 'bar', lines: [{ from: 'carol' }] },
mkCfg([]),
);
return v === null;
})());
check('the roster: duplicates keep the first entry', (() => {
const c = mkCfg([
{ id: 'dup', location: 'bar', lines: [{ from: 'carol', text: 'One.' }] },
{ id: 'dup', location: 'bar', lines: [{ from: 'carol', text: 'Two.' }] },
]);
const roster = listVignettes(c);
return roster.length === 1 && roster[0].lines[0].text === 'One.';
})());
check('the roster: a $comment string is skipped', (() => {
const c = {
enabled: true,
characters: chars,
vignettes: ['$comment', { id: 'real', location: 'bar', lines: [{ from: 'carol', text: 'Hi.' }] }],
};
return listVignettes(c).length === 1 && listVignettes(c)[0].id === 'real';
})());
// ----------------------------------------------------------------------
// the location binding (Explore's Bar / the deck's Shop)
// ----------------------------------------------------------------------
const twoLoc = mkCfg([
{ id: 'bar-a', location: 'bar', lines: [{ from: 'carol', text: 'Bar one.' }] },
{ id: 'shop-a', location: 'shop', lines: [{ from: 'trestle', text: 'Shop one.' }] },
{ id: 'bar-b', location: 'bar', lines: [{ from: 'trestle', text: 'Bar two.' }] },
]);
check('location: the bar’s scenes, in config order', JSON.stringify(vignettesForLocation(twoLoc, 'bar').map((v) => v.id)) === JSON.stringify(['bar-a', 'bar-b']));
check('location: the shop’s scenes', JSON.stringify(vignettesForLocation(twoLoc, 'SHOP').map((v) => v.id)) === JSON.stringify(['shop-a']));
check('location: an empty location yields nothing', vignettesForLocation(twoLoc, ' ').length === 0);
// ----------------------------------------------------------------------
// availability (the gate — the scene is a quest step)
// ----------------------------------------------------------------------
const gated = {
id: 'bar-trestle-carol',
location: 'bar',
quest: 'special_delivery',
step: 0,
once: true,
lines: [{ from: 'trestle', text: 'One.' }],
};
const openGate = { granted: true, claimed: false, stepExists: true, stepDone: false };
const played = () => new Set();
check('available: ungated + unplayed → plays', vignetteAvailable({ ...gated, quest: null, step: null }, { played: played(), gate: null }) === true);
check('available: ungated + played → done (once)', vignetteAvailable({ ...gated, quest: null, step: null }, { played: new Set(['bar-trestle-carol']), gate: null }) === false);
check('available: once:false re-arms after it played', vignetteAvailable({ ...gated, quest: null, step: null, once: false }, { played: new Set(['bar-trestle-carol']), gate: null }) === true);
check('available: the gate held (granted, unclaimed, step open) → plays', vignetteAvailable(gated, { played: played(), gate: openGate }) === true);
check('available: the quest not granted → stays closed', vignetteAvailable(gated, { played: played(), gate: { ...openGate, granted: false } }) === false);
check('available: the quest claimed → done (the step paid out)', vignetteAvailable(gated, { played: played(), gate: { ...openGate, claimed: true } }) === false);
check('available: the step already done → done (the witness stands)', vignetteAvailable(gated, { played: played(), gate: { ...openGate, stepDone: true } }) === false);
check('available: a missing step (miswired gate) → stays closed', vignetteAvailable(gated, { played: played(), gate: { ...openGate, stepExists: false } }) === false);
check('available: a gate the scene couldn’t read → stays closed', vignetteAvailable(gated, { played: played(), gate: null }) === false);
check('available: completed (played) + gate open → done (the ledger marks on completion)', vignetteAvailable(gated, { played: new Set(['bar-trestle-carol']), gate: openGate }) === false);
// ----------------------------------------------------------------------
// the deferred-load specs (keys + urls)
// ----------------------------------------------------------------------
check('video: the key is stable per character', videoKeyFor('carol') === 'vign_vid_carol');
check(
'video: the default is the PLANET cut (assets/videos/characters/planet-<id>.mp4)',
videoUrlFor('carol') === 'assets/videos/characters/planet-carol.mp4',
);
check('video: a character entry may override its clip', (() => {
const c = { characters: { carol: { label: 'CAROL', video: 'assets/videos/characters/carol-alt.mp4' } } };
const a = lineAssets(c, { from: 'carol', text: 'x', audio: null });
return a.video?.url === 'assets/videos/characters/carol-alt.mp4' && a.video?.key === 'vign_vid_carol';
})());
check('video: the default cut ships on disk', existsSync(join(root, videoUrlFor('trestle'))) && existsSync(join(root, videoUrlFor('carol'))));
check('speech: the key is folder-scoped (two characters may share a basename)', audioKeyFor('assets/speech/carol/carol-q2-01.mp3') !== audioKeyFor('assets/speech/trestle/carol-q2-01.mp3'));
check('speech: the same file across lines shares one key', audioKeyFor('assets/speech/carol/carol-q2-01.mp3') === audioKeyFor('assets/speech/carol/carol-q2-01.mp3'));
check('speech: a text-only line has no audio spec', lineAssets({ characters: chars }, { from: 'carol', text: 'x', audio: null }).audio === null);
check(
'a scene’s asset pass: dedupes by key (a two-beat Carol scene fetches her clip once)',
(() => {
const c = mkCfg([]);
const vig = normalizeVignette(
{
id: 'multi',
location: 'bar',
lines: [
{ from: 'carol', text: 'One.', audio: 'assets/speech/carol/carol-q2-01.mp3' },
{ from: 'carol', text: 'Two.', audio: 'assets/speech/carol/carol-q2-01.mp3' },
{ from: 'trestle', text: 'Three.' },
],
},
c,
);
const a = vignetteAssets(c, vig);
return a.videos.length === 2 && a.audios.length === 1 && a.videos.every((v) => v.url.startsWith('assets/videos/characters/planet-'));
})(),
);
// ----------------------------------------------------------------------
// the enable switch (a config predating vignettes reads as enabled)
// ----------------------------------------------------------------------
check('a config without the section reads as enabled (pre-vignettes saves)', vignettesEnabled({}) === true && vignettesEnabled({ enabled: false }) === false);
// ----------------------------------------------------------------------
// the hub's duck (v4's Sound API — the music must ACTUALLY duck;
// this is a source-shape regression guard: the old `snd.keys` +
// `isPlaying()` loop iterated nothing in the v4 "Giedi" build)
// ----------------------------------------------------------------------
const hubSrc = readFileSync(join(root, 'js/vignettes/VignetteHub.js'), 'utf8');
const duckSrc = (hubSrc.match(/_duck\(on\) \{[\s\S]*?\n \}/) ?? [''])[0];
check('duck: reads the v4 manager (getAllPlaying — no `keys` property exists)', /getAllPlaying/.test(duckSrc) && !/\.keys/.test(duckSrc));
check('duck: treats a body isPlaying as data, never a method call', !/\.isPlaying\(/.test(duckSrc));
check('duck: exempts the voice, ducks by the musicDuck factor, restores its own volume', /_voiceKey/.test(duckSrc) && /this\.musicDuck/.test(duckSrc) && /s\.setVolume\(vol\)/.test(duckSrc));
// ----------------------------------------------------------------------
console.warn = origWarn;
if (failures > 0) {
console.error(`\n${failures} vignette-model check(s) failed`);
process.exit(1);
}
console.log('\nAll vignette-model checks passed.');