diablo2-web/tests/skills/sor/skill-038-charged-bolt.test.ts

502 lines
20 KiB
TypeScript

/**
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #038: Charged Bolt (SOR)
* Gitea Tracking Issue: #192
*
* 7-Dimensional Verification Coverage:
* D1: 1.13c Data Table Parsing & Canonical Missile Definition (Skills.txt row 40 / Id 38, Missiles.txt 'chargedbolt' / ID 56)
* D2: Visual Assets & Metadata Integrity (chargedbolt.png/json, light_cast_1.png/json, CHARGEDBOLT_META, LIGHT_CAST_1_META)
* D3: Kinematics, Bolt Count Scaling & Discrete Orthogonal Jitter (min(24, slvl + 2), 32-entry lookup table)
* D4: NextDelay 0 Multi-Hit & Terrain Collision Clipping (Multiple bolts strike same target, wall collision terminates bolt)
* D5: 1.13c 5-Band Piecewise Damage Scaling & +6% Synergy from Lightning (EMin 4, EMax 8, +1/+1/+2/+3/+4 per band, Lightning blvl)
* D6: Lifecycle, Range 98 Frames, Mana Cost Formula (3 + (slvl - 1) * 0.5, 0 cooldown ticks)
* D7: WorldArena & Runtime Cast Integration (Cast action frame, mana consumption, 0 cooldown, overlay attachment)
*/
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 { StateBus } from '../../../src/game/engine/state-bus.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/sor/skill-038-charged-bolt.ts'
import {
parseMissilesTxt,
getMissileTxtData,
CANONICAL_113C_MISSILES,
BATCH1_SKILLS,
getBatch1SkillDef,
getSkillManaCost,
getSkillCooldownTicks,
getSkillCastOverlay,
SORCERESS_SKILL_CAST_OVERLAYS,
GAT_CHARGED_BOLT_OFFSETS,
calculateChargedBoltCount,
calculateChargedBoltSynergyMultiplier,
calculateChargedBoltDamage,
calculateSkillDamage,
tickProjectiles,
type Projectile,
} from '../../../src/game/skills.ts'
import {
MissileEngine,
} from '../../../src/game/engine/missile-engine.ts'
import {
CHARGEDBOLT_META,
MISSILE_METAS,
} from '../../../src/render/missiles-meta.ts'
import {
LIGHT_CAST_1_META,
OVERLAY_METAS,
} from '../../../src/render/overlays-meta.ts'
describe('[Skill #038] Charged Bolt (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #192)', () => {
const missilesTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Missiles.txt')
const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1')
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 Missile Definition
// =========================================================================
describe('Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition', () => {
it('parses authentic Skills.txt row 38 (Charged Bolt)', () => {
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 srvMissileIdx = headers.indexOf('srvmissilea') !== -1 ? headers.indexOf('srvmissilea') : headers.indexOf('srvmissile')
const eMinIdx = headers.indexOf('EMin')
const eMaxIdx = headers.indexOf('EMax')
const eLev1Idx = headers.indexOf('EMinLev1') !== -1 ? headers.indexOf('EMinLev1') : headers.indexOf('ELev1')
const eLev2Idx = headers.indexOf('EMinLev2') !== -1 ? headers.indexOf('EMinLev2') : headers.indexOf('ELev2')
const eLev3Idx = headers.indexOf('EMinLev3') !== -1 ? headers.indexOf('EMinLev3') : headers.indexOf('ELev3')
const eLev4Idx = headers.indexOf('EMinLev4') !== -1 ? headers.indexOf('EMinLev4') : headers.indexOf('ELev4')
const eLev5Idx = headers.indexOf('EMinLev5') !== -1 ? headers.indexOf('EMinLev5') : headers.indexOf('ELev5')
const param8Idx = headers.indexOf('Param8') !== -1 ? headers.indexOf('Param8') : headers.indexOf('param8')
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '38')
expect(row).toBeDefined()
expect(row![skillIdx]?.trim()).toBe('Charged Bolt')
expect(Number(row![manaIdx])).toBe(24)
expect(Number(row![lvlManaIdx])).toBe(4)
expect(Number(row![manaShiftIdx])).toBe(5)
expect(row![castOverlayIdx]?.trim()).toBe('light_cast_1')
expect(row![srvMissileIdx]?.trim()).toBe('chargedbolt')
expect(Number(row![eMinIdx])).toBe(4)
expect(Number(row![eMaxIdx])).toBe(8)
expect(Number(row![eLev1Idx])).toBe(1)
expect(Number(row![eLev2Idx])).toBe(1)
expect(Number(row![eLev3Idx])).toBe(2)
expect(Number(row![eLev4Idx])).toBe(3)
expect(Number(row![eLev5Idx])).toBe(4)
expect(Number(row![param8Idx])).toBe(6) // +6% per point in Lightning
// Verify authentic 1.13c mana formula: (24 + 4 * (lvl - 1)) * 2^(5 - 8)
expect((24 + 4 * (1 - 1)) * (2 ** (5 - 8))).toBe(3)
expect((24 + 4 * (10 - 1)) * (2 ** (5 - 8))).toBe(7.5)
expect((24 + 4 * (20 - 1)) * (2 ** (5 - 8))).toBe(12.5)
})
it('parses authentic Missiles.txt row for chargedbolt (ID 56)', async () => {
const map = parseMissilesTxt(missilesTxtContent)
const bolt = map.get('chargedbolt')
expect(bolt).toBeDefined()
expect(bolt!.id).toBe(56)
expect(bolt!.vel).toBe(12)
expect(bolt!.range).toBe(98)
expect(bolt!.speedPxPerSec).toBe(300) // 12 * 25
expect(bolt!.distancePx).toBe(1176) // 98 * 12
expect(bolt!.nextDelay ?? 0).toBe(0)
const registry = await getSharedDataRegistry()
const missileRec = registry.getMissileById(56)!
expect(missileRec).toBeDefined()
expect(missileRec.pCltDoFunc).toBe(1)
expect(missileRec.pSrvDoFunc).toBe(1)
})
it('CANONICAL_113C_MISSILES contains exact chargedbolt definition', () => {
const bolt = getMissileTxtData('chargedbolt')
expect(bolt.id).toBe(56)
expect(bolt.vel).toBe(12)
expect(bolt.range).toBe(98)
expect(bolt.speedPxPerSec).toBe(300)
expect(bolt.distancePx).toBe(1176)
expect(CANONICAL_113C_MISSILES['chargedbolt']).toBeDefined()
})
})
// =========================================================================
// Dimension 2: Visual Assets & Metadata Integrity
// =========================================================================
describe('Dimension 2: Visual Assets & Metadata Integrity', () => {
it('verifies baked DCC atlas sprite sheet files exist with non-zero size', () => {
const missilePng = path.resolve(__dirname, '../../../public/missiles/chargedbolt.png')
const missileJson = path.resolve(__dirname, '../../../public/missiles/chargedbolt.json')
const overlayPng = path.resolve(__dirname, '../../../public/overlays/light_cast_1.png')
const overlayJson = path.resolve(__dirname, '../../../public/overlays/light_cast_1.json')
expect(fs.existsSync(missilePng)).toBe(true)
expect(fs.statSync(missilePng).size).toBeGreaterThan(10000)
expect(fs.existsSync(missileJson)).toBe(true)
expect(fs.existsSync(overlayPng)).toBe(true)
expect(fs.statSync(overlayPng).size).toBeGreaterThan(10000)
expect(fs.existsSync(overlayJson)).toBe(true)
})
it('verifies CHARGEDBOLT_META metadata and registry registration', () => {
expect(CHARGEDBOLT_META.name).toBe('chargedbolt')
expect(CHARGEDBOLT_META.directions).toBe(16)
expect(CHARGEDBOLT_META.framesPerDirection).toBe(10)
expect(MISSILE_METAS['chargedbolt']).toBe(CHARGEDBOLT_META)
})
it('verifies cast overlay mapping and metadata', () => {
expect(SORCERESS_SKILL_CAST_OVERLAYS[38]).toBe('light_cast_1')
expect(getSkillCastOverlay(38)).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)
})
})
// =========================================================================
// Dimension 3: Kinematics, Bolt Count Scaling & Discrete Orthogonal Jitter
// =========================================================================
describe('Dimension 3: Kinematics, Bolt Count Scaling & Discrete Orthogonal Jitter', () => {
it('scales bolt count correctly: min(24, slvl + 2)', () => {
expect(calculateChargedBoltCount(1)).toBe(3)
expect(calculateChargedBoltCount(2)).toBe(4)
expect(calculateChargedBoltCount(5)).toBe(7)
expect(calculateChargedBoltCount(10)).toBe(12)
expect(calculateChargedBoltCount(20)).toBe(22)
expect(calculateChargedBoltCount(22)).toBe(24)
expect(calculateChargedBoltCount(30)).toBe(24) // capped at 24
expect(calculateChargedBoltCount(50)).toBe(24)
})
it('advances at velocity 12 px/tick (300 px/s)', () => {
const missile = getMissileTxtData('chargedbolt')
expect(missile.speedPxPerSec / 25).toBe(12)
expect(missile.range).toBe(98)
})
it('GAT_CHARGED_BOLT_OFFSETS contains authentic 32-entry discrete lookup table', () => {
expect(GAT_CHARGED_BOLT_OFFSETS.length).toBe(32)
for (const val of GAT_CHARGED_BOLT_OFFSETS) {
expect([-1, 0, 1]).toContain(val)
}
expect(GAT_CHARGED_BOLT_OFFSETS[0]).toBe(-1)
expect(GAT_CHARGED_BOLT_OFFSETS[1]).toBe(0)
expect(GAT_CHARGED_BOLT_OFFSETS[2]).toBe(1)
expect(GAT_CHARGED_BOLT_OFFSETS[31]).toBe(1)
})
it('tickProjectiles applies discrete orthogonal jitter displacing sideways while propagating', () => {
const projectiles: readonly Projectile[] = [
{
skillId: '38',
x: 0,
y: 0,
vx: 12,
vy: 0,
damage: 10,
ttl: 98,
fromPlayer: true,
missileType: 'chargedbolt',
jitter: true,
seed: 0,
jitterIndex: 0,
},
]
const openTerrain = { overlap: () => 0 }
const tick1 = tickProjectiles(projectiles, [], openTerrain)
expect(tick1.alive.length).toBe(1)
const p1 = tick1.alive[0]!
// Forward advancement
expect(p1.x).toBe(12)
// Orthogonal displacement: perp vector (-vy/speed, vx/speed) = (0, 1). Offset = GAT[0] * 3 = -1 * 3 = -3 * ISO_RATIO
expect(p1.y).not.toBe(0)
expect(p1.jitterIndex).toBe(1)
})
it('MissileEngine simulates Charged Bolt propagation with orthogonal jitter', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const ownerStats = new UnitStatList()
const owner = {
id: 'player',
x: 0,
y: 0,
statList: ownerStats,
stateBus: new StateBus(ownerStats),
} as any
const m = engine.spawnMissile({
missileNameOrId: 'chargedbolt',
sourceSkillId: 38,
slvl: 10,
owner,
startX: 0,
startY: 0,
targetX: 300,
targetY: 0,
dmgPacket: { minDamage: 10, maxDamage: 15, damageType: 'lightning' } as any,
vx: 12,
vy: 0,
maxRangeTicks: 98,
seed: 3,
jitterIndex: 3,
})
expect(m).not.toBeNull()
engine.tick()
expect(m!.x).toBeGreaterThan(0)
expect(m!.jitterIndex).toBe(4)
})
})
// =========================================================================
// Dimension 4: NextDelay 0 Multi-Hit & Terrain Collision Clipping
// =========================================================================
describe('Dimension 4: NextDelay 0 Multi-Hit & Terrain Collision Clipping', () => {
it('NextDelay 0 permits multiple bolts in the same volley to strike the same enemy', () => {
const enemy = { index: 1, x: 24, y: 0, radius: 16, alive: true }
// 3 individual bolts converging on enemy
const projectiles: readonly Projectile[] = [
{
skillId: '38',
x: 0,
y: 0,
vx: 12,
vy: 0,
damage: 20,
ttl: 98,
fromPlayer: true,
missileType: 'chargedbolt',
},
{
skillId: '38',
x: 0,
y: 4,
vx: 12,
vy: 0,
damage: 20,
ttl: 98,
fromPlayer: true,
missileType: 'chargedbolt',
},
{
skillId: '38',
x: 0,
y: -4,
vx: 12,
vy: 0,
damage: 20,
ttl: 98,
fromPlayer: true,
missileType: 'chargedbolt',
},
]
const openTerrain = { overlap: () => 0 }
const tick1 = tickProjectiles(projectiles, [enemy], openTerrain)
// Step to reach enemy
const tick2 = tickProjectiles(tick1.alive, [enemy], openTerrain)
// All 3 bolts must register hits because NextDelay is 0!
const totalHits = tick1.hits.length + tick2.hits.length
expect(totalHits).toBe(3)
})
it('terminates and dies on solid wall collision', () => {
const projectiles: readonly Projectile[] = [
{
skillId: '38',
x: 0,
y: 0,
vx: 12,
vy: 0,
damage: 20,
ttl: 98,
fromPlayer: true,
missileType: 'chargedbolt',
},
]
// Wall at x >= 10
const wallTerrain = {
overlap: (x: number) => (x >= 10 ? 1 : 0),
}
const outcome = tickProjectiles(projectiles, [], wallTerrain)
expect(outcome.wallHits).toBe(1)
expect(outcome.alive.length).toBe(0)
})
})
// =========================================================================
// Dimension 5: 1.13c 5-Band Piecewise Damage Scaling & +6% Synergy from Lightning
// =========================================================================
describe('Dimension 5: 1.13c 5-Band Piecewise Damage Scaling & +6% Synergy', () => {
it('evaluates exact 5-band base damage without synergies', () => {
// Base: 2-4
expect(calculateChargedBoltDamage(1)).toEqual({ min: 2, max: 4 })
// Level 8 (band 1 end: +1/+1 per level): 2 + 7 = 9, 4 + 7 = 11
expect(calculateChargedBoltDamage(8)).toEqual({ min: 9, max: 11 })
// Level 16 (band 2 end: +1/+1 per level): 9 + 8 = 17, 11 + 8 = 19
expect(calculateChargedBoltDamage(16)).toEqual({ min: 17, max: 19 })
// Level 20 (band 3: +2/+2 per level): 17 + 4*2 = 25, 19 + 4*2 = 27
expect(calculateChargedBoltDamage(20)).toEqual({ min: 25, max: 27 })
// Level 22 (band 3 end): 17 + 6*2 = 29, 19 + 6*2 = 31
expect(calculateChargedBoltDamage(22)).toEqual({ min: 29, max: 31 })
// Level 28 (band 4 end: +3/+3 per level): 29 + 6*3 = 47, 31 + 6*3 = 49
expect(calculateChargedBoltDamage(28)).toEqual({ min: 47, max: 49 })
// Level 30 (band 5: +4/+4 per level): 47 + 2*4 = 55, 49 + 2*4 = 57
expect(calculateChargedBoltDamage(30)).toEqual({ min: 55, max: 57 })
})
it('evaluates +6% synergy per point in Lightning (49)', () => {
expect(calculateChargedBoltSynergyMultiplier(0)).toBe(1.0)
expect(calculateChargedBoltSynergyMultiplier({ lightning: 1 })).toBeCloseTo(1.06, 5)
expect(calculateChargedBoltSynergyMultiplier({ lightning: 10 })).toBeCloseTo(1.60, 5)
expect(calculateChargedBoltSynergyMultiplier({ lightning: 20 })).toBeCloseTo(2.20, 5)
// Level 20 base (25-27) with 20 points in Lightning:
// min = floor(25 * 2.2) = 55, max = floor(27 * 2.2) = 59
const dmg = calculateChargedBoltDamage(20, { lightning: 20 })
expect(dmg).toEqual({ min: 55, max: 59 })
})
it('integrates with calculateSkillDamage helper', () => {
expect(calculateSkillDamage(38, 1)).toEqual({ min: 2, max: 4 })
expect(calculateSkillDamage('chargedbolt', 20, { lightning: 20 } as any)).toEqual({ min: 55, max: 59 })
})
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(38)!
const synId = 49
const statsBase = new UnitStatList()
statsBase.setBaseSkillLevel(38, 20)
statsBase.setBaseSkillLevel(synId, 20)
const withHardPoints = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsBase,
})
expect(withHardPoints.synergyBonusPct).toBe(120)
// +20 allskills and +33 charges without hard points must yield 0 synergy
const statsSoftOnly = new UnitStatList()
statsSoftOnly.setBaseSkillLevel(38, 20)
statsSoftOnly.addStat('item_allskills', 20)
statsSoftOnly.setChargedSkillLevel(synId, 33)
const withSoftOnly = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsSoftOnly,
})
expect(withSoftOnly.synergyBonusPct).toBe(0)
})
})
// =========================================================================
// Dimension 6: Lifecycle, Range 98 Frames, Mana Cost Formula
// =========================================================================
describe('Dimension 6: Lifecycle, Range 98 Frames, Mana Cost Formula', () => {
it('projectile lives exactly 98 ticks in open space and expires on tick 98', () => {
let projectiles: readonly Projectile[] = [
{
skillId: '38',
x: 0,
y: 0,
vx: 12,
vy: 0,
damage: 10,
ttl: 98,
fromPlayer: true,
missileType: 'chargedbolt',
},
]
const openTerrain = { overlap: () => 0 }
for (let t = 1; t <= 98; t++) {
const outcome = tickProjectiles(projectiles, [], openTerrain)
expect(outcome.alive.length).toBe(1)
expect(outcome.expired).toBe(0)
projectiles = outcome.alive
}
// Tick 99: expires after completing 98 ticks of flight
const outcome99 = tickProjectiles(projectiles, [], openTerrain)
expect(outcome99.alive.length).toBe(0)
expect(outcome99.expired).toBe(1)
})
it('verifies getSkillManaCost formula: 3 + (slvl - 1) * 0.5', () => {
expect(getSkillManaCost(38, 1)).toBe(3)
expect(getSkillManaCost(38, 2)).toBe(3.5)
expect(getSkillManaCost(38, 3)).toBe(4)
expect(getSkillManaCost(38, 10)).toBe(7.5)
expect(getSkillManaCost(38, 20)).toBe(12.5)
})
it('verifies getSkillCooldownTicks returns 0 frames', () => {
expect(getSkillCooldownTicks(38)).toBe(0)
})
})
// =========================================================================
// Dimension 7: WorldArena & Runtime Cast Integration
// =========================================================================
describe('Dimension 7: WorldArena & Runtime Cast Integration', () => {
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: 38,
slvl: 20,
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(38)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
it('verifies skillModule definition and metadata for Charged Bolt (38)', () => {
expect(skillModule.skillId).toBe(38)
expect(skillModule.name).toBe('Charged Bolt')
expect(skillModule.charClass).toBe('sor')
expect(skillModule.srvStFunc).toBe(0)
expect(skillModule.srvDoFunc).toBe(17)
expect(getSkillCastOverlay(38)).toBe('light_cast_1')
expect(SORCERESS_SKILL_CAST_OVERLAYS[38]).toBe('light_cast_1')
expect(getSkillCooldownTicks(38)).toBe(0)
expect(getSkillManaCost(38, 1)).toBe(3)
const boltMissile = CANONICAL_113C_MISSILES['chargedbolt']
expect(boltMissile).toBeDefined()
expect(boltMissile.vel).toBe(12) // 300 px/s (12 px/frame at 25 fps)
expect(boltMissile.range).toBe(98)
})
})
})