253 lines
8.2 KiB
TypeScript
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
|
|
})
|
|
})
|