orbit/dev/cdp-faction-arena.mjs

342 lines
16 KiB
JavaScript

// CDP smoke check for the FACTION DUEL ARENA (the ?combat=apex /
// ?combat=mindweave boots + the picker + the plain-?combat demo regression).
// Needs: a static server on :8080 (the repo root) and a headless Chrome
// with --remote-debugging-port=9333. Each scenario opens a fresh tab.
//
// node dev/cdp-faction-arena.mjs
import { setTimeout as sleep } from 'node:timers/promises';
const CDP = '127.0.0.1:9333';
let failures = 0;
const check = (label, cond, extra = '') => {
console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${extra ? ` [${extra}]` : ''}`);
if (!cond) failures += 1;
};
class Tab {
constructor(info) { this.info = info; this.seq = 0; this.pending = new Map(); }
async attach() {
this.ws = new WebSocket(this.info.webSocketDebuggerUrl);
this.ws.onmessage = (m) => {
const q = JSON.parse(m.data);
if (q.id && this.pending.has(q.id)) {
const o = this.pending.get(q.id); this.pending.delete(q.id);
q.error ? o.rej(new Error(q.error.message)) : o.res(q.result);
}
};
await new Promise((res, rej) => { this.ws.onopen = res; this.ws.onerror = () => rej(new Error('ws')); });
await this.send('Runtime.enable');
}
send(method, params = {}) {
return new Promise((res, rej) => {
const id = ++this.seq;
this.pending.set(id, { res, rej });
this.ws.send(JSON.stringify({ id, method, params }));
setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); rej(new Error('timeout ' + method)); } }, 15000);
});
}
async eval(expr) {
const r = await this.send('Runtime.evaluate', { expression: expr, returnByValue: true });
if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails).slice(0, 1200));
return r.result.value;
}
close() { try { this.ws.close(); } catch { /* already gone */ } }
}
async function newTab(url) {
try {
const r = await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' });
const t = await r.json();
if (t && t.webSocketDebuggerUrl) return new Tab(t);
} catch { /* fall through to POST */ }
const r = await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'POST' });
return new Tab(await r.json());
}
const S = `window.game.scene.getScene('GameScene')`;
const C = `${S}.combat`;
// The menu's NEW GAME button (its panel is the interactive child — the
// CDP stand-in for a click; the menu is the only way into the game).
async function startGame(tab) {
for (let i = 0; i < 60; i++) {
const ok = await tab.eval(`!!(window.game && window.game.scene && window.game.scene.getScene('MenuScene') && window.game.scene.getScene('MenuScene').newGameBtn)`);
if (ok) break;
await sleep(500);
}
await tab.eval(`${S && 'window.game'} && window.game.scene && window.game.scene.getScene('MenuScene') ? window.game.scene.getScene('MenuScene').newGameBtn.panel.emit('pointerdown') : null; true`);
for (let i = 0; i < 60; i++) {
const ok = await tab.eval(`!!(window.game && window.game.scene && window.game.scene.getScene('GameScene') && window.game.scene.getScene('GameScene').combat)`);
if (ok) break;
await sleep(500);
}
}
// ------------------------------------------------------------------ apex tab
{
const tab = await newTab('http://127.0.0.1:8080/index.html?combat=apex');
await tab.attach();
await startGame(tab); // the menu's NEW GAME → the arena's boot
await sleep(3000); // the scene's settle
let v = await tab.eval(`JSON.stringify({
boot: ${S}._combatBootMode(),
duel: ${C}.duel,
rows: ${S}.duelPicker ? ${S}.duelPicker.rows().map(r => r.id) : null,
})`);
const boot = JSON.parse(v);
check('apex: ?combat=apex boots the arena (boot mode, duel state, 8 picker rows)',
boot.boot === 'apex' && boot.duel && boot.duel.faction === 'apex' && boot.duel.active === false &&
Array.isArray(boot.rows) && boot.rows.length === 8 && boot.rows[0] === 'apex_raptor', v);
// Fighter 1 (the Raptor — the forward sweep + the flank's cut).
await tab.eval(`${S}.duelPicker.select(1); true`);
await sleep(1500);
v = await tab.eval(`(() => { const e = ${C}.enemies[0]; return JSON.stringify({
active: ${C}.duel.active, n: ${C}.enemies.length,
name: e ? e.name : null, pattern: e ? e.wpn.pattern : null,
abilities: e ? e.abilities.map(a => a.id) : null,
armor: e ? (e.hull + (e.shields||0)) : null,
}); })()`);
let f = JSON.parse(v);
check('apex: pick 1 = the Raptor, marked duel, no respawn, 78 armor, its own kit',
f.active === true && f.n === 1 && f.name === 'Raptor' && f.pattern === 'sweep' &&
f.abilities.includes('flank_strafe') && f.armor === 78, v);
v = await tab.eval(`(() => {
const c = ${S}.combat;
const e = c.enemies[0];
const pl = c.ship;
if (!e || !pl) return JSON.stringify({ reason: 'no-enemy-or-player' });
let t = (c._now ? c._now() : 100000) + 1000;
let fired = false;
for (let i = 0; i < 400 && !fired; i++) {
t += 16;
e.nextFireAt = 0; // its clock is ready; only the aim gate stands between
if (e.update(t, 16, pl)) fired = true;
}
if (!fired) return JSON.stringify({ fired: false });
c.fireEnemy(e, pl, t);
return JSON.stringify({ fired: true, bolts: c.bullets.length, ms: c.missiles.length });
})()`);
f = JSON.parse(v);
check('apex: the Raptor fires (its sweep bolts land in the world)', f.fired === true && f.bolts >= 5, v);
await tab.eval(`const e=${C}.enemies[0]; if (e) { e.damage(9999); ${C}.killEnemy(e, 0); } true`);
await sleep(800);
v = await tab.eval(`${C}.duel.active`);
check('apex: the kill releases the arena (duel.active back to the picker)', v === false);
// The whole pack (key 0).
await tab.eval(`${S}.duelPicker.select(0); true`);
await sleep(1500);
v = await tab.eval(`JSON.stringify({ n: ${C}.enemies.length, names: ${C}.enemies.map(e => e.name), allDuel: ${C}.enemies.every(e => e.duel === true) })`);
f = JSON.parse(v);
check('apex: pick 0 = the whole pack (8 fighters, all marked)',
f.n === 8 && f.allDuel === true && f.names.includes('Alpha') && f.names.includes('Stalker'), v);
await tab.eval(`${C}.enemies.slice().forEach(e => { e.damage(9999); ${C}.killEnemy(e, 0); }); true`);
await sleep(800);
check('apex: the pack down-drop releases the arena', (await tab.eval(`${C}.duel.active`)) === false);
tab.close();
}
// ------------------------------------------------------------------ mindweave tab
{
const tab = await newTab('http://127.0.0.1:8080/index.html?combat=mindweave');
await tab.attach();
await startGame(tab); // the menu's NEW GAME → the arena's boot
await sleep(3000);
const v = await tab.eval(`JSON.stringify({
boot: ${S}._combatBootMode(),
duel: ${C}.duel,
rows: ${S}.duelPicker ? ${S}.duelPicker.rows().map(r => r.id) : null,
})`);
const boot = JSON.parse(v);
check('mindweave: ?combat=mindweave boots the arena (8 rows, first = the Mindblade)',
boot.boot === 'mindweave' && boot.duel.faction === 'mindweave' && boot.duel.active === false &&
boot.rows && boot.rows.length === 8 && boot.rows[0] === 'weave_mindblade', v);
await tab.eval(`${S}.duelPicker.select(1); true`);
await sleep(1500);
const f0 = JSON.parse(await tab.eval(`(() => { const e = ${C}.enemies[0]; return JSON.stringify({ active: ${C}.duel.active, n: ${C}.enemies.length, name: e && e.name, pattern: e && e.wpn.pattern, ab: e && e.abilities.map(a => a.id) }); })()`));
check('mindweave: pick 1 = the Mindblade (spiral + the blink), 78 armor',
f0.active === true && f0.n === 1 && f0.name === 'Mindblade' && f0.pattern === 'spiral' && f0.ab.includes('blink_reposition'), JSON.stringify(f0));
await sleep(1500);
const f1 = JSON.parse(await tab.eval(`(() => {
const c = ${S}.combat;
const e = c.enemies[0];
const pl = c.ship;
if (!e || !pl) return JSON.stringify({ reason: 'no-enemy-or-player' });
let t = (c._now ? c._now() : 100000) + 1000;
let fired = false;
for (let i = 0; i < 400 && !fired; i++) {
t += 16;
e.nextFireAt = 0;
if (e.update(t, 16, pl)) fired = true;
}
if (!fired) return JSON.stringify({ fired: false });
c.fireEnemy(e, pl, t);
return JSON.stringify({ fired: true, bolts: c.bullets.length, il: c.illusions.length });
})()`));
check('mindweave: the Mindblade fires (its spiral volley lands in the world)',
f1.fired === true && f1.bolts >= 10, JSON.stringify(f1));
await tab.eval(`const e=${C}.enemies[0]; if (e) { e.damage(9999); ${C}.killEnemy(e, 0); } true`);
await sleep(800);
check('mindweave: the kill releases the arena', (await tab.eval(`${C}.duel.active`)) === false);
// The Dreamshell (pick 6 — the large slow shells).
await tab.eval(`${S}.duelPicker.select(6); true`);
await sleep(1500);
const f2 = JSON.parse(await tab.eval(`(() => { const e = ${C}.enemies[0]; return JSON.stringify({ name: e && e.name, pattern: e && e.wpn.pattern, ab: e && e.abilities.map(a => a.id) }); })()`));
check('mindweave: pick 6 = the Dreamshell (shell + the tide + the phantoms)',
f2.name === 'Dreamshell' && f2.pattern === 'shell' && f2.ab.includes('telekinetic_wave'), JSON.stringify(f2));
const f3 = JSON.parse(await tab.eval(`(() => {
const c = ${S}.combat;
const e = c.enemies[0];
const pl = c.ship;
if (!e || !pl) return JSON.stringify({ reason: 'no-enemy-or-player' });
let t = (c._now ? c._now() : 100000) + 1000;
let fired = false;
for (let i = 0; i < 400 && !fired; i++) {
t += 16;
e.nextFireAt = 0; // its clock is ready; only the aim gate stands between
if (e.update(t, 16, pl)) fired = true;
}
if (!fired) return JSON.stringify({ fired: false });
c.fireEnemy(e, pl, t);
return JSON.stringify({ fired: true, bolts: c.bullets.length, ms: c.missiles.length });
})()`));
check('mindweave: the Dreamshell fires (its large shells land in the world)',
f3.fired === true && f3.bolts >= 1, JSON.stringify(f3));
tab.close();
}
// ------------------------------------------------------------------ zenith tab
{
const tab = await newTab('http://127.0.0.1:8080/index.html?combat=zenith');
await tab.attach();
await startGame(tab); // the menu's NEW GAME → the arena's boot
await sleep(3000);
const v0 = await tab.eval(`JSON.stringify({
boot: ${S}._combatBootMode(),
duel: ${C}.duel,
rows: ${S}.duelPicker ? ${S}.duelPicker.rows().map(r => r.id) : null,
})`);
const boot = JSON.parse(v0);
check('zenith: ?combat=zenith boots the arena (8 rows, first = the Wingblade)',
boot.boot === 'zenith' && boot.duel.faction === 'zenith' && boot.duel.active === false &&
boot.rows && boot.rows.length === 8 && boot.rows[0] === 'zenith_wingblade' &&
boot.rows[4] === 'zenith_voltspire', v0);
// Fighter 1 (the Wingblade — the rapid gun + the six-seeker wing volley).
await tab.eval(`${S}.duelPicker.select(1); true`);
await sleep(1500);
const f0 = JSON.parse(await tab.eval(`(() => { const e = ${C}.enemies[0]; return JSON.stringify({ active: ${C}.duel.active, n: ${C}.enemies.length, name: e && e.name, pattern: e && e.wpn.pattern, ab: e && e.abilities.map(a => a.id), armor: e ? e.hull + (e.shields || 0) : null }); })()`));
check('zenith: pick 1 = the Wingblade (rapid gun + the flank_barrage), 210 armor',
f0.active === true && f0.n === 1 && f0.name === 'Wingblade' && f0.pattern === 'rapid' &&
f0.ab.includes('flank_barrage') && f0.armor === 210, JSON.stringify(f0));
// THE WING VOLLEY: run the combat clock until the ability fires and the
// seekers land on the shared missile roster (the armor is buffed so the
// volley can't down the player mid-check).
const f1 = JSON.parse(await tab.eval(`(() => {
const c = ${C}; const e = c.enemies[0]; const pl = c.ship;
if (!e || !pl) return JSON.stringify({ reason: 'no-enemy-or-player' });
c.hull = 999999; c.shields = 999999;
let t = (c._now ? c._now() : 100000) + 1000;
for (let i = 0; i < 2000 && (c.missiles || []).length === 0; i++) { t += 16; c.update(t, 16); }
const ms = c.missiles || [];
return JSON.stringify({
ms: ms.length,
turn: ms.map(m => m.turnRate),
dmg: ms.map(m => m.damage),
target: ms.length > 0 ? ms[0].targetRef === pl : null,
eHdg: typeof e.heading === 'function' ? e.heading() : null,
mHdg: ms.length > 0 ? ms[0].heading : null,
});
})()`));
check('zenith: the wing volley fires (six seekers, SLOW capped seek, homing on the player)',
f1.ms >= 6 && f1.turn.every(r => r <= 1.3) && f1.dmg.every(d => d === 16) && f1.target === true,
JSON.stringify(f1));
await tab.eval(`const e=${C}.enemies[0]; if (e) { e.damage(9999); ${C}.killEnemy(e, 0); } true`);
await sleep(800);
check('zenith: the kill releases the arena', (await tab.eval(`${C}.duel.active`)) === false);
// Fighter 5 (the Voltspire — the lance + the shock dart / electrified zone).
await tab.eval(`${S}.duelPicker.select(5); true`);
await sleep(1500);
const f2 = JSON.parse(await tab.eval(`(() => { const e = ${C}.enemies[0]; return JSON.stringify({ active: ${C}.duel.active, n: ${C}.enemies.length, name: e && e.name, pattern: e && e.wpn.pattern, ab: e && e.abilities.map(a => a.id) }); })()`));
check('zenith: pick 5 = the Voltspire (lance + the shock_dart), 210 armor',
f2.active === true && f2.n === 1 && f2.name === 'Voltspire' && f2.pattern === 'lance' && f2.ab.includes('shock_dart'), JSON.stringify(f2));
// THE VOLT LINE: run the clock until the dart detonates into a zone,
// then watch the zone GROW (the bloom) and TICK (the DoT on the player
// standing inside) — the armor is buffed and tracked for the delta.
const f3 = JSON.parse(await tab.eval(`(() => {
const c = ${C}; const e = c.enemies[0]; const pl = c.ship;
if (!e || !pl) return JSON.stringify({ reason: 'no-enemy-or-player' });
c.hull = 999999; c.shields = 999999;
let armor0 = c.hull + c.shields;
let t = (c._now ? c._now() : 100000) + 1000;
let zone = null;
for (let i = 0; i < 2000; i++) {
t += 16; c.update(t, 16);
if ((c.shockzones || []).length > 0) { zone = c.shockzones[0]; break; }
}
if (!zone) return JSON.stringify({ zone: false });
const r0 = zone.r;
let tickDmg = 0;
for (let i = 0; i < 300; i++) {
t += 16; c.update(t, 16);
zone = (c.shockzones || [])[0] || zone;
if ((c.hull + c.shields) < armor0 - 3) { tickDmg = armor0 - (c.hull + c.shields); break; }
}
return JSON.stringify({
zone: true,
r0: Math.round(r0), r1: zone ? Math.round(zone.r) : null,
maxR: zone ? zone.maxR : null,
finite: zone ? (zone.end - zone.born) : null,
tickDmg: Math.round(tickDmg),
});
})()`));
check('zenith: the shock dart blooms a GROWING electrified zone that ticks damage (finite life)',
f3.zone === true && f3.r1 > f3.r0 && f3.r1 <= f3.maxR && f3.finite <= 5000 && f3.tickDmg >= 3, JSON.stringify(f3));
await tab.eval(`const e=${C}.enemies[0]; if (e) { e.damage(9999); ${C}.killEnemy(e, 0); } true`);
await sleep(800);
check('zenith: the Voltspire kill releases the arena', (await tab.eval(`${C}.duel.active`)) === false);
// The whole pack (key 0 — all eight, both archetypes).
await tab.eval(`${S}.duelPicker.select(0); true`);
await sleep(1500);
const f4 = JSON.parse(await tab.eval(`JSON.stringify({ n: ${C}.enemies.length, names: ${C}.enemies.map(e => e.name), allDuel: ${C}.enemies.every(e => e.duel === true) })`));
check('zenith: pick 0 = the whole pack (8 fighters, all marked)',
f4.n === 8 && f4.allDuel === true && f4.names.includes('Tempest') && f4.names.includes('Kestrel'), JSON.stringify(f4));
await tab.eval(`${C}.enemies.slice().forEach(e => { e.damage(9999); ${C}.killEnemy(e, 0); }); true`);
await sleep(800);
check('zenith: the pack down-drop releases the arena', (await tab.eval(`${C}.duel.active`)) === false);
tab.close();
}
// ------------------------------------------------------------------ the demo regression tab
{
const tab = await newTab('http://127.0.0.1:8080/index.html?combat');
await tab.attach();
await startGame(tab); // the menu's NEW GAME → the demo's boot
await sleep(10000);
const v = await tab.eval(`JSON.stringify({
boot: ${S}._combatBootMode(),
wave: ${C}.waveRun ? ${C}.waveRun.state : null,
enemies: ${C}.enemies.length,
picker: !!${S}.duelPicker,
})`);
const d = JSON.parse(v);
check('demo: the plain ?combat STILL boots the seven-type wave demo (unchanged regression)',
d.boot === 'demo' && d.wave && !['cancelled', 'done'].includes(d.wave) && d.picker === false, v);
tab.close();
}
console.log(failures === 0 ? '\nfaction arena CDP: ALL CHECKS PASSED' : `\n${failures} check(s) FAILED`);
process.exit(failures === 0 ? 0 : 1);