296 lines
12 KiB
TypeScript
296 lines
12 KiB
TypeScript
/**
|
|
* 1.13c Numerical & Runtime Verification Suite — Skill #051: Fire Wall (SOR)
|
|
* Gitea Tracking Issue: #205
|
|
*
|
|
* 7-Dimensional Verification Scope:
|
|
* Dimension 1: Static data table parsing (Skills.txt row 51, Missiles.txt IDs 82 firewallmaker, 83 firewall/groundfirebig).
|
|
* Dimension 2: Pre-baked visual assets & atlas metadata on disk (PNG/JSON exist with non-zero size).
|
|
* Dimension 3: Kinematics & 15-patch perpendicular line emitter geometry (24px spacing, 0.5 isometric ratio, obstacle clipping).
|
|
* Dimension 4: Collision detection & continuous ground ticks (hit radius 24px, per-frame damage, NextDelay = 0).
|
|
* Dimension 5: Damage scaling & synergies (5-band math: 15-20 base, Warmth +4%/lvl, Inferno +1%/lvl, 256ths precision).
|
|
* Dimension 6: Mana progression & cooldown / cast delay rules (22 + slvl - 1, strictly 35 frames / 1.40s).
|
|
* 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-051-fire-wall.ts'
|
|
import {
|
|
parseMissilesTxt,
|
|
CANONICAL_113C_MISSILES,
|
|
BATCH1_SKILLS,
|
|
calculateFireWallDamage,
|
|
calculateFireWallPatches,
|
|
calculateSkillDamage,
|
|
getSkillManaCost,
|
|
getSkillCooldownTicks,
|
|
tickProjectiles,
|
|
ISO_GROUND_ASPECT_RATIO,
|
|
type Projectile,
|
|
} from '../../../src/game/skills.ts'
|
|
import {
|
|
FIREWALL_META,
|
|
GROUNDFIREBIG_META,
|
|
} from '../../../src/render/missiles-meta.ts'
|
|
import { FIRE_CAST_2_META } from '../../../src/render/overlays-meta.ts'
|
|
|
|
describe('[Skill #051] Fire Wall (SOR) — 1.13c Parity (Issue #205)', () => {
|
|
// ==========================================================================
|
|
// 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(51)!
|
|
expect(skillRec).toBeDefined()
|
|
expect(skillModule.skillId).toBe(51)
|
|
expect(skillModule.name).toBe('Fire Wall')
|
|
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)
|
|
|
|
// firewallmaker (ID 82)
|
|
const fwm = map.get('firewallmaker')
|
|
expect(fwm).toBeDefined()
|
|
expect(fwm!.id).toBe(68)
|
|
expect(fwm!.range).toBe(7)
|
|
|
|
// firewall (ID 83)
|
|
const fw = map.get('firewall')
|
|
expect(fw).toBeDefined()
|
|
expect(fw!.id).toBe(69)
|
|
expect(fw!.range).toBe(90)
|
|
expect(fw!.animLen).toBe(37)
|
|
expect(fw!.animSpeed).toBe(16)
|
|
expect(fw!.loopAnim).toBe(1)
|
|
expect(fw!.trans).toBe(1)
|
|
expect(fw!.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 size', () => {
|
|
const publicDir = path.resolve(__dirname, '../../../public')
|
|
|
|
const firewallPng = path.join(publicDir, 'missiles/firewall.png')
|
|
const groundfirebigPng = path.join(publicDir, 'missiles/groundfirebig.png')
|
|
const castOverlayPng = path.join(publicDir, 'overlays/fire_cast_2.png')
|
|
|
|
expect(fs.existsSync(firewallPng)).toBe(true)
|
|
expect(fs.statSync(firewallPng).size).toBeGreaterThan(0)
|
|
|
|
expect(fs.existsSync(groundfirebigPng)).toBe(true)
|
|
expect(fs.statSync(groundfirebigPng).size).toBeGreaterThan(0)
|
|
|
|
expect(fs.existsSync(castOverlayPng)).toBe(true)
|
|
expect(fs.statSync(castOverlayPng).size).toBeGreaterThan(0)
|
|
|
|
// Metas
|
|
expect(FIREWALL_META.name).toBe('firewall')
|
|
expect(FIREWALL_META.framesPerDirection).toBe(37)
|
|
expect(GROUNDFIREBIG_META.name).toBe('groundfirebig')
|
|
expect(GROUNDFIREBIG_META.framesPerDirection).toBe(37)
|
|
expect(FIRE_CAST_2_META.name).toBe('fire_cast_2')
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Dimension 3: Kinematics & 15-Patch Perpendicular Line Emitter Geometry
|
|
// ==========================================================================
|
|
it('Dimension 3: calculates strictly perpendicular 15-patch emitter with 24px spacing and obstacle clipping', () => {
|
|
const casterX = 100
|
|
const casterY = 100
|
|
const targetX = 200
|
|
const targetY = 100 // Travel vector is purely horizontal (+100, 0)
|
|
|
|
const patches = calculateFireWallPatches(casterX, casterY, targetX, targetY, {
|
|
count: 15,
|
|
spacing: 24,
|
|
})
|
|
|
|
expect(patches).toHaveLength(15)
|
|
|
|
// Center patch (i = 0) must be exactly at target
|
|
const center = patches.find(p => p.index === 0)!
|
|
expect(center.x).toBe(targetX)
|
|
expect(center.y).toBe(targetY)
|
|
|
|
// Since travel vector is (100, 0), normal is vertical: (0, 1)
|
|
// In 2:1 isometric ground ratio, dy offset is scaled by ISO_GROUND_ASPECT_RATIO (0.5)
|
|
// For i = 1: x = targetX, y = targetY + 1 * 24 * 1 * 0.5 = 100 + 12 = 112
|
|
const p1 = patches.find(p => p.index === 1)!
|
|
expect(p1.x).toBe(targetX)
|
|
expect(p1.y).toBe(targetY + 12)
|
|
|
|
// Test dot product of travel vector and patch displacement vector is strictly 0 (perpendicular)
|
|
const travelDx = targetX - casterX
|
|
const travelDy = targetY - casterY
|
|
for (const p of patches) {
|
|
if (p.index === 0) continue
|
|
const patchDisplacementX = p.x - targetX
|
|
// Unscale isometric Y to get true world normal
|
|
const unscaledPatchDisplacementY = (p.y - targetY) / ISO_GROUND_ASPECT_RATIO
|
|
const dot = travelDx * patchDisplacementX + travelDy * unscaledPatchDisplacementY
|
|
expect(Math.abs(dot)).toBeLessThan(1e-4)
|
|
}
|
|
|
|
// Obstacle clipping: suppressed patches landing on obstacles
|
|
const blockedPatches = calculateFireWallPatches(casterX, casterY, targetX, targetY, {
|
|
count: 15,
|
|
spacing: 24,
|
|
isBlocked: (x, y) => y > 120, // block patches with y > 120
|
|
})
|
|
expect(blockedPatches.length).toBeLessThan(15)
|
|
for (const bp of blockedPatches) {
|
|
expect(bp.y).toBeLessThanOrEqual(120)
|
|
}
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Dimension 4: Collision Detection & Continuous Ground Ticks
|
|
// ==========================================================================
|
|
it('Dimension 4: standing in fire inflicts continuous damage per frame with NextDelay = 0', () => {
|
|
const patch: Projectile = {
|
|
skillId: '51',
|
|
x: 200,
|
|
y: 200,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: 100,
|
|
ttl: 90,
|
|
fromPlayer: true,
|
|
missileType: 'firewall',
|
|
pierce: true,
|
|
aoeRadius: 24,
|
|
}
|
|
|
|
const enemy = {
|
|
index: 1,
|
|
alive: true,
|
|
x: 200,
|
|
y: 200,
|
|
radius: 16,
|
|
}
|
|
|
|
const terrain = { overlap: () => 0 }
|
|
|
|
// Tick 1
|
|
const outcome1 = tickProjectiles([patch], [enemy], terrain)
|
|
expect(outcome1.hits).toHaveLength(1)
|
|
expect(outcome1.hits[0]!.damage).toBe(100)
|
|
|
|
// Tick 2: Since it is a ground fire patch, hitTargetIds clears every frame -> damages again!
|
|
const outcome2 = tickProjectiles(outcome1.alive, [enemy], terrain)
|
|
expect(outcome2.hits).toHaveLength(1)
|
|
expect(outcome2.hits[0]!.damage).toBe(100)
|
|
|
|
// Patch persists for full 90 ticks and does not terminate on enemy contact
|
|
expect(outcome2.alive).toHaveLength(1)
|
|
expect(outcome2.alive[0]!.ttl).toBe(88)
|
|
})
|
|
|
|
// ==========================================================================
|
|
// 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(51)!
|
|
|
|
// Level 1: raw 15-20 -> in 256ths: 240 - 320
|
|
const stats1 = new UnitStatList()
|
|
stats1.setBaseSkillLevel(51, 1)
|
|
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
|
|
expect(r1.minElemDmg256).toBe(240)
|
|
expect(r1.maxElemDmg256).toBe(320)
|
|
|
|
const d1 = calculateFireWallDamage(1)
|
|
expect(d1.min256).toBe(240)
|
|
expect(d1.max256).toBe(320)
|
|
expect(d1.perSecMin).toBeCloseTo(23.44, 1)
|
|
expect(d1.perSecMax).toBeCloseTo(31.25, 1)
|
|
|
|
// Level 10: raw 106-111 -> in 256ths: 1696 - 1776
|
|
const stats10 = new UnitStatList()
|
|
stats10.setBaseSkillLevel(51, 10)
|
|
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
|
|
expect(r10.minElemDmg256).toBe(1696)
|
|
expect(r10.maxElemDmg256).toBe(1776)
|
|
|
|
const d10 = calculateFireWallDamage(10)
|
|
expect(d10.min256).toBe(1696)
|
|
expect(d10.max256).toBe(1776)
|
|
expect(d10.perSecMin).toBeCloseTo(165.62, 1)
|
|
expect(d10.perSecMax).toBeCloseTo(173.44, 1)
|
|
|
|
// Level 20: raw 274-279 -> in 256ths: 4384 - 4464
|
|
const stats20 = new UnitStatList()
|
|
stats20.setBaseSkillLevel(51, 20)
|
|
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
|
expect(r20.minElemDmg256).toBe(4384)
|
|
expect(r20.maxElemDmg256).toBe(4464)
|
|
|
|
const d20 = calculateFireWallDamage(20)
|
|
expect(d20.min256).toBe(4384)
|
|
expect(d20.max256).toBe(4464)
|
|
expect(d20.perSecMin).toBeCloseTo(428.12, 1)
|
|
expect(d20.perSecMax).toBeCloseTo(435.94, 1)
|
|
|
|
// Synergy: Warmth (+4%/lvl), Inferno (+1%/lvl)
|
|
// 20 Warmth points = +80% damage
|
|
const d20Syn = calculateFireWallDamage(20, { warmth: 20, inferno: 0 })
|
|
expect(d20Syn.min256).toBe(Math.floor(4384 * 1.8))
|
|
expect(d20Syn.max256).toBe(Math.floor(4464 * 1.8))
|
|
|
|
// Hard point synergy rule: soft points (allskills/charged) provide 0 synergy
|
|
const statsSoftOnly = new UnitStatList()
|
|
statsSoftOnly.setBaseSkillLevel(51, 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 strictly 35 frames cast delay', () => {
|
|
// Mana: 22 + (slvl - 1)
|
|
expect(getSkillManaCost(51, 1)).toBe(22)
|
|
expect(getSkillManaCost(51, 10)).toBe(31)
|
|
expect(getSkillManaCost(51, 20)).toBe(41)
|
|
|
|
// Cooldown / Cast Delay: strictly 35 frames (1.40s)
|
|
expect(getSkillCooldownTicks(51)).toBe(35)
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Dimension 7: Invariants & Integration
|
|
// ==========================================================================
|
|
it('Dimension 7: enforces BATCH1_SKILLS length invariant = 38 and routing via calculateSkillDamage', () => {
|
|
// STRICT INVARIANT: BATCH1_SKILLS length must remain exactly 38
|
|
expect(Object.keys(BATCH1_SKILLS)).toHaveLength(38)
|
|
|
|
// Routing in calculateSkillDamage
|
|
const dmg1 = calculateSkillDamage(51, 1)
|
|
expect(dmg1.min).toBe(15)
|
|
expect(dmg1.max).toBe(20)
|
|
|
|
const dmg10 = calculateSkillDamage('51', 10)
|
|
expect(dmg10.min).toBe(106)
|
|
expect(dmg10.max).toBe(111)
|
|
|
|
const dmg20 = calculateSkillDamage('firewall', 20)
|
|
expect(dmg20.min).toBe(274)
|
|
expect(dmg20.max).toBe(279)
|
|
|
|
// Canonical missiles
|
|
expect(CANONICAL_113C_MISSILES.firewallmaker).toBeDefined()
|
|
expect(CANONICAL_113C_MISSILES.firewall).toBeDefined()
|
|
expect(CANONICAL_113C_MISSILES.groundfirebig).toBeDefined()
|
|
})
|
|
})
|