/** * Dev-only: the COMMS intro self-check — boots the game straight into * the GameScene (a FRESH run, so the 'newGame' trigger arms OAC's * welcome) and paints a plain-text report answering "did the ack comm * actually happen?": * * 1. Did the comm ARM (trigger → queue) and PLAY (box + voice)? * 2. Is the pause contract live (comms.paused, the ship parked)? * 3. Is the clip muted + rolling (the video's element state)? * 4. Does ACKNOWLEDGE actually end it (button press → finish → * the box gone, the action free, the music volume restored)? * * node dev/slow-server.mjs 8080 # or any static server on the repo * → open http://127.0.0.1:8080/dev/comms-intro.html * * Headless screenshot: * node dev/cdp-shot.mjs "http://127.0.0.1:8080/dev/comms-intro.html" \ * out.png "document.getElementById('report').textContent.includes('SELF-CHECK')" */ 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); }; if (typeof window !== 'undefined') { 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:72%;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('COMMS SELF-CHECK RUNNING… (waiting for OAC’s welcome)'); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const until = async (fn, ms = 30000) => { const t0 = Date.now(); for (;;) { const v = fn(); if (v) return v; if (Date.now() - t0 > ms) return null; await sleep(150); } }; const scene = () => game.scene.getScene('GameScene'); setTimeout(async () => { const d = []; try { const s = scene(); const ready = await until(() => (s && s.ship && s.comms ? s : null)); if (!ready) { setReport(['FAIL — scene never became ready']); window.__commsResult = { ok: false }; return; } // 1) The welcome armed + played (the hub's deferred load may take a // moment — the clip + the speech come in on demand). const live = await until(() => (s.comms.active ? s : null)); d.push(`arm+play: active=${s.comms.active} paused=${s.comms.paused} queued=${s.comms.queued}`); if (!live) { setReport(['FAIL — the newGame comm never came up', ...d]); window.__commsResult = { ok: false, details: d }; return; } // The clip may take a beat to start (v4's autoplay retry — the // browser unlocks muted autoplay on its own schedule); a real // player has the whole voice to see it, so give it 4 s here. const box = s.comms.box; const clip = box?.video; const el = clip?.video; if (el) await until(() => el.paused === false, 4000); const snd = s.sound; const voiceKey = s.comms._voiceKey; d.push(`box: ${box ? `${Math.round(box.x)},${Math.round(box.y)} ${box.W}×${box.H}` : '—'}`); d.push(`clip: present=${!!clip} muted=${el ? el.muted : '—'} paused=${el ? el.paused : '—'} loop=${el ? el.loop : '—'} t=${el && Number.isFinite(el.currentTime) ? el.currentTime.toFixed(2) + 's' : '—'}`); d.push(`voice: key=${voiceKey ?? '—'} playing=${voiceKey ? snd.isPlaying(voiceKey) : '—'} ducked=${s.comms._ducked}`); d.push(`ship: paused-contract=${s.ship.body.velocity.length().toFixed(1)} state=${s.ship.state}`); d.push(`text: "${String(box?.text?.text ?? '').slice(0, 60)}…"`); // Per-sound ducking (the volume contract): the VOICE's own sound // stays at FULL volume (1.0); the music's own sound drops by // musicDuck (0.6 → 0.18); the manager's MASTER is untouched. (The // old bug: `sound.setVolume` IS the master in v4, so the voice was // being quieted along with the music — that is why the comms // sounded quiet.) Asserted on currentConfig.volume — the LOGICAL // volume; the gain-node read lags in a suspended (headless) context. const allPlaying = typeof snd.getAllPlaying === 'function' ? snd.getAllPlaying() : []; const vs = allPlaying.find((x) => x.key === voiceKey); const ms = allPlaying.find((x) => typeof x.key === 'string' && x.key.startsWith('music_')); const musicVol = config.get('music.volume', 0.6); const cfgVol = (x) => (x && x.currentConfig && typeof x.currentConfig.volume === 'number' ? x.currentConfig.volume : null); d.push(`duck: voice.volume=${cfgVol(vs)} (expect 1.0) music.volume=${cfgVol(ms)} (expect ${(musicVol * 0.3).toFixed(2)}) master=${snd.masterVolumeNode && snd.masterVolumeNode.gain.value} (expect 1)`); const duckOk = s.comms._ducked === true && // the music duck was issued cfgVol(vs) === 1.0 && // the voice is FULL volume (cfgVol(ms) !== null && Math.abs(cfgVol(ms) - musicVol * 0.3) < 0.001); // music ducked per-sound const ok = s.comms.active === true && s.comms.paused === true && // ack: the action holds !!box && !!box.button && !!clip && !!el && el.muted === true && // the video is silent (the voice is the speech) el.loop === true && // the ack clip loops until acknowledged el.paused === false && // …and it's rolling voiceKey !== null && snd.isPlaying(voiceKey) === true && // the speech is talking duckOk && s.ship.body.velocity.length() < 1; // the ship is parked // 2) ACKNOWLEDGE — press the box's button (its own pointerdown, the // save-panel's modal idiom) and the comm must end: box gone, // action free, the voice stopped. const before = { paused: s.comms.paused, ducked: s.comms._ducked }; box.button.panel.emit('pointerdown'); await until(() => (s.comms.active === false ? s : null), 5000); d.push(`ack: active=${s.comms.active} paused=${before.paused} → ${s.comms.paused} ducked=${before.ducked} → ${s.comms._ducked} voice=${voiceKey ? snd.isPlaying(voiceKey) : '—'}`); // The duck must RESTORE: the music goes back to its own volume. const msAfter = ms ? (snd.getAll(ms.key) ?? [])[0] : null; d.push(`duck-restore: music.volume=${msAfter ? cfgVol(msAfter) : '—'} (expect ${musicVol})`); const duckRestoreOk = !ms || (msAfter !== null && cfgVol(msAfter) !== null && Math.abs(cfgVol(msAfter) - musicVol) < 0.001); // The comm must NOT replay: give the hub several frames to (wrongly) // re-arm the same entry, then assert it stays finished. await sleep(700); const noReplay = s.comms.active === false && s.comms.queue.length === 0; d.push(`no-replay: active=${s.comms.active} (expect false) queue=${s.comms.queue.length} (expect 0)`); const ackOk = s.comms.active === false && s.comms.paused === false && s.comms._ducked === false && duckRestoreOk && (voiceKey ? snd.isPlaying(voiceKey) === false : true) && noReplay; // 3) The ledger: the welcome is PLAYED (a reload of this run would // not re-hear it). d.push(`ledger: played=${JSON.stringify(Array.from(s.registry.get('commsPlayed') ?? []))}`); const playedOk = s.registry.get('commsPlayed')?.has('oac-intro') === true; // 4) The played filter: re-triggering arms nothing. const armed = s.comms.trigger('newGame'); d.push(`re-arm: armed=${armed} (expect 0 — the ledger holds)`); const rearmOk = armed === 0; const allOk = ok && ackOk && playedOk && rearmOk; const errLine = errors.length ? ['console errors:', ...errors.slice(0, 4)] : []; window.__commsResult = { ok: allOk, details: [...d, ...errLine] }; setReport(['COMMS SELF-CHECK ' + (allOk ? 'PASS ✔' : 'FAIL ✘'), '', ...d, ...errLine]); } catch (err) { window.__commsResult = { ok: false, details: ['EXCEPTION: ' + String(err?.stack ?? err)] }; setReport(['COMMS SELF-CHECK FAIL ✘', String(err?.stack ?? err)]); } }, 600);