Add system-complete comm and audioPool random clip support
- Introduce the oac-system-complete visual comm that fires on the 'systemComplete' trigger when a system's jumpgate research unlocks, arming before the generic researchComplete sign-off so the FIFO plays specific -> generic. - Support an optional audioPool list of speech clips in CommsModel; the hub picks one at random per arm so only the chosen clip is fetched and cached, with validation that audio comms need a clip or pool. - Wire GameScene to trigger systemComplete on activateGates when the system nav chart is complete. - Update comms tests for the new comm, pool normalization/asset rolling, roster counts, and add a tether-rules audit script plus station shop video references and lava assets.
This commit is contained in:
parent
c84e64c5b0
commit
6dcc340a3b
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
Binary file not shown.
Binary file not shown.
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"$comment": "COMMS — the characters of the ship speaking to the player (OAC first). Three comm types: 'ack' pauses the action and holds the box open (muted looping video + text + voice) until the player clicks ACKNOWLEDGE; 'visual' shows the box (video plays once + text + voice) and fades out when the voice ends; 'audio' is voice only — no box, no pause. Comms are EVENTS: each one binds a trigger (when) + a sender (who) + text + a speech clip (what). Trigger vocabulary (live): 'newGame' — the fresh-run tutorial welcome (oac-intro → oac-tether → oac-scan; the hub's FIFO plays each right after the previous is acknowledged); 'firstScan' — oac-upgrade reacts to the player's first SCAN press (GameScene.startScan), 5 s after the click; 'tetherResearch' — oac-minerals → oac-fly-mine react to the player committing to the Tether Level 2 research (GameScene.beginResearch, held by the busy() contract until the console closes); 'firstCluster' — oac-first-cluster celebrates the run's first asteroid-cluster discovery (GameScene.celebrateDiscovery), 1.5 s after the chime so the discovery SFX lands first; 'minerals200' — oac-tether2 confirms the moment the run's lifetime mining first crosses 200 (the 'Mine 200 Minerals' quest line and the tether-l2 build cost; GameScene's ore event); 'tetherL2Launch' — oac-relaunch → oac-explore, the launch-back pair: SurfaceScene's take-off that puts the player in space after the home world's level-2 tether is installed (the run's build record is the witness); the hub's FIFO plays the pair back-to-back; 'firstWorld' — trestle-meetup → trestle-quest: the FIRST world the player discovers — the starter quest's own witness (discoveredWorldCount: any planet or space station, the home world never counts, in any system — the comm and the quest can never drift; GameScene.celebrateDiscovery, plus a scene-create arm for loaded saves that already hold discovered worlds), the hub's FIFO plays the pair back-to-back; 'researchComplete' — oac-research-complete, the AUDIO-ONLY sign-off on EVERY research completion (GameScene._completeResearch; once:false, held by the busy() contract while the research console is still open). Reserved for future comms: researchNew, questNew, questComplete, discovery, landing, jump, buildComplete. Assets are loaded ON DEMAND by the hub (js/comms/CommsHub.js) when a comm is due — never in a scene's preload, so the initial load stays lean.",
|
||||
"$comment": "COMMS — the characters of the ship speaking to the player (OAC first). Three comm types: 'ack' pauses the action and holds the box open (muted looping video + text + voice) until the player clicks ACKNOWLEDGE; 'visual' shows the box (video plays once + text + voice) and fades out when the voice ends; 'audio' is voice only — no box, no pause. Comms are EVENTS: each one binds a trigger (when) + a sender (who) + text + a speech clip (what). Trigger vocabulary (live): 'newGame' — the fresh-run tutorial welcome (oac-intro → oac-tether → oac-scan; the hub's FIFO plays each right after the previous is acknowledged); 'firstScan' — oac-upgrade reacts to the player's first SCAN press (GameScene.startScan), 5 s after the click; 'tetherResearch' — oac-minerals → oac-fly-mine react to the player committing to the Tether Level 2 research (GameScene.beginResearch, held by the busy() contract until the console closes); 'firstCluster' — oac-first-cluster celebrates the run's first asteroid-cluster discovery (GameScene.celebrateDiscovery), 1.5 s after the chime so the discovery SFX lands first; 'minerals200' — oac-tether2 confirms the moment the run's lifetime mining first crosses 200 (the 'Mine 200 Minerals' quest line and the tether-l2 build cost; GameScene's ore event); 'tetherL2Launch' — oac-relaunch → oac-explore, the launch-back pair: SurfaceScene's take-off that puts the player in space after the home world's level-2 tether is installed (the run's build record is the witness); the hub's FIFO plays the pair back-to-back; 'firstWorld' — trestle-meetup → trestle-quest: the FIRST world the player discovers — the starter quest's own witness (discoveredWorldCount: any planet or space station, the home world never counts, in any system — the comm and the quest can never drift; GameScene.celebrateDiscovery, plus a scene-create arm for loaded saves that already hold discovered worlds), the hub's FIFO plays the pair back-to-back; 'researchComplete' — oac-research-complete, the AUDIO-ONLY sign-off on EVERY research completion (GameScene._completeResearch; once:false, held by the busy() contract while the research console is still open); 'systemComplete' — oac-system-complete, the system-complete milestone: the moment a system's 'Unlock Jumpgates' research is unlocked (GameScene._applyResearchEffects, the activateGates effect) — that research is only researchable once EVERY NAV point of the system is discovered (the SYSTEM tree's available hook), so the full chart and the jumpgate unlock land together, once per system (once:false re-arms per system). It arms BEFORE oac-research-complete on the same completion, so the FIFO plays the specific milestone first. Reserved for future comms: researchNew, questNew, questComplete, discovery, landing, jump, buildComplete. Assets are loaded ON DEMAND by the hub (js/comms/CommsHub.js) when a comm is due — never in a scene's preload, so the initial load stays lean. A comm's `audioPool` (a list of speech files) speaks one clip at RANDOM per arm — the pick is made when the deferred-load spec is built (js/comms/CommsModel.js → commAssets), so only the chosen clip is fetched.",
|
||||
"enabled": true,
|
||||
"audio": {
|
||||
"$comment": "The comm's voice plays at its OWN volume (volume) — the manager's master is left untouched (in v4 `sound.setVolume` IS the master, so ducking it would quiet the voice too). While the voice talks, the other playing sounds (the game music) are ducked PER-SOUND by musicDuck (a fraction of each one's own volume) and restored exactly when the voice ends.",
|
||||
|
|
@ -104,6 +104,21 @@
|
|||
"once": false,
|
||||
"delayMs": 1500
|
||||
},
|
||||
{
|
||||
"$comment": "The system-complete milestone (trigger 'systemComplete' — GameScene._applyResearchEffects, the activateGates effect: the moment the system's 'Unlock Jumpgates' research is unlocked). That research was only researchable while EVERY NAV point of the system — planets, space stations, jump gates — was discovered (the SYSTEM tree's available hook, js/research/SystemCategory.js), so the chart-complete + jumpgate-unlock conditions hold together, and the node unlocks permanently — the comm plays once per system (once:false just lets the NEXT system re-arm it). type 'visual': OAC's box READS the milestone line and the voice rides with it, no pause — a passing sign-off, held by the busy() contract until the research console (or any modal/action) clears. Arming happens BEFORE the generic 'researchComplete' sign-off on the same completion, so the FIFO plays specific → generic. delayMs beats the completion chime (1.5 s, sfx.discovery). audioPool: the voice rolls ONE of the three discovery-complete clips at random per arm (the hub's deferred load fetches only the chosen file).",
|
||||
"id": "oac-system-complete",
|
||||
"from": "oac",
|
||||
"type": "visual",
|
||||
"trigger": "systemComplete",
|
||||
"text": "System Discovery Complete. Jumpgate Research Unlocked",
|
||||
"audioPool": [
|
||||
"assets/speech/oac/discovery-complete-01.mp3",
|
||||
"assets/speech/oac/discovery-complete-02.mp3",
|
||||
"assets/speech/oac/discovery-complete-03.mp3"
|
||||
],
|
||||
"once": false,
|
||||
"delayMs": 1500
|
||||
},
|
||||
{
|
||||
"$comment": "The 200-mineral threshold (trigger 'minerals200' — GameScene's ore event when the run's lifetime mining first crosses 200 — the 'Mine 200 Minerals' quest line and the tether-l2 build cost, data/builds.json). OAC confirms the build can be afforded and sends the player home to build it. The 1.5 s beat lets the HUD/quest beat land before the voice.",
|
||||
"id": "oac-tether2",
|
||||
|
|
|
|||
|
|
@ -69,8 +69,8 @@
|
|||
],
|
||||
|
||||
"stationVideos": [
|
||||
{ "land": "ss-land-01.mp4", "surface": "ss-surface-01.mp4", "takeoff": "ss-takeoff-01.mp4", "shop": null },
|
||||
{ "land": "ss-land-02.mp4", "surface": "ss-surface-02.mp4", "takeoff": "ss-takeoff-02.mp4", "shop": null },
|
||||
{ "land": "ss-land-03.mp4", "surface": "ss-surface-03.mp4", "takeoff": "ss-takeoff-03.mp4", "shop": null }
|
||||
{ "land": "ss-land-01.mp4", "surface": "ss-surface-01.mp4", "takeoff": "ss-takeoff-01.mp4", "shop": "ss-shop-01.mp4" },
|
||||
{ "land": "ss-land-02.mp4", "surface": "ss-surface-02.mp4", "takeoff": "ss-takeoff-02.mp4", "shop": "ss-shop-02.mp4" },
|
||||
{ "land": "ss-land-03.mp4", "surface": "ss-surface-03.mp4", "takeoff": "ss-takeoff-03.mp4", "shop": "ss-shop-03.mp4" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
* 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 /
|
||||
|
|
@ -24,7 +25,7 @@
|
|||
import { pathToFileURL } from 'node:url';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
|
|
@ -72,7 +73,7 @@ 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 === 13);
|
||||
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(
|
||||
|
|
@ -163,6 +164,31 @@ check(
|
|||
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)',
|
||||
|
|
@ -231,6 +257,7 @@ check(
|
|||
);
|
||||
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']));
|
||||
|
|
@ -258,6 +285,18 @@ 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)
|
||||
|
|
@ -281,15 +320,20 @@ check('keep: an audio comm with no text (voice only)', (() => {
|
|||
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 === 13 && ghostList[0].id === 'oac-intro');
|
||||
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 === 13 && dupList[0].id === 'oac-intro');
|
||||
check('drop: duplicate ids (first wins)', dupList.length === 14 && dupList[0].id === 'oac-intro');
|
||||
warnOn = true;
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
|
@ -305,6 +349,7 @@ check('a played oac-minerals does not re-arm its half (the other still does)', J
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* One-off audit: do generated galaxies obey the two tether rules?
|
||||
* Rule 1: every jump gate within 1 tether region (level1Radius) of a
|
||||
* planet or space station — unless the system is a dead end
|
||||
* (a barren system: no planets/stations, single gate).
|
||||
* Rule 2: every planet & space station within 2 tether lengths
|
||||
* (2 × level1Radius) of at least one other planet/station —
|
||||
* unless the system holds exactly one such object.
|
||||
*/
|
||||
process.env.NODE_ENV = 'dev';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
|
||||
const fs = await import('node:fs');
|
||||
const dataDir = join(__dirname, '../data');
|
||||
const configData = {};
|
||||
for (const f of fs.readdirSync(dataDir)) {
|
||||
if (!f.endsWith('.json') || f === 'manifest.json') continue;
|
||||
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
|
||||
}
|
||||
config.init(configData);
|
||||
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
|
||||
|
||||
const T1 = config.get('tether.level1Radius', 5120);
|
||||
const T2 = T1 * 2;
|
||||
const EPS = 1;
|
||||
|
||||
for (const seed of ['audit-a', 'audit-b', 'audit-c']) {
|
||||
const g = Galaxy.create(seed);
|
||||
g.generateAll();
|
||||
let r1Bad = 0, r1Checked = 0;
|
||||
let r2Bad = 0, r2Checked = 0;
|
||||
const r1Examples = [];
|
||||
const r2ByN = new Map(); // N → [checked, bad]
|
||||
for (const rec of g.records) {
|
||||
const c = g.contentOf(rec.id);
|
||||
const isHome = rec.id === g.homeSystemId;
|
||||
const objects = [...c.planets.map((p) => ({ x: p.x, y: p.y }))]
|
||||
.concat(c.settlements.filter((s) => s.anchor?.type === 'space').map((s) => ({ x: s.x, y: s.y })));
|
||||
if (isHome) objects.push({ x: 0, y: 0 }); // the home world
|
||||
const isBarren = objects.length === 0;
|
||||
// Rule 1
|
||||
for (const j of c.jumps) {
|
||||
r1Checked++;
|
||||
if (isBarren) continue;
|
||||
let best = Infinity;
|
||||
for (const o of objects) best = Math.min(best, Math.hypot(j.x - o.x, j.y - o.y));
|
||||
if (best > T1 + EPS) {
|
||||
r1Bad++;
|
||||
if (r1Examples.length < 3) r1Examples.push(`${rec.id} gate→${j.to} d=${Math.round(best)}`);
|
||||
}
|
||||
}
|
||||
// Rule 2
|
||||
if (objects.length >= 2) {
|
||||
for (const o of objects) {
|
||||
r2Checked++;
|
||||
let best = Infinity;
|
||||
for (const q of objects) {
|
||||
if (q === o) continue;
|
||||
best = Math.min(best, Math.hypot(o.x - q.x, o.y - q.y));
|
||||
}
|
||||
const [cc, bb] = r2ByN.get(objects.length) ?? [0, 0];
|
||||
r2ByN.set(objects.length, [cc + 1, bb + (best > T2 + EPS ? 1 : 0)]);
|
||||
if (best > T2 + EPS) r2Bad++;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`\nseed="${seed}" systems=${g.records.length} (tether L1=${T1}px, 2×L1=${T2}px)`);
|
||||
console.log(` Rule 1 (gates ≤ ${T1}px from a planet/station, barren exempt): ${r1Checked - r1Bad}/${r1Checked} OK${r1Bad ? ' — BAD: ' + r1Examples.join(', ') : ''}`);
|
||||
const byN = [...r2ByN.entries()].map(([n, [cc, bb]]) => `${n}-obj ${cc - bb}/${cc}`).join(', ');
|
||||
console.log(` Rule 2 (every object ≤ ${T2}px from another, N=1 exempt): ${r2Checked - r2Bad}/${r2Checked} OK — ${byN}`);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
* COMMS MODEL — the pure, Node-testable rules of data/comms.json.
|
||||
*
|
||||
* A comm is an EVENT with a sender: { id, from, type, trigger, text,
|
||||
* audio, once, delayMs }. This module normalizes the config (dropping —
|
||||
* audio, audioPool, once, delayMs }. This module normalizes the config (dropping —
|
||||
* with a console warning — anything that can't be shown) and answers
|
||||
* the two questions the hub asks:
|
||||
*
|
||||
|
|
@ -75,8 +75,13 @@ export function charEntry(cfg, from) {
|
|||
* - from: must resolve to a character (the box needs its face);
|
||||
* - text: required for ack|visual (a box with no words is noise) —
|
||||
* optional for 'audio' (voice only);
|
||||
* - audio: required for 'audio' (voice is the whole comm) — optional
|
||||
* for ack|visual (a text-only comm is a valid comm);
|
||||
* - audio: a single speech clip — optional for ack|visual (a text-only
|
||||
* comm is a valid comm); for 'audio' it satisfies the voice (or the
|
||||
* pool below does);
|
||||
* - audioPool: an optional LIST of speech clips — the comm speaks one
|
||||
* of them at RANDOM per arm (the pick happens in commAssets, when the
|
||||
* deferred-load spec is built, so only the chosen clip is fetched and
|
||||
* cached); for 'audio' comms it is an alternative to `audio`;
|
||||
* - once defaults true (comms don't repeat unless the author says so);
|
||||
* - delayMs defaults 0 (clamped to >= 0).
|
||||
*/
|
||||
|
|
@ -97,12 +102,15 @@ export function normalizeComm(raw) {
|
|||
const from = String(raw.from ?? '').trim();
|
||||
const text = String(raw.text ?? '').trim();
|
||||
const audio = String(raw.audio ?? '').trim();
|
||||
const pool = Array.isArray(raw.audioPool)
|
||||
? [...new Set(raw.audioPool.map((f) => String(f ?? '').trim()).filter(Boolean))]
|
||||
: [];
|
||||
if (!text && type !== 'audio') {
|
||||
console.warn(`[comms] "${id}" (${type}) has no text — dropped`);
|
||||
return null;
|
||||
}
|
||||
if (!audio && type === 'audio') {
|
||||
console.warn(`[comms] "${id}" (audio) has no speech clip — dropped`);
|
||||
if (!audio && pool.length === 0 && type === 'audio') {
|
||||
console.warn(`[comms] "${id}" (audio) has no speech clip (audio or audioPool) — dropped`);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
|
|
@ -112,6 +120,7 @@ export function normalizeComm(raw) {
|
|||
trigger,
|
||||
text,
|
||||
audio: audio || null,
|
||||
audioPool: pool.length ? pool : null,
|
||||
once: raw.once !== false,
|
||||
delayMs: Math.max(0, Number(raw.delayMs) || 0),
|
||||
};
|
||||
|
|
@ -146,14 +155,20 @@ export function listComms(cfg) {
|
|||
/**
|
||||
* The comm's deferred-load spec. The character clip is keyed BY CHARACTER
|
||||
* (every OAC comm shares one 185 KB clip); the speech clip BY FILE (a clip
|
||||
* reused across comms loads once).
|
||||
* reused across comms loads once). A comm with an audioPool speaks ONE
|
||||
* pool file at RANDOM — the pick is made HERE, at arm time, because the
|
||||
* deferred load is keyed to the file (only the chosen clip is fetched and
|
||||
* cached; the next arm rolls again).
|
||||
*
|
||||
* @returns {{video: {key:string,url:string}|null, audio: {key:string,url:string}|null}}
|
||||
*/
|
||||
export function commAssets(cfg, comm) {
|
||||
const entry = charEntry(cfg, comm.from);
|
||||
const videoUrl = String(entry?.video ?? '').trim();
|
||||
const audioUrl = String(comm.audio ?? '').trim();
|
||||
const pool = Array.isArray(comm.audioPool) ? comm.audioPool : [];
|
||||
const audioUrl = String(
|
||||
pool.length ? pool[(Math.random() * pool.length) | 0] : (comm.audio ?? ''),
|
||||
).trim();
|
||||
return {
|
||||
video: videoUrl ? { key: videoKeyFor(comm.from), url: videoUrl } : null,
|
||||
audio: audioUrl ? { key: audioKeyFor(audioUrl), url: audioUrl } : null,
|
||||
|
|
|
|||
|
|
@ -4076,6 +4076,19 @@ export class GameScene extends Phaser.Scene {
|
|||
// `id` (passed in) is the source of truth — NOT `node.id`.
|
||||
const sysId = systemIdOfGatesNode(id);
|
||||
if (sysId) this._activateSystemJumpgates(sysId);
|
||||
// The comms beat (data/comms.json: systemComplete —
|
||||
// oac-system-complete): the milestone — EVERY planet, space station,
|
||||
// and jump gate of the system is discovered AND its jumpgate
|
||||
// research is now unlocked. The gates research was only researchable
|
||||
// while that full chart held (the SYSTEM tree's available hook), so
|
||||
// both conditions land together exactly here — once per system.
|
||||
// OAC's box reads the milestone; the voice rolls one of the
|
||||
// discovery-complete clips at random (audioPool). Arming it here,
|
||||
// before the 'researchComplete' sign-off _completeResearch queues
|
||||
// next, keeps the FIFO order specific → generic.
|
||||
if (sysId && this.isSystemNavComplete()) {
|
||||
this.comms?.trigger('systemComplete');
|
||||
}
|
||||
} else {
|
||||
console.warn(`[orbit] research: unknown effect ${type}`, spec, id);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue