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