diablo2-web/tests/skills/sor/skill-043-telekinesis.test.ts

388 lines
18 KiB
TypeScript

/**
* 1.13c Numerical & Runtime Verification Suite — Skill #043: Telekinesis (SOR)
* Gitea Tracking Issue: #197
*
* 7-Dimensional Verification Suite for authentic Diablo II v1.13c Telekinesis:
* - Dimension 1: 1.13c Data Table Parsing & Canonical Definition
* - Dimension 2: Visual Assets & Metadata Integrity (Zero Runtime MPQ)
* - Dimension 3: Range Limit & Remote Object Interaction (Param1 = 25 Subtiles)
* - Dimension 4: Remote Ground Item Pickup Whitelist & Equipment Restriction
* - Dimension 5: Combat Hit, Damage, Knockback & Stun (Param2 = 35)
* - Dimension 6: Hard Synergy to Energy Shield & Mana / Cooldown Lifecycle
* - Dimension 7: WorldArena & Runtime Cast Integration
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
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-043-telekinesis.ts'
import {
BATCH1_SKILLS,
getSkillManaCost,
getSkillCooldownTicks,
calculateSkillDamage,
getSkillCastOverlay,
SORCERESS_SKILL_CAST_OVERLAYS,
} from '../../../src/game/skills.ts'
import {
calculateEnergyShieldManaRatio,
calculateTelekinesisDamage,
isTelekinesisItemAllowed,
isTelekinesisObjectAllowed,
} from '../../../src/game/engine/missile-engine.ts'
import {
LIGHT_CAST_2_META,
OVERLAY_METAS,
} from '../../../src/render/overlays-meta.ts'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
describe('[Skill #043] Telekinesis (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #197)', () => {
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 Definition
// =========================================================================
describe('Dimension 1: 1.13c Data Table Parsing & Canonical Definition', () => {
it('parses authentic Skills.txt row 43 (Telekinesis)', () => {
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 srvStFuncIdx = headers.indexOf('srvstfunc')
const srvDoFuncIdx = headers.indexOf('srvdofunc')
const eMinIdx = headers.indexOf('EMin')
const eMaxIdx = headers.indexOf('EMax')
const eLev1Idx = headers.indexOf('EMinLev1') !== -1 ? headers.indexOf('EMinLev1') : headers.indexOf('ELev1')
const hitShiftIdx = headers.indexOf('HitShift') !== -1 ? headers.indexOf('HitShift') : headers.indexOf('hitshift')
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 row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '43')
expect(row).toBeDefined()
expect(row![skillIdx]?.trim()).toBe('Telekinesis')
expect(row![charClassIdx]?.trim()).toBe('sor')
expect(Number(row![manaIdx])).toBe(7)
expect(Number(row![lvlManaIdx])).toBe(0)
expect(Number(row![manaShiftIdx])).toBe(8)
expect(row![castOverlayIdx]?.trim()).toBe('light_cast_2')
expect(Number(row![srvStFuncIdx])).toBe(12)
expect(Number(row![srvDoFuncIdx])).toBe(21)
expect(Number(row![eMinIdx])).toBe(1)
expect(Number(row![eMaxIdx])).toBe(2)
expect(Number(row![eLev1Idx])).toBe(1)
expect(Number(row![hitShiftIdx])).toBe(8)
expect(Number(row![param1Idx])).toBe(25) // Range in subtiles
expect(Number(row![param2Idx])).toBe(35) // 35% knockback, 35 stun ticks
})
})
// =========================================================================
// Dimension 2: Visual Assets & Metadata Integrity (Zero Runtime MPQ)
// =========================================================================
describe('Dimension 2: Visual Assets & Metadata Integrity', () => {
it('verifies baked overlay atlas sprite sheet files exist with non-zero size', () => {
const overlayPng = path.resolve(__dirname, '../../../public/overlays/light_cast_2.png')
const overlayJson = path.resolve(__dirname, '../../../public/overlays/light_cast_2.json')
expect(fs.existsSync(overlayPng)).toBe(true)
expect(fs.statSync(overlayPng).size).toBeGreaterThan(1000)
expect(fs.existsSync(overlayJson)).toBe(true)
expect(fs.statSync(overlayJson).size).toBeGreaterThan(100)
})
it('verifies LIGHT_CAST_2_META metadata and registry registration', () => {
expect(LIGHT_CAST_2_META.name).toBe('light_cast_2')
expect(OVERLAY_METAS['light_cast_2']).toBe(LIGHT_CAST_2_META)
})
it('verifies cast overlay mapping and metadata for Telekinesis (43)', () => {
expect(SORCERESS_SKILL_CAST_OVERLAYS[43]).toBe('light_cast_2')
expect(getSkillCastOverlay(43)).toBe('light_cast_2')
})
it('verifies skillModule registration and metadata for Telekinesis (43)', () => {
expect(skillModule.skillId).toBe(43)
expect(skillModule.name).toBe('Telekinesis')
expect(skillModule.charClass).toBe('sor')
expect(skillModule.srvStFunc).toBe(12)
expect(skillModule.srvDoFunc).toBe(21)
expect(getSkillManaCost(43, 1)).toBe(7)
expect(getSkillCooldownTicks(43)).toBe(0)
})
})
// =========================================================================
// Dimension 3: Range Limit & Remote Object Interaction (Param1 = 25 Subtiles)
// =========================================================================
describe('Dimension 3: Range Limit & Remote Object Interaction', () => {
it('enforces 600px range gating for remote interaction', () => {
const maxRange = 600
const origin = { x: 1000, y: 1000 }
const inRangeTarget = { x: 1400, y: 1300 } // dist: sqrt(400^2 + 300^2) = 500 <= 600
const outOfRangeTarget = { x: 1600, y: 1500 } // dist: sqrt(600^2 + 500^2) = 781 > 600
const distIn = Math.hypot(inRangeTarget.x - origin.x, inRangeTarget.y - origin.y)
const distOut = Math.hypot(outOfRangeTarget.x - origin.x, outOfRangeTarget.y - origin.y)
expect(distIn).toBeLessThanOrEqual(maxRange)
expect(distOut).toBeGreaterThan(maxRange)
})
it('isTelekinesisObjectAllowed permits authentic interactive objects', () => {
expect(isTelekinesisObjectAllowed('chest')).toBe(true)
expect(isTelekinesisObjectAllowed('locked_chest')).toBe(true)
expect(isTelekinesisObjectAllowed('stash')).toBe(true)
expect(isTelekinesisObjectAllowed('waypoint')).toBe(true)
expect(isTelekinesisObjectAllowed('portal')).toBe(true)
expect(isTelekinesisObjectAllowed('shrine')).toBe(true)
expect(isTelekinesisObjectAllowed('door')).toBe(true)
expect(isTelekinesisObjectAllowed('well')).toBe(true)
expect(isTelekinesisObjectAllowed('urn')).toBe(true)
expect(isTelekinesisObjectAllowed('sarcophagus')).toBe(true)
})
it('isTelekinesisObjectAllowed rejects non-interactive entities', () => {
expect(isTelekinesisObjectAllowed('npc')).toBe(false)
expect(isTelekinesisObjectAllowed('trap')).toBe(false)
expect(isTelekinesisObjectAllowed('unknown_gizmo')).toBe(false)
expect(isTelekinesisObjectAllowed('')).toBe(false)
expect(isTelekinesisObjectAllowed(null as unknown as string)).toBe(false)
})
})
// =========================================================================
// Dimension 4: Remote Ground Item Pickup Whitelist & Equipment Restriction
// =========================================================================
describe('Dimension 4: Remote Ground Item Pickup Whitelist & Equipment Restriction', () => {
it('allows remote pickup of potions, scrolls, gold, ammo, and keys', () => {
// Potions
expect(isTelekinesisItemAllowed({ type: 'potion' })).toBe(true)
expect(isTelekinesisItemAllowed({ type: 'healing_potion' })).toBe(true)
expect(isTelekinesisItemAllowed({ type: 'mana_potion' })).toBe(true)
expect(isTelekinesisItemAllowed({ type: 'rejuv_potion' })).toBe(true)
expect(isTelekinesisItemAllowed({ code: 'hp1' })).toBe(true)
expect(isTelekinesisItemAllowed({ code: 'mp3' })).toBe(true)
expect(isTelekinesisItemAllowed({ code: 'rvs' })).toBe(true)
expect(isTelekinesisItemAllowed({ code: 'rvl' })).toBe(true)
// Scrolls
expect(isTelekinesisItemAllowed({ type: 'scroll' })).toBe(true)
expect(isTelekinesisItemAllowed({ code: 'tsc' })).toBe(true)
expect(isTelekinesisItemAllowed({ code: 'isc' })).toBe(true)
// Gold
expect(isTelekinesisItemAllowed({ type: 'gold' })).toBe(true)
expect(isTelekinesisItemAllowed({ code: 'gld' })).toBe(true)
// Ammo
expect(isTelekinesisItemAllowed({ type: 'ammo' })).toBe(true)
expect(isTelekinesisItemAllowed({ type: 'arrows' })).toBe(true)
expect(isTelekinesisItemAllowed({ type: 'bolts' })).toBe(true)
expect(isTelekinesisItemAllowed({ code: 'aqv' })).toBe(true)
expect(isTelekinesisItemAllowed({ code: 'cqv' })).toBe(true)
// Keys
expect(isTelekinesisItemAllowed({ type: 'key' })).toBe(true)
expect(isTelekinesisItemAllowed({ code: 'key' })).toBe(true)
})
it('rejects remote pickup of equipment (weapons, armor, jewelry, charms)', () => {
// Weapons
expect(isTelekinesisItemAllowed({ type: 'weapon' })).toBe(false)
expect(isTelekinesisItemAllowed({ type: 'sword' })).toBe(false)
expect(isTelekinesisItemAllowed({ type: 'bow' })).toBe(false)
expect(isTelekinesisItemAllowed({ code: 'ssd' })).toBe(false)
// Armor
expect(isTelekinesisItemAllowed({ type: 'armor' })).toBe(false)
expect(isTelekinesisItemAllowed({ type: 'body_armor' })).toBe(false)
expect(isTelekinesisItemAllowed({ type: 'helm' })).toBe(false)
expect(isTelekinesisItemAllowed({ type: 'shield' })).toBe(false)
expect(isTelekinesisItemAllowed({ type: 'gloves' })).toBe(false)
expect(isTelekinesisItemAllowed({ type: 'boots' })).toBe(false)
expect(isTelekinesisItemAllowed({ type: 'belt' })).toBe(false)
// Jewelry & Charms
expect(isTelekinesisItemAllowed({ type: 'ring' })).toBe(false)
expect(isTelekinesisItemAllowed({ type: 'amulet' })).toBe(false)
expect(isTelekinesisItemAllowed({ type: 'charm' })).toBe(false)
// Invalid inputs
expect(isTelekinesisItemAllowed(null)).toBe(false)
expect(isTelekinesisItemAllowed(undefined)).toBe(false)
expect(isTelekinesisItemAllowed({})).toBe(false)
})
})
// =========================================================================
// Dimension 5: Combat Hit, Damage, Knockback & Stun (Param2 = 35)
// =========================================================================
describe('Dimension 5: Combat Hit, Damage, Knockback & Stun', () => {
it('evaluates exact 1.13c Telekinesis damage scaling: slvl to slvl + 1', () => {
expect(calculateTelekinesisDamage(1)).toEqual({ min: 1, max: 2 })
expect(calculateTelekinesisDamage(2)).toEqual({ min: 2, max: 3 })
expect(calculateTelekinesisDamage(5)).toEqual({ min: 5, max: 6 })
expect(calculateTelekinesisDamage(10)).toEqual({ min: 10, max: 11 })
expect(calculateTelekinesisDamage(20)).toEqual({ min: 20, max: 21 })
expect(calculateTelekinesisDamage(30)).toEqual({ min: 30, max: 31 })
})
it('integrates with calculateSkillDamage helper', () => {
const dmg1 = calculateSkillDamage(43, 1)
expect(dmg1.min).toBe(1)
expect(dmg1.max).toBe(2)
const dmg10 = calculateSkillDamage(43, 10)
expect(dmg10.min).toBe(10)
expect(dmg10.max).toBe(11)
const dmg20 = calculateSkillDamage(43, 20)
expect(dmg20.min).toBe(20)
expect(dmg20.max).toBe(21)
})
it('verifies 35% knockback chance and 35 stun ticks from Param2', () => {
const param2 = 35
const knockbackChance = param2 / 100 // 0.35
const stunTicks = param2 // 35 ticks = 1.4 seconds at 25 fps
expect(knockbackChance).toBe(0.35)
expect(stunTicks).toBe(35)
})
})
// =========================================================================
// Dimension 6: Hard Synergy to Energy Shield & Mana / Cooldown Lifecycle
// =========================================================================
describe('Dimension 6: Hard Synergy to Energy Shield & Mana / Cooldown Lifecycle', () => {
it('calculates Energy Shield mana ratio reduction: (32 - min(20, tkBlvl)) / 16', () => {
expect(calculateEnergyShieldManaRatio(0)).toBe(2.0) // 32/16 = 200% mana consumed
expect(calculateEnergyShieldManaRatio(1)).toBe(31 / 16) // 1.9375
expect(calculateEnergyShieldManaRatio(4)).toBe(28 / 16) // 1.75
expect(calculateEnergyShieldManaRatio(8)).toBe(24 / 16) // 1.5
expect(calculateEnergyShieldManaRatio(12)).toBe(20 / 16) // 1.25
expect(calculateEnergyShieldManaRatio(16)).toBe(16 / 16) // 1.0 (100% parity)
expect(calculateEnergyShieldManaRatio(20)).toBe(12 / 16) // 0.75 (75% mana consumed)
expect(calculateEnergyShieldManaRatio(25)).toBe(0.75) // capped at 20 hard points
})
it('enforces 1.13c blvl synergy rule (hard points boost synergy; +skills and item charges do NOT)', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(43)!
const statsBase = new UnitStatList()
statsBase.setBaseSkillLevel(43, 20)
const withHardPoints = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsBase,
})
expect(withHardPoints.synergyBonusPct).toBe(0)
// +20 allskills and charged items without hard points must yield 0 synergy
const statsSoftOnly = new UnitStatList()
statsSoftOnly.setBaseSkillLevel(43, 20)
statsSoftOnly.addStat('item_allskills', 20)
const withSoftOnly = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsSoftOnly,
})
expect(withSoftOnly.synergyBonusPct).toBe(0)
})
it('verifies constant mana cost of 7 across all slvls', () => {
expect(getSkillManaCost(43, 1)).toBe(7)
expect(getSkillManaCost(43, 5)).toBe(7)
expect(getSkillManaCost(43, 10)).toBe(7)
expect(getSkillManaCost(43, 20)).toBe(7)
expect(getSkillManaCost(43, 30)).toBe(7)
})
it('verifies getSkillCooldownTicks returns 0 frames', () => {
expect(getSkillCooldownTicks(43)).toBe(0)
})
})
// =========================================================================
// Dimension 7: WorldArena & Runtime Cast Integration
// =========================================================================
describe('Dimension 7: WorldArena & Runtime Cast Integration', () => {
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20 via evaluate', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(43)!
expect(skillRec).toBeDefined()
expect(skillModule.skillId).toBe(43)
expect(skillModule.name).toBe('Telekinesis')
expect(skillModule.charClass).toBe('sor')
const stats1 = new UnitStatList()
stats1.setBaseSkillLevel(43, 1)
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
expect(r1.manaCost).toBe(7)
expect(r1.manaCost256).toBe(1792)
expect(r1.minPhysDmg).toBe(0)
expect(r1.maxPhysDmg).toBe(0)
expect(r1.minElemDmg256).toBe(256)
expect(r1.maxElemDmg256).toBe(512)
const stats10 = new UnitStatList()
stats10.setBaseSkillLevel(43, 10)
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
expect(r10.manaCost).toBe(7)
expect(r10.manaCost256).toBe(1792)
expect(r10.minPhysDmg).toBe(0)
expect(r10.maxPhysDmg).toBe(0)
expect(r10.minElemDmg256).toBe(2560)
expect(r10.maxElemDmg256).toBe(2816)
const stats20 = new UnitStatList()
stats20.setBaseSkillLevel(43, 20)
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
expect(r20.manaCost).toBe(7)
expect(r20.manaCost256).toBe(1792)
expect(r20.minPhysDmg).toBe(0)
expect(r20.maxPhysDmg).toBe(0)
expect(r20.minElemDmg256).toBe(5120)
expect(r20.maxElemDmg256).toBe(5376)
expect(r20.durationFrames).toBe(0)
expect(r20.radiusSubtiles).toBe(25)
expect(r20.petMax).toBe(0)
expect(r20.cooldownFrames).toBe(0)
})
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: 43,
slvl: 20,
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(43)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
})
})