/** * Adversarial Challenger Kinematics & Physics Stress Suite * Milestone M2: Necromancer Poison and Bone Spells (1.13c Ground Truth) * Worktree: .worktrees/necro-poison-bone (branch: feat/necro-poison-bone) * * Verifies: * 1. Bone Spirit homing steering: tracking targets moving at various velocities (stationary, * slow, medium, fast, perpendicular, evasive) in 2:1 isometric space (ISO_GROUND_ASPECT_RATIO = 0.5), * and retargeting upon target death. * 2. Bone Spear piercing: penetrating 5 collinear monsters without projectile destruction * (collideKill = 0 / isInfinitePierce), sub-missile bonespearexplode generation, and penetration beyond 5 hits. * 3. Poison Nova radial ring: exactly 64 projectiles covering 360 degrees uniformly with * 2:1 isometric velocity foreshortening, 50-frame poison debuff lifecycle, and 30-frame flight range. * 4. Corpse Explosion: detonation on valid normal corpse, consumption locking, 2:1 isometric AoE blast, * strict 50% physical / 50% fire split of 70%–120% base monster HP, and refusal on living targets, * boss corpses, and already consumed corpses. */ import { describe, expect, it, beforeEach } from 'vitest' import { ISO_GROUND_ASPECT_RATIO, spawnPoisonNovaRing, calculateCorpseExplosionDamage, calculateCorpseExplosionRadiusPx, calculateCorpseExplosionRadiusYards, calculatePoisonNovaDamage, } from '../../../src/server/skills/skills.ts' import { MissileEngine } from '../../../src/server/engine/missile-engine.ts' import { computeCorpseExplosion113c } from '../../../src/server/engine/combat-pipeline.ts' import { getSharedDataRegistry } from '../../../src/baker/mpq-archive-source.ts' import { UnitStatList } from '../../../src/server/engine/stat-list.ts' import { StateBus } from '../../../src/server/engine/state-bus.ts' import { GameEngine, type GameEngineOptions } from '../../../src/server/engine/engine.ts' import { castSkill } from '../../../src/server/scene/skill-caster.ts' import { WorldArena } from '../../../src/server/engine/world-arena.ts' import type { Monster } from '../../../src/server/engine/combat.ts' describe('Adversarial Challenger: Poison & Bone Kinematics & Physics Stress', () => { // ========================================================================= // Focus Area 1: Bone Spirit Homing Steering in 2:1 Isometric Space // ========================================================================= describe('Focus Area 1: Bone Spirit Homing Steering (pSrvDoFunc = 7)', () => { it('steers toward stationary targets in 2:1 isometric ground perspective', async () => { const registry = await getSharedDataRegistry() const engine = new MissileEngine(registry) const casterStats = new UnitStatList() const targetStats = new UnitStatList() targetStats.setHp256(1000 * 256) const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any // Target positioned South-East at (100, 100) const target = { id: 'dummy_se', name: 'Target', statList: targetStats, stateBus: new StateBus(targetStats) } as any const targetPositions = new Map([[target.id, { x: 100, y: 100 }]]) // Launch horizontally East const missile = engine.spawnMissile({ missileNameOrId: 'bonespirit', sourceSkillId: 93, slvl: 20, owner: caster, startX: 0, startY: 0, targetX: 100, targetY: 0, dmgPacket: { minDamage: 100, maxDamage: 100, damageType: 'magic' } as any, })! expect(missile).toBeDefined() // Step simulation 1 tick: homing steering must immediately re-orient missile toward (100, 100) engine.tick(1, [target], targetPositions) // Expected angle in 2:1 isometric space: atan2(dy / 0.5, dx) = atan2(100 / 0.5, 100) = atan2(200, 100) const expectedAngle = Math.atan2(100 / ISO_GROUND_ASPECT_RATIO, 100) expect(missile.angleRad).toBeCloseTo(expectedAngle, 3) // Verify velocity vector matches expected 2:1 foreshortened speed const speed = 12 expect(missile.vx).toBeCloseTo(Math.cos(expectedAngle) * speed, 2) expect(missile.vy).toBeCloseTo(Math.sin(expectedAngle) * speed * ISO_GROUND_ASPECT_RATIO, 2) }) it('documents failure mode: Bone Spirit scalar speed doubles to 24 when launched with non-zero vertical velocity', async () => { const registry = await getSharedDataRegistry() const engine = new MissileEngine(registry) const casterStats = new UnitStatList() const targetStats = new UnitStatList() targetStats.setHp256(1000 * 256) const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any const target = { id: 'target_east', name: 'Target', statList: targetStats, stateBus: new StateBus(targetStats) } as any const targetPositions = new Map([[target.id, { x: 100, y: 0 }]]) // Launch missile vertically South (targetX: 0, targetY: 100) // spawnMissile initializes vx = 0, vy = 12 (unforeshortened) const missile = engine.spawnMissile({ missileNameOrId: 'bonespirit', sourceSkillId: 93, slvl: 20, owner: caster, startX: 0, startY: 0, targetX: 0, targetY: 100, dmgPacket: { minDamage: 100, maxDamage: 100, damageType: 'magic' } as any, })! expect(missile.vx).toBeCloseTo(0, 5) expect(missile.vy).toBeCloseTo(12, 5) // On tick 1, missile steers towards East target at (100, 0) // Line 1047: speed = Math.hypot(m.vx, m.vy / ISO_GROUND_ASPECT_RATIO) = Math.hypot(0, 12 / 0.5) = 24! engine.tick(1, [target], targetPositions) // In 1.13c ground truth, missile velocity should remain 12. // Asserting that speed remains 12 fails because speed is doubled to 24! expect(missile.vx).toBeCloseTo(12, 1) // FAILS: received 24 due to unforeshortened spawn division bug! }) it('continuously tracks targets moving at various linear velocities (slow, medium, fast, evasive)', async () => { const registry = await getSharedDataRegistry() const velocityProfiles = [ { name: 'Slow moving East', vx: 3, vy: 0 }, { name: 'Medium moving South-East', vx: 6, vy: 3 }, { name: 'Perpendicular moving North', vx: 0, vy: -6 }, { name: 'Diagonal retreating', vx: 8, vy: 4 }, ] for (const profile of velocityProfiles) { const engine = new MissileEngine(registry) const casterStats = new UnitStatList() const targetStats = new UnitStatList() targetStats.setHp256(2000 * 256) const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any const target = { id: 'moving_target', name: 'Runner', statList: targetStats, stateBus: new StateBus(targetStats) } as any let currentPos = { x: 80, y: 40 } const targetPositions = new Map([[target.id, currentPos]]) const missile = engine.spawnMissile({ missileNameOrId: 'bonespirit', sourceSkillId: 93, slvl: 20, owner: caster, startX: 0, startY: 0, targetX: 100, targetY: 0, dmgPacket: { minDamage: 100, maxDamage: 100, damageType: 'magic' } as any, })! // Simulate 10 consecutive frames of moving target for (let t = 1; t <= 10; t++) { const preSteerX = missile.x const preSteerY = missile.y currentPos = { x: currentPos.x + profile.vx, y: currentPos.y + profile.vy * ISO_GROUND_ASPECT_RATIO, } targetPositions.set(target.id, currentPos) engine.tick(t, [target], targetPositions) // Verify missile steered toward target position from its pre-tick location const dx = currentPos.x - preSteerX const dy = (currentPos.y - preSteerY) / ISO_GROUND_ASPECT_RATIO const expectedTargetAngle = Math.atan2(dy, dx) expect(Math.abs(missile.angleRad - expectedTargetAngle)).toBeLessThan(0.15) if (missile.expired) break } } }) it('dynamically retargets to closest living enemy when current target is slain mid-flight', async () => { const registry = await getSharedDataRegistry() const engine = new MissileEngine(registry) const casterStats = new UnitStatList() const target1Stats = new UnitStatList() target1Stats.setHp256(500 * 256) const target2Stats = new UnitStatList() target2Stats.setHp256(500 * 256) const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any const target1 = { id: 'primary_target', name: 'Zombie 1', statList: target1Stats, stateBus: new StateBus(target1Stats) } as any const target2 = { id: 'secondary_target', name: 'Zombie 2', statList: target2Stats, stateBus: new StateBus(target2Stats) } as any // Target 1 is at East (100, 0); Target 2 is at South (0, 100) const targetPositions = new Map([ ['primary_target', { x: 100, y: 0 }], ['secondary_target', { x: 0, y: 100 }], ]) const missile = engine.spawnMissile({ missileNameOrId: 'bonespirit', sourceSkillId: 93, slvl: 20, owner: caster, startX: 0, startY: 0, targetX: 100, targetY: 0, dmgPacket: { minDamage: 100, maxDamage: 100, damageType: 'magic' } as any, })! // Tick 1: Spirit tracks primary target at East engine.tick(1, [target1, target2], targetPositions) expect(missile.vx).toBeGreaterThan(10) expect(Math.abs(missile.vy)).toBeLessThan(2) // Primary target dies abruptly target1Stats.setHp256(0) // Tick 2: Spirit must automatically acquire secondary target (South) engine.tick(2, [target1, target2], targetPositions) // Vy must now turn strongly positive (moving South toward target 2) expect(missile.vy).toBeGreaterThan(4) const angleToTarget2 = Math.atan2((100 - missile.y) / ISO_GROUND_ASPECT_RATIO, 0 - missile.x) expect(missile.angleRad).toBeCloseTo(angleToTarget2, 1) }) }) // ========================================================================= // Focus Area 2: Bone Spear Piercing with collideKill = 0 // ========================================================================= describe('Focus Area 2: Bone Spear Collinear Piercing (collideKill = 0)', () => { it('penetrates 5 collinear monsters without projectile destruction', async () => { const registry = await getSharedDataRegistry() const engine = new MissileEngine(registry) const casterStats = new UnitStatList() const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any // Arrange 5 monsters along collinear East line at intervals of 35px const monsterCount = 5 const targets: any[] = [] const targetPositions = new Map() for (let i = 1; i <= monsterCount; i++) { const mStats = new UnitStatList() mStats.setHp256(5000 * 256) const t = { id: `collinear_mon_${i}`, name: `Target ${i}`, statList: mStats, stateBus: new StateBus(mStats), } targets.push(t) targetPositions.set(t.id, { x: 35 * i, y: 0 }) } // Fire Bone Spear eastwards (startX: 0, startY: 0 -> targetX: 300, targetY: 0) const spear = engine.spawnMissile({ missileNameOrId: 'bonespear', sourceSkillId: 84, slvl: 20, owner: caster, startX: 0, startY: 0, targetX: 300, targetY: 0, dmgPacket: { minDamage: 150, maxDamage: 200, damageType: 'magic' } as any, })! expect(spear.canPierce).toBe(true) expect(spear.record.collideKill).toBeFalsy() // collideKill is false/0 in 1.13c Missiles.txt // Step simulation until missile travels past all 5 monsters for (let t = 1; t <= 15; t++) { engine.tick(t, targets, targetPositions) } // Assertions: All 5 monsters pierced, projectile NOT destroyed expect(spear.pierceCount).toBe(5) expect(spear.expired).toBe(false) expect(spear.hitTargetIds.size).toBe(5) for (const t of targets) { expect(spear.hitTargetIds.has(t.id)).toBe(true) expect(t.statList.getHp256()).toBeLessThan(5000 * 256) } }) it('demonstrates infinite piercing beyond standard 4-pierce cap (e.g. 7 collinear targets)', async () => { const registry = await getSharedDataRegistry() const engine = new MissileEngine(registry) const casterStats = new UnitStatList() const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any // Standard pierce skills cap at 4 pierces (5 total hits). // Bone Spear has infinite pierce (collideKill = 0), so it should pierce 7+ targets! const monsterCount = 7 const targets: any[] = [] const targetPositions = new Map() for (let i = 1; i <= monsterCount; i++) { const mStats = new UnitStatList() mStats.setHp256(5000 * 256) const t = { id: `line_mon_${i}`, name: `Target ${i}`, statList: mStats, stateBus: new StateBus(mStats), } targets.push(t) targetPositions.set(t.id, { x: 25 * i, y: 0 }) } const spear = engine.spawnMissile({ missileNameOrId: 'bonespear', sourceSkillId: 84, slvl: 20, owner: caster, startX: 0, startY: 0, targetX: 300, targetY: 0, dmgPacket: { minDamage: 150, maxDamage: 200, damageType: 'magic' } as any, })! for (let t = 1; t <= 15; t++) { engine.tick(t, targets, targetPositions) } expect(spear.pierceCount).toBe(7) expect(spear.hitTargetIds.size).toBe(7) expect(spear.expired).toBe(false) }) it('documents failure mode: runaway exponential sub-missile explosion cascade (18,466 sub-missiles spawned for 5 monsters)', async () => { const registry = await getSharedDataRegistry() const engine = new MissileEngine(registry) const casterStats = new UnitStatList() const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any const targets: any[] = [] const targetPositions = new Map() for (let i = 1; i <= 5; i++) { const s = new UnitStatList() s.setHp256(5000 * 256) const t = { id: `m_${i}`, name: `Target ${i}`, statList: s, stateBus: new StateBus(s) } targets.push(t) targetPositions.set(`m_${i}`, { x: 35 * i, y: 0 }) } engine.spawnMissile({ missileNameOrId: 'bonespear', sourceSkillId: 84, slvl: 20, owner: caster, startX: 0, startY: 0, targetX: 300, targetY: 0, dmgPacket: { minDamage: 100, maxDamage: 100, damageType: 'magic' } as any, }) let totalExplosionsSpawned = 0 for (let t = 1; t <= 15; t++) { const res = engine.tick(t, targets, targetPositions) totalExplosionsSpawned += res.subMissilesSpawned.filter(s => s === 'bonespearexplode').length } // In 1.13c ground truth, hitting 5 monsters should spawn at most 5 bonespearexplode submissiles. // Asserting totalExplosionsSpawned === 5 fails catastrophically: received 18,466 submissiles! expect(totalExplosionsSpawned).toBe(5) }) }) // ========================================================================= // Focus Area 3: Poison Nova Radial Ring & 2:1 Isometric Foreshortening // ========================================================================= describe('Focus Area 3: Poison Nova 64-Radial Ring & 2:1 Foreshortening', () => { it('spawns exactly 64 radial projectiles uniformly covering 360 degrees', () => { const bolts = spawnPoisonNovaRing(100, 200, 350) expect(bolts.length).toBe(64) const angleStep = (2 * Math.PI) / 64 for (let i = 0; i < 64; i++) { const expectedAngle = i * angleStep const bolt = bolts[i]! expect(bolt.skillId).toBe('92') expect(bolt.missileType).toBe('poisonnova') expect(bolt.x).toBe(100) expect(bolt.y).toBe(200) // Ground velocity validation const speed = 12 expect(bolt.vx).toBeCloseTo(Math.cos(expectedAngle) * speed, 5) expect(bolt.vy).toBeCloseTo(Math.sin(expectedAngle) * speed * ISO_GROUND_ASPECT_RATIO, 5) } }) it('enforces strict 2:1 isometric ground foreshortening across all 64 projectiles', () => { const bolts = spawnPoisonNovaRing(0, 0, 100) let maxVx = -Infinity let minVx = Infinity let maxVy = -Infinity let minVy = Infinity for (const b of bolts) { maxVx = Math.max(maxVx, b.vx) minVx = Math.min(minVx, b.vx) maxVy = Math.max(maxVy, b.vy) minVy = Math.min(minVy, b.vy) } // Horizontal span: [-12, 12] -> total width = 24 // Vertical span: [-6, 6] -> total height = 12 expect(maxVx).toBeCloseTo(12, 1) expect(minVx).toBeCloseTo(-12, 1) expect(maxVy).toBeCloseTo(6, 1) expect(minVy).toBeCloseTo(-6, 1) const horizontalSpan = maxVx - minVx const verticalSpan = maxVy - minVy expect(horizontalSpan / verticalSpan).toBeCloseTo(2.0, 3) // Strict 2:1 aspect ratio! }) it('verifies poison status effect duration is strictly fixed at 50 frames (2.0s)', () => { const bolts = spawnPoisonNovaRing(0, 0, 100) for (const b of bolts) { expect(b.statusEffect).toBe('poison') expect(b.statusDuration).toBe(50) // Exactly 50 frames (2.0s) } // Duration formula in calculatePoisonNovaDamage is strictly 50 frames across all levels for (const slvl of [1, 5, 10, 20, 30, 99]) { const dmg = calculatePoisonNovaDamage(slvl) expect(dmg.durationFrames).toBe(50) expect(dmg.durationSeconds).toBe(2.0) } }) it('documents discrepancy: projectile ttl is 30 frames (Missiles.txt Range=30) while prompt specifies expiring at frame 50', () => { const bolts = spawnPoisonNovaRing(0, 0, 100) // In 1.13c Missiles.txt, poisonnova has Vel = 12, Range = 30. // The prompt specified "expiring at exactly frame 50". // Asserting b.ttl === 50 fails because worker implemented ttl: 30 based on Missiles.txt Range: expect(bolts[0]!.ttl).toBe(50) // FAILS: received 30! }) }) // ========================================================================= // Focus Area 4: Corpse Explosion Detonation, Damage Split & Refusal // ========================================================================= describe('Focus Area 4: Corpse Explosion (Skill 74) Detonation & Damage Split', () => { let engine: GameEngine let statusElem: HTMLElement const dummyRuntime: any = { level: 'test', grid: { width: 100, height: 100, cells: new Uint8Array(10000) }, waypoints: [], } beforeEach(() => { const dummyTerrain = { widthPx: 1000, heightPx: 1000, overlap: () => 0, } const dummyOpts: GameEngineOptions = { spawn: { x: 100, y: 100 }, stats: [], xpTable: [0, 100], itemBases: [], prefixAffixes: [], suffixAffixes: [], skills: [], npcDefs: [], questDefs: [], monsterCount: 0, combatOptions: { playerSpeed: 100, playerReach: 50, playerCooldownTicks: 0, playerDamage: 5, playerManaPerAttack: 0, respawnTicks: 100, }, talkRadius: 80, pickupRadius: 40, inventoryCols: 10, inventoryRows: 4, } engine = new GameEngine(dummyTerrain, dummyOpts) engine.world.player.x = 100 engine.world.player.y = 100 engine.world.player.cooldown = 0 engine.world.player.mana = 1000 statusElem = { textContent: '' } as any }) it('successfully detonates on a valid normal dead monster corpse, consuming it', () => { const normalCorpse: Monster = { index: 0, x: 110, y: 100, hp: 0, cooldown: 0, hitFlash: 0, corpseTicks: 10, state: 'dead', facing: 0, stats: { id: 'zombie', name: 'Zombie', hp: 800, damage: 10, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 10 }, } const bystanderMonster: Monster = { index: 1, x: 130, y: 100, hp: 1500, cooldown: 0, hitFlash: 0, corpseTicks: 0, state: 'idle', facing: 0, stats: { id: 'skeleton', name: 'Skeleton', hp: 1500, damage: 10, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 10 }, } engine.world.monsters = [normalCorpse, bystanderMonster] const success = castSkill(74, 110, 100, { engine, runtime: dummyRuntime, hudManager: null, status: statusElem, }) expect(success).toBe(true) expect((normalCorpse as any).corpseConsumed).toBe(true) expect(bystanderMonster.hp).toBeLessThan(1500) // Bystander received CE damage! }) it('refuses detonation when targeting an already-consumed corpse', () => { const consumedCorpse: Monster = { index: 0, x: 110, y: 100, hp: 0, cooldown: 0, hitFlash: 0, corpseTicks: 10, state: 'dead', facing: 0, stats: { id: 'zombie', name: 'Zombie', hp: 800, damage: 10, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 10 }, } ;(consumedCorpse as any).corpseConsumed = true engine.world.monsters = [consumedCorpse] const success = castSkill(74, 110, 100, { engine, runtime: dummyRuntime, hudManager: null, status: statusElem, }) expect(success).toBe(false) expect(statusElem.textContent).toContain('需要有效的目标尸体') }) it('refuses detonation when targeting a living monster (non-corpse)', () => { const livingMonster: Monster = { index: 0, x: 110, y: 100, hp: 800, cooldown: 0, hitFlash: 0, corpseTicks: 0, state: 'idle', facing: 0, stats: { id: 'zombie', name: 'Zombie', hp: 800, damage: 10, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 10 }, } engine.world.monsters = [livingMonster] const success = castSkill(74, 110, 100, { engine, runtime: dummyRuntime, hudManager: null, status: statusElem, }) expect(success).toBe(false) expect(statusElem.textContent).toContain('需要有效的目标尸体') }) it('refuses detonation when targeting a Boss corpse (isBoss = true or type = "boss")', () => { const bossCorpse1: Monster = { index: 0, x: 110, y: 100, hp: 0, cooldown: 0, hitFlash: 0, corpseTicks: 10, state: 'dead', facing: 0, stats: { id: 'diablo', name: 'Diablo', hp: 50000, damage: 100, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 100, isBoss: true } as any, } const bossCorpse2: Monster = { index: 1, x: 110, y: 100, hp: 0, cooldown: 0, hitFlash: 0, corpseTicks: 10, state: 'dead', facing: 0, stats: { id: 'baal', name: 'Baal', hp: 60000, damage: 100, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 100, type: 'boss' } as any, } engine.world.monsters = [bossCorpse1, bossCorpse2] const success = castSkill(74, 110, 100, { engine, runtime: dummyRuntime, hudManager: null, status: statusElem, }) expect(success).toBe(false) expect(statusElem.textContent).toContain('需要有效的目标尸体') expect((bossCorpse1 as any).corpseConsumed).toBeUndefined() expect((bossCorpse2 as any).corpseConsumed).toBeUndefined() }) it('verifies exact 50% physical and 50% fire split across random and edge-case base HP values', () => { const testCases = [ { hp: 100, roll: 70 }, { hp: 100, roll: 100 }, { hp: 100, roll: 120 }, { hp: 1000, roll: 95 }, { hp: 2500, roll: 80 }, { hp: 12345, roll: 111 }, { hp: 65535, roll: 100 }, ] for (const tc of testCases) { // Test decoupled calculateCorpseExplosionDamage const ce = calculateCorpseExplosionDamage(tc.hp, tc.roll) const expectedTotal = Math.trunc((tc.hp * tc.roll) / 100) expect(ce.phys + ce.fire).toBe(expectedTotal) // Verify 50% split: difference between fire and phys must be <= 1 (rounding integer truncation) expect(Math.abs(ce.phys - ce.fire)).toBeLessThanOrEqual(1) expect(ce.fire).toBe(Math.trunc((expectedTotal * 50) / 100)) expect(ce.phys).toBe(expectedTotal - ce.fire) // Test combat-pipeline computeCorpseExplosion113c const pipelineCe = computeCorpseExplosion113c({ corpseBaseHp: tc.hp, rollPct: tc.roll, targetPhysRes: 0, targetFireRes: 0, }) expect(pipelineCe.rawTotalDamage).toBe(expectedTotal) expect(pipelineCe.rawPhysDamage).toBe(ce.phys) expect(pipelineCe.rawFireDamage).toBe(ce.fire) expect(pipelineCe.finalPhysDamage).toBe(ce.phys) expect(pipelineCe.finalFireDamage).toBe(ce.fire) expect(pipelineCe.finalTotalDamage).toBe(expectedTotal) } }) it('executes Corpse Explosion in WorldArena validating corpse consumption and damage delivery', async () => { const registry = await getSharedDataRegistry() const arena = new WorldArena({ registry, classCode: 'nec', skillId: 74, slvl: 20, }) expect(arena.corpses.length).toBe(3) expect(arena.corpses.every(c => !c.consumed)).toBe(true) // Trigger Corpse Explosion cast in arena const outcome = arena.triggerCast(0, 80) arena.stepTicks(15) // Exactly 1 corpse must have been consumed expect(outcome.executed).toBe(true) expect(outcome.corpsesConsumed).toBe(1) expect(arena.corpses.filter(c => c.consumed).length).toBe(1) expect(arena.totalDamageDealt).toBeGreaterThan(0) }) }) })