orbit/dev/comms.test.mjs

391 lines
24 KiB
JavaScript
Raw 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.

/**
* 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 tutorial
* chain wired: oac-intro → oac-tether → oac-scan on newGame, oac-upgrade
* on firstScan, oac-minerals → oac-fly-mine on tetherResearch, and
* oac-first-cluster on firstCluster, oac-tether2 on minerals200,
* oac-relaunch → oac-explore on tetherL2Launch, and
* oac-research-complete (audio-only, once:false) on researchComplete,
* oac-system-complete (visual, once:false, audioPool) on systemComplete,
* and trestle-meetup → trestle-quest on firstWorld);
* - 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, 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;
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 trestle = charEntry(cfg, 'trestle');
check('Trestle is registered (label + role + his own clip)', !!trestle && trestle.label === 'TRESTLE' && !!trestle.role && trestle.video === 'assets/videos/characters/trestle.mp4');
const roster = listComms(cfg);
check('the roster holds exactly the shipped comms', roster.length === 14);
const byId = Object.fromEntries(roster.map((c) => [c.id, c]));
const intro = byId['oac-intro'];
check(
'oac-intro: the fresh-run welcome (ack, newGame, OAC, once)',
!!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);
// The welcome chain — beats two + three (the hub's FIFO plays them in
// this order, each ack holding until its own ACKNOWLEDGE press).
check(
'oac-tether: welcome beat two (ack, newGame, intro-02)',
!!byId['oac-tether'] &&
byId['oac-tether'].type === 'ack' &&
byId['oac-tether'].trigger === 'newGame' &&
byId['oac-tether'].audio === 'assets/speech/oac/intro-02.mp3' &&
byId['oac-tether'].once === true,
);
check('oac-tether: the tether text (the brief, verbatim)', byId['oac-tether']?.text.includes('tethered to your home planet') && byId['oac-tether']?.text.includes('build more tethers'));
check(
'oac-scan: welcome beat three (ack, newGame, intro-03)',
!!byId['oac-scan'] &&
byId['oac-scan'].type === 'ack' &&
byId['oac-scan'].trigger === 'newGame' &&
byId['oac-scan'].audio === 'assets/speech/oac/intro-03.mp3' &&
byId['oac-scan'].once === true,
);
check('oac-scan: the scan instruction (the brief, verbatim)', byId['oac-scan']?.text.includes('begin by scanning') && byId['oac-scan']?.text.includes('click Scan on the bottom bar'));
// The scan reaction — the fourth beat, bound to the player's first SCAN press.
check(
'oac-upgrade: the scan reaction (ack, firstScan, intro-04, once)',
!!byId['oac-upgrade'] &&
byId['oac-upgrade'].type === 'ack' &&
byId['oac-upgrade'].trigger === 'firstScan' &&
byId['oac-upgrade'].audio === 'assets/speech/oac/intro-04.mp3' &&
byId['oac-upgrade'].once === true,
);
check('oac-upgrade: the 5 s beat after the scan click', byId['oac-upgrade']?.delayMs === 5000);
check('oac-upgrade: the upgrade instruction (the brief, verbatim)', byId['oac-upgrade']?.text.includes("I don't see any planets or space stations") && byId['oac-upgrade']?.text.includes('Tether Level 2 upgrade'));
// The tether-research reaction — the pair bound to 'tetherResearch'
// (OAC the moment the player commits to Tether Level 2 research).
check(
'oac-minerals: the tether-research reaction (ack, tetherResearch, intro-05, once)',
!!byId['oac-minerals'] &&
byId['oac-minerals'].type === 'ack' &&
byId['oac-minerals'].trigger === 'tetherResearch' &&
byId['oac-minerals'].audio === 'assets/speech/oac/intro-05.mp3' &&
byId['oac-minerals'].once === true,
);
check('oac-minerals: the 200-minerals brief (the brief, verbatim)', byId['oac-minerals']?.text.includes('upgrade the tether on your home planet itself') && byId['oac-minerals']?.text.includes('mine 200 minerals'));
check(
'oac-fly-mine: chained after oac-minerals (ack, tetherResearch, intro-06, once)',
!!byId['oac-fly-mine'] &&
byId['oac-fly-mine'].type === 'ack' &&
byId['oac-fly-mine'].trigger === 'tetherResearch' &&
byId['oac-fly-mine'].audio === 'assets/speech/oac/intro-06.mp3' &&
byId['oac-fly-mine'].once === true,
);
check('oac-fly-mine: the fly-to + continuous-fly brief (the brief, verbatim)', byId['oac-fly-mine']?.text.includes('hold shift when you click') && byId['oac-fly-mine']?.text.includes('find some asteroids to mine'));
check(
'oac-first-cluster: the first rock field (ack, firstCluster, intro-07, once)',
!!byId['oac-first-cluster'] &&
byId['oac-first-cluster'].type === 'ack' &&
byId['oac-first-cluster'].trigger === 'firstCluster' &&
byId['oac-first-cluster'].audio === 'assets/speech/oac/intro-07.mp3' &&
byId['oac-first-cluster'].once === true,
);
check('oac-first-cluster: the chime beat (delayMs > 0, lands after the discovery SFX)', (byId['oac-first-cluster']?.delayMs ?? 0) > 0);
check('oac-first-cluster: the mining brief (the brief, verbatim)', byId['oac-first-cluster']?.text.includes('first asteroid cluster') && byId['oac-first-cluster']?.text.includes('Keep mining until you reach 200 minerals or more'));
// The research-complete sign-off — the AUDIO-ONLY repeat comm (once:false).
check(
'oac-research-complete: the sign-off (audio-only, researchComplete, once:false)',
!!byId['oac-research-complete'] &&
byId['oac-research-complete'].from === 'oac' &&
byId['oac-research-complete'].type === 'audio' &&
byId['oac-research-complete'].trigger === 'researchComplete' &&
byId['oac-research-complete'].once === false,
);
check('oac-research-complete: speaks research-complete.mp3', byId['oac-research-complete']?.audio === 'assets/speech/oac/research-complete.mp3');
check('oac-research-complete: beats the completion chime (delayMs > 0)', (byId['oac-research-complete']?.delayMs ?? 0) > 0);
// The system-complete milestone — OAC's box reads the line; the voice
// rolls ONE of the discovery-complete clips at random (audioPool).
check(
'oac-system-complete: the milestone (visual, systemComplete, OAC, once:false)',
!!byId['oac-system-complete'] &&
byId['oac-system-complete'].from === 'oac' &&
byId['oac-system-complete'].type === 'visual' &&
byId['oac-system-complete'].trigger === 'systemComplete' &&
byId['oac-system-complete'].once === false,
);
check('oac-system-complete: the milestone line (the brief, verbatim)', byId['oac-system-complete']?.text === 'System Discovery Complete. Jumpgate Research Unlocked');
const sysPool = byId['oac-system-complete']?.audioPool ?? [];
check(
'oac-system-complete: the discovery-complete pool (the three clips, no fixed audio)',
byId['oac-system-complete']?.audio === null &&
JSON.stringify(sysPool) ===
JSON.stringify([
'assets/speech/oac/discovery-complete-01.mp3',
'assets/speech/oac/discovery-complete-02.mp3',
'assets/speech/oac/discovery-complete-03.mp3',
]),
);
check('oac-system-complete: every pool clip ships on disk', sysPool.every((f) => existsSync(join(root, f))));
check('oac-system-complete: beats the completion chime (delayMs > 0)', (byId['oac-system-complete']?.delayMs ?? 0) > 0);
// The 200-mineral threshold — OAC sends the player home to build.
check(
'oac-tether2: the 200-mineral beat (ack, minerals200, once)',
!!byId['oac-tether2'] &&
byId['oac-tether2'].from === 'oac' &&
byId['oac-tether2'].type === 'ack' &&
byId['oac-tether2'].trigger === 'minerals200' &&
byId['oac-tether2'].once === true,
);
check('oac-tether2: the build-it-home brief (the brief, verbatim)', byId['oac-tether2']?.text === 'Looks like we have enough minerals to afford that level 2 tether. To build it return to your home planet. You can auto pilot to it by clicking it\'s name in that little green box. When you land click the build menu and build the level 2 tether. Then launch back into space and we\'ll see if we can find another planet or space station.');
check('oac-tether2: speaks intro-08.mp3', byId['oac-tether2']?.audio === 'assets/speech/oac/intro-08.mp3');
check('oac-tether2: beats the HUD/quest beat (delayMs > 0)', (byId['oac-tether2']?.delayMs ?? 0) > 0);
// The launch-back pair — OAC clears the player to scan, then sends off.
check(
'oac-relaunch: the launch-back beat (ack, tetherL2Launch, once)',
!!byId['oac-relaunch'] &&
byId['oac-relaunch'].from === 'oac' &&
byId['oac-relaunch'].type === 'ack' &&
byId['oac-relaunch'].trigger === 'tetherL2Launch' &&
byId['oac-relaunch'].once === true,
);
check('oac-relaunch: the scan-again brief (the brief, verbatim)', byId['oac-relaunch']?.text === 'OK we should be all set. Click Scan again and let\'s see what we can find. Planets show up as green and space stations show up as purple on our indicator.');
check('oac-relaunch: speaks intro-09.mp3', byId['oac-relaunch']?.audio === 'assets/speech/oac/intro-09.mp3');
check(
'oac-explore: chained after oac-relaunch (ack, tetherL2Launch, once, immediate)',
!!byId['oac-explore'] &&
byId['oac-explore'].from === 'oac' &&
byId['oac-explore'].type === 'ack' &&
byId['oac-explore'].trigger === 'tetherL2Launch' &&
byId['oac-explore'].once === true &&
(byId['oac-explore']?.delayMs ?? 0) === 0,
);
check('oac-explore: the send-off (the brief, verbatim)', byId['oac-explore']?.text === 'This should be everything you need to get around and explore. Once you discover all the objects in a solar system, you can research that systems jump gates and then use them to jump into other solar systems. I\'ll let you do some exploring for a while on your own now... Good luck.');
check('oac-explore: speaks intro-10.mp3', byId['oac-explore']?.audio === 'assets/speech/oac/intro-10.mp3');
// The first-world pair — Trestle meets the player the moment the FIRST
// world is discovered: the starter quest's own witness (any planet or
// space station, the home world never counts — data/comms.json: firstWorld).
check(
'trestle-meetup: the first-world intro (ack, firstWorld, trestle, once)',
!!byId['trestle-meetup'] &&
byId['trestle-meetup'].from === 'trestle' &&
byId['trestle-meetup'].type === 'ack' &&
byId['trestle-meetup'].trigger === 'firstWorld' &&
byId['trestle-meetup'].once === true,
);
check('trestle-meetup: the meet-up brief (the brief, verbatim)', byId['trestle-meetup']?.text === 'Hey Kid. Not bad.. not bad at all.. You\'re getting the hang of this quicker than I thought. Listen, I\'m just out of system, but I think it\'s time we meet up. Finish exploring this system so you can unlock the jump gates. I\'ll ping you once you\'re ready.');
check('trestle-meetup: speaks trestle/intro-01.mp3', byId['trestle-meetup']?.audio === 'assets/speech/trestle/intro-01.mp3');
check('trestle-meetup: fires AT the moment of discovery (delayMs 0 — before the player can land)', (byId['trestle-meetup']?.delayMs ?? 0) === 0);
check(
'trestle-quest: chained after trestle-meetup (ack, firstWorld, once, immediate)',
!!byId['trestle-quest'] &&
byId['trestle-quest'].from === 'trestle' &&
byId['trestle-quest'].type === 'ack' &&
byId['trestle-quest'].trigger === 'firstWorld' &&
byId['trestle-quest'].once === true &&
(byId['trestle-quest']?.delayMs ?? 0) === 0,
);
check('trestle-quest: the first-quest/claim brief (the brief, verbatim)', byId['trestle-quest']?.text === 'Oh and by the way, you just completed your first quest. If you click on the quest in the upper left, or click the quests button below, you can complete the quest and claim 200 minerals I set aside just for you. See you soon kid!');
check('trestle-quest: speaks trestle/intro-02.mp3', byId['trestle-quest']?.audio === 'assets/speech/trestle/intro-02.mp3');
check(
'tetherResearch arms the pair in config order (minerals → fly-mine)',
JSON.stringify(commsForTrigger(cfg, 'tetherResearch').map((c) => c.id)) === JSON.stringify(['oac-minerals', 'oac-fly-mine']),
);
check('firstCluster arms the rock-field comm', commsForTrigger(cfg, 'firstCluster').length === 1 && commsForTrigger(cfg, 'firstCluster')[0].id === 'oac-first-cluster');
check('researchComplete arms the sign-off', commsForTrigger(cfg, 'researchComplete').length === 1 && commsForTrigger(cfg, 'researchComplete')[0].id === 'oac-research-complete');
check('systemComplete arms the milestone comm', commsForTrigger(cfg, 'systemComplete').length === 1 && commsForTrigger(cfg, 'systemComplete')[0].id === 'oac-system-complete');
check('minerals200 arms the 200-mineral beat', commsForTrigger(cfg, 'minerals200').length === 1 && commsForTrigger(cfg, 'minerals200')[0].id === 'oac-tether2');
check('tetherL2Launch arms the launch-back pair in config order', JSON.stringify(commsForTrigger(cfg, 'tetherL2Launch').map((c) => c.id)) === JSON.stringify(['oac-relaunch', 'oac-explore']));
check('firstWorld arms the trestle pair in config order', JSON.stringify(commsForTrigger(cfg, 'firstWorld').map((c) => c.id)) === JSON.stringify(['trestle-meetup', 'trestle-quest']));
check(
'the chain arms in config order (intro → tether → scan)',
JSON.stringify(commsForTrigger(cfg, 'newGame').map((c) => c.id)) === JSON.stringify(['oac-intro', 'oac-tether', 'oac-scan']),
);
check(
'the reserved triggers are reserved (no comm uses them yet)',
['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' && videoKeyFor('trestle') === 'comms_vid_trestle');
check('audio key is per clip (dedupes shared clips)', audioKeyFor('assets/speech/oac/intro-01.mp3') === 'comms_au_oac-intro-01' && audioKeyFor('assets/speech/oac/intro-01.mp3') === audioKeyFor('./assets/speech/oac/intro-01.mp3'));
check("audio keys are folder-scoped (Trestle's intro-01 is NOT OAC's)", audioKeyFor('assets/speech/oac/intro-01.mp3') !== audioKeyFor('assets/speech/trestle/intro-01.mp3') && audioKeyFor('assets/speech/trestle/intro-01.mp3') === 'comms_au_trestle-intro-01');
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_oac-intro-01');
const tAssets = commAssets(cfg, byId['trestle-meetup']);
check('trestle-meetup assets: his clip + his speech, keyed', tAssets.video?.key === 'comms_vid_trestle' && tAssets.video.url === 'assets/videos/characters/trestle.mp4' && tAssets.audio?.key === 'comms_au_trestle-intro-01');
const sysAssets = commAssets(cfg, byId['oac-system-complete']);
check(
'oac-system-complete assets: one pool clip, keyed + resolving to the pool',
sysAssets.video?.key === 'comms_vid_oac' &&
sysPool.includes(sysAssets.audio?.url) &&
sysAssets.audio?.key === audioKeyFor(sysAssets.audio.url),
);
// The pool rolls per arm — over many arms every clip must show up (the
// random pick is per-file uniform; 200 arms makes a dead clip near-impossible).
const rolled = new Set();
for (let i = 0; i < 200; i++) rolled.add(commAssets(cfg, byId['oac-system-complete']).audio?.url);
check('oac-system-complete: the pool rolls (all three clips land in 200 arms)', rolled.size === sysPool.length && [...rolled].every((f) => sysPool.includes(f)));
// ----------------------------------------------------------------------
// 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 authors repeat)', normalizeComm({ ...base, id: 'b6', once: false })?.once === false);
check('keep: an audio comm voiced by a pool (no fixed clip)', (() => {
const c = normalizeComm({ id: 'b8', from: 'oac', type: 'audio', trigger: 'systemComplete', audio: '', audioPool: ['assets/speech/oac/a.mp3', 'assets/speech/oac/b.mp3'] });
return !!c && c.audio === null && JSON.stringify(c.audioPool) === JSON.stringify(['assets/speech/oac/a.mp3', 'assets/speech/oac/b.mp3']);
})());
check('drop: an audio comm with neither clip nor pool', normalizeComm({ ...base, id: 'b9', type: 'audio', audio: '', audioPool: [] }) === null);
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 === 14 && ghostList[0].id === 'oac-intro');
const dupList = listComms({ ...cfg, comms: [...cfg.comms, { ...cfg.comms[0] }] });
check('drop: duplicate ids (first wins)', dupList.length === 14 && dupList[0].id === 'oac-intro');
warnOn = true;
// ----------------------------------------------------------------------
// commsForTrigger — the due set (order + the played ledger)
// ----------------------------------------------------------------------
check('newGame arms the whole welcome chain on a fresh run', commsForTrigger(cfg, 'newGame').length === 3 && commsForTrigger(cfg, 'newGame')[0].id === 'oac-intro');
check('a played `once` comm does not re-arm (the rest of the chain does)', JSON.stringify(commsForTrigger(cfg, 'newGame', new Set(['oac-intro'])).map((c) => c.id)) === JSON.stringify(['oac-tether', 'oac-scan']));
check('firstScan arms the scan reaction', commsForTrigger(cfg, 'firstScan').length === 1 && commsForTrigger(cfg, 'firstScan')[0].id === 'oac-upgrade');
check('a played oac-upgrade does not re-arm firstScan', commsForTrigger(cfg, 'firstScan', new Set(['oac-upgrade'])).length === 0);
check('tetherResearch arms the pair on a fresh run', commsForTrigger(cfg, 'tetherResearch').length === 2);
check('a played oac-minerals does not re-arm its half (the other still does)', JSON.stringify(commsForTrigger(cfg, 'tetherResearch', new Set(['oac-minerals'])).map((c) => c.id)) === JSON.stringify(['oac-fly-mine']));
check('firstCluster arms the rock-field comm', commsForTrigger(cfg, 'firstCluster').length === 1);
check('a played oac-first-cluster does not re-arm firstCluster', commsForTrigger(cfg, 'firstCluster', new Set(['oac-first-cluster'])).length === 0);
check('a played oac-research-complete RE-arms (once:false — every completion signs off)', commsForTrigger(cfg, 'researchComplete', new Set(['oac-research-complete'])).length === 1);
check('a played oac-system-complete RE-arms (once:false — every system plays its milestone)', commsForTrigger(cfg, 'systemComplete', new Set(['oac-system-complete'])).length === 1);
check('a played oac-tether2 does not re-arm minerals200', commsForTrigger(cfg, 'minerals200', new Set(['oac-tether2'])).length === 0);
check('a played half of the launch-back pair still arms the other', JSON.stringify(commsForTrigger(cfg, 'tetherL2Launch', new Set(['oac-relaunch'])).map((c) => c.id)) === JSON.stringify(['oac-explore']));
check('a fully played launch-back pair re-arms nothing', commsForTrigger(cfg, 'tetherL2Launch', new Set(['oac-relaunch', 'oac-explore'])).length === 0);
check('firstWorld arms the trestle pair on a fresh run', commsForTrigger(cfg, 'firstWorld').length === 2);
check('a played trestle-meetup does not re-arm its half (the other still does)', JSON.stringify(commsForTrigger(cfg, 'firstWorld', new Set(['trestle-meetup'])).map((c) => c.id)) === JSON.stringify(['trestle-quest']));
check('a fully played trestle pair re-arms nothing', commsForTrigger(cfg, 'firstWorld', new Set(['trestle-meetup', 'trestle-quest'])).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 triggers 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.');