diablo2-web/tests/skills/sor/skill-052-enchant.test.ts

399 lines
17 KiB
TypeScript

/**
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #052: Enchant (SOR)
* Gitea Tracking Issue: #206
*
* 7-Dimensional Verification Coverage:
* D1: 1.13c Data Table Parsing & Schema Integrity (Skills.txt row 52, mana 25, lvlmana 1, manashift 8, castoverlay 'fire_cast_2')
* D2: Visual Assets & Metadata Integrity (Sprite atlas 'enchant.png' / 'enchant.json', ENCHANT_OVERLAY_META, FIRE_CAST_2_META)
* D3: Formulas & Scaling (Duration: 3600 + (slvl-1)*600 frames, AR bonus: 20 + (slvl-1)*9%)
* D4: Ranged Penalty (33%) vs Sorceress Melee Double-Dip (Ranged 33% penalty, Sorc melee double-dip with FM, immunity intact)
* D5: Synergy Math & 5-Band Integer Accuracy (Warmth +9%/blvl, soft points 0%, HitShift 7 0.5 fire dmg band progression)
* D6: Mana Progression & 0 Cooldown Ticks (Mana 25 + slvl - 1, 0 cooldown frames, InTown target ally/pet permitted)
* D7: Invariants (BATCH1_SKILLS length strictly 38, zero runtime MPQ queries, WorldArena cast execution)
*/
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-052-enchant.ts'
import {
calculateEnchantDamage,
calculateEnchantAttackRating,
calculateEnchantDuration,
calculateEnchantAttackDamage,
calculateFireMasteryBonus,
getSkillCastOverlay,
getSkillManaCost,
getSkillCooldownTicks,
BATCH1_SKILLS,
} from '../../../src/game/skills.ts'
import { applyEnchantBuff, canCastEnchantOn } from '../../../src/game/combat.ts'
import {
ENCHANT_OVERLAY_META,
FIRE_CAST_2_META,
OVERLAY_METAS,
} from '../../../src/render/overlays-meta.ts'
describe('[Skill #052] Enchant (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #206)', () => {
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 52 (Enchant)', () => {
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 aurastateIdx = headers.indexOf('aurastate')
const srvDoFuncIdx = headers.indexOf('srvdofunc')
const srvStFuncIdx = headers.indexOf('srvstfunc')
const eMinIdx = headers.indexOf('EMin')
const eMinLev1Idx = headers.indexOf('EMinLev1')
const eMinLev2Idx = headers.indexOf('EMinLev2')
const eMinLev3Idx = headers.indexOf('EMinLev3')
const eMinLev4Idx = headers.indexOf('EMinLev4')
const eMinLev5Idx = headers.indexOf('EMinLev5')
const eMaxIdx = headers.indexOf('EMax')
const eMaxLev1Idx = headers.indexOf('EMaxLev1')
const eMaxLev2Idx = headers.indexOf('EMaxLev2')
const eMaxLev3Idx = headers.indexOf('EMaxLev3')
const eMaxLev4Idx = headers.indexOf('EMaxLev4')
const eMaxLev5Idx = headers.indexOf('EMaxLev5')
const hitShiftIdx = headers.indexOf('HitShift')
const toHitIdx = headers.indexOf('ToHit')
const toHitLevIdx = headers.indexOf('LevToHit')
const param1Idx = headers.indexOf('Param1')
const param2Idx = headers.indexOf('Param2')
const param3Idx = headers.indexOf('Param3')
const param8Idx = headers.indexOf('Param8')
const auraLenCalcIdx = headers.indexOf('auralencalc')
const inTownIdx = headers.indexOf('InTown')
const targetAllyIdx = headers.indexOf('TargetAlly')
const targetPetIdx = headers.indexOf('TargetPet')
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '52')
expect(row).toBeDefined()
expect(row![skillIdx]?.trim()).toBe('Enchant')
expect(row![charClassIdx]?.trim()).toBe('sor')
expect(Number(row![manaIdx])).toBe(25)
expect(Number(row![lvlManaIdx])).toBe(1)
expect(Number(row![manaShiftIdx])).toBe(8)
expect(row![castOverlayIdx]?.trim()).toBe('')
expect(row![aurastateIdx]?.trim()).toBe('enchant')
expect(Number(row![srvDoFuncIdx])).toBe(25)
expect(Number(row![srvStFuncIdx])).toBe(0)
expect(Number(row![inTownIdx])).toBe(1)
expect(Number(row![targetAllyIdx])).toBe(1)
expect(Number(row![targetPetIdx])).toBe(1)
// 5-band min & max damage definitions
expect(Number(row![eMinIdx])).toBe(16)
expect(Number(row![eMinLev1Idx])).toBe(3)
expect(Number(row![eMinLev2Idx])).toBe(7)
expect(Number(row![eMinLev3Idx])).toBe(11)
expect(Number(row![eMinLev4Idx])).toBe(15)
expect(Number(row![eMinLev5Idx])).toBe(19)
expect(Number(row![eMaxIdx])).toBe(20)
expect(Number(row![eMaxLev1Idx])).toBe(5)
expect(Number(row![eMaxLev2Idx])).toBe(9)
expect(Number(row![eMaxLev3Idx])).toBe(13)
expect(Number(row![eMaxLev4Idx])).toBe(17)
expect(Number(row![eMaxLev5Idx])).toBe(21)
// HitShift = 7 (0.5 damage per raw point)
expect(Number(row![hitShiftIdx])).toBe(7)
// Attack rating progression: 20 base, +9 per level
expect(Number(row![toHitIdx])).toBe(20)
expect(Number(row![toHitLevIdx])).toBe(9)
// Duration: 3600 frames + 600 per level
expect(Number(row![param1Idx])).toBe(3600)
expect(Number(row![param2Idx])).toBe(600)
expect(row![auraLenCalcIdx]?.trim()).toBe('ln12')
// Ranged missile penalty percent (33%) and Warmth synergy (9%)
expect(Number(row![param3Idx])).toBe(33)
expect(Number(row![param8Idx])).toBe(9)
})
})
// =========================================================================
// Dimension 2: Visual Assets & Metadata Integrity
// =========================================================================
describe('Dimension 2: Visual Assets & Metadata Integrity', () => {
it('verifies prebaked overlay assets exist on disk in public/overlays/', () => {
const pngPath = path.resolve(__dirname, '../../../public/overlays/enchant.png')
const jsonPath = path.resolve(__dirname, '../../../public/overlays/enchant.json')
expect(fs.existsSync(pngPath)).toBe(true)
expect(fs.existsSync(jsonPath)).toBe(true)
const jsonRaw = fs.readFileSync(jsonPath, 'utf8')
const parsed = JSON.parse(jsonRaw)
expect(parsed).toBeDefined()
expect(parsed.frames).toBeDefined()
})
it('verifies overlay metadata definitions in overlays-meta.ts', () => {
expect(OVERLAY_METAS['enchant']).toBeDefined()
expect(OVERLAY_METAS['enchant']).toBe(ENCHANT_OVERLAY_META)
expect(ENCHANT_OVERLAY_META.name).toBe('enchant')
expect(ENCHANT_OVERLAY_META.celFile).toBe('FireEnchant')
expect(ENCHANT_OVERLAY_META.frames).toBe(17)
expect(getSkillCastOverlay(52)).toBe('fire_cast_2')
expect(OVERLAY_METAS['fire_cast_2']).toBe(FIRE_CAST_2_META)
expect(FIRE_CAST_2_META.name).toBe('fire_cast_2')
})
})
// =========================================================================
// Dimension 3: Formulas & Scaling
// =========================================================================
describe('Dimension 3: Formulas & Scaling', () => {
it('calculates duration: 3600 + (slvl - 1) * 600 frames', () => {
expect(calculateEnchantDuration(1)).toBe(3600) // 144s
expect(calculateEnchantDuration(2)).toBe(4200) // 168s
expect(calculateEnchantDuration(10)).toBe(9000) // 360s
expect(calculateEnchantDuration(20)).toBe(15000) // 600s
expect(calculateEnchantDuration(30)).toBe(21000) // 840s
})
it('calculates attack rating percentage: 20 + (slvl - 1) * 9%', () => {
expect(calculateEnchantAttackRating(1)).toBe(20)
expect(calculateEnchantAttackRating(2)).toBe(29)
expect(calculateEnchantAttackRating(10)).toBe(101)
expect(calculateEnchantAttackRating(20)).toBe(191)
expect(calculateEnchantAttackRating(30)).toBe(281)
})
it('applies Enchant buff correctly via applyEnchantBuff helper', () => {
const target: {
enchantTicks?: number | undefined
enchantDamage?: { min: number; max: number } | undefined
enchantAttackRatingPercent?: number | undefined
} = {}
applyEnchantBuff(target, 20, { casterWarmth: 20, casterFireMasterySlvl: 20 })
expect(target.enchantTicks).toBe(15000)
expect(target.enchantAttackRatingPercent).toBe(191)
expect(target.enchantDamage).toBeDefined()
expect(target.enchantDamage!.min).toBeGreaterThan(68)
expect(target.enchantDamage!.max).toBeGreaterThan(89)
})
})
// =========================================================================
// Dimension 4: Ranged Penalty (33%) vs Sorceress Melee Double-Dip
// =========================================================================
describe('Dimension 4: Ranged Penalty (33%) vs Sorceress Melee Double-Dip', () => {
it('applies 33% penalty to ranged attacks and excludes second Fire Mastery multiplier', () => {
const baseDmg = { min: 100, max: 200 }
// Ranged Sorceress with slvl 20 Fire Mastery (163% bonus)
const rangedRes = calculateEnchantAttackDamage({
enchantDamage: baseDmg,
isRanged: true,
isSorceress: true,
fireMasterySlvl: 20,
})
// Exactly 33% of base damage, no second 2.63x multiplier!
expect(rangedRes.min).toBe(33)
expect(rangedRes.max).toBe(66)
expect(rangedRes.average).toBe(49.5)
})
it('applies Sorceress melee double-dip multiplying fire damage by Fire Mastery again', () => {
const baseDmg = { min: 100, max: 200 }
const fmSlvl = 20
const fmMultiplier = 1.0 + calculateFireMasteryBonus(fmSlvl) / 100 // 1 + 1.63 = 2.63
const meleeSorcRes = calculateEnchantAttackDamage({
enchantDamage: baseDmg,
isRanged: false,
isSorceress: true,
fireMasterySlvl: fmSlvl,
})
expect(meleeSorcRes.min).toBe(Math.round(100 * fmMultiplier * 10) / 10)
expect(meleeSorcRes.max).toBe(Math.round(200 * fmMultiplier * 10) / 10)
})
it('applies 100% base damage for non-Sorceress melee ally (no double-dip)', () => {
const baseDmg = { min: 100, max: 200 }
const allyRes = calculateEnchantAttackDamage({
enchantDamage: baseDmg,
isRanged: false,
isSorceress: false,
})
expect(allyRes.min).toBe(100)
expect(allyRes.max).toBe(200)
})
it('preserves fire immunity: enemies with fire resist >= 100% take 0 damage', () => {
const baseDmg = { min: 100, max: 200 }
const immuneRes = calculateEnchantAttackDamage({
enchantDamage: baseDmg,
isRanged: false,
isSorceress: true,
fireMasterySlvl: 20,
targetFireResist: 100,
})
expect(immuneRes.min).toBe(0)
expect(immuneRes.max).toBe(0)
expect(immuneRes.average).toBe(0)
const overcappedRes = calculateEnchantAttackDamage({
enchantDamage: baseDmg,
isRanged: false,
isSorceress: true,
fireMasterySlvl: 20,
targetFireResist: 115,
})
expect(overcappedRes.min).toBe(0)
expect(overcappedRes.max).toBe(0)
})
it('scales damage against partially resistant targets', () => {
const baseDmg = { min: 100, max: 200 }
const res50 = calculateEnchantAttackDamage({
enchantDamage: baseDmg,
isRanged: false,
isSorceress: false,
targetFireResist: 50,
})
expect(res50.min).toBe(50)
expect(res50.max).toBe(100)
expect(res50.average).toBe(75)
})
})
// =========================================================================
// Dimension 5: Synergy Math & 5-Band Integer Accuracy
// =========================================================================
describe('Dimension 5: Synergy Math & 5-Band Integer Accuracy', () => {
it('evaluates exact 5-band base progression with HitShift = 7', () => {
// slvl 1: raw 16-20 => 8.0 - 10.0 fire damage (2048 - 2560 in 256ths)
const d1 = calculateEnchantDamage(1)
expect(d1.min).toBe(8)
expect(d1.max).toBe(10)
expect(d1.min256).toBe(2048)
expect(d1.max256).toBe(2560)
// slvl 10: raw 51-73 => 25.5 - 36.5 fire damage (6528 - 9344 in 256ths)
const d10 = calculateEnchantDamage(10)
expect(d10.min).toBe(25.5)
expect(d10.max).toBe(36.5)
expect(d10.min256).toBe(6528)
expect(d10.max256).toBe(9344)
// slvl 20: raw 137-179 => 68.5 - 89.5 fire damage (17536 - 22912 in 256ths)
const d20 = calculateEnchantDamage(20)
expect(d20.min).toBe(68.5)
expect(d20.max).toBe(89.5)
expect(d20.min256).toBe(17536)
expect(d20.max256).toBe(22912)
})
it('enforces Warmth +9% synergy per hard point and rejects soft points', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(52)!
const statsBase = new UnitStatList()
statsBase.setBaseSkillLevel(52, 20)
statsBase.setBaseSkillLevel(37, 20) // Warmth 20 hard points
const withHardPoints = skillModule.evaluate!({
registry,
skill: skillRec,
slvl: 20,
blvl: 20,
statList: statsBase,
})
expect(withHardPoints.synergyBonusPct).toBe(180) // 20 * 9% = 180%
// Soft points only (+skills)
const statsSoftOnly = new UnitStatList()
statsSoftOnly.setBaseSkillLevel(52, 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('scales cast damage with Warmth synergy and Fire Mastery', () => {
// 20 Warmth (+180% synergy = 2.8x) and 20 Fire Mastery (163% bonus = 2.63x)
const baseD20 = calculateEnchantDamage(20)
const buffedD20 = calculateEnchantDamage(20, { warmth: 20, fireMastery: 20 })
expect(buffedD20.min).toBeGreaterThan(baseD20.min * 2.8 * 2.6)
expect(buffedD20.max).toBeGreaterThan(baseD20.max * 2.8 * 2.6)
})
})
// =========================================================================
// Dimension 6: Mana Progression & 0 Cooldown Ticks
// =========================================================================
describe('Dimension 6: Mana Progression & 0 Cooldown Ticks', () => {
it('calculates mana cost progression: 25 + (slvl - 1)', () => {
expect(getSkillManaCost(52, 1)).toBe(25)
expect(getSkillManaCost(52, 2)).toBe(26)
expect(getSkillManaCost(52, 10)).toBe(34)
expect(getSkillManaCost(52, 20)).toBe(44)
})
it('verifies zero cooldown ticks (no cast delay)', () => {
expect(getSkillCooldownTicks(52)).toBe(0)
})
it('verifies InTown targeting rules (can buff self, allies, pets in town; not enemies)', () => {
expect(canCastEnchantOn('self', true)).toBe(true)
expect(canCastEnchantOn('ally', true)).toBe(true)
expect(canCastEnchantOn('pet', true)).toBe(true)
expect(canCastEnchantOn('enemy', true)).toBe(false)
})
})
// =========================================================================
// Dimension 7: Invariants & Runtime Execution
// =========================================================================
describe('Dimension 7: Invariants & Runtime Execution', () => {
it('preserves BATCH1_SKILLS length strictly at 38', () => {
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
})
it('executes runtime cast in WorldArena with action frame and outcome', async () => {
const registry = await getSharedDataRegistry()
const arena = new WorldArena({
registry,
classCode: 'sor',
skillId: 52,
slvl: 20,
})
const outcome = arena.triggerCast()
arena.stepTicks(15)
const dbg = arena.getDebugState()
expect(dbg.ready).toBe(true)
expect(dbg.skillId).toBe(52)
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
})
})