389 lines
18 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|