1024 lines
35 KiB
TypeScript
1024 lines
35 KiB
TypeScript
/**
|
||
* 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)
|
||
})
|
||
})
|
||
})
|