329 lines
13 KiB
TypeScript
329 lines
13 KiB
TypeScript
/**
|
|
* Diablo II: Lord of Destruction v1.13c — Milestone M19
|
|
* Barbarian Warcries Adversarial Stress & Parity Suite
|
|
*
|
|
* Ground Truth:
|
|
* - Skills.txt & Missiles.txt (Patch_D2.mpq)
|
|
* - Strict fixed-point 24.8 mana calculations
|
|
* - 5-band physical damage progression for War Cry
|
|
* - Diminishing returns dm12 for Find Potion (0..100) and Find Item (5..60)
|
|
* - Shout / Battle Orders / Battle Command mutual duration synergy (+125 frames / blvl)
|
|
* - Grim Ward 1000 frame (40s) totem life
|
|
* - Strict BATCH1_SKILLS invariant: length === 38
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
calculateHowlStats,
|
|
calculateFindPotionStats,
|
|
calculateTauntStats,
|
|
calculateShoutStats,
|
|
calculateFindItemStats,
|
|
calculateBattleCryStats,
|
|
calculateBattleOrdersStats,
|
|
calculateGrimWardStats,
|
|
calculateWarCryStats,
|
|
calculateBattleCommandStats,
|
|
evaluateBarbarianWarcry,
|
|
} from '../../../src/game/skills/barbarian-warcries.ts'
|
|
import { BATCH1_SKILLS } from '../../../src/game/skills.ts'
|
|
|
|
describe('Milestone M19 — Barbarian Warcries Parity & Adversarial Stress Suite', () => {
|
|
describe('Dimension 1: Exact 1.13c Numerical Scaling & Formulas', () => {
|
|
it('1.1 Howl (130): Evaluates exact radius, duration, and mana at slvl 1, 10, 20', () => {
|
|
const s1 = calculateHowlStats(1)
|
|
expect(s1.radiusSubtiles).toBe(2)
|
|
expect(s1.fleeDurationFrames).toBe(24)
|
|
expect(s1.manaCost).toBe(4)
|
|
|
|
const s10 = calculateHowlStats(10)
|
|
expect(s10.radiusSubtiles).toBe(11) // 2 + 1 * 9
|
|
expect(s10.fleeDurationFrames).toBe(69) // 24 + 5 * 9
|
|
|
|
const s20 = calculateHowlStats(20)
|
|
expect(s20.radiusSubtiles).toBe(21) // 2 + 1 * 19
|
|
expect(s20.fleeDurationFrames).toBe(119) // 24 + 5 * 19
|
|
})
|
|
|
|
it('1.2 Find Potion (131): Evaluates success chance % and corpse requirement at slvl 1, 10, 20', () => {
|
|
const s1 = calculateFindPotionStats(1)
|
|
expect(s1.successChancePct).toBe(15) // dm(0, 100, 1) = floor(110*100/700) = 15
|
|
expect(s1.requiresCorpse).toBe(true)
|
|
expect(s1.manaCost).toBe(2)
|
|
|
|
const s10 = calculateFindPotionStats(10)
|
|
expect(s10.successChancePct).toBe(68) // dm(0, 100, 10) = floor(1100*100/1600) = 68
|
|
|
|
const s20 = calculateFindPotionStats(20)
|
|
expect(s20.successChancePct).toBe(84) // dm(0, 100, 20) = floor(2200*100/2600) = 84
|
|
})
|
|
|
|
it('1.3 Taunt (137): Evaluates AR and damage debuff % and melee forcing at slvl 1, 10, 20', () => {
|
|
const s1 = calculateTauntStats(1)
|
|
expect(s1.targetAttackRatingReductionPct).toBe(5)
|
|
expect(s1.targetDamageReductionPct).toBe(5)
|
|
expect(s1.forcesMeleeAttack).toBe(true)
|
|
expect(s1.manaCost).toBe(3)
|
|
|
|
const s10 = calculateTauntStats(10)
|
|
expect(s10.targetAttackRatingReductionPct).toBe(23) // 5 + 2 * 9
|
|
expect(s10.targetDamageReductionPct).toBe(23)
|
|
|
|
const s20 = calculateTauntStats(20)
|
|
expect(s20.targetAttackRatingReductionPct).toBe(43) // 5 + 2 * 19
|
|
expect(s20.targetDamageReductionPct).toBe(43)
|
|
})
|
|
|
|
it('1.4 Shout (138): Evaluates party defense bonus % and base duration at slvl 1, 10, 20', () => {
|
|
const s1 = calculateShoutStats(1)
|
|
expect(s1.defenseBonusPct).toBe(100)
|
|
expect(s1.durationFrames).toBe(500) // 20s
|
|
expect(s1.durationSeconds).toBe(20.0)
|
|
expect(s1.manaCost).toBe(6)
|
|
|
|
const s10 = calculateShoutStats(10)
|
|
expect(s10.defenseBonusPct).toBe(190) // 100 + 10 * 9
|
|
expect(s10.durationFrames).toBe(2750) // 500 + 250 * 9 = 110s
|
|
expect(s10.durationSeconds).toBe(110.0)
|
|
|
|
const s20 = calculateShoutStats(20)
|
|
expect(s20.defenseBonusPct).toBe(290) // 100 + 10 * 19
|
|
expect(s20.durationFrames).toBe(5250) // 500 + 250 * 19 = 210s
|
|
expect(s20.durationSeconds).toBe(210.0)
|
|
})
|
|
|
|
it('1.5 Find Item (142): Evaluates item drop chance % and corpse requirement at slvl 1, 10, 20', () => {
|
|
const s1 = calculateFindItemStats(1)
|
|
expect(s1.dropChancePct).toBe(13) // dm(5, 60, 1) = 5 + floor(110*55/700) = 5 + 8 = 13
|
|
expect(s1.requiresCorpse).toBe(true)
|
|
expect(s1.manaCost).toBe(7)
|
|
|
|
const s10 = calculateFindItemStats(10)
|
|
expect(s10.dropChancePct).toBe(42) // dm(5, 60, 10) = 5 + floor(1100*55/1600) = 5 + 37 = 42
|
|
|
|
const s20 = calculateFindItemStats(20)
|
|
expect(s20.dropChancePct).toBe(51) // dm(5, 60, 20) = 5 + floor(2200*55/2600) = 5 + 46 = 51
|
|
})
|
|
|
|
it('1.6 Battle Cry (146): Evaluates target defense & damage reduction and duration at slvl 1, 10, 20', () => {
|
|
const s1 = calculateBattleCryStats(1)
|
|
expect(s1.targetDefenseReductionPct).toBe(50)
|
|
expect(s1.targetDamageReductionPct).toBe(25)
|
|
expect(s1.durationFrames).toBe(300) // 12s
|
|
expect(s1.durationSeconds).toBe(12.0)
|
|
expect(s1.manaCost).toBe(5)
|
|
|
|
const s10 = calculateBattleCryStats(10)
|
|
expect(s10.targetDefenseReductionPct).toBe(68) // 50 + 2 * 9
|
|
expect(s10.targetDamageReductionPct).toBe(34) // 25 + 1 * 9
|
|
expect(s10.durationFrames).toBe(840) // 300 + 60 * 9 = 33.6s
|
|
expect(s10.durationSeconds).toBe(33.6)
|
|
|
|
const s20 = calculateBattleCryStats(20)
|
|
expect(s20.targetDefenseReductionPct).toBe(88) // 50 + 2 * 19
|
|
expect(s20.targetDamageReductionPct).toBe(44) // 25 + 1 * 19
|
|
expect(s20.durationFrames).toBe(1440) // 300 + 60 * 19 = 57.6s
|
|
expect(s20.durationSeconds).toBe(57.6)
|
|
})
|
|
|
|
it('1.7 Battle Orders (149): Evaluates life/mana/stamina bonus % and base duration at slvl 1, 10, 20', () => {
|
|
const s1 = calculateBattleOrdersStats(1)
|
|
expect(s1.maxLifeBonusPct).toBe(35)
|
|
expect(s1.maxManaBonusPct).toBe(35)
|
|
expect(s1.maxStaminaBonusPct).toBe(35)
|
|
expect(s1.durationFrames).toBe(750) // 30s
|
|
expect(s1.durationSeconds).toBe(30.0)
|
|
expect(s1.manaCost).toBe(7)
|
|
|
|
const s10 = calculateBattleOrdersStats(10)
|
|
expect(s10.maxLifeBonusPct).toBe(62) // 35 + 3 * 9
|
|
expect(s10.durationFrames).toBe(3000) // 750 + 250 * 9 = 120s
|
|
expect(s10.durationSeconds).toBe(120.0)
|
|
|
|
const s20 = calculateBattleOrdersStats(20)
|
|
expect(s20.maxLifeBonusPct).toBe(92) // 35 + 3 * 19
|
|
expect(s20.durationFrames).toBe(5500) // 750 + 250 * 19 = 220s
|
|
expect(s20.durationSeconds).toBe(220.0)
|
|
})
|
|
|
|
it('1.8 Grim Ward (150): Evaluates totem duration (1000 frames) and flee radius at slvl 1, 10, 20', () => {
|
|
const s1 = calculateGrimWardStats(1)
|
|
expect(s1.totemDurationFrames).toBe(1000)
|
|
expect(s1.totemDurationSeconds).toBe(40.0)
|
|
expect(s1.fleeRadiusYards).toBe(3)
|
|
expect(s1.fleeDurationFrames).toBe(60)
|
|
expect(s1.requiresCorpse).toBe(true)
|
|
expect(s1.manaCost).toBe(4)
|
|
|
|
const s10 = calculateGrimWardStats(10)
|
|
expect(s10.totemDurationFrames).toBe(1000)
|
|
expect(s10.fleeRadiusYards).toBe(12) // 3 + 1 * 9
|
|
|
|
const s20 = calculateGrimWardStats(20)
|
|
expect(s20.totemDurationFrames).toBe(1000)
|
|
expect(s20.fleeRadiusYards).toBe(22) // 3 + 1 * 19
|
|
})
|
|
|
|
it('1.9 War Cry (154): Evaluates 5-band physical damage, stun duration, and scaling mana at slvl 1, 10, 20', () => {
|
|
const s1 = calculateWarCryStats(1)
|
|
expect(s1.minPhysicalDamage).toBe(20)
|
|
expect(s1.maxPhysicalDamage).toBe(30)
|
|
expect(s1.stunDurationFrames).toBe(25) // 1.0s
|
|
expect(s1.stunDurationSeconds).toBe(1.0)
|
|
expect(s1.manaCost).toBe(10)
|
|
|
|
const s10 = calculateWarCryStats(10)
|
|
expect(s10.minPhysicalDamage).toBe(76)
|
|
expect(s10.maxPhysicalDamage).toBe(86)
|
|
expect(s10.stunDurationFrames).toBe(70) // 25 + 5 * 9 = 2.8s
|
|
expect(s10.stunDurationSeconds).toBe(2.8)
|
|
expect(s10.manaCost).toBe(19)
|
|
|
|
const s20 = calculateWarCryStats(20)
|
|
expect(s20.minPhysicalDamage).toBe(150)
|
|
expect(s20.maxPhysicalDamage).toBe(160)
|
|
expect(s20.stunDurationFrames).toBe(120) // 25 + 5 * 19 = 4.8s
|
|
expect(s20.stunDurationSeconds).toBe(4.8)
|
|
expect(s20.manaCost).toBe(29)
|
|
})
|
|
|
|
it('1.10 Battle Command (155): Evaluates fixed +1 all skills and base duration at slvl 1, 10, 20', () => {
|
|
const s1 = calculateBattleCommandStats(1)
|
|
expect(s1.allSkillsBonus).toBe(1)
|
|
expect(s1.durationFrames).toBe(125) // 5s
|
|
expect(s1.durationSeconds).toBe(5.0)
|
|
expect(s1.manaCost).toBe(11)
|
|
|
|
const s10 = calculateBattleCommandStats(10)
|
|
expect(s10.allSkillsBonus).toBe(1)
|
|
expect(s10.durationFrames).toBe(2375) // 125 + 250 * 9 = 95s
|
|
expect(s10.durationSeconds).toBe(95.0)
|
|
|
|
const s20 = calculateBattleCommandStats(20)
|
|
expect(s20.allSkillsBonus).toBe(1)
|
|
expect(s20.durationFrames).toBe(4875) // 125 + 250 * 19 = 195s
|
|
expect(s20.durationSeconds).toBe(195.0)
|
|
})
|
|
})
|
|
|
|
describe('Dimension 2: 1.13c Synergy Integration', () => {
|
|
it('2.1 Shout receives +125 frames (5.0s) per blvl in Battle Orders and Battle Command', () => {
|
|
const base = calculateShoutStats(20, 0, 0)
|
|
expect(base.durationFrames).toBe(5250)
|
|
|
|
const withBO = calculateShoutStats(20, 10, 0)
|
|
expect(withBO.durationFrames).toBe(5250 + 125 * 10) // 6500 frames
|
|
|
|
const withBoth = calculateShoutStats(20, 10, 10)
|
|
expect(withBoth.durationFrames).toBe(5250 + 125 * 20) // 7750 frames
|
|
})
|
|
|
|
it('2.2 Battle Orders receives +125 frames (5.0s) per blvl in Shout and Battle Command', () => {
|
|
const base = calculateBattleOrdersStats(20, 0, 0)
|
|
expect(base.durationFrames).toBe(5500)
|
|
|
|
const withShout = calculateBattleOrdersStats(20, 10, 0)
|
|
expect(withShout.durationFrames).toBe(5500 + 125 * 10) // 6750 frames
|
|
|
|
const withBoth = calculateBattleOrdersStats(20, 20, 20)
|
|
expect(withBoth.durationFrames).toBe(5500 + 125 * 40) // 10500 frames
|
|
})
|
|
|
|
it('2.3 Battle Command receives +125 frames (5.0s) per blvl in Shout and Battle Orders', () => {
|
|
const base = calculateBattleCommandStats(20, 0, 0)
|
|
expect(base.durationFrames).toBe(4875)
|
|
|
|
const withBoth = calculateBattleCommandStats(20, 20, 20)
|
|
expect(withBoth.durationFrames).toBe(4875 + 125 * 40) // 9875 frames
|
|
})
|
|
|
|
it('2.4 War Cry receives +6% physical damage per hard point in Howl, Taunt, Battle Cry', () => {
|
|
const base = calculateWarCryStats(20, 0, 0, 0)
|
|
expect(base.minPhysicalDamage).toBe(150)
|
|
expect(base.maxPhysicalDamage).toBe(160)
|
|
|
|
// 20 points in Howl: +120% dmg
|
|
const withHowl = calculateWarCryStats(20, 20, 0, 0)
|
|
expect(withHowl.synergyBonusPct).toBe(120)
|
|
expect(withHowl.minPhysicalDamage).toBe(Math.floor((150 * 220) / 100)) // 330
|
|
expect(withHowl.maxPhysicalDamage).toBe(Math.floor((160 * 220) / 100)) // 352
|
|
|
|
// 20 points in Howl, Taunt, Battle Cry (60 total): +360% dmg
|
|
const maxSyn = calculateWarCryStats(20, 20, 20, 20)
|
|
expect(maxSyn.synergyBonusPct).toBe(360)
|
|
expect(maxSyn.minPhysicalDamage).toBe(Math.floor((150 * 460) / 100)) // 690
|
|
expect(maxSyn.maxPhysicalDamage).toBe(Math.floor((160 * 460) / 100)) // 736
|
|
})
|
|
})
|
|
|
|
describe('Dimension 3: Adversarial Stress & Boundary Sanitization', () => {
|
|
it('3.1 Handles zero and negative levels safely without NaN or crash', () => {
|
|
for (const slvl of [0, -1, -50]) {
|
|
const howl = calculateHowlStats(slvl)
|
|
expect(howl.radiusSubtiles).toBe(0)
|
|
|
|
const pot = calculateFindPotionStats(slvl)
|
|
expect(pot.successChancePct).toBe(0)
|
|
|
|
const taunt = calculateTauntStats(slvl)
|
|
expect(taunt.targetAttackRatingReductionPct).toBe(0)
|
|
|
|
const shout = calculateShoutStats(slvl)
|
|
expect(shout.defenseBonusPct).toBe(0)
|
|
|
|
const item = calculateFindItemStats(slvl)
|
|
expect(item.dropChancePct).toBe(0)
|
|
|
|
const cry = calculateBattleCryStats(slvl)
|
|
expect(cry.targetDefenseReductionPct).toBe(0)
|
|
|
|
const bo = calculateBattleOrdersStats(slvl)
|
|
expect(bo.maxLifeBonusPct).toBe(0)
|
|
|
|
const ward = calculateGrimWardStats(slvl)
|
|
expect(ward.fleeRadiusYards).toBe(0)
|
|
|
|
const wc = calculateWarCryStats(slvl)
|
|
expect(wc.minPhysicalDamage).toBe(0)
|
|
|
|
const bc = calculateBattleCommandStats(slvl)
|
|
expect(bc.allSkillsBonus).toBe(0)
|
|
}
|
|
})
|
|
|
|
it('3.2 Handles massive extreme levels (slvl 99) monotonically', () => {
|
|
const pot99 = calculateFindPotionStats(99)
|
|
expect(pot99.successChancePct).toBeLessThanOrEqual(100)
|
|
expect(pot99.successChancePct).toBeGreaterThan(90)
|
|
|
|
const item99 = calculateFindItemStats(99)
|
|
expect(item99.dropChancePct).toBeLessThanOrEqual(65)
|
|
expect(item99.dropChancePct).toBeGreaterThan(55)
|
|
|
|
const wc99 = calculateWarCryStats(99)
|
|
expect(wc99.minPhysicalDamage).toBeGreaterThan(500)
|
|
expect(wc99.maxPhysicalDamage).toBeGreaterThan(wc99.minPhysicalDamage)
|
|
})
|
|
})
|
|
|
|
describe('Dimension 4: Data-Driven Registry Integration', () => {
|
|
it('4.1 Verifies all 10 Barbarian Warcries evaluate via evaluateBarbarianWarcry', () => {
|
|
const warcryIds = [130, 131, 137, 138, 142, 146, 149, 150, 154, 155]
|
|
for (const id of warcryIds) {
|
|
const res = evaluateBarbarianWarcry(id, 10, {
|
|
howlBlvl: 5,
|
|
shoutBlvl: 5,
|
|
battleOrdersBlvl: 5,
|
|
battleCommandBlvl: 5,
|
|
})
|
|
expect(res).not.toBeNull()
|
|
expect(res?.manaCost).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
|
|
it('4.2 Enforces BATCH1_SKILLS length invariant = strictly 38', () => {
|
|
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
|
|
})
|
|
})
|
|
})
|