377 lines
16 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|