orbit/dev/combat-demo-check.mjs

354 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Dev-only: the SEVEN-SHIP VERIFICATION (headless, CDP) — the "manual
* playtest" on rails.
*
* node dev/combat-demo-check.mjs
*
* Two parts, both through the PRODUCTION code:
*
* A) THE RANGE (/combat) — every one of the seven new types
* (data/combat.json → enemyTypes, frames 1–7) is spawned via the
* real EnemyShip + enemyTypeConfig, fired once through the shared
* dispatch (CombatScene.fireEnemy → js/combat/Weapons.js →
* firePattern), and the volley's SHAPE is checked:
* bomber (lance) one aimed bolt
* spitter (spray) five bolts inside the ±fan/2 cone
* stinger (burst) three bolts, staggered speeds (the snap)
* hunter (missile) one heat-seeker, CAPPED turn (dodgeable)
* gunner (rapid) one bolt (its rapidity is the short cooldown)
* barrage (ring) eight bolts, evenly around 2π
* warden (combo) a bolt AND a heat-seeker
*
* B) THE MAIN GAME (index.html?combat) — the new boot path for real:
* the run starts on the menu, startNewGame() enters it, and the
* combat_demo script must land all SEVEN waves in difficulty order
* (bomber → warden), exactly THREE on screen at a time, one
* replacement per kill until the six-ship force is spent
* (3 → 3 → 3 → 2 → 1 → 0), with each wave's pattern firing through
* the shared path (the 'fire' / 'missile_fire' SFX beats), ending
* 'done' at 42 kills (7 × 6). The player is made unkillable for the
* run (a stray ring can't respawn it mid-wave and poison the counts)
* — the waves and the patterns are what's under test.
*
* 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 / dev/combat.sfxcheck.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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function connect(target) {
const ws = new WebSocket(target.webSocketDebuggerUrl);
let id = 0;
const pending = new Map();
ws.onmessage = (x) => {
const m = JSON.parse(x.data);
if (m.id && pending.has(m.id)) {
const { res, rej } = pending.get(m.id);
pending.delete(m.id);
m.error ? rej(new Error(m.error.message)) : res(m.result);
}
};
const open = new Promise((r) => {
ws.onopen = r;
ws.onerror = () => r();
});
const send = (method, params = {}) =>
new Promise((res, rej) => {
const mid = ++id;
pending.set(mid, { res, rej });
ws.send(JSON.stringify({ id: mid, method, params }));
});
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 };
}
// ---------------------------------------------------------------- the plan
// (data/combat.json → enemyTypes, difficulty order — hulls are distinct
// per wave, so a wave is identifiable by maxHull + pattern.)
const ROT_SPEED = 6.4; // combat.player.rotSpeed — the dodge-rule reference
const PLAN = [
{ id: 'bomber', hull: 80, pattern: 'lance', bolts: 1, missiles: 0 },
{ id: 'spitter', hull: 90, pattern: 'spray', bolts: 5, missiles: 0 },
{ id: 'stinger', hull: 100, pattern: 'burst', bolts: 3, missiles: 0 },
{ id: 'hunter', hull: 110, pattern: 'missile', bolts: 0, missiles: 1 },
{ id: 'gunner', hull: 120, pattern: 'rapid', bolts: 1, missiles: 0 },
{ id: 'barrage', hull: 135, pattern: 'ring', bolts: 8, missiles: 0 },
{ id: 'warden', hull: 150, pattern: 'combo', bolts: 1, missiles: 1 },
];
const wrap = (a) => Math.atan2(Math.sin(a), Math.cos(a));
const dOf = (a, aim) => wrap(a - aim);
/** The shared shape check — the range's volley and the main game's, alike. */
function checkShape(exp, info, aim) {
if (info.pattern !== exp.pattern) return `pattern '${info.pattern}' != '${exp.pattern}'`;
if (info.bolts !== exp.bolts) return `bolts ${info.bolts} != ${exp.bolts}`;
if (info.missiles !== exp.missiles) return `missiles ${info.missiles} != ${exp.missiles}`;
if (exp.pattern === 'spray') {
const half = 1.1 / 2 + 0.06; // weapon.fan/2 + the aim jitter (0.03) + eps
if (info.angles.some((a) => Math.abs(dOf(a, aim)) > half)) return 'a spray bolt escaped the fan';
}
if (exp.pattern === 'ring') {
const ang = [...info.angles].sort((x, y) => x - y);
for (let i = 0; i < ang.length; i++) {
const gap = i === ang.length - 1 ? 2 * Math.PI + ang[0] - ang[i] : ang[i + 1] - ang[i];
if (Math.abs(gap - (2 * Math.PI / 8)) > 0.07) return `ring gap ${gap.toFixed(3)} != ${(2 * Math.PI / 8).toFixed(3)}`;
}
}
if (exp.pattern === 'burst') {
const s = (info.speeds || []).filter(Number.isFinite).sort((x, y) => x - y);
if (s.length !== 3 || s[2] - s[0] < 0.05 * s[1]) return 'the burst speeds are not staggered';
}
for (const t of info.turns || []) {
if (!(t > 0 && t < ROT_SPEED)) return `missile turn ${t} is not under the player's ${ROT_SPEED} rad/s (the dodge rule)`;
}
return null;
}
// ---------------------------------------------------------------- part A
const RANGE_CODE = `(async () => {
const scene = window.game && window.game.scene.getScene('CombatScene');
if (!scene || !scene.ship) return 'no CombatScene';
const [shipMod, typeMod] = await Promise.all([
import('/js/combat/EnemyShip.js'),
import('/js/combat/EnemyTypes.js'),
]);
const px = scene.ship.x, py = scene.ship.y;
const liveBolts = () => scene.bullets.filter((b) => !b.dead);
const liveMs = () => (scene.missiles || []).filter((m) => !m.dead);
const out = [];
const ids = ${JSON.stringify(PLAN.map((p) => p.id))};
for (const id of ids) {
const cfg = typeMod.enemyTypeConfig(id);
const e = new shipMod.EnemyShip(scene, px + 260, py, 9000, cfg);
e.setDepth(10);
e.orbitAngle = Math.PI / 2;
e.nextFireAt = 1e12; // never volunteer — this check drives the shot
scene.enemies.push(e);
const b0 = liveBolts().length;
const m0 = liveMs().length;
const aim = Math.atan2(py - e.y, px - e.x);
const pattern = scene.fireEnemy(e); // the shared dispatch (Weapons.js)
const bolts = liveBolts().slice(b0);
const ms = liveMs().slice(m0);
out.push({
id, pattern, aim,
bolts: bolts.length,
angles: bolts.map((b) => b.angle0),
speeds: bolts.map((b) => (b.body && b.body.velocity) ? Math.hypot(b.body.velocity.x, b.body.velocity.y) : null),
missiles: ms.length,
turns: ms.map((m) => m.turnRate),
hull: e.maxHull,
});
e.dead = true;
const ix = scene.enemies.indexOf(e);
if (ix >= 0) scene.enemies.splice(ix, 1);
}
return out;
})()`;
async function runRange() {
const { c, target } = await openPage('/combat');
try {
let ready = false;
for (let i = 0; i < 80; i++) {
await sleep(500);
try { ready = await evalJs(c, 'Boolean(window.game && window.game.scene.getScene("CombatScene") && window.game.scene.getScene("CombatScene").ship)', false); } catch { ready = false; }
if (ready) break;
}
if (!ready) throw new Error('range never booted');
await sleep(600);
const rows = await evalJs(c, RANGE_CODE);
if (!Array.isArray(rows)) throw new Error(`range drive failed: ${rows}`);
const bad = [];
for (const row of rows) {
const exp = PLAN.find((p) => p.id === row.id);
if (!exp) { bad.push(`${row.id}: not in the plan`); continue; }
if (row.hull !== exp.hull) bad.push(`${row.id}: hull ${row.hull} != ${exp.hull}`);
const shapeErr = checkShape(exp, row, row.aim);
if (shapeErr) bad.push(`${row.id}: ${shapeErr}`);
}
if (bad.length) throw new Error(bad.join(' · '));
console.log('range : all 7 types fire their pattern with the right shape (lance / spray / burst / missile / rapid / ring / combo)');
return true;
} catch (err) {
console.error(`range : FAIL — ${err.message}`);
return false;
} finally {
try { await fetch(`http://${CDP}/json/close/${target.id}`).catch(() => {}); } catch { /* ignore */ }
c.ws.close();
}
}
// ---------------------------------------------------------------- part B
const MAIN_CODE = `(async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const oc = window.__ORBIT_COMBAT__;
if (!oc) return 'no hook';
const sys = oc.system;
// Isolate the WAVE LOGIC under test: the main game AUTO-FIRES at the
// nearest hostile in range (CombatSystem.autoFire) — left on, the
// player's tracers kill the wave's ships on their own and the run
// races ahead of this scripted check. Off, the run is driven exactly
// as the Node suite drives it (kill → replacement → drain → next).
if (sys.autoFire) sys.autoFire.enabled = false;
// Unkillable for the run — the waves and the patterns are what's under
// test, and a respawn mid-wave would poison the on-screen counts.
sys.maxHull = 1e9; sys.hull = 1e9; sys.maxShields = 1e9; sys.shields = 1e9;
const live = () => sys.enemies.filter((e) => !e.dead);
// The ACK PAUSE CONTRACT (js/comms/CommsHub.js): an 'ack' comm — the
// fresh-save welcome lands seconds into a new run — holds its box open
// and FREEZES THE ACTION (GameScene gates combat.update on it) until
// the player presses ACKNOWLEDGE. This scripted check plays the part
// of the player: whenever the pause holds, press the button — the
// box's own onAck path (CommsHub._ack, the same call its button makes).
const gs = window.game && window.game.scene ? window.game.scene.getScene('GameScene') : null;
const ackIfPaused = () => {
try {
if (gs && gs.comms && gs.comms.paused) gs.comms._ack();
} catch { /* the box is gone — the pause is gone with it */ }
};
ackIfPaused(); // the welcome may already be holding the box at boot
const sfxLog = () => (window.__COMBAT_SFX__ && window.__COMBAT_SFX__.log) || [];
const out = { waves: [], state: null, kills: null };
const plan = ${JSON.stringify(PLAN)};
for (let i = 0; i < plan.length; i++) {
const exp = plan[i];
let deployed = false;
for (let t0 = Date.now(); Date.now() - t0 < 20000 && !deployed; await sleep(150)) {
ackIfPaused(); // a comm may land while we wait — answer it
const a = live();
deployed = a.length === 3 && a.every((e) => e.maxHull === exp.hull && ((e.wpn && e.wpn.pattern) || 'lance') === exp.pattern);
}
if (!deployed) { out.waves.push({ id: exp.id, fail: 'wave did not deploy (3 on screen, right type)' }); return out; }
out.waves.push({ id: exp.id, deployed: 3, onScreen: 3 }); // 3 on screen at the start
// Drive one shot through the production dispatch + read the shape.
const e = live()[0];
const logLen = sfxLog().length;
const b0 = sys.bullets.filter((b) => !b.dead).length;
const m0 = (sys.missiles || []).filter((m) => !m.dead).length;
const aim = Math.atan2(sys.ship.y - e.y, sys.ship.x - e.x);
const fired = sys.fireEnemy(e, sys.ship, sys._now());
const bolts = sys.bullets.filter((b) => !b.dead).slice(b0);
const ms = (sys.missiles || []).filter((m) => !m.dead).slice(m0);
const log = sfxLog().slice(logLen);
out.waves[out.waves.length - 1].shot = {
pattern: fired, aim,
bolts: bolts.length,
angles: bolts.map((b) => b.angle0),
speeds: bolts.map((b) => (b.body && b.body.velocity) ? Math.hypot(b.body.velocity.x, b.body.velocity.y) : null),
missiles: ms.length,
turns: ms.map((m) => m.turnRate),
sfx: [...new Set(log.map((x) => x.event))],
};
// Kill the wave's six: a replacement rides the first three kills, then
// the force drains (3 → 3 → 3 → 2 → 1 → 0).
const drains = [3, 3, 3, 2, 1, 0];
for (let k = 0; k < 6; k++) {
ackIfPaused(); // a comm may land mid-wave — answer it
const t = live().find((x) => x.maxHull === exp.hull);
if (!t) { out.waves[out.waves.length - 1].fail = 'no ship to kill at step ' + k; return out; }
t.damage(9999);
sys.killEnemy(t, sys._now());
await sleep(150);
const n = live().length;
if (n !== drains[k]) { out.waves[out.waves.length - 1].fail = 'after kill ' + (k + 1) + ': ' + n + ' on screen, expected ' + drains[k]; return out; }
out.waves[out.waves.length - 1].onScreen = n; // the cap holds, then the drain
}
}
for (let t0 = Date.now(); Date.now() - t0 < 10000; await sleep(200)) {
ackIfPaused();
const st = sys.waveStatus();
if (st && st.state === 'done') { out.state = st.state; break; }
}
out.kills = sys.kills;
return out;
})()`;
async function runMain() {
const { c, target } = await openPage('/index.html?combat');
try {
let ready = false;
for (let i = 0; i < 80; i++) {
await sleep(500);
try { ready = await evalJs(c, 'Boolean(window.game)', false); } catch { ready = false; }
if (ready) break;
}
if (!ready) throw new Error('game never booted');
// Enter the run (the main game boots on the menu).
const started = await evalJs(c, `(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';
})()`);
if (started !== 'started') throw new Error(`start: ${started}`);
let scoped = false;
for (let i = 0; i < 60; i++) {
await sleep(500);
try { scoped = await evalJs(c, 'Boolean(window.__ORBIT_COMBAT__)', false); } catch { scoped = false; }
if (scoped) break;
}
if (!scoped) throw new Error('combat scope never appeared');
await sleep(600);
const out = await evalJs(c, MAIN_CODE);
if (!out || out.state === undefined) throw new Error(`main drive failed: ${JSON.stringify(out)}`);
const bad = [];
for (const w of out.waves) {
const exp = PLAN.find((p) => p.id === w.id);
if (w.fail) { bad.push(`${w.id}: ${w.fail}`); continue; }
if (exp && w.shot) {
const shapeErr = checkShape(exp, w.shot, w.shot.aim);
if (shapeErr) bad.push(`${w.id}: ${shapeErr}`);
const wantSfx = exp.pattern === 'missile' || exp.pattern === 'combo' ? 'missile_fire' : 'fire';
if (!(w.shot.sfx || []).includes(wantSfx)) bad.push(`${w.id}: SFX beat '${wantSfx}' missing (got ${w.shot.sfx})`);
}
}
if (out.state !== 'done') bad.push(`run state '${out.state}' != 'done'`);
if (out.kills !== 42) bad.push(`kills ${out.kills} != 42`);
if (bad.length) throw new Error(bad.join(' · '));
console.log('main : 7 waves in difficulty order, 3 on screen, a replacement per kill, all patterns fired, done at 42 kills');
return true;
} catch (err) {
console.error(`main : FAIL — ${err.message}`);
return false;
} finally {
try { await fetch(`http://${CDP}/json/close/${target.id}`).catch(() => {}); } catch { /* ignore */ }
c.ws.close();
}
}
// ---------------------------------------------------------------- main
const results = [await runRange(), await runMain()];
if (results.every(Boolean)) {
console.log('seven ships: PASS (the range renders all seven patterns; the ?combat boot runs all seven waves)');
process.exit(0);
} else {
console.log('seven ships: FAIL');
process.exit(1);
}