import { describe, expect, it } from 'vitest' import { GameEngine } from '../src/game/engine.ts' import type { Monster } from '../src/game/combat.ts' import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts' describe('Issue #420: Death Animation Frame Gating (1.13c Parity)', () => { const dropTables = getEmbeddedDropTables() function createTestEngine(overrides?: any) { const engine = new GameEngine( { widthPx: 1000, heightPx: 1000, overlap: () => 0 }, { spawn: { x: 500, y: 500 }, stats: [], xpTable: [0, 500, 1500], 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: 42, dropTables, ...overrides, }, ) engine.setDropTables(dropTables) return engine } function createTestMonster( index: number, id: string, x: number, y: number, hp = 20, rank: 'normal' | 'champion' | 'unique' | 'minion' = 'normal', superUniqueId?: string, ): Monster { return { index, stats: { id, name: id, hp, damage: 1, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 10, xp: 10, level: 5, rank, ...(superUniqueId !== undefined ? { superUniqueId } : {}), }, 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('Suite 1: Death Animation Drop Gating Invariant', () => { it('1.1: drops do NOT spawn on the lethal damage tick when deathGated is true', () => { const engine = createTestEngine() const monster = createTestMonster(0, 'zombie1', 520, 500, 20) monster.deathGated = true engine.world.monsters.push(monster) // Apply lethal damage const killed = engine.damageMonster(0, 999) expect(killed).toBe(true) expect(monster.state).toBe('dead') expect(monster.animMode).toBe('dt') // Tick the engine to process kill event engine.tick(emptyInput) // Ground items must NOT have spawned yet expect(engine.groundItems.count).toBe(0) expect(monster.dropRolled).toBeFalsy() expect(monster.pendingDrop).toBeDefined() expect(monster.pendingDrop?.monsterIndex).toBe(0) }) it('1.2: drops do NOT spawn on the lethal damage tick when deathTicks > 0', () => { const engine = createTestEngine() const monster = createTestMonster(0, 'zombie1', 520, 500, 20) monster.deathTicks = 5 engine.world.monsters.push(monster) engine.damageMonster(0, 999) engine.tick(emptyInput) expect(engine.groundItems.count).toBe(0) expect(monster.dropRolled).toBeFalsy() expect(monster.pendingDrop).toBeDefined() }) }) describe('Suite 2: Final Frame Drop Trigger & Headless Countdown', () => { it('2.1: advances deathTicks countdown and triggers drop when ticks reach 0', () => { const engine = createTestEngine() const monster = createTestMonster(0, 'fallen1', 520, 500, 20) monster.deathTicks = 3 engine.world.monsters.push(monster) engine.damageMonster(0, 999) // Tick 1: deathTicks becomes 2 (queued in tick 1) engine.tick(emptyInput) expect(monster.deathTicks).toBe(2) expect(engine.groundItems.count).toBe(0) // Tick 2: deathTicks becomes 1 engine.tick(emptyInput) expect(monster.deathTicks).toBe(1) expect(engine.groundItems.count).toBe(0) // Tick 3: deathTicks reaches 0 -> triggers 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).toBeGreaterThanOrEqual(1) expect(engine.groundItems.count).toBeGreaterThanOrEqual(1) }) it('2.2: explicit triggerMonsterDrop spawns items immediately when called on animation finish', () => { const engine = createTestEngine() const monster = createTestMonster(0, 'skeleton1', 520, 500, 20) monster.deathGated = true engine.world.monsters.push(monster) engine.damageMonster(0, 999) engine.tick(emptyInput) expect(engine.groundItems.count).toBe(0) expect(monster.pendingDrop).toBeDefined() // Simulate ActorAnimator completing DT sequence const dropped = engine.triggerMonsterDrop(monster) expect(monster.dropRolled).toBe(true) expect(monster.animMode).toBe('dd') expect(monster.pendingDrop).toBeUndefined() expect(dropped.length).toBe(engine.groundItems.count) expect(dropped.length).toBeGreaterThanOrEqual(1) }) }) describe('Suite 3: Killer Context Locking & Exploit Prevention', () => { it('3.1: preserves player MF/GF from lethal strike, ignoring weapon swaps during DT', () => { const engine = createTestEngine({ equippedItems: [ { name: 'Goldwrap', stats: [{ text: '80% Extra Gold' }, { text: '30% Better Chance of Getting Magic Items' }], }, ], }) const monster = createTestMonster(0, 'fallen1', 520, 500, 20) monster.deathTicks = 4 engine.world.monsters.push(monster) engine.damageMonster(0, 999) engine.tick(emptyInput) expect(monster.pendingDrop).toBeDefined() expect(monster.pendingDrop?.killerContext.playerMf).toBe(30) expect(monster.pendingDrop?.killerContext.playerGf).toBe(80) // Player swaps to naked weapon (0 MF/GF) during death animation ;(engine.opts as any).equippedItems = [] // Advance through remainder of death animation for (let i = 0; i < 4; i++) { engine.tick(emptyInput) } expect(monster.dropRolled).toBe(true) }) }) describe('Suite 4: Instant Fallback & Single-Drop Gate', () => { it('4.1: monsters without deathGated or deathTicks drop immediately on kill tick', () => { const engine = createTestEngine() const monster = createTestMonster(0, 'zombie1', 520, 500, 20) engine.world.monsters.push(monster) expect(monster.deathGated).toBeUndefined() expect(monster.deathTicks).toBeUndefined() engine.damageMonster(0, 999) engine.tick(emptyInput) // Immediate drop expect(monster.dropRolled).toBe(true) expect(monster.animMode).toBe('dd') expect(engine.metrics.dropsRolled).toBe(1) expect(engine.groundItems.count).toBeGreaterThanOrEqual(1) }) it('4.2: multiple damage hits or multiple triggerMonsterDrop calls never duplicate drops', () => { const engine = createTestEngine() const monster = createTestMonster(0, 'zombie1', 520, 500, 20) engine.world.monsters.push(monster) engine.damageMonster(0, 999) engine.tick(emptyInput) const initialCount = engine.groundItems.count expect(initialCount).toBeGreaterThanOrEqual(1) // Try hitting dead monster again const damagedAgain = engine.damageMonster(0, 999) expect(damagedAgain).toBe(false) // Try calling triggerMonsterDrop again const secondCall = engine.triggerMonsterDrop(monster) expect(secondCall).toEqual([]) expect(engine.groundItems.count).toBe(initialCount) expect(engine.metrics.dropsRolled).toBe(1) }) }) })