feat(dru): Milestone M22 Druid elemental skills pure calculators, absorption, kinematics, and stress suite (closes #469)

This commit is contained in:
troytt 2026-09-25 02:51:27 +00:00
parent 51ac3b2d77
commit 5eded536ef
2 changed files with 1380 additions and 0 deletions

View File

@ -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)),
}
}

View File

@ -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)
})
})
})