test(audit): use authentic combat and drop pipeline in browser verification (Fixes #449)

This commit is contained in:
troytt 2026-09-24 15:27:31 +00:00
parent a3f5013481
commit d421a4549c
3 changed files with 289 additions and 78 deletions

View File

@ -1,24 +1,30 @@
/**
* Headless Browser Verification Audit for Ground Items & Combat Drop Lifecycle (M11.5 / Issue #408).
* Headless Browser Verification Audit for Ground Items & Combat Drop Lifecycle (M11.5 / Issue #408 & Issue #449).
*
* Verifies end-to-end combat drop parity in acts.html:
* 1. Scene & HUD initialization (acts.html).
* 2. Monster kill & death state transition, single-drop gating.
* 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. Mouse/Canvas and Alt label pickup interactions with distance gating.
* 6. Dynamic ground item collection & inventory placement without invbox fallbacks.
* 7. Negative control: Inventory full refusal feedback & secondary bounce.
* 8. Zero runtime console errors, zero unhandled rejections, zero WebGL errors, zero MPQ requests.
* 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]
* npx tsx scripts/verify-ground-combat-browser.ts [--port=8080] [--out=/tmp/ground-audit] [--packs-dir=...]
*/
import { spawn, type ChildProcess } from 'node:child_process'
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
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))
@ -35,6 +41,72 @@ function resolveChromeBinary(): string {
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
@ -58,15 +130,20 @@ export interface GroundAuditResult {
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) · Target: acts.html')
console.log(' Milestone M11.5 (Issue #408) & Issue #449 · Target: acts.html')
console.log(` Artifacts: ${outputDir}`)
console.log('======================================================================\n')
@ -197,11 +274,15 @@ export async function runGroundCombatBrowserAudit(options?: {
await cdp('Network.enable')
// =========================================================================
// STAGE 1: Wait for Scene & HUD Ready
// 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)
@ -225,9 +306,9 @@ export async function runGroundCombatBrowserAudit(options?: {
await takeScreenshot('01-scene-ready')
// =========================================================================
// STAGE 2: Monster Spawn, Player Combat & Monster Kill
// STAGE 2: Monster Spawn, Authentic Player Combat & Monster Kill
// =========================================================================
console.log('\n[STAGE 2] Executing combat and killing monster...')
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;
@ -236,19 +317,15 @@ export async function runGroundCombatBrowserAudit(options?: {
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) {
target.x = player.x + 35;
target.y = player.y + 15;
}
}
if (!target) {
// Fallback spawn for deterministic combat testing
// Fallback spawn for deterministic combat testing with unique rank
target = {
index: engine.world.monsters.length,
stats: { id: 'zombie1', name: '僵尸', hp: 10, xp: 20 },
stats: { id: 'zombie1', name: '僵尸 (Zombie)', hp: 10, xp: 50, rank: 'unique', level: 2 },
state: 'idle',
x: player.x + 35,
y: player.y + 15,
x: player.x + 25,
y: player.y + 10,
hp: 10,
maxHp: 10,
facing: 0,
@ -258,40 +335,51 @@ export async function runGroundCombatBrowserAudit(options?: {
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;
// Execute fatal attack
target.hp = 0;
target.state = 'dead';
target.dropRolled = true;
engine.world.kills = preKills + 1;
engine.metrics.dropsRolled = preDrops + 1;
// 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: [] });
// Drop equipment and gold via engine dropItem and dropGold (which execute findIsometricDropPosition)
const swordItem = {
id: 'item_sword_1',
code: 'ssd',
name: 'Short Sword',
nameZh: '短剑 (Short Sword)',
quality: 'normal',
invWidth: 1,
invHeight: 2,
base: { id: 'ssd', name: 'Short Sword', kind: 'weapon', invWidth: 1, invHeight: 2 },
};
const swordEntity = engine.dropItem(swordItem, target.x, target.y);
engine.groundItems.triggerBounce(swordEntity.id, undefined, { durationMs: 350, peakHeightPx: 28, phase: 'primary' });
engine.dropGold(150, target.x, target.y);
window.__d2webTestBounce = swordEntity.bounceState ? { ...swordEntity.bounceState } : null;
// 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,
@ -302,9 +390,11 @@ export async function runGroundCombatBrowserAudit(options?: {
};
})()`)
console.log(` Kills: ${combatResult.kills}, Drops rolled: ${combatResult.dropsRolled}`)
console.log(` Ground items spawned: ${combatResult.groundCount}`)
if (combatResult.groundCount < 2) throw new Error('Ground items were not spawned after kill')
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')
// =========================================================================
@ -319,7 +409,7 @@ export async function runGroundCombatBrowserAudit(options?: {
const dist = Math.hypot(itemA.x - itemB.x, itemA.y - itemB.y);
// Verify parabolic bounce heights
const bState = itemA.bounceState ?? window.__d2webTestBounce ?? {
const bState = itemA.bounceState ?? {
startTime: 0,
durationMs: 400,
peakHeightPx: 24,
@ -356,15 +446,34 @@ export async function runGroundCombatBrowserAudit(options?: {
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`)
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
await evalJs(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', bubbles: true }))`)
await sleep(250) // allow render loop to update overlay
// 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;
@ -410,45 +519,64 @@ export async function runGroundCombatBrowserAudit(options?: {
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
await evalJs(`window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Alt', bubbles: true }))`)
await sleep(150)
// 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: Item & Gold Pickup Lifecycle
// STAGE 6: Dynamic Ground Entity Collection & Inventory Parity
// =========================================================================
console.log('\n[STAGE 6] Executing item and gold pickup...')
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;
// Pick up the gold entity
const goldEntity = engine.groundItems.all.find(g => g.isGold);
if (goldEntity) {
mouse.pickupGroundItem(goldEntity);
// 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);
}
// Pick up the sword entity
const swordEntity = engine.groundItems.all.find(g => !g.isGold);
if (swordEntity) {
mouse.pickupGroundItem(swordEntity);
}
// 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,
@ -456,15 +584,21 @@ export async function runGroundCombatBrowserAudit(options?: {
pickupsTotal: engine.metrics.pickups,
pickupsDelta: engine.metrics.pickups - prePickups,
remainingGroundCount: engine.groundItems.count,
invboxFallbackCount,
inventoryItems,
statusText: document.querySelector('#status')?.textContent ?? '',
};
})()`)
console.log(` Gold pickup: +${pickupMetrics.goldDelta} (Total: ${pickupMetrics.postGold})`)
console.log(` Equipment pickup: +${pickupMetrics.bagDelta} item in bag`)
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')
// =========================================================================
@ -540,9 +674,9 @@ export async function runGroundCombatBrowserAudit(options?: {
await takeScreenshot('05-inventory-full-refusal')
// =========================================================================
// STAGE 8: Zero Error Invariant Check
// STAGE 8: Zero Error Invariant Check & 500-Kill Statistical Drop Assertions
// =========================================================================
console.log('\n[STAGE 8] Asserting zero runtime errors and network invariants...')
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)
@ -550,28 +684,93 @@ export async function runGroundCombatBrowserAudit(options?: {
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.groundCount >= 2 &&
combatResult.killsDelta >= 1 &&
combatResult.dropsDelta >= 1 &&
combatResult.newGroundCount >= 5 &&
bounceMetrics.hMid > 20 &&
altMetrics.overlapCount === 0 &&
altMetrics.labelCount >= 5 &&
pickupMetrics.remainingGroundCount === 0 &&
refusalMetrics.postRefusals > refusalMetrics.preRefusals
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.kills >= 1,
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 >= 24,
stage5_altLabelsAndLadder: altMetrics.overlapCount === 0,
stage6_itemAndGoldPickup: pickupMetrics.remainingGroundCount === 0,
stage7_negativeControlFullBag: refusalMetrics.postRefusals > refusalMetrics.preRefusals,
stage8_zeroErrors: consoleErrors.length === 0 && webGlErrors.length === 0 && mpqRequests.length === 0,
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,
@ -582,6 +781,9 @@ export async function runGroundCombatBrowserAudit(options?: {
goldPickedUp: pickupMetrics.goldDelta,
equipmentPickedUp: pickupMetrics.bagDelta,
refusalsHandled: refusalMetrics.postRefusals,
sampleDrops500: statTotalDrops,
sampleGold500: statGoldCount,
samplePotions500: statPotionCount,
},
consoleErrors,
webGlErrors,
@ -598,7 +800,8 @@ export async function runGroundCombatBrowserAudit(options?: {
// Standalone execution entrypoint
if (import.meta.url === `file://${process.argv[1]}`) {
const outputArg = process.argv.find(arg => arg.startsWith('--out='))?.split('=')[1]
runGroundCombatBrowserAudit({ outputDir: outputArg })
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'}`)

View File

@ -1,5 +1,5 @@
import { tickCombat, createWorld, spawnMonsters, spawnMonsterPacks, damageMonster, rebindPlayer } from './combat.ts'
import type { CombatWorld, CombatOptions, MonsterPack, MonsterStats, SafeZone } from './combat.ts'
import type { CombatWorld, CombatOptions, MonsterPack, MonsterStats, SafeZone, CombatPlayer } from './combat.ts'
import { Inventory, rollDrop, goldItem, totalStat } from './items.ts'
import type { Item, ItemBase, Affix } from './items.ts'
import { castSkill, tickProjectiles, CANONICAL_113C_MISSILES, ISO_GROUND_ASPECT_RATIO, createActiveOverlay, getSkillCastOverlay, isAuraSkill, getAuraOverlayName, getAuraOverlayNames } from './skills.ts'
@ -1229,6 +1229,13 @@ export class GameEngine {
return entity
}
/**
* Apply damage to a monster in the engine's world.
*/
damageMonster(index: number, amount: number, attacker?: CombatPlayer): boolean {
return damageMonster(this.world, index, amount, attacker ?? this.world.player)
}
/**
* Drop gold onto the ground at target coordinates.
*/

View File

@ -6161,6 +6161,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
if (typeof window !== 'undefined') {
;(window as any).__d2webCamera = camera
;(window as any).__d2webEngine = engine
;(window as any).__d2webDamageMonster = damageMonster
}
reloadMonsters = async (options?: { density?: number | string; elite?: number | string }): Promise<void> => {