diablo2-web/tests/frozen-orb-missile-113c.tes...

391 lines
14 KiB
TypeScript

/**
* Diablo II: Lord of Destruction v1.13c — Skill #064: Frozen Orb (SOR)
* Missile Physics, Discrete Circle Emitter, Visual Assets & 1.13c Parity Suite
*
* Requirements (Issue #218 / Frozen Orb Parity):
* 1. Missiles.txt ground truth: parses and verifies frozenorb (ID 260), frozenorbbolt (ID 261),
* frozenorbnova (ID 262), and frozenorbexplode (ID 263).
* 2. Visual assets & atlas metadata: FROZENORB_META (IceOrb.dcc, 16 frames),
* FROZENORBEXPLODE_META (IceOrbExplode.dcc, 18 frames), baked PNG/JSON files exist.
* 3. Discrete circle emitter (pSrvDoFunc = 15): 64-point lookup table, step 19, 2:1 isometric
* ground foreshortening (ISO_GROUND_ASPECT_RATIO = 0.5), emits 30 frozenorbbolt missiles over 30 ticks.
* 4. Main orb monster penetration: pierces through monsters without dying (CollideKill = 0) and
* does not deal direct contact damage (damage delivered by emitted bolts).
* 5. Expiration burst & wall detonation (pSrvHitFunc = 29): 16 frozenorbnova missiles along 64
* directions (step 4) + frozenorbexplode explosion.
* 6. Frozen Orb Nova affine rotation & damping curl (pSrvDoFunc = 16): last 6 frames every 2 frames
* transforms x' = (x - y)/2, y' = (x + y)/2.
* 7. Zero NextDelay sweet spot burst damage: multiple overlapping child bolts hit in same frame.
*/
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,
type Projectile,
} from '../src/game/skills.ts'
import {
FROZEN_ORB_X_POSITIONS,
FROZEN_ORB_Y_POSITIONS,
} from '../src/game/engine/missile-engine.ts'
import {
FROZENORB_META,
FROZENORBEXPLODE_META,
MISSILE_METAS,
} from '../src/render/missiles-meta.ts'
describe('[Skill #064] Frozen Orb — 1.13c Missile Physics & Runtime Parity', () => {
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 frozenorb missile suite', () => {
const map = parseMissilesTxt(missilesTxtContent)
// frozenorb (ID 260)
const orb = map.get('frozenorb')
expect(orb).toBeDefined()
expect(orb!.id).toBe(260)
expect(orb!.vel).toBe(10)
expect(orb!.range).toBe(30)
expect(orb!.speedPxPerSec).toBe(250) // 10 * 25
expect(orb!.distancePx).toBe(300) // 10 * 30
expect(orb!.celFile.toLowerCase()).toBe('iceorb')
expect(orb!.animLen).toBe(16)
expect(orb!.explosionMissile).toBe('') // In raw Missiles.txt, pSrvHitFunc 29 spawns frozenorbexplode via code
expect(orb!.light).toBe(6)
expect(orb!.red).toBe(81)
expect(orb!.green).toBe(81)
expect(orb!.blue).toBe(255)
// frozenorbbolt (ID 261)
const bolt = map.get('frozenorbbolt')
expect(bolt).toBeDefined()
expect(bolt!.id).toBe(261)
expect(bolt!.vel).toBe(18)
expect(bolt!.range).toBe(25)
expect(bolt!.speedPxPerSec).toBe(450) // 18 * 25
expect(bolt!.distancePx).toBe(450) // 18 * 25
expect(bolt!.celFile.toLowerCase()).toBe('icebolt')
expect(bolt!.animLen).toBe(6)
// frozenorbnova (ID 262)
const nova = map.get('frozenorbnova')
expect(nova).toBeDefined()
expect(nova!.id).toBe(262)
expect(nova!.vel).toBe(24)
expect(nova!.range).toBe(25)
expect(nova!.speedPxPerSec).toBe(600) // 24 * 25
expect(nova!.distancePx).toBe(600) // 24 * 25
expect(nova!.celFile.toLowerCase()).toBe('icebolt')
expect(nova!.animLen).toBe(6)
// frozenorbexplode (ID 263)
const exp = map.get('frozenorbexplode')
expect(exp).toBeDefined()
expect(exp!.id).toBe(263)
expect(exp!.vel).toBe(0)
expect(exp!.range).toBe(16)
expect(exp!.celFile.toLowerCase()).toBe('iceorbexplode')
expect(exp!.animLen).toBe(16)
})
it('2. CANONICAL_113C_MISSILES export contains frozenorb suite with exact values', () => {
const orb = getMissileTxtData('frozenorb')
expect(orb.id).toBe(260)
expect(orb.vel).toBe(10)
expect(orb.range).toBe(30)
expect(orb.speedPxPerSec).toBe(250)
expect(orb.distancePx).toBe(300)
expect(orb.explosionMissile).toBe('frozenorbexplode')
const bolt = getMissileTxtData('frozenorbbolt')
expect(bolt.id).toBe(261)
expect(bolt.vel).toBe(18)
expect(bolt.range).toBe(25)
expect(bolt.speedPxPerSec).toBe(450)
const nova = getMissileTxtData('frozenorbnova')
expect(nova.id).toBe(262)
expect(nova.vel).toBe(24)
expect(nova.range).toBe(25)
expect(nova.speedPxPerSec).toBe(600)
const explode = getMissileTxtData('frozenorbexplode')
expect(explode.id).toBe(263)
expect(explode.animLen).toBe(16)
})
it('3. Authentic baked DCC atlas metadata and files exist with non-zero byte size', () => {
expect(FROZENORB_META.name).toBe('frozenorb')
expect(FROZENORB_META.celFile).toBe('IceOrb')
expect(FROZENORB_META.directions).toBe(1)
expect(FROZENORB_META.framesPerDirection).toBe(16)
expect(FROZENORB_META.groups.length).toBe(1)
expect(FROZENORB_META.groups[0]!.length).toBe(16)
expect(FROZENORBEXPLODE_META.name).toBe('frozenorbexplode')
expect(FROZENORBEXPLODE_META.celFile).toBe('IceOrbExplode')
expect(FROZENORBEXPLODE_META.directions).toBe(1)
expect(FROZENORBEXPLODE_META.framesPerDirection).toBe(18)
expect(FROZENORBEXPLODE_META.groups.length).toBe(1)
expect(FROZENORBEXPLODE_META.groups[0]!.length).toBe(18)
expect(MISSILE_METAS['frozenorb']).toBe(FROZENORB_META)
expect(MISSILE_METAS['frozenorbexplode']).toBe(FROZENORBEXPLODE_META)
const orbPngPath = path.resolve(__dirname, '../public/missiles/frozenorb.png')
const orbJsonPath = path.resolve(__dirname, '../public/missiles/frozenorb.json')
const expPngPath = path.resolve(__dirname, '../public/missiles/frozenorbexplode.png')
const expJsonPath = path.resolve(__dirname, '../public/missiles/frozenorbexplode.json')
expect(fs.existsSync(orbPngPath)).toBe(true)
expect(fs.statSync(orbPngPath).size).toBeGreaterThan(10000)
expect(fs.existsSync(orbJsonPath)).toBe(true)
expect(fs.existsSync(expPngPath)).toBe(true)
expect(fs.statSync(expPngPath).size).toBeGreaterThan(20000)
expect(fs.existsSync(expJsonPath)).toBe(true)
})
it('4. Discrete circle table matches 1.13c binary assembly (64 entries, radius 30)', () => {
expect(FROZEN_ORB_X_POSITIONS.length).toBe(64)
expect(FROZEN_ORB_Y_POSITIONS.length).toBe(64)
expect(FROZEN_ORB_X_POSITIONS[0]).toBe(30)
expect(FROZEN_ORB_Y_POSITIONS[0]).toBe(0)
expect(FROZEN_ORB_X_POSITIONS[16]).toBe(0)
expect(FROZEN_ORB_Y_POSITIONS[16]).toBe(30)
expect(FROZEN_ORB_X_POSITIONS[32]).toBe(-30)
expect(FROZEN_ORB_Y_POSITIONS[32]).toBe(0)
expect(FROZEN_ORB_X_POSITIONS[48]).toBe(0)
expect(FROZEN_ORB_Y_POSITIONS[48]).toBe(-30)
})
it('5. tickProjectiles: simulates 30-tick flight, emitting 1 bolt per frame along 64-table with step 19', () => {
const orb: Projectile = {
skillId: '64',
x: 100,
y: 100,
vx: 10, // 250 px/s / 25 fps = 10 px/tick
vy: 0,
damage: 42,
ttl: 30, // 30 frames
fromPlayer: true,
missileType: 'frozenorb',
pierce: true,
subMissileIdx: 0,
}
let currentProjectiles: readonly Projectile[] = [orb]
const allEmittedBolts: Projectile[] = []
const openTerrain = { overlap: () => 0 }
for (let tick = 1; tick <= 30; tick += 1) {
const outcome = tickProjectiles(currentProjectiles, [], openTerrain)
expect(outcome.spawnedProjectiles).toBeDefined()
expect(outcome.spawnedProjectiles!.length).toBe(1)
const bolt = outcome.spawnedProjectiles![0]!
expect(bolt.missileType).toBe('frozenorbbolt')
expect(bolt.damage).toBe(42)
expect(bolt.statusEffect).toBe('chill')
expect(bolt.statusDuration).toBe(100)
expect(bolt.ttl).toBe(25)
expect(Math.hypot(bolt.vx, bolt.vy)).toBeCloseTo(18, 0.1) // vel = 18 px/tick
allEmittedBolts.push(bolt)
// Filter to keep only the orb for tracking emitter state
currentProjectiles = outcome.alive.filter(p => p.missileType === 'frozenorb')
if (tick < 30) {
expect(currentProjectiles.length).toBe(1)
expect(currentProjectiles[0]!.x).toBe(100 + tick * 10)
expect(currentProjectiles[0]!.ttl).toBe(30 - tick)
// Verify direction index advanced by 19 mod 64
const expectedIdx = (tick * 19) % 64
expect(currentProjectiles[0]!.subMissileIdx).toBe(expectedIdx)
} else {
// At tick 30, moved.ttl reaches 0
expect(currentProjectiles.length).toBe(1)
expect(currentProjectiles[0]!.ttl).toBe(0)
}
}
expect(allEmittedBolts.length).toBe(30)
// At tick 31, the orb with ttl: 0 detonates into 16 frozenorbnova missiles!
const detonateOutcome = tickProjectiles(currentProjectiles, [], openTerrain)
expect(detonateOutcome.expired).toBe(1)
expect(detonateOutcome.alive.length).toBe(16) // 16 nova missiles spawned
expect(detonateOutcome.spawnedProjectiles!.length).toBe(16)
for (const nova of detonateOutcome.spawnedProjectiles!) {
expect(nova.missileType).toBe('frozenorbnova')
expect(nova.damage).toBe(42)
expect(nova.statusEffect).toBe('chill')
expect(nova.isNovaCurl).toBe(true)
expect(nova.ttl).toBe(25)
expect(Math.hypot(nova.vx, nova.vy)).toBeCloseTo(24, 0.1) // vel = 24 px/tick
}
// Verify explosion event was pushed
expect(detonateOutcome.wallHitEvents).toBeDefined()
expect(detonateOutcome.wallHitEvents!.some(e => e.missileType === 'frozenorb')).toBe(true)
})
it('6. Main orb pierces monsters without dealing direct contact damage (CollideKill = 0)', () => {
const orb: Projectile = {
skillId: '64',
x: 100,
y: 100,
vx: 10,
vy: 0,
damage: 50,
ttl: 10,
fromPlayer: true,
missileType: 'frozenorb',
pierce: true,
subMissileIdx: 0,
}
// Place dummy enemy directly in front of the orb
const targets = [{ index: 0, x: 110, y: 100, radius: 15, alive: true }]
const outcome = tickProjectiles([orb], targets, { overlap: () => 0 })
// Main orb must NOT deal direct contact damage
const directHits = outcome.hits.filter(h => h.missileType === 'frozenorb')
expect(directHits.length).toBe(0)
// Main orb must NOT die on the monster
const survivingOrb = outcome.alive.find(p => p.missileType === 'frozenorb')
expect(survivingOrb).toBeDefined()
expect(survivingOrb!.x).toBe(110)
expect(survivingOrb!.ttl).toBe(9)
})
it('7. Wall collision immediately detonates orb into 16 nova bolts and stops flight', () => {
const orb: Projectile = {
skillId: '64',
x: 100,
y: 100,
vx: 10,
vy: 0,
damage: 40,
ttl: 25,
fromPlayer: true,
missileType: 'frozenorb',
pierce: true,
subMissileIdx: 0,
}
// Wall at x >= 110
const wallTerrain = { overlap: (x: number) => (x >= 110 ? 1 : 0) }
const outcome = tickProjectiles([orb], [], wallTerrain)
expect(outcome.wallHits).toBe(1)
// Orb must not survive as an active frozenorb
expect(outcome.alive.some(p => p.missileType === 'frozenorb')).toBe(false)
// Must have detonated 16 nova bolts
expect(outcome.spawnedProjectiles!.filter(p => p.missileType === 'frozenorbnova').length).toBe(16)
// Wall hit events must register the explosion
expect(outcome.wallHitEvents!.some(e => e.missileType === 'frozenorb')).toBe(true)
})
it('8. Frozen Orb Nova: affine curl damping transforms velocity in last 6 frames', () => {
// Nova bolt with ttl = 6, vx = 20, vy = 10
const nova: Projectile = {
skillId: '64',
x: 200,
y: 200,
vx: 20,
vy: 10,
damage: 40,
ttl: 6,
fromPlayer: true,
missileType: 'frozenorbnova',
isNovaCurl: true,
}
// Step 1: ttl 6 -> curl applied! (6 <= 6 && 6 % 2 === 0)
// D2 1.13c: vx' = (vx - vy) / 2 = (20 - 10) / 2 = 5
// vy' = (vx + vy) / 2 = (20 + 10) / 2 = 15
// x' = 200 + 5 = 205, y' = 200 + 15 = 215
const step1 = tickProjectiles([nova], [], { overlap: () => 0 })
const moved1 = step1.alive.find(p => p.missileType === 'frozenorbnova')!
expect(moved1).toBeDefined()
expect(moved1.vx).toBe(5)
expect(moved1.vy).toBe(15)
expect(moved1.x).toBe(205)
expect(moved1.y).toBe(215)
expect(moved1.ttl).toBe(5)
// Step 2: ttl 5 (odd, no curl transform, normal linear flight with vx=5, vy=15)
const step2 = tickProjectiles([moved1], [], { overlap: () => 0 })
const moved2 = step2.alive.find(p => p.missileType === 'frozenorbnova')!
expect(moved2.vx).toBe(5)
expect(moved2.vy).toBe(15)
expect(moved2.x).toBe(210)
expect(moved2.y).toBe(230)
expect(moved2.ttl).toBe(4)
// Step 3: ttl 4 (even, curl applied again!)
// vx' = (5 - 15) / 2 = -5
// vy' = (5 + 15) / 2 = 10
// x' = 210 - 5 = 205, y' = 230 + 10 = 240
const step3 = tickProjectiles([moved2], [], { overlap: () => 0 })
const moved3 = step3.alive.find(p => p.missileType === 'frozenorbnova')!
expect(moved3.vx).toBe(-5)
expect(moved3.vy).toBe(10)
expect(moved3.x).toBe(205)
expect(moved3.y).toBe(240)
})
it('9. Zero NextDelay sweet spot burst: multiple child bolts damage the same target in single frame', () => {
// 3 separate nova bolts converging on the same enemy position
const bolt1: Projectile = {
skillId: '64',
x: 95,
y: 100,
vx: 5,
vy: 0,
damage: 40,
ttl: 20,
fromPlayer: true,
missileType: 'frozenorbnova',
}
const bolt2: Projectile = {
skillId: '64',
x: 105,
y: 100,
vx: -5,
vy: 0,
damage: 40,
ttl: 20,
fromPlayer: true,
missileType: 'frozenorbnova',
}
const bolt3: Projectile = {
skillId: '64',
x: 100,
y: 95,
vx: 0,
vy: 5,
damage: 40,
ttl: 20,
fromPlayer: true,
missileType: 'frozenorbnova',
}
const dummyTarget = [{ index: 1, x: 100, y: 100, radius: 10, alive: true }]
const outcome = tickProjectiles([bolt1, bolt2, bolt3], dummyTarget, { overlap: () => 0 })
// In 1.13c, Frozen Orb child missiles have NextDelay = 0.
// Therefore all 3 intersecting bolts hit the dummy monster and register damage!
expect(outcome.hits.length).toBe(3)
const targetHits = outcome.hits.filter(h => h.targetIndex === 1)
expect(targetHits.length).toBe(3)
const totalDmg = targetHits.reduce((sum, h) => sum + h.damage, 0)
expect(totalDmg).toBe(120) // 3 * 40
})
})