/** * 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/server/skills/skills.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 { MissileEngine } from '../../../src/server/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(2.67) expect(calculateCorpseExplosionRadiusYards(10)).toBe(5.67) expect(calculateCorpseExplosionRadiusYards(20)).toBe(9.0) expect(calculateCorpseExplosionRadiusPx(1)).toBe(107) expect(calculateCorpseExplosionRadiusPx(20)).toBe(360) }) 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([ ['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([ ['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) }) }) })