309 lines
18 KiB
JavaScript
309 lines
18 KiB
JavaScript
/**
|
||
* 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);
|
||
* - 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 === 11);
|
||
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 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');
|
||
|
||
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('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(
|
||
'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');
|
||
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 === 11 && ghostList[0].id === 'oac-intro');
|
||
const dupList = listComms({ ...cfg, comms: [...cfg.comms, { ...cfg.comms[0] }] });
|
||
check('drop: duplicate ids (first wins)', dupList.length === 11 && 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-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('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.');
|