diablo2-web/scripts/verify-generators.ts

646 lines
28 KiB
TypeScript

/**
* Verify the generated levels for every non-preset level in the game.
*
* 70 levels are randomly generated mazes (`Levels.txt.DrlgType == 1`) and 31 are
* wilderness (`== 3`); the other 35 are presets and are covered by
* `verify-acts.ts`. This script builds a generator request for each of those 101
* levels straight from the shipped tables, generates it with a fixed seed, and
* asserts the four things that would otherwise only show up as a broken map in an
* asset pack:
*
* 1. **Determinism** — two runs of the same request must hash identically. The
* generators are seeded, so a mismatch means a stray `Math.random`, a `Map`
* iteration that depends on insertion order somewhere it should not, or state
* leaking between calls.
* 2. **Connectivity** — a flood fill from the level's spawn point must reach at
* least 90 % of the map's walkable areas. For a maze this is the whole claim of
* the generator: sections are only ever attached to a section already placed,
* so anything unreachable is a bug in the layout, not a design choice.
* 3. **Resolvability** — the level must build through `buildIsoMapScene` with the
* libraries `resolveLevelLibraries` returns, with under 1 % of tile references
* missing. A maze piece is only usable if the level type's DT1 libraries can
* actually draw it.
* 4. **Parameter fidelity** — the synthesized grid must respect the parameters it
* was built from: a maze's section size is `LvlMaze.SizeX/SizeY`, it places at
* least `LvlMaze.Rooms` sections, and a wilderness map covers exactly
* `floor(SizeX/8) x floor(SizeY/8)` blocks of 8 cells.
*
* Plus one hygiene check that needs no archives: the two generators must stay
* browser-safe, so neither may import a `node:` builtin, reference `Buffer`, or
* call `Math.random`.
*
* Usage:
* node scripts/verify-generators.ts [directory]
*/
import { readFileSync } 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 } 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 } from '../src/formats/dt1.ts'
import type { Dt1 } from '../src/formats/dt1.ts'
import { levelSeed, buildIsoMapScene, cellAt } from '../src/game/d2map.ts'
import type { IsoMapScene } from '../src/game/d2map.ts'
import { generateMaze, classifyMazePieceName, inferLevelTypeName } from '../src/game/maze.ts'
import type { MazePiece, MazePieceKind } from '../src/game/maze.ts'
import { generateWilderness, classifySubstitutionRole } from '../src/game/wilderness.ts'
import type { WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts'
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
/** Where the archives live by default. */
const dir = process.argv[2] ?? 'samples/d2'
/** Mount order: later archives override earlier ones, exactly as the game loads them. */
const MOUNTS = ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']
/** Blocks, in cells, used to slice a generated map into "areas" for the fill. */
const AREA_CELLS = 8
/** Walking must reach this share of the walkable areas. */
const MIN_REACHABLE_SHARE = 0.9
/** Missing tile references must stay under this share. */
const MAX_MISSING_SHARE = 0.01
/** Documented dimension bound for a synthesized map. */
const MAX_CELLS_PER_SIDE = 4096
let checks = 0
let failures = 0
const failureReasons: string[] = []
/**
* Record one assertion.
*
* @param ok - whether it held.
* @param level - the level label.
* @param message - what was checked.
*/
function check(ok: boolean, level: string, message: string): void {
checks += 1
if (!ok) {
failures += 1
failureReasons.push(`${level}: ${message}`)
}
}
/* ------------------------------------------------------------------------- *
* A stable hash
* ------------------------------------------------------------------------- */
/**
* FNV-1a over a stream of numbers.
*
* Used instead of serializing a whole map to a string because a 240x48 map has
* over a hundred thousand cells and building the text would cost more than the
* comparison is worth.
*/
class Hasher {
private hash = 2166136261
/**
* Mix one number in.
*
* @param value - the value.
* @returns this, for chaining.
*/
push(value: number): this {
let word = Math.trunc(value) | 0
for (let byte = 0; byte < 4; byte += 1) {
this.hash ^= (word >>> (byte * 8)) & 0xff
this.hash = Math.imul(this.hash, 16777619) >>> 0
}
return this
}
/**
* The digest.
*
* @returns eight hex digits.
*/
get digest(): string {
return (this.hash >>> 0).toString(16).padStart(8, '0')
}
}
/**
* Hash a decoded map into a canonical digest.
*
* Every field that could differ between two runs is folded in, including the
* layer counts and the objects' sub-tile coordinates, so two maps that look
* alike but stamp their objects differently hash differently.
*
* @param level - the map.
* @returns the digest.
*/
function canonicalHash(level: Ds1): string {
const hasher = new Hasher()
hasher.push(level.version).push(level.width).push(level.height).push(level.act)
hasher.push(level.substitutionType).push(level.wallLayers).push(level.floorLayers)
for (let y = 0; y < level.height; y += 1) {
const row = level.cells[y]
if (row === undefined) continue
for (let x = 0; x < level.width; x += 1) {
const cellRecord = row[x]
if (cellRecord === undefined) continue
for (const wall of cellRecord.walls) {
hasher.push(wall.prop1).push(wall.sequence).push(wall.style).push(wall.type).push(wall.hidden ? 1 : 0)
}
for (const floor of cellRecord.floors) {
hasher.push(floor.prop1).push(floor.sequence).push(floor.style).push(floor.hidden ? 1 : 0)
}
for (const shadow of cellRecord.shadows) hasher.push(shadow.prop1).push(shadow.sequence).push(shadow.style)
for (const substitution of cellRecord.substitutions) hasher.push(substitution.value)
}
}
for (const object of level.objects) hasher.push(object.type).push(object.id).push(object.x).push(object.y).push(object.flags)
return hasher.digest
}
/* ------------------------------------------------------------------------- *
* Connectivity
* ------------------------------------------------------------------------- */
/** A walkable area, keyed by its top-left cell. */
interface Area {
readonly key: number
readonly subTile: number
}
/**
* Slice a map into areas and find one open sub-tile per area.
*
* An area is an 8x8-cell block. A block counts only when it contains at least one
* cell that carries a floor, and its representative sub-tile is the first
* unblocked sub-tile in it — so a block that is entirely wall is neither expected
* to be reached nor counted against the generator.
*
* @param level - the map.
* @param scene - the built scene.
* @returns the representative sub-tile index of every walkable area.
*/
function walkableAreas(level: Ds1, scene: IsoMapScene): Area[] {
const areas: Area[] = []
const blocksX = Math.ceil(level.width / AREA_CELLS)
const blocksY = Math.ceil(level.height / AREA_CELLS)
for (let by = 0; by < blocksY; by += 1) {
for (let bx = 0; bx < blocksX; bx += 1) {
let hasFloor = false
let representative = -1
for (let cy = by * AREA_CELLS; cy < Math.min((by + 1) * AREA_CELLS, level.height) && representative === -1; cy += 1) {
const row = level.cells[cy]
if (row === undefined) continue
for (let cx = bx * AREA_CELLS; cx < Math.min((bx + 1) * AREA_CELLS, level.width) && representative === -1; cx += 1) {
const cellRecord = row[cx]
if (cellRecord === undefined) continue
for (const floor of cellRecord.floors) {
if (!floor.hidden && floor.prop1 !== 0) { hasFloor = true; break }
}
for (let sy = 0; sy < SUB_TILES_PER_TILE && representative === -1; 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
if (gx >= scene.gridWidth || gy >= scene.gridHeight) continue
if (scene.blocked[gy * scene.gridWidth + gx] === 1) continue
representative = gy * scene.gridWidth + gx
break
}
}
}
}
if (hasFloor && representative !== -1) areas.push({ key: by * blocksX + bx, subTile: representative })
}
}
return areas
}
/**
* Flood fill the scene's sub-tile grid from a starting sub-tile.
*
* @param scene - the built scene.
* @param start - the starting sub-tile index.
* @returns the set of reachable sub-tile indices.
*/
function floodFill(scene: IsoMapScene, start: number): Set<number> {
const seen = new Set<number>([start])
const queue = [start]
while (queue.length > 0) {
const at = queue.pop()!
const x = at % scene.gridWidth
const y = (at - x) / scene.gridWidth
const neighbours: readonly (readonly [number, number])[] = [[1, 0], [-1, 0], [0, 1], [0, -1]]
for (const [dx, dy] of neighbours) {
const nx = x + dx
const ny = y + dy
if (nx < 0 || ny < 0 || nx >= scene.gridWidth || ny >= scene.gridHeight) continue
const next = ny * scene.gridWidth + nx
if (scene.blocked[next] === 1 || seen.has(next)) continue
seen.add(next)
queue.push(next)
}
}
return seen
}
/**
* Find the sub-tile nearest the map's centre that is open.
*
* Mirrors `findIsoSpawn`'s spiral, but returns a grid index because the fill
* works on the collision grid rather than on scene pixels.
*
* @param scene - the built scene.
* @returns the sub-tile index, or -1 when nothing is walkable.
*/
function findOpenSubTile(scene: IsoMapScene): number {
const centreX = Math.floor(scene.cellsX / 2) * SUB_TILES_PER_TILE
const centreY = Math.floor(scene.cellsY / 2) * SUB_TILES_PER_TILE
const limit = Math.max(scene.gridWidth, scene.gridHeight)
for (let radius = 0; radius < limit; radius += 1) {
for (let dy = -radius; dy <= radius; dy += 1) {
for (let dx = -radius; dx <= radius; dx += 1) {
if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue
const gx = centreX + dx
const gy = centreY + dy
if (gx < 0 || gy < 0 || gx >= scene.gridWidth || gy >= scene.gridHeight) continue
const index = gy * scene.gridWidth + gx
if (scene.blocked[index] !== 1) return index
}
}
}
return -1
}
/**
* Run the connectivity check.
*
* @param level - the map.
* @param scene - the built scene.
* @returns the shares and counts, for printing.
*/
function connectivity(level: Ds1, scene: IsoMapScene): { share: number; reached: number; total: number } {
const areas = walkableAreas(level, scene)
const start = findOpenSubTile(scene)
if (start === -1 || areas.length === 0) return { share: 0, reached: 0, total: areas.length }
const reachedTiles = floodFill(scene, start)
let reached = 0
for (const area of areas) if (reachedTiles.has(area.subTile)) reached += 1
return { share: reached / areas.length, reached, total: areas.length }
}
/* ------------------------------------------------------------------------- *
* Table plumbing
* ------------------------------------------------------------------------- */
/** Decoded DS1s, keyed by member path, so a piece shared by many levels decodes once. */
const ds1Cache = new Map<string, Ds1>()
/**
* Decode a member once.
*
* @param archives - the mounted archives.
* @param relative - the tile-relative path from a table cell.
* @returns the map.
*/
async function loadDs1(archives: MountedArchives, 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
}
/**
* Decode a level type's DT1 libraries.
*
* @param archives - the mounted archives.
* @param names - full member paths, in file-list order.
* @returns the libraries.
*/
async function decodeLibraries(archives: MountedArchives, names: readonly string[]): Promise<Dt1[]> {
const libraries: Dt1[] = []
for (const name of names) libraries.push(decodeDt1(await archives.read(name)))
return libraries
}
/**
* Read a row's decoded `File1..File6` variants.
*
* @param archives - the mounted archives.
* @param table - the table the row belongs to.
* @param row - the row.
* @returns the decoded maps, in file order.
*/
async function rowDs1s(archives: 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(archives, value))
}
return levels
}
/**
* Read one of the five per-theme parameter groups.
*
* @param table - the `LvlSub` table.
* @param row - the row.
* @param prefix - `Prob`, `Trials` or `Max`.
* @returns the five values, in theme order.
*/
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
}
/* ------------------------------------------------------------------------- *
* Request builders
* ------------------------------------------------------------------------- */
/** Which `LvlPrest` name families belong to which wilderness level type. */
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 1 - Wilderness': ['Act 1 - Wild'],
'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 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'],
}
/**
* Build the maze pieces for a level type.
*
* @param archives - the mounted archives.
* @param lvlprest - the `LvlPrest` table.
* @param levelTypeName - the level type's name.
* @param levelTypeId - the level type's id.
* @returns the pieces.
*/
async function mazePieces(
archives: MountedArchives,
lvlprest: D2Table,
levelTypeName: string,
levelTypeId: string,
): Promise<MazePiece[]> {
const pieces: MazePiece[] = []
for (const row of lvlprest.rows) {
// Maze pieces are the rows whose level type matches; `LevelId` is 0 for all
// of them, so the join is by level type id plus the name prefix.
if (cell(lvlprest, row, 'LevelId') !== '0' && cell(lvlprest, row, 'LevelId') !== '') continue
const name = cell(lvlprest, row, 'Name')
const classified = classifyMazePieceName(name, levelTypeName)
if (classified === null) continue
const levels = await rowDs1s(archives, lvlprest, row)
if (levels.length === 0) continue
pieces.push({ name, kind: classified.kind satisfies MazePieceKind, sides: classified.sides, levels })
}
void levelTypeId
return pieces
}
/**
* Build the wilderness pieces for a level type.
*
* @param archives - the mounted archives.
* @param lvlprest - the `LvlPrest` table.
* @param levelTypeName - the level type's name.
* @returns the pieces.
*/
async function wildernessPieces(
archives: 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
const levels = await rowDs1s(archives, lvlprest, row)
if (levels.length === 0) continue
pieces.push({ name, levels, border: /border|cliff/i.test(name) })
}
return pieces
}
/**
* Build the wilderness substitutions for a level's `LvlSub` type.
*
* @param archives - the mounted archives.
* @param lvlsub - the `LvlSub` table.
* @param type - the `LvlSub` `Type` to select.
* @returns the rows.
*/
async function substitutions(archives: MountedArchives, lvlsub: D2Table, type: number): Promise<WildernessSubstitution[]> {
if (type < 0) return []
const rows: WildernessSubstitution[] = []
for (const row of lvlsub.rows) {
if (Number(cell(lvlsub, row, 'Type')) !== type) continue
const file = cell(lvlsub, row, 'File')
if (file === '' || file === '0') continue
const levels = [await loadDs1(archives, file)]
rows.push({
name: cell(lvlsub, row, 'Name'),
type,
gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1,
bordType: Number(cell(lvlsub, row, 'BordType')),
dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0,
prob: themeValues(lvlsub, row, 'Prob'),
trials: themeValues(lvlsub, row, 'Trials'),
max: themeValues(lvlsub, row, 'Max'),
levels,
})
}
return rows
}
/* ------------------------------------------------------------------------- *
* Hygiene
* ------------------------------------------------------------------------- */
/** The generators that must stay browser-safe. */
const BROWSER_SAFE_FILES = ['src/game/maze.ts', 'src/game/wilderness.ts']
/**
* Assert the generators import nothing Node-only.
*
* `src/` is bundled for the browser, so a `node:fs` import or a `Buffer`
* reference would only fail once the page was loaded. Catching it here is cheap.
*/
function checkHygiene(): void {
for (const path of BROWSER_SAFE_FILES) {
const source = readFileSync(path, 'utf8')
check(!/from\s+['"]node:/.test(source), path, 'imports a node: builtin')
check(!/\bBuffer\b/.test(source), path, 'references Buffer')
check(!/Math\.random/.test(source), path, 'calls Math.random')
check(!/\bprocess\.env\b/.test(source), path, 'reads process.env')
}
}
/* ------------------------------------------------------------------------- *
* Main
* ------------------------------------------------------------------------- */
checkHygiene()
const archives = new MountedArchives()
for (const name of MOUNTS) {
try {
archives.add(name, await MpqArchive.open(await fileSource(`${dir}/${name}`)))
} catch (err) {
console.log(`skip ${name}: ${String(err)}`)
}
}
if (archives.size === 0) {
console.log(`no archives found in ${dir}`)
process.exit(2)
}
const tables = await loadActTables(archives)
const lvlmaze = parseTable(await archives.read('data\\global\\excel\\LvlMaze.txt'))
const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
/** The level type row for a level row. */
function levelType(levelRow: readonly string[]): { id: string; name: string } {
const id = cell(tables.levels, levelRow, 'LevelType')
const row = tables.lvltypes.rows.find(candidate => cell(tables.lvltypes, candidate, 'Id') === id)
return { id, name: row === undefined ? '' : cell(tables.lvltypes, row, 'Name') }
}
/** The `LvlMaze` row for a level, by id then by name. */
function mazeRow(levelId: number, levelName: string): readonly string[] | undefined {
return lvlmaze.rows.find(row => Number(cell(lvlmaze, row, 'Level')) === levelId)
?? lvlmaze.rows.find(row => cell(lvlmaze, row, 'Name') === levelName)
}
const mazeLevels: { id: number; name: string }[] = []
const wildLevels: { id: number; name: string }[] = []
for (const row of tables.levels.rows) {
const drlg = cell(tables.levels, row, 'DrlgType')
const id = Number(cell(tables.levels, row, 'Id'))
const name = cell(tables.levels, row, 'Name')
if (drlg === '1') mazeLevels.push({ id, name })
else if (drlg === '3') wildLevels.push({ id, name })
}
console.log(`== generators ==`)
console.log(` ${String(mazeLevels.length)} maze levels (DrlgType 1), ${String(wildLevels.length)} wilderness levels (DrlgType 3)`)
let mazePassed = 0
let wildPassed = 0
console.log(`\n== maze levels (DrlgType 1) ==`)
console.log(' id name type sect rooms map hash reach missing')
for (const { id, name } of mazeLevels) {
const label = `${String(id)} ${name}`
try {
const row = mazeRow(id, name)
if (row === undefined) throw new Error('no LvlMaze.txt row')
const type = levelType(tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === id)!)
const sectionX = Number(cell(lvlmaze, row, 'SizeX'))
const sectionY = Number(cell(lvlmaze, row, 'SizeY'))
const minRooms = Number(cell(lvlmaze, row, 'Rooms'))
const merge = Number(cell(lvlmaze, row, 'Merge'))
const pieces = await mazePieces(archives, tables.lvlprest, type.name, type.id)
const seed = 0x5eed_0000 + id
const request = {
levelId: id, levelName: name, levelTypeName: type.name,
sectionSize: sectionX, sectionHeight: sectionY,
minRooms, merge, seed, pieces,
}
const first = generateMaze(request)
const second = generateMaze(request)
const hashA = canonicalHash(first.level)
const hashB = canonicalHash(second.level)
const libraries = resolveLevelLibraries(tables, id)
const scene = buildIsoMapScene(first.level, await decodeLibraries(archives, libraries.dt1Names), levelSeed(libraries.dt1Names[0] ?? "generated"))
const totalRefs = scene.floors.length + scene.walls.length + scene.missingTiles
const missingShare = totalRefs === 0 ? 1 : scene.missingTiles / totalRefs
const fill = connectivity(first.level, scene)
check(hashA === hashB, label, `not deterministic (${hashA} vs ${hashB})`)
check(pieces.length > 0, label, 'no pieces resolved')
check(first.level.width > 0 && first.level.height > 0, label, 'empty map')
check(first.level.width <= MAX_CELLS_PER_SIDE && first.level.height <= MAX_CELLS_PER_SIDE, label, `map ${String(first.level.width)}x${String(first.level.height)} exceeds the side bound`)
check(Number(first.stats.sectionX) === sectionX && Number(first.stats.sectionY) === sectionY, label, `section size ${String(first.stats.sectionX)}x${String(first.stats.sectionY)} != LvlMaze ${String(sectionX)}x${String(sectionY)}`)
check(Number(first.stats.roomsPlaced) >= minRooms, label, `placed ${String(first.stats.roomsPlaced)} sections, LvlMaze.Rooms is ${String(minRooms)}`)
check(missingShare <= MAX_MISSING_SHARE, label, `${(missingShare * 100).toFixed(2)}% of tile references are missing`)
check(fill.share >= MIN_REACHABLE_SHARE, label, `only ${(fill.share * 100).toFixed(1)}% of walkable areas are reachable (${String(fill.reached)}/${String(fill.total)})`)
if (hashA === hashB && fill.share >= MIN_REACHABLE_SHARE && missingShare <= MAX_MISSING_SHARE) mazePassed += 1
console.log(` ${String(id).padStart(3)} ${name.padEnd(29)} ${type.name.padEnd(20)} ${String(sectionX).padStart(4)} ${String(Number(first.stats.roomsPlaced)).padStart(6)} ${`${String(first.level.width)}x${String(first.level.height)}`.padEnd(10)} ${hashA} ${(fill.share * 100).toFixed(1).padStart(5)}% ${(missingShare * 100).toFixed(2).padStart(6)}%`)
} catch (err) {
check(false, label, `threw: ${err instanceof Error ? err.message : String(err)}`)
console.log(` ${String(id).padStart(3)} ${name.padEnd(29)} FAILED: ${err instanceof Error ? err.message : String(err)}`)
}
}
console.log(`\n== wilderness levels (DrlgType 3) ==`)
console.log(' id name type size blocks hash reach missing')
for (const { id, name } of wildLevels) {
const label = `${String(id)} ${name}`
try {
const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === id)!
const type = levelType(levelRow)
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, type.name)
const rows = await substitutions(archives, lvlsub, subType)
const shrineRows = await substitutions(archives, lvlsub, subShrine)
const seed = 0x5eed_1000 + id
const request = {
levelId: id, levelName: name, levelTypeName: type.name,
sizeX, sizeY, subType, subTheme: Math.max(0, subTheme), seed,
pieces, substitutions: rows, shrineSubstitutions: shrineRows,
}
const first = generateWilderness(request)
const second = generateWilderness(request)
const hashA = canonicalHash(first.level)
const hashB = canonicalHash(second.level)
const libraries = resolveLevelLibraries(tables, id)
const dt1s = await decodeLibraries(archives, libraries.dt1Names)
const scene = buildIsoMapScene(first.level, dt1s, levelSeed("generated"))
const totalRefs = scene.floors.length + scene.walls.length + scene.missingTiles
const missingShare = totalRefs === 0 ? 1 : scene.missingTiles / totalRefs
const fill = connectivity(first.level, scene)
const blockGrid = first.stats.blockGrid as { width: number; height: number }
const expectedWidth = Number(first.stats.sizeX)
const expectedHeight = Number(first.stats.sizeY)
const expectedBlocksX = Math.floor(expectedWidth / AREA_CELLS)
const expectedBlocksY = Math.floor(expectedHeight / AREA_CELLS)
check(hashA === hashB, label, `not deterministic (${hashA} vs ${hashB})`)
check(pieces.length > 0, label, 'no pieces resolved')
check(first.level.width > 0 && first.level.height > 0, label, 'empty map')
check(first.level.width <= MAX_CELLS_PER_SIDE && first.level.height <= MAX_CELLS_PER_SIDE, label, `map ${String(first.level.width)}x${String(first.level.height)} exceeds the side bound`)
check(blockGrid.width === expectedBlocksX && blockGrid.height === expectedBlocksY, label, `block grid ${String(blockGrid.width)}x${String(blockGrid.height)} != floor(size/8) ${String(expectedBlocksX)}x${String(expectedBlocksY)}`)
check(first.level.width === expectedBlocksX * AREA_CELLS && first.level.height === expectedBlocksY * AREA_CELLS, label, 'map extent does not match whole blocks of the declared size')
check(missingShare <= MAX_MISSING_SHARE, label, `${(missingShare * 100).toFixed(2)}% of tile references are missing`)
check(fill.share >= MIN_REACHABLE_SHARE, label, `only ${(fill.share * 100).toFixed(1)}% of walkable areas are reachable (${String(fill.reached)}/${String(fill.total)})`)
if (hashA === hashB && fill.share >= MIN_REACHABLE_SHARE && missingShare <= MAX_MISSING_SHARE) wildPassed += 1
console.log(` ${String(id).padStart(3)} ${name.padEnd(29)} ${type.name.padEnd(20)} ${`${String(first.stats.sizeX)}x${String(first.stats.sizeY)}`.padEnd(10)} ${`${String(blockGrid.width)}x${String(blockGrid.height)}`.padEnd(8)} ${hashA} ${(fill.share * 100).toFixed(1).padStart(5)}% ${(missingShare * 100).toFixed(2).padStart(6)}%`)
} catch (err) {
check(false, label, `threw: ${err instanceof Error ? err.message : String(err)}`)
console.log(` ${String(id).padStart(3)} ${name.padEnd(29)} FAILED: ${err instanceof Error ? err.message : String(err)}`)
}
}
console.log(`\n== summary ==`)
console.log(` maze ${String(mazePassed)}/${String(mazeLevels.length)} passed`)
console.log(` wilderness ${String(wildPassed)}/${String(wildLevels.length)} passed`)
console.log(` checks ${String(checks - failures)}/${String(checks)} assertions held`)
if (failures > 0) {
console.log(`\n ${String(failures)} failure(s):`)
for (const reason of failureReasons.slice(0, 60)) console.log(` - ${reason}`)
if (failureReasons.length > 60) console.log(` ... and ${String(failureReasons.length - 60)} more`)
process.exit(1)
}
console.log('\nall generator checks passed')