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

580 lines
22 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import {
getInventoryGoldLimit,
getGoldFlippyRect,
resolveGroundItemSpriteRect,
} from '../src/ui/inventory.ts'
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
import { goldItem } from '../src/game/items.ts'
import { GameEngine } from '../src/game/engine.ts'
import type { Monster } from '../src/game/combat.ts'
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
import {
GroundItemManager,
findSafeDropPosition,
getIsometricRingCandidates,
resolveGroundItemMetadata,
triggerFlippyBounce,
} from '../src/game/ground-items.ts'
import {
COLLIDE_NONE,
COLLIDE_WALL,
COLLIDE_BLANK,
COLLIDE_MASK_INVALID,
COLLIDE_WATER,
type CollisionGrid,
} from '../src/game/d2map.ts'
const dropTables = getEmbeddedDropTables()
function createTestEngine(playerLevel = 1, initialGold = 0, overrides?: Record<string, unknown>): GameEngine {
const engine = new GameEngine(
{ widthPx: 2000, heightPx: 2000, overlap: () => 0 },
{
spawn: { x: 500, y: 500 },
stats: [],
xpTable: [0, 500, 1500, 3750, 7875, 14175],
skills: [],
npcDefs: [],
questDefs: [],
combatOptions: {
playerSpeed: 4,
playerReach: 50,
playerCooldownTicks: 10,
playerDamage: 100,
playerManaPerAttack: 1,
respawnTicks: 100,
},
talkRadius: 50,
pickupRadius: 30,
inventoryCols: 10,
inventoryRows: 4,
lootSeed: 1337,
dropTables,
...overrides,
},
)
engine.setDropTables(dropTables)
engine.world.player.level = playerLevel
engine.bag.gold = initialGold
return engine
}
function createTestMonster(
index: number,
id: string,
x: number,
y: number,
hp = 20,
rank: 'normal' | 'champion' | 'unique' | 'minion' | 'boss' = 'normal',
level = 5,
): Monster {
return {
index,
stats: {
id,
name: id,
hp,
damage: 1,
cooldownTicks: 10,
reach: 20,
aggroRadius: 100,
speed: 10,
xp: 10,
level,
rank,
},
x,
y,
hp,
cooldown: 0,
state: 'idle',
facing: 0,
hitFlash: 0,
corpseTicks: 0,
dropRolled: false,
}
}
const emptyInput = {
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
digits: [],
saving: false,
loading: false,
}
describe('Challenger M3-1: Adversarial Fuzzing & Boundary Stress Suite', () => {
describe('1. Gold Capacity Boundaries & Visual Tier Mutation Fuzzing', () => {
it('1.1: fuzzes player.level across all valid levels (1..99) and degenerate boundaries (0, negative, float, NaN, >99)', () => {
// Every valid D2 level 1..99 must strictly equal level * 10,000
for (let lvl = 1; lvl <= 99; lvl++) {
const expected = lvl * 10_000
expect(getInventoryGoldLimit(lvl)).toBe(expected)
const engine = createTestEngine(lvl, 0)
expect(engine.maxGoldCapacity).toBe(expected)
}
// Degenerate lower bounds clamp safely to level 1 (10,000)
expect(getInventoryGoldLimit(0)).toBe(10_000)
expect(getInventoryGoldLimit(-1)).toBe(10_000)
expect(getInventoryGoldLimit(-9999)).toBe(10_000)
expect(getInventoryGoldLimit(Number.NaN)).toBe(10_000)
// Fractional levels floor cleanly before multiplying by 10,000
expect(getInventoryGoldLimit(12.9)).toBe(120_000)
expect(getInventoryGoldLimit(85.1)).toBe(850_000)
// Upper bound clamping at D2 max character level 99 (990,000)
expect(getInventoryGoldLimit(99)).toBe(990_000)
expect(getInventoryGoldLimit(100)).toBe(990_000)
expect(getInventoryGoldLimit(999)).toBe(990_000)
})
it('1.2: tests small, exact, and oversized gold pickups at empty, half-full, 1-below-max, and at-max capacity', () => {
const level = 5 // Max capacity = 50,000
const maxCap = 50_000
// Case A: Empty inventory (0 / 50,000)
{
const engine = createTestEngine(level, 0)
// Small pickup
const pSmall = engine.dropGold(45, 200, 200)!
const rSmall = engine.pickupGold(pSmall.id)
expect(rSmall).toEqual({ success: true, amount: 45, remaining: 0 })
expect(engine.gold).toBe(45)
expect(engine.groundItems.get(pSmall.id)).toBeUndefined()
// Reset to empty, exact max pickup
engine.bag.gold = 0
const pExact = engine.dropGold(maxCap, 200, 200)!
const rExact = engine.pickupGold(pExact.id)
expect(rExact).toEqual({ success: true, amount: maxCap, remaining: 0 })
expect(engine.gold).toBe(maxCap)
expect(engine.groundItems.get(pExact.id)).toBeUndefined()
// Reset to empty, oversized pickup (60,000 -> picks 50,000, leaves 10,000)
engine.bag.gold = 0
const pOver = engine.dropGold(60_000, 200, 200)!
const rOver = engine.pickupGold(pOver.id)
expect(rOver).toEqual({ success: true, amount: maxCap, remaining: 10_000 })
expect(engine.gold).toBe(maxCap)
expect(engine.groundItems.get(pOver.id)?.amount).toBe(10_000)
}
// Case B: Half-full inventory (25,000 / 50,000)
{
const engine = createTestEngine(level, 25_000)
// Small pickup (500)
const pSmall = engine.dropGold(500, 200, 200)!
expect(engine.pickupGold(pSmall.id)).toEqual({ success: true, amount: 500, remaining: 0 })
expect(engine.gold).toBe(25_500)
// Exact remaining pickup (24,500)
const pExact = engine.dropGold(24_500, 200, 200)!
expect(engine.pickupGold(pExact.id)).toEqual({ success: true, amount: 24_500, remaining: 0 })
expect(engine.gold).toBe(maxCap)
// Reset to half-full, oversized pickup (25,000 + 25,049 -> picks 25,000, leaves 49)
engine.bag.gold = 25_000
const pOver = engine.dropGold(25_049, 200, 200)!
expect(engine.pickupGold(pOver.id)).toEqual({ success: true, amount: 25_000, remaining: 49 })
expect(engine.gold).toBe(maxCap)
expect(engine.groundItems.get(pOver.id)?.amount).toBe(49)
}
// Case C: 1 gold below max (49,999 / 50,000)
{
const engine = createTestEngine(level, maxCap - 1)
// Exact 1 gold pickup
const pOne = engine.dropGold(1, 200, 200)!
expect(engine.pickupGold(pOne.id)).toEqual({ success: true, amount: 1, remaining: 0 })
expect(engine.gold).toBe(maxCap)
expect(engine.groundItems.get(pOne.id)).toBeUndefined()
// Reset to 1 below max, oversized 500 gold pile -> picks 1, leaves 499 (shifts Tier 2 -> Tier 1!)
engine.bag.gold = maxCap - 1
const p500 = engine.dropGold(500, 200, 200)!
expect(resolveGroundItemSpriteRect(p500)).toEqual(BAKED_UI_MANIFEST.flippyRects['flpgld_2'])
const r500 = engine.pickupGold(p500.id)
expect(r500).toEqual({ success: true, amount: 1, remaining: 499 })
expect(engine.gold).toBe(maxCap)
expect(resolveGroundItemSpriteRect(p500)).toEqual(BAKED_UI_MANIFEST.flippyRects['flpgld_1'])
}
// Case D: Exactly at max capacity (50,000 / 50,000)
{
const engine = createTestEngine(level, maxCap)
const pAny = engine.dropGold(1, 200, 200)!
const rAny = engine.pickupGold(pAny.id)
expect(rAny).toEqual({ success: false, amount: 0, remaining: 1 })
expect(engine.gold).toBe(maxCap)
expect(engine.groundItems.get(pAny.id)?.amount).toBe(1)
}
})
it('1.3: verifies 3-tier visual transitions across exact tier boundaries (500 -> 499 -> 50 -> 49 -> 1)', () => {
const flippy = BAKED_UI_MANIFEST.flippyRects
const engine = createTestEngine(1, 9500) // 500 space left
// Drop 1000 gold (Tier 2: >= 500)
const pile = engine.dropGold(1000, 200, 200)!
expect(resolveGroundItemSpriteRect(pile)).toEqual(flippy['flpgld_2'])
// Pick up 500 -> 500 left (still Tier 2: exactly 500)
engine.pickupGold(pile.id)
expect(pile.amount).toBe(500)
expect(resolveGroundItemSpriteRect(pile)).toEqual(flippy['flpgld_2'])
// Free 1 gold capacity -> pick up 1 -> 499 left (Tier 1: 50..499)
engine.bag.gold = 9999
engine.pickupGold(pile.id)
expect(pile.amount).toBe(499)
expect(resolveGroundItemSpriteRect(pile)).toEqual(flippy['flpgld_1'])
// Free 449 gold capacity -> pick up 449 -> 50 left (still Tier 1: exactly 50)
engine.bag.gold = 10_000 - 449
engine.pickupGold(pile.id)
expect(pile.amount).toBe(50)
expect(resolveGroundItemSpriteRect(pile)).toEqual(flippy['flpgld_1'])
// Free 1 gold capacity -> pick up 1 -> 49 left (Tier 0: 1..49)
engine.bag.gold = 9999
engine.pickupGold(pile.id)
expect(pile.amount).toBe(49)
expect(resolveGroundItemSpriteRect(pile)).toEqual(flippy['flpgld_0'])
// Free 48 gold capacity -> pick up 48 -> 1 left (still Tier 0: 1)
engine.bag.gold = 10_000 - 48
engine.pickupGold(pile.id)
expect(pile.amount).toBe(1)
expect(resolveGroundItemSpriteRect(pile)).toEqual(flippy['flpgld_0'])
// Free 1 gold capacity -> pick up final 1 -> despawned
engine.bag.gold = 9999
const finalRes = engine.pickupGold(pile.id)
expect(finalRes).toEqual({ success: true, amount: 1, remaining: 0 })
expect(engine.groundItems.get(pile.id)).toBeUndefined()
expect(engine.ground.length).toBe(0)
})
it('1.4: walkover auto-pickup stops mid-loop when multiple piles exceed capacity', () => {
const engine = createTestEngine(1, 9900, { pickupRadius: 150 }) // 100 capacity remaining, reach adjacent scattered tiles
const p1 = engine.dropGold(60, 500, 500)!
const p2 = engine.dropGold(80, 500, 500)!
const p3 = engine.dropGold(200, 500, 500)!
engine.tick(emptyInput)
// Player must reach exactly 10,000 gold
expect(engine.gold).toBe(10_000)
// p1 (60) was fully picked up; p2 (80) had 40 picked up leaving 40; p3 (200) untouched
expect(engine.groundItems.get(p1.id)).toBeUndefined()
expect(engine.groundItems.get(p2.id)?.amount).toBe(40)
expect(engine.groundItems.get(p3.id)?.amount).toBe(200)
expect(engine.groundItems.count).toBe(2)
})
})
describe('2. Death Animation Gating Fuzzing', () => {
it('2.1: handles 0-frame death animation (deathTicks = 0, deathGated = false) by dropping immediately', () => {
const engine = createTestEngine()
const monster = createTestMonster(0, 'zombie1', 520, 500, 20)
monster.deathTicks = 0
monster.deathGated = false
engine.world.monsters.push(monster)
engine.damageMonster(0, 999)
engine.tick(emptyInput)
expect(monster.dropRolled).toBe(true)
expect(monster.animMode).toBe('dd')
expect(monster.pendingDrop).toBeUndefined()
expect(engine.metrics.dropsRolled).toBe(1)
})
it('2.2: handles long-death animation (50 ticks) with zero premature spawns', () => {
const engine = createTestEngine()
const monster = createTestMonster(0, 'fallen1', 520, 500, 20)
monster.deathTicks = 50
engine.world.monsters.push(monster)
engine.damageMonster(0, 999)
for (let t = 1; t < 50; t++) {
engine.tick(emptyInput)
expect(monster.dropRolled).toBe(false)
expect(monster.animMode).toBe('dt')
expect(monster.pendingDrop).toBeDefined()
expect(engine.groundItems.count).toBe(0)
}
// 50th tick completes DT -> transitions to DD and rolls drop
engine.tick(emptyInput)
expect(monster.deathTicks).toBe(0)
expect(monster.dropRolled).toBe(true)
expect(monster.animMode).toBe('dd')
expect(monster.pendingDrop).toBeUndefined()
expect(engine.metrics.dropsRolled).toBe(1)
expect(engine.groundItems.count).toBeGreaterThanOrEqual(1)
})
it('2.3: stress-tests 20 simultaneous monster deaths with heterogeneous death durations and zero memory leaks', () => {
const engine = createTestEngine(1, 0, { pickupRadius: 0 }) // disable auto-pickup so all gold stays on ground
const monsterCount = 20
for (let i = 0; i < monsterCount; i++) {
const m = createTestMonster(i, 'skeleton1', 600 + (i % 5) * 40, 600 + Math.floor(i / 5) * 40, 10, 'unique', 10)
// Mix of instant (undefined), 0-tick, 1-tick, 5-tick, 12-tick, and explicit deathGated
if (i % 5 === 0) {
m.deathTicks = undefined
} else if (i % 5 === 1) {
m.deathTicks = 0
} else if (i % 5 === 2) {
m.deathTicks = 1
} else if (i % 5 === 3) {
m.deathTicks = 5
} else {
m.deathGated = true
}
engine.world.monsters.push(m)
}
// Kill all 20 monsters in the same frame
for (let i = 0; i < monsterCount; i++) {
expect(engine.damageMonster(i, 999)).toBe(true)
}
// Tick 1: instant (i%5===0), 0-tick (i%5===1), and 1-tick (i%5===2) monsters finish (12 monsters)
engine.tick(emptyInput)
expect(engine.metrics.dropsRolled).toBe(12)
// Ticks 2..4: 5-tick monsters still in DT
for (let t = 2; t <= 4; t++) {
engine.tick(emptyInput)
expect(engine.metrics.dropsRolled).toBe(12)
}
// Tick 5: 5-tick monsters (i%5===3) finish (16 monsters total)
engine.tick(emptyInput)
expect(engine.metrics.dropsRolled).toBe(16)
// Explicitly gated monsters (i%5===4) must still be waiting in DT
const gatedMonsters = engine.world.monsters.filter((_, i) => i % 5 === 4)
for (const gm of gatedMonsters) {
expect(gm.dropRolled).toBe(false)
expect(gm.pendingDrop).toBeDefined()
engine.triggerMonsterDrop(gm)
}
// All 20 monsters must have completed drops with zero retained pendingDrop closures
expect(engine.metrics.dropsRolled).toBe(20)
for (const m of engine.world.monsters) {
expect(m.dropRolled).toBe(true)
expect(m.animMode).toBe('dd')
expect(m.pendingDrop).toBeUndefined()
}
})
})
describe('3. Drop SFX & Bounce Audio Fuzzing', () => {
it('3.1: handles undefined dropsound, empty string dropsound, and non-standard dropsfxframe (0, negative, huge, NaN)', () => {
const manager = new GroundItemManager()
const startTime = 1000
const played: string[] = []
const audioMock = {
playSfx: vi.fn((s: string) => played.push(s)),
}
// 1. Undefined dropsound -> falls back to 'item_drop'
const eUndefined = manager.add({ name: 'Mystery' }, 0, 0, 0, 0, { bounce: true, now: startTime, durationMs: 400 })
;(eUndefined as any).dropSound = undefined
// 2. Frame 0 -> triggers immediately at startTime
const eFrame0 = manager.add(
{ name: 'ZeroFrame', base: { dropsound: 'sfx_zero', dropsfxframe: 0 } },
1, 0, 16, 0,
{ bounce: true, now: startTime, durationMs: 400 },
)
;(eFrame0 as any).dropSfxFrame = 0
// 3. Negative frame (-10) -> triggers immediately at startTime
const eNeg = manager.add(
{ name: 'NegFrame', base: { dropsound: 'sfx_neg', dropsfxframe: -10 } },
2, 0, 32, 0,
{ bounce: true, now: startTime, durationMs: 400 },
)
// 4. Huge frame (999) -> triggers on landing at startTime + 400
const eHuge = manager.add(
{ name: 'HugeFrame', base: { dropsound: 'sfx_huge', dropsfxframe: 999 } },
3, 0, 48, 0,
{ bounce: true, now: startTime, durationMs: 400 },
)
// 5. NaN frame -> triggers on landing at startTime + 400
const eNaN = manager.add(
{ name: 'NaNFrame', base: { dropsound: 'sfx_nan', dropsfxframe: Number.NaN } },
4, 0, 64, 0,
{ bounce: true, now: startTime, durationMs: 400 },
)
// Tick at elapsed = 0ms: frame 0 and negative frame trigger immediately
manager.updateBounces(startTime, audioMock)
expect(eFrame0.sfxPlayed).toBe(true)
expect(eNeg.sfxPlayed).toBe(true)
expect(eUndefined.sfxPlayed).toBe(false)
expect(eHuge.sfxPlayed).toBe(false)
expect(eNaN.sfxPlayed).toBe(false)
// Rapid 1ms bounce ticks from startTime + 1 to startTime + 500
for (let t = 1; t <= 500; t += 5) {
manager.updateBounces(startTime + t, audioMock)
}
// Every single item must have played its SFX exactly once
expect(eUndefined.sfxPlayed).toBe(true)
expect(eHuge.sfxPlayed).toBe(true)
expect(eNaN.sfxPlayed).toBe(true)
expect(audioMock.playSfx).toHaveBeenCalledTimes(5)
expect(played).toContain('item_drop')
expect(played).toContain('sfx_zero')
expect(played).toContain('sfx_neg')
expect(played).toContain('sfx_huge')
expect(played).toContain('sfx_nan')
})
it('3.2: survives broken audioManager objects and re-triggers SFX on partial gold pickup bounce', () => {
const manager = new GroundItemManager()
const startTime = 2000
const goldEntity = manager.add(
{ isGold: true, amount: 500, code: 'gld' },
5, 5, 100, 100,
{ bounce: true, now: startTime, durationMs: 400 },
)
// Broken audioManager without playSfx function
expect(() => manager.updateBounces(startTime + 480, {} as any)).not.toThrow()
expect(goldEntity.sfxPlayed).toBe(true)
// Trigger flippy bounce again (as happens during partial gold pickup)
triggerFlippyBounce(goldEntity, startTime + 500, { durationMs: 350 })
expect(goldEntity.sfxPlayed).toBe(false)
const validAudio = { playSfx: vi.fn() }
manager.updateBounces(startTime + 500 + 350, validAudio)
expect(validAudio.playSfx).toHaveBeenCalledTimes(1)
expect(validAudio.playSfx).toHaveBeenCalledWith('item_gold')
})
})
describe('4. Collision Grid Quadrant Spiral Stress', () => {
it('4.1: 1-cell wide zigzag corridor surrounded by walls & void: 10 items drop with 0 blocked placements', () => {
const width = 15
const height = 15
const masks = new Uint16Array(width * height).fill(COLLIDE_WALL | COLLIDE_BLANK)
// Carve a narrow 1-cell wide L-shaped corridor through (7, 4..10) and (4..10, 7)
for (let i = 4; i <= 10; i++) {
masks[7 * width + i] = COLLIDE_NONE
masks[i * width + 7] = COLLIDE_NONE
}
const grid = { width, height, collisionMasks: masks }
const occupied: { cellX: number; cellY: number }[] = []
for (let i = 0; i < 10; i++) {
const pos = findSafeDropPosition(grid, 7, 7, 4, occupied)
occupied.push(pos)
const cellMask = masks[pos.cellY * width + pos.cellX]
expect(cellMask).toBe(COLLIDE_NONE)
}
// All 10 items must occupy distinct walkable cells along the cross corridor
const uniqueCells = new Set(occupied.map(p => `${p.cellX},${p.cellY}`))
expect(uniqueCells.size).toBe(10)
})
it('4.2: single-cell walkable island surrounded by COLLIDE_WALL / COLLIDE_MASK_INVALID: 20 burst drops never escape into walls', () => {
const cellsX = 9
const cellsY = 9
const gridWidth = cellsX * 5
const blocked = new Uint8Array(gridWidth * cellsY * 5).fill(1)
const collisionMasks = new Uint16Array(gridWidth * cellsY * 5).fill(COLLIDE_WALL | COLLIDE_MASK_INVALID)
// Only cell (4, 4) center sub-tile is walkable floor
const walkableSubIdx = (4 * 5 + 2) * gridWidth + (4 * 5 + 2)
blocked[walkableSubIdx] = 0
collisionMasks[walkableSubIdx] = COLLIDE_NONE
const collisionGrid: CollisionGrid = {
cellsX,
cellsY,
gridWidth,
originX: 0,
originY: 0,
blocked,
collisionMasks,
}
const occupied: { cellX: number; cellY: number }[] = []
for (let i = 0; i < 20; i++) {
// Test both dropping directly on (4, 4) and dropping from an adjacent wall (3, 4)
const originX = i % 2 === 0 ? 4 : 3
const pos = findSafeDropPosition(collisionGrid, originX, 4, 3, occupied)
occupied.push(pos)
expect(pos.cellX).toBe(4)
expect(pos.cellY).toBe(4)
}
})
it('4.3: massive 25-item burst drop in open room places all 25 items on unique walkable coordinates', () => {
const width = 20
const height = 20
const masks = new Uint16Array(width * height).fill(COLLIDE_NONE)
const grid = { width, height, collisionMasks: masks }
const occupied: { cellX: number; cellY: number }[] = []
for (let i = 0; i < 25; i++) {
const pos = findSafeDropPosition(grid, 10, 10, 4, occupied)
occupied.push(pos)
expect(masks[pos.cellY * width + pos.cellX]).toBe(COLLIDE_NONE)
}
const uniqueCells = new Set(occupied.map(p => `${p.cellX},${p.cellY}`))
expect(uniqueCells.size).toBe(25)
})
it('4.4: 25-item burst drop in constrained 3x3 room fills all 9 walkable cells then safely stacks on walkable floor', () => {
const width = 11
const height = 11
const masks = new Uint16Array(width * height).fill(COLLIDE_WALL)
// 3x3 walkable room at x=4..6, y=4..6
for (let y = 4; y <= 6; y++) {
for (let x = 4; x <= 6; x++) {
masks[y * width + x] = COLLIDE_NONE
}
}
const grid = { width, height, collisionMasks: masks }
const occupied: { cellX: number; cellY: number }[] = []
for (let i = 0; i < 25; i++) {
const pos = findSafeDropPosition(grid, 5, 5, 4, occupied)
occupied.push(pos)
// Every single item must be within [4..6, 4..6] walkable room
expect(pos.cellX).toBeGreaterThanOrEqual(4)
expect(pos.cellX).toBeLessThanOrEqual(6)
expect(pos.cellY).toBeGreaterThanOrEqual(4)
expect(pos.cellY).toBeLessThanOrEqual(6)
expect(masks[pos.cellY * width + pos.cellX]).toBe(COLLIDE_NONE)
}
// All 9 walkable cells must have been utilized before stacking
const uniqueCells = new Set(occupied.map(p => `${p.cellX},${p.cellY}`))
expect(uniqueCells.size).toBe(9)
})
})
})