615 lines
25 KiB
TypeScript
615 lines
25 KiB
TypeScript
/**
|
|
* Adversarial Stress Testing & Parity Verification Suite for Cohort 5:
|
|
* Barbarian Combat Skills & Combat Masteries (1.13c Ground Truth)
|
|
*
|
|
* Focus Areas:
|
|
* 1. Whirlwind 1.13c Hit Cadence (4th and 8th frame unconditional, IAS/WSM breakpoints, dual-wield)
|
|
* 2. Frenzy Speed Stacking & Decay (sequential hits, 150-frame duration, expiration reset, zero-stack edge case)
|
|
* 3. Berserk Magic Conversion & Zero Defense (100% conversion, physical immunity bypass, magic resistance, zero defense/block)
|
|
* 4. Double Swing Mana Cost Progression (descending cost, 0 at slvl 9+, extreme slvl bounds)
|
|
* 5. Combat Masteries Diminishing Returns (Critical Strike dm56, Natural Resistance dm12, Iron Skin ln12 linear verification)
|
|
* 6. Pre-Cast Equipment Checks (melee weapon check, dual-wield check, runtime execution rejection)
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
calculateWhirlwindHitFrames,
|
|
calculateWhirlwindStats,
|
|
calculateFrenzyStats,
|
|
calculateFrenzyStackState,
|
|
calculateBerserkStats,
|
|
calculateDoubleSwingStats,
|
|
validateBarbarianSkillEquipment,
|
|
} from '../../../src/game/skills/barbarian-combat.ts'
|
|
import {
|
|
calculateSwordMasteryStats,
|
|
calculateAxeMasteryStats,
|
|
calculateMaceMasteryStats,
|
|
calculatePolearmMasteryStats,
|
|
calculateThrowingMasteryStats,
|
|
calculateSpearMasteryStats,
|
|
calculateIronSkinStats,
|
|
calculateNaturalResistanceStats,
|
|
calculateIncreasedSpeedStats,
|
|
} from '../../../src/game/skills/barbarian-masteries.ts'
|
|
import { computeDiminishingReturns } from '../../../src/game/engine/calc-ast.ts'
|
|
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
|
import { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts'
|
|
import { StateBus } from '../../../src/game/engine/state-bus.ts'
|
|
import {
|
|
executeSUnitDmg,
|
|
computeToHitChance,
|
|
evaluateAvoidanceAndBlock,
|
|
type CombatUnitContext,
|
|
} from '../../../src/game/engine/combat-pipeline.ts'
|
|
import {
|
|
evaluateSkill113c,
|
|
executeSkill113c,
|
|
registerSkillModule,
|
|
} from '../../../src/game/skills/registry.ts'
|
|
import { skillModule as doubleSwingModule } from '../../../src/game/skills/impl/bar/skill-133-double-swing.ts'
|
|
import { skillModule as frenzyModule } from '../../../src/game/skills/impl/bar/skill-147-frenzy.ts'
|
|
import { skillModule as berserkModule } from '../../../src/game/skills/impl/bar/skill-152-berserk.ts'
|
|
import { skillModule as whirlwindModule } from '../../../src/game/skills/impl/bar/skill-151-whirlwind.ts'
|
|
|
|
describe('Cohort 5 Adversarial Stress Testing — Barbarian Combat & Masteries', () => {
|
|
// =========================================================================
|
|
// 1. Whirlwind 1.13c Hit Cadence
|
|
// =========================================================================
|
|
describe('1. Whirlwind 1.13c Hit Cadence', () => {
|
|
it('1.1 Unconditional hit checks at 4th and 8th frame regardless of duration', () => {
|
|
// Below frame 4: zero checks
|
|
for (let f = 1; f < 4; f++) {
|
|
const res = calculateWhirlwindHitFrames(f, 0, 0, false)
|
|
expect(res.hitFrames).toEqual([])
|
|
}
|
|
|
|
// Frames 4 through 7: exactly frame 4
|
|
for (let f = 4; f < 8; f++) {
|
|
const res = calculateWhirlwindHitFrames(f, 0, 0, false)
|
|
expect(res.hitFrames).toEqual([4])
|
|
}
|
|
|
|
// Frames 8 through 11 (with slow weapon, interval 12): exactly frames 4 and 8
|
|
for (let f = 8; f < 12; f++) {
|
|
const res = calculateWhirlwindHitFrames(f, 0, 10, false)
|
|
expect(res.hitFrames).toEqual([4, 8])
|
|
}
|
|
})
|
|
|
|
it('1.2 Verifies exact Effective Weapon Speed (WSM - Weapon IAS) breakpoints: 4, 6, 8, 10, 12 frames', () => {
|
|
// EWS <= -60: interval = 4
|
|
const b4 = calculateWhirlwindHitFrames(30, 60, 0)
|
|
expect(b4.hitInterval).toBe(4)
|
|
expect(b4.hitFrames).toEqual([4, 8, 12, 16, 20, 24, 28])
|
|
|
|
// -59 <= EWS <= -35: interval = 6
|
|
const b6_lower = calculateWhirlwindHitFrames(30, 59, 0)
|
|
expect(b6_lower.hitInterval).toBe(6)
|
|
expect(b6_lower.hitFrames).toEqual([4, 8, 14, 20, 26])
|
|
|
|
const b6_upper = calculateWhirlwindHitFrames(30, 35, 0)
|
|
expect(b6_upper.hitInterval).toBe(6)
|
|
expect(b6_upper.hitFrames).toEqual([4, 8, 14, 20, 26])
|
|
|
|
// -34 <= EWS <= -15: interval = 8
|
|
const b8_lower = calculateWhirlwindHitFrames(30, 34, 0)
|
|
expect(b8_lower.hitInterval).toBe(8)
|
|
expect(b8_lower.hitFrames).toEqual([4, 8, 16, 24])
|
|
|
|
const b8_upper = calculateWhirlwindHitFrames(30, 15, 0)
|
|
expect(b8_upper.hitInterval).toBe(8)
|
|
expect(b8_upper.hitFrames).toEqual([4, 8, 16, 24])
|
|
|
|
// -14 <= EWS <= 0: interval = 10
|
|
const b10_lower = calculateWhirlwindHitFrames(30, 14, 0)
|
|
expect(b10_lower.hitInterval).toBe(10)
|
|
expect(b10_lower.hitFrames).toEqual([4, 8, 18, 28])
|
|
|
|
const b10_zero = calculateWhirlwindHitFrames(30, 0, 0)
|
|
expect(b10_zero.hitInterval).toBe(10)
|
|
expect(b10_zero.hitFrames).toEqual([4, 8, 18, 28])
|
|
|
|
// EWS > 0: interval = 12
|
|
const b12 = calculateWhirlwindHitFrames(30, 0, 10)
|
|
expect(b12.hitInterval).toBe(12)
|
|
expect(b12.hitFrames).toEqual([4, 8, 20])
|
|
})
|
|
|
|
it('1.3 Fuzzing sweep across EWS [-100..+50] validates monotonic intervals and strict step boundaries', () => {
|
|
let previousInterval = 4
|
|
for (let ews = -100; ews <= 50; ews++) {
|
|
// weaponBaseSpeed = ews, weaponIas = 0 -> EWS = ews
|
|
const res = calculateWhirlwindHitFrames(50, 0, ews)
|
|
if (ews <= -60) {
|
|
expect(res.hitInterval).toBe(4)
|
|
} else if (ews <= -35) {
|
|
expect(res.hitInterval).toBe(6)
|
|
} else if (ews <= -15) {
|
|
expect(res.hitInterval).toBe(8)
|
|
} else if (ews <= 0) {
|
|
expect(res.hitInterval).toBe(10)
|
|
} else {
|
|
expect(res.hitInterval).toBe(12)
|
|
}
|
|
expect(res.hitInterval).toBeGreaterThanOrEqual(previousInterval)
|
|
previousInterval = res.hitInterval
|
|
}
|
|
})
|
|
|
|
it('1.4 Dual-wielding flag propagation', () => {
|
|
const dwRes = calculateWhirlwindHitFrames(24, 60, 0, true)
|
|
expect(dwRes.isDualWield).toBe(true)
|
|
expect(dwRes.hitFrames).toEqual([4, 8, 12, 16, 20, 24])
|
|
|
|
const singleRes = calculateWhirlwindHitFrames(24, 60, 0, false)
|
|
expect(singleRes.isDualWield).toBe(false)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// 2. Frenzy Speed Stacking & Decay
|
|
// =========================================================================
|
|
describe('2. Frenzy Speed Stacking & Decay', () => {
|
|
it('2.1 Sequential successful hits stack attack speed and run/walk speed buffs monotonically up to caps', () => {
|
|
const slvl = 20
|
|
const stats = calculateFrenzyStats(slvl)
|
|
let currentStacks = 0
|
|
let lastRunBonus = 0
|
|
let lastAtkBonus = 0
|
|
|
|
// Simulate 15 consecutive hits
|
|
for (let hit = 1; hit <= 15; hit++) {
|
|
const state = calculateFrenzyStackState(currentStacks, 1, slvl, 100, 105)
|
|
expect(state.isExpired).toBe(false)
|
|
expect(state.activeStacks).toBe(Math.min(10, hit))
|
|
|
|
if (state.activeStacks < 10) {
|
|
expect(state.currentRunSpeedBonusPct).toBeGreaterThanOrEqual(lastRunBonus)
|
|
expect(state.currentAttackSpeedBonusPct).toBeGreaterThanOrEqual(lastAtkBonus)
|
|
} else {
|
|
// At max 10 stacks, cap is reached
|
|
expect(state.currentRunSpeedBonusPct).toBe(stats.maxRunSpeedPct)
|
|
expect(state.currentAttackSpeedBonusPct).toBe(stats.maxAttackSpeedPct)
|
|
}
|
|
|
|
currentStacks = state.activeStacks
|
|
lastRunBonus = state.currentRunSpeedBonusPct
|
|
lastAtkBonus = state.currentAttackSpeedBonusPct
|
|
}
|
|
|
|
// Max stack count is strictly 10
|
|
expect(currentStacks).toBe(10)
|
|
})
|
|
|
|
it('2.2 Frenzy stack duration is strictly 150 frames (6.0s) and properly resets upon expiration', () => {
|
|
const slvl = 10
|
|
const lastHit = 1000
|
|
|
|
// At frame 1149 (elapsed = 149 frames < 150): active and remainingFrames = 1
|
|
const activeState = calculateFrenzyStackState(10, 0, slvl, lastHit, 1149)
|
|
expect(activeState.isExpired).toBe(false)
|
|
expect(activeState.activeStacks).toBe(10)
|
|
expect(activeState.remainingFrames).toBe(1)
|
|
expect(activeState.currentRunSpeedBonusPct).toBeGreaterThan(0)
|
|
expect(activeState.currentAttackSpeedBonusPct).toBeGreaterThan(0)
|
|
|
|
// At frame 1150 (elapsed = 150 frames): EXPIRED and fully reset to 0
|
|
const expiredAtExact = calculateFrenzyStackState(10, 0, slvl, lastHit, 1150)
|
|
expect(expiredAtExact.isExpired).toBe(true)
|
|
expect(expiredAtExact.activeStacks).toBe(0)
|
|
expect(expiredAtExact.remainingFrames).toBe(0)
|
|
expect(expiredAtExact.currentRunSpeedBonusPct).toBe(0)
|
|
expect(expiredAtExact.currentAttackSpeedBonusPct).toBe(0)
|
|
|
|
// Long after expiration (elapsed = 500 frames): remains 0
|
|
const wayPast = calculateFrenzyStackState(10, 0, slvl, lastHit, 1500)
|
|
expect(wayPast.isExpired).toBe(true)
|
|
expect(wayPast.activeStacks).toBe(0)
|
|
expect(wayPast.currentRunSpeedBonusPct).toBe(0)
|
|
expect(wayPast.currentAttackSpeedBonusPct).toBe(0)
|
|
})
|
|
|
|
it('2.3 Adversarial Edge Case: 0 stacks and 0 hits within elapsed window', () => {
|
|
// When a player has 0 current stacks and makes 0 hits, newStacks is 0
|
|
const zeroState = calculateFrenzyStackState(0, 0, 1, 0, 50)
|
|
expect(zeroState.activeStacks).toBe(0)
|
|
// Note: ratio 0/10 yields minRunSpeedPct in raw formula unless clamped or gated
|
|
expect(zeroState.currentAttackSpeedBonusPct).toBe(0)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// 3. Berserk Magic Conversion & Zero Defense
|
|
// =========================================================================
|
|
describe('3. Berserk Magic Conversion & Zero Defense', () => {
|
|
it('3.1 100% of physical damage converts to Magic damage (bypassing physical resistance)', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const heroStats = new UnitStatList()
|
|
heroStats.addStat('level', 80)
|
|
heroStats.addStat('tohit', 5000)
|
|
heroStats.addStat('damageresist', 0)
|
|
const heroBus = new StateBus(heroStats, registry)
|
|
|
|
const attacker: CombatUnitContext = {
|
|
id: 'barb',
|
|
name: 'Barbarian',
|
|
statList: heroStats,
|
|
stateBus: heroBus,
|
|
weaponMinPhys: 100,
|
|
weaponMaxPhys: 100,
|
|
}
|
|
|
|
// Defender with 100% Physical Immunity (damageresist = 100) and 0% Magic Resist (magicresist = 0)
|
|
const immuneStats = new UnitStatList()
|
|
immuneStats.addStat('level', 80)
|
|
immuneStats.addStat('current_hp', 50000)
|
|
immuneStats.addStat('armorclass', 100)
|
|
immuneStats.addStat('damageresist', 100) // Physical Immune
|
|
immuneStats.addStat('magicresist', 0)
|
|
const immuneBus = new StateBus(immuneStats, registry)
|
|
const physImmuneDefender: CombatUnitContext = {
|
|
id: 'stone_skin_boss',
|
|
name: 'Physical Immune Monster',
|
|
statList: immuneStats,
|
|
stateBus: immuneBus,
|
|
}
|
|
|
|
// Normal physical attack deals 0 damage due to physical immunity
|
|
const normalDmg = executeSUnitDmg(attacker, physImmuneDefender, {
|
|
skillId: 0,
|
|
attackKind: 'melee',
|
|
srcDam: 128,
|
|
physDamagePct: 0,
|
|
autoHit: true,
|
|
})
|
|
expect(normalDmg.immuneToPhys).toBe(true)
|
|
expect(normalDmg.totalDamage).toBe(0)
|
|
|
|
// Berserk (skillId = 152) converts 100% to Magic damage and completely BYPASSES physical immunity
|
|
const berserkDmg = executeSUnitDmg(attacker, physImmuneDefender, {
|
|
skillId: 152,
|
|
attackKind: 'melee',
|
|
srcDam: 128,
|
|
physDamagePct: 150, // +150% ED at slvl 1
|
|
physToElemPct: 100,
|
|
autoHit: true,
|
|
})
|
|
expect(berserkDmg.immuneToPhys).toBe(false)
|
|
expect(berserkDmg.physDamage256).toBe(0)
|
|
expect(berserkDmg.elemDamage256).toBeGreaterThan(0)
|
|
expect(berserkDmg.totalDamage).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('3.2 Converted magic damage is correctly mitigated by target magic resistance and blocked by magic immunity', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const heroStats = new UnitStatList()
|
|
heroStats.addStat('level', 80)
|
|
heroStats.addStat('tohit', 5000)
|
|
const heroBus = new StateBus(heroStats, registry)
|
|
|
|
const attacker: CombatUnitContext = {
|
|
id: 'barb',
|
|
name: 'Barbarian',
|
|
statList: heroStats,
|
|
stateBus: heroBus,
|
|
weaponMinPhys: 100,
|
|
weaponMaxPhys: 100,
|
|
}
|
|
|
|
// Defender with 50% Magic Resistance
|
|
const mrStats = new UnitStatList()
|
|
mrStats.addStat('level', 80)
|
|
mrStats.addStat('current_hp', 50000)
|
|
mrStats.addStat('armorclass', 100)
|
|
mrStats.addStat('magicresist', 50)
|
|
const mrBus = new StateBus(mrStats, registry)
|
|
const halfResDefender: CombatUnitContext = {
|
|
id: 'magic_res_monster',
|
|
name: 'Magic Res Monster',
|
|
statList: mrStats,
|
|
stateBus: mrBus,
|
|
}
|
|
|
|
const halfDmg = executeSUnitDmg(attacker, halfResDefender, {
|
|
skillId: 152,
|
|
attackKind: 'melee',
|
|
srcDam: 128,
|
|
physDamagePct: 0,
|
|
physToElemPct: 100,
|
|
autoHit: true,
|
|
})
|
|
// 100 base weapon damage with 50% magic resist yields 50 damage
|
|
expect(halfDmg.totalDamage).toBe(50)
|
|
|
|
// Defender with 100% Magic Immunity
|
|
const miStats = new UnitStatList()
|
|
miStats.addStat('level', 80)
|
|
miStats.addStat('current_hp', 50000)
|
|
miStats.addStat('magicresist', 100) // Magic Immune
|
|
const miBus = new StateBus(miStats, registry)
|
|
const magicImmuneDefender: CombatUnitContext = {
|
|
id: 'unraveler_boss',
|
|
name: 'Magic Immune Monster',
|
|
statList: miStats,
|
|
stateBus: miBus,
|
|
}
|
|
|
|
const zeroDmg = executeSUnitDmg(attacker, magicImmuneDefender, {
|
|
skillId: 152,
|
|
attackKind: 'melee',
|
|
srcDam: 128,
|
|
physDamagePct: 0,
|
|
physToElemPct: 100,
|
|
autoHit: true,
|
|
})
|
|
expect(zeroDmg.immuneToElem).toBe(true)
|
|
expect(zeroDmg.totalDamage).toBe(0)
|
|
})
|
|
|
|
it('3.3 Berserk stats calculate strictly 100% magic conversion and slvl-dependent zero-defense duration', () => {
|
|
for (let slvl = 1; slvl <= 30; slvl++) {
|
|
const stats = calculateBerserkStats(slvl)
|
|
expect(stats.magicConversionPct).toBe(100)
|
|
expect(stats.zeroDefenseDurationFrames).toBeLessThanOrEqual(75)
|
|
expect(stats.zeroDefenseDurationFrames).toBeGreaterThanOrEqual(25)
|
|
}
|
|
|
|
// Duration decreases with higher skill level
|
|
const bz1 = calculateBerserkStats(1)
|
|
const bz10 = calculateBerserkStats(10)
|
|
const bz20 = calculateBerserkStats(20)
|
|
const bz30 = calculateBerserkStats(30)
|
|
expect(bz1.zeroDefenseDurationFrames).toBeGreaterThan(bz10.zeroDefenseDurationFrames)
|
|
expect(bz10.zeroDefenseDurationFrames).toBeGreaterThan(bz20.zeroDefenseDurationFrames)
|
|
expect(bz20.zeroDefenseDurationFrames).toBeGreaterThan(bz30.zeroDefenseDurationFrames)
|
|
})
|
|
|
|
it('3.4 Adversarial Audit: Verifies defense rating and blocking when defender has 0 defense / no block', () => {
|
|
// When defender defense is 0, ToHit chance depends solely on level ratio (capped [5..95])
|
|
const toHitZeroDef = computeToHitChance({
|
|
attackerAr: 1000,
|
|
defenderDef: 0,
|
|
attackerLvl: 80,
|
|
defenderLvl: 80,
|
|
})
|
|
// 2 * 1000 / (1000 + 0) * (80 / (80 + 80)) = 2 * 1 * 0.5 = 1.0 (clamped to 95%)
|
|
expect(toHitZeroDef).toBe(95)
|
|
|
|
// When toblock is 0, blocking cannot occur
|
|
const dummyStats = new UnitStatList()
|
|
dummyStats.addStat('toblock', 0)
|
|
const dummyDefender: CombatUnitContext = {
|
|
id: 'target',
|
|
name: 'Target',
|
|
statList: dummyStats,
|
|
stateBus: new StateBus(dummyStats, {} as any),
|
|
hasShield: true,
|
|
}
|
|
const blockResult = evaluateAvoidanceAndBlock({
|
|
defender: dummyDefender,
|
|
attackKind: 'melee',
|
|
roll100: 10,
|
|
})
|
|
expect(blockResult.avoided).toBe(false)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// 4. Double Swing Mana Cost Progression
|
|
// =========================================================================
|
|
describe('4. Double Swing Mana Cost Progression', () => {
|
|
it('4.1 Verifies Double Swing mana cost decreases with level and becomes strictly 0 at slvl 9+', () => {
|
|
// Theoretical D2 1.13c progression: (8 - (slvl - 1)) * 64 in 256ths
|
|
// slvl 1: 512 (2 mana)
|
|
// slvl 2: 448 (1.75 -> 2 mana)
|
|
// slvl 3: 384 (1.50 -> 2 mana)
|
|
// slvl 4: 320 (1.25 -> 2 mana)
|
|
// slvl 5: 256 (1.00 -> 1 mana)
|
|
// slvl 6: 192 (0.75 -> 1 mana)
|
|
// slvl 7: 128 (0.50 -> 1 mana)
|
|
// slvl 8: 64 (0.25 -> 1 mana)
|
|
// slvl 9+: 0 mana
|
|
const expectedMana256 = [
|
|
512, // slvl 1
|
|
448, // slvl 2
|
|
384, // slvl 3
|
|
320, // slvl 4
|
|
256, // slvl 5
|
|
192, // slvl 6
|
|
128, // slvl 7
|
|
64, // slvl 8
|
|
0, // slvl 9
|
|
]
|
|
|
|
for (let slvl = 1; slvl <= 9; slvl++) {
|
|
const stats = calculateDoubleSwingStats(slvl)
|
|
expect(stats.manaCost256).toBe(expectedMana256[slvl - 1])
|
|
if (slvl < 9) {
|
|
expect(stats.manaCost).toBeGreaterThan(0)
|
|
} else {
|
|
expect(stats.manaCost).toBe(0)
|
|
}
|
|
}
|
|
|
|
// slvl 10 through 99 must remain strictly 0 mana
|
|
for (let slvl = 10; slvl <= 99; slvl += 10) {
|
|
const stats = calculateDoubleSwingStats(slvl)
|
|
expect(stats.manaCost).toBe(0)
|
|
expect(stats.manaCost256).toBe(0)
|
|
}
|
|
})
|
|
|
|
it('4.2 Differential testing of Double Swing mana cost against naive oracle', () => {
|
|
const oracleManaCost256 = (lvl: number) => Math.max(0, (8 - (lvl - 1)) * 64)
|
|
for (let slvl = -5; slvl <= 100; slvl++) {
|
|
const actual = calculateDoubleSwingStats(slvl).manaCost256
|
|
const effLvl = Math.max(1, slvl)
|
|
const expected = oracleManaCost256(effLvl)
|
|
expect(actual).toBe(expected)
|
|
}
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// 5. Combat Masteries Diminishing Returns
|
|
// =========================================================================
|
|
describe('5. Combat Masteries Diminishing Returns', () => {
|
|
it('5.1 Critical Strike chance follows authentic diminishing returns formula dm(0, 35, slvl) across levels 1..30', () => {
|
|
const oracleCrit = (lvl: number) => Math.trunc((110 * lvl * 35) / (100 * (lvl + 6)))
|
|
|
|
const masteryFuncs = [
|
|
{ name: 'Sword', fn: calculateSwordMasteryStats },
|
|
{ name: 'Axe', fn: calculateAxeMasteryStats },
|
|
{ name: 'Mace', fn: calculateMaceMasteryStats },
|
|
{ name: 'Polearm', fn: calculatePolearmMasteryStats },
|
|
{ name: 'Throwing', fn: calculateThrowingMasteryStats },
|
|
{ name: 'Spear', fn: calculateSpearMasteryStats },
|
|
]
|
|
|
|
for (const m of masteryFuncs) {
|
|
for (let slvl = 1; slvl <= 30; slvl++) {
|
|
const stats = m.fn(slvl)
|
|
const expected = oracleCrit(slvl)
|
|
expect(stats.criticalStrikeChancePct, `${m.name} Mastery Crit at slvl ${slvl}`).toBe(expected)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('5.2 Natural Resistance elemental resistance % follows authentic diminishing returns curve dm(0, 80, slvl) across levels 1..30', () => {
|
|
const oracleResist = (lvl: number) => Math.trunc((110 * lvl * 80) / (100 * (lvl + 6)))
|
|
|
|
for (let slvl = 1; slvl <= 30; slvl++) {
|
|
const stats = calculateNaturalResistanceStats(slvl)
|
|
const expected = oracleResist(slvl)
|
|
expect(stats.resistAllPct, `Natural Resistance at slvl ${slvl}`).toBe(expected)
|
|
expect(stats.fireResistPct).toBe(expected)
|
|
expect(stats.coldResistPct).toBe(expected)
|
|
expect(stats.lightningResistPct).toBe(expected)
|
|
expect(stats.poisonResistPct).toBe(expected)
|
|
}
|
|
})
|
|
|
|
it('5.3 Ground Truth Parity Check: Iron Skin defense bonus is strictly LINEAR (ln12: 30 + 10*(slvl-1)), NOT diminishing returns', () => {
|
|
// 1.13c Skills.txt specifies:
|
|
// Param1: 30, Param2: 10, passivecalc1: ln12
|
|
// Formula: 30 + (slvl - 1) * 10
|
|
for (let slvl = 1; slvl <= 30; slvl++) {
|
|
const stats = calculateIronSkinStats(slvl)
|
|
const expectedLinear = 30 + (slvl - 1) * 10
|
|
expect(stats.defenseBonusPct, `Iron Skin at slvl ${slvl}`).toBe(expectedLinear)
|
|
}
|
|
})
|
|
|
|
it('5.4 Increased Speed velocity follows authentic diminishing returns curve dm(7, 50, slvl)', () => {
|
|
const oracleSpeed = (lvl: number) => 7 + Math.trunc((110 * lvl * (50 - 7)) / (100 * (lvl + 6)))
|
|
|
|
for (let slvl = 1; slvl <= 30; slvl++) {
|
|
const stats = calculateIncreasedSpeedStats(slvl)
|
|
const expected = oracleSpeed(slvl)
|
|
expect(stats.runWalkSpeedBonusPct, `Increased Speed at slvl ${slvl}`).toBe(expected)
|
|
}
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// 6. Pre-Cast Equipment Checks
|
|
// =========================================================================
|
|
describe('6. Pre-Cast Equipment Checks', () => {
|
|
it('6.1 Melee weapon requirement strictly enforced across Bash, Double Swing, Stun, Concentrate, Frenzy, Whirlwind, Berserk', () => {
|
|
const meleeSkills = [126, 133, 139, 144, 147, 151, 152]
|
|
|
|
for (const skillId of meleeSkills) {
|
|
// Rejects Bow
|
|
expect(validateBarbarianSkillEquipment(skillId, { weaponItemType: 'bow' }).valid).toBe(false)
|
|
expect(validateBarbarianSkillEquipment(skillId, { weaponItemType: 'bow' }).reason).toBe('requires_melee')
|
|
|
|
// Rejects Crossbow
|
|
expect(validateBarbarianSkillEquipment(skillId, { weaponItemType: 'crossbow' }).valid).toBe(false)
|
|
expect(validateBarbarianSkillEquipment(skillId, { weaponItemType: 'crossbow' }).reason).toBe('requires_melee')
|
|
|
|
// Rejects isRanged: true
|
|
expect(validateBarbarianSkillEquipment(skillId, { isRanged: true }).valid).toBe(false)
|
|
expect(validateBarbarianSkillEquipment(skillId, { isRanged: true }).reason).toBe('requires_melee')
|
|
|
|
// Accepts melee weapons
|
|
for (const meleeType of ['sword', 'axe', 'mace', 'spear', 'polearm']) {
|
|
expect(validateBarbarianSkillEquipment(skillId, { weaponItemType: meleeType, isRanged: false }).valid).toBe(true)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('6.2 Dual-wield requirement strictly enforced on Double Swing (#133) and Frenzy (#147)', () => {
|
|
for (const skillId of [133, 147]) {
|
|
// Missing dual wield flag
|
|
expect(validateBarbarianSkillEquipment(skillId, { hasDualWield: false }).valid).toBe(false)
|
|
expect(validateBarbarianSkillEquipment(skillId, { hasDualWield: false }).reason).toBe('requires_dual_wield')
|
|
|
|
// Not dual wielding
|
|
expect(validateBarbarianSkillEquipment(skillId, { isDualWielding: false }).valid).toBe(false)
|
|
expect(validateBarbarianSkillEquipment(skillId, { isDualWielding: false }).reason).toBe('requires_dual_wield')
|
|
|
|
// Valid dual wielding
|
|
expect(validateBarbarianSkillEquipment(skillId, { hasDualWield: true, isDualWielding: true }).valid).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('6.3 Runtime execution rejection: Double Swing and Frenzy fail fast when dual-wielding is absent', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
registerSkillModule(doubleSwingModule)
|
|
registerSkillModule(frenzyModule)
|
|
|
|
const singleWieldHero: CombatUnitContext = {
|
|
id: 'hero',
|
|
name: 'Barbarian',
|
|
statList: new UnitStatList(),
|
|
stateBus: new StateBus(new UnitStatList(), registry),
|
|
hasDualWield: false,
|
|
isDualWielding: false,
|
|
}
|
|
|
|
// Double Swing (#133)
|
|
const dsRec = registry.getSkillById(133)!
|
|
const dsEval = evaluateSkill113c({ registry, skill: dsRec, slvl: 1, blvl: 1, statList: singleWieldHero.statList })
|
|
const dsOutcome = executeSkill113c({
|
|
registry,
|
|
skill: dsRec,
|
|
evalResult: dsEval,
|
|
caster: singleWieldHero,
|
|
targets: [],
|
|
targetPositions: new Map(),
|
|
corpses: [],
|
|
missileEngine: {} as any,
|
|
auraScanner: {} as any,
|
|
summonManager: {} as any,
|
|
currentTick: 1,
|
|
targetX: 0,
|
|
targetY: 0,
|
|
})
|
|
expect(dsOutcome.executed).toBe(false)
|
|
expect(dsOutcome.notes).toContain('fail: requires dual-wield weapons')
|
|
|
|
// Frenzy (#147)
|
|
const fRec = registry.getSkillById(147)!
|
|
const fEval = evaluateSkill113c({ registry, skill: fRec, slvl: 1, blvl: 1, statList: singleWieldHero.statList })
|
|
const fOutcome = executeSkill113c({
|
|
registry,
|
|
skill: fRec,
|
|
evalResult: fEval,
|
|
caster: singleWieldHero,
|
|
targets: [],
|
|
targetPositions: new Map(),
|
|
corpses: [],
|
|
missileEngine: {} as any,
|
|
auraScanner: {} as any,
|
|
summonManager: {} as any,
|
|
currentTick: 1,
|
|
targetX: 0,
|
|
targetY: 0,
|
|
})
|
|
expect(fOutcome.executed).toBe(false)
|
|
expect(fOutcome.notes).toContain('fail: requires dual-wield weapons')
|
|
})
|
|
})
|
|
})
|