diablo2-web/tests/skills/sor/skill-041-inferno.test.ts

351 lines
14 KiB
TypeScript

/**
* 1.13c Numerical & Runtime Verification Suite — Skill #041: Inferno (SOR)
* Gitea Tracking Issue: #195
*
* 7-Dimensional Verification Scope:
* Dimension 1: Static data table parsing (Skills.txt row 41, Missiles.txt IDs 60 infernoflame1, 61 infernoflame2).
* Dimension 2: Pre-baked visual assets & atlas metadata on disk (PNG/JSON exist with non-zero size).
* Dimension 3: Kinematics & stream cone geometry (calculateInfernoRange, calculateInfernoRangePx, stream velocity & TTL).
* Dimension 4: Collision detection & continuous stream ticks (hitTargets.clear() every frame, pierce persistence).
* Dimension 5: Damage scaling & synergies (5-band math: 32-64 base, Warmth +13%/lvl, 256ths precision).
* Dimension 6: Mana progression & continuous drain rules (6 starting mana, 144-220 256ths/frame drain, 0 cooldown).
* Dimension 7: Invariants & integration (BATCH1_SKILLS length === 38, zero runtime MPQ queries, routing).
*/
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-041-inferno.ts'
import {
parseMissilesTxt,
CANONICAL_113C_MISSILES,
BATCH1_SKILLS,
calculateInfernoDamage,
calculateInfernoRange,
calculateInfernoRangePx,
calculateInfernoContinuousMana256,
calculateSkillDamage,
getSkillManaCost,
getSkillCooldownTicks,
tickProjectiles,
type Projectile,
} from '../../../src/game/skills.ts'
import {
INFERNOFLAME1_META,
INFERNOFLAME2_META,
} from '../../../src/render/missiles-meta.ts'
import { FIRE_CAST_2_META } from '../../../src/render/overlays-meta.ts'
import { castSkill } from '../../../src/scene/act-scene.ts'
describe('[Skill #041] Inferno (SOR) — 1.13c Parity (Issue #195)', () => {
// ==========================================================================
// Dimension 1: Static Data Table Parsing
// ==========================================================================
it('Dimension 1: parses authentic 1.13c Skills.txt & Missiles.txt definitions', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(41)!
expect(skillRec).toBeDefined()
expect(skillModule.skillId).toBe(41)
expect(skillModule.name).toBe('Inferno')
expect(skillModule.charClass).toBe('sor')
const missilesTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Missiles.txt')
const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1')
const map = parseMissilesTxt(missilesTxtContent)
// infernoflame1 (ID 60)
const inf1 = map.get('infernoflame1')
expect(inf1).toBeDefined()
expect(inf1!.id).toBe(60)
expect(inf1!.vel).toBe(12)
expect(inf1!.range).toBe(30)
expect(inf1!.animLen).toBe(15)
expect(inf1!.animSpeed).toBe(16)
expect(inf1!.trans).toBe(1)
expect(inf1!.light).toBe(5)
// infernoflame2 (ID 61)
const inf2 = map.get('infernoflame2')
expect(inf2).toBeDefined()
expect(inf2!.id).toBe(61)
expect(inf2!.vel).toBe(12)
expect(inf2!.range).toBe(30)
expect(inf2!.animLen).toBe(15)
expect(inf2!.animSpeed).toBe(16)
expect(inf2!.trans).toBe(1)
expect(inf2!.light).toBe(5)
})
// ==========================================================================
// Dimension 2: Pre-baked Visual Assets & Atlas Metadata
// ==========================================================================
it('Dimension 2: pre-baked visual assets and metadata exist on disk with non-zero byte size', () => {
const flame1Png = path.resolve(__dirname, '../../../public/missiles/infernoflame1.png')
const flame2Png = path.resolve(__dirname, '../../../public/missiles/infernoflame2.png')
const castOverlayPng = path.resolve(__dirname, '../../../public/overlays/fire_cast_2.png')
expect(fs.existsSync(flame1Png)).toBe(true)
expect(fs.statSync(flame1Png).size).toBeGreaterThan(0)
expect(fs.existsSync(flame2Png)).toBe(true)
expect(fs.statSync(flame2Png).size).toBeGreaterThan(0)
expect(fs.existsSync(castOverlayPng)).toBe(true)
expect(fs.statSync(castOverlayPng).size).toBeGreaterThan(0)
// Atlas metadata
expect(INFERNOFLAME1_META).toBeDefined()
expect(INFERNOFLAME1_META.name).toBe('infernoflame1')
expect(INFERNOFLAME1_META.framesPerDirection).toBe(15)
expect(INFERNOFLAME2_META).toBeDefined()
expect(INFERNOFLAME2_META.name).toBe('infernoflame2')
expect(INFERNOFLAME2_META.framesPerDirection).toBe(15)
expect(FIRE_CAST_2_META).toBeDefined()
expect(FIRE_CAST_2_META.name).toBe('fire_cast_2')
})
// ==========================================================================
// Dimension 3: Kinematics & Stream Cone Geometry
// ==========================================================================
it('Dimension 3: calculates exact 1.13c stream range progression in yards and screen pixels', () => {
// Range in yards: (20 + 3 * (slvl - 1)) / 2 = 10 + 1.5 * (slvl - 1)
expect(calculateInfernoRange(1)).toBe(10)
expect(calculateInfernoRange(10)).toBe(23.5)
expect(calculateInfernoRange(20)).toBe(38.5)
// Range in pixels: yards * 32 (1 yard = 1 subtile = 32 horizontal pixels)
expect(calculateInfernoRangePx(1)).toBe(320)
expect(calculateInfernoRangePx(10)).toBe(752)
expect(calculateInfernoRangePx(20)).toBe(1232)
})
// ==========================================================================
// Dimension 4: Collision Detection & Continuous Stream Ticks
// ==========================================================================
it('Dimension 4: damages enemies continuously per frame with piercing stream flame persistence', () => {
const flame: Projectile = {
skillId: '41',
x: 100,
y: 100,
vx: 12,
vy: 0,
damage: 32,
ttl: 30,
fromPlayer: true,
missileType: 'infernoflame1',
pierce: true,
aoeRadius: 16,
}
const enemy = {
index: 1,
alive: true,
x: 110,
y: 100,
radius: 16,
}
const terrain = { overlap: () => 0 }
// Tick 1: flame advances and damages enemy
const outcome1 = tickProjectiles([flame], [enemy], terrain)
expect(outcome1.hits).toHaveLength(1)
expect(outcome1.hits[0]!.damage).toBe(32)
// Tick 2: hitTargets resets every frame for infernoflame -> damages enemy again continuously!
const outcome2 = tickProjectiles(outcome1.alive, [enemy], terrain)
expect(outcome2.hits).toHaveLength(1)
expect(outcome2.hits[0]!.damage).toBe(32)
// Flame is piercing and persists with decremented TTL
expect(outcome2.alive).toHaveLength(1)
expect(outcome2.alive[0]!.ttl).toBe(28)
})
// ==========================================================================
// Dimension 5: Damage Scaling & Synergies
// ==========================================================================
it('Dimension 5: calculates exact 1.13c 5-band damage progression, 256ths precision, and synergies', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(41)!
// Level 1: raw 32-64 -> in 256ths: 128 - 256
const stats1 = new UnitStatList()
stats1.setBaseSkillLevel(41, 1)
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
expect(r1.minElemDmg256).toBe(128)
expect(r1.maxElemDmg256).toBe(256)
const d1 = calculateInfernoDamage(1)
expect(d1.min256).toBe(128)
expect(d1.max256).toBe(256)
expect(d1.perSecMin).toBeCloseTo(12.5, 1)
expect(d1.perSecMax).toBeCloseTo(25.0, 1)
// Level 10: raw 252-286 -> in 256ths: 1008 - 1144
const stats10 = new UnitStatList()
stats10.setBaseSkillLevel(41, 10)
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
expect(r10.minElemDmg256).toBe(1008)
expect(r10.maxElemDmg256).toBe(1144)
const d10 = calculateInfernoDamage(10)
expect(d10.min256).toBe(1008)
expect(d10.max256).toBe(1144)
expect(d10.perSecMin).toBeCloseTo(98.43, 1)
expect(d10.perSecMax).toBeCloseTo(111.71, 1)
// Level 20: raw 520-564 -> in 256ths: 2080 - 2256
const stats20 = new UnitStatList()
stats20.setBaseSkillLevel(41, 20)
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
expect(r20.minElemDmg256).toBe(2080)
expect(r20.maxElemDmg256).toBe(2256)
const d20 = calculateInfernoDamage(20)
expect(d20.min256).toBe(2080)
expect(d20.max256).toBe(2256)
expect(d20.perSecMin).toBeCloseTo(203.12, 1)
expect(d20.perSecMax).toBeCloseTo(220.31, 1)
// Synergy: Warmth (+13%/lvl)
// 20 Warmth points = +260% damage (multiplier 3.6)
const d20Syn = calculateInfernoDamage(20, { warmth: 20 })
expect(d20Syn.min256).toBe(Math.floor(2080 * 3.6))
expect(d20Syn.max256).toBe(Math.floor(2256 * 3.6))
// Hard point synergy rule: soft points (+allskills / charges) provide 0 synergy
const statsSoftOnly = new UnitStatList()
statsSoftOnly.setBaseSkillLevel(41, 20)
statsSoftOnly.addStat('item_allskills', 20)
statsSoftOnly.setChargedSkillLevel(37, 33)
const evalSoft = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: statsSoftOnly })
expect(evalSoft.synergyBonusPct).toBe(0)
})
// ==========================================================================
// Dimension 6: Mana Progression & Continuous Drain Rules
// ==========================================================================
it('Dimension 6: verifies authentic starting mana, continuous drain in 256ths, and 0 cooldown', () => {
// Starting cast mana: strictly 6
expect(getSkillManaCost(41, 1)).toBe(6)
expect(getSkillManaCost(41, 10)).toBe(6)
expect(getSkillManaCost(41, 20)).toBe(6)
// Continuous mana drain per frame in 256ths: (36 + (slvl - 1)) * 4
expect(calculateInfernoContinuousMana256(1)).toBe(144)
expect(calculateInfernoContinuousMana256(10)).toBe(180)
expect(calculateInfernoContinuousMana256(20)).toBe(220)
// Cooldown / Cast Delay: strictly 0 frames (streaming skill)
expect(getSkillCooldownTicks(41)).toBe(0)
})
it('Dimension 6: verifies continuous channeled mana drain and channel cessation upon depletion in castSkill', () => {
const player = {
x: 100,
y: 100,
facing: 0,
cooldown: 0,
mana: 10,
maxMana: 100,
infernoChanneling: false,
infernoManaRemainder256: 0,
}
const engine: any = {
world: {
player,
events: [],
},
projectiles: [],
spawnOverlay: () => {},
metrics: { casts: 0 },
}
const status = { textContent: '' } as any
const context = {
engine,
runtime: {} as any,
hudManager: null,
status,
}
// Call 1: initial cast costs 6 mana
const cast1 = castSkill(41, 200, 100, context)
expect(cast1).toBe(true)
expect(player.mana).toBe(4) // 10 - 6 = 4
expect(player.infernoChanneling).toBe(true)
expect(player.infernoManaRemainder256).toBe(0)
// Call 2: frame 1 of continuous channeling (slvl 1 drain = 144/256)
// Accumulator = 0 + 144 = 144, wholeMana = 0, mana remains 4
const cast2 = castSkill(41, 200, 100, context)
expect(cast2).toBe(true)
expect(player.mana).toBe(4)
expect(player.infernoManaRemainder256).toBe(144)
// Call 3: frame 2 of continuous channeling (slvl 1 drain = 144/256)
// Accumulator = 144 + 144 = 288, wholeMana = 1, remainder = 32, mana becomes 3
const cast3 = castSkill(41, 200, 100, context)
expect(cast3).toBe(true)
expect(player.mana).toBe(3)
expect(player.infernoManaRemainder256).toBe(32)
// Drain down to 0 mana
player.mana = 0
player.infernoManaRemainder256 = 0
// Calling while mana is 0 should stop channeling and return false
const castEmpty = castSkill(41, 200, 100, context)
expect(castEmpty).toBe(false)
expect(status.textContent).toBe('法力不足!')
expect(player.infernoChanneling).toBe(false)
})
// ==========================================================================
// Dimension 7: Invariants & Integration
// ==========================================================================
it('Dimension 7: enforces BATCH1_SKILLS length invariant = 38, routing, and WorldArena execution', async () => {
// STRICT INVARIANT: BATCH1_SKILLS length must remain exactly 38
expect(Object.keys(BATCH1_SKILLS)).toHaveLength(38)
// Routing in calculateSkillDamage
const dmg1 = calculateSkillDamage(41, 1)
expect(dmg1.min).toBe(32)
expect(dmg1.max).toBe(64)
const dmg10 = calculateSkillDamage('41', 10)
expect(dmg10.min).toBe(252)
expect(dmg10.max).toBe(286)
const dmg20 = calculateSkillDamage('inferno', 20)
expect(dmg20.min).toBe(520)
expect(dmg20.max).toBe(564)
// Canonical missiles
expect(CANONICAL_113C_MISSILES.infernoflame1).toBeDefined()
expect(CANONICAL_113C_MISSILES.infernoflame2).toBeDefined()
// WorldArena Execution
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: 'sor',
skillId: 41,
slvl: 20,
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(41)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
})