// Visual test for the engine flare (js/visuals/EngineFlare.js). // Loads the real game in headless Chromium, starts GameScene, drives the // ship through idle -> ignition -> full burn -> coast -> stopped, capturing // a screenshot + fx state at each beat. // // node dev/engine-flare-test.mjs import { spawn } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; const outDir = process.argv[2] || '/tmp/efx'; fs.mkdirSync(outDir, { recursive: true }); const PORT = 9450 + Math.floor(Math.random() * 90); const HOME = process.env.HOME || os.homedir(); const CHROME = [ path.join(HOME, '.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell'), path.join(HOME, '.cache/ms-playwright/chromium-1234/chrome-linux64/chrome'), ].find((p) => fs.existsSync(p)); if (!CHROME) throw new Error('no chromium'); const udd = fs.mkdtempSync(path.join(os.tmpdir(), 'efx-')); const chrome = spawn(CHROME, [ '--headless', `--remote-debugging-port=${PORT}`, `--user-data-dir=${udd}`, '--no-first-run', '--disable-gpu', '--enable-unsafe-swiftshader', '--no-sandbox', '--window-size=960,600', 'about:blank', ], { stdio: 'ignore' }); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); try { let v; for (let i = 0; i < 100; i++) { try { v = await (await fetch(`http://127.0.0.1:${PORT}/json/version`)).json(); break; } catch { await sleep(200); } } if (!v) throw new Error('CDP never came up'); const t = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent('http://127.0.0.1:8123/index.html')}`, { method: 'PUT' })).json(); const ws = new WebSocket(t.webSocketDebuggerUrl); let id = 0; const p = new Map(); const send = (m, pa = {}) => new Promise((res, rej) => { const i = ++id; p.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method: m, params: pa })); }); ws.onmessage = (m) => { const x = JSON.parse(m.data); if (x.id && p.has(x.id)) { const { res, rej } = p.get(x.id); p.delete(x.id); x.error ? rej(new Error(x.error.message)) : res(x.result); } }; await new Promise((r) => (ws.onopen = r)); await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable'); await send('Network.setCacheDisabled', { cacheDisabled: true }); const evaluate = 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).slice(0, 500)); return r.result?.value; }; const shot = async (name) => { const s = await send('Page.captureScreenshot', { format: 'png' }); const f = path.join(outDir, name + '.png'); fs.writeFileSync(f, Buffer.from(s.data, 'base64')); return f; }; // Wait for game boot + GameScene ready. const ready = async (expr, ms = 30000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { const r = await evaluate(expr).catch(() => null); if (r) return r; await sleep(250); } throw new Error('timeout waiting: ' + expr); }; await ready('window.game && window.game.isRunning'); // Start the game scene directly (skip the menu). await evaluate('(() => { window.game.scene.start("GameScene"); return "started"; })()'); await ready('(() => { const s = window.game.scene.getScene("GameScene"); return s && s.ship && s.engineFx ? { burn: s.engineFx.burn, nozzles: s.engineFx.nozzles?.length } : null; })()', 30000); // Keep the test deterministic: an auto-playing 'ack' comm pauses the // action contract (ship.stop()) mid-sequence. Quiet the comms hub — // the effect under test (engine flare) does not depend on it. await evaluate('(() => { const s = window.game.scene.getScene("GameScene"); if (s.comms) { s.comms._down = true; s.comms._setPaused(false); } return "ok"; })()'); const fx = () => `(() => { const s = window.game.scene.getScene("GameScene"); const f = s.engineFx; const sh = s.ship; const alive = f.emitters.reduce((n, e) => n + (e.alive ? e.alive.length : 0), 0); return { burn: +f.burn.toFixed(3), flare: +f.flare.toFixed(3), burst: +f.burst.toFixed(3), alive, speed: +Math.hypot(sh.body.velocity.x, sh.body.velocity.y).toFixed(1), state: sh.state, accel: +Math.hypot(sh.body.acceleration.x, sh.body.acceleration.y).toFixed(1), }; })()`; const base = await evaluate('(() => { const sh = window.game.scene.getScene("GameScene").ship; return { x: Math.round(sh.x), y: Math.round(sh.y) }; })()'); console.log('ship at', base); // Some seeds spawn the ship touching the tether/a solid (it is then braked // on contact — real game behavior). Clear that first so the thrust test // can run: if a constraint touches, step the ship out of it. const clear = await evaluate(`(() => { const s = window.game.scene.getScene("GameScene"); const sh = s.ship; const touches = () => !!(s.tetherField.constrainShip(sh) || s.solids.some((o) => o.constrainShip(sh, sh.radius))); if (!touches()) return { ok: true, wasTouching: false }; // Step the ship out of whatever touches it: away from the nearest // tether anchor / solid center. let best = null; for (const t of s.tetherField.tethers) { const d = Math.hypot(sh.x - t.x, sh.y - t.y); if (d < t.radius + 40) best = { x: t.x, y: t.y }; } if (!best) { let min = Infinity; for (const o of s.solids) { const d = Math.hypot(sh.x - o.x, sh.y - o.y); if (d < min) { min = d; best = { x: o.x, y: o.y }; } } } for (let i = 0; i < 20 && touches(); i++) { const d = Math.hypot(sh.x - best.x, sh.y - best.y) || 1; sh.setPosition(sh.x + ((sh.x - best.x) / d) * 80, sh.y + ((sh.y - best.y) / d) * 80); sh.body.velocity.set(0, 0); sh.body.acceleration.set(0, 0); } return { ok: !touches(), wasTouching: true, pos: [Math.round(sh.x), Math.round(sh.y)] }; })()`); if (!clear.ok) throw new Error('could not clear obstacle: ' + JSON.stringify(clear)); if (clear.wasTouching) console.log('cleared obstacle at', clear.pos); const pos = await evaluate('(() => { const sh = window.game.scene.getScene("GameScene").ship; return { x: Math.round(sh.x), y: Math.round(sh.y) }; })()'); Object.assign(base, pos); // 0) idle await sleep(500); console.log('idle ', JSON.stringify(await evaluate(fx()))); await shot('0-idle'); // 1) ignition: full throttle lock. The spawn seed decides where the // tether rim and solids are, so RAYCAST candidate headings against // the real geometry (the ship must stay inside the tether union and // clear of solid worlds) and use the first clear corridor. const corridor = await evaluate(`(() => { const s = window.game.scene.getScene("GameScene"); const sh = s.ship; const tethers = s.tetherField.tethers; const insideUnion = (x, y) => tethers.some((t) => { const dx = x - t.x, dy = y - t.y; return dx * dx + dy * dy <= (t.radius + 1) * (t.radius + 1); }); const clearOfSolids = (x, y) => s.solids.every((o) => Math.hypot(o.x - x, o.y - y) > (o.radius ?? 30) + sh.radius + 80); for (let a = 0; a < 360; a += 10) { const rad = a * Math.PI / 180; let good = true; for (let d = 150; d <= 750; d += 150) { const x = sh.x + Math.cos(rad) * d, y = sh.y + Math.sin(rad) * d; if (!insideUnion(x, y) || !clearOfSolids(x, y)) { good = false; break; } } if (good) return { ang: a, tx: Math.round(sh.x + Math.cos(rad) * 900), ty: Math.round(sh.y + Math.sin(rad) * 900) }; } return null; })()`); if (!corridor) throw new Error('no clear flight corridor found'); console.log('corridor ang=' + corridor.ang + ' target', corridor.tx, corridor.ty); await evaluate(`(() => { const s = window.game.scene.getScene("GameScene"); s.ship.thrustToward(${corridor.tx}, ${corridor.ty}); return "ok"; })()`); await sleep(130); console.log('ignite ', JSON.stringify(await evaluate(fx()))); await shot('1-ignite'); // 2) full burn (throttle lock holds) await sleep(700); console.log('burn ', JSON.stringify(await evaluate(fx()))); await shot('2-burn'); // 3) coast: cut throttle -> burn decays, trail fades await evaluate('(() => { const s = window.game.scene.getScene("GameScene"); s.ship.stop(); return s.ship.state; })()'); await sleep(160); console.log('coast ', JSON.stringify(await evaluate(fx()))); await shot('3-coast'); // 4) settled: nothing lit, no live particles await sleep(900); console.log('done ', JSON.stringify(await evaluate(fx()))); await shot('4-settled'); ws.close(); } finally { chrome.kill(); setTimeout(() => fs.rmSync(udd, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }), 1500); }