287 lines
14 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|