chore(tools): drop acts.html-only single-player browser verifiers

These CDP scripts drove the long-deleted acts.html page and read SP globals
(__d2webAct, __d2webHud, __d2webHudInstance) that no code publishes anymore:
verify-{animation,ground-combat,controlbar,issue-503,issue-504,skill,
tooltip,ui}-browser, verify-char-appearance, verify-vendor-ui and
execute-r3-all-221-skills. Remove them together with the package.json
scripts verify:animation, verify:ground-browser and verify:char-appearance.

audit-levels-browser.ts and verify-automap.ts are kept: they are the
136-level audits referenced by AGENTS.md / tests and still need a port to
play.html.
This commit is contained in:
troytt 2026-10-01 06:04:04 +00:00
parent 13b2a4c326
commit 3c1ed16bf9
12 changed files with 0 additions and 5874 deletions

View File

@ -48,11 +48,8 @@
"pack:overlays": "tsx src/baker/packers/pack-overlays.ts",
"verify:entity-packs": "tsx src/baker/audit/verify-entity-packs.ts",
"verify:entities": "tsx src/baker/audit/verify-entity-packs.ts",
"verify:char-appearance": "tsx tools/verify-char-appearance.ts",
"verify:packs": "tsx src/baker/audit/verify-packs.ts",
"verify:bundle-no-mpq": "tsx tools/verify-bundle-no-mpq.ts",
"verify:animation": "tsx tools/verify-animation-browser.ts",
"verify:ground-browser": "tsx tools/verify-ground-combat-browser.ts",
"verify:deploy": "tsx tools/verify-deploy.ts",
"verify:listfile": "tsx tools/verify-listfile.ts",
"verify:object-lookup": "tsx tools/verify-object-lookup.ts",

View File

@ -1,697 +0,0 @@
/**
* Complete R3 Execution Pipeline for All 221 Player Skills (Milestones M3 & M4).
*
* Performs:
* 1. Persistent Vite + Headless Chromium (`chromium-1228`) WebGL2 arena casting & PNG screenshot
* capture (`/tmp/d2-skill-screenshots/skill-<id3>-<slug>.png`) across all 221 player skills.
* 2. Strict isolated `git worktree` lifecycle (`../wt-skill-<id3>` on branch `feat/skill-<id3>`):
* - Generates `src/server/skills/impl/<charClass>/skill-<id3>-<slug>.ts` (`SkillModule`)
* - Generates `tests/skills/<charClass>/skill-<id3>-<slug>.test.ts` (1.13c `slvl` 1/10/20 + `blvl` synergy + `WorldArena` runtime effect)
* - Commits on `feat/skill-<id3>` inside `../wt-skill-<id3>`
* - Uploads genuine PNG screenshot to Gitea (`POST /issues/{issueNumber}/assets`)
* - Posts 1.13c numerical + Vitest + Chromium telemetry comment (`POST /issues/{issueNumber}/comments`)
* - Merges `feat/skill-<id3>` into `main` (`--no-ff`, `fixes #<issueNumber>`)
* - Immediately deletes worktree (`git worktree remove ../wt-skill-<id3>`) and branch (`git branch -d feat/skill-<id3>`)
* - Pushes `main` to `origin/main` in batches of 25 skills and verifies Milestone #9 has 0 open issues and 221 closed issues.
*/
import { execFileSync, spawn } from 'node:child_process'
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { createServer } from 'vite'
import { getSharedDataRegistry, type SkillRecord } from '../src/baker/mpq-archive-source.ts'
import { UnitStatList } from '../src/server/engine/stat-list.ts'
import { evaluateSkillCore113c } from '../src/server/skills/registry.ts'
import { closeGiteaIssue, postGiteaIssueComment, uploadPngToGiteaIssue } from './gitea-skill-workflow.ts'
const REPO_ROOT = '/usr/local/google/home/taodao/diablo2-web'
const SCREENSHOT_DIR = '/tmp/d2-skill-screenshots'
const TELEMETRY_CACHE_PATH = '/tmp/d2-skill-screenshots/telemetry-221.json'
const PROGRESS_MD_PATH = '/usr/local/google/home/taodao/diablo2-web/.agents/sub_orch_m3_m4_r3/progress.md'
const GITEA_BASE_URL = 'https://git.projectdiablo2.cn/api/v1/repos/troytt/diablo2-web'
const GITEA_TOKEN = '94c84cc3bd2eab804346cb889650c843d0cda244'
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))
interface IssueEntry {
skillId: number
issueNumber: number
issueId: number
title: string
charClass: string
slug: string
milestoneId: number
}
interface ArenaTelemetrySnap {
skillId: number
skillName: string
classCode: string
charToken: string
slvl: number
castCount: number
playerAnimMode: string
playerAnimFrame: number
manaCost: number
minDmg: number
maxDmg: number
totalDamageDealt: number
missilesSpawned: number
petsCount: number
corpsesRemaining: number
activeStates: string[]
activeOverlays: string[]
pngBytes: number
}
function runGit(args: string[], cwd = REPO_ROOT): string {
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
}
function findFirstSynergySkillId(expr: string, registry: Awaited<ReturnType<typeof getSharedDataRegistry>>): number | null {
const m = /skill\('([^']+)'\.blvl\)/.exec(expr)
if (!m?.[1]) return null
const rec = registry.getSkillByName(m[1])
return rec ? rec.id : null
}
function generateSkillImplSource(entry: IssueEntry, skill: SkillRecord, id3: string): string {
const escapedName = JSON.stringify(skill.name)
const escapedMissile = JSON.stringify(skill.srvMissile || skill.srvMissileA || skill.cltMissile || '')
const escapedState = JSON.stringify(skill.auraTgtState || skill.auraState || skill.passiveState || '')
const escapedSummon = JSON.stringify(skill.summon || '')
return `/**
* Diablo II: Lord of Destruction v1.13c — Skill #${id3}: ${skill.name} (${entry.charClass.toUpperCase()})
*
* Authoritative 1.13c Ground Truth (` + '`Patch_D2.mpq`' + ` -> ` + '`data\\\\global\\\\excel\\\\Skills.txt`' + `):
* - Skill ID: ${String(skill.id)} (${escapedName})
* - Class: ${JSON.stringify(entry.charClass)}
* - Required Level: ${String(skill.reqLevel)}
* - Server Functions: srvstfunc=${String(skill.srvStFunc)}, srvdofunc=${String(skill.srvDoFunc)}, srvprgfunc1=${String(skill.srvPrgFunc1)}, srvprgfunc2=${String(skill.srvPrgFunc2)}, srvprgfunc3=${String(skill.srvPrgFunc3)}
* - Associated Missile / State / Summon: missile=${escapedMissile}, state=${escapedState}, summon=${escapedSummon}
* - 1.13c Synergy Formulas: EDmgSymPerCalc=${JSON.stringify(skill.eDmgSymPerCalc)}, DmgSymPerCalc=${JSON.stringify(skill.dmgSymPerCalc)}, ELenSymPerCalc=${JSON.stringify(skill.eLenSymPerCalc)}
*/
import type {
SkillEvalContext,
SkillEvalResult,
SkillExecContext,
SkillExecOutcome,
SkillModule,
} from '../../types.ts'
import { evaluateSkillCore113c, executeSkillCore113c } from '../../registry.ts'
export const skillModule: SkillModule = {
skillId: ${String(skill.id)},
name: ${escapedName},
charClass: ${JSON.stringify(entry.charClass)},
srvStFunc: ${String(skill.srvStFunc)},
srvDoFunc: ${String(skill.srvDoFunc)},
evaluate(ctx: SkillEvalContext): SkillEvalResult {
return evaluateSkillCore113c(ctx)
},
executeDo(ctx: SkillExecContext): SkillExecOutcome {
const outcome = executeSkillCore113c(ctx)
return {
...outcome,
notes: [
...outcome.notes,
'skill_module:skill-${id3}-${entry.slug}',
],
}
},
}
export default skillModule
`
}
function generateSkillTestSource(
entry: IssueEntry,
skill: SkillRecord,
id3: string,
ev1: ReturnType<typeof evaluateSkillCore113c>,
ev10: ReturnType<typeof evaluateSkillCore113c>,
ev20: ReturnType<typeof evaluateSkillCore113c>,
synSkillId: number | null,
ev20WithSyn: ReturnType<typeof evaluateSkillCore113c>,
): string {
const escapedName = JSON.stringify(skill.name)
return `/**
* 1.13c Numerical & Runtime Verification Suite — Skill #${id3}: ${skill.name} (${entry.charClass.toUpperCase()})
* Gitea Tracking Issue: #${String(entry.issueNumber)}
*/
import { describe, expect, it } from 'vitest'
import { getSharedDataRegistry } from '../../../src/baker/mpq-archive-source.ts'
import { UnitStatList } from '../../../src/server/engine/stat-list.ts'
import { WorldArena } from '../../../src/server/engine/world-arena.ts'
import { skillModule } from '../../../src/server/skills/impl/${entry.charClass}/skill-${id3}-${entry.slug}.ts'
describe('[Skill #${id3}] ${skill.name.replace(/'/g, "\\'")} (${entry.charClass.toUpperCase()}) — 1.13c Parity (Issue #${String(entry.issueNumber)})', () => {
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(${String(skill.id)})!
expect(skillRec).toBeDefined()
expect(skillModule.skillId).toBe(${String(skill.id)})
expect(skillModule.name).toBe(${escapedName})
expect(skillModule.charClass).toBe(${JSON.stringify(entry.charClass)})
const stats1 = new UnitStatList()
stats1.setBaseSkillLevel(${String(skill.id)}, 1)
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
expect(r1.manaCost).toBe(${String(ev1.manaCost)})
expect(r1.manaCost256).toBe(${String(ev1.manaCost256)})
expect(r1.minPhysDmg).toBe(${String(ev1.minPhysDmg)})
expect(r1.maxPhysDmg).toBe(${String(ev1.maxPhysDmg)})
expect(r1.minElemDmg256).toBe(${String(ev1.minElemDmg256)})
expect(r1.maxElemDmg256).toBe(${String(ev1.maxElemDmg256)})
const stats10 = new UnitStatList()
stats10.setBaseSkillLevel(${String(skill.id)}, 10)
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
expect(r10.manaCost).toBe(${String(ev10.manaCost)})
expect(r10.manaCost256).toBe(${String(ev10.manaCost256)})
expect(r10.minPhysDmg).toBe(${String(ev10.minPhysDmg)})
expect(r10.maxPhysDmg).toBe(${String(ev10.maxPhysDmg)})
expect(r10.minElemDmg256).toBe(${String(ev10.minElemDmg256)})
expect(r10.maxElemDmg256).toBe(${String(ev10.maxElemDmg256)})
const stats20 = new UnitStatList()
stats20.setBaseSkillLevel(${String(skill.id)}, 20)
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
expect(r20.manaCost).toBe(${String(ev20.manaCost)})
expect(r20.manaCost256).toBe(${String(ev20.manaCost256)})
expect(r20.minPhysDmg).toBe(${String(ev20.minPhysDmg)})
expect(r20.maxPhysDmg).toBe(${String(ev20.maxPhysDmg)})
expect(r20.minElemDmg256).toBe(${String(ev20.minElemDmg256)})
expect(r20.maxElemDmg256).toBe(${String(ev20.maxElemDmg256)})
expect(r20.durationFrames).toBe(${String(ev20.durationFrames)})
expect(r20.radiusSubtiles).toBe(${String(ev20.radiusSubtiles)})
expect(r20.petMax).toBe(${String(ev20.petMax)})
expect(r20.cooldownFrames).toBe(${String(ev20.cooldownFrames)})
})
it('enforces 1.13c blvl synergy rule (hard points boost synergy; +skills and item charges do NOT)', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(${String(skill.id)})!
const synId = ${synSkillId !== null ? String(synSkillId) : 'null'}
const statsBase = new UnitStatList()
statsBase.setBaseSkillLevel(${String(skill.id)}, 20)
if (synId !== null) {
statsBase.setBaseSkillLevel(synId, 20)
}
const withHardPoints = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsBase,
})
expect(withHardPoints.synergyBonusPct).toBe(${String(ev20WithSyn.synergyBonusPct)})
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
const statsSoftOnly = new UnitStatList()
statsSoftOnly.setBaseSkillLevel(${String(skill.id)}, 20)
statsSoftOnly.addStat('item_allskills', 20)
if (synId !== null) {
statsSoftOnly.setChargedSkillLevel(synId, 33)
}
const withSoftOnly = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsSoftOnly,
})
expect(withSoftOnly.synergyBonusPct).toBe(0)
})
it('executes runtime skill effect in WorldArena (castCount, action frame, and combat/state/missile/summon outcome)', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: ${JSON.stringify(entry.charClass)},
skillId: ${String(skill.id)},
slvl: 20,
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(${String(skill.id)})
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
})
`
}
async function captureAll221ScreenshotsInHeadlessChrome(
entries: IssueEntry[],
): Promise<Record<number, ArenaTelemetrySnap>> {
mkdirSync(SCREENSHOT_DIR, { recursive: true })
let cached: Record<number, ArenaTelemetrySnap> = {}
if (existsSync(TELEMETRY_CACHE_PATH)) {
try {
cached = JSON.parse(readFileSync(TELEMETRY_CACHE_PATH, 'utf8')) as Record<number, ArenaTelemetrySnap>
} catch {}
}
const missingEntries = entries.filter(e => {
const id3 = String(e.skillId).padStart(3, '0')
const pngPath = `${SCREENSHOT_DIR}/skill-${id3}-${e.slug}.png`
return !existsSync(pngPath) || statSync(pngPath).size < 10_000 || !cached[e.skillId]
})
if (missingEntries.length === 0) {
console.log(`All 221 Chromium PNG screenshots already exist in ${SCREENSHOT_DIR}.`)
return cached
}
console.log(`Capturing ${String(missingEntries.length)} genuine Chromium screenshots via persistent Vite + chromium-1228...`)
const chosenPort = 5280 + Math.floor(Math.random() * 400)
const viteServer = await createServer({
root: REPO_ROOT,
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
const baseUrl = `http://127.0.0.1:${String(port)}`
const initialUrl = `${baseUrl}/acts.html?arena=1&class=sor&skill=47&slvl=20&autocast=1`
const chromeBin = resolveChromeBinary()
const debugPort = 9400 + Math.floor(Math.random() * 400)
const chrome = spawn(
chromeBin,
[
'--headless=new',
'--no-sandbox',
'--disable-dev-shm-usage',
'--window-size=1024,680',
`--remote-debugging-port=${String(debugPort)}`,
initialUrl,
],
{ 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 matched = list.find(x => x.type === 'page' && x.url.includes('acts.html'))
if (matched?.webSocketDebuggerUrl) {
pageTarget = matched
break
}
} catch {}
}
if (!pageTarget) {
throw new Error(`Headless Chromium failed to open ${initialUrl}`)
}
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')
// Wait for initial arena ready
for (let i = 0; i < 50; i += 1) {
await sleep(250)
const evalRes = await send('Runtime.evaluate', {
expression: `(async () => {
if (!window.__d2webArena && typeof window.__initBrowserArenaMode === 'function') {
await window.__initBrowserArenaMode();
}
return Boolean(window.__d2webArena && window.__d2webArena.ready);
})()`,
awaitPromise: true,
returnByValue: true,
})
const ready = (evalRes['result'] as { result?: { value?: boolean } } | undefined)?.result?.value
if (ready) break
}
// Wait 500ms for WebGL2 map background tiles to finish rendering
await sleep(500)
for (let idx = 0; idx < missingEntries.length; idx += 1) {
const entry = missingEntries[idx]!
const id3 = String(entry.skillId).padStart(3, '0')
const pngPath = `${SCREENSHOT_DIR}/skill-${id3}-${entry.slug}.png`
const evalRes = await send('Runtime.evaluate', {
expression: `(async () => {
const a = window.__d2webArena;
if (!a || typeof a.switchAndCastSkill !== 'function') {
throw new Error('window.__d2webArena.switchAndCastSkill missing');
}
const dbg = a.switchAndCastSkill(${JSON.stringify(entry.charClass)}, ${String(entry.skillId)}, 20);
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
return {
skillId: dbg.skillId,
skillName: dbg.skillName,
classCode: dbg.classCode,
charToken: dbg.charToken,
slvl: dbg.slvl,
castCount: dbg.castCount,
playerAnimMode: dbg.playerAnimMode,
playerAnimFrame: dbg.playerAnimFrame,
manaCost: dbg.tooltipStats.manaCost,
minDmg: dbg.tooltipStats.minDmg,
maxDmg: dbg.tooltipStats.maxDmg,
totalDamageDealt: dbg.totalDamageDealt,
missilesSpawned: dbg.missilesSpawned,
petsCount: dbg.petsActive.length,
corpsesRemaining: dbg.corpsesRemaining,
activeStates: dbg.activeStates,
activeOverlays: dbg.activeOverlays,
};
})()`,
awaitPromise: true,
returnByValue: true,
})
const snap = (evalRes['result'] as { result?: { value?: Omit<ArenaTelemetrySnap, 'pngBytes'> } } | undefined)
?.result?.value
if (!snap || snap.skillId !== entry.skillId || snap.castCount < 1) {
throw new Error(`Arena switchAndCastSkill failed for skill #${id3} (${entry.slug})`)
}
const shot = await send('Page.captureScreenshot', { format: 'png' })
const base64Data = (shot['result'] as { data?: string } | undefined)?.data
if (!base64Data) {
throw new Error(`Empty screenshot for skill #${id3}`)
}
const pngBuf = Buffer.from(base64Data, 'base64')
writeFileSync(pngPath, pngBuf)
cached[entry.skillId] = {
...snap,
pngBytes: pngBuf.byteLength,
}
if ((idx + 1) % 25 === 0 || idx === missingEntries.length - 1) {
console.log(` [Chromium Screenshot] ${String(idx + 1)}/${String(missingEntries.length)} captured (latest: skill-${id3}-${entry.slug}.png, ${String(pngBuf.byteLength)} bytes)`)
writeFileSync(TELEMETRY_CACHE_PATH, JSON.stringify(cached, null, 2))
}
}
if (consoleErrors.length > 0) {
throw new Error(`Chromium console/runtime errors occurred: ${consoleErrors.slice(0, 3).join('; ')}`)
}
ws.close()
} finally {
chrome.kill()
await viteServer.close()
}
writeFileSync(TELEMETRY_CACHE_PATH, JSON.stringify(cached, null, 2))
return cached
}
function updateProgressFile(completedCount: number, totalCount: number, lastSkillLabel: string): void {
const now = new Date().toISOString()
const md = `# Progress — worker_m3_m4_r3_all_skills
Last visited: ${now}
## Status
- Completed isolated worktree + Vitest + Chromium screenshot + Gitea upload/comment/merge/cleanup: **${String(completedCount)} / ${String(totalCount)}** skills
- Latest completed skill: \`${lastSkillLabel}\`
`
writeFileSync(PROGRESS_MD_PATH, md)
}
async function main(): Promise<void> {
const registry = await getSharedDataRegistry()
const issueMap = JSON.parse(
readFileSync(resolve(REPO_ROOT, 'src/data/gitea-skill-issues.json'), 'utf8'),
) as Record<string, IssueEntry>
const entries = Object.values(issueMap).sort((a, b) => a.skillId - b.skillId)
console.log(`Loaded ${String(entries.length)} player skill entries from src/data/gitea-skill-issues.json.`)
// Step 1: Capture all 221 genuine Chromium screenshots
const telemetryBySkill = await captureAll221ScreenshotsInHeadlessChrome(entries)
// Step 2: Execute strict isolated git worktree lifecycle for every remaining skill
const remaining = entries.filter(entry => {
const id3 = String(entry.skillId).padStart(3, '0')
const implRelPath = `src/server/skills/impl/${entry.charClass}/skill-${id3}-${entry.slug}.ts`
const testRelPath = `tests/skills/${entry.charClass}/skill-${id3}-${entry.slug}.test.ts`
return !existsSync(resolve(REPO_ROOT, implRelPath)) || !existsSync(resolve(REPO_ROOT, testRelPath))
})
console.log(`Remaining skills to complete via isolated worktree + Gitea upload/comment/merge: ${String(remaining.length)} / ${String(entries.length)}`)
// Precompute all evaluation results for all 221 skills
const precomputed = new Map<
number,
{
id3: string
skill: SkillRecord
implRelPath: string
testRelPath: string
ev1: ReturnType<typeof evaluateSkillCore113c>
ev10: ReturnType<typeof evaluateSkillCore113c>
ev20: ReturnType<typeof evaluateSkillCore113c>
synSkillId: number | null
ev20WithSyn: ReturnType<typeof evaluateSkillCore113c>
}
>()
for (const entry of entries) {
const id3 = String(entry.skillId).padStart(3, '0')
const skill = registry.getSkillById(entry.skillId)!
const implRelPath = `src/server/skills/impl/${entry.charClass}/skill-${id3}-${entry.slug}.ts`
const testRelPath = `tests/skills/${entry.charClass}/skill-${id3}-${entry.slug}.test.ts`
const s1 = new UnitStatList()
s1.setBaseSkillLevel(skill.id, 1)
const ev1 = evaluateSkillCore113c({ registry, skill, slvl: 1, blvl: 1, statList: s1 })
const s10 = new UnitStatList()
s10.setBaseSkillLevel(skill.id, 10)
const ev10 = evaluateSkillCore113c({ registry, skill, slvl: 10, blvl: 10, statList: s10 })
const s20 = new UnitStatList()
s20.setBaseSkillLevel(skill.id, 20)
const ev20 = evaluateSkillCore113c({ registry, skill, slvl: 20, blvl: 20, statList: s20 })
const synExpr = skill.eDmgSymPerCalc || skill.dmgSymPerCalc || (skill.id === 260 ? "skill('Claw Mastery'.blvl)*par8" : '')
const synSkillId = findFirstSynergySkillId(synExpr, registry)
const s20Syn = new UnitStatList()
s20Syn.setBaseSkillLevel(skill.id, 20)
if (synSkillId !== null) s20Syn.setBaseSkillLevel(synSkillId, 20)
const ev20WithSyn = evaluateSkillCore113c({ registry, skill, slvl: 20, blvl: 20, statList: s20Syn })
precomputed.set(entry.skillId, {
id3,
skill,
implRelPath,
testRelPath,
ev1,
ev10,
ev20,
synSkillId,
ev20WithSyn,
})
}
// 2a. Execute isolated git worktree add -> write -> commit -> merge --no-ff -> worktree remove -> branch -d
let completedTotal = entries.length - remaining.length
let batchSincePush = 0
for (let i = 0; i < remaining.length; i += 1) {
const entry = remaining[i]!
const data = precomputed.get(entry.skillId)!
const { id3, skill, implRelPath, testRelPath, ev1, ev10, ev20, synSkillId, ev20WithSyn } = data
const wtPath = `/usr/local/google/home/taodao/wt-skill-${id3}`
const branchName = `feat/skill-${id3}`
try {
if (existsSync(wtPath)) runGit(['worktree', 'remove', '--force', wtPath])
} catch {}
try {
runGit(['branch', '-D', branchName])
} catch {}
// 1. Create isolated git worktree from main
runGit(['worktree', 'add', wtPath, '-b', branchName, 'main'])
// 2. Write implementation & Vitest file inside worktree
const wtImplAbs = resolve(wtPath, implRelPath)
const wtTestAbs = resolve(wtPath, testRelPath)
mkdirSync(resolve(wtPath, `src/server/skills/impl/${entry.charClass}`), { recursive: true })
mkdirSync(resolve(wtPath, `tests/skills/${entry.charClass}`), { recursive: true })
writeFileSync(wtImplAbs, generateSkillImplSource(entry, skill, id3))
writeFileSync(wtTestAbs, generateSkillTestSource(entry, skill, id3, ev1, ev10, ev20, synSkillId, ev20WithSyn))
// 3. Stage and commit on branch feat/skill-<id3>
runGit(['add', implRelPath, testRelPath], wtPath)
runGit(
['commit', '-m', `feat(skill-${id3}): implement 1.13c ${skill.name} module and Vitest suite (#${String(entry.issueNumber)})`],
wtPath,
)
// 4. Merge feat/skill-<id3> into main (--no-ff, fixes #<issueNumber>)
runGit([
'merge',
'--no-ff',
branchName,
'-m',
`feat(skills): implement 1.13c ${skill.name} [Skill #${id3}] (fixes #${String(entry.issueNumber)})`,
])
// 5. Immediately remove worktree and branch
runGit(['worktree', 'remove', wtPath])
runGit(['branch', '-d', branchName])
completedTotal += 1
batchSincePush += 1
if (batchSincePush >= 25 || i === remaining.length - 1) {
runGit(['push', 'origin', 'main'])
batchSincePush = 0
updateProgressFile(completedTotal, entries.length, `[Skill #${id3}] ${skill.name} (Issue #${String(entry.issueNumber)})`)
console.log(` [R3 Worktree+Merge+Push] Completed ${String(completedTotal)}/${String(entries.length)} (latest: #${id3} ${skill.name} -> Issue #${String(entry.issueNumber)})`)
}
}
// 2b. Query Gitea Milestone #9 for any issues that still need PNG screenshot + verification comment or closure
const uploadedTrackerPath = `${SCREENSHOT_DIR}/gitea-commented-221.json`
const commentedIssues = new Set<number>(
existsSync(uploadedTrackerPath) ? (JSON.parse(readFileSync(uploadedTrackerPath, 'utf8')) as number[]) : [],
)
// Mark the first 35 issues that already had comments posted during Phase 1
for (let i = 0; i < 35; i += 1) {
commentedIssues.add(entries[i]!.issueNumber)
}
const toUpload = entries.filter(e => !commentedIssues.has(e.issueNumber))
console.log(`Uploading PNG screenshot + posting verification comment for ${String(toUpload.length)} remaining Gitea issues (4-way concurrency)...`)
const CONCURRENCY = 4
let cursor = 0
let uploadedCount = 0
async function giteaWorker(): Promise<void> {
while (cursor < toUpload.length) {
const idx = cursor++
const entry = toUpload[idx]!
const data = precomputed.get(entry.skillId)!
const { id3, skill, implRelPath, testRelPath, ev1, ev10, ev20, ev20WithSyn } = data
const pngPath = `${SCREENSHOT_DIR}/skill-${id3}-${entry.slug}.png`
const asset = await uploadPngToGiteaIssue(entry.issueNumber, pngPath)
const snap = telemetryBySkill[entry.skillId]
const commentMd = `### ✅ Completed: [Skill #${id3}] ${skill.name} (\`${entry.charClass.toUpperCase()}\`) — 1.13c Parity Verified
#### 1. Isolated Worktree & Module Implementation
- **Worktree & Branch**: \`../wt-skill-${id3}\` (\`feat/skill-${id3}\`)
- **Skill Module**: \`${implRelPath}\`
- **Vitest Suite**: \`${testRelPath}\`
- **1.13c Dispatch**: \`srvstfunc=${String(skill.srvStFunc)}\`, \`srvdofunc=${String(skill.srvDoFunc)}\`, \`missile=${skill.srvMissile || skill.srvMissileA || 'none'}\`, \`state=${skill.auraTgtState || skill.auraState || skill.passiveState || 'none'}\`, \`summon=${skill.summon || 'none'}\`
#### 2. 1.13c Numerical & Synergy Verification Table
| Metric | \`slvl = 1\` | \`slvl = 10\` | \`slvl = 20\` (0 Syn) | \`slvl = 20\` (+20 \`blvl\` Syn) |
| :--- | :---: | :---: | :---: | :---: |
| **Mana Cost (\`usmc\`)** | \`${String(ev1.manaCost)}\` (\`${String(ev1.manaCost256)}\`) | \`${String(ev10.manaCost)}\` (\`${String(ev10.manaCost256)}\`) | \`${String(ev20.manaCost)}\` (\`${String(ev20.manaCost256)}\`) | \`${String(ev20WithSyn.manaCost)}\` (\`${String(ev20WithSyn.manaCost256)}\`) |
| **Phys Dmg (\`Min-Max\`)** | \`${String(ev1.minPhysDmg)}-${String(ev1.maxPhysDmg)}\` | \`${String(ev10.minPhysDmg)}-${String(ev10.maxPhysDmg)}\` | \`${String(ev20.minPhysDmg)}-${String(ev20.maxPhysDmg)}\` | \`${String(ev20WithSyn.minPhysDmg)}-${String(ev20WithSyn.maxPhysDmg)}\` |
| **Elem Dmg (\`Min-Max\`)** | \`${String(ev1.minElemDmg)}-${String(ev1.maxElemDmg)}\` (\`${ev1.elemType}\`) | \`${String(ev10.minElemDmg)}-${String(ev10.maxElemDmg)}\` (\`${ev10.elemType}\`) | \`${String(ev20.minElemDmg)}-${String(ev20.maxElemDmg)}\` (\`${ev20.elemType}\`) | \`${String(ev20WithSyn.minElemDmg)}-${String(ev20WithSyn.maxElemDmg)}\` (+${String(ev20WithSyn.synergyBonusPct)}%) |
| **AR % / Duration / Radius** | \`+${String(ev1.toHitBonusPct)}%\` / \`${String(ev1.durationFrames)}f\` / \`${String(ev1.radiusSubtiles)}\` | \`+${String(ev10.toHitBonusPct)}%\` / \`${String(ev10.durationFrames)}f\` / \`${String(ev10.radiusSubtiles)}\` | \`+${String(ev20.toHitBonusPct)}%\` / \`${String(ev20.durationFrames)}f\` / \`${String(ev20.radiusSubtiles)}\` | \`+${String(ev20WithSyn.toHitBonusPct)}%\` / \`${String(ev20WithSyn.durationFrames)}f\` / \`${String(ev20WithSyn.radiusSubtiles)}\` |
#### 3. Headless Chromium (\`chromium-1228\`) Runtime Telemetry & Screenshot
- **Arena State**: \`ready=true\`, \`classCode=${snap?.classCode ?? entry.charClass}\` (\`${snap?.charToken ?? 'so'}\`), \`castCount=${String(snap?.castCount ?? 1)}\`, \`anim=${snap?.playerAnimMode ?? 'SC'}:${String(snap?.playerAnimFrame ?? 5)}\`, \`totalDamageDealt=${String(snap?.totalDamageDealt ?? 0)}\`, \`consoleErrors=0\`
- **Captured PNG Size**: \`${String(snap?.pngBytes ?? 0)}\` bytes
![Skill #${id3} ${skill.name}](${asset.browser_download_url})
`
await postGiteaIssueComment(entry.issueNumber, commentMd)
await closeGiteaIssue(entry.issueNumber)
commentedIssues.add(entry.issueNumber)
writeFileSync(uploadedTrackerPath, JSON.stringify(Array.from(commentedIssues), null, 2))
uploadedCount += 1
if (uploadedCount % 10 === 0 || uploadedCount === toUpload.length) {
console.log(` [Gitea Upload+Comment+Close] ${String(uploadedCount)}/${String(toUpload.length)} completed`)
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, () => giteaWorker()))
writeFileSync(uploadedTrackerPath, JSON.stringify(Array.from(commentedIssues), null, 2))
// Ensure any remaining open issues in Milestone #9 are closed
for (let page = 1; page <= 5; page += 1) {
const openRes = await fetch(`${GITEA_BASE_URL}/issues?state=open&type=issues&milestones=9&limit=50&page=1`, {
headers: { Authorization: `token ${GITEA_TOKEN}` },
signal: AbortSignal.timeout(10000),
})
const openList = (await openRes.json()) as { number: number }[]
if (openList.length === 0) break
for (const iss of openList) {
await closeGiteaIssue(iss.number)
}
}
// Final check on Milestone #9 open/closed count
const msRes = await fetch(`${GITEA_BASE_URL}/milestones/9`, {
headers: { Authorization: `token ${GITEA_TOKEN}` },
})
const msJson = (await msRes.json()) as { open_issues: number; closed_issues: number }
console.log(`Milestone #9 status: open_issues=${String(msJson.open_issues)}, closed_issues=${String(msJson.closed_issues)}`)
}
void main()

File diff suppressed because it is too large Load Diff

View File

@ -1,691 +0,0 @@
import { readFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { spawn, type ChildProcess } from 'node:child_process'
import { COLOR_CODES, GEM_TRANSFORM_BY_CODE } from '../src/common/items/gems-data.ts'
import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts'
import { resolveItemColorTransform } from '../src/common/items/item-bridge.ts'
import {
SorceressLayerCompositor,
resolveSorceressAppearance,
} from '../src/common/units/char-appearance.ts'
import type { ResolvableEquippedItem } from '../src/common/units/char-appearance.ts'
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
function assert(cond: boolean, message: string): void {
if (!cond) {
throw new Error(`ASSERTION FAILED: ${message}`)
}
}
function makeItem(overrides: Partial<ResolvableEquippedItem> & { code: string; name: string }): ResolvableEquippedItem {
return {
quality: 'normal',
identified: true,
...overrides,
}
}
interface CdpSession {
send: <T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
evalJs: <T = unknown>(expression: string) => Promise<T>
close: () => void
}
async function connectCdp(debugPort: number, onEvent: (msg: Record<string, any>) => void): Promise<CdpSession> {
let wsUrl: string | null = null
let lastErr = 'none'
for (let i = 0; i < 80; i++) {
await sleep(200)
try {
const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`)
const pages = (await res.json()) as { type: string; webSocketDebuggerUrl?: string }[]
const page = pages.find(p => p.type === 'page')
if (page?.webSocketDebuggerUrl !== undefined) {
wsUrl = page.webSocketDebuggerUrl
break
}
lastErr = 'no page target yet'
} catch (err) {
lastErr = err instanceof Error ? err.message : String(err)
}
}
if (wsUrl === null) throw new Error(`could not reach Chrome CDP endpoint (last error: ${lastErr})`)
const ws = new WebSocket(wsUrl)
await new Promise<void>((res, rej) => {
ws.onopen = () => res()
ws.onerror = () => rej(new Error('CDP WebSocket failed to open'))
})
let idCounter = 1
const send = <T = unknown>(
method: string,
params: Record<string, unknown> = {},
timeoutMs = 20000,
): Promise<T> =>
new Promise<T>((res, rej) => {
const id = idCounter++
const timer = setTimeout(() => {
ws.removeEventListener('message', handler)
rej(new Error(`CDP ${method} timed out after ${timeoutMs}ms`))
}, timeoutMs)
const handler = (event: MessageEvent): void => {
const msg = JSON.parse(String(event.data)) as Record<string, any>
if (msg['id'] === id) {
clearTimeout(timer)
ws.removeEventListener('message', handler)
if (msg['error'] !== undefined) rej(new Error(`CDP ${method}: ${JSON.stringify(msg['error'])}`))
else res(msg['result'] as T)
}
}
ws.addEventListener('message', handler)
ws.send(JSON.stringify({ id, method, params }))
})
ws.addEventListener('message', (event: MessageEvent) => {
onEvent(JSON.parse(String(event.data)) as Record<string, any>)
})
const evalJs = async <T = unknown>(expression: string): Promise<T> => {
const res = await send<{ result: { value: T }; exceptionDetails?: unknown }>('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
})
if (res.exceptionDetails !== undefined) {
throw new Error(`in-page JS threw: ${JSON.stringify(res.exceptionDetails)}\n expression: ${expression}`)
}
return res.result.value
}
return { send, evalJs, close: () => ws.close() }
}
async function runUnitAndCompositorChecks(): Promise<void> {
console.log('--- 1. Verifying Item Color & Palette Tables ---')
assert(COLOR_CODES.length === 21, `Expected 21 COLOR_CODES, got ${COLOR_CODES.length}`)
assert(COLOR_CODES[0] === 'whit' && COLOR_CODES[20] === 'bwht', 'COLOR_CODES boundary mismatch')
assert(Object.keys(GEM_TRANSFORM_BY_CODE).length === 35, 'Expected 35 gem transform entries')
const dropTables = getEmbeddedDropTables()
// Unidentified item -> undefined
const unidUnique = resolveItemColorTransform(
{ name: 'The Oculus', identified: false } as any,
'unique',
'oba',
dropTables,
)
assert(
unidUnique.chrtransform === undefined,
`Unidentified item must resolve to undefined chrtransform, got ${String(unidUnique.chrtransform)}`,
)
// Unique items
assert(
resolveItemColorTransform({ name: "Ginther's Rift", identified: true } as any, 'unique', '9cr', dropTables).chrtransform === 'cblu',
"Ginther's Rift should resolve to cblu",
)
assert(
resolveItemColorTransform({ name: 'Harlequin Crest', identified: true } as any, 'unique', 'uap', dropTables).chrtransform === 'cgrn',
'Harlequin Crest should resolve to cgrn',
)
assert(
resolveItemColorTransform({ name: 'Skin of the Vipermagi', identified: true } as any, 'unique', 'xea', dropTables).chrtransform === 'dblu',
'Skin of the Vipermagi should resolve to dblu',
)
// Set items (including display name vs internal index fallback)
assert(
resolveItemColorTransform({ name: "Tal Rasha's Horadric Crest", identified: true } as any, 'set', 'xsk', dropTables).chrtransform === 'dpur',
"Tal Rasha's Horadric Crest should resolve to dpur",
)
assert(
resolveItemColorTransform({ name: "Tal Rasha's Guardianship", identified: true } as any, 'set', 'uth', dropTables).chrtransform === 'dpur',
"Tal Rasha's Guardianship (uth) should resolve to dpur",
)
// Magic/Rare suffix > prefix priority
assert(
resolveItemColorTransform(
{
name: 'Cobalt Cap of the Mammoth',
identified: true,
rolledMagicAffixes: {
prefixes: [{ name: 'Cobalt', transform: true, transformColor: 'cblu' }],
suffixes: [{ name: 'of the Mammoth', transform: true, transformColor: 'cred' }],
},
} as any,
'magic',
'cap',
dropTables,
).chrtransform === 'cred',
'Magic suffix color (cred) must override prefix color (cblu)',
)
assert(
resolveItemColorTransform(
{
name: 'Cobalt Cap',
identified: true,
rolledMagicAffixes: {
prefixes: [{ name: 'Cobalt', transform: true, transformColor: 'cblu' }],
suffixes: [{ name: 'of Dexterity', transform: false, transformColor: '' }],
},
} as any,
'magic',
'cap',
dropTables,
).chrtransform === 'cblu',
'Magic prefix color (cblu) must apply when suffix has no transform',
)
// Socketed gems vs Runeword exclusion
assert(
resolveItemColorTransform(
{
name: 'Socketed Cap',
identified: true,
socketedItems: [{ code: 'gpw', name: 'Perfect Diamond' }],
} as any,
'normal',
'cap',
dropTables,
).chrtransform === 'whit',
'Socketed Perfect Diamond (gpw) must dye normal item whit',
)
assert(
resolveItemColorTransform(
{
name: 'Enigma',
identified: true,
runewordRunes: "'JahIthBer'",
socketedItems: [{ code: 'gpw', name: 'Perfect Diamond' }],
} as any,
'rune',
'utp',
dropTables,
).chrtransform === undefined,
'Runeword must not inherit Unique or Gem color transform',
)
// AutoMagic
assert(
resolveItemColorTransform(
{
name: 'Plate Mail',
identified: true,
autoAffix: { name: 'Cobalt', transform: true, transformColor: 'cblu' },
} as any,
'normal',
'plt',
dropTables,
).chrtransform === 'cblu',
'AutoMagic Cobalt must resolve to cblu',
)
console.log('✓ Item color resolution (ITEMS_GetColor) passed all checks.')
console.log('--- 2. Verifying Sorceress 10-Component Appearance Mapping ---')
// Unarmed
const unarmed = resolveSorceressAppearance({}, dropTables)
assert(unarmed.wclass === 'hth', `Unarmed wclass should be hth, got ${unarmed.wclass}`)
assert(unarmed.layers.hd.variant === 'lit', 'Unarmed hd should be lit')
assert(unarmed.layers.tr.variant === 'lit', 'Unarmed tr should be lit')
assert(
unarmed.layers.rh.variant === null &&
unarmed.layers.lh.variant === null &&
unarmed.layers.sh.variant === null,
'Unarmed hands/shield must be null',
)
// Circlet exemption (D2Inventory.cpp:2624)
const circletSpec = resolveSorceressAppearance(
{ helm: makeItem({ code: 'ci3', name: "Griffon's Eye", quality: 'unique', chrtransform: 'cblu' }) },
dropTables,
)
assert(
circletSpec.layers.hd.variant === 'lit' && circletSpec.layers.hd.color === null,
'Circlet (ci3) must keep hd=lit with null color',
)
// Asymmetric Studded Leather (stu) vs Breast Plate (brs)
const stuSpec = resolveSorceressAppearance(
{ armor: makeItem({ code: 'stu', name: 'Studded Leather', chrtransform: 'cgrn' }) },
dropTables,
)
assert(
stuSpec.layers.tr.variant === 'lit' &&
stuSpec.layers.lg.variant === 'med' &&
stuSpec.layers.ra.variant === 'med' &&
stuSpec.layers.la.variant === 'lit' &&
stuSpec.layers.s1.variant === 'med' &&
stuSpec.layers.s2.variant === 'med' &&
stuSpec.layers.tr.color === 'cgrn',
'Studded Leather (stu) asymmetric component mapping mismatch',
)
const brsSpec = resolveSorceressAppearance(
{ armor: makeItem({ code: 'brs', name: 'Breast Plate', chrtransform: 'lgld' }) },
dropTables,
)
assert(
brsSpec.layers.tr.variant === 'hvy' &&
brsSpec.layers.lg.variant === 'lit' &&
brsSpec.layers.ra.variant === 'lit' &&
brsSpec.layers.la.variant === 'lit' &&
brsSpec.layers.s1.variant === 'hvy' &&
brsSpec.layers.s2.variant === 'hvy' &&
brsSpec.layers.tr.color === 'lgld',
'Breast Plate (brs) component/color mapping mismatch',
)
// Weapons: Orb + Shield, Bow (lh!), Crossbow (rh!)
const orbShieldSpec = resolveSorceressAppearance(
{
weapon1: makeItem({ code: 'oba', name: 'The Oculus', quality: 'unique', chrtransform: 'whit' }),
weapon2: makeItem({ code: 'uit', name: 'Monarch', chrtransform: 'cblu' }),
},
dropTables,
)
assert(
orbShieldSpec.wclass === '1hs' &&
orbShieldSpec.layers.rh.variant === 'ob4' &&
orbShieldSpec.layers.rh.color === 'whit' &&
orbShieldSpec.layers.sh.variant === 'kit' &&
orbShieldSpec.layers.sh.color === 'cblu',
`Orb + Monarch shield appearance mismatch: ${JSON.stringify(orbShieldSpec)}`,
)
const bowSpec = resolveSorceressAppearance(
{
weapon1: makeItem({ code: 'sbw', name: 'Short Bow' }),
},
dropTables,
)
assert(
bowSpec.wclass === 'bow' &&
bowSpec.layers.lh.variant === 'sbw' &&
bowSpec.layers.rh.variant === null &&
bowSpec.layers.sh.variant === null,
'Short Bow must set wclass=bow, lh=sbw, rh=null, sh=null',
)
const xbwSpec = resolveSorceressAppearance(
{ weapon1: makeItem({ code: 'lxb', name: 'Light Crossbow' }) },
dropTables,
)
assert(
xbwSpec.wclass === 'xbw' && xbwSpec.layers.rh.variant === 'lxb' && xbwSpec.layers.lh.variant === null,
'Crossbow must set wclass=xbw, rh=lxb, lh=null',
)
console.log('✓ Sorceress 10-component appearance mapping passed all checks.')
console.log('--- 3. Verifying SorceressLayerCompositor Offline Pack & Palette Shifts ---')
const origFetch = globalThis.fetch
const rootDir = resolve('.')
globalThis.fetch = (async (input: RequestInfo | URL) => {
const urlStr = String(input)
const relPath = urlStr.replace(/^\/+/, '')
const buf = await readFile(join(rootDir, relPath))
return new Response(buf, { status: 200 })
}) as typeof fetch
try {
const compositor = new SorceressLayerCompositor('samples/d2-packs/chars/so')
const compUnarmed = await compositor.composite(unarmed)
const compOrbShield = await compositor.composite(orbShieldSpec)
const compOrbShieldRed = await compositor.composite(
resolveSorceressAppearance(
{
helm: makeItem({ code: 'crn', name: 'Crown', chrtransform: 'cred' }),
armor: makeItem({ code: 'ful', name: 'Full Plate Mail', chrtransform: 'cred' }),
weapon1: makeItem({ code: 'oba', name: 'The Oculus', quality: 'unique' }),
weapon2: makeItem({ code: 'uit', name: 'Monarch', chrtransform: 'cred' }),
},
dropTables,
),
)
const compOrbShieldBlue = await compositor.composite(
resolveSorceressAppearance(
{
helm: makeItem({ code: 'crn', name: 'Crown', chrtransform: 'cblu' }),
armor: makeItem({ code: 'ful', name: 'Full Plate Mail', chrtransform: 'cblu' }),
weapon1: makeItem({ code: 'oba', name: 'The Oculus', quality: 'unique' }),
weapon2: makeItem({ code: 'uit', name: 'Monarch', chrtransform: 'cblu' }),
},
dropTables,
),
)
assert(compUnarmed.members === 47, `Expected 47 unarmed members across 11 modes, got ${compUnarmed.members}`)
assert(compOrbShield.members > compUnarmed.members, `Expected orb+shield members > unarmed, got ${compOrbShield.members}`)
assert(compOrbShieldRed.members > compOrbShield.members, `Expected full plate+crown+orb+shield members > orb+shield, got ${compOrbShieldRed.members}`)
// Compare palette indices between Red and Blue dyed identical gear
assert(
compOrbShieldRed.r8.length === compOrbShieldBlue.r8.length,
'Identical gear with different dye colors must produce same atlas dimensions',
)
let diffPixels = 0
for (let i = 0; i < compOrbShieldRed.r8.length; i += 1) {
if (compOrbShieldRed.r8[i] !== compOrbShieldBlue.r8[i]) diffPixels += 1
}
assert(
diffPixels > 50_000,
`Expected >50,000 palette-shifted pixels between cred and cblu gear, got ${diffPixels}`,
)
console.log(`✓ SorceressLayerCompositor verified (${diffPixels.toLocaleString()} dyed pixels shifted between cred and cblu).`)
} finally {
globalThis.fetch = origFetch
}
}
async function startViteServer(port: number): Promise<ChildProcess> {
const child = spawn('npx', ['vite', '--host', '127.0.0.1', '--port', String(port)], {
cwd: resolve('.'),
stdio: ['ignore', 'pipe', 'pipe'],
})
const start = Date.now()
while (Date.now() - start < 20_000) {
try {
const resp = await fetch(`http://127.0.0.1:${String(port)}/acts.html`)
if (resp.ok) return child
} catch {
// wait
}
await sleep(250)
}
child.kill('SIGTERM')
throw new Error('Timed out waiting for Vite dev server')
}
async function runBrowserE2ECheck(): Promise<void> {
console.log('--- 4. Verifying Live WebGL Browser E2E Appearance & Dye Updates ---')
const port = 5173
let server: ChildProcess | null = null
const debugPort = 9249
let chrome: ChildProcess | null = null
try {
const probe = await fetch(`http://127.0.0.1:${String(port)}/acts.html`).catch(() => null)
if (!probe || !probe.ok) {
server = await startViteServer(port)
}
const consoleErrors: string[] = []
const mpqRequests: string[] = []
chrome = spawn('/usr/bin/google-chrome', [
'--headless=new',
`--remote-debugging-port=${String(debugPort)}`,
'--no-sandbox',
'--disable-dev-shm-usage',
'--enable-webgl',
'--ignore-gpu-blocklist',
'--use-gl=angle',
'--use-angle=swiftshader',
'--window-size=1280,720',
'about:blank',
])
const cdp = await connectCdp(debugPort, msg => {
const method = msg['method'] as string | undefined
if (method === 'Network.requestWillBeSent') {
const req = msg['params'].request as { url: string }
if (/\.mpq(\?|$)/i.test(req.url)) mpqRequests.push(req.url)
} else if (method === 'Runtime.consoleAPICalled') {
if (msg['params'].type === 'error') {
const args = (msg['params'].args ?? []) as { value?: unknown; description?: string }[]
const text = args.map(a => String(a.value ?? a.description ?? '')).join(' ')
consoleErrors.push(text)
}
} else if (method === 'Runtime.exceptionThrown') {
const d = msg['params'].exceptionDetails as { text?: string; exception?: { description?: string } }
consoleErrors.push(d.exception?.description ?? d.text ?? 'unknown exception')
}
})
try {
await cdp.send('Page.enable')
await cdp.send('Runtime.enable')
await cdp.send('Network.enable')
const targetUrl = `http://127.0.0.1:${String(port)}/acts.html?source=pack&base=samples/d2-packs&act=1&hero=so`
await cdp.send('Page.navigate', { url: targetUrl })
let ready = false
for (let i = 0; i < 120; i += 1) {
ready = await cdp.evalJs<boolean>(`Boolean(window.__d2webAct?.ready)`)
if (ready) break
await sleep(300)
}
assert(ready, 'Scene did not become ready within timeout')
// 1. Check initial Sorceress starter equipment appearance (Shako + Tal Armor + Oculus + Spirit Monarch)
const initialInfo = await cdp.evalJs<{
heroToken: string
weaponClass: string
members: number
key: string
appearance: any
}>(`(() => {
const act = window.__d2webAct
return {
heroToken: act?.heroToken,
weaponClass: act?.weaponClass,
members: act?.characterMembers,
key: act?.characterAppearanceKey,
appearance: act?.characterAppearance,
}
})()`)
assert(initialInfo.heroToken === 'so', `Expected heroToken=so, got ${String(initialInfo.heroToken)}`)
assert(initialInfo.weaponClass === '1hs', `Expected starter weaponClass=1hs, got ${String(initialInfo.weaponClass)}`)
assert(
initialInfo.appearance?.layers?.hd?.variant === 'msk' &&
initialInfo.appearance?.layers?.hd?.color === 'dpur',
`Expected starter hd=msk:dpur (Tal Rasha's Horadric Crest), got ${JSON.stringify(initialInfo.appearance?.layers?.hd)}`,
)
assert(
initialInfo.appearance?.layers?.tr?.variant === 'med' &&
initialInfo.appearance?.layers?.tr?.color === 'dpur' &&
initialInfo.appearance?.layers?.ra?.variant === 'hvy',
`Expected starter tr=med:dpur, ra=hvy (Tal Rasha's Guardianship), got ${JSON.stringify(initialInfo.appearance?.layers?.tr)}`,
)
assert(
initialInfo.appearance?.layers?.rh?.variant === 'ob4' &&
initialInfo.appearance?.layers?.rh?.color === 'dpur',
`Expected starter rh=ob4:dpur (Tal Rasha's Lidless Eye), got ${JSON.stringify(initialInfo.appearance?.layers?.rh)}`,
)
assert(
initialInfo.appearance?.layers?.sh?.variant === null &&
initialInfo.appearance?.layers?.sh?.color === null,
`Expected starter sh=null:null (5-piece Tal Rasha set has no shield), got ${JSON.stringify(initialInfo.appearance?.layers?.sh)}`,
)
// 1b. Equip Monarch Shield (uit -> kit, lgld) into weapon2 alongside Tal Rasha's Lidless Eye
const gearUpdateShield = await cdp.evalJs<{
changed: boolean
weaponClass: string
appearance: any
}>(`(async () => {
const hud = window.__d2webHudInstance
const scene = window.__d2webActScene
const act = window.__d2webAct
hud.inventory.equipped = {
...hud.inventory.equipped,
weapon2: {
id: 'gold-monarch',
code: 'uit',
name: "Holy Monarch",
nameZh: '神圣统治者大盾',
invFile: 'invuit',
quality: 'magic',
chrtransform: 'lgld',
identified: true,
invWidth: 2,
invHeight: 3,
allowedSlots: ['weapon2'],
stats: [],
},
}
const changed = await scene.syncAppearance()
return {
changed,
weaponClass: act.weaponClass,
appearance: act.characterAppearance,
}
})()`)
assert(gearUpdateShield.changed === true, 'Expected syncAppearance() to return true after equipping Monarch shield')
assert(
gearUpdateShield.appearance?.layers?.sh?.variant === 'kit' &&
gearUpdateShield.appearance?.layers?.sh?.color === 'lgld',
`Expected sh=kit:lgld (Holy Monarch), got ${JSON.stringify(gearUpdateShield.appearance?.layers?.sh)}`,
)
// 2. Equip Crown (cred) + Studded Leather (cblu) + Short Staff (sst)
const gearUpdate1 = await cdp.evalJs<{
changed: boolean
weaponClass: string
members: number
key: string
appearance: any
}>(`(async () => {
const hud = window.__d2webHudInstance
const scene = window.__d2webActScene
const act = window.__d2webAct
hud.inventory.equipped = {
helm: {
id: 'red-crown',
code: 'crn',
name: 'Ruby Crown',
nameZh: '红宝石皇冠',
invFile: 'invcrn',
quality: 'magic',
chrtransform: 'cred',
identified: true,
invWidth: 2,
invHeight: 2,
allowedSlots: ['helm'],
stats: [],
},
armor: {
id: 'blue-stu',
code: 'stu',
name: 'Cobalt Studded Leather',
nameZh: '钴蓝镶嵌皮甲',
invFile: 'invstu',
quality: 'magic',
chrtransform: 'cblu',
identified: true,
invWidth: 2,
invHeight: 3,
allowedSlots: ['armor'],
stats: [],
},
weapon1: {
id: 'short-staff',
code: 'sst',
name: 'Short Staff',
nameZh: '短棍',
invFile: 'invsst',
quality: 'normal',
identified: true,
invWidth: 1,
invHeight: 3,
allowedSlots: ['weapon1'],
stats: [],
},
}
const changed = await scene.syncAppearance()
return {
changed,
weaponClass: act.weaponClass,
members: act.characterMembers,
key: act.characterAppearanceKey,
appearance: act.characterAppearance,
}
})()`)
assert(gearUpdate1.changed === true, 'Expected syncAppearance() to return true after equipping Crown + Studded Leather + Short Staff')
assert(gearUpdate1.weaponClass === 'stf', `Expected weaponClass=stf, got ${String(gearUpdate1.weaponClass)}`)
assert(
gearUpdate1.appearance.layers.hd.variant === 'crn' &&
gearUpdate1.appearance.layers.hd.color === 'cred',
'Ruby Crown should resolve to crn:cred',
)
assert(
gearUpdate1.appearance.layers.tr.variant === 'lit' &&
gearUpdate1.appearance.layers.tr.color === 'cblu' &&
gearUpdate1.appearance.layers.lg.variant === 'med' &&
gearUpdate1.appearance.layers.ra.variant === 'med' &&
gearUpdate1.appearance.layers.la.variant === 'lit',
`Cobalt Studded Leather should resolve to tr=lit:cblu, lg=med, ra=med, la=lit, got ${JSON.stringify(gearUpdate1.appearance.layers)}`,
)
assert(
gearUpdate1.appearance.layers.rh.variant === 'bst' &&
gearUpdate1.appearance.layers.sh.variant === null,
`Short Staff (sst -> alternateGfx=bst) should resolve to rh=bst and sh=null, got ${JSON.stringify(gearUpdate1.appearance.layers)}`,
)
// 3. Equip Bow (Short Bow) and verify wclass='bow' and lh='sbw'
const gearUpdateBow = await cdp.evalJs<{
changed: boolean
weaponClass: string
members: number
appearance: any
}>(`(async () => {
const hud = window.__d2webHudInstance
const scene = window.__d2webActScene
const act = window.__d2webAct
hud.inventory.equipped = {
weapon1: {
id: 'test-bow',
code: 'sbw',
name: 'Short Bow',
nameZh: '短弓',
invFile: 'invsbw',
quality: 'normal',
identified: true,
invWidth: 2,
invHeight: 3,
allowedSlots: ['weapon1'],
stats: [],
},
}
const changed = await scene.syncAppearance()
return {
changed,
weaponClass: act.weaponClass,
members: act.characterMembers,
appearance: act.characterAppearance,
}
})()`)
assert(gearUpdateBow.changed === true, 'Expected syncAppearance() to return true when equipping Short Bow')
assert(gearUpdateBow.weaponClass === 'bow', `Expected weaponClass=bow, got ${String(gearUpdateBow.weaponClass)}`)
assert(gearUpdateBow.appearance.layers.lh?.variant === 'sbw', 'Short Bow must equip on LH layer')
assert(gearUpdateBow.appearance.layers.rh?.variant === null, 'Short Bow must leave RH layer null')
assert(consoleErrors.length === 0, `Browser console/page errors: ${consoleErrors.join('; ')}`)
assert(mpqRequests.length === 0, `Forbidden runtime MPQ requests: ${mpqRequests.join('; ')}`)
console.log('✓ Live WebGL Browser E2E appearance & dye verification passed with 0 errors and 0 MPQ requests.')
} finally {
cdp.close()
}
} finally {
if (chrome) chrome.kill('SIGKILL')
if (server) server.kill('SIGTERM')
}
}
async function main(): Promise<void> {
await runUnitAndCompositorChecks()
await runBrowserE2ECheck()
console.log('\n🎉 ALL SORCERESS APPEARANCE & COLOR VERIFICATION CHECKS PASSED!')
}
main().catch(err => {
console.error(err)
process.exit(1)
})

View File

@ -1,296 +0,0 @@
/**
* scripts/verify-controlbar-browser.ts
*
* Headless Chrome browser verification of ControlBar XP bar rendering & hover interactions:
* 1. Offscreen HTML Canvas Pixel Audit:
* - Real OffscreenCanvas in Google Chrome.
* - Renders 800CtrlPnl7 panel + ControlBarHud.
* - Extracts real pixel data via getImageData().
* - Verifies groove [256, 560, 120, 4] colors.
* - Verifies gold trim borders at y=559 and y=564 have ZERO pixel bleed.
* 2. Real DOM Mouse Event Projection across DPRs & Viewports:
* - Viewports: 800x600, 1920x1080.
* - DPRs: 1.0, 1.25, 1.5, 2.0.
* - Inspects window.__d2webHudInstance.controlBar.hoveredElement.
*/
import { spawn } from 'node:child_process'
import { createServer } from 'vite'
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
async function main(): Promise<void> {
console.log('=== Headless Chrome ControlBar Stress Test ===')
// 1. Start Vite dev server
const server = await createServer({
root: process.cwd(),
server: { port: 0, host: '127.0.0.1' },
})
await server.listen()
const address = server.httpServer?.address()
const port = typeof address === 'object' && address ? address.port : 5173
const baseUrl = `http://127.0.0.1:${port}`
console.log(`1. Vite server running at ${baseUrl}`)
// 2. Launch headless Chrome with CDP
const debugPort = 9248
const chromeProc = spawn('/usr/bin/google-chrome', [
'--headless=new',
`--remote-debugging-port=${debugPort}`,
'--no-sandbox',
'--disable-dev-shm-usage',
'--enable-webgl',
'--window-size=1920,1080',
`${baseUrl}/acts.html?act=1`,
])
try {
let wsUrl: string | null = null
for (let i = 0; i < 60; i++) {
await sleep(300)
try {
const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`)
const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[]
const page = pages.find(p => p.type === 'page' && p.url.includes('acts.html'))
if (page?.webSocketDebuggerUrl) {
wsUrl = page.webSocketDebuggerUrl
break
}
} catch {}
}
if (!wsUrl) throw new Error('Failed to connect to Chrome CDP')
console.log('2. Connected to Chrome CDP:', wsUrl)
const ws = new WebSocket(wsUrl)
await new Promise<void>(resolve => { ws.onopen = () => resolve() })
let idCounter = 1
const sendCommand = <T = any>(method: string, params: Record<string, unknown> = {}): Promise<T> =>
new Promise((resolve, reject) => {
const id = idCounter++
const handler = (event: MessageEvent) => {
const msg = JSON.parse(String(event.data))
if (msg.id === id) {
ws.removeEventListener('message', handler)
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
else resolve(msg.result as T)
}
}
ws.addEventListener('message', handler)
ws.send(JSON.stringify({ id, method, params }))
})
const evalJs = async <T = any>(expression: string): Promise<T> => {
const res = await sendCommand<{ result: { value: T }; exceptionDetails?: any }>('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
})
if (res.exceptionDetails) {
throw new Error(`JS exception: ${JSON.stringify(res.exceptionDetails)}`)
}
return res.result.value
}
await sendCommand('Runtime.enable')
await sendCommand('Page.enable')
// Wait for HudManager to be ready
for (let i = 0; i < 100; i++) {
const ready = await evalJs<boolean>(`Boolean(window.__d2webHud?.ready && window.__d2webHudInstance)`)
if (ready) break
await sleep(300)
}
console.log('3. HudManager ready in Chrome!')
// Test 1: Real OffscreenCanvas rendering & pixel audit inside Chrome
console.log('\n--- Test 1: Chrome OffscreenCanvas Pixel Audit ---')
const pixelAuditResult = await evalJs<{
bleedTop559Count: number
bleedBot564Count: number
grooveBackgroundRgb: number[]
grooveFilledRgb: number[]
tickNotchRgb: number[]
tickPositions: number[]
}>(`(() => {
const hudInst = window.__d2webHudInstance;
const ctrlBar = hudInst.controlBar;
const ctrlImg = hudInst.images.get('ctrlPnl');
// Create base OffscreenCanvas with only background image
const baseCanvas = new OffscreenCanvas(800, 600);
const baseCtx = baseCanvas.getContext('2d');
baseCtx.drawImage(ctrlImg, 0, 496);
const baseImgData = baseCtx.getImageData(0, 0, 800, 600).data;
// Create test OffscreenCanvas and render ControlBar with 50% XP
const testCanvas = new OffscreenCanvas(800, 600);
const testCtx = testCanvas.getContext('2d');
const testState = {
level: 1,
xp: 1500,
currentLevelXp: 1000,
nextLevelXp: 2000,
stamina: 100,
maxStamina: 100,
isRunning: false,
unspentStatPoints: 0,
unspentSkillPoints: 0,
};
const assetBundle = {
ctrlPnlImg: hudInst.images.get('ctrlPnl') ?? null,
runBtnImg: hudInst.images.get('runbutton') ?? null,
menuBtnImg: hudInst.images.get('menubutton') ?? null,
miniPanelImg: hudInst.images.get('minipanel') ?? null,
miniPanelBtnsImg: hudInst.images.get('minipanelBtns') ?? null,
levelBtnImg: hudInst.images.get('levelBtn') ?? null,
levelSocketImg: hudInst.images.get('levelSocket') ?? null,
};
ctrlBar.draw(testCtx, testState, assetBundle, hudInst.font);
const testImgData = testCtx.getImageData(0, 0, 800, 600).data;
let bleedTop559Count = 0;
let bleedBot564Count = 0;
// Check row y=559 for x in [256..375]
for (let x = 256; x <= 375; x++) {
const idx = (559 * 800 + x) * 4;
if (
testImgData[idx] !== baseImgData[idx] ||
testImgData[idx + 1] !== baseImgData[idx + 1] ||
testImgData[idx + 2] !== baseImgData[idx + 2]
) {
bleedTop559Count++;
}
}
// Check row y=564 for x in [256..375]
for (let x = 256; x <= 375; x++) {
const idx = (564 * 800 + x) * 4;
if (
testImgData[idx] !== baseImgData[idx] ||
testImgData[idx + 1] !== baseImgData[idx + 1] ||
testImgData[idx + 2] !== baseImgData[idx + 2]
) {
bleedBot564Count++;
}
}
// Sample groove pixels
// Filled area (x=260, y=561)
const filledIdx = (561 * 800 + 260) * 4;
const grooveFilledRgb = [testImgData[filledIdx], testImgData[filledIdx + 1], testImgData[filledIdx + 2]];
// Unfilled area (x=350, y=561)
const unfilledIdx = (561 * 800 + 350) * 4;
const grooveBackgroundRgb = [testImgData[unfilledIdx], testImgData[unfilledIdx + 1], testImgData[unfilledIdx + 2]];
// Tick notch at x=268, y=561
const notchIdx = (561 * 800 + 268) * 4;
const tickNotchRgb = [testImgData[notchIdx], testImgData[notchIdx + 1], testImgData[notchIdx + 2]];
// Verify all 9 ticks
const tickPositions = [];
for (let i = 1; i < 10; i++) {
const tx = 256 + i * 12;
const tidx = (561 * 800 + tx) * 4;
tickPositions.push(testImgData[tidx]);
}
return {
bleedTop559Count,
bleedBot564Count,
grooveBackgroundRgb,
grooveFilledRgb,
tickNotchRgb,
tickPositions,
};
})()`)
console.log('Pixel audit result from Chrome:', JSON.stringify(pixelAuditResult, null, 2))
// Test 2: Hover Hit-Testing in real Chrome across DPRs & Resolutions
console.log('\n--- Test 2: Hover Hit-Testing in Chrome across DPRs & Resolutions ---')
const resolutions = [
{ w: 800, h: 600 },
{ w: 1920, h: 1080 },
]
const dprs = [1.0, 1.25, 1.5, 2.0]
for (const res of resolutions) {
for (const dpr of dprs) {
// Set emulation device metrics
await sendCommand('Emulation.setDeviceMetricsOverride', {
width: res.w,
height: res.h,
deviceScaleFactor: dpr,
mobile: false,
})
await sleep(100)
// Evaluate hover at key points
const hoverResult = await evalJs<{
centerHover: string | null
outsideHover: string | null
leftEdgeHover: string | null
rightEdgeHover: string | null
}>(`(() => {
const inst = window.__d2webHudInstance;
const rect = inst.hudCanvas.getBoundingClientRect();
// Calculate client coords for center
const scale = Math.min(rect.width / 800, rect.height / 600);
const offX = Math.max(0, (rect.width - 800 * scale) / 2);
const offY = Math.max(0, rect.height - 600 * scale);
const clientCenterX = 316 * scale + offX;
const clientCenterY = 562 * scale + offY;
const mappedCenter = inst.clientToLogical(clientCenterX, clientCenterY);
inst.controlBar.handleMouseMove(mappedCenter.x, mappedCenter.y);
const centerHover = inst.controlBar.hoveredElement;
// Test point outside (above groove)
const clientOutsideY = 550 * scale + offY;
const mappedOutside = inst.clientToLogical(clientCenterX, clientOutsideY);
inst.controlBar.handleMouseMove(mappedOutside.x, mappedOutside.y);
const outsideHover = inst.controlBar.hoveredElement;
// Left edge (x = 256.5 to avoid IEEE 754 epsilon boundary)
const clientLeftX = 256.5 * scale + offX;
const mappedLeft = inst.clientToLogical(clientLeftX, clientCenterY);
inst.controlBar.handleMouseMove(mappedLeft.x, mappedLeft.y);
const leftEdgeHover = inst.controlBar.hoveredElement;
// Right edge (x = 375.5)
const clientRightX = 375.5 * scale + offX;
const mappedRight = inst.clientToLogical(clientRightX, clientCenterY);
inst.controlBar.handleMouseMove(mappedRight.x, mappedRight.y);
const rightEdgeHover = inst.controlBar.hoveredElement;
return {
centerHover,
outsideHover,
leftEdgeHover,
rightEdgeHover,
};
})()`)
console.log(`Resolution ${res.w}x${res.h} @ DPR ${dpr}:`, hoverResult)
}
}
} finally {
chromeProc.kill('SIGKILL')
await server.close()
}
}
main().catch(err => {
console.error(err)
process.exit(1)
})

View File

@ -1,817 +0,0 @@
/**
* Headless Browser Verification Audit for Ground Items & Combat Drop Lifecycle (M11.5 / Issue #408 & Issue #449).
*
* Verifies end-to-end authentic combat drop parity in acts.html:
* 1. Scene & HUD initialization with fail-fast pack error detection.
* 2. Authentic monster kill & death state transition, unique elite multi-drops (Act 1 Unique A -> 4 potions + 1 item/gold).
* 3. Isometric ground drop scattering (findIsometricDropPosition).
* 4. Flippy bounce parabolic animation trajectory & WebGL canvas rendering.
* 5. Alt floating labels, Chinese localization, quality colors, and ladder collision resolution.
* 6. Dynamic ground item collection & inventory placement without invbox fallbacks.
* 7. Negative control: Inventory full refusal feedback & secondary bounce.
* 8. Zero runtime errors, zero MPQ requests, and 500-kill statistical drop assertions (3-sigma Binomial confidence).
*
* Run:
* npx tsx scripts/verify-ground-combat-browser.ts [--port=8080] [--out=/tmp/ground-audit] [--packs-dir=...]
*/
import { execSync, spawn, type ChildProcess } from 'node:child_process'
import { existsSync, mkdirSync, symlinkSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { createServer, type ViteDevServer } from 'vite'
import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts'
import { executeDropPipeline } from '../src/common/items/drop-tables.ts'
import { D2Rng } from '../src/common/rng/d2-rng.ts'
import { resolveGroundItemSpriteRect, resolveItemSpriteRect } from '../src/client/ui/inventory.ts'
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
import { itemToUiInventoryItem } from '../src/common/items/item-bridge.ts'
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
const CHROME_CANDIDATES = [
'/usr/bin/google-chrome',
'/usr/local/google/home/taodao/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome',
'/root/.cache/ms-playwright/chromium-1223/chrome-linux64/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(', ')}`)
}
/**
* Preflight check ensuring `samples/d2-packs` is accessible.
* In a git worktree, automatically detects the parent repository via `git rev-parse --git-common-dir`
* and creates a symlink to prevent 404s and 20-second timeout hangs.
*/
export function ensurePacksAvailable(customPacksDir?: string): string {
// 1. Explicit CLI argument or environment variable
const envDir = customPacksDir ?? process.env.D2_PACKS_DIR
if (envDir && existsSync(join(envDir, 'index.json'))) {
return envDir
}
// 2. Local samples/d2-packs
const localPacks = resolve('samples/d2-packs')
if (existsSync(join(localPacks, 'index.json'))) {
return localPacks
}
// 3. Resolve via git common dir (for git worktrees)
try {
const gitCommonDir = execSync('git rev-parse --git-common-dir', { encoding: 'utf-8' }).trim()
if (gitCommonDir) {
const mainRepoDir = resolve(gitCommonDir, '..')
const parentPacks = join(mainRepoDir, 'samples/d2-packs')
if (existsSync(join(parentPacks, 'index.json'))) {
mkdirSync(resolve('samples'), { recursive: true })
if (!existsSync(localPacks)) {
symlinkSync(parentPacks, localPacks)
console.log(`[Preflight] Auto-symlinked samples/d2-packs from main repository: ${parentPacks} -> ${localPacks}`)
}
return localPacks
}
}
} catch {
// Ignore git command error
}
// 4. Fail-fast
console.error('\n[FATAL] Missing required pre-baked pack assets at samples/d2-packs/index.json.')
console.error('When running in a git worktree, ensure samples/d2-packs is symlinked from the main repo or set D2_PACKS_DIR.')
console.error('To generate packs, run: npm run pack:data\n')
process.exit(1)
}
function ensureD2Available(): void {
const localD2 = resolve('samples/d2')
if (existsSync(localD2)) return
try {
const gitCommonDir = execSync('git rev-parse --git-common-dir', { encoding: 'utf-8' }).trim()
if (gitCommonDir) {
const parentD2 = join(resolve(gitCommonDir, '..'), 'samples/d2')
if (existsSync(parentD2)) {
mkdirSync(resolve('samples'), { recursive: true })
symlinkSync(parentD2, localD2)
console.log(`[Preflight] Auto-symlinked samples/d2 from main repository: ${parentD2} -> ${localD2}`)
return
}
}
} catch {}
if (existsSync('/usr/local/google/home/taodao/d2-data')) {
mkdirSync(resolve('samples'), { recursive: true })
symlinkSync('/usr/local/google/home/taodao/d2-data', localD2)
console.log(`[Preflight] Auto-symlinked samples/d2 from /usr/local/google/home/taodao/d2-data -> ${localD2}`)
}
}
export interface GroundAuditResult {
readonly success: boolean
readonly durationMs: number
readonly stages: {
readonly stage1_sceneReady: boolean
readonly stage2_monsterKill: boolean
readonly stage3_groundScatter: boolean
readonly stage4_flippyBounce: boolean
readonly stage5_altLabelsAndLadder: boolean
readonly stage6_itemAndGoldPickup: boolean
readonly stage7_negativeControlFullBag: boolean
readonly stage8_zeroErrors: boolean
}
readonly metrics: Record<string, unknown>
readonly consoleErrors: string[]
readonly webGlErrors: number[]
readonly mpqRequests: string[]
readonly screenshots: string[]
}
export async function runGroundCombatBrowserAudit(options?: {
port?: number | undefined
outputDir?: string | undefined
packsDir?: string | undefined
}): Promise<GroundAuditResult> {
const startTime = Date.now()
const port = options?.port ?? (5300 + Math.floor(Math.random() * 500))
const outputDir = options?.outputDir ?? resolve('/tmp/ground-audit-' + Date.now())
mkdirSync(outputDir, { recursive: true })
// Worktree asset preflight check
ensurePacksAvailable(options?.packsDir)
ensureD2Available()
console.log('======================================================================')
console.log(' Diablo II Web · Ground Combat & Drop Lifecycle Browser Audit')
console.log(' Milestone M11.5 (Issue #408) & Issue #449 · Target: acts.html')
console.log(` Artifacts: ${outputDir}`)
console.log('======================================================================\n')
// 1. Launch Vite Dev Server
console.log(`[1/8] Starting Vite dev server on port ${port}...`)
const viteServer: ViteDevServer = await createServer({
root: resolve('.'),
logLevel: 'silent',
server: {
host: '127.0.0.1',
port,
strictPort: false,
},
})
await viteServer.listen()
const actualPort = (viteServer.httpServer?.address() as any)?.port ?? port
const baseUrl = `http://127.0.0.1:${actualPort}`
console.log(` Vite server ready at ${baseUrl}/acts.html\n`)
// 2. Launch Headless Chromium with Remote Debugging
const chromeBin = resolveChromeBinary()
const debugPort = 9400 + Math.floor(Math.random() * 500)
const targetUrl = `${baseUrl}/acts.html?act=1&level=2-act-1-wilderness-1-var1`
console.log(`[2/8] Spawning Headless Chromium (${chromeBin})`)
console.log(` Target URL: ${targetUrl}`)
const chromeProc: ChildProcess = spawn(
chromeBin,
[
'--headless=new',
'--no-sandbox',
'--disable-dev-shm-usage',
'--window-size=1280,840',
'--enable-webgl',
'--ignore-gpu-blocklist',
'--use-gl=angle',
'--use-angle=swiftshader',
'--no-proxy-server',
`--remote-debugging-port=${debugPort}`,
targetUrl,
],
{ stdio: ['ignore', 'ignore', 'pipe'] },
)
const consoleErrors: string[] = []
const webGlErrors: number[] = []
const mpqRequests: string[] = []
const screenshots: string[] = []
let ws: WebSocket | null = null
try {
// 3. Connect to Chrome DevTools Protocol
let wsDebuggerUrl: string | null = null
for (let i = 0; i < 80; i++) {
await sleep(150)
try {
const res = await fetch(`http://127.0.0.1:${debugPort}/json`)
const pages = (await res.json()) as any[]
const page = pages.find(p => p.type === 'page' && p.url.includes('acts.html'))
if (page?.webSocketDebuggerUrl) {
wsDebuggerUrl = page.webSocketDebuggerUrl
break
}
} catch {}
}
if (!wsDebuggerUrl) throw new Error(`Failed to attach to Chrome CDP on debug port ${debugPort}`)
ws = new WebSocket(wsDebuggerUrl)
await new Promise((res, rej) => {
ws!.onopen = () => res(true)
ws!.onerror = rej
})
let cmdId = 1
const pendingCmds = new Map<number, (res: any) => void>()
ws.onmessage = (event: MessageEvent) => {
const msg = JSON.parse(String(event.data))
if (msg.method === 'Runtime.exceptionThrown') {
consoleErrors.push(`[Exception] ${JSON.stringify(msg.params)}`)
} else if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') {
consoleErrors.push(`[Console.error] ${JSON.stringify(msg.params.args)}`)
} else if (msg.method === 'Network.requestWillBeSent') {
const url = msg.params?.request?.url ?? ''
if (/\.(mpq|dll)(\?|$)/i.test(url)) {
mpqRequests.push(url)
}
}
if (msg.id && pendingCmds.has(msg.id)) {
pendingCmds.get(msg.id)!(msg)
pendingCmds.delete(msg.id)
}
}
const cdp = <T = any>(method: string, params: Record<string, unknown> = {}): Promise<T> =>
new Promise((resolve, reject) => {
const id = cmdId++
pendingCmds.set(id, (msg: any) => {
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
else resolve(msg.result as T)
})
ws!.send(JSON.stringify({ id, method, params }))
})
const evalJs = async <T = any>(expression: string): Promise<T> => {
const res = await cdp<{ result: { value: T }; exceptionDetails?: any }>('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
})
if (res.exceptionDetails) {
throw new Error(`Eval error in browser: ${JSON.stringify(res.exceptionDetails)}`)
}
return res.result.value
}
const takeScreenshot = async (name: string): Promise<string> => {
const shot = await cdp<{ data: string }>('Page.captureScreenshot', { format: 'png' })
const filePath = join(outputDir, `${name}.png`)
writeFileSync(filePath, Buffer.from(shot.data, 'base64'))
screenshots.push(filePath)
console.log(` Saved screenshot: ${name}.png`)
return filePath
}
await cdp('Runtime.enable')
await cdp('Page.enable')
await cdp('Network.enable')
// =========================================================================
// STAGE 1: Wait for Scene & HUD Ready (Fast Error Abort)
// =========================================================================
console.log('\n[STAGE 1] Waiting for ActScene and HudManager initialization...')
let ready = false
for (let i = 0; i < 100; i++) {
const actError = await evalJs<string | null>(`window.__d2webAct?.error ?? null`)
if (actError) {
throw new Error(`ActScene initialization failed with error: ${actError}`)
}
ready = await evalJs<boolean>(`Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready)`)
if (ready) break
await sleep(200)
}
if (!ready) throw new Error('Timeout waiting for window.__d2webAct & window.__d2webHud')
const initialMetrics = await evalJs<any>(`(() => {
const gl = document.querySelector('#view')?.getContext('webgl2');
const err = gl ? gl.getError() : -1;
return {
level: window.__d2webAct?.level,
tick: window.__d2webAct?.tick,
monstersCount: window.__d2webEngine?.world?.monsters?.length ?? 0,
playerPos: { x: window.__d2webEngine?.world?.player?.x, y: window.__d2webEngine?.world?.player?.y },
glError: err,
};
})()`)
if (initialMetrics.glError !== 0) webGlErrors.push(initialMetrics.glError)
console.log(` Scene loaded level: ${initialMetrics.level}, monsters: ${initialMetrics.monstersCount}`)
console.log(` Player spawn: (${initialMetrics.playerPos.x}, ${initialMetrics.playerPos.y})`)
await takeScreenshot('01-scene-ready')
// =========================================================================
// STAGE 2: Monster Spawn, Authentic Player Combat & Monster Kill
// =========================================================================
console.log('\n[STAGE 2] Executing authentic combat and killing unique elite monster...')
const combatResult = await evalJs<any>(`(() => {
const engine = window.__d2webEngine;
const player = engine.world.player;
// Locate or place a test monster in front of player
let target = engine.world.monsters.find(m => m.state !== 'dead' && Math.hypot(m.x - player.x, m.y - player.y) < 160);
if (!target && engine.world.monsters.length > 0) {
target = engine.world.monsters.find(m => m.state !== 'dead');
}
if (!target) {
// Fallback spawn for deterministic combat testing with unique rank
target = {
index: engine.world.monsters.length,
stats: { id: 'zombie1', name: '僵尸 (Zombie)', hp: 10, xp: 50, rank: 'unique', level: 2 },
state: 'idle',
x: player.x + 25,
y: player.y + 10,
hp: 10,
maxHp: 10,
facing: 0,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
dropRolled: false,
};
engine.world.monsters.push(target);
} else {
// Upgrade to unique elite monster in Act 1 (rolls Act 1 Unique A -> 4 potions + 1 equip/gold)
target.stats.rank = 'unique';
if (!target.stats.level || target.stats.level < 2) target.stats.level = 2;
if (!target.stats.id) target.stats.id = 'zombie1';
target.hp = Math.max(target.hp, 10);
target.state = 'idle';
target.dropRolled = false;
target.x = player.x + 25;
target.y = player.y + 10;
}
const preKills = engine.world.kills || 0;
const preDrops = engine.metrics.dropsRolled || 0;
const preGroundCount = engine.groundItems.count || 0;
// Route damage through authentic combat execution:
// Player cooldown reset, player damage set to lethal, attacking tick executed
player.cooldown = 0;
engine.opts.combatOptions.playerDamage = 9999;
engine.tick({ movement: { x: 0, y: 0 }, attacking: true, digits: [] });
// If target wasn't finished in first swing (e.g. if out of reach), execute lethal hit via damageMonster
if (target.state !== 'dead') {
if (typeof window.__d2webDamageMonster === 'function') {
window.__d2webDamageMonster(engine.world, target.index, 9999, player);
} else if (typeof engine.damageMonster === 'function') {
engine.damageMonster(target.index, 9999, player);
}
engine.tick({ movement: { x: 0, y: 0 }, attacking: false, digits: [] });
}
return {
kills: engine.world.kills,
killsDelta: (engine.world.kills || 0) - preKills,
dropsRolled: engine.metrics.dropsRolled,
dropsDelta: (engine.metrics.dropsRolled || 0) - preDrops,
groundCount: engine.groundItems.count,
newGroundCount: engine.groundItems.count - preGroundCount,
targetState: target.state,
targetRank: target.stats?.rank,
items: engine.groundItems.all.map(g => ({
id: g.id,
code: g.item?.code ?? g.code,
name: g.item?.name ?? g.name,
nameZh: g.nameZh,
isGold: g.isGold,
amount: g.amount,
x: g.x,
y: g.y,
hasBounce: Boolean(g.bounceState),
})),
};
})()`)
console.log(` Kills: ${combatResult.kills} (+${combatResult.killsDelta}), Drops rolled: ${combatResult.dropsRolled} (+${combatResult.dropsDelta})`)
console.log(` Ground items spawned: ${combatResult.groundCount} (New drops: ${combatResult.newGroundCount})`)
if (combatResult.killsDelta < 1) throw new Error('Monster was not killed by combat execution')
if (combatResult.dropsDelta < 1) throw new Error('Drop pipeline was not rolled on monster kill')
if (combatResult.newGroundCount < 5) throw new Error(`Expected at least 5 drops from unique elite kill, got ${combatResult.newGroundCount}`)
await takeScreenshot('02-monster-killed-and-dropped')
// =========================================================================
// STAGE 3 & 4: Ground Scatter Spacing & Flippy Bounce Parabolic Flight
// =========================================================================
console.log('\n[STAGE 3 & 4] Verifying Isometric Scatter & Flippy Bounce trajectory...')
const bounceMetrics = await evalJs<any>(`(() => {
const engine = window.__d2webEngine;
const items = engine.groundItems.all;
const itemA = items[0];
const itemB = items[1];
const dist = Math.hypot(itemA.x - itemB.x, itemA.y - itemB.y);
// Verify parabolic bounce heights
const bState = itemA.bounceState ?? {
startTime: 0,
durationMs: 400,
peakHeightPx: 24,
phase: 'primary',
};
const calcHeight = (t) => {
const elapsed = t - bState.startTime;
if (elapsed <= 0 || elapsed >= bState.durationMs) return 0;
const p = elapsed / bState.durationMs;
return 4 * bState.peakHeightPx * p * (1 - p);
};
const hStart = calcHeight(bState.startTime);
const hMid = calcHeight(bState.startTime + bState.durationMs / 2);
const hEnd = calcHeight(bState.startTime + bState.durationMs + 10);
// Canvas WebGL render check
const gl = document.querySelector('#view')?.getContext('webgl2');
const glErr = gl ? gl.getError() : -1;
return {
distBetweenItems: dist,
durationMs: bState.durationMs,
peakHeightPx: bState.peakHeightPx,
hStart,
hMid,
hEnd,
glErr,
};
})()`)
if (bounceMetrics.glErr !== 0) webGlErrors.push(bounceMetrics.glErr)
console.log(` Distance between scattered items: ${bounceMetrics.distBetweenItems.toFixed(1)}px (>= 10px)`)
console.log(` Bounce duration: ${bounceMetrics.durationMs}ms, Peak height: ${bounceMetrics.peakHeightPx}px`)
console.log(` Parabolic heights: Start=${bounceMetrics.hStart}px, Mid=${bounceMetrics.hMid}px, End=${bounceMetrics.hEnd}px`)
if (bounceMetrics.distBetweenItems < 10) throw new Error('Items stacked without isometric scatter')
if (Math.abs(bounceMetrics.hMid - bounceMetrics.peakHeightPx) > 0.5) {
throw new Error(`Parabolic peak height mismatch: expected ${bounceMetrics.peakHeightPx}px, got ${bounceMetrics.hMid}px`)
}
// Ground sprite flippy rect resolution verification
const groundSpriteValidation = combatResult.items.map((item: any) => {
const sr = resolveGroundItemSpriteRect(item, BAKED_UI_MANIFEST.flippyRects, BAKED_UI_MANIFEST.codeToFlippyFile)
return {
id: item.id,
code: item.code,
hasRect: sr !== null,
isFlpbox: sr === BAKED_UI_MANIFEST.flippyRects?.['flpbox'] && item.code !== 'box',
}
})
const invalidGroundSprites = groundSpriteValidation.filter((v: any) => !v.hasRect || v.isFlpbox)
if (invalidGroundSprites.length > 0) {
throw new Error(`Ground items failed flippy sprite resolution: ${JSON.stringify(invalidGroundSprites)}`)
}
// =========================================================================
// STAGE 5: Alt Key Labels & Vertical Ladder Collision Resolution
// =========================================================================
console.log('\n[STAGE 5] Triggering Alt key floating labels & verifying ladder collisions...')
// Press Alt and wait for render loop to update overlay
await evalJs(`new Promise(resolve => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', bubbles: true }));
requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(resolve, 200)));
})`)
const altMetrics = await evalJs<any>(`(() => {
const hud = window.__d2webHud;
const container = document.querySelector('#ground-labels');
const labelEls = Array.from(container ? container.querySelectorAll('.d2-ground-label') : [])
.filter(el => el.style.display !== 'none' && !el.hidden);
const boxes = labelEls.map(el => {
const r = el.getBoundingClientRect();
return {
text: el.textContent,
color: el.style.color,
left: r.left,
top: r.top,
right: r.right,
bottom: r.bottom,
width: r.width,
height: r.height,
};
});
// Check for bounding box collisions
let overlapCount = 0;
for (let i = 0; i < boxes.length; i++) {
for (let j = i + 1; j < boxes.length; j++) {
const a = boxes[i];
const b = boxes[j];
const xOverlap = a.left < b.right && a.right > b.left;
const yOverlap = a.top < b.bottom && a.bottom > b.top;
if (xOverlap && yOverlap) overlapCount++;
}
}
return {
showGroundLabels: hud.showGroundLabels,
labelCount: labelEls.length,
boxes,
overlapCount,
};
})()`)
console.log(` Alt pressed: showGroundLabels = ${altMetrics.showGroundLabels}`)
console.log(` Active DOM labels rendered: ${altMetrics.labelCount}`)
console.log(` Labels text & color: ${altMetrics.boxes.map((b: any) => `${b.text} (${b.color})`).join(', ')}`)
console.log(` Ladder collision bounding box overlaps: ${altMetrics.overlapCount} (MUST BE 0)`)
if (altMetrics.labelCount < 5) throw new Error(`Expected at least 5 ground labels rendered for unique drop, got ${altMetrics.labelCount}`)
if (altMetrics.overlapCount !== 0) throw new Error(`Ladder collision resolution failed: ${altMetrics.overlapCount} overlapping labels found!`)
await takeScreenshot('03-alt-labels-ladder-stacked')
// Release Alt and wait for overlay to hide
await evalJs(`new Promise(resolve => {
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Alt', bubbles: true }));
requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(resolve, 150)));
})`)
const altReleasedCount = await evalJs<number>(`(() => {
const container = document.querySelector('#ground-labels');
return Array.from(container ? container.querySelectorAll('.d2-ground-label') : [])
.filter(el => el.style.display !== 'none' && !el.hidden).length;
})()`)
console.log(` Alt released: active visible labels = ${altReleasedCount} (expected 0)`)
if (altReleasedCount !== 0) throw new Error(`Ground labels still visible after Alt release: ${altReleasedCount}`)
// =========================================================================
// STAGE 6: Dynamic Ground Entity Collection & Inventory Parity
// =========================================================================
console.log('\n[STAGE 6] Dynamically collecting all ground entities and verifying inventory parity...')
const pickupMetrics = await evalJs<any>(`(() => {
const engine = window.__d2webEngine;
const mouse = window.__d2webMouseController;
const hud = window.__d2webHudInstance;
const player = engine.world.player;
const preGold = hud.inventory.gold || 0;
const preBagCount = hud.inventory.gridItems.length;
const prePickups = engine.metrics.pickups || 0;
const initialGroundCount = engine.groundItems.count;
// Dynamically collect all ground entities
const itemsToPick = [...engine.groundItems.all];
for (const item of itemsToPick) {
player.x = item.x;
player.y = item.y;
mouse.pickupGroundItem(item);
}
// Check for invbox fallback in inventory items
let invboxFallbackCount = 0;
const inventoryItems = hud.inventory.gridItems.map(gi => {
const item = gi.item;
if (item.invFile === 'invbox' && item.code !== 'box') {
invboxFallbackCount++;
}
return {
id: item.id,
code: item.code,
name: item.name,
invFile: item.invFile,
};
});
return {
initialGroundCount,
pickedCount: itemsToPick.length,
postGold: hud.inventory.gold,
goldDelta: hud.inventory.gold - preGold,
postBagCount: hud.inventory.gridItems.length,
bagDelta: hud.inventory.gridItems.length - preBagCount,
pickupsTotal: engine.metrics.pickups,
pickupsDelta: engine.metrics.pickups - prePickups,
remainingGroundCount: engine.groundItems.count,
invboxFallbackCount,
inventoryItems,
statusText: document.querySelector('#status')?.textContent ?? '',
};
})()`)
console.log(` Dynamic collection: ${pickupMetrics.pickedCount} ground entities processed`)
console.log(` Gold pickup delta: +${pickupMetrics.goldDelta} (Total: ${pickupMetrics.postGold})`)
console.log(` Equipment & potion pickup: +${pickupMetrics.bagDelta} items in bag`)
console.log(` Ground items remaining: ${pickupMetrics.remainingGroundCount} (expected 0)`)
console.log(` Invbox fallback count: ${pickupMetrics.invboxFallbackCount} (MUST BE 0)`)
console.log(` Status text feedback: "${pickupMetrics.statusText}"`)
if (pickupMetrics.remainingGroundCount !== 0) throw new Error('Ground items were not cleared after pickup')
if (pickupMetrics.bagDelta <= 0) throw new Error('No items were placed in bag after picking up drops')
if (pickupMetrics.invboxFallbackCount !== 0) throw new Error(`UI items hit invbox fallback: ${pickupMetrics.invboxFallbackCount}`)
await takeScreenshot('04-items-picked-up-inventory')
// =========================================================================
// STAGE 7: Negative Control (Inventory Full Refusal & Secondary Bounce)
// =========================================================================
console.log('\n[STAGE 7] Negative Control: Inventory full refusal & secondary bounce...')
const refusalMetrics = await evalJs<any>(`(() => {
const engine = window.__d2webEngine;
const mouse = window.__d2webMouseController;
const hud = window.__d2webHudInstance;
// Fill player inventory completely (40 cells) with 10 2x2 items
hud.inventory.gridItems = [];
for (let c = 0; c < 10; c += 2) {
for (let r = 0; r < 4; r += 2) {
hud.inventory.gridItems.push({
item: {
id: 'filler-' + c + '-' + r,
code: 'cap',
invFile: 'invcap',
name: 'Cap',
nameZh: '帽子 (Cap)',
baseNameZh: '帽子 (Cap)',
quality: 'normal',
invWidth: 2,
invHeight: 2,
allowedSlots: ['helm'],
stats: [],
},
col: c,
row: r,
});
}
}
// Spawn test item on ground near player
const player = engine.world.player;
const testItem = {
id: 'test_drop_full',
code: 'hax',
name: 'Hand Axe',
nameZh: '手斧 (Hand Axe)',
quality: 'normal',
invWidth: 1,
invHeight: 2,
base: { id: 'hax', name: 'Hand Axe', kind: 'weapon', invWidth: 1, invHeight: 2 },
};
const groundItem = engine.dropItem(testItem, player.x + 10, player.y + 10);
const preRefusals = engine.metrics.inventoryRefusals || 0;
// Attempt pickup with full bag
mouse.pickupGroundItem(groundItem);
return {
preRefusals,
postRefusals: engine.metrics.inventoryRefusals,
itemStillOnGround: Boolean(engine.groundItems.get(groundItem.id)),
hasSecondaryBounce: Boolean(groundItem.bounceState),
bounceDuration: groundItem.bounceState?.durationMs,
bouncePeak: groundItem.bounceState?.peakHeightPx,
statusText: document.querySelector('#status')?.textContent ?? '',
};
})()`)
console.log(` Inventory refusals: ${refusalMetrics.preRefusals} -> ${refusalMetrics.postRefusals}`)
console.log(` Item remains on ground: ${refusalMetrics.itemStillOnGround}`)
console.log(` Secondary bounce triggered: ${refusalMetrics.hasSecondaryBounce} (Peak: ${refusalMetrics.bouncePeak}px)`)
console.log(` Feedback notification: "${refusalMetrics.statusText}"`)
if (refusalMetrics.postRefusals <= refusalMetrics.preRefusals) throw new Error('Inventory refusal counter did not increment')
if (!refusalMetrics.itemStillOnGround) throw new Error('Item disappeared despite bag full refusal')
if (!refusalMetrics.hasSecondaryBounce) throw new Error('Refusal flippy bounce was not triggered')
await takeScreenshot('05-inventory-full-refusal')
// =========================================================================
// STAGE 8: Zero Error Invariant Check & 500-Kill Statistical Drop Assertions
// =========================================================================
console.log('\n[STAGE 8] Asserting zero runtime errors and 500-kill statistical drop invariants...')
const finalGlErr = await evalJs<number>(`document.querySelector('#view')?.getContext('webgl2')?.getError() ?? -1`)
if (finalGlErr !== 0) webGlErrors.push(finalGlErr)
console.log(` Console errors: ${consoleErrors.length}`)
console.log(` WebGL errors: ${webGlErrors.length}`)
console.log(` MPQ / DLL network requests: ${mpqRequests.length}`)
// 500-kill drop rate statistical assertions
console.log(' Executing 500-kill drop statistical audit (Act 1 H2H A)...')
const dropTables = getEmbeddedDropTables()
let statTotalDrops = 0
let statGoldCount = 0
let statPotionCount = 0
let statEquipCount = 0
let statInvboxHits = 0
let statFlpboxHits = 0
let statMissingFlippy = 0
const N = 500
for (let i = 0; i < N; i++) {
const drops = executeDropPipeline(dropTables, {
tcName: 'Act 1 H2H A',
nLevel: 2,
monsterType: 1,
difficulty: 'normal',
monsterRng: new D2Rng(i * 997 + 13),
})
statTotalDrops += drops.length
for (const drop of drops) {
const isGold = drop.code?.trim() === 'gld'
if (isGold) {
statGoldCount++
} else if (['hp1', 'hp2', 'mp1', 'mp2', 'rvs'].includes(drop.code?.trim() ?? '')) {
statPotionCount++
} else {
statEquipCount++
}
const sr = resolveGroundItemSpriteRect(drop, BAKED_UI_MANIFEST.flippyRects, BAKED_UI_MANIFEST.codeToFlippyFile)
if (!sr) statMissingFlippy++
if (sr === BAKED_UI_MANIFEST.flippyRects?.['flpbox'] && drop.code?.trim() !== 'box') statFlpboxHits++
if (!isGold) {
const uiItem = itemToUiInventoryItem(drop, dropTables)
if (uiItem.invFile === 'invbox' && drop.code?.trim() !== 'box') statInvboxHits++
const ir = resolveItemSpriteRect(uiItem, BAKED_UI_MANIFEST.itemRects)
if (ir === BAKED_UI_MANIFEST.itemRects?.['invbox'] && drop.code?.trim() !== 'box') statInvboxHits++
}
}
}
console.log(` 500-kill sample: Total=${statTotalDrops} (3σ: [155, 220]), Gold=${statGoldCount} (3σ: [43, 88]), Potions=${statPotionCount} (3σ: [16, 49]), Equip=${statEquipCount}`)
console.log(` Asset parity: missingFlippy=${statMissingFlippy}, flpboxHits=${statFlpboxHits}, invboxHits=${statInvboxHits}`)
const statsPass =
statTotalDrops >= 155 && statTotalDrops <= 220 &&
statGoldCount >= 43 && statGoldCount <= 88 &&
statPotionCount >= 16 && statPotionCount <= 49 &&
statMissingFlippy === 0 &&
statFlpboxHits === 0 &&
statInvboxHits === 0
if (!statsPass) {
throw new Error(`500-kill statistical drop audit failed 3-sigma confidence bounds or had sprite fallbacks!`)
}
const auditPass =
consoleErrors.length === 0 &&
webGlErrors.length === 0 &&
mpqRequests.length === 0 &&
combatResult.killsDelta >= 1 &&
combatResult.dropsDelta >= 1 &&
combatResult.newGroundCount >= 5 &&
bounceMetrics.hMid > 20 &&
altMetrics.overlapCount === 0 &&
altMetrics.labelCount >= 5 &&
pickupMetrics.remainingGroundCount === 0 &&
pickupMetrics.bagDelta > 0 &&
pickupMetrics.invboxFallbackCount === 0 &&
refusalMetrics.postRefusals > refusalMetrics.preRefusals &&
statsPass
return {
success: auditPass,
durationMs: Date.now() - startTime,
stages: {
stage1_sceneReady: ready,
stage2_monsterKill: combatResult.killsDelta >= 1 && combatResult.dropsDelta >= 1 && combatResult.newGroundCount >= 5,
stage3_groundScatter: bounceMetrics.distBetweenItems >= 10,
stage4_flippyBounce: Math.abs(bounceMetrics.hMid - bounceMetrics.peakHeightPx) < 1 && bounceMetrics.peakHeightPx >= 20,
stage5_altLabelsAndLadder: altMetrics.overlapCount === 0 && altMetrics.labelCount >= 5,
stage6_itemAndGoldPickup: pickupMetrics.remainingGroundCount === 0 && pickupMetrics.bagDelta > 0 && pickupMetrics.invboxFallbackCount === 0,
stage7_negativeControlFullBag: refusalMetrics.postRefusals > refusalMetrics.preRefusals && refusalMetrics.itemStillOnGround && refusalMetrics.hasSecondaryBounce,
stage8_zeroErrors: consoleErrors.length === 0 && webGlErrors.length === 0 && mpqRequests.length === 0 && statsPass,
},
metrics: {
kills: combatResult.kills,
drops: combatResult.dropsRolled,
scatterDistance: bounceMetrics.distBetweenItems,
ladderLabelsCount: altMetrics.labelCount,
ladderOverlaps: altMetrics.overlapCount,
goldPickedUp: pickupMetrics.goldDelta,
equipmentPickedUp: pickupMetrics.bagDelta,
refusalsHandled: refusalMetrics.postRefusals,
sampleDrops500: statTotalDrops,
sampleGold500: statGoldCount,
samplePotions500: statPotionCount,
},
consoleErrors,
webGlErrors,
mpqRequests,
screenshots,
}
} finally {
if (ws) ws.close()
if (chromeProc) chromeProc.kill('SIGTERM')
if (viteServer) await viteServer.close()
}
}
// Standalone execution entrypoint
if (import.meta.url === `file://${process.argv[1]}`) {
const outputArg = process.argv.find(arg => arg.startsWith('--out='))?.split('=')[1]
const packsArg = process.argv.find(arg => arg.startsWith('--packs-dir='))?.split('=')[1]
runGroundCombatBrowserAudit({ outputDir: outputArg, packsDir: packsArg })
.then(result => {
console.log('\n======================================================================')
console.log(` AUDIT RESULT: ${result.success ? 'PASSED (ALL STAGES GREEN)' : 'FAILED'}`)
console.log(` Duration: ${(result.durationMs / 1000).toFixed(2)}s`)
console.log(' Stages:', JSON.stringify(result.stages, null, 2))
console.log('======================================================================\n')
process.exit(result.success ? 0 : 1)
})
.catch(err => {
console.error('\nHARNESS FATAL ERROR:', err)
process.exit(1)
})
}

View File

@ -1,381 +0,0 @@
/**
* Automated Headless Browser Verification & Visual Audit for Issue #503:
* Full Chinese (`CHI`) and English (`ENG`) Bilingual Versions (`index.html`, `acts-zh.html`, `acts-en.html`, `acts.html`).
*
* Uses Vite dev server + `/usr/bin/google-chrome` over native WebSocket CDP.
*/
import { spawn } from 'node:child_process'
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { createServer } from 'vite'
const ARTIFACT_DIR =
process.argv[2] ||
'/usr/local/google/home/taodao/diablo2-web/.agents/teamwork/worker_503_1'
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
interface CdpSession {
evalJs: <T = any>(expression: string) => Promise<T>
saveScreenshot: (filename: string) => Promise<void>
waitForGameReady: () => Promise<void>
}
async function withChromePage<T>(
url: string,
matchSubstr: string,
debugPort: number,
fn: (session: CdpSession) => Promise<T>,
): Promise<T> {
const chromeProc = spawn('/usr/bin/google-chrome', [
'--headless=new',
`--remote-debugging-port=${debugPort}`,
'--no-sandbox',
'--disable-dev-shm-usage',
'--enable-webgl',
'--ignore-gpu-blocklist',
'--use-gl=angle',
'--use-angle=swiftshader',
'--window-size=1280,840',
url,
])
try {
let wsUrl: string | null = null
for (let i = 0; i < 60; i++) {
await sleep(300)
try {
const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`)
const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[]
const page = pages.find(p => p.type === 'page' && p.url.includes(matchSubstr))
if (page?.webSocketDebuggerUrl) {
wsUrl = page.webSocketDebuggerUrl
break
}
} catch {
// Wait for Chrome CDP
}
}
if (!wsUrl) {
throw new Error(`Failed to connect to Chrome CDP for ${url}`)
}
const ws = new WebSocket(wsUrl)
await new Promise<void>(resolve => {
ws.onopen = () => resolve()
})
let idCounter = 1
const sendCommand = <R = any>(method: string, params: Record<string, unknown> = {}): Promise<R> =>
new Promise((resolve, reject) => {
const id = idCounter++
const handler = (event: MessageEvent) => {
const msg = JSON.parse(String(event.data))
if (msg.id === id) {
ws.removeEventListener('message', handler)
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
else resolve(msg.result as R)
}
}
ws.addEventListener('message', handler)
ws.send(JSON.stringify({ id, method, params }))
})
const evalJs = async <R = any>(expression: string): Promise<R> => {
const res = await sendCommand<{ result: { value: R }; exceptionDetails?: any }>('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
})
if (res.exceptionDetails) {
throw new Error(`JS exception: ${JSON.stringify(res.exceptionDetails)}`)
}
return res.result.value
}
const saveScreenshot = async (filename: string): Promise<void> => {
const shot = await sendCommand<{ data: string }>('Page.captureScreenshot', { format: 'png' })
writeFileSync(join(ARTIFACT_DIR, filename), Buffer.from(shot.data, 'base64'))
console.log(` Saved screenshot: ${filename}`)
}
const waitForGameReady = async (): Promise<void> => {
for (let i = 0; i < 120; i++) {
const ready = await evalJs<boolean>(
`Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready && window.__d2webHudInstance && window.__d2webHud?.assetsLoaded?.ctrlPnl)`,
)
if (ready) {
await sleep(500)
return
}
await sleep(300)
}
throw new Error(`Timed out waiting for game ready at ${url}`)
}
await sendCommand('Runtime.enable')
await sendCommand('Page.enable')
const result = await fn({ evalJs, saveScreenshot, waitForGameReady })
ws.close()
return result
} finally {
chromeProc.kill('SIGKILL')
await sleep(200)
}
}
async function main(): Promise<void> {
mkdirSync(ARTIFACT_DIR, { recursive: true })
const server = await createServer({
root: process.cwd(),
server: { port: 0, host: '127.0.0.1' },
})
await server.listen()
const address = server.httpServer?.address()
const port = typeof address === 'object' && address ? address.port : 5173
const baseUrl = `http://127.0.0.1:${port}`
console.log(`1. Vite dev server listening at ${baseUrl}`)
try {
// ── Phase A: Verify index.html bilingual entry portal ──
await withChromePage(`${baseUrl}/index.html`, 'index.html', 9241, async ({ evalJs, saveScreenshot }) => {
await sleep(400)
const indexCheck = await evalJs<{
zhHref: string | null
enHref: string | null
title: string
}>(`({
zhHref: document.getElementById('enter-zh')?.getAttribute('href') ?? null,
enHref: document.getElementById('enter-en')?.getAttribute('href') ?? null,
title: document.title,
})`)
console.log('2. Verified index.html entry portal:', JSON.stringify(indexCheck))
if (indexCheck.zhHref !== './acts-zh.html' || indexCheck.enHref !== './acts-en.html') {
throw new Error(`Unexpected index.html links: ${JSON.stringify(indexCheck)}`)
}
await saveScreenshot('index-bilingual.png')
})
// ── Phase B: Verify acts-zh.html (Chinese CHI edition) ──
await withChromePage(`${baseUrl}/acts-zh.html?act=1`, 'acts-zh.html', 9242, async ({ evalJs, saveScreenshot, waitForGameReady }) => {
await waitForGameReady()
const zhCheck = await evalJs<{
lang: string
title: string
langSwitchHref: string | null
langSwitchText: string
hudText: string
actOpt1: string
heroSor: string
sceneSelectedText: string
charSheetDraws: string[]
}>(`(() => {
const inst = window.__d2webHudInstance;
const draws = [];
const origFill = CanvasRenderingContext2D.prototype.fillText;
CanvasRenderingContext2D.prototype.fillText = function(text, ...rest) {
draws.push(String(text));
return origFill.call(this, text, ...rest);
};
try {
if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char');
if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv');
inst.inventory.handleMouseMove(561, 93);
inst.render(performance.now(), false);
} finally {
CanvasRenderingContext2D.prototype.fillText = origFill;
}
const sceneSel = document.getElementById('scene');
return {
lang: inst.lang,
title: document.title,
langSwitchHref: document.getElementById('lang-switch')?.getAttribute('href') ?? null,
langSwitchText: document.getElementById('lang-switch')?.textContent?.trim() ?? '',
hudText: document.getElementById('hud')?.textContent ?? '',
actOpt1: document.querySelector('#act option[value="1"]')?.textContent ?? '',
heroSor: document.querySelector('#hero option[value="so"]')?.textContent ?? '',
sceneSelectedText: sceneSel?.selectedOptions?.[0]?.textContent ?? '',
charSheetDraws: draws,
};
})()`)
console.log('3. Verified acts-zh.html state:', JSON.stringify({
lang: zhCheck.lang,
title: zhCheck.title,
langSwitchHref: zhCheck.langSwitchHref,
hudText: zhCheck.hudText,
actOpt1: zhCheck.actOpt1,
heroSor: zhCheck.heroSor,
sceneSelectedText: zhCheck.sceneSelectedText,
sampleDraws: zhCheck.charSheetDraws.slice(0, 14),
}))
if (zhCheck.lang !== 'CHI') {
throw new Error(`Expected acts-zh.html lang === 'CHI', got ${zhCheck.lang}`)
}
if (!zhCheck.langSwitchHref?.endsWith('acts-en.html?act=1')) {
throw new Error(`Expected #lang-switch href ending with 'acts-en.html?act=1', got ${zhCheck.langSwitchHref}`)
}
if (!zhCheck.actOpt1.includes('第一章') || !zhCheck.heroSor.includes('法师')) {
throw new Error(`Expected Chinese act/hero options in acts-zh.html, got ${JSON.stringify(zhCheck)}`)
}
await saveScreenshot('acts-zh-panels.png')
})
// ── Phase C: Verify acts-en.html (English ENG edition — strict 0 CJK audit) ──
await withChromePage(`${baseUrl}/acts-en.html?act=1`, 'acts-en.html', 9243, async ({ evalJs, saveScreenshot, waitForGameReady }) => {
await waitForGameReady()
const enCheck = await evalJs<{
lang: string
title: string
langSwitchHref: string | null
domTexts: Record<string, string>
cjkDomViolations: string[]
cjkCanvasViolations: string[]
sampleCanvasDraws: string[]
}>(`(() => {
const inst = window.__d2webHudInstance;
const cjkRe = /[\\u4e00-\\u9fff]/;
// 1. Check all DOM texts except #lang-switch (which shows "Lang: ZH")
const domTexts = {
title: document.title,
headerH1: document.querySelector('header h1')?.textContent || '',
hud: document.getElementById('hud')?.textContent || '',
heroOptions: Array.from(document.querySelectorAll('#hero option')).map(el => el.textContent || '').join(' | '),
actOptions: Array.from(document.querySelectorAll('#act option')).map(el => el.textContent || '').join(' | '),
sceneGroups: Array.from(document.querySelectorAll('#scene optgroup')).map(el => el.getAttribute('label') || '').join(' | '),
sceneOptions: Array.from(document.querySelectorAll('#scene option')).map(el => el.textContent || '').join(' | '),
variantOptions: Array.from(document.querySelectorAll('#variant option')).map(el => el.textContent || '').join(' | '),
lightingOptions: Array.from(document.querySelectorAll('#lighting option')).map(el => el.textContent || '').join(' | '),
densityOptions: Array.from(document.querySelectorAll('#density option')).map(el => el.textContent || '').join(' | '),
eliteOptions: Array.from(document.querySelectorAll('#elite option')).map(el => el.textContent || '').join(' | '),
controlLabels: Array.from(document.querySelectorAll('#controls label')).map(el => el.childNodes[0]?.textContent?.trim() || '').join(' | '),
hint: document.querySelector('#controls .hint')?.textContent || '',
status: document.getElementById('status')?.textContent || '',
dialog: document.getElementById('dialog')?.textContent || '',
labels: document.getElementById('labels')?.textContent || '',
};
const cjkDomViolations = [];
for (const [k, v] of Object.entries(domTexts)) {
if (cjkRe.test(v)) {
cjkDomViolations.push(k + ': ' + v);
}
}
// 2. Intercept all Canvas fillText/strokeText calls across all HUD panels
const canvasDraws = [];
const origFill = CanvasRenderingContext2D.prototype.fillText;
const origStroke = CanvasRenderingContext2D.prototype.strokeText;
CanvasRenderingContext2D.prototype.fillText = function(text, ...rest) {
canvasDraws.push(String(text));
return origFill.call(this, text, ...rest);
};
CanvasRenderingContext2D.prototype.strokeText = function(text, ...rest) {
canvasDraws.push(String(text));
return origStroke.call(this, text, ...rest);
};
try {
// Exercise Quest Log, Waypoint, Stash, Cube, Character, Skill Tree, Inventory, NPC Menu, and Vendor Tabs
for (const left of ['quest', 'waypoint', 'stash', 'cube', 'char']) {
if (inst.leftPanel !== left) inst.toggleLeftPanel(left);
inst.render(performance.now(), false);
}
for (const right of ['skill', 'inv']) {
if (inst.rightPanel !== right) inst.toggleRightPanel(right);
inst.render(performance.now(), false);
}
inst.openNpcMenuByName('Akara', 400, 220);
inst.render(performance.now(), false);
inst.worldPanels.npcMenu = null;
inst.openVendorSession('Charsi', 'trade', 0);
for (const tabId of ['armor', 'weapons1', 'weapons2', 'misc']) {
inst.worldPanels.selectVendorTab(tabId);
const placements = inst.worldPanels.vendorTabPlacements[tabId] || [];
for (const p of placements) {
inst.worldPanels.hoveredVendorItem = { item: p.item, x: 200, y: 200, col: p.col, row: p.row };
inst.render(performance.now(), false);
}
}
inst.worldPanels.hoveredVendorItem = null;
// Leave char + inv open with helm tooltip hovered for screenshot
if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char');
if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv');
inst.inventory.handleMouseMove(561, 93);
inst.render(performance.now(), false);
} finally {
CanvasRenderingContext2D.prototype.fillText = origFill;
CanvasRenderingContext2D.prototype.strokeText = origStroke;
}
const cjkCanvasViolations = canvasDraws.filter(t => cjkRe.test(t));
return {
lang: inst.lang,
title: document.title,
langSwitchHref: document.getElementById('lang-switch')?.getAttribute('href') ?? null,
domTexts,
cjkDomViolations,
cjkCanvasViolations,
sampleCanvasDraws: Array.from(new Set(canvasDraws)).slice(0, 25),
};
})()`)
console.log('4. Verified acts-en.html state:', JSON.stringify({
lang: enCheck.lang,
title: enCheck.title,
langSwitchHref: enCheck.langSwitchHref,
hud: enCheck.domTexts.hud,
actOptions: enCheck.domTexts.actOptions,
heroOptions: enCheck.domTexts.heroOptions,
cjkDomViolations: enCheck.cjkDomViolations,
cjkCanvasViolations: enCheck.cjkCanvasViolations,
sampleCanvasDraws: enCheck.sampleCanvasDraws,
}))
if (enCheck.lang !== 'ENG') {
throw new Error(`Expected acts-en.html lang === 'ENG', got ${enCheck.lang}`)
}
if (!enCheck.langSwitchHref?.endsWith('acts-zh.html?act=1')) {
throw new Error(`Expected #lang-switch href ending with 'acts-zh.html?act=1', got ${enCheck.langSwitchHref}`)
}
if (enCheck.cjkDomViolations.length > 0) {
throw new Error(`Found CJK in acts-en.html DOM: ${JSON.stringify(enCheck.cjkDomViolations)}`)
}
if (enCheck.cjkCanvasViolations.length > 0) {
throw new Error(`Found CJK in acts-en.html Canvas HUD: ${JSON.stringify(enCheck.cjkCanvasViolations)}`)
}
await saveScreenshot('acts-en-panels.png')
})
// ── Phase D: Verify acts.html?act=2&lang=en query override ──
await withChromePage(`${baseUrl}/acts.html?act=2&lang=en`, 'acts.html', 9244, async ({ evalJs, waitForGameReady }) => {
await waitForGameReady()
const overrideCheck = await evalJs<{
lang: string
hud: string
langSwitchHref: string | null
}>(`({
lang: window.__d2webHudInstance.lang,
hud: document.getElementById('hud')?.textContent || '',
langSwitchHref: document.getElementById('lang-switch')?.getAttribute('href') ?? null,
})`)
console.log('5. Verified /acts.html?act=2&lang=en override:', JSON.stringify(overrideCheck))
if (overrideCheck.lang !== 'ENG' || !overrideCheck.hud.includes('Act 2')) {
throw new Error(`Unexpected /acts.html?act=2&lang=en state: ${JSON.stringify(overrideCheck)}`)
}
})
console.log('✅ All Issue #503 Headless Browser E2E Checks PASSED!')
} finally {
await server.close()
}
}
main().catch(err => {
console.error('❌ Issue #503 Browser Verification Failed:', err)
process.exit(1)
})

View File

@ -1,594 +0,0 @@
/**
* Automated Headless Browser Verification & Visual Audit for Issue #504:
* Authentic Diablo II v1.13c Exocet (`D2Exocet`) & Formal 436 (`D2Formal436`)
* Typography across Canvas HUD (`D2FontRenderer` DC6 bitmap blitting) and DOM/CSS
* (`index.html`, `acts-en.html`, `acts.html`, `acts-zh.html`).
*/
import { spawn } from 'node:child_process'
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { createServer } from 'vite'
const ARTIFACT_DIR =
process.argv[2] ||
'/usr/local/google/home/taodao/diablo2-web/.agents/teamwork/reviewer_504_r4'
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
interface CdpSession {
sendCommand: <R = any>(method: string, params?: Record<string, unknown>) => Promise<R>
evalJs: <T = any>(expression: string) => Promise<T>
saveScreenshot: (filename: string) => Promise<void>
waitForGameReady: () => Promise<void>
externalUrls: string[]
}
async function withChromePage<T>(
url: string,
matchSubstr: string,
debugPort: number,
fn: (session: CdpSession) => Promise<T>,
): Promise<T> {
const chromeProc = spawn('/usr/bin/google-chrome', [
'--headless=new',
`--remote-debugging-port=${debugPort}`,
'--no-sandbox',
'--disable-dev-shm-usage',
'--enable-webgl',
'--ignore-gpu-blocklist',
'--use-gl=angle',
'--use-angle=swiftshader',
'--window-size=1280,840',
url,
])
try {
let wsUrl: string | null = null
for (let i = 0; i < 60; i++) {
await sleep(300)
try {
const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`)
const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[]
const page = pages.find(p => p.type === 'page' && p.url.includes(matchSubstr))
if (page?.webSocketDebuggerUrl) {
wsUrl = page.webSocketDebuggerUrl
break
}
} catch {
// Wait for Chrome CDP
}
}
if (!wsUrl) {
throw new Error(`Failed to connect to Chrome CDP for ${url}`)
}
const ws = new WebSocket(wsUrl)
await new Promise<void>(resolve => {
ws.onopen = () => resolve()
})
const externalUrls: string[] = []
ws.addEventListener('message', (event: MessageEvent) => {
try {
const msg = JSON.parse(String(event.data))
if (msg.method === 'Network.requestWillBeSent') {
const reqUrl = String(msg.params?.request?.url ?? '')
if (
reqUrl &&
!reqUrl.startsWith('http://127.0.0.1:') &&
!reqUrl.startsWith('data:') &&
!reqUrl.startsWith('blob:')
) {
externalUrls.push(reqUrl)
}
}
} catch {
// Ignore non-JSON CDP events
}
})
let idCounter = 1
const sendCommand = <R = any>(method: string, params: Record<string, unknown> = {}): Promise<R> =>
new Promise((resolve, reject) => {
const id = idCounter++
const handler = (event: MessageEvent) => {
const msg = JSON.parse(String(event.data))
if (msg.id === id) {
ws.removeEventListener('message', handler)
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
else resolve(msg.result as R)
}
}
ws.addEventListener('message', handler)
ws.send(JSON.stringify({ id, method, params }))
})
const evalJs = async <R = any>(expression: string): Promise<R> => {
const res = await sendCommand<{ result: { value: R }; exceptionDetails?: any }>('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
})
if (res.exceptionDetails) {
throw new Error(`JS exception: ${JSON.stringify(res.exceptionDetails)}`)
}
return res.result.value
}
const saveScreenshot = async (filename: string): Promise<void> => {
const shot = await sendCommand<{ data: string }>('Page.captureScreenshot', { format: 'png' })
writeFileSync(join(ARTIFACT_DIR, filename), Buffer.from(shot.data, 'base64'))
console.log(` Saved screenshot: ${filename}`)
}
const waitForGameReady = async (): Promise<void> => {
for (let i = 0; i < 120; i++) {
const ready = await evalJs<boolean>(
`Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready && window.__d2webHudInstance && window.__d2webHud?.assetsLoaded?.ctrlPnl)`,
)
if (ready) {
await sleep(500)
return
}
await sleep(300)
}
throw new Error(`Timed out waiting for game ready at ${url}`)
}
await sendCommand('Network.enable')
await sendCommand('Runtime.enable')
await sendCommand('Page.enable')
const result = await fn({ sendCommand, evalJs, saveScreenshot, waitForGameReady, externalUrls })
ws.close()
return result
} finally {
chromeProc.kill('SIGKILL')
await sleep(200)
}
}
async function main(): Promise<void> {
mkdirSync(ARTIFACT_DIR, { recursive: true })
const server = await createServer({
root: process.cwd(),
server: { port: 0, host: '127.0.0.1' },
})
await server.listen()
const address = server.httpServer?.address()
const port = typeof address === 'object' && address ? address.port : 5173
const baseUrl = `http://127.0.0.1:${port}`
console.log(`1. Vite dev server listening at ${baseUrl}`)
try {
// ── Phase A: Verify index.html WebFont loading, pixel-level Exocet O cross vs Formal 436 hollow O, & zero external requests ──
await withChromePage(`${baseUrl}/index.html`, 'index.html', 9251, async ({ evalJs, saveScreenshot, externalUrls }) => {
await sleep(500)
const indexFonts = await evalJs<{
exocetLoaded: boolean
formalLoaded: boolean
h1FontFamily: string
descFontFamily: string
exocetOCenterAlpha: number
formalOCenterAlpha: number
}>(`(async () => {
await document.fonts.ready;
await Promise.all([
document.fonts.load('48px D2Exocet'),
document.fonts.load('48px D2Formal436'),
]);
const measureGlyphCenterAlpha = (fontSpec) => {
const c = document.createElement('canvas');
c.width = 96;
c.height = 96;
const ctx = c.getContext('2d');
ctx.font = fontSpec;
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = '#ffffff';
ctx.fillText('O', 16, 68);
const d = ctx.getImageData(0, 0, 96, 96).data;
let minX = 96, maxX = 0, minY = 96, maxY = 0;
for (let y = 0; y < 96; y++) {
for (let x = 0; x < 96; x++) {
if (d[(y * 96 + x) * 4 + 3] > 64) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
const cx = Math.round((minX + maxX) / 2);
const cy = Math.round((minY + maxY) / 2);
let maxCenterAlpha = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const a = d[((cy + dy) * 96 + (cx + dx)) * 4 + 3];
if (a > maxCenterAlpha) maxCenterAlpha = a;
}
}
return maxCenterAlpha;
};
return {
exocetLoaded: document.fonts.check('16px D2Exocet'),
formalLoaded: document.fonts.check('14px D2Formal436'),
h1FontFamily: getComputedStyle(document.querySelector('h1')).fontFamily,
descFontFamily: getComputedStyle(document.querySelector('.lang-desc')).fontFamily,
exocetOCenterAlpha: measureGlyphCenterAlpha('48px "D2Exocet"'),
formalOCenterAlpha: measureGlyphCenterAlpha('48px "D2Formal436"'),
};
})()`)
console.log('2. Verified index.html WebFonts & O-glyph center pixels:', JSON.stringify(indexFonts))
if (!indexFonts.exocetLoaded || !indexFonts.formalLoaded) {
throw new Error(`index.html failed to load D2Exocet or D2Formal436: ${JSON.stringify(indexFonts)}`)
}
if (!indexFonts.h1FontFamily.includes('D2Exocet') || !indexFonts.descFontFamily.includes('D2Formal436')) {
throw new Error(`index.html unexpected font-family: ${JSON.stringify(indexFonts)}`)
}
if (indexFonts.exocetOCenterAlpha < 128 || indexFonts.formalOCenterAlpha !== 0) {
throw new Error(`Expected D2Exocet 'O' center to have cross pixels (>128) and D2Formal436 'O' center to be hollow (0), got: ${JSON.stringify(indexFonts)}`)
}
if (externalUrls.length > 0) {
throw new Error(`External network requests detected on index.html: ${JSON.stringify(externalUrls)}`)
}
await saveScreenshot('issue-504-index.png')
})
// ── Phase B: Verify acts-en.html Canvas DC6 bitmap font blitting, 12 color codes, Area Banner, Ground Labels, Panels & Formal 436 Prose ──
await withChromePage(`${baseUrl}/acts-en.html?act=1`, 'acts-en.html', 9252, async ({ sendCommand, evalJs, saveScreenshot, waitForGameReady, externalUrls }) => {
await waitForGameReady()
// 1. Capture Area Entry Banner (`Font30` Exocet) + Top-left `#hud` (`D2Exocet`) + Ground `Alt` Item Labels (`D2Exocet`)
const groundLabelCheck = await evalJs<{
groundLabelsCount: number
groundLabelFontFamily: string
groundLabelTexts: string[]
groundLabelPositions1x: Array<{ left: string; top: string }>
}>(`(async () => {
const inst = window.__d2webHudInstance;
inst.leftPanel = 'none';
inst.rightPanel = 'none';
inst.worldPanels.npcMenu = null;
inst.worldPanels.areaBanner = {
titleEn: 'Rogue Encampment',
titleZh: '罗格营地',
untilMs: performance.now() + 60000,
};
if (inst.inventory.equipped?.helm) {
inst.callbacks.onDropItemToGround?.({ ...inst.inventory.equipped.helm, id: 'verify-ground-helm' });
}
if (inst.inventory.equipped?.weapon1) {
inst.callbacks.onDropItemToGround?.({ ...inst.inventory.equipped.weapon1, id: 'verify-ground-weapon' });
}
inst.showGroundLabels = true;
await new Promise(r => setTimeout(r, 300));
const els = Array.from(document.querySelectorAll('.d2-ground-label'));
return {
groundLabelsCount: els.length,
groundLabelFontFamily: els[0] ? getComputedStyle(els[0]).fontFamily : '',
groundLabelTexts: els.map(el => el.textContent || ''),
groundLabelPositions1x: els.map(el => ({ left: el.style.left, top: el.style.top })),
};
})()`)
console.log(' Verified Alt ground labels (1x DPR):', JSON.stringify(groundLabelCheck))
if (groundLabelCheck.groundLabelsCount < 2 || !groundLabelCheck.groundLabelFontFamily.includes('D2Exocet')) {
throw new Error(`Expected >=2 .d2-ground-label DOM elements using D2Exocet, got: ${JSON.stringify(groundLabelCheck)}`)
}
await saveScreenshot('issue-504-acts-en-area-banner.png')
// 1b. Verify High-DPI (devicePixelRatio = 2) rendering on GroundLabelOverlay and #d2-hud-canvas
await sendCommand('Emulation.setDeviceMetricsOverride', {
width: 1280,
height: 840,
deviceScaleFactor: 2,
mobile: false,
})
await sleep(300)
const hiDpiCheck = await evalJs<{
dpr: number
hudCanvasWidth: number
hudCanvasHeight: number
imageSmoothingEnabled: boolean
groundLabelsCount: number
groundLabelFontFamily: string
groundLabelPositions2x: Array<{ left: number; top: number }>
viewCenterCssY: number
}>(`(async () => {
const inst = window.__d2webHudInstance;
inst.render(performance.now(), false);
await new Promise(r => setTimeout(r, 200));
const hudCanvas = document.getElementById('d2-hud-canvas');
const viewCanvas = document.getElementById('view');
const viewRect = viewCanvas.getBoundingClientRect();
const ctx = hudCanvas.getContext('2d');
const els = Array.from(document.querySelectorAll('.d2-ground-label'));
return {
dpr: window.devicePixelRatio,
hudCanvasWidth: hudCanvas.width,
hudCanvasHeight: hudCanvas.height,
imageSmoothingEnabled: ctx.imageSmoothingEnabled,
groundLabelsCount: els.length,
groundLabelFontFamily: els[0] ? getComputedStyle(els[0]).fontFamily : '',
groundLabelPositions2x: els.map(el => ({
left: parseFloat(el.style.left),
top: parseFloat(el.style.top),
})),
viewCenterCssY: Math.round(viewRect.top + viewRect.height * 0.5),
};
})()`)
console.log(' Verified High-DPI (2x DPR) GroundLabelOverlay & #d2-hud-canvas:', JSON.stringify(hiDpiCheck))
if (
hiDpiCheck.dpr !== 2 ||
hiDpiCheck.hudCanvasWidth !== 2560 ||
hiDpiCheck.hudCanvasHeight !== 1680 ||
hiDpiCheck.imageSmoothingEnabled !== false ||
hiDpiCheck.groundLabelsCount !== groundLabelCheck.groundLabelsCount ||
!hiDpiCheck.groundLabelFontFamily.includes('D2Exocet') ||
hiDpiCheck.groundLabelPositions2x.some(
p => Math.abs(p.left - 640) > 30 || Math.abs(p.top - hiDpiCheck.viewCenterCssY) > 60,
)
) {
throw new Error(`High-DPI (2x DPR) verification failed: ${JSON.stringify(hiDpiCheck)}`)
}
await sendCommand('Emulation.clearDeviceMetricsOverride')
await sleep(200)
// 2. Verify DC6 bitmap blitting, DC6 'O' cross vs Formal 436 hollow 'O', 12 distinct color codes, and multi-color ÿc rare item affix tooltip alignment
const enCheck = await evalJs<{
exocetLoaded: boolean
formalLoaded: boolean
hudFontFamily: string
controlsFontFamily: string
dialogFontFamily: string
dialogTitleFontFamily: string
dc6FontLoaded: boolean
dc6BlitCount: number
fillTextCount: number
dc6Font30OCenterAlpha: number
dc6Font16OCenterAlpha: number
dc6Formal12OCenterAlpha: number
distinctColorCodesCount: number
multiColorTooltipAligned: boolean
multiColorTooltipBlitCount: number
}>(`(async () => {
await document.fonts.ready;
await Promise.all([
document.fonts.load('16px D2Exocet'),
document.fonts.load('14px D2Formal436'),
]);
const inst = window.__d2webHudInstance;
inst.showGroundLabels = false;
const measureDc6GlyphCenterAlpha = (fontName) => {
const c = document.createElement('canvas');
c.width = 64;
c.height = 64;
const ctx = c.getContext('2d');
inst.font.drawText(ctx, 'O', 10, 40, { font: fontName, color: 'white', shadow: false });
const d = ctx.getImageData(0, 0, 64, 64).data;
let minX = 64, maxX = 0, minY = 64, maxY = 0;
for (let y = 0; y < 64; y++) {
for (let x = 0; x < 64; x++) {
if (d[(y * 64 + x) * 4 + 3] > 128) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
const cx = Math.round((minX + maxX) / 2);
const cy = Math.round((minY + maxY) / 2);
let maxCenterAlpha = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const a = d[((cy + dy) * 64 + (cx + dx)) * 4 + 3];
if (a > maxCenterAlpha) maxCenterAlpha = a;
}
}
return maxCenterAlpha;
};
const colorCodes = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', ';'];
const rgbSignatures = new Set();
for (const cc of colorCodes) {
const c = document.createElement('canvas');
c.width = 32;
c.height = 32;
const ctx = c.getContext('2d');
inst.font.drawText(ctx, 'ÿc' + cc + 'I', 4, 24, { font: 'font16', shadow: false });
const d = ctx.getImageData(0, 0, 32, 32).data;
let brightest = '0,0,0';
let maxSum = -1;
for (let i = 0; i < d.length; i += 4) {
if (d[i + 3] >= 180) {
const sum = d[i] + d[i + 1] + d[i + 2];
if (sum > maxSum) {
maxSum = sum;
brightest = d[i] + ',' + d[i + 1] + ',' + d[i + 2];
}
}
}
rgbSignatures.add(brightest);
}
// Verify per-segment DC6 bitmap blitting alignment across multi-color ÿc transitions in a long rare item affix tooltip
const multiColorAffix = 'ÿc3+20% Faster Cast Rate ÿc0[Max] ÿc9+40% LRes ÿc1+35% FRes';
const plainAffix = '+20% Faster Cast Rate [Max] +40% LRes +35% FRes';
const segCalls = [];
const testCanvas = document.createElement('canvas');
testCanvas.width = 800;
testCanvas.height = 600;
const testCtx = testCanvas.getContext('2d');
const origDrawImg = testCtx.drawImage.bind(testCtx);
testCtx.drawImage = function(img, sx, sy, sw, sh, dx, dy, dw, dh) {
if (arguments.length === 9) {
segCalls.push({ dx, dy, dw, dh });
}
return origDrawImg.apply(this, arguments);
};
inst.font.drawText(testCtx, multiColorAffix, 400, 200, {
font: 'font8',
color: 'blue',
align: 'center',
shadow: false,
});
const meta8 = inst.font.getFontMeta('font8');
let expectedX = Math.round(400 - inst.font.measureText(plainAffix, 'font8') / 2);
let segIdx = 0;
let multiColorTooltipAligned = true;
for (let i = 0; i < plainAffix.length; i++) {
const code = plainAffix.charCodeAt(i);
const g = meta8.glyphs[code];
const adv = Math.max(4, g.width);
if (code !== 0x20 && g.frameW > 0 && g.frameH > 0) {
if (!segCalls[segIdx] || segCalls[segIdx].dx !== expectedX) {
multiColorTooltipAligned = false;
}
segIdx++;
}
expectedX += adv;
}
if (segIdx !== segCalls.length) multiColorTooltipAligned = false;
let blitCount = 0;
let fillCount = 0;
const origDrawImage = CanvasRenderingContext2D.prototype.drawImage;
const origFillText = CanvasRenderingContext2D.prototype.fillText;
CanvasRenderingContext2D.prototype.drawImage = function(...args) {
if (args.length === 9) blitCount++;
return origDrawImage.apply(this, args);
};
CanvasRenderingContext2D.prototype.fillText = function(...args) {
fillCount++;
return origFillText.apply(this, args);
};
try {
if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char');
if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv');
inst.inventory.handleMouseMove(561, 93);
inst.render(performance.now(), false);
} finally {
CanvasRenderingContext2D.prototype.drawImage = origDrawImage;
CanvasRenderingContext2D.prototype.fillText = origFillText;
}
return {
exocetLoaded: document.fonts.check('16px D2Exocet'),
formalLoaded: document.fonts.check('14px D2Formal436'),
hudFontFamily: getComputedStyle(document.getElementById('hud')).fontFamily,
controlsFontFamily: getComputedStyle(document.getElementById('controls')).fontFamily,
dialogFontFamily: getComputedStyle(document.getElementById('dialog')).fontFamily,
dialogTitleFontFamily: getComputedStyle(document.getElementById('status')).fontFamily,
dc6FontLoaded: Boolean(inst.font?.loaded),
dc6BlitCount: blitCount,
fillTextCount: fillCount,
dc6Font30OCenterAlpha: measureDc6GlyphCenterAlpha('font30'),
dc6Font16OCenterAlpha: measureDc6GlyphCenterAlpha('font16'),
dc6Formal12OCenterAlpha: measureDc6GlyphCenterAlpha('fontformal12'),
distinctColorCodesCount: rgbSignatures.size,
multiColorTooltipAligned,
multiColorTooltipBlitCount: segCalls.length,
};
})()`)
console.log('3. Verified acts-en.html Canvas DC6 blitting, O-glyph cross, 12 colors & multi-color tooltip alignment:', JSON.stringify(enCheck))
if (!enCheck.exocetLoaded || !enCheck.formalLoaded || !enCheck.dc6FontLoaded) {
throw new Error(`acts-en.html font loading failed: ${JSON.stringify(enCheck)}`)
}
if (enCheck.dc6BlitCount < 100 || enCheck.fillTextCount !== 0) {
throw new Error(`Expected acts-en.html HUD to use DC6 bitmap blitting (>100 blits, 0 fillText), got: ${JSON.stringify(enCheck)}`)
}
if (enCheck.dc6Font30OCenterAlpha < 200 || enCheck.dc6Font16OCenterAlpha < 200 || enCheck.dc6Formal12OCenterAlpha !== 0) {
throw new Error(`Expected DC6 font30/font16 'O' center to be solid cross (>200) and fontformal12 'O' center to be hollow (0), got: ${JSON.stringify(enCheck)}`)
}
if (enCheck.distinctColorCodesCount !== 12) {
throw new Error(`Expected 12 distinct rendered RGB colors for ÿc0..ÿc;, got: ${enCheck.distinctColorCodesCount}`)
}
if (!enCheck.multiColorTooltipAligned || enCheck.multiColorTooltipBlitCount < 35) {
throw new Error(`Expected multi-color ÿc tooltip DC6 blits to align contiguously across color transitions, got: ${JSON.stringify(enCheck)}`)
}
if (externalUrls.length > 0) {
throw new Error(`External network requests detected on acts-en.html: ${JSON.stringify(externalUrls)}`)
}
await saveScreenshot('issue-504-acts-en-panels.png')
// 3. Open Quest Log + Skill Tree + Canvas NPC Speech Dialog to verify Formal 436 (`fontformal12`) & Exocet in all panels
await evalJs(`(() => {
const inst = window.__d2webHudInstance;
inst.inventory.hoveredItem = null;
if (inst.leftPanel !== 'quest') inst.toggleLeftPanel('quest');
if (inst.rightPanel !== 'skill') inst.toggleRightPanel('skill');
inst.worldPanels.npcMenu = {
npcId: 1,
npcName: 'Akara',
x: 400,
y: 440,
talking: true,
dialogLines: [
'I am Akara, High Priestess of the Sisterhood of the Sightless Eye.',
'Welcome to our camp, traveler. Please cleanse the Den of Evil.',
],
};
inst.render(performance.now(), false);
})()`)
await sleep(300)
await saveScreenshot('issue-504-acts-en-quests-skills-dialog.png')
})
// ── Phase C: Verify acts-zh.html hybrid DC6 + CJK bilingual rendering ──
await withChromePage(`${baseUrl}/acts-zh.html?act=1`, 'acts-zh.html', 9253, async ({ evalJs, saveScreenshot, waitForGameReady, externalUrls }) => {
await waitForGameReady()
const zhCheck = await evalJs<{
dc6BlitCount: number
fillTextCount: number
}>(`(() => {
const inst = window.__d2webHudInstance;
let blitCount = 0;
let fillCount = 0;
const origDrawImage = CanvasRenderingContext2D.prototype.drawImage;
const origFillText = CanvasRenderingContext2D.prototype.fillText;
CanvasRenderingContext2D.prototype.drawImage = function(...args) {
if (args.length === 9) blitCount++;
return origDrawImage.apply(this, args);
};
CanvasRenderingContext2D.prototype.fillText = function(...args) {
fillCount++;
return origFillText.apply(this, args);
};
try {
if (inst.leftPanel !== 'char') inst.toggleLeftPanel('char');
if (inst.rightPanel !== 'inv') inst.toggleRightPanel('inv');
inst.inventory.handleMouseMove(561, 93);
inst.render(performance.now(), false);
} finally {
CanvasRenderingContext2D.prototype.drawImage = origDrawImage;
CanvasRenderingContext2D.prototype.fillText = origFillText;
}
return {
dc6BlitCount: blitCount,
fillTextCount: fillCount,
};
})()`)
console.log('4. Verified acts-zh.html hybrid DC6 + CJK rendering:', JSON.stringify(zhCheck))
if (zhCheck.dc6BlitCount < 100 || zhCheck.fillTextCount < 10) {
throw new Error(`Expected acts-zh.html hybrid rendering (>100 DC6 blits and >=10 CJK fillText calls), got: ${JSON.stringify(zhCheck)}`)
}
await sleep(300)
if (externalUrls.length > 0) {
throw new Error(`External network requests detected on acts-zh.html: ${JSON.stringify(externalUrls)}`)
}
await saveScreenshot('issue-504-acts-zh-bilingual.png')
})
console.log('✅ All Issue #504 Headless Browser E2E Checks PASSED!')
} finally {
await server.close()
}
}
main().catch(err => {
console.error('❌ Issue #504 browser verification failed:', err)
process.exit(1)
})

View File

@ -1,255 +0,0 @@
/**
* 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/baker/mpq-archive-source.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()
}

View File

@ -1,501 +0,0 @@
/**
* Automated Headless Browser Verification & Visual Audit for Diablo II Tooltip & Color Dye Parity.
*
* Verifies:
* 1. Harlequin Crest in helm slot renders emerald green dyed sprite (`invcap_cgrn`).
* 2. Call to Arms in bag inventory renders full authentic 11-line tooltip without truncation.
* 3. Dynamic viewport vertical flip (hover.y < 280) and screen boundary clamping.
* 4. Zero runtime requests for `*.mpq` and `*.dll` files.
*/
import { spawn, type ChildProcess } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { createServer } from 'vite'
const ARTIFACT_DIR =
process.argv[2] ||
'/usr/local/google/home/taodao/d2w-wg1-main/.agents/teamwork_preview_worker_m5_tooltip'
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
interface NetworkRequestEvent {
requestId: string
request: {
url: string
method: string
}
}
async function main(): Promise<void> {
mkdirSync(ARTIFACT_DIR, { recursive: true })
console.log(`Starting headless browser audit, output directory: ${ARTIFACT_DIR}`)
const server = await createServer({
root: process.cwd(),
server: { port: 0, host: '127.0.0.1' },
})
await server.listen()
const address = server.httpServer?.address()
const port = typeof address === 'object' && address ? address.port : 5173
const baseUrl = `http://127.0.0.1:${port}`
console.log(`1. Vite dev server listening at ${baseUrl}`)
const debugPort = 9244
const chromeProc: ChildProcess = spawn('/usr/bin/google-chrome', [
'--headless=new',
`--remote-debugging-port=${debugPort}`,
'--no-sandbox',
'--disable-dev-shm-usage',
'--enable-webgl',
'--ignore-gpu-blocklist',
'--use-gl=angle',
'--use-angle=swiftshader',
'--window-size=1280,840',
`${baseUrl}/acts.html?act=1`,
])
const networkRequests: string[] = []
const mpqOrDllRequests: string[] = []
try {
let wsUrl: string | null = null
for (let i = 0; i < 60; i++) {
await sleep(300)
try {
const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`)
const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[]
const page = pages.find(p => p.type === 'page' && p.url.includes('acts.html'))
if (page?.webSocketDebuggerUrl) {
wsUrl = page.webSocketDebuggerUrl
break
}
} catch {
// Wait for Chrome CDP
}
}
if (!wsUrl) {
throw new Error('Failed to connect to Chrome CDP')
}
console.log('2. Connected to Chrome CDP WebSocket:', wsUrl)
const ws = new WebSocket(wsUrl)
await new Promise<void>(resolve => {
ws.onopen = () => resolve()
})
let idCounter = 1
const sendCommand = <T = any>(method: string, params: Record<string, unknown> = {}): Promise<T> =>
new Promise((resolve, reject) => {
const id = idCounter++
const handler = (event: MessageEvent) => {
const msg = JSON.parse(String(event.data))
if (msg.id === id) {
ws.removeEventListener('message', handler)
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
else resolve(msg.result as T)
}
}
ws.addEventListener('message', handler)
ws.send(JSON.stringify({ id, method, params }))
})
ws.addEventListener('message', (event: MessageEvent) => {
try {
const msg = JSON.parse(String(event.data))
if (msg.method === 'Network.requestWillBeSent') {
const params = msg.params as NetworkRequestEvent
const url = params.request.url
networkRequests.push(url)
if (/\.(mpq|dll)(\?|$)/i.test(url)) {
mpqOrDllRequests.push(url)
}
}
} catch {
// Ignore JSON parse errors on non-event messages
}
})
await sendCommand('Network.enable')
await sendCommand('Runtime.enable')
await sendCommand('Page.enable')
const evalJs = async <T = any>(expression: string): Promise<T> => {
const res = await sendCommand<{ result: { value: T }; exceptionDetails?: any }>('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
})
if (res.exceptionDetails) {
throw new Error(`JS exception: ${JSON.stringify(res.exceptionDetails)}`)
}
return res.result.value
}
const saveScreenshot = async (filename: string): Promise<string> => {
const shot = await sendCommand<{ data: string }>('Page.captureScreenshot', { format: 'png' })
const filePath = join(ARTIFACT_DIR, filename)
writeFileSync(filePath, Buffer.from(shot.data, 'base64'))
console.log(` Saved screenshot: ${filename} -> ${filePath}`)
return filePath
}
// Wait for ActScene and HudManager to become fully ready
console.log('3. Waiting for __d2webAct and __d2webHud ready...')
for (let i = 0; i < 120; i++) {
const ready = await evalJs<boolean>(
`Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready && window.__d2webHud?.assetsLoaded?.itemsAtlas)`,
)
if (ready) break
await sleep(300)
}
await sleep(600)
// Open inventory panel
await evalJs(`(() => {
const inst = window.__d2webHudInstance;
if (inst.rightDockPanel !== 'inv') {
inst.toggleRightPanel('inv');
}
inst.render(performance.now(), false);
})()`)
await sleep(300)
// --- Verification 1: Harlequin Crest Dyed Sprite Parity ---
console.log('4. Verifying Harlequin Crest dyed sprite (invcap_cgrn)...')
const helmAudit = await evalJs<any>(`(() => {
const inst = window.__d2webHudInstance;
const helm = inst.inventory.equipped.helm;
inst.inventory.handleMouseMove(561, 93); // Hover over helm slot (x=532..590, y=64..122)
inst.render(performance.now(), false);
const hovered = inst.inventory.hoveredItem;
const hudCanvas = document.getElementById('d2-hud-canvas');
const ctx = hudCanvas ? hudCanvas.getContext('2d') : null;
// Read pixel in the helm area to verify green color presence
let hasGreen = false;
let sampleR = 0, sampleG = 0, sampleB = 0;
let atlasHasGreen = false;
let atlasSampleR = 0, atlasSampleG = 0, atlasSampleB = 0;
// Check 1: Sample directly from the itemsAtlas image rect for invcap_cgrn (x: 554, y: 1796, w: 56, h: 56)
const atlasImg = inst.images.get('itemsAtlas');
if (atlasImg) {
const offCanvas = document.createElement('canvas');
offCanvas.width = 56;
offCanvas.height = 56;
const offCtx = offCanvas.getContext('2d');
if (offCtx) {
offCtx.drawImage(atlasImg, 554, 1796, 56, 56, 0, 0, 56, 56);
const imgData = offCtx.getImageData(0, 0, 56, 56).data;
for (let i = 0; i < imgData.length; i += 4) {
const r = imgData[i];
const g = imgData[i + 1];
const b = imgData[i + 2];
const a = imgData[i + 3];
// Table 104 emerald green check: G significantly higher than R and B (RGB ~ [51, 135, 75])
if (a > 150 && g > r * 1.3 && g > b * 1.2 && g > 70) {
atlasHasGreen = true;
atlasSampleR = r; atlasSampleG = g; atlasSampleB = b;
break;
}
}
}
}
// Check 2: Sample physical canvas at paperdoll helm slot
if (ctx && hudCanvas) {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const cssW = Math.max(320, hudCanvas.clientWidth || window.innerWidth);
const cssH = Math.max(240, hudCanvas.clientHeight || window.innerHeight);
const uiScale = Math.min(cssW / 800, cssH / 600);
const offsetX = Math.max(0, (cssW - 800 * uiScale) / 2);
const offsetY = Math.max(0, cssH - 600 * uiScale);
const physX = Math.round((offsetX + 545 * uiScale) * dpr);
const physY = Math.round((offsetY + 75 * uiScale) * dpr);
const sampleW = Math.round(30 * uiScale * dpr);
const sampleH = Math.round(30 * uiScale * dpr);
const pixelData = ctx.getImageData(physX, physY, sampleW, sampleH).data;
for (let i = 0; i < pixelData.length; i += 4) {
const r = pixelData[i];
const g = pixelData[i + 1];
const b = pixelData[i + 2];
const a = pixelData[i + 3];
if (a > 150 && g > r * 1.2 && g > b * 1.1 && g > 50) {
hasGreen = true;
sampleR = r; sampleG = g; sampleB = b;
break;
}
}
}
return {
helmName: helm?.name,
helmNameZh: helm?.nameZh,
invtransform: helm?.invtransform,
durability: helm?.durability,
defense: helm?.defense,
reqStr: helm?.reqStr,
reqLevel: helm?.reqLevel,
statCount: helm?.stats?.length,
hoveredTitle: hovered?.item?.nameZh,
hoveredY: hovered?.y,
hasEmeraldGreenPixels: hasGreen,
sampleR, sampleG, sampleB,
atlasHasGreen,
atlasSampleR, atlasSampleG, atlasSampleB,
};
})()`)
console.log(' Helm audit result:', JSON.stringify(helmAudit))
if (helmAudit.helmName !== 'Harlequin Crest' || helmAudit.invtransform !== 'cgrn') {
throw new Error(`Harlequin Crest audit failed: ${JSON.stringify(helmAudit)}`)
}
const shotHelm = await saveScreenshot('harlequin_crest_helm_hover.png')
// --- Verification 2: Call to Arms Runeword 11 Canonical Stats & Unoccluded Title ---
console.log('5. Verifying Call to Arms in bag inventory...')
const ctaAudit = await evalJs<any>(`(() => {
const inst = window.__d2webHudInstance;
const ctaGrid = inst.inventory.gridItems.find(g => g.item.id === 'bag-cta');
// Hover Call to Arms in inventory grid (col 8, row 0: cell center x=664, y=330)
inst.inventory.handleMouseMove(664, 330);
inst.render(performance.now(), false);
const hovered = inst.inventory.hoveredItem;
const cta = ctaGrid?.item;
const wp = inst.worldPanels;
const hudCanvas = document.getElementById('d2-hud-canvas');
const controls = document.getElementById('controls');
const hudZIndex = hudCanvas ? parseInt(window.getComputedStyle(hudCanvas).zIndex || '0', 10) : 0;
const controlsZIndex = controls ? parseInt(window.getComputedStyle(controls).zIndex || '0', 10) : 0;
const lastRect = wp ? wp.lastTooltipRect : null;
// Sample gold title text pixels on canvas to verify full rendering and unoccluded title
let hasGoldTitlePixels = false;
let titleSampleR = 0, titleSampleG = 0, titleSampleB = 0;
const ctx = hudCanvas ? hudCanvas.getContext('2d') : null;
if (ctx && hudCanvas && lastRect) {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const cssW = Math.max(320, hudCanvas.clientWidth || window.innerWidth);
const cssH = Math.max(240, hudCanvas.clientHeight || window.innerHeight);
const uiScale = Math.min(cssW / 800, cssH / 600);
const offsetX = Math.max(0, (cssW - 800 * uiScale) / 2);
const offsetY = Math.max(0, cssH - 600 * uiScale);
// Title is centered around (lastRect.bx + lastRect.boxW / 2) at y = lastRect.by + 24
const physX = Math.round((offsetX + (lastRect.bx + lastRect.boxW / 2) * uiScale) * dpr);
const physY = Math.round((offsetY + (lastRect.by + 24) * uiScale) * dpr);
const sampleW = Math.round(120 * uiScale * dpr);
const sampleH = Math.round(16 * uiScale * dpr);
const pixelData = ctx.getImageData(
Math.max(0, physX - Math.round(sampleW / 2)),
Math.max(0, physY - Math.round(sampleH / 2)),
sampleW,
sampleH,
).data;
for (let i = 0; i < pixelData.length; i += 4) {
const r = pixelData[i];
const g = pixelData[i + 1];
const b = pixelData[i + 2];
const a = pixelData[i + 3];
// D2 Gold font (#c8a15a: R ~ 200, G ~ 161, B ~ 90)
if (a > 150 && r > 150 && g > 120 && b < 130 && r > b * 1.3) {
hasGoldTitlePixels = true;
titleSampleR = r;
titleSampleG = g;
titleSampleB = b;
break;
}
}
}
return {
id: cta?.id,
name: cta?.name,
nameZh: cta?.nameZh,
baseNameZh: cta?.baseNameZh,
quality: cta?.quality,
runes: cta?.runewordRunes,
damage: cta?.damage,
durability: cta?.durability,
speedText: cta?.speedText,
reqStr: cta?.reqStr,
reqLevel: cta?.reqLevel,
sockets: cta?.sockets,
statCount: cta?.stats?.length,
stats: cta?.stats?.map(s => s.text),
hoveredItemName: hovered?.item?.nameZh,
lastTooltipRect: lastRect,
hudZIndex,
controlsZIndex,
isHudAboveControls: hudZIndex > controlsZIndex,
isTooltipClampedBelowControls: lastRect ? lastRect.by >= 44 : false,
hasGoldTitlePixels,
titleSampleR,
titleSampleG,
titleSampleB,
};
})()`)
console.log(' Call to Arms audit result:', JSON.stringify(ctaAudit, null, 2))
if (ctaAudit.name !== 'Call to Arms' || ctaAudit.statCount !== 11) {
throw new Error(`Call to Arms audit failed: expected 11 stats, got ${ctaAudit.statCount}`)
}
if (!ctaAudit.isHudAboveControls) {
throw new Error(
`HUD canvas z-index (${ctaAudit.hudZIndex}) must be higher than controls overlay (${ctaAudit.controlsZIndex})!`,
)
}
if (!ctaAudit.isTooltipClampedBelowControls) {
throw new Error(
`Call to Arms tooltip must be clamped below controls overlay (by >= 44, got ${ctaAudit.lastTooltipRect?.by})!`,
)
}
if (!ctaAudit.hasGoldTitlePixels) {
throw new Error('Call to Arms title text gold pixels not detected on canvas!')
}
const shotCta = await saveScreenshot('call_to_arms_inventory_hover.png')
// --- Verification 3: Dynamic Vertical Flip & Clamping (Live UI Interaction) ---
console.log('6. Verifying Viewport Vertical Flip and Clamping behavior with live interaction...')
const flipAudit = await evalJs<any>(`(() => {
const inst = window.__d2webHudInstance;
const wp = inst.worldPanels;
// 1. Move to helm slot at (561, 93) near top of screen
inst.inventory.handleMouseMove(561, 93);
inst.render(performance.now(), false);
const topHelmHover = inst.inventory.hoveredItem;
const topHelmRect = wp.lastTooltipRect ? { ...wp.lastTooltipRect } : null;
const helmHoverY = topHelmHover?.y ?? 93;
const isHelmFlippedBelow = topHelmRect ? topHelmRect.by > helmHoverY : false;
// 2. Move to amulet slot at (618, 107) near top of screen
inst.inventory.handleMouseMove(618, 107);
inst.render(performance.now(), false);
const topAmuHover = inst.inventory.hoveredItem;
const topAmuRect = wp.lastTooltipRect ? { ...wp.lastTooltipRect } : null;
const amuHoverY = topAmuHover?.y ?? 107;
const isAmuFlippedBelow = topAmuRect ? topAmuRect.by > amuHoverY : false;
// 3. Move to Call to Arms in bag grid at (664, 330) near bottom
inst.inventory.handleMouseMove(664, 330);
inst.render(performance.now(), false);
const btmHover = inst.inventory.hoveredItem;
const btmRect = wp.lastTooltipRect ? { ...wp.lastTooltipRect } : null;
const btmHoverY = btmHover?.y ?? 330;
const isBtmPlacedAbove = btmRect ? btmRect.by < btmHoverY : false;
const isBtmClamped = btmRect ? (btmRect.by >= 44 && btmRect.by + btmRect.boxH <= 600 - 8) : false;
// 4. Trigger mouse move to amulet slot (618, 107) and render so screenshot captures genuine flipped tooltip
inst.inventory.handleMouseMove(618, 107);
inst.render(performance.now(), false);
return {
helmHoverY,
topHelmRect,
isHelmFlippedBelow,
amuHoverY,
topAmuRect,
isAmuFlippedBelow,
btmHoverY,
btmRect,
isBtmPlacedAbove,
isBtmClamped,
liveItemNameZh: inst.inventory.hoveredItem?.item?.nameZh,
activeRect: wp.lastTooltipRect ? { ...wp.lastTooltipRect } : null,
};
})()`)
console.log(' Vertical flip and clamping audit:', JSON.stringify(flipAudit, null, 2))
if (!flipAudit.isHelmFlippedBelow || !flipAudit.isAmuFlippedBelow) {
throw new Error(
`Vertical flip below cursor failed: helm=${flipAudit.isHelmFlippedBelow}, amu=${flipAudit.isAmuFlippedBelow}`,
)
}
if (!flipAudit.isBtmPlacedAbove || !flipAudit.isBtmClamped) {
throw new Error(
`Bottom placement or clamping failed: placedAbove=${flipAudit.isBtmPlacedAbove}, clamped=${flipAudit.isBtmClamped}`,
)
}
const shotFlip = await saveScreenshot('tooltip_clamping_and_flip.png')
// Verify distinct screenshot SHA256 checksums
const ctaBuf = readFileSync(shotCta)
const flipBuf = readFileSync(shotFlip)
const helmBuf = readFileSync(shotHelm)
const ctaSha256 = createHash('sha256').update(ctaBuf).digest('hex')
const flipSha256 = createHash('sha256').update(flipBuf).digest('hex')
const helmSha256 = createHash('sha256').update(helmBuf).digest('hex')
console.log(' Screenshot SHA256 verification:')
console.log(` harlequin_crest_helm_hover.png: ${helmSha256}`)
console.log(` call_to_arms_inventory_hover.png: ${ctaSha256}`)
console.log(` tooltip_clamping_and_flip.png: ${flipSha256}`)
if (flipSha256 === ctaSha256) {
throw new Error(
`INTEGRITY VIOLATION: Duplicate screenshot detected! tooltip_clamping_and_flip.png and call_to_arms_inventory_hover.png have identical SHA256 (${flipSha256})`,
)
}
if (flipSha256 === helmSha256) {
throw new Error(
`INTEGRITY VIOLATION: Duplicate screenshot detected! tooltip_clamping_and_flip.png and harlequin_crest_helm_hover.png have identical SHA256 (${flipSha256})`,
)
}
// --- Verification 4: Zero Runtime MPQ/DLL Requests ---
console.log('7. Verifying Zero Runtime MPQ/DLL requests...')
console.log(` Total Network Requests observed: ${networkRequests.length}`)
console.log(` MPQ or DLL Requests observed: ${mpqOrDllRequests.length}`)
if (mpqOrDllRequests.length > 0) {
throw new Error(
`VIOLATION: Found ${mpqOrDllRequests.length} runtime MPQ/DLL requests: ${JSON.stringify(mpqOrDllRequests)}`,
)
}
// Write full audit report artifact
const auditSummary = {
timestamp: new Date().toISOString(),
status: 'VERIFIED_PASSING',
zeroMpqDllInvariant: {
totalNetworkRequests: networkRequests.length,
mpqDllRequestsCount: mpqOrDllRequests.length,
verifiedZero: true,
},
harlequinCrest: helmAudit,
callToArms: ctaAudit,
verticalFlipAndClamping: flipAudit,
screenshotChecksums: {
'harlequin_crest_helm_hover.png': helmSha256,
'call_to_arms_inventory_hover.png': ctaSha256,
'tooltip_clamping_and_flip.png': flipSha256,
allDistinct: true,
},
screenshots: [
'harlequin_crest_helm_hover.png',
'call_to_arms_inventory_hover.png',
'tooltip_clamping_and_flip.png',
],
}
const summaryPath = join(ARTIFACT_DIR, 'audit_summary.json')
writeFileSync(summaryPath, JSON.stringify(auditSummary, null, 2))
console.log(`8. Wrote audit summary to ${summaryPath}`)
console.log('=== ALL HEADLESS BROWSER CDP AUDITS PASSED 100% ===')
ws.close()
} finally {
chromeProc.kill()
await server.close()
}
}
main().catch(err => {
console.error('verify-tooltip-browser failed:', err)
process.exit(1)
})

View File

@ -1,223 +0,0 @@
/**
* Automated Headless Browser Verification & Visual Audit for Issue #27 (`acts.html`).
*
* Uses Vite dev server + `/usr/bin/google-chrome` over native WebSocket CDP (zero external deps).
*/
import { spawn } from 'node:child_process'
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { createServer } from 'vite'
const ARTIFACT_DIR =
process.argv[2] ||
'/usr/local/google/home/taodao/.gemini/jetski/brain/aa680fb2-b60b-4fb6-86df-d401210969d8'
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
async function main(): Promise<void> {
mkdirSync(ARTIFACT_DIR, { recursive: true })
const server = await createServer({
root: process.cwd(),
server: { port: 0, host: '127.0.0.1' },
})
await server.listen()
const address = server.httpServer?.address()
const port = typeof address === 'object' && address ? address.port : 5173
const baseUrl = `http://127.0.0.1:${port}`
console.log(`1. Vite dev server listening at ${baseUrl}`)
const debugPort = 9237
const chromeProc = spawn('/usr/bin/google-chrome', [
'--headless=new',
`--remote-debugging-port=${debugPort}`,
'--no-sandbox',
'--disable-dev-shm-usage',
'--enable-webgl',
'--ignore-gpu-blocklist',
'--use-gl=angle',
'--use-angle=swiftshader',
'--window-size=1280,840',
`${baseUrl}/acts.html?act=1`,
])
try {
let wsUrl: string | null = null
for (let i = 0; i < 60; i++) {
await sleep(300)
try {
const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`)
const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[]
const page = pages.find(p => p.type === 'page' && p.url.includes('acts.html'))
if (page?.webSocketDebuggerUrl) {
wsUrl = page.webSocketDebuggerUrl
break
}
} catch {
// Wait for Chrome CDP
}
}
if (!wsUrl) {
throw new Error('Failed to connect to Chrome CDP')
}
console.log('2. Connected to Chrome CDP WebSocket:', wsUrl)
const ws = new WebSocket(wsUrl)
await new Promise<void>(resolve => {
ws.onopen = () => resolve()
})
let idCounter = 1
const sendCommand = <T = any>(method: string, params: Record<string, unknown> = {}): Promise<T> =>
new Promise((resolve, reject) => {
const id = idCounter++
const handler = (event: MessageEvent) => {
const msg = JSON.parse(String(event.data))
if (msg.id === id) {
ws.removeEventListener('message', handler)
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
else resolve(msg.result as T)
}
}
ws.addEventListener('message', handler)
ws.send(JSON.stringify({ id, method, params }))
})
const evalJs = async <T = any>(expression: string): Promise<T> => {
const res = await sendCommand<{ result: { value: T }; exceptionDetails?: any }>('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
})
if (res.exceptionDetails) {
throw new Error(`JS exception: ${JSON.stringify(res.exceptionDetails)}`)
}
return res.result.value
}
const saveScreenshot = async (filename: string): Promise<void> => {
const shot = await sendCommand<{ data: string }>('Page.captureScreenshot', { format: 'png' })
writeFileSync(join(ARTIFACT_DIR, filename), Buffer.from(shot.data, 'base64'))
console.log(` Saved screenshot: ${filename}`)
}
await sendCommand('Runtime.enable')
await sendCommand('Page.enable')
// Wait for both ActScene and HudManager to become ready
for (let i = 0; i < 120; i++) {
const ready = await evalJs<boolean>(
`Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready && window.__d2webHud?.assetsLoaded?.ctrlPnl)`,
)
if (ready) break
await sleep(300)
}
await sleep(600)
const initialHud = await evalJs<any>(`window.__d2webHud`)
console.log('3. Verified initial __d2webHud state:', JSON.stringify(initialHud))
if (!initialHud || initialHud.dc6DecodeFailures !== 0) {
throw new Error(`Expected 0 DC6 decode failures, got: ${JSON.stringify(initialHud)}`)
}
for (const [k, v] of Object.entries(initialHud.assetsLoaded)) {
if (!v) throw new Error(`Asset group failed to load: ${k}`)
}
// Screenshot 1: Base Town HUD
await saveScreenshot('hud_base_town.png')
// Screenshot 2: Left Character Sheet (`A`) + Right Inventory (`I`) + Hover Harlequin Crest
const dualState = await evalJs<any>(`(() => {
const inst = window.__d2webHudInstance;
inst.toggleLeftPanel('char');
inst.toggleRightPanel('inv');
inst.inventory.handleMouseMove(561, 93); // Hover Harlequin Crest in Helm slot
inst.render(performance.now(), false);
return window.__d2webHud;
})()`)
if (dualState.leftPanel !== 'char' || dualState.rightPanel !== 'inv') {
throw new Error(`Split panel assertion failed: ${JSON.stringify(dualState)}`)
}
await sleep(200)
await saveScreenshot('hud_char_and_inv.png')
// Verify Inventory Drag-and-Drop, Equipment Swap, Belt Drink, Stat & Skill Allocation
const interactionCheck = await evalJs<any>(`(() => {
const inst = window.__d2webHudInstance;
inst.inventory.clickEquipSlot('weapon1');
const pickedCode = inst.inventory.cursorItem?.code;
inst.inventory.clickGridCell(8, 0);
const swappedCode = inst.inventory.cursorItem?.code;
inst.inventory.clickEquipSlot('weapon1');
const finalWeapon1 = inst.inventory.equipped.weapon1?.code;
inst.hp = 900;
inst.drinkBeltSlot(0);
const afterBeltCount = inst.belt.countTotalPotions();
const healedHp = inst.hp;
inst.charSheet.allocateStat('vit');
inst.syncPublishedState();
return { pickedCode, swappedCode, finalWeapon1, afterBeltCount, healedHp, remainingStatPts: inst.state.statPoints };
})()`)
console.log('4. Verified HUD interactions via CDP:', JSON.stringify(interactionCheck))
if (
interactionCheck.pickedCode !== 'oba' ||
interactionCheck.swappedCode !== 'crs' ||
interactionCheck.finalWeapon1 !== 'crs' ||
interactionCheck.afterBeltCount !== 15 ||
interactionCheck.healedHp !== 1080
) {
throw new Error(`HUD interaction check failed: ${JSON.stringify(interactionCheck)}`)
}
// Screenshot 3: Left Quest Log (`Q`) + Right Skill Tree (`T`) + Hover Frozen Orb
const questSkillState = await evalJs<any>(`(() => {
const inst = window.__d2webHudInstance;
inst.toggleLeftPanel('quest');
inst.toggleRightPanel('skill');
inst.skillTree.handleMouseMove(445, 422); // Hover Frozen Orb
inst.render(performance.now(), false);
return window.__d2webHud;
})()`)
if (questSkillState.leftPanel !== 'quest' || questSkillState.rightPanel !== 'skill') {
throw new Error(`Quest/Skill panel assertion failed: ${JSON.stringify(questSkillState)}`)
}
await sleep(200)
await saveScreenshot('hud_quest_and_skills.png')
// Screenshot 4: Left Waypoint Menu (`V`) + 4x4 Pop-up Belt (`~`) + Right Skill Speedbar Popup + Monster Plaque
const wpBeltState = await evalJs<any>(`(() => {
const inst = window.__d2webHudInstance;
inst.toggleLeftPanel('waypoint');
inst.toggleRightPanel('skill'); // close right panel
inst.belt.lockedOpen = true;
inst.hotkeys.openPopup = 'right';
inst.hotkeys.hoveredPopupSkill = { side: 'right', skillId: 64 };
inst.targetMonster = {
name: 'Corpsefire (尸体发火)',
hp: 740,
maxHp: 980,
rank: 'superunique',
affixes: ['Spectral Hit · 特别命中', 'Undead · 不死族'],
};
inst.render(performance.now(), false);
return window.__d2webHud;
})()`)
if (wpBeltState.leftPanel !== 'waypoint' || !wpBeltState.beltExpanded) {
throw new Error(`Waypoint/Belt assertion failed: ${JSON.stringify(wpBeltState)}`)
}
await sleep(200)
await saveScreenshot('hud_waypoint_and_belt.png')
ws.close()
console.log('5. ALL Issue #27 Browser & CDP Assertions Passed 100%!')
} finally {
chromeProc.kill()
await server.close()
}
}
main().catch((err) => {
console.error('verify-ui-browser failed:', err)
process.exit(1)
})

View File

@ -1,346 +0,0 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { createServer, type ViteDevServer } from 'vite'
import { ensurePacksAvailable } from './verify-ground-combat-browser.ts'
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
const CHROME_CANDIDATES = [
'/usr/bin/google-chrome',
'/usr/local/google/home/taodao/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome',
'/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome',
]
function resolveChromeBinary(): string {
for (const bin of CHROME_CANDIDATES) {
if (existsSync(bin)) return bin
}
throw new Error('No accessible Chromium binary found')
}
async function main() {
const outputDir = process.env.VENDOR_AUDIT_OUT
if (!outputDir) {
throw new Error('VENDOR_AUDIT_OUT environment variable is required')
}
mkdirSync(outputDir, { recursive: true })
ensurePacksAvailable()
const port = 5419
const viteServer: ViteDevServer = await createServer({
root: resolve('.'),
logLevel: 'silent',
server: { host: '127.0.0.1', port, strictPort: false },
})
await viteServer.listen()
const actualPort = (viteServer.httpServer?.address() as any)?.port ?? port
const targetUrl = `http://127.0.0.1:${actualPort}/acts.html?act=1&level=1-act-1-town-1`
const chromeBin = resolveChromeBinary()
const debugPort = 9519
const chromeProc: ChildProcess = spawn(
chromeBin,
[
'--headless=new',
'--no-sandbox',
'--disable-dev-shm-usage',
'--window-size=1280,840',
'--enable-webgl',
'--ignore-gpu-blocklist',
'--use-gl=angle',
'--use-angle=swiftshader',
'--no-proxy-server',
`--remote-debugging-port=${debugPort}`,
targetUrl,
],
{ stdio: ['ignore', 'ignore', 'pipe'] },
)
let ws: WebSocket | null = null
try {
let wsDebuggerUrl: string | null = null
for (let i = 0; i < 80; i++) {
await sleep(150)
try {
const res = await fetch(`http://127.0.0.1:${debugPort}/json`)
const pages = (await res.json()) as any[]
const page = pages.find(p => p.type === 'page' && p.url.includes('acts.html'))
if (page?.webSocketDebuggerUrl) {
wsDebuggerUrl = page.webSocketDebuggerUrl
break
}
} catch {}
}
if (!wsDebuggerUrl) throw new Error('Failed to attach to Chrome CDP')
ws = new WebSocket(wsDebuggerUrl)
await new Promise((res, rej) => {
ws!.onopen = () => res(true)
ws!.onerror = rej
})
let cmdId = 1
const pending = new Map<number, (res: any) => void>()
ws.onmessage = (event: MessageEvent) => {
const msg = JSON.parse(String(event.data))
if (msg.id && pending.has(msg.id)) {
pending.get(msg.id)!(msg)
pending.delete(msg.id)
}
}
const cdp = <T = any>(method: string, params: Record<string, unknown> = {}): Promise<T> =>
new Promise((resolve, reject) => {
const id = cmdId++
pending.set(id, (msg: any) => {
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
else resolve(msg.result as T)
})
ws!.send(JSON.stringify({ id, method, params }))
})
const evalJs = async <T = any>(expression: string): Promise<T> => {
const res = await cdp<{ result: { value: T }; exceptionDetails?: any }>('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
})
if (res.exceptionDetails) {
throw new Error(`Eval error: ${JSON.stringify(res.exceptionDetails)}`)
}
return res.result.value
}
const takeScreenshot = async (name: string): Promise<string> => {
const shot = await cdp<{ data: string }>('Page.captureScreenshot', { format: 'png' })
const filePath = join(outputDir, `${name}.png`)
writeFileSync(filePath, Buffer.from(shot.data, 'base64'))
console.log(`Saved screenshot: ${filePath}`)
return filePath
}
await cdp('Runtime.enable')
await cdp('Page.enable')
for (let i = 0; i < 100; i++) {
const ready = await evalJs<boolean>(`Boolean(window.__d2webAct?.ready && window.__d2webHud?.ready)`)
if (ready) break
await sleep(200)
}
// 1. Exhaustive audit of all 16 trade NPCs and 6 gamble NPCs
const fullAudit = await evalJs(`(() => {
const hud = window.__d2webHudInstance;
const wp = hud.worldPanels;
hud.charSheet.attrs.level = 30;
const tradeNpcs = [
'Akara', 'Charsi', 'Gheed',
'Fara', 'Drognan', 'Elzix', 'Lysander',
'Ormus', 'Hratli', 'Asheara', 'Alkor',
'Halbu', 'Jamella',
'Larzuk', 'Malah', 'Anya',
];
const gambleNpcs = ['Gheed', 'Elzix', 'Alkor', 'Jamella', 'Anya', 'Nihlathak'];
const tradeResults = [];
for (const name of tradeNpcs) {
const ok = hud.openVendorSession(name, 'trade', 0);
if (!ok) throw new Error('Failed to open trade vendor: ' + name);
hud.render();
const frames = [0, 1, 2, 3].map(slot => wp.getVendorButtonFrame(slot));
tradeResults.push({
name,
hcIdx: wp.activeVendorDescriptor.hcIdx,
canRepair: wp.activeVendorDescriptor.canRepair,
layout: wp.getActiveVendorButtonLayout(),
frames,
activeTab: wp.activeVendorTab,
itemsCount: wp.getActiveVendorPlacements().length,
});
}
const gambleResults = [];
for (const name of gambleNpcs) {
const ok = hud.openVendorSession(name, 'gamble', 1);
if (!ok) throw new Error('Failed to open gamble vendor: ' + name);
hud.render();
const frames = [0, 1, 2, 3].map(slot => wp.getVendorButtonFrame(slot));
gambleResults.push({
name,
hcIdx: wp.activeVendorDescriptor.hcIdx,
layout: wp.getActiveVendorButtonLayout(),
frames,
itemsCount: wp.getActiveVendorPlacements().length,
});
}
return { tradeCount: tradeResults.length, gambleCount: gambleResults.length, tradeResults, gambleResults };
})()`)
console.log(
`Audited ${fullAudit.tradeCount} trade NPCs and ${fullAudit.gambleCount} gamble NPCs:`,
JSON.stringify({
blacksmiths: fullAudit.tradeResults.filter((r: any) => r.canRepair).map((r: any) => `${r.name}:${r.frames.join(',')}`),
traders: fullAudit.tradeResults.filter((r: any) => !r.canRepair).map((r: any) => `${r.name}:${r.frames.join(',')}`),
gamblers: fullAudit.gambleResults.map((r: any) => `${r.name}:${r.frames.join(',')}`),
}),
)
// 2. Open Charsi NPC Menu (move player next to Charsi in town and trigger interactWithNpc)
const menuInfo = await evalJs(`(() => {
const hud = window.__d2webHudInstance;
const engine = window.__d2webEngine;
const mc = window.__d2webMouseController;
hud.closeAllPanels();
const charsi = engine?.npcEntities?.find(n => n.def?.name?.toLowerCase() === 'charsi');
if (charsi && mc) {
engine.world.player.x = charsi.x + 28;
engine.world.player.y = charsi.y + 18;
mc.interactWithNpc(charsi);
} else {
hud.openNpcMenuByName('Charsi', 400, 220);
}
hud.render();
const dialogEl = document.querySelector('#dialog');
return {
npcName: hud.worldPanels.npcMenu?.npcName,
options: hud.worldPanels.npcMenu?.options?.map(o => o.labelZh),
hoveredOptionIdx: hud.worldPanels.npcMenu?.hoveredOptionIdx,
layout: hud.worldPanels.getNpcMenuLayout(hud.font),
domDialogHidden: dialogEl ? dialogEl.hidden : true,
};
})()`)
console.log('NPC Menu:', JSON.stringify(menuInfo))
if (!menuInfo.domDialogHidden) {
throw new Error('Legacy #dialog DOM element should be hidden when HudManager is active')
}
await sleep(250)
await takeScreenshot('vendor_npc_menu_charsi')
// 3. Open Charsi Trade/Repair 4-tab 10x10 Vendor UI (buysell.dc6 + buyselltabs.dc6 + [2,4,6,18] buttons)
const charsiInfo = await evalJs(`(() => {
const hud = window.__d2webHudInstance;
hud.charSheet.attrs.level = 24;
hud.openVendorSession('Charsi', 'trade', 0);
const wp = hud.worldPanels;
wp.selectVendorTab('armor');
wp.hoveredVendorButtonSlot = null;
const armorItems = wp.vendorTabPlacements.armor;
const docking = hud.getDockingLayout();
const deltaLeft = -docking.marginW;
if (armorItems.length > 0) {
const first = armorItems[0];
wp.hoveredVendorItem = {
item: first.item,
x: 80 + 16 + first.col * 29 + 14,
y: 60 + 63 + first.row * 29 + 14,
col: first.col,
row: first.row,
};
hud.inventory.hoveredItem = {
item: first.item,
x: wp.hoveredVendorItem.x + deltaLeft,
y: wp.hoveredVendorItem.y,
};
}
hud.render();
return {
leftPanel: hud.leftPanel,
rightPanel: hud.rightPanel,
buttonLayout: wp.getActiveVendorButtonLayout(),
armorCount: wp.vendorTabPlacements.armor.length,
weapons1Count: wp.vendorTabPlacements.weapons1.length,
weapons2Count: wp.vendorTabPlacements.weapons2.length,
miscCount: wp.vendorTabPlacements.misc.length,
firstItem: armorItems[0] ? { nameZh: armorItems[0].item.nameZh, buyCost: armorItems[0].item.buyCost } : null,
};
})()`)
console.log('Charsi Trade UI:', JSON.stringify(charsiInfo))
await sleep(200)
await takeScreenshot('vendor_trade_charsi')
// 4. Charsi Repair Latched + Repair All Hover Tooltip
const charsiRepairInfo = await evalJs(`(() => {
const hud = window.__d2webHudInstance;
const wp = hud.worldPanels;
wp.hoveredVendorItem = null;
hud.inventory.hoveredItem = null;
// Damage an equipped item to produce a non-zero Repair All cost
const equippedList = Object.values(hud.inventory.equipped).filter(Boolean);
if (equippedList.length > 0 && equippedList[0].durability) {
equippedList[0].durability.current = Math.max(1, Math.floor(equippedList[0].durability.max / 2));
}
wp.vendorTradeState = 'repair';
wp.hoveredVendorButtonSlot = 3;
hud.render();
return {
vendorTradeState: wp.vendorTradeState,
repairFrame: wp.getVendorButtonFrame(2),
repairAllCost: wp.getRepairAllCost(hud.inventory),
};
})()`)
console.log('Charsi Repair UI:', JSON.stringify(charsiRepairInfo))
await sleep(200)
await takeScreenshot('vendor_repair_charsi')
// 5. Akara Trader UI ([Buy, Sell, Blank, Close] + Close hover)
const akaraInfo = await evalJs(`(() => {
const hud = window.__d2webHudInstance;
hud.openVendorSession('Akara', 'trade', 0);
const wp = hud.worldPanels;
wp.hoveredVendorItem = null;
hud.inventory.hoveredItem = null;
wp.hoveredVendorButtonSlot = 3;
hud.render();
return {
buttonLayout: wp.getActiveVendorButtonLayout(),
activeTab: wp.activeVendorTab,
};
})()`)
console.log('Akara Trade UI:', JSON.stringify(akaraInfo))
await sleep(200)
await takeScreenshot('vendor_trade_akara')
// 6. Open Gheed Gamble UI
const gheedInfo = await evalJs(`(() => {
const hud = window.__d2webHudInstance;
hud.charSheet.attrs.level = 60;
hud.openVendorSession('Gheed', 'gamble', 1);
const wp = hud.worldPanels;
wp.hoveredVendorButtonSlot = null;
const gambleItems = wp.gamblePlacements;
const docking = hud.getDockingLayout();
const deltaLeft = -docking.marginW;
if (gambleItems.length > 0) {
const first = gambleItems[0];
wp.hoveredVendorItem = {
item: first.item,
x: 80 + 16 + first.col * 29 + 14,
y: 60 + 63 + first.row * 29 + 14,
col: first.col,
row: first.row,
};
hud.inventory.hoveredItem = {
item: first.item,
x: wp.hoveredVendorItem.x + deltaLeft,
y: wp.hoveredVendorItem.y,
};
}
hud.render();
return {
buttonLayout: wp.getActiveVendorButtonLayout(),
gambleCount: gambleItems.length,
firstGamble: gambleItems[0] ? { nameZh: gambleItems[0].item.nameZh, buyCost: gambleItems[0].item.buyCost } : null,
};
})()`)
console.log('Gheed Gamble UI:', JSON.stringify(gheedInfo))
await sleep(200)
await takeScreenshot('vendor_gamble_gheed')
} finally {
ws?.close()
chromeProc.kill('SIGKILL')
await viteServer.close()
}
}
main().catch(err => {
console.error(err)
process.exit(1)
})