orbit/dev/combat.sfxcheck.mjs

285 lines
11 KiB
JavaScript

/**
* 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 nine combat events (cannon_fire / cannon_hit / 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 TENTH 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 = ['cannon_fire', 'cannon_hit', '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';
// Wait for a LIVE hostile before driving the enemy-dependent events.
// The main game's ?combat boot now runs the 7-wave demo script — the
// first wave lands after its lead-in, so "fire / hit / kill" can't be
// driven before a ship deploys. (The range packs instantly; live() is
// already true there.)
if (scope.live) {
const liveDeadline = Date.now() + 15000;
while (Date.now() < liveDeadline && !scope.live()) await sleep(200);
if (!scope.live()) return 'no live hostile after 15 s';
}
const driveLevelUp = ${Boolean(withLevelUp)}; // injected at build time
const logBefore = (window.__COMBAT_SFX__ && window.__COMBAT_SFX__.log || []).length;
// cannon_fire — the player's cannon goes off.
scope.fire();
// cannon_hit — the player's tracer strikes a hostile.
scope.hitEnemy();
// fire — the enemy's laser goes off (full volume).
scope.fireEnemy();
// 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 {
live: () => scene.enemies.some((e) => !e.dead),
fire: () => scene.firePlayer(0, cfg.section('combat.player.weapon', {})),
hitEnemy: () => {
const e = scene.enemies.find((x) => !x.dead);
if (!e) return;
const wpn = cfg.section('combat.player.weapon', {});
// A tracer parked 18px off the hostile's flank, aimed at it — the
// real collide() pass connects it (cannon_hit + the layer voices).
scene.spawnBullet(e.x - 18, e.y, 0, wpn, 'player');
scene.collide(16);
},
fireEnemy: () => {
const e = scene.enemies.find((x) => !x.dead);
if (e) scene.fireEnemy(e); // a hostile's lance at the player (the laser voice)
},
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 {
live: () => sys.enemies.some((e) => !e.dead),
fire: () => {
const t = sys.nearestEnemy(sys.ship.x, sys.ship.y);
if (t) sys.firePlayerAt(sys.ship, t, sys._now());
},
hitEnemy: () => {
const e = sys.enemies.find((x) => !x.dead);
if (!e) return;
const v = sys.visibleView();
const wpn = window.config.section('combat.player.weapon', {});
// Park the hostile at the camera view's CENTER (the on-screen rule
// gates hits — the pack streams in from off-screen), then a tracer
// 18px off its flank, aimed at it — the real collide() pass
// connects it (cannon_hit + the layer voices).
if (v) { e.x = v.x + v.width / 2; e.y = v.y + v.height / 2; }
sys.spawnBullet(e.x - 18, e.y, 0, wpn, 'player');
sys.collide(16, sys.ship);
},
fireEnemy: () => {
const e = sys.enemies.find((x) => !x.dead);
if (e) sys.fireEnemy(e, sys.ship, sys._now()); // a hostile's lance (the laser voice)
},
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 nine, the main game\'s ten)');
process.exit(0);
} else {
console.log('combat SFX wiring: FAIL');
process.exit(1);
}