/** * 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 { const seen = new Set([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() /** * 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 { 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 { 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 { 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> = { '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 { 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 { 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 { 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')