351 lines
14 KiB
TypeScript
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)
|
|
})
|
|
})
|