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:
parent
13b2a4c326
commit
3c1ed16bf9
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||

|
||||
`
|
||||
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
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
Loading…
Reference in New Issue