/** * Combat SFX wiring check (dev tool, run with Node + a CDP browser): * * node dev/combat.sfxcheck.mjs * * Drives REAL game events in BOTH combat contexts — the range * (http://127.0.0.1:8080/combat) and the main game * (http://127.0.0.1:8080/index.html?combat) — and asserts that each of * the seven combat events (fire / impact / shield / hull / kill / * down / respawn) went through the SHARED SFX path * (js/combat/CombatSfx.js → window.__COMBAT_SFX__ dev log) — and, in * the main game, the EIGHTH event, levelup (the combat level landing — * the run's XP bank, fed through the dev hook). * * Why a log: a headless browser has no audio manager, so the shared * guards (js/utils/Sfx.js) stay silent — the dev log records the call * itself, which is exactly the wiring under test. In a real browser * the same calls play the data/sfx.json voices. * * Requires: the dev server on :8080 (node dev/server.mjs 8080) and a * headless Chromium/Brave with CDP on 127.0.0.1:9333 (see dev/cdp-shot.mjs). */ const CDP = process.env.CDP_PORT ? `127.0.0.1:${process.env.CDP_PORT}` : '127.0.0.1:9333'; const BASE = process.env.BASE_URL ?? 'http://127.0.0.1:8080'; const EVENTS_RANGE = ['fire', 'impact', 'shield', 'hull', 'kill', 'down', 'respawn']; const EVENTS_MAIN = [...EVENTS_RANGE, 'levelup']; // the main game's XP bank function connect(target) { const ws = new WebSocket(target.webSocketDebuggerUrl); let id = 0; const pending = new Map(); const send = (method, params = {}) => new Promise((res, rej) => { const mid = ++id; pending.set(mid, { res, rej }); ws.send(JSON.stringify({ id: mid, method, params })); }); ws.onmessage = (m) => { const x = JSON.parse(m.data); if (x.id && pending.has(x.id)) { const { res, rej } = pending.get(x.id); pending.delete(x.id); x.error ? rej(new Error(x.error.message)) : res(x.result); } }; const open = new Promise((r) => { ws.onopen = r; ws.onerror = () => r(); }); return { ws, send, open }; } async function evalJs(c, expression, awaitPromise = true) { const r = await c.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise }); if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails, null, 2)); return r.result?.value; } async function openPage(path) { const url = `${BASE}${path}`; let target; try { target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json(); } catch { target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'POST' })).json(); } const c = connect(target); await c.open; await c.send('Runtime.enable'); return { c, target }; } /** * Drive the seven events in `scope` (a JS expression resolving to a * scope object) and return the distinct events observed in the dev log. */ function driveScript(scopeExpr, withLevelUp = false) { return `(async () => { const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const scope = ${scopeExpr}; if (!scope) return 'no scope'; const driveLevelUp = ${Boolean(withLevelUp)}; // injected at build time const logBefore = (window.__COMBAT_SFX__ && window.__COMBAT_SFX__.log || []).length; // fire — the cannon goes off (player side, full voice). scope.fire(); // impact + shield — a hit absorbed by the shields. scope.armShielded(); scope.hitPlayer(5); // impact + hull — the shields are gone, the armor takes it. scope.armBare(); scope.hitPlayer(5); // kill — a hostile goes down. scope.killOne(); // levelup — a combat level lands (the main game's XP bank — the // level's beat fires the solar_flare voice; the range has no XP // bank, so it drives the seven). if (driveLevelUp) scope.levelUp(); // down — the player's hull breaks. scope.killPlayer(); // respawn — the player reforms. scope.respawn(); await sleep(80); const log = (window.__COMBAT_SFX__ && window.__COMBAT_SFX__.log) || []; const slice = log.slice(logBefore); return [...new Set(slice.map((e) => e.event))]; })()`; } async function checkContext(name, path, scopeExpr, startExpr, expected, withLevelUp = false) { const { c, target } = await openPage(path); try { // Give the game time to boot (assets, fonts). let ready = false; for (let i = 0; i < 80; i++) { await new Promise((r) => setTimeout(r, 500)); try { ready = await evalJs(c, 'Boolean(window.game)', false); } catch { ready = false; } if (ready) break; } if (!ready) throw new Error('game never booted'); // Start the run (the main game boots on the menu — the range doesn't). if (startExpr) await evalJs(c, startExpr, false); // Wait for the combat scope to exist (the hook lands in create()). let scoped = false; for (let i = 0; i < 60; i++) { await new Promise((r) => setTimeout(r, 500)); try { scoped = await evalJs(c, `Boolean(${scopeExpr})`, false); } catch { scoped = false; } if (scoped) break; } if (!scoped) throw new Error('combat scope never appeared'); await new Promise((r) => setTimeout(r, 800)); const events = await evalJs(c, driveScript(scopeExpr, withLevelUp)); if (!Array.isArray(events)) throw new Error(`drive failed: ${events}`); const missing = expected.filter((e) => !events.includes(e)); if (missing.length) throw new Error(`missing events: ${missing.join(', ')}`); console.log(`${name}: all ${expected.length} events fired through the shared SFX path`); return true; } catch (err) { console.error(`${name}: FAIL — ${err.message}`); return false; } finally { try { await fetch(`http://${CDP}/json/close/${target.id}`).catch(() => {}); } catch { /* ignore */ } c.ws.close(); } } const scopeRange = `(() => { const scene = window.game.scene.getScene('CombatScene'); const cfg = window.config; return { fire: () => scene.firePlayer(0, cfg.section('combat.player.weapon', {})), armShielded: () => { scene.shields = scene.maxShields; scene.playerDead = false; scene.ship.setVisible(true); }, armBare: () => { scene.shields = 0; scene.hull = 50; }, hitPlayer: (n) => scene.damagePlayer(n), killOne: () => { const e = scene.enemies.find((x) => !x.dead); if (e) { e.damage(9999); scene.killEnemy(e); } }, killPlayer: () => { if (scene.playerDead) { scene.respawnAt = 0; scene.maybeRespawn(scene.time.now); } scene.shields = 0; scene.hull = 1; scene.damagePlayer(9999); }, respawn: () => { scene.respawnAt = 0; scene.maybeRespawn(scene.time.now); }, }; })()`; const scopeMain = `(() => { const oc = window.__ORBIT_COMBAT__; if (!oc) return null; const sys = oc.system; return { fire: () => { const t = sys.nearestEnemy(sys.ship.x, sys.ship.y); if (t) sys.firePlayerAt(sys.ship, t, sys._now()); }, armShielded: () => { sys.shields = sys.maxShields; sys.playerDown = false; sys.ship.setVisible(true); }, armBare: () => { sys.shields = 0; sys.hull = 50; }, hitPlayer: (n) => sys.damagePlayer(n), killOne: () => { const e = sys.enemies.find((x) => !x.dead); if (e) { e.damage(9999); sys.killEnemy(e, sys._now()); } }, killPlayer: () => { if (sys.playerDown) sys.revivePlayer(sys._now()); sys.shields = 0; sys.hull = 1; sys.damagePlayer(9999); }, respawn: () => sys.revivePlayer(sys._now()), levelUp: () => { // A grant big enough to cross at least one level (a fresh run // pays 100 for level 1 — take the WHOLE cost + a kill's worth, // so a restore mid-level can't starve it). sys.xp.grant((sys.xp.xpForNext() ?? 100) + 100, 'sfxcheck'); }, }; })()`; const results = []; results.push(await checkContext('range (/combat)', '/combat', scopeRange, null, EVENTS_RANGE)); results.push(await checkContext( 'main (index.html?combat)', '/index.html?combat', scopeMain, `(async () => { const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); for (let i = 0; i < 120; i++) { const m = window.game && window.game.scene && window.game.scene.getScene('MenuScene'); if (m && typeof m.seedValue === 'string') { m.startNewGame(); return 'started'; } await sleep(500); } return 'menu never ready'; })()`, EVENTS_MAIN, true, )); if (results.every(Boolean)) { console.log('combat SFX wiring: PASS (both contexts — the range\'s seven, the main game\'s eight)'); process.exit(0); } else { console.log('combat SFX wiring: FAIL'); process.exit(1); }