354 lines
16 KiB
JavaScript
354 lines
16 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|