diablo2-web/tests/skills/sor/skill-057-thunder-storm.tes...

287 lines
14 KiB
TypeScript

/**
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #057: Thunder Storm (SOR)
* Gitea Tracking Issue: #211
*
* 7-Dimensional Verification Coverage:
* D1: 1.13c Data Table Parsing & Canonical Missile Definition (Skills.txt row 57, Missiles.txt 'thunderstorm1' / ID 166)
* D2: Visual Assets & Metadata Integrity (LightningboltBig, light_cast_2, additive blending, overlay mapping)
* D3: Cadence & Hyperbolic Interval Mechanics (Duration 800 + 200/lvl, Interval hyperbolic decay, 17 subtiles = 340px)
* D4: 5-Band Piecewise Damage Scaling & Lightning Mastery Multiplicative Integration (EMin: 1, EMax: 100, +10/+10/+11)
* D5: StateBus & Buff Management (State 'thunderstorm', timer cadence, WorldArena evaluation)
* D6: Edge Cases & Boundary Conditions (slvl clamping, zero/negative inputs, cooldown = 0, mana cost = 19)
* D7: Invariants (BATCH1_SKILLS length strictly 38, zero runtime MPQ queries)
*/
import { describe, expect, it } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/sor/skill-057-thunder-storm.ts'
import {
parseMissilesTxt,
CANONICAL_113C_MISSILES,
getSkillCastOverlay,
calculateSkillDamage,
calculateThunderStormDuration,
calculateThunderStormInterval,
calculateThunderStormDamage,
calculateLightningMasteryBonusPct,
BATCH1_SKILLS,
} from '../../../src/game/skills.ts'
import { getSkillRange, getSkillMana } from '../../../src/scene/act-scene.ts'
import {
THUNDERSTORM1_META,
MISSILE_METAS,
} from '../../../src/render/missiles-meta.ts'
import {
LIGHT_CAST_2_META,
OVERLAY_METAS,
} from '../../../src/render/overlays-meta.ts'
describe('[Skill #057] Thunder Storm (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #211)', () => {
const missilesTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Missiles.txt')
const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1')
const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt')
const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1')
// =========================================================================
// Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition
// =========================================================================
describe('Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition', () => {
it('parses authentic Skills.txt row 57 (Thunder Storm)', () => {
const lines = skillsTxtContent.split('\n')
const headers = lines[0]!.split('\t')
const idIdx = headers.indexOf('Id')
const skillIdx = headers.indexOf('skill')
const charClassIdx = headers.indexOf('charclass')
const manaIdx = headers.indexOf('mana')
const lvlManaIdx = headers.indexOf('lvlmana')
const manaShiftIdx = headers.indexOf('manashift')
const castOverlayIdx = headers.indexOf('castoverlay')
const eMinIdx = headers.indexOf('EMin')
const eMaxIdx = headers.indexOf('EMax')
const eMinLev1Idx = headers.indexOf('EMinLev1')
const eMaxLev1Idx = headers.indexOf('EMaxLev1')
const eMinLev2Idx = headers.indexOf('EMinLev2')
const eMaxLev2Idx = headers.indexOf('EMaxLev2')
const eMinLev3Idx = headers.indexOf('EMinLev3')
const eMaxLev3Idx = headers.indexOf('EMaxLev3')
const param1Idx = headers.indexOf('Param1') !== -1 ? headers.indexOf('Param1') : headers.indexOf('param1')
const param2Idx = headers.indexOf('Param2') !== -1 ? headers.indexOf('Param2') : headers.indexOf('param2')
const param3Idx = headers.indexOf('Param3') !== -1 ? headers.indexOf('Param3') : headers.indexOf('param3')
const param7Idx = headers.indexOf('Param7') !== -1 ? headers.indexOf('Param7') : headers.indexOf('param7')
const auraStateIdx = headers.indexOf('aurastate')
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '57')
expect(row).toBeDefined()
expect(row![skillIdx]?.trim()).toBe('Thunder Storm')
expect(row![charClassIdx]?.trim()).toBe('sor')
expect(Number(row![manaIdx])).toBe(19)
expect(Number(row![lvlManaIdx])).toBe(0)
expect(Number(row![manaShiftIdx])).toBe(8)
expect(row![castOverlayIdx]?.trim()).toBe('light_cast_2')
expect(Number(row![eMinIdx])).toBe(1)
expect(Number(row![eMaxIdx])).toBe(100)
expect(Number(row![eMinLev1Idx])).toBe(10)
expect(Number(row![eMaxLev1Idx])).toBe(10)
expect(Number(row![eMinLev2Idx])).toBe(10)
expect(Number(row![eMaxLev2Idx])).toBe(10)
expect(Number(row![eMinLev3Idx])).toBe(11)
expect(Number(row![eMaxLev3Idx])).toBe(11)
expect(Number(row![param1Idx])).toBe(800) // Base duration
expect(Number(row![param2Idx])).toBe(200) // Duration per level
expect(Number(row![param3Idx])).toBe(25) // Floor strike interval (frames)
expect(Number(row![param7Idx])).toBe(17) // Radius subtiles
expect(row![auraStateIdx]?.trim()).toBe('thunderstorm')
})
it('parses authentic Missiles.txt row for thunderstorm1 (ID 166)', () => {
const map = parseMissilesTxt(missilesTxtContent)
const ts = map.get('thunderstorm1')
expect(ts).toBeDefined()
expect(ts!.id).toBe(166)
expect(ts!.celFile.toLowerCase()).toBe('lightningboltbig')
expect(ts!.range).toBe(9)
expect(ts!.animLen).toBe(8)
})
})
// =========================================================================
// Dimension 2: Visual Assets & Metadata Integrity
// =========================================================================
describe('Dimension 2: Visual Assets & Metadata Integrity', () => {
it('verifies light_cast_2 cast overlay metadata in OVERLAY_METAS', () => {
expect(OVERLAY_METAS['light_cast_2']).toBeDefined()
expect(OVERLAY_METAS['light_cast_2']).toBe(LIGHT_CAST_2_META)
expect(LIGHT_CAST_2_META.frames).toBeGreaterThan(0)
expect(getSkillCastOverlay(57)).toBe('light_cast_2')
})
it('verifies thunderstorm1 missile metadata in CANONICAL_113C_MISSILES and MISSILE_METAS', () => {
const msl = CANONICAL_113C_MISSILES['thunderstorm1']
expect(msl).toBeDefined()
expect(msl.id).toBe(166)
expect(msl.celFile).toBe('LightningboltBig')
expect(msl.animLen).toBe(8)
expect(MISSILE_METAS['thunderstorm1']).toBeDefined()
expect(MISSILE_METAS['thunderstorm1']).toBe(THUNDERSTORM1_META)
})
})
// =========================================================================
// Dimension 3: Cadence & Hyperbolic Interval Mechanics
// =========================================================================
describe('Dimension 3: Cadence & Hyperbolic Interval Mechanics', () => {
it('calculates exact 1.13c duration: 800 + (slvl - 1) * 200 frames', () => {
expect(calculateThunderStormDuration(1)).toBe(800) // 32.00s
expect(calculateThunderStormDuration(2)).toBe(1000) // 40.00s
expect(calculateThunderStormDuration(10)).toBe(2600) // 104.00s
expect(calculateThunderStormDuration(20)).toBe(4600) // 184.00s
expect(calculateThunderStormDuration(40)).toBe(8600) // 344.00s
})
it('calculates authentic hyperbolic cadence interval (floor 25 frames)', () => {
// 125 - Math.floor(110 * slvl / (slvl + 6))
expect(calculateThunderStormInterval(1)).toBe(110) // 125 - 15 = 110 (4.40s)
expect(calculateThunderStormInterval(2)).toBe(98) // 125 - 27 = 98 (3.92s)
expect(calculateThunderStormInterval(5)).toBe(75) // 125 - 50 = 75 (3.00s)
expect(calculateThunderStormInterval(10)).toBe(57) // 125 - 68 = 57 (2.28s)
expect(calculateThunderStormInterval(20)).toBe(41) // 125 - 84 = 41 (1.64s)
expect(calculateThunderStormInterval(40)).toBe(30) // 125 - 95 = 30 (1.20s)
expect(calculateThunderStormInterval(100)).toBe(25) // Clamped at floor 25 (1.00s)
})
it('verifies target acquisition radius is 17 subtiles (340px)', () => {
expect(getSkillRange(57)).toBe(340)
})
})
// =========================================================================
// Dimension 4: 5-Band Piecewise Damage Scaling & Lightning Mastery Multiplicative Integration
// =========================================================================
describe('Dimension 4: 5-Band Piecewise Damage Scaling & Lightning Mastery Multiplicative Integration', () => {
it('calculates unmastered base damage across bands', () => {
// Level 1: 1 - 100
expect(calculateThunderStormDamage(1)).toEqual({ min: 1, max: 100 })
// Level 2: 1+10 - 100+10 = 11 - 110
expect(calculateThunderStormDamage(2)).toEqual({ min: 11, max: 110 })
// Level 8: 1+70 - 100+70 = 71 - 170
expect(calculateThunderStormDamage(8)).toEqual({ min: 71, max: 170 })
// Level 10: 71+20 - 170+20 = 91 - 190
expect(calculateThunderStormDamage(10)).toEqual({ min: 91, max: 190 })
// Level 16: 91+60 - 190+60 = 151 - 250
expect(calculateThunderStormDamage(16)).toEqual({ min: 151, max: 250 })
// Level 20: 151+44 - 250+44 = 195 - 294
expect(calculateThunderStormDamage(20)).toEqual({ min: 195, max: 294 })
// Level 30: 195 + 66 + 44 = 305 - 404
expect(calculateThunderStormDamage(30)).toEqual({ min: 305, max: 404 })
})
it('verifies 0 synergies in 1.13c and multiplicative scaling with Lightning Mastery', () => {
// Thunder Storm has 0 synergies in 1.13c: synergy points change nothing
const noMastery = calculateThunderStormDamage(20)
expect(noMastery).toEqual({ min: 195, max: 294 })
// Multiplicative with Lightning Mastery:
// Level 1 LM (+50%): 195 * 1.5 = 292.5 -> 292, 294 * 1.5 = 441
const withLm1 = calculateThunderStormDamage(20, { lightningMastery: 1 })
expect(withLm1).toEqual({ min: 292, max: 441 })
// Level 20 LM (+278%): 195 * 3.78 = 737.1 -> 737, 294 * 3.78 = 1111.32 -> 1111
const withLm20 = calculateThunderStormDamage(20, { lightningMastery: 20 })
expect(withLm20).toEqual({ min: 737, max: 1111 })
// Parameter overload: passing number as 3rd argument
const withLm20Arg = calculateThunderStormDamage(20, undefined, 20)
expect(withLm20Arg).toEqual({ min: 737, max: 1111 })
})
it('routes calculateSkillDamage correctly for Thunder Storm', () => {
expect(calculateSkillDamage(57, 1)).toEqual({ min: 1, max: 100 })
expect(calculateSkillDamage('57', 20)).toEqual({ min: 195, max: 294 })
expect(calculateSkillDamage('thunderstorm', 20, { lightningMastery: 20 } as any)).toEqual({ min: 737, max: 1111 })
})
})
// =========================================================================
// Dimension 5: StateBus & Buff Management
// =========================================================================
describe('Dimension 5: StateBus & Buff Management', () => {
it('evaluates exact 1.13c Skills.txt formulas at slvl 1, 10, and 20 via SkillModule', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(57)!
expect(skillRec).toBeDefined()
expect(skillModule.skillId).toBe(57)
expect(skillModule.name).toBe('Thunder Storm')
expect(skillModule.charClass).toBe('sor')
const stats1 = new UnitStatList()
stats1.setBaseSkillLevel(57, 1)
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
expect(r1.manaCost).toBe(19)
expect(r1.manaCost256).toBe(4864) // 19 * 256
expect(r1.minElemDmg256).toBe(256) // 1 * 256
expect(r1.maxElemDmg256).toBe(25600) // 100 * 256
const stats10 = new UnitStatList()
stats10.setBaseSkillLevel(57, 10)
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
expect(r10.manaCost).toBe(19)
expect(r10.minElemDmg256).toBe(23296) // 91 * 256
expect(r10.maxElemDmg256).toBe(48640) // 190 * 256
const stats20 = new UnitStatList()
stats20.setBaseSkillLevel(57, 20)
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
expect(r20.manaCost).toBe(19)
expect(r20.minElemDmg256).toBe(49920) // 195 * 256
expect(r20.maxElemDmg256).toBe(75264) // 294 * 256
expect(r20.durationFrames).toBe(4600)
})
})
// =========================================================================
// Dimension 6: Edge Cases & Boundary Conditions
// =========================================================================
describe('Dimension 6: Edge Cases & Boundary Conditions', () => {
it('handles non-positive slvl gracefully (clamps to 1)', () => {
expect(calculateThunderStormDuration(0)).toBe(800)
expect(calculateThunderStormDuration(-5)).toBe(800)
expect(calculateThunderStormInterval(0)).toBe(110)
expect(calculateThunderStormDamage(0)).toEqual({ min: 1, max: 100 })
})
it('verifies 1.13c mana cost is constant 19 and cooldown is 0', () => {
expect(getSkillMana(57)).toBe(19)
})
})
// =========================================================================
// Dimension 7: Invariants & WorldArena Integration
// =========================================================================
describe('Dimension 7: Invariants & WorldArena Integration', () => {
it('executes runtime cast in WorldArena with action frame and mana deduction', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: 'sor',
skillId: 57,
slvl: 20,
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(57)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
it('enforces BATCH1_SKILLS length invariant strictly 38', () => {
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
})
})
})