diff --git a/tests/blizzard-challenger-stress.test.ts b/tests/blizzard-challenger-stress.test.ts new file mode 100644 index 0000000..8f1f4cf --- /dev/null +++ b/tests/blizzard-challenger-stress.test.ts @@ -0,0 +1,506 @@ +/** + * Diablo II: Lord of Destruction v1.13c — Blizzard (Skill 59) Empirical Challenger Suite + * + * EMPIRICAL CHALLENGER VERIFICATION (solution-stress-testing playbook): + * 1. 5-band damage progression across slvl 1 to 50 matches 1.13c formula exactly + * (base 45-75, bands [15/16, 30/31, 45/46, 55/56, 65/66]). + * 2. Synergy multiplier matches +5% per hard point in Ice Bolt, Ice Blast, Glacial Spike. + * 3. Authoritative 64-entry circle table values in `src/game/engine/blizzard-table.ts` + * match D2Game.dll 0x6FD1C960 and 0x6FD1CA60 with 0 errors. + * 4. Exactly 25 shards dropped over 100 ticks (cadence 4, step 13 mod 64). + * 5. 2:1 isometric aspect ratio scaling on ground plane (`dy = tblY * scale * 0.5`). + * 6. Altitude descent (120 px to 0 over 9 ticks) and ground detonation. + * 7. NextDelay = 0 sweet-spot burst collision. + * 8. High-density performance stress test (100 concurrent Blizzard centers = 2,500 shards). + */ +import { describe, expect, it } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +import { + calculateBlizzardDamage, + calculateBlizzardSynergyMultiplier, + tickProjectiles, + ISO_GROUND_ASPECT_RATIO, + type Projectile, +} from '../src/game/skills.ts' +import { + BLIZZARD_CIRCLE_X, + BLIZZARD_CIRCLE_Y, + getBlizzardDropOffset, + SUBTILE_TO_PX, +} from '../src/game/engine/blizzard-table.ts' +import { getSharedDataRegistry } from '../src/game/engine/data-registry.ts' +import { UnitStatList } from '../src/game/engine/stat-list.ts' +import { skillModule } from '../src/game/skills/impl/sor/skill-059-blizzard.ts' + +/** + * Independent mathematical oracle for 1.13c Blizzard 5-band damage. + * Derived directly from D2Common.dll disassembly VA 0x6FD9DDB0 & 0x6FDBA580: + * Base: 45 - 75 + * Band 1 (slvl 2..8): +15 / +16 per slvl (7 levels) + * Band 2 (slvl 9..16): +30 / +31 per slvl (8 levels) + * Band 3 (slvl 17..22): +45 / +46 per slvl (6 levels) + * Band 4 (slvl 23..28): +55 / +56 per slvl (6 levels) + * Band 5 (slvl 29+): +65 / +66 per slvl + */ +function oracle113cBlizzardDamage( + slvl: number, + synergies: { iceBolt?: number; iceBlast?: number; glacialSpike?: number } = {}, +): { min: number; max: number } { + const lvl = Math.max(1, Math.floor(slvl)) + let min = 45 + let max = 75 + + if (lvl > 1) { + const b1 = Math.min(lvl, 8) - 1 + min += b1 * 15 + max += b1 * 16 + } + if (lvl > 8) { + const b2 = Math.min(lvl, 16) - 8 + min += b2 * 30 + max += b2 * 31 + } + if (lvl > 16) { + const b3 = Math.min(lvl, 22) - 16 + min += b3 * 45 + max += b3 * 46 + } + if (lvl > 22) { + const b4 = Math.min(lvl, 28) - 22 + min += b4 * 55 + max += b4 * 56 + } + if (lvl > 28) { + const b5 = lvl - 28 + min += b5 * 65 + max += b5 * 66 + } + + const hardPoints = + (synergies.iceBolt ?? 0) + (synergies.iceBlast ?? 0) + (synergies.glacialSpike ?? 0) + const synMultiplier = 1.0 + hardPoints * 0.05 + + return { + min: Math.floor(min * synMultiplier), + max: Math.floor(max * synMultiplier), + } +} + +describe('[Skill #059 Challenger] Empirical Stress Testing & Oracle Verification Suite', () => { + it('1. 5-band damage progression across slvl 1 to 50 matches 1.13c formula exactly with 0 deviations', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(59)! + expect(skillRec).toBeDefined() + + // Test every level from 1 to 50 + for (let slvl = 1; slvl <= 50; slvl++) { + const expected = oracle113cBlizzardDamage(slvl) + const actual = calculateBlizzardDamage(slvl) + + expect(actual.min).toBe(expected.min) + expect(actual.max).toBe(expected.max) + + // Also compare against D2Common UnitStatList AST evaluation + const stats = new UnitStatList() + stats.setBaseSkillLevel(59, slvl) + const evalRes = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl, + blvl: slvl, + statList: stats, + }) + + // HitShift = 8 -> 256 fixed point + expect(evalRes.minElemDmg256).toBe(expected.min * 256) + expect(evalRes.maxElemDmg256).toBe(expected.max * 256) + } + + // Explicit checkpoint assertions for all 5 band transition points + // Band 1: slvl 1 (base 45-75), slvl 8 (150-187) + expect(calculateBlizzardDamage(1)).toEqual({ min: 45, max: 75 }) + expect(calculateBlizzardDamage(8)).toEqual({ min: 150, max: 187 }) + + // Band 2: slvl 9 (180-218), slvl 10 (210-249), slvl 16 (390-435) + expect(calculateBlizzardDamage(9)).toEqual({ min: 180, max: 218 }) + expect(calculateBlizzardDamage(10)).toEqual({ min: 210, max: 249 }) + expect(calculateBlizzardDamage(16)).toEqual({ min: 390, max: 435 }) + + // Band 3: slvl 17 (435-481), slvl 20 (570-619), slvl 22 (660-711) + expect(calculateBlizzardDamage(17)).toEqual({ min: 435, max: 481 }) + expect(calculateBlizzardDamage(20)).toEqual({ min: 570, max: 619 }) + expect(calculateBlizzardDamage(22)).toEqual({ min: 660, max: 711 }) + + // Band 4: slvl 23 (715-767), slvl 28 (990-1047) + expect(calculateBlizzardDamage(23)).toEqual({ min: 715, max: 767 }) + expect(calculateBlizzardDamage(28)).toEqual({ min: 990, max: 1047 }) + + // Band 5: slvl 29 (1055-1113), slvl 30 (1120-1179), slvl 50 (2420-2499) + expect(calculateBlizzardDamage(29)).toEqual({ min: 1055, max: 1113 }) + expect(calculateBlizzardDamage(30)).toEqual({ min: 1120, max: 1179 }) + expect(calculateBlizzardDamage(50)).toEqual({ min: 2420, max: 2499 }) + }) + + it('2. Synergy multiplier matches +5% per hard point with 1,000 randomized combination fuzzing', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(59)! + + // Test zero synergies + expect(calculateBlizzardSynergyMultiplier({})).toBe(1.0) + expect(calculateBlizzardDamage(20, {})).toEqual({ min: 570, max: 619 }) + + // Test individual synergies + expect(calculateBlizzardSynergyMultiplier({ iceBolt: 1 })).toBeCloseTo(1.05, 5) + expect(calculateBlizzardSynergyMultiplier({ iceBlast: 1 })).toBeCloseTo(1.05, 5) + expect(calculateBlizzardSynergyMultiplier({ glacialSpike: 1 })).toBeCloseTo(1.05, 5) + + // Test 20 in single skill: +100% (2.0x) + expect(calculateBlizzardSynergyMultiplier({ iceBolt: 20 })).toBe(2.0) + expect(calculateBlizzardDamage(20, { iceBolt: 20 })).toEqual({ min: 1140, max: 1238 }) + + // Test 20 in two skills: +200% (3.0x) + expect(calculateBlizzardSynergyMultiplier({ iceBolt: 20, iceBlast: 20 })).toBe(3.0) + expect(calculateBlizzardDamage(20, { iceBolt: 20, iceBlast: 20 })).toEqual({ min: 1710, max: 1857 }) + + // Test max legal synergies (60 points total): +300% (4.0x) + expect( + calculateBlizzardSynergyMultiplier({ iceBolt: 20, iceBlast: 20, glacialSpike: 20 }), + ).toBe(4.0) + expect( + calculateBlizzardDamage(20, { iceBolt: 20, iceBlast: 20, glacialSpike: 20 }), + ).toEqual({ min: 2280, max: 2476 }) + + // Fuzz test 1,000 random synergy triples (p1, p2, p3) in range [0..20] + for (let i = 0; i < 1000; i++) { + const p1 = Math.floor(Math.random() * 21) + const p2 = Math.floor(Math.random() * 21) + const p3 = Math.floor(Math.random() * 21) + const synObj = { iceBolt: p1, iceBlast: p2, glacialSpike: p3 } + + const expectedSyn = 1.0 + (p1 + p2 + p3) * 0.05 + const actualSyn = calculateBlizzardSynergyMultiplier(synObj) + expect(actualSyn).toBeCloseTo(expectedSyn, 5) + + const slvl = 1 + Math.floor(Math.random() * 40) + const expectedDmg = oracle113cBlizzardDamage(slvl, synObj) + const actualDmg = calculateBlizzardDamage(slvl, synObj) + expect(actualDmg.min).toBe(expectedDmg.min) + expect(actualDmg.max).toBe(expectedDmg.max) + } + + // Verify 1.13c hard point invariant: soft skills (+skills, charged skills) produce ZERO synergy + const statsSoft = new UnitStatList() + statsSoft.setBaseSkillLevel(59, 20) + statsSoft.addStat('item_allskills', 20) // +20 all skills + statsSoft.setChargedSkillLevel(39, 33) // 33 charges of Ice Bolt (Marrowwalk bug test) + statsSoft.setChargedSkillLevel(45, 33) // 33 charges of Ice Blast + statsSoft.setChargedSkillLevel(55, 33) // 33 charges of Glacial Spike + + const evalSoft = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 40, + blvl: 20, + statList: statsSoft, + }) + expect(evalSoft.synergyBonusPct).toBe(0) + }) + + it('3. Authoritative 64-entry circle table values match D2Game.dll 0x6FD1C960 & 0x6FD1CA60 with 0 errors', () => { + // Read D2Game.dll directly from 1.13c installation on disk + const dllPath = '/usr/local/google/home/taodao/d2-data/D2Game.dll' + expect(fs.existsSync(dllPath), `Missing D2Game.dll at ${dllPath}`).toBe(true) + + const dllBuffer = fs.readFileSync(dllPath) + + // Offsets in D2Game.dll file: + // ImageBase = 0x6FC20000, .rdata file offset = 0xF8000, VA = 0x6FD18000 + // VA 0x6FD1C960 -> file offset 0xFC960 + // VA 0x6FD1CA60 -> file offset 0xFCA60 + const fileOffsetTableX = 0xFC960 + const fileOffsetTableY = 0xFCA60 + + expect(BLIZZARD_CIRCLE_X.length).toBe(64) + expect(BLIZZARD_CIRCLE_Y.length).toBe(64) + expect(Object.isFrozen(BLIZZARD_CIRCLE_X)).toBe(true) + expect(Object.isFrozen(BLIZZARD_CIRCLE_Y)).toBe(true) + + const dllCircleX: number[] = [] + const dllCircleY: number[] = [] + + for (let i = 0; i < 64; i++) { + dllCircleX.push(dllBuffer.readInt32LE(fileOffsetTableX + i * 4)) + dllCircleY.push(dllBuffer.readInt32LE(fileOffsetTableY + i * 4)) + } + + // Compare all 64 coordinates byte-for-byte against D2Game.dll + let mismatchCount = 0 + const mismatches: string[] = [] + + for (let i = 0; i < 64; i++) { + const codeX = BLIZZARD_CIRCLE_X[i] + const codeY = BLIZZARD_CIRCLE_Y[i] + const binaryX = dllCircleX[i] + const binaryY = dllCircleY[i] + + if (codeX !== binaryX || codeY !== binaryY) { + mismatchCount++ + mismatches.push( + `Index ${i}: code=(${codeX}, ${codeY}) vs dll=(${binaryX}, ${binaryY})`, + ) + } + } + + expect(mismatchCount).toBe(0) + expect(mismatches).toEqual([]) + }) + + it('4. Exactly 25 shards dropped over 100 ticks (cadence 4, step 13 mod 64) with zero clustering', () => { + let center: Projectile = { + skillId: '59', + x: 500, + y: 500, + vx: 0, + vy: 0, + damage: 250, + ttl: 100, // 100 ticks = 4.0s + fromPlayer: true, + missileType: 'blizzardcenter', + subMissileIdx: 0, + } + + const emittedShards: Projectile[] = [] + const angleIndicesUsed: number[] = [] + const dummyTerrain = { overlap: () => 0 } + + for (let tick = 1; tick <= 100; tick++) { + const outcome = tickProjectiles([center], [], dummyTerrain) + + 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.ttl).toBe(9) + expect(shard.altitude).toBe(120) + expect(shard.damage).toBe(250) + expect(shard.statusEffect).toBe('chill') + expect(shard.statusDuration).toBe(100) + + emittedShards.push(shard) + } else { + expect(outcome.spawnedProjectiles?.length ?? 0).toBe(0) + } + + center = outcome.alive.find(p => p.missileType === 'blizzardcenter')! + if (tick < 100) { + expect(center).toBeDefined() + expect(center.ttl).toBe(100 - tick) + } + } + + // 1. Total count is strictly 25 shards + expect(emittedShards.length).toBe(25) + + // 2. Center expires at tick 101 + const finalOutcome = tickProjectiles([center], [], dummyTerrain) + expect(finalOutcome.expired).toBe(1) + expect(finalOutcome.alive.length).toBe(0) + + // 3. Step 13 mod 64 sequence: gcd(13, 64) = 1, ensuring zero duplicate indices in 25 drops + for (let k = 0; k < 25; k++) { + const expectedIndex = (k * 13) % 64 + angleIndicesUsed.push(expectedIndex) + } + const uniqueIndices = new Set(angleIndicesUsed) + expect(uniqueIndices.size).toBe(25) // All 25 drops use distinct angle indices! + }) + + it('5. 2:1 isometric aspect ratio scaling on ground plane (dy = tblY * scale * 0.5)', () => { + expect(ISO_GROUND_ASPECT_RATIO).toBe(0.5) + + const radiusSubtiles = 7 + const subtileToPx = SUBTILE_TO_PX // 16 + const scale = (radiusSubtiles * subtileToPx) / 30 // 112 / 30 = 3.7333333333333334 + + // Test all 64 drop offsets + for (let i = 0; i < 64; i++) { + const offset = getBlizzardDropOffset(i, radiusSubtiles, subtileToPx) + const rawX = BLIZZARD_CIRCLE_X[i]! + const rawY = BLIZZARD_CIRCLE_Y[i]! + + const expectedDx = rawX * scale + const expectedDy = rawY * scale * 0.5 + + expect(offset.dx).toBeCloseTo(expectedDx, 10) + expect(offset.dy).toBeCloseTo(expectedDy, 10) + + // When rawY is non-zero, aspect ratio dy / (rawY * scale) must be strictly 0.5 + if (rawY !== 0) { + const uncompressedY = rawY * scale + expect(offset.dy / uncompressedY).toBeCloseTo(0.5, 10) + } + } + + // Cardinal coordinate checks: + // East: index 16 (X=30, Y=0) -> dx = 112, dy = 0 + const east = getBlizzardDropOffset(16, 7, 16) + expect(east.dx).toBe(112) + expect(east.dy).toBe(0) + + // South: index 0 (X=0, Y=30) -> dx = 0, dy = 56 + const south = getBlizzardDropOffset(0, 7, 16) + expect(south.dx).toBe(0) + expect(south.dy).toBe(56) + + // West: index 48 (X=-30, Y=0) -> dx = -112, dy = 0 + const west = getBlizzardDropOffset(48, 7, 16) + expect(west.dx).toBe(-112) + expect(west.dy).toBe(0) + + // North: index 32 (X=0, Y=-30) -> dx = 0, dy = -56 + const north = getBlizzardDropOffset(32, 7, 16) + expect(north.dx).toBe(0) + expect(north.dy).toBe(-56) + + // Bounding ellipse width and height: + // Full width = 2 * 112 = 224 px (14 subtiles = 7 subtiles radius) + // Full height = 2 * 56 = 112 px (7 subtiles = 3.5 subtiles vertical radius) + // Ratio height / width = 112 / 224 = 0.5 (2:1 isometric ellipse) + expect(56 / 112).toBe(0.5) + }) + + it('6. Falling shard altitude descends vertically from 120 px to 0 over 9 ticks and detonates on impact', () => { + let shard: Projectile = { + skillId: '59', + x: 200, + y: 200, + vx: 0, + vy: 0, + altitude: 120, + damage: 180, + ttl: 9, + fromPlayer: true, + missileType: 'blizzard1', + } + + const dummyTerrain = { overlap: () => 0 } + + for (let t = 1; t <= 9; t++) { + const outcome = tickProjectiles([shard], [], dummyTerrain) + shard = outcome.alive.find(p => p.missileType === 'blizzard1')! + + if (t < 9) { + expect(shard).toBeDefined() + expect(shard.ttl).toBe(9 - t) + const expectedAlt = 120 - t * (120 / 9) + expect(shard.altitude).toBeCloseTo(expectedAlt, 0.1) + } else { + expect(shard.ttl).toBe(0) + } + } + + // Ttl = 0 expires and detonates ground explosion + const detonation = tickProjectiles([shard], [], dummyTerrain) + expect(detonation.expired).toBe(1) + expect(detonation.spawnedProjectiles!.length).toBe(1) + const exp = detonation.spawnedProjectiles![0]! + expect(exp.missileType).toBe('blizzardexplode1') + expect(exp.damage).toBe(180) + expect(exp.ttl).toBe(6) + expect(exp.statusEffect).toBe('chill') + expect(exp.statusDuration).toBe(100) + }) + + it('7. NextDelay = 0 sweet-spot burst collision: overlapping shards deal full stacked damage', () => { + const shardCount = 5 + const shards: Projectile[] = [] + + for (let i = 0; i < shardCount; i++) { + shards.push({ + skillId: '59', + x: 300 + (i % 2), + y: 300, + vx: 0, + vy: 0, + altitude: 10, + damage: 150, + ttl: 2, + fromPlayer: true, + missileType: `blizzard${(i % 4) + 1}`, + }) + } + + const bossMonster = [{ index: 42, x: 300, y: 300, radius: 25, alive: true }] + const outcome = tickProjectiles(shards, bossMonster, { overlap: () => 0 }) + + // All 5 shards must hit the target without being blocked by NextDelay immunity frames + expect(outcome.hits.length).toBe(shardCount) + for (const hit of outcome.hits) { + expect(hit.targetIndex).toBe(42) + expect(hit.damage).toBe(150) + expect(hit.statusEffect).toBe('chill') + expect(hit.statusDuration).toBe(100) + } + + // Total damage dealt across the single frame is exactly 5 * 150 = 750 + const totalDmg = outcome.hits.reduce((sum, h) => sum + h.damage, 0) + expect(totalDmg).toBe(750) + + // All 5 shards must spawn explosion missiles + expect(outcome.spawnedProjectiles!.length).toBe(shardCount) + }) + + it('8. High-density performance stress test: 100 concurrent Blizzard centers execute well under 50% budget', () => { + // 100 concurrent Blizzard centers active simultaneously + const centers: Projectile[] = [] + for (let c = 0; c < 100; c++) { + centers.push({ + skillId: '59', + x: 100 + c * 5, + y: 100 + c * 5, + vx: 0, + vy: 0, + damage: 300, + ttl: 100, + fromPlayer: true, + missileType: 'blizzardcenter', + subMissileIdx: c % 64, + }) + } + + let activeProjectiles: Projectile[] = [...centers] + let totalShardsSpawned = 0 + let totalExplosionsSpawned = 0 + const dummyTerrain = { overlap: () => 0 } + + const startTime = performance.now() + + // Simulate 100 ticks (2,500 falling shards + 4,400 ground explosions spawned + simulated) + for (let tick = 1; tick <= 100; tick++) { + const outcome = tickProjectiles(activeProjectiles, [], dummyTerrain) + activeProjectiles = [...outcome.alive] + if (outcome.spawnedProjectiles) { + for (const p of outcome.spawnedProjectiles) { + if (['blizzard1', 'blizzard2', 'blizzard3', 'blizzard4'].includes(p.missileType ?? '')) { + totalShardsSpawned++ + } else if (p.missileType?.startsWith('blizzardexplode')) { + totalExplosionsSpawned++ + } + } + activeProjectiles.push(...outcome.spawnedProjectiles) + } + } + + const elapsedMs = performance.now() - startTime + + // 100 centers * 25 shards = 2,500 shards spawned + expect(totalShardsSpawned).toBe(2500) + expect(totalExplosionsSpawned).toBe(4400) + + // 100 frames simulation of 100 centers + 2,500 shards + 4,400 explosions should execute in < 500ms + expect(elapsedMs).toBeLessThan(500) + }) +}) diff --git a/tests/blizzard-stress.test.ts b/tests/blizzard-stress.test.ts new file mode 100644 index 0000000..bf8b275 --- /dev/null +++ b/tests/blizzard-stress.test.ts @@ -0,0 +1,636 @@ +/** + * Diablo II: Lord of Destruction v1.13c — Skill #059: Blizzard + * Empirical Stress Testing & Adversarial Verification Suite + * + * Requirements (User Request & Solution Stress Testing Playbook): + * 1. Altitude descent: 120 px to 0 px over 9 ticks (linear 120/9 px/tick, clamped at 0). + * 2. Ground impact explosion at tick 9 vs immediate explosion on monster collision at tick t < 9. + * 3. NextDelay = 0: multiple shards hitting the same target deal damage without cooldown immunity + * (both simultaneous hits and consecutive tick hits, with differential oracle contrast). + * 4. Wall collisions: shards hitting walls explode and terminate cleanly without memory leak or hanging. + * 5. 100,000 tick continuous stress simulation: verifying stability, bounded active missile count, + * zero NaNs, zero crashes, and zero memory leaks over 2,222+ Blizzard casts. + */ +import { describe, expect, it } from 'vitest' +import { + tickProjectiles, + ISO_GROUND_ASPECT_RATIO, + calculateBlizzardDamage, + calculateBlizzardSynergyMultiplier, + CANONICAL_113C_MISSILES, + type Projectile, + type ProjectileTarget, +} from '../src/game/skills.ts' +import { + BLIZZARD_CIRCLE_X, + BLIZZARD_CIRCLE_Y, + getBlizzardDropOffset, +} from '../src/game/engine/blizzard-table.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' +import { drawMissileProjectile } from '../src/scene/act-scene.ts' + +describe('[Skill #059] Blizzard — Empirical Stress & Physics Challenger Suite', () => { + const openTerrain = { overlap: () => 0 } + const wallTerrain = { overlap: () => 1 } + + // ========================================================================= + // Dimension 1: Shard Altitude Descent Physics (120 px -> 0 px over 9 ticks) + // ========================================================================= + describe('Dimension 1: Shard Altitude Descent Physics (120 px -> 0 px over 9 ticks)', () => { + const shardVariants = ['blizzard1', 'blizzard2', 'blizzard3', 'blizzard4'] as const + + for (const variant of shardVariants) { + it(`verifies altitude descent of ${variant} tick-by-tick from 120 px to 0 px over exactly 9 ticks`, () => { + let shard: Projectile = { + skillId: '59', + x: 200, + y: 200, + vx: 0, + vy: 0, + altitude: 120, + damage: 100, + ttl: 9, + fromPlayer: true, + missileType: variant, + } + + const descentRate = 120 / 9 // 13.333333333333334 px/tick + + for (let tick = 1; tick <= 9; tick++) { + const outcome = tickProjectiles([shard], [], openTerrain) + shard = outcome.alive.find(p => p.missileType === variant)! + + expect(shard).toBeDefined() + expect(shard.ttl).toBe(9 - tick) + + const expectedAltitude = Math.max(0, 120 - tick * descentRate) + expect(shard.altitude).toBeCloseTo(expectedAltitude, 0.001) + + if (tick === 9) { + // At tick 9, altitude reaches ground level (within IEEE-754 float precision ~1e-14) + expect(shard.altitude).toBeCloseTo(0, 5) + expect(shard.ttl).toBe(0) + } else { + expect(shard.altitude).toBeGreaterThan(0) + } + } + }) + } + + it('verifies altitude never drops below zero and remains non-negative even if ticked past 0', () => { + let shard: Projectile = { + skillId: '59', + x: 100, + y: 100, + vx: 0, + vy: 0, + altitude: 5, // very close to ground + damage: 100, + ttl: 5, + fromPlayer: true, + missileType: 'blizzard1', + } + + // Tick once: 5 - (120/9) < 0, must be clamped to exactly 0 + const outcome = tickProjectiles([shard], [], openTerrain) + const ticked = outcome.alive.find(p => p.missileType === 'blizzard1')! + expect(ticked.altitude).toBe(0) + expect(Number.isNaN(ticked.altitude!)).toBe(false) + expect(Number.isFinite(ticked.altitude!)).toBe(true) + }) + + it('verifies visual screen projection offset: drawY = WorldY - altitude', () => { + const drawnSprites: { x: number; y: number }[] = [] + const mockRenderer = { + draw: (_frame: any, x: number, y: number) => { + drawnSprites.push({ x, y }) + }, + drawSolid: (_x: number, _y: number) => {}, + } as any + + const mockArt = { + meta: { name: 'blizzard1', directions: 1, framesPerDirection: 6, animSpeed: 16 }, + frames: { 0: [{ anchorX: 0, anchorY: 0, width: 32, height: 64 }] }, + handle: 'atlas', + } as any + + // Shard at World (200, 200), altitude 120 -> screen Y should be 200 - 120 = 80 + const shardHigh: Projectile = { + skillId: '59', + x: 200, + y: 200, + vx: 0, + vy: 0, + altitude: 120, + damage: 100, + ttl: 9, + fromPlayer: true, + missileType: 'blizzard1', + } + + drawMissileProjectile(mockRenderer, shardHigh, mockArt) + expect(drawnSprites.length).toBe(1) + expect(drawnSprites[0]!.y).toBe(200 - 120) // 80 px + + // Shard at World (200, 200), altitude 0 (ground level) -> screen Y should be 200 + drawnSprites.length = 0 + const shardGround: Projectile = { + ...shardHigh, + altitude: 0, + ttl: 0, + } + drawMissileProjectile(mockRenderer, shardGround, mockArt) + expect(drawnSprites.length).toBe(1) + expect(drawnSprites[0]!.y).toBe(200) // 200 px + }) + + it('verifies all 25 shards spawned from blizzardcenter initialize with altitude 120 px', () => { + const center: Projectile = { + skillId: '59', + x: 300, + y: 300, + vx: 0, + vy: 0, + damage: 200, + ttl: 100, + fromPlayer: true, + missileType: 'blizzardcenter', + subMissileIdx: 0, + } + + let current: Projectile[] = [center] + const spawnedShards: Projectile[] = [] + + for (let t = 1; t <= 100; t++) { + const res = tickProjectiles(current, [], openTerrain) + current = res.alive as Projectile[] + if (res.spawnedProjectiles && res.spawnedProjectiles.length > 0) { + for (const p of res.spawnedProjectiles) { + if (p.missileType?.startsWith('blizzard') && !p.missileType.includes('explode')) { + spawnedShards.push(p) + } + } + } + } + + expect(spawnedShards.length).toBe(25) + for (const shard of spawnedShards) { + expect(shard.altitude).toBe(120) + expect(shard.ttl).toBe(9) + expect(shard.damage).toBe(200) + expect(shard.statusEffect).toBe('chill') + expect(shard.statusDuration).toBe(100) + } + }) + }) + + // ========================================================================= + // Dimension 2: Ground Impact Explosion (t = 9) vs Immediate Collision (t < 9) + // ========================================================================= + describe('Dimension 2: Ground Impact Explosion (t = 9) vs Immediate Collision (t < 9)', () => { + it('Case A (Ground Impact): shard reaches altitude 0 at tick 9 and detonates ground impact explosion on expiry', () => { + let shard: Projectile = { + skillId: '59', + x: 250, + y: 250, + vx: 0, + vy: 0, + altitude: 120, + damage: 150, + ttl: 9, + fromPlayer: true, + missileType: 'blizzard1', + } + + // Over ticks 1..9, no monster is present + for (let tick = 1; tick <= 9; tick++) { + const res = tickProjectiles([shard], [], openTerrain) + expect(res.hits.length).toBe(0) + expect(res.expired).toBe(0) + shard = res.alive.find(p => p.missileType === 'blizzard1')! + expect(shard).toBeDefined() + } + + // At tick 9, shard is at ground level (altitude ~0 within IEEE-754 precision, ttl 0) + expect(shard.altitude).toBeCloseTo(0, 5) + expect(shard.ttl).toBe(0) + + // On next tick (tick 10), shard expires and detonates ground explosion + const detonateRes = tickProjectiles([shard], [], openTerrain) + expect(detonateRes.expired).toBe(1) + expect(detonateRes.alive.some(p => p.missileType === 'blizzard1')).toBe(false) + expect(detonateRes.spawnedProjectiles).toBeDefined() + expect(detonateRes.spawnedProjectiles!.length).toBe(1) + + const exp = detonateRes.spawnedProjectiles![0]! + expect(exp.missileType).toBe('blizzardexplode1') + expect(exp.ttl).toBe(6) // 1.13c Range = 6 + expect(exp.statusEffect).toBe('chill') + expect(exp.statusDuration).toBe(100) // 1.13c ElemLen = 100 + expect(exp.x).toBe(250) + expect(exp.y).toBe(250) + }) + + it('Case B (Immediate Monster Collision at t < 9): parametric sweep across all ticks t in [1..8]', () => { + // Test collision at every possible mid-air tick: t = 1, 2, 3, 4, 5, 6, 7, 8 + for (let collisionTick = 1; collisionTick <= 8; collisionTick++) { + let shard: Projectile = { + skillId: '59', + x: 200, + y: 200, + vx: 0, + vy: 0, + altitude: 120, + damage: 120, + ttl: 9, + fromPlayer: true, + missileType: 'blizzard2', + } + + // Ticks before collision: target is distant/inactive + const distantTarget: ProjectileTarget = { index: 1, x: 9999, y: 9999, radius: 20, alive: true } + for (let t = 1; t < collisionTick; t++) { + const res = tickProjectiles([shard], [distantTarget], openTerrain) + expect(res.hits.length).toBe(0) + shard = res.alive.find(p => p.missileType === 'blizzard2')! + expect(shard).toBeDefined() + expect(shard.altitude).toBeGreaterThan(0) + } + + // At collisionTick, place target directly under the falling shard + const closeTarget: ProjectileTarget = { index: 1, x: 200, y: 200, radius: 20, alive: true } + const hitRes = tickProjectiles([shard], [closeTarget], openTerrain) + + // 1. Hit must register IMMEDIATELY at collisionTick + expect(hitRes.hits.length).toBe(1) + const hit = hitRes.hits[0]! + expect(hit.targetIndex).toBe(1) + expect(hit.damage).toBe(120) + expect(hit.statusEffect).toBe('chill') + expect(hit.statusDuration).toBe(100) + + // 2. Shard must be destroyed IMMEDIATELY on collision (not survive to tick 9) + expect(hitRes.alive.some(p => p.missileType === 'blizzard2')).toBe(false) + + // 3. Impact explosion must be spawned IMMEDIATELY at collisionTick + expect(hitRes.spawnedProjectiles).toBeDefined() + expect(hitRes.spawnedProjectiles!.length).toBe(1) + const exp = hitRes.spawnedProjectiles![0]! + expect(exp.missileType).toBe('blizzardexplode2') + expect(exp.ttl).toBe(6) + expect(exp.x).toBe(200) + expect(exp.y).toBe(200) + } + }) + + it('empirical finding: blizzardexplode spawned on monster hit carries damage: 0 to prevent double damage', () => { + const shard: Projectile = { + skillId: '59', + x: 200, + y: 200, + vx: 0, + vy: 0, + altitude: 60, + damage: 150, + ttl: 4, + fromPlayer: true, + missileType: 'blizzard3', + } + + const target: ProjectileTarget = { index: 1, x: 200, y: 200, radius: 20, alive: true } + const res = tickProjectiles([shard], [target], openTerrain) + + expect(res.hits.length).toBe(1) + expect(res.hits[0]!.damage).toBe(150) // shard dealt full damage + + // Spawned explosion must have damage = 0 + const exp = res.spawnedProjectiles![0]! + expect(exp.missileType).toBe('blizzardexplode3') + expect(exp.damage).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 3: NextDelay = 0 Invariant (No Cooldown Immunity / Sweet-Spot Burst) + // ========================================================================= + describe('Dimension 3: NextDelay = 0 Invariant (No Cooldown Immunity / Sweet-Spot Burst)', () => { + it('verifies simultaneous hits: 5 overlapping shards hitting the same target on the same tick all deal full damage', () => { + const shards: Projectile[] = [1, 2, 3, 4, 5].map(i => ({ + skillId: '59', + x: 200, + y: 200, + vx: 0, + vy: 0, + damage: 100, + ttl: 5, + fromPlayer: true, + missileType: `blizzard${((i - 1) % 4) + 1}`, + })) + + const target: ProjectileTarget = { index: 99, x: 200, y: 200, radius: 20, alive: true } + const res = tickProjectiles(shards, [target], openTerrain) + + // All 5 shards must hit the target without being blocked by immunity frames + expect(res.hits.length).toBe(5) + let totalDamage = 0 + for (const hit of res.hits) { + expect(hit.targetIndex).toBe(99) + expect(hit.damage).toBe(100) + expect(hit.statusEffect).toBe('chill') + totalDamage += hit.damage + } + expect(totalDamage).toBe(500) + }) + + it('verifies consecutive tick hits: shards hitting on ticks t, t+1, t+2, t+3 all deal damage without NextDelay lockout', () => { + const target: ProjectileTarget = { index: 42, x: 200, y: 200, radius: 20, alive: true } + let totalDamage = 0 + const recordedHits: number[] = [] + + // On 4 consecutive ticks, a new shard hits the target + for (let t = 1; t <= 4; t++) { + const shard: Projectile = { + skillId: '59', + x: 200, + y: 200, + vx: 0, + vy: 0, + damage: 150, + ttl: 5, + fromPlayer: true, + missileType: 'blizzard1', + } + + const res = tickProjectiles([shard], [target], openTerrain) + expect(res.hits.length).toBe(1) + expect(res.hits[0]!.damage).toBe(150) + recordedHits.push(res.hits[0]!.damage) + totalDamage += res.hits[0]!.damage + } + + expect(recordedHits.length).toBe(4) + expect(totalDamage).toBe(600) // 150 * 4 + }) + + it('differential contrast oracle: compares NextDelay = 0 (Blizzard) against NextDelay = 4 (Nova / Chain Lightning)', 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: 'boss-target', + name: 'BossDummy', + isUndead: false, + isDemon: true, + statList: dummyStatList, + stateBus: new StateBus(dummyStatList, registry), + } + + const targetPositions = new Map([['boss-target', { x: 100, y: 100 }]]) + + // 1. Contrast Test: Nova with nextDelay = 4 + // Spawn 2 Nova missiles hitting on tick 1 and tick 2 + engine.spawnMissile({ + missileNameOrId: 'nova', + sourceSkillId: 48, + slvl: 20, + owner, + startX: 100, + startY: 100, + targetX: 100, + targetY: 100, + dmgPacket: { skillId: 48, attackKind: 'spell', elemType: 'ltng', elemMin256: 100 * 256, elemMax256: 100 * 256, autoHit: true }, + }) + engine.spawnMissile({ + missileNameOrId: 'nova', + sourceSkillId: 48, + slvl: 20, + owner, + startX: 100, + startY: 100, + targetX: 100, + targetY: 100, + dmgPacket: { skillId: 48, attackKind: 'spell', elemType: 'ltng', elemMin256: 100 * 256, elemMax256: 100 * 256, autoHit: true }, + }) + + const novaRes = engine.tick(1, [dummy], targetPositions) + // Under NextDelay = 4, first Nova hits, second Nova is locked out on the same tick! + expect(novaRes.hits.length).toBe(1) + expect(engine.isTargetInNextDelay('boss-target', 1)).toBe(true) + + // 2. Blizzard Test: NextDelay = 0 + // In contrast, Blizzard has NextDelay = 0 (empty in Missiles.txt) + const b1 = registry.getMissileByName('blizzard1')! + expect(b1.nextHit).toBe(false) + expect(b1.nextDelay).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 4: Wall Collisions & Resource Cleanup + // ========================================================================= + describe('Dimension 4: Wall Collisions & Resource Cleanup', () => { + it('verifies descending shard hitting a wall detonates immediately and spawns impact explosion', () => { + const shard: Projectile = { + skillId: '59', + x: 150, + y: 150, + vx: 0, + vy: 0, + altitude: 70, + damage: 100, + ttl: 6, + fromPlayer: true, + missileType: 'blizzard4', + } + + const res = tickProjectiles([shard], [], wallTerrain) + + // Shard halted and destroyed by wall + expect(res.wallHits).toBe(1) + expect(res.alive.some(p => p.missileType === 'blizzard4')).toBe(false) + + // Wall hit event recorded + expect(res.wallHitEvents).toBeDefined() + expect(res.wallHitEvents!.length).toBe(1) + expect(res.wallHitEvents![0]!.missileType).toBe('blizzard4') + + // Explosion spawned + expect(res.spawnedProjectiles).toBeDefined() + expect(res.spawnedProjectiles!.length).toBe(1) + const exp = res.spawnedProjectiles![0]! + expect(exp.missileType).toBe('blizzardexplode1') + expect(exp.ttl).toBe(6) + }) + + it('verifies 1,000 shards fired into solid walls terminate cleanly without memory leak or hanging', () => { + const shards: Projectile[] = [] + for (let i = 0; i < 1000; i++) { + shards.push({ + skillId: '59', + x: 100 + (i % 50), + y: 100 + Math.floor(i / 50), + vx: 0, + vy: 0, + altitude: 120, + damage: 50, + ttl: 9, + fromPlayer: true, + missileType: 'blizzard1', + }) + } + + // Tick 1: all 1,000 shards strike the wall immediately + const tick1 = tickProjectiles(shards, [], wallTerrain) + expect(tick1.wallHits).toBe(1000) + // None of the original shards should survive + expect(tick1.alive.every(p => p.missileType === 'blizzardexplode1')).toBe(true) + expect(tick1.alive.length).toBe(1000) // 1,000 spawned explosions + + // Now tick the 1,000 explosions over open terrain until they all expire (range = 6) + let active = tick1.alive as Projectile[] + for (let t = 1; t <= 6; t++) { + const step = tickProjectiles(active, [], openTerrain) + active = step.alive as Projectile[] + } + + // After 6 ticks, the explosions reached ttl = 0 + // Next tick (tick 7): all 1,000 explosions expire + const finalStep = tickProjectiles(active, [], openTerrain) + expect(finalStep.expired).toBe(1000) + expect(finalStep.alive.length).toBe(0) // Clean zero active missiles! + }) + }) + + // ========================================================================= + // Dimension 5: 100,000-Tick Long-Run Stress Simulation (Crashes, NaNs, Leaks) + // ========================================================================= + describe('Dimension 5: 100,000-Tick Long-Run Stress Simulation (Crashes, NaNs, Leaks)', () => { + it('runs 100,000 ticks of continuous Blizzard 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 targets moving in combat arena + const targets: ProjectileTarget[] = [ + { index: 0, x: 200, y: 200, radius: 25, alive: true }, + { index: 1, x: 215, y: 210, radius: 20, alive: true }, + { index: 2, x: 190, y: 220, radius: 20, alive: true }, + { index: 3, x: 205, y: 185, radius: 20, alive: true }, + { index: 4, x: 300, y: 300, radius: 25, alive: true }, + ] + + // Semi-solid map terrain with boundary walls + const hybridTerrain = { + overlap: (x: number, y: number) => (x > 380 || x < 20 || y > 380 || y < 20 ? 1 : 0), + } + + // 100,000 ticks = 4,000 seconds = 66.6 minutes of non-stop game simulation + for (let tick = 0; tick < 100000; tick++) { + // Cast Blizzard on cooldown (every 45 ticks = 1.80s cast delay) + if (tick % 45 === 0) { + totalCasts++ + // Settle Blizzard center with slight spatial variation across casts + const cx = 200 + ((tick / 45) % 5) * 10 + const cy = 200 + ((tick / 45) % 7) * 8 + activeProjectiles.push({ + skillId: '59', + x: cx, + y: cy, + vx: 0, + vy: 0, + damage: 150, + ttl: 100, + fromPlayer: true, + missileType: 'blizzardcenter', + subMissileIdx: 0, + }) + } + + // Monsters oscillate positions slightly every 10 ticks + if (tick % 10 === 0) { + for (let m = 0; m < targets.length; m++) { + const angle = (tick * 0.05) + m + targets[m] = { + ...targets[m]!, + x: 200 + Math.cos(angle) * 30, + y: 200 + Math.sin(angle) * 15 * 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 memory leak! + // At any tick, at most 3 blizzard centers (ttl 100 / 45 = 2.22) can exist concurrently, + // each emitting shards with ttl 9. Peak active projectiles must never exceed 60. + if (activeProjectiles.length > 60) { + throw new Error(`Memory leak detected at tick ${tick}: active projectile 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 100,000 tick completion + expect(totalCasts).toBe(Math.floor(100000 / 45) + 1) // 2,223 Blizzard casts + expect(totalHits).toBeGreaterThan(1000) // thousands of hits registered + expect(peakActiveCount).toBeLessThanOrEqual(45) // comfortably bounded + expect(activeProjectiles.length).toBeLessThanOrEqual(25) // final active count is bounded + expect(heapDiffMb).toBeLessThan(15) // stable heap (< 15 MB variance) + + console.log(`[100,000-Tick Blizzard Stress Sim Completed]`) + console.log(` Duration: ${simElapsedMs} ms (${(100000 / (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`) + }) + }) +})