diff --git a/src/game/skills/druid-shapeshift.ts b/src/game/skills/druid-shapeshift.ts new file mode 100644 index 0000000..424fb1d --- /dev/null +++ b/src/game/skills/druid-shapeshift.ts @@ -0,0 +1,614 @@ +/** + * Diablo II: Lord of Destruction v1.13c — Druid Shape Shifting Skills Module + * + * Dedicated pure calculation, transformation states, charge-up orb progression, + * contagious spread, and kinematics formulas for the 10 Druid Shape Shifting Tree Skills: + * - Skill 223: Werewolf (ReqLevel 1) + * - Skill 224: Lycanthropy (ReqLevel 1) + * - Skill 228: Werebear (ReqLevel 6) + * - Skill 232: Feral Rage (ReqLevel 12) + * - Skill 233: Maul (ReqLevel 12) + * - Skill 238: Rabies (ReqLevel 18) + * - Skill 239: Fire Claws (ReqLevel 18) + * - Skill 242: Hunger (ReqLevel 24) + * - Skill 243: Shock Wave (ReqLevel 24) + * - Skill 248: Fury (ReqLevel 30) + * + * 1.13c Ground Truth: + * - Skills.txt, Missiles.txt, States.txt, Overlay.txt (Patch_D2.mpq) + * - Werewolf: attack rate dm(10, 80, slvl), attack rating, stamina, life, Lycanthropy duration + * - Lycanthropy: passive +20s/slvl shift duration, +20% base + 5%/slvl max HP + * - Werebear: enhanced physical damage 55% + 8%/slvl, armor bonus 25% + 6%/slvl, max HP 75% + Lycanthropy + * - Feral Rage: 3-stage charge orb, speed bonus dm(10, 70, slvl), life leech 4% * slvl + * - Maul: 5-stage charge orb, damage bonus 20% * slvl per charge, stun dm(10, 100, slvl) + * - Rabies: infectious poison bite spreading to adjacent targets within 4 subtiles + * - Fire Claws: massive elemental fire melee strike synergized by 4 elemental spells (+22%/blvl) + * - Hunger: dual life & mana leech dm(50, 200, slvl) with -75% damage penalty + * - Shock Wave: bear-only stun cone ln12 stun duration (40 frames + 15/slvl) + * - Fury: 5-hit uninterrupted feral frenzy sequence (min(2 + slvl - 1, 5) hits) + * - Zero runtime MPQ reading + */ + +import { compute5BandScaling, computeDiminishingReturns } from '../engine/calc-ast.ts' + +// --- Skill IDs --- + +export const DRUID_SHAPESHIFT_SKILL_IDS = { + WEREWOLF: 223, + LYCANTHROPY: 224, + WEREBEAR: 228, + FERAL_RAGE: 232, + MAUL: 233, + RABIES: 238, + FIRE_CLAWS: 239, + HUNGER: 242, + SHOCK_WAVE: 243, + FURY: 248, +} as const + +// --- Interfaces for Skill Calculations --- + +export interface WerewolfStats { + readonly attackRateBonusPct: number + readonly toHitBonusPct: number + readonly staminaBonusPct: number + readonly maxHpBonusPct: number + readonly durationFrames: number + readonly durationSeconds: number + readonly cooldownFrames: number + readonly cooldownSeconds: number + readonly manaCost: number + readonly manaCost256: number + readonly stateName: string +} + +export interface LycanthropyStats { + readonly durationBonusFrames: number + readonly durationBonusSeconds: number + readonly maxHpBonusPct: number + readonly isPassive: boolean +} + +export interface WerebearStats { + readonly damageBonusPct: number + readonly defenseBonusPct: number + readonly maxHpBonusPct: number + readonly durationFrames: number + readonly durationSeconds: number + readonly cooldownFrames: number + readonly cooldownSeconds: number + readonly manaCost: number + readonly manaCost256: number + readonly stateName: string +} + +export interface FeralRageStats { + readonly damageBonusPct: number + readonly toHitBonusPct: number + readonly lifeLeechPct: number + readonly velocityBonusPct: number + readonly durationFrames: number + readonly durationSeconds: number + readonly maxCharges: number + readonly manaCost: number + readonly manaCost256: number + readonly stateName: string + readonly overlayName: string +} + +export interface MaulStats { + readonly damageBonusPerChargePct: number + readonly maxDamageBonusPct: number + readonly toHitBonusPct: number + readonly stunDurationFrames: number + readonly stunDurationSeconds: number + readonly durationFrames: number + readonly durationSeconds: number + readonly maxCharges: number + readonly manaCost: number + readonly manaCost256: number + readonly stateName: string + readonly overlayName: string +} + +export interface RabiesStats { + readonly minPoisonDamage: number + readonly maxPoisonDamage: number + readonly poisonDurationFrames: number + readonly poisonDurationSeconds: number + readonly toHitBonusPct: number + readonly contagionRadiusSubtiles: number + readonly contagionSpreadIntervalFrames: number + readonly synergyBonusPct: number + readonly manaCost: number + readonly manaCost256: number + readonly overlayName: string +} + +export interface FireClawsStats { + readonly minFireDamage: number + readonly maxFireDamage: number + readonly toHitBonusPct: number + readonly synergyBonusPct: number + readonly manaCost: number + readonly manaCost256: number +} + +export interface HungerStats { + readonly damagePenaltyPct: number + readonly lifeLeechPct: number + readonly manaLeechPct: number + readonly toHitBonusPct: number + readonly manaCost: number + readonly manaCost256: number +} + +export interface ShockWaveStats { + readonly minPhysicalDamage: number + readonly maxPhysicalDamage: number + readonly stunDurationFrames: number + readonly stunDurationSeconds: number + readonly synergyBonusPct: number + readonly cooldownFrames: number + readonly manaCost: number + readonly manaCost256: number + readonly missileName: string +} + +export interface FuryStats { + readonly damageBonusPct: number + readonly toHitBonusPct: number + readonly attackCount: number + readonly isUninterruptible: boolean + readonly manaCost: number + readonly manaCost256: number +} + +// --- Pure Calculation Functions --- + +/** + * Skill 223: Werewolf (Wearwolf) + * Ground Truth: + * - Attack Rate bonus: dm(10, 80, slvl) + * - ToHit: 50 + (slvl - 1) * 15 + * - Stamina: 25% (constant) + * - Max HP: 25% base + Lycanthropy.ln34 (20 + (lycBlvl - 1) * 5)% + * - Duration: 1000 frames (40s) + Lycanthropy.ln12 (1000 + (lycBlvl - 1) * 500 frames) + * - Mana: 15, Cooldown: 25 frames (1.0s) + */ +export function calculateWerewolfStats( + slvl: number, + lycanthropyBlvl = 0, +): WerewolfStats { + const lvl = Math.max(1, slvl) + const lycPts = Math.max(0, lycanthropyBlvl) + + const attackRateBonusPct = computeDiminishingReturns(10, 80, lvl) + const toHitBonusPct = 50 + (lvl - 1) * 15 + + const lycHpBonus = lycPts > 0 ? 20 + (lycPts - 1) * 5 : 0 + const maxHpBonusPct = 25 + lycHpBonus + + const lycDurationBonus = lycPts > 0 ? 1000 + (lycPts - 1) * 500 : 0 + const durationFrames = 1000 + lycDurationBonus + + return { + attackRateBonusPct, + toHitBonusPct, + staminaBonusPct: 25, + maxHpBonusPct, + durationFrames, + durationSeconds: durationFrames / 25, + cooldownFrames: 25, + cooldownSeconds: 1.0, + manaCost: 15, + manaCost256: 3840, + stateName: 'wolf', + } +} + +/** + * Skill 224: Lycanthropy (Shape Shifting) + * Ground Truth: + * - Passive skill + * - Duration bonus: 1000 + (slvl - 1) * 500 frames (+20s/lvl) + * - Max HP bonus: 20 + (slvl - 1) * 5 % (+20% base, +5%/lvl) + */ +export function calculateLycanthropyStats(slvl: number): LycanthropyStats { + const lvl = Math.max(1, slvl) + const durationBonusFrames = 1000 + (lvl - 1) * 500 + const maxHpBonusPct = 20 + (lvl - 1) * 5 + + return { + durationBonusFrames, + durationBonusSeconds: durationBonusFrames / 25, + maxHpBonusPct, + isPassive: true, + } +} + +/** + * Skill 228: Werebear (Wearbear) + * Ground Truth: + * - Enhanced Damage: 55 + (slvl - 1) * 8 % + * - Armor / Defense: 25 + (slvl - 1) * 6 % + * - Max HP: 75% base + Lycanthropy.ln34 (20 + (lycBlvl - 1) * 5)% + * - Duration: 1000 frames (40s) + Lycanthropy.ln12 (1000 + (lycBlvl - 1) * 500 frames) + * - Mana: 15, Cooldown: 25 frames (1.0s) + */ +export function calculateWerebearStats( + slvl: number, + lycanthropyBlvl = 0, +): WerebearStats { + const lvl = Math.max(1, slvl) + const lycPts = Math.max(0, lycanthropyBlvl) + + const damageBonusPct = 55 + (lvl - 1) * 8 + const defenseBonusPct = 25 + (lvl - 1) * 6 + + const lycHpBonus = lycPts > 0 ? 20 + (lycPts - 1) * 5 : 0 + const maxHpBonusPct = 75 + lycHpBonus + + const lycDurationBonus = lycPts > 0 ? 1000 + (lycPts - 1) * 500 : 0 + const durationFrames = 1000 + lycDurationBonus + + return { + damageBonusPct, + defenseBonusPct, + maxHpBonusPct, + durationFrames, + durationSeconds: durationFrames / 25, + cooldownFrames: 25, + cooldownSeconds: 1.0, + manaCost: 15, + manaCost256: 3840, + stateName: 'bear', + } +} + +/** + * Skill 232: Feral Rage + * Ground Truth: + * - Wolf-only attack + * - Damage: 50 + (slvl - 1) * 5 % + * - ToHit: 20 + (slvl - 1) * 10 % + * - Life Leech: 4 * slvl % (accumulates with charge levels 1..3) + * - Velocity: dm(10, 70, slvl) % + * - Duration: 500 frames (20.0s) + * - Mana: 3 + */ +export function calculateFeralRageStats(slvl: number): FeralRageStats { + const lvl = Math.max(1, slvl) + const damageBonusPct = 50 + (lvl - 1) * 5 + const toHitBonusPct = 20 + (lvl - 1) * 10 + const lifeLeechPct = 4 * lvl + const velocityBonusPct = computeDiminishingReturns(10, 70, lvl) + + return { + damageBonusPct, + toHitBonusPct, + lifeLeechPct, + velocityBonusPct, + durationFrames: 500, + durationSeconds: 20.0, + maxCharges: 3, + manaCost: 3, + manaCost256: 768, + stateName: 'feralrage', + overlayName: 'feralrage1front', + } +} + +/** + * Skill 233: Maul + * Ground Truth: + * - Bear-only attack + * - Damage bonus per charge: slvl * 20 % (up to 5 charges = slvl * 100 %) + * - ToHit: 20 + (slvl - 1) * 10 % + * - Stun duration: dm(10, 100, slvl) frames + * - Duration: 500 frames (20.0s) + * - Mana: 3 + */ +export function calculateMaulStats(slvl: number): MaulStats { + const lvl = Math.max(1, slvl) + const damageBonusPerChargePct = lvl * 20 + const maxDamageBonusPct = lvl * 100 + const toHitBonusPct = 20 + (lvl - 1) * 10 + const stunDurationFrames = computeDiminishingReturns(10, 100, lvl) + + return { + damageBonusPerChargePct, + maxDamageBonusPct, + toHitBonusPct, + stunDurationFrames, + stunDurationSeconds: Number((stunDurationFrames / 25).toFixed(2)), + durationFrames: 500, + durationSeconds: 20.0, + maxCharges: 5, + manaCost: 3, + manaCost256: 768, + stateName: 'maul', + overlayName: 'maul1front', + } +} + +/** + * Skill 238: Rabies + * Ground Truth: + * - Wolf-only infectious bite + * - Poison damage: eMin=6 [4, 5, 7, 11, 16], eMax=14 [4, 5, 7, 11, 16] + * - Poison length: 100 + (slvl - 1) * 10 frames (4s + 0.4s/lvl) + * - ToHit: 50 + (slvl - 1) * 7 % + * - Synergy: +18% poison damage per blvl in Plague Poppy (Poison Creeper) + * - Contagion radius: 4 subtiles, spread cadence: every 4 frames + * - Mana: 10 + */ +export function calculateRabiesStats( + slvl: number, + plaguePoppyBlvl = 0, +): RabiesStats { + const lvl = Math.max(1, slvl) + const ppPts = Math.max(0, plaguePoppyBlvl) + + const rawMin = compute5BandScaling(lvl, 6, 4, 5, 7, 11, 16) + const rawMax = compute5BandScaling(lvl, 14, 4, 5, 7, 11, 16) + const synergyBonusPct = ppPts * 18 + + const minPoisonDamage = Math.trunc((rawMin * (100 + synergyBonusPct)) / 100) + const maxPoisonDamage = Math.trunc((rawMax * (100 + synergyBonusPct)) / 100) + const poisonDurationFrames = 100 + (lvl - 1) * 10 + + return { + minPoisonDamage, + maxPoisonDamage, + poisonDurationFrames, + poisonDurationSeconds: poisonDurationFrames / 25, + toHitBonusPct: 50 + (lvl - 1) * 7, + contagionRadiusSubtiles: 4, + contagionSpreadIntervalFrames: 4, + synergyBonusPct, + manaCost: 10, + manaCost256: 2560, + overlayName: 'rabiesplague', + } +} + +/** + * Skill 239: Fire Claws + * Ground Truth: + * - Usable in Wolf or Bear form + * - Fire damage: eMin=15 [8, 12, 20, 24, 30], eMax=20 [8, 12, 22, 26, 34] + * - ToHit: 50 + (slvl - 1) * 15 % + * - Synergies: +22% fire damage per blvl in Firestorm, Molten Boulder, Volcano, Eruption + * - Mana: 4 + */ +export function calculateFireClawsStats( + slvl: number, + firestormBlvl = 0, + moltenBoulderBlvl = 0, + volcanoBlvl = 0, + eruptionBlvl = 0, +): FireClawsStats { + const lvl = Math.max(1, slvl) + const synTotal = + Math.max(0, firestormBlvl) + + Math.max(0, moltenBoulderBlvl) + + Math.max(0, volcanoBlvl) + + Math.max(0, eruptionBlvl) + + const rawMin = compute5BandScaling(lvl, 15, 8, 12, 20, 24, 30) + const rawMax = compute5BandScaling(lvl, 20, 8, 12, 22, 26, 34) + const synergyBonusPct = synTotal * 22 + + const minFireDamage = Math.trunc((rawMin * (100 + synergyBonusPct)) / 100) + const maxFireDamage = Math.trunc((rawMax * (100 + synergyBonusPct)) / 100) + + return { + minFireDamage, + maxFireDamage, + toHitBonusPct: 50 + (lvl - 1) * 15, + synergyBonusPct, + manaCost: 4, + manaCost256: 1024, + } +} + +/** + * Skill 242: Hunger + * Ground Truth: + * - Usable in Wolf or Bear form + * - Damage penalty: -75% weapon damage + * - Life Leech: dm(50, 200, slvl) % + * - Mana Leech: dm(50, 200, slvl) % + * - ToHit: 50 + (slvl - 1) * 10 % + * - Mana: 3 + */ +export function calculateHungerStats(slvl: number): HungerStats { + const lvl = Math.max(1, slvl) + const leechPct = computeDiminishingReturns(50, 200, lvl) + + return { + damagePenaltyPct: -75, + lifeLeechPct: leechPct, + manaLeechPct: leechPct, + toHitBonusPct: 50 + (lvl - 1) * 10, + manaCost: 3, + manaCost256: 768, + } +} + +/** + * Skill 243: Shock Wave + * Ground Truth: + * - Bear-only frontal sonic roar + * - Phys damage: minDam=10 [3, 5, 7, 7, 7], maxDam=20 [3, 5, 7, 7, 7] + * - Stun duration: ln12 = 40 + (slvl - 1) * 15 frames (1.6s at slvl 1, +0.6s/lvl) + * - Synergy: +5% physical damage per blvl in Maul + * - Mana: 7 + */ +export function calculateShockWaveStats( + slvl: number, + maulBlvl = 0, +): ShockWaveStats { + const lvl = Math.max(1, slvl) + const maulPts = Math.max(0, maulBlvl) + + const rawMin = compute5BandScaling(lvl, 10, 3, 5, 7, 7, 7) + const rawMax = compute5BandScaling(lvl, 20, 3, 5, 7, 7, 7) + const synergyBonusPct = maulPts * 5 + + const minPhysicalDamage = Math.trunc((rawMin * (100 + synergyBonusPct)) / 100) + const maxPhysicalDamage = Math.trunc((rawMax * (100 + synergyBonusPct)) / 100) + const stunDurationFrames = 40 + (lvl - 1) * 15 + + return { + minPhysicalDamage, + maxPhysicalDamage, + stunDurationFrames, + stunDurationSeconds: stunDurationFrames / 25, + synergyBonusPct, + cooldownFrames: 0, + manaCost: 7, + manaCost256: 1792, + missileName: 'shockwave', + } +} + +/** + * Skill 248: Fury + * Ground Truth: + * - Wolf-only rapid multi-attack sequence + * - Enhanced Damage: 100 + (slvl - 1) * 17 % + * - ToHit: 50 + (slvl - 1) * 7 % + * - Attack count: min(2 + slvl - 1, 5) hits + * - Uninterruptible sequence + * - Mana: 4 + */ +export function calculateFuryStats(slvl: number): FuryStats { + const lvl = Math.max(1, slvl) + const damageBonusPct = 100 + (lvl - 1) * 17 + const toHitBonusPct = 50 + (lvl - 1) * 7 + const attackCount = Math.min(2 + lvl - 1, 5) + + return { + damageBonusPct, + toHitBonusPct, + attackCount, + isUninterruptible: true, + manaCost: 4, + manaCost256: 1024, + } +} + +// --- Kinematics & Mechanics Simulation Helpers --- + +/** + * Validates whether a given skill can be executed in the current Druid form. + */ +export function canCastShapeShiftSkill( + currentForm: 'human' | 'wolf' | 'bear', + skillId: number, +): boolean { + switch (skillId) { + case DRUID_SHAPESHIFT_SKILL_IDS.WEREWOLF: + // Can cast if human (to transform into wolf) or wolf (to revert to human) + return currentForm === 'human' || currentForm === 'wolf' + + case DRUID_SHAPESHIFT_SKILL_IDS.WEREBEAR: + // Can cast if human (to transform into bear) or bear (to revert to human) + return currentForm === 'human' || currentForm === 'bear' + + case DRUID_SHAPESHIFT_SKILL_IDS.FERAL_RAGE: + case DRUID_SHAPESHIFT_SKILL_IDS.RABIES: + case DRUID_SHAPESHIFT_SKILL_IDS.FURY: + // Strictly Werewolf only + return currentForm === 'wolf' + + case DRUID_SHAPESHIFT_SKILL_IDS.MAUL: + case DRUID_SHAPESHIFT_SKILL_IDS.SHOCK_WAVE: + // Strictly Werebear only + return currentForm === 'bear' + + case DRUID_SHAPESHIFT_SKILL_IDS.FIRE_CLAWS: + case DRUID_SHAPESHIFT_SKILL_IDS.HUNGER: + // Can be used in either beast form, but not human + return currentForm === 'wolf' || currentForm === 'bear' + + case DRUID_SHAPESHIFT_SKILL_IDS.LYCANTHROPY: + // Passive + return true + + default: + return true + } +} + +/** + * Advance charge-up orb counter on successful melee connection (Maul, Feral Rage). + */ +export function advanceChargeOrb(currentCharge: number, maxCharge: number): number { + return Math.min(Math.max(0, currentCharge) + 1, maxCharge) +} + +export interface ContagionTarget { + readonly id: string + readonly x: number + readonly y: number +} + +/** + * Simulate Rabies contagious spread between adjacent infected and healthy targets. + * Spread occurs within contagionRadiusSubtiles (default 4 subtiles = ~128px in world units). + */ +export function simulateRabiesContagion( + infectedTargets: readonly ContagionTarget[], + healthyTargets: readonly ContagionTarget[], + radiusPx = 128, +): string[] { + const newlyInfectedIds: string[] = [] + + for (const healthy of healthyTargets) { + let infected = false + for (const source of infectedTargets) { + const dx = healthy.x - source.x + const dy = (healthy.y - source.y) * 2 // 2:1 isometric space + const distSq = dx * dx + dy * dy + if (distSq <= radiusPx * radiusPx) { + infected = true + break + } + } + if (infected) { + newlyInfectedIds.push(healthy.id) + } + } + + return newlyInfectedIds +} + +export interface FuryStrikeDistribution { + readonly targetId: string + readonly hitIndex: number +} + +/** + * Distribute Fury multi-hit strike sequence across nearby target enemies. + * If 1 target, all strikes focus on that target. If multiple targets, alternates between them. + */ +export function distributeFuryStrikes( + targetIds: readonly string[], + totalHits: number, +): FuryStrikeDistribution[] { + if (targetIds.length === 0 || totalHits <= 0) return [] + + const strikes: FuryStrikeDistribution[] = [] + for (let i = 0; i < totalHits; i++) { + const targetId = targetIds[i % targetIds.length]! + strikes.push({ + targetId, + hitIndex: i + 1, + }) + } + + return strikes +} diff --git a/tests/skills/dru/adv-dru-shapeshift-stress.test.ts b/tests/skills/dru/adv-dru-shapeshift-stress.test.ts new file mode 100644 index 0000000..9db482f --- /dev/null +++ b/tests/skills/dru/adv-dru-shapeshift-stress.test.ts @@ -0,0 +1,588 @@ +import { describe, expect, it } from 'vitest' +import { + calculateWerewolfStats, + calculateLycanthropyStats, + calculateWerebearStats, + calculateFeralRageStats, + calculateMaulStats, + calculateRabiesStats, + calculateFireClawsStats, + calculateHungerStats, + calculateShockWaveStats, + calculateFuryStats, + canCastShapeShiftSkill, + advanceChargeOrb, + simulateRabiesContagion, + distributeFuryStrikes, + DRUID_SHAPESHIFT_SKILL_IDS, +} from '../../../src/game/skills/druid-shapeshift.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 } from '../../../src/game/skills/registry.ts' + +describe('Milestone M23 — Druid Shape Shifting Skills Parity & Adversarial Stress Suite', () => { + // ========================================================================= + // Dimension 1: Exact 1.13c Numerical Scaling & Formulas + // ========================================================================= + describe('Dimension 1: Exact 1.13c Numerical Scaling & Formulas', () => { + it('1.1 Werewolf (223): Evaluates attack rate diminishing returns, stamina, and Lycanthropy integration', () => { + // slvl 1 without Lycanthropy + const w1 = calculateWerewolfStats(1, 0) + expect(w1.attackRateBonusPct).toBe(21) // 10 + floor(110*1*70 / 700) = 21% + expect(w1.toHitBonusPct).toBe(50) + expect(w1.staminaBonusPct).toBe(25) + expect(w1.maxHpBonusPct).toBe(25) // 25 base + expect(w1.durationFrames).toBe(1000) // 40s base + expect(w1.durationSeconds).toBe(40.0) + expect(w1.cooldownFrames).toBe(25) + expect(w1.manaCost).toBe(15) + expect(w1.manaCost256).toBe(3840) + + // slvl 10 with slvl 10 Lycanthropy + // attack rate: 10 + floor(110*10*70 / 1600) = 10 + 48 = 58% + // toHit: 50 + 9*15 = 185% + // lycHp: 20 + 9*5 = 65% -> total HP = 25 + 65 = 90% + // lycDuration: 1000 + 9*500 = 5500 -> total duration = 1000 + 5500 = 6500 frames (260s) + const w10 = calculateWerewolfStats(10, 10) + expect(w10.attackRateBonusPct).toBe(58) + expect(w10.toHitBonusPct).toBe(185) + expect(w10.maxHpBonusPct).toBe(90) + expect(w10.durationFrames).toBe(6500) + expect(w10.durationSeconds).toBe(260.0) + + // slvl 20 with slvl 20 Lycanthropy + // attack rate: 10 + floor(110*20*70 / 2600) = 10 + 59 = 69% + // toHit: 50 + 19*15 = 335% + // lycHp: 20 + 19*5 = 115% -> total HP = 25 + 115 = 140% + // lycDuration: 1000 + 19*500 = 10500 -> total duration = 1000 + 10500 = 11500 frames (460s) + const w20 = calculateWerewolfStats(20, 20) + expect(w20.attackRateBonusPct).toBe(69) + expect(w20.toHitBonusPct).toBe(335) + expect(w20.maxHpBonusPct).toBe(140) + expect(w20.durationFrames).toBe(11500) + expect(w20.durationSeconds).toBe(460.0) + }) + + it('1.2 Lycanthropy (224): Evaluates passive form duration bonus and maximum HP progression', () => { + // slvl 1 + const lyc1 = calculateLycanthropyStats(1) + expect(lyc1.durationBonusFrames).toBe(1000) // +40s + expect(lyc1.durationBonusSeconds).toBe(40.0) + expect(lyc1.maxHpBonusPct).toBe(20) + expect(lyc1.isPassive).toBe(true) + + // slvl 10 + const lyc10 = calculateLycanthropyStats(10) + expect(lyc10.durationBonusFrames).toBe(1000 + 9 * 500) // 5500 frames (+220s) + expect(lyc10.durationBonusSeconds).toBe(220.0) + expect(lyc10.maxHpBonusPct).toBe(20 + 9 * 5) // 65% + + // slvl 20 + const lyc20 = calculateLycanthropyStats(20) + expect(lyc20.durationBonusFrames).toBe(1000 + 19 * 500) // 10500 frames (+420s) + expect(lyc20.durationBonusSeconds).toBe(420.0) + expect(lyc20.maxHpBonusPct).toBe(20 + 19 * 5) // 115% + }) + + it('1.3 Werebear (228): Evaluates massive defense, enhanced damage, and life scaling', () => { + // slvl 1 without Lycanthropy + const wb1 = calculateWerebearStats(1, 0) + expect(wb1.damageBonusPct).toBe(55) + expect(wb1.defenseBonusPct).toBe(25) + expect(wb1.maxHpBonusPct).toBe(75) // 75 base + expect(wb1.durationFrames).toBe(1000) + expect(wb1.durationSeconds).toBe(40.0) + expect(wb1.cooldownFrames).toBe(25) + expect(wb1.manaCost).toBe(15) + + // slvl 10 with slvl 10 Lycanthropy + // damage: 55 + 9*8 = 127% + // defense: 25 + 9*6 = 79% + // hp: 75 + (20 + 9*5) = 75 + 65 = 140% + const wb10 = calculateWerebearStats(10, 10) + expect(wb10.damageBonusPct).toBe(127) + expect(wb10.defenseBonusPct).toBe(79) + expect(wb10.maxHpBonusPct).toBe(140) + expect(wb10.durationFrames).toBe(6500) + + // slvl 20 with slvl 20 Lycanthropy + // damage: 55 + 19*8 = 207% + // defense: 25 + 19*6 = 139% + // hp: 75 + (20 + 19*5) = 75 + 115 = 190% + const wb20 = calculateWerebearStats(20, 20) + expect(wb20.damageBonusPct).toBe(207) + expect(wb20.defenseBonusPct).toBe(139) + expect(wb20.maxHpBonusPct).toBe(190) + expect(wb20.durationFrames).toBe(11500) + }) + + it('1.4 Feral Rage (232): Evaluates damage, attack rating, speed bonus, and life leech', () => { + // slvl 1 + const fr1 = calculateFeralRageStats(1) + expect(fr1.damageBonusPct).toBe(50) + expect(fr1.toHitBonusPct).toBe(20) + expect(fr1.lifeLeechPct).toBe(4) // 4 * 1 + expect(fr1.velocityBonusPct).toBe(19) // 10 + floor(110*1*60 / 700) = 19% + expect(fr1.durationFrames).toBe(500) // 20.0s + expect(fr1.maxCharges).toBe(3) + expect(fr1.manaCost).toBe(3) + expect(fr1.manaCost256).toBe(768) + + // slvl 10 + // damage: 50 + 9*5 = 95% + // toHit: 20 + 9*10 = 110% + // life leech: 4 * 10 = 40% + // velocity: 10 + floor(110*10*60 / 1600) = 10 + 41 = 51% + const fr10 = calculateFeralRageStats(10) + expect(fr10.damageBonusPct).toBe(95) + expect(fr10.toHitBonusPct).toBe(110) + expect(fr10.lifeLeechPct).toBe(40) + expect(fr10.velocityBonusPct).toBe(51) + + // slvl 20 + // damage: 50 + 19*5 = 145% + // toHit: 20 + 19*10 = 210% + // life leech: 4 * 20 = 80% + // velocity: 10 + floor(110*20*60 / 2600) = 10 + 50 = 60% + const fr20 = calculateFeralRageStats(20) + expect(fr20.damageBonusPct).toBe(145) + expect(fr20.toHitBonusPct).toBe(210) + expect(fr20.lifeLeechPct).toBe(80) + expect(fr20.velocityBonusPct).toBe(60) + }) + + it('1.5 Maul (233): Evaluates 5-stage charge damage and stun scaling', () => { + // slvl 1 + const m1 = calculateMaulStats(1) + expect(m1.damageBonusPerChargePct).toBe(20) // 1 * 20 + expect(m1.maxDamageBonusPct).toBe(100) // 5 charges = 100% + expect(m1.toHitBonusPct).toBe(20) + expect(m1.stunDurationFrames).toBe(24) // 10 + floor(110*1*90 / 700) = 24 frames (~0.96s) + expect(m1.maxCharges).toBe(5) + expect(m1.manaCost).toBe(3) + + // slvl 10 + // damage/charge: 10 * 20 = 200%, max 5 charges = 1000% + // toHit: 20 + 9*10 = 110% + // stun: 10 + floor(110*10*90 / 1600) = 10 + 61 = 71 frames (2.84s) + const m10 = calculateMaulStats(10) + expect(m10.damageBonusPerChargePct).toBe(200) + expect(m10.maxDamageBonusPct).toBe(1000) + expect(m10.toHitBonusPct).toBe(110) + expect(m10.stunDurationFrames).toBe(71) + + // slvl 20 + // damage/charge: 20 * 20 = 400%, max 5 charges = 2000% + // toHit: 20 + 19*10 = 210% + // stun: 10 + floor(110*20*90 / 2600) = 10 + 76 = 86 frames (3.44s) + const m20 = calculateMaulStats(20) + expect(m20.damageBonusPerChargePct).toBe(400) + expect(m20.maxDamageBonusPct).toBe(2000) + expect(m20.toHitBonusPct).toBe(210) + expect(m20.stunDurationFrames).toBe(86) + }) + + it('1.6 Rabies (238): Evaluates poison progression, duration, and Poison Creeper synergy', () => { + // slvl 1 + const r1 = calculateRabiesStats(1, 0) + expect(r1.minPoisonDamage).toBe(6) + expect(r1.maxPoisonDamage).toBe(14) + expect(r1.poisonDurationFrames).toBe(100) // 4.0s + expect(r1.toHitBonusPct).toBe(50) + expect(r1.contagionRadiusSubtiles).toBe(4) + expect(r1.contagionSpreadIntervalFrames).toBe(4) + expect(r1.manaCost).toBe(10) + + // slvl 10: min: 6 + 7*4 + 2*5 = 44, max: 14 + 7*4 + 2*5 = 52 + // duration: 100 + 9*10 = 190 frames (7.6s) + // toHit: 50 + 9*7 = 113% + const r10 = calculateRabiesStats(10, 0) + expect(r10.minPoisonDamage).toBe(44) + expect(r10.maxPoisonDamage).toBe(52) + expect(r10.poisonDurationFrames).toBe(190) + expect(r10.toHitBonusPct).toBe(113) + + // slvl 20: min: 44 + 6*5 + 4*7 = 102, max: 52 + 6*5 + 4*7 = 110 + // duration: 100 + 19*10 = 290 frames (11.6s) + // toHit: 50 + 19*7 = 183% + const r20 = calculateRabiesStats(20, 0) + expect(r20.minPoisonDamage).toBe(102) + expect(r20.maxPoisonDamage).toBe(110) + expect(r20.poisonDurationFrames).toBe(290) + expect(r20.toHitBonusPct).toBe(183) + + // Synergy: 20 points in Poison Creeper (+18%/blvl = +360%) + const r20Syn = calculateRabiesStats(20, 20) + expect(r20Syn.synergyBonusPct).toBe(360) + expect(r20Syn.minPoisonDamage).toBe(Math.trunc((102 * 460) / 100)) // 469 + expect(r20Syn.maxPoisonDamage).toBe(Math.trunc((110 * 460) / 100)) // 506 + }) + + it('1.7 Fire Claws (239): Evaluates elemental fire melee damage and 4-way synergy scaling', () => { + // slvl 1 + const fc1 = calculateFireClawsStats(1, 0, 0, 0, 0) + expect(fc1.minFireDamage).toBe(15) + expect(fc1.maxFireDamage).toBe(20) + expect(fc1.toHitBonusPct).toBe(50) + expect(fc1.manaCost).toBe(4) + + // slvl 10: min: 15 + 7*8 + 2*12 = 95, max: 20 + 7*8 + 2*12 = 100 + const fc10 = calculateFireClawsStats(10, 0, 0, 0, 0) + expect(fc10.minFireDamage).toBe(95) + expect(fc10.maxFireDamage).toBe(100) + + // slvl 20: min: 95 + 6*12 + 4*20 = 247, max: 100 + 6*12 + 4*22 = 260 + const fc20 = calculateFireClawsStats(20, 0, 0, 0, 0) + expect(fc20.minFireDamage).toBe(247) + expect(fc20.maxFireDamage).toBe(260) + + // Synergies: 20 in Firestorm, 20 in Molten Boulder, 20 in Volcano, 20 in Fissure + // total points = 80 -> synergy = 80 * 22% = +1760% + const fc20Syn = calculateFireClawsStats(20, 20, 20, 20, 20) + expect(fc20Syn.synergyBonusPct).toBe(1760) + expect(fc20Syn.minFireDamage).toBe(Math.trunc((247 * 1860) / 100)) // 4594 + expect(fc20Syn.maxFireDamage).toBe(Math.trunc((260 * 1860) / 100)) // 4836 + }) + + it('1.8 Hunger (242): Evaluates -75% damage penalty and dual life & mana leech', () => { + // slvl 1 + // leech: 50 + floor(110*1*150 / 700) = 50 + 23 = 73% + const h1 = calculateHungerStats(1) + expect(h1.damagePenaltyPct).toBe(-75) + expect(h1.lifeLeechPct).toBe(73) + expect(h1.manaLeechPct).toBe(73) + expect(h1.toHitBonusPct).toBe(50) + expect(h1.manaCost).toBe(3) + + // slvl 10: 50 + floor(110*10*150 / 1600) = 50 + 103 = 153% + const h10 = calculateHungerStats(10) + expect(h10.lifeLeechPct).toBe(153) + expect(h10.manaLeechPct).toBe(153) + expect(h10.toHitBonusPct).toBe(140) + + // slvl 20: 50 + floor(110*20*150 / 2600) = 50 + 126 = 176% + const h20 = calculateHungerStats(20) + expect(h20.lifeLeechPct).toBe(176) + expect(h20.manaLeechPct).toBe(176) + expect(h20.toHitBonusPct).toBe(240) + }) + + it('1.9 Shock Wave (243): Evaluates bear frontal sonic stun cone and Maul synergy', () => { + // slvl 1 + const sw1 = calculateShockWaveStats(1, 0) + expect(sw1.minPhysicalDamage).toBe(10) + expect(sw1.maxPhysicalDamage).toBe(20) + expect(sw1.stunDurationFrames).toBe(40) // 1.6s + expect(sw1.stunDurationSeconds).toBe(1.6) + expect(sw1.manaCost).toBe(7) + expect(sw1.missileName).toBe('shockwave') + + // slvl 10: min: 10 + 7*3 + 2*5 = 41, max: 20 + 7*3 + 2*5 = 51 + // stun: 40 + 9*15 = 175 frames (7.0s) + const sw10 = calculateShockWaveStats(10, 0) + expect(sw10.minPhysicalDamage).toBe(41) + expect(sw10.maxPhysicalDamage).toBe(51) + expect(sw10.stunDurationFrames).toBe(175) + expect(sw10.stunDurationSeconds).toBe(7.0) + + // slvl 20: min: 41 + 6*5 + 4*7 = 99, max: 51 + 6*5 + 4*7 = 109 + // stun: 40 + 19*15 = 325 frames (13.0s) + const sw20 = calculateShockWaveStats(20, 0) + expect(sw20.minPhysicalDamage).toBe(99) + expect(sw20.maxPhysicalDamage).toBe(109) + expect(sw20.stunDurationFrames).toBe(325) + expect(sw20.stunDurationSeconds).toBe(13.0) + + // Synergy: 20 in Maul (+5%/lvl = +100%) + const sw20Syn = calculateShockWaveStats(20, 20) + expect(sw20Syn.synergyBonusPct).toBe(100) + expect(sw20Syn.minPhysicalDamage).toBe(99 * 2) // 198 + expect(sw20Syn.maxPhysicalDamage).toBe(109 * 2) // 218 + }) + + it('1.10 Fury (248): Evaluates 5-hit uninterrupted feral frenzy sequence', () => { + // slvl 1: min(2 + 0, 5) = 2 hits + const f1 = calculateFuryStats(1) + expect(f1.damageBonusPct).toBe(100) + expect(f1.toHitBonusPct).toBe(50) + expect(f1.attackCount).toBe(2) + expect(f1.isUninterruptible).toBe(true) + expect(f1.manaCost).toBe(4) + + // slvl 2: min(2 + 1, 5) = 3 hits + const f2 = calculateFuryStats(2) + expect(f2.attackCount).toBe(3) + + // slvl 3: min(2 + 2, 5) = 4 hits + const f3 = calculateFuryStats(3) + expect(f3.attackCount).toBe(4) + + // slvl 4+: min(2 + 3, 5) = 5 hits (capped) + const f4 = calculateFuryStats(4) + expect(f4.attackCount).toBe(5) + + // slvl 10: 100 + 9*17 = 253%, 50 + 9*7 = 113% + const f10 = calculateFuryStats(10) + expect(f10.damageBonusPct).toBe(253) + expect(f10.toHitBonusPct).toBe(113) + expect(f10.attackCount).toBe(5) + + // slvl 20: 100 + 19*17 = 423%, 50 + 19*7 = 183% + const f20 = calculateFuryStats(20) + expect(f20.damageBonusPct).toBe(423) + expect(f20.toHitBonusPct).toBe(183) + expect(f20.attackCount).toBe(5) + }) + }) + + // ========================================================================= + // Dimension 2: Beast Form Restrictions & State Validation Matrix + // ========================================================================= + describe('Dimension 2: Beast Form Restrictions & State Validation Matrix', () => { + it('2.1 Enforces Wolf-only skills: Feral Rage, Rabies, Fury', () => { + const wolfSkills = [ + DRUID_SHAPESHIFT_SKILL_IDS.FERAL_RAGE, + DRUID_SHAPESHIFT_SKILL_IDS.RABIES, + DRUID_SHAPESHIFT_SKILL_IDS.FURY, + ] + + for (const skillId of wolfSkills) { + expect(canCastShapeShiftSkill('wolf', skillId)).toBe(true) + expect(canCastShapeShiftSkill('human', skillId)).toBe(false) + expect(canCastShapeShiftSkill('bear', skillId)).toBe(false) + } + }) + + it('2.2 Enforces Bear-only skills: Maul, Shock Wave', () => { + const bearSkills = [ + DRUID_SHAPESHIFT_SKILL_IDS.MAUL, + DRUID_SHAPESHIFT_SKILL_IDS.SHOCK_WAVE, + ] + + for (const skillId of bearSkills) { + expect(canCastShapeShiftSkill('bear', skillId)).toBe(true) + expect(canCastShapeShiftSkill('human', skillId)).toBe(false) + expect(canCastShapeShiftSkill('wolf', skillId)).toBe(false) + } + }) + + it('2.3 Enforces dual-form beast skills: Fire Claws and Hunger', () => { + const dualSkills = [ + DRUID_SHAPESHIFT_SKILL_IDS.FIRE_CLAWS, + DRUID_SHAPESHIFT_SKILL_IDS.HUNGER, + ] + + for (const skillId of dualSkills) { + expect(canCastShapeShiftSkill('wolf', skillId)).toBe(true) + expect(canCastShapeShiftSkill('bear', skillId)).toBe(true) + expect(canCastShapeShiftSkill('human', skillId)).toBe(false) + } + }) + + it('2.4 Enforces transformation and reversion capabilities', () => { + // Werewolf: can cast in human (to become wolf) or wolf (to revert to human), but not bear + expect(canCastShapeShiftSkill('human', DRUID_SHAPESHIFT_SKILL_IDS.WEREWOLF)).toBe(true) + expect(canCastShapeShiftSkill('wolf', DRUID_SHAPESHIFT_SKILL_IDS.WEREWOLF)).toBe(true) + expect(canCastShapeShiftSkill('bear', DRUID_SHAPESHIFT_SKILL_IDS.WEREWOLF)).toBe(false) + + // Werebear: can cast in human (to become bear) or bear (to revert to human), but not wolf + expect(canCastShapeShiftSkill('human', DRUID_SHAPESHIFT_SKILL_IDS.WEREBEAR)).toBe(true) + expect(canCastShapeShiftSkill('bear', DRUID_SHAPESHIFT_SKILL_IDS.WEREBEAR)).toBe(true) + expect(canCastShapeShiftSkill('wolf', DRUID_SHAPESHIFT_SKILL_IDS.WEREBEAR)).toBe(false) + }) + }) + + // ========================================================================= + // Dimension 3: Kinematics, Contagion & Multi-Hit Mechanics + // ========================================================================= + describe('Dimension 3: Kinematics, Contagion & Multi-Hit Mechanics', () => { + it('3.1 Rabies: Contagion spreads to nearby targets within 4 subtiles and ignores distant targets', () => { + const infected = [{ id: 'target_0', x: 100, y: 100 }] + const healthy = [ + { id: 'target_close_1', x: 120, y: 105 }, // ~22px away (inside 128px radius) + { id: 'target_close_2', x: 80, y: 95 }, // ~22px away (inside 128px radius) + { id: 'target_far', x: 400, y: 400 }, // ~424px away (outside 128px radius) + ] + + const newlyInfected = simulateRabiesContagion(infected, healthy, 128) + expect(newlyInfected).toContain('target_close_1') + expect(newlyInfected).toContain('target_close_2') + expect(newlyInfected).not.toContain('target_far') + }) + + it('3.2 Maul & Feral Rage: Charge-up orb increments up to exact maximum charges', () => { + // Feral Rage caps at 3 + let frCharge = 0 + frCharge = advanceChargeOrb(frCharge, 3) + expect(frCharge).toBe(1) + frCharge = advanceChargeOrb(frCharge, 3) + expect(frCharge).toBe(2) + frCharge = advanceChargeOrb(frCharge, 3) + expect(frCharge).toBe(3) + frCharge = advanceChargeOrb(frCharge, 3) + expect(frCharge).toBe(3) // Capped at 3 + + // Maul caps at 5 + let mCharge = 0 + for (let i = 0; i < 10; i++) { + mCharge = advanceChargeOrb(mCharge, 5) + } + expect(mCharge).toBe(5) // Capped at 5 + }) + + it('3.3 Fury: Distributes 5-hit sequence evenly across multiple targets or focuses single target', () => { + // Single target + const singleStrikes = distributeFuryStrikes(['boss_1'], 5) + expect(singleStrikes.length).toBe(5) + expect(singleStrikes.every((s) => s.targetId === 'boss_1')).toBe(true) + expect(singleStrikes.map((s) => s.hitIndex)).toEqual([1, 2, 3, 4, 5]) + + // 2 targets (alternating) + const dualStrikes = distributeFuryStrikes(['enemy_a', 'enemy_b'], 5) + expect(dualStrikes.length).toBe(5) + expect(dualStrikes.map((s) => s.targetId)).toEqual([ + 'enemy_a', + 'enemy_b', + 'enemy_a', + 'enemy_b', + 'enemy_a', + ]) + }) + }) + + // ========================================================================= + // Dimension 4: Invariants, DataRegistry, and SkillModule Integration + // ========================================================================= + describe('Dimension 4: Invariants, DataRegistry, and SkillModule Integration', () => { + it('4.1 BATCH1_SKILLS length invariant = 38 is strictly preserved', () => { + const batch1Keys = Object.keys(BATCH1_SKILLS) + expect(batch1Keys.length).toBe(38) + }) + + it('4.2 All 10 Druid Shape Shifting skills are properly defined in SkillModule architecture', async () => { + const shapeShiftIds = [223, 224, 228, 232, 233, 238, 239, 242, 243, 248] + const { skillModule: m223 } = await import('../../../src/game/skills/impl/dru/skill-223-wearwolf.ts') + const { skillModule: m224 } = await import('../../../src/game/skills/impl/dru/skill-224-shape-shifting.ts') + const { skillModule: m228 } = await import('../../../src/game/skills/impl/dru/skill-228-wearbear.ts') + const { skillModule: m232 } = await import('../../../src/game/skills/impl/dru/skill-232-feral-rage.ts') + const { skillModule: m233 } = await import('../../../src/game/skills/impl/dru/skill-233-maul.ts') + const { skillModule: m238 } = await import('../../../src/game/skills/impl/dru/skill-238-rabies.ts') + const { skillModule: m239 } = await import('../../../src/game/skills/impl/dru/skill-239-fire-claws.ts') + const { skillModule: m242 } = await import('../../../src/game/skills/impl/dru/skill-242-hunger.ts') + const { skillModule: m243 } = await import('../../../src/game/skills/impl/dru/skill-243-shock-wave.ts') + const { skillModule: m248 } = await import('../../../src/game/skills/impl/dru/skill-248-fury.ts') + + const modules = [m223, m224, m228, m232, m233, m238, m239, m242, m243, m248] + for (let i = 0; i < shapeShiftIds.length; i++) { + const id = shapeShiftIds[i] + const mod = modules[i] + expect(mod).toBeDefined() + expect(mod.skillId).toBe(id) + expect(mod.charClass).toBe('dru') + } + }) + + it('4.3 DataRegistry loads all 10 Druid Shape Shifting skills from Patch_D2.mpq with exact attributes', async () => { + const reg = await getSharedDataRegistry('samples/d2') + + // Werewolf + const ww = reg.getSkillById(223) + expect(ww).toBeDefined() + expect(ww!.name).toBe('Wearwolf') + expect(ww!.auraState).toBe('wolf') + expect(ww!.delay).toBe('25') + + // Lycanthropy + const lyc = reg.getSkillById(224) + expect(lyc).toBeDefined() + expect(lyc!.name).toBe('Shape Shifting') + expect(lyc!.passive).toBe(true) + + // Werebear + const wb = reg.getSkillById(228) + expect(wb).toBeDefined() + expect(wb!.name).toBe('Wearbear') + expect(wb!.auraState).toBe('bear') + expect(wb!.delay).toBe('25') + + // Feral Rage + const fr = reg.getSkillById(232) + expect(fr).toBeDefined() + expect(fr!.name).toBe('Feral Rage') + expect(fr!.auraState).toBe('feralrage') + + // Maul + const m = reg.getSkillById(233) + expect(m).toBeDefined() + expect(m!.name).toBe('Maul') + expect(m!.auraState).toBe('maul') + + // Rabies + const r = reg.getSkillById(238) + expect(r).toBeDefined() + expect(r!.name).toBe('Rabies') + expect(r!.srvMissileA).toBe('rabiesplague') + + // Fire Claws + const fc = reg.getSkillById(239) + expect(fc).toBeDefined() + expect(fc!.name).toBe('Fire Claws') + expect(fc!.eType).toBe('fire') + + // Hunger + const h = reg.getSkillById(242) + expect(h).toBeDefined() + expect(h!.name).toBe('Hunger') + + // Shock Wave + const sw = reg.getSkillById(243) + expect(sw).toBeDefined() + expect(sw!.name).toBe('Shock Wave') + expect(sw!.srvMissileA).toBe('shockwave') + + // Fury + const f = reg.getSkillById(248) + expect(f).toBeDefined() + expect(f!.name).toBe('Fury') + }) + + it('4.4 evaluateSkill113c evaluates Druid Shape Shifting skills against StatList with exact attributes', async () => { + const reg = await getSharedDataRegistry('samples/d2') + const stats = new UnitStatList() + + // Werewolf + const wwRes = evaluateSkill113c({ + registry: reg, + skill: reg.getSkillById(223)!, + slvl: 1, + blvl: 1, + statList: stats, + }) + expect(wwRes.manaCost).toBe(15) + expect(wwRes.cooldownFrames).toBe(25) + + // Maul + const mRes = evaluateSkill113c({ + registry: reg, + skill: reg.getSkillById(233)!, + slvl: 10, + blvl: 10, + statList: stats, + }) + expect(mRes.manaCost).toBe(3) + expect(mRes.toHitBonusPct).toBe(110) + + // Fury + const fRes = evaluateSkill113c({ + registry: reg, + skill: reg.getSkillById(248)!, + slvl: 20, + blvl: 20, + statList: stats, + }) + expect(fRes.manaCost).toBe(4) + expect(fRes.toHitBonusPct).toBe(183) + }) + }) +})