208 lines
8.0 KiB
JavaScript
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);
|