/** * 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 .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-.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.');