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