diablo2-web/tests/skills/sor/skill-058-energy-shield.tes...

377 lines
16 KiB
TypeScript

/**
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #058: Energy Shield (SOR)
* Gitea Tracking Issue: #212
*
* 7-Dimensional Verification Coverage:
* D1: 1.13c Data Table Parsing & Schema Integrity (Skills.txt row 58, mana 5, manashift 8, castoverlay 'light_cast_2')
* D2: Visual Assets & Metadata Integrity (Looping body overlay 'energyshield', hit overlay 'energyshieldhit0')
* D3: Absorption Curve & 95% Hard Cap (EMin: 20, +5% lvl 2-8, +2% lvl 9-16, +1% lvl 17+, cap 95% at lvl 40)
* D4: Telekinesis Synergy Mechanics (Hard points blvl only, ratio (32 - min(20, blvl))/16 from 2.0 down to 0.75)
* D5: Absorption Before Resistances & Bypass Rules (Poison and Open Wounds bypass directly to HP)
* D6: Mana Depletion, Shield Collapse & Edge Cases (Partial absorb, collapse on 0 mana, remainder spills to HP)
* D7: Invariants (BATCH1_SKILLS length strictly 38, zero runtime MPQ queries, WorldArena cast)
*/
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-058-energy-shield.ts'
import {
calculateEnergyShieldDuration,
calculateEnergyShieldAbsorbPct,
calculateEnergyShieldManaRatio,
getSkillCastOverlay,
calculateSkillDamage,
BATCH1_SKILLS,
} from '../../../src/game/skills.ts'
import { getSkillRange, getSkillMana } from '../../../src/scene/act-scene.ts'
import {
ENERGY_SHIELD_OVERLAY_META,
ENERGY_SHIELD_HIT_OVERLAY_META,
LIGHT_CAST_2_META,
OVERLAY_METAS,
} from '../../../src/render/overlays-meta.ts'
describe('[Skill #058] Energy Shield (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #212)', () => {
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 58 (Energy Shield)', () => {
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 eMinLev1Idx = headers.indexOf('EMinLev1')
const eMinLev2Idx = headers.indexOf('EMinLev2')
const eMinLev3Idx = headers.indexOf('EMinLev3')
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 param5Idx = headers.indexOf('Param5') !== -1 ? headers.indexOf('Param5') : headers.indexOf('param5')
const auraStateIdx = headers.indexOf('aurastate')
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '58')
expect(row).toBeDefined()
expect(row![skillIdx]?.trim()).toBe('Energy Shield')
expect(row![charClassIdx]?.trim()).toBe('sor')
expect(Number(row![manaIdx])).toBe(5)
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(20) // Base 20% absorb
expect(Number(row![eMinLev1Idx])).toBe(5) // +5% (lvl 2-8)
expect(Number(row![eMinLev2Idx])).toBe(2) // +2% (lvl 9-16)
expect(Number(row![eMinLev3Idx])).toBe(1) // +1% (lvl 17+)
expect(Number(row![param1Idx])).toBe(3600) // Base duration frames (144s)
expect(Number(row![param2Idx])).toBe(1500) // Duration frames per level (60s)
expect(Number(row![param5Idx])).toBe(32) // Telekinesis numerator (32/16 = 2.0)
expect(row![auraStateIdx]?.trim()).toBe('energyshield')
})
})
// =========================================================================
// Dimension 2: Visual Assets & Metadata Integrity
// =========================================================================
describe('Dimension 2: Visual Assets & Metadata Integrity', () => {
it('verifies looping body overlay energyshield (ManaShieldBall)', () => {
expect(OVERLAY_METAS['energyshield']).toBeDefined()
expect(OVERLAY_METAS['energyshield']).toBe(ENERGY_SHIELD_OVERLAY_META)
expect(ENERGY_SHIELD_OVERLAY_META.name).toBe('energyshield')
})
it('verifies hit overlay energyshieldhit0 (ManaShieldDir00)', () => {
expect(OVERLAY_METAS['energyshieldhit0']).toBeDefined()
expect(OVERLAY_METAS['energyshieldhit0']).toBe(ENERGY_SHIELD_HIT_OVERLAY_META)
expect(ENERGY_SHIELD_HIT_OVERLAY_META.name).toBe('energyshieldhit0')
})
it('verifies cast overlay light_cast_2', () => {
expect(getSkillCastOverlay(58)).toBe('light_cast_2')
expect(OVERLAY_METAS['light_cast_2']).toBe(LIGHT_CAST_2_META)
})
})
// =========================================================================
// Dimension 3: Absorption Curve & 95% Hard Cap
// =========================================================================
describe('Dimension 3: Absorption Curve & 95% Hard Cap', () => {
it('calculates duration: 3600 + (slvl - 1) * 1500 frames', () => {
expect(calculateEnergyShieldDuration(1)).toBe(3600) // 144.00s
expect(calculateEnergyShieldDuration(2)).toBe(5100) // 204.00s
expect(calculateEnergyShieldDuration(10)).toBe(17100) // 684.00s
expect(calculateEnergyShieldDuration(20)).toBe(32100) // 1284.00s
expect(calculateEnergyShieldDuration(40)).toBe(62100) // 2484.00s
})
it('calculates absorption percentage across 1.13c bands with 95% hard cap', () => {
// Level 1: 20%
expect(calculateEnergyShieldAbsorbPct(1)).toBe(20)
// Level 2..8: +5%/lvl
expect(calculateEnergyShieldAbsorbPct(2)).toBe(25)
expect(calculateEnergyShieldAbsorbPct(5)).toBe(40)
expect(calculateEnergyShieldAbsorbPct(8)).toBe(55)
// Level 9..16: +2%/lvl
expect(calculateEnergyShieldAbsorbPct(9)).toBe(57)
expect(calculateEnergyShieldAbsorbPct(12)).toBe(63)
expect(calculateEnergyShieldAbsorbPct(16)).toBe(71)
// Level 17+: +1%/lvl
expect(calculateEnergyShieldAbsorbPct(17)).toBe(72)
expect(calculateEnergyShieldAbsorbPct(20)).toBe(75)
// Cap at 95% (at level 40)
expect(calculateEnergyShieldAbsorbPct(40)).toBe(95)
expect(calculateEnergyShieldAbsorbPct(50)).toBe(95)
expect(calculateEnergyShieldAbsorbPct(99)).toBe(95)
})
})
// =========================================================================
// Dimension 4: Telekinesis Synergy Mechanics
// =========================================================================
describe('Dimension 4: Telekinesis Synergy Mechanics', () => {
it('calculates exact mana-to-damage ratio: (32 - min(20, blvl)) / 16', () => {
expect(calculateEnergyShieldManaRatio(0)).toBe(2.0) // 32/16 = 2.00
expect(calculateEnergyShieldManaRatio(4)).toBe(1.75) // 28/16 = 1.75
expect(calculateEnergyShieldManaRatio(8)).toBe(1.5) // 24/16 = 1.50
expect(calculateEnergyShieldManaRatio(12)).toBe(1.25) // 20/16 = 1.25
expect(calculateEnergyShieldManaRatio(16)).toBe(1.0) // 16/16 = 1.00
expect(calculateEnergyShieldManaRatio(20)).toBe(0.75) // 12/16 = 0.75
// Clamped at 20 hard points: Level 25 gives 0.75 floor
expect(calculateEnergyShieldManaRatio(25)).toBe(0.75)
expect(calculateEnergyShieldManaRatio(-5)).toBe(2.0)
})
it('enforces hard points only (blvl) in StateBus; soft points have 0 effect', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 1000 * FIXED_ONE,
maxmana: 1000 * FIXED_ONE,
mana: 1000 * FIXED_ONE,
})
// 16 hard points in Telekinesis (43) => 1.0 ratio
stats.setBaseSkillLevel(43, 16)
// +20 all skills should NOT improve the ratio beyond 1.0!
stats.addStat('item_allskills', 20)
const bus = new StateBus(stats, registry)
bus.applyState({
stateNameOrId: 'energyshield',
sourceSkillId: 58,
slvl: 20, // 75% absorb
})
const out = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 200 * FIXED_ONE,
incomingElem256: 0,
})
// 75% of 200 = 150 damage absorbed.
expect(out.absorbedPhys256).toBe(150 * FIXED_ONE)
// At 1.0 ratio, mana drained is exactly 150 * FIXED_ONE!
expect(out.manaDrained256).toBe(150 * FIXED_ONE)
expect(stats.getMana()).toBe(850)
})
})
// =========================================================================
// Dimension 5: Absorption Before Resistances & Bypass Rules
// =========================================================================
describe('Dimension 5: Absorption Before Resistances & Bypass Rules', () => {
it('absorbs raw damage BEFORE player resistances are applied in executeSUnitDmg', 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,
maxmana: 1000 * FIXED_ONE,
mana: 1000 * FIXED_ONE,
fireresist: 75, // 75% fire resistance
})
defenderStats.setBaseSkillLevel(43, 16) // 1.0 ratio
const attackerBus = new StateBus(attackerStats, registry)
const defenderBus = new StateBus(defenderStats, registry)
defenderBus.applyState({
stateNameOrId: 'energyshield',
sourceSkillId: 58,
slvl: 20, // 75% absorb
})
const attackerCtx: CombatUnitContext = {
id: 'attacker-1',
name: 'Attacker',
statList: attackerStats,
stateBus: attackerBus,
}
const defenderCtx: CombatUnitContext = {
id: 'defender-1',
name: 'Sorceress',
statList: defenderStats,
stateBus: defenderBus,
}
// Attack: 400 Fire Damage (raw)
const res = executeSUnitDmg(attackerCtx, defenderCtx, {
skillId: 47, // Fire Ball
attackKind: 'spell',
autoHit: true,
elemType: 'fire',
elemMin256: 400 * FIXED_ONE,
elemMax256: 400 * FIXED_ONE,
})
// Ground truth logic:
// 1. Raw Fire = 400.
// 2. ES absorbs 75% of raw = 300 damage.
// 3. Mana drained: 300 * 1.0 = 300 mana.
// 4. Remaining raw fire = 100.
// 5. 75% Fire Resistance reduces remaining 100 to 25!
// 6. Net HP lost = 25.
expect(defenderStats.getMana()).toBe(700) // 1000 - 300
expect(res.totalDamage).toBe(25)
expect(defenderStats.getHp()).toBe(975)
})
it('enforces Poison bypasses Energy Shield directly to HP', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 1000 * FIXED_ONE,
maxmana: 1000 * FIXED_ONE,
mana: 1000 * FIXED_ONE,
})
const bus = new StateBus(stats, registry)
bus.applyState({
stateNameOrId: 'energyshield',
sourceSkillId: 58,
slvl: 20,
})
const out = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 0,
incomingElem256: 200 * FIXED_ONE,
elemType: 'pois',
isPoison: true,
})
// Poison strictly bypasses: 0 absorbed, 0 mana drained!
expect(out.absorbedElem256).toBe(0)
expect(out.manaDrained256).toBe(0)
expect(stats.getMana()).toBe(1000)
})
it('enforces Open Wounds bypasses Energy Shield directly to HP', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 1000 * FIXED_ONE,
maxmana: 1000 * FIXED_ONE,
mana: 1000 * FIXED_ONE,
})
const bus = new StateBus(stats, registry)
bus.applyState({
stateNameOrId: 'energyshield',
sourceSkillId: 58,
slvl: 20,
})
const out = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 200 * FIXED_ONE,
incomingElem256: 0,
isOpenWounds: true,
})
// Open Wounds strictly bypasses: 0 absorbed, 0 mana drained!
expect(out.absorbedPhys256).toBe(0)
expect(out.manaDrained256).toBe(0)
expect(stats.getMana()).toBe(1000)
})
})
// =========================================================================
// Dimension 6: Mana Depletion, Shield Collapse & Edge Cases
// =========================================================================
describe('Dimension 6: Mana Depletion, Shield Collapse & Edge Cases', () => {
it('collapses shield on insufficient mana, draining mana to 0 and spilling remainder to HP', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 1000 * FIXED_ONE,
maxmana: 100 * FIXED_ONE,
mana: 50 * FIXED_ONE, // Only 50 mana available!
})
stats.setBaseSkillLevel(43, 0) // Level 0 TK: ratio = 2.0 (needs 2 mana per 1 HP absorbed)
const bus = new StateBus(stats, registry)
bus.applyState({
stateNameOrId: 'energyshield',
sourceSkillId: 58,
slvl: 20, // 75% absorb
})
// Incoming: 200 physical damage.
// Desired absorb: 150 damage -> would require 300 mana!
// But only 50 mana available: actual absorbed = 50 / 2.0 = 25 damage.
const out = bus.triggerReactiveEvents({
event: 'absorbdamage',
incomingPhys256: 200 * FIXED_ONE,
incomingElem256: 0,
})
expect(out.absorbedPhys256).toBe(25 * FIXED_ONE)
expect(out.manaDrained256).toBe(50 * FIXED_ONE)
expect(stats.getMana()).toBe(0)
// Shield collapsed: state removed!
expect(bus.hasState('energyshield')).toBe(false)
expect(bus.hasState(60)).toBe(false)
})
it('verifies mana cost is constant 5 and range is 0', () => {
expect(getSkillMana(58)).toBe(5)
expect(getSkillRange(58)).toBe(0)
expect(calculateSkillDamage(58, 20)).toEqual({ min: 0, max: 0 })
})
})
// =========================================================================
// 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: 58,
slvl: 20,
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(58)
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)
})
})
})