feat(bar): implement Barbarian Warcries (IDs 130, 131, 137, 138, 142, 146, 149, 150, 154, 155)

- Authoritative 1.13c parity for 10 Barbarian Warcries
- Exact Shout, Battle Orders, Battle Command duration scaling & mutual synergies
- Exact 5-band physical damage progression for War Cry with +6%/blvl synergies
- Authentic Battle Cry defense/damage debuff scaling and duration
- Authentic Taunt forced melee & debuff progression
- Diminishing returns for Find Potion and Find Item
- Grim Ward 1000-frame (40s) totem duration & flee radius scaling
- Adversarial stress tests (slvl 0, 1, 10, 20, 99)
- Enforces BATCH1_SKILLS length invariant = strictly 38
- Fixes #466
This commit is contained in:
troytt 2026-09-25 02:07:49 +00:00
parent 0c3388b18b
commit dbd80db251
2 changed files with 836 additions and 0 deletions

View File

@ -0,0 +1,508 @@
/**
* Diablo II: Lord of Destruction v1.13c — Barbarian Warcries Module
*
* Dedicated pure calculation, duration, synergy, and kinematics formulas for the 10 Barbarian Warcries:
* - Skill 130: Howl (ReqLevel 1)
* - Skill 131: Find Potion (ReqLevel 1)
* - Skill 137: Taunt (ReqLevel 6)
* - Skill 138: Shout (ReqLevel 6)
* - Skill 142: Find Item (ReqLevel 12)
* - Skill 146: Battle Cry (ReqLevel 18)
* - Skill 149: Battle Orders (ReqLevel 24)
* - Skill 150: Grim Ward (ReqLevel 24)
* - Skill 154: War Cry (ReqLevel 30)
* - Skill 155: Battle Command (ReqLevel 30)
*
* 1.13c 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 = 5s / blvl)
* - Grim Ward 1000 frame (40s) totem life
* - Zero runtime MPQ reading
*/
import { compute5BandScaling, computeDiminishingReturns } from '../engine/calc-ast.ts'
// --- Interfaces for Skill Calculations ---
export interface HowlStats {
readonly fleeDurationFrames: number
readonly fleeDurationSeconds: number
readonly radiusSubtiles: number
readonly manaCost: number
readonly manaCost256: number
}
export interface FindPotionStats {
readonly successChancePct: number
readonly requiresCorpse: boolean
readonly manaCost: number
readonly manaCost256: number
}
export interface TauntStats {
readonly targetAttackRatingReductionPct: number
readonly targetDamageReductionPct: number
readonly forcesMeleeAttack: boolean
readonly manaCost: number
readonly manaCost256: number
}
export interface ShoutStats {
readonly defenseBonusPct: number
readonly durationFrames: number
readonly durationSeconds: number
readonly radiusSubtiles: number
readonly manaCost: number
readonly manaCost256: number
}
export interface FindItemStats {
readonly dropChancePct: number
readonly requiresCorpse: boolean
readonly manaCost: number
readonly manaCost256: number
}
export interface BattleCryStats {
readonly durationFrames: number
readonly durationSeconds: number
readonly targetDefenseReductionPct: number
readonly targetDamageReductionPct: number
readonly manaCost: number
readonly manaCost256: number
}
export interface BattleOrdersStats {
readonly maxLifeBonusPct: number
readonly maxManaBonusPct: number
readonly maxStaminaBonusPct: number
readonly durationFrames: number
readonly durationSeconds: number
readonly manaCost: number
readonly manaCost256: number
}
export interface GrimWardStats {
readonly totemDurationFrames: number
readonly totemDurationSeconds: number
readonly fleeRadiusYards: number
readonly fleeDurationFrames: number
readonly fleeDurationSeconds: number
readonly requiresCorpse: boolean
readonly manaCost: number
readonly manaCost256: number
}
export interface WarCryStats {
readonly minPhysicalDamage: number
readonly maxPhysicalDamage: number
readonly baseMinPhysicalDamage: number
readonly baseMaxPhysicalDamage: number
readonly synergyBonusPct: number
readonly stunDurationFrames: number
readonly stunDurationSeconds: number
readonly radiusSubtiles: number
readonly manaCost: number
readonly manaCost256: number
}
export interface BattleCommandStats {
readonly allSkillsBonus: number
readonly durationFrames: number
readonly durationSeconds: number
readonly manaCost: number
readonly manaCost256: number
}
// --- Calculation Implementations ---
/**
* Skill 130: Howl
* Radius: 2 + 1 * (slvl - 1) subtiles
* Flee duration: 24 + 5 * (slvl - 1) frames (or 150 frames terror)
* Mana: 4
*/
export function calculateHowlStats(slvl: number): HowlStats {
const lvl = Math.max(0, Math.floor(slvl))
if (lvl === 0) {
return {
fleeDurationFrames: 0,
fleeDurationSeconds: 0,
radiusSubtiles: 0,
manaCost: 0,
manaCost256: 0,
}
}
const durationFrames = 24 + 5 * (lvl - 1)
return {
fleeDurationFrames: durationFrames,
fleeDurationSeconds: Math.round((durationFrames / 25) * 10) / 10,
radiusSubtiles: 2 + 1 * (lvl - 1),
manaCost: 4,
manaCost256: 1024,
}
}
/**
* Skill 131: Find Potion
* Success Chance %: dm(0, 100, slvl)
* Mana: 2
*/
export function calculateFindPotionStats(slvl: number): FindPotionStats {
const lvl = Math.max(0, Math.floor(slvl))
if (lvl === 0) {
return {
successChancePct: 0,
requiresCorpse: true,
manaCost: 0,
manaCost256: 0,
}
}
return {
successChancePct: Math.min(100, computeDiminishingReturns(0, 100, lvl)),
requiresCorpse: true,
manaCost: 2,
manaCost256: 512,
}
}
/**
* Skill 137: Taunt
* Target AR debuff: -(5 + 2 * (slvl - 1))%
* Target Dmg debuff: -(5 + 2 * (slvl - 1))%
* Mana: 3
*/
export function calculateTauntStats(slvl: number): TauntStats {
const lvl = Math.max(0, Math.floor(slvl))
if (lvl === 0) {
return {
targetAttackRatingReductionPct: 0,
targetDamageReductionPct: 0,
forcesMeleeAttack: true,
manaCost: 0,
manaCost256: 0,
}
}
const debuffPct = 5 + 2 * (lvl - 1)
return {
targetAttackRatingReductionPct: debuffPct,
targetDamageReductionPct: debuffPct,
forcesMeleeAttack: true,
manaCost: 3,
manaCost256: 768,
}
}
/**
* Skill 138: Shout
* Defense %: 100 + 10 * (slvl - 1)%
* Duration: 500 + 250 * (slvl - 1) + 125 * (battleOrdersBlvl + battleCommandBlvl) frames
* Mana: 6
*/
export function calculateShoutStats(
slvl: number,
battleOrdersBlvl = 0,
battleCommandBlvl = 0
): ShoutStats {
const lvl = Math.max(0, Math.floor(slvl))
if (lvl === 0) {
return {
defenseBonusPct: 0,
durationFrames: 0,
durationSeconds: 0,
radiusSubtiles: 0,
manaCost: 0,
manaCost256: 0,
}
}
const baseDuration = 500 + 250 * (lvl - 1)
const synDuration = 125 * (Math.max(0, battleOrdersBlvl) + Math.max(0, battleCommandBlvl))
const totalFrames = baseDuration + synDuration
return {
defenseBonusPct: 100 + 10 * (lvl - 1),
durationFrames: totalFrames,
durationSeconds: Math.round((totalFrames / 25) * 10) / 10,
radiusSubtiles: 290,
manaCost: 6,
manaCost256: 1536,
}
}
/**
* Skill 142: Find Item
* Drop Chance %: dm(5, 60, slvl)
* Mana: 7
*/
export function calculateFindItemStats(slvl: number): FindItemStats {
const lvl = Math.max(0, Math.floor(slvl))
if (lvl === 0) {
return {
dropChancePct: 0,
requiresCorpse: true,
manaCost: 0,
manaCost256: 0,
}
}
return {
dropChancePct: computeDiminishingReturns(5, 60, lvl),
requiresCorpse: true,
manaCost: 7,
manaCost256: 1792,
}
}
/**
* Skill 146: Battle Cry
* Duration: 300 + 60 * (slvl - 1) frames
* Target Defense reduction: -(50 + 2 * (slvl - 1))%
* Target Damage reduction: -(25 + 1 * (slvl - 1))%
* Mana: 5
*/
export function calculateBattleCryStats(slvl: number): BattleCryStats {
const lvl = Math.max(0, Math.floor(slvl))
if (lvl === 0) {
return {
durationFrames: 0,
durationSeconds: 0,
targetDefenseReductionPct: 0,
targetDamageReductionPct: 0,
manaCost: 0,
manaCost256: 0,
}
}
const durationFrames = 300 + 60 * (lvl - 1)
return {
durationFrames,
durationSeconds: Math.round((durationFrames / 25) * 10) / 10,
targetDefenseReductionPct: 50 + 2 * (lvl - 1),
targetDamageReductionPct: 25 + 1 * (lvl - 1),
manaCost: 5,
manaCost256: 1280,
}
}
/**
* Skill 149: Battle Orders
* Life / Mana / Stamina %: 35 + 3 * (slvl - 1)%
* Duration: 750 + 250 * (slvl - 1) + 125 * (shoutBlvl + battleCommandBlvl) frames
* Mana: 7
*/
export function calculateBattleOrdersStats(
slvl: number,
shoutBlvl = 0,
battleCommandBlvl = 0
): BattleOrdersStats {
const lvl = Math.max(0, Math.floor(slvl))
if (lvl === 0) {
return {
maxLifeBonusPct: 0,
maxManaBonusPct: 0,
maxStaminaBonusPct: 0,
durationFrames: 0,
durationSeconds: 0,
manaCost: 0,
manaCost256: 0,
}
}
const bonusPct = 35 + 3 * (lvl - 1)
const baseDuration = 750 + 250 * (lvl - 1)
const synDuration = 125 * (Math.max(0, shoutBlvl) + Math.max(0, battleCommandBlvl))
const totalFrames = baseDuration + synDuration
return {
maxLifeBonusPct: bonusPct,
maxManaBonusPct: bonusPct,
maxStaminaBonusPct: bonusPct,
durationFrames: totalFrames,
durationSeconds: Math.round((totalFrames / 25) * 10) / 10,
manaCost: 7,
manaCost256: 1792,
}
}
/**
* Skill 150: Grim Ward
* Totem life: 1000 frames (40s)
* Flee radius: 3 + 1 * (slvl - 1) yards
* Flee duration: 60 frames (2.4s)
* Mana: 4
*/
export function calculateGrimWardStats(slvl: number): GrimWardStats {
const lvl = Math.max(0, Math.floor(slvl))
if (lvl === 0) {
return {
totemDurationFrames: 0,
totemDurationSeconds: 0,
fleeRadiusYards: 0,
fleeDurationFrames: 0,
fleeDurationSeconds: 0,
requiresCorpse: true,
manaCost: 0,
manaCost256: 0,
}
}
return {
totemDurationFrames: 1000,
totemDurationSeconds: 40.0,
fleeRadiusYards: 3 + 1 * (lvl - 1),
fleeDurationFrames: 60,
fleeDurationSeconds: 2.4,
requiresCorpse: true,
manaCost: 4,
manaCost256: 1024,
}
}
/**
* Skill 154: War Cry
* 5-band physical damage: (20..30 base, +6 / +7 / +8 / +9 / +10)
* Synergy: +6% damage per blvl in Howl, Taunt, Battle Cry
* Stun duration: 25 + 5 * (slvl - 1) frames
* Mana: 10 + (slvl - 1)
*/
export function calculateWarCryStats(
slvl: number,
howlBlvl = 0,
tauntBlvl = 0,
battleCryBlvl = 0
): WarCryStats {
const lvl = Math.max(0, Math.floor(slvl))
if (lvl === 0) {
return {
minPhysicalDamage: 0,
maxPhysicalDamage: 0,
baseMinPhysicalDamage: 0,
baseMaxPhysicalDamage: 0,
synergyBonusPct: 0,
stunDurationFrames: 0,
stunDurationSeconds: 0,
radiusSubtiles: 0,
manaCost: 0,
manaCost256: 0,
}
}
const baseMin = compute5BandScaling(lvl, 20, 6, 7, 8, 9, 10)
const baseMax = compute5BandScaling(lvl, 30, 6, 7, 8, 9, 10)
const synPoints = Math.max(0, howlBlvl) + Math.max(0, tauntBlvl) + Math.max(0, battleCryBlvl)
const synergyBonusPct = synPoints * 6
const finalMin = Math.floor((baseMin * (100 + synergyBonusPct)) / 100)
const finalMax = Math.floor((baseMax * (100 + synergyBonusPct)) / 100)
const stunFrames = 25 + 5 * (lvl - 1)
const mana = 10 + (lvl - 1)
return {
minPhysicalDamage: finalMin,
maxPhysicalDamage: finalMax,
baseMinPhysicalDamage: baseMin,
baseMaxPhysicalDamage: baseMax,
synergyBonusPct,
stunDurationFrames: stunFrames,
stunDurationSeconds: Math.round((stunFrames / 25) * 10) / 10,
radiusSubtiles: 120,
manaCost: mana,
manaCost256: mana << 8,
}
}
/**
* Skill 155: Battle Command
* +1 to All Skills (fixed)
* Duration: 125 + 250 * (slvl - 1) + 125 * (shoutBlvl + battleOrdersBlvl) frames
* Mana: 11
*/
export function calculateBattleCommandStats(
slvl: number,
shoutBlvl = 0,
battleOrdersBlvl = 0
): BattleCommandStats {
const lvl = Math.max(0, Math.floor(slvl))
if (lvl === 0) {
return {
allSkillsBonus: 0,
durationFrames: 0,
durationSeconds: 0,
manaCost: 0,
manaCost256: 0,
}
}
const baseDuration = 125 + 250 * (lvl - 1)
const synDuration = 125 * (Math.max(0, shoutBlvl) + Math.max(0, battleOrdersBlvl))
const totalFrames = baseDuration + synDuration
return {
allSkillsBonus: 1,
durationFrames: totalFrames,
durationSeconds: Math.round((totalFrames / 25) * 10) / 10,
manaCost: 11,
manaCost256: 2816,
}
}
/**
* Unified dispatch evaluation for Barbarian Warcries.
*/
export function evaluateBarbarianWarcry(
skillId: number,
slvl: number,
synergies: {
howlBlvl?: number
tauntBlvl?: number
shoutBlvl?: number
battleCryBlvl?: number
battleOrdersBlvl?: number
battleCommandBlvl?: number
} = {}
) {
switch (skillId) {
case 130:
return calculateHowlStats(slvl)
case 131:
return calculateFindPotionStats(slvl)
case 137:
return calculateTauntStats(slvl)
case 138:
return calculateShoutStats(
slvl,
synergies.battleOrdersBlvl ?? 0,
synergies.battleCommandBlvl ?? 0
)
case 142:
return calculateFindItemStats(slvl)
case 146:
return calculateBattleCryStats(slvl)
case 149:
return calculateBattleOrdersStats(
slvl,
synergies.shoutBlvl ?? 0,
synergies.battleCommandBlvl ?? 0
)
case 150:
return calculateGrimWardStats(slvl)
case 154:
return calculateWarCryStats(
slvl,
synergies.howlBlvl ?? 0,
synergies.tauntBlvl ?? 0,
synergies.battleCryBlvl ?? 0
)
case 155:
return calculateBattleCommandStats(
slvl,
synergies.shoutBlvl ?? 0,
synergies.battleOrdersBlvl ?? 0
)
default:
return null
}
}

View File

@ -0,0 +1,328 @@
/**
* 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)
})
})
})