355 lines
18 KiB
JavaScript
355 lines
18 KiB
JavaScript
/**
|
||
* 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.');
|