/** * 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); }