diablo2-web/tests/challenger-m4-adversarial.t...

506 lines
20 KiB
TypeScript

/**
* Challenger M4-1 — Adversarial & Boundary Stress Verification Suite
* Covers:
* - Issue #423: Flippy DC6 Coverage (all 506 base items + gold tiers), Fail-Fast Throw, Frozen Dying Monster Animation
* - Issue #424: Ground Label Color Cartesian Permutations & UI Pickup -> Re-Drop Round-Trip
* - Issue #425: 64px Sub-Tile Edge Pickup Boundary Fuzzing & 2-Pass Auto-Belt Quick-Slotting Stress
* - Issue #427: Multiplayer Lockstep dwInitSeed Determinism & Hash Parity
*/
import { describe, it, expect, vi } from 'vitest'
import { drawGroundItem, GROUND_ITEM_QUALITY_COLORS } from '../src/scene/act-scene.ts'
import { ActorAnimator } from '../src/game/actor-animator.ts'
import {
GROUND_LABEL_QUALITY_COLORS,
getGroundItemQualityColor,
isGroundItemEthereal,
isGroundItemSocketed,
isGroundItemEtherealOrSocketed,
computeInitialGroundLabelLayouts,
} from '../src/ui/ground-labels.ts'
import {
BeltHud,
BELT_COLS,
BELT_ROWS,
BELT_POTION_CATALOG,
areBeltPotionsCompatible,
isAutoBeltablePotion,
itemToBeltPotion,
} from '../src/ui/belt.ts'
import { resolveGroundItemSpriteRect, itemToUiInventoryItem } from '../src/ui/inventory.ts'
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
import {
GroundItemManager,
PLAYER_COLLISION_EXTENT_PX,
ITEM_COLLISION_EXTENT_PX,
D2_PICKUP_SUBTILE_REACH_PX,
getPickupEdgeDistance,
isWithinPickupBounds,
type GroundItemEntity,
} from '../src/game/ground-items.ts'
import { GameEngine } from '../src/game/engine.ts'
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/game/demo-data.ts'
import type { SpriteRenderer } from '../src/render/renderer.ts'
function createAdversarialEngine(pickupRadius = 64, belt?: BeltHud) {
const terrain = { widthPx: 2000, heightPx: 2000, overlap: () => 0 }
return new GameEngine(terrain, {
spawn: { x: 500, y: 500 },
stats: [],
xpTable: DEMO_EXPERIENCE,
skills: DEMO_SKILLS,
questDefs: DEMO_QUESTS,
combatOptions: {
playerSpeed: 4,
playerReach: 50,
playerCooldownTicks: 10,
playerDamage: 50,
playerManaPerAttack: 1,
respawnTicks: 100,
},
talkRadius: 48,
pickupRadius,
inventoryCols: 10,
inventoryRows: 4,
lootSeed: 0x13572468,
npcDefs: [],
dropTables: getEmbeddedDropTables(),
belt,
})
}
describe('Challenger M4-1 — Adversarial & Boundary Stress Verification', () => {
// ============================================================================
// 1. Issue #423: Flippy DC6 Coverage, Fail-Fast Throw & Frozen Death Anim
// ============================================================================
describe('Issue #423 — Exhaustive Flippy DC6 Coverage & Fail-Fast Rendering', () => {
it('exhaustively resolves and renders every weapon, armor, and misc base item plus all gold tiers without procedural color boxes', () => {
const tables = getEmbeddedDropTables()
const allBases = [
...tables.weapons.all,
...tables.armor.all,
...tables.misc.all,
]
expect(allBases.length).toBe(659)
const mgr = new GroundItemManager()
for (const base of allBases) {
const sr = resolveGroundItemSpriteRect(
{ code: base.code, name: base.name, flippyFile: (base as any).flippyfile, invFile: (base as any).invfile },
BAKED_UI_MANIFEST.flippyRects,
BAKED_UI_MANIFEST.codeToFlippyFile,
)
expect(sr, `Missing flippy rect for ${base.code} (${base.name})`).not.toBeNull()
expect(sr!.w).toBeGreaterThan(0)
expect(sr!.h).toBeGreaterThan(0)
const quads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
const sprites: { frame: any; x: number; y: number; options: any }[] = []
const renderer = {
drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) {
quads.push({ x, y, w, h, color })
},
draw(frame: any, x: number, y: number, options: any) {
sprites.push({ frame, x, y, options })
},
} as unknown as SpriteRenderer
const entity = mgr.add(
{ code: base.code, name: base.name, flippyFile: (base as any).flippyfile, invFile: (base as any).invfile },
0,
0,
300,
300,
)
drawGroundItem(renderer, entity, 1000, undefined)
expect(sprites.length).toBe(1)
expect(sprites[0]!.frame.width).toBeGreaterThan(0)
expect(sprites[0]!.frame.height).toBeGreaterThan(0)
// Outside sparkle window (t=1000, cellX=0, cellY=0), only 1 ground shadow quad is drawn
expect(quads.length).toBe(1)
expect(quads[0]!.color[0]).toBe(0)
expect(quads[0]!.color[1]).toBe(0)
expect(quads[0]!.color[2]).toBe(0)
}
// Gold boundary tiers: 1, 49 (small: 24x7), 50, 499 (medium: 26x13), 500, 10000 (large: 32x20)
const goldCases = [
{ amount: 1, w: 24, h: 7 },
{ amount: 49, w: 24, h: 7 },
{ amount: 50, w: 26, h: 13 },
{ amount: 499, w: 26, h: 13 },
{ amount: 500, w: 32, h: 20 },
{ amount: 10000, w: 32, h: 20 },
]
for (const gc of goldCases) {
const sr = resolveGroundItemSpriteRect(
{ isGold: true, amount: gc.amount, code: 'gld' },
BAKED_UI_MANIFEST.flippyRects,
BAKED_UI_MANIFEST.codeToFlippyFile,
)
expect(sr).not.toBeNull()
expect(sr!.w).toBe(gc.w)
expect(sr!.h).toBe(gc.h)
const sprites: { frame: any; x: number; y: number; options: any }[] = []
const renderer = {
drawSolid() {},
draw(frame: any, x: number, y: number, options: any) {
sprites.push({ frame, x, y, options })
},
} as unknown as SpriteRenderer
const goldEntity = mgr.add({ isGold: true, amount: gc.amount, code: 'gld' }, 0, 0, 200, 200, { amount: gc.amount })
drawGroundItem(renderer, goldEntity, 1000, undefined)
expect(sprites.length).toBe(1)
expect(sprites[0]!.options.width).toBe(gc.w)
expect(sprites[0]!.options.height).toBe(gc.h)
}
})
it('fails fast with an explicit Error in drawGroundItem when flippy sprite rect resolves to null', () => {
// Temporarily empty flippyRects and itemRects to simulate a corrupt unmapped item with null sprite rect
const savedFlippy = { ...BAKED_UI_MANIFEST.flippyRects }
const savedItems = { ...BAKED_UI_MANIFEST.itemRects }
try {
for (const k of Object.keys(BAKED_UI_MANIFEST.flippyRects)) {
delete (BAKED_UI_MANIFEST.flippyRects as any)[k]
}
for (const k of Object.keys(BAKED_UI_MANIFEST.itemRects)) {
delete (BAKED_UI_MANIFEST.itemRects as any)[k]
}
const renderer = {
drawSolid() {},
draw() {},
} as unknown as SpriteRenderer
const corruptEntity: GroundItemEntity = {
id: 'corrupt_999',
item: { code: 'zzz', name: 'Corrupt Unknown Item' },
name: 'Corrupt Unknown Item',
nameZh: '损坏未知物品',
quality: 'normal',
isGold: false,
amount: 1,
invWidth: 9,
invHeight: 9,
dropTime: 0,
x: 100,
y: 100,
cellX: 0,
cellY: 0,
sparklePhase: 0,
}
expect(() => drawGroundItem(renderer, corruptEntity, 1000, undefined)).toThrow(
/GroundItem render failure: missing flippy sprite rect/,
)
} finally {
Object.assign(BAKED_UI_MANIFEST.flippyRects, savedFlippy)
Object.assign(BAKED_UI_MANIFEST.itemRects, savedItems)
}
})
it('advances death animation (DT -> DD) even when monster has frozenTicks > 0', () => {
const animator = new ActorAnimator()
const dtClip = { group: 0, frames: 4, directions: 8, speed: 256, loop: false, events: [] }
const ddClip = { group: 8, frames: 1, directions: 8, speed: 256, loop: true, events: [] }
const monster = {
state: 'dead' as const,
animMode: 'dt' as 'dt' | 'dd',
frozenTicks: 100,
deathGated: true,
dropRolled: false,
}
animator.play('dt', dtClip)
let dropTriggered = false
for (let step = 0; step < 10; step++) {
if (monster.state === 'dead' || monster.animMode === 'dt') {
if (animator.mode === 'dt' && animator.finished) {
animator.play('dd', ddClip)
monster.animMode = 'dd'
dropTriggered = true
}
}
// Exact condition from src/scene/act-scene.ts:7236
if (monster.state === 'dead' || monster.animMode === 'dt' || !monster.frozenTicks || monster.frozenTicks <= 0) {
animator.tick()
}
}
expect(dropTriggered).toBe(true)
expect(monster.animMode).toBe('dd')
expect(animator.mode).toBe('dd')
})
})
// ============================================================================
// 2. Issue #424: Ground Label Color Permutations & UI Round-Trip
// ============================================================================
describe('Issue #424 — Ground Label Color Full Cartesian Permutations & UI Round-Trip', () => {
it('fuzzes all combinations of quality, ethereal, sockets, isRuneword, and isGold across getGroundItemQualityColor and computeInitialGroundLabelLayouts', () => {
const qualities = ['low', 'normal', 'superior', 'magic', 'rare', 'set', 'unique', 'craft', 'rune'] as const
const ethereals = [false, true] as const
const socketCounts = [0, 1, 6] as const
const runewords = [false, true] as const
const goldFlags = [false, true] as const
const mgr = new GroundItemManager()
let totalPermutations = 0
for (const quality of qualities) {
for (const ethereal of ethereals) {
for (const sockets of socketCounts) {
for (const isRuneword of runewords) {
for (const isGold of goldFlags) {
totalPermutations += 1
const entity: GroundItemEntity = {
id: `perm_${totalPermutations}`,
item: {
code: isGold ? 'gld' : 'bsd',
name: isGold ? '100 Gold' : 'Broad Sword',
quality,
ethereal,
sockets,
isRuneword,
isGold,
},
name: isGold ? '100 Gold' : 'Broad Sword',
nameZh: isGold ? '100 金币' : '阔剑',
quality,
isGold,
amount: isGold ? 100 : 1,
invWidth: 2,
invHeight: 3,
dropTime: 0,
x: 200,
y: 200,
cellX: 5,
cellY: 5,
sparklePhase: 0,
...(ethereal ? { ethereal: true } : {}),
...(sockets > 0 ? { sockets } : {}),
}
// Independent 1.13c ground truth oracle
let expectedColor: string
if (isGold) {
expectedColor = GROUND_LABEL_QUALITY_COLORS.gold!
} else if (quality === 'low') {
expectedColor = GROUND_LABEL_QUALITY_COLORS.low!
} else if (isRuneword) {
expectedColor = GROUND_LABEL_QUALITY_COLORS.unique!
} else if ((quality === 'normal' || quality === 'superior') && (ethereal || sockets > 0)) {
expectedColor = '#808080'
} else {
expectedColor = GROUND_LABEL_QUALITY_COLORS[quality]!
}
const actualColor = getGroundItemQualityColor(entity)
expect(
actualColor,
`Mismatch for quality=${quality}, eth=${ethereal}, sock=${sockets}, rw=${isRuneword}, gold=${isGold}`,
).toBe(expectedColor)
const layouts = computeInitialGroundLabelLayouts([entity], (wx, wy) => ({ x: wx, y: wy }))
expect(layouts.length).toBe(1)
expect(layouts[0]!.color).toBe(expectedColor)
expect(layouts[0]!.borderColor).toBe(expectedColor)
}
}
}
}
}
// 9 * 2 * 3 * 2 * 2 = 216 exhaustive permutations verified
expect(totalPermutations).toBe(216)
expect(mgr.count).toBe(0)
})
it('preserves ethereal, sockets, and #808080 ground label color across ground -> UI inventory -> ground re-drop round-trip', () => {
const tables = getEmbeddedDropTables()
const mgr = new GroundItemManager()
// 1. Spawn ethereal socketed normal item on ground
const droppedEthSocketed = mgr.add(
{
id: 'roundtrip_monarch',
code: 'uit',
name: 'Monarch',
quality: 'normal',
ethereal: true,
sockets: 4,
invWidth: 2,
invHeight: 3,
},
10,
10,
320,
320,
)
expect(isGroundItemEthereal(droppedEthSocketed)).toBe(true)
expect(isGroundItemSocketed(droppedEthSocketed)).toBe(true)
expect(getGroundItemQualityColor(droppedEthSocketed)).toBe('#808080')
// 2. Pick up into UI inventory via itemToUiInventoryItem
const uiItem = itemToUiInventoryItem(droppedEthSocketed.item, tables)
expect(uiItem.ethereal).toBe(true)
expect(uiItem.sockets).toBe(4)
// 3. Re-drop UI inventory item back onto ground
const redropped = mgr.add(uiItem, 11, 11, 340, 340)
expect(redropped.ethereal).toBe(true)
expect(redropped.sockets).toBe(4)
expect(isGroundItemEthereal(redropped)).toBe(true)
expect(isGroundItemSocketed(redropped)).toBe(true)
expect(getGroundItemQualityColor(redropped)).toBe('#808080')
const layouts = computeInitialGroundLabelLayouts([redropped], (wx, wy) => ({ x: wx, y: wy }))
expect(layouts[0]!.color).toBe('#808080')
})
})
// ============================================================================
// 3. Issue #425: Pickup Bounds Fuzzing & 2-Pass Auto-Belt Stress
// ============================================================================
describe('Issue #425 — 64px Edge-to-Edge Pickup Boundary Fuzzing & 2-Pass Auto-Belt Stress', () => {
it('accurately discriminates boundary distances around edgeDist = 63.99, 64.0, 64.01 (centerDist = 95.99, 96.0, 96.01) across 360 angles', () => {
expect(PLAYER_COLLISION_EXTENT_PX + ITEM_COLLISION_EXTENT_PX).toBe(32)
expect(D2_PICKUP_SUBTILE_REACH_PX).toBe(64)
// Exact axis-aligned boundary checks
expect(getPickupEdgeDistance(100, 100, 195.99, 100)).toBeCloseTo(63.99, 5)
expect(isWithinPickupBounds(100, 100, 195.99, 100, 64)).toBe(true)
expect(getPickupEdgeDistance(100, 100, 196.0, 100)).toBeCloseTo(64.0, 5)
expect(isWithinPickupBounds(100, 100, 196.0, 100, 64)).toBe(true)
expect(getPickupEdgeDistance(100, 100, 196.01, 100)).toBeCloseTo(64.01, 5)
expect(isWithinPickupBounds(100, 100, 196.01, 100, 64)).toBe(false)
// Overlapping boxes clamp edge distance to 0
expect(getPickupEdgeDistance(100, 100, 110, 100)).toBe(0)
expect(isWithinPickupBounds(100, 100, 110, 100, 64)).toBe(true)
// Radial fuzzing across 360 degrees
const px = 500
const py = 500
for (let deg = 0; deg < 360; deg += 5) {
const rad = (deg * Math.PI) / 180
const inX = px + Math.cos(rad) * 95.99
const inY = py + Math.sin(rad) * 95.99
expect(isWithinPickupBounds(px, py, inX, inY, 64)).toBe(true)
const outX = px + Math.cos(rad) * 96.02
const outY = py + Math.sin(rad) * 96.02
expect(isWithinPickupBounds(px, py, outX, outY, 64)).toBe(false)
}
})
it('stress-tests 2-pass BeltHud.autoPlacePotion across all 15 potion codes, mixed-tier stacking, utility potion isolation, non-beltable rejection, partially drained columns, and full-belt fallback', () => {
const allPotionCodes = [
'hp1', 'hp2', 'hp3', 'hp4', 'hp5',
'mp1', 'mp2', 'mp3', 'mp4', 'mp5',
'rvs', 'rvl',
'vps', 'yps', 'wms',
]
expect(allPotionCodes.length).toBe(15)
for (const code of allPotionCodes) {
expect(BELT_POTION_CATALOG[code]).toBeDefined()
expect(isAutoBeltablePotion({ code })).toBe(true)
}
// Reject non-beltable scrolls, tomes, weapons, and throwing potions
for (const nonBeltCode of ['tsc', 'isc', 'tbk', 'ibk', 'gpm', 'opm', 'gpl', 'opl', 'gps', 'ops', 'ssd', 'cap']) {
expect(isAutoBeltablePotion({ code: nonBeltCode })).toBe(false)
}
const belt = new BeltHud()
belt.clear()
// Seed base row (row 0): col 0 = hp1, col 1 = mp1, col 2 = rvs, col 3 = vps (stamina)
expect(belt.autoPlacePotionSlot({ code: 'hp1', id: 'c0-r0' })).toEqual(
expect.objectContaining({ row: 0, col: 0 }),
)
expect(belt.autoPlacePotionSlot({ code: 'mp1', id: 'c1-r0' })).toEqual(
expect.objectContaining({ row: 0, col: 1 }),
)
expect(belt.autoPlacePotionSlot({ code: 'rvs', id: 'c2-r0' })).toEqual(
expect.objectContaining({ row: 0, col: 2 }),
)
expect(belt.autoPlacePotionSlot({ code: 'vps', id: 'c3-r0' })).toEqual(
expect.objectContaining({ row: 0, col: 3 }),
)
// Mixed-tier stacking: hp2, hp4, hp5 stack into col 0 (rows 1, 2, 3)
expect(belt.autoPlacePotionSlot({ code: 'hp2', id: 'c0-r1' })).toEqual(
expect.objectContaining({ row: 1, col: 0 }),
)
expect(belt.autoPlacePotionSlot({ code: 'hp4', id: 'c0-r2' })).toEqual(
expect.objectContaining({ row: 2, col: 0 }),
)
expect(belt.autoPlacePotionSlot({ code: 'hp5', id: 'c0-r3' })).toEqual(
expect.objectContaining({ row: 3, col: 0 }),
)
// Mixed-tier mana: mp3, mp5 stack into col 1 (rows 1, 2)
expect(belt.autoPlacePotionSlot({ code: 'mp3', id: 'c1-r1' })).toEqual(
expect.objectContaining({ row: 1, col: 1 }),
)
expect(belt.autoPlacePotionSlot({ code: 'mp5', id: 'c1-r2' })).toEqual(
expect.objectContaining({ row: 2, col: 1 }),
)
// Mixed-tier rejuv: rvl stacks onto rvs in col 2 (row 1)
expect(belt.autoPlacePotionSlot({ code: 'rvl', id: 'c2-r1' })).toEqual(
expect.objectContaining({ row: 1, col: 2 }),
)
// Utility potion isolation: yps (antidote) and wms (thawing) do NOT stack onto vps (stamina) in col 3,
// and because all 4 base columns (row 0) are occupied, they return null (fallback to inventory)!
expect(belt.autoPlacePotionSlot({ code: 'yps', id: 'anti-1' })).toBeNull()
expect(belt.autoPlacePotionSlot({ code: 'wms', id: 'thaw-1' })).toBeNull()
// Same utility potion (vps) DOES stack into col 3 (row 1)
expect(belt.autoPlacePotionSlot({ code: 'vps', id: 'c3-r1' })).toEqual(
expect.objectContaining({ row: 1, col: 3 }),
)
// Partially drained column: drink bottom potion in col 0 (shifts rows 1..3 down to 0..2, leaving row 3 empty)
const consumed = belt.useSlot(0)
expect(consumed?.code).toBe('hp1')
expect(belt.grid[0]![0]?.code).toBe('hp2')
expect(belt.grid[3]![0]).toBeNull()
// Next hp3 refills row 3 of col 0
expect(belt.autoPlacePotionSlot({ code: 'hp3', id: 'c0-refill' })).toEqual(
expect.objectContaining({ row: 3, col: 0 }),
)
// Fill remaining slots in cols 1, 2, 3 and verify full belt fallback to engine.bag
expect(belt.autoPlacePotion({ code: 'mp2' })).toBe(true) // col 1 row 3
expect(belt.autoPlacePotion({ code: 'rvs' })).toBe(true) // col 2 row 2
expect(belt.autoPlacePotion({ code: 'rvl' })).toBe(true) // col 2 row 3
expect(belt.autoPlacePotion({ code: 'vps' })).toBe(true) // col 3 row 2
expect(belt.autoPlacePotion({ code: 'vps' })).toBe(true) // col 3 row 3
expect(belt.countTotalPotions()).toBe(BELT_COLS * BELT_ROWS)
const engine = createAdversarialEngine(64, belt)
const overflowGrounded = engine.dropItem(
{ id: 'overflow_hp5', code: 'hp5', name: 'Super Healing Potion', invWidth: 1, invHeight: 1 },
500,
500,
)
const pickupRes = engine.pickupItem(overflowGrounded.id)
expect(pickupRes.success).toBe(true)
expect(pickupRes.destination).toBe('inventory')
expect(engine.bag.contents.length).toBe(1)
})
})
})