Add OAC "new research" audio beat on research sign-off end
- Arm `oac-research-new` (trigger `researchNew`) from `_commEnded` when the `oac-research-complete` sign-off finishes and `researchAnyAvailable()` is true, so the hub FIFO plays it right behind the sign-off - Register the new audio-only comm in `data/comms.json` and move `researchNew` out of the reserved trigger list - Update roster count to 25 and add node checks for the beat's fields, clip presence, and zero delay in `dev/comms.test.mjs` - Add headless browser harness (`dev/new-research-shot.html`/`.mjs`) that verifies the seam, gating (in-flight vs available), and FIFO ordering through the real `onEnd` hook - Ship `assets/videos/arid-shop-03.mp4`
This commit is contained in:
parent
1f39c25c2f
commit
58aab347c2
Binary file not shown.
File diff suppressed because one or more lines are too long
|
|
@ -10,7 +10,9 @@
|
|||
* 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-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,
|
||||
|
|
@ -85,7 +87,7 @@ check('Carol is registered (label + role + her own clip)', !!carol && carol.labe
|
|||
check('Carol\'s space clip ships on disk', existsSync(join(root, 'assets/videos/characters/carol.mp4')));
|
||||
|
||||
const roster = listComms(cfg);
|
||||
check('the roster holds exactly the shipped comms', roster.length === 24);
|
||||
check('the roster holds exactly the shipped comms', roster.length === 25);
|
||||
const byId = Object.fromEntries(roster.map((c) => [c.id, c]));
|
||||
const intro = byId['oac-intro'];
|
||||
check(
|
||||
|
|
@ -176,6 +178,22 @@ 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 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(
|
||||
|
|
@ -444,7 +462,7 @@ check(
|
|||
|
||||
check(
|
||||
'the reserved triggers are reserved (no comm uses them yet)',
|
||||
['researchNew', 'questComplete', 'discovery', 'landing', 'jump', 'buildComplete'].every(
|
||||
['questComplete', 'discovery', 'landing', 'jump', 'buildComplete'].every(
|
||||
(t) => !roster.some((c) => c.trigger === t),
|
||||
),
|
||||
);
|
||||
|
|
@ -506,9 +524,9 @@ 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 === 24 && ghostList[0].id === 'oac-intro');
|
||||
check('drop: unknown sender (the roster keeps the rest)', ghostList.length === 25 && ghostList[0].id === 'oac-intro');
|
||||
const dupList = listComms({ ...cfg, comms: [...cfg.comms, { ...cfg.comms[0] }] });
|
||||
check('drop: duplicate ids (first wins)', dupList.length === 24 && dupList[0].id === 'oac-intro');
|
||||
check('drop: duplicate ids (first wins)', dupList.length === 25 && dupList[0].id === 'oac-intro');
|
||||
warnOn = true;
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<base href="../" />
|
||||
<title>Orbit — new-research beat (dev verify)</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
|
||||
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
</style>
|
||||
<script src="lib/phaser.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="game"></div>
|
||||
<script type="module" src="dev/new-research-shot.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
/**
|
||||
* Dev-only: boot GameScene and VERIFY the NEW-RESEARCH beat end-to-end
|
||||
* in a real browser (the node tests pin the roster contract,
|
||||
* dev/comms.test.mjs):
|
||||
*
|
||||
* oac-research-complete (the audio-only sign-off on EVERY research
|
||||
* completion) finishes speaking → the hub's onEnd →
|
||||
* GameScene._commEnded: if a tech is available to research that isn't
|
||||
* yet (researchAnyAvailable — nothing in flight, a node whose requires
|
||||
* are met), OAC's 'new research' line (oac-research-new →
|
||||
* assets/speech/oac/new-research.mp3) is armed and the hub's FIFO
|
||||
* plays it right behind the sign-off — the same beat the shimmer cue
|
||||
* lights, as voice.
|
||||
*
|
||||
* Checks (deterministic — the deferred voice load rides the hub's 15 s
|
||||
* stall valve in headless, so the harness drives the hub's own end
|
||||
* sequence instead of waiting for the voice):
|
||||
* 1. roster: oac-research-new is OAC's audio-only 'researchNew' comm
|
||||
* (once:false — every sign-off end can re-arm it) speaking
|
||||
* new-research.mp3, and the clip ships on disk;
|
||||
* 2. the scene wires the hub's onEnd to _commEnded (the seam this
|
||||
* whole beat rides);
|
||||
* 3. the decision, through the REAL onEnd hook with the hub's
|
||||
* trigger() spied:
|
||||
* - in flight → nothing can start → NOT armed (the silence is the
|
||||
* answer);
|
||||
* - nothing in flight + a tech ready → armed exactly once;
|
||||
* - another comm's end (and a null def) → NOT armed;
|
||||
* 4. the FIFO: the armed beat sits BEHIND the sign-off in the hub's
|
||||
* queue (it plays only once the sign-off has spoken), and its
|
||||
* deferred asset spec resolves to the clip.
|
||||
*
|
||||
* Run:
|
||||
* node dev/server.mjs 8091
|
||||
* <chrome> --headless --disable-gpu --no-sandbox --remote-debugging-port=9333 about:blank
|
||||
* node dev/cdp-shot.mjs http://127.0.0.1:8091/dev/new-research-shot.html \
|
||||
* /tmp/new-research.png "window.__NEW_RESEARCH_SHOT && window.__NEW_RESEARCH_SHOT.ready" 90000
|
||||
*/
|
||||
import Phaser from '../js/vendor/phaser.js';
|
||||
import { config } from '../js/config/Config.js';
|
||||
import { ConfigLoader } from '../js/config/ConfigLoader.js';
|
||||
import { createGameConfig } from '../js/config/GameConfig.js';
|
||||
import { GameScene } from '../js/scenes/GameScene.js';
|
||||
|
||||
const data = await ConfigLoader.load();
|
||||
config.init(data);
|
||||
|
||||
const errors = [];
|
||||
const origErr = console.error.bind(console);
|
||||
console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); };
|
||||
window.addEventListener('error', (e) => errors.push(String(e.message)));
|
||||
window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`));
|
||||
|
||||
const gameConfig = createGameConfig();
|
||||
gameConfig.scene = [GameScene];
|
||||
const game = new Phaser.Game(gameConfig);
|
||||
window.game = game;
|
||||
|
||||
const report = document.createElement('pre');
|
||||
report.id = 'report';
|
||||
report.style.cssText = 'position:fixed;left:10px;top:10px;z-index:9999;max-width:75%;margin:0;padding:8px 12px;font:13px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.92);border:1px solid #1b3a5a;white-space:pre-wrap;';
|
||||
document.body.appendChild(report);
|
||||
const setReport = (lines) => { report.textContent = (Array.isArray(lines) ? lines : [lines]).join('\n'); };
|
||||
setReport('booting…');
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function waitScene() {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const s = game.scene.getScene('GameScene');
|
||||
if (s && s.ship && s.researchState) return s;
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error('GameScene never booted');
|
||||
}
|
||||
|
||||
const SIGN_OFF = { id: 'oac-research-complete' }; // the onEnd's def carries the id — that is all the seam keys on
|
||||
|
||||
try {
|
||||
const s = await waitScene();
|
||||
await sleep(900); // let the boot settle
|
||||
const lines = [];
|
||||
const check = (label, cond) => lines.push(`${cond ? '✔' : '✘ FAIL'} ${label}`);
|
||||
const h = s.comms;
|
||||
const st = s.researchState;
|
||||
|
||||
// ---- 1. the roster contract, as the live scene sees it -------------
|
||||
const roster = config.get('comms.comms') ?? [];
|
||||
const signOff = roster.find((c) => c.id === 'oac-research-complete');
|
||||
check('roster: the sign-off — oac-research-complete (audio-only, researchComplete, once:false)',
|
||||
!!signOff && signOff.from === 'oac' && signOff.type === 'audio' &&
|
||||
signOff.trigger === 'researchComplete' && signOff.once === false);
|
||||
const beat = roster.find((c) => c.id === 'oac-research-new');
|
||||
check('roster: oac-research-new — OAC, audio-only, trigger researchNew, once:false',
|
||||
!!beat && beat.from === 'oac' && beat.type === 'audio' && beat.trigger === 'researchNew' && beat.once === false);
|
||||
check('roster: it speaks assets/speech/oac/new-research.mp3 (delayMs 0 — right behind the sign-off)',
|
||||
beat?.audio === 'assets/speech/oac/new-research.mp3' && (beat?.delayMs ?? 0) === 0);
|
||||
const clip = await fetch('assets/speech/oac/new-research.mp3', { method: 'HEAD' }).catch(() => null);
|
||||
check('roster: the clip ships (HTTP 200 on the dev server)', clip?.status === 200);
|
||||
check('roster: a tech can start on a fresh run (the beat has a moment to arm)',
|
||||
s.researchAnyAvailable() === true);
|
||||
|
||||
// ---- 2. the seam: the hub's onEnd is the scene's _commEnded --------
|
||||
check('seam: the hub\'s onEnd is the scene\'s _commEnded (the beat rides it)',
|
||||
typeof h.onEnd === 'function');
|
||||
|
||||
// ---- 3..4. the decision + the FIFO, through the real onEnd --------
|
||||
// One synchronous block (no awaits — the hub's pump is frame-driven and
|
||||
// must not run between the arms and the checks).
|
||||
const arms = [];
|
||||
const realTrigger = h.trigger.bind(h);
|
||||
h.trigger = (name) => { arms.push(name); return realTrigger(name); };
|
||||
|
||||
// Release the intro hold the player would (ACK the one in play, drop the
|
||||
// rest of the intro chain) so the hub is idle and our arms are first.
|
||||
for (const e of h.queue) if (e.def.id) h.played.add(e.def.id);
|
||||
h.queue.length = 0;
|
||||
if (h.current) h._finish(); // oac-intro's onEnd — a no-op for the beat
|
||||
check('gate: the hub is idle (the intro hold is released)', h.current === null && h.queue.length === 0);
|
||||
|
||||
// Arm the sign-off the scene would (a completion just happened) — it is
|
||||
// due 1.5 s out and rides the deferred voice load (the 15 s valve in
|
||||
// headless), so the harness drives the hub's own END sequence instead
|
||||
// of waiting for the voice.
|
||||
h.trigger('researchComplete');
|
||||
check('fifo: the sign-off is armed on the hub (queued)', h.queue.some((e) => e.def.id === 'oac-research-complete'));
|
||||
|
||||
// (3a) IN FLIGHT — nothing can start → NOT armed.
|
||||
s.beginResearch('exploration', 'tether_l2'); // a project runs (maxConcurrent 1)
|
||||
check('gate: in flight — researchAnyAvailable rests (no beat to arm)', s.researchAnyAvailable() === false);
|
||||
const beforeInflight = arms.length;
|
||||
h.onEnd(SIGN_OFF); // the hub's onEnd — exactly what _finish calls for the sign-off
|
||||
check('gate: in flight → the sign-off end arms nothing', arms.length === beforeInflight);
|
||||
|
||||
// (3b) NOTHING IN FLIGHT + A TECH READY → armed exactly once.
|
||||
st.active = null; // dev poke — the same one the cue harness uses
|
||||
check('gate: run cleared — the beat is armed again (the completion opened it)', s.researchAnyAvailable() === true);
|
||||
h.onEnd(SIGN_OFF);
|
||||
check('arm: available beat → the sign-off end arms "researchNew" exactly once',
|
||||
arms.filter((n) => n === 'researchNew').length === 1);
|
||||
|
||||
// (3c) another comm's end → NOT armed (only the sign-off answers).
|
||||
const beforeOther = arms.length;
|
||||
h.onEnd({ id: 'oac-first-cluster' });
|
||||
h.onEnd(null);
|
||||
check('arm: a different comm\'s end (and a null def) → no beat', arms.length === beforeOther);
|
||||
|
||||
// (4) the FIFO: the beat sits BEHIND the sign-off (anything the commit
|
||||
// itself queued — e.g. the tetherResearch pair — may sit between)
|
||||
// and its deferred asset spec resolves to the clip.
|
||||
const idxSignOff = h.queue.findIndex((e) => e.def.id === 'oac-research-complete');
|
||||
const idxBeat = h.queue.findIndex((e) => e.def.id === 'oac-research-new');
|
||||
check('fifo: the beat queues BEHIND the sign-off (it plays only once the sign-off has spoken)',
|
||||
idxSignOff >= 0 && idxBeat > idxSignOff);
|
||||
const spec = h.queue[idxBeat]?.assets?.audio;
|
||||
check('fifo: the beat\'s deferred spec resolves to the clip', spec?.url === 'assets/speech/oac/new-research.mp3');
|
||||
|
||||
h.trigger = realTrigger;
|
||||
window.__NEW_RESEARCH_SHOT = { ready: true, lines, errors };
|
||||
setReport(lines);
|
||||
console.info('new-research-shot: report painted');
|
||||
} catch (err) {
|
||||
errors.push(`FATAL: ${err.message}`);
|
||||
setReport(['FATAL: ' + err.message, ...errors.slice(0, 3)]);
|
||||
window.__NEW_RESEARCH_SHOT = { ready: true, fatal: String(err.message), errors };
|
||||
console.error('new-research-shot: fatal', err);
|
||||
}
|
||||
|
|
@ -4223,7 +4223,15 @@ export class GameScene extends Phaser.Scene {
|
|||
|
||||
/**
|
||||
* A comm ENDED (the CommsHub's onEnd hook — for 'ack' comms this is
|
||||
* the ACKNOWLEDGE press): the scene's ack-side bookkeeping.
|
||||
* the ACKNOWLEDGE press, for 'audio'/'visual' comms the moment the
|
||||
* voice finishes): the scene's end-side bookkeeping.
|
||||
*
|
||||
* THE RESEARCH-COMPLETE SIGN-OFF (data/comms.json:
|
||||
* oac-research-complete, audio-only — onEnd fires the moment the voice
|
||||
* finishes): a completed tech may have OPENED a new one; if any category
|
||||
* still holds a ready-to-research tech (researchAnyAvailable), OAC's
|
||||
* 'new research' line answers the sign-off (oac-research-new — the
|
||||
* hub's FIFO plays it right behind; see the in-line note).
|
||||
*
|
||||
* TRESTLE'S LAST BRIEF (data/comms.json: trestle-brief-3) — he is
|
||||
* "in the system right next to you in this star cluster's capital" and
|
||||
|
|
@ -4238,6 +4246,24 @@ export class GameScene extends Phaser.Scene {
|
|||
* reaches the capital world (GameScene._checkDestinationReached).
|
||||
*/
|
||||
_commEnded(def) {
|
||||
// THE RESEARCH-COMPLETE SIGN-OFF (data/comms.json: oac-research-complete,
|
||||
// audio-only — onEnd fires the moment the voice finishes): the moment
|
||||
// it has spoken, a completed tech may have OPENED a new one. If any
|
||||
// category still holds a ready-to-research tech (researchAnyAvailable
|
||||
// — nothing in flight, a node whose requires are met, the SYSTEM
|
||||
// chart's gate included), arm OAC's 'new research' line
|
||||
// (oac-research-new, data/comms.json → trigger 'researchNew') — the
|
||||
// hub is idle again, so its FIFO starts it right now, and the voice
|
||||
// rides on the comm system (music ducked, the same contract as the
|
||||
// sign-off it answers). The same beat the shimmer cue lights
|
||||
// (GameScene._applyResearchCue / ResearchWindow._paintCues), as voice.
|
||||
// A leaf tech's completion opens nothing → the silence is the answer.
|
||||
if (def && def.id === 'oac-research-complete') {
|
||||
if (typeof this.researchAnyAvailable === 'function' && this.researchAnyAvailable()) {
|
||||
this.comms?.trigger('researchNew');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!def || def.id !== 'trestle-brief-3') return;
|
||||
const t = this.galaxy?.neutralCapitalTarget?.();
|
||||
if (!t) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue