874 lines
54 KiB
JavaScript
874 lines
54 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
|
||
* + oac-research-new (audio-only, once:false — the 'new research'
|
||
* answer to the sign-off, armed on its onEnd) on researchNew,
|
||
* oac-system-complete (visual, once:false, audioPool) on systemComplete,
|
||
* oac-quest-new (audio-only, once:false) on questNew, and
|
||
* trestle-meetup → trestle-quest on firstWorld,
|
||
* trestle-brief-1 → trestle-brief-2 → trestle-brief-3 (the
|
||
* 'Leaving the Nest' three-brief sequence, ack) on trestleBrief,
|
||
* trestle-arrive-1 → trestle-arrive-2 (ack) on trestleArrival,
|
||
* carol-delivered-1 → carol-delivered-2 → carol-delivered-3 →
|
||
* oac-delivered-1 (the 'Special Delivery' payoff — the launch back
|
||
* into space after the package's home-world sale, ack) on
|
||
* deliveredLaunch, oac-enemies-detected (visual, once:false,
|
||
* audioPool) on combatStart, and oac-enemies-defeated (visual,
|
||
* once:false, audioPool, the 1 s delay) on combatEnd, with Carol
|
||
* registered as a sender);
|
||
* - 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 carol = charEntry(cfg, 'carol');
|
||
check('Carol is registered (label + role + her own clip)', !!carol && carol.label === 'CAROL' && !!carol.role && carol.video === 'assets/videos/characters/carol.mp4');
|
||
check('Carol\'s space clip ships on disk', existsSync(join(root, 'assets/videos/characters/carol.mp4')));
|
||
|
||
const pirate = charEntry(cfg, 'pirate');
|
||
check('the pirate is registered (label + role + the attack clip)', !!pirate && pirate.label === 'PIRATE' && !!pirate.role && pirate.video === 'assets/videos/enemies/pirate.mp4');
|
||
check('the pirate\'s clip ships on disk', existsSync(join(root, 'assets/videos/enemies/pirate.mp4')));
|
||
const pirateDefeat = charEntry(cfg, 'pirate_defeat');
|
||
check('the pirate\'s defeat face is registered (the dying clip)', !!pirateDefeat && pirateDefeat.label === 'PIRATE' && pirateDefeat.video === 'assets/videos/enemies/pirate-defeat.mp4');
|
||
check('the defeat clip ships on disk', existsSync(join(root, 'assets/videos/enemies/pirate-defeat.mp4')));
|
||
const pirateBounty = charEntry(cfg, 'pirate_bounty');
|
||
check('the bounty mark is registered (label + role + the bounty clip)', !!pirateBounty && pirateBounty.label === 'PIRATE' && !!pirateBounty.role && pirateBounty.video === 'assets/videos/enemies/pirate-bounty.mp4');
|
||
check('the bounty clip ships on disk', existsSync(join(root, 'assets/videos/enemies/pirate-bounty.mp4')));
|
||
const neutralPilot = charEntry(cfg, 'neutral_pilot');
|
||
check('the neutral pilot is registered (label + role + the pilot clip)', !!neutralPilot && neutralPilot.label === 'NEUTRAL PILOT' && !!neutralPilot.role && neutralPilot.video === 'assets/videos/allies/neutral-pilot.mp4');
|
||
check('the pilot clip ships on disk', existsSync(join(root, 'assets/videos/allies/neutral-pilot.mp4')));
|
||
check('the scan voice ships on disk', existsSync(join(root, 'assets/speech/neutral/scan-01.mp3')));
|
||
check('the scan-complete voice ships on disk', existsSync(join(root, 'assets/speech/neutral/scan-complete-01.mp3')));
|
||
|
||
const roster = listComms(cfg);
|
||
// The shipped roster count (the data pins it: a stray unvalidated comm
|
||
// landing in data/comms.json changes the number and fails this line —
|
||
// 25 pre-'Pirate Problems' + the trestleQ4 warning trio (trestle-q4-1/
|
||
// 2/3, data/comms.json) + the payoff pair (trestle-q4-4/5, the
|
||
// quest-complete sign-off) + the combat pair (oac-enemies-detected /
|
||
// oac-enemies-defeated) + the pirate trio (pirate-attack / pirate-defeat /
|
||
// pirate-bounty-attack — the comming type's own voice, the mark's too)
|
||
// + the neutral pilot's pair (neutral-scan / neutral-scan-complete)
|
||
// + the escort denial (oac-escort-wait) + the transport captain's pair
|
||
// (neutral-transport-link / neutral-transport-delivery) = 40).
|
||
check('the roster holds exactly the shipped comms', roster.length === 40);
|
||
const byId = Object.fromEntries(roster.map((c) => [c.id, c]));
|
||
const transportCaptain = charEntry(cfg, 'transport_captain');
|
||
check('the transport captain is registered (label + role + the transport clip)', !!transportCaptain && transportCaptain.label === 'TRANSPORT CAPTAIN' && !!transportCaptain.role && transportCaptain.video === 'assets/videos/allies/neutral-transport.mp4');
|
||
check('the transport clip ships on disk', existsSync(join(root, 'assets/videos/allies/neutral-transport.mp4')));
|
||
const tLink = byId['neutral-transport-link'];
|
||
check(
|
||
'neutral-transport-link: the captain link greeting (visual, escortLink, transport_captain, re-arms)',
|
||
!!tLink && tLink.from === 'transport_captain' && tLink.type === 'visual' && tLink.trigger === 'escortLink' && tLink.once === false,
|
||
);
|
||
check('neutral-transport-link: the greeting', /good to see you/i.test(tLink?.text ?? '') && /appreciate you escorting us/i.test(tLink?.text ?? ''));
|
||
check('neutral-transport-link: the pool is the three transport clips', (tLink?.audioPool ?? []).length === 3 && tLink?.audioPool.every((f) => /^assets\/speech\/neutral\/transport-0[123]\.mp3$/.test(f)));
|
||
check('neutral-transport-link: the pool ships on disk', (tLink?.audioPool ?? []).every((f) => existsSync(join(root, f))));
|
||
check('neutral-transport-link: every arm re-fires (once:false)', commsForTrigger(cfg, 'escortLink', new Set(['neutral-transport-link'])).length === 1);
|
||
const tDeliv = byId['neutral-transport-delivery'];
|
||
check(
|
||
'neutral-transport-delivery: the captain delivery thanks (visual, escortDelivered, transport_captain, re-arms)',
|
||
!!tDeliv && tDeliv.from === 'transport_captain' && tDeliv.type === 'visual' && tDeliv.trigger === 'escortDelivered' && tDeliv.once === false,
|
||
);
|
||
check('neutral-transport-delivery: the thanks', tDeliv?.text.includes('Thank you for the escort'));
|
||
check('neutral-transport-delivery: the pool is the three transport-success clips', (tDeliv?.audioPool ?? []).length === 3 && tDeliv?.audioPool.every((f) => /^assets\/speech\/neutral\/transport-success-0[123]\.mp3$/.test(f)));
|
||
check('neutral-transport-delivery: the pool ships on disk', (tDeliv?.audioPool ?? []).every((f) => existsSync(join(root, f))));
|
||
check('neutral-transport-delivery: every arm re-fires (once:false)', commsForTrigger(cfg, 'escortDelivered', new Set(['neutral-transport-delivery'])).length === 1);
|
||
const escortWait = byId['oac-escort-wait'];
|
||
check(
|
||
'oac-escort-wait: the escort denial (visual, escortDeny, OAC, re-arms)',
|
||
!!escortWait &&
|
||
escortWait.from === 'oac' &&
|
||
escortWait.type === 'visual' &&
|
||
escortWait.trigger === 'escortDeny' &&
|
||
escortWait.once === false,
|
||
);
|
||
check('oac-escort-wait: the words, verbatim', escortWait?.text === 'Our access is being denied. We need to wait for the transport ship.');
|
||
check('oac-escort-wait: the escort-wait voice', escortWait?.audio === 'assets/speech/oac/escort-wait.mp3');
|
||
check('oac-escort-wait: the voice ships on disk', existsSync(join(root, 'assets/speech/oac/escort-wait.mp3')));
|
||
check('oac-escort-wait: every arm re-fires (once:false)', commsForTrigger(cfg, 'escortDeny', new Set(['oac-escort-wait'])).length === 1);
|
||
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 new-research beat — OAC's answer to the sign-off (armed on the
|
||
// sign-off's onEnd only when a tech is still ready: GameScene._commEnded
|
||
// → comms.trigger('researchNew')). AUDIO-ONLY, once:false, delayMs 0 — it
|
||
// lands right behind the sign-off's voice (the hub's FIFO serializes them).
|
||
check(
|
||
'oac-research-new: the beat (audio-only, researchNew, OAC, once:false)',
|
||
!!byId['oac-research-new'] &&
|
||
byId['oac-research-new'].from === 'oac' &&
|
||
byId['oac-research-new'].type === 'audio' &&
|
||
byId['oac-research-new'].trigger === 'researchNew' &&
|
||
byId['oac-research-new'].once === false,
|
||
);
|
||
check('oac-research-new: speaks new-research.mp3', byId['oac-research-new']?.audio === 'assets/speech/oac/new-research.mp3');
|
||
check('oac-research-new: the clip ships on disk', existsSync(join(root, 'assets/speech/oac/new-research.mp3')));
|
||
check('oac-research-new: lands right behind the sign-off (delayMs === 0)', (byId['oac-research-new']?.delayMs ?? -1) === 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 discovery 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');
|
||
|
||
// The quest-grant voice — the NEW QUEST beat (audio-only, once:false:
|
||
// every grant re-arms it — the starter boot + every unlock, the scene's
|
||
// _grantUnlockedQuests triggers 'questNew').
|
||
check(
|
||
'oac-quest-new: the quest-grant voice (audio, questNew, once:false)',
|
||
!!byId['oac-quest-new'] &&
|
||
byId['oac-quest-new'].from === 'oac' &&
|
||
byId['oac-quest-new'].type === 'audio' &&
|
||
byId['oac-quest-new'].trigger === 'questNew' &&
|
||
byId['oac-quest-new'].once === false,
|
||
);
|
||
check('oac-quest-new: speaks oac/quest-new.mp3', byId['oac-quest-new']?.audio === 'assets/speech/oac/quest-new.mp3');
|
||
check('oac-quest-new: the clip ships on disk', existsSync(join(root, byId['oac-quest-new']?.audio ?? '')));
|
||
check('oac-quest-new: beats the grant SFX (delayMs > 0)', (byId['oac-quest-new']?.delayMs ?? 0) > 0);
|
||
|
||
// The combat pair — OAC's contact call the moment the fight goes live
|
||
// and the sign-off the moment the last hostile is down (GameScene
|
||
// ._setCombatBar — the CombatSystem active-flag flips; both once:false,
|
||
// both voiced by an audioPool — the same contract as oac-system-complete).
|
||
check(
|
||
'oac-enemies-detected: the combat-start call (visual, combatStart, oac, once:false)',
|
||
!!byId['oac-enemies-detected'] &&
|
||
byId['oac-enemies-detected'].from === 'oac' &&
|
||
byId['oac-enemies-detected'].type === 'visual' &&
|
||
byId['oac-enemies-detected'].trigger === 'combatStart' &&
|
||
byId['oac-enemies-detected'].once === false,
|
||
);
|
||
const detPool = byId['oac-enemies-detected']?.audioPool ?? [];
|
||
check(
|
||
'oac-enemies-detected: the three detected clips (audioPool, no fixed audio)',
|
||
byId['oac-enemies-detected']?.audio === null &&
|
||
JSON.stringify(detPool) ===
|
||
JSON.stringify([
|
||
'assets/speech/oac/enemies-detected-01.mp3',
|
||
'assets/speech/oac/enemies-detected-02.mp3',
|
||
'assets/speech/oac/enemies-detected-03.mp3',
|
||
]),
|
||
);
|
||
check('oac-enemies-detected: every pool clip ships on disk', detPool.every((f) => existsSync(join(root, f))));
|
||
check('oac-enemies-detected: fires AT the fight (delayMs === 0)', (byId['oac-enemies-detected']?.delayMs ?? -1) === 0);
|
||
check(
|
||
'oac-enemies-detected: a played call RE-arms (once:false — every combat start)',
|
||
commsForTrigger(cfg, 'combatStart', new Set(['oac-enemies-detected'])).length === 1,
|
||
);
|
||
check(
|
||
'oac-enemies-defeated: the combat-end sign-off (visual, combatEnd, oac, once:false)',
|
||
!!byId['oac-enemies-defeated'] &&
|
||
byId['oac-enemies-defeated'].from === 'oac' &&
|
||
byId['oac-enemies-defeated'].type === 'visual' &&
|
||
byId['oac-enemies-defeated'].trigger === 'combatEnd' &&
|
||
byId['oac-enemies-defeated'].once === false,
|
||
);
|
||
const defPool = byId['oac-enemies-defeated']?.audioPool ?? [];
|
||
check(
|
||
'oac-enemies-defeated: the three defeated clips (audioPool, no fixed audio)',
|
||
byId['oac-enemies-defeated']?.audio === null &&
|
||
JSON.stringify(defPool) ===
|
||
JSON.stringify([
|
||
'assets/speech/oac/enemies-defeated-01.mp3',
|
||
'assets/speech/oac/enemies-defeated-02.mp3',
|
||
'assets/speech/oac/enemies-defeated-03.mp3',
|
||
]),
|
||
);
|
||
check('oac-enemies-defeated: every pool clip ships on disk', defPool.every((f) => existsSync(join(root, f))));
|
||
check('oac-enemies-defeated: waits 1 s for the last kill to land (delayMs === 1000)', (byId['oac-enemies-defeated']?.delayMs ?? -1) === 1000);
|
||
check(
|
||
'oac-enemies-defeated: a played sign-off RE-arms (once:false — every combat end)',
|
||
commsForTrigger(cfg, 'combatEnd', new Set(['oac-enemies-defeated'])).length === 1,
|
||
);
|
||
const detAssets = commAssets(cfg, byId['oac-enemies-detected']);
|
||
check(
|
||
'oac-enemies-detected assets: the pool rolls to a shipped clip, keyed',
|
||
detAssets.video?.key === 'comms_vid_oac' &&
|
||
detPool.includes(detAssets.audio?.url) &&
|
||
detAssets.audio?.key === audioKeyFor(detAssets.audio.url),
|
||
);
|
||
const defAssets = commAssets(cfg, byId['oac-enemies-defeated']);
|
||
check(
|
||
'oac-enemies-defeated assets: the pool rolls to a shipped clip, keyed',
|
||
defAssets.video?.key === 'comms_vid_oac' &&
|
||
defPool.includes(defAssets.audio?.url) &&
|
||
defAssets.audio?.key === audioKeyFor(defAssets.audio.url),
|
||
);
|
||
|
||
// The PIRATE'S OWN VOICE (data/combat.json → enemyTypes.raider.comms rolls
|
||
// the pirate-attack / pirate-defeat triggers; GameScene's bounty hunt arms
|
||
// pirate-bounty-attack) — the box shows the pirate's clip with the voice
|
||
// rolling one of the four clips per arm, all riding over the live fight.
|
||
const atk = byId['pirate-attack'];
|
||
check(
|
||
'pirate-attack: the on-screen/arrival call (visual, pirateAttack, pirate, once:false)',
|
||
!!atk && atk.from === 'pirate' && atk.type === 'visual' && atk.trigger === 'pirateAttack' && atk.once === false,
|
||
);
|
||
const atkPool = atk?.audioPool ?? [];
|
||
check(
|
||
'pirate-attack: the four attack clips (audioPool, no fixed audio)',
|
||
atk?.audio === null &&
|
||
JSON.stringify(atkPool) ===
|
||
JSON.stringify([
|
||
'assets/speech/pirates/attack-01.mp3',
|
||
'assets/speech/pirates/attack-02.mp3',
|
||
'assets/speech/pirates/attack-03.mp3',
|
||
'assets/speech/pirates/attack-04.mp3',
|
||
]),
|
||
);
|
||
check('pirate-attack: every pool clip ships on disk', atkPool.every((f) => existsSync(join(root, f))));
|
||
check('pirate-attack: fires AT the fight (delayMs === 0)', (atk?.delayMs ?? -1) === 0);
|
||
check(
|
||
'pirate-attack: a played call RE-arms (once:false — every roll that hits)',
|
||
commsForTrigger(cfg, 'pirateAttack', new Set(['pirate-attack'])).length === 1,
|
||
);
|
||
const atkAssets = commAssets(cfg, atk);
|
||
check(
|
||
'pirate-attack assets: the attack clip + the pool, keyed',
|
||
atkAssets.video?.key === 'comms_vid_pirate' &&
|
||
atkAssets.video.url === 'assets/videos/enemies/pirate.mp4' &&
|
||
atkPool.includes(atkAssets.audio?.url) &&
|
||
atkAssets.audio?.key === audioKeyFor(atkAssets.audio.url),
|
||
);
|
||
const dft = byId['pirate-defeat'];
|
||
check(
|
||
'pirate-defeat: the kill sign-off (visual, pirateDefeat, pirate_defeat, once:false)',
|
||
!!dft && dft.from === 'pirate_defeat' && dft.type === 'visual' && dft.trigger === 'pirateDefeat' && dft.once === false,
|
||
);
|
||
const dftPool = dft?.audioPool ?? [];
|
||
check(
|
||
'pirate-defeat: the four defeat clips (audioPool, no fixed audio)',
|
||
dft?.audio === null &&
|
||
JSON.stringify(dftPool) ===
|
||
JSON.stringify([
|
||
'assets/speech/pirates/defeat-01.mp3',
|
||
'assets/speech/pirates/defeat-02.mp3',
|
||
'assets/speech/pirates/defeat-03.mp3',
|
||
'assets/speech/pirates/defeat-04.mp3',
|
||
]),
|
||
);
|
||
check('pirate-defeat: every pool clip ships on disk', dftPool.every((f) => existsSync(join(root, f))));
|
||
check('pirate-defeat: fires AT the kill (delayMs === 0)', (dft?.delayMs ?? -1) === 0);
|
||
check(
|
||
'pirate-defeat: a played sign-off RE-arms (once:false — every roll that hits)',
|
||
commsForTrigger(cfg, 'pirateDefeat', new Set(['pirate-defeat'])).length === 1,
|
||
);
|
||
const dftAssets = commAssets(cfg, dft);
|
||
check(
|
||
'pirate-defeat assets: the dying clip + the pool, keyed',
|
||
dftAssets.video?.key === 'comms_vid_pirate_defeat' &&
|
||
dftAssets.video.url === 'assets/videos/enemies/pirate-defeat.mp4' &&
|
||
dftPool.includes(dftAssets.audio?.url) &&
|
||
dftAssets.audio?.key === audioKeyFor(dftAssets.audio.url),
|
||
);
|
||
const bty = byId['pirate-bounty-attack'];
|
||
check(
|
||
'pirate-bounty-attack: the mark\'s call (visual, pirateBountyAttack, pirate_bounty, once:false)',
|
||
!!bty && bty.from === 'pirate_bounty' && bty.type === 'visual' && bty.trigger === 'pirateBountyAttack' && bty.once === false,
|
||
);
|
||
const btyPool = bty?.audioPool ?? [];
|
||
check(
|
||
'pirate-bounty-attack: the four bounty clips (audioPool, no fixed audio)',
|
||
bty?.audio === null &&
|
||
JSON.stringify(btyPool) ===
|
||
JSON.stringify([
|
||
'assets/speech/pirates/bounty-01.mp3',
|
||
'assets/speech/pirates/bounty-02.mp3',
|
||
'assets/speech/pirates/bounty-03.mp3',
|
||
'assets/speech/pirates/bounty-04.mp3',
|
||
]),
|
||
);
|
||
check('pirate-bounty-attack: every pool clip ships on disk', btyPool.every((f) => existsSync(join(root, f))));
|
||
check('pirate-bounty-attack: fires AT the engagement (delayMs === 0)', (bty?.delayMs ?? -1) === 0);
|
||
check(
|
||
'pirate-bounty-attack: a played call RE-arms (once:false — every engagement that stands)',
|
||
commsForTrigger(cfg, 'pirateBountyAttack', new Set(['pirate-bounty-attack'])).length === 1,
|
||
);
|
||
const btyAssets = commAssets(cfg, bty);
|
||
check(
|
||
'pirate-bounty-attack assets: the bounty clip + the pool, keyed',
|
||
btyAssets.video?.key === 'comms_vid_pirate_bounty' &&
|
||
btyAssets.video.url === 'assets/videos/enemies/pirate-bounty.mp4' &&
|
||
btyPool.includes(btyAssets.audio?.url) &&
|
||
btyAssets.audio?.key === audioKeyFor(btyAssets.audio.url),
|
||
);
|
||
// The scene seam — the mark's fight stands up ONLY in the bounty branch of
|
||
// GameScene._fireBountyEngagements (the picket skirmish throws no mark):
|
||
// the trigger is armed there, and nowhere else.
|
||
const bountySrc = (await import('node:fs')).readFileSync(join(root, 'js/scenes/GameScene.js'), 'utf8');
|
||
check(
|
||
'GameScene arms pirateBountyAttack in the mark branch (and nowhere else)',
|
||
bountySrc.split("trigger('pirateBountyAttack')").length - 1 === 1 &&
|
||
bountySrc.indexOf("trigger('pirateBountyAttack')") > bountySrc.indexOf('_fireBountyEngagements'),
|
||
);
|
||
|
||
// The 'Leaving the Nest' beats — Trestle's THREE-BRIEF sequence (the
|
||
// home gates go online; ALL THREE in the run's comms played-ledger are
|
||
// the quest's 'Talk to Trestle' witness — GameScene._trestleCheck) and
|
||
// the arrival pair (the player arrives at the destination planet — the
|
||
// neutral capital world; BOTH in the ledger are the 'Meet Trestle'
|
||
// witness).
|
||
check(
|
||
'trestle-brief-1: the gates-online briefing, beat 1/3 (ack, trestleBrief, trestle, once)',
|
||
!!byId['trestle-brief-1'] &&
|
||
byId['trestle-brief-1'].from === 'trestle' &&
|
||
byId['trestle-brief-1'].type === 'ack' &&
|
||
byId['trestle-brief-1'].trigger === 'trestleBrief' &&
|
||
byId['trestle-brief-1'].once === true,
|
||
);
|
||
check('trestle-brief-1: the charted-system brief (the brief, verbatim)', byId['trestle-brief-1']?.text === 'Hey Kid. You\'ve discovered all the important objects in the system. That means Planets, Space Stations and Jump gates. As you move around the galaxy, you can always find out how many objects a system has in the map view on your console.');
|
||
check('trestle-brief-1: speaks trestle/q2-01.mp3', byId['trestle-brief-1']?.audio === 'assets/speech/trestle/q2-01.mp3');
|
||
check('trestle-brief-1: the clip ships on disk', existsSync(join(root, byId['trestle-brief-1']?.audio ?? '')));
|
||
check('trestle-brief-1: lands after the gates-online beat (delayMs > 0)', (byId['trestle-brief-1']?.delayMs ?? 0) > 0);
|
||
check(
|
||
'trestle-brief-2: the cargo-setup brief, beat 2/3 (ack, trestleBrief, once, immediate)',
|
||
!!byId['trestle-brief-2'] &&
|
||
byId['trestle-brief-2'].from === 'trestle' &&
|
||
byId['trestle-brief-2'].type === 'ack' &&
|
||
byId['trestle-brief-2'].trigger === 'trestleBrief' &&
|
||
byId['trestle-brief-2'].once === true &&
|
||
(byId['trestle-brief-2']?.delayMs ?? 0) === 0,
|
||
);
|
||
check('trestle-brief-2: the cargo/Carol brief (the brief, verbatim)', byId['trestle-brief-2']?.text === 'Listen, I could use your help. Nothing major, but I think it\'s time you learned how to move cargo. I have a friend here named Carol who has something we need to get delivered pronto.');
|
||
check('trestle-brief-2: speaks trestle/q2-02.mp3', byId['trestle-brief-2']?.audio === 'assets/speech/trestle/q2-02.mp3');
|
||
check('trestle-brief-2: the clip ships on disk', existsSync(join(root, byId['trestle-brief-2']?.audio ?? '')));
|
||
check(
|
||
'trestle-brief-3: the rendezvous brief, beat 3/3 (ack, trestleBrief, once, immediate)',
|
||
!!byId['trestle-brief-3'] &&
|
||
byId['trestle-brief-3'].from === 'trestle' &&
|
||
byId['trestle-brief-3'].type === 'ack' &&
|
||
byId['trestle-brief-3'].trigger === 'trestleBrief' &&
|
||
byId['trestle-brief-3'].once === true &&
|
||
(byId['trestle-brief-3']?.delayMs ?? 0) === 0,
|
||
);
|
||
check('trestle-brief-3: the coordinates/orange-marker brief (the brief, verbatim)', byId['trestle-brief-3']?.text === 'I\'m in the system right next to you in this star cluster\'s capital. I\'m sending the coordinates to your computer right now. Just follow the orange markers on your compass or map and we\'ll talk more when you get here.');
|
||
check('trestle-brief-3: speaks trestle/q2-03.mp3', byId['trestle-brief-3']?.audio === 'assets/speech/trestle/q2-03.mp3');
|
||
check('trestle-brief-3: the clip ships on disk', existsSync(join(root, byId['trestle-brief-3']?.audio ?? '')));
|
||
check(
|
||
'trestle-arrive-1: the capital-world welcome, beat 1/2 (ack, trestleArrival, trestle, once)',
|
||
!!byId['trestle-arrive-1'] &&
|
||
byId['trestle-arrive-1'].from === 'trestle' &&
|
||
byId['trestle-arrive-1'].type === 'ack' &&
|
||
byId['trestle-arrive-1'].trigger === 'trestleArrival' &&
|
||
byId['trestle-arrive-1'].once === true,
|
||
);
|
||
check('trestle-arrive-1: the welcome brief (you made it)', byId['trestle-arrive-1']?.text.includes('you made it'));
|
||
check('trestle-arrive-1: speaks trestle/q2-04.mp3', byId['trestle-arrive-1']?.audio === 'assets/speech/trestle/q2-04.mp3');
|
||
check('trestle-arrive-1: the clip ships on disk', existsSync(join(root, byId['trestle-arrive-1']?.audio ?? '')));
|
||
check('trestle-arrive-1: lands after the arrival beat (delayMs > 0)', (byId['trestle-arrive-1']?.delayMs ?? 0) > 0);
|
||
check(
|
||
'trestle-arrive-2: the rendezvous send-off, beat 2/2 (ack, trestleArrival, once, immediate)',
|
||
!!byId['trestle-arrive-2'] &&
|
||
byId['trestle-arrive-2'].from === 'trestle' &&
|
||
byId['trestle-arrive-2'].type === 'ack' &&
|
||
byId['trestle-arrive-2'].trigger === 'trestleArrival' &&
|
||
byId['trestle-arrive-2'].once === true &&
|
||
(byId['trestle-arrive-2']?.delayMs ?? 0) === 0,
|
||
);
|
||
check('trestle-arrive-2: the bar/credits brief (Carol + the 200-credit top-up)', byId['trestle-arrive-2']?.text.includes('Carol') && byId['trestle-arrive-2']?.text.includes('200 credits'));
|
||
check('trestle-arrive-2: speaks trestle/q2-05.mp3', byId['trestle-arrive-2']?.audio === 'assets/speech/trestle/q2-05.mp3');
|
||
check('trestle-arrive-2: the clip ships on disk', existsSync(join(root, byId['trestle-arrive-2']?.audio ?? '')));
|
||
|
||
// The 'Special Delivery' payoff — the launch back into space after the
|
||
// package's home-world sale (GameScene.onSurfaceDeparture arms
|
||
// 'deliveredLaunch' when the run's delivery witness, story.delivered,
|
||
// stands; the comms' once-ledger keeps the four-beat chain a one-shot —
|
||
// a later re-arm is a no-op). Carol's thanks + trade sign-off, then
|
||
// OAC's confirmation that the buy/sell codes are live on every world.
|
||
check(
|
||
'carol-delivered-1: the thanks, beat 1/4 (ack, deliveredLaunch, carol, once)',
|
||
!!byId['carol-delivered-1'] &&
|
||
byId['carol-delivered-1'].from === 'carol' &&
|
||
byId['carol-delivered-1'].type === 'ack' &&
|
||
byId['carol-delivered-1'].trigger === 'deliveredLaunch' &&
|
||
byId['carol-delivered-1'].once === true,
|
||
);
|
||
check('carol-delivered-1: the thanks (verbatim)', byId['carol-delivered-1']?.text === 'Thank you. I appreciate you taking care of that for me.');
|
||
check('carol-delivered-1: speaks carol/q3-05.mp3', byId['carol-delivered-1']?.audio === 'assets/speech/carol/carol-q3-05.mp3');
|
||
check('carol-delivered-1: the clip ships on disk', existsSync(join(root, byId['carol-delivered-1']?.audio ?? '')));
|
||
check('carol-delivered-1: lands after the take-off settles (delayMs > 0)', (byId['carol-delivered-1']?.delayMs ?? 0) > 0);
|
||
check(
|
||
'carol-delivered-2: the trade sign-off, beat 2/4 (ack, deliveredLaunch, once, immediate)',
|
||
!!byId['carol-delivered-2'] &&
|
||
byId['carol-delivered-2'].from === 'carol' &&
|
||
byId['carol-delivered-2'].type === 'ack' &&
|
||
byId['carol-delivered-2'].trigger === 'deliveredLaunch' &&
|
||
byId['carol-delivered-2'].once === true &&
|
||
(byId['carol-delivered-2']?.delayMs ?? 0) === 0,
|
||
);
|
||
check(
|
||
'carol-delivered-2: the unlock brief (the brief, verbatim)',
|
||
byId['carol-delivered-2']?.text === 'All shops in the galaxy are now fully unlocked for you. Trade is an important an often lucrative way to make money in the galaxy.',
|
||
);
|
||
check('carol-delivered-2: speaks carol/q3-06.mp3', byId['carol-delivered-2']?.audio === 'assets/speech/carol/carol-q3-06.mp3');
|
||
check('carol-delivered-2: the clip ships on disk', existsSync(join(root, byId['carol-delivered-2']?.audio ?? '')));
|
||
check(
|
||
'carol-delivered-3: the risk/yield hint, beat 3/4 (ack, deliveredLaunch, once, immediate)',
|
||
!!byId['carol-delivered-3'] &&
|
||
byId['carol-delivered-3'].from === 'carol' &&
|
||
byId['carol-delivered-3'].type === 'ack' &&
|
||
byId['carol-delivered-3'].trigger === 'deliveredLaunch' &&
|
||
byId['carol-delivered-3'].once === true &&
|
||
(byId['carol-delivered-3']?.delayMs ?? 0) === 0,
|
||
);
|
||
check(
|
||
'carol-delivered-3: the nuance brief (the brief, verbatim)',
|
||
byId['carol-delivered-3']?.text === 'The higher the risk, the higher the rewards. Long routes and different planet types have different yields on products. I\'ll leave it to you to figure out the nuance.',
|
||
);
|
||
check('carol-delivered-3: speaks carol/q3-07.mp3', byId['carol-delivered-3']?.audio === 'assets/speech/carol/carol-q3-07.mp3');
|
||
check('carol-delivered-3: the clip ships on disk', existsSync(join(root, byId['carol-delivered-3']?.audio ?? '')));
|
||
check(
|
||
'oac-delivered-1: the buy/sell confirmation, beat 4/4 (ack, deliveredLaunch, oac, once, immediate)',
|
||
!!byId['oac-delivered-1'] &&
|
||
byId['oac-delivered-1'].from === 'oac' &&
|
||
byId['oac-delivered-1'].type === 'ack' &&
|
||
byId['oac-delivered-1'].trigger === 'deliveredLaunch' &&
|
||
byId['oac-delivered-1'].once === true &&
|
||
(byId['oac-delivered-1']?.delayMs ?? 0) === 0,
|
||
);
|
||
check(
|
||
'oac-delivered-1: the codes brief (the brief, verbatim)',
|
||
byId['oac-delivered-1']?.text === "I've confirmed. We have buy and sell codes on all worlds in the galaxy. This is a big deal. We can make real credits now.",
|
||
);
|
||
check('oac-delivered-1: speaks oac/q3-01.mp3', byId['oac-delivered-1']?.audio === 'assets/speech/oac/oac-q3-01.mp3');
|
||
check('oac-delivered-1: the clip ships on disk', existsSync(join(root, byId['oac-delivered-1']?.audio ?? '')));
|
||
|
||
// The 'Pirate Problems' payoff — the moment the quest becomes COMPLETE
|
||
// + UNCLAIMED (all its checks standing, the reward claimable),
|
||
// Trestle's two-beat sign-off on the run's pirate arc: the job-board
|
||
// unlock (patrols, bounty hunts, escorts) + the level-5 hook. Armed by
|
||
// GameScene._questRefresh's once-per-quest READY pass (the same moment
|
||
// the QUEST READY toast + chime fire); the comms' once-ledger keeps the
|
||
// pair a one-shot (a loaded save hears the unplayed beats).
|
||
check(
|
||
'trestle-q4-4: the payoff beat 1/2 (ack, trestleQ4Payoff, trestle, once)',
|
||
!!byId['trestle-q4-4'] &&
|
||
byId['trestle-q4-4'].from === 'trestle' &&
|
||
byId['trestle-q4-4'].type === 'ack' &&
|
||
byId['trestle-q4-4'].trigger === 'trestleQ4Payoff' &&
|
||
byId['trestle-q4-4'].once === true,
|
||
);
|
||
check(
|
||
'trestle-q4-4: the job-board brief (the brief, verbatim)',
|
||
byId['trestle-q4-4']?.text === 'Not bad kid. You made quick work of those pirates. There are many more where those came from. I\'m unlocking the job boards for you. From patrols to bounty hunts and escorts, it will get you the exposure you need to become a threat to be reckoned with.',
|
||
);
|
||
check('trestle-q4-4: speaks trestle-q4-04.mp3', byId['trestle-q4-4']?.audio === 'assets/speech/trestle/trestle-q4-04.mp3');
|
||
check('trestle-q4-4: the clip ships on disk', existsSync(join(root, byId['trestle-q4-4']?.audio ?? '')));
|
||
check('trestle-q4-4: lands after the READY toast settles (delayMs > 0)', (byId['trestle-q4-4']?.delayMs ?? 0) > 0);
|
||
check(
|
||
'trestle-q4-5: the payoff beat 2/2 (ack, trestleQ4Payoff, trestle, once, immediate)',
|
||
!!byId['trestle-q4-5'] &&
|
||
byId['trestle-q4-5'].from === 'trestle' &&
|
||
byId['trestle-q4-5'].type === 'ack' &&
|
||
byId['trestle-q4-5'].trigger === 'trestleQ4Payoff' &&
|
||
byId['trestle-q4-5'].once === true &&
|
||
(byId['trestle-q4-5']?.delayMs ?? 0) === 0,
|
||
);
|
||
check(
|
||
'trestle-q4-5: the level-5 brief (the brief, verbatim)',
|
||
byId['trestle-q4-5']?.text === 'Take some of these jobs on to help fight the pirate threat in the neutral star systems, and run a few cargo jobs. Earn enough experience to reach level 5 and I\'ll reach back out to you. We\'ll pick a class for your ship and talk about the factions controlling the rest of the galaxy then. Good luck!',
|
||
);
|
||
check('trestle-q4-5: speaks trestle-q4-05.mp3', byId['trestle-q4-5']?.audio === 'assets/speech/trestle/trestle-q4-05.mp3');
|
||
check('trestle-q4-5: the clip ships on disk', existsSync(join(root, byId['trestle-q4-5']?.audio ?? '')));
|
||
|
||
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('questNew arms the quest-grant voice', commsForTrigger(cfg, 'questNew').length === 1 && commsForTrigger(cfg, 'questNew')[0].id === 'oac-quest-new');
|
||
check('trestleBrief arms the three-brief sequence in config order (back-to-back)', JSON.stringify(commsForTrigger(cfg, 'trestleBrief').map((c) => c.id)) === JSON.stringify(['trestle-brief-1', 'trestle-brief-2', 'trestle-brief-3']));
|
||
check('trestleArrival arms the arrival pair in config order (back-to-back)', JSON.stringify(commsForTrigger(cfg, 'trestleArrival').map((c) => c.id)) === JSON.stringify(['trestle-arrive-1', 'trestle-arrive-2']));
|
||
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(
|
||
'deliveredLaunch arms the payoff chain in config order (Carol ×3 → OAC)',
|
||
JSON.stringify(commsForTrigger(cfg, 'deliveredLaunch').map((c) => c.id)) ===
|
||
JSON.stringify(['carol-delivered-1', 'carol-delivered-2', 'carol-delivered-3', 'oac-delivered-1']),
|
||
);
|
||
check(
|
||
'trestleQ4Payoff arms the payoff pair in config order (back-to-back)',
|
||
JSON.stringify(commsForTrigger(cfg, 'trestleQ4Payoff').map((c) => c.id)) ===
|
||
JSON.stringify(['trestle-q4-4', 'trestle-q4-5']),
|
||
);
|
||
check(
|
||
'a played trestle-q4-4 does not re-arm its half (the other still does)',
|
||
JSON.stringify(commsForTrigger(cfg, 'trestleQ4Payoff', new Set(['trestle-q4-4'])).map((c) => c.id)) ===
|
||
JSON.stringify(['trestle-q4-5']),
|
||
);
|
||
check(
|
||
'a fully played payoff pair re-arms nothing (the one-shot)',
|
||
commsForTrigger(cfg, 'trestleQ4Payoff', new Set(['trestle-q4-4', 'trestle-q4-5'])).length === 0,
|
||
);
|
||
check(
|
||
'a played carol-delivered-1 does not re-arm (the rest of the chain still does)',
|
||
JSON.stringify(commsForTrigger(cfg, 'deliveredLaunch', new Set(['carol-delivered-1'])).map((c) => c.id)) ===
|
||
JSON.stringify(['carol-delivered-2', 'carol-delivered-3', 'oac-delivered-1']),
|
||
);
|
||
check(
|
||
'a fully played payoff chain re-arms nothing (the one-shot)',
|
||
commsForTrigger(cfg, 'deliveredLaunch', new Set(['carol-delivered-1', 'carol-delivered-2', 'carol-delivered-3', 'oac-delivered-1'])).length === 0,
|
||
);
|
||
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)',
|
||
['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 author’s 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 === roster.length && ghostList[0].id === 'oac-intro');
|
||
const dupList = listComms({ ...cfg, comms: [...cfg.comms, { ...cfg.comms[0] }] });
|
||
check('drop: duplicate ids (first wins)', dupList.length === roster.length && 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 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.');
|