487 lines
20 KiB
TypeScript
487 lines
20 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
calculateBashStats,
|
|
calculateLeapStats,
|
|
calculateDoubleSwingStats,
|
|
calculateStunStats,
|
|
calculateDoubleThrowStats,
|
|
calculateLeapAttackStats,
|
|
calculateConcentrateStats,
|
|
calculateFrenzyStats,
|
|
calculateWhirlwindStats,
|
|
calculateBerserkStats,
|
|
validateBarbarianSkillEquipment,
|
|
validateBarbarianLeapDestination,
|
|
calculateWhirlwindHitFrames,
|
|
calculateFrenzyStackState,
|
|
} from '../../../src/game/skills/barbarian-combat.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 bashModule } from '../../../src/game/skills/impl/bar/skill-126-bash.ts'
|
|
import { StateBus } from '../../../src/game/engine/state-bus.ts'
|
|
import type { CombatUnitContext } from '../../../src/game/engine/combat-pipeline.ts'
|
|
|
|
|
|
describe('Milestone M17 — Barbarian Combat Skills Parity & Adversarial Stress Suite', () => {
|
|
// =========================================================================
|
|
// Dimension 1: Exact 1.13c Numerical Scaling & Level Progressions
|
|
// =========================================================================
|
|
describe('Dimension 1: Exact 1.13c Numerical Scaling & Formulas', () => {
|
|
it('1.1 Bash (126): Evaluates exact damage %, flat damage, and knockback at slvl 1, 10, 20', () => {
|
|
// slvl 1
|
|
const b1 = calculateBashStats(1, 0)
|
|
expect(b1.damageBonusPct).toBe(50)
|
|
expect(b1.flatPhysicalMin).toBe(1)
|
|
expect(b1.flatPhysicalMax).toBe(1)
|
|
expect(b1.knockbackChancePct).toBe(100)
|
|
expect(b1.toHitBonusPct).toBe(20)
|
|
expect(b1.manaCost).toBe(2)
|
|
expect(b1.manaCost256).toBe(512)
|
|
|
|
// slvl 10
|
|
const b10 = calculateBashStats(10, 0)
|
|
expect(b10.damageBonusPct).toBe(50 + 9 * 5) // 95%
|
|
expect(b10.flatPhysicalMin).toBe(1 + 9 * 1) // 10
|
|
expect(b10.flatPhysicalMax).toBe(10)
|
|
expect(b10.toHitBonusPct).toBe(20 + 9 * 5) // 65%
|
|
|
|
// slvl 20
|
|
const b20 = calculateBashStats(20, 0)
|
|
expect(b20.damageBonusPct).toBe(50 + 19 * 5) // 145%
|
|
expect(b20.flatPhysicalMin).toBe(20)
|
|
expect(b20.flatPhysicalMax).toBe(20)
|
|
expect(b20.toHitBonusPct).toBe(20 + 19 * 5) // 115%
|
|
})
|
|
|
|
it('1.2 Leap (132): Evaluates knockback radius scaling', () => {
|
|
const l1 = calculateLeapStats(1)
|
|
expect(l1.knockbackRadiusSubtiles).toBe(4)
|
|
expect(l1.minDistanceYards).toBe(4)
|
|
expect(l1.maxDistanceYards).toBe(30)
|
|
expect(l1.manaCost).toBe(2)
|
|
|
|
const l10 = calculateLeapStats(10)
|
|
expect(l10.knockbackRadiusSubtiles).toBe(4 + 9 * 1) // 13 subtiles
|
|
|
|
const l20 = calculateLeapStats(20)
|
|
expect(l20.knockbackRadiusSubtiles).toBe(4 + 19 * 1) // 23 subtiles
|
|
})
|
|
|
|
it('1.3 Double Swing (133): Evaluates attack rating and descending mana cost to 0 at slvl 9+', () => {
|
|
const ds1 = calculateDoubleSwingStats(1, 0)
|
|
expect(ds1.damageBonusPct).toBe(0)
|
|
expect(ds1.attackRateBonusPct).toBe(50)
|
|
expect(ds1.toHitBonusPct).toBe(15)
|
|
expect(ds1.manaCost).toBe(2)
|
|
expect(ds1.manaCost256).toBe(512)
|
|
expect(ds1.requiresDualWield).toBe(true)
|
|
|
|
const ds5 = calculateDoubleSwingStats(5, 0)
|
|
expect(ds5.toHitBonusPct).toBe(15 + 4 * 5) // 35%
|
|
expect(ds5.manaCost256).toBe(256) // 1 mana
|
|
|
|
const ds9 = calculateDoubleSwingStats(9, 0)
|
|
expect(ds9.manaCost).toBe(0)
|
|
expect(ds9.manaCost256).toBe(0)
|
|
|
|
const ds20 = calculateDoubleSwingStats(20, 0)
|
|
expect(ds20.manaCost).toBe(0)
|
|
expect(ds20.manaCost256).toBe(0)
|
|
expect(ds20.toHitBonusPct).toBe(15 + 19 * 5) // 110%
|
|
})
|
|
|
|
it('1.4 Stun (139): Evaluates base stun duration and attack rating', () => {
|
|
const s1 = calculateStunStats(1, 0, 0)
|
|
expect(s1.baseDurationFrames).toBe(30) // 1.2s
|
|
expect(s1.stunDurationFrames).toBe(30)
|
|
expect(s1.toHitBonusPct).toBe(15)
|
|
expect(s1.manaCost).toBe(2)
|
|
expect(s1.overlayName).toBe('stun')
|
|
|
|
const s10 = calculateStunStats(10, 0, 0)
|
|
expect(s10.baseDurationFrames).toBe(30 + 9 * 5) // 75 frames = 3.0s
|
|
expect(s10.toHitBonusPct).toBe(15 + 9 * 5) // 60%
|
|
|
|
const s20 = calculateStunStats(20, 0, 0)
|
|
expect(s20.baseDurationFrames).toBe(30 + 19 * 5) // 125 frames = 5.0s
|
|
expect(s20.toHitBonusPct).toBe(15 + 19 * 5) // 110%
|
|
})
|
|
|
|
it('1.5 Double Throw (140): Evaluates attack rating progression', () => {
|
|
const dt1 = calculateDoubleThrowStats(1, 0)
|
|
expect(dt1.toHitBonusPct).toBe(20)
|
|
expect(dt1.manaCost).toBe(2)
|
|
expect(dt1.requiresDualThrowing).toBe(true)
|
|
|
|
const dt10 = calculateDoubleThrowStats(10, 0)
|
|
expect(dt10.toHitBonusPct).toBe(20 + 9 * 10) // 110%
|
|
|
|
const dt20 = calculateDoubleThrowStats(20, 0)
|
|
expect(dt20.toHitBonusPct).toBe(20 + 19 * 10) // 210%
|
|
})
|
|
|
|
it('1.6 Leap Attack (143): Evaluates massive damage and knockback radius', () => {
|
|
const la1 = calculateLeapAttackStats(1, 0)
|
|
expect(la1.damageBonusPct).toBe(100)
|
|
expect(la1.knockbackRadiusSubtiles).toBe(4)
|
|
expect(la1.toHitBonusPct).toBe(50)
|
|
expect(la1.manaCost).toBe(9)
|
|
expect(la1.manaCost256).toBe(2304)
|
|
|
|
const la10 = calculateLeapAttackStats(10, 0)
|
|
expect(la10.damageBonusPct).toBe(100 + 9 * 30) // 370%
|
|
expect(la10.knockbackRadiusSubtiles).toBe(4 + 9 * 2) // 22 subtiles
|
|
expect(la10.toHitBonusPct).toBe(50 + 9 * 15) // 185%
|
|
|
|
const la20 = calculateLeapAttackStats(20, 0)
|
|
expect(la20.damageBonusPct).toBe(100 + 19 * 30) // 670%
|
|
expect(la20.knockbackRadiusSubtiles).toBe(4 + 19 * 2) // 42 subtiles
|
|
expect(la20.toHitBonusPct).toBe(50 + 19 * 15) // 335%
|
|
})
|
|
|
|
it('1.7 Concentrate (144): Evaluates defense bonus and uninterruptible attack', () => {
|
|
const c1 = calculateConcentrateStats(1, 0, 0, 0)
|
|
expect(c1.damageBonusPct).toBe(70)
|
|
expect(c1.defenseBonusPct).toBe(100)
|
|
expect(c1.toHitBonusPct).toBe(60)
|
|
expect(c1.isUninterruptible).toBe(true)
|
|
expect(c1.manaCost).toBe(2)
|
|
|
|
const c10 = calculateConcentrateStats(10, 0, 0, 0)
|
|
expect(c10.damageBonusPct).toBe(70 + 9 * 5) // 115%
|
|
expect(c10.defenseBonusPct).toBe(100 + 9 * 10) // 190%
|
|
expect(c10.toHitBonusPct).toBe(60 + 9 * 10) // 150%
|
|
|
|
const c20 = calculateConcentrateStats(20, 0, 0, 0)
|
|
expect(c20.damageBonusPct).toBe(70 + 19 * 5) // 165%
|
|
expect(c20.defenseBonusPct).toBe(100 + 19 * 10) // 290%
|
|
expect(c20.toHitBonusPct).toBe(60 + 19 * 10) // 250%
|
|
})
|
|
|
|
it('1.8 Frenzy (147): Evaluates speed caps, duration (150 frames), and attack progression', () => {
|
|
const f1 = calculateFrenzyStats(1, 0, 0, 0)
|
|
expect(f1.damageBonusPct).toBe(90)
|
|
expect(f1.toHitBonusPct).toBe(100)
|
|
expect(f1.minRunSpeedPct).toBe(20)
|
|
expect(f1.durationFrames).toBe(150)
|
|
expect(f1.manaCost).toBe(3)
|
|
expect(f1.overlayName).toBe('frenzy')
|
|
expect(f1.requiresDualWield).toBe(true)
|
|
|
|
const f10 = calculateFrenzyStats(10, 0, 0, 0)
|
|
expect(f10.damageBonusPct).toBe(90 + 9 * 5) // 135%
|
|
expect(f10.toHitBonusPct).toBe(100 + 9 * 7) // 163%
|
|
expect(f10.maxRunSpeedPct).toBe(20 + 9 * 5) // 65%
|
|
|
|
const f20 = calculateFrenzyStats(20, 0, 0, 0)
|
|
expect(f20.damageBonusPct).toBe(90 + 19 * 5) // 185%
|
|
expect(f20.toHitBonusPct).toBe(100 + 19 * 7) // 233%
|
|
expect(f20.maxRunSpeedPct).toBe(20 + 19 * 5) // 115%
|
|
expect(f20.maxAttackSpeedPct).toBe(Math.min(50, 7 + 19 * 2)) // 45%
|
|
})
|
|
|
|
it('1.9 Whirlwind (151): Evaluates initial negative damage scaling (-50% to +102%) and hit frequency (4 frames)', () => {
|
|
const ww1 = calculateWhirlwindStats(1)
|
|
expect(ww1.damageBonusPct).toBe(-50)
|
|
expect(ww1.toHitBonusPct).toBe(0)
|
|
expect(ww1.hitFrequencyFrames).toBe(4)
|
|
expect(ww1.manaCost).toBe(25)
|
|
expect(ww1.manaCost256).toBe(6400)
|
|
|
|
const ww10 = calculateWhirlwindStats(10)
|
|
expect(ww10.damageBonusPct).toBe(-50 + 9 * 8) // +22%
|
|
expect(ww10.toHitBonusPct).toBe(9 * 5) // 45%
|
|
expect(ww10.manaCost).toBe(34)
|
|
|
|
const ww20 = calculateWhirlwindStats(20)
|
|
expect(ww20.damageBonusPct).toBe(-50 + 19 * 8) // +102%
|
|
expect(ww20.toHitBonusPct).toBe(19 * 5) // 95%
|
|
expect(ww20.manaCost).toBe(44)
|
|
})
|
|
|
|
it('1.10 Berserk (152): Evaluates 100% magic damage conversion and decreasing zero-defense duration', () => {
|
|
const bz1 = calculateBerserkStats(1, 0, 0)
|
|
expect(bz1.damageBonusPct).toBe(150)
|
|
expect(bz1.magicConversionPct).toBe(100)
|
|
expect(bz1.toHitBonusPct).toBe(100)
|
|
expect(bz1.manaCost).toBe(4)
|
|
expect(bz1.zeroDefenseDurationFrames).toBeLessThanOrEqual(75)
|
|
expect(bz1.zeroDefenseDurationFrames).toBeGreaterThanOrEqual(25)
|
|
expect(bz1.overlayName).toBe('berserkfront')
|
|
|
|
const bz20 = calculateBerserkStats(20, 0, 0)
|
|
expect(bz20.damageBonusPct).toBe(150 + 19 * 15) // 435%
|
|
expect(bz20.toHitBonusPct).toBe(100 + 19 * 15) // 385%
|
|
// Duration decreases with level
|
|
expect(bz20.zeroDefenseDurationFrames).toBeLessThan(bz1.zeroDefenseDurationFrames)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Dimension 2: 1.13c Synergy Integration & Blvl Rules
|
|
// =========================================================================
|
|
describe('Dimension 2: 1.13c Synergy Integration', () => {
|
|
it('2.1 Bash receives +5% damage per hard point in Stun', () => {
|
|
const bBase = calculateBashStats(10, 0)
|
|
const bSyn10 = calculateBashStats(10, 10)
|
|
const bSyn20 = calculateBashStats(10, 20)
|
|
|
|
expect(bSyn10.damageBonusPct - bBase.damageBonusPct).toBe(50) // +50%
|
|
expect(bSyn20.damageBonusPct - bBase.damageBonusPct).toBe(100) // +100%
|
|
})
|
|
|
|
it('2.2 Double Swing receives +10% damage per hard point in Bash', () => {
|
|
const dsBase = calculateDoubleSwingStats(1, 0)
|
|
const dsSyn20 = calculateDoubleSwingStats(1, 20)
|
|
|
|
expect(dsSyn20.damageBonusPct - dsBase.damageBonusPct).toBe(200) // +200%
|
|
})
|
|
|
|
it('2.3 Stun receives +8% damage from Bash and +5% duration from War Cry', () => {
|
|
const sBase = calculateStunStats(10, 0, 0)
|
|
const sSyn = calculateStunStats(10, 20, 20)
|
|
|
|
expect(sSyn.damageBonusPct).toBe(160) // 20 * 8%
|
|
expect(sSyn.stunDurationFrames).toBe(Math.trunc(sBase.baseDurationFrames * 2.0)) // +100% duration
|
|
})
|
|
|
|
it('2.4 Concentrate receives +5% dmg from Bash, +10% dmg from Battle Orders, +1% magic from Berserk', () => {
|
|
const cBase = calculateConcentrateStats(10, 0, 0, 0)
|
|
const cSyn = calculateConcentrateStats(10, 20, 20, 20)
|
|
|
|
expect(cSyn.damageBonusPct - cBase.damageBonusPct).toBe(20 * 5 + 20 * 10) // +300%
|
|
expect(cSyn.magicConversionPct).toBe(20) // 20% magic damage conversion
|
|
})
|
|
|
|
it('2.5 Frenzy receives +8% dmg from Double Swing and Taunt, +1% magic from Berserk', () => {
|
|
const fBase = calculateFrenzyStats(10, 0, 0, 0)
|
|
const fSyn = calculateFrenzyStats(10, 20, 20, 20)
|
|
|
|
expect(fSyn.damageBonusPct - fBase.damageBonusPct).toBe((20 + 20) * 8) // +320%
|
|
expect(fSyn.magicConversionPct).toBe(20)
|
|
})
|
|
|
|
it('2.6 Berserk receives +10% magic damage from Howl and Shout', () => {
|
|
const bzBase = calculateBerserkStats(10, 0, 0)
|
|
const bzSyn = calculateBerserkStats(10, 20, 20)
|
|
|
|
expect(bzSyn.damageBonusPct - bzBase.damageBonusPct).toBe((20 + 20) * 10) // +400%
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Dimension 3: Adversarial Boundary & Stress Cases
|
|
// =========================================================================
|
|
describe('Dimension 3: Adversarial Stress & Boundary Sanitization', () => {
|
|
it('3.1 Handles zero and negative levels safely without NaN or crash', () => {
|
|
for (const slvl of [-10, 0, 1]) {
|
|
const bash = calculateBashStats(slvl)
|
|
expect(Number.isFinite(bash.damageBonusPct)).toBe(true)
|
|
expect(bash.flatPhysicalMin).toBeGreaterThan(0)
|
|
|
|
const ds = calculateDoubleSwingStats(slvl)
|
|
expect(Number.isFinite(ds.manaCost)).toBe(true)
|
|
expect(ds.manaCost).toBeGreaterThanOrEqual(0)
|
|
|
|
const ww = calculateWhirlwindStats(slvl)
|
|
expect(Number.isFinite(ww.damageBonusPct)).toBe(true)
|
|
expect(ww.manaCost).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
|
|
it('3.2 Handles massive extreme levels (slvl 99) monotonically', () => {
|
|
const bz99 = calculateBerserkStats(99, 20, 20)
|
|
expect(bz99.damageBonusPct).toBe(150 + 98 * 15 + 400) // 2020%
|
|
expect(bz99.zeroDefenseDurationFrames).toBeGreaterThanOrEqual(25)
|
|
|
|
const la99 = calculateLeapAttackStats(99, 20)
|
|
expect(la99.damageBonusPct).toBe(100 + 98 * 30 + 200) // 3240%
|
|
|
|
const ww99 = calculateWhirlwindStats(99)
|
|
expect(ww99.damageBonusPct).toBe(-50 + 98 * 8) // 734%
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Dimension 4: Registry Integration & Data-Driven Parity
|
|
// =========================================================================
|
|
describe('Dimension 4: Data-Driven Registry Integration', () => {
|
|
it('4.1 Verifies all 10 Barbarian Combat Skills evaluate via evaluateSkill113c', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const stats = new UnitStatList()
|
|
const skillIds = [126, 132, 133, 139, 140, 143, 144, 147, 151, 152]
|
|
|
|
for (const id of skillIds) {
|
|
const skillRec = registry.getSkillById(id)
|
|
expect(skillRec, `Skills.txt record missing for skill ${id}`).toBeDefined()
|
|
stats.setBaseSkillLevel(id, 10)
|
|
|
|
const res = evaluateSkill113c({
|
|
registry,
|
|
skill: skillRec!,
|
|
slvl: 10,
|
|
blvl: 10,
|
|
statList: stats,
|
|
})
|
|
|
|
expect(res.skillId).toBe(id)
|
|
expect(res.manaCost).toBeGreaterThanOrEqual(0)
|
|
expect(Number.isFinite(res.manaCost256)).toBe(true)
|
|
expect(Number.isFinite(res.toHitBonusPct)).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('4.2 Enforces BATCH1_SKILLS length invariant = strictly 38', () => {
|
|
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Dimension 5: 1.13c SrvSt Pre-Cast Equipment Guards & Kinematics
|
|
// =========================================================================
|
|
describe('Dimension 5: 1.13c SrvSt Pre-Cast Equipment Guards & Kinematics', () => {
|
|
it('5.1 Validates melee weapon requirements for combat skills', () => {
|
|
const meleeSkills = [126, 133, 139, 144, 147, 151, 152]
|
|
for (const skillId of meleeSkills) {
|
|
// Fails if equipped with a bow
|
|
const bowRes = validateBarbarianSkillEquipment(skillId, { weaponItemType: 'bow' })
|
|
expect(bowRes.valid, `Skill ${skillId} must reject bow`).toBe(false)
|
|
expect(bowRes.reason).toBe('requires_melee')
|
|
|
|
// Fails if marked as ranged
|
|
const rangedRes = validateBarbarianSkillEquipment(skillId, { isRanged: true })
|
|
expect(rangedRes.valid, `Skill ${skillId} must reject ranged`).toBe(false)
|
|
|
|
// Passes with a melee weapon
|
|
const swordRes = validateBarbarianSkillEquipment(skillId, { weaponItemType: 'sword' })
|
|
expect(swordRes.valid, `Skill ${skillId} must accept sword`).toBe(true)
|
|
|
|
// Passes with undefined (tri-state backwards compatibility)
|
|
const undefRes = validateBarbarianSkillEquipment(skillId, undefined)
|
|
expect(undefRes.valid).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('5.2 Validates dual-wield requirements for Double Swing (#133) and Frenzy (#147)', () => {
|
|
for (const skillId of [133, 147]) {
|
|
const noDual = validateBarbarianSkillEquipment(skillId, { hasDualWield: false })
|
|
expect(noDual.valid).toBe(false)
|
|
expect(noDual.reason).toBe('requires_dual_wield')
|
|
|
|
const notDualWielding = validateBarbarianSkillEquipment(skillId, { isDualWielding: false })
|
|
expect(notDualWielding.valid).toBe(false)
|
|
expect(notDualWielding.reason).toBe('requires_dual_wield')
|
|
|
|
const dualOk = validateBarbarianSkillEquipment(skillId, { hasDualWield: true, isDualWielding: true })
|
|
expect(dualOk.valid).toBe(true)
|
|
}
|
|
|
|
// Double Throw (#140) dual-throw check
|
|
const noThrow = validateBarbarianSkillEquipment(140, { hasDualThrow: false })
|
|
expect(noThrow.valid).toBe(false)
|
|
expect(noThrow.reason).toBe('requires_dual_throw')
|
|
|
|
const throwOk = validateBarbarianSkillEquipment(140, { hasDualThrow: true })
|
|
expect(throwOk.valid).toBe(true)
|
|
})
|
|
|
|
it('5.3 Validates Leap and Leap Attack destination & range constraints', () => {
|
|
// Leap path blocked
|
|
const blocked = validateBarbarianLeapDestination(132, { isPathBlocked: true })
|
|
expect(blocked.valid).toBe(false)
|
|
expect(blocked.reason).toBe('path_blocked')
|
|
|
|
// Out of range (< 4 or > 30 yards)
|
|
const tooClose = validateBarbarianLeapDestination(132, { distanceYards: 2 })
|
|
expect(tooClose.valid).toBe(false)
|
|
expect(tooClose.reason).toBe('out_of_range')
|
|
|
|
const tooFar = validateBarbarianLeapDestination(132, { distanceYards: 35 })
|
|
expect(tooFar.valid).toBe(false)
|
|
expect(tooFar.reason).toBe('out_of_range')
|
|
|
|
// Leap Attack cannot target unit already in melee range (D2Common SKILLS_CheckIfCanLeapTo)
|
|
const meleeTarget = validateBarbarianLeapDestination(143, { isInMeleeRange: true })
|
|
expect(meleeTarget.valid).toBe(false)
|
|
expect(meleeTarget.reason).toBe('in_melee_range')
|
|
|
|
const validLeap = validateBarbarianLeapDestination(143, { distanceYards: 15, isInMeleeRange: false })
|
|
expect(validLeap.valid).toBe(true)
|
|
})
|
|
|
|
it('5.4 Whirlwind authentic 1.13c frame progression & breakpoints', () => {
|
|
// Fast weapon (EWS <= -60): checks at frame 4, 8, 12, 16, 20...
|
|
const fastWW = calculateWhirlwindHitFrames(24, 60, 0, false)
|
|
expect(fastWW.hitInterval).toBe(4)
|
|
expect(fastWW.hitFrames).toEqual([4, 8, 12, 16, 20, 24])
|
|
|
|
// Slower weapon (EWS > 0): checks at frame 4, 8, 20...
|
|
const slowWW = calculateWhirlwindHitFrames(24, 0, 10, false)
|
|
expect(slowWW.hitInterval).toBe(12)
|
|
expect(slowWW.hitFrames).toEqual([4, 8, 20])
|
|
})
|
|
|
|
it('5.5 Frenzy momentum stacking and expiration decay', () => {
|
|
// Stacking up with consecutive hits
|
|
const s1 = calculateFrenzyStackState(0, 2, 20, 100, 110)
|
|
expect(s1.activeStacks).toBe(2)
|
|
expect(s1.isExpired).toBe(false)
|
|
expect(s1.remainingFrames).toBe(140) // 150 - 10
|
|
expect(s1.currentAttackSpeedBonusPct).toBeGreaterThan(0)
|
|
expect(s1.currentRunSpeedBonusPct).toBeGreaterThan(20)
|
|
|
|
// Max stack clamp at 10
|
|
const sMax = calculateFrenzyStackState(8, 5, 20, 100, 110)
|
|
expect(sMax.activeStacks).toBe(10)
|
|
|
|
// Timer expired (elapsed >= 150 frames = 6.0s) -> decays to 0
|
|
const expired = calculateFrenzyStackState(10, 0, 20, 100, 260)
|
|
expect(expired.isExpired).toBe(true)
|
|
expect(expired.activeStacks).toBe(0)
|
|
expect(expired.currentRunSpeedBonusPct).toBe(0)
|
|
expect(expired.currentAttackSpeedBonusPct).toBe(0)
|
|
})
|
|
|
|
it('5.6 Enforces pre-cast guards during runtime executeSkill113c execution', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const bashRec = registry.getSkillById(126)!
|
|
const stats = new UnitStatList()
|
|
const stateBus = new StateBus(stats, registry)
|
|
const evalRes = evaluateSkill113c({ registry, skill: bashRec, slvl: 1, blvl: 1, statList: stats })
|
|
registerSkillModule(bashModule)
|
|
|
|
const rangedHero: CombatUnitContext = {
|
|
id: 'hero',
|
|
name: 'Barbarian',
|
|
statList: stats,
|
|
stateBus,
|
|
weaponItemType: 'bow',
|
|
isRanged: true,
|
|
}
|
|
|
|
// Bash cast attempt with bow should fail pre-cast check
|
|
const bashOutcome = executeSkill113c({
|
|
registry,
|
|
skill: bashRec,
|
|
evalResult: evalRes,
|
|
caster: rangedHero,
|
|
targets: [],
|
|
targetPositions: new Map(),
|
|
corpses: [],
|
|
missileEngine: {} as any,
|
|
auraScanner: {} as any,
|
|
summonManager: {} as any,
|
|
currentTick: 1,
|
|
targetX: 0,
|
|
targetY: 0,
|
|
})
|
|
|
|
expect(bashOutcome.executed).toBe(false)
|
|
expect(bashOutcome.notes).toContain('fail: requires melee weapon')
|
|
})
|
|
})
|
|
})
|
|
|