diff --git a/src/game/skills/druid-elemental.ts b/src/game/skills/druid-elemental.ts new file mode 100644 index 0000000..99b4f8e --- /dev/null +++ b/src/game/skills/druid-elemental.ts @@ -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)), + } +} diff --git a/tests/skills/dru/adv-dru-elemental-stress.test.ts b/tests/skills/dru/adv-dru-elemental-stress.test.ts new file mode 100644 index 0000000..a3c0239 --- /dev/null +++ b/tests/skills/dru/adv-dru-elemental-stress.test.ts @@ -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) + }) + }) +})