diablo2-web/tests/skills/nec/adv-pnb-kinematics-stress.t...

702 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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/game/skills.ts'
import { MissileEngine } from '../../../src/game/engine/missile-engine.ts'
import { computeCorpseExplosion113c } from '../../../src/game/engine/combat-pipeline.ts'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import { GameEngine, type GameEngineOptions } from '../../../src/game/engine.ts'
import { castSkill } from '../../../src/scene/act-scene.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import type { Monster } from '../../../src/game/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<string, { x: number; y: number }>([[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<string, { x: number; y: number }>([[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<string, { x: number; y: number }>([[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<string, { x: number; y: number }>([
['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<string, { x: number; y: number }>()
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<string, { x: number; y: number }>()
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<string, { x: number; y: number }>()
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)
})
})
})