/** * Integration tests for Diablo II v1.13c Dynamic Lighting & Environment System. * * Verifies: * 1. Single-pass draw call batching with dynamic lighting enabled (drawCalls === 1). * 2. Exact 10-float vertex stride invariant (FLOATS_PER_VERTEX === 10). * 3. Lazy 48x48 RGBA8 lightmap texture allocation upon first setLighting() invocation. * 4. Additive blend mode bypass (missiles & glowing spell overlays stay radiant in darkness). * 5. Environment ambient calculations for outdoor, indoor, and Act IV levels. * 6. LightGrid obstacle attenuation & Alpha Max Plus Beta Min falloff. */ import { describe, it, expect } from 'vitest' import { SpriteRenderer, FLOATS_PER_VERTEX, type LightingConfig, } from '../src/render/renderer.ts' import { LightGrid, LIGHT_GRID_SIZE } from '../src/game/engine/light-grid.ts' import { Environment } from '../src/game/engine/environment.ts' import { getLevelLighting } from '../src/game/levels-meta.ts' import { COLLIDE_VISIBLE, COLLIDE_WALL, type CollisionGrid } from '../src/game/d2map.ts' interface MockResource { id: number type: string } function createMockCanvas(): { canvas: any stats: { createdTextures: Set glDrawCalls: number } } { let nextId = 1 const stats = { createdTextures: new Set(), glDrawCalls: 0, } const gl: any = { VERTEX_SHADER: 35633, FRAGMENT_SHADER: 35632, COMPILE_STATUS: 35713, LINK_STATUS: 35714, MAX_TEXTURE_SIZE: 3379, MAX_TEXTURE_IMAGE_UNITS: 34930, TEXTURE_2D: 3553, RGBA: 6408, RGBA8: 32856, R8: 33321, RED: 6403, UNSIGNED_BYTE: 5121, UNSIGNED_INT: 5125, FLOAT: 5126, ARRAY_BUFFER: 34962, ELEMENT_ARRAY_BUFFER: 34963, DYNAMIC_DRAW: 35048, STATIC_DRAW: 35044, TRIANGLES: 4, TEXTURE_MIN_FILTER: 10241, TEXTURE_MAG_FILTER: 10240, TEXTURE_WRAP_S: 10242, TEXTURE_WRAP_T: 10243, CLAMP_TO_EDGE: 33071, NEAREST: 9728, LINEAR: 9729, UNPACK_ALIGNMENT: 3317, BLEND: 3042, SRC_ALPHA: 770, ONE: 1, ONE_MINUS_SRC_ALPHA: 771, DEPTH_TEST: 2929, COLOR_BUFFER_BIT: 16384, TEXTURE0: 33984, drawingBufferWidth: 1280, drawingBufferHeight: 720, isContextLost: () => false, getParameter: (param: number) => { if (param === 34930) return 16 // MAX_TEXTURE_IMAGE_UNITS if (param === 3379) return 8192 // MAX_TEXTURE_SIZE return 16 }, createProgram: () => ({ id: nextId++, type: 'program' }), deleteProgram: () => {}, attachShader: () => {}, detachShader: () => {}, linkProgram: () => {}, getProgramParameter: () => true, getProgramInfoLog: () => '', useProgram: () => {}, createShader: (type: number) => ({ id: nextId++, type: type === 35633 ? 'vertexShader' : 'fragmentShader' }), shaderSource: () => {}, compileShader: () => {}, getShaderParameter: () => true, getShaderInfoLog: () => '', deleteShader: () => {}, createVertexArray: () => ({ id: nextId++, type: 'vao' }), deleteVertexArray: () => {}, bindVertexArray: () => {}, createBuffer: () => ({ id: nextId++, type: 'buffer' }), deleteBuffer: () => {}, bindBuffer: () => {}, bufferData: () => {}, bufferSubData: () => {}, getAttribLocation: (_p: any, name: string) => { const map: Record = { a_position: 0, a_uv: 1, a_tint: 2, a_unit: 3, a_paletteRow: 4, } return map[name] ?? -1 }, enableVertexAttribArray: () => {}, vertexAttribPointer: () => {}, vertexAttribIPointer: () => {}, createTexture: () => { const res: MockResource = { id: nextId++, type: 'texture' } stats.createdTextures.add(res) return res }, deleteTexture: (t: MockResource) => { stats.createdTextures.delete(t) }, bindTexture: () => {}, texParameteri: () => {}, texImage2D: () => {}, texStorage2D: () => {}, texSubImage2D: () => {}, pixelStorei: () => {}, getUniformLocation: (_p: any, name: string) => ({ name, id: nextId++ }), uniform2f: () => {}, uniform4f: () => {}, uniform1f: () => {}, uniform1i: () => {}, uniform1iv: () => {}, enable: () => {}, disable: () => {}, blendFunc: () => {}, viewport: () => {}, clearColor: () => {}, clear: () => {}, activeTexture: () => {}, drawArrays: () => { stats.glDrawCalls += 1 }, drawElements: () => { stats.glDrawCalls += 1 }, } const canvas: any = { getContext: (type: string) => (type === 'webgl2' ? gl : null), addEventListener: () => {}, removeEventListener: () => {}, width: 1280, height: 720, drawingBufferWidth: 1280, drawingBufferHeight: 720, } return { canvas, stats } } describe('Scene Lighting & Environment Integration', () => { it('maintains strict 10-float vertex stride invariant', () => { expect(FLOATS_PER_VERTEX).toBe(10) }) it('allocates lightmap texture lazily on first setLighting call', () => { const { canvas } = createMockCanvas() const renderer = new SpriteRenderer(canvas) // Non-lighting setup has 3 default textures (atlas, white, palette) const initialTextures = (renderer as any).allocatedTextures.size expect(initialTextures).toBe(3) // Clear and draw unlit quad renderer.begin({ x: 0, y: 0, zoom: 1 }) renderer.drawSolid(0, 0, 100, 100, [1, 1, 1, 1]) renderer.flush() expect((renderer as any).allocatedTextures.size).toBe(3) // Set lighting const lightGrid = new LightGrid() lightGrid.centerOnSubTile(100, 100) renderer.setLighting({ ambient: [0.2, 0.2, 0.2], originSubX: lightGrid.originSubX, originSubY: lightGrid.originSubY, sceneOriginX: 0, sceneOriginY: 0, lightmap: lightGrid.exportTextureBuffer(), }) // Lightmap texture is now allocated (3 + 1 = 4) expect((renderer as any).allocatedTextures.size).toBe(4) renderer.dispose() }) it('maintains single-pass draw call batching when dynamic lighting is active', () => { const { canvas, stats } = createMockCanvas() const renderer = new SpriteRenderer(canvas) const lightGrid = new LightGrid() lightGrid.centerOnSubTile(200, 200) lightGrid.addLight({ subTileX: 200, subTileY: 200, radius: 55, intensity: 255, red: 255, green: 255, blue: 255, }) renderer.begin({ x: 100, y: 100, zoom: 1 }, [0.03, 0.03, 0.04]) renderer.setLighting({ ambient: [0.15, 0.15, 0.18], originSubX: lightGrid.originSubX, originSubY: lightGrid.originSubY, sceneOriginX: 0, sceneOriginY: 0, lightmap: lightGrid.exportTextureBuffer(), }) // Draw world floor tiles and entities for (let i = 0; i < 50; i += 1) { renderer.drawSolid(i * 10, i * 5, 80, 40, [0.8, 0.8, 0.8, 1]) } // Flush batch renderer.flush() // All 50 quads rendered in exactly 1 single draw call expect(renderer.drawCalls).toBe(1) expect(stats.glDrawCalls).toBe(1) expect(renderer.quadsSubmitted).toBe(50) renderer.dispose() }) it('supports unshaded UI rendering by setting lighting to null before minimap', () => { const { canvas, stats } = createMockCanvas() const renderer = new SpriteRenderer(canvas) const lightGrid = new LightGrid() lightGrid.centerOnSubTile(200, 200) renderer.begin({ x: 100, y: 100, zoom: 1 }) // 1. World pass with dynamic lighting renderer.setLighting({ ambient: [0.1, 0.1, 0.1], originSubX: lightGrid.originSubX, originSubY: lightGrid.originSubY, sceneOriginX: 0, sceneOriginY: 0, lightmap: lightGrid.exportTextureBuffer(), }) renderer.drawSolid(0, 0, 50, 50, [1, 1, 1, 1]) // 2. Automap / Minimap pass without lighting renderer.setLighting(null) renderer.drawSolid(10, 10, 20, 20, [0, 1, 0, 1]) renderer.flush() // Total draw calls: 1 for lit world + 1 for unshaded automap = 2 expect(renderer.drawCalls).toBe(2) expect(stats.glDrawCalls).toBe(2) renderer.dispose() }) it('evaluates authentic 1.13c ambient lighting for indoor and outdoor levels', () => { const env = new Environment() // Level 1: Rogue Encampment (Act 1 outdoor town) const townLight = env.tick(1, 1) expect(townLight.isInside).toBe(false) expect(townLight.intensity).toBeGreaterThan(0) // Level 37: Catacombs Level 4 (Act 1 indoor dungeon, Andariel lair) const cataLight = env.tick(37, 1) expect(cataLight.isInside).toBe(true) const cataMeta = getLevelLighting(37) expect(cataMeta).toBeDefined() expect(cataLight.intensity).toBe(cataMeta!.intensity) expect(cataLight.red).toBe(cataMeta!.red) expect(cataLight.green).toBe(cataMeta!.green) expect(cataLight.blue).toBe(cataMeta!.blue) // Level 103: The Pandemonium Fortress (Act 4 town) // Lerps towards target intensity 128 for (let t = 0; t < 200; t += 1) { env.tick(103, 4) } expect(env.currentIntensity).toBe(128) // Level 104: Outer Steppes (Act 4 wilderness) // Advance several ticks to let lerp converge to target 64 for (let t = 0; t < 200; t += 1) { env.tick(104, 4) } expect(env.currentIntensity).toBe(64) }) it('illuminates emitter cell and wall front-faces while casting shadows behind walls via radial ray-marching', () => { const grid = new LightGrid() grid.centerOnSubTile(200, 200) const collision: CollisionGrid = { cellsX: 80, cellsY: 80, originX: 0, originY: 0, gridWidth: 80 * 5, blocked: new Uint8Array(80 * 5 * 80 * 5), collisionMasks: new Uint16Array(80 * 5 * 80 * 5), } // Place a 2-cell thick sight-blocking wall at cells (25, 24) and (26, 24), // and also touch the player's own cell (24, 24) to test hugging a wall for (const cx of [24, 25, 26]) { const cellStartSubX = grid.originSubX + cx * 8 const cellStartSubY = grid.originSubY + 24 * 8 for (let dy = 2; dy <= 5; dy += 2) { for (let dx = 2; dx <= 5; dx += 2) { const idx = (cellStartSubY + dy) * collision.gridWidth + (cellStartSubX + dx) collision.collisionMasks![idx] = COLLIDE_WALL | COLLIDE_VISIBLE } } } grid.updateObstacles(collision) const centerIdx = 24 * LIGHT_GRID_SIZE + 24 const wallFrontIdx = 24 * LIGHT_GRID_SIZE + 25 const wallSecondIdx = 24 * LIGHT_GRID_SIZE + 26 const behindWallIdx = 24 * LIGHT_GRID_SIZE + 27 expect(grid.obstacle[centerIdx]).toBe(4) expect(grid.obstacle[wallFrontIdx]).toBe(4) expect(grid.obstacle[wallSecondIdx]).toBe(4) expect(grid.obstacle[behindWallIdx]).toBe(0) // Add player torch at center (200, 200) grid.addLight({ subTileX: 200, subTileY: 200, radius: 48, intensity: 255, red: 255, green: 255, blue: 255, }) // 1. Player's own cell (24, 24) is NEVER self-occluded when hugging a wall expect(grid.intensity[centerIdx]).toBeGreaterThan(200) // 2. Front face of the wall (25, 24) receives bright illumination (no black blotches on walls) expect(grid.intensity[wallFrontIdx]).toBeGreaterThan(140) // 3. Second layer of the wall (26, 24) is in penumbra (attenuated by wallFront) expect(grid.intensity[wallSecondIdx]).toBeLessThan(grid.intensity[wallFrontIdx]!) // 4. Floor behind the 2-cell thick wall (27, 24) is completely in shadow (rayObstacle = 4 + 4 = 8) expect(grid.intensity[behindWallIdx]).toBe(0) // Texture buffer export produces valid RGBA8 values const buf = grid.exportTextureBuffer() expect(buf.length).toBe(48 * 48 * 4) const frontAlpha = buf[wallFrontIdx * 4 + 3] expect(frontAlpha).toBe(grid.intensity[wallFrontIdx]) }) })