/** * Diablo II: Lord of Destruction v1.13c — Fire Bolt (Skill 36) & Missiles.txt Parity Test Suite * * Requirements (Issue #385): * 1. Read missile speed (500 px/s, Vel=20) and flight distance (1000 px, Range=50) directly from 1.13c Missiles.txt. * 2. Fire Bolt projectile in flight spawns with missileType: 'firebolt' and authentic physical parameters. * 3. Fire Bolt projectile hit inflicts damage and triggers 1.13c fireexplode explosion (12 frames). * 4. Terrain collision (wall hit) triggers fireexplode explosion. * 5. Visual rendering routines for fireball and explosion execute with WebGL batcher. */ import { describe, expect, it } from 'vitest' import fs from 'node:fs' import path from 'node:path' import { Rng } from '../src/game/rng.ts' import { parseMissilesTxt, getMissileTxtData, skillFromRow, BATCH1_SKILLS, getBatch1SkillDef, castSkill, tickProjectiles, type Projectile, } from '../src/game/skills.ts' import { getSkillRange, drawFireboltProjectile, drawFireExplosion, type LoadedMissileArt } from '../src/scene/act-scene.ts' import { GameEngine } from '../src/game/engine.ts' import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/game/demo-data.ts' import { SpriteRenderer, type AtlasHandle } from '../src/render/renderer.ts' import { FIREBOLT_META, FIREEXPLODE_META, velocityToDccDirection } from '../src/render/missiles-meta.ts' describe('Issue #385 — Fire Bolt (Skill #36) & Missiles.txt Ground Truth', () => { const missilesTxtPath = path.resolve(__dirname, '../samples/fixtures/data/global/excel/Missiles.txt') const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1') it('1. Parses authentic 1.13c Missiles.txt file and resolves firebolt & fireexplode physics', () => { expect(missilesTxtContent.length).toBeGreaterThan(10000) const map = parseMissilesTxt(missilesTxtContent) // Verify firebolt (Id: 58) const firebolt = map.get('firebolt') expect(firebolt).toBeDefined() expect(firebolt!.id).toBe(58) expect(firebolt!.vel).toBe(20) expect(firebolt!.range).toBe(50) expect(firebolt!.speedPxPerSec).toBe(500) // 20 * 25 expect(firebolt!.distancePx).toBe(1000) // 20 * 50 expect(firebolt!.celFile.toLowerCase()).toBe('firebolt') expect(firebolt!.explosionMissile.toLowerCase()).toBe('fireexplode') expect(firebolt!.light).toBe(7) expect(firebolt!.red).toBe(255) expect(firebolt!.green).toBe(178) expect(firebolt!.blue).toBe(64) // Verify fireexplode (Id: 29) const fireexplode = map.get('fireexplode') expect(fireexplode).toBeDefined() expect(fireexplode!.id).toBe(29) expect(fireexplode!.vel).toBe(0) expect(fireexplode!.range).toBe(12) // 12 animation frames at 25 fps expect(fireexplode!.celFile.toLowerCase()).toBe('firearrowexplode2') expect(fireexplode!.animLen).toBe(12) expect(fireexplode!.animSpeed).toBe(16) }) it('2. getMissileTxtData resolves firebolt parameters from raw text, map, or canonical fallback', () => { // From raw content string const dataFromTxt = getMissileTxtData('firebolt', missilesTxtContent) expect(dataFromTxt.vel).toBe(20) expect(dataFromTxt.range).toBe(50) expect(dataFromTxt.speedPxPerSec).toBe(500) expect(dataFromTxt.distancePx).toBe(1000) expect(dataFromTxt.explosionMissile).toBe('fireexplode') // From parsed map const map = parseMissilesTxt(missilesTxtContent) const dataFromMap = getMissileTxtData('firebolt', map) expect(dataFromMap.speedPxPerSec).toBe(500) expect(dataFromMap.distancePx).toBe(1000) // Fallback without explicit table source (guarantees zero-network / missing-file resilience) const fallback = getMissileTxtData('firebolt', undefined) expect(fallback.vel).toBe(20) expect(fallback.range).toBe(50) expect(fallback.speedPxPerSec).toBe(500) expect(fallback.distancePx).toBe(1000) expect(fallback.explosionMissile).toBe('fireexplode') }) it('3. skillFromRow resolves speed and range from Missiles.txt via srvmissile', () => { const row = { Id: '36', skill: 'Fire Bolt', skilldesc: 'fire bolt', srvmissile: 'firebolt', cltmissile: 'firebolt', mana: '2', mindam: '6', maxdam: '12', } const def = skillFromRow(row, 36, { get: () => 'Fire Bolt' }, missilesTxtContent) expect(def.id).toBe('36') expect(def.projectile).toBe(true) expect(def.speed).toBe(500) expect(def.range).toBe(1000) }) it('4. BATCH1_SKILLS[36] and getSkillRange(36) enforce range = 1000 and speed = 500', () => { const batch1 = getBatch1SkillDef(36)! expect(batch1).toBeDefined() expect(batch1.name).toBe('Fire Bolt') expect(batch1.range).toBe(1000) expect(batch1.speed).toBe(500) const skillRange = getSkillRange(36) expect(skillRange).toBe(1000) }) it('5. Projectile cast for Fire Bolt produces missileType: "firebolt" with exact 20 px/tick velocity', () => { const def = { id: '36', name: 'Fire Bolt', manaCost: 2, cooldownTicks: 0, range: 1000, projectile: true, speed: 500, baseMinDamage: 6, baseMaxDamage: 12, damagePerLevel: 2, radius: 0, } const caster = { x: 100, y: 100, facing: 6 } // facing East: [1, 0] const result = castSkill(def, caster, 1, new Rng(1), 0, 100) expect(result.kind).toBe('projectile') if (result.kind === 'projectile') { const p = result.projectile expect(p.skillId).toBe('36') expect(p.missileType).toBe('firebolt') expect(p.vx).toBe(20) // 500 / 25 expect(p.vy).toBe(0) expect(p.ttl).toBe(50) // 1000 / 20 = 50 ticks } }) it('6. tickProjectiles hits monster target, records missileType and hit position', () => { const projectile: Projectile = { skillId: '36', missileType: 'firebolt', x: 100, y: 100, vx: 20, vy: 0, damage: 15, ttl: 50, fromPlayer: true, } const targets = [ { index: 0, x: 120, y: 100, radius: 10, alive: true }, ] const outcome = tickProjectiles([projectile], targets, { overlap: () => 0 }) expect(outcome.hits.length).toBe(1) expect(outcome.hits[0]!.targetIndex).toBe(0) expect(outcome.hits[0]!.damage).toBe(15) expect(outcome.hits[0]!.x).toBe(120) expect(outcome.hits[0]!.y).toBe(100) expect(outcome.hits[0]!.missileType).toBe('firebolt') expect(outcome.alive.length).toBe(0) // hit monster and consumed }) it('7. GameEngine.tick processes Fire Bolt hit, damages monster and spawns 12-frame fireexplode', () => { const engine = new GameEngine( { widthPx: 1000, heightPx: 1000, overlap: () => 0 }, { spawn: { x: 100, y: 100 }, stats: [ { id: 'fallen', name: 'Fallen', hp: 30, damage: 2, cooldownTicks: 25, reach: 30, aggroRadius: 100, speed: 4, xp: 10, rank: 'normal', }, ], xpTable: DEMO_EXPERIENCE, skills: DEMO_SKILLS, npcDefs: [], questDefs: DEMO_QUESTS, combatOptions: { playerSpeed: 4, playerReach: 50, playerCooldownTicks: 10, playerDamage: 5, playerManaPerAttack: 1, respawnTicks: 100, }, talkRadius: 50, pickupRadius: 30, inventoryCols: 10, inventoryRows: 4, monsterCount: 1, }, ) // Position monster right in front of player const monster = engine.world.monsters[0]! monster.x = 120 monster.y = 100 const initialHp = monster.hp // Add Fire Bolt projectile in flight towards monster engine.projectiles.push({ skillId: '36', missileType: 'firebolt', x: 100, y: 100, vx: 20, vy: 0, damage: 18, ttl: 50, fromPlayer: true, }) // Tick the engine engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [], saving: false, loading: false, }) // Monster took damage expect(monster.hp).toBe(initialHp - 18) expect(engine.metrics.castHits).toBe(1) // Active explosion spawned with 12 frames expect(engine.explosions.length).toBe(1) const explosion = engine.explosions[0]! expect(explosion.missileType).toBe('fireexplode') expect(explosion.x).toBe(120) expect(explosion.y).toBe(100) expect(explosion.maxFrames).toBe(12) // missileExplode combat event emitted const explodeEvent = engine.world.events.find(e => e.kind === 'missileExplode') expect(explodeEvent).toBeDefined() expect(explodeEvent!.x).toBe(120) expect(explodeEvent!.y).toBe(100) expect(explodeEvent!.missileType).toBe('fireexplode') // Advance 12 ticks: explosion should advance frames and expire cleanly for (let i = 0; i < 12; i += 1) { engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [], saving: false, loading: false, }) } expect(engine.explosions.length).toBe(0) }) it('8. Terrain wall collision triggers fireexplode explosion', () => { const engine = new GameEngine( { widthPx: 1000, heightPx: 1000, // Mock a wall at x >= 120 overlap: (x) => (x >= 120 ? 1 : 0), }, { spawn: { x: 100, y: 100 }, stats: [], xpTable: DEMO_EXPERIENCE, skills: DEMO_SKILLS, npcDefs: [], questDefs: DEMO_QUESTS, combatOptions: { playerSpeed: 4, playerReach: 50, playerCooldownTicks: 10, playerDamage: 5, playerManaPerAttack: 1, respawnTicks: 100, }, talkRadius: 50, pickupRadius: 30, inventoryCols: 10, inventoryRows: 4, }, ) engine.projectiles.push({ skillId: '36', missileType: 'firebolt', x: 100, y: 100, vx: 20, vy: 0, damage: 18, ttl: 50, fromPlayer: true, }) engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [], saving: false, loading: false, }) // Projectile was blocked by wall and stopped expect(engine.projectiles.length).toBe(0) // Triggered wall explosion expect(engine.explosions.length).toBe(1) expect(engine.explosions[0]!.missileType).toBe('fireexplode') expect(engine.explosions[0]!.x).toBe(120) expect(engine.explosions[0]!.y).toBe(100) }) it('9. drawFireboltProjectile and drawFireExplosion execute without errors in WebGL SpriteRenderer', () => { // Create a mock canvas & WebGL2 context or SpriteRenderer mock const drawnQuads: any[] = [] const mockRenderer = { drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) { drawnQuads.push({ x, y, w, h, color }) }, } as unknown as SpriteRenderer const shot: Projectile = { skillId: '36', missileType: 'firebolt', x: 200, y: 150, vx: 16, vy: 12, damage: 16, ttl: 40, fromPlayer: true, } drawFireboltProjectile(mockRenderer, shot) // Core + glow + 5 trail puffs = at least 9 solid quads expect(drawnQuads.length).toBeGreaterThanOrEqual(9) // Check explosion rendering at early frame (flash) and mid frame drawnQuads.length = 0 drawFireExplosion(mockRenderer, { missileType: 'fireexplode', x: 200, y: 150, frame: 1, maxFrames: 12, }) // 2 blast layers + 1 flash core + 8 radial sparks = 11 quads expect(drawnQuads.length).toBe(11) }) it('10. Verifies offline baked authentic missile DCC assets and metadata', () => { const publicFireboltPng = path.resolve(__dirname, '../public/missiles/firebolt.png') const publicFireboltJson = path.resolve(__dirname, '../public/missiles/firebolt.json') const publicExplodePng = path.resolve(__dirname, '../public/missiles/fireexplode.png') const publicExplodeJson = path.resolve(__dirname, '../public/missiles/fireexplode.json') expect(fs.existsSync(publicFireboltPng)).toBe(true) expect(fs.statSync(publicFireboltPng).size).toBeGreaterThan(50000) expect(fs.existsSync(publicFireboltJson)).toBe(true) const fireboltJson = JSON.parse(fs.readFileSync(publicFireboltJson, 'utf-8')) expect(fireboltJson.name).toBe('firebolt') expect(fireboltJson.celFile).toBe('Firebolt') expect(fireboltJson.directions).toBe(16) expect(fireboltJson.framesPerDirection).toBe(5) expect(fireboltJson.groups.length).toBe(16) expect(fireboltJson.groups[0].length).toBe(5) expect(fs.existsSync(publicExplodePng)).toBe(true) expect(fs.statSync(publicExplodePng).size).toBeGreaterThan(10000) expect(fs.existsSync(publicExplodeJson)).toBe(true) const explodeJson = JSON.parse(fs.readFileSync(publicExplodeJson, 'utf-8')) expect(explodeJson.name).toBe('fireexplode') expect(explodeJson.celFile).toBe('FireArrowExplode2') expect(explodeJson.directions).toBe(1) expect(explodeJson.framesPerDirection).toBe(12) expect(explodeJson.groups[0].length).toBe(12) // Check samples/d2-packs mirrors expect(fs.existsSync(path.resolve(__dirname, '../samples/d2-packs/missiles/firebolt.png'))).toBe(true) expect(fs.existsSync(path.resolve(__dirname, '../samples/d2-packs/missiles/fireexplode.png'))).toBe(true) }) it('11. Renders authentic baked DCC frames with correct direction mapping and sprite-space anchors', () => { const drawnSprites: any[] = [] const mockAtlasHandle = { texture: {} as WebGLTexture, width: 512, height: 439, } as AtlasHandle const mockRenderer = { draw(frame: any, x: number, y: number, options?: any) { drawnSprites.push({ frame, x, y, options }) }, drawSolid() {}, } as unknown as SpriteRenderer // Build loaded art from metadata const fireboltFrames = FIREBOLT_META.groups.map(group => group.map(([x, y, width, height, anchorX, anchorY]) => ({ x, y, width, height, anchorX, anchorY, })) ) const fireboltArt: LoadedMissileArt = { meta: FIREBOLT_META, handle: mockAtlasHandle, frames: fireboltFrames, } // Shot moving East (+x): vx = 20, vy = 0 const dccEast = velocityToDccDirection(20, 0, 16) expect(dccEast).toBeDefined() const shot: Projectile = { skillId: '36', missileType: 'firebolt', x: 300, y: 200, vx: 20, vy: 0, damage: 16, ttl: 48, fromPlayer: true, } drawFireboltProjectile(mockRenderer, shot, fireboltArt) expect(drawnSprites.length).toBe(1) const drawn = drawnSprites[0]! expect(drawn.options.atlas).toBe(mockAtlasHandle) // Verify sprite is positioned with anchor offset: x + anchorX, y + anchorY expect(drawn.x).toBe(300 + drawn.frame.anchorX) expect(drawn.y).toBe(200 + drawn.frame.anchorY) // Verify explosion DCC rendering const explodeFrames = FIREEXPLODE_META.groups.map(group => group.map(([x, y, width, height, anchorX, anchorY]) => ({ x, y, width, height, anchorX, anchorY, })) ) const explodeArt: LoadedMissileArt = { meta: FIREEXPLODE_META, handle: mockAtlasHandle, frames: explodeFrames, } drawnSprites.length = 0 drawFireExplosion(mockRenderer, { missileType: 'fireexplode', x: 350, y: 250, frame: 3, maxFrames: 12, }, explodeArt) expect(drawnSprites.length).toBe(1) const expDrawn = drawnSprites[0]! expect(expDrawn.options.atlas).toBe(mockAtlasHandle) expect(expDrawn.x).toBe(350 + expDrawn.frame.anchorX) expect(expDrawn.y).toBe(250 + expDrawn.frame.anchorY) }) })