702 lines
26 KiB
TypeScript
702 lines
26 KiB
TypeScript
/**
|
||
* 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)
|
||
})
|
||
})
|
||
})
|