diablo2-web/tests/challenger-batch2-stress.te...

675 lines
21 KiB
TypeScript

import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { beforeAll, describe, expect, it } from 'vitest'
import '../tools/lib/node-map-loader.ts'
import {
AUTOMAP_CROSSHAIR_COLORS,
AUTOMAP_FADE_ALPHAS,
type AutomapEntityMarker,
type AutomapLayerData,
computeAutomapViewport,
Minimap,
type MinimapLevel,
} from '../src/client/ui/minimap.ts'
import {
AutomapView,
type MinimapEntityDot,
} from '../src/client/automap/automap-view.ts'
import { MapService } from '../src/client/map/map-service.ts'
import { renderSceneSourceFrame } from '../src/client/scene/world-renderer.ts'
import { OnlineSession } from '../src/client/session/online-session.ts'
import { createSettingsStore } from '../src/client/settings/settings-store.ts'
import { getMonsterKindByClassId } from '../src/common/units/monsters-data.ts'
import { NPC_HCIDX_TO_DESCRIPTOR_ID } from '../src/common/units/npc-table.ts'
import type { AtlasFrame } from '../src/client/render/atlas.ts'
import type { AtlasHandle, SpriteRenderer } from '../src/client/render/renderer.ts'
import type { RenderableUnitInput } from '../src/client/view/unit-drawables.ts'
interface RecordedSpriteDraw {
readonly kind: 'sprite'
readonly frame: AtlasFrame
readonly x: number
readonly y: number
readonly atlas?: AtlasHandle | undefined
readonly width?: number | undefined
readonly height?: number | undefined
readonly tint?: readonly [number, number, number, number] | undefined
}
interface RecordedSolidDraw {
readonly kind: 'solid'
readonly x: number
readonly y: number
readonly width: number
readonly height: number
readonly color: readonly [number, number, number, number]
}
interface RecordedQuadDraw {
readonly kind: 'quad'
readonly x0: number
readonly y0: number
readonly x1: number
readonly y1: number
readonly x2: number
readonly y2: number
readonly x3: number
readonly y3: number
readonly color: readonly [number, number, number, number]
}
type RecordedDraw = RecordedSpriteDraw | RecordedSolidDraw | RecordedQuadDraw
function createMockRenderer(): {
renderer: SpriteRenderer
draws: RecordedDraw[]
scissors: ({ x: number; y: number; width: number; height: number } | null)[]
} {
const draws: RecordedDraw[] = []
const scissors: ({ x: number; y: number; width: number; height: number } | null)[] = []
let activeScissor: { x: number; y: number; width: number; height: number } | null = null
const mockGl = {
SCISSOR_TEST: 0x0c11,
enable(cap: number) {
if (cap === 0x0c11) {
activeScissor = { x: 0, y: 0, width: 0, height: 0 }
}
},
scissor(x: number, y: number, width: number, height: number) {
activeScissor = { x, y, width, height }
scissors.push(activeScissor)
},
disable(cap: number) {
if (cap === 0x0c11) {
activeScissor = null
scissors.push(null)
}
},
}
const renderer = {
gl: mockGl,
flush() {},
draw(
frame: AtlasFrame,
x: number,
y: number,
options?: {
atlas?: AtlasHandle
width?: number
height?: number
tint?: readonly [number, number, number, number]
},
) {
draws.push({
kind: 'sprite',
frame,
x,
y,
atlas: options?.atlas,
width: options?.width,
height: options?.height,
tint: options?.tint,
})
},
drawSolid(
x: number,
y: number,
width: number,
height: number,
color: readonly [number, number, number, number],
) {
draws.push({ kind: 'solid', x, y, width, height, color })
},
drawQuad(
x0: number,
y0: number,
x1: number,
y1: number,
x2: number,
y2: number,
x3: number,
y3: number,
color: readonly [number, number, number, number],
) {
draws.push({ kind: 'quad', x0, y0, x1, y1, x2, y2, x3, y3, color })
},
} as unknown as SpriteRenderer
return { renderer, draws, scissors }
}
function registerSyntheticAtlases(minimap: Minimap): void {
const keys = [
'MaxiMap',
'MaxiMapS',
'Act2Map',
'Act2MapS',
'Act4Map',
'Act4MapS',
'ExTnMap',
'ExTnMapS',
] as const
for (const key of keys) {
const meta = minimap.getSheetMeta(key)
minimap.setAtlasHandle(key, {
texture: key as unknown as WebGLTexture,
width: meta.atlasWidth,
height: meta.atlasHeight,
})
}
}
function makeSyntheticLevel(
key: string,
cellsX: number,
cellsY: number,
overrides?: Partial<MinimapLevel>,
): MinimapLevel {
const gridWidth = cellsX * 5
const blocked = new Uint8Array(gridWidth * cellsY * 5)
const floors: AutomapLayerData['floors'][number][] = []
for (let cy = 0; cy < cellsY; cy += 4) {
for (let cx = 0; cx < cellsX; cx += 4) {
floors.push({
cellNo: 1,
xPixel: (cx - cy) * 16,
yPixel: (cx + cy) * 8,
cellX: cx,
cellY: cy,
fSaved: 0,
weight: 0,
})
}
}
const automap: AutomapLayerData = {
layerId: 1,
levelTypeId: 1,
floors,
walls: [],
objects: [],
extras: [],
}
return {
key,
cellsX,
cellsY,
blocked,
gridWidth,
originX: 0,
originY: 0,
automap,
levelId: 1,
levelName: 'Test Area',
difficulty: 'Normal',
markers: [],
...overrides,
}
}
let sharedMapService: MapService
beforeAll(async () => {
sharedMapService = await MapService.fromPackedDir('public/act-packs/tiles')
})
describe('Challenger 1 Adversarial Stress Suite: Batch 2 Parity Gate', () => {
describe('Dimension 1: Fog of War, Monotonicity & Exploration State', () => {
it('reveals cells strictly monotonically across 500 random walk room transitions', () => {
const minimap = new Minimap()
const level = makeSyntheticLevel('test-wilderness', 100, 100)
let prevRevealedRooms = 0
let prevExploredRatio = 0
let playerCellX = 10
let playerCellY = 10
let seed = 1337
const nextRandom = (): number => {
seed = (seed * 1664525 + 1013904223) >>> 0
return seed / 4294967296
}
for (let step = 0; step < 500; step++) {
const dx = Math.floor(nextRandom() * 5) - 2
const dy = Math.floor(nextRandom() * 5) - 2
playerCellX = Math.max(2, Math.min(level.cellsX - 3, playerCellX + dx))
playerCellY = Math.max(2, Math.min(level.cellsY - 3, playerCellY + dy))
minimap.reveal(level, playerCellX, playerCellY)
const currentRevealedRooms = minimap.revealedRoomCount(level.key)
const currentExploredRatio = minimap.exploredFraction(level)
expect(currentRevealedRooms).toBeGreaterThanOrEqual(prevRevealedRooms)
expect(currentExploredRatio).toBeGreaterThanOrEqual(prevExploredRatio)
prevRevealedRooms = currentRevealedRooms
prevExploredRatio = currentExploredRatio
}
expect(prevRevealedRooms).toBeGreaterThan(10)
expect(prevExploredRatio).toBeGreaterThan(0.05)
})
it('shares and preserves exploration grid identically between Corner Minimap and Full Automap modes', () => {
const minimap = new Minimap()
const level = makeSyntheticLevel('test-shared', 40, 40)
minimap.mode = 'minimap'
minimap.reveal(level, 8, 8)
const countMinimap = minimap.revealedRoomCount(level.key)
const ratioMinimap = minimap.exploredFraction(level)
minimap.mode = 'full'
const countFull = minimap.revealedRoomCount(level.key)
const ratioFull = minimap.exploredFraction(level)
expect(countFull).toBe(countMinimap)
expect(ratioFull).toBe(ratioMinimap)
minimap.reveal(level, 24, 24)
const countUpdatedFull = minimap.revealedRoomCount(level.key)
expect(countUpdatedFull).toBeGreaterThan(countFull)
minimap.mode = 'minimap'
const countRestoredMinimap = minimap.revealedRoomCount(level.key)
expect(countRestoredMinimap).toBe(countUpdatedFull)
})
it('isolates revealed rooms strictly by level key across multi-level area transitions', () => {
const minimap = new Minimap()
const townLevel = makeSyntheticLevel('town-key', 30, 30)
const wildernessLevel = makeSyntheticLevel('wilderness-key', 50, 50)
minimap.reveal(townLevel, 5, 5)
expect(minimap.revealedRoomCount('town-key')).toBeGreaterThan(0)
expect(minimap.revealedRoomCount('wilderness-key')).toBe(0)
minimap.reveal(wildernessLevel, 20, 20)
expect(minimap.revealedRoomCount('town-key')).toBeGreaterThan(0)
expect(minimap.revealedRoomCount('wilderness-key')).toBeGreaterThan(0)
const townCount = minimap.revealedRoomCount('town-key')
const wildCount = minimap.revealedRoomCount('wilderness-key')
expect(townCount).not.toBe(wildCount)
})
it('world-renderer reveals fog-of-war even when automap mode is off', () => {
const text = readFileSync(resolve('tests/fixtures/netproto/04-d2gs-idle-60s.d2cap'), 'utf8')
const settingsStore = createSettingsStore()
const session = new OnlineSession({ settingsStore, mapService: sharedMapService })
session.loadD2CapReplay(text)
session.replayAllD2Cap()
const minimap = new Minimap()
session.automapView.setMode('off')
renderSceneSourceFrame({
source: session.sceneSource,
lightingPreset: 'fullbright',
renderer: {
begin() {},
draw() {},
drawSolidQuad() {},
setLighting() {},
setPalette() {},
flush() {},
getGl() {
return null
},
} as never,
tileGpuCache: {
loadedPageCount: 1,
ensureActPalette() {},
getPageHandle() {
return { index: 1, width: 2048, height: 1932 }
},
} as never,
minimap,
automapView: session.automapView,
})
expect(minimap.revealedRoomsCount).toBeGreaterThan(0)
session.automapView.setMode('level')
renderSceneSourceFrame({
source: session.sceneSource,
lightingPreset: 'fullbright',
renderer: {
begin() {},
draw() {},
drawSolidQuad() {},
setLighting() {},
setPalette() {},
flush() {},
getGl() {
return null
},
} as never,
tileGpuCache: {
loadedPageCount: 1,
ensureActPalette() {},
getPageHandle() {
return { index: 1, width: 2048, height: 1932 }
},
} as never,
minimap,
automapView: session.automapView,
})
expect(minimap.revealedRoomsCount).toBeGreaterThan(0)
})
})
describe('Dimension 2: Viewport Geometry, Dynamic Resizing & Scissor Bounds', () => {
it('maintains valid viewport bounds and scissor clipping across 500 random resolution combinations', () => {
let seed = 4242
const nextRandom = (): number => {
seed = (seed * 1664525 + 1013904223) >>> 0
return seed / 4294967296
}
for (let i = 0; i < 500; i++) {
const screenW = Math.max(1, Math.floor(nextRandom() * 7680) + 1)
const screenH = Math.max(1, Math.floor(nextRandom() * 4320) + 1)
const leftPanel = nextRandom() > 0.5
const rightPanel = nextRandom() > 0.5
const panX = (nextRandom() - 0.5) * 2000
const panY = (nextRandom() - 0.5) * 2000
const vpMinimap = computeAutomapViewport(
screenW,
screenH,
true,
leftPanel,
rightPanel,
panX,
panY,
)
expect(vpMinimap.bMinimap).toBe(true)
expect(vpMinimap.divisor).toBe(20)
expect(vpMinimap.width).toBeGreaterThan(0)
expect(vpMinimap.height).toBeGreaterThan(0)
expect(vpMinimap.x).toBeGreaterThanOrEqual(0)
expect(vpMinimap.y).toBeGreaterThanOrEqual(0)
if (screenW >= 280) {
expect(vpMinimap.width).toBeLessThanOrEqual(screenW)
}
const vpFull = computeAutomapViewport(
screenW,
screenH,
false,
leftPanel,
rightPanel,
panX,
panY,
)
expect(vpFull.bMinimap).toBe(false)
expect(vpFull.divisor).toBe(10)
expect(vpFull.x).toBe(0)
expect(vpFull.y).toBe(0)
expect(vpFull.width).toBe(screenW)
expect(vpFull.height).toBe(screenH)
const neutralCenterX = Math.trunc(screenW / 2)
const shiftAmount = screenW >> 2
if (leftPanel && !rightPanel) {
expect(vpFull.centerX).toBe(neutralCenterX + shiftAmount)
} else if (rightPanel && !leftPanel) {
expect(vpFull.centerX).toBe(neutralCenterX - shiftAmount)
} else {
expect(vpFull.centerX).toBe(neutralCenterX)
}
}
})
it('enables and disables hardware scissor test around automap sprite rendering', () => {
const minimap = new Minimap()
registerSyntheticAtlases(minimap)
minimap.mode = 'full'
const level = makeSyntheticLevel('test-scissor', 20, 20)
minimap.reveal(level, 4, 4)
const { renderer, scissors } = createMockRenderer()
minimap.draw(
renderer,
level,
4,
4,
{ x: 0, y: 0, zoom: 1 },
{ width: 1920, height: 1080 },
{ playerWorldX: 0, playerWorldY: 0, levelId: 1 },
)
expect(scissors.length).toBe(2)
expect(scissors[0]).toEqual({ x: 0, y: 0, width: 1920, height: 1080 })
expect(scissors[1]).toBeNull()
})
})
describe('Dimension 3: Fade State Cycling & Distance Opacity Mapping', () => {
it('cycles through all 4 discrete fade states and updates alpha values correctly', () => {
const minimap = new Minimap()
expect(minimap.fadeState).toBe(0)
expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[0])
minimap.cycleFade()
expect(minimap.fadeState).toBe(1)
expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[1])
minimap.cycleFade()
expect(minimap.fadeState).toBe(2)
expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[2])
minimap.cycleFade()
expect(minimap.fadeState).toBe(3)
expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[3])
minimap.cycleFade()
expect(minimap.fadeState).toBe(0)
expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[0])
})
it('applies distance-based alpha factor in Full Automap mode according to radius bands', () => {
const minimap = new Minimap()
registerSyntheticAtlases(minimap)
minimap.mode = 'full'
const level = makeSyntheticLevel('test-fade-dist', 50, 50)
minimap.reveal(level, 0, 0)
const { renderer, draws } = createMockRenderer()
minimap.draw(
renderer,
level,
0,
0,
{ x: 0, y: 0, zoom: 1 },
{ width: 800, height: 600 },
{ playerWorldX: 0, playerWorldY: 0, levelId: 1 },
)
const spriteDraws = draws.filter((d): d is RecordedSpriteDraw => d.kind === 'sprite')
expect(spriteDraws.length).toBeGreaterThan(0)
for (const draw of spriteDraws) {
expect(draw.tint).toBeDefined()
const alpha = draw.tint![3]
expect(alpha).toBeGreaterThan(0)
expect(alpha).toBeLessThanOrEqual(1.0)
}
})
})
describe('Dimension 4: Dynamic Unit Marker Fuzzing & Palette Verification', () => {
it('strictly excludes hostile monsters, bosses, ancient statues, and dying monsters from the automap', () => {
const minimap = new Minimap()
registerSyntheticAtlases(minimap)
minimap.mode = 'full'
const level = makeSyntheticLevel('test-entities', 50, 50)
minimap.reveal(level, 25, 25)
const hostileEntities: AutomapEntityMarker[] = [
{ x: 100, y: 100, kind: 'boss', alive: true, classId: 1 },
{ x: 110, y: 110, kind: 'boss', alive: true, classId: 156 },
{ x: 120, y: 120, kind: 'boss', alive: true, classId: 211 },
{ x: 130, y: 130, kind: 'boss', alive: true, classId: 242 },
{ x: 140, y: 140, kind: 'boss', alive: true, classId: 243 },
{ x: 150, y: 150, kind: 'boss', alive: true, classId: 544 },
{ x: 160, y: 160, kind: 'npc', alive: true, classId: 537 },
{ x: 170, y: 170, kind: 'npc', alive: true, classId: 538 },
{ x: 180, y: 180, kind: 'npc', alive: true, classId: 539 },
{ x: 190, y: 190, kind: 'boss', alive: false, classId: 1 },
]
const { renderer } = createMockRenderer()
minimap.draw(
renderer,
level,
25,
25,
{ x: 0, y: 0, zoom: 1 },
{ width: 800, height: 600 },
{
playerWorldX: 0,
playerWorldY: 0,
levelId: 1,
entities: hostileEntities,
},
)
expect(minimap.lastRenderStats.crosshairsDrawn.npcs).toBe(0)
})
it('fuzzes 1000 unit entities and asserts friendly palette conformity and corpse rendering', () => {
const minimap = new Minimap()
registerSyntheticAtlases(minimap)
minimap.mode = 'full'
const level = makeSyntheticLevel('test-fuzz-1000', 100, 100)
minimap.reveal(level, 50, 50)
const entities: AutomapEntityMarker[] = []
let seed = 9999
const nextRandom = (): number => {
seed = (seed * 1664525 + 1013904223) >>> 0
return seed / 4294967296
}
for (let i = 0; i < 1000; i++) {
const r = nextRandom()
const x = Math.floor((nextRandom() - 0.5) * 800)
const y = Math.floor((nextRandom() - 0.5) * 600)
if (r < 0.2) {
entities.push({ x, y, kind: 'mercenary', alive: true, name: `Merc ${i}` })
} else if (r < 0.4) {
entities.push({ x, y, kind: 'corpse', alive: false, name: `Corpse ${i}` })
} else if (r < 0.6) {
entities.push({ x, y, kind: 'party', alive: true, name: `Ally ${i}` })
} else if (r < 0.8) {
entities.push({ x, y, kind: 'summon', alive: true, classId: 357 })
} else {
entities.push({ x, y, kind: 'summon', alive: true, classId: 291 })
}
}
const { renderer, draws } = createMockRenderer()
minimap.draw(
renderer,
level,
50,
50,
{ x: 0, y: 0, zoom: 1 },
{ width: 800, height: 600 },
{
playerWorldX: 0,
playerWorldY: 0,
levelId: 1,
entities,
},
)
expect(minimap.lastRenderStats.crosshairsDrawn.total).toBeGreaterThan(100)
expect(minimap.lastRenderStats.crosshairsDrawn.mercenaries).toBeGreaterThan(0)
const quadDraws = draws.filter((d): d is RecordedQuadDraw => d.kind === 'quad')
for (const quad of quadDraws) {
for (const channel of quad.color) {
expect(channel).toBeGreaterThanOrEqual(0)
expect(channel).toBeLessThanOrEqual(1.0001)
}
}
})
it('renders town NPCs as npc entityDots in AutomapView', () => {
sharedMapService.loadAct({ act: 0, seed: 0x12345, areaId: 1 })
const automapView = new AutomapView({ mapService: sharedMapService, initialMode: 'level' })
const townNpcs: (RenderableUnitInput & { isNpc?: boolean })[] = [
{ kind: 'monster', id: 101, classId: 146, x: 10, y: 10, isNpc: true, name: 'Deckard Cain' },
{ kind: 'monster', id: 102, classId: 147, x: 12, y: 12, isNpc: true, name: 'Gheed' },
{ kind: 'monster', id: 103, classId: 148, x: 14, y: 14, isNpc: true, name: 'Akara' },
{ kind: 'monster', id: 104, classId: 150, x: 16, y: 16, isNpc: true, name: 'Kashya' },
{ kind: 'monster', id: 105, classId: 154, x: 18, y: 18, isNpc: true, name: 'Charsi' },
{ kind: 'monster', id: 106, classId: 155, x: 20, y: 20, isNpc: true, name: 'Warriv' },
]
for (const npc of townNpcs) {
const mk = getMonsterKindByClassId(npc.classId)
expect(mk).toBeDefined()
expect(mk!.interact).toBe(false)
expect(NPC_HCIDX_TO_DESCRIPTOR_ID.has(npc.classId) || NPC_HCIDX_TO_DESCRIPTOR_ID[npc.classId]).toBeTruthy()
}
const snapshot = automapView.buildSnapshot(townNpcs)
const npcsInSnapshot = snapshot.entityDots.filter((d: MinimapEntityDot) => d.kind === 'npc')
expect(npcsInSnapshot.length).toBe(townNpcs.length)
})
})
describe('Dimension 5: Grounded Spawn Verification & Topological Robustness', () => {
it('verifies grounded spawn across diverse game areas avoids naive geometric centroid', () => {
const testAreaIds = [1, 2, 3, 4, 8]
for (const areaId of testAreaIds) {
const levelView = sharedMapService.loadAct({ act: 0, seed: 0x12345, areaId })!
expect(levelView.spawnX).toBeGreaterThanOrEqual(0)
expect(levelView.spawnX).toBeLessThan(levelView.cellsX)
expect(levelView.spawnY).toBeGreaterThanOrEqual(0)
expect(levelView.spawnY).toBeLessThan(levelView.cellsY)
const geometricCenterX = Math.floor(levelView.cellsX / 2)
const geometricCenterY = Math.floor(levelView.cellsY / 2)
const isGeometricCentroid =
levelView.spawnX === geometricCenterX && levelView.spawnY === geometricCenterY
expect(isGeometricCentroid).toBe(false)
let walkableSubCount = 0
for (let dy = 0; dy < 5; dy++) {
for (let dx = 0; dx < 5; dx++) {
const idx =
(levelView.spawnY * 5 + dy) * levelView.gridWidth +
(levelView.spawnX * 5 + dx)
if (levelView.walkable[idx] === 1) {
walkableSubCount++
}
}
}
expect(walkableSubCount).toBeGreaterThan(0)
}
})
})
})