520 lines
19 KiB
TypeScript
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)
|
|
}
|