814 lines
34 KiB
TypeScript
814 lines
34 KiB
TypeScript
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)
|
||
})
|
||
})
|
||
})
|
||
})
|
||
|