482 lines
22 KiB
TypeScript
482 lines
22 KiB
TypeScript
/**
|
|
* 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/game/skills.ts'
|
|
import { getAttackWeaponInfo } from '../../../src/scene/act-scene.ts'
|
|
import { StateBus } from '../../../src/game/engine/state-bus.ts'
|
|
import { UnitStatList, FIXED_ONE } from '../../../src/game/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)
|
|
})
|
|
})
|
|
})
|