diablo2-web/tests/skills/sor/skill-042-static-field.test.ts

432 lines
19 KiB
TypeScript

/**
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #042: Static Field (SOR)
* Gitea Tracking Issue: #196
*
* 7-Dimensional Verification Coverage:
* D1: 1.13c Data Table Parsing & Ground Truth (Skills.txt row 42, srvdofunc 20, non-projectile)
* D2: Visual Assets & Metadata Integrity (light_cast_1.png/json, lightning.png/json, Additive Blending)
* D3: Instantaneous 360° AoE Geometry & Radius Scaling (4 + slvl yards, 40 px/yard, 200px at lvl 1 to 960px at lvl 20)
* D4: 25% Current HP Cut, Resistance & Immunity Scaling (Cuts 25% of current HP, lightning resist reduces, immunity ignores)
* D5: Line-of-Sight (LOS) Raycast Obstruction (Targets behind solid walls receive 0 damage)
* D6: Difficulty Floor Clamps & Assembly Pre-Check Invariant (D2Game.dll 0x6FC62F13: Normal 1 HP, NM 33%, Hell 50%)
* D7: WorldArena & Runtime Cast Integration (Cast action frame, 9 flat mana, 0 cooldown, cast & hit overlays)
*/
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 } 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-042-static-field.ts'
import {
getSkillManaCost,
getSkillCooldownTicks,
getSkillCastOverlay,
SORCERESS_SKILL_CAST_OVERLAYS,
calculateStaticFieldDamage,
calculateStaticFieldRadiusYards,
calculateStaticFieldRadiusPx,
hasLineOfSight,
executeStaticField,
} from '../../../src/game/skills.ts'
import {
LIGHT_CAST_1_META,
LIGHTNING_OVERLAY_META,
OVERLAY_METAS,
} from '../../../src/render/overlays-meta.ts'
describe('[Skill #042] Static Field (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #196)', () => {
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 & Ground Truth
// =========================================================================
describe('Dimension 1: 1.13c Data Table Parsing & Ground Truth', () => {
it('parses authentic Skills.txt row 42 (Static Field)', () => {
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 srvDoFuncIdx = headers.indexOf('srvdofunc')
const losIdx = headers.indexOf('LineOfSight')
const p1Idx = headers.indexOf('Param1')
const p2Idx = headers.indexOf('Param2')
const p4Idx = headers.indexOf('Param4')
const rangeCalcIdx = headers.indexOf('aurarangecalc')
const castOverlayIdx = headers.indexOf('castoverlay')
const srvMissileAIdx = headers.indexOf('srvmissilea')
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx] === '42')
expect(row).toBeDefined()
expect(row![skillIdx]).toBe('Static Field')
expect(row![charClassIdx]).toBe('sor')
expect(Number(row![manaIdx])).toBe(9)
expect(Number(row![lvlManaIdx])).toBe(0)
expect(Number(row![srvDoFuncIdx])).toBe(20) // SKILLS_SrvDo020_StaticField
expect(Number(row![losIdx])).toBe(4) // Requires Line Of Sight
expect(Number(row![p1Idx])).toBe(5) // Base radius: 5 yards
expect(Number(row![p2Idx])).toBe(1) // Radius per level: 1 yard
expect(Number(row![p4Idx])).toBe(25) // 25% current HP damage
expect(row![rangeCalcIdx]).toBe('ln12')
expect(row![castOverlayIdx]).toBe('light_cast_1')
// Non-projectile: no missile entity spawned
expect(row![srvMissileAIdx] ?? '').toBe('')
})
it('verifies canonical skill metadata registration for Skill 42', () => {
expect(getSkillManaCost(42, 1)).toBe(9)
expect(getSkillManaCost(42, 10)).toBe(9)
expect(getSkillCooldownTicks(42)).toBe(0)
expect(getSkillCastOverlay(42)).toBe('light_cast_1')
expect(SORCERESS_SKILL_CAST_OVERLAYS[42]).toBe('light_cast_1')
})
})
// =========================================================================
// Dimension 2: Visual Assets & Metadata Integrity
// =========================================================================
describe('Dimension 2: Visual Assets & Metadata Integrity', () => {
it('verifies cast and hit overlay atlas sprite sheets exist with non-zero size', () => {
const castPng = path.resolve(__dirname, '../../../public/overlays/light_cast_1.png')
const castJson = path.resolve(__dirname, '../../../public/overlays/light_cast_1.json')
const hitPng = path.resolve(__dirname, '../../../public/overlays/lightning.png')
const hitJson = path.resolve(__dirname, '../../../public/overlays/lightning.json')
expect(fs.existsSync(castPng)).toBe(true)
expect(fs.statSync(castPng).size).toBeGreaterThan(10000)
expect(fs.existsSync(castJson)).toBe(true)
expect(fs.existsSync(hitPng)).toBe(true)
expect(fs.statSync(hitPng).size).toBeGreaterThan(10000)
expect(fs.existsSync(hitJson)).toBe(true)
})
it('verifies cast overlay mapping and LIGHT_CAST_1_META metadata', () => {
expect(SORCERESS_SKILL_CAST_OVERLAYS[42]).toBe('light_cast_1')
expect(getSkillCastOverlay(42)).toBe('light_cast_1')
expect(LIGHT_CAST_1_META.name).toBe('light_cast_1')
expect(OVERLAY_METAS['light_cast_1']).toBe(LIGHT_CAST_1_META)
})
it('verifies hit overlay LIGHTNING_OVERLAY_META metadata', () => {
expect(LIGHTNING_OVERLAY_META.name).toBe('lightning')
expect(LIGHTNING_OVERLAY_META.celFile).toBe('ShockHitSm')
expect(LIGHTNING_OVERLAY_META.frames).toBe(20)
expect(OVERLAY_METAS['lightning']).toBe(LIGHTNING_OVERLAY_META)
})
})
// =========================================================================
// Dimension 3: Instantaneous 360° AoE Geometry & Radius Scaling
// =========================================================================
describe('Dimension 3: Instantaneous 360° AoE Geometry & Radius Scaling', () => {
it('calculates exact 1.13c radius in yards: (4 + slvl)', () => {
expect(calculateStaticFieldRadiusYards(1)).toBe(5)
expect(calculateStaticFieldRadiusYards(10)).toBe(14)
expect(calculateStaticFieldRadiusYards(20)).toBe(24)
expect(calculateStaticFieldRadiusYards(30)).toBe(34)
})
it('calculates exact radius in pixels: (4 + slvl) * 40 px', () => {
expect(calculateStaticFieldRadiusPx(1)).toBe(200) // 5 * 40
expect(calculateStaticFieldRadiusPx(10)).toBe(560) // 14 * 40
expect(calculateStaticFieldRadiusPx(20)).toBe(960) // 24 * 40 (covers 800x600 screen)
expect(calculateStaticFieldRadiusPx(30)).toBe(1360)
})
it('executeStaticField hits targets inside radius and excludes targets outside radius', () => {
// slvl 1 radius is 200 px
const targetInside = { id: 'm1', x: 150, y: 0, currentHp: 1000, maxHp: 1000 }
const targetOutside = { id: 'm2', x: 250, y: 0, currentHp: 1000, maxHp: 1000 }
const result = executeStaticField({
casterX: 0,
casterY: 0,
slvl: 1,
targets: [targetInside, targetOutside],
difficulty: 'normal',
})
expect(result.radiusPx).toBe(200)
// Only targetInside should be hit
expect(result.hits.length).toBe(1)
expect(result.hits[0]!.targetId).toBe('m1')
expect(result.hits[0]!.damage).toBe(250) // 25% of 1000
expect(result.hits[0]!.newHp).toBe(750)
})
})
// =========================================================================
// Dimension 4: 25% Current HP Cut, Resistance & Immunity Scaling
// =========================================================================
describe('Dimension 4: 25% Current HP Cut, Resistance & Immunity Scaling', () => {
it('cuts 25% of current HP sequentially on repeated casts', () => {
// Cast 1: 1000 HP -> cuts 250 -> 750 HP
const r1 = calculateStaticFieldDamage(1000, 1000, 'normal', 0)
expect(r1.damage).toBe(250)
expect(r1.newHp).toBe(750)
expect(r1.immune).toBe(false)
expect(r1.blockedByFloor).toBe(false)
// Cast 2: 750 HP -> cuts floor(750 * 0.25) = 187 -> 563 HP
const r2 = calculateStaticFieldDamage(750, 1000, 'normal', 0)
expect(r2.damage).toBe(187)
expect(r2.newHp).toBe(563)
// Cast 3: 563 HP -> cuts floor(563 * 0.25) = 140 -> 423 HP
const r3 = calculateStaticFieldDamage(563, 1000, 'normal', 0)
expect(r3.damage).toBe(140)
expect(r3.newHp).toBe(423)
})
it('scales damage proportionally with lightning resistance', () => {
// 50% lightning resist: 250 * (100 - 50) / 100 = 125 damage
const r50 = calculateStaticFieldDamage(1000, 1000, 'normal', 50)
expect(r50.damage).toBe(125)
expect(r50.newHp).toBe(875)
// 75% lightning resist: 250 * (100 - 75) / 100 = 62 damage
const r75 = calculateStaticFieldDamage(1000, 1000, 'normal', 75)
expect(r75.damage).toBe(62)
expect(r75.newHp).toBe(938)
})
it('deals 0 damage to lightning-immune enemies (resistance >= 100)', () => {
// 100% lightning resist (immune)
const r100 = calculateStaticFieldDamage(1000, 1000, 'normal', 100)
expect(r100.damage).toBe(0)
expect(r100.newHp).toBe(1000)
expect(r100.immune).toBe(true)
// 120% lightning resist (over-capped immune)
const r120 = calculateStaticFieldDamage(1000, 1000, 'normal', 120)
expect(r120.damage).toBe(0)
expect(r120.newHp).toBe(1000)
expect(r120.immune).toBe(true)
})
it('enforces 1.13c zero synergy rule (Static Field receives 0% synergy from other skills)', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(42)!
const statsBase = new UnitStatList()
statsBase.setBaseSkillLevel(42, 20)
statsBase.setBaseSkillLevel(48, 20) // Nova 20
statsBase.setBaseSkillLevel(49, 20) // Lightning 20
const withHardPoints = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsBase,
})
expect(withHardPoints.synergyBonusPct).toBe(0)
const statsSoftOnly = new UnitStatList()
statsSoftOnly.setBaseSkillLevel(42, 20)
statsSoftOnly.addStat('item_allskills', 20)
const withSoftOnly = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsSoftOnly,
})
expect(withSoftOnly.synergyBonusPct).toBe(0)
})
})
// =========================================================================
// Dimension 5: Line-of-Sight (LOS) Raycast Obstruction
// =========================================================================
describe('Dimension 5: Line-of-Sight (LOS) Raycast Obstruction', () => {
it('hasLineOfSight correctly distinguishes clear vs obstructed paths', () => {
// Wall between x=40 and x=60
const isWallBlocked = (x: number) => x >= 40 && x <= 60
// Path from (0, 0) to (100, 0) intersects wall -> false
expect(hasLineOfSight(0, 0, 100, 0, isWallBlocked)).toBe(false)
// Path from (0, 0) to (30, 0) stays in front of wall -> true
expect(hasLineOfSight(0, 0, 30, 0, isWallBlocked)).toBe(true)
// Path from (70, 0) to (100, 0) stays behind wall -> true
expect(hasLineOfSight(70, 0, 100, 0, isWallBlocked)).toBe(true)
})
it('executeStaticField blocks damage to targets obstructed by solid walls', () => {
const isWallBlocked = (x: number) => x >= 40 && x <= 60
const targetBehindWall = { id: 'wall_target', x: 100, y: 0, currentHp: 1000, maxHp: 1000 }
const targetInOpen = { id: 'open_target', x: 0, y: 100, currentHp: 1000, maxHp: 1000 }
const result = executeStaticField({
casterX: 0,
casterY: 0,
slvl: 5,
targets: [targetBehindWall, targetInOpen],
difficulty: 'normal',
isBlocked: (x: number) => isWallBlocked(x),
})
const behindHit = result.hits.find(h => h.targetId === 'wall_target')
expect(behindHit).toBeDefined()
expect(behindHit!.blockedByLos).toBe(true)
expect(behindHit!.damage).toBe(0)
expect(behindHit!.newHp).toBe(1000)
const openHit = result.hits.find(h => h.targetId === 'open_target')
expect(openHit).toBeDefined()
expect(openHit!.blockedByLos).toBe(false)
expect(openHit!.damage).toBe(250)
expect(openHit!.newHp).toBe(750)
})
})
// =========================================================================
// Dimension 6: Difficulty Floor Clamps & Assembly Pre-Check Invariant
// =========================================================================
describe('Dimension 6: Difficulty Floor Clamps & Assembly Pre-Check Invariant', () => {
it('Normal difficulty: 1 HP floor (cannot reduce HP below 1, never kills)', () => {
// Monster with 10 HP takes 2 damage -> 8 HP
const r1 = calculateStaticFieldDamage(10, 100, 'normal', 0)
expect(r1.damage).toBe(2)
expect(r1.newHp).toBe(8)
// Monster with 5 HP takes 1 damage (floor(5 * 0.25) = 1) -> 4 HP
const r2 = calculateStaticFieldDamage(5, 100, 'normal', 0)
expect(r2.damage).toBe(1)
expect(r2.newHp).toBe(4)
// Monster with 2 HP: floor(2 * 0.25) = 0 damage in 1.13c integer division
const r3 = calculateStaticFieldDamage(2, 100, 'normal', 0)
expect(r3.damage).toBe(0)
expect(r3.newHp).toBe(2)
// Monster with 1 HP: pre-check (currentHp <= 1) returns 0 damage immediately!
const r4 = calculateStaticFieldDamage(1, 100, 'normal', 0)
expect(r4.damage).toBe(0)
expect(r4.newHp).toBe(1)
expect(r4.blockedByFloor).toBe(true)
})
it('Nightmare difficulty: 33% max HP floor', () => {
// Monster at 400 HP / 1000 max HP (40%): cuts 25% (100) -> 300 HP (< 330 floor, clamped)
const r400 = calculateStaticFieldDamage(400, 1000, 'nightmare', 0)
expect(r400.damage).toBe(100)
expect(r400.newHp).toBe(300)
// Monster at 330 HP / 1000 max HP (33%): exactly at floor -> returns 0 damage immediately!
const r330 = calculateStaticFieldDamage(330, 1000, 'nightmare', 0)
expect(r330.damage).toBe(0)
expect(r330.newHp).toBe(330)
expect(r330.blockedByFloor).toBe(true)
// Monster at 200 HP / 1000 max HP (20%): below floor -> returns 0 damage immediately!
const r200 = calculateStaticFieldDamage(200, 1000, 'nightmare', 0)
expect(r200.damage).toBe(0)
expect(r200.newHp).toBe(200)
expect(r200.blockedByFloor).toBe(true)
})
it('Hell difficulty: 50% max HP floor with authentic assembly pre-check invariant', () => {
// Reverse-engineered assembly ground truth scenario (Spec Miner analysis):
// Target in Hell has 1000 max HP, starts at 520 current HP (52%)
// Floor in Hell is 50% = 500 HP
// Cast 1: 520 > 500. Cuts 25% of 520 = 130 damage. Target reduced to 390 HP (39%)
const cast1 = calculateStaticFieldDamage(520, 1000, 'hell', 0)
expect(cast1.damage).toBe(130)
expect(cast1.newHp).toBe(390)
expect(cast1.blockedByFloor).toBe(false)
// Cast 2: Current HP is 390 <= 500. D2Game.dll 0x6FC62F13 pre-check triggers!
// Returns 0 damage immediately without performing damage calculation!
const cast2 = calculateStaticFieldDamage(cast1.newHp, 1000, 'hell', 0)
expect(cast2.damage).toBe(0)
expect(cast2.newHp).toBe(390)
expect(cast2.blockedByFloor).toBe(true)
// Target at exactly 500 HP (50%): returns 0 damage immediately
const castAtFloor = calculateStaticFieldDamage(500, 1000, 'hell', 0)
expect(castAtFloor.damage).toBe(0)
expect(castAtFloor.newHp).toBe(500)
expect(castAtFloor.blockedByFloor).toBe(true)
})
})
// =========================================================================
// Dimension 7: WorldArena & Runtime Cast Integration
// =========================================================================
describe('Dimension 7: WorldArena & Runtime Cast Integration', () => {
it('evaluates exact 1.13c Skills.txt formulas in skillModule.evaluate', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(42)!
expect(skillRec).toBeDefined()
expect(skillModule.skillId).toBe(42)
expect(skillModule.name).toBe("Static Field")
expect(skillModule.charClass).toBe("sor")
const stats1 = new UnitStatList()
stats1.setBaseSkillLevel(42, 1)
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
expect(r1.manaCost).toBe(9)
expect(r1.manaCost256).toBe(2304)
expect(r1.minPhysDmg).toBe(0)
expect(r1.maxPhysDmg).toBe(0)
expect(r1.minElemDmg256).toBe(0)
expect(r1.maxElemDmg256).toBe(0)
const stats10 = new UnitStatList()
stats10.setBaseSkillLevel(42, 10)
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
expect(r10.manaCost).toBe(9)
expect(r10.manaCost256).toBe(2304)
expect(r10.minPhysDmg).toBe(0)
expect(r10.maxPhysDmg).toBe(0)
expect(r10.minElemDmg256).toBe(0)
expect(r10.maxElemDmg256).toBe(0)
const stats20 = new UnitStatList()
stats20.setBaseSkillLevel(42, 20)
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
expect(r20.manaCost).toBe(9)
expect(r20.manaCost256).toBe(2304)
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)
})
it('executes runtime skill effect in WorldArena (castCount, action frame, and combat outcome)', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: "sor",
skillId: 42,
slvl: 20,
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(42)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
it('verifies getSkillManaCost and getSkillCooldownTicks', () => {
expect(getSkillManaCost(42, 1)).toBe(9)
expect(getSkillManaCost(42, 10)).toBe(9)
expect(getSkillManaCost(42, 20)).toBe(9)
expect(getSkillCooldownTicks(42)).toBe(0)
})
})
})