diablo2-web/scripts/verify-skill-browser.ts

256 lines
8.5 KiB
TypeScript

/**
* Headless Chromium Skill Verification & Screenshot Runner (`scripts/verify-skill-browser.ts`).
*
* Usage:
* npx tsx scripts/verify-skill-browser.ts <skillId> [--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=<cls>&skill=<skillId>&slvl=<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<string, unknown>
}
export async function verifySkillInBrowser(params: {
skillId: number
classCode?: string
slvl?: number
screenshotPath: string
baseUrl?: string
}): Promise<BrowserSkillVerificationResult> {
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<ReturnType<typeof createServer>> | 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<number, (msg: Record<string, unknown>) => void>()
const consoleErrors: string[] = []
ws.onmessage = event => {
const msg = JSON.parse(String(event.data)) as Record<string, unknown>
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<string, unknown> = {}) =>
new Promise<Record<string, unknown>>(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<string, unknown> | 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<string, unknown> | 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<void> {
const args = process.argv.slice(2)
if (args.length === 0) {
console.error('Usage: npx tsx scripts/verify-skill-browser.ts <skillId> [--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()
}