diablo2-web/tests/combat-kill-lifecycle.test.ts

294 lines
9.4 KiB
TypeScript

import { describe, it, expect, beforeAll } from 'vitest'
import * as fs from 'fs'
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { loadDropTables, type DropTables } from '../src/game/drop-pipeline.ts'
import { GameEngine, type GameEngineOptions } from '../src/game/engine.ts'
import { damageMonster, type Monster } from '../src/game/combat.ts'
import { DEMO_EXPERIENCE, DEMO_NPCS, DEMO_QUESTS } from '../src/game/demo-data.ts'
const hasD2 = fs.existsSync('samples/d2/d2data.mpq')
describe('Combat Kill Event Lifecycle & Single-Drop Gate (Issue #404)', () => {
let dropTables: DropTables
beforeAll(async () => {
if (!hasD2) {
throw new Error('samples/d2 MPQ files required for Combat Kill Lifecycle test')
}
const archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
}
dropTables = await loadDropTables(archives)
})
const dummyTerrain = {
overlap: () => 0,
isMissileBlocked: () => false,
}
const DUMMY_INPUT = {
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
digits: [],
saving: false,
loading: false,
}
function createMonster(index: number, x: number, y: number, hp = 20): Monster {
return {
index,
stats: {
id: 'fallen1',
name: 'Fallen',
hp,
damage: 2,
cooldownTicks: 25,
reach: 30,
aggroRadius: 100,
speed: 60,
xp: 20,
rank: 'normal',
level: 5,
},
x,
y,
hp,
cooldown: 0,
state: 'idle',
facing: 0,
hitFlash: 0,
corpseTicks: 0,
dropRolled: false,
}
}
function createTestEngine(): GameEngine {
const engineOpts: GameEngineOptions = {
spawn: { x: 100, y: 100 },
combatPlayer: { x: 100, y: 100, maxHp: 100, maxMana: 100 },
stats: [
{
id: 'fallen1',
name: 'Fallen',
hp: 20,
damage: 2,
cooldownTicks: 25,
reach: 30,
aggroRadius: 100,
speed: 60,
xp: 20,
},
],
xpTable: DEMO_EXPERIENCE,
dropTables,
difficulty: 'normal',
monsterKinds: dropTables.monsterKinds,
skills: [
{
id: 'attack',
name: 'Attack',
manaCost: 0,
cooldownTicks: 1,
radius: 48,
},
],
npcDefs: DEMO_NPCS,
questDefs: DEMO_QUESTS,
combatOptions: {
playerSpeed: 100,
playerReach: 60,
playerCooldownTicks: 1,
playerDamage: 50,
playerManaPerAttack: 0,
respawnTicks: 100,
},
talkRadius: 80,
pickupRadius: 64,
inventoryCols: 10,
inventoryRows: 4,
monsterCount: 1,
monsterSpread: 50,
}
const engine = new GameEngine(dummyTerrain, engineOpts)
engine.world.monsters = [createMonster(0, 110, 100, 20)]
return engine
}
// --- Case 1: Pre-tick melee attack kill produces drops ---
it('Case 1: Pre-tick melee attack kill produces drops', () => {
const engine = createTestEngine()
const targetMonster = engine.world.monsters[0]!
expect(targetMonster.state).toBe('idle')
expect(targetMonster.dropRolled).toBe(false)
// Execute pre-tick lethal melee damage via damageMonster
const killed = damageMonster(engine.world, targetMonster.index, 100, engine.world.player)
expect(killed).toBe(true)
expect(targetMonster.state).toBe('dead')
expect(engine.world.pendingKills?.length).toBe(1)
const initialDropsRolled = engine.metrics.dropsRolled
// Run tick to process drops
engine.tick(DUMMY_INPUT)
// Drops must have rolled and monster.dropRolled must be true
expect(engine.metrics.dropsRolled).toBe(initialDropsRolled + 1)
expect(targetMonster.dropRolled).toBe(true)
expect(engine.world.events.some(e => e.kind === 'kill' && e.monsterIndex === targetMonster.index)).toBe(true)
})
// --- Case 2: Pre-tick instant AoE skill kill (Frost Nova / Nova) produces drops ---
it('Case 2: Pre-tick instant AoE skill kill (Frost Nova / Nova) produces drops', () => {
const engine = createTestEngine()
// Spawn 3 monsters around player
engine.world.monsters = [
createMonster(0, 120, 100, 20),
createMonster(1, 80, 100, 20),
createMonster(2, 100, 120, 20),
]
// Configure Nova (instant AoE skill)
engine.opts.skills.push({
id: 'nova',
name: 'Nova',
manaCost: 0,
cooldownTicks: 1,
radius: 80,
damage: 50, // Lethal to 20 hp monsters
})
engine.selectedSkill = engine.opts.skills.length - 1
const initialDropsRolled = engine.metrics.dropsRolled
// Tick with attacking = true to trigger instant skill cast
engine.tick({
...DUMMY_INPUT,
attacking: true,
})
// All 3 monsters must be killed and have drops rolled
expect(engine.world.monsters[0]!.state).toBe('dead')
expect(engine.world.monsters[1]!.state).toBe('dead')
expect(engine.world.monsters[2]!.state).toBe('dead')
expect(engine.world.monsters[0]!.dropRolled).toBe(true)
expect(engine.world.monsters[1]!.dropRolled).toBe(true)
expect(engine.world.monsters[2]!.dropRolled).toBe(true)
expect(engine.metrics.dropsRolled).toBe(initialDropsRolled + 3)
const killEvents = engine.world.events.filter(e => e.kind === 'kill')
expect(killEvents.length).toBe(3)
})
// --- Case 3: Missile projectile kill (direct & splash) produces drops ---
it('Case 3: Missile projectile kill (direct & splash) produces drops', () => {
const engine = createTestEngine()
// Direct target at (150, 100) and splash target nearby at (160, 100)
const directTarget = createMonster(0, 150, 100, 20)
const splashTarget = createMonster(1, 160, 100, 20)
engine.world.monsters = [directTarget, splashTarget]
// Spawn a projectile with aoeRadius targeting (150, 100)
engine.projectiles.push({
skillId: '36',
x: 148,
y: 100,
vx: 4,
vy: 0,
damage: 50,
ttl: 10,
fromPlayer: true,
missileType: 'firebolt',
pierce: false,
aoeRadius: 40,
})
const initialDropsRolled = engine.metrics.dropsRolled
// Advance 1 tick: missile collides with directTarget and splashes to splashTarget
engine.tick(DUMMY_INPUT)
expect(directTarget.state).toBe('dead')
expect(splashTarget.state).toBe('dead')
expect(directTarget.dropRolled).toBe(true)
expect(splashTarget.dropRolled).toBe(true)
expect(engine.metrics.dropsRolled).toBe(initialDropsRolled + 2)
const killEvents = engine.world.events.filter(e => e.kind === 'kill')
expect(killEvents.length).toBe(2)
})
// --- Case 4: Periodic aura pulse kill (Holy Freeze / Holy Fire) produces drops ---
it('Case 4: Periodic aura pulse kill (Holy Freeze / Holy Fire) produces drops', () => {
const engine = createTestEngine()
engine.levelId = 2 // Blood Moor (wilderness, not town)
const targetMonster = createMonster(0, 130, 100, 5) // 5 HP
engine.world.monsters = [targetMonster]
// Activate Holy Freeze aura (skill 114) at slvl 20
engine.updateActiveAura(114, 20)
// Advance tick to 49 so next tick is 50 (50 % 50 === 0 aura pulse)
engine.world.tick = 49
const initialDrops = engine.metrics.dropsRolled
engine.tick(DUMMY_INPUT)
expect(engine.world.tick).toBe(50)
expect(targetMonster.state).toBe('dead')
expect(targetMonster.dropRolled).toBe(true)
expect(engine.metrics.dropsRolled).toBe(initialDrops + 1)
expect(engine.world.events.some(e => e.kind === 'kill' && e.monsterIndex === targetMonster.index)).toBe(true)
})
// --- Case 5: Multi-damage concurrent hits & resurrected monster drop idempotency (drops strictly once) ---
it('Case 5: Multi-damage concurrent hits & resurrected monster drop idempotency (drops strictly once)', () => {
const engine = createTestEngine()
const monster = createMonster(0, 110, 100, 20)
engine.world.monsters = [monster]
const initialDrops = engine.metrics.dropsRolled
// 1. Concurrent hits: hit 1 kills monster, hit 2 strikes dead monster on same tick
const hit1Killed = damageMonster(engine.world, monster.index, 30, engine.world.player)
expect(hit1Killed).toBe(true)
expect(monster.state).toBe('dead')
// Hit 2 against already dead monster returns false and does not queue extra kill
const hit2Killed = damageMonster(engine.world, monster.index, 30, engine.world.player)
expect(hit2Killed).toBe(false)
// Tick to resolve drops
engine.tick(DUMMY_INPUT)
expect(engine.metrics.dropsRolled).toBe(initialDrops + 1)
expect(monster.dropRolled).toBe(true)
// 2. Resurrect the monster (e.g. by Shaman)
monster.state = 'idle'
monster.hp = 20
// Notice: monster.dropRolled remains true (1.13c UNITFLAG_NOITEM parity)
expect(monster.dropRolled).toBe(true)
// Kill resurrected monster a second time
const killedAgain = damageMonster(engine.world, monster.index, 50, engine.world.player)
expect(killedAgain).toBe(true)
expect(monster.state).toBe('dead')
// Advance tick: drop loop must skip drops because monster.dropRolled is already true
engine.tick(DUMMY_INPUT)
// dropsRolled must NOT have increased!
expect(engine.metrics.dropsRolled).toBe(initialDrops + 1)
})
})