/** * Comms model test (dev tool, run with Node — no browser): * * node dev/comms.test.mjs * * The pure rules of data/comms.json (js/comms/CommsModel.js) — the comm * roster's contract: * - the shipped config is VALID (enabled, OAC registered, the * oac-intro comm wired: ack + newGame + the real assets); * - the reserved triggers are RESERVED (no comm uses them yet); * - normalizeComm drops what can't be shown (bad type / no trigger / * no text for a box / no speech for an audio comm / unknown sender), * and keeps the rest (once defaults true, delayMs clamped); * - commsForTrigger returns the due comms in order, filtering the * played `once` ones (and re-arming `once: false` ones); * - asset keys are stable (per character for the clip, per file for * the speech) and resolve to the shipped files. */ import { pathToFileURL } from 'node:url'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { readFileSync } 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; let warnOn = true; console.warn = (...a) => { if (warnOn) origWarn(...a); }; const { config } = await import(pathToFileURL(join(root, 'js/config/Config.js')).href); const cfg = JSON.parse(readFileSync(join(root, 'data/comms.json'), 'utf8')); config.init({ comms: cfg }); const { commsEnabled, listComms, commsForTrigger, normalizeComm, commAssets, charEntry, videoKeyFor, audioKeyFor, COMM_TYPES, } = await import(pathToFileURL(join(root, 'js/comms/CommsModel.js')).href); // ---------------------------------------------------------------------- // the shipped config (the contract the hub + box build on) // ---------------------------------------------------------------------- check('comms.json is enabled', cfg.enabled === true); check('COMM_TYPES = ack | visual | audio', JSON.stringify(COMM_TYPES) === JSON.stringify(['ack', 'visual', 'audio'])); const oac = charEntry(cfg, 'oac'); check('OAC is registered (label + role + clip)', !!oac && oac.label === 'O.A.C.' && !!oac.role && oac.video === 'assets/videos/characters/oac.mp4'); const roster = listComms(cfg); check('the roster holds exactly the shipped comms', roster.length === 1); const intro = roster[0]; check( 'oac-intro: the fresh-run welcome (ack, newGame, OAC, once)', !!intro && intro.id === 'oac-intro' && intro.from === 'oac' && intro.type === 'ack' && intro.trigger === 'newGame' && intro.once === true, ); check('oac-intro: the welcome text (the brief, verbatim)', intro?.text.includes('Welcome to your ship') && intro?.text.includes('O.A.C.')); check('oac-intro: the parked speech clip', intro?.audio === 'assets/speech/oac/intro-01.mp3'); check('oac-intro: its delay beats the opening toast', (intro?.delayMs ?? 0) >= 1000); check( 'the reserved triggers are reserved (no comm uses them yet)', ['researchComplete', 'researchNew', 'questNew', 'questComplete', 'discovery', 'landing', 'jump', 'buildComplete'].every( (t) => !roster.some((c) => c.trigger === t), ), ); // ---------------------------------------------------------------------- // the deferred-load keys (stable + deduping) // ---------------------------------------------------------------------- check('video key is per character', videoKeyFor('oac') === 'comms_vid_oac'); check('audio key is per file (dedupes shared clips)', audioKeyFor('assets/speech/oac/intro-01.mp3') === 'comms_au_intro-01' && audioKeyFor('assets/speech/oac/intro-01.mp3') === audioKeyFor('./assets/speech/oac/intro-01.mp3')); const assets = commAssets(cfg, intro); check('oac-intro assets: the clip + the speech, keyed', assets.video?.key === 'comms_vid_oac' && assets.video.url === 'assets/videos/characters/oac.mp4' && assets.audio?.key === 'comms_au_intro-01'); // ---------------------------------------------------------------------- // normalizeComm — the drops (what can't be shown doesn't ship) // ---------------------------------------------------------------------- const base = { id: 't', from: 'oac', type: 'ack', trigger: 'newGame', text: 'hi', audio: 'assets/speech/oac/intro-01.mp3' }; // The drop checks below EXPECT the warnings — mute them (the checks // verify the drops themselves). warnOn = false; check('a clean comm normalizes (defaults: once=true, delayMs=0)', (() => { const c = normalizeComm({ ...base, id: 't2' }); return !!c && c.once === true && c.delayMs === 0 && c.id === 't2'; })()); check('drop: unknown type', normalizeComm({ ...base, id: 'b1', type: 'hologram' }) === null); check('drop: no trigger', normalizeComm({ ...base, id: 'b2', trigger: ' ' }) === null); check('drop: ack/visual without text', normalizeComm({ ...base, id: 'b3', text: '' }) === null); check('drop: audio comm without speech', normalizeComm({ ...base, id: 'b4', type: 'audio', audio: '' }) === null); check('keep: an audio comm with no text (voice only)', (() => { const c = normalizeComm({ ...base, id: 'b5', type: 'audio', text: '' }); return !!c && c.text === '' && c.type === 'audio'; })()); check('keep: once:false survives (the author’s repeat)', normalizeComm({ ...base, id: 'b6', once: false })?.once === false); check('delayMs clamps to >= 0', normalizeComm({ ...base, id: 'b7', delayMs: -50 })?.delayMs === 0); warnOn = true; // unknown senders are dropped by the ROSTER (listComms), not normalize const withGhost = { ...cfg, comms: [...cfg.comms, { ...base, id: 'ghost', from: 'wraith' }] }; const ghostList = listComms(withGhost); check('drop: unknown sender (the roster keeps the rest)', ghostList.length === 1 && ghostList[0].id === 'oac-intro'); const dupList = listComms({ ...cfg, comms: [...cfg.comms, { ...cfg.comms[0] }] }); check('drop: duplicate ids (first wins)', dupList.length === 1 && dupList[0].id === 'oac-intro'); warnOn = true; // ---------------------------------------------------------------------- // commsForTrigger — the due set (order + the played ledger) // ---------------------------------------------------------------------- check('newGame arms the welcome on a fresh run', commsForTrigger(cfg, 'newGame').length === 1 && commsForTrigger(cfg, 'newGame')[0].id === 'oac-intro'); check('a played `once` comm does not re-arm', commsForTrigger(cfg, 'newGame', new Set(['oac-intro'])).length === 0); check('an unknown trigger arms nothing', commsForTrigger(cfg, 'launchParty').length === 0); check('a blank trigger arms nothing', commsForTrigger(cfg, ' ').length === 0); // an `once: false` comm re-arms on every trigger (the repeat comm) const repeatCfg = { ...cfg, comms: [{ id: 'chatter', from: 'oac', type: 'audio', trigger: 'landing', audio: 'assets/speech/oac/intro-01.mp3', once: false }], }; check('once:false re-arms after it played', commsForTrigger(repeatCfg, 'landing', new Set(['chatter'])).length === 1); // a future roster: several comms on one trigger keep config order const multiCfg = { ...cfg, comms: [ { id: 'a', from: 'oac', type: 'audio', trigger: 'researchComplete', audio: 'assets/speech/oac/research-complete.mp3' }, { id: 'b', from: 'oac', type: 'audio', trigger: 'researchComplete', audio: 'assets/speech/oac/quest-new.mp3' }, { id: 'c', from: 'oac', type: 'audio', trigger: 'questNew', audio: 'assets/speech/oac/quest-new.mp3' }, ], }; check('a trigger’s comms arm in config order', JSON.stringify(commsForTrigger(multiCfg, 'researchComplete').map((c) => c.id)) === JSON.stringify(['a', 'b'])); // disabled master: the model still describes the roster (the hub is the // one that honors `enabled`) — keep the two honest. check('a config without the section reads as enabled (pre-comms saves)', commsEnabled({}) === true && commsEnabled({ enabled: false }) === false); // ---------------------------------------------------------------------- if (failures > 0) { console.error(`\n${failures} comms-model check(s) failed`); process.exit(1); } console.log('\nAll comms-model checks passed.');