/** * COMMS × MINING — the TYPE-AWARE busy() contract (2026-07, user ask: * "let audio-only comms play even if the player is currently mining"). * * The scene's busy() (GameScene → CommsHub) is now comm-type-aware: * * screen-owned (modal window/panel up, or a jump in flight) * → NO comm of any type starts until it's clear; * action in progress (mining live, the scan sweep out) * → box comms (ack/visual — they pause the action or take the * screen) wait; an 'audio' comm (voice only — no box, no pause, * no input) rides right over the action. * * Walk: * 1) free scene → busy is false for every type; * 2) start REAL mining (the pop-up's own path: Mining.begin on a live * cluster member → the arm extends → the beam goes live) * → busy('audio') is FALSE, busy('ack')/busy('visual') stay TRUE; * 3) trigger oac-research-complete (the AUDIO comm) MID-MINING * → it starts (delayMs 1500 beat, then the pump), no box, no * pause, the voice plays, and the MINING KEEPS RUNNING (the * comm did not stop the action); * 4) the screen-owned branch still holds audio: a jump in flight * (_jumping) → busy('audio') is TRUE. * * Headless note: the AudioContext is suspended, so the 1.7 s clip never * reaches its natural end — the test stops the voice with stopByKey() * (exactly what the hub's isPlaying poll reads as "the voice ended"); * the hub must then finish the comm on its own. * * node dev/cdp-shot.mjs "http://127.0.0.1:3000/dev/comms-mine.html" o.png \ * "window.__commsMine" 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(); // Roster override: only the audio comm — the newGame welcome chain must // not pollute the walk (it is ack comms, which this test is NOT about). const roster = (data.comms?.comms ?? []).filter((c) => c.trigger === 'researchComplete'); if (roster.length !== 1 || roster[0].id !== 'oac-research-complete') { throw new Error('roster override expected exactly oac-research-complete'); } data.comms = { ...(data.comms ?? {}), comms: roster }; config.init(data); 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;margin:0;padding:8px 12px;font:12px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.9);border:1px solid #1b3a5a;max-width:55%;white-space:pre-wrap;'; document.body.appendChild(report); const log = (m) => { report.textContent += m + '\n'; }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const until = async (fn, ms = 20000) => { const t0 = Date.now(); for (;;) { const v = fn(); if (v) return v; if (Date.now() - t0 > ms) return null; await sleep(120); } }; setTimeout(async () => { const d = []; try { const s = () => game.scene.getScene('GameScene'); const ready = await until(() => { const sc = s(); return sc && sc.ship && sc.comms && (sc.asteroidClusters?.length ?? 0) > 0 ? sc : null; }, 30000); if (!ready) throw new Error('scene never ready (no cluster?)'); const busy = (t) => s().comms.busy(t); // ---- 1) free scene ------------------------------------------------- const freeOk = busy('audio') === false && busy('ack') === false && busy('visual') === false; d.push(`free: busy audio=${busy('audio')} ack=${busy('ack')} visual=${busy('visual')} (expect false/false/false) ${freeOk ? '✔' : '✘'}`); // ---- 2) start REAL mining ----------------------------------------- const cluster = s().asteroidClusters[0]; const member = cluster.members[0]; const began = s().mining.begin(cluster, member); // the pop-up's own path if (began === false) throw new Error('mining.begin refused (already extending?)'); const miningLive = await until(() => (s().mining.isActive ? s() : null), 8000); if (!miningLive) throw new Error('mining never went active'); const phaseOk = busy('audio') === false && busy('ack') === true && busy('visual') === true; d.push(`mining live (state=${s().mining.state}, ship=${s().ship.state}): busy audio=${busy('audio')} (expect false — RIDES OVER) ack=${busy('ack')} (expect true) visual=${busy('visual')} (expect true) ${phaseOk ? '✔' : '✘'}`); // ---- 3) the audio comm MID-MINING (the user ask) ------------------- s().comms.trigger('researchComplete'); const live = await until( () => (s().comms.active && s().comms.current?.id === 'oac-research-complete' ? s() : null), 10000, ); if (!live) throw new Error('the audio comm never came up mid-mining'); const vKey = s().comms._voiceKey; const vPlaying = vKey ? s().sound.isPlaying(vKey) : false; const stillMining = s().mining.isActive === true && s().ship.state === 'mining'; const step3 = s().comms.box === null && // NO box (the audio contract) s().comms.paused === false && // NO pause vPlaying === true && // the voice is talking stillMining; // the action KEEPS RUNNING d.push(`mid-mining: active=true box=${!!s().comms.box} (expect false) paused=${s().comms.paused} (expect false) voice=${vKey ? 'playing' : 'SILENT'} (expect playing) miningStillActive=${stillMining} (expect true) ${step3 ? '✔' : '✘'}`); // End the voice the way the hub's poll sees an end; the comm must // finish on its own (and leave the action untouched). if (vKey) s().sound.stopByKey(vKey); const done = await until(() => (s().comms.current?.id !== 'oac-research-complete' ? true : null), 6000); d.push(`voice-ended: comm auto-finished=${!!done} miningStillActive=${s().mining.isActive} (expect true) paused=${s().comms.paused}`); const step3b = !!done && s().mining.isActive === true; // ---- 4) screen-owned still holds audio (a jump in flight) ---------- s()._jumping = true; const jumpAudio = busy('audio'); const jumpAck = busy('ack'); const jumpHolds = jumpAudio === true && jumpAck === true; s()._jumping = false; const afterAudio = busy('audio'); const afterAck = busy('ack'); d.push(`jump in flight: busy audio=${jumpAudio} ack=${jumpAck} (both expect true) → after reset audio=${afterAudio} ack=${afterAck} (mining still live → false/true) ${jumpHolds ? '✔' : '✘'}`); const ok = freeOk && phaseOk && step3 && step3b && jumpHolds; window.__commsMine = { ok, details: d }; console.log('[comms-mine]', ok ? 'PASS' : 'FAIL', d.join(' | ')); } catch (err) { window.__commsMine = { ok: false, details: [...d, 'EXCEPTION: ' + String(err?.stack ?? err)] }; console.log('[comms-mine] FAIL', String(err)); } }, 800);