feat(combat): fix kill event lifecycle & single-drop gate (Fixes #404)
This commit is contained in:
commit
d5b69548d0
|
|
@ -102,6 +102,8 @@ export interface CombatOptions {
|
||||||
readonly respawnTicks: number
|
readonly respawnTicks: number
|
||||||
/** When true, monsters ignore aggro distance and remain standing still (idle). */
|
/** When true, monsters ignore aggro distance and remain standing still (idle). */
|
||||||
readonly disableMonsterAggro?: boolean
|
readonly disableMonsterAggro?: boolean
|
||||||
|
/** When true, world.events is preserved across ticks instead of cleared at tick start. */
|
||||||
|
readonly preserveEvents?: boolean | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One monster's immutable numbers, read from a table row. */
|
/** One monster's immutable numbers, read from a table row. */
|
||||||
|
|
@ -185,6 +187,8 @@ export interface Monster {
|
||||||
chillTicks?: number | undefined
|
chillTicks?: number | undefined
|
||||||
/** Ticks remaining poisoned (damage over time). */
|
/** Ticks remaining poisoned (damage over time). */
|
||||||
poisonTicks?: number | undefined
|
poisonTicks?: number | undefined
|
||||||
|
/** Whether drops have already been rolled for this monster instance. */
|
||||||
|
dropRolled?: boolean | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The player's combat-relevant state. */
|
/** The player's combat-relevant state. */
|
||||||
|
|
@ -262,6 +266,8 @@ export interface CombatEvent {
|
||||||
readonly text?: string
|
readonly text?: string
|
||||||
/** Subject identity (a monster's table id on a kill, for quest counters). */
|
/** Subject identity (a monster's table id on a kill, for quest counters). */
|
||||||
readonly subjectId?: string
|
readonly subjectId?: string
|
||||||
|
/** Stable monster index for kill and hit events. */
|
||||||
|
readonly monsterIndex?: number | undefined
|
||||||
/** Monster level when event is 'kill'. */
|
/** Monster level when event is 'kill'. */
|
||||||
readonly monsterLevel?: number | undefined
|
readonly monsterLevel?: number | undefined
|
||||||
/** Monster rank when event is 'kill'. */
|
/** Monster rank when event is 'kill'. */
|
||||||
|
|
@ -304,6 +310,8 @@ export interface CombatWorld {
|
||||||
tick: number
|
tick: number
|
||||||
/** Events produced during the last tick (cleared each tick). */
|
/** Events produced during the last tick (cleared each tick). */
|
||||||
events: CombatEvent[]
|
events: CombatEvent[]
|
||||||
|
/** Kill events pending drop resolution. */
|
||||||
|
pendingKills?: CombatEvent[] | undefined
|
||||||
/** Monsters killed. */
|
/** Monsters killed. */
|
||||||
kills: number
|
kills: number
|
||||||
}
|
}
|
||||||
|
|
@ -389,10 +397,14 @@ export function createWorld(x: number, y: number, maxHp = 60, maxMana = 30): Com
|
||||||
monsters: [],
|
monsters: [],
|
||||||
tick: 0,
|
tick: 0,
|
||||||
events: [],
|
events: [],
|
||||||
|
pendingKills: [],
|
||||||
kills: 0,
|
kills: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Alias for createWorld conforming to combat world nomenclature. */
|
||||||
|
export const createCombatWorld = createWorld
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create one player body.
|
* Create one player body.
|
||||||
*
|
*
|
||||||
|
|
@ -730,6 +742,7 @@ function buildKillEvent(monster: Monster): CombatEvent {
|
||||||
y: monster.y,
|
y: monster.y,
|
||||||
text: monster.stats.name,
|
text: monster.stats.name,
|
||||||
subjectId: monster.stats.id,
|
subjectId: monster.stats.id,
|
||||||
|
monsterIndex: monster.index,
|
||||||
monsterLevel: monster.stats.level,
|
monsterLevel: monster.stats.level,
|
||||||
monsterRank: monster.stats.rank,
|
monsterRank: monster.stats.rank,
|
||||||
monsterType,
|
monsterType,
|
||||||
|
|
@ -746,8 +759,13 @@ export function damageMonster(world: CombatWorld, monsterIndex: number, amount:
|
||||||
if (monster.hp > 0) return false
|
if (monster.hp > 0) return false
|
||||||
monster.state = 'dead'
|
monster.state = 'dead'
|
||||||
monster.corpseTicks = 100
|
monster.corpseTicks = 100
|
||||||
|
monster.dropRolled = monster.dropRolled ?? false
|
||||||
world.kills += 1
|
world.kills += 1
|
||||||
world.events.push(buildKillEvent(monster))
|
const killEvent = buildKillEvent(monster)
|
||||||
|
world.events.push(killEvent)
|
||||||
|
if (world.pendingKills !== undefined) {
|
||||||
|
world.pendingKills.push(killEvent)
|
||||||
|
}
|
||||||
attacker.xp += monster.stats.xp
|
attacker.xp += monster.stats.xp
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -796,7 +814,9 @@ export function tickCombatMulti(
|
||||||
xpTable: readonly number[],
|
xpTable: readonly number[],
|
||||||
): void {
|
): void {
|
||||||
world.tick += 1
|
world.tick += 1
|
||||||
world.events = []
|
if (!options.preserveEvents) {
|
||||||
|
world.events = []
|
||||||
|
}
|
||||||
// Who the monsters may attack is decided at the *start* of the tick. A player
|
// Who the monsters may attack is decided at the *start* of the tick. A player
|
||||||
// who comes back to life during this tick is therefore safe in it, and a world
|
// who comes back to life during this tick is therefore safe in it, and a world
|
||||||
// with nobody alive gives the monsters nothing to do: their cooldowns and the
|
// with nobody alive gives the monsters nothing to do: their cooldowns and the
|
||||||
|
|
@ -902,8 +922,13 @@ function tickPlayer(
|
||||||
if (target.hp <= 0) {
|
if (target.hp <= 0) {
|
||||||
target.state = 'dead'
|
target.state = 'dead'
|
||||||
target.corpseTicks = 100
|
target.corpseTicks = 100
|
||||||
|
target.dropRolled = target.dropRolled ?? false
|
||||||
world.kills += 1
|
world.kills += 1
|
||||||
world.events.push(buildKillEvent(target))
|
const killEvent = buildKillEvent(target)
|
||||||
|
world.events.push(killEvent)
|
||||||
|
if (world.pendingKills !== undefined) {
|
||||||
|
world.pendingKills.push(killEvent)
|
||||||
|
}
|
||||||
player.xp += target.stats.xp
|
player.xp += target.stats.xp
|
||||||
// Level up while the threshold is crossed; the table is cumulative.
|
// Level up while the threshold is crossed; the table is cumulative.
|
||||||
while (player.level + 1 < xpTable.length && player.xp >= (xpTable[player.level + 1] ?? Number.POSITIVE_INFINITY)) {
|
while (player.level + 1 < xpTable.length && player.xp >= (xpTable[player.level + 1] ?? Number.POSITIVE_INFINITY)) {
|
||||||
|
|
|
||||||
|
|
@ -370,6 +370,9 @@ export class GameEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
tick(input: EngineInput): void {
|
tick(input: EngineInput): void {
|
||||||
|
if (!this.world.pendingKills || this.world.pendingKills.length === 0) {
|
||||||
|
this.world.events = []
|
||||||
|
}
|
||||||
const movement = input.movement
|
const movement = input.movement
|
||||||
const player = this.world.player
|
const player = this.world.player
|
||||||
const beforeX = player.x
|
const beforeX = player.x
|
||||||
|
|
@ -417,7 +420,8 @@ export class GameEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tickCombat(this.world, { movement, attack: castHappened ? false : input.attacking }, this.opts.combatOptions, this.terrain, this.opts.xpTable)
|
const combatOptions = { ...this.opts.combatOptions, preserveEvents: true }
|
||||||
|
tickCombat(this.world, { movement, attack: castHappened ? false : input.attacking }, combatOptions, this.terrain, this.opts.xpTable)
|
||||||
|
|
||||||
if (this.world.events.some(event => event.kind === 'monsterHit')) this.metrics.playerHits += 1
|
if (this.world.events.some(event => event.kind === 'monsterHit')) this.metrics.playerHits += 1
|
||||||
|
|
||||||
|
|
@ -857,8 +861,22 @@ export class GameEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.world.pendingKills && this.world.pendingKills.length > 0) {
|
||||||
|
for (const kill of this.world.pendingKills) {
|
||||||
|
if (!this.world.events.includes(kill)) {
|
||||||
|
this.world.events.push(kill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.world.pendingKills = []
|
||||||
|
}
|
||||||
|
|
||||||
for (const event of this.world.events) {
|
for (const event of this.world.events) {
|
||||||
if (event.kind !== 'kill') continue
|
if (event.kind !== 'kill') continue
|
||||||
|
const monster = event.monsterIndex !== undefined
|
||||||
|
? this.world.monsters.find(m => m.index === event.monsterIndex)
|
||||||
|
: this.world.monsters.find(m => m.stats.id === event.subjectId && Math.hypot(m.x - event.x, m.y - event.y) < 10)
|
||||||
|
if (monster?.dropRolled) continue
|
||||||
|
if (monster) monster.dropRolled = true
|
||||||
for (const reward of this.questLog.recordKill(event.subjectId ?? '')) {
|
for (const reward of this.questLog.recordKill(event.subjectId ?? '')) {
|
||||||
player.xp += reward.xp
|
player.xp += reward.xp
|
||||||
this.bag.add(goldItem(reward.gold))
|
this.bag.add(goldItem(reward.gold))
|
||||||
|
|
|
||||||
|
|
@ -5627,6 +5627,9 @@ if (typeof window !== 'undefined') {
|
||||||
const loop = new GameLoop({
|
const loop = new GameLoop({
|
||||||
tickRate: 25,
|
tickRate: 25,
|
||||||
onTick: () => {
|
onTick: () => {
|
||||||
|
// Clean tick boundary: flush previous tick events before mouseController runs
|
||||||
|
engine.world.events = []
|
||||||
|
if (engine.world.pendingKills) engine.world.pendingKills = []
|
||||||
const mouseStep = mouseController.tick()
|
const mouseStep = mouseController.tick()
|
||||||
const effectiveMovement = mouseStep.movement
|
const effectiveMovement = mouseStep.movement
|
||||||
if (hudManager) {
|
if (hudManager) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,293 @@
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue