diablo2-web/tests/formulas.test.ts

814 lines
34 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from 'vitest'
import {
calculateHitChance,
calculatePlayerBlockChance,
calculateCriticalChance,
evaluateCriticalAndDeadlyStrike,
calculateCrushingBlowDamage,
calculateOpenWoundsDamage,
calculateLeech,
calculateFreezeTarget,
calculateResistanceFactor,
calculateResistanceReduction,
calculateMonsterRegeneration,
evaluatePreventMonsterHeal,
calculateKnockbackSuccess,
calculateSlowTarget,
calculateHitBlindsTarget,
calculateHitCausesFlee,
calculatePhysicalWeaponDamage,
calculatePoisonStack,
checkHitRecovery,
calculateAttack,
D2_EXPERIENCE_TABLE_99,
} from '../src/game/formulas.ts'
describe('Diablo II Combat Mathematics (Issue #24 / M9)', () => {
describe('calculateHitChance (AR vs DR)', () => {
it('calculates exact 50% hit chance when AR == DR and AttackerLevel == DefenderLevel', () => {
// 100% * (2 * 1000 / (1000 + 1000)) * (20 / (20 + 20)) = 100% * 1 * 0.5 = 50%
const chance = calculateHitChance(1000, 1000, 20, 20)
expect(chance).toBe(50)
})
it('clamps upper bound to 95% even with overwhelmingly high AR and level advantage', () => {
const chance = calculateHitChance(50000, 10, 90, 1)
expect(chance).toBe(95)
})
it('clamps lower bound to 5% even with tiny AR and severe level disadvantage', () => {
const chance = calculateHitChance(10, 50000, 1, 90)
expect(chance).toBe(5)
})
it('accurately reproduces community calculator scenario (Level 25 Paladin vs Level 20 Monster)', () => {
const chance = calculateHitChance(1200, 800, 25, 20)
expect(chance).toBeCloseTo(66.67, 1)
})
it('handles Ignore Target Defense (ITD) against normal monsters vs bosses', () => {
// Normal monster: ITD treats defense as 0.
// 100% * (2 * 1000 / (1000 + 0)) * (20 / (20 + 20)) = 100% * 2 * 0.5 = 100% -> clamped to 95%
const normalWithItd = calculateHitChance(1000, 10000, 20, 20, { ignoreTargetDefense: true, isBossOrChampion: false })
expect(normalWithItd).toBe(95)
// Boss / Champion: ITD fails! Retains full 10000 defense.
const bossWithItd = calculateHitChance(1000, 10000, 20, 20, { ignoreTargetDefense: true, isBossOrChampion: true })
// 100% * (2000 / 11000) * 0.5 = 9.09%
expect(bossWithItd).toBeCloseTo(9.09, 1)
})
it('halves -% Target Defense against bosses (50% effectiveness)', () => {
// Normal monster: -50% defense reduces 1000 to 500.
const normalDefRed = calculateHitChance(1000, 1000, 20, 20, { targetDefenseReductionPercent: 50, isBossOrChampion: false })
// 100% * (2000 / 1500) * 0.5 = 66.67%
expect(normalDefRed).toBeCloseTo(66.67, 1)
// Boss: -50% defense only reduces 25% (defense 1000 -> 750).
const bossDefRed = calculateHitChance(1000, 1000, 20, 20, { targetDefenseReductionPercent: 50, isBossOrChampion: true })
// 100% * (2000 / 1750) * 0.5 = 57.14%
expect(bossDefRed).toBeCloseTo(57.14, 1)
})
it('treats defender DR as 0 when target is running', () => {
const runningChance = calculateHitChance(1000, 50000, 20, 20, { isTargetRunning: true })
expect(runningChance).toBe(95)
})
})
describe('calculatePlayerBlockChance (Shield Blocking)', () => {
it('returns 0% if character has <= 15 dexterity or no shield block', () => {
expect(calculatePlayerBlockChance(50, 15, 30)).toBe(0)
expect(calculatePlayerBlockChance(0, 100, 30)).toBe(0)
})
it('calculates standard max-block target: 75% cap reached with sufficient dexterity', () => {
const block = calculatePlayerBlockChance(75, 150, 50)
expect(block).toBe(75)
})
it('calculates partial block correctly', () => {
const block = calculatePlayerBlockChance(50, 50, 40)
expect(block).toBe(21)
})
it('reduces block to 1/3 and caps at 25% when running', () => {
const blockRunning = calculatePlayerBlockChance(75, 150, 50, true)
expect(blockRunning).toBe(25)
const partialRunning = calculatePlayerBlockChance(50, 50, 40, true)
expect(partialRunning).toBe(7)
})
})
describe('Critical Strike (CS) vs Deadly Strike (DS)', () => {
it('calculates combined double damage chance (P = CS + DS * (1 - CS))', () => {
expect(calculateCriticalChance(25, 0)).toBe(25)
expect(calculateCriticalChance(0, 30)).toBe(30)
expect(calculateCriticalChance(20, 50)).toBe(60)
expect(calculateCriticalChance(50, 50)).toBe(75)
expect(calculateCriticalChance(100, 100)).toBe(100)
})
it('evaluates CS first, and bypasses DS roll if CS procs', () => {
const rolls = [0.10] // CS roll is 10% (< 50% CS)
let idx = 0
const res = evaluateCriticalAndDeadlyStrike(50, 50, () => rolls[idx++] ?? 0)
expect(res.isCritical).toBe(true)
expect(res.triggeredBy).toBe('critical_strike')
expect(res.csRoll).toBe(10)
expect(res.dsRoll).toBe(0) // DS roll skipped
})
it('evaluates DS if CS roll fails', () => {
const rolls = [0.80, 0.20] // CS roll is 80% (fails), DS roll is 20% (< 50% DS -> procs!)
let idx = 0
const res = evaluateCriticalAndDeadlyStrike(50, 50, () => rolls[idx++] ?? 0)
expect(res.isCritical).toBe(true)
expect(res.triggeredBy).toBe('deadly_strike')
expect(res.csRoll).toBe(80)
expect(res.dsRoll).toBe(20)
})
it('returns none if both CS and DS fail', () => {
const rolls = [0.80, 0.90] // CS roll 80%, DS roll 90%
let idx = 0
const res = evaluateCriticalAndDeadlyStrike(50, 50, () => rolls[idx++] ?? 0)
expect(res.isCritical).toBe(false)
expect(res.triggeredBy).toBe('none')
})
})
describe('calculateCrushingBlowDamage', () => {
it('deals 25% of current HP on melee hit against normal monster', () => {
expect(calculateCrushingBlowDamage(1000, 'normal', false)).toBe(250)
expect(calculateCrushingBlowDamage(1000, false, false)).toBe(250) // backward-compatibility
})
it('deals 12.5% of current HP on ranged hit against normal monster', () => {
expect(calculateCrushingBlowDamage(1000, 'normal', true)).toBe(125)
expect(calculateCrushingBlowDamage(1000, false, true)).toBe(125)
})
it('deals 12.5% of current HP on melee hit against boss', () => {
expect(calculateCrushingBlowDamage(1000, 'boss', false)).toBe(125)
expect(calculateCrushingBlowDamage(1000, true, false)).toBe(125)
})
it('deals 6.25% of current HP on ranged hit against boss', () => {
expect(calculateCrushingBlowDamage(1000, 'boss', true)).toBe(62)
expect(calculateCrushingBlowDamage(1000, true, true)).toBe(62)
})
it('deals 10% melee / 5% ranged on player tier (PvP)', () => {
expect(calculateCrushingBlowDamage(1000, 'player', false)).toBe(100)
expect(calculateCrushingBlowDamage(1000, 'player', true)).toBe(50)
})
it('reduces CB damage by target physical resistance (DR%)', () => {
// 1000 HP, normal melee = 250 base. With 50% physical resistance -> 125
expect(calculateCrushingBlowDamage(1000, 'normal', false, 50)).toBe(125)
// With 75% physical resistance -> 62
expect(calculateCrushingBlowDamage(1000, 'normal', false, 75)).toBe(62)
})
it('completely negates CB if target has 100% or greater physical resistance (Physical Immune)', () => {
expect(calculateCrushingBlowDamage(1000, 'normal', false, 100)).toBe(0)
expect(calculateCrushingBlowDamage(1000, 'normal', false, 120)).toBe(0)
})
it('does NOT amplify CB damage when physical resistance is negative (e.g. Amplify Damage / Decrepify)', () => {
// 1000 HP base = 250. Negative DR cannot increase CB damage above 1.0 factor!
expect(calculateCrushingBlowDamage(1000, 'normal', false, -50)).toBe(250)
expect(calculateCrushingBlowDamage(1000, 'normal', false, -100)).toBe(250)
})
})
describe('calculateOpenWoundsDamage (OW 200-frame level formula)', () => {
it('calculates exact damage for Level 1–15 bracket', () => {
// Level 10: 9 * 10 + 31 = 121 / 256 HP/frame. 200 frames: floor(121 * 200 / 256) = 94 HP
const ow = calculateOpenWoundsDamage(10, 'normal', false)
expect(ow.lifeDrainPerFrame).toBe(121)
expect(ow.totalDamage200Frames).toBe(94)
expect(ow.durationSeconds).toBe(8.0)
})
it('calculates exact damage for Level 16–30 bracket', () => {
// Level 25: 18 * 25 - 104 = 346 / 256. 200 frames: floor(346 * 200 / 256) = 270 HP
const ow = calculateOpenWoundsDamage(25, 'normal', false)
expect(ow.lifeDrainPerFrame).toBe(346)
expect(ow.totalDamage200Frames).toBe(270)
})
it('calculates exact damage for Level 31–45 bracket', () => {
// Level 40: 27 * 40 - 374 = 706 / 256. 200 frames: floor(706 * 200 / 256) = 551 HP
const ow = calculateOpenWoundsDamage(40, 'normal', false)
expect(ow.lifeDrainPerFrame).toBe(706)
expect(ow.totalDamage200Frames).toBe(551)
})
it('calculates exact damage for Level 46–60 bracket', () => {
// Level 55: 36 * 55 - 779 = 1201 / 256. 200 frames: floor(1201 * 200 / 256) = 938 HP
const ow = calculateOpenWoundsDamage(55, 'normal', false)
expect(ow.lifeDrainPerFrame).toBe(1201)
expect(ow.totalDamage200Frames).toBe(938)
})
it('calculates exact damage for Level >60 bracket', () => {
// Level 80: 45 * 80 - 1319 = 2281 / 256. 200 frames: floor(2281 * 200 / 256) = 1782 HP
const ow = calculateOpenWoundsDamage(80, 'normal', false)
expect(ow.lifeDrainPerFrame).toBe(2281)
expect(ow.totalDamage200Frames).toBe(1782)
})
it('halves damage against Bosses (0.5 modifier)', () => {
// Level 80 against Boss: 1782 / 2 = 891 HP
const owBoss = calculateOpenWoundsDamage(80, 'boss', false)
expect(owBoss.modifierFactor).toBe(0.5)
expect(owBoss.totalDamage200Frames).toBe(891)
})
it('applies player modifiers (0.25 melee / 0.125 ranged)', () => {
const owPlayerMelee = calculateOpenWoundsDamage(80, 'player', false)
expect(owPlayerMelee.modifierFactor).toBe(0.25)
expect(owPlayerMelee.totalDamage200Frames).toBe(445)
const owPlayerRanged = calculateOpenWoundsDamage(80, 'player', true)
expect(owPlayerRanged.modifierFactor).toBe(0.125)
expect(owPlayerRanged.totalDamage200Frames).toBe(222)
})
})
describe('calculateLeech (Life & Mana Steal)', () => {
it('leeches correctly in Normal difficulty with 100% drain effectiveness', () => {
// 100 physical damage, 10% LL, 5% ML
const leech = calculateLeech(100, 10, 5, 100, 'normal')
expect(leech.lifeLeeched).toBe(10)
expect(leech.manaLeeched).toBe(5)
})
it('applies 50% penalty in Nightmare and 33.3% penalty in Hell', () => {
// 100 physical damage, 20% LL
const leechNM = calculateLeech(100, 20, 0, 100, 'nightmare')
expect(leechNM.lifeLeeched).toBe(10) // 20 * 0.5 = 10
const leechHell = calculateLeech(100, 30, 0, 100, 'hell')
expect(leechHell.lifeLeeched).toBe(10) // 30 * (1/3) = 10
})
it('returns 0 leech against targets with 0% drain effectiveness (e.g. Skeletons)', () => {
const leechSkeleton = calculateLeech(500, 20, 20, 0, 'normal')
expect(leechSkeleton.lifeLeeched).toBe(0)
expect(leechSkeleton.manaLeeched).toBe(0)
})
it('returns 0 leech if physical damage dealt is 0', () => {
const leechZeroDmg = calculateLeech(0, 20, 20, 100, 'normal')
expect(leechZeroDmg.lifeLeeched).toBe(0)
expect(leechZeroDmg.manaLeeched).toBe(0)
})
})
describe('calculateFreezeTarget', () => {
it('calculates freeze chance and duration on normal monster', () => {
// Bonus = 1, AttLvl = 30, DefLvl = 20, Melee:
// Chance = 5 * (4*1 + 30 - 20) + 30 = 5 * 14 + 30 = 100%
const res = calculateFreezeTarget(1, 30, 20, 'normal', false, 'normal', () => 0.10)
expect(res.willFreeze).toBe(true)
expect(res.isChilledOnly).toBe(false)
expect(res.durationSeconds).toBeGreaterThan(0)
})
it('falls back to chill on Bosses without hard-freezing', () => {
const resBoss = calculateFreezeTarget(2, 50, 50, 'boss', false, 'normal', () => 0.05)
expect(resBoss.willFreeze).toBe(false)
expect(resBoss.isChilledOnly).toBe(true)
expect(resBoss.durationSeconds).toBe(3.0)
})
})
describe('calculateResistanceFactor (Elemental Resistances & Immunities)', () => {
it('handles immunity at 100% or higher', () => {
const res100 = calculateResistanceFactor(100)
expect(res100.isImmune).toBe(true)
expect(res100.factor).toBe(0)
const res120 = calculateResistanceFactor(120)
expect(res120.isImmune).toBe(true)
expect(res120.factor).toBe(0)
})
it('handles normal resistance (0% to 99%)', () => {
expect(calculateResistanceFactor(0).factor).toBe(1.0)
expect(calculateResistanceFactor(50).factor).toBe(0.5)
expect(calculateResistanceFactor(75).factor).toBe(0.25)
})
it('handles negative resistance (amplified damage up to 2x at -100%)', () => {
expect(calculateResistanceFactor(-50).factor).toBe(1.5)
expect(calculateResistanceFactor(-100).factor).toBe(2.0)
expect(calculateResistanceFactor(-150).factor).toBe(2.0)
})
})
describe('checkHitRecovery', () => {
it('triggers 100% when damage >= 25% of MaxHP', () => {
const check = checkHitRecovery(30, 100, 0.99)
expect(check.chancePercent).toBe(100)
expect(check.triggered).toBe(true)
})
it('triggers 50% chance when 12.5% <= damage < 25% of MaxHP', () => {
const checkPass = checkHitRecovery(20, 100, 0.40)
expect(checkPass.chancePercent).toBe(50)
expect(checkPass.triggered).toBe(true)
const checkFail = checkHitRecovery(20, 100, 0.60)
expect(checkFail.chancePercent).toBe(50)
expect(checkFail.triggered).toBe(false)
})
it('triggers 25% chance when 6.25% <= damage < 12.5% of MaxHP', () => {
const checkPass = checkHitRecovery(10, 100, 0.20)
expect(checkPass.chancePercent).toBe(25)
expect(checkPass.triggered).toBe(true)
const checkFail = checkHitRecovery(10, 100, 0.30)
expect(checkFail.chancePercent).toBe(25)
expect(checkFail.triggered).toBe(false)
})
it('does not trigger when damage < 6.25% of MaxHP', () => {
const check = checkHitRecovery(5, 100, 0.01)
expect(check.chancePercent).toBe(0)
expect(check.triggered).toBe(false)
})
})
describe('calculateAttack integrated', () => {
it('correctly reports a miss when hitRoll exceeds hitChancePercent', () => {
const result = calculateAttack(
10, 100,
{ physicalMin: 10, physicalMax: 20 },
{ level: 20, defense: 500, blockPercent: 0, currentHp: 100, maxHp: 100 },
{},
() => 0.90,
)
expect(result.isHit).toBe(false)
expect(result.totalDamage).toBe(0)
})
it('correctly reports blocked hit and negates damage', () => {
const rolls = [0.10, 0.10]
let idx = 0
const result = calculateAttack(
50, 5000,
{ physicalMin: 50, physicalMax: 50 },
{ level: 50, defense: 1000, blockPercent: 50, currentHp: 200, maxHp: 200 },
{},
() => rolls[idx++] ?? 0.5,
)
expect(result.isHit).toBe(true)
expect(result.isBlocked).toBe(true)
expect(result.totalDamage).toBe(0)
})
it('calculates full damage and triggers hit recovery on critical strike', () => {
// 1: hit roll = 0.20 (hit)
// 2: crit roll (CS) = 0.10 (< 50% -> critical strike)
// 3: damage roll = 0.50 (20 * 2 = 40)
// 4: hit recovery roll = 0.10 (< 100% -> triggered)
const rolls = [0.20, 0.10, 0.50, 0.10]
let idx = 0
const result = calculateAttack(
50, 5000,
{ physicalMin: 20, physicalMax: 20, fire: 15 },
{ level: 50, defense: 1000, blockPercent: 0, resPhysical: 25, resFire: -20, currentHp: 100, maxHp: 100 },
{ criticalStrikePercent: 50 },
() => rolls[idx++] ?? 0.5,
)
expect(result.isHit).toBe(true)
expect(result.isBlocked).toBe(false)
expect(result.isCritical).toBe(true)
expect(result.criticalTrigger).toBe('critical_strike')
// Physical: 20 * 2 (crit) = 40. Res 25% => 40 * 0.75 = 30.
expect(result.finalPhysicalDamage).toBe(30)
// Fire: 15. Res -20% => 15 * 1.20 = 18.
expect(result.finalFireDamage).toBe(18)
expect(result.totalDamage).toBe(48)
expect(result.hitRecoveryTriggered).toBe(true)
})
it('integrates Open Wounds and Leech into combat result', () => {
// 1: hit roll = 0.10 (hit)
// 2: OW roll = 0.10 (< 100% -> triggers OW)
// 3: damage roll = 0.50 (50 physical)
// 4: recovery roll = 0.90
const rolls = [0.10, 0.10, 0.50, 0.90]
let idx = 0
const result = calculateAttack(
50, 5000,
{ physicalMin: 50, physicalMax: 50 },
{ level: 50, defense: 100, blockPercent: 0, currentHp: 500, maxHp: 500, drainEffectiveness: 100 },
{ openWoundsPercent: 100, lifeLeechPercent: 20 },
() => rolls[idx++] ?? 0.5,
)
expect(result.isHit).toBe(true)
expect(result.isOpenWounds).toBe(true)
expect(result.openWoundsResult?.attackerLevel).toBe(50)
expect(result.finalPhysicalDamage).toBe(50)
expect(result.lifeLeeched).toBe(10) // 50 * 0.20 = 10
})
it('bypasses AR/DR when isSpell is set', () => {
const result = calculateAttack(
1, 0,
{ physicalMin: 0, physicalMax: 0, fire: 50 },
{ level: 90, defense: 99999, blockPercent: 75, resFire: 0, currentHp: 500, maxHp: 500 },
{ isSpell: true },
() => 0.99,
)
expect(result.isHit).toBe(true)
expect(result.isBlocked).toBe(false)
expect(result.finalFireDamage).toBe(50)
expect(result.totalDamage).toBe(50)
})
})
describe('D2_EXPERIENCE_TABLE_99', () => {
it('contains exact 100 elements (indices 0 to 99)', () => {
expect(D2_EXPERIENCE_TABLE_99.length).toBe(100)
})
it('matches known progression thresholds', () => {
expect(D2_EXPERIENCE_TABLE_99[1]).toBe(0)
expect(D2_EXPERIENCE_TABLE_99[2]).toBe(500)
expect(D2_EXPERIENCE_TABLE_99[10]).toBe(57715)
expect(D2_EXPERIENCE_TABLE_99[30]).toBe(4663904)
expect(D2_EXPERIENCE_TABLE_99[90]).toBe(1619361124)
expect(D2_EXPERIENCE_TABLE_99[99]).toBe(3522678586)
})
it('is strictly monotonic increasing from level 1 to 99', () => {
for (let i = 2; i <= 99; i++) {
expect(D2_EXPERIENCE_TABLE_99[i]!).toBeGreaterThan(D2_EXPERIENCE_TABLE_99[i - 1]!)
}
})
})
describe('PvE Depth Mechanics (Issue #39)', () => {
describe('calculateResistanceReduction (1/5th Rule & Immunity Breaking)', () => {
it('applies 1/5th penalty to Conviction and Lower Resist against immune monsters (>= 100% res)', () => {
// Monster has 110% Lightning resistance.
// Conviction Level 12 provides -85% res. Against immune target, only 85 / 5 = 17% is effective!
// 110% - 17% = 93% (< 100% => Immunity Broken!)
const result = calculateResistanceReduction(110, 85, 0, 0)
expect(result.wasImmune).toBe(true)
expect(result.effectiveConviction).toBe(17)
expect(result.resAfterSkills).toBe(93)
expect(result.immunityBroken).toBe(true)
expect(result.isImmune).toBe(false)
expect(result.finalRes).toBe(93)
expect(result.damageMultiplier).toBeCloseTo(0.07, 2)
})
it('prevents direct item -% resistance from working when immunity is NOT broken', () => {
// Monster has 120% Fire resistance.
// Conviction 85% provides 17% effective reduction -> 120% - 17% = 103% (still >= 100%!).
// Player has -20% Enemy Fire Resistance from equipment.
// In D2, equipment -% res CANNOT break immunity and does NOT apply if target remains immune!
const result = calculateResistanceReduction(120, 85, 0, 20)
expect(result.wasImmune).toBe(true)
expect(result.immunityBroken).toBe(false)
expect(result.isImmune).toBe(true)
expect(result.effectiveItemMinusRes).toBe(0) // Item minus res completely fails!
expect(result.finalRes).toBe(103)
expect(result.damageMultiplier).toBe(0)
})
it('applies direct item -% resistance at 100% full strength once immunity is broken by skills', () => {
// Monster has 110% Lightning resistance.
// Conviction 85% -> 17% reduction -> 93% res (broken!).
// Now equipment -25% from Griffon's Eye applies at 100% full effect!
// 93% - 25% = 68% final resistance.
const result = calculateResistanceReduction(110, 85, 0, 25)
expect(result.immunityBroken).toBe(true)
expect(result.effectiveItemMinusRes).toBe(25)
expect(result.finalRes).toBe(68)
expect(result.damageMultiplier).toBeCloseTo(0.32, 2)
})
it('combines Conviction and Lower Resist both with 1/5th penalty', () => {
// Monster has 130% Lightning resistance.
// Conviction 85% -> 17% reduction.
// Lower Resist 60% -> 12% reduction.
// Total skill reduction = 17 + 12 = 29% -> 130 - 29 = 101% (still immune!).
const stillImmune = calculateResistanceReduction(130, 85, 60, 0)
expect(stillImmune.effectiveConviction).toBe(17)
expect(stillImmune.effectiveLowerRes).toBe(12)
expect(stillImmune.resAfterSkills).toBe(101)
expect(stillImmune.isImmune).toBe(true)
// But against 125% res: 125 - 29 = 96% (broken!).
const broken = calculateResistanceReduction(125, 85, 60, 0)
expect(broken.immunityBroken).toBe(true)
expect(broken.resAfterSkills).toBe(96)
expect(broken.isImmune).toBe(false)
})
it('applies 100% full effectiveness to non-immune monsters (< 100% res) and respects -100% cap', () => {
// Monster has 50% Fire resistance.
// Conviction 85% + Lower Resist 30% + Equipment -25% = -140% total reduction.
// 50% - 140% = -90%.
const result = calculateResistanceReduction(50, 85, 30, 25)
expect(result.wasImmune).toBe(false)
expect(result.effectiveConviction).toBe(85)
expect(result.effectiveLowerRes).toBe(30)
expect(result.effectiveItemMinusRes).toBe(25)
expect(result.finalRes).toBe(-90)
expect(result.damageMultiplier).toBe(1.9)
// Lower bound clamp at -100% (2x damage)
const capped = calculateResistanceReduction(0, 85, 60, 25)
expect(capped.finalRes).toBe(-100)
expect(capped.damageMultiplier).toBe(2.0)
})
})
describe('calculateMonsterRegeneration & evaluatePreventMonsterHeal', () => {
it('calculates natural monster regeneration per frame and per second', () => {
// Monster with 40,960 HP, regen rate 10
// Regen/frame = (40960 * 10) / 4096 = 100 HP/frame
// Regen/sec = 100 * 25 = 2500 HP/sec
const regen = calculateMonsterRegeneration(40960, 10)
expect(regen.rawRegenPerFrame).toBe(100)
expect(regen.rawRegenPerSecond).toBe(2500)
expect(regen.effectiveRegenPerFrame).toBe(100)
expect(regen.effectiveRegenPerSecond).toBe(2500)
})
it('suppresses natural monster regeneration to 0 when Open Wounds (OW) is active', () => {
const regen = calculateMonsterRegeneration(40960, 10, true, false)
expect(regen.isBlockedByOW).toBe(true)
expect(regen.effectiveRegenPerFrame).toBe(0)
expect(regen.effectiveRegenPerSecond).toBe(0)
})
it('suppresses natural monster regeneration to 0 when PMH is active', () => {
const regen = calculateMonsterRegeneration(40960, 10, false, true)
expect(regen.isBlockedByPMH).toBe(true)
expect(regen.effectiveRegenPerFrame).toBe(0)
expect(regen.effectiveRegenPerSecond).toBe(0)
})
it('evaluates Prevent Monster Heal constraints (player only, uber immunity, 80 min duration)', () => {
// Player attack succeeds
const playerAttack = evaluatePreventMonsterHeal('player', 'boss')
expect(playerAttack.applied).toBe(true)
expect(playerAttack.durationFrames).toBe(120000)
expect(playerAttack.durationSeconds).toBe(4800)
// Mercenary attack fails
const mercAttack = evaluatePreventMonsterHeal('mercenary', 'boss')
expect(mercAttack.applied).toBe(false)
expect(mercAttack.durationFrames).toBe(0)
// Minion / Iron Golem attack fails
const minionAttack = evaluatePreventMonsterHeal('minion', 'boss')
expect(minionAttack.applied).toBe(false)
// Pandemonium Ubers are immune to PMH
const uberAttack = evaluatePreventMonsterHeal('player', 'uber')
expect(uberAttack.applied).toBe(false)
expect(uberAttack.durationFrames).toBe(0)
})
})
describe('calculateKnockbackSuccess (Size & Boss Immunity)', () => {
it('never knocks back bosses or siege beasts', () => {
const bossKb = calculateKnockbackSuccess(true, 'small', true, false, () => 0.01)
expect(bossKb.triggered).toBe(false)
expect(bossKb.finalChancePercent).toBe(0)
})
it('returns 0% if attack has no knockback affix', () => {
const noKb = calculateKnockbackSuccess(false, 'small', false, false, () => 0.01)
expect(noKb.triggered).toBe(false)
expect(noKb.hasKnockbackAffix).toBe(false)
})
it('scales knockback chance by target size for melee attacks (Small 100%, Medium 50%, Large 25%)', () => {
const small = calculateKnockbackSuccess(true, 'small', false, false, () => 0.99)
expect(small.baseSizeChancePercent).toBe(100)
expect(small.triggered).toBe(true) // 100% always triggers
const medSuccess = calculateKnockbackSuccess(true, 'medium', false, false, () => 0.40)
expect(medSuccess.baseSizeChancePercent).toBe(50)
expect(medSuccess.triggered).toBe(true)
const medFail = calculateKnockbackSuccess(true, 'medium', false, false, () => 0.60)
expect(medFail.triggered).toBe(false)
const largeSuccess = calculateKnockbackSuccess(true, 'large', false, false, () => 0.20)
expect(largeSuccess.baseSizeChancePercent).toBe(25)
expect(largeSuccess.triggered).toBe(true)
const largeFail = calculateKnockbackSuccess(true, 'large', false, false, () => 0.30)
expect(largeFail.triggered).toBe(false)
})
it('halves knockback chances for ranged attacks (Small 50%, Medium 25%, Large 12.5%)', () => {
const smallRanged = calculateKnockbackSuccess(true, 'small', false, true, () => 0.40)
expect(smallRanged.baseSizeChancePercent).toBe(50)
expect(smallRanged.triggered).toBe(true)
const medRanged = calculateKnockbackSuccess(true, 'medium', false, true, () => 0.20)
expect(medRanged.baseSizeChancePercent).toBe(25)
expect(medRanged.triggered).toBe(true)
const largeRanged = calculateKnockbackSuccess(true, 'large', false, true, () => 0.10)
expect(largeRanged.baseSizeChancePercent).toBe(12.5)
expect(largeRanged.triggered).toBe(true)
})
})
describe('calculateSlowTarget', () => {
it('allows up to 90% slow on normal monsters', () => {
const slow75 = calculateSlowTarget(75, false)
expect(slow75.effectiveSlowPercent).toBe(75)
const slow95 = calculateSlowTarget(95, false)
expect(slow95.effectiveSlowPercent).toBe(90) // Capped at 90%
})
it('caps slow at 50% for bosses and champions', () => {
const bossSlow = calculateSlowTarget(75, true)
expect(bossSlow.effectiveSlowPercent).toBe(50) // Capped at 50%
})
})
describe('Crowd Control: Hit Blinds Target (HBT) & Hit Causes Monster to Flee (HCMTF)', () => {
it('calculates Hit Blinds Target chance, duration and boss immunity', () => {
// Attacker Lv50, Defender Lv40, Bonus 1:
// Raw melee chance = 50 + (50 + 4 - 40) * 2 = 50 + 28 = 78%
const blindMelee = calculateHitBlindsTarget(1, 50, 40, false, false, () => 0.50)
expect(blindMelee.chancePercent).toBe(78)
expect(blindMelee.triggered).toBe(true)
expect(blindMelee.durationSeconds).toBe(8) // (1*4+4) = 8s
expect(blindMelee.durationFrames).toBe(200)
// Ranged divides chance by 3: 78 / 3 = 26%
const blindRanged = calculateHitBlindsTarget(1, 50, 40, false, true, () => 0.20)
expect(blindRanged.chancePercent).toBe(26)
expect(blindRanged.triggered).toBe(true)
// Bosses are immune
const blindBoss = calculateHitBlindsTarget(1, 50, 40, true, false, () => 0.01)
expect(blindBoss.triggered).toBe(false)
expect(blindBoss.chancePercent).toBe(0)
})
it('calculates Hit Causes Monster to Flee duration (125 frames / 5s) and boss immunity', () => {
const fleeNormal = calculateHitCausesFlee(50, false, () => 0.20)
expect(fleeNormal.triggered).toBe(true)
expect(fleeNormal.durationSeconds).toBe(5.0)
expect(fleeNormal.durationFrames).toBe(125)
const fleeBoss = calculateHitCausesFlee(50, true, () => 0.01)
expect(fleeBoss.triggered).toBe(false)
})
})
describe('calculatePhysicalWeaponDamage (Off-Weapon ED & Grief +Flat Damage)', () => {
it('calculates pure Grief Phase Blade with Fortitude and Strength bonus', () => {
// Phase Blade base damage: 31 to 35
// On-weapon ED: 0%
// Grief flat damage: +400
// Base damage becomes: [31+400, 35+400] = [431, 435]
// Off-Weapon ED: 300% (Fortitude Armor) + 150% (Fanaticism Aura) = 450%
// Stat Bonus: 150% (Strength)
// Total Multiplier = 1 + (450 + 150) / 100 = 7.0x
// Final Min = 431 * 7 = 3017
// Final Max = 435 * 7 = 3045
const griefDmg = calculatePhysicalWeaponDamage({
weaponBaseMin: 31,
weaponBaseMax: 35,
onWeaponEdPercent: 0,
flatDamage: 400,
offWeaponEdPercent: 450,
statBonusPercent: 150,
})
expect(griefDmg.baseMinWithFlat).toBe(431)
expect(griefDmg.baseMaxWithFlat).toBe(435)
expect(griefDmg.totalMultiplier).toBe(7.0)
expect(griefDmg.finalMinDamage).toBe(3017)
expect(griefDmg.finalMaxDamage).toBe(3045)
})
it('correctly layers on-weapon ED before flat damage', () => {
// Base: 10 to 20
// On-Weapon ED: 100% -> [20, 40]
// Flat Damage: +10 -> [30, 50]
// Off-Weapon ED: 100% -> Multiplier 2.0x -> [60, 100]
const dmg = calculatePhysicalWeaponDamage({
weaponBaseMin: 10,
weaponBaseMax: 20,
onWeaponEdPercent: 100,
flatDamage: 10,
offWeaponEdPercent: 100,
})
expect(dmg.baseMinWithWeaponEd).toBe(20)
expect(dmg.baseMaxWithWeaponEd).toBe(40)
expect(dmg.baseMinWithFlat).toBe(30)
expect(dmg.baseMaxWithFlat).toBe(50)
expect(dmg.finalMinDamage).toBe(60)
expect(dmg.finalMaxDamage).toBe(100)
})
})
describe('calculatePoisonStack (Bit Rates & Averaging)', () => {
it('calculates bit rate summation and average duration across multiple poison charms', () => {
// Source 1: 100 poison damage over 5 seconds (125 frames)
// Bit rate 1 = (100 * 256) / 125 = 204.8
// Source 2: 50 poison damage over 3 seconds (75 frames)
// Bit rate 2 = (50 * 256) / 75 = 170.6667
// Total BitRate = 204.8 + 170.6667 = 375.4667
// Average Duration = (125 + 75) / 2 = 100 frames (4.0 seconds)
// Total Poison Damage = floor(375.4667 * 100 / 256) = floor(146.6667) = 146
const poison = calculatePoisonStack([
{ damage: 100, durationSeconds: 5 },
{ damage: 50, durationSeconds: 3 },
])
expect(poison.sourcesCount).toBe(2)
expect(poison.averageDurationFrames).toBe(100)
expect(poison.averageDurationSeconds).toBe(4.0)
expect(poison.totalPoisonDamage).toBe(146)
expect(poison.damagePerSecond).toBeCloseTo(36.67, 1)
})
it('handles empty poison sources safely', () => {
const empty = calculatePoisonStack([])
expect(empty.totalPoisonDamage).toBe(0)
expect(empty.totalBitRate).toBe(0)
})
})
describe('calculateAttack with integrated PvE options', () => {
it('breaks elemental immunity with conviction and applies equipment -% res', () => {
const rolls = [0.1, 0.5, 0.5]
let idx = 0
const result = calculateAttack(
80, 5000,
{ physicalMin: 10, physicalMax: 10, lightning: 1000 },
{ level: 80, defense: 500, blockPercent: 0, resLightning: 110, currentHp: 5000, maxHp: 5000 },
{
convictionPenalty: 85,
itemMinusLightRes: 25,
},
() => rolls[idx++] ?? 0.5,
)
// Lightning resistance 110% -> 93% (Conviction 17%) -> 68% (Griffon -25%) -> 32% damage factor
expect(result.resistanceBreakResults?.lightning?.immunityBroken).toBe(true)
expect(result.resistanceBreakResults?.lightning?.finalRes).toBe(68)
expect(result.finalLightningDamage).toBe(320)
})
it('evaluates knockback size and boss immunity in calculateAttack', () => {
// Small monster knocked back
const smallResult = calculateAttack(
50, 5000,
{ physicalMin: 20, physicalMax: 20 },
{ level: 50, defense: 100, blockPercent: 0, currentHp: 200, maxHp: 200, size: 'small', tier: 'normal' },
{ knockbackPercent: 100 },
() => 0.05,
)
expect(smallResult.knockbackTriggered).toBe(true)
// Boss monster immune to knockback
const bossResult = calculateAttack(
50, 5000,
{ physicalMin: 20, physicalMax: 20 },
{ level: 50, defense: 100, blockPercent: 0, currentHp: 200, maxHp: 200, size: 'small', tier: 'boss' },
{ knockbackPercent: 100 },
() => 0.05,
)
expect(bossResult.knockbackTriggered).toBe(false)
})
it('applies PMH and calculates monster natural regeneration suppression', () => {
const result = calculateAttack(
50, 5000,
{ physicalMin: 50, physicalMax: 50 },
{ level: 50, defense: 100, blockPercent: 0, currentHp: 40960, maxHp: 40960, regenRate: 10, tier: 'boss' },
{ preventMonsterHeal: true, attackerType: 'player' },
() => 0.05,
)
expect(result.pmhResult?.applied).toBe(true)
expect(result.monsterRegenResult?.isBlockedByPMH).toBe(true)
expect(result.monsterRegenResult?.effectiveRegenPerFrame).toBe(0)
})
})
})
})