orbit/dev/stun-shot.mjs

208 lines
8.0 KiB
JavaScript

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