diablo2-web/tests/challenger-m4-lighting-auto...

1024 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Challenger M4-2 — Empirical Lighting, Shaders, Flippy & Automap Cels Stress Test Suite
*
* Adversarially verifies:
* 1. Data-Driven Point Lights & Player Radius (#605, #606):
* - Canonical MPQ static tables for Objects, Monsters, Missiles, and Overlays.
* - Mode 10 casting light removal from non-casting monsters (MonMode S3, #605).
* - Base 11 sub-tiles player radius with bonus math, zero sine flicker, zero 20/22 inflation.
* - True pitch-black darkness: ambient 0 produces [0, 0, 0] ambient light without floor clamping.
* 2. Elevation-Aware Vertex Lighting (#614):
* - SpriteRenderer quad top vertices modified by `elevationY * 16`.
* - 10-float vertex stride preservation under elevation offsets.
* - Fragment shader applies linear light curves (`dynamicLight = lightSample.rgb;`).
* 3. Ground Item Flippy Resolution & Fail-Fast Hardening (#609):
* - Unique and set items prioritize authentic `flippyFile` over base item row.
* - Fail-fast throwing on missing/empty item code without silent fallback to ring ('rin').
* 4. Automap Cel Extraction & Landmark Cleanliness (#622, #623):
* - Zero synthetic automap cels (no floor cel 0 or lava wall cel 9).
* - Zero synthetic markers (no cel 309 well, no cel 306 seal).
* - Waypoint markers placed strictly from valid entrance rooms, never phantom room 0.
*/
import { describe, it, expect } from 'vitest'
import {
applyLightingInputs,
CANONICAL_MONSTER_LIGHTS,
CANONICAL_OBJECT_LIGHTS,
CANONICAL_MISSILE_LIGHTS,
CANONICAL_OVERLAY_LIGHTS,
getMonsterLightConfig,
getObjectLightConfig,
getMissileLightConfig,
getOverlayLightConfig,
type PointLightInput,
} from '../src/client/scene/lighting-presets.ts'
import { LightGrid, LIGHT_GRID_SIZE } from '../src/common/drlg/light-grid.ts'
import type { CollisionGrid } from '../src/common/world/d2map.ts'
import {
SpriteRenderer,
fragmentShaderSource,
type AtlasHandle,
} from '../src/client/render/renderer.ts'
import {
buildSceneDrawables,
type RenderableUnitInput,
} from '../src/client/view/unit-drawables.ts'
import {
onlineItemDataToUiInventoryItem,
itemToUiInventoryItem,
} from '../src/common/items/item-bridge.ts'
import { getEmbeddedDropTables } from '../src/common/items/embedded-drop-tables.ts'
import {
Minimap,
type MinimapLevel,
type MinimapMarker,
} from '../src/client/ui/minimap.ts'
import { AutomapView } from '../src/client/automap/automap-view.ts'
import type { LevelView } from '../src/client/map/level-view.ts'
import type { Item, ItemBase } from '../src/common/items/items.ts'
import { ItemQuality } from '../src/common/items/items.ts'
// --- Mock Helpers ---
function createMockCollisionGrid(subWidth = 100, subHeight = 100): CollisionGrid {
return {
cellsX: Math.ceil(subWidth / 5),
cellsY: Math.ceil(subHeight / 5),
gridWidth: subWidth,
gridHeight: subHeight,
originX: 0,
originY: 0,
blocked: new Uint8Array(subWidth * subHeight),
collisionMasks: new Uint16Array(subWidth * subHeight),
} as unknown as CollisionGrid
}
function createMockRendererCanvas(): { canvas: any; gl: any; shaderSources: string[] } {
let nextId = 1
const shaderSources: string[] = []
const gl: any = {
VERTEX_SHADER: 35633,
FRAGMENT_SHADER: 35632,
COMPILE_STATUS: 35713,
LINK_STATUS: 35714,
MAX_TEXTURE_SIZE: 4096,
MAX_TEXTURE_IMAGE_UNITS: 16,
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,
UNPACK_ALIGNMENT: 3317,
BLEND: 3042,
SRC_ALPHA: 770,
ONE_MINUS_SRC_ALPHA: 771,
DEPTH_TEST: 2929,
COLOR_BUFFER_BIT: 16384,
TEXTURE0: 33984,
drawingBufferWidth: 800,
drawingBufferHeight: 600,
isContextLost: () => false,
createProgram: () => ({ id: nextId++, type: 'program' }),
deleteProgram: () => {},
attachShader: () => {},
detachShader: () => {},
linkProgram: () => {},
getProgramParameter: () => true,
getProgramInfoLog: () => '',
useProgram: () => {},
createShader: (type: number) => ({ id: nextId++, type }),
shaderSource: (_shader: any, source: string) => { shaderSources.push(source) },
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) => {
switch (name) {
case 'a_position': return 0
case 'a_uv': return 1
case 'a_tint': return 2
case 'a_unit': return 3
case 'a_palette': return 4
default: return -1
}
},
enableVertexAttribArray: () => {},
vertexAttribPointer: () => {},
createTexture: () => ({ id: nextId++, type: 'texture' }),
deleteTexture: () => {},
bindTexture: () => {},
texParameteri: () => {},
pixelStorei: () => {},
texImage2D: () => {},
texSubImage2D: () => {},
texStorage2D: () => {},
getParameter: (param: number) => (param === 3379 ? 4096 : param === 34930 ? 16 : 0),
getUniformLocation: (_p: any, name: string) => ({ name }),
uniform2f: () => {},
uniform1f: () => {},
uniform1i: () => {},
uniform1iv: () => {},
enable: () => {},
disable: () => {},
blendFunc: () => {},
viewport: () => {},
clearColor: () => {},
clear: () => {},
activeTexture: () => {},
drawArrays: () => {},
drawElements: () => {},
}
const canvas: any = {
getContext: (type: string) => (type === 'webgl2' ? gl : null),
addEventListener: () => {},
removeEventListener: () => {},
drawingBufferWidth: 800,
drawingBufferHeight: 600,
}
return { canvas, gl, shaderSources }
}
describe('Challenger M4-2: Empirical Verification Suite', () => {
const dropTables = getEmbeddedDropTables()
// =========================================================================
// Section 1: Data-Driven Point Lights Parity & Canonical MPQ Metadata (#605, #606)
// =========================================================================
describe('1. Data-Driven Point Lights & Mode 10 Parity (#605, #606)', () => {
it('verifies canonical MPQ objects.txt point lights metadata', () => {
expect(Object.keys(CANONICAL_OBJECT_LIGHTS).length).toBeGreaterThanOrEqual(200)
// Object 26: lit radius 10 in mode 1, [255, 255, 255], flicker 0
const obj26 = getObjectLightConfig(26, 1)
expect(obj26).not.toBeNull()
expect(obj26!.radius).toBe(10)
expect(obj26!.r).toBe(255)
expect(obj26!.g).toBe(255)
expect(obj26!.b).toBe(255)
// Object 38: lit radius 19 in mode 1, RGB [255, 236, 176], flicker 1
const obj38 = getObjectLightConfig(38, 1)
expect(obj38).not.toBeNull()
expect(obj38!.radius).toBe(19)
expect(obj38!.r).toBe(255)
expect(obj38!.g).toBe(236)
expect(obj38!.b).toBe(176)
expect(obj38!.flicker).toBe(1)
// Object 48: lit radius 19, RGB [255, 236, 176], flicker 1
const obj48 = getObjectLightConfig(48)
expect(obj48).not.toBeNull()
expect(obj48!.radius).toBe(19)
expect(obj48!.r).toBe(255)
expect(obj48!.g).toBe(236)
expect(obj48!.b).toBe(176)
// Object 119: Waypoint (mode 1 radius 18, RGB [100, 100, 255], flicker 1)
const waypoint = getObjectLightConfig(119)
expect(waypoint).not.toBeNull()
expect(waypoint!.radius).toBe(18)
expect(waypoint!.r).toBe(100)
expect(waypoint!.g).toBe(100)
expect(waypoint!.b).toBe(255)
expect(waypoint!.flicker).toBe(1)
// Object 59: Town Portal (mode 1 radius 18, RGB [120, 120, 255], flicker 1)
const townPortal = getObjectLightConfig(59)
expect(townPortal).not.toBeNull()
expect(townPortal!.radius).toBe(18)
expect(townPortal!.r).toBe(120)
expect(townPortal!.g).toBe(120)
expect(townPortal!.b).toBe(255)
expect(townPortal!.flicker).toBe(1)
// Object 60: Red Portal (mode 1 radius 18, RGB [255, 120, 120], flicker 1)
const redPortal = getObjectLightConfig(60)
expect(redPortal).not.toBeNull()
expect(redPortal!.radius).toBe(18)
expect(redPortal!.r).toBe(255)
expect(redPortal!.g).toBe(120)
expect(redPortal!.b).toBe(120)
// Non-lit object returns null
expect(getObjectLightConfig(1)).toBeNull() // standard chest / unlit
expect(getObjectLightConfig(999999)).toBeNull()
})
it('verifies mode-dependent object light resolution and out-of-bounds safety', () => {
// Object 26 has modes [5, 10, 5, 0, 0, 0, 0, 0]
const mode0 = getObjectLightConfig(26, 0)
expect(mode0).not.toBeNull()
expect(mode0!.radius).toBe(5)
const mode1 = getObjectLightConfig(26, 1)
expect(mode1).not.toBeNull()
expect(mode1!.radius).toBe(10)
const mode2 = getObjectLightConfig(26, 2)
expect(mode2).not.toBeNull()
expect(mode2!.radius).toBe(5)
const mode3 = getObjectLightConfig(26, 3)
expect(mode3).toBeNull() // radius 0 for mode 3 returns null
// Out of bounds mode falls back safely to max lit mode
const outOfBounds = getObjectLightConfig(26, 99)
expect(outOfBounds).not.toBeNull()
expect(outOfBounds!.radius).toBe(10)
})
it('verifies canonical MPQ monstats2.txt monster lights with case and whitespace trimming', () => {
expect(Object.keys(CANONICAL_MONSTER_LIGHTS).length).toBeGreaterThanOrEqual(90)
// Andariel: radius 8, [255, 255, 255]
const andariel = getMonsterLightConfig('andariel')
expect(andariel).not.toBeNull()
expect(andariel!.radius).toBe(8)
expect([andariel!.r, andariel!.g, andariel!.b]).toEqual([255, 255, 255])
// Case and whitespace insensitivity
expect(getMonsterLightConfig(' ANDARIEL ')).toEqual(andariel)
expect(getMonsterLightConfig('AnDaRiEl')).toEqual(andariel)
// Duriel: radius 9, [255, 255, 255]
const duriel = getMonsterLightConfig('duriel')
expect(duriel).not.toBeNull()
expect(duriel!.radius).toBe(9)
// Mephisto: radius 9, [255, 255, 255]
const mephisto = getMonsterLightConfig('mephisto')
expect(mephisto).not.toBeNull()
expect(mephisto!.radius).toBe(9)
// Tyrael: radius 11, [255, 255, 255]
const tyrael = getMonsterLightConfig('tyrael1')
expect(tyrael).not.toBeNull()
expect(tyrael!.radius).toBe(11)
// Fallen Shaman: radius 5, [230, 168, 255]
const shaman = getMonsterLightConfig('fallenshaman1')
expect(shaman).not.toBeNull()
expect(shaman!.radius).toBe(5)
expect([shaman!.r, shaman!.g, shaman!.b]).toEqual([230, 168, 255])
// Willowisp: radius 6, [255, 255, 255]
const wisp = getMonsterLightConfig('willowisp1')
expect(wisp).not.toBeNull()
expect(wisp!.radius).toBe(6)
// Unlit monster returns null
expect(getMonsterLightConfig('zombie1')).toBeNull()
expect(getMonsterLightConfig('')).toBeNull()
expect(getMonsterLightConfig('nonexistent_monster')).toBeNull()
})
it('verifies Issue #605: Monster activeCastState mode 10 (S3) produces ZERO artificial point lights', () => {
const monsterWithMode10: RenderableUnitInput = {
kind: 'monster',
id: 42,
classId: 5,
x: 30,
y: 30,
castState: {
skillId: 0,
mode: 10, // MonMode 10 is S3 (Skill 3)
targetX: 35,
targetY: 35,
startedAtMs: 100,
untilMs: 1000,
},
}
const drawablesResult = buildSceneDrawables([monsterWithMode10])
expect(drawablesResult.pointLights).toHaveLength(0)
// Adversarial check: even with multiple monster casts, no fake lights leak
const multipleMonsters: RenderableUnitInput[] = [
monsterWithMode10,
{
kind: 'monster',
id: 43,
classId: 8,
x: 40,
y: 40,
castState: { skillId: 12, mode: 10, startedAtMs: 0, untilMs: 500, targetX: 0, targetY: 0 },
},
]
const batchResult = buildSceneDrawables(multipleMonsters)
expect(batchResult.pointLights).toHaveLength(0)
})
it('verifies canonical MPQ missiles.txt lights with numeric ID and name equivalence', () => {
expect(Object.keys(CANONICAL_MISSILE_LIGHTS).length).toBeGreaterThanOrEqual(250)
const testMissiles: Array<{ name: string; id: number; radius: number; r: number; g: number; b: number; flicker: number }> = [
{ name: 'firebolt', id: 58, radius: 7, r: 255, g: 178, b: 64, flicker: 0 },
{ name: 'icebolt', id: 59, radius: 7, r: 81, g: 81, b: 255, flicker: 0 },
{ name: 'fireball', id: 62, radius: 7, r: 255, g: 178, b: 64, flicker: 0 },
{ name: 'blizzardcenter', id: 158, radius: 9, r: 81, g: 81, b: 255, flicker: 0 },
{ name: 'frozenorb', id: 260, radius: 6, r: 81, g: 81, b: 255, flicker: 0 },
{ name: 'chargedbolt', id: 56, radius: 3, r: 255, g: 255, b: 255, flicker: 4 },
{ name: 'lightningbolt', id: 98, radius: 4, r: 255, g: 255, b: 255, flicker: 0 },
{ name: 'chainlightning', id: 93, radius: 4, r: 255, g: 255, b: 255, flicker: 0 },
]
for (const m of testMissiles) {
const byName = getMissileLightConfig(m.name)
const byId = getMissileLightConfig(m.id)
expect(byName).not.toBeNull()
expect(byId).not.toBeNull()
expect(byName).toEqual(byId)
expect(byName!.radius).toBe(m.radius)
expect([byName!.r, byName!.g, byName!.b]).toEqual([m.r, m.g, m.b])
expect(byName!.flicker).toBe(m.flicker)
}
// Invalid missile queries return null
expect(getMissileLightConfig(-1)).toBeNull()
expect(getMissileLightConfig(999999)).toBeNull()
expect(getMissileLightConfig('nonexistent_missile')).toBeNull()
expect(getMissileLightConfig('')).toBeNull()
})
it('verifies canonical MPQ overlay.txt lights', () => {
expect(Object.keys(CANONICAL_OVERLAY_LIGHTS).length).toBeGreaterThanOrEqual(70)
const ov1 = getOverlayLightConfig(1)
expect(ov1).not.toBeNull()
expect(ov1!.radius).toBe(11)
expect(ov1!.initRadius).toBe(11)
expect([ov1!.r, ov1!.g, ov1!.b]).toEqual([255, 255, 255])
const ov2 = getOverlayLightConfig(2)
expect(ov2).not.toBeNull()
expect(ov2!.radius).toBe(10)
expect(ov2!.initRadius).toBe(1)
expect([ov2!.r, ov2!.g, ov2!.b]).toEqual([255, 193, 103])
const ov31 = getOverlayLightConfig(31)
expect(ov31).not.toBeNull()
expect(ov31!.radius).toBe(9)
expect([ov31!.r, ov31!.g, ov31!.b]).toEqual([81, 81, 255])
const ov35 = getOverlayLightConfig(35)
expect(ov35).not.toBeNull()
expect(ov35!.radius).toBe(14)
expect(ov35!.initRadius).toBe(14)
expect([ov35!.r, ov35!.g, ov35!.b]).toEqual([255, 193, 103])
expect(getOverlayLightConfig(0)).toBeNull()
expect(getOverlayLightConfig(-5)).toBeNull()
expect(getOverlayLightConfig(99999)).toBeNull()
})
})
// =========================================================================
// Section 2: Player Light Radius & True Pitch-Black Darkness (#606)
// =========================================================================
describe('2. Player Light Radius & Pitch Black Darkness (#606)', () => {
const collisionGrid = createMockCollisionGrid()
it('verifies player base light radius is strictly 11 sub-tiles with pure white color [255, 255, 255]', () => {
const lightGrid = new LightGrid()
let uploadedParams: any = null
const renderer = {
setLighting: (params: any) => { uploadedParams = params },
}
applyLightingInputs(
{
preset: 'auto',
levelId: 1,
collisionGrid,
playerSubTile: { x: 50, y: 50 },
pointLights: [],
tick: 0,
playerLightRadiusBonus: 0,
environmentIntensity: 128,
},
{ lightGrid, renderer },
)
expect(uploadedParams).not.toBeNull()
// In LightGrid center (24, 24), verify authentic 1.13c peak intensity and pure white contribution
const centerIdx = 24 * LIGHT_GRID_SIZE + 24
expect(lightGrid.intensity[centerIdx]).toBeGreaterThanOrEqual(250)
expect(lightGrid.red[centerIdx]).toBe(255)
expect(lightGrid.green[centerIdx]).toBe(255)
expect(lightGrid.blue[centerIdx]).toBe(255)
})
it('verifies player light radius bonus scaling across negative, zero, and positive bounds', () => {
const renderer = { setLighting: () => {} }
const bonuses = [-30, -11, -10, -5, 0, 1, 3, 5, 10, 20, 50]
for (const bonus of bonuses) {
const grid = new LightGrid()
applyLightingInputs(
{
preset: 'auto',
levelId: 1,
collisionGrid,
playerSubTile: { x: 50, y: 50 },
pointLights: [],
tick: 0,
playerLightRadiusBonus: bonus,
environmentIntensity: 0,
},
{ lightGrid: grid, renderer },
)
// Expected radius clamped at 1
const expectedRadius = Math.max(1, 11 + bonus)
expect(expectedRadius).toBeGreaterThanOrEqual(1)
// Center must always be illuminated
const centerIdx = 24 * LIGHT_GRID_SIZE + 24
expect(grid.intensity[centerIdx]).toBeGreaterThan(0)
}
})
it('verifies ZERO sine wave flicker across 100 consecutive ticks', () => {
const renderer = { setLighting: () => {} }
const sampleDistances = [1, 2, 3, 4, 5]
const initialIntensities: number[] = []
// Capture baseline at tick 0
const baselineGrid = new LightGrid()
applyLightingInputs(
{
preset: 'auto',
levelId: 1,
collisionGrid,
playerSubTile: { x: 50, y: 50 },
pointLights: [],
tick: 0,
playerLightRadiusBonus: 0,
environmentIntensity: 0,
},
{ lightGrid: baselineGrid, renderer },
)
for (const d of sampleDistances) {
initialIntensities.push(baselineGrid.intensity[24 * LIGHT_GRID_SIZE + (24 + d)]!)
}
// Check ticks 1 to 99: light values must be identical (no sine wave modulation)
for (let tick = 1; tick < 100; tick += 1) {
const grid = new LightGrid()
applyLightingInputs(
{
preset: 'auto',
levelId: 1,
collisionGrid,
playerSubTile: { x: 50, y: 50 },
pointLights: [],
tick,
playerLightRadiusBonus: 0,
environmentIntensity: 0,
},
{ lightGrid: grid, renderer },
)
for (let i = 0; i < sampleDistances.length; i += 1) {
const d = sampleDistances[i]!
const actualIntensity = grid.intensity[24 * LIGHT_GRID_SIZE + (24 + d)]!
expect(actualIntensity).toBe(initialIntensities[i])
}
}
})
it('verifies true pitch-black darkness: environment intensity 0 produces [0, 0, 0] ambient light without floor clamping', () => {
const lightGrid = new LightGrid()
let uploadedParams: any = null
const renderer = {
setLighting: (params: any) => { uploadedParams = params },
}
// Test with multiple environment colors: intensity 0 MUST force ambient [0, 0, 0]
const testColors: readonly [number, number, number][] = [
[255, 255, 255],
[200, 100, 50],
[185, 175, 165],
[45, 85, 245],
]
for (const color of testColors) {
applyLightingInputs(
{
preset: 'auto',
levelId: 1,
collisionGrid,
playerSubTile: { x: 50, y: 50 },
pointLights: [],
tick: 0,
environmentIntensity: 0,
environmentColor: color,
},
{ lightGrid, renderer },
)
expect(uploadedParams.ambient[0]).toBe(0)
expect(uploadedParams.ambient[1]).toBe(0)
expect(uploadedParams.ambient[2]).toBe(0)
}
})
it('verifies linear ambient scaling without arbitrary minimum floor clamp', () => {
const lightGrid = new LightGrid()
let uploadedParams: any = null
const renderer = {
setLighting: (params: any) => { uploadedParams = params },
}
// When environmentIntensity is 1 (out of 255), ambient should be strictly (1 / 255)
applyLightingInputs(
{
preset: 'auto',
levelId: 1,
collisionGrid,
playerSubTile: { x: 50, y: 50 },
pointLights: [],
tick: 0,
environmentIntensity: 1,
environmentColor: [255, 255, 255],
},
{ lightGrid, renderer },
)
expect(uploadedParams.ambient[0]).toBeCloseTo(1 / 255, 6)
expect(uploadedParams.ambient[1]).toBeCloseTo(1 / 255, 6)
expect(uploadedParams.ambient[2]).toBeCloseTo(1 / 255, 6)
})
})
// =========================================================================
// Section 3: Elevation-Aware Vertex Lighting & Linear Light Curve (#614)
// =========================================================================
describe('3. Elevation-Aware Shaders & Linear Lighting Curve (#614)', () => {
it('verifies elevation modifies top vertex palette row by elevationY * 16 in SpriteRenderer.draw', () => {
const { canvas } = createMockRendererCanvas()
const renderer = new SpriteRenderer(canvas)
const mockAtlasHandle: AtlasHandle = {
texture: {} as any,
width: 1024,
height: 1024,
indexed: true,
}
const frame = { x: 0, y: 0, width: 64, height: 64 }
// Test 1: elevationY = 0 -> topPalette == paletteRow
renderer.draw(frame, 100, 100, {
atlas: mockAtlasHandle,
paletteRow: 1,
elevationY: 0,
})
const v1 = (renderer as any).vertices as Float32Array
// Quad 0: 4 vertices × 10 floats
// v0: top-left (float index 9)
// v1: top-right (float index 19)
// v2: bottom-left (float index 29)
// v3: bottom-right (float index 39)
expect(v1[9]).toBe(1) // top-left
expect(v1[19]).toBe(1) // top-right
expect(v1[29]).toBe(1) // bottom-left
expect(v1[39]).toBe(1) // bottom-right
// Test 2: elevationY = 2 -> topPalette = 1 + 2 * 16 = 33
renderer.draw(frame, 200, 200, {
atlas: mockAtlasHandle,
paletteRow: 1,
elevationY: 2,
})
// Quad 1: float index 40..79
expect(v1[49]).toBe(33) // top-left topPalette = 1 + 32 = 33
expect(v1[59]).toBe(33) // top-right topPalette = 33
expect(v1[69]).toBe(1) // bottom-left paletteRow = 1
expect(v1[79]).toBe(1) // bottom-right paletteRow = 1
// Test 3: elevationY = 4 -> topPalette = 2 + 4 * 16 = 66
renderer.draw(frame, 300, 300, {
atlas: mockAtlasHandle,
paletteRow: 2,
elevationY: 4,
})
// Quad 2: float index 80..119
expect(v1[89]).toBe(66) // top-left
expect(v1[99]).toBe(66) // top-right
expect(v1[109]).toBe(2) // bottom-left
expect(v1[119]).toBe(2) // bottom-right
// Test 4: negative elevationY is ignored (stays paletteRow)
renderer.draw(frame, 400, 400, {
atlas: mockAtlasHandle,
paletteRow: 3,
elevationY: -5,
})
// Quad 3: float index 120..159
expect(v1[129]).toBe(3)
expect(v1[139]).toBe(3)
expect(v1[149]).toBe(3)
expect(v1[159]).toBe(3)
})
it('verifies elevation modifies top vertex palette row in SpriteRenderer.drawQuad', () => {
const { canvas } = createMockRendererCanvas()
const renderer = new SpriteRenderer(canvas)
const mockAtlasHandle: AtlasHandle = {
texture: {} as any,
width: 1024,
height: 1024,
indexed: true,
}
const frame = { x: 0, y: 0, width: 32, height: 32 }
renderer.drawQuad(frame, 0, 0, 32, 0, 0, 32, 32, 32, {
atlas: mockAtlasHandle,
paletteRow: 0,
elevationY: 3, // 0 + 3 * 16 = 48
})
const v = (renderer as any).vertices as Float32Array
expect(v[9]).toBe(48) // top-left topPalette
expect(v[19]).toBe(48) // top-right topPalette
expect(v[29]).toBe(0) // bottom-left paletteRow
expect(v[39]).toBe(0) // bottom-right paletteRow
})
it('verifies fragment shader source strictly enforces linear dynamic lighting without curves', () => {
const shader = fragmentShaderSource(8)
// Must use authentic linear dynamic lighting
expect(shader).toContain('vec3 dynamicLight = lightSample.rgb;')
// Must clamp ambient + dynamic linearly
expect(shader).toContain('vec3 totalLight = clamp(u_ambient.rgb + dynamicLight, 0.0, 1.0);')
// Must NOT contain arbitrary curved math
expect(shader).not.toContain('0.22 + 0.78 * lightSample.a')
expect(shader).not.toContain('pow(')
expect(shader).not.toContain('sqrt(')
})
})
// =========================================================================
// Section 4: Ground Item Flippy Resolution & Fail-Fast Hardening (#609)
// =========================================================================
describe('4. Ground Item Flippy & Fail-Fast Integrity (#609)', () => {
it('prioritizes unique item flippyFile over base item flippyfile', () => {
// Annihilus (unique small charm): base code 'cm1' has base flippyfile 'flpsmc',
// but Annihilus has unique flippyfile 'flpmss'.
const annihilusItemData = {
id: 'unique-annihilus',
code: 'cm1',
quality: 7,
name: 'Annihilus',
identified: true,
}
const annihilusUi = onlineItemDataToUiInventoryItem(annihilusItemData as any, undefined, dropTables)
expect(annihilusUi.flippyFile).toBe('flpmss')
expect(annihilusUi.flippyFile).not.toBe('flpsmc')
// Hellfire Torch (unique large charm): base code 'cm2' has base flippyfile 'flpmdc',
// but Hellfire Torch has unique flippyfile 'flptrch'.
const torchItemData = {
id: 'unique-torch',
code: 'cm2',
quality: 7,
name: 'Hellfire Torch',
identified: true,
}
const torchUi = onlineItemDataToUiInventoryItem(torchItemData as any, undefined, dropTables)
expect(torchUi.flippyFile).toBe('flptrch')
expect(torchUi.flippyFile).not.toBe('flpmdc')
// Direct Item domain model: verify uniqueItemDef.flippyfile overrides base.flippyfile
const uniqueDirectItem: Item = {
id: 'unique-annihilus-direct',
code: 'cm1',
quality: ItemQuality.UNIQUE,
name: 'Annihilus',
base: {
id: 'cm1',
name: 'Small Charm',
kind: 'misc',
invWidth: 1,
invHeight: 1,
maxStack: 1,
value: 0,
damage: 0,
defense: 0,
tags: [],
level: 1,
flippyfile: 'flpsmc',
} as any,
uniqueItemDef: {
index: 'Annihilus',
code: 'cm1',
flippyfile: 'flpmss',
} as any,
} as unknown as Item
const uniqueDirectUi = itemToUiInventoryItem(uniqueDirectItem, dropTables)
expect(uniqueDirectUi.flippyFile).toBe('flpmss')
expect(uniqueDirectUi.flippyFile).not.toBe('flpsmc')
// Direct Item domain model: verify setItemDef.flippyfile overrides base.flippyfile
const setDirectItem: Item = {
id: 'set-hatchet-direct',
code: 'hax',
quality: ItemQuality.SET,
name: "Berserker's Hatchet",
base: {
id: 'hax',
name: 'Hand Axe',
kind: 'weapon',
invWidth: 1,
invHeight: 3,
maxStack: 1,
value: 100,
damage: 10,
defense: 0,
tags: [],
level: 1,
flippyfile: 'flphax',
} as any,
setItemDef: {
index: "Berserker's Hatchet",
item: 'hax',
flippyfile: 'flpsetaxe',
} as any,
} as unknown as Item
const setDirectUi = itemToUiInventoryItem(setDirectItem, dropTables)
expect(setDirectUi.flippyFile).toBe('flpsetaxe')
expect(setDirectUi.flippyFile).not.toBe('flphax')
})
it('falls back to base item flippyFile for normal items without unique overrides', () => {
const normalItem: Item = {
id: 'normal-hax',
code: 'hax',
quality: ItemQuality.NORMAL,
name: 'Hand Axe',
base: {
id: 'hax',
name: 'Hand Axe',
kind: 'weapon',
invWidth: 1,
invHeight: 3,
maxStack: 1,
value: 100,
damage: 10,
defense: 0,
tags: [],
level: 1,
flippyfile: 'flphax',
} as any,
} as unknown as Item
const axeUi = itemToUiInventoryItem(normalItem, dropTables)
expect(axeUi.flippyFile).toBe('flphax')
// Also verify via onlineItemData when base table defines flippyfile
const customDropTables = {
...dropTables,
weapons: {
...dropTables.weapons,
get: (code: string) => {
const row = dropTables.weapons.get(code)
return row ? { ...row, flippyfile: 'flphax' } : undefined
},
},
}
const onlineNormalUi = onlineItemDataToUiInventoryItem(
{ id: 'normal-online-hax', code: 'hax', quality: 0, identified: true },
undefined,
customDropTables as any,
)
expect(onlineNormalUi.flippyFile).toBe('flphax')
})
it('enforces fail-fast throwing on missing or empty item code without silent fallback to ring (rin)', () => {
const invalidCodes = [
undefined,
null,
'',
' ',
'\t\n',
]
for (const badCode of invalidCodes) {
const badItem = {
id: 'corrupt-item',
code: badCode,
quality: 'normal' as const,
}
expect(() => {
onlineItemDataToUiInventoryItem(badItem as any, undefined, dropTables)
}).toThrowError(/\[item-bridge\] Missing item code in onlineItemDataToUiInventoryItem/)
}
})
})
// =========================================================================
// Section 5: Automap Cel Extraction & Marker Cleanliness (#622, #623)
// =========================================================================
describe('5. Automap Cel Extraction & Landmark Cleanliness (#622, #623)', () => {
it('verifies zero synthetic automap cels (no floor 0, no wall 9) when automap layer is omitted', () => {
const minimap = new Minimap()
const { canvas } = createMockRendererCanvas()
const renderer = new SpriteRenderer(canvas)
// Create minimal MinimapLevel with undefined automap layer
const minimalLevel: MinimapLevel = {
key: 'test_level_zero_cels',
cellsX: 10,
cellsY: 10,
gridWidth: 50,
blocked: new Uint8Array(2500),
markers: [],
originX: 0,
originY: 0,
levelId: 1,
levelName: 'Test Area',
difficulty: 'Normal',
preRevealTown: false,
}
minimap.draw(
renderer,
minimalLevel,
5,
5,
{ x: 0, y: 0, zoom: 1 },
{ width: 800, height: 600 },
)
const stats = minimap.lastRenderStats
// Assert zero synthetic cels counted or drawn
expect(stats.cellCounts.floors).toBe(0)
expect(stats.cellCounts.walls).toBe(0)
expect(stats.cellCounts.objects).toBe(0)
expect(stats.cellCounts.extras).toBe(0)
expect(stats.cellCounts.total).toBe(0)
expect(stats.spritesDrawn.floors).toBe(0)
expect(stats.spritesDrawn.walls).toBe(0)
expect(stats.spritesDrawn.objects).toBe(0)
expect(stats.spritesDrawn.extras).toBe(0)
})
it('verifies zero synthetic markers (no cel 309 well, no cel 306 seal)', () => {
const minimap = new Minimap()
const { canvas } = createMockRendererCanvas()
const renderer = new SpriteRenderer(canvas)
const markersWithWaypoint: MinimapMarker[] = [
{ cellX: 5, cellY: 5, kind: 'waypoint', targetName: 'Waypoint' },
{ cellX: 8, cellY: 8, kind: 'exit', toLevelId: 2, targetName: 'Blood Moor' },
]
const levelWithMarkers: MinimapLevel = {
key: 'test_level_markers',
cellsX: 10,
cellsY: 10,
gridWidth: 50,
blocked: new Uint8Array(2500),
markers: markersWithWaypoint,
originX: 0,
originY: 0,
levelId: 1,
}
minimap.draw(
renderer,
levelWithMarkers,
5,
5,
{ x: 0, y: 0, zoom: 1 },
{ width: 800, height: 600 },
)
const stats = minimap.lastRenderStats
// Neither cel 309 (well) nor cel 306 (seal) must be drawn
expect(stats.spritesDrawn.extras).toBe(0)
expect(stats.spritesDrawn.objects).toBe(0)
})
it('verifies waypoint markers are placed strictly from entrances, never from phantom room 0', () => {
const automapView = new AutomapView({ initialMode: 'off' })
const mockLevelView: LevelView = {
areaId: 1,
cellsX: 20,
cellsY: 20,
gridWidth: 100,
gridHeight: 100,
tileOriginX: 0,
tileOriginY: 0,
subOriginX: 0,
subOriginY: 0,
blocked: new Uint8Array(10000),
collisionMasks: new Uint16Array(10000),
rooms: [
// Room 0 (phantom landmark trap)
{ tileX: 0, tileY: 0, tileW: 10, tileH: 10 } as any,
// Room 1 (valid entrance room)
{ tileX: 10, tileY: 10, tileW: 10, tileH: 10 } as any,
],
entrances: [
// Valid entrance to area 2
{ x: 15, y: 15, toLevelId: 2, interiorX: 15, interiorY: 15, side: null, kind: 'preset' },
// Invalid: connects back to self (area 1)
{ x: 2, y: 2, toLevelId: 1, interiorX: 2, interiorY: 2, side: null, kind: 'preset' },
// Invalid: non-positive level ID
{ x: 3, y: 3, toLevelId: 0, interiorX: 3, interiorY: 3, side: null, kind: 'preset' },
],
} as unknown as LevelView
automapView.syncActiveLevel(1, mockLevelView)
const minimapLevel = automapView.buildMinimapLevel('ENG')
// Assert markers contain ONLY valid cross-area entrance
expect(minimapLevel.markers).toHaveLength(1)
const marker = minimapLevel.markers[0]!
expect(marker.kind).toBe('exit')
expect(marker.toLevelId).toBe(2)
expect(marker.cellX).toBe(15)
expect(marker.cellY).toBe(15)
// Crucial parity check: NO marker at Room 0 (cellX: 5, cellY: 5)
expect(minimapLevel.markers.some(m => m.cellX === 5 && m.cellY === 5)).toBe(false)
expect(minimapLevel.markers.some(m => m.kind === 'waypoint')).toBe(false)
})
})
})