diff --git a/src/game/skills/barbarian-warcries.ts b/src/game/skills/barbarian-warcries.ts new file mode 100644 index 0000000..906a325 --- /dev/null +++ b/src/game/skills/barbarian-warcries.ts @@ -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 + } +} diff --git a/tests/skills/bar/adv-bar-warcries-stress.test.ts b/tests/skills/bar/adv-bar-warcries-stress.test.ts new file mode 100644 index 0000000..d82b46f --- /dev/null +++ b/tests/skills/bar/adv-bar-warcries-stress.test.ts @@ -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) + }) + }) +})