/** * Tests for Diablo II v1.13c LightGrid and Environment engine. * Verifies mathematical parity against decompiled D2Client `dLightMap.c` and D2Common `D2Environment.cpp`. */ import { describe, it, expect } from 'vitest' import { LightGrid, fastDistanceApprox, LIGHT_GRID_SIZE, SUB_TILES_PER_LIGHT_CELL, } from '../src/game/engine/light-grid.ts' import { Environment, NORMAL_ENV_CYCLES, ACT4_ENV_CYCLES, EnvCycleIndex, } from '../src/game/engine/environment.ts' import { COLLIDE_VISIBLE, COLLIDE_BLANK, type CollisionGrid } from '../src/game/d2map.ts' describe('D2Client v1.13c dLightMap Fast Distance Approximation', () => { it('implements Alpha Max Plus Beta Min with <= 3.96% theoretical error', () => { // Exact cardinal distances expect(fastDistanceApprox(100, 0)).toBe((983 * 100) >> 10) // 95 expect(fastDistanceApprox(0, 100)).toBe((983 * 100) >> 10) // 95 expect(fastDistanceApprox(-100, 0)).toBe((983 * 100) >> 10) // 45 degree diagonal: dx = 100, dy = 100 // Real hypot = sqrt(20000) ~ 141.42 // Approx = (983 * 100 + 407 * 100) >> 10 = 139000 >> 10 = 135 (error ~4.5% integer vs float) const approx45 = fastDistanceApprox(100, 100) expect(approx45).toBe(135) }) }) describe('D2Client v1.13c LightGrid Attenuation & Blending', () => { it('correctly creates 48x48 cell buffers with proper dimensions', () => { const grid = new LightGrid() expect(grid.intensity.length).toBe(48 * 48) expect(grid.textureBuffer.length).toBe(48 * 48 * 4) }) it('calculates linear falloff and attenuation matching 1.13c formula', () => { const grid = new LightGrid() grid.centerOnSubTile(200, 200) // Add a white light at the center: radius 32 sub-tiles, intensity 255 grid.addLight({ subTileX: 200, subTileY: 200, radius: 32, intensity: 255, red: 255, green: 255, blue: 255, }) // Center cell (24, 24) should be brightest const centerIdx = 24 * LIGHT_GRID_SIZE + 24 expect(grid.intensity[centerIdx]).toBeGreaterThan(200) expect(grid.red[centerIdx]).toBe(255) expect(grid.green[centerIdx]).toBe(255) expect(grid.blue[centerIdx]).toBe(255) // Cell at distance > 32 sub-tiles (4 cells away) should have 0 added intensity const farIdx = (24 + 5) * LIGHT_GRID_SIZE + 24 expect(grid.intensity[farIdx]).toBe(0) }) it('blocks light completely when obstacle factor >= 16', () => { const grid = new LightGrid() grid.centerOnSubTile(200, 200) // Artificially place an opaque obstacle in front of the light const targetCellIdx = 24 * LIGHT_GRID_SIZE + 25 grid.obstacle[targetCellIdx] = 16 grid.addLight({ subTileX: 200, subTileY: 200, radius: 32, intensity: 255, red: 255, green: 200, blue: 100, }) // Target cell was completely blocked expect(grid.intensity[targetCellIdx]).toBe(0) }) it('performs intensity-weighted color blending when lights overlap', () => { const grid = new LightGrid() grid.centerOnSubTile(200, 200) // Light 1: Pure Red (255, 0, 0), Intensity 100 grid.addLight({ subTileX: 200, subTileY: 200, radius: 24, intensity: 100, red: 255, green: 0, blue: 0, }) // Light 2: Pure Blue (0, 0, 255), Intensity 100 at same position grid.addLight({ subTileX: 200, subTileY: 200, radius: 24, intensity: 100, red: 0, green: 0, blue: 255, }) const centerIdx = 24 * LIGHT_GRID_SIZE + 24 // Intensities sum up to ~180-200 expect(grid.intensity[centerIdx]).toBeGreaterThan(150) // Red and Blue should be roughly equal (purple blend) const r = grid.red[centerIdx]! const b = grid.blue[centerIdx]! expect(Math.abs(r - b)).toBeLessThanOrEqual(5) expect(grid.green[centerIdx]).toBe(0) }) it('performs 8.8 fixed point bilinear interpolation smoothly', () => { const grid = new LightGrid() grid.centerOnSubTile(200, 200) grid.addLight({ subTileX: 200, subTileY: 200, radius: 40, intensity: 255, red: 255, green: 255, blue: 255, }) // Sampling at exact sub-tile positions should yield smooth gradient const sample0 = grid.sampleBilinear(200, 200) const sample1 = grid.sampleBilinear(208, 200) const sample2 = grid.sampleBilinear(216, 200) expect(sample0.intensity).toBeGreaterThan(sample1.intensity) expect(sample1.intensity).toBeGreaterThan(sample2.intensity) }) }) describe('D2Common v1.13c Environment Day/Night Simulation', () => { it('indoor levels return constant ambient from Levels.txt', () => { const env = new Environment() // Level 8 (Den of Evil) is inside with Intensity 0 (total darkness without light sources) const ambientDen = env.tick(8, 1) expect(ambientDen.isInside).toBe(true) expect(ambientDen.intensity).toBe(0) // Level 50 (Harem Level 1) is inside with Intensity 128 const ambientHarem = env.tick(50, 2) expect(ambientHarem.isInside).toBe(true) expect(ambientHarem.intensity).toBe(128) }) it('outdoor levels follow solar angle sine curve', () => { const env = new Environment() // Start at noon env.setTime(EnvCycleIndex.Noon, 0) const noonAmbient = env.tick(2, 1) // Level 2 = Blood Moor expect(noonAmbient.isInside).toBe(false) expect(noonAmbient.intensity).toBeGreaterThan(120) }) it('Act IV levels lerp towards canonical fixed target intensities', () => { const env = new Environment() // Level 103 = Pandemonium Fortress (target 128) env.currentIntensity = 120 const ambient = env.tick(103, 4) expect(ambient.intensity).toBe(121) // Increments by 1 towards 128 // Level 104 = Outer Steppes (target 64) env.currentIntensity = 100 const ambientSteppes = env.tick(104, 4) expect(ambientSteppes.intensity).toBe(99) // Decrements by 1 towards 64 }) it('Rocky Summit (Level 120) has fixed intensity 200 and custom RGB tint', () => { const env = new Environment() const ambient = env.tick(120, 5) expect(ambient.intensity).toBe(200) expect(ambient.red).toBe(245) expect(ambient.green).toBe(240) expect(ambient.blue).toBe(255) }) })