diff --git a/scripts/verify-ground-combat-browser.ts b/scripts/verify-ground-combat-browser.ts index 12bf26f..74d90f2 100644 --- a/scripts/verify-ground-combat-browser.ts +++ b/scripts/verify-ground-combat-browser.ts @@ -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(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 { 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(`window.__d2webAct?.error ?? null`) + if (actError) { + throw new Error(`ActScene initialization failed with error: ${actError}`) + } ready = await evalJs(`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(`(() => { 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(`(() => { 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(`(() => { 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(`(() => { 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(`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'}`) diff --git a/src/game/engine.ts b/src/game/engine.ts index 0cbe724..0810f53 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -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. */ diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index c48b5c7..5934cfc 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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 => {