676 lines
26 KiB
TypeScript
676 lines
26 KiB
TypeScript
/**
|
|
* Wilderness Topological Reachability & Collision Boundary Stress Test Suite (Issue #455).
|
|
*
|
|
* Empirical verification harness for Challenger 2:
|
|
* 1. Flood-fill reachability from player spawn / Rogue Encampment gate (`TownSTrans`)
|
|
* to Den of Evil entrance (`DenEnt.ds1`), Cold Plains boundary (river bridge / exit),
|
|
* and all landmark presets (Pond1, Cott1, Circle, Clover).
|
|
* 2. Impassable choke point, void trap, and bisecting barrier detection.
|
|
* 3. Secondary border (`Bord5..8` peninsulas) and 2x3 interior closure (`Bord9..12`) safety.
|
|
* 4. Multi-seed stress fuzzing across horizontal (96x56) and vertical (56x96) layouts.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll } from 'vitest'
|
|
import { existsSync } from 'node:fs'
|
|
import { MountedArchives } from '../src/mpq/mount.ts'
|
|
import { MpqArchive } from '../src/mpq/archive.ts'
|
|
import { fileSource } from '../src/mpq/file-source.ts'
|
|
import { loadActTables, parseTable, cell, tileMemberPath, resolveLevelLibraries, type D2Table, type ActTables } from '../src/game/acts.ts'
|
|
import { decodeDs1, type Ds1 } from '../src/formats/ds1.ts'
|
|
import { decodeDt1, type Dt1 } from '../src/formats/dt1.ts'
|
|
import { buildIsoMapScene, levelSeed } from '../src/game/d2map.ts'
|
|
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
|
|
import {
|
|
generateWilderness,
|
|
type WildernessPiece,
|
|
type WildernessSubstitution,
|
|
type WildernessResult,
|
|
} from '../src/game/wilderness.ts'
|
|
import { solveAct1WildernessTopology } from '../src/game/wilderness/wilderness-topology.ts'
|
|
|
|
const hasD2 = existsSync('samples/d2/d2data.mpq')
|
|
|
|
export interface LandmarkTarget {
|
|
name: string
|
|
tileX: number
|
|
tileY: number
|
|
widthTiles: number
|
|
heightTiles: number
|
|
reachable: boolean
|
|
minSubTileDist: number
|
|
}
|
|
|
|
export interface ReachabilityReport {
|
|
seed: number
|
|
orientation: 'horizontal' | 'vertical'
|
|
widthTiles: number
|
|
heightTiles: number
|
|
totalSubTiles: number
|
|
walkableSubTiles: number
|
|
largestComponentSize: number
|
|
connectivityRatio: number
|
|
townSpawn: { x: number; y: number }
|
|
denReachable: boolean
|
|
denDistance: number
|
|
exitReachable: boolean
|
|
exitDistance: number
|
|
landmarks: LandmarkTarget[]
|
|
minShortestPathClearance: number
|
|
hasBisectingBarrier: boolean
|
|
hasVoidTrapAtSpawn: boolean
|
|
}
|
|
|
|
describe.skipIf(!hasD2)('Challenger 2: Wilderness Topological Reachability & Collision Stress Suite', () => {
|
|
let archives: MountedArchives
|
|
let tables: ActTables
|
|
let lvlsubTable: D2Table
|
|
let act1Pieces: WildernessPiece[]
|
|
let act1Subs: WildernessSubstitution[]
|
|
const dt1Cache = new Map<string, Dt1>()
|
|
let dt1s: Dt1[] = []
|
|
|
|
beforeAll(async () => {
|
|
archives = new MountedArchives()
|
|
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
|
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
|
|
}
|
|
tables = await loadActTables(archives)
|
|
lvlsubTable = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
|
|
|
|
const ds1Cache = new Map<string, Ds1>()
|
|
async function loadDs1(relative: string): Promise<Ds1> {
|
|
const member = tileMemberPath(relative)
|
|
const cached = ds1Cache.get(member)
|
|
if (cached !== undefined) return cached
|
|
const decoded = decodeDs1(await archives.read(member))
|
|
ds1Cache.set(member, decoded)
|
|
return decoded
|
|
}
|
|
|
|
async function rowDs1s(table: D2Table, row: readonly string[]): Promise<Ds1[]> {
|
|
const levels: Ds1[] = []
|
|
for (let slot = 1; slot <= 6; slot += 1) {
|
|
const val = cell(table, row, `File${String(slot)}`)
|
|
if (val === '' || val === '0') continue
|
|
levels.push(await loadDs1(val))
|
|
}
|
|
return levels
|
|
}
|
|
|
|
const WILDERNESS_FAMILIES = [
|
|
'Act 1 - Wild',
|
|
'Act 1 - Town 1 Transition',
|
|
'Act 1 - DOE Entrance',
|
|
'Act 1 - Cottages',
|
|
'Act 1 - Pond',
|
|
'Act 1 - Stone Fill',
|
|
'Act 1 - River',
|
|
'Act 1 - Bridge',
|
|
'Act 1 - Cave Entrance',
|
|
]
|
|
|
|
act1Pieces = []
|
|
for (const row of tables.lvlprest.rows) {
|
|
const name = cell(tables.lvlprest, row, 'Name')
|
|
if (!WILDERNESS_FAMILIES.some(fam => name.startsWith(fam))) continue
|
|
const levels = await rowDs1s(tables.lvlprest, row)
|
|
if (levels.length === 0) continue
|
|
act1Pieces.push({ name, levels, border: /\bBorder\b/i.test(name) })
|
|
}
|
|
const tPiece = act1Pieces.find(p => /Town.*STrans|Transition.*S/i.test(p.name))?.levels[0]
|
|
console.log('TownSTrans ds1 size:', tPiece?.width, 'x', tPiece?.height)
|
|
|
|
act1Subs = []
|
|
for (const row of lvlsubTable.rows) {
|
|
const subType = Number(cell(lvlsubTable, row, 'Type'))
|
|
if (subType !== 0 && subType !== 1 && subType !== 6) continue
|
|
const file = cell(lvlsubTable, row, 'File')
|
|
if (!file || file === '0') continue
|
|
const levels = [await loadDs1(file)]
|
|
act1Subs.push({
|
|
name: cell(lvlsubTable, row, 'Name'),
|
|
type: subType,
|
|
gridSize: Number(cell(lvlsubTable, row, 'GridSize')) || 1,
|
|
bordType: Number(cell(lvlsubTable, row, 'BordType')),
|
|
dt1Mask: Number(cell(lvlsubTable, row, 'Dt1Mask')) || 0,
|
|
prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Prob${String(i)}`)) || 0),
|
|
trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Trials${String(i)}`)) || 0),
|
|
max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Max${String(i)}`)) || 0),
|
|
levels,
|
|
})
|
|
}
|
|
|
|
const libraries = resolveLevelLibraries(tables, 2)
|
|
for (const n of libraries.dt1Names) {
|
|
if (!dt1Cache.has(n)) {
|
|
try {
|
|
dt1Cache.set(n, decodeDt1(await archives.read(n)))
|
|
} catch {
|
|
// Ignore missing optional DT1s
|
|
}
|
|
}
|
|
}
|
|
dt1s = libraries.dt1Names.map(n => dt1Cache.get(n)).filter((d): d is Dt1 => d !== undefined)
|
|
}, 30000)
|
|
|
|
/**
|
|
* Evaluates flood-fill reachability on sub-tile collision grid for a generated level.
|
|
*/
|
|
function evaluateReachability(seed: number): ReachabilityReport {
|
|
const result: WildernessResult = generateWilderness({
|
|
levelId: 2,
|
|
levelName: 'Blood Moor',
|
|
levelTypeName: 'Act 1 - Wilderness',
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 0,
|
|
subTheme: 0,
|
|
seed,
|
|
pieces: act1Pieces,
|
|
substitutions: act1Subs,
|
|
})
|
|
|
|
if (seed === 359) {
|
|
console.log('Seed 359 stats.notes:', result.stats.notes)
|
|
}
|
|
|
|
const topo = solveAct1WildernessTopology(seed, 2)
|
|
const orientation = topo.width > topo.height ? 'horizontal' : 'vertical'
|
|
|
|
const scene = buildIsoMapScene(result.level, dt1s, levelSeed(`stress_${seed}`))
|
|
const cellsX = result.level.width
|
|
const cellsY = result.level.height
|
|
const gridW = scene.gridWidth ?? cellsX * SUB_TILES_PER_TILE
|
|
const gridH = scene.gridHeight ?? cellsY * SUB_TILES_PER_TILE
|
|
const totalSubTiles = gridW * gridH
|
|
|
|
// Check walkable sub-tiles
|
|
let walkableCount = 0
|
|
for (let i = 0; i < totalSubTiles; i += 1) {
|
|
if (scene.blocked[i] === 0) walkableCount += 1
|
|
}
|
|
|
|
// Locate town gate / spawn
|
|
const gateVertex = (result.stats.drlgVertices as Array<{ type: string; x: number; y: number }>)?.find(v => v.type === 'gate')
|
|
const denVertex = (result.stats.drlgVertices as Array<{ type: string; x: number; y: number }>)?.find(v => v.type === 'preset')
|
|
const exitVertex = (result.stats.drlgVertices as Array<{ type: string; x: number; y: number }>)?.filter(v => v.type === 'gate')[1]
|
|
|
|
// Convert tile coordinates to sub-tile coordinates (center of tile = +2)
|
|
const spawnTileX = gateVertex?.x ?? Math.floor(cellsX / 2)
|
|
const spawnTileY = gateVertex?.y ?? 4
|
|
|
|
let startSx = spawnTileX * SUB_TILES_PER_TILE + 2
|
|
let startSy = spawnTileY * SUB_TILES_PER_TILE + 2
|
|
|
|
// Search local window for nearest walkable sub-tile if exact center is blocked
|
|
if (scene.blocked[startSy * gridW + startSx] !== 0) {
|
|
let found = false
|
|
for (let r = 1; r <= 20 && !found; r += 1) {
|
|
for (let dy = -r; dy <= r && !found; dy += 1) {
|
|
for (let dx = -r; dx <= r && !found; dx += 1) {
|
|
const sx = startSx + dx
|
|
const sy = startSy + dy
|
|
if (sx >= 0 && sx < gridW && sy >= 0 && sy < gridH) {
|
|
if (scene.blocked[sy * gridW + sx] === 0) {
|
|
startSx = sx
|
|
startSy = sy
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const spawnWalkable = scene.blocked[startSy * gridW + startSx] === 0
|
|
|
|
// BFS Flood-fill from start sub-tile
|
|
const dist = new Int32Array(totalSubTiles).fill(-1)
|
|
const prev = new Int32Array(totalSubTiles).fill(-1)
|
|
const queue = new Int32Array(totalSubTiles)
|
|
let qHead = 0
|
|
let qTail = 0
|
|
|
|
const startIdx = startSy * gridW + startSx
|
|
dist[startIdx] = 0
|
|
queue[qTail++] = startIdx
|
|
|
|
const DIRS = [
|
|
[1, 0], [-1, 0], [0, 1], [0, -1],
|
|
[1, 1], [1, -1], [-1, 1], [-1, -1],
|
|
]
|
|
|
|
while (qHead < qTail) {
|
|
const curr = queue[qHead++]
|
|
const cx = curr % gridW
|
|
const cy = Math.floor(curr / gridW)
|
|
const d = dist[curr]!
|
|
|
|
for (const [dx, dy] of DIRS) {
|
|
const nx = cx + dx
|
|
const ny = cy + dy
|
|
if (nx >= 0 && nx < gridW && ny >= 0 && ny < gridH) {
|
|
const nidx = ny * gridW + nx
|
|
if (dist[nidx] === -1 && scene.blocked[nidx] === 0) {
|
|
dist[nidx] = d + 1
|
|
prev[nidx] = curr
|
|
queue[qTail++] = nidx
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const largestComponentSize = qTail
|
|
const connectivityRatio = walkableCount > 0 ? largestComponentSize / walkableCount : 0
|
|
|
|
if (seed === 359) {
|
|
let minTx = 999, maxTx = 0, minTy = 999, maxTy = 0
|
|
for (let i = 0; i < qTail; i += 1) {
|
|
const idx = queue[i]!
|
|
const tx = Math.floor((idx % gridW) / SUB_TILES_PER_TILE)
|
|
const ty = Math.floor(Math.floor(idx / gridW) / SUB_TILES_PER_TILE)
|
|
if (tx < minTx) minTx = tx
|
|
if (tx > maxTx) maxTx = tx
|
|
if (ty < minTy) minTy = ty
|
|
if (ty > maxTy) maxTy = ty
|
|
}
|
|
console.log(`[Seed 359 Spawn Component Bounds] tx: ${minTx}..${maxTx}, ty: ${minTy}..${maxTy}, spawnTile: (${Math.floor(startSx/5)}, ${Math.floor(startSy/5)})`)
|
|
console.log('Cell at 8, 44:', JSON.stringify(result.level.cells[8]?.[44]))
|
|
console.log('Cell at 9, 44:', JSON.stringify(result.level.cells[9]?.[44]))
|
|
console.log('Cell at 10, 44:', JSON.stringify(result.level.cells[10]?.[44]))
|
|
for (let ty = 6; ty <= 18; ty += 1) {
|
|
let rowStr = `ty=${ty.toString().padStart(2)}: `
|
|
for (let tx = 42; tx <= 48; tx += 1) {
|
|
let cellBlocked = 0
|
|
for (let sy = 0; sy < 5; sy += 1) {
|
|
for (let sx = 0; sx < 5; sx += 1) {
|
|
if (scene.blocked[(ty * 5 + sy) * gridW + (tx * 5 + sx)] !== 0) cellBlocked += 1
|
|
}
|
|
}
|
|
rowStr += ` tx${tx}:${cellBlocked}/25`
|
|
}
|
|
console.log(rowStr)
|
|
}
|
|
}
|
|
|
|
// Find all disconnected components
|
|
const visitedAll = new Uint8Array(totalSubTiles)
|
|
for (let i = 0; i < totalSubTiles; i += 1) {
|
|
if (dist[i] !== -1) visitedAll[i] = 1
|
|
}
|
|
const otherComponents: Array<{ size: number; sampleTile: { x: number; y: number } }> = []
|
|
for (let i = 0; i < totalSubTiles; i += 1) {
|
|
if (scene.blocked[i] === 0 && visitedAll[i] === 0) {
|
|
// BFS this component
|
|
let cSize = 0
|
|
const cQueue = [i]
|
|
visitedAll[i] = 1
|
|
const sampleTile = { x: (i % gridW) / SUB_TILES_PER_TILE, y: Math.floor(i / gridW) / SUB_TILES_PER_TILE }
|
|
while (cQueue.length > 0) {
|
|
const curr = cQueue.pop()!
|
|
cSize += 1
|
|
const cx = curr % gridW
|
|
const cy = Math.floor(curr / gridW)
|
|
for (const [dx, dy] of DIRS) {
|
|
const nx = cx + dx
|
|
const ny = cy + dy
|
|
if (nx >= 0 && nx < gridW && ny >= 0 && ny < gridH) {
|
|
const nidx = ny * gridW + nx
|
|
if (visitedAll[nidx] === 0 && scene.blocked[nidx] === 0) {
|
|
visitedAll[nidx] = 1
|
|
cQueue.push(nidx)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
otherComponents.push({ size: cSize, sampleTile })
|
|
}
|
|
}
|
|
otherComponents.sort((a, b) => b.size - a.size)
|
|
if (seed === 12345 || seed === 42 || seed === 359) {
|
|
console.log(`[Diagnostic Seed ${seed}] Total walkable: ${walkableCount}, Main component: ${largestComponentSize} (${(connectivityRatio * 100).toFixed(1)}%), Other components: ${otherComponents.length}`)
|
|
console.log(`Top 5 disconnected components:`, otherComponents.slice(0, 5))
|
|
}
|
|
|
|
// Parse landmark coordinates from stats.notes
|
|
const landmarks: LandmarkTarget[] = []
|
|
const notes = (result.stats.notes as string[]) ?? []
|
|
for (const note of notes) {
|
|
const match = note.match(/Placed ([\w\.-]+).*at \((\d+),\s*(\d+)\)/)
|
|
if (match) {
|
|
const name = match[1]!
|
|
const tileX = parseInt(match[2]!, 10)
|
|
const tileY = parseInt(match[3]!, 10)
|
|
let widthTiles = 16
|
|
let heightTiles = 16
|
|
if (/Circle|Clover/i.test(name)) {
|
|
widthTiles = 8
|
|
heightTiles = 8
|
|
} else if (/Town/i.test(name)) {
|
|
widthTiles = 56
|
|
heightTiles = 8
|
|
}
|
|
|
|
// Test reachability of landmark perimeter or entrance
|
|
let minDist = Infinity
|
|
for (let dy = -2; dy <= heightTiles + 2; dy += 1) {
|
|
for (let dx = -2; dx <= widthTiles + 2; dx += 1) {
|
|
const cx = tileX + dx
|
|
const cy = tileY + dy
|
|
if (cx < 0 || cx >= cellsX || cy < 0 || cy >= cellsY) continue
|
|
for (let sy = 0; sy < SUB_TILES_PER_TILE; sy += 1) {
|
|
for (let sx = 0; sx < SUB_TILES_PER_TILE; sx += 1) {
|
|
const gX = cx * SUB_TILES_PER_TILE + sx
|
|
const gY = cy * SUB_TILES_PER_TILE + sy
|
|
const idx = gY * gridW + gX
|
|
const d = dist[idx]
|
|
if (d !== undefined && d >= 0 && d < minDist) {
|
|
minDist = d
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
landmarks.push({
|
|
name,
|
|
tileX,
|
|
tileY,
|
|
widthTiles,
|
|
heightTiles,
|
|
reachable: minDist !== Infinity,
|
|
minSubTileDist: minDist === Infinity ? -1 : minDist,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Den of Evil reachability
|
|
const denTarget = landmarks.find(l => /Den|DOE/i.test(l.name))
|
|
const denTile = denTarget ? { x: denTarget.tileX + 8, y: denTarget.tileY + 8 } : (denVertex ?? { x: 16, y: Math.floor(cellsY / 2) })
|
|
let minDenDist = Infinity
|
|
for (let dy = -3; dy <= 3; dy += 1) {
|
|
for (let dx = -3; dx <= 3; dx += 1) {
|
|
const cx = denTile.x + dx
|
|
const cy = denTile.y + dy
|
|
if (cx < 0 || cx >= cellsX || cy < 0 || cy >= cellsY) continue
|
|
for (let sy = 0; sy < SUB_TILES_PER_TILE; sy += 1) {
|
|
for (let sx = 0; sx < SUB_TILES_PER_TILE; sx += 1) {
|
|
const gX = cx * SUB_TILES_PER_TILE + sx
|
|
const gY = cy * SUB_TILES_PER_TILE + sy
|
|
const idx = gY * gridW + gX
|
|
const d = dist[idx]
|
|
if (d !== undefined && d >= 0 && d < minDenDist) {
|
|
minDenDist = d
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (seed === 359) {
|
|
console.log('Seed 359 denTarget:', denTarget, 'denTile:', denTile, 'minDenDist:', minDenDist, 'landmarks:', landmarks.map(l => ({ name: l.name, x: l.tileX, y: l.tileY, reachable: l.reachable })))
|
|
}
|
|
|
|
// Cold Plains exit reachability (Bridge or East exit)
|
|
const exitCol = topo.riverColumn ?? cellsX - 2
|
|
let minExitDist = Infinity
|
|
let exitSubTileTarget = -1
|
|
for (let r = 0; r < cellsY; r += 1) {
|
|
for (let c = exitCol * 8; c < cellsX; c += 1) {
|
|
for (let sy = 0; sy < SUB_TILES_PER_TILE; sy += 1) {
|
|
for (let sx = 0; sx < SUB_TILES_PER_TILE; sx += 1) {
|
|
const gX = c * SUB_TILES_PER_TILE + sx
|
|
const gY = r * SUB_TILES_PER_TILE + sy
|
|
const idx = gY * gridW + gX
|
|
const d = dist[idx]
|
|
if (d !== undefined && d >= 0 && d < minExitDist) {
|
|
minExitDist = d
|
|
exitSubTileTarget = idx
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Measure bottleneck clearance along shortest path to Exit
|
|
let minClearance = Infinity
|
|
if (exitSubTileTarget !== -1) {
|
|
let pCurr = exitSubTileTarget
|
|
let steps = 0
|
|
while (pCurr !== startIdx && pCurr !== -1 && steps < 5000) {
|
|
steps += 1
|
|
const px = pCurr % gridW
|
|
const py = Math.floor(pCurr / gridW)
|
|
// Measure clearance in radius
|
|
let localWalkable = 0
|
|
for (let dy = -2; dy <= 2; dy += 1) {
|
|
for (let dx = -2; dx <= 2; dx += 1) {
|
|
const sx = px + dx
|
|
const sy = py + dy
|
|
if (sx >= 0 && sx < gridW && sy >= 0 && sy < gridH) {
|
|
if (scene.blocked[sy * gridW + sx] === 0) localWalkable += 1
|
|
}
|
|
}
|
|
}
|
|
if (localWalkable < minClearance) minClearance = localWalkable
|
|
pCurr = prev[pCurr]!
|
|
}
|
|
}
|
|
|
|
return {
|
|
seed,
|
|
orientation,
|
|
widthTiles: cellsX,
|
|
heightTiles: cellsY,
|
|
totalSubTiles,
|
|
walkableSubTiles: walkableCount,
|
|
largestComponentSize,
|
|
connectivityRatio,
|
|
townSpawn: { x: startSx, y: startSy },
|
|
denReachable: minDenDist !== Infinity,
|
|
denDistance: minDenDist,
|
|
exitReachable: minExitDist !== Infinity,
|
|
exitDistance: minExitDist,
|
|
landmarks,
|
|
minShortestPathClearance: minClearance === Infinity ? 0 : minClearance,
|
|
hasBisectingBarrier: minExitDist === Infinity || minDenDist === Infinity,
|
|
hasVoidTrapAtSpawn: !spawnWalkable || largestComponentSize < 200,
|
|
}
|
|
}
|
|
|
|
/* ======================================================================= *
|
|
* SUITE 1: Canonical Seeds & Dual-Orientation Verification
|
|
* ======================================================================= */
|
|
describe('Suite 1: Canonical Seeds & Dual-Orientation Verification', () => {
|
|
it('asserts 100% reachability on horizontal 96x56 Moor (Seed 0x12345678)', () => {
|
|
const rep = evaluateReachability(0x12345678)
|
|
expect(rep.orientation).toBe('horizontal')
|
|
expect(rep.widthTiles).toBe(96)
|
|
expect(rep.heightTiles).toBe(56)
|
|
expect(rep.hasVoidTrapAtSpawn).toBe(false)
|
|
expect(rep.hasBisectingBarrier).toBe(false)
|
|
expect(rep.denReachable).toBe(true)
|
|
expect(rep.exitReachable).toBe(true)
|
|
expect(rep.connectivityRatio).toBeGreaterThan(0.88)
|
|
expect(rep.landmarks.every(l => l.reachable)).toBe(true)
|
|
expect(rep.minShortestPathClearance).toBeGreaterThanOrEqual(3)
|
|
})
|
|
|
|
it('asserts 100% reachability on horizontal 96x56 Moor (Seed 12345)', () => {
|
|
const rep = evaluateReachability(12345)
|
|
expect(rep.orientation).toBe('horizontal')
|
|
expect(rep.widthTiles).toBe(96)
|
|
expect(rep.heightTiles).toBe(56)
|
|
expect(rep.hasVoidTrapAtSpawn).toBe(false)
|
|
expect(rep.hasBisectingBarrier).toBe(false)
|
|
expect(rep.denReachable).toBe(true)
|
|
expect(rep.exitReachable).toBe(true)
|
|
expect(rep.connectivityRatio).toBeGreaterThan(0.88)
|
|
expect(rep.landmarks.every(l => l.reachable)).toBe(true)
|
|
})
|
|
|
|
it('asserts 100% reachability on vertical 56x96 Moor (Seed 1)', () => {
|
|
const rep = evaluateReachability(1)
|
|
expect(rep.orientation).toBe('vertical')
|
|
expect(rep.widthTiles).toBe(56)
|
|
expect(rep.heightTiles).toBe(96)
|
|
expect(rep.hasVoidTrapAtSpawn).toBe(false)
|
|
expect(rep.hasBisectingBarrier).toBe(false)
|
|
expect(rep.denReachable).toBe(true)
|
|
expect(rep.exitReachable).toBe(true)
|
|
expect(rep.connectivityRatio).toBeGreaterThan(0.78)
|
|
expect(rep.landmarks.every(l => l.reachable)).toBe(true)
|
|
})
|
|
|
|
it('asserts 100% reachability on vertical 56x96 Moor (Seed 42)', () => {
|
|
const rep = evaluateReachability(42)
|
|
expect(rep.orientation).toBe('vertical')
|
|
expect(rep.widthTiles).toBe(56)
|
|
expect(rep.heightTiles).toBe(96)
|
|
expect(rep.hasVoidTrapAtSpawn).toBe(false)
|
|
expect(rep.hasBisectingBarrier).toBe(false)
|
|
expect(rep.denReachable).toBe(true)
|
|
expect(rep.exitReachable).toBe(true)
|
|
expect(rep.connectivityRatio).toBeGreaterThan(0.78)
|
|
expect(rep.landmarks.every(l => l.reachable)).toBe(true)
|
|
})
|
|
})
|
|
|
|
/* ======================================================================= *
|
|
* SUITE 2: Edge-Case Seeds (0, 0xFFFFFFFF, 0x7FFFFFFF, 666, 999999)
|
|
* ======================================================================= */
|
|
describe('Suite 2: Boundary & Extreme Seed Topologies', () => {
|
|
const boundarySeeds = [0, 666, 999999, 0x7fffffff, 0x5eed2001, 0xdeadbeef, 359]
|
|
|
|
for (const s of boundarySeeds) {
|
|
it(`verifies zero impassable bottlenecks & reachability on Seed ${s}`, () => {
|
|
const rep = evaluateReachability(s)
|
|
expect(rep.hasVoidTrapAtSpawn).toBe(false)
|
|
expect(rep.hasBisectingBarrier).toBe(false)
|
|
expect(rep.denReachable).toBe(true)
|
|
expect(rep.exitReachable).toBe(true)
|
|
const minRatio = rep.orientation === 'horizontal' ? 0.85 : 0.75
|
|
expect(rep.connectivityRatio).toBeGreaterThan(minRatio)
|
|
expect(rep.landmarks.every(l => l.reachable)).toBe(true)
|
|
})
|
|
}
|
|
})
|
|
|
|
/* ======================================================================= *
|
|
* SUITE 3: 50-Seed Fuzzing Sweep (Topological Invariants)
|
|
* ======================================================================= */
|
|
describe('Suite 3: 50-Seed Random Fuzzing Sweep', () => {
|
|
it('verifies 100% topological reachability across 50 pseudo-random seeds', () => {
|
|
const testSeeds = Array.from({ length: 50 }, (_, i) => 100 + i * 37)
|
|
|
|
let horizontalCount = 0
|
|
let verticalCount = 0
|
|
let passedCount = 0
|
|
|
|
for (const s of testSeeds) {
|
|
const rep = evaluateReachability(s)
|
|
if (rep.orientation === 'horizontal') horizontalCount += 1
|
|
else verticalCount += 1
|
|
|
|
if (rep.hasBisectingBarrier) {
|
|
console.log(`[Suite 3 Failure] Seed ${s}: orientation=${rep.orientation}, denReachable=${rep.denReachable}, exitReachable=${rep.exitReachable}, denDist=${rep.denDistance}, exitDist=${rep.exitDistance}`)
|
|
}
|
|
|
|
expect(rep.hasVoidTrapAtSpawn).toBe(false)
|
|
expect(rep.hasBisectingBarrier, `Seed ${s} has bisecting barrier (denReachable=${rep.denReachable}, exitReachable=${rep.exitReachable})`).toBe(false)
|
|
expect(rep.denReachable).toBe(true)
|
|
expect(rep.exitReachable).toBe(true)
|
|
const minRatio = rep.orientation === 'horizontal' ? 0.85 : 0.75
|
|
expect(rep.connectivityRatio).toBeGreaterThan(minRatio)
|
|
const unreachableLandmarks = rep.landmarks.filter(l => !l.reachable)
|
|
if (unreachableLandmarks.length > 0) {
|
|
console.log(`[Suite 3 Landmark Failure] Seed ${s}:`, unreachableLandmarks.map(l => ({ name: l.name, x: l.tileX, y: l.tileY, w: l.widthTiles, h: l.heightTiles })))
|
|
}
|
|
expect(unreachableLandmarks, `Seed ${s} has unreachable landmarks`).toEqual([])
|
|
|
|
passedCount += 1
|
|
}
|
|
|
|
expect(passedCount).toBe(50)
|
|
expect(horizontalCount).toBeGreaterThan(15)
|
|
expect(verticalCount).toBeGreaterThan(15)
|
|
})
|
|
})
|
|
|
|
/* ======================================================================= *
|
|
* SUITE 4: Secondary Borders & 2x3 Closures Boundary Non-Trapping
|
|
* ======================================================================= */
|
|
describe('Suite 4: Secondary Borders & 2x3 Interior Closures Non-Trapping', () => {
|
|
it('asserts 2x3 closures (Bord9..12) do not trap player spawn or block critical paths', () => {
|
|
// In Bord9..12, interior 2x3 closures sit in block (2,2)..(3,3).
|
|
// We assert that spawn is outside of (2,2)..(3,3), and distance from Town to Exit is finite.
|
|
const rep = evaluateReachability(0x12345678)
|
|
const spawnTileX = Math.floor(rep.townSpawn.x / SUB_TILES_PER_TILE)
|
|
const spawnTileY = Math.floor(rep.townSpawn.y / SUB_TILES_PER_TILE)
|
|
|
|
// Spawn must not be inside the 2x3 closure blocks [16..31, 16..31]
|
|
const inside2x3Closure = spawnTileX >= 16 && spawnTileX < 32 && spawnTileY >= 16 && spawnTileY < 32
|
|
expect(inside2x3Closure).toBe(false)
|
|
|
|
// Shortest path to exit exists and has minimum corridor clearance
|
|
expect(rep.exitReachable).toBe(true)
|
|
expect(rep.exitDistance).toBeGreaterThan(0)
|
|
expect(rep.minShortestPathClearance).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('asserts secondary peninsulas (Bord5..8) do not sever perimeter or town gate', () => {
|
|
const rep = evaluateReachability(12345)
|
|
// Town transition is at top or left edge; peninsulas must not seal it off
|
|
expect(rep.denReachable).toBe(true)
|
|
expect(rep.exitReachable).toBe(true)
|
|
expect(rep.hasBisectingBarrier).toBe(false)
|
|
})
|
|
})
|
|
|
|
/* ======================================================================= *
|
|
* SUITE 5: River Bridge Crossing & Dirt Road Walkability
|
|
* ======================================================================= */
|
|
describe('Suite 5: River Bridge Crossing & Dirt Road Walkability', () => {
|
|
it('verifies river bridge creates unbroken walkable corridor connecting mainland to exit', () => {
|
|
const result: WildernessResult = generateWilderness({
|
|
levelId: 2,
|
|
levelName: 'Blood Moor',
|
|
levelTypeName: 'Act 1 - Wilderness',
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 0,
|
|
subTheme: 0,
|
|
seed: 0x12345678,
|
|
pieces: act1Pieces,
|
|
substitutions: act1Subs,
|
|
})
|
|
const scene = buildIsoMapScene(result.level, dt1s, levelSeed('bridge_test'))
|
|
const gridW = scene.gridWidth ?? result.level.width * SUB_TILES_PER_TILE
|
|
|
|
// River columns are x in [80..95] in tiles -> sub-tiles [400..479]
|
|
// Bridge is placed at bridgeRow. Find bridgeRow from stats.notes
|
|
const riverColStart = (result.level.width - 2) * 8
|
|
let bridgeCrossingWalkable = false
|
|
|
|
// Check all rows across river columns: at least one row must have a continuous walkable sub-tile path across the river
|
|
for (let y = 0; y < result.level.height; y += 1) {
|
|
let spanWalkable = true
|
|
for (let x = riverColStart; x < result.level.width; x += 1) {
|
|
const sy = y * SUB_TILES_PER_TILE + 2
|
|
const sx = x * SUB_TILES_PER_TILE + 2
|
|
if (scene.blocked[sy * gridW + sx] !== 0) {
|
|
spanWalkable = false
|
|
break
|
|
}
|
|
}
|
|
if (spanWalkable) {
|
|
bridgeCrossingWalkable = true
|
|
break
|
|
}
|
|
}
|
|
|
|
expect(bridgeCrossingWalkable).toBe(true)
|
|
})
|
|
})
|
|
})
|
|
|