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

482 lines
17 KiB
TypeScript

/**
* Diablo II: Lord of Destruction v1.13c — Skill #059: Blizzard (SOR)
* Missile Physics, 64-Entry Circle Emitter, Falling Altitude & 1.13c Parity Suite
*
* Requirements (Issue #400 / Blizzard Parity):
* 1. Missiles.txt ground truth: parses and verifies blizzardcenter (ID 158), blizzard1..4 (IDs 159..162),
* and blizzardexplode1..3 (IDs 163..165).
* 2. Visual assets & atlas metadata: BLIZZARD1..4_META (icestormfallvar01/02 and Blizzard.dcc),
* BLIZZARDEXPLODE1..3_META (icestormimpactvar01/02.dcc), baked PNG/JSON files exist.
* 3. Authoritative 64-entry circle table from D2Game.dll (VA 0x6FD1C960, 0x6FD1CA60),
* 2:1 isometric ground foreshortening (ISO_GROUND_ASPECT_RATIO = 0.5), step 13 coprime rotation.
* 4. Controller missile blizzardcenter (pSrvDoFunc = 10): lives 100 ticks, emits 25 shards total
* (every 4 ticks), harmless emitter (no direct contact damage).
* 5. Falling shard vertical descent: altitude drops from 120 px to 0 over 9 ticks (120/9 px/tick).
* 6. Ground impact & wall detonation: spawns blizzardexplode1..3 upon expiry or obstacle collision.
* 7. NextDelay = 0 sweet-spot burst: multiple overlapping shards deal full damage without immunity windows.
*/
import { describe, expect, it } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import {
parseMissilesTxt,
getMissileTxtData,
CANONICAL_113C_MISSILES,
tickProjectiles,
ISO_GROUND_ASPECT_RATIO,
calculateBlizzardDamage,
calculateBlizzardSynergyMultiplier,
type Projectile,
} from '../src/game/skills.ts'
import {
BLIZZARD_CIRCLE_X,
BLIZZARD_CIRCLE_Y,
getBlizzardDropOffset,
} from '../src/game/engine/blizzard-table.ts'
import {
BLIZZARD1_META,
BLIZZARD2_META,
BLIZZARD3_META,
BLIZZARD4_META,
BLIZZARDEXPLODE1_META,
BLIZZARDEXPLODE2_META,
BLIZZARDEXPLODE3_META,
MISSILE_METAS,
} from '../src/render/missiles-meta.ts'
describe('[Skill #059] Blizzard — 1.13c Missile Physics & Runtime Parity (Issue #400)', () => {
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 and verifies Blizzard missile suite (IDs 158..165)', () => {
const map = parseMissilesTxt(missilesTxtContent)
// blizzardcenter (ID 158)
const center = map.get('blizzardcenter')
expect(center).toBeDefined()
expect(center!.id).toBe(158)
expect(center!.vel).toBe(0)
expect(center!.range).toBe(100)
expect(center!.animLen).toBe(2)
expect(center!.red).toBe(81)
expect(center!.green).toBe(81)
expect(center!.blue).toBe(255)
// blizzard1 (ID 159)
const b1 = map.get('blizzard1')
expect(b1).toBeDefined()
expect(b1!.id).toBe(159)
expect(b1!.vel).toBe(0)
expect(b1!.range).toBe(9)
expect(b1!.celFile.toLowerCase()).toBe('icestormfallvar01')
expect(b1!.animLen).toBe(6)
// blizzard2 (ID 160)
const b2 = map.get('blizzard2')
expect(b2).toBeDefined()
expect(b2!.id).toBe(160)
expect(b2!.vel).toBe(0)
expect(b2!.range).toBe(9)
expect(b2!.celFile.toLowerCase()).toBe('icestormfallvar02')
expect(b2!.animLen).toBe(6)
// blizzard3 (ID 161)
const b3 = map.get('blizzard3')
expect(b3).toBeDefined()
expect(b3!.id).toBe(161)
expect(b3!.vel).toBe(0)
expect(b3!.range).toBe(9)
expect(b3!.celFile.toLowerCase()).toBe('blizzard')
expect(b3!.animLen).toBe(8)
// blizzard4 (ID 162)
const b4 = map.get('blizzard4')
expect(b4).toBeDefined()
expect(b4!.id).toBe(162)
expect(b4!.vel).toBe(0)
expect(b4!.range).toBe(9)
expect(b4!.celFile.toLowerCase()).toBe('blizzard')
expect(b4!.animLen).toBe(8)
// blizzardexplode1 (ID 163)
const exp1 = map.get('blizzardexplode1')
expect(exp1).toBeDefined()
expect(exp1!.id).toBe(163)
expect(exp1!.vel).toBe(0)
expect(exp1!.range).toBe(6)
expect(exp1!.celFile.toLowerCase()).toBe('icestormimpactvar01')
expect(exp1!.animLen).toBe(6)
// blizzardexplode2 (ID 164)
const exp2 = map.get('blizzardexplode2')
expect(exp2).toBeDefined()
expect(exp2!.id).toBe(164)
expect(exp2!.vel).toBe(0)
expect(exp2!.range).toBe(6)
expect(exp2!.celFile.toLowerCase()).toBe('icestormimpactvar02')
expect(exp2!.animLen).toBe(6)
// blizzardexplode3 (ID 165)
const exp3 = map.get('blizzardexplode3')
expect(exp3).toBeDefined()
expect(exp3!.id).toBe(165)
expect(exp3!.vel).toBe(0)
expect(exp3!.range).toBe(6)
expect(exp3!.celFile.toLowerCase()).toBe('icestormimpactvar02')
expect(exp3!.animLen).toBe(6)
})
it('2. CANONICAL_113C_MISSILES export contains Blizzard suite with exact 1.13c values', () => {
const center = getMissileTxtData('blizzardcenter')
expect(center.id).toBe(158)
expect(center.vel).toBe(0)
expect(center.range).toBe(100)
const b1 = getMissileTxtData('blizzard1')
expect(b1.id).toBe(159)
expect(b1.vel).toBe(0)
expect(b1.range).toBe(9)
expect(b1.animLen).toBe(6)
expect(b1.explosionMissile).toBe('blizzardexplode1')
const b2 = getMissileTxtData('blizzard2')
expect(b2.id).toBe(160)
expect(b2.range).toBe(9)
expect(b2.explosionMissile).toBe('blizzardexplode2')
const b3 = getMissileTxtData('blizzard3')
expect(b3.id).toBe(161)
expect(b3.range).toBe(9)
expect(b3.animLen).toBe(8)
expect(b3.explosionMissile).toBe('blizzardexplode3')
const b4 = getMissileTxtData('blizzard4')
expect(b4.id).toBe(162)
expect(b4.range).toBe(9)
expect(b4.animLen).toBe(8)
expect(b4.explosionMissile).toBe('blizzardexplode1')
const exp1 = getMissileTxtData('blizzardexplode1')
expect(exp1.id).toBe(163)
expect(exp1.range).toBe(6)
const exp2 = getMissileTxtData('blizzardexplode2')
expect(exp2.id).toBe(164)
expect(exp2.range).toBe(6)
const exp3 = getMissileTxtData('blizzardexplode3')
expect(exp3.id).toBe(165)
expect(exp3.range).toBe(6)
})
it('3. Authentic baked DCC atlas metadata and files exist on disk with non-zero byte size', () => {
expect(BLIZZARD1_META.name).toBe('blizzard1')
expect(BLIZZARD1_META.celFile).toBe('icestormfallvar01')
expect(BLIZZARD1_META.framesPerDirection).toBe(6)
expect(BLIZZARD2_META.name).toBe('blizzard2')
expect(BLIZZARD2_META.celFile).toBe('icestormfallvar02')
expect(BLIZZARD2_META.framesPerDirection).toBe(6)
expect(BLIZZARD3_META.name).toBe('blizzard3')
expect(BLIZZARD3_META.celFile).toBe('Blizzard')
expect(BLIZZARD3_META.framesPerDirection).toBe(8)
expect(BLIZZARD4_META.name).toBe('blizzard4')
expect(BLIZZARD4_META.celFile).toBe('Blizzard')
expect(BLIZZARD4_META.framesPerDirection).toBe(8)
expect(BLIZZARDEXPLODE1_META.name).toBe('blizzardexplode1')
expect(BLIZZARDEXPLODE1_META.celFile).toBe('icestormimpactvar01')
expect(BLIZZARDEXPLODE1_META.framesPerDirection).toBe(6)
expect(BLIZZARDEXPLODE2_META.name).toBe('blizzardexplode2')
expect(BLIZZARDEXPLODE2_META.celFile).toBe('icestormimpactvar02')
expect(BLIZZARDEXPLODE2_META.framesPerDirection).toBe(6)
expect(BLIZZARDEXPLODE3_META.name).toBe('blizzardexplode3')
expect(BLIZZARDEXPLODE3_META.celFile).toBe('icestormimpactvar02')
expect(BLIZZARDEXPLODE3_META.framesPerDirection).toBe(6)
expect(MISSILE_METAS['blizzard1']).toBe(BLIZZARD1_META)
expect(MISSILE_METAS['blizzard2']).toBe(BLIZZARD2_META)
expect(MISSILE_METAS['blizzard3']).toBe(BLIZZARD3_META)
expect(MISSILE_METAS['blizzard4']).toBe(BLIZZARD4_META)
expect(MISSILE_METAS['blizzardexplode1']).toBe(BLIZZARDEXPLODE1_META)
expect(MISSILE_METAS['blizzardexplode2']).toBe(BLIZZARDEXPLODE2_META)
expect(MISSILE_METAS['blizzardexplode3']).toBe(BLIZZARDEXPLODE3_META)
const missileFiles = [
'blizzard1',
'blizzard2',
'blizzard3',
'blizzard4',
'blizzardexplode1',
'blizzardexplode2',
'blizzardexplode3',
]
for (const name of missileFiles) {
const pngPath = path.resolve(__dirname, `../public/missiles/${name}.png`)
const jsonPath = path.resolve(__dirname, `../public/missiles/${name}.json`)
expect(fs.existsSync(pngPath), `Missing ${pngPath}`).toBe(true)
expect(fs.statSync(pngPath).size).toBeGreaterThan(1000)
expect(fs.existsSync(jsonPath), `Missing ${jsonPath}`).toBe(true)
expect(fs.statSync(jsonPath).size).toBeGreaterThan(100)
}
})
it('4. Authoritative 64-entry circle lookup table matches 1.13c binary assembly (radius 30, Y*0.5)', () => {
expect(BLIZZARD_CIRCLE_X.length).toBe(64)
expect(BLIZZARD_CIRCLE_Y.length).toBe(64)
// Primary cardinal points
expect(BLIZZARD_CIRCLE_X[0]).toBe(0)
expect(BLIZZARD_CIRCLE_Y[0]).toBe(30)
expect(BLIZZARD_CIRCLE_X[16]).toBe(30)
expect(BLIZZARD_CIRCLE_Y[16]).toBe(0)
expect(BLIZZARD_CIRCLE_X[32]).toBe(0)
expect(BLIZZARD_CIRCLE_Y[32]).toBe(-30)
expect(BLIZZARD_CIRCLE_X[48]).toBe(-30)
expect(BLIZZARD_CIRCLE_Y[48]).toBe(0)
// getBlizzardDropOffset checks
// At index 16 (X=30, Y=0), radius 7 subtiles: dx = 30 * (112/30) = 112, dy = 0
const offEast = getBlizzardDropOffset(16, 7, 16)
expect(offEast.dx).toBe(112)
expect(offEast.dy).toBe(0)
// At index 0 (X=0, Y=30), radius 7 subtiles: dx = 0, dy = 30 * (112/30) * 0.5 = 56
const offSouth = getBlizzardDropOffset(0, 7, 16)
expect(offSouth.dx).toBe(0)
expect(offSouth.dy).toBe(56)
// 2:1 isometric aspect ratio verification
expect(ISO_GROUND_ASPECT_RATIO).toBe(0.5)
})
it('5. tickProjectiles: blizzardcenter lives 100 ticks and emits exactly 25 shards (cadence = 4 ticks, step = 13)', () => {
let center: Projectile = {
skillId: '59',
x: 300,
y: 300,
vx: 0,
vy: 0,
damage: 150,
ttl: 100, // 100 frames = 4.0s
fromPlayer: true,
missileType: 'blizzardcenter',
subMissileIdx: 0,
}
const allEmittedShards: Projectile[] = []
const openTerrain = { overlap: () => 0 }
for (let tick = 1; tick <= 100; tick += 1) {
const outcome = tickProjectiles([center], [], openTerrain)
if (tick % 4 === 0) {
expect(outcome.spawnedProjectiles).toBeDefined()
expect(outcome.spawnedProjectiles!.length).toBe(1)
const shard = outcome.spawnedProjectiles![0]!
expect(['blizzard1', 'blizzard2', 'blizzard3', 'blizzard4']).toContain(shard.missileType)
expect(shard.damage).toBe(150)
expect(shard.statusEffect).toBe('chill')
expect(shard.statusDuration).toBe(100)
expect(shard.ttl).toBe(9)
expect(shard.altitude).toBe(120)
allEmittedShards.push(shard)
} else {
expect(outcome.spawnedProjectiles?.length ?? 0).toBe(0)
}
// Center must remain alive until tick 100
center = outcome.alive.find(p => p.missileType === 'blizzardcenter')!
if (tick < 100) {
expect(center).toBeDefined()
expect(center.ttl).toBe(100 - tick)
}
}
// Exactly 25 shards produced over 100 ticks (100 / 4)
expect(allEmittedShards.length).toBe(25)
// At tick 101, center expires
const expiredOutcome = tickProjectiles([center], [], openTerrain)
expect(expiredOutcome.expired).toBe(1)
expect(expiredOutcome.alive.length).toBe(0)
})
it('6. Main blizzardcenter is a harmless emitter and pierces monsters without dealing direct contact damage', () => {
const center: Projectile = {
skillId: '59',
x: 200,
y: 200,
vx: 0,
vy: 0,
damage: 100,
ttl: 20,
fromPlayer: true,
missileType: 'blizzardcenter',
subMissileIdx: 0,
}
const monster = [{ index: 0, x: 200, y: 200, radius: 25, alive: true }]
const outcome = tickProjectiles([center], monster, { overlap: () => 0 })
// Center must not trigger hits or take damage on the monster
expect(outcome.hits.length).toBe(0)
expect(outcome.alive.some(p => p.missileType === 'blizzardcenter')).toBe(true)
})
it('7. Falling shard altitude descends vertically from 120 px to 0 over 9 ticks and detonates on ground impact', () => {
let shard: Projectile = {
skillId: '59',
x: 150,
y: 150,
vx: 0,
vy: 0,
altitude: 120,
damage: 80,
ttl: 9,
fromPlayer: true,
missileType: 'blizzard1',
}
const openTerrain = { overlap: () => 0 }
for (let tick = 1; tick <= 9; tick += 1) {
const outcome = tickProjectiles([shard], [], openTerrain)
shard = outcome.alive.find(p => p.missileType === 'blizzard1')!
if (tick < 9) {
expect(shard).toBeDefined()
expect(shard.ttl).toBe(9 - tick)
// Decrements by 120 / 9 px per tick
const expectedAltitude = 120 - tick * (120 / 9)
expect(shard.altitude).toBeCloseTo(expectedAltitude, 0.1)
} else {
// At tick 9, ttl reached 0
expect(shard.ttl).toBe(0)
}
}
// Next tick: shard with ttl 0 expires and detonates ground impact explosion
const impactOutcome = tickProjectiles([shard], [], openTerrain)
expect(impactOutcome.expired).toBe(1)
expect(impactOutcome.spawnedProjectiles).toBeDefined()
expect(impactOutcome.spawnedProjectiles!.length).toBe(1)
const exp = impactOutcome.spawnedProjectiles![0]!
expect(exp.missileType).toBe('blizzardexplode1')
expect(exp.damage).toBe(80)
expect(exp.ttl).toBe(6) // 1.13c Range = 6
expect(exp.statusEffect).toBe('chill')
expect(exp.statusDuration).toBe(100) // 100 ticks chill length
})
it('8. Wall collision halts descending shard and spawns impact explosion', () => {
const shard: Projectile = {
skillId: '59',
x: 100,
y: 100,
vx: 0,
vy: 0,
altitude: 80,
damage: 90,
ttl: 7,
fromPlayer: true,
missileType: 'blizzard2',
}
// Solid wall at (100, 100)
const wallTerrain = { overlap: () => 1 }
const outcome = tickProjectiles([shard], [], wallTerrain)
expect(outcome.wallHits).toBe(1)
expect(outcome.alive.some(p => p.missileType === 'blizzard2')).toBe(false)
expect(outcome.spawnedProjectiles).toBeDefined()
expect(outcome.spawnedProjectiles!.length).toBe(1)
const exp = outcome.spawnedProjectiles![0]!
expect(exp.missileType).toBe('blizzardexplode2')
expect(exp.ttl).toBe(6)
})
it('9. Direct monster hit while falling deals cold damage and chill without immunity window (NextDelay = 0)', () => {
const shard1: Projectile = {
skillId: '59',
x: 100,
y: 100,
vx: 0,
vy: 0,
damage: 100,
ttl: 5,
fromPlayer: true,
missileType: 'blizzard1',
}
const shard2: Projectile = {
skillId: '59',
x: 102,
y: 100,
vx: 0,
vy: 0,
damage: 100,
ttl: 5,
fromPlayer: true,
missileType: 'blizzard2',
}
const dummyMonster = [{ index: 1, x: 101, y: 100, radius: 12, alive: true }]
const outcome = tickProjectiles([shard1, shard2], dummyMonster, { overlap: () => 0 })
// NextDelay = 0 allows both shards to hit the monster in the same frame
expect(outcome.hits.length).toBe(2)
for (const hit of outcome.hits) {
expect(hit.targetIndex).toBe(1)
expect(hit.damage).toBe(100)
expect(hit.statusEffect).toBe('chill')
expect(hit.statusDuration).toBe(100)
}
// Both shards must trigger explosion
expect(outcome.spawnedProjectiles!.length).toBe(2)
expect(outcome.spawnedProjectiles!.some(p => p.missileType === 'blizzardexplode1')).toBe(true)
expect(outcome.spawnedProjectiles!.some(p => p.missileType === 'blizzardexplode2')).toBe(true)
})
it('10. calculateBlizzardDamage and calculateBlizzardSynergyMultiplier evaluate 5-band formulas & synergies', () => {
// slvl 1: 45 - 75
const d1 = calculateBlizzardDamage(1)
expect(d1.min).toBe(45)
expect(d1.max).toBe(75)
// slvl 10: 210 - 249
const d10 = calculateBlizzardDamage(10)
expect(d10.min).toBe(210)
expect(d10.max).toBe(249)
// slvl 20: 570 - 619
const d20 = calculateBlizzardDamage(20)
expect(d20.min).toBe(570)
expect(d20.max).toBe(619)
// slvl 30: 1120 - 1179
const d30 = calculateBlizzardDamage(30)
expect(d30.min).toBe(1120)
expect(d30.max).toBe(1179)
// Synergy: 20 points in Ice Bolt (+100% = 2.0x)
const syn20 = calculateBlizzardSynergyMultiplier({ iceBolt: 20 })
expect(syn20).toBe(2.0)
const d20Syn = calculateBlizzardDamage(20, { iceBolt: 20 })
expect(d20Syn.min).toBe(1140) // 570 * 2.0
expect(d20Syn.max).toBe(1238) // 619 * 2.0
// Full synergies: 20 in Ice Bolt, 20 in Ice Blast, 20 in Glacial Spike (+300% = 4.0x)
const syn60 = calculateBlizzardSynergyMultiplier({ iceBolt: 20, iceBlast: 20, glacialSpike: 20 })
expect(syn60).toBe(4.0)
const d20Full = calculateBlizzardDamage(20, { iceBolt: 20, iceBlast: 20, glacialSpike: 20 })
expect(d20Full.min).toBe(2280) // 570 * 4.0
expect(d20Full.max).toBe(2476) // 619 * 4.0
})
})