/** * Diablo II: Lord of Destruction v1.13c — Issue #440 Verification Suite * Fire Ball (Skill #047) AoE Explosion & Blast Visual Ground Truth * * Invariants: * 1. Missiles.txt: fireball explosionMissile === 'explodingarrowexp' (ID 42, CelFile ExpArrowExplode, 16 frames). * 2. 80px (4 subtiles) AoE splash radius in 2:1 isometric ground perspective (sHitPar1: 4). * 3. Dynamic damage calculation with 1.13c 5-band level scaling and +14% synergy from Fire Bolt / Meteor. * 4. Wall collision and target impact trigger full 16-frame explodingarrowexp detonation. */ import { describe, expect, it } from 'vitest' import { getMissileTxtData, CANONICAL_113C_MISSILES, calculateFireBallDamage, calculateFireBallSynergyMultiplier, calculateSkillDamage, ISO_GROUND_ASPECT_RATIO, } from '../../../src/server/skills/skills.ts' import { MISSILE_METAS } from '../../../src/client/render/missiles-meta.ts' import { GameEngine } from '../../../src/server/engine/engine.ts' import { castSkill } from '../../../src/server/scene/skill-caster.ts' import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../../../src/common/data/demo-data.ts' function createTestEngine( monsters?: { x: number; y: number; hp?: number }[], terrainOverrides?: any, ) { const terrain = { widthPx: 1000, heightPx: 1000, overlap: () => 0, ...terrainOverrides, } const engine = new GameEngine(terrain, { spawn: { x: 100, y: 100 }, stats: [], xpTable: DEMO_EXPERIENCE, skills: DEMO_SKILLS, npcDefs: [], questDefs: DEMO_QUESTS, combatOptions: { playerSpeed: 4, playerReach: 50, playerCooldownTicks: 0, playerDamage: 5, playerManaPerAttack: 1, respawnTicks: 100, }, talkRadius: 50, pickupRadius: 30, inventoryCols: 10, inventoryRows: 4, monsterCount: 0, }) if (monsters) { engine.world.monsters.length = 0 for (let i = 0; i < monsters.length; i++) { const m = monsters[i]! engine.world.monsters.push({ index: i, stats: { id: 'fallen', name: 'Fallen', hp: m.hp ?? 100, damage: 5, cooldownTicks: 25, reach: 25, aggroRadius: 200, speed: 100, xp: 10, }, x: m.x, y: m.y, hp: m.hp ?? 100, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0, }) } } return engine } const DUMMY_INPUT = { movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [], saving: false, loading: false, } describe('[Issue #440] Fire Ball AoE Explosion & Blast Visual (1.13c Ground Truth)', () => { it('1.13c Missiles.txt: fireball explosionMissile points to explodingarrowexp (ExpArrowExplode, 16 frames)', () => { const fb = getMissileTxtData('fireball') expect(fb.explosionMissile).toBe('explodingarrowexp') const exp = getMissileTxtData('explodingarrowexp') expect(exp.id).toBe(42) expect(exp.celFile).toBe('ExpArrowExplode') expect(exp.animLen).toBe(16) expect(exp.animSpeed).toBe(16) expect(exp.range).toBe(16) }) it('missiles-meta: explodingarrowexp resolves to authentic 16-frame ExpArrowExplode atlas', () => { expect(MISSILE_METAS.explodingarrowexp).toBeDefined() expect(MISSILE_METAS.explodingarrowexp.celFile).toBe('ExpArrowExplode') expect(MISSILE_METAS.explodingarrowexp.framesPerDirection).toBe(16) expect(MISSILE_METAS.explodingarrowexp.groups[0]!.length).toBe(16) }) it('calculates 1.13c 5-band damage and synergies for Fire Ball', () => { // slvl 1: min 6, max 14 const d1 = calculateFireBallDamage(1) expect(d1.min).toBe(6) expect(d1.max).toBe(14) // slvl 10: raw min = 12 + 7*13 + 2*23 = 149 -> floor(149*0.5) = 74 // raw max = 28 + 7*15 + 2*25 = 183 -> floor(183*0.5) = 91 const d10 = calculateFireBallDamage(10) expect(d10.min).toBe(74) expect(d10.max).toBe(91) // slvl 20: raw min = 12 + 7*13 + 8*23 + 4*28 = 399 -> floor(399*0.5) = 199 // raw max = 28 + 7*15 + 8*25 + 4*30 = 453 -> floor(453*0.5) = 226 const d20 = calculateFireBallDamage(20) expect(d20.min).toBe(199) expect(d20.max).toBe(226) // Synergies: +14% per hard point in Fire Bolt (36) and Meteor (56) const synMult = calculateFireBallSynergyMultiplier({ fireBolt: 10, meteor: 10 }) // points = 20 -> synPct = 280% -> multiplier = 3.8 expect(synMult).toBeCloseTo(3.8, 5) const d20Syn = calculateFireBallDamage(20, { fireBolt: 10, meteor: 10 }) // 199.5 * 3.8 = 758.1 -> floor = 758 // 226.5 * 3.8 = 860.7 -> floor = 860 expect(d20Syn.min).toBe(758) expect(d20Syn.max).toBe(860) // calculateSkillDamage routes skill 47 const viaGeneral = calculateSkillDamage(47, 20, { fireBolt: 10, meteor: 10 }) expect(viaGeneral).toEqual(d20Syn) }) it('castSkill spawns fireball with 80px AoE and dynamic damage', () => { const engine = createTestEngine() const hudMock = { getSkillLevel: (id: number) => { if (id === 47) return 10 if (id === 36) return 5 return 0 }, } castSkill(47, 200, 100, { engine, runtime: {} as any, hudManager: hudMock as any, status: { textContent: '' } as any, }) expect(engine.projectiles.length).toBe(1) const p = engine.projectiles[0]! expect(p.missileType).toBe('fireball') expect(p.aoeRadius).toBe(80) // Expected damage at slvl 10 with 5 pts fireBolt (+70% synergy): // syn = 1.7. min = floor(74.5 * 1.7) = 126, max = floor(91.5 * 1.7) = 155. avg = 141. const expectedDmg = calculateFireBallDamage(10, { fireBolt: 5 }) const avgDmg = Math.round((expectedDmg.min + expectedDmg.max) / 2) expect(p.damage).toBe(avgDmg) }) it('monster impact triggers explodingarrowexp with 16 frames and damages all enemies in 80px 2:1 radius', () => { // Setup monsters: // Direct target at (150, 100) // Splash target A at (190, 100): dx=40, dy=0 -> groundDist=40 <= 80 (HIT) // Splash target B at (150, 135): dx=0, dy=35 -> groundDist=35/0.5=70 <= 80 (HIT) // Distant target C at (150, 150): dx=0, dy=50 -> groundDist=50/0.5=100 > 80 (MISS) const engine = createTestEngine([ { x: 150, y: 100, hp: 500 }, { x: 190, y: 100, hp: 500 }, { x: 150, y: 135, hp: 500 }, { x: 150, y: 150, hp: 500 }, ]) castSkill(47, 200, 100, { engine, runtime: {} as any, hudManager: null, status: { textContent: '' } as any, }) // Step until impact for (let t = 0; t < 10; t++) { engine.tick(DUMMY_INPUT) } // Explodingarrowexp spawned with 16 frames const explosion = engine.explosions.find(e => e.missileType === 'explodingarrowexp') expect(explosion).toBeDefined() expect(explosion!.maxFrames).toBe(16) expect(explosion!.radius).toBe(80) // Damage check: expect(engine.world.monsters[0]!.hp).toBeLessThan(500) // direct target hit expect(engine.world.monsters[1]!.hp).toBeLessThan(500) // splash target A hit expect(engine.world.monsters[2]!.hp).toBeLessThan(500) // splash target B hit (2:1 isometric ground) expect(engine.world.monsters[3]!.hp).toBe(500) // target C out of 80px ground radius, unharmed }) it('wall collision detonates fireball into explodingarrowexp and deals 80px AoE splash', () => { // Terrain blocks at x >= 120 const engine = createTestEngine( [ { x: 120, y: 120, hp: 200 }, // dy=20 -> groundDist = 40 <= 80 (HIT) { x: 120, y: 160, hp: 200 }, // dy=60 -> groundDist = 120 > 80 (MISS) ], { overlap: (x: number) => (x >= 120 ? 1 : 0), isMissileBlocked: (x: number) => x >= 120, }, ) castSkill(47, 200, 100, { engine, runtime: {} as any, hudManager: null, status: { textContent: '' } as any, }) for (let t = 0; t < 10; t++) { engine.tick(DUMMY_INPUT) } const explosion = engine.explosions.find(e => e.missileType === 'explodingarrowexp') expect(explosion).toBeDefined() expect(explosion!.maxFrames).toBe(16) expect(engine.world.monsters[0]!.hp).toBeLessThan(200) // monster A damaged by wall detonation expect(engine.world.monsters[1]!.hp).toBe(200) // monster B outside AoE untouched }) })