diablo2-web/tests/skills/sor/issue-440-fireball-explosio...

253 lines
8.2 KiB
TypeScript

/**
* 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/game/skills.ts'
import { MISSILE_METAS } from '../../../src/render/missiles-meta.ts'
import { GameEngine } from '../../../src/game/engine.ts'
import { castSkill } from '../../../src/scene/act-scene.ts'
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../../../src/game/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
})
})