342 lines
16 KiB
JavaScript
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);
|