486 lines
16 KiB
TypeScript
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)
|
|
})
|
|
})
|