/** * Dev-only CDP screenshot harness for the COMBAT FEEDBACK set — drives * the real game in BOTH combat contexts (the training range `/combat` and * the main game `index.html?combat`), fires the combat events through the * PRODUCTION paths (fire / impact / shield+hull / kill / player-hit), and * captures a labeled frame per context so the new feedback (VFX + the * readability layer) is on record. * * node dev/combat-shot.mjs [outDir] * * Requires the dev server (http://127.0.0.1:8080) and a headless * Chromium-CDP browser on 127.0.0.1:9333 (same as dev/cdp-shot.mjs): * /opt/brave.com/brave/brave --headless --disable-gpu \ * --remote-debugging-port=9333 about:blank * * Outputs (committed as evidence under the given outDir, default * dev/shots/): * combat-range.png — /combat: lock reticle, threat cue, enemy-hit * flash + damage number, kill burst + feed, * player-hit flash + gauge flash * combat-main.png — index.html?combat: the same set in the * main game, with the real combat bar gauging — * plus a level-up beat (the LEVEL floater over the * ship + the bar's XP row at level 1, live fill) * * Every event goes through the shared production code (CombatSfx / Fx / * CombatHud), so the frame is a faithful picture of what a player sees. */ const fs = await import('node:fs'); const CDP = process.env.CDP_PORT ? `127.0.0.1:${process.env.CDP_PORT}` : '127.0.0.1:9333'; const BASE = process.env.BASE ?? 'http://127.0.0.1:8080'; const OUT = process.argv[2] ?? 'dev/shots'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function main() { fs.mkdirSync(OUT, { recursive: true }); // Open a fresh page target at `url`. async function openPage(url) { 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 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 })); }); await new Promise((res, rej) => { ws.onopen = res; ws.onerror = () => rej(new Error('ws error')); }); 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); } }; await send('Runtime.enable'); await send('Page.enable'); await send('Network.enable'); await send('Network.setCacheDisabled', { cacheDisabled: true }); await send('Page.navigate', { url }); const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error('page: ' + JSON.stringify(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text)); return r.result?.value; }; const waitTrue = async (expr, timeoutMs = 45000) => { const end = Date.now() + timeoutMs; while (Date.now() < end) { const v = await ev(expr).catch(() => null); if (v) return v; await sleep(250); } throw new Error('timed out waiting for: ' + expr.slice(0, 60)); }; const shot = async (path, delayMs = 110) => { await sleep(delayMs); // let the last paint land (flash near peak, numbers mid-rise) const r = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path, Buffer.from(r.data, 'base64')); console.log(` shot: ${path}`); }; const close = async () => { await fetch(`http://${CDP}/json/close/${target.id}`).catch(() => {}); ws.close(); }; return { ev, waitTrue, shot, close, target }; } // ------------------------------------------------------------ the range { console.log('range (/combat):'); const p = await openPage(`${BASE}/combat`); try { await p.waitTrue('Boolean(window.game && window.game.scene.getScene("CombatScene"))'); await sleep(1200); // let the pack settle into its ring await p.ev(`(async () => { const s = window.game.scene.getScene('CombatScene'); // A live target on the current-target rule (the lock + threat cue show it). const e = s.enemies.find((x) => !x.dead) || (s.spawnEnemy(s.w / 2, s.h / 2, 99), s.enemies[s.enemies.length - 1]); // Fan the cannon at it through the real fire path (the shared weapon). const cfg = await (await fetch('/data/combat.json', { cache: 'no-store' })).json(); const wpn = cfg.player.weapon; const a = Math.atan2(e.y - s.ship.y, e.x - s.ship.x); for (let i = -3; i <= 3; i++) s.firePlayer(a + i * 0.05, wpn); // A guaranteed enemy hit through the real collide path: spawn a // tracer ON the enemy so the first frame's swept segment crosses it. const e2 = s.enemies.find((x) => !x.dead); if (e2) s.spawnBullet(e2.x, e2.y, a, wpn, 'player'); return true; })()`); // Wait for the fan to land (the SFX dev log records an // impact/shield/hull voice — it stays true once it has fired) so // the enemy-hit feedback is live, then finish the cue set + capture. await p.waitTrue(`(() => { const log = window.__COMBAT_SFX__?.log ?? []; return log.some((x) => x.event === 'impact' || x.event === 'shield' || x.event === 'hull'); })()`, 8000).catch(() => null); await p.ev(`(() => { const s = window.game.scene.getScene('CombatScene'); // The kill (burst + kill feed + the thunder voice). const e = s.enemies.find((x) => !x.dead); if (e) s.killEnemy(e); // A player hit (damage number + the bar's gauge flash). s.shields = 40; s.hull = 100; s.damagePlayer(8); return true; })()`); await p.shot(`${OUT}/combat-range.png`); } finally { await p.close(); } } // ---------------------------------------------------------- the main { console.log('main (index.html?combat):'); const p = await openPage(`${BASE}/index.html?combat`); try { await p.waitTrue('Boolean(window.game && window.game.scene.getScene("MenuScene") && window.game.scene.getScene("MenuScene").seedValue)'); await p.ev('window.game.scene.getScene("MenuScene").startNewGame()'); await p.waitTrue('Boolean(window.__ORBIT_COMBAT__)'); // The new-run tutorial comms are 'ack' comms: each owns the screen // and pauses the sim until the player ACKs. For the combat shot we // clear the chain through the hub's own finish path (end the active // one, drop the pending queue, un-pause) so the fight is LIVE — // the sim ticks, the tracers fly, and the bar's gauge flash is // visible — instead of frozen behind a comm panel. await p.ev(`(() => { const g = window.game.scene.getScene('GameScene'); const h = g.comms; if (!h) return true; if (h.current) { try { h._finish(); } catch (e) {} } if (Array.isArray(h.queue)) h.queue.length = 0; h._setPaused(false); try { h.box?.close(); } catch (e) {} h.box = null; return true; })()`); // The camera follows the ship smoothly (game.camera.followRate). The // ship settles at its system rim AFTER create (tether), so the camera // must catch up — wait until it is centered on the ship, or the // ship + pack (now re-anchored on it) sit off-screen for the shot. await p.waitTrue(`(() => { const g = window.game.scene.getScene('GameScene'); const cam = g.cameras.main; const w = g.scale.width, h = g.scale.height; const cx = (cam.worldView?.x ?? cam.scrollX ?? cam.x) + w / 2; const cy = (cam.worldView?.y ?? cam.scrollY ?? cam.y) + h / 2; return Math.abs(cx - g.ship.x) < 24 && Math.abs(cy - g.ship.y) < 24; })()`, 8000).catch(() => null); await sleep(600); // the pack settles + the lock acquires + any drift damps await p.ev(`(async () => { const oc = window.__ORBIT_COMBAT__; const sys = oc.system; const t = sys.currentTarget() || sys.enemies.find((x) => !x.dead); if (!t) return false; const cfg = await (await fetch('/data/combat.json', { cache: 'no-store' })).json(); const wpn = cfg.player.weapon; const nose = (sys.ship.radius || 12) + 8; const a = Math.atan2(t.y - sys.ship.y, t.x - sys.ship.x); for (let i = -3; i <= 3; i++) { const aa = a + i * 0.05; sys.spawnBullet(sys.ship.x + Math.cos(aa) * nose, sys.ship.y + Math.sin(aa) * nose, aa, wpn, 'player'); } // A guaranteed enemy hit through the real collide path: spawn a // tracer ON the target so the first frame's swept segment crosses it. const t2 = sys.enemies.find((x) => !x.dead); if (t2) sys.spawnBullet(t2.x, t2.y, a, wpn, 'player'); return true; })()`); await p.waitTrue(`(() => { const log = window.__COMBAT_SFX__?.log ?? []; return log.some((x) => x.event === 'impact' || x.event === 'shield' || x.event === 'hull'); })()`, 8000).catch(() => null); await p.ev(`(() => { const oc = window.__ORBIT_COMBAT__; const sys = oc.system; const e = sys.enemies.find((x) => !x.dead); if (e) { e.shields = 0; e.damage(9999); sys.killEnemy(e, sys._now()); } sys.shields = 40; sys.hull = 100; sys.damagePlayer(8); // The level-up beat (the kill's XP + this grant crosses level 1 // — the LEVEL floater off the ship, the armor bump, and the bar's // XP row at level 1 with a live fill) — captured, ~110 ms in. sys.xp.grant(300, 'shot'); return true; })()`); await p.shot(`${OUT}/combat-main.png`); } finally { await p.close(); } } console.log('combat feedback screenshots: done'); } main().catch((err) => { console.error('combat-shot failed:', err.message); process.exit(1); });