/** * Adversarial Challenger Test Suite: Necromancer Poison & Bone Spells * Milestone M2 Numerical Boundary & Synergy Stress Audit * * Ground Truth: Diablo II v1.13c (D2Common.dll, D2Game.dll, Skills.txt, Missiles.txt) * Worktree: .worktrees/necro-poison-bone * * Verifies: * 1. Teeth count scaling up to the strict 24 cap: min(ln12, 24). * 2. Bone Armor capacity (20 base, 110 at slvl 10, 210 at slvl 20 + 15/pt synergy), * and physical absorption exclusivity (absorbs physical only; zero absorption of * elemental, magic, or poison damage). * 3. Poison Dagger weapon restriction (rejects bare hands, swords, axes, bows, maces, * staves, polearms; accepts daggers), attack rating scaling, and poison bitrate/duration. * 4. Poison Nova fixed 50 frames (2.0s) duration across all skill levels and synergies. * 5. Cross-skill synergy isolation (hard points only, no cross-pollution). */ import { describe, expect, it } from 'vitest' import { BATCH1_SKILLS, calculateTeethCount, calculateTeethDamage, calculateTeethSynergyMultiplier, calculateBoneArmorCapacity, calculateBoneArmorSynergyBonus, calculatePoisonDaggerAttackRatingBonus, calculatePoisonDaggerDamage, calculatePoisonDaggerSynergyMultiplier, calculatePoisonNovaDamage, calculatePoisonNovaSynergyMultiplier, spawnPoisonNovaRing, calculateBoneSpearDamage, calculateBoneSpearSynergyMultiplier, calculateBoneSpiritDamage, calculateBoneSpiritSynergyMultiplier, calculateCorpseExplosionDamage, calculateCorpseExplosionRadiusYards, calculateCorpseExplosionRadiusPx, calculateBoneWallStats, calculateBonePrisonStats, calculatePoisonExplosionDamage, calculatePoisonExplosionSynergyMultiplier, calculateSkillDamage, ISO_GROUND_ASPECT_RATIO, } from '../../../src/server/skills/skills.ts' import { getAttackWeaponInfo } from '../../../src/common/skills/weapon-combat-meta.ts' import { StateBus } from '../../../src/server/engine/state-bus.ts' import { UnitStatList, FIXED_ONE } from '../../../src/server/engine/stat-list.ts' describe('Adversarial Challenger: Necromancer Poison & Bone Synergy & Boundary Stress', () => { // ========================================================================= // Challenge Dimension 1: Teeth Projectile Count & Scaling Boundary Stress // ========================================================================= describe('Dimension 1: Teeth Projectile Count min(ln12, 24) Cap Stress', () => { it('scales teeth count strictly linearly from slvl 1 to 23, then caps at 24 at slvl 23+', () => { // ln12 formula: par1 + (lvl - 1) * par2 = 2 + (lvl - 1) * 1 for (let lvl = 1; lvl <= 22; lvl++) { const expected = 2 + (lvl - 1) expect(calculateTeethCount(lvl)).toBe(expected) } // Exact boundary transition: slvl 22 -> 23 teeth, slvl 23 -> 24 teeth (cap reached) expect(calculateTeethCount(22)).toBe(23) expect(calculateTeethCount(23)).toBe(24) expect(calculateTeethCount(24)).toBe(24) expect(calculateTeethCount(25)).toBe(24) expect(calculateTeethCount(30)).toBe(24) expect(calculateTeethCount(50)).toBe(24) expect(calculateTeethCount(99)).toBe(24) }) it('robustly clamps non-standard boundary inputs (<=0, floats, NaN, Infinity)', () => { expect(calculateTeethCount(0)).toBe(2) expect(calculateTeethCount(-10)).toBe(2) expect(calculateTeethCount(1.7)).toBe(2) expect(calculateTeethCount(23.9)).toBe(24) expect(calculateTeethCount(Number.NaN)).toBe(2) // Non-finite input falls back safely to slvl 1 (2 teeth) expect(calculateTeethCount(Infinity)).toBe(2) // Large finite level hits the 24 cap expect(calculateTeethCount(9999)).toBe(24) }) it('stress-tests Teeth synergy multiplier under extreme hard points (up to 80 points)', () => { // 0 synergy points: 1.0x expect(calculateTeethSynergyMultiplier({})).toBe(1.0) expect(calculateTeethSynergyMultiplier({ boneWall: 0, bonePrison: 0 })).toBe(1.0) // 4 synergies maxed at 20 hard points each = 80 points * 15% = +1200% -> 13.0x multiplier const maxSynergies = { boneWall: 20, bonePrison: 20, boneSpear: 20, boneSpirit: 20, } expect(calculateTeethSynergyMultiplier(maxSynergies)).toBe(13.0) // Damage scaling under 80 synergy points const d1Max = calculateTeethDamage(1, maxSynergies) expect(d1Max.min).toBe(Math.floor(2 * 13.0)) // 26 expect(d1Max.max).toBe(Math.floor(4 * 13.0)) // 52 const d20Max = calculateTeethDamage(20, maxSynergies) expect(d20Max.min).toBe(Math.floor(23 * 13.0)) // 299 expect(d20Max.max).toBe(Math.floor(31 * 13.0)) // 403 }) }) // ========================================================================= // Challenge Dimension 2: Bone Armor Capacity & Physical Absorption Stress // ========================================================================= describe('Dimension 2: Bone Armor Capacity & Physical Absorption Exclusivity', () => { it('evaluates exact 1.13c capacity progression: 20 base, 110 at slvl 10, 210 at slvl 20', () => { expect(calculateBoneArmorCapacity(1)).toBe(20) expect(calculateBoneArmorCapacity(10)).toBe(110) expect(calculateBoneArmorCapacity(20)).toBe(210) expect(calculateBoneArmorCapacity(30)).toBe(310) expect(calculateBoneArmorCapacity(99)).toBe(1000) // Extreme inputs expect(calculateBoneArmorCapacity(0)).toBe(20) expect(calculateBoneArmorCapacity(-5)).toBe(20) expect(calculateBoneArmorCapacity(Number.NaN)).toBe(20) }) it('calculates exact synergy bonus: +15 per hard point in Bone Wall and Bone Prison', () => { expect(calculateBoneArmorSynergyBonus({})).toBe(0) expect(calculateBoneArmorSynergyBonus({ boneWall: 1 })).toBe(15) expect(calculateBoneArmorSynergyBonus({ bonePrison: 1 })).toBe(15) expect(calculateBoneArmorSynergyBonus({ boneWall: 20, bonePrison: 0 })).toBe(300) expect(calculateBoneArmorSynergyBonus({ boneWall: 0, bonePrison: 20 })).toBe(300) expect(calculateBoneArmorSynergyBonus({ boneWall: 20, bonePrison: 20 })).toBe(600) // Total capacity at slvl 20 with 40 hard synergy points = 210 + 600 = 810 HP pool const maxCap = calculateBoneArmorCapacity(20, { boneWall: 20, bonePrison: 20 }) expect(maxCap).toBe(810) }) it('empirically verifies Bone Armor absorbs physical damage in StateBus', () => { const stats = new UnitStatList() const bus = new StateBus(stats) // Apply Bone Armor with 210 capacity bus.applyState({ stateNameOrId: 'bonearmor', slvl: 20, stats: { damagearmor: 210 }, }) expect(bus.hasState('bonearmor')).toBe(true) // Hit 1: 100 Physical Damage const outcome1 = bus.triggerReactiveEvents({ event: 'absorbdamage', incomingPhys256: 100 * FIXED_ONE, incomingElem256: 0, }) expect(outcome1.absorbedPhys256).toBe(100 * FIXED_ONE) expect(outcome1.absorbedElem256).toBe(0) // Remaining shield pool should be 110 const entry1 = bus.getState('bonearmor') expect(entry1).toBeDefined() expect(entry1?.stats['damagearmor']).toBe(110) // Hit 2: 150 Physical Damage (exceeds remaining 110 shield pool) const outcome2 = bus.triggerReactiveEvents({ event: 'absorbdamage', incomingPhys256: 150 * FIXED_ONE, incomingElem256: 0, }) expect(outcome2.absorbedPhys256).toBe(110 * FIXED_ONE) // Shield should be completely depleted and state removed expect(bus.hasState('bonearmor')).toBe(false) }) it('empirically verifies Bone Armor does NOT absorb elemental, magic, or poison damage', () => { const stats = new UnitStatList() const bus = new StateBus(stats) bus.applyState({ stateNameOrId: 'bonearmor', slvl: 20, stats: { damagearmor: 210 }, }) // Test A: Incoming Fire Damage (150 fire) const fireOutcome = bus.triggerReactiveEvents({ event: 'absorbdamage', incomingPhys256: 0, incomingElem256: 150 * FIXED_ONE, elemType: 'fire', }) expect(fireOutcome.absorbedPhys256).toBe(0) expect(fireOutcome.absorbedElem256).toBe(0) // Shield pool remains completely intact at 210 let entry = bus.getState('bonearmor') expect(entry?.stats['damagearmor']).toBe(210) // Test B: Incoming Magic Damage (100 magic) const magOutcome = bus.triggerReactiveEvents({ event: 'absorbdamage', incomingPhys256: 0, incomingElem256: 100 * FIXED_ONE, elemType: 'mag', }) expect(magOutcome.absorbedPhys256).toBe(0) expect(magOutcome.absorbedElem256).toBe(0) entry = bus.getState('bonearmor') expect(entry?.stats['damagearmor']).toBe(210) // Test C: Incoming Poison Damage (80 poison) const poisOutcome = bus.triggerReactiveEvents({ event: 'absorbdamage', incomingPhys256: 0, incomingElem256: 80 * FIXED_ONE, elemType: 'pois', isPoison: true, }) expect(poisOutcome.absorbedPhys256).toBe(0) expect(poisOutcome.absorbedElem256).toBe(0) entry = bus.getState('bonearmor') expect(entry?.stats['damagearmor']).toBe(210) // Test D: Hybrid Damage (60 physical + 120 cold) const hybridOutcome = bus.triggerReactiveEvents({ event: 'absorbdamage', incomingPhys256: 60 * FIXED_ONE, incomingElem256: 120 * FIXED_ONE, elemType: 'cold', }) expect(hybridOutcome.absorbedPhys256).toBe(60 * FIXED_ONE) expect(hybridOutcome.absorbedElem256).toBe(0) // Cold is NOT absorbed! entry = bus.getState('bonearmor') expect(entry?.stats['damagearmor']).toBe(150) // 210 - 60 = 150 }) }) // ========================================================================= // Challenge Dimension 3: Poison Dagger Weapon Restriction & Scaling Stress // ========================================================================= describe('Dimension 3: Poison Dagger Weapon Restriction & Scaling Stress', () => { it('strictly accepts daggers and rejects bare hands, swords, axes, bows, and others', () => { // 1. Bare hands / Unarmed expect(getAttackWeaponInfo(null).subtype).toBe('fist') expect(getAttackWeaponInfo(undefined).subtype).toBe('fist') expect(getAttackWeaponInfo({} as any).subtype).toBe('fist') expect(getAttackWeaponInfo(null).subtype === 'dagger').toBe(false) // 2. Swords (1H & 2H) expect(getAttackWeaponInfo({ code: 'ssd', type: 'swor', name: 'Short Sword' }).subtype).toBe('sword') expect(getAttackWeaponInfo({ code: 'bsd', type: 'swor', name: 'Broad Sword' }).subtype).toBe('sword') expect(getAttackWeaponInfo({ code: 'gsd', type: 'swor', name: 'Great Sword' }).subtype).toBe('sword') expect(getAttackWeaponInfo({ code: 'clm', type: 'swor', nameZh: '双手大剑' }).subtype).toBe('sword') expect(getAttackWeaponInfo({ code: 'ssd', type: 'swor', name: 'Short Sword' }).subtype === 'dagger').toBe(false) // 3. Axes expect(getAttackWeaponInfo({ code: 'hax', type: 'axe', name: 'Hand Axe' }).subtype).toBe('axe') expect(getAttackWeaponInfo({ code: '2ax', type: 'axe', name: 'Double Axe' }).subtype).toBe('axe') expect(getAttackWeaponInfo({ code: 'hax', type: 'axe', nameZh: '手斧' }).subtype === 'dagger').toBe(false) // 4. Bows & Crossbows expect(getAttackWeaponInfo({ code: 'sbw', type: 'bow', name: 'Short Bow' }).subtype).toBe('bow') expect(getAttackWeaponInfo({ code: 'lxb', type: 'xbow', name: 'Light Crossbow' }).subtype).toBe('crossbow') expect(getAttackWeaponInfo({ code: 'sbw', type: 'bow', name: 'Short Bow' }).subtype === 'dagger').toBe(false) // 5. Maces, Staves, Polearms, Spears, Wands expect(getAttackWeaponInfo({ code: 'clb', type: 'club', name: 'Club' }).subtype).toBe('mace') expect(getAttackWeaponInfo({ code: 'cst', type: 'staf', name: 'Short Staff' }).subtype).toBe('staff') expect(getAttackWeaponInfo({ code: 'vou', type: 'pole', name: 'Voulge' }).subtype).toBe('polearm') expect(getAttackWeaponInfo({ code: 'spr', type: 'spea', name: 'Spear' }).subtype).toBe('spear') expect(getAttackWeaponInfo({ code: 'wnd', type: 'wand', name: 'Wand' }).subtype).toBe('fist') // 6. Daggers (MUST be accepted) const dagger1 = getAttackWeaponInfo({ code: 'dgr', type: 'knif', name: 'Dagger' }) expect(dagger1.subtype).toBe('dagger') expect(dagger1.subtype === 'dagger').toBe(true) const dirk = getAttackWeaponInfo({ code: 'dir', type: 'knif', name: 'Dirk' }) expect(dirk.subtype).toBe('dagger') const kris = getAttackWeaponInfo({ code: 'kri', type: 'knif', name: 'Kris' }) expect(kris.subtype).toBe('dagger') const poignard = getAttackWeaponInfo({ code: '9dg', type: 'knif', name: 'Poignard' }) expect(poignard.subtype).toBe('dagger') const daggerZh = getAttackWeaponInfo({ code: 'dgr', nameZh: '匕首' }) expect(daggerZh.subtype).toBe('dagger') const dirkZh = getAttackWeaponInfo({ code: 'dir', nameZh: '短刀' }) expect(dirkZh.subtype).toBe('dagger') }) it('exposes weapon classification defect: 1.13c dagger "Blade" (code bld, type knif) misclassified as sword', () => { // 1.13c ground truth: code 'bld' (Blade) has type 'knif' in Weapons.txt. // However, getAttackWeaponInfo matches /blade/i under the sword branch first, // misclassifying this authentic 1.13c dagger as 'sword' and blocking Poison Dagger casting! const blade = getAttackWeaponInfo({ code: 'bld', type: 'knif', name: 'Blade' }) expect(blade.subtype).toBe('dagger') }) it('evaluates attack rating bonus progression (+30% base, +20%/lvl)', () => { expect(calculatePoisonDaggerAttackRatingBonus(1)).toBe(30) expect(calculatePoisonDaggerAttackRatingBonus(2)).toBe(50) expect(calculatePoisonDaggerAttackRatingBonus(10)).toBe(210) expect(calculatePoisonDaggerAttackRatingBonus(20)).toBe(410) expect(calculatePoisonDaggerAttackRatingBonus(30)).toBe(610) expect(calculatePoisonDaggerAttackRatingBonus(99)).toBe(1990) expect(calculatePoisonDaggerAttackRatingBonus(0)).toBe(30) expect(calculatePoisonDaggerAttackRatingBonus(Number.NaN)).toBe(30) }) it('evaluates duration, bitrates, and damage scaling under extreme synergies', () => { // Duration scales with slvl: 50 + (slvl - 1) * 10 const pd1 = calculatePoisonDaggerDamage(1) expect(pd1.durationFrames).toBe(50) expect(pd1.durationSeconds).toBe(2.0) expect(pd1.minRate).toBe(36) expect(pd1.maxRate).toBe(80) const pd20 = calculatePoisonDaggerDamage(20) expect(pd20.durationFrames).toBe(240) expect(pd20.durationSeconds).toBe(9.6) expect(pd20.minRate).toBe(576) expect(pd20.maxRate).toBe(620) // Synergy: 20 pts Poison Explosion + 20 pts Poison Nova = 40 pts * 20% = +800% -> 9.0x multiplier const synMax = calculatePoisonDaggerSynergyMultiplier({ poisonExplosion: 20, poisonNova: 20 }) expect(synMax).toBe(9.0) const pd20Syn = calculatePoisonDaggerDamage(20, { poisonExplosion: 20, poisonNova: 20 }) expect(pd20Syn.durationFrames).toBe(240) // Duration does NOT scale with synergy expect(pd20Syn.minRate).toBe(576 * 9) expect(pd20Syn.maxRate).toBe(620 * 9) expect(pd20Syn.minDamage).toBe(Math.floor((576 * 9 * 240) / 256)) // 4860 expect(pd20Syn.maxDamage).toBe(Math.floor((620 * 9 * 240) / 256)) // 5231 }) }) // ========================================================================= // Challenge Dimension 4: Poison Nova Fixed 50 Frames (2.0s) Duration Stress // ========================================================================= describe('Dimension 4: Poison Nova Fixed 50 Frames (2.0s) Duration Invariant', () => { it('strictly maintains 50 frames (2.0s) duration across all skill levels from 1 to 99', () => { const testLevels = [1, 2, 5, 10, 15, 20, 25, 30, 50, 99] for (const lvl of testLevels) { const spec = calculatePoisonNovaDamage(lvl) expect(spec.durationFrames).toBe(50) expect(spec.durationSeconds).toBe(2.0) } }) it('strictly maintains 50 frames duration even when synergies are applied at maximum', () => { const maxSynergies = { poisonDagger: 20, poisonExplosion: 20 } const syn = calculatePoisonNovaSynergyMultiplier(maxSynergies) expect(syn).toBe(5.0) // 1.0 + 40 * 0.10 = 5.0 const spec = calculatePoisonNovaDamage(20, maxSynergies) expect(spec.durationFrames).toBe(50) // Fixed! expect(spec.durationSeconds).toBe(2.0) expect(spec.minRate).toBe(2048 * 5) expect(spec.maxRate).toBe(2256 * 5) expect(spec.minDamage).toBe(Math.floor((2048 * 5 * 50) / 256)) // 2000 expect(spec.maxDamage).toBe(Math.floor((2256 * 5 * 50) / 256)) // 2203 }) it('verifies spawnPoisonNovaRing generates exactly 64 bolts with 50 frames duration', () => { const ring = spawnPoisonNovaRing(0, 0, 100) expect(ring.length).toBe(64) for (const bolt of ring) { expect(bolt.missileType).toBe('poisonnova') expect(bolt.statusEffect).toBe('poison') expect(bolt.statusDuration).toBe(50) // 50 frames duration on each missile } // Check angular coverage (360 degrees, 5.625 deg per bolt) const angles = ring.map(b => Math.atan2(b.vy / ISO_GROUND_ASPECT_RATIO, b.vx)) // Verify first bolt is along X axis (angle ≈ 0) expect(ring[0]!.vx).toBeCloseTo(12, 1) expect(ring[0]!.vy).toBeCloseTo(0, 1) // Verify quarter bolt (index 16) is along +Y axis (angle ≈ PI/2) expect(ring[16]!.vx).toBeCloseTo(0, 1) expect(ring[16]!.vy).toBeCloseTo(12 * ISO_GROUND_ASPECT_RATIO, 1) // 6 // Verify half bolt (index 32) is along -X axis (angle ≈ PI) expect(ring[32]!.vx).toBeCloseTo(-12, 1) expect(ring[32]!.vy).toBeCloseTo(0, 1) // Verify three-quarter bolt (index 48) is along -Y axis (angle ≈ -PI/2) expect(ring[48]!.vx).toBeCloseTo(0, 1) expect(ring[48]!.vy).toBeCloseTo(-12 * ISO_GROUND_ASPECT_RATIO, 1) // -6 }) }) // ========================================================================= // Challenge Dimension 5: Cross-Skill Synergies & Hard-Point Invariants // ========================================================================= describe('Dimension 5: Cross-Skill Synergies & Hard-Point Isolation', () => { it('verifies Bone Spear receives +7% synergy from Teeth, Bone Wall, Prison, Spirit', () => { const syn = calculateBoneSpearSynergyMultiplier({ teeth: 20, boneWall: 20, bonePrison: 20, boneSpirit: 20, }) // 80 hard points * 7% = +560% -> 6.6x multiplier expect(syn).toBe(1.0 + 80 * 0.07) const d20 = calculateBoneSpearDamage(20, { teeth: 20, boneWall: 20, bonePrison: 20, boneSpirit: 20 }) expect(d20.min).toBe(Math.floor(192 * 6.6)) expect(d20.max).toBe(Math.floor(204 * 6.6)) }) it('verifies Bone Spirit receives +6% synergy from Teeth, Bone Wall, Spear, Prison', () => { const syn = calculateBoneSpiritSynergyMultiplier({ teeth: 20, boneWall: 20, boneSpear: 20, bonePrison: 20, }) // 80 hard points * 6% = +480% -> 5.8x multiplier expect(syn).toBe(1.0 + 80 * 0.06) const d20 = calculateBoneSpiritDamage(20, { teeth: 20, boneWall: 20, boneSpear: 20, bonePrison: 20 }) expect(d20.min).toBe(Math.floor(340 * 5.8)) expect(d20.max).toBe(Math.floor(369 * 5.8)) }) it('verifies Corpse Explosion scales unscaled base HP (50% phys / 50% fire) and radius', () => { const ce100 = calculateCorpseExplosionDamage(5000, 100) // 100% roll of 5000 HP monster expect(ce100.phys).toBe(2500) expect(ce100.fire).toBe(2500) expect(ce100.minTotal).toBe(3500) // 70% of 5000 expect(ce100.maxTotal).toBe(6000) // 120% of 5000 expect(ce100.minPhys).toBe(1750) expect(ce100.maxPhys).toBe(3000) expect(ce100.minFire).toBe(1750) expect(ce100.maxFire).toBe(3000) // Radius verification expect(calculateCorpseExplosionRadiusYards(1)).toBe(2.67) expect(calculateCorpseExplosionRadiusYards(10)).toBe(5.67) expect(calculateCorpseExplosionRadiusYards(20)).toBe(9.0) }) it('verifies Bone Wall and Bone Prison fixed 24.0s duration and segment counts', () => { const wall = calculateBoneWallStats(20) expect(wall.durationSeconds).toBe(24.0) expect(wall.durationFrames).toBe(600) expect(wall.segmentCount).toBe(5) const prison = calculateBonePrisonStats(20) expect(prison.durationSeconds).toBe(24.0) expect(prison.durationFrames).toBe(600) expect(prison.segmentCount).toBe(16) }) it('verifies Poison Explosion duration and +15% synergy scaling', () => { const pe1 = calculatePoisonExplosionDamage(1) expect(pe1.durationFrames).toBe(50) expect(pe1.durationSeconds).toBe(2.0) const pe20 = calculatePoisonExplosionDamage(20) expect(pe20.durationFrames).toBe(240) expect(pe20.durationSeconds).toBe(9.6) // Synergies: +15% per point in Poison Dagger and Poison Nova const syn = calculatePoisonExplosionSynergyMultiplier({ poisonDagger: 20, poisonNova: 20 }) expect(syn).toBe(1.0 + 40 * 0.15) // 7.0x const pe20Syn = calculatePoisonExplosionDamage(20, { poisonDagger: 20, poisonNova: 20 }) expect(pe20Syn.minRate).toBe(1248 * 7) expect(pe20Syn.maxRate).toBe(1504 * 7) }) it('verifies BATCH1_SKILLS length remains strictly 38 throughout all PnB wiring', () => { expect(Object.keys(BATCH1_SKILLS).length).toBe(38) }) }) })