diablo2-web/tests/wilderness-reachability-str...

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)
})
})
})