/** * 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/baker/mpq-archive-source.ts' import { UnitStatList } from '../../../src/server/engine/stat-list.ts' import { WorldArena } from '../../../src/server/engine/world-arena.ts' import { skillModule } from '../../../src/server/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/server/skills/skills.ts' import { BLAZE_META, GROUNDFIREBIG_META, } from '../../../src/client/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) }) })