diablo2-web/scripts/verify-ground-combat-browse...

818 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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/game/embedded-drop-tables.ts'
import { executeDropPipeline } from '../src/game/drop-pipeline.ts'
import { D2Rng } from '../src/game/d2-rng.ts'
import { resolveGroundItemSpriteRect, resolveItemSpriteRect } from '../src/ui/inventory.ts'
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
import { itemToUiInventoryItem } from '../src/ui/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)
})
}