285 lines
11 KiB
JavaScript
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);
|
|
}
|