diablo2-web/tests/skills/nec/poison-bone.test.ts

515 lines
19 KiB
TypeScript

/**
* Diablo II: Lord of Destruction v1.13c — Necromancer Poison & Bone Spells Verification Suite
* Milestone M2: Skills 67, 68, 73, 74, 78, 83, 84, 88, 92, 93
*
* Verifies:
* 1. Exact 1.13c damage, bitrate, and duration formulas at slvl 1, 10, 20 + synergies.
* 2. Strict base skill point (`blvl`) synergy invariant (soft points / charges give 0 synergy).
* 3. Architecture invariants:
* - BATCH1_SKILLS length strictly 38.
* - Decoupled standalone exported damage functions.
* - 2:1 isometric ground projection geometry (ISO_GROUND_ASPECT_RATIO = 0.5).
* 4. Runtime mechanics:
* - Teeth multi-shot projectile count min(ln12, 24) and teethexplode on hit.
* - Bone Armor physical absorption pool and overlays.
* - Poison Dagger weapon restriction (dagger only) and attack rating bonus.
* - Corpse Explosion unscaled monster base HP damage (50% phys / 50% fire) and corpse consumption.
* - Bone Wall & Bone Prison destructible segment stats and fixed 24s duration.
* - Poison Explosion corpse consumption and poison cloud.
* - Bone Spear piercing kinematics (collideKill = 0) and trail/hit submissiles.
* - Poison Nova 64 radial bolts and fixed 2.0s duration.
* - Bone Spirit homing missile kinematics.
*/
import { describe, expect, it } from 'vitest'
import {
BATCH1_SKILLS,
calculateTeethDamage,
calculateTeethSynergyMultiplier,
calculateTeethCount,
calculateBoneArmorCapacity,
calculateBoneArmorSynergyBonus,
calculatePoisonDaggerDamage,
calculatePoisonDaggerSynergyMultiplier,
calculatePoisonDaggerAttackRatingBonus,
calculateCorpseExplosionDamage,
calculateCorpseExplosionRadiusYards,
calculateCorpseExplosionRadiusPx,
calculateBoneWallStats,
calculatePoisonExplosionDamage,
calculatePoisonExplosionSynergyMultiplier,
calculateBoneSpearDamage,
calculateBoneSpearSynergyMultiplier,
calculateBonePrisonStats,
calculatePoisonNovaDamage,
calculatePoisonNovaSynergyMultiplier,
calculateBoneSpiritDamage,
calculateBoneSpiritSynergyMultiplier,
spawnNovaRing,
spawnPoisonNovaRing,
calculateSkillDamage,
ISO_GROUND_ASPECT_RATIO,
} from '../../../src/game/skills.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 { MissileEngine } from '../../../src/game/engine/missile-engine.ts'
describe('Milestone M2: Necromancer Poison and Bone Spells (1.13c Ground Truth)', () => {
describe('Architecture Invariants', () => {
it('preserves BATCH1_SKILLS length strictly at 38 skills', () => {
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
})
it('enforces ISO_GROUND_ASPECT_RATIO = 0.5', () => {
expect(ISO_GROUND_ASPECT_RATIO).toBe(0.5)
})
})
describe('Skill 67: Teeth (牙)', () => {
it('evaluates exact damage at slvl 1, 10, 20 without synergies', () => {
const d1 = calculateTeethDamage(1)
expect(d1.min).toBe(2)
expect(d1.max).toBe(4)
const d10 = calculateTeethDamage(10)
expect(d10.min).toBe(11)
expect(d10.max).toBe(14)
const d20 = calculateTeethDamage(20)
expect(d20.min).toBe(23)
expect(d20.max).toBe(31)
})
it('calculates projectile count as min(ln12, 24) capped at 24', () => {
expect(calculateTeethCount(1)).toBe(2)
expect(calculateTeethCount(10)).toBe(11)
expect(calculateTeethCount(20)).toBe(21)
expect(calculateTeethCount(23)).toBe(24)
expect(calculateTeethCount(30)).toBe(24)
})
it('scales damage with +15% synergy per hard point in Bone Wall, Prison, Spear, Spirit', () => {
// 20 points in Bone Wall: +300% synergy -> multiplier = 4.0
const syn = calculateTeethSynergyMultiplier({ boneWall: 20 })
expect(syn).toBe(4.0)
const d20Syn = calculateTeethDamage(20, { boneWall: 20 })
expect(d20Syn.min).toBe(Math.floor(23 * 4.0))
expect(d20Syn.max).toBe(Math.floor(31 * 4.0))
})
it('routes through calculateSkillDamage correctly', () => {
const res = calculateSkillDamage(67, 10)
expect(res.min).toBe(11)
expect(res.max).toBe(14)
})
})
describe('Skill 68: Bone Armor (白骨装甲)', () => {
it('evaluates base absorption capacity at slvl 1, 10, 20', () => {
expect(calculateBoneArmorCapacity(1)).toBe(20)
expect(calculateBoneArmorCapacity(10)).toBe(110)
expect(calculateBoneArmorCapacity(20)).toBe(210)
})
it('scales with +15 damage per hard point from Bone Wall and Bone Prison', () => {
// 10 pts Bone Wall + 10 pts Bone Prison = 20 pts * 15 = +300 capacity
expect(calculateBoneArmorSynergyBonus({ boneWall: 10, bonePrison: 10 })).toBe(300)
const cap = calculateBoneArmorCapacity(20, { boneWall: 10, bonePrison: 10 })
expect(cap).toBe(210 + 300)
})
it('routes through calculateSkillDamage correctly', () => {
const res = calculateSkillDamage(68, 20)
expect(res.min).toBe(210)
expect(res.max).toBe(210)
})
})
describe('Skill 73: Poison Dagger (淬毒匕首)', () => {
it('evaluates attack rating bonus +30% base + 20%/lvl', () => {
expect(calculatePoisonDaggerAttackRatingBonus(1)).toBe(30)
expect(calculatePoisonDaggerAttackRatingBonus(10)).toBe(210)
expect(calculatePoisonDaggerAttackRatingBonus(20)).toBe(410)
})
it('evaluates duration, bitrates, and total poison damage at slvl 1, 10, 20', () => {
const p1 = calculatePoisonDaggerDamage(1)
expect(p1.durationFrames).toBe(50) // 2.0s
expect(p1.durationSeconds).toBe(2.0)
expect(p1.minRate).toBe(36)
expect(p1.maxRate).toBe(80)
expect(p1.minDamage).toBe(7)
expect(p1.maxDamage).toBe(15)
const p10 = calculatePoisonDaggerDamage(10)
expect(p10.durationFrames).toBe(140) // 5.6s
expect(p10.durationSeconds).toBe(5.6)
expect(p10.minRate).toBe(236)
expect(p10.maxRate).toBe(280)
expect(p10.minDamage).toBe(129)
expect(p10.maxDamage).toBe(153)
const p20 = calculatePoisonDaggerDamage(20)
expect(p20.durationFrames).toBe(240) // 9.6s
expect(p20.durationSeconds).toBe(9.6)
expect(p20.minRate).toBe(576)
expect(p20.maxRate).toBe(620)
expect(p20.minDamage).toBe(540)
expect(p20.maxDamage).toBe(581)
})
it('scales with +20% synergy per point in Poison Explosion and Poison Nova', () => {
const syn = calculatePoisonDaggerSynergyMultiplier({ poisonExplosion: 5, poisonNova: 5 })
expect(syn).toBe(3.0) // 1.0 + 10 * 0.20 = 3.0
const p20Syn = calculatePoisonDaggerDamage(20, { poisonExplosion: 5, poisonNova: 5 })
expect(p20Syn.minRate).toBe(Math.trunc(576 * 3.0))
expect(p20Syn.maxRate).toBe(Math.trunc(620 * 3.0))
})
it('routes through calculateSkillDamage correctly', () => {
const res = calculateSkillDamage(73, 20)
expect(res.min).toBe(540)
expect(res.max).toBe(581)
})
})
describe('Skill 74: Corpse Explosion (尸体爆炸)', () => {
it('evaluates exact 70% to 120% base HP split 50% physical and 50% fire', () => {
const baseHp = 1000
const ce = calculateCorpseExplosionDamage(baseHp, 100) // 100% roll
expect(ce.phys).toBe(500)
expect(ce.fire).toBe(500)
expect(ce.minTotal).toBe(700)
expect(ce.maxTotal).toBe(1200)
expect(ce.minPhys).toBe(350)
expect(ce.minFire).toBe(350)
expect(ce.maxPhys).toBe(600)
expect(ce.maxFire).toBe(600)
})
it('evaluates blast radius scaling in yards and pixels', () => {
expect(calculateCorpseExplosionRadiusYards(1)).toBe(5.33)
expect(calculateCorpseExplosionRadiusYards(10)).toBe(11.33)
expect(calculateCorpseExplosionRadiusYards(20)).toBe(18.0)
expect(calculateCorpseExplosionRadiusPx(1)).toBe(213)
expect(calculateCorpseExplosionRadiusPx(20)).toBe(720)
})
it('routes through calculateSkillDamage correctly', () => {
const res = calculateSkillDamage(74, 1, {}, { min: 1000, max: 1000 })
expect(res.min).toBe(700)
expect(res.max).toBe(1200)
})
})
describe('Skill 78: Bone Wall (骨墙)', () => {
it('evaluates base HP, duration, and segment count', () => {
const bw1 = calculateBoneWallStats(1)
expect(bw1.hp).toBe(19)
expect(bw1.durationSeconds).toBe(24.0)
expect(bw1.durationFrames).toBe(600)
expect(bw1.segmentCount).toBe(5)
const bw10 = calculateBoneWallStats(10)
expect(bw10.hp).toBe(60) // 19 + 9 * 4.6 = 60.4 -> 60
const bw20 = calculateBoneWallStats(20)
expect(bw20.hp).toBe(106) // 19 + 19 * 4.6 = 106.4 -> 106
})
it('scales with +10% synergy from Bone Armor and Bone Prison', () => {
const bw20Syn = calculateBoneWallStats(20, { boneArmor: 10, bonePrison: 10 })
// 20 points * 10% = +200% -> 3.0x
expect(bw20Syn.hp).toBe(Math.floor(106.4 * 3.0))
})
it('routes through calculateSkillDamage correctly', () => {
const res = calculateSkillDamage(78, 1)
expect(res.min).toBe(19)
expect(res.max).toBe(19)
})
})
describe('Skill 83: Poison Explosion (毒爆)', () => {
it('evaluates duration, bitrates, and total poison damage at slvl 1, 10, 20', () => {
const pe1 = calculatePoisonExplosionDamage(1)
expect(pe1.durationFrames).toBe(50)
expect(pe1.durationSeconds).toBe(2.0)
expect(pe1.minRate).toBe(128)
expect(pe1.maxRate).toBe(384)
expect(pe1.minDamage).toBe(25)
expect(pe1.maxDamage).toBe(75)
const pe10 = calculatePoisonExplosionDamage(10)
expect(pe10.durationFrames).toBe(140)
expect(pe10.durationSeconds).toBe(5.6)
expect(pe10.minRate).toBe(480)
expect(pe10.maxRate).toBe(736)
expect(pe10.minDamage).toBe(262)
expect(pe10.maxDamage).toBe(402)
const pe20 = calculatePoisonExplosionDamage(20)
expect(pe20.durationFrames).toBe(240)
expect(pe20.durationSeconds).toBe(9.6)
expect(pe20.minRate).toBe(1248)
expect(pe20.maxRate).toBe(1504)
expect(pe20.minDamage).toBe(1170)
expect(pe20.maxDamage).toBe(1410)
})
it('scales with +15% synergy from Poison Dagger and Poison Nova', () => {
const syn = calculatePoisonExplosionSynergyMultiplier({ poisonDagger: 10, poisonNova: 10 })
expect(syn).toBe(4.0) // 1.0 + 20 * 0.15 = 4.0
const pe20Syn = calculatePoisonExplosionDamage(20, { poisonDagger: 10, poisonNova: 10 })
expect(pe20Syn.minRate).toBe(1248 * 4)
expect(pe20Syn.maxRate).toBe(1504 * 4)
})
it('routes through calculateSkillDamage correctly', () => {
const res = calculateSkillDamage(83, 20)
expect(res.min).toBe(1170)
expect(res.max).toBe(1410)
})
})
describe('Skill 84: Bone Spear (骨矛)', () => {
it('evaluates exact magic damage at slvl 1, 10, 20 without synergies', () => {
const d1 = calculateBoneSpearDamage(1)
expect(d1.min).toBe(16)
expect(d1.max).toBe(24)
const d10 = calculateBoneSpearDamage(10)
expect(d10.min).toBe(90)
expect(d10.max).toBe(98)
const d20 = calculateBoneSpearDamage(20)
expect(d20.min).toBe(192)
expect(d20.max).toBe(204)
})
it('scales with +7% synergy from Teeth, Bone Wall, Prison, Spirit', () => {
const syn = calculateBoneSpearSynergyMultiplier({ teeth: 10, boneWall: 10, bonePrison: 10, boneSpirit: 10 })
expect(syn).toBe(1.0 + 40 * 0.07) // 3.8
const d20Syn = calculateBoneSpearDamage(20, { teeth: 10, boneWall: 10, bonePrison: 10, boneSpirit: 10 })
expect(d20Syn.min).toBe(Math.floor(192 * 3.8))
expect(d20Syn.max).toBe(Math.floor(204 * 3.8))
})
it('routes through calculateSkillDamage correctly', () => {
const res = calculateSkillDamage(84, 20)
expect(res.min).toBe(192)
expect(res.max).toBe(204)
})
})
describe('Skill 88: Bone Prison (骨牢)', () => {
it('evaluates base HP, duration, and structure', () => {
const bp1 = calculateBonePrisonStats(1)
expect(bp1.hp).toBe(19)
expect(bp1.durationSeconds).toBe(24.0)
expect(bp1.durationFrames).toBe(600)
expect(bp1.segmentCount).toBe(16)
const bp20 = calculateBonePrisonStats(20)
expect(bp20.hp).toBe(106)
})
it('scales with +8% synergy from Bone Armor and Bone Wall', () => {
const bp20Syn = calculateBonePrisonStats(20, { boneArmor: 10, boneWall: 10 })
// 20 points * 8% = +160% -> 2.6x
expect(bp20Syn.hp).toBe(Math.floor(106.4 * 2.6))
})
it('routes through calculateSkillDamage correctly', () => {
const res = calculateSkillDamage(88, 1)
expect(res.min).toBe(19)
expect(res.max).toBe(19)
})
})
describe('Skill 92: Poison Nova (剧毒新星)', () => {
it('evaluates fixed 2.0s duration and exact bitrates/damage at slvl 1, 10, 20', () => {
const pn1 = calculatePoisonNovaDamage(1)
expect(pn1.durationFrames).toBe(50)
expect(pn1.durationSeconds).toBe(2.0)
expect(pn1.minRate).toBe(256)
expect(pn1.maxRate).toBe(464)
expect(pn1.minDamage).toBe(50)
expect(pn1.maxDamage).toBe(90)
const pn10 = calculatePoisonNovaDamage(10)
expect(pn10.durationFrames).toBe(50)
expect(pn10.durationSeconds).toBe(2.0)
expect(pn10.minRate).toBe(896)
expect(pn10.maxRate).toBe(1104)
expect(pn10.minDamage).toBe(175)
expect(pn10.maxDamage).toBe(215)
const pn20 = calculatePoisonNovaDamage(20)
expect(pn20.durationFrames).toBe(50)
expect(pn20.durationSeconds).toBe(2.0)
expect(pn20.minRate).toBe(2048)
expect(pn20.maxRate).toBe(2256)
expect(pn20.minDamage).toBe(400)
expect(pn20.maxDamage).toBe(440)
})
it('spawns exactly 64 radial bolts in 360 degrees foreshortened by 0.5', () => {
const bolts = spawnPoisonNovaRing(100, 100, 200)
expect(bolts.length).toBe(64)
expect(bolts[0]!.missileType).toBe('poisonnova')
expect(bolts[0]!.statusEffect).toBe('poison')
expect(bolts[0]!.statusDuration).toBe(50)
// Test vertical velocity foreshortening
const northBolt = bolts[16] // angle = PI / 2
expect(northBolt!.vx).toBeCloseTo(0, 1)
expect(northBolt!.vy).toBeCloseTo(12 * ISO_GROUND_ASPECT_RATIO, 1)
})
it('scales with +10% synergy from Poison Dagger and Poison Explosion', () => {
const syn = calculatePoisonNovaSynergyMultiplier({ poisonDagger: 10, poisonExplosion: 10 })
expect(syn).toBe(3.0)
const pn20Syn = calculatePoisonNovaDamage(20, { poisonDagger: 10, poisonExplosion: 10 })
expect(pn20Syn.minRate).toBe(2048 * 3)
expect(pn20Syn.maxRate).toBe(2256 * 3)
expect(pn20Syn.minDamage).toBe(1200)
expect(pn20Syn.maxDamage).toBe(1321)
})
it('routes through calculateSkillDamage correctly', () => {
const res = calculateSkillDamage(92, 20)
expect(res.min).toBe(400)
expect(res.max).toBe(440)
})
})
describe('Skill 93: Bone Spirit (白骨之魂)', () => {
it('evaluates exact magic damage at slvl 1, 10, 20 without synergies', () => {
const d1 = calculateBoneSpiritDamage(1)
expect(d1.min).toBe(20)
expect(d1.max).toBe(30)
const d10 = calculateBoneSpiritDamage(10)
expect(d10.min).toBe(166)
expect(d10.max).toBe(185)
const d20 = calculateBoneSpiritDamage(20)
expect(d20.min).toBe(340)
expect(d20.max).toBe(369)
})
it('scales with +6% synergy from Teeth, Bone Wall, Spear, Prison', () => {
const syn = calculateBoneSpiritSynergyMultiplier({ teeth: 10, boneWall: 10, boneSpear: 10, bonePrison: 10 })
expect(syn).toBe(1.0 + 40 * 0.06) // 3.4
const d20Syn = calculateBoneSpiritDamage(20, { teeth: 10, boneWall: 10, boneSpear: 10, bonePrison: 10 })
expect(d20Syn.min).toBe(Math.floor(340 * 3.4))
expect(d20Syn.max).toBe(Math.floor(369 * 3.4))
})
it('routes through calculateSkillDamage correctly', () => {
const res = calculateSkillDamage(93, 20)
expect(res.min).toBe(340)
expect(res.max).toBe(369)
})
})
describe('Kinematics & Missile Engine Runtime', () => {
it('executes Bone Spear piercing trajectory without terminating on target hit', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const casterStats = new UnitStatList()
const target1Stats = new UnitStatList()
target1Stats.setHp256(1000 * 256)
const target2Stats = new UnitStatList()
target2Stats.setHp256(1000 * 256)
const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any
const target1 = { id: 'mon_1', name: 'Zombie', statList: target1Stats, stateBus: new StateBus(target1Stats) } as any
const target2 = { id: 'mon_2', name: 'Skeleton', statList: target2Stats, stateBus: new StateBus(target2Stats) } as any
const targetPositions = new Map<string, { x: number; y: number }>([
['mon_1', { x: 50, y: 0 }],
['mon_2', { x: 100, y: 0 }],
])
// Spawn Bone Spear flying eastward (startX: 0, startY: 0, targetX: 200, targetY: 0)
engine.spawnMissile({
missileNameOrId: 'bonespear',
sourceSkillId: 84,
slvl: 20,
owner: caster,
startX: 0,
startY: 0,
targetX: 200,
targetY: 0,
dmgPacket: { minDamage: 100, maxDamage: 100, damageType: 'magic' } as any,
})
const activeBefore = engine.getActiveMissiles()
expect(activeBefore.length).toBe(1)
const spear = activeBefore[0]!
expect(spear.canPierce).toBe(true)
// Step simulation until missile reaches mon_1 and continues to mon_2
for (let t = 1; t <= 10; t++) {
engine.tick(t, [target1, target2], targetPositions)
}
// Spear must have pierced target1 (pierceCount >= 1) and remained active
expect(spear.pierceCount).toBeGreaterThanOrEqual(1)
expect(spear.expired).toBe(false)
})
it('steers Bone Spirit homing missile toward target 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(500 * 256)
const caster = { id: 'player', name: 'Necromancer', statList: casterStats, stateBus: new StateBus(casterStats) } as any
const target = { id: 'mon_target', name: 'Demon', statList: targetStats, stateBus: new StateBus(targetStats) } as any
// Target is positioned south-east
const targetPositions = new Map<string, { x: number; y: number }>([
['mon_target', { x: 100, y: 100 }],
])
// Launch Bone Spirit pointing north (startX: 0, startY: 0, targetX: 0, targetY: -100)
engine.spawnMissile({
missileNameOrId: 'bonespirit',
sourceSkillId: 93,
slvl: 20,
owner: caster,
startX: 0,
startY: 0,
targetX: 0,
targetY: -100,
dmgPacket: { minDamage: 200, maxDamage: 200, damageType: 'magic' } as any,
})
const active = engine.getActiveMissiles()
const spirit = active[0]!
// Initial velocity points north
expect(spirit.vy).toBeLessThan(0)
// Step simulation: homing logic should reorient missile toward mon_target (south)
engine.tick(1, [target], targetPositions)
// After tick with homing logic, vy must have turned positive (heading south toward target)
expect(spirit.vy).toBeGreaterThan(0)
})
})
})