diablo2-web/scripts/verify-challenger-m4-traver...

520 lines
19 KiB
TypeScript

/**
* Adversarial Empirical Challenger Harness: Wilderness Traversability & Walkability Stress Test
*
* Scope:
* 1. Pre-Baked Pack Traversability Audit:
* - All 60 procedural wilderness variants across Acts 2..5 (Levels 41..46, 76..81, 104..106, 110..112, 117, 134).
* - Plus all 24 Act 1 procedural wilderness variants (Levels 2..7, 17, 39).
* - Total: 84 baked scene variants.
* - Asserts:
* * Spawn point is non-null, in-bounds, walkable (blocked === 0), and grounded on floor cell.
* * Area block reachability >= 90% (canonical DRLG area connectivity).
* * Every exit/entrance warp is reachable from spawn via 8-way BFS.
* * Every border entrance seam is reachable from spawn via 8-way BFS.
* * Every waypoint pedestal is reachable from spawn via 8-way BFS.
* * Bidirectional path connectivity between entry and exit points.
*
* 2. Multi-Seed Procedural Wilderness Stress Test:
* - Generates all 20 procedural wilderness levels in Acts 2..5 across 10 diverse seeds (200 generations).
* - Asserts:
* * findIsoSpawn succeeds and lands on a walkable, floor-grounded sub-tile.
* * Procedural layout has >= 90% area reachability.
* * All generated warps and seams are reachable from player spawn.
*/
import { readFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { loadActTables, parseTable, cell, tileMemberPath, resolveLevelLibraries } from '../src/game/acts.ts'
import type { D2Table } from '../src/game/acts.ts'
import { decodeDs1 } from '../src/formats/ds1.ts'
import type { Ds1 } from '../src/formats/ds1.ts'
import { decodeDt1, type Dt1 } from '../src/formats/dt1.ts'
import { buildIsoMapScene, subTileAt, levelSeed } from '../src/game/d2map.ts'
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
import { generateWilderness, type WildernessPiece, type WildernessSubstitution } from '../src/game/wilderness.ts'
import { findIsoSpawn } from '../src/game/level-links.ts'
const packDir = 'samples/d2-packs'
const archiveDir = 'samples/d2'
let totalChecks = 0
let failedChecks = 0
const failures: string[] = []
function check(ok: boolean, message: string): void {
totalChecks += 1
if (!ok) {
failedChecks += 1
failures.push(message)
console.log(` FAIL: ${message}`)
}
}
// =========================================================================
// PART 1: BAKED PACK TRAVERSABILITY AUDIT
// =========================================================================
console.log('=== PART 1: BAKED SCENE TRAVERSABILITY AUDIT (84 WILDERNESS VARIANTS) ===\n')
const index = JSON.parse(readFileSync(join(packDir, 'index.json'), 'utf8')) as {
levels: readonly { act: number; levelId: number; path: string; ds1: string; label: string; kind?: string }[]
}
const targetWildernessLevelIds = [
// Act 1
2, 3, 4, 5, 6, 7, 17, 39,
// Act 2
41, 42, 43, 44, 45, 46,
// Act 3
76, 77, 78, 79, 80, 81,
// Act 4
104, 105, 106,
// Act 5
110, 111, 112, 117, 134,
]
const bakedVariants = index.levels.filter(l => targetWildernessLevelIds.includes(l.levelId))
console.log(`Found ${bakedVariants.length} matching wilderness variants in index.json.\n`)
for (const entry of bakedVariants) {
const scenePath = join(packDir, entry.path, 'scene.json')
check(existsSync(scenePath), `${entry.path}: scene.json exists`)
if (!existsSync(scenePath)) continue
const scene = JSON.parse(readFileSync(scenePath, 'utf8'))
const W = scene.collision.width
const H = scene.collision.height
const expanded = new Uint8Array(W * H)
let cursor = 0
for (const run of scene.collision.runs) {
const val = run[0] ?? 0
const len = run[1] ?? 0
if (val !== 0) expanded.fill(val, cursor, Math.min(cursor + len, expanded.length))
cursor += len
}
const floorSet = new Set<string>()
for (const f of scene.floors) {
floorSet.add(`${f[3]},${f[4]}`)
}
const grid = {
originX: scene.originX,
originY: scene.originY,
cellsX: scene.cellsX,
cellsY: scene.cellsY,
gridWidth: W,
blocked: expanded,
}
// 1. Spawn existence & validity
check(scene.spawn !== null && Array.isArray(scene.spawn) && scene.spawn.length === 2, `${entry.path}: spawn exists`)
if (!scene.spawn) continue
const st = subTileAt(grid, scene.spawn[0], scene.spawn[1])
check(st.inBounds, `${entry.path}: spawn is in-bounds (${st.subX}, ${st.subY})`)
if (!st.inBounds) continue
const isSpawnWalkable = expanded[st.subY * W + st.subX] === 0
check(isSpawnWalkable, `${entry.path}: spawn sub-tile is walkable (blocked=${expanded[st.subY * W + st.subX]})`)
const spawnCellX = Math.floor(st.subX / 5)
const spawnCellY = Math.floor(st.subY / 5)
check(floorSet.has(`${spawnCellX},${spawnCellY}`), `${entry.path}: spawn is grounded on floor cell (${spawnCellX}, ${spawnCellY})`)
// 2. BFS Flood Fill from spawn
const visited = new Uint8Array(W * H)
const dist = new Int32Array(W * H).fill(-1)
const queue = [st.subX, st.subY]
visited[st.subY * W + st.subX] = 1
dist[st.subY * W + st.subX] = 0
let head = 0
while (head < queue.length) {
const cx = queue[head++]!
const cy = queue[head++]!
const curDist = dist[cy * W + cx]!
const neighbours = [
[cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1],
[cx + 1, cy + 1], [cx - 1, cy - 1], [cx + 1, cy - 1], [cx - 1, cy + 1],
]
for (const [nx, ny] of neighbours) {
if (nx >= 0 && nx < W && ny >= 0 && ny < H) {
const idx = ny * W + nx
if (visited[idx] === 0 && expanded[idx] === 0) {
visited[idx] = 1
dist[idx] = curDist + 1
queue.push(nx, ny)
}
}
}
}
// 3. Area-based block reachability (8x8 cell areas)
const AREA_CELLS = 8
const blocksX = Math.floor(scene.cellsX / AREA_CELLS)
const blocksY = Math.floor(scene.cellsY / AREA_CELLS)
let totalBlocks = 0
let reachedBlocks = 0
for (let by = 0; by < blocksY; by++) {
for (let bx = 0; bx < blocksX; bx++) {
let blockHasWalkable = false
let blockIsReached = false
for (let cy = by * AREA_CELLS; cy < (by + 1) * AREA_CELLS; cy++) {
for (let cx = bx * AREA_CELLS; cx < (bx + 1) * AREA_CELLS; cx++) {
if (!floorSet.has(`${cx},${cy}`)) continue
for (let sy = 0; sy < 5; sy++) {
for (let sx = 0; sx < 5; sx++) {
const gx = cx * 5 + sx
const gy = cy * 5 + sy
if (gx < W && gy < H && expanded[gy * W + gx] === 0) {
blockHasWalkable = true
if (visited[gy * W + gx] === 1) blockIsReached = true
}
}
}
}
}
if (blockHasWalkable) {
totalBlocks++
if (blockIsReached) reachedBlocks++
}
}
}
const blockReachRatio = totalBlocks > 0 ? (reachedBlocks / totalBlocks) : 1
check(blockReachRatio >= 0.90, `${entry.path}: block reachability >= 90% (${reachedBlocks}/${totalBlocks} = ${(blockReachRatio * 100).toFixed(1)}%)`)
// 4. Warp connectivity
for (const w of scene.warps ?? []) {
const ax = w.arriveX ?? w.x
const ay = w.arriveY ?? w.y
let minD = -1
for (let dy = -5; dy <= 5; dy++) {
for (let dx = -5; dx <= 5; dx++) {
const wx = ax + dx
const wy = ay + dy
if (wx >= 0 && wx < W && wy >= 0 && wy < H && visited[wy * W + wx] === 1) {
const d = dist[wy * W + wx]!
if (minD === -1 || d < minD) minD = d
}
}
}
check(minD !== -1, `${entry.path}: warp to level ${w.toLevelId} (${w.label ?? ''}) at (${ax},${ay}) reachable (dist=${minD})`)
}
// 5. Entrance connectivity
for (const ent of scene.entrances ?? []) {
const ax = ent.arriveX ?? ent.x
const ay = ent.arriveY ?? ent.y
let minD = -1
for (let dy = -3; dy <= 3; dy++) {
for (let dx = -3; dx <= 3; dx++) {
const ex = ax + dx
const ey = ay + dy
if (ex >= 0 && ex < W && ey >= 0 && ey < H && visited[ey * W + ex] === 1) {
const d = dist[ey * W + ex]!
if (minD === -1 || d < minD) minD = d
}
}
}
check(minD !== -1, `${entry.path}: entrance to level ${ent.toLevelId} (${ent.label ?? ''}) at (${ax},${ay}) reachable (dist=${minD})`)
}
// 6. Waypoint connectivity
for (const wp of scene.waypoints ?? []) {
const ax = wp.arriveX ?? wp.x
const ay = wp.arriveY ?? wp.y
let minD = -1
for (let dy = -5; dy <= 5; dy++) {
for (let dx = -5; dx <= 5; dx++) {
const wx = ax + dx
const wy = ay + dy
if (wx >= 0 && wx < W && wy >= 0 && wy < H && visited[wy * W + wx] === 1) {
const d = dist[wy * W + wx]!
if (minD === -1 || d < minD) minD = d
}
}
}
check(minD !== -1, `${entry.path}: waypoint ${wp.waypointId} at (${ax},${ay}) reachable (dist=${minD})`)
}
// 7. Cross-transition traversability (from entrance A to exit B)
const allEndpoints = [
...(scene.warps ?? []).map((w: any) => ({ type: 'warp', to: w.toLevelId, x: w.arriveX ?? w.x, y: w.arriveY ?? w.y })),
...(scene.entrances ?? []).map((e: any) => ({ type: 'entrance', to: e.toLevelId, x: e.arriveX ?? e.x, y: e.arriveY ?? e.y })),
]
if (allEndpoints.length >= 2) {
for (let i = 0; i < allEndpoints.length; i++) {
for (let j = i + 1; j < allEndpoints.length; j++) {
const ep1 = allEndpoints[i]!
const ep2 = allEndpoints[j]!
const ep1Reached = visited[ep1.y * W + ep1.x] === 1 || hasVisitedNeighbor(visited, W, H, ep1.x, ep1.y, 4)
const ep2Reached = visited[ep2.y * W + ep2.x] === 1 || hasVisitedNeighbor(visited, W, H, ep2.x, ep2.y, 4)
check(ep1Reached && ep2Reached, `${entry.path}: path exists between ${ep1.type} to ${ep1.to} and ${ep2.type} to ${ep2.to}`)
}
}
}
}
function hasVisitedNeighbor(visited: Uint8Array, W: number, H: number, x: number, y: number, radius: number): boolean {
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const nx = x + dx
const ny = y + dy
if (nx >= 0 && nx < W && ny >= 0 && ny < H && visited[ny * W + nx] === 1) return true
}
}
return false
}
console.log(`Part 1 complete: ${totalChecks - failedChecks}/${totalChecks} checks passed.`)
// =========================================================================
// PART 2: MULTI-SEED PROCEDURAL WILDERNESS GENERATION STRESS TEST
// =========================================================================
console.log('\n=== PART 2: PROCEDURAL MULTI-SEED STRESS TEST (ACTS 2..5) ===\n')
const archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
archives.add(name, await MpqArchive.open(await fileSource(join(archiveDir, name))))
}
const tables = await loadActTables(archives)
const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
const ds1Cache = new Map<string, Ds1>()
async function loadDs1(arch: MountedArchives, relative: string): Promise<Ds1> {
const member = tileMemberPath(relative)
const cached = ds1Cache.get(member)
if (cached !== undefined) return cached
const decoded = decodeDs1(await arch.read(member))
ds1Cache.set(member, decoded)
return decoded
}
async function rowDs1s(arch: MountedArchives, table: D2Table, row: readonly string[]): Promise<Ds1[]> {
const levels: Ds1[] = []
for (let slot = 1; slot <= 6; slot += 1) {
const value = cell(table, row, `File${String(slot)}`)
if (value === '' || value === '0') continue
levels.push(await loadDs1(arch, value))
}
return levels
}
function themeValues(table: D2Table, row: readonly string[], prefix: string): number[] {
const values: number[] = []
for (let index = 0; index < 5; index += 1) values.push(Number(cell(table, row, `${prefix}${String(index)}`)) || 0)
return values
}
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 2 - Desert': ['Act 2 - Desert'],
'Act 3 - Jungle': ['Act 3 - Jungle'],
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'],
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'],
'Act 5 - Siege': ['Act 5 - Siege'],
'Act 5 - Barricade': ['Act 5 - Barricade'],
}
async function wildernessPieces(arch: MountedArchives, lvlprest: D2Table, levelTypeName: string): Promise<WildernessPiece[]> {
const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? []
const pieces: WildernessPiece[] = []
for (const row of lvlprest.rows) {
const name = cell(lvlprest, row, 'Name')
if (!families.some(family => name.startsWith(family))) continue
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
const levels = await rowDs1s(arch, lvlprest, row)
if (levels.length === 0) continue
const isBorder = /border|cliff/i.test(name)
pieces.push({ name, levels, border: isBorder })
}
return pieces
}
async function substitutions(arch: MountedArchives, subTable: D2Table, type: number): Promise<WildernessSubstitution[]> {
if (type < 0) return []
const rows: WildernessSubstitution[] = []
for (const row of subTable.rows) {
if (Number(cell(subTable, row, 'Type')) !== type) continue
const file = cell(subTable, row, 'File')
if (file === '' || file === '0') continue
const levels = [await loadDs1(arch, file)]
rows.push({
name: cell(subTable, row, 'Name'),
type,
gridSize: Number(cell(subTable, row, 'GridSize')) || 1,
bordType: Number(cell(subTable, row, 'BordType')),
dt1Mask: Number(cell(subTable, row, 'Dt1Mask')) || 0,
prob: themeValues(subTable, row, 'Prob'),
trials: themeValues(subTable, row, 'Trials'),
max: themeValues(subTable, row, 'Max'),
levels,
})
}
return rows
}
const dt1Cache = new Map<string, Dt1>()
async function loadDt1(arch: MountedArchives, name: string): Promise<Dt1> {
const cached = dt1Cache.get(name)
if (cached !== undefined) return cached
const decoded = decodeDt1(await arch.read(name))
dt1Cache.set(name, decoded)
return decoded
}
async function decodeLibraries(arch: MountedArchives, names: readonly string[]): Promise<Dt1[]> {
const libraries: Dt1[] = []
for (const name of names) {
try {
libraries.push(await loadDt1(arch, name))
} catch {}
}
return libraries
}
const stressSeeds = [
0x301cd095,
0x416d61c5,
0xdeadbeef,
42,
0x5eed_2026,
]
const procLevels = [
// Act 2
41, 42, 43, 44, 45, 46,
// Act 3
76, 77, 78, 79, 80, 81,
// Act 4
104, 105, 106,
// Act 5
110, 111, 112, 117, 134,
]
let procGenerations = 0
for (const levelId of procLevels) {
const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === levelId)!
const levelName = cell(tables.levels, levelRow, 'Name')
const typeId = cell(tables.levels, levelRow, 'LevelType')
const typeRow = tables.lvltypes.rows.find(candidate => cell(tables.lvltypes, candidate, 'Id') === typeId)!
const typeName = typeRow === undefined ? '' : cell(tables.lvltypes, typeRow, 'Name')
const sizeX = Number(cell(tables.levels, levelRow, 'SizeX'))
const sizeY = Number(cell(tables.levels, levelRow, 'SizeY'))
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine'))
const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme'))
const pieces = await wildernessPieces(archives, tables.lvlprest, typeName)
const rows = await substitutions(archives, lvlsub, subType)
const shrineRows = await substitutions(archives, lvlsub, subShrine)
const libraries = resolveLevelLibraries(tables, levelId)
const dt1s = await decodeLibraries(archives, libraries.dt1Names)
console.log(`Checking Level ${levelId} (${levelName}) across ${stressSeeds.length} seeds...`)
for (const seed of stressSeeds) {
procGenerations++
const request = {
levelId,
levelName,
levelTypeName: typeName,
sizeX,
sizeY,
subType,
subTheme: Math.max(0, subTheme),
seed,
pieces,
substitutions: rows,
shrineSubstitutions: shrineRows,
}
try {
const generated = generateWilderness(request)
const scene = buildIsoMapScene(generated.level, dt1s, levelSeed('generated'))
// Verify spawn
const spawn = findIsoSpawn(scene)
check(spawn !== null, `Proc Lvl ${levelId} (${levelName}) seed 0x${seed.toString(16)}: spawn point found`)
if (spawn !== null) {
const st = subTileAt(scene, spawn.x, spawn.y)
check(st.inBounds, `Proc Lvl ${levelId} seed 0x${seed.toString(16)}: spawn in-bounds (${st.subX}, ${st.subY})`)
const isWalkable = scene.blocked[st.subY * scene.gridWidth + st.subX] === 0
check(isWalkable, `Proc Lvl ${levelId} seed 0x${seed.toString(16)}: spawn is walkable`)
// BFS flood fill
const W = scene.gridWidth
const H = scene.cellsY * 5
const visited = new Uint8Array(W * H)
const q = [st.subX, st.subY]
visited[st.subY * W + st.subX] = 1
let h = 0
while (h < q.length) {
const cx = q[h++]!
const cy = q[h++]!
const neighbours = [
[cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1],
[cx + 1, cy + 1], [cx - 1, cy - 1], [cx + 1, cy - 1], [cx - 1, cy + 1],
]
for (const [nx, ny] of neighbours) {
if (nx >= 0 && nx < W && ny >= 0 && ny < H) {
const idx = ny * W + nx
if (visited[idx] === 0 && scene.blocked[idx] === 0) {
visited[idx] = 1
q.push(nx, ny)
}
}
}
}
// Verify all gate openings are reachable
const gateOpenings = (generated.stats.gateOpenings ?? []) as readonly any[]
for (const gate of gateOpenings) {
const ax = gate.tileCoord.x * 5 + 2
const ay = gate.tileCoord.y * 5 + 2
let reached = false
for (let dy = -5; dy <= 5 && !reached; dy++) {
for (let dx = -5; dx <= 5 && !reached; dx++) {
const gx = ax + dx
const gy = ay + dy
if (gx >= 0 && gx < W && gy >= 0 && gy < H && visited[gy * W + gx] === 1) reached = true
}
}
check(reached, `Proc Lvl ${levelId} seed 0x${seed.toString(16)}: gate opening to ${gate.toLevelId} reachable`)
}
}
} catch (err) {
check(false, `Proc Lvl ${levelId} seed 0x${seed.toString(16)} threw: ${err instanceof Error ? err.message : String(err)}`)
}
}
}
console.log(`Part 2 complete: tested ${procGenerations} procedural level generations across Acts 2..5.`)
console.log('\n=========================================================================')
console.log(`FINAL STRESS TEST SUMMARY: ${totalChecks - failedChecks}/${totalChecks} assertions passed (${failures.length} failures)`)
console.log('=========================================================================\n')
if (failures.length > 0) {
console.log(`Top Failures:`)
for (const f of failures.slice(0, 20)) {
console.log(` - ${f}`)
}
process.exit(1)
} else {
console.log('ALL ADVERSARIAL TRAVERSABILITY & WALKABILITY ASSERTIONS PASSED!')
process.exit(0)
}