/** * 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 * --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); }