orbit/dev/zenith-arena-browser.mjs

193 lines
10 KiB
JavaScript

/**
* dev/zenith-arena-browser.mjs — the ?combat=zenith duel arena in a REAL
* browser (Playwright, headless Chromium; the same environment the
* *-browser.test.mjs files use):
*
* node dev/server.mjs 8123 & # the static dev server
* node dev/zenith-arena-browser.mjs
*
* What it pins (the goal's verification contract, in the browser):
* · ?combat=zenith boots the duel arena — zero console errors, the
* picker's 8 rows (Wingblade → Tempest), the duel state;
* · the COMBO archetype: the Wingblade's wing volley — seekers out of
* both wings, the SLOW capped seek (out-turnable), homing on the
* player;
* · the ELEMENTAL archetype: the Voltspire's dart detonating into a
* GROWING electrified zone that ticks damage and then dies;
* · '0' = the whole pack (8 fighters, all duel-marked);
* · the regressions: ?combat=apex still boots the APEX arena, and a
* bare ?combat still boots the seven-type demo.
*/
import { pathToFileURL } from 'node:url';
import { setTimeout as sleep } from 'node:timers/promises';
const { chromium } = await import(
pathToFileURL('/home/brianfertig/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs').href
);
const BASE = process.env.ORBIT_URL || 'http://127.0.0.1:8123';
let fails = 0;
const check = (msg, cond, extra = '') => {
console.log(` ${cond ? '✔' : '✘ FAIL'} ${msg}${extra && !cond ? ` [${extra}]` : ''}`);
if (!cond) fails++;
};
const browser = await chromium.launch({
headless: true,
executablePath: '/home/brianfertig/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome',
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--autoplay-policy=no-user-gesture-required'],
});
const errors = [];
const S = `window.game.scene.getScene('GameScene')`;
const C = `${S}.combat`;
async function openScene(url) {
const page = await browser.newPage({ viewport: { width: 1440, height: 860 } });
const log = [];
page.on('pageerror', (e) => log.push(String(e)));
page.on('console', (m) => {
// the repo's own browser tests filter the dev-server favicon 404 (no
// favicon in the repo) — 'Failed to load resource' with no stack is that.
if (m.type() === 'error' && !/Failed to load resource/i.test(m.text())) log.push(m.text());
});
await page.goto(`${BASE}${url}`, { waitUntil: 'load' });
for (let i = 0; i < 80; i++) {
const ready = await page.evaluate(`!!(window.game && window.game.scene && window.game.scene.getScene('MenuScene') && window.game.scene.getScene('MenuScene').newGameBtn)`);
if (ready) break;
await sleep(250);
}
await page.evaluate(`window.game.scene.getScene('MenuScene').newGameBtn.panel.emit('pointerdown'); true`);
for (let i = 0; i < 80; i++) {
const ready = await page.evaluate(`!!(window.game && window.game.scene && window.game.scene.getScene('GameScene') && window.game.scene.getScene('GameScene').combat)`);
if (ready) break;
await sleep(250);
}
await sleep(2500); // the scene's settle
return { page, log };
}
// ------------------------------------------------------------------ zenith
{
const { page, log } = await openScene('/index.html?combat=zenith');
check('zenith: zero console/page errors through the boot', log.length === 0, JSON.stringify(log.slice(0, 3)));
const boot = await page.evaluate(`JSON.stringify({
boot: ${S}._combatBootMode(),
duel: ${C}.duel,
rows: ${S}.duelPicker ? ${S}.duelPicker.rows().map(r => r.id) : null,
styles: ${S}.duelPicker ? ${S}.duelPicker.rows().length : 0,
})`);
const b = JSON.parse(boot);
check('zenith: ?combat=zenith boots the arena (8 rows, Wingblade first, Tempest last)',
b.boot === 'zenith' && b.duel.faction === 'zenith' && b.duel.active === false &&
b.rows && b.rows.length === 8 && b.rows[0] === 'zenith_wingblade' && b.rows[7] === 'zenith_tempest', boot);
// THE COMBO ARCHETYPE — the Wingblade (pick 1): the wing volley.
await page.evaluate(`${S}.duelPicker.select(1); true`);
await sleep(1200);
const f0 = JSON.parse(await page.evaluate(`(() => { 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, sprite: e ? !!(e.texture || e.sheetFrame) : null }); })()`));
check('zenith: pick 1 = the Wingblade (rapid + flank_barrage), 210 armor, a visible ship',
f0.active === true && f0.n === 1 && f0.name === 'Wingblade' && f0.pattern === 'rapid' &&
f0.ab.includes('flank_barrage') && f0.armor === 210, JSON.stringify(f0));
const f1 = JSON.parse(await page.evaluate(`(() => {
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 || [];
const eH = typeof e.heading === 'function' ? e.heading() : null;
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,
wing90: eH == null ? null : ms.every(m => Math.abs(((m.heading - eH - Math.PI / 2) % (Math.PI * 2) + Math.PI * 3) % (Math.PI * 2) - Math.PI) <= 0.4 || Math.abs(((m.heading - eH + Math.PI / 2) % (Math.PI * 2) + Math.PI * 3) % (Math.PI * 2) - Math.PI) <= 0.4),
missileSprite: ms.length > 0 ? !!ms[0].texture : null,
});
})()`));
check('zenith: the wing volley flies (seekers out of BOTH wings, ≤1.3 rad/s seek, 16 dmg, homing)',
f1.ms >= 6 && f1.turn.every((r) => r <= 1.3) && f1.dmg.every((d) => d === 16) &&
f1.target === true && f1.wing90 === true && f1.missileSprite === true, JSON.stringify(f1));
await page.evaluate(`const e=${C}.enemies[0]; if (e) { e.damage(9999); ${C}.killEnemy(e, 0); } true`);
await sleep(700);
check('zenith: the Wingblade kill releases the arena', (await page.evaluate(`${C}.duel.active`)) === false);
// THE ELEMENTAL ARCHETYPE — the Voltspire (pick 5): the shock line.
await page.evaluate(`${S}.duelPicker.select(5); true`);
await sleep(1200);
const f2 = JSON.parse(await page.evaluate(`(() => { 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 + shock_dart)',
f2.active === true && f2.n === 1 && f2.name === 'Voltspire' && f2.pattern === 'lance' && f2.ab.includes('shock_dart'), JSON.stringify(f2));
const f3 = JSON.parse(await page.evaluate(`(() => {
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 dmg = 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) { dmg = armor0 - (c.hull + c.shields); break; }
}
return JSON.stringify({
zone: true,
zoneSprite: !!zone.sprite,
r0: Math.round(r0), r1: zone ? Math.round(zone.r) : null,
maxR: zone ? zone.maxR : null,
finite: zone ? zone.end - zone.born : null,
dmg: Math.round(dmg),
});
})()`));
check('zenith: the shock dart blooms a VISIBLE GROWING zone that ticks damage (finite)',
f3.zone === true && f3.zoneSprite === true && f3.r1 > f3.r0 && f3.r1 <= f3.maxR &&
f3.finite <= 5000 && f3.dmg >= 3, JSON.stringify(f3));
await page.evaluate(`const e=${C}.enemies[0]; if (e) { e.damage(9999); ${C}.killEnemy(e, 0); } true`);
await sleep(700);
check('zenith: the Voltspire kill releases the arena', (await page.evaluate(`${C}.duel.active`)) === false);
// THE PACK (pick 0): all eight at once, both archetypes.
await page.evaluate(`${S}.duelPicker.select(0); true`);
await sleep(1200);
const f4 = JSON.parse(await page.evaluate(`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 page.evaluate(`${C}.enemies.slice().forEach(e => { e.damage(9999); ${C}.killEnemy(e, 0); }); true`);
await sleep(700);
check('zenith: the pack down-drop releases the arena', (await page.evaluate(`${C}.duel.active`)) === false);
await page.close();
}
// ------------------------------------------------------------------ apex regression
{
const { page, log } = await openScene('/index.html?combat=apex');
const boot = JSON.parse(await page.evaluate(`JSON.stringify({ boot: ${S}._combatBootMode(), rows: ${S}.duelPicker ? ${S}.duelPicker.rows().map(r => r.id) : null })`));
check('regression: ?combat=apex STILL boots the APEX arena (8 rows, Raptor first)',
boot.boot === 'apex' && boot.rows && boot.rows.length === 8 && boot.rows[0] === 'apex_raptor', JSON.stringify(boot));
check('regression: the apex boot is quiet (no console errors)', log.length === 0, JSON.stringify(log.slice(0, 3)));
await page.close();
}
// ------------------------------------------------------------------ the demo regression
{
const { page, log } = await openScene('/index.html?combat');
const d = JSON.parse(await page.evaluate(`JSON.stringify({ boot: ${S}._combatBootMode(), wave: ${C}.waveRun ? ${C}.waveRun.state : null, picker: !!${S}.duelPicker })`));
check('regression: the plain ?combat STILL boots the seven-type demo',
d.boot === 'demo' && d.wave && !['cancelled', 'done'].includes(d.wave) && d.picker === false, JSON.stringify(d));
check('regression: the demo boot is quiet (no console errors)', log.length === 0, JSON.stringify(log.slice(0, 3)));
await page.close();
}
await browser.close();
console.log(fails === 0 ? '\nzenith arena (browser): ALL CHECKS PASSED' : `\n${fails} check(s) FAILED`);
process.exit(fails === 0 ? 0 : 1);