diablo2-web/tests/skills/sor/skill-063-lightning-mastery...

389 lines
18 KiB
TypeScript

/**
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #063: Lightning Mastery (SOR)
* Gitea Tracking Issue: #217
*
* 7-Dimensional Verification Coverage:
* D1: 1.13c Data Table Parsing & Schema Integrity (Skills.txt row 63, passive=1, passivestate='lightningmastery', Param1=50, Param2=12)
* D2: Passive Metadata & Scene Integration (0 mana cost, 0 cooldown, no active missile, calculateSkillDamage returns 0)
* D3: Exact 1.13c Formula Scaling (50 + (slvl - 1) * 12: lvl 1=50%, lvl 2=62%, lvl 10=158%, lvl 20=278%, lvl 40=518%)
* D4: Multiplicative Synergy Scaling across all 5 Lightning Skills (Charged Bolt, Lightning, Chain Lightning, Nova, Thunder Storm)
* D5: StatList & Combat Pipeline Integration (getAccruedStat('passive_ltng_mastery'), item_allskills scaling, executeSUnitDmg)
* D6: Edge Cases & Boundary Conditions (slvl 0, negative slvl, non-lightning elemental isolation, zero synergy receiver)
* D7: Invariants & WorldArena Integration (BATCH1_SKILLS length strictly 38, zero runtime MPQ queries, WorldArena cast execution)
*/
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, FIXED_ONE } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import { executeSUnitDmg, type CombatUnitContext } from '../../../src/game/engine/combat-pipeline.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/sor/skill-063-lightning-mastery.ts'
import {
calculateLightningMasteryBonusPct,
calculateChargedBoltDamage,
calculateLightningDamage,
calculateChainLightningDamage,
calculateNovaDamage,
calculateThunderStormDamage,
calculateSkillDamage,
BATCH1_SKILLS,
} from '../../../src/game/skills.ts'
import { getSkillRange, getSkillMana } from '../../../src/scene/act-scene.ts'
describe('[Skill #063] Lightning Mastery (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #217)', () => {
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 & Schema Integrity
// =========================================================================
describe('Dimension 1: 1.13c Data Table Parsing & Schema Integrity', () => {
it('parses authentic Skills.txt row 63 (Lightning Mastery)', () => {
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 reqLevelIdx = headers.indexOf('reqlevel')
const passiveIdx = headers.indexOf('passive')
const passiveStateIdx = headers.indexOf('passivestate')
const manaIdx = headers.indexOf('mana')
const lvlManaIdx = headers.indexOf('lvlmana')
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 srvStFuncIdx = headers.indexOf('srvstfunc')
const srvDoFuncIdx = headers.indexOf('srvdofunc')
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '63')
expect(row).toBeDefined()
expect(row![skillIdx]?.trim()).toBe('Lightning Mastery')
expect(row![charClassIdx]?.trim()).toBe('sor')
expect(Number(row![reqLevelIdx])).toBe(30)
expect(Number(row![passiveIdx])).toBe(1)
expect(row![passiveStateIdx]?.trim()).toBe('lightningmastery')
expect(Number(row![manaIdx])).toBe(0)
expect(Number(row![lvlManaIdx])).toBe(0)
expect(Number(row![param1Idx])).toBe(50) // Base 50% at slvl 1
expect(Number(row![param2Idx])).toBe(12) // +12% per slvl
expect(Number(row![srvStFuncIdx])).toBe(0)
expect(Number(row![srvDoFuncIdx])).toBe(0)
})
})
// =========================================================================
// Dimension 2: Passive Metadata & Scene Integration
// =========================================================================
describe('Dimension 2: Passive Metadata & Scene Integration', () => {
it('verifies skill module properties and zero active costs', () => {
expect(skillModule.skillId).toBe(63)
expect(skillModule.name).toBe('Lightning Mastery')
expect(skillModule.charClass).toBe('sor')
expect(skillModule.srvStFunc).toBe(0)
expect(skillModule.srvDoFunc).toBe(0)
})
it('verifies scene query interfaces: mana cost = 0, range = 0, damage = {min: 0, max: 0}', () => {
expect(getSkillMana(63)).toBe(0)
expect(getSkillRange(63)).toBe(0)
expect(calculateSkillDamage(63, 1)).toEqual({ min: 0, max: 0 })
expect(calculateSkillDamage(63, 20)).toEqual({ min: 0, max: 0 })
expect(calculateSkillDamage('63', 20)).toEqual({ min: 0, max: 0 })
expect(calculateSkillDamage('lightningmastery', 20)).toEqual({ min: 0, max: 0 })
})
it('evaluates via skillModule.evaluate with 0 mana and passive radius reflecting bonus', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(63)!
expect(skillRec).toBeDefined()
const stats20 = new UnitStatList()
stats20.setBaseSkillLevel(63, 20)
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
expect(r20.manaCost).toBe(0)
expect(r20.manaCost256).toBe(0)
expect(r20.minPhysDmg).toBe(0)
expect(r20.maxPhysDmg).toBe(0)
expect(r20.minElemDmg256).toBe(0)
expect(r20.maxElemDmg256).toBe(0)
expect(r20.durationFrames).toBe(0)
expect(r20.cooldownFrames).toBe(0)
expect(r20.radiusSubtiles).toBe(278) // 50 + 19 * 12 = 278
})
})
// =========================================================================
// Dimension 3: Exact 1.13c Formula Scaling
// =========================================================================
describe('Dimension 3: Exact 1.13c Formula Scaling', () => {
it('calculates authentic 1.13c bonus percentages: 50 + (slvl - 1) * 12', () => {
expect(calculateLightningMasteryBonusPct(1)).toBe(50) // Level 1: +50%
expect(calculateLightningMasteryBonusPct(2)).toBe(62) // Level 2: +62%
expect(calculateLightningMasteryBonusPct(5)).toBe(98) // Level 5: +98%
expect(calculateLightningMasteryBonusPct(10)).toBe(158) // Level 10: +158%
expect(calculateLightningMasteryBonusPct(15)).toBe(218) // Level 15: +218%
expect(calculateLightningMasteryBonusPct(20)).toBe(278) // Level 20: +278%
expect(calculateLightningMasteryBonusPct(30)).toBe(398) // Level 30: +398%
expect(calculateLightningMasteryBonusPct(40)).toBe(518) // Level 40: +518%
})
it('safely clamps non-positive slvl to 0% bonus', () => {
expect(calculateLightningMasteryBonusPct(0)).toBe(0)
expect(calculateLightningMasteryBonusPct(-1)).toBe(0)
expect(calculateLightningMasteryBonusPct(-10)).toBe(0)
})
})
// =========================================================================
// Dimension 4: Multiplicative Synergy Scaling across all 5 Lightning Skills
// =========================================================================
describe('Dimension 4: Multiplicative Synergy Scaling across all 5 Lightning Skills', () => {
it('proves multiplicative scaling on Charged Bolt (Skill 38)', () => {
// Level 20 base: 25 - 27
const base = calculateChargedBoltDamage(20)
expect(base).toEqual({ min: 25, max: 27 })
// Synergy only (+120% from 20 hard points in Lightning):
// min: floor(25 * 2.2) = 55, max: floor(27 * 2.2) = 59
const synOnly = calculateChargedBoltDamage(20, { lightningPoints: 20 })
expect(synOnly).toEqual({ min: 55, max: 59 })
// Mastery only (+278% from Level 20 Lightning Mastery):
// min: floor(25 * 3.78) = 94, max: floor(27 * 3.78) = 102
const masteryOnly = calculateChargedBoltDamage(20, undefined, 20)
expect(masteryOnly).toEqual({ min: 94, max: 102 })
// BOTH Synergy (+120%) and Mastery (+278%):
// Multiplicative: Base * 2.20 * 3.78 = Base * 8.316
// min: floor(25 * 8.316) = 207
// max: floor(27 * 8.316) = 224
const both = calculateChargedBoltDamage(20, { lightningPoints: 20, lightningMastery: 20 })
expect(both).toEqual({ min: 207, max: 224 })
// Invariant check: Multiplicative is far higher than additive (25 * (1 + 1.20 + 2.78) = 25 * 4.98 = 124.5 -> 124)
expect(both.min).toBeGreaterThan(124)
expect(both.max).toBeGreaterThan(134)
})
it('proves multiplicative scaling on Lightning (Skill 49)', () => {
// Level 20 base: 1 - 272
const base = calculateLightningDamage(20)
expect(base).toEqual({ min: 1, max: 272 })
// Synergy only (+160% from 20 hard points in Chain Lightning):
// min: floor(1 * 2.6) = 2, max: floor(272 * 2.6) = 707
const synOnly = calculateLightningDamage(20, { chainLightningPoints: 20 })
expect(synOnly).toEqual({ min: 2, max: 707 })
// Mastery only (+278% from Level 20 Lightning Mastery):
// min: floor(1 * 3.78) = 3, max: floor(272 * 3.78) = 1028
const masteryOnly = calculateLightningDamage(20, undefined, 20)
expect(masteryOnly).toEqual({ min: 3, max: 1028 })
// BOTH: floor(272 * 2.60 * 3.78) = 2673
const both = calculateLightningDamage(20, { chainLightningPoints: 20, lightningMastery: 20 })
expect(both).toEqual({ min: 9, max: 2673 })
expect(both.max).toBeGreaterThan(272 * (1 + 1.60 + 2.78)) // 272 * 5.38 = 1463
})
it('proves multiplicative scaling on Chain Lightning (Skill 53)', () => {
// Level 20 base: 1 - 281
const base = calculateChainLightningDamage(20)
expect(base).toEqual({ min: 1, max: 281 })
// Synergy only (+80% from 20 hard points in Lightning):
// min: floor(1 * 1.8) = 1, max: floor(281 * 1.8) = 505
const synOnly = calculateChainLightningDamage(20, { lightningPoints: 20 })
expect(synOnly).toEqual({ min: 1, max: 505 })
// Mastery only (+278%):
// min: floor(1 * 3.78) = 3, max: floor(281 * 3.78) = 1062
const masteryOnly = calculateChainLightningDamage(20, undefined, 20)
expect(masteryOnly).toEqual({ min: 3, max: 1062 })
// BOTH: floor(281 * 1.80 * 3.78) = 1911
const both = calculateChainLightningDamage(20, { lightningPoints: 20, lightningMastery: 20 })
expect(both).toEqual({ min: 6, max: 1911 })
})
it('proves scaling on Nova (Skill 48)', () => {
// Level 20 base: 131 - 188
const base = calculateNovaDamage(20)
expect(base).toEqual({ min: 131, max: 188 })
// With LM 20 (+278%):
// min: floor(131 * 3.78) = 495
// max: floor(188 * 3.78) = 710
const withLm20 = calculateNovaDamage(20, { lightningMastery: 20 })
expect(withLm20).toEqual({ min: 495, max: 710 })
})
it('proves scaling on Thunder Storm (Skill 57)', () => {
// Level 20 base: 195 - 294
const base = calculateThunderStormDamage(20)
expect(base).toEqual({ min: 195, max: 294 })
// With LM 20 (+278%):
// min: 195 * 3.78 = 737.1 -> 737
// max: 294 * 3.78 = 1111.32 -> 1111
const withLm20 = calculateThunderStormDamage(20, { lightningMastery: 20 })
expect(withLm20).toEqual({ min: 737, max: 1111 })
})
})
// =========================================================================
// Dimension 5: StatList & Combat Pipeline Integration
// =========================================================================
describe('Dimension 5: StatList & Combat Pipeline Integration', () => {
it('accrues passive_ltng_mastery stat dynamically from skill level', () => {
const stats = new UnitStatList()
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(0)
expect(stats.getAccruedStat('passive_lightning_mastery')).toBe(0)
stats.setBaseSkillLevel(63, 1)
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(50)
expect(stats.getAccruedStat('passive_lightning_mastery')).toBe(50)
stats.setBaseSkillLevel(63, 20)
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(278)
expect(stats.getAccruedStat('passive_lightning_mastery')).toBe(278)
// +5 allskills boosts effective skill level to 25 -> 50 + 24 * 12 = 338%
stats.addStat('item_allskills', 5)
expect(stats.getAccruedStat('passive_ltng_mastery')).toBe(338)
expect(stats.getAccruedStat('passive_lightning_mastery')).toBe(338)
})
it('applies passive_ltng_mastery inside executeSUnitDmg combat pipeline', async () => {
const registry = await getSharedDataRegistry()
const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const defenderStats = new UnitStatList(registry, { maxhp: 2000 * FIXED_ONE, hitpoints: 2000 * FIXED_ONE })
// Attacker has Level 20 Lightning Mastery (+278%)
attackerStats.setBaseSkillLevel(63, 20)
const attackerBus = new StateBus(attackerStats, registry)
const defenderBus = new StateBus(defenderStats, registry)
const attackerCtx: CombatUnitContext = {
id: 'sorceress-1',
name: 'Sorceress',
statList: attackerStats,
stateBus: attackerBus,
}
const defenderCtx: CombatUnitContext = {
id: 'target-dummy-1',
name: 'Target Dummy',
statList: defenderStats,
stateBus: defenderBus,
}
// Deal 100 raw lightning damage
const res = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 49, // Lightning
attackKind: 'spell',
autoHit: true,
elemType: 'ltng',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
})
// Raw 100 scaled by +278% mastery: 100 * 3.78 = 378 total damage!
expect(res.elemDamage256).toBe(378 * FIXED_ONE)
expect(res.totalDamage).toBe(378)
expect(defenderStats.getHp()).toBe(2000 - 378)
})
})
// =========================================================================
// Dimension 6: Edge Cases & Boundary Conditions
// =========================================================================
describe('Dimension 6: Edge Cases & Boundary Conditions', () => {
it('does NOT alter non-lightning damage in combat pipeline', async () => {
const registry = await getSharedDataRegistry()
const attackerStats = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const defenderStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE })
// Attacker has Level 20 Lightning Mastery (+278%)
attackerStats.setBaseSkillLevel(63, 20)
const attackerBus = new StateBus(attackerStats, registry)
const defenderBus = new StateBus(defenderStats, registry)
const attackerCtx: CombatUnitContext = { id: 'a1', name: 'A', statList: attackerStats, stateBus: attackerBus }
const defenderCtx: CombatUnitContext = { id: 'd1', name: 'D', statList: defenderStats, stateBus: defenderBus }
// Deal 100 Fire damage
const resFire = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 47, // Fire Ball
attackKind: 'spell',
autoHit: true,
elemType: 'fire',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
})
// Lightning Mastery does NOT boost Fire damage: remains 100
expect(resFire.totalDamage).toBe(100)
// Deal 100 Physical damage
const resPhys = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 1,
attackKind: 'spell',
autoHit: true,
flatPhysMin256: 100 * FIXED_ONE,
flatPhysMax256: 100 * FIXED_ONE,
})
// Lightning Mastery does NOT boost Physical damage: remains 100
expect(resPhys.totalDamage).toBe(100)
})
it('enforces that Lightning Mastery itself receives 0 synergies in 1.13c', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(63)!
const statsWithAllSkills = new UnitStatList()
statsWithAllSkills.setBaseSkillLevel(63, 20)
statsWithAllSkills.setBaseSkillLevel(49, 20) // 20 points in Lightning
statsWithAllSkills.setBaseSkillLevel(53, 20) // 20 points in Chain Lightning
const evalResult = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsWithAllSkills,
})
expect(evalResult.synergyBonusPct).toBe(0)
})
})
// =========================================================================
// Dimension 7: Invariants & WorldArena Integration
// =========================================================================
describe('Dimension 7: Invariants & WorldArena Integration', () => {
it('executes runtime skill effect in WorldArena (castCount, action frame, and outcome)', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: 'sor',
skillId: 63,
slvl: 20,
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(63)
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)
})
})
})