diablo2-web/tests/firebolt-missile-113c.test.ts

486 lines
16 KiB
TypeScript

/**
* 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)
})
})