256 lines
8.5 KiB
TypeScript
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()
|
|
}
|