feat(dru): Milestone M22 Druid elemental skills pure calculators, absorption, kinematics, and stress suite (closes #469)
This commit is contained in:
parent
51ac3b2d77
commit
5eded536ef
|
|
@ -0,0 +1,781 @@
|
|||
/**
|
||||
* Diablo II: Lord of Destruction v1.13c — Druid Elemental Skills Module
|
||||
*
|
||||
* Dedicated pure calculation, duration, synergy, absorption, and kinematics formulas
|
||||
* for the 10 Druid Elemental Tree Skills:
|
||||
* - Skill 225: Firestorm (ReqLevel 1)
|
||||
* - Skill 229: Molten Boulder (ReqLevel 6)
|
||||
* - Skill 230: Arctic Blast (ReqLevel 6)
|
||||
* - Skill 234: Eruption / Fissure (ReqLevel 12)
|
||||
* - Skill 235: Cyclone Armor (ReqLevel 12)
|
||||
* - Skill 240: Twister (ReqLevel 18)
|
||||
* - Skill 244: Volcano (ReqLevel 24)
|
||||
* - Skill 245: Tornado (ReqLevel 24)
|
||||
* - Skill 249: Armageddon (ReqLevel 30)
|
||||
* - Skill 250: Hurricane (ReqLevel 30)
|
||||
*
|
||||
* 1.13c Ground Truth:
|
||||
* - Skills.txt, Missiles.txt, Overlay.txt, States.txt (Patch_D2.mpq)
|
||||
* - 5-band progression for elemental and physical damages
|
||||
* - Strict fixed-point 24.8 mana calculations
|
||||
* - Cyclone Armor absorption: Fire, Cold, Lightning ONLY (zero effect on Phys/Mag/Psn)
|
||||
* - Arctic Blast continuous channeled cold stream with scaling chill length
|
||||
* - Molten Boulder knockback and dual-type rolling & explosion damage
|
||||
* - Twister 10-frame stun and 3-way chaotic spread
|
||||
* - Tornado erratic wandering trajectory and 25-frame NextDelay
|
||||
* - Armageddon & Hurricane duration scaling from synergies (+2s / blvl)
|
||||
* - Zero runtime MPQ reading
|
||||
*/
|
||||
|
||||
import { compute5BandScaling, compute3BandDuration } from '../engine/calc-ast.ts'
|
||||
|
||||
// --- Skill IDs ---
|
||||
|
||||
export const DRUID_ELEMENTAL_SKILL_IDS = {
|
||||
FIRESTORM: 225,
|
||||
MOLTEN_BOULDER: 229,
|
||||
ARCTIC_BLAST: 230,
|
||||
FISSURE: 234,
|
||||
CYCLONE_ARMOR: 235,
|
||||
TWISTER: 240,
|
||||
VOLCANO: 244,
|
||||
TORNADO: 245,
|
||||
ARMAGEDDON: 249,
|
||||
HURRICANE: 250,
|
||||
} as const
|
||||
|
||||
// --- Interfaces for Skill Calculations ---
|
||||
|
||||
export interface FirestormStats {
|
||||
readonly minFireDamage: number
|
||||
readonly maxFireDamage: number
|
||||
readonly snakesCount: number
|
||||
readonly synergyBonusPct: number
|
||||
readonly cooldownFrames: number
|
||||
readonly cooldownSeconds: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
readonly castOverlay: string
|
||||
}
|
||||
|
||||
export interface MoltenBoulderStats {
|
||||
readonly minPhysicalDamage: number
|
||||
readonly maxPhysicalDamage: number
|
||||
readonly minFireDamage: number
|
||||
readonly maxFireDamage: number
|
||||
readonly knockbackRadius: number
|
||||
readonly physSynergyBonusPct: number
|
||||
readonly fireSynergyBonusPct: number
|
||||
readonly cooldownFrames: number
|
||||
readonly cooldownSeconds: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
readonly missileName: string
|
||||
readonly castOverlay: string
|
||||
}
|
||||
|
||||
export interface ArcticBlastStats {
|
||||
readonly minColdDamagePerSecond: number
|
||||
readonly maxColdDamagePerSecond: number
|
||||
readonly minColdDamagePerTick256: number
|
||||
readonly maxColdDamagePerTick256: number
|
||||
readonly chillDurationFrames: number
|
||||
readonly chillDurationSeconds: number
|
||||
readonly rangeYards: number
|
||||
readonly synergyBonusPct: number
|
||||
readonly manaCostPerTick256: number
|
||||
readonly isChanneled: boolean
|
||||
readonly missileName: string
|
||||
}
|
||||
|
||||
export interface FissureStats {
|
||||
readonly minFireDamage: number
|
||||
readonly maxFireDamage: number
|
||||
readonly durationFrames: number
|
||||
readonly durationSeconds: number
|
||||
readonly fissureVentsCount: number
|
||||
readonly synergyBonusPct: number
|
||||
readonly cooldownFrames: number
|
||||
readonly cooldownSeconds: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
readonly castOverlay: string
|
||||
}
|
||||
|
||||
export interface CycloneArmorStats {
|
||||
readonly baseAbsorption: number
|
||||
readonly totalAbsorption: number
|
||||
readonly totalAbsorption256: number
|
||||
readonly synergyBonusPct: number
|
||||
readonly absorbedElements: readonly ['fire', 'cold', 'lightning']
|
||||
readonly unabsorbedElements: readonly ['physical', 'magic', 'poison']
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
readonly overlayFront: string
|
||||
readonly overlayBack: string
|
||||
readonly stateName: string
|
||||
}
|
||||
|
||||
export interface TwisterStats {
|
||||
readonly minPhysicalDamage: number
|
||||
readonly maxPhysicalDamage: number
|
||||
readonly twisterCount: number
|
||||
readonly stunDurationFrames: number
|
||||
readonly stunDurationSeconds: number
|
||||
readonly synergyBonusPct: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
readonly missileName: string
|
||||
}
|
||||
|
||||
export interface VolcanoStats {
|
||||
readonly minPhysicalDamage: number
|
||||
readonly maxPhysicalDamage: number
|
||||
readonly minFireDamage: number
|
||||
readonly maxFireDamage: number
|
||||
readonly durationFrames: number
|
||||
readonly durationSeconds: number
|
||||
readonly radiusYards: number
|
||||
readonly physSynergyBonusPct: number
|
||||
readonly fireSynergyBonusPct: number
|
||||
readonly cooldownFrames: number
|
||||
readonly cooldownSeconds: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
readonly castOverlay: string
|
||||
readonly baseMissile: string
|
||||
readonly subMissile: string
|
||||
}
|
||||
|
||||
export interface TornadoStats {
|
||||
readonly minPhysicalDamage: number
|
||||
readonly maxPhysicalDamage: number
|
||||
readonly nextDelayFrames: number
|
||||
readonly synergyBonusPct: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
readonly missileName: string
|
||||
}
|
||||
|
||||
export interface ArmageddonStats {
|
||||
readonly minPhysicalDamage: number
|
||||
readonly maxPhysicalDamage: number
|
||||
readonly minFireDamage: number
|
||||
readonly maxFireDamage: number
|
||||
readonly durationFrames: number
|
||||
readonly durationSeconds: number
|
||||
readonly radiusYards: number
|
||||
readonly meteorIntervalFrames: number
|
||||
readonly fireSynergyBonusPct: number
|
||||
readonly cooldownFrames: number
|
||||
readonly cooldownSeconds: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
readonly castOverlay: string
|
||||
readonly stateName: string
|
||||
}
|
||||
|
||||
export interface HurricaneStats {
|
||||
readonly minColdDamage: number
|
||||
readonly maxColdDamage: number
|
||||
readonly chillDurationFrames: number
|
||||
readonly chillDurationSeconds: number
|
||||
readonly durationFrames: number
|
||||
readonly durationSeconds: number
|
||||
readonly radiusYards: number
|
||||
readonly pulseIntervalFrames: number
|
||||
readonly coldSynergyBonusPct: number
|
||||
readonly cooldownFrames: number
|
||||
readonly cooldownSeconds: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
readonly stateName: string
|
||||
}
|
||||
|
||||
// --- Pure Calculation Functions ---
|
||||
|
||||
/**
|
||||
* Skill 225: Firestorm
|
||||
* Ground Truth:
|
||||
* - Fire damage: eMin=3 [3, 5, 7, 14, 21], eMax=6 [3, 6, 8, 15, 23]
|
||||
* - Synergies: +23% fire damage per blvl in Molten Boulder & Eruption (Fissure)
|
||||
* - Mana: 4 (constant), cooldown: 15 frames (0.6s)
|
||||
* - 3 wandering fire trails (snakes)
|
||||
*/
|
||||
export function calculateFirestormStats(
|
||||
slvl: number,
|
||||
moltenBoulderBlvl = 0,
|
||||
eruptionBlvl = 0,
|
||||
): FirestormStats {
|
||||
const lvl = Math.max(1, slvl)
|
||||
const mbPts = Math.max(0, moltenBoulderBlvl)
|
||||
const erupPts = Math.max(0, eruptionBlvl)
|
||||
|
||||
const rawMin = compute5BandScaling(lvl, 3, 3, 5, 7, 14, 21)
|
||||
const rawMax = compute5BandScaling(lvl, 6, 3, 6, 8, 15, 23)
|
||||
const synergyBonusPct = (mbPts + erupPts) * 23
|
||||
|
||||
const minFireDamage = Math.trunc((rawMin * (100 + synergyBonusPct)) / 100)
|
||||
const maxFireDamage = Math.trunc((rawMax * (100 + synergyBonusPct)) / 100)
|
||||
|
||||
return {
|
||||
minFireDamage,
|
||||
maxFireDamage,
|
||||
snakesCount: 3,
|
||||
synergyBonusPct,
|
||||
cooldownFrames: 15,
|
||||
cooldownSeconds: 15 / 25,
|
||||
manaCost: 4,
|
||||
manaCost256: 1024,
|
||||
castOverlay: 'druid_fire_cast_1',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 229: Molten Boulder
|
||||
* Ground Truth:
|
||||
* - Phys damage: minDam=6 [4, 7, 10, 13, 16], maxDam=12 [5, 8, 11, 14, 17]
|
||||
* - Fire damage: eMin=6 [4, 7, 10, 13, 16], eMax=12 [5, 8, 11, 14, 17]
|
||||
* - Synergies:
|
||||
* - Volcano: +10% physical damage / blvl
|
||||
* - Firestorm: +8% fire damage / blvl
|
||||
* - Mana: mana=20, lvlmana=1, manashift=7 -> (20 + (lvl - 1)) << 7
|
||||
* - Cooldown: 50 frames (2.0s)
|
||||
*/
|
||||
export function calculateMoltenBoulderStats(
|
||||
slvl: number,
|
||||
firestormBlvl = 0,
|
||||
volcanoBlvl = 0,
|
||||
): MoltenBoulderStats {
|
||||
const lvl = Math.max(1, slvl)
|
||||
const fsPts = Math.max(0, firestormBlvl)
|
||||
const volcPts = Math.max(0, volcanoBlvl)
|
||||
|
||||
const rawPhysMin = compute5BandScaling(lvl, 6, 4, 7, 10, 13, 16)
|
||||
const rawPhysMax = compute5BandScaling(lvl, 12, 5, 8, 11, 14, 17)
|
||||
const rawFireMin = rawPhysMin
|
||||
const rawFireMax = rawPhysMax
|
||||
|
||||
const physSynergyBonusPct = volcPts * 10
|
||||
const fireSynergyBonusPct = fsPts * 8
|
||||
|
||||
const minPhysicalDamage = Math.trunc((rawPhysMin * (100 + physSynergyBonusPct)) / 100)
|
||||
const maxPhysicalDamage = Math.trunc((rawPhysMax * (100 + physSynergyBonusPct)) / 100)
|
||||
const minFireDamage = Math.trunc((rawFireMin * (100 + fireSynergyBonusPct)) / 100)
|
||||
const maxFireDamage = Math.trunc((rawFireMax * (100 + fireSynergyBonusPct)) / 100)
|
||||
|
||||
const manaCost256 = (20 + (lvl - 1)) << 7
|
||||
const manaCost = manaCost256 >> 8
|
||||
|
||||
return {
|
||||
minPhysicalDamage,
|
||||
maxPhysicalDamage,
|
||||
minFireDamage,
|
||||
maxFireDamage,
|
||||
knockbackRadius: 7,
|
||||
physSynergyBonusPct,
|
||||
fireSynergyBonusPct,
|
||||
cooldownFrames: 50,
|
||||
cooldownSeconds: 50 / 25,
|
||||
manaCost,
|
||||
manaCost256,
|
||||
missileName: 'moltenboulderemerge',
|
||||
castOverlay: 'druid_fire_cast_1',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 230: Arctic Blast
|
||||
* Ground Truth:
|
||||
* - Channeled cold beam (like Inferno)
|
||||
* - Cold damage per second: eMin=21 [16, 18, 20, 24, 29], eMax=40 [16, 19, 21, 25, 31]
|
||||
* - Chill length: eLen=100 + (lvl-1)*15 frames
|
||||
* - Range in yards: (35 + (lvl - 1) * 2) / 4 - 2
|
||||
* - Synergies: +15% cold damage per blvl in Cyclone Armor & Hurricane
|
||||
* - Mana per tick: (24 + (lvl - 1)) << 2 in 256ths (minMana=0)
|
||||
*/
|
||||
export function calculateArcticBlastStats(
|
||||
slvl: number,
|
||||
cycloneArmorBlvl = 0,
|
||||
hurricaneBlvl = 0,
|
||||
): ArcticBlastStats {
|
||||
const lvl = Math.max(1, slvl)
|
||||
const caPts = Math.max(0, cycloneArmorBlvl)
|
||||
const hurrPts = Math.max(0, hurricaneBlvl)
|
||||
|
||||
const rawMin = compute5BandScaling(lvl, 21, 16, 18, 20, 24, 29)
|
||||
const rawMax = compute5BandScaling(lvl, 40, 16, 19, 21, 25, 31)
|
||||
const synergyBonusPct = (caPts + hurrPts) * 15
|
||||
|
||||
const minColdDamagePerSecond = Math.trunc((rawMin * (100 + synergyBonusPct)) / 100)
|
||||
const maxColdDamagePerSecond = Math.trunc((rawMax * (100 + synergyBonusPct)) / 100)
|
||||
|
||||
// In D2 1.13c, channeled spells apply damage per 25-frame tick in 256 fixed point:
|
||||
const minColdDamagePerTick256 = Math.trunc((minColdDamagePerSecond * 256) / 25)
|
||||
const maxColdDamagePerTick256 = Math.trunc((maxColdDamagePerSecond * 256) / 25)
|
||||
|
||||
const chillDurationFrames = 100 + (lvl - 1) * 15
|
||||
const rangeYards = Number((((35 + (lvl - 1) * 2) / 4) - 2).toFixed(2))
|
||||
|
||||
const manaCostPerTick256 = (24 + (lvl - 1)) << 2
|
||||
|
||||
return {
|
||||
minColdDamagePerSecond,
|
||||
maxColdDamagePerSecond,
|
||||
minColdDamagePerTick256,
|
||||
maxColdDamagePerTick256,
|
||||
chillDurationFrames,
|
||||
chillDurationSeconds: chillDurationFrames / 25,
|
||||
rangeYards,
|
||||
synergyBonusPct,
|
||||
manaCostPerTick256,
|
||||
isChanneled: true,
|
||||
missileName: 'arcticblast1',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 234: Eruption / Fissure
|
||||
* Ground Truth:
|
||||
* - Fire damage: eMin=15 [6, 12, 16, 18, 22], eMax=25 [6, 12, 16, 19, 23]
|
||||
* - Synergies: +12% fire damage per blvl in Firestorm & Volcano
|
||||
* - Duration: 80 frames (3.2 seconds)
|
||||
* - Mana: 15 (constant), Cooldown: 50 frames (2.0s)
|
||||
*/
|
||||
export function calculateFissureStats(
|
||||
slvl: number,
|
||||
firestormBlvl = 0,
|
||||
volcanoBlvl = 0,
|
||||
): FissureStats {
|
||||
const lvl = Math.max(1, slvl)
|
||||
const fsPts = Math.max(0, firestormBlvl)
|
||||
const volcPts = Math.max(0, volcanoBlvl)
|
||||
|
||||
const rawMin = compute5BandScaling(lvl, 15, 6, 12, 16, 18, 22)
|
||||
const rawMax = compute5BandScaling(lvl, 25, 6, 12, 16, 19, 23)
|
||||
const synergyBonusPct = (fsPts + volcPts) * 12
|
||||
|
||||
const minFireDamage = Math.trunc((rawMin * (100 + synergyBonusPct)) / 100)
|
||||
const maxFireDamage = Math.trunc((rawMax * (100 + synergyBonusPct)) / 100)
|
||||
|
||||
return {
|
||||
minFireDamage,
|
||||
maxFireDamage,
|
||||
durationFrames: 80,
|
||||
durationSeconds: 80 / 25,
|
||||
fissureVentsCount: 7,
|
||||
synergyBonusPct,
|
||||
cooldownFrames: 50,
|
||||
cooldownSeconds: 50 / 25,
|
||||
manaCost: 15,
|
||||
manaCost256: 3840,
|
||||
castOverlay: 'druid_fire_cast_2',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 235: Cyclone Armor
|
||||
* Ground Truth:
|
||||
* - Base absorption: 40 + (slvl - 1) * 12
|
||||
* - Synergies: +7% absorption per blvl in Twister, Tornado, Hurricane
|
||||
* - Formula: (base * (100 + synPct)) / 100
|
||||
* - Absorbs: Fire, Cold, Lightning ONLY.
|
||||
* - Zero effect on: Physical, Magic, Poison.
|
||||
* - Mana: 5 + (slvl - 1) * 1
|
||||
*/
|
||||
export function calculateCycloneArmorStats(
|
||||
slvl: number,
|
||||
twisterBlvl = 0,
|
||||
tornadoBlvl = 0,
|
||||
hurricaneBlvl = 0,
|
||||
): CycloneArmorStats {
|
||||
const lvl = Math.max(1, slvl)
|
||||
const twPts = Math.max(0, twisterBlvl)
|
||||
const torPts = Math.max(0, tornadoBlvl)
|
||||
const hurrPts = Math.max(0, hurricaneBlvl)
|
||||
|
||||
const baseAbsorption = 40 + (lvl - 1) * 12
|
||||
const synergyBonusPct = (twPts + torPts + hurrPts) * 7
|
||||
const totalAbsorption = Math.trunc((baseAbsorption * (100 + synergyBonusPct)) / 100)
|
||||
const totalAbsorption256 = totalAbsorption << 8
|
||||
|
||||
const manaCost = 5 + (lvl - 1)
|
||||
const manaCost256 = manaCost << 8
|
||||
|
||||
return {
|
||||
baseAbsorption,
|
||||
totalAbsorption,
|
||||
totalAbsorption256,
|
||||
synergyBonusPct,
|
||||
absorbedElements: ['fire', 'cold', 'lightning'] as const,
|
||||
unabsorbedElements: ['physical', 'magic', 'poison'] as const,
|
||||
manaCost,
|
||||
manaCost256,
|
||||
overlayFront: 'cyclonearmor1front',
|
||||
overlayBack: 'cyclonearmor1back',
|
||||
stateName: 'cyclonearmor',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 240: Twister
|
||||
* Ground Truth:
|
||||
* - Phys damage: minDam=12 [4, 7, 10, 13, 16], maxDam=16 [4, 7, 11, 14, 17]
|
||||
* - Stun: 10 frames (0.4s)
|
||||
* - Synergies: +10% damage per blvl in Tornado & Hurricane
|
||||
* - Twisters count: 3
|
||||
* - Mana: 7 (constant), Cooldown: 0
|
||||
*/
|
||||
export function calculateTwisterStats(
|
||||
slvl: number,
|
||||
tornadoBlvl = 0,
|
||||
hurricaneBlvl = 0,
|
||||
): TwisterStats {
|
||||
const lvl = Math.max(1, slvl)
|
||||
const torPts = Math.max(0, tornadoBlvl)
|
||||
const hurrPts = Math.max(0, hurricaneBlvl)
|
||||
|
||||
const rawMin = compute5BandScaling(lvl, 12, 4, 7, 10, 13, 16)
|
||||
const rawMax = compute5BandScaling(lvl, 16, 4, 7, 11, 14, 17)
|
||||
const synergyBonusPct = (torPts + hurrPts) * 10
|
||||
|
||||
const minPhysicalDamage = Math.trunc((rawMin * (100 + synergyBonusPct)) / 100)
|
||||
const maxPhysicalDamage = Math.trunc((rawMax * (100 + synergyBonusPct)) / 100)
|
||||
|
||||
return {
|
||||
minPhysicalDamage,
|
||||
maxPhysicalDamage,
|
||||
twisterCount: 3,
|
||||
stunDurationFrames: 10,
|
||||
stunDurationSeconds: 10 / 25,
|
||||
synergyBonusPct,
|
||||
manaCost: 7,
|
||||
manaCost256: 1792,
|
||||
missileName: 'twister',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 244: Volcano
|
||||
* Ground Truth:
|
||||
* - Phys damage: minDam=8 [2, 4, 6, 8, 10], maxDam=10 [2, 4, 6, 8, 10]
|
||||
* - Fire damage: eMin=8 [2, 4, 6, 8, 11], eMax=10 [2, 4, 6, 8, 13]
|
||||
* - Synergies:
|
||||
* - Molten Boulder: +12% physical damage / blvl
|
||||
* - Eruption & Armageddon: +12% fire damage / blvl
|
||||
* - Mana: 25 (constant), Cooldown: 100 frames (4.0s)
|
||||
*/
|
||||
export function calculateVolcanoStats(
|
||||
slvl: number,
|
||||
moltenBoulderBlvl = 0,
|
||||
eruptionBlvl = 0,
|
||||
armageddonBlvl = 0,
|
||||
): VolcanoStats {
|
||||
const lvl = Math.max(1, slvl)
|
||||
const mbPts = Math.max(0, moltenBoulderBlvl)
|
||||
const erupPts = Math.max(0, eruptionBlvl)
|
||||
const armPts = Math.max(0, armageddonBlvl)
|
||||
|
||||
const rawPhysMin = compute5BandScaling(lvl, 8, 2, 4, 6, 8, 10)
|
||||
const rawPhysMax = compute5BandScaling(lvl, 10, 2, 4, 6, 8, 10)
|
||||
const rawFireMin = compute5BandScaling(lvl, 8, 2, 4, 6, 8, 11)
|
||||
const rawFireMax = compute5BandScaling(lvl, 10, 2, 4, 6, 8, 13)
|
||||
|
||||
const physSynergyBonusPct = mbPts * 12
|
||||
const fireSynergyBonusPct = (erupPts + armPts) * 12
|
||||
|
||||
const minPhysicalDamage = Math.trunc((rawPhysMin * (100 + physSynergyBonusPct)) / 100)
|
||||
const maxPhysicalDamage = Math.trunc((rawPhysMax * (100 + physSynergyBonusPct)) / 100)
|
||||
const minFireDamage = Math.trunc((rawFireMin * (100 + fireSynergyBonusPct)) / 100)
|
||||
const maxFireDamage = Math.trunc((rawFireMax * (100 + fireSynergyBonusPct)) / 100)
|
||||
|
||||
return {
|
||||
minPhysicalDamage,
|
||||
maxPhysicalDamage,
|
||||
minFireDamage,
|
||||
maxFireDamage,
|
||||
durationFrames: 120, // 4.8s
|
||||
durationSeconds: 120 / 25,
|
||||
radiusYards: 12,
|
||||
physSynergyBonusPct,
|
||||
fireSynergyBonusPct,
|
||||
cooldownFrames: 100,
|
||||
cooldownSeconds: 100 / 25,
|
||||
manaCost: 25,
|
||||
manaCost256: 6400,
|
||||
castOverlay: 'druid_fire_cast_2',
|
||||
baseMissile: 'volcanobase',
|
||||
subMissile: 'volcanorock',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 245: Tornado
|
||||
* Ground Truth:
|
||||
* - Phys damage: minDam=25 [8, 14, 20, 24, 28], maxDam=35 [8, 15, 21, 25, 29]
|
||||
* - Synergies: +9% damage per blvl in Cyclone Armor, Twister, Hurricane
|
||||
* - NextDelay: 25 frames (1.0s)
|
||||
* - Mana: 10 (constant), Cooldown: 0
|
||||
*/
|
||||
export function calculateTornadoStats(
|
||||
slvl: number,
|
||||
cycloneArmorBlvl = 0,
|
||||
twisterBlvl = 0,
|
||||
hurricaneBlvl = 0,
|
||||
): TornadoStats {
|
||||
const lvl = Math.max(1, slvl)
|
||||
const caPts = Math.max(0, cycloneArmorBlvl)
|
||||
const twPts = Math.max(0, twisterBlvl)
|
||||
const hurrPts = Math.max(0, hurricaneBlvl)
|
||||
|
||||
const rawMin = compute5BandScaling(lvl, 25, 8, 14, 20, 24, 28)
|
||||
const rawMax = compute5BandScaling(lvl, 35, 8, 15, 21, 25, 29)
|
||||
const synergyBonusPct = (caPts + twPts + hurrPts) * 9
|
||||
|
||||
const minPhysicalDamage = Math.trunc((rawMin * (100 + synergyBonusPct)) / 100)
|
||||
const maxPhysicalDamage = Math.trunc((rawMax * (100 + synergyBonusPct)) / 100)
|
||||
|
||||
return {
|
||||
minPhysicalDamage,
|
||||
maxPhysicalDamage,
|
||||
nextDelayFrames: 25,
|
||||
synergyBonusPct,
|
||||
manaCost: 10,
|
||||
manaCost256: 2560,
|
||||
missileName: 'tornado',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 249: Armageddon
|
||||
* Ground Truth:
|
||||
* - Phys damage: minDam=4 [4, 6, 8, 10, 12], maxDam=16 [4, 7, 9, 12, 14]
|
||||
* - Fire damage: eMin=25 [15, 20, 25, 31, 38], eMax=75 [16, 22, 27, 34, 40]
|
||||
* - Duration: 250 frames (10s) + 50 frames (2s) per blvl in Eruption (Fissure)
|
||||
* - Synergies: +14% fire damage per blvl in Firestorm, Molten Boulder, Volcano
|
||||
* - Mana: 35 (constant), Cooldown: 150 frames (6.0s)
|
||||
* - Radius: 8 yards (12 subtiles)
|
||||
*/
|
||||
export function calculateArmageddonStats(
|
||||
slvl: number,
|
||||
eruptionBlvl = 0,
|
||||
firestormBlvl = 0,
|
||||
moltenBoulderBlvl = 0,
|
||||
volcanoBlvl = 0,
|
||||
): ArmageddonStats {
|
||||
const lvl = Math.max(1, slvl)
|
||||
const erupPts = Math.max(0, eruptionBlvl)
|
||||
const fsPts = Math.max(0, firestormBlvl)
|
||||
const mbPts = Math.max(0, moltenBoulderBlvl)
|
||||
const volcPts = Math.max(0, volcanoBlvl)
|
||||
|
||||
const rawPhysMin = compute5BandScaling(lvl, 4, 4, 6, 8, 10, 12)
|
||||
const rawPhysMax = compute5BandScaling(lvl, 16, 4, 7, 9, 12, 14)
|
||||
const rawFireMin = compute5BandScaling(lvl, 25, 15, 20, 25, 31, 38)
|
||||
const rawFireMax = compute5BandScaling(lvl, 75, 16, 22, 27, 34, 40)
|
||||
|
||||
const fireSynergyBonusPct = (fsPts + mbPts + volcPts) * 14
|
||||
|
||||
const minPhysicalDamage = rawPhysMin
|
||||
const maxPhysicalDamage = rawPhysMax
|
||||
const minFireDamage = Math.trunc((rawFireMin * (100 + fireSynergyBonusPct)) / 100)
|
||||
const maxFireDamage = Math.trunc((rawFireMax * (100 + fireSynergyBonusPct)) / 100)
|
||||
|
||||
const durationFrames = 250 + erupPts * 50
|
||||
|
||||
return {
|
||||
minPhysicalDamage,
|
||||
maxPhysicalDamage,
|
||||
minFireDamage,
|
||||
maxFireDamage,
|
||||
durationFrames,
|
||||
durationSeconds: durationFrames / 25,
|
||||
radiusYards: 8,
|
||||
meteorIntervalFrames: 25, // 1 second between impacts
|
||||
fireSynergyBonusPct,
|
||||
cooldownFrames: 150,
|
||||
cooldownSeconds: 150 / 25,
|
||||
manaCost: 35,
|
||||
manaCost256: 8960,
|
||||
castOverlay: 'druid_fire_cast_2',
|
||||
stateName: 'armageddon',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 250: Hurricane
|
||||
* Ground Truth:
|
||||
* - Cold damage: eMin=25 [7, 10, 12, 14, 16], eMax=50 [7, 10, 12, 14, 16]
|
||||
* - Chill length: 50 frames (2.0s)
|
||||
* - Duration: 250 frames (10s) + 50 frames (2s) per blvl in Cyclone Armor
|
||||
* - Synergies: +9% cold damage per blvl in Twister & Tornado
|
||||
* - Mana: 30 (constant), Cooldown: 150 frames (6.0s)
|
||||
* - Radius: 9 yards (13.5 subtiles)
|
||||
* - Pulse interval: 20 frames (0.8s)
|
||||
*/
|
||||
export function calculateHurricaneStats(
|
||||
slvl: number,
|
||||
cycloneArmorBlvl = 0,
|
||||
twisterBlvl = 0,
|
||||
tornadoBlvl = 0,
|
||||
): HurricaneStats {
|
||||
const lvl = Math.max(1, slvl)
|
||||
const caPts = Math.max(0, cycloneArmorBlvl)
|
||||
const twPts = Math.max(0, twisterBlvl)
|
||||
const torPts = Math.max(0, tornadoBlvl)
|
||||
|
||||
const rawMin = compute5BandScaling(lvl, 25, 7, 10, 12, 14, 16)
|
||||
const rawMax = compute5BandScaling(lvl, 50, 7, 10, 12, 14, 16)
|
||||
const coldSynergyBonusPct = (twPts + torPts) * 9
|
||||
|
||||
const minColdDamage = Math.trunc((rawMin * (100 + coldSynergyBonusPct)) / 100)
|
||||
const maxColdDamage = Math.trunc((rawMax * (100 + coldSynergyBonusPct)) / 100)
|
||||
|
||||
const durationFrames = 250 + caPts * 50
|
||||
|
||||
return {
|
||||
minColdDamage,
|
||||
maxColdDamage,
|
||||
chillDurationFrames: 50,
|
||||
chillDurationSeconds: 50 / 25,
|
||||
durationFrames,
|
||||
durationSeconds: durationFrames / 25,
|
||||
radiusYards: 9,
|
||||
pulseIntervalFrames: 20,
|
||||
coldSynergyBonusPct,
|
||||
cooldownFrames: 150,
|
||||
cooldownSeconds: 150 / 25,
|
||||
manaCost: 30,
|
||||
manaCost256: 7680,
|
||||
stateName: 'hurricane',
|
||||
}
|
||||
}
|
||||
|
||||
// --- Kinematics & Mechanics Simulation Helpers ---
|
||||
|
||||
export interface AbsorptionResult {
|
||||
readonly absorbed: number
|
||||
readonly penetrated: number
|
||||
readonly remainingArmor: number
|
||||
readonly didDissipate: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate Cyclone Armor absorbing incoming elemental damage.
|
||||
* Strictly absorbs Fire, Cold, and Lightning only.
|
||||
* Physical, Magic, and Poison damage pass straight through with 0 absorption.
|
||||
*/
|
||||
export function simulateCycloneArmorAbsorption(
|
||||
currentArmorCapacity: number,
|
||||
incomingDamage: number,
|
||||
damageType: 'fire' | 'cold' | 'lightning' | 'physical' | 'magic' | 'poison',
|
||||
): AbsorptionResult {
|
||||
const isElemental = damageType === 'fire' || damageType === 'cold' || damageType === 'lightning'
|
||||
|
||||
if (!isElemental || currentArmorCapacity <= 0) {
|
||||
return {
|
||||
absorbed: 0,
|
||||
penetrated: incomingDamage,
|
||||
remainingArmor: Math.max(0, currentArmorCapacity),
|
||||
didDissipate: currentArmorCapacity <= 0,
|
||||
}
|
||||
}
|
||||
|
||||
const absorbed = Math.min(currentArmorCapacity, incomingDamage)
|
||||
const penetrated = incomingDamage - absorbed
|
||||
const remainingArmor = currentArmorCapacity - absorbed
|
||||
|
||||
return {
|
||||
absorbed,
|
||||
penetrated,
|
||||
remainingArmor,
|
||||
didDissipate: remainingArmor <= 0,
|
||||
}
|
||||
}
|
||||
|
||||
export interface SerpentinePathPoint {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Firestorm meandering sinusoidal snake coordinates.
|
||||
*/
|
||||
export function calculateFirestormPathPoint(
|
||||
originX: number,
|
||||
originY: number,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
snakeIndex: number, // 0, 1, 2
|
||||
step: number, // 0..40
|
||||
): SerpentinePathPoint {
|
||||
const dx = targetX - originX
|
||||
const dy = targetY - originY
|
||||
const baseAngle = Math.atan2(dy, dx)
|
||||
// Spread: -0.25, 0, +0.25 rad
|
||||
const snakeAngle = baseAngle + (snakeIndex - 1) * 0.25
|
||||
const speed = 8 // missile vel
|
||||
const dist = step * speed
|
||||
|
||||
// Perpendicular sinusoidal wobble
|
||||
const wobbleAmplitude = 14
|
||||
const wobblePhase = step * 0.4 + snakeIndex * 2.1
|
||||
const perpX = -Math.sin(snakeAngle) * Math.sin(wobblePhase) * wobbleAmplitude
|
||||
const perpY = Math.cos(snakeAngle) * Math.sin(wobblePhase) * wobbleAmplitude
|
||||
|
||||
return {
|
||||
x: originX + Math.cos(snakeAngle) * dist + perpX,
|
||||
y: originY + Math.sin(snakeAngle) * dist + perpY,
|
||||
}
|
||||
}
|
||||
|
||||
export interface TornadoWanderPoint {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly currentPhase: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate Tornado wandering path progression.
|
||||
*/
|
||||
export function simulateTornadoWanderStep(
|
||||
currentX: number,
|
||||
currentY: number,
|
||||
baseHeadingRad: number,
|
||||
currentPhase: number,
|
||||
stepSpeed = 8,
|
||||
): TornadoWanderPoint {
|
||||
// Angular wobble
|
||||
const nextPhase = currentPhase + 0.35
|
||||
const wobbleAngle = baseHeadingRad + Math.sin(nextPhase) * 0.8
|
||||
return {
|
||||
x: currentX + Math.cos(wobbleAngle) * stepSpeed,
|
||||
y: currentY + Math.sin(wobbleAngle) * stepSpeed,
|
||||
currentPhase: nextPhase,
|
||||
}
|
||||
}
|
||||
|
||||
export interface ArmageddonImpactLocation {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly distanceYards: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine random impact location for an Armageddon meteor drop around Druid.
|
||||
*/
|
||||
export function calculateArmageddonImpactLocation(
|
||||
druidX: number,
|
||||
druidY: number,
|
||||
randomAngleRad: number,
|
||||
randomRadiusRatio: number, // 0..1
|
||||
maxRadiusYards = 8,
|
||||
): ArmageddonImpactLocation {
|
||||
const radiusYards = Math.sqrt(Math.max(0, Math.min(1, randomRadiusRatio))) * maxRadiusYards
|
||||
const radiusPx = radiusYards * 16 // 16px per yard in 2:1 isometric ground
|
||||
return {
|
||||
x: druidX + Math.cos(randomAngleRad) * radiusPx,
|
||||
y: druidY + Math.sin(randomAngleRad) * radiusPx * 0.5, // 2:1 perspective
|
||||
distanceYards: Number(radiusYards.toFixed(2)),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,599 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
calculateFirestormStats,
|
||||
calculateMoltenBoulderStats,
|
||||
calculateArcticBlastStats,
|
||||
calculateFissureStats,
|
||||
calculateCycloneArmorStats,
|
||||
calculateTwisterStats,
|
||||
calculateVolcanoStats,
|
||||
calculateTornadoStats,
|
||||
calculateArmageddonStats,
|
||||
calculateHurricaneStats,
|
||||
simulateCycloneArmorAbsorption,
|
||||
calculateFirestormPathPoint,
|
||||
simulateTornadoWanderStep,
|
||||
calculateArmageddonImpactLocation,
|
||||
} from '../../../src/game/skills/druid-elemental.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, getRegisteredSkillModule } from '../../../src/game/skills/registry.ts'
|
||||
|
||||
describe('Milestone M22 — Druid Elemental 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 Firestorm (225): Evaluates exact fire damage, 3-snake count, and +23%/blvl synergy', () => {
|
||||
// slvl 1
|
||||
const fs1 = calculateFirestormStats(1, 0, 0)
|
||||
expect(fs1.minFireDamage).toBe(3)
|
||||
expect(fs1.maxFireDamage).toBe(6)
|
||||
expect(fs1.snakesCount).toBe(3)
|
||||
expect(fs1.manaCost).toBe(4)
|
||||
expect(fs1.manaCost256).toBe(1024)
|
||||
expect(fs1.cooldownFrames).toBe(15)
|
||||
expect(fs1.cooldownSeconds).toBe(0.6)
|
||||
expect(fs1.castOverlay).toBe('druid_fire_cast_1')
|
||||
|
||||
// slvl 10: 3 + 7*3 + 2*5 = 34 min, 6 + 7*3 + 2*6 = 39 max
|
||||
const fs10 = calculateFirestormStats(10, 0, 0)
|
||||
expect(fs10.minFireDamage).toBe(34)
|
||||
expect(fs10.maxFireDamage).toBe(39)
|
||||
|
||||
// slvl 20: 3 + 7*3 + 8*5 + 4*7 = 92 min, 6 + 7*3 + 8*6 + 4*8 = 107 max
|
||||
const fs20 = calculateFirestormStats(20, 0, 0)
|
||||
expect(fs20.minFireDamage).toBe(92)
|
||||
expect(fs20.maxFireDamage).toBe(107)
|
||||
|
||||
// Synergy: 10 in Molten Boulder + 10 in Fissure = +460% fire damage
|
||||
const fs20Syn = calculateFirestormStats(20, 10, 10)
|
||||
expect(fs20Syn.synergyBonusPct).toBe(460)
|
||||
expect(fs20Syn.minFireDamage).toBe(Math.trunc((92 * 560) / 100)) // 515
|
||||
expect(fs20Syn.maxFireDamage).toBe(Math.trunc((107 * 560) / 100)) // 599
|
||||
})
|
||||
|
||||
it('1.2 Molten Boulder (229): Evaluates dual physical/fire damage and fixed-point mana scaling', () => {
|
||||
// slvl 1
|
||||
const mb1 = calculateMoltenBoulderStats(1, 0, 0)
|
||||
expect(mb1.minPhysicalDamage).toBe(6)
|
||||
expect(mb1.maxPhysicalDamage).toBe(12)
|
||||
expect(mb1.minFireDamage).toBe(6)
|
||||
expect(mb1.maxFireDamage).toBe(12)
|
||||
expect(mb1.manaCost).toBe(10)
|
||||
expect(mb1.manaCost256).toBe(2560)
|
||||
expect(mb1.cooldownFrames).toBe(50)
|
||||
expect(mb1.knockbackRadius).toBe(7)
|
||||
|
||||
// slvl 10: base: 6 + 7*4 + 2*7 = 48 min, 12 + 7*5 + 2*8 = 63 max
|
||||
// mana: (20 + 9) << 7 = 3712 -> manaCost = 14
|
||||
const mb10 = calculateMoltenBoulderStats(10, 0, 0)
|
||||
expect(mb10.minPhysicalDamage).toBe(48)
|
||||
expect(mb10.maxPhysicalDamage).toBe(63)
|
||||
expect(mb10.minFireDamage).toBe(48)
|
||||
expect(mb10.maxFireDamage).toBe(63)
|
||||
expect(mb10.manaCost).toBe(14)
|
||||
expect(mb10.manaCost256).toBe(3712)
|
||||
|
||||
// slvl 20: base: 48 + 6*7 + 4*10 = 130 min, 63 + 6*8 + 4*11 = 155 max
|
||||
// mana: (20 + 19) << 7 = 4992 -> manaCost = 19
|
||||
const mb20 = calculateMoltenBoulderStats(20, 0, 0)
|
||||
expect(mb20.minPhysicalDamage).toBe(130)
|
||||
expect(mb20.maxPhysicalDamage).toBe(155)
|
||||
expect(mb20.manaCost).toBe(19)
|
||||
expect(mb20.manaCost256).toBe(4992)
|
||||
|
||||
// Synergies: Volcano (+10%/lvl phys), Firestorm (+8%/lvl fire)
|
||||
const mb20Syn = calculateMoltenBoulderStats(20, 20, 20)
|
||||
expect(mb20Syn.physSynergyBonusPct).toBe(200)
|
||||
expect(mb20Syn.fireSynergyBonusPct).toBe(160)
|
||||
expect(mb20Syn.minPhysicalDamage).toBe(130 * 3) // 390
|
||||
expect(mb20Syn.maxPhysicalDamage).toBe(155 * 3) // 465
|
||||
expect(mb20Syn.minFireDamage).toBe(Math.trunc((130 * 260) / 100)) // 338
|
||||
expect(mb20Syn.maxFireDamage).toBe(Math.trunc((155 * 260) / 100)) // 403
|
||||
})
|
||||
|
||||
it('1.3 Arctic Blast (230): Evaluates continuous channeled cold damage, expanding arc, and chill scaling', () => {
|
||||
// slvl 1
|
||||
const ab1 = calculateArcticBlastStats(1, 0, 0)
|
||||
expect(ab1.minColdDamagePerSecond).toBe(21)
|
||||
expect(ab1.maxColdDamagePerSecond).toBe(40)
|
||||
expect(ab1.chillDurationFrames).toBe(100) // 4s
|
||||
expect(ab1.chillDurationSeconds).toBe(4.0)
|
||||
expect(ab1.rangeYards).toBe(6.75) // (35/4) - 2 = 8.75 - 2 = 6.75
|
||||
expect(ab1.manaCostPerTick256).toBe(96) // 24 << 2
|
||||
expect(ab1.isChanneled).toBe(true)
|
||||
|
||||
// slvl 10: 21 + 7*16 + 2*18 = 169 min, 40 + 7*16 + 2*19 = 190 max
|
||||
const ab10 = calculateArcticBlastStats(10, 0, 0)
|
||||
expect(ab10.minColdDamagePerSecond).toBe(169)
|
||||
expect(ab10.maxColdDamagePerSecond).toBe(190)
|
||||
expect(ab10.chillDurationFrames).toBe(235) // 100 + 9*15 = 235 (9.4s)
|
||||
expect(ab10.manaCostPerTick256).toBe(132) // (24 + 9) << 2 = 132
|
||||
|
||||
// slvl 20: 169 + 6*18 + 4*20 = 357 min, 190 + 6*19 + 4*21 = 388 max
|
||||
const ab20 = calculateArcticBlastStats(20, 0, 0)
|
||||
expect(ab20.minColdDamagePerSecond).toBe(357)
|
||||
expect(ab20.maxColdDamagePerSecond).toBe(388)
|
||||
expect(ab20.chillDurationFrames).toBe(385) // 100 + 19*15 = 385 (15.4s)
|
||||
expect(ab20.manaCostPerTick256).toBe(172) // (24 + 19) << 2 = 172
|
||||
|
||||
// Synergy: Cyclone Armor + Hurricane (+15% / blvl each)
|
||||
const ab20Syn = calculateArcticBlastStats(20, 20, 20)
|
||||
expect(ab20Syn.synergyBonusPct).toBe(600)
|
||||
expect(ab20Syn.minColdDamagePerSecond).toBe(357 * 7) // 2499
|
||||
expect(ab20Syn.maxColdDamagePerSecond).toBe(388 * 7) // 2716
|
||||
})
|
||||
|
||||
it('1.4 Eruption / Fissure (234): Evaluates geological fire damage and duration', () => {
|
||||
// slvl 1
|
||||
const f1 = calculateFissureStats(1, 0, 0)
|
||||
expect(f1.minFireDamage).toBe(15)
|
||||
expect(f1.maxFireDamage).toBe(25)
|
||||
expect(f1.durationFrames).toBe(80) // 3.2s
|
||||
expect(f1.cooldownFrames).toBe(50) // 2.0s
|
||||
expect(f1.manaCost).toBe(15)
|
||||
expect(f1.manaCost256).toBe(3840)
|
||||
|
||||
// slvl 10: 15 + 7*6 + 2*12 = 81 min, 25 + 7*6 + 2*12 = 91 max
|
||||
const f10 = calculateFissureStats(10, 0, 0)
|
||||
expect(f10.minFireDamage).toBe(81)
|
||||
expect(f10.maxFireDamage).toBe(91)
|
||||
|
||||
// slvl 20: 81 + 6*12 + 4*16 = 217 min, 91 + 6*12 + 4*16 = 227 max
|
||||
const f20 = calculateFissureStats(20, 0, 0)
|
||||
expect(f20.minFireDamage).toBe(217)
|
||||
expect(f20.maxFireDamage).toBe(227)
|
||||
|
||||
// Synergies: Firestorm + Volcano (+12%/lvl)
|
||||
const f20Syn = calculateFissureStats(20, 20, 20)
|
||||
expect(f20Syn.synergyBonusPct).toBe(480)
|
||||
expect(f20Syn.minFireDamage).toBe(Math.trunc((217 * 580) / 100)) // 1258
|
||||
expect(f20Syn.maxFireDamage).toBe(Math.trunc((227 * 580) / 100)) // 1316
|
||||
})
|
||||
|
||||
it('1.5 Cyclone Armor (235): Evaluates base absorption, synergy scaling, and mana progression', () => {
|
||||
// slvl 1
|
||||
const ca1 = calculateCycloneArmorStats(1, 0, 0, 0)
|
||||
expect(ca1.baseAbsorption).toBe(40)
|
||||
expect(ca1.totalAbsorption).toBe(40)
|
||||
expect(ca1.totalAbsorption256).toBe(40 << 8)
|
||||
expect(ca1.manaCost).toBe(5)
|
||||
expect(ca1.manaCost256).toBe(1280)
|
||||
expect(ca1.absorbedElements).toEqual(['fire', 'cold', 'lightning'])
|
||||
expect(ca1.unabsorbedElements).toEqual(['physical', 'magic', 'poison'])
|
||||
|
||||
// slvl 10: 40 + 9*12 = 148, mana: 5 + 9 = 14
|
||||
const ca10 = calculateCycloneArmorStats(10, 0, 0, 0)
|
||||
expect(ca10.baseAbsorption).toBe(148)
|
||||
expect(ca10.totalAbsorption).toBe(148)
|
||||
expect(ca10.manaCost).toBe(14)
|
||||
expect(ca10.manaCost256).toBe(3584)
|
||||
|
||||
// slvl 20: 40 + 19*12 = 268, mana: 5 + 19 = 24
|
||||
const ca20 = calculateCycloneArmorStats(20, 0, 0, 0)
|
||||
expect(ca20.baseAbsorption).toBe(268)
|
||||
expect(ca20.totalAbsorption).toBe(268)
|
||||
expect(ca20.manaCost).toBe(24)
|
||||
expect(ca20.manaCost256).toBe(6144)
|
||||
|
||||
// Synergies: Twister (20) + Tornado (20) + Hurricane (20) = 60 * 7% = +420%
|
||||
const ca20Syn = calculateCycloneArmorStats(20, 20, 20, 20)
|
||||
expect(ca20Syn.synergyBonusPct).toBe(420)
|
||||
expect(ca20Syn.totalAbsorption).toBe(Math.trunc((268 * 520) / 100)) // 1393
|
||||
})
|
||||
|
||||
it('1.6 Twister (240): Evaluates 3-way dispersion, physical damage, and 10-frame stun', () => {
|
||||
// slvl 1
|
||||
const tw1 = calculateTwisterStats(1, 0, 0)
|
||||
expect(tw1.minPhysicalDamage).toBe(12)
|
||||
expect(tw1.maxPhysicalDamage).toBe(16)
|
||||
expect(tw1.twisterCount).toBe(3)
|
||||
expect(tw1.stunDurationFrames).toBe(10)
|
||||
expect(tw1.stunDurationSeconds).toBe(0.4)
|
||||
expect(tw1.manaCost).toBe(7)
|
||||
expect(tw1.manaCost256).toBe(1792)
|
||||
|
||||
// slvl 10: 12 + 7*4 + 2*7 = 54 min, 16 + 7*4 + 2*7 = 58 max
|
||||
const tw10 = calculateTwisterStats(10, 0, 0)
|
||||
expect(tw10.minPhysicalDamage).toBe(54)
|
||||
expect(tw10.maxPhysicalDamage).toBe(58)
|
||||
|
||||
// slvl 20: 54 + 6*7 + 4*10 = 136 min, 58 + 6*7 + 4*11 = 144 max
|
||||
const tw20 = calculateTwisterStats(20, 0, 0)
|
||||
expect(tw20.minPhysicalDamage).toBe(136)
|
||||
expect(tw20.maxPhysicalDamage).toBe(144)
|
||||
|
||||
// Synergies: Tornado + Hurricane (+10%/lvl each)
|
||||
const tw20Syn = calculateTwisterStats(20, 20, 20)
|
||||
expect(tw20Syn.synergyBonusPct).toBe(400)
|
||||
expect(tw20Syn.minPhysicalDamage).toBe(136 * 5) // 680
|
||||
expect(tw20Syn.maxPhysicalDamage).toBe(144 * 5) // 720
|
||||
})
|
||||
|
||||
it('1.7 Volcano (244): Evaluates physical and fire damage split and cooldown', () => {
|
||||
// slvl 1
|
||||
const v1 = calculateVolcanoStats(1, 0, 0, 0)
|
||||
expect(v1.minPhysicalDamage).toBe(8)
|
||||
expect(v1.maxPhysicalDamage).toBe(10)
|
||||
expect(v1.minFireDamage).toBe(8)
|
||||
expect(v1.maxFireDamage).toBe(10)
|
||||
expect(v1.cooldownFrames).toBe(100)
|
||||
expect(v1.cooldownSeconds).toBe(4.0)
|
||||
expect(v1.manaCost).toBe(25)
|
||||
expect(v1.manaCost256).toBe(6400)
|
||||
|
||||
// slvl 10: phys: 8 + 7*2 + 2*4 = 30 min, 10 + 7*2 + 2*4 = 32 max
|
||||
// fire: same at slvl 10
|
||||
const v10 = calculateVolcanoStats(10, 0, 0, 0)
|
||||
expect(v10.minPhysicalDamage).toBe(30)
|
||||
expect(v10.maxPhysicalDamage).toBe(32)
|
||||
expect(v10.minFireDamage).toBe(30)
|
||||
expect(v10.maxFireDamage).toBe(32)
|
||||
|
||||
// slvl 20: phys: 30 + 6*4 + 4*6 = 78 min, 32 + 6*4 + 4*6 = 80 max
|
||||
const v20 = calculateVolcanoStats(20, 0, 0, 0)
|
||||
expect(v20.minPhysicalDamage).toBe(78)
|
||||
expect(v20.maxPhysicalDamage).toBe(80)
|
||||
|
||||
// Synergies: Molten Boulder (+12% phys), Eruption & Armageddon (+12% fire)
|
||||
const v20Syn = calculateVolcanoStats(20, 20, 20, 20)
|
||||
expect(v20Syn.physSynergyBonusPct).toBe(240)
|
||||
expect(v20Syn.fireSynergyBonusPct).toBe(480)
|
||||
expect(v20Syn.minPhysicalDamage).toBe(Math.trunc((78 * 340) / 100)) // 265
|
||||
expect(v20Syn.minFireDamage).toBe(Math.trunc((78 * 580) / 100)) // 452
|
||||
})
|
||||
|
||||
it('1.8 Tornado (245): Evaluates heavy physical vortex damage and NextDelay', () => {
|
||||
// slvl 1
|
||||
const tor1 = calculateTornadoStats(1, 0, 0, 0)
|
||||
expect(tor1.minPhysicalDamage).toBe(25)
|
||||
expect(tor1.maxPhysicalDamage).toBe(35)
|
||||
expect(tor1.nextDelayFrames).toBe(25)
|
||||
expect(tor1.manaCost).toBe(10)
|
||||
expect(tor1.manaCost256).toBe(2560)
|
||||
|
||||
// slvl 10: 25 + 7*8 + 2*14 = 109 min, 35 + 7*8 + 2*15 = 121 max
|
||||
const tor10 = calculateTornadoStats(10, 0, 0, 0)
|
||||
expect(tor10.minPhysicalDamage).toBe(109)
|
||||
expect(tor10.maxPhysicalDamage).toBe(121)
|
||||
|
||||
// slvl 20: 109 + 6*14 + 4*20 = 273 min, 121 + 6*15 + 4*21 = 295 max
|
||||
const tor20 = calculateTornadoStats(20, 0, 0, 0)
|
||||
expect(tor20.minPhysicalDamage).toBe(273)
|
||||
expect(tor20.maxPhysicalDamage).toBe(295)
|
||||
|
||||
// Synergies: Cyclone Armor + Twister + Hurricane (+9% each / blvl)
|
||||
const tor20Syn = calculateTornadoStats(20, 20, 20, 20)
|
||||
expect(tor20Syn.synergyBonusPct).toBe(540)
|
||||
expect(tor20Syn.minPhysicalDamage).toBe(Math.trunc((273 * 640) / 100)) // 1747
|
||||
expect(tor20Syn.maxPhysicalDamage).toBe(Math.trunc((295 * 640) / 100)) // 1888
|
||||
})
|
||||
|
||||
it('1.9 Armageddon (249): Evaluates fire + physical meteor rain and duration scaling', () => {
|
||||
// slvl 1
|
||||
const arm1 = calculateArmageddonStats(1, 0, 0, 0, 0)
|
||||
expect(arm1.minPhysicalDamage).toBe(4)
|
||||
expect(arm1.maxPhysicalDamage).toBe(16)
|
||||
expect(arm1.minFireDamage).toBe(25)
|
||||
expect(arm1.maxFireDamage).toBe(75)
|
||||
expect(arm1.durationFrames).toBe(250) // 10.0s
|
||||
expect(arm1.radiusYards).toBe(8)
|
||||
expect(arm1.cooldownFrames).toBe(150) // 6.0s
|
||||
expect(arm1.manaCost).toBe(35)
|
||||
expect(arm1.manaCost256).toBe(8960)
|
||||
|
||||
// Duration synergy: +50 frames (2.0s) per blvl in Eruption (Fissure)
|
||||
const arm1WithFissure = calculateArmageddonStats(1, 15, 0, 0, 0)
|
||||
expect(arm1WithFissure.durationFrames).toBe(250 + 15 * 50) // 1000 frames = 40s
|
||||
expect(arm1WithFissure.durationSeconds).toBe(40.0)
|
||||
|
||||
// slvl 20 fire damage: 25 + 7*15 + 8*20 + 4*25 = 390 min, 75 + 7*16 + 8*22 + 4*27 = 471 max
|
||||
const arm20 = calculateArmageddonStats(20, 0, 0, 0, 0)
|
||||
expect(arm20.minFireDamage).toBe(390)
|
||||
expect(arm20.maxFireDamage).toBe(471)
|
||||
|
||||
// Damage synergy: Firestorm, Molten Boulder, Volcano (+14% each / blvl)
|
||||
const arm20Syn = calculateArmageddonStats(20, 0, 20, 20, 20)
|
||||
expect(arm20Syn.fireSynergyBonusPct).toBe(840) // 60 * 14
|
||||
expect(arm20Syn.minFireDamage).toBe(Math.trunc((390 * 940) / 100)) // 3666
|
||||
expect(arm20Syn.maxFireDamage).toBe(Math.trunc((471 * 940) / 100)) // 4427
|
||||
})
|
||||
|
||||
it('1.10 Hurricane (250): Evaluates orbiting blizzard cold damage, chill, and duration scaling', () => {
|
||||
// slvl 1
|
||||
const hurr1 = calculateHurricaneStats(1, 0, 0, 0)
|
||||
expect(hurr1.minColdDamage).toBe(25)
|
||||
expect(hurr1.maxColdDamage).toBe(50)
|
||||
expect(hurr1.chillDurationFrames).toBe(50) // 2.0s
|
||||
expect(hurr1.durationFrames).toBe(250) // 10.0s
|
||||
expect(hurr1.radiusYards).toBe(9)
|
||||
expect(hurr1.cooldownFrames).toBe(150) // 6.0s
|
||||
expect(hurr1.manaCost).toBe(30)
|
||||
expect(hurr1.manaCost256).toBe(7680)
|
||||
|
||||
// Duration synergy: +50 frames (2.0s) per blvl in Cyclone Armor
|
||||
const hurr1WithArmor = calculateHurricaneStats(1, 20, 0, 0)
|
||||
expect(hurr1WithArmor.durationFrames).toBe(250 + 20 * 50) // 1250 frames = 50s
|
||||
expect(hurr1WithArmor.durationSeconds).toBe(50.0)
|
||||
|
||||
// slvl 20 cold damage: 25 + 7*7 + 8*10 + 4*12 = 202 min, 50 + 7*7 + 8*10 + 4*12 = 227 max
|
||||
const hurr20 = calculateHurricaneStats(20, 0, 0, 0)
|
||||
expect(hurr20.minColdDamage).toBe(202)
|
||||
expect(hurr20.maxColdDamage).toBe(227)
|
||||
|
||||
// Damage synergy: Twister + Tornado (+9% each / blvl)
|
||||
const hurr20Syn = calculateHurricaneStats(20, 0, 20, 20)
|
||||
expect(hurr20Syn.coldSynergyBonusPct).toBe(360)
|
||||
expect(hurr20Syn.minColdDamage).toBe(Math.trunc((202 * 460) / 100)) // 929
|
||||
expect(hurr20Syn.maxColdDamage).toBe(Math.trunc((227 * 460) / 100)) // 1044
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 2: Cyclone Armor Strict Absorption Matrix & Edge Cases
|
||||
// =========================================================================
|
||||
describe('Dimension 2: Cyclone Armor Strict Absorption Matrix & Edge Cases', () => {
|
||||
it('2.1 Strictly absorbs Fire, Cold, and Lightning elemental damage', () => {
|
||||
const initialCapacity = 500
|
||||
|
||||
// Fire damage
|
||||
const fireRes = simulateCycloneArmorAbsorption(initialCapacity, 150, 'fire')
|
||||
expect(fireRes.absorbed).toBe(150)
|
||||
expect(fireRes.penetrated).toBe(0)
|
||||
expect(fireRes.remainingArmor).toBe(350)
|
||||
expect(fireRes.didDissipate).toBe(false)
|
||||
|
||||
// Cold damage
|
||||
const coldRes = simulateCycloneArmorAbsorption(fireRes.remainingArmor, 200, 'cold')
|
||||
expect(coldRes.absorbed).toBe(200)
|
||||
expect(coldRes.penetrated).toBe(0)
|
||||
expect(coldRes.remainingArmor).toBe(150)
|
||||
expect(coldRes.didDissipate).toBe(false)
|
||||
|
||||
// Lightning damage
|
||||
const ltngRes = simulateCycloneArmorAbsorption(coldRes.remainingArmor, 100, 'lightning')
|
||||
expect(ltngRes.absorbed).toBe(100)
|
||||
expect(ltngRes.penetrated).toBe(0)
|
||||
expect(ltngRes.remainingArmor).toBe(50)
|
||||
expect(ltngRes.didDissipate).toBe(false)
|
||||
})
|
||||
|
||||
it('2.2 Passes Physical, Magic, and Poison damage straight through with zero absorption', () => {
|
||||
const initialCapacity = 500
|
||||
|
||||
// Physical damage (e.g. crushing blow, arrow)
|
||||
const physRes = simulateCycloneArmorAbsorption(initialCapacity, 250, 'physical')
|
||||
expect(physRes.absorbed).toBe(0)
|
||||
expect(physRes.penetrated).toBe(250)
|
||||
expect(physRes.remainingArmor).toBe(500)
|
||||
expect(physRes.didDissipate).toBe(false)
|
||||
|
||||
// Magic damage (e.g. Bone Spear, Blessed Hammer)
|
||||
const magRes = simulateCycloneArmorAbsorption(initialCapacity, 300, 'magic')
|
||||
expect(magRes.absorbed).toBe(0)
|
||||
expect(magRes.penetrated).toBe(300)
|
||||
expect(magRes.remainingArmor).toBe(500)
|
||||
expect(magRes.didDissipate).toBe(false)
|
||||
|
||||
// Poison damage (e.g. Poison Nova)
|
||||
const psnRes = simulateCycloneArmorAbsorption(initialCapacity, 400, 'poison')
|
||||
expect(psnRes.absorbed).toBe(0)
|
||||
expect(psnRes.penetrated).toBe(400)
|
||||
expect(psnRes.remainingArmor).toBe(500)
|
||||
expect(psnRes.didDissipate).toBe(false)
|
||||
})
|
||||
|
||||
it('2.3 Depletion & Dissipation: Absorbs up to remaining capacity and passes remaining damage to player', () => {
|
||||
const initialCapacity = 100
|
||||
|
||||
// Huge incoming fire attack (400 fire damage against 100 capacity)
|
||||
const overRes = simulateCycloneArmorAbsorption(initialCapacity, 400, 'fire')
|
||||
expect(overRes.absorbed).toBe(100)
|
||||
expect(overRes.penetrated).toBe(300)
|
||||
expect(overRes.remainingArmor).toBe(0)
|
||||
expect(overRes.didDissipate).toBe(true)
|
||||
|
||||
// Subsequent attacks hit directly with 0 armor
|
||||
const postRes = simulateCycloneArmorAbsorption(overRes.remainingArmor, 50, 'lightning')
|
||||
expect(postRes.absorbed).toBe(0)
|
||||
expect(postRes.penetrated).toBe(50)
|
||||
expect(postRes.remainingArmor).toBe(0)
|
||||
expect(postRes.didDissipate).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 3: Kinematics, Trajectory & Multi-Missile Mechanics
|
||||
// =========================================================================
|
||||
describe('Dimension 3: Kinematics, Trajectory & Multi-Missile Mechanics', () => {
|
||||
it('3.1 Firestorm: 3 snakes disperse along divergent angular paths with non-collinear coordinates', () => {
|
||||
const originX = 100
|
||||
const originY = 100
|
||||
const targetX = 300
|
||||
const targetY = 100
|
||||
|
||||
// Step 10
|
||||
const s0 = calculateFirestormPathPoint(originX, originY, targetX, targetY, 0, 10)
|
||||
const s1 = calculateFirestormPathPoint(originX, originY, targetX, targetY, 1, 10)
|
||||
const s2 = calculateFirestormPathPoint(originX, originY, targetX, targetY, 2, 10)
|
||||
|
||||
// All 3 snakes must travel forward (x > originX)
|
||||
expect(s0.x).toBeGreaterThan(originX)
|
||||
expect(s1.x).toBeGreaterThan(originX)
|
||||
expect(s2.x).toBeGreaterThan(originX)
|
||||
|
||||
// The 3 snakes must be spatially separated along the Y axis
|
||||
expect(s0.y).not.toBe(s1.y)
|
||||
expect(s1.y).not.toBe(s2.y)
|
||||
expect(s0.y).not.toBe(s2.y)
|
||||
})
|
||||
|
||||
it('3.2 Tornado: Wanders smoothly with continuous lateral oscillation without snapping backwards', () => {
|
||||
let x = 200
|
||||
let y = 200
|
||||
const baseHeading = 0 // moving East
|
||||
let phase = 0
|
||||
|
||||
const history: { x: number; y: number }[] = []
|
||||
for (let step = 0; step < 20; step++) {
|
||||
const next = simulateTornadoWanderStep(x, y, baseHeading, phase, 8)
|
||||
expect(next.x).toBeGreaterThan(x) // Monotonically progresses forward in general heading
|
||||
x = next.x
|
||||
y = next.y
|
||||
phase = next.currentPhase
|
||||
history.push({ x, y })
|
||||
}
|
||||
|
||||
// Check that lateral Y oscillates
|
||||
const yValues = history.map((p) => p.y)
|
||||
const minY = Math.min(...yValues)
|
||||
const maxY = Math.max(...yValues)
|
||||
expect(maxY - minY).toBeGreaterThan(10) // Demonstrated non-zero lateral sway
|
||||
})
|
||||
|
||||
it('3.3 Armageddon: Drops meteors strictly within 8-yard radial boundary of Druid', () => {
|
||||
const druidX = 500
|
||||
const druidY = 500
|
||||
|
||||
// Test multiple deterministic angles and radius ratios
|
||||
for (let i = 0; i < 36; i++) {
|
||||
const angle = (i * Math.PI * 2) / 36
|
||||
const radiusRatio = (i % 10) / 10
|
||||
const loc = calculateArmageddonImpactLocation(druidX, druidY, angle, radiusRatio, 8)
|
||||
|
||||
expect(loc.distanceYards).toBeLessThanOrEqual(8.0)
|
||||
expect(loc.distanceYards).toBeGreaterThanOrEqual(0.0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// 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 Elemental skills are properly registered in SkillModule architecture', async () => {
|
||||
const elementalIds = [225, 229, 230, 234, 235, 240, 244, 245, 249, 250]
|
||||
const { skillModule: m225 } = await import('../../../src/game/skills/impl/dru/skill-225-firestorm.ts')
|
||||
const { skillModule: m229 } = await import('../../../src/game/skills/impl/dru/skill-229-molten-boulder.ts')
|
||||
const { skillModule: m230 } = await import('../../../src/game/skills/impl/dru/skill-230-arctic-blast.ts')
|
||||
const { skillModule: m234 } = await import('../../../src/game/skills/impl/dru/skill-234-eruption.ts')
|
||||
const { skillModule: m235 } = await import('../../../src/game/skills/impl/dru/skill-235-cyclone-armor.ts')
|
||||
const { skillModule: m240 } = await import('../../../src/game/skills/impl/dru/skill-240-twister.ts')
|
||||
const { skillModule: m244 } = await import('../../../src/game/skills/impl/dru/skill-244-volcano.ts')
|
||||
const { skillModule: m245 } = await import('../../../src/game/skills/impl/dru/skill-245-tornado.ts')
|
||||
const { skillModule: m249 } = await import('../../../src/game/skills/impl/dru/skill-249-armageddon.ts')
|
||||
const { skillModule: m250 } = await import('../../../src/game/skills/impl/dru/skill-250-hurricane.ts')
|
||||
|
||||
const modules = [m225, m229, m230, m234, m235, m240, m244, m245, m249, m250]
|
||||
for (let i = 0; i < elementalIds.length; i++) {
|
||||
const id = elementalIds[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 elemental skills from Patch_D2.mpq with exact attributes', async () => {
|
||||
const reg = await getSharedDataRegistry('samples/d2')
|
||||
|
||||
// Firestorm
|
||||
const fs = reg.getSkillById(225)
|
||||
expect(fs).toBeDefined()
|
||||
expect(fs!.name).toBe('Firestorm')
|
||||
expect(fs!.castOverlay).toBe('druid_fire_cast_1')
|
||||
expect(fs!.delay).toBe('15')
|
||||
|
||||
// Molten Boulder
|
||||
const mb = reg.getSkillById(229)
|
||||
expect(mb).toBeDefined()
|
||||
expect(mb!.name).toBe('Molten Boulder')
|
||||
expect(mb!.srvMissile).toBe('moltenboulderemerge')
|
||||
expect(mb!.delay).toBe('50')
|
||||
|
||||
// Arctic Blast
|
||||
const ab = reg.getSkillById(230)
|
||||
expect(ab).toBeDefined()
|
||||
expect(ab!.name).toBe('Arctic Blast')
|
||||
expect(ab!.srvStFunc).toBe(11)
|
||||
expect(ab!.srvDoFunc).toBe(19)
|
||||
|
||||
// Cyclone Armor
|
||||
const ca = reg.getSkillById(235)
|
||||
expect(ca).toBeDefined()
|
||||
expect(ca!.name).toBe('Cyclone Armor')
|
||||
expect(ca!.auraState).toBe('cyclonearmor')
|
||||
|
||||
// Twister
|
||||
const tw = reg.getSkillById(240)
|
||||
expect(tw).toBeDefined()
|
||||
expect(tw!.name).toBe('Twister')
|
||||
expect(tw!.srvMissileA).toBe('twister')
|
||||
|
||||
// Tornado
|
||||
const tor = reg.getSkillById(245)
|
||||
expect(tor).toBeDefined()
|
||||
expect(tor!.name).toBe('Tornado')
|
||||
expect(tor!.srvMissileA).toBe('tornado')
|
||||
|
||||
// Armageddon
|
||||
const arm = reg.getSkillById(249)
|
||||
expect(arm).toBeDefined()
|
||||
expect(arm!.name).toBe('Armageddon')
|
||||
expect(arm!.auraState).toBe('armageddon')
|
||||
expect(arm!.delay).toBe('150')
|
||||
|
||||
// Hurricane
|
||||
const hurr = reg.getSkillById(250)
|
||||
expect(hurr).toBeDefined()
|
||||
expect(hurr!.name).toBe('Hurricane')
|
||||
expect(hurr!.auraState).toBe('hurricane')
|
||||
expect(hurr!.delay).toBe('150')
|
||||
})
|
||||
|
||||
it('4.4 evaluateSkill113c evaluates Druid elemental skills against StatList with exact cooldown & mana', async () => {
|
||||
const reg = await getSharedDataRegistry('samples/d2')
|
||||
const stats = new UnitStatList()
|
||||
|
||||
// Evaluate Firestorm
|
||||
const fsRes = evaluateSkill113c({
|
||||
registry: reg,
|
||||
skill: reg.getSkillById(225)!,
|
||||
slvl: 5,
|
||||
blvl: 5,
|
||||
statList: stats,
|
||||
})
|
||||
expect(fsRes.manaCost).toBe(4)
|
||||
expect(fsRes.cooldownFrames).toBe(15)
|
||||
|
||||
// Evaluate Molten Boulder
|
||||
const mbRes = evaluateSkill113c({
|
||||
registry: reg,
|
||||
skill: reg.getSkillById(229)!,
|
||||
slvl: 10,
|
||||
blvl: 10,
|
||||
statList: stats,
|
||||
})
|
||||
expect(mbRes.manaCost).toBe(14)
|
||||
expect(mbRes.cooldownFrames).toBe(50)
|
||||
|
||||
// Evaluate Hurricane
|
||||
const hurrRes = evaluateSkill113c({
|
||||
registry: reg,
|
||||
skill: reg.getSkillById(250)!,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: stats,
|
||||
})
|
||||
expect(hurrRes.manaCost).toBe(30)
|
||||
expect(hurrRes.cooldownFrames).toBe(150)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue