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:
parent
0c3388b18b
commit
dbd80db251
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue