/** * Headless Chromium Skill Verification & Screenshot Runner (`scripts/verify-skill-browser.ts`). * * Usage: * npx tsx scripts/verify-skill-browser.ts [--class=sor] [--slvl=20] [--out=/tmp/skill-47.png] * * Launches Vite (if not already running on 127.0.0.1:5173) + headless Chromium * (`/usr/local/google/home/taodao/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome`), * opens `/acts.html?arena=1&class=&skill=&slvl=&autocast=1`, * verifies `window.__d2webArena.ready === true && castCount >= 1` and zero runtime errors, * and saves a PNG screenshot to `--out`. */ import { spawn } from 'node:child_process' import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { createServer } from 'vite' import { getSharedDataRegistry } from '../src/game/engine/data-registry.ts' const CHROME_CANDIDATES = [ '/usr/local/google/home/taodao/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome', '/usr/bin/google-chrome', ] function resolveChromeBinary(): string { for (const bin of CHROME_CANDIDATES) { if (existsSync(bin)) return bin } throw new Error(`No accessible Chromium binary found among: ${CHROME_CANDIDATES.join(', ')}`) } const sleep = (ms: number) => new Promise(res => setTimeout(res, ms)) export interface BrowserSkillVerificationResult { readonly ok: boolean readonly url: string readonly screenshotPath: string readonly screenshotBytes: number readonly consoleErrors: string[] readonly arenaState: Record } export async function verifySkillInBrowser(params: { skillId: number classCode?: string slvl?: number screenshotPath: string baseUrl?: string }): Promise { const registry = await getSharedDataRegistry() const skillRec = registry.getSkillById(params.skillId) const classCode = (params.classCode || skillRec?.charClass || 'sor').toLowerCase() const slvl = params.slvl ?? 20 let viteServer: Awaited> | null = null let baseUrl = params.baseUrl ?? '' if (!baseUrl) { const chosenPort = 5200 + Math.floor(Math.random() * 600) viteServer = await createServer({ root: resolve('.'), logLevel: 'silent', server: { host: '127.0.0.1', port: chosenPort, strictPort: false, }, }) await viteServer.listen() const addr = viteServer.httpServer?.address() const port = typeof addr === 'object' && addr ? addr.port : chosenPort baseUrl = `http://127.0.0.1:${String(port)}` } const targetUrl = `${baseUrl}/acts.html?arena=1&class=${encodeURIComponent(classCode)}&skill=${String(params.skillId)}&slvl=${String(slvl)}&autocast=1` const chromeBin = resolveChromeBinary() const debugPort = 9300 + Math.floor(Math.random() * 500) const chrome = spawn( chromeBin, [ '--headless=new', '--no-sandbox', '--disable-dev-shm-usage', '--window-size=1024,680', `--remote-debugging-port=${String(debugPort)}`, targetUrl, ], { stdio: ['ignore', 'ignore', 'pipe'] }, ) try { let pageTarget: { webSocketDebuggerUrl: string; url: string } | null = null for (let i = 0; i < 80; i += 1) { await sleep(150) try { const list = (await (await fetch(`http://127.0.0.1:${String(debugPort)}/json`)).json()) as { type: string url: string webSocketDebuggerUrl: string }[] const pages = list.filter(x => x.type === 'page') const matched = pages.find(x => x.url.includes('acts.html')) if (matched && matched.webSocketDebuggerUrl) { pageTarget = matched break } } catch {} } if (!pageTarget) { throw new Error(`Headless Chromium did not open ${targetUrl}`) } const ws = new WebSocket(pageTarget.webSocketDebuggerUrl) await new Promise((res, rej) => { ws.onopen = res ws.onerror = rej }) let msgId = 0 const pending = new Map) => void>() const consoleErrors: string[] = [] ws.onmessage = event => { const msg = JSON.parse(String(event.data)) as Record if (msg['method'] === 'Runtime.exceptionThrown') { consoleErrors.push(JSON.stringify(msg['params'])) } const id = msg['id'] as number | undefined if (id && pending.has(id)) { pending.get(id)!(msg) pending.delete(id) } } const send = (method: string, cdpParams: Record = {}) => new Promise>(res => { const id = ++msgId pending.set(id, res) ws.send(JSON.stringify({ id, method, params: cdpParams })) }) await send('Runtime.enable') await send('Page.enable') // Poll until `window.__d2webArena.ready === true && window.__d2webArena.castCount >= 1` let arenaState: Record | null = null for (let i = 0; i < 40; i += 1) { await sleep(250) const evalRes = await send('Runtime.evaluate', { expression: `(async () => { if (!window.__d2webArena && typeof window.__initBrowserArenaMode === 'function') { await window.__initBrowserArenaMode(); } if (!window.__d2webArena) { const { initBrowserArenaMode } = await import('/src/scene/act-scene.ts'); if (typeof initBrowserArenaMode === 'function') await initBrowserArenaMode(); } const a = window.__d2webArena; if (!a || !a.ready) return null; if (a.castCount < 1 && typeof a.triggerCast === 'function') a.triggerCast(); return { ready: a.ready, classCode: a.classCode, charToken: a.charToken, skillId: a.skillId, skillName: a.skillName, slvl: a.slvl, tick: a.tick, castCount: a.castCount, actionFrameTriggered: a.actionFrameTriggered, playerAnimMode: a.playerAnimMode, playerAnimFrame: a.playerAnimFrame, activeStates: a.activeStates, activeOverlays: a.activeOverlays, missilesSpawned: a.missilesSpawned, petsActive: a.petsActive, corpsesRemaining: a.corpsesRemaining, totalDamageDealt: a.totalDamageDealt, tooltipStats: a.tooltipStats, }; })()`, awaitPromise: true, returnByValue: true, }) const val = (evalRes['result'] as { result?: { value?: Record | null } } | undefined) ?.result?.value if (val && val['ready'] === true && Number(val['castCount']) >= 1) { arenaState = val break } } if (!arenaState) { throw new Error(`Timed out waiting for window.__d2webArena.ready on ${targetUrl}`) } // Give canvas 250ms to settle paint before screenshot await sleep(250) const shot = await send('Page.captureScreenshot', { format: 'png' }) const base64Data = (shot['result'] as { data?: string } | undefined)?.data if (!base64Data) { throw new Error('CDP Page.captureScreenshot returned empty image data') } const outPath = resolve(params.screenshotPath) mkdirSync(dirname(outPath), { recursive: true }) const pngBuf = Buffer.from(base64Data, 'base64') writeFileSync(outPath, pngBuf) ws.close() return { ok: consoleErrors.length === 0 && pngBuf.byteLength > 1000, url: targetUrl, screenshotPath: outPath, screenshotBytes: pngBuf.byteLength, consoleErrors, arenaState, } } finally { chrome.kill() if (viteServer) { await viteServer.close() } } } async function main(): Promise { const args = process.argv.slice(2) if (args.length === 0) { console.error('Usage: npx tsx scripts/verify-skill-browser.ts [--class=sor] [--slvl=20] [--out=/tmp/skill.png]') process.exit(1) } const skillId = Number(args[0]) const classArg = args.find(a => a.startsWith('--class='))?.split('=')[1] const slvlArg = args.find(a => a.startsWith('--slvl='))?.split('=')[1] const outArg = args.find(a => a.startsWith('--out='))?.split('=')[1] ?? `/tmp/skill-${String(skillId)}.png` const res = await verifySkillInBrowser({ skillId, ...(classArg !== undefined ? { classCode: classArg } : {}), slvl: slvlArg ? Number(slvlArg) : 20, screenshotPath: outArg, }) console.log(JSON.stringify(res, null, 2)) if (!res.ok) { process.exit(1) } } if (import.meta.url === `file://${process.argv[1]}`) { void main() }