/** * dev/stun-shot.mjs — the Boleadoras STUN READ in the real game: the strung * hostiles dim to the data's fx.stunAlpha and crackle with the discharge * (Fx.stunArms) while the snare holds, then recover (alpha back, bolts * settled) exactly when the data's clock runs out. * * node dev/stun-shot.mjs * * Needs: the user's headless Brave (CDP :9333) and a static server on * :8080 (or ORBIT_URL). Screenshots land in dev/shots/stun-*.png. */ import { mkdir, writeFile } from 'node:fs/promises'; const BASE = process.env.ORBIT_URL || 'http://127.0.0.1:8080'; const CDP = process.env.CDP_PORT ? `127.0.0.1:${CDP_PORT}` : '127.0.0.1:9333'; const URL = `${BASE}/index.html?skill=wraith&combat`; let target = await (await fetch(`http://${CDP}/json/list`)).json() .then((ts) => ts.find((t) => t.type === 'page')); if (!target) { target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(URL)}`, { method: 'PUT' })).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 })); }); const consoleErrors = []; await new Promise((res, rej) => { ws.onopen = res; ws.onerror = () => rej(new Error('ws connect')); }); ws.onmessage = (m) => { const msg = JSON.parse(m.data); if (msg.id && pending.has(msg.id)) { const { res, rej } = pending.get(msg.id); pending.delete(msg.id); msg.error ? rej(new Error(msg.error.message)) : res(msg.result); } else if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') { consoleErrors.push(msg.params.args.map((a) => a.value ?? a.description ?? '').join(' ')); } else if (msg.method === 'Runtime.exceptionThrown') { consoleErrors.push(msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text ?? 'exception'); } }; await send('Page.enable'); await send('Runtime.enable'); await send('Page.navigate', { url: 'about:blank' }); await new Promise((r) => setTimeout(r, 250)); await send('Page.navigate', { url: URL }); const evaluate = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error('page: ' + (r.exceptionDetails.exception?.description ?? r.exceptionDetails.text)); return r.result?.value; }; const shot = async (name) => { const r = await send('Page.captureScreenshot', { format: 'png' }); await mkdir('dev/shots', { recursive: true }); await writeFile(`dev/shots/${name}.png`, Buffer.from(r.data, 'base64')); }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // 1) wait for the MENU (seeded), start a NEW GAME, then wait for the scene // + the combat layer (ack any comm that holds the screen) const menuReady = `(function(){ const m = window.game && window.game.scene.getScene('MenuScene'); return !!(m && typeof m.seedValue === 'string' && m.seedValue.length > 0); })()`; let booted = false; { const deadline = Date.now() + 90000; while (Date.now() < deadline && !booted) { booted = await evaluate(menuReady).catch(() => false); await sleep(250); } if (!booted) { console.error('FAIL: the menu never became ready'); process.exit(1); } await evaluate(`(function(){ const m = window.game.scene.getScene('MenuScene'); if (m && !m.dead) m.startNewGame(); return true; })()`); const deadline2 = Date.now() + 90000; booted = false; while (Date.now() < deadline2 && !booted) { booted = await evaluate(`(function(){ const g = window.game && window.game.scene.getScene('GameScene'); if (g && g.comms?.paused) { try { g.comms._ack(); } catch {} } return !!(g && g.ship && g.skillState && g.combat && typeof g.combat.cast === 'function'); })()`).catch(() => false); await sleep(250); } if (!booted) { console.error('FAIL: the scene never became ready'); process.exit(1); } } await sleep(1500); // let the demo pack spawn + fly in // 2a) the dev door filed the wraith but its tree came in unranked — rank it // the way ?skill=max does (the door's own loop: grant + rankUpSkill) const ranked = await evaluate(`(function(){ const s = window.game.scene.getScene('GameScene'); const tree = window.config.data.skills.classes[s.classState.id].skills; s.classState.awardSkillPoints(tree.reduce((n, d) => n + (d.maxPoints || 0), 0)); const ranks = {}; for (const def of tree) { ranks[def.id] = 0; for (let i = 0; i < (def.maxPoints || 0); i++) { if (!s.rankUpSkill(def.id).ok) break; ranks[def.id] += 1; } } return ranks; })()`).catch((e) => ({ error: String(e) })); console.log('ranks after the door:', JSON.stringify(ranked)); // 2) make the player unkillable (the test's own trick) and note the roster await evaluate(`(function(){ const s = window.game.scene.getScene('GameScene'); const c = s.combat; c.maxHull = 1e6; c.hull = 1e6; c.maxShields = 1e6; c.shields = 1e6; return true; })()`); const before = await evaluate(`(function(){ const s = window.game.scene.getScene('GameScene'); const row = s.combat.skillState && s.combat.skillState.seatRow && s.combat.skillState.seatRow()[0]; return { enemies: s.combat.enemies.length, seat1: row && row.def ? { id: row.def.id, rank: s.combat.skillState.rank(row.def.id) } : null, energy: Math.floor(s.combat.energy), }; })()`); console.log('roster before cast:', JSON.stringify(before)); // 3) cast Boleadoras (seat 1) and wait for the snag const cast = await evaluate(`(function(){ const s = window.game.scene.getScene('GameScene'); return s.combat.cast(0); })()`); console.log('cast:', JSON.stringify({ ok: cast?.ok, cost: cast?.cost, bolas: cast?.bolas?.count })); // 4) poll for the strung read (dim + the live discharge) let strung = null; { const deadline = Date.now() + 8000; while (Date.now() < deadline) { const v = await evaluate(`(function(){ const s = window.game.scene.getScene('GameScene'); s._devReleaseComms && s._devReleaseComms(); const c = s.combat; const now = s.time.now; const es = c.enemies.filter((e) => !e.dead && (e.stunUntil || 0) > now); if (es.length < 3) return null; return es.map((e) => ({ alpha: +e.alpha.toFixed(3), base: e._stunBaseAlpha ?? null, arms: !!e._stunArms, stunLeftMs: Math.round((e.stunUntil || 0) - now), bolts: e._stunArms ? e._stunArms.bolts.length : null, })); })()`).catch(() => null); if (v) { strung = v; break; } await sleep(120); } } if (!strung) { console.error('FAIL: no strung read within 8s (the sets never landed?)'); process.exit(1); } console.log('STUNNED:', JSON.stringify(strung, null, 1)); // 5) capture the read (the crackle changes frame to frame — three beats) await shot('stun-1'); await sleep(420); await shot('stun-2'); await sleep(420); await shot('stun-3'); // 6) the release: alpha back, the discharge settled let released = null; { const deadline = Date.now() + 8000; while (Date.now() < deadline) { const v = await evaluate(`(function(){ const s = window.game.scene.getScene('GameScene'); s._devReleaseComms && s._devReleaseComms(); const c = s.combat; const now = s.time.now; const live = c.enemies.filter((e) => !e.dead && (e.stunUntil || 0) > now); if (live.length) return null; return c.enemies.filter((e) => !e.dead).map((e) => ({ alpha: +e.alpha.toFixed(3), arms: !!e._stunArms })); })()`).catch(() => null); if (v) { released = v; break; } await sleep(150); } } if (!released) { console.error('FAIL: the stun never released'); process.exit(1); } console.log('RELEASED:', JSON.stringify(released)); await shot('stun-4-released'); const errs = consoleErrors.filter((m) => !/favicon|404|font/i.test(m)); console.log(errs.length === 0 ? 'console: clean' : 'console errors:\n' + errs.join('\n')); ws.close(); process.exit(strung.every((e) => e.alpha < 0.9 && e.arms) && released.every((e) => e.alpha >= 0.99 && !e.arms) && errs.length === 0 ? 0 : 1);