diff --git a/assets/videos/arid-shop-03.mp4 b/assets/videos/arid-shop-03.mp4 new file mode 100644 index 0000000..f294c11 Binary files /dev/null and b/assets/videos/arid-shop-03.mp4 differ diff --git a/data/comms.json b/data/comms.json index 9c7d757..149cba0 100644 --- a/data/comms.json +++ b/data/comms.json @@ -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); 'systemComplete' — oac-system-complete, the system-complete milestone: the moment a system's NAV chart COMPLETES — its last planet/space station/jump-gate discovery (GameScene.celebrateDiscovery), which is exactly when its 'Unlock Jumpgates' research becomes AVAILABLE (the SYSTEM tree's available hook, js/research/SystemCategory.js) — NOT on the research's completion; once per system (once:false re-arms per system; the scene's arming set keeps it a single fire, so later cluster discoveries in a charted system don't re-arm it). 'questNew' — oac-quest-new: the quest-grant voice (GameScene._grantUnlockedQuests — the starter on the first boot, 'Leaving the Nest' on the starter's claim, every future unlock; once:false re-arms per grant); 'trestleBrief' — trestle-brief-1 → trestle-brief-2 → trestle-brief-3: the 'Leaving the Nest' quest's three back-to-back ACK briefs — armed the moment the home system's jumpgates go ONLINE (the activateGates effect in GameScene._applyResearchEffects — the player can now jump out of the nest; also the quest's grant, and scene create for loaded saves whose gates are already online — GameScene._trestleBeats). The hub's FIFO plays the sequence back-to-back (each 'ack' holds until its own ACKNOWLEDGE, so the 1→2→3 order is guaranteed); ALL THREE in the run's comms played-ledger (registry 'commsPlayed' — marked when a comm starts, saved with the run) are the quest's 'Talk to Trestle' witness (GameScene._questCheckEval), and a quit mid-sequence re-arms only the unplayed beats on the next load; 'trestleArrival' — trestle-arrive-1 → trestle-arrive-2: the 'Leaving the Nest' quest's ARRIVAL PAIR on the NEUTRAL CAPITAL WORLD (the destination trestle-brief-3's acknowledgment sends the player to) — the quest completes on the PLANET, not on entering the neighboring system: armed the moment the ship reaches the capital world (GameScene._checkDestinationReached), plus a scene-create arm in GameScene._trestleBeats for a loaded save parked at the capital's rim. The hub's FIFO plays the pair back-to-back (each 'ack' holds until its own ACKNOWLEDGE); BOTH in the run's comms played-ledger (registry 'commsPlayed' — marked when a comm starts, saved with the run) are the quest's 'Meet Trestle' witness (GameScene._questCheckEval). 'deliveredLaunch' — carol-delivered-1 → carol-delivered-2 → carol-delivered-3 → oac-delivered-1: the 'Special Delivery' quest's PAYOFF — armed the moment the player LAUNCHES after the package's home-world sale (GameScene.onSurfaceDeparture: the run's delivery witness, story.delivered, stands when the shop's CONFIRM sells it — data/goods.json → special-package) — Carol's thanks + the trade sign-off, then OAC's confirmation that buy/sell codes are live on every world (the four 'ack' beats chained by the hub's FIFO, each holding until its own ACKNOWLEDGE; the run's comms played-ledger keeps it a one-shot — a later re-arm is a no-op). Reserved for future comms: researchNew, 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.", + "$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), and oac-research-new — the 'new research available' beat that ANSWERS the sign-off: armed the moment the sign-off's voice has FINISHED (the hub's onEnd — GameScene._commEnded) and only if a tech is still ready to research that isn't yet (researchAnyAvailable — nothing in flight, a node whose requires are met); the hub's FIFO plays it right behind the sign-off, so the two voices never overlap; 'systemComplete' — oac-system-complete, the system-complete milestone: the moment a system's NAV chart COMPLETES — its last planet/space station/jump-gate discovery (GameScene.celebrateDiscovery), which is exactly when its 'Unlock Jumpgates' research becomes AVAILABLE (the SYSTEM tree's available hook, js/research/SystemCategory.js) — NOT on the research's completion; once per system (once:false re-arms per system; the scene's arming set keeps it a single fire, so later cluster discoveries in a charted system don't re-arm it). 'questNew' — oac-quest-new: the quest-grant voice (GameScene._grantUnlockedQuests — the starter on the first boot, 'Leaving the Nest' on the starter's claim, every future unlock; once:false re-arms per grant); 'trestleBrief' — trestle-brief-1 → trestle-brief-2 → trestle-brief-3: the 'Leaving the Nest' quest's three back-to-back ACK briefs — armed the moment the home system's jumpgates go ONLINE (the activateGates effect in GameScene._applyResearchEffects — the player can now jump out of the nest; also the quest's grant, and scene create for loaded saves whose gates are already online — GameScene._trestleBeats). The hub's FIFO plays the sequence back-to-back (each 'ack' holds until its own ACKNOWLEDGE, so the 1→2→3 order is guaranteed); ALL THREE in the run's comms played-ledger (registry 'commsPlayed' — marked when a comm starts, saved with the run) are the quest's 'Talk to Trestle' witness (GameScene._questCheckEval), and a quit mid-sequence re-arms only the unplayed beats on the next load; 'trestleArrival' — trestle-arrive-1 → trestle-arrive-2: the 'Leaving the Nest' quest's ARRIVAL PAIR on the NEUTRAL CAPITAL WORLD (the destination trestle-brief-3's acknowledgment sends the player to) — the quest completes on the PLANET, not on entering the neighboring system: armed the moment the ship reaches the capital world (GameScene._checkDestinationReached), plus a scene-create arm in GameScene._trestleBeats for a loaded save parked at the capital's rim. The hub's FIFO plays the pair back-to-back (each 'ack' holds until its own ACKNOWLEDGE); BOTH in the run's comms played-ledger (registry 'commsPlayed' — marked when a comm starts, saved with the run) are the quest's 'Meet Trestle' witness (GameScene._questCheckEval). 'deliveredLaunch' — carol-delivered-1 → carol-delivered-2 → carol-delivered-3 → oac-delivered-1: the 'Special Delivery' quest's PAYOFF — armed the moment the player LAUNCHES after the package's home-world sale (GameScene.onSurfaceDeparture: the run's delivery witness, story.delivered, stands when the shop's CONFIRM sells it — data/goods.json → special-package) — Carol's thanks + the trade sign-off, then OAC's confirmation that buy/sell codes are live on every world (the four 'ack' beats chained by the hub's FIFO, each holding until its own ACKNOWLEDGE; the run's comms played-ledger keeps it a one-shot — a later re-arm is a no-op). Reserved for future comms: 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.", @@ -120,6 +120,16 @@ "once": false, "delayMs": 1500 }, + { + "$comment": "The NEW-RESEARCH beat (trigger 'researchNew') — OAC's answer to its own sign-off: the moment oac-research-complete's voice has FINISHED (the hub's onEnd — GameScene._commEnded), if the completed tech OPENED one (or another is already ready) — researchAnyAvailable: nothing in flight, at least one node whose requires are met — this line plays right behind it. AUDIO ONLY: no box, no pause — the voice ducks the music, same as the sign-off. once:false — the played ledger never bars it (every sign-off end re-arms; the FIFO + the in-flight gate keep it honest: a completion that opens nothing stays silent, and a player who already started the next project hears no stale line). delayMs 0: the sign-off's voice is already done — the beat lands immediately (no chime to beat).", + "id": "oac-research-new", + "from": "oac", + "type": "audio", + "trigger": "researchNew", + "audio": "assets/speech/oac/new-research.mp3", + "once": false, + "delayMs": 0 + }, { "$comment": "The system-complete milestone (trigger 'systemComplete' — GameScene.celebrateDiscovery: the moment the system's NAV chart COMPLETES — its last planet / space station / jump-gate discovery — which is exactly when the 'Unlock Jumpgates' research becomes AVAILABLE (the SYSTEM tree's available hook, js/research/SystemCategory.js — the node flips live in the research console); NOT on the research's completion). One fire per system: once:false just lets the NEXT system re-arm it, and the scene's _systemCompleteSeen set keeps the fire to the chart-completing discovery (a later cluster discovery in a charted system still passes isSystemNavComplete() — clusters aren't NAV points). 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 while the research console (or any modal/action) is open. delayMs beats the discovery 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", diff --git a/dev/comms.test.mjs b/dev/comms.test.mjs index d508a29..d91758a 100644 --- a/dev/comms.test.mjs +++ b/dev/comms.test.mjs @@ -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; // ---------------------------------------------------------------------- diff --git a/dev/new-research-shot.html b/dev/new-research-shot.html new file mode 100644 index 0000000..eab8230 --- /dev/null +++ b/dev/new-research-shot.html @@ -0,0 +1,17 @@ + + + + + + Orbit — new-research beat (dev verify) + + + + +
+ + + diff --git a/dev/new-research-shot.mjs b/dev/new-research-shot.mjs new file mode 100644 index 0000000..bc95ad0 --- /dev/null +++ b/dev/new-research-shot.mjs @@ -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 + * --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); +} diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index ed44b43..297d841 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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) {