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