294 lines
9.4 KiB
TypeScript
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)
|
|
})
|
|
})
|