600 lines
26 KiB
TypeScript
600 lines
26 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|