/** * Dev-only: the COMMS tutorial self-check — boots the game straight into * the GameScene (a FRESH run, so the 'newGame' trigger arms OAC's welcome * CHAIN) and walks the whole sequence exactly as the player lives it: * * 1. oac-intro (ack) — full contract: box + muted looping clip + voice * (full volume, music ducked per-sound) + the * action paused; ACKNOWLEDGE ends it and the duck * restores. * 2. oac-tether (ack) — chains right after (the FIFO queue), voice is * intro-02. * 3. oac-scan (ack) — chains right after that, voice is intro-03. * 4. QUIET — nothing else arms until the player acts (firstScan not * triggered yet). * 5. the player clicks SCAN on the deck (the real deckAction path); * 5 s later oac-upgrade (ack, voice intro-04) must come up; it * ACKNOWLEDGEs clean. * 6. the player opens the research console and commits to the Tether * Level 2 research (beginResearch): the pair oac-minerals → * oac-fly-mine arms (queued) but must HOLD while the console is * open (the busy() contract) and play back-to-back once it closes * (voices intro-05, intro-06). The project's clock is then * fast-forwarded to its natural 60 s completion: oac-research- * complete (AUDIO-ONLY, once:false) must play with NO box and NO * pause, and re-arm on the next trigger. * 7. the player finds the first asteroid cluster (the ship brought * within discovery range of one): oac-first-cluster (ack, voice * intro-07, 1.5 s after the chime) must come up and ACKNOWLEDGE * clean. * 8. the player mines their first 200 minerals (the real onOre path): * oac-tether2 (ack, voice intro-08, the build-it-home brief) must * come up and ACKNOWLEDGE clean. * 9. the player builds the level-2 tether on home (the real beginBuild * path — research gate, tether gate, the 200-mineral cost — with the * build clock fast-forwarded to its natural 20 s completion), then * launches back into space (SurfaceScene.finishTakeoff's arm): * oac-relaunch (ack, voice intro-09) must come up, ACKNOWLEDGE, * and oac-explore (ack, voice intro-10) must chain IMMEDIATELY * after — OAC's send-off. * 10. the ledger holds all eleven (the ten `once` comms + the sign-off * it heard); re-arming any `once` trigger arms nothing (and the * sign-off re-arms, as it should). * * 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 "window.__commsResult && JSON.stringify(window.__commsResult)" */ 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; } const snd = s.sound; // --- a walk helper: the comm `id` must be in play (box + voice); // --- ACKNOWLEDGE it and let it finish. `vk` is the expected voice key. const ackStep = async (id, vk, extra = null) => { const up = await until(() => (s.comms.active && s.comms.current?.id === id ? s : null), 20000); if (!up) { d.push(`${id}: NEVER CAME UP (active=${s.comms.active} current=${s.comms.current?.id ?? '—'})`); return false; } const box = s.comms.box; const el = box?.video?.video; if (el) await until(() => el.paused === false, 4000); // v4's autoplay retry const voiceKey = s.comms._voiceKey; const voicePlaying = voiceKey ? snd.isPlaying(voiceKey) === true : false; d.push(`${id}: paused=${s.comms.paused} box=${!!box} ackBtn=${!!box?.button} clip=${el ? `muted=${el.muted} loop=${el.loop}` : '—'} voice=${voiceKey ?? '—'} playing=${voicePlaying}`); const okStep = s.comms.paused === true && !!box && !!box.button && vk === voiceKey && voicePlaying; d.push(`${id}: ${okStep ? 'contract ✔' : 'contract ✘ (expect paused, box+button, voice ' + vk + ' playing)'}`); // Press ACKNOWLEDGE (the button's own pointerdown) and let it finish. box.button.panel.emit('pointerdown'); await until(() => (s.comms.active === false ? s : null), 5000); if (extra) extra(id); return okStep && s.comms.active === false && s.comms.paused === false; }; // ---- 1) the welcome — the FULL contract (box, clip, voice, duck) -- const up1 = await until(() => (s.comms.active ? s : null), 20000); if (!up1) { setReport(['FAIL — oac-intro never came up']); window.__commsResult = { ok: false, details: d }; return; } const box = s.comms.box; const clip = box?.video; const el = clip?.video; if (el) await until(() => el.paused === false, 4000); 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 : '—'}`); 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. 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 introOk = s.comms.current?.id === 'oac-intro' && 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 && voiceKey === 'comms_au_intro-01' && snd.isPlaying(voiceKey) === true && // the speech is talking 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 s.ship.body.velocity.length() < 1; // the ship is parked d.push(`oac-intro: ${introOk ? 'contract ✔' : 'contract ✘'}`); // Press ACKNOWLEDGE; the duck must RESTORE (the music's own volume). box.button.panel.emit('pointerdown'); await until(() => (s.comms.active === false ? s : null), 5000); const msAfter = ms ? (snd.getAll(ms.key) ?? [])[0] : null; const duckRestoreOk = !ms || (msAfter !== null && cfgVol(msAfter) !== null && Math.abs(cfgVol(msAfter) - musicVol) < 0.001); d.push(`duck-restore: music.volume=${msAfter ? cfgVol(msAfter) : '—'} (expect ${musicVol})`); await sleep(300); // > the endedWithin(200) race window before the next step // ---- 2) + 3) the chain — tether, then the scan brief -------------- const tetherOk = await ackStep('oac-tether', 'comms_au_intro-02'); await sleep(300); const scanOk = await ackStep('oac-scan', 'comms_au_intro-03'); // ---- 4) QUIET — nothing else arms until the player acts ----------- await sleep(700); const quiet = s.comms.active === false && s.comms.queue.length === 0; d.push(`quiet after chain: active=${s.comms.active} (expect false) queue=${s.comms.queue.length} (expect 0)`); // ---- 5) the player clicks SCAN on the deck — the real path -------- s.deckAction('scan'); // the comm guard must let it through (the scene is free) d.push(`scan: ${s.scanPulse.busy ? 'sweep is out (busy)' : 'NO SWEEP (busy=false — the comm guard swallowed it?)'}`); const upgradeOk = await ackStep('oac-upgrade', 'comms_au_intro-04'); // the 5 s beat + the sweep settle await sleep(400); // > the endedWithin(200) race window before the next input // ---- 6) Tether Level 2 research — the pair + the busy() hold ------ s.deckAction('research'); // the deck's console path const winUp = await until(() => (s.researchWindow?.isOpen === true ? s : null), 5000); if (!winUp) { setReport(['FAIL — the research console never opened', ...d]); window.__commsResult = { ok: false, details: d }; return; } s.beginResearch('exploration', 'tether_l2'); // the console's RESEARCH button path await sleep(600); // frames for the hub to (wrongly) pump while the console owns the screen const proj = s.researchState.getActive(); d.push(`research: in-flight=${proj?.id ?? '—'} (expect tether_l2) queued=${s.comms.queue.length} (expect 2) active=${s.comms.active} (expect false — the console owns the screen)`); const holdOk = !!proj && proj.id === 'tether_l2' && s.comms.queue.length === 2 && s.comms.active === false; d.push(`busy-hold: ${holdOk ? 'contract ✔' : 'contract ✘'}`); s.researchWindow.close(); // the player steps away from the console await until(() => (s.researchWindow?.isOpen === false), 5000); const mineralsOk = await ackStep('oac-minerals', 'comms_au_intro-05'); await sleep(300); const flyMineOk = await ackStep('oac-fly-mine', 'comms_au_intro-06'); await sleep(400); // ---- 6b) the project completes — the AUDIO-ONLY sign-off ---------- // Fast-forward the in-flight clock to its natural expiry (a real // player gets this 60 s into the project; the tick is gated on the // comm pause, so it lands on a live run exactly as in play). const projDone = s.researchState.getActive(); if (projDone) projDone.startedAt = s.time.now - projDone.durationMs - 1000; // the clock expires on the next tick const rcKey = 'comms_au_research-complete'; const rcUp = await until(() => (s.comms.active && s.comms.current?.id === 'oac-research-complete' ? s : null), 12000); const rcOk = !!rcUp && s.comms.box === null && s.comms.paused === false && s.comms._voiceKey === rcKey && s.sound.isPlaying(rcKey); d.push(`research-complete: active=${s.comms.active} box=${String(s.comms.box)} (expect null — audio only) paused=${s.comms.paused} (expect false — no pause) voice=${s.comms._voiceKey ?? '—'} playing=${s.sound.isPlaying(rcKey)}`); d.push(`audio-only contract: ${rcOk ? '✔' : '✘'}`); if (s.sound) s.sound.stopByKey(rcKey); // headless: the AudioContext never advances — simulate the voice ending await until(() => s.comms.active === false, 5000); // once:false — EVERY completion signs off (a second trigger re-arms) const rcArmed = s.comms.trigger('researchComplete'); const rc2Up = await until(() => (s.comms.active && s.comms.current?.id === 'oac-research-complete' ? s : null), 12000); d.push(`once:false: re-arm=${rcArmed} (expect 1) replay=${!!rc2Up} (expect true)`); if (rc2Up && s.sound) s.sound.stopByKey(rcKey); await until(() => s.comms.active === false, 5000); await sleep(300); // ---- 7) the first rock field — discovery range, then the comm ----- const cluster = s.asteroidClusters[0]; if (!cluster) { setReport(['FAIL — the run has no asteroid clusters to find', ...d]); window.__commsResult = { ok: false, details: d }; return; } s.ship.stop(); // park the ship, then bring it within discovery range s.ship.x = cluster.x; s.ship.y = cluster.y + cluster.bound + 100; // 100px off the rim (< the 540px discovery distance) d.push(`discover: ship at ${Math.round(s.ship.x)},${Math.round(s.ship.y)} — ${cluster.discoveryId} is ${Math.round(Math.hypot(s.ship.x - cluster.x, s.ship.y - cluster.y) - cluster.bound)}px off its rim`); const clusterOk = await ackStep('oac-first-cluster', 'comms_au_intro-07'); // the 1.5 s chime beat // ---- 8) the first 200 minerals — the build-it-home beat ------------ // The real ore event (Mining.js loads the hold, then the scene's // onOre: the ledger, the HUD, the quest — and the comm's arm). const added = s.ship.addMinerals(200); // the beam's 'take' (capped to the hold) s.mining.onOre?.(added, s.ship?.minerals ?? 0); d.push(`mine: hold=${s.ship.minerals} (expect >= 200) totalMined=${s.totalMined}`); const tether2Ok = await ackStep('oac-tether2', 'comms_au_intro-08'); // the 1.5 s beat await sleep(400); // ---- 9) the level-2 tether + the launch-back pair ------------------ // The REAL build path (research gate, the home's level-1 tether gate, // the 200-mineral cost), then the build clock fast-forwarded to its // natural 20 s completion (a real player waits it out on the surface). const buildRes = s.beginBuild(s.homeWorldName, 'tether-l2', s.game.loop.now); const b = s.buildState.getActive(); if (b) b.startedAt = s.game.loop.now - b.durationMs - 1000; // the clock expires on the next tick const built = await until(() => (s.buildState.isBuilt(s.homeWorldName, 'tether-l2') ? s : null), 8000); const buildOk = buildRes?.ok === true && !!built && (s.tetherLevelFor(s.homeWorldName) ?? 0) === 2; d.push(`build: res=${JSON.stringify(buildRes)} built=${!!built} tetherLevel=${s.tetherLevelFor(s.homeWorldName)} (expect 2)`); d.push(`tether-l2 on home: ${buildOk ? '✔' : '✘'}`); // The take-off (SurfaceScene.finishTakeoff's arm — the same witness + // the same trigger) puts the player back in space: if (s.homeWorldName && s.buildState.isBuilt(s.homeWorldName, 'tether-l2')) { s.comms.trigger('tetherL2Launch'); } const relaunchOk = await ackStep('oac-relaunch', 'comms_au_intro-09'); // the 1.5 s beat await sleep(300); const exploreOk = await ackStep('oac-explore', 'comms_au_intro-10'); // IMMEDIATE after the ACK (delayMs 0) await sleep(400); // ---- 10) the ledger holds what the run heard ----------------------- // The ten `once` comms (the ledger is what bars their next trigger) // plus the once:false sign-off (recorded as heard; the model ignores // the ledger for it, so it re-arms on every completion — proven in 6b) await sleep(700); // > the race window; also the hub's no-replay window const noReplay = s.comms.active === false && s.comms.queue.length === 0; const ledger = s.registry.get('commsPlayed'); d.push(`ledger: played=${JSON.stringify(ledger ? Array.from(ledger) : [])}`); const playedOk = !!ledger && ['oac-intro', 'oac-tether', 'oac-scan', 'oac-upgrade', 'oac-minerals', 'oac-fly-mine', 'oac-research-complete', 'oac-first-cluster', 'oac-tether2', 'oac-relaunch', 'oac-explore'].every((id) => ledger.has(id)); const armed2 = s.comms.trigger('newGame'); const armed3 = s.comms.trigger('firstScan'); const armed4 = s.comms.trigger('tetherResearch'); const armed5 = s.comms.trigger('firstCluster'); const armed6 = s.comms.trigger('minerals200'); const armed7 = s.comms.trigger('tetherL2Launch'); d.push(`re-arm: newGame=${armed2} firstScan=${armed3} tetherResearch=${armed4} firstCluster=${armed5} minerals200=${armed6} tetherL2Launch=${armed7} (expect 0 0 0 0 0 0 — the ledger holds)`); const allOk = introOk && duckRestoreOk && tetherOk && scanOk && quiet && upgradeOk && holdOk && mineralsOk && flyMineOk && rcOk && rcArmed === 1 && rc2Up && clusterOk && tether2Ok && buildOk && relaunchOk && exploreOk && noReplay && playedOk && armed2 === 0 && armed3 === 0 && armed4 === 0 && armed5 === 0 && armed6 === 0 && armed7 === 0; 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);