275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
/**
|
|
* 1.13c Numerical & Runtime Verification Suite — Skill #046: Blaze (SOR)
|
|
* Gitea Tracking Issue: #200
|
|
*
|
|
* 7-Dimensional Verification Scope:
|
|
* Dimension 1: Static data table parsing (Skills.txt row 46, Missiles.txt ID 67 blaze).
|
|
* Dimension 2: Pre-baked visual assets & atlas metadata on disk (PNG/JSON exist with non-zero size).
|
|
* Dimension 3: Kinematics & buff/patch duration formulas (calculateBlazeDuration, calculateBlazePatchDuration, +30% FRW boost).
|
|
* Dimension 4: Collision detection & continuous ground ticks (self-hit immunity, per-frame damage, NextDelay = 0).
|
|
* Dimension 5: Damage scaling & synergies (5-band math: 4-8 base, Warmth +4%/lvl, Fire Wall +1%/lvl, 256ths precision).
|
|
* Dimension 6: Mana progression & cooldown / cast delay rules (floor((22 + slvl - 1) * 0.5), strictly 0 frames).
|
|
* 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-046-blaze.ts'
|
|
import {
|
|
parseMissilesTxt,
|
|
CANONICAL_113C_MISSILES,
|
|
BATCH1_SKILLS,
|
|
calculateBlazeDamage,
|
|
calculateBlazeDuration,
|
|
calculateBlazePatchDuration,
|
|
calculateSkillDamage,
|
|
getSkillManaCost,
|
|
getSkillCooldownTicks,
|
|
tickProjectiles,
|
|
type Projectile,
|
|
} from '../../../src/game/skills.ts'
|
|
import {
|
|
BLAZE_META,
|
|
GROUNDFIREBIG_META,
|
|
} from '../../../src/render/missiles-meta.ts'
|
|
|
|
describe('[Skill #046] Blaze (SOR) — 1.13c Parity (Issue #200)', () => {
|
|
// ==========================================================================
|
|
// 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(46)!
|
|
expect(skillRec).toBeDefined()
|
|
expect(skillModule.skillId).toBe(46)
|
|
expect(skillModule.name).toBe('Blaze')
|
|
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)
|
|
|
|
// blaze (ID 67)
|
|
const blz = map.get('blaze')
|
|
expect(blz).toBeDefined()
|
|
expect(blz!.id).toBe(67)
|
|
expect(blz!.range).toBe(90)
|
|
expect(blz!.levRange).toBe(25)
|
|
expect(blz!.animLen).toBe(37)
|
|
expect(blz!.animSpeed).toBe(16)
|
|
expect(blz!.loopAnim).toBe(1)
|
|
expect(blz!.trans).toBe(1)
|
|
expect(blz!.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 blazePng = path.resolve(__dirname, '../../../public/missiles/blaze.png')
|
|
const groundfirebigPng = path.resolve(__dirname, '../../../public/missiles/groundfirebig.png')
|
|
|
|
expect(fs.existsSync(blazePng)).toBe(true)
|
|
expect(fs.statSync(blazePng).size).toBeGreaterThan(0)
|
|
|
|
expect(fs.existsSync(groundfirebigPng)).toBe(true)
|
|
expect(fs.statSync(groundfirebigPng).size).toBeGreaterThan(0)
|
|
|
|
// Atlas metadata
|
|
expect(BLAZE_META).toBeDefined()
|
|
expect(BLAZE_META.name).toBe('blaze')
|
|
expect(BLAZE_META.framesPerDirection).toBe(37)
|
|
|
|
expect(GROUNDFIREBIG_META).toBeDefined()
|
|
expect(GROUNDFIREBIG_META.name).toBe('groundfirebig')
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Dimension 3: Kinematics & Buff / Footstep Duration Formulas
|
|
// ==========================================================================
|
|
it('Dimension 3: calculates exact 1.13c buff duration, patch duration, and movement dynamics', () => {
|
|
// Buff duration: floor((110 * slvl * 450) / (100 * (slvl + 6))) + 50
|
|
expect(calculateBlazeDuration(1)).toBe(120) // floor(49500 / 700) + 50 = 70 + 50
|
|
expect(calculateBlazeDuration(10)).toBe(359) // floor(495000 / 1600) + 50 = 309 + 50
|
|
expect(calculateBlazeDuration(20)).toBe(430) // floor(990000 / 2600) + 50 = 380 + 50
|
|
|
|
// Patch duration: 90 + slvl * 25
|
|
expect(calculateBlazePatchDuration(1)).toBe(115)
|
|
expect(calculateBlazePatchDuration(10)).toBe(340)
|
|
expect(calculateBlazePatchDuration(20)).toBe(590)
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Dimension 4: Collision Detection & Continuous Ground Ticks
|
|
// ==========================================================================
|
|
it('Dimension 4: damages enemies continuously per frame and enforces self-hit immunity', () => {
|
|
const patch: Projectile = {
|
|
skillId: '46',
|
|
x: 300,
|
|
y: 300,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: 50,
|
|
ttl: 115,
|
|
fromPlayer: true,
|
|
missileType: 'blaze',
|
|
pierce: true,
|
|
aoeRadius: 24,
|
|
}
|
|
|
|
const casterPlayer = {
|
|
index: 0,
|
|
alive: true,
|
|
x: 300,
|
|
y: 300,
|
|
radius: 16,
|
|
isPlayer: true,
|
|
}
|
|
|
|
const enemy = {
|
|
index: 1,
|
|
alive: true,
|
|
x: 300,
|
|
y: 300,
|
|
radius: 16,
|
|
}
|
|
|
|
const terrain = { overlap: () => 0 }
|
|
|
|
// Tick 1: Caster standing directly on the patch takes NO damage (self-hit immunity), enemy takes 50
|
|
const outcome1 = tickProjectiles([patch], [casterPlayer, enemy], terrain)
|
|
expect(outcome1.hits).toHaveLength(1)
|
|
expect(outcome1.hits[0]!.targetIndex).toBe(1)
|
|
expect(outcome1.hits[0]!.damage).toBe(50)
|
|
|
|
// Tick 2: Continuous damage resets hitTargets -> enemy hit again, caster still immune
|
|
const outcome2 = tickProjectiles(outcome1.alive, [casterPlayer, enemy], terrain)
|
|
expect(outcome2.hits).toHaveLength(1)
|
|
expect(outcome2.hits[0]!.targetIndex).toBe(1)
|
|
expect(outcome2.hits[0]!.damage).toBe(50)
|
|
|
|
// Patch persists across ticks with decremented ttl
|
|
expect(outcome2.alive).toHaveLength(1)
|
|
expect(outcome2.alive[0]!.ttl).toBe(113)
|
|
})
|
|
|
|
// ==========================================================================
|
|
// 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(46)!
|
|
|
|
// Level 1: raw 4-8 -> in 256ths: 64 - 128
|
|
const stats1 = new UnitStatList()
|
|
stats1.setBaseSkillLevel(46, 1)
|
|
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
|
|
expect(r1.minElemDmg256).toBe(64)
|
|
expect(r1.maxElemDmg256).toBe(128)
|
|
|
|
const d1 = calculateBlazeDamage(1)
|
|
expect(d1.min256).toBe(64)
|
|
expect(d1.max256).toBe(128)
|
|
expect(d1.perSecMin).toBeCloseTo(6.25, 1)
|
|
expect(d1.perSecMax).toBeCloseTo(12.5, 1)
|
|
|
|
// Level 10: raw 24-28 -> in 256ths: 384 - 448
|
|
const stats10 = new UnitStatList()
|
|
stats10.setBaseSkillLevel(46, 10)
|
|
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
|
|
expect(r10.minElemDmg256).toBe(384)
|
|
expect(r10.maxElemDmg256).toBe(448)
|
|
|
|
const d10 = calculateBlazeDamage(10)
|
|
expect(d10.min256).toBe(384)
|
|
expect(d10.max256).toBe(448)
|
|
expect(d10.perSecMin).toBeCloseTo(37.5, 1)
|
|
expect(d10.perSecMax).toBeCloseTo(43.75, 1)
|
|
|
|
// Level 20: raw 58-62 -> in 256ths: 928 - 992
|
|
const stats20 = new UnitStatList()
|
|
stats20.setBaseSkillLevel(46, 20)
|
|
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
|
expect(r20.minElemDmg256).toBe(928)
|
|
expect(r20.maxElemDmg256).toBe(992)
|
|
|
|
const d20 = calculateBlazeDamage(20)
|
|
expect(d20.min256).toBe(928)
|
|
expect(d20.max256).toBe(992)
|
|
expect(d20.perSecMin).toBeCloseTo(90.625, 1)
|
|
expect(d20.perSecMax).toBeCloseTo(96.875, 1)
|
|
|
|
// Synergy: Warmth (+4%/lvl), Fire Wall (+1%/lvl)
|
|
// 20 Warmth points = +80% damage
|
|
const d20Syn = calculateBlazeDamage(20, { warmth: 20, fireWall: 0 })
|
|
expect(d20Syn.min256).toBe(Math.floor(928 * 1.8))
|
|
expect(d20Syn.max256).toBe(Math.floor(992 * 1.8))
|
|
|
|
// Hard point synergy rule: soft points (+allskills / charges) provide 0 synergy
|
|
const statsSoftOnly = new UnitStatList()
|
|
statsSoftOnly.setBaseSkillLevel(46, 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 & Cooldown / Cast Delay
|
|
// ==========================================================================
|
|
it('Dimension 6: verifies authentic mana progression and zero cast delay', () => {
|
|
// Mana: floor((22 + slvl - 1) * 0.5)
|
|
expect(getSkillManaCost(46, 1)).toBe(11)
|
|
expect(getSkillManaCost(46, 10)).toBe(15)
|
|
expect(getSkillManaCost(46, 20)).toBe(20)
|
|
|
|
// Cooldown / Cast Delay: strictly 0 frames (Blaze has no cast delay)
|
|
expect(getSkillCooldownTicks(46)).toBe(0)
|
|
})
|
|
|
|
// ==========================================================================
|
|
// 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(46, 1)
|
|
expect(dmg1.min).toBe(4)
|
|
expect(dmg1.max).toBe(8)
|
|
|
|
const dmg10 = calculateSkillDamage('46', 10)
|
|
expect(dmg10.min).toBe(24)
|
|
expect(dmg10.max).toBe(28)
|
|
|
|
const dmg20 = calculateSkillDamage('blaze', 20)
|
|
expect(dmg20.min).toBe(58)
|
|
expect(dmg20.max).toBe(62)
|
|
|
|
// Canonical missiles
|
|
expect(CANONICAL_113C_MISSILES.blaze).toBeDefined()
|
|
|
|
// WorldArena Execution
|
|
const registry = await getSharedDataRegistry()
|
|
const arena = new WorldArena({
|
|
registry,
|
|
classCode: 'sor',
|
|
skillId: 46,
|
|
slvl: 20,
|
|
})
|
|
const outcome = arena.triggerCast()
|
|
arena.stepTicks(15)
|
|
const dbg = arena.getDebugState()
|
|
|
|
expect(dbg.ready).toBe(true)
|
|
expect(dbg.skillId).toBe(46)
|
|
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
|
expect(dbg.actionFrameTriggered).toBe(true)
|
|
expect(outcome?.executed).toBe(true)
|
|
})
|
|
})
|