702 lines
30 KiB
TypeScript
702 lines
30 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`. The 8 Act I wilderness levels are generated by the DRLG port
|
|
* (`src/game/drlg`) and verified by `tests/drlg-act1-oracle.test.ts` (bit-exact
|
|
* against native D2MOO) and `tests/drlg-act1-invariants.test.ts`, so they are
|
|
* skipped here. This script builds a generator request for each of the other 93
|
|
* 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 subTiles: readonly number[]
|
|
}
|
|
|
|
/**
|
|
* Slice a map into areas and find open sub-tiles 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 sub-tiles are the unblocked sub-tiles on
|
|
* visible floor cells — 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 walkable areas with their open sub-tiles.
|
|
*/
|
|
function walkableAreas(level: Ds1, scene: IsoMapScene, isWilderness = false): Area[] {
|
|
const areas: Area[] = []
|
|
const blocksX = Math.ceil(level.width / AREA_CELLS)
|
|
const blocksY = Math.ceil(level.height / AREA_CELLS)
|
|
const hasBorder = isWilderness && blocksX >= 3 && blocksY >= 3
|
|
for (let by = 0; by < blocksY; by += 1) {
|
|
for (let bx = 0; bx < blocksX; bx += 1) {
|
|
if (hasBorder && (bx === 0 || bx === blocksX - 1 || by === 0 || by === blocksY - 1)) {
|
|
continue
|
|
}
|
|
const subTiles: number[] = []
|
|
for (let cy = by * AREA_CELLS; cy < Math.min((by + 1) * AREA_CELLS, level.height); 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); cx += 1) {
|
|
const cellRecord = row[cx]
|
|
if (cellRecord === undefined) continue
|
|
let hasFloor = false
|
|
for (const floor of cellRecord.floors) {
|
|
if (!floor.hidden && floor.prop1 !== 0) { hasFloor = true; break }
|
|
}
|
|
if (!hasFloor) 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
|
|
if (gx >= scene.gridWidth || gy >= scene.gridHeight) continue
|
|
if (scene.blocked[gy * scene.gridWidth + gx] === 1) continue
|
|
subTiles.push(gy * scene.gridWidth + gx)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (subTiles.length > 0) areas.push({ key: by * blocksX + bx, subTiles })
|
|
}
|
|
}
|
|
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 on an active floor.
|
|
*
|
|
* Mirrors `findIsoSpawn`'s spiral, but returns a grid index because the fill
|
|
* works on the collision grid rather than on scene pixels.
|
|
*
|
|
* @param level - the map.
|
|
* @param scene - the built scene.
|
|
* @returns the sub-tile index, or -1 when nothing is walkable.
|
|
*/
|
|
function findOpenSubTile(level: Ds1, 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) continue
|
|
const cx = Math.floor(gx / SUB_TILES_PER_TILE)
|
|
const cy = Math.floor(gy / SUB_TILES_PER_TILE)
|
|
const c = level.cells[cy]?.[cx]
|
|
if (c !== undefined && c.floors.some(f => !f.hidden && f.prop1 !== 0)) {
|
|
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, isWilderness = false): { share: number; reached: number; total: number } {
|
|
const areas = walkableAreas(level, scene, isWilderness)
|
|
if (areas.length === 0) return { share: 0, reached: 0, total: 0 }
|
|
const start = findOpenSubTile(level, scene)
|
|
let bestReached = 0
|
|
if (start !== -1) {
|
|
const reachedTiles = floodFill(scene, start)
|
|
let reached = 0
|
|
for (const area of areas) {
|
|
if (area.subTiles.some(st => reachedTiles.has(st))) reached += 1
|
|
}
|
|
if (reached / areas.length >= MIN_REACHABLE_SHARE) {
|
|
return { share: reached / areas.length, reached, total: areas.length }
|
|
}
|
|
bestReached = reached
|
|
}
|
|
|
|
// If the centre-out point landed in an isolated decorative pocket,
|
|
// probe the areas to find the main connected component.
|
|
for (const area of areas) {
|
|
const candidate = area.subTiles[0]
|
|
if (candidate === undefined) continue
|
|
const reachedTiles = floodFill(scene, candidate)
|
|
let reached = 0
|
|
for (const a of areas) {
|
|
if (a.subTiles.some(st => reachedTiles.has(st))) reached += 1
|
|
}
|
|
if (reached > bestReached) {
|
|
bestReached = reached
|
|
if (bestReached / areas.length >= MIN_REACHABLE_SHARE) {
|
|
return { share: bestReached / areas.length, reached: bestReached, total: areas.length }
|
|
}
|
|
}
|
|
}
|
|
return { share: bestReached / areas.length, reached: bestReached, 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. Act I is not here: its outdoor
|
|
* levels are generated by the DRLG port (src/game/drlg), which this script does not drive (see
|
|
* `drlgPortLevels` below).
|
|
*/
|
|
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
|
'Act 2 - Desert': ['Act 2 - Desert'],
|
|
'Act 3 - Jungle': ['Act 3 - Jungle'],
|
|
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
|
|
'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'],
|
|
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'],
|
|
'Act 5 - Siege': ['Act 5 - Siege'],
|
|
'Act 5 - Barricade': ['Act 5 - Barricade'],
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
|
|
const levels = await rowDs1s(archives, lvlprest, row)
|
|
if (levels.length === 0) continue
|
|
const isBorder = /border|cliff/i.test(name)
|
|
pieces.push({ name, levels, border: isBorder })
|
|
}
|
|
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', 'src/game/preset.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 }[] = []
|
|
/** Act I outdoor levels: generated by the D2MOO port (src/game/drlg), not by the legacy generator. */
|
|
const drlgPortLevels: number[] = []
|
|
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' && cell(tables.levels, row, 'Act') === '0') drlgPortLevels.push(id)
|
|
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)`)
|
|
console.log(` Act I outdoor levels ${drlgPortLevels.join(', ')}: DRLG port, verified by tests/drlg-act1-oracle.test.ts and tests/drlg-act1-invariants.test.ts`)
|
|
|
|
let mazePassed = 0
|
|
let wildPassed = 0
|
|
|
|
const filterLevel = process.env.FILTER_LEVEL
|
|
|
|
console.log(`\n== maze levels (DrlgType 1) ==`)
|
|
console.log(' id name type sect rooms map hash reach missing')
|
|
for (const { id, name } of mazeLevels) {
|
|
if (filterLevel !== undefined && String(id) !== filterLevel) continue
|
|
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`)
|
|
const isSingleRoom = id === 61 || id === 114 || id === 116 || id === 119
|
|
const expectedMinRooms = isSingleRoom ? 1 : minRooms
|
|
check(Number(first.stats.roomsPlaced) >= expectedMinRooms, 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) {
|
|
if (filterLevel !== undefined && String(id) !== filterLevel) continue
|
|
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, true)
|
|
|
|
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)})`)
|
|
check(Number(first.stats.groundCells) === first.level.width * first.level.height, label, `ground floor coverage is incomplete: ${String(first.stats.groundCells)}/${String(first.level.width * first.level.height)}`)
|
|
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')
|