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