diablo2-web/tests/skills/bar/adv-bar-warcries-stress.tes...

427 lines
17 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,
validateBarbarianCorpseTarget,
} from '../../../src/game/skills/barbarian-warcries.ts'
import { BATCH1_SKILLS } from '../../../src/game/skills.ts'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { evaluateSkill113c, executeSkill113c, registerSkillModule } from '../../../src/game/skills/registry.ts'
import { skillModule as findPotionModule } from '../../../src/game/skills/impl/bar/skill-131-find-potion.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import type { CombatUnitContext } from '../../../src/game/engine/combat-pipeline.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)
})
})
// =========================================================================
// Dimension 5: 1.13c Corpse Target Validity & SrvSt Guards
// =========================================================================
describe('Dimension 5: 1.13c Corpse Target Validity & SrvSt Guards', () => {
it('5.1 Validates corpse conditions for Find Potion, Find Item, Grim Ward', () => {
for (const skillId of [131, 142, 150]) {
// Missing corpse
const noCorpse = validateBarbarianCorpseTarget(skillId, undefined)
expect(noCorpse.valid).toBe(false)
expect(noCorpse.reason).toBe('no_corpse')
// Already consumed corpse
const consumed = validateBarbarianCorpseTarget(skillId, { consumed: true })
expect(consumed.valid).toBe(false)
expect(consumed.reason).toBe('already_consumed')
// Shattered corpse
const shattered = validateBarbarianCorpseTarget(skillId, { consumed: false, shattered: true })
expect(shattered.valid).toBe(false)
expect(shattered.reason).toBe('corpse_shattered')
// Boss corpse (invalid for Grim Ward / horking)
const boss = validateBarbarianCorpseTarget(skillId, { consumed: false, isBoss: true })
expect(boss.valid).toBe(false)
expect(boss.reason).toBe('boss_corpse_invalid')
// Clean unconsumed monster corpse
const valid = validateBarbarianCorpseTarget(skillId, { consumed: false, shattered: false, isBoss: false })
expect(valid.valid).toBe(true)
}
})
it('5.2 Enforces corpse validation during runtime executeSkill113c execution', async () => {
const registry = await getSharedDataRegistry()
const fpRec = registry.getSkillById(131)!
const stats = new UnitStatList()
const stateBus = new StateBus(stats, registry)
const evalRes = evaluateSkill113c({ registry, skill: fpRec, slvl: 1, blvl: 1, statList: stats })
registerSkillModule(findPotionModule)
const hero: CombatUnitContext = {
id: 'hero',
name: 'Barbarian',
statList: stats,
stateBus,
}
// Execution with only a shattered corpse should fail
const outcomeShattered = executeSkill113c({
registry,
skill: fpRec,
evalResult: evalRes,
caster: hero,
targets: [],
targetPositions: new Map(),
corpses: [{ id: 'c1', consumed: false, shattered: true } as any],
missileEngine: {} as any,
auraScanner: {} as any,
summonManager: {} as any,
currentTick: 1,
targetX: 0,
targetY: 0,
})
expect(outcomeShattered.executed).toBe(false)
expect(outcomeShattered.notes).toContain('fail: requires valid unconsumed non-boss corpse')
// Execution with valid corpse succeeds
const outcomeValid = executeSkill113c({
registry,
skill: fpRec,
evalResult: evalRes,
caster: hero,
targets: [],
targetPositions: new Map(),
corpses: [{ id: 'c2', consumed: false, shattered: false, isBoss: false } as any],
missileEngine: {} as any,
auraScanner: {} as any,
summonManager: {} as any,
currentTick: 1,
targetX: 0,
targetY: 0,
})
expect(outcomeValid.executed).toBe(true)
expect(outcomeValid.corpsesConsumed).toBe(1)
})
})
})