diablo2-web/tests/meteor-stress.test.ts

574 lines
22 KiB
TypeScript

/**
* Diablo II: Lord of Destruction v1.13c — Skill #056: Meteor
* Empirical Stress Testing & Adversarial Verification Suite
*
* Requirements (User Request & Solution Stress Testing Playbook):
* 1. Countdown timing: verify meteor lands and detonates exactly at tick 60, not sooner or later.
* 2. Dual sprite trajectory: verify meteor core (ID 100) and tail (ID 102) maintain synchronized coordinate descent.
* 3. Exact 18 residual fire patch offsets spawned around impact point matching 0x6FD1C918 and 0x6FD1C8D0.
* 4. Wall collision drop: fire patches spawned in wall cells are rejected (COLLIDE_WALL | COLLIDE_BLANK).
* 5. 50,000-tick continuous rapid-casting stress test: verify zero unhandled exceptions, zero NaN coordinates,
* clean missile disposal, and bounded memory.
*/
import { describe, expect, it } from 'vitest'
import {
tickProjectiles,
ISO_GROUND_ASPECT_RATIO,
calculateMeteorDamage,
calculateMeteorResidualFireDamage,
calculateMeteorDetailedDamage,
METEOR_FIRE_SUBTILE_OFFSETS,
type Projectile,
type ProjectileTarget,
} from '../src/game/skills.ts'
import { MissileEngine } from '../src/game/engine/missile-engine.ts'
import { getSharedDataRegistry } from '../src/game/engine/data-registry.ts'
import { UnitStatList } from '../src/game/engine/stat-list.ts'
import { StateBus } from '../src/game/engine/state-bus.ts'
import type { CombatUnitContext } from '../src/game/engine/combat-pipeline.ts'
describe('[Skill #056] Meteor — Empirical Stress & Physics Challenger Suite', () => {
const openTerrain = { overlap: () => 0 }
// =========================================================================
// Dimension 1: Countdown Timing Parity (Detonates Exactly at Tick 60)
// =========================================================================
describe('Dimension 1: Countdown Timing Parity (Exact Tick 60 Detonation)', () => {
it('verifies in MissileEngine that meteorcenter detonates at tick 60 and not at tick 59 or 61', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const ownerStatList = new UnitStatList(registry, {})
const owner: CombatUnitContext = {
id: 'sorc',
name: 'Sorceress',
isUndead: false,
isDemon: false,
statList: ownerStatList,
stateBus: new StateBus(ownerStatList, registry),
}
const dummyStatList = new UnitStatList(registry, { maxhp: 50000 * 256, hitpoints: 50000 * 256 })
const dummy: CombatUnitContext = {
id: 'target-dummy',
name: 'TargetDummy',
isUndead: false,
isDemon: false,
statList: dummyStatList,
stateBus: new StateBus(dummyStatList, registry),
}
const targetPositions = new Map([['target-dummy', { x: 300, y: 300 }]])
// Spawn meteorcenter at (300, 300)
engine.spawnMissile({
missileNameOrId: 'meteorcenter',
sourceSkillId: 56,
slvl: 1,
owner,
startX: 300,
startY: 300,
targetX: 300,
targetY: 300,
dmgPacket: {
skillId: 56,
attackKind: 'spell',
elemType: 'fire',
elemMin256: 80 * 256,
elemMax256: 100 * 256,
autoHit: true,
},
})
let detonationTick: number | null = null
let spawnedSubmissilesOnDetonation: string[] = []
// Step simulation tick by tick from 1 to 65
for (let tick = 1; tick <= 65; tick++) {
const step = engine.tick(tick, [dummy], targetPositions)
if (step.hits.length > 0) {
if (detonationTick === null) {
detonationTick = tick
spawnedSubmissilesOnDetonation = [...step.subMissilesSpawned]
}
}
if (tick < 60) {
// In ticks 1..59, meteorcenter must still be falling/waiting, 0 damage inflicted
expect(step.hits.length, `Premature hit at tick ${tick}`).toBe(0)
const center = engine.getActiveMissiles().find(m => m.name === 'meteorcenter')
expect(center, `meteorcenter disappeared before tick 60 at tick ${tick}`).toBeDefined()
expect(center!.ageTicks).toBe(tick)
} else if (tick === 60) {
// Exactly on tick 60: primary detonation must trigger!
expect(step.hits.length, `Expected detonation at tick 60, but got 0 hits`).toBe(1)
expect(step.hits[0]!.missileName).toBe('meteorexplode')
expect(step.hits[0]!.damage).toBeGreaterThan(0)
} else {
// Past tick 60: meteorcenter must be expired and removed from active missiles
const center = engine.getActiveMissiles().find(m => m.name === 'meteorcenter')
expect(center, `meteorcenter lingered past tick 60 at tick ${tick}`).toBeUndefined()
}
}
expect(detonationTick, 'Detonation must happen exactly at tick 60').toBe(60)
expect(spawnedSubmissilesOnDetonation).toContain('meteorexplode')
expect(spawnedSubmissilesOnDetonation.filter(s => s === 'meteorfire').length).toBe(18)
})
it('verifies in tickProjectiles whether meteorcenter detonates at tick 60 or tick 61', () => {
let center: Projectile = {
skillId: '56',
x: 300,
y: 300,
vx: 0,
vy: 0,
damage: 500,
ttl: 60,
fromPlayer: true,
missileType: 'meteorcenter',
slvl: 1,
}
const dummyMonster = [{ index: 0, x: 300, y: 300, radius: 20, alive: true }]
let detonationTick: number | null = null
for (let tick = 1; tick <= 65; tick++) {
const outcome = tickProjectiles([center], dummyMonster, openTerrain)
if (outcome.hits.length > 0 && detonationTick === null) {
detonationTick = tick
}
if (outcome.alive.length > 0) {
center = outcome.alive[0]!
} else {
center = { ...center, ttl: -1 }
}
}
// 1.13c Ground Truth: Meteor has Range: 60 ticks.
// If tickProjectiles detonates at tick 61 instead of tick 60, this test detects the off-by-one tick delay.
expect(detonationTick, `tickProjectiles detonation tick should be 60, but was ${detonationTick}`).toBe(60)
})
})
// =========================================================================
// Dimension 2: Dual Sprite Trajectory Synchronization (Core & Tail)
// =========================================================================
describe('Dimension 2: Dual Sprite Trajectory Synchronization (Core & Tail)', () => {
it('verifies meteor core (ID 100) and tail (ID 102) exist and maintain synchronized coordinate descent', () => {
// 1.13c Ground Truth (D2Client.dll 0x6FB68840 / Missiles.txt):
// When meteorcenter (101) is cast, client children meteor (100) and meteortail (102)
// are spawned and descend from the sky to the ground target over 60 ticks.
// Altitude: Z(t) = (60 - t) * 25
// Coordinate descent: X(t) = targetX - (60 - t) * 15, Y(t) = targetY - (60 - t) * 15
// Both core and tail must share synchronized trajectory.
const targetX = 400
const targetY = 300
// Create core and tail projectiles
const core: Projectile = {
skillId: '56',
x: targetX,
y: targetY,
vx: 0,
vy: 0,
damage: 0,
ttl: 60,
fromPlayer: true,
missileType: 'meteor',
altitude: 60 * 25,
}
const tail: Projectile = {
skillId: '56',
x: targetX,
y: targetY,
vx: 0,
vy: 0,
damage: 0,
ttl: 60,
fromPlayer: true,
missileType: 'meteortail',
altitude: 60 * 25,
}
let activeProjectiles: Projectile[] = [core, tail]
for (let t = 1; t <= 60; t++) {
const step = tickProjectiles(activeProjectiles, [], openTerrain)
activeProjectiles = step.alive as Projectile[]
const curCore = activeProjectiles.find(p => p.missileType === 'meteor')
const curTail = activeProjectiles.find(p => p.missileType === 'meteortail')
// If core and tail are implemented in simulation:
if (curCore !== undefined && curTail !== undefined) {
// Verify synchronized coordinates
expect(curCore.x).toBe(curTail.x)
expect(curCore.y).toBe(curTail.y)
expect(curCore.altitude).toBe(curTail.altitude)
// Verify monotonic descent
if (curCore.altitude !== undefined) {
expect(curCore.altitude).toBeLessThanOrEqual(60 * 25)
if (t === 60) {
expect(curCore.altitude).toBeLessThanOrEqual(0)
}
}
} else {
// If neither core nor tail is updated/supported, fail and report missing dual sprite trajectory
expect(curCore, `meteor (ID 100) core missile missing from simulation at tick ${t}`).toBeDefined()
expect(curTail, `meteortail (ID 102) tail missile missing from simulation at tick ${t}`).toBeDefined()
}
}
})
})
// =========================================================================
// Dimension 3: Exact 18 Residual Fire Patch Offsets Matching 0x6FD1C918/8D0
// =========================================================================
describe('Dimension 3: Exact 18 Residual Fire Patch Offsets (0x6FD1C918 / 0x6FD1C8D0)', () => {
it('verifies verbatim match of all 18 subtile offset pairs against D2Game.dll ground truth', () => {
const GROUND_TRUTH_OFFSETS = [
{ x: 2, y: -2 }, // [0] Inner Ring
{ x: -2, y: -2 }, // [1] Inner Ring
{ x: 0, y: 2 }, // [2] Inner Ring
{ x: 0, y: 5 }, // [3] North Tip
{ x: -3, y: 3 }, // [4] North-West Outer
{ x: 0, y: 3 }, // [5] Center-North
{ x: 3, y: 3 }, // [6] North-East Outer
{ x: -1, y: 2 }, // [7] Center Cluster
{ x: 1, y: 1 }, // [8] Center Cluster
{ x: -1, y: -1 }, // [9] Center Cluster
{ x: 2, y: -1 }, // [10] Center Cluster
{ x: -4, y: -2 }, // [11] West Tip
{ x: -3, y: -2 }, // [12] West Outer
{ x: -1, y: -3 }, // [13] South-West Outer
{ x: 0, y: -4 }, // [14] South Tip
{ x: 1, y: -3 }, // [15] South-East Outer
{ x: 3, y: -3 }, // [16] East Outer
{ x: 4, y: -2 }, // [17] East Tip
]
expect(METEOR_FIRE_SUBTILE_OFFSETS.length).toBe(18)
for (let i = 0; i < 18; i++) {
expect(METEOR_FIRE_SUBTILE_OFFSETS[i]).toEqual(GROUND_TRUTH_OFFSETS[i])
}
})
it('verifies that detonation spawns exactly 18 meteorfire submissiles at correctly scaled pixel coordinates', () => {
const centerX = 500
const centerY = 500
const center: Projectile = {
skillId: '56',
x: centerX,
y: centerY,
vx: 0,
vy: 0,
damage: 300,
ttl: 0, // detonates immediately
fromPlayer: true,
missileType: 'meteorcenter',
slvl: 1,
}
const outcome = tickProjectiles([center], [], openTerrain)
const spawnedFlames = outcome.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? []
expect(spawnedFlames.length).toBe(18)
for (let i = 0; i < 18; i++) {
const off = METEOR_FIRE_SUBTILE_OFFSETS[i]!
const expectedX = centerX + off.x * 20
const expectedY = centerY + off.y * 10
const flame = spawnedFlames.find(f => f.x === expectedX && f.y === expectedY)
expect(flame, `Missing meteorfire at offset [${i}] (${expectedX}, ${expectedY})`).toBeDefined()
expect(flame!.ttl).toBe(90) // slvl 1: 90 ticks (3.6s per 1.13c)
expect(flame!.pierce).toBe(true)
}
})
})
// =========================================================================
// Dimension 4: Wall Collision Drop (COLLIDE_WALL | COLLIDE_BLANK)
// =========================================================================
describe('Dimension 4: Wall Collision Drop (COLLIDE_WALL | COLLIDE_BLANK)', () => {
it('verifies fire patches falling inside solid wall cells are completely rejected in tickProjectiles', () => {
const centerX = 300
const centerY = 300
const center: Projectile = {
skillId: '56',
x: centerX,
y: centerY,
vx: 0,
vy: 0,
damage: 300,
ttl: 0,
fromPlayer: true,
missileType: 'meteorcenter',
slvl: 1,
}
// Case 1: East half is a wall (x > centerX = 300)
const halfWallTerrain = {
overlap: (x: number, _y: number) => (x > 300 ? 1 : 0),
}
const resHalf = tickProjectiles([center], [], halfWallTerrain)
const flamesHalf = resHalf.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? []
expect(flamesHalf.length).toBeGreaterThan(0)
expect(flamesHalf.length).toBeLessThan(18)
for (const f of flamesHalf) {
expect(f.x).toBeLessThanOrEqual(300)
}
// Case 2: Entire area is a solid wall
const fullWallTerrain = {
overlap: () => 1,
}
const resFull = tickProjectiles([center], [], fullWallTerrain)
const flamesFull = resFull.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? []
expect(flamesFull.length).toBe(0) // All 18 fire patches dropped!
// Case 3: Selective pinpoint wall blocking North Tip [3] (0, 5) -> (300, 350)
const northTipX = centerX + 0 * 20
const northTipY = centerY + 5 * 10
const selectiveTerrain = {
overlap: (x: number, y: number) => (x === northTipX && y === northTipY ? 1 : 0),
}
const resSelective = tickProjectiles([center], [], selectiveTerrain)
const flamesSelective = resSelective.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? []
expect(flamesSelective.length).toBe(17)
expect(flamesSelective.some(f => f.x === northTipX && f.y === northTipY)).toBe(false)
})
it('checks whether MissileEngine implements wall collision filtering for meteorfire spawning', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const ownerStatList = new UnitStatList(registry, {})
const owner: CombatUnitContext = {
id: 'sorc',
name: 'Sorceress',
isUndead: false,
isDemon: false,
statList: ownerStatList,
stateBus: new StateBus(ownerStatList, registry),
}
engine.spawnMissile({
missileNameOrId: 'meteorcenter',
sourceSkillId: 56,
slvl: 1,
owner,
startX: 200,
startY: 200,
targetX: 200,
targetY: 200,
dmgPacket: {
skillId: 56,
attackKind: 'spell',
elemType: 'fire',
elemMin256: 80 * 256,
elemMax256: 100 * 256,
},
})
// Tick 60 to trigger detonation
for (let t = 1; t <= 60; t++) {
engine.tick(t, [], new Map())
}
const activeFires = engine.getActiveMissiles().filter(m => m.name === 'meteorfire')
// When no collision terrain is provided, all 18 submissiles spawn
expect(activeFires.length).toBe(18)
})
it('verifies in MissileEngine that meteorfire submissiles landing in solid walls are dropped', async () => {
const registry = await getSharedDataRegistry()
// Terrain with eastern wall (x > 200)
const halfWallTerrain = {
overlap: (x: number, _y: number) => (x > 200 ? 1 : 0),
}
const engine = new MissileEngine(registry, halfWallTerrain)
const ownerStatList = new UnitStatList(registry, {})
const owner: CombatUnitContext = {
id: 'sorc',
name: 'Sorceress',
isUndead: false,
isDemon: false,
statList: ownerStatList,
stateBus: new StateBus(ownerStatList, registry),
}
engine.spawnMissile({
missileNameOrId: 'meteorcenter',
sourceSkillId: 56,
slvl: 1,
owner,
startX: 200,
startY: 200,
targetX: 200,
targetY: 200,
dmgPacket: {
skillId: 56,
attackKind: 'spell',
elemType: 'fire',
elemMin256: 80 * 256,
elemMax256: 100 * 256,
},
})
// Tick 60 to trigger detonation
for (let t = 1; t <= 60; t++) {
engine.tick(t, [], new Map())
}
const activeFires = engine.getActiveMissiles().filter(m => m.name === 'meteorfire')
expect(activeFires.length).toBeGreaterThan(0)
expect(activeFires.length).toBeLessThan(18)
expect(activeFires.every(m => m.x <= 200)).toBe(true)
})
})
// =========================================================================
// Dimension 5: 50,000-Tick Continuous Rapid-Casting Stress Simulation
// =========================================================================
describe('Dimension 5: 50,000-Tick Continuous Rapid-Casting Stress Simulation', () => {
it('runs 50,000 ticks of continuous Meteor casting without crashes, NaNs, or memory leaks', () => {
const simStartMs = Date.now()
const heapBefore = process.memoryUsage().heapUsed
let activeProjectiles: Projectile[] = []
let totalCasts = 0
let totalHits = 0
let totalWallHits = 0
let totalExpired = 0
let peakActiveCount = 0
// Dynamic moving targets in combat arena
const targets: ProjectileTarget[] = [
{ index: 0, x: 250, y: 250, radius: 25, alive: true },
{ index: 1, x: 270, y: 260, radius: 20, alive: true },
{ index: 2, x: 230, y: 240, radius: 20, alive: true },
{ index: 3, x: 260, y: 230, radius: 20, alive: true },
{ index: 4, x: 350, y: 350, radius: 30, alive: true },
]
// Semi-solid map terrain with boundary walls and interior pillar
const hybridTerrain = {
overlap: (x: number, y: number) => {
if (x < 30 || x > 470 || y < 30 || y > 470) return 1 // perimeter walls
if (x >= 280 && x <= 320 && y >= 280 && y <= 320) return 1 // central obstacle pillar
return 0
},
}
// 50,000 ticks = 2,000 seconds = 33.3 minutes of non-stop game simulation
for (let tick = 0; tick < 50000; tick++) {
// Cast Meteor on cooldown (every 30 ticks = 1.20s cast delay)
if (tick % 30 === 0) {
totalCasts++
// Slight spatial variation across casts
const cx = 250 + ((tick / 30) % 7) * 8 - 24
const cy = 250 + ((tick / 30) % 5) * 10 - 20
activeProjectiles.push({
skillId: '56',
x: cx,
y: cy,
vx: 0,
vy: 0,
damage: 200,
ttl: 60, // 2.40s countdown
fromPlayer: true,
missileType: 'meteorcenter',
slvl: 1,
residualDamage: 25,
fireDuration: 30,
})
}
// Monsters move slightly every 8 ticks
if (tick % 8 === 0) {
for (let m = 0; m < targets.length; m++) {
const angle = (tick * 0.04) + m * 1.2
targets[m] = {
...targets[m]!,
x: 250 + Math.cos(angle) * 35,
y: 250 + Math.sin(angle) * 20 * ISO_GROUND_ASPECT_RATIO,
}
}
}
// Advance simulation tick
const outcome = tickProjectiles(activeProjectiles, targets, hybridTerrain)
activeProjectiles = outcome.alive as Projectile[]
totalHits += outcome.hits.length
totalWallHits += outcome.wallHits
totalExpired += outcome.expired
if (activeProjectiles.length > peakActiveCount) {
peakActiveCount = activeProjectiles.length
}
// INVARIANT 1: Bounded missile count — no runaway projectile accumulation!
// At any tick:
// - At most 2 falling meteorcenters (ttl 60 / 30 = 2)
// - At most 1 meteorexplode (ttl 16)
// - At most 18-36 active residual fire patches (ttl 30)
// Peak active projectiles should never exceed 100.
if (activeProjectiles.length > 100) {
throw new Error(`Runaway projectile leak at tick ${tick}: active count reached ${activeProjectiles.length}`)
}
// INVARIANT 2: Zero NaNs or non-finite numbers in any active missile
for (const p of activeProjectiles) {
if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) {
throw new Error(`NaN detected in position at tick ${tick}: x=${p.x}, y=${p.y}`)
}
if (p.altitude !== undefined && (!Number.isFinite(p.altitude) || p.altitude < 0)) {
throw new Error(`Invalid altitude at tick ${tick}: altitude=${p.altitude}`)
}
if (!Number.isFinite(p.ttl) || p.ttl < 0) {
throw new Error(`Invalid ttl at tick ${tick}: ttl=${p.ttl}`)
}
}
}
const simElapsedMs = Date.now() - simStartMs
const heapAfter = process.memoryUsage().heapUsed
const heapDiffMb = (heapAfter - heapBefore) / (1024 * 1024)
// Assertions on 50,000 tick completion
expect(totalCasts).toBe(Math.floor(50000 / 30) + 1) // 1,667 Meteor casts
expect(totalHits).toBeGreaterThan(1000) // thousands of hits registered
expect(peakActiveCount).toBeLessThanOrEqual(65) // comfortably bounded
expect(activeProjectiles.length).toBeLessThanOrEqual(40) // final active count bounded
expect(heapDiffMb).toBeLessThan(25) // stable heap (< 25 MB delta)
console.log(`[50,000-Tick Meteor Stress Sim Completed]`)
console.log(` Duration: ${simElapsedMs} ms (${(50000 / (simElapsedMs / 1000)).toFixed(0)} ticks/sec)`)
console.log(` Total Casts: ${totalCasts}`)
console.log(` Total Hits: ${totalHits}`)
console.log(` Total Wall Hits: ${totalWallHits}`)
console.log(` Total Expired Missiles: ${totalExpired}`)
console.log(` Peak Active Projectiles: ${peakActiveCount}`)
console.log(` Final Active Projectiles: ${activeProjectiles.length}`)
console.log(` Heap Diff: ${heapDiffMb.toFixed(2)} MB`)
})
})
})