diablo2-web/src/game/maze.ts

2789 lines
109 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Diablo II's random-maze level generator (`Levels.txt.DrlgType == 1`).
*
* 70 of the game's 136 levels have no fixed layout: their shape is produced at
* load time from `LvlMaze.txt` (how many sections, how big each section is, and
* how eagerly neighbouring sections are joined) plus the level type's
* `LvlPrest.txt` *piece* rows — each piece a DS1 that knows which of its four
* sides has a doorway. This module rebuilds that generator and emits a single
* synthesized {@link Ds1} the existing isometric renderer can draw unmodified.
*
* ## Provenance
*
* This is an **independent TypeScript port** of the level-generation logic in
* **ThePhrozenKeep/D2MOO** ("Diablo II Method and Ordinal Overhaul", a C++
* re-implementation of Diablo II 1.10f). D2MOO ships no licence file, so no code
* was copied: every algorithm below was read from that project and re-expressed
* here in this repository's own structure and naming, with the source function
* cited at each step. The algorithms themselves are Blizzard's, and D2MOO's
* contribution is having recovered them from the shipped binary.
*
* Primary sources, all under `source/D2Common/` in D2MOO:
*
* - `src/Drlg/DrlgMaze.cpp` — `DRLGMAZE_GenerateLevel` (the per-level-type
* dispatch), `DRLGMAZE_AddAdjacentMazeRoom` + `DRLGMAZE_BuildBasicMaze` +
* `DRLGMAZE_MergeMazeRooms` (placement and the `Merge` roll),
* `DRLGMAZE_InitBasicMazeLayout` (the ring layout), `DRLGMAZE_PickRoomPreset`
* (side bitmask → piece), `DRLGMAZE_RollAct_1_2_3_BasicPresets` (the `Theme`
* pass), `DRLGMAZE_ScanReplaceSpecialPreset` (`Prev`/`Next`/quest replacement),
* `DRLGMAZE_PlaceArcaneSanctuary`, `DRLGMAZE_PlaceAct5LavaPresets`,
* `DRLGMAZE_FillBlankMazeSpaces`, `DRLGMAZE_PlaceAct2TombPrev_Act5BaalPrev`.
* - `src/Drlg/DrlgDrlgRoom.cpp` — `DRLG_ComputeManhattanDistance`,
* `DRLG_GetRectanglesManhattanDistanceAndCheckNotOverlapping`,
* `DRLGMAZE_CheckRoomNotOverlaping`, `DRLGROOM_AllocDrlgOrthsForRooms`.
* - `src/Drlg/DrlgDrlg.cpp` — `DRLG_GetDirectionFromCoordinates`,
* `DRLG_UpdateRoomExCoordinates`.
* - `src/Drlg/DrlgPreset.cpp` — `DRLGPRESET_InitPresetRoomGrids` and
* `DRLGPRESET_AddPresetUnitToDrlgMap`, which fix the stamping geometry and the
* unit of a DS1 object's coordinates.
* - `doc/Coordinates.md` — the coordinate systems.
*
* ## What D2MOO settles that this repository did not know
*
* **`LvlMaze.Merge` is a per-mille probability, not a flag.** Both
* `DRLGMAZE_AddAdjacentMazeRoom` (inline) and `DRLGMAZE_MergeMazeRooms` contain
* `if (SEED_RollRandomNumber(&i->pSeed) % 1000 < pLevel->pMaze->dwMerge)` before
* adding an extra doorway. So while a level grows, every *accidental* adjacency
* between the room just placed and some other already-placed room (orthogonally
* touching, not already connected) is joined with probability `Merge / 1000`.
* `Merge = 0` yields a tree (Act 1's cave treasures), `Merge = 500` joins about
* half of those contacts, and `Merge = 1000` (Act 2's Harem, Basement and the
* Arcane Sanctuary) joins all of them. The shipped values are 0, 500, 800 and
* 1000, which is why a per-mille reading is the only one that fits. The knowledge
* base's "possibly related to how adjacent ds1s are connected, but what the
* different values are for is unknown" is answered: it controls how many *loops*
* the maze has, i.e. how many alternate routes exist between rooms.
*
* **Connectivity is structural, not luck.** Growth only ever attaches a new room
* to an existing one and immediately records a doorway between them
* (`DRLGROOM_AllocDrlgOrthsForRooms`), so the doorway graph always contains a
* spanning tree rooted at the first room. `Merge` only ever *adds* doorways.
* {@link generateMaze} re-derives that with a flood fill and throws if it ever
* fails, so the guarantee is checked rather than asserted.
*
* **A maze piece is exactly one cell larger than its section.** `LvlMaze.SizeX`
* is the placement stride (24 in Act 1) while the piece DS1 is 25×25 — verified
* against the shipped archives: `Act1/Caves/CaveW.ds1` is 25×25 with
* `LvlPrest.SizeX = 24`. `DRLGPRESET_InitPresetRoomGrids` confirms the intent: it
* fills a room grid of `nTileWidth + 1` by `nTileHeight + 1` cells from the
* piece, so adjacent pieces overlap by exactly one cell and the later-stamped
* piece owns the shared border. Both halves of that border are authored to match,
* so the overwrite is harmless. The repository's assumption was correct.
*
* **Objects are in sub-tiles, cells are in cells.**
* `DRLGPRESET_AddPresetUnitToDrlgMap` converts the room's *tile* origin with
* `DUNGEON_GameTileToSubtileCoords` (×5) and *then* adds the DS1 object's own
* coordinates, which are therefore already sub-tiles relative to the piece. A
* stamp offset of `N` cells shifts an object by `N * 5` sub-tiles, exactly as
* this repository assumed.
*/
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.ts'
import type { D2Table } from './acts.ts'
import { Rng } from './rng.ts'
import { SUB_TILES_PER_TILE } from './map.ts'
export { SUB_TILES_PER_TILE }
import { type SpecialPassName, runMazeSpecialPasses } from './maze-special-passes.ts'
import {
populateDungeonObjects,
type PlacedMazeObject,
type MazeObjectPopulationOptions,
type MazeRoomDescriptor,
} from './maze-objects.ts'
export type {
PlacedMazeObject,
MazeObjectPopulationOptions,
MazeObjectCategory,
ShrineSubType,
ChestSubType,
RackSubType,
ContainerSubType,
WallAlignment,
} from './maze-objects.ts'
/* ------------------------------------------------------------------------- *
* Directions
* ------------------------------------------------------------------------- */
/**
* Direction indices, in D2MOO's `D2AltDirections` order.
*
* `DRLG_GetDirectionFromCoordinates` returns the `D2Directions` *names* but the
* same numbers, and D2MOO relies on that coincidence: `DIRECTION_SOUTHWEST` is 0
* and `ALTDIR_WEST` is 0, `DIRECTION_NORTHWEST`/`ALTDIR_NORTH` are 1,
* `DIRECTION_SOUTHEAST`/`ALTDIR_EAST` are 2 and `DIRECTION_NORTHEAST`/`ALTDIR_SOUTH`
* are 3. Both are reproduced here as one space, since a maze direction is always
* an offset on the cell grid.
*
* The signs are the ones `doc/Coordinates.md` states: East is +X, West is -X,
* South is +Y, North is -Y. Note that D2's *names* for the quarter directions do
* not mean screen directions — `DIRECTION_NORTHEAST` is +Y, i.e. towards the
* bottom-left of the isometric view. Only the numbers are meaningful.
*/
export const DIRECTION_WEST = 0
/** North is -Y in game coordinates. */
export const DIRECTION_NORTH = 1
/** East is +X in game coordinates. */
export const DIRECTION_EAST = 2
/** South is +Y in game coordinates. */
export const DIRECTION_SOUTH = 3
/**
* The side bit `DRLGMAZE_PickRoomPreset` sets for each direction.
*
* It ORs 1 for direction 0, 8 for 1, 2 for 2 and 4 for 3, so the resulting 4-bit
* value is **W=1, E=2, S=4, N=8**. That looks arbitrary until it is compared with
* `LvlPrest.txt`: side tokens are written with their letters in N,S,E,W order and
* read as a bitfield most-significant-bit first, so `NS` = 8+4 = 12, `EW` = 2+1 =
* 3 and `NSEW` = 15. Verified against the real table — `Act 1 - Cave
* W/E/EW/S/SW/SE/SEW/N/NW/NE/NEW/NS/NSW/NSE/NSEW` are fifteen consecutive rows
* following the level type's base row, so a piece's row number is `base + mask`.
*/
const DIRECTION_SIDE_BIT = [1, 8, 2, 4] as const
/**
* Cell offset for one step in a direction.
*
* Directions 4..7 are the diagonals, which only `DRLGMAZE_FillBlankMazeSpaces`
* uses (it tries all eight). Quarters and diagonals were cross-checked against
* `DRLGMAZE_LinkMazeRooms`' eight `case`s.
*
* @param direction - 0..7.
* @param width - one section's width in cells.
* @param height - one section's height in cells.
* @returns the offset.
*/
function step(direction: number, width: number, height: number): { dx: number; dy: number } {
const dx = (direction === DIRECTION_WEST || direction === 4 || direction === 7 ? -width
: direction === DIRECTION_EAST || direction === 5 || direction === 6 ? width : 0)
const dy = (direction === DIRECTION_NORTH || direction === 4 || direction === 5 ? -height
: direction === DIRECTION_SOUTH || direction === 6 || direction === 7 ? height : 0)
return { dx, dy }
}
/* ------------------------------------------------------------------------- *
* Side tokens
* ------------------------------------------------------------------------- */
/** The four side letters, in the order `LvlPrest.txt` writes them. */
const SIDE_LETTERS = ['N', 'S', 'E', 'W'] as const
/**
* Decode a side token such as `NSEW`, `EW` or `N` into a bitmask.
*
* @param token - the token; characters other than `NSEW` are ignored, so a whole
* piece name may be passed.
* @returns the mask, 0..15.
*/
export function sideMaskFromToken(token: string): number {
let mask = 0
for (const letter of token.toUpperCase()) {
const index = SIDE_LETTERS.indexOf(letter as (typeof SIDE_LETTERS)[number])
if (index !== -1) mask |= 1 << (3 - index)
}
return mask
}
/**
* Encode a bitmask the way `LvlPrest.txt` spells it.
*
* @param mask - the mask, 0..15.
* @returns the token, e.g. `NSEW`; an empty string for 0.
*/
export function sideTokenFromMask(mask: number): string {
let token = ''
SIDE_LETTERS.forEach((letter, index) => {
if ((mask & (1 << (3 - index))) !== 0) token += letter
})
return token
}
/* ------------------------------------------------------------------------- *
* Public request / response shapes
* ------------------------------------------------------------------------- */
/** How a maze piece participates in generation. */
export type MazePieceKind =
| 'entrance'
| 'room'
| 'theme'
| 'prev'
| 'next'
| 'down'
| 'quest'
| 'treasure'
| 'waypoint'
/**
* A waypoint room, as reported in `stats.waypoints`.
*/
export interface MazeWaypoint {
/** The room the piece went into, matching `specialsApplied`. */
readonly room: number
/** The room's top-left corner in the final map, in cells. */
readonly x: number
readonly y: number
/** The room's extent, in cells. */
readonly width: number
readonly height: number
/** The room's centre, where the player lands or clicks. */
readonly centreX: number
readonly centreY: number
/** Sub-tile coordinates of the waypoint entity within the synthesized map. */
readonly entityX: number
readonly entityY: number
}
/**
* One `LvlPrest.txt` row that can be stamped into a maze level.
*
* The caller owns the classification, because it is the only party that knows
* which DS1 files sit behind each row. {@link classifyMazePieceName} does that
* classification for callers that want it, since the trailing-token grammar is
* subtle enough to be worth centralising.
*/
export interface MazePiece {
/** `LvlPrest.txt` `Name`, e.g. `Act 1 - Cave NSEW`. */
readonly name: string
/** Which generator pass may place this piece. */
readonly kind: MazePieceKind
/**
* The side token decoded from the name — `NSEW`, `EW`, `N`, or `''` for a piece
* with no doorways (`Entrance`).
*/
readonly sides: string
/**
* Decoded variants, in `File1..File6` order.
*
* A piece usually has several: `Act 1 - Cave W` ships `CaveW.ds1` and
* `CaveW2.ds1`. D2MOO walks them round-robin per piece so consecutive rooms of
* the same shape do not look identical (`DRLGMAZE_RollBasicPresets`), and
* {@link generateMaze} does the same.
*/
readonly levels: readonly Ds1[]
/**
* `LvlPrest.txt` `KillEdge`.
*
* 1.13c reads this off the maze map's `LvlPrest` record and, when set, trims
* the one-cell overhang off whichever room sits on the map's outer edge — see
* {@link stamp} and `DRLGPRESET_InitPresetRoomGrids`
* (`DrlgPreset.cpp:1105`). The shipped table sets it per *family*, not per
* row: `Act 1 - Cave`, `Act 2 - Sewer`, `Act 2 - Arcane`, `Act 4 - Mesa`,
* `Act 5 - Temple` and `Act 5 - Ice` are all `1`, while `Act 1 - Crypt`,
* `Barracks`, `Jail`, `Catacombs`, `Act 2 - Basement`, `Act 2 - Tomb` and
* `Act 3 - Sewer` are all `0`.
*
* Optional so that callers which do not read the column keep the pre-1.13c
* behaviour of never trimming.
*/
readonly killEdge?: boolean
/**
* Animated tile playback speed from `LvlPrest.txt` `Animate` column.
* In 1.13c, 0 defaults to 80 (128ms per frame at 25Hz).
*/
readonly animSpeed?: number
}
/** Everything {@link generateMaze} needs. */
export interface MazeRequest {
/** `Levels.txt` `Id`; used in diagnostics and for a few per-level specials. */
readonly levelId: number
/** `Levels.txt` `Name`, e.g. `Act 1 - Cave 1`. */
readonly levelName: string
/** `LvlMaze.txt` `SizeX`: the section stride in cells. */
readonly sectionSize: number
/**
* `LvlMaze.txt` `SizeY`.
*
* Defaults to {@link sectionSize}. It is a separate field because the two
* genuinely differ for Act 1's Barracks (`SizeX = 10`, `SizeY = 14`), and a
* single number would silently produce a wrong layout there.
*/
readonly sectionHeight?: number
/** `LvlMaze.txt` `Rooms`: the target number of sections. */
readonly minRooms: number
/**
* `LvlMaze.txt` `Merge`, in per-mille.
*
* Values in the shipped table are 0, 500, 800 and 1000.
*/
readonly merge: number
/** Seed for the whole generator; reported back in `stats`. */
readonly seed: number
/** The level type's pieces, classified by kind and side token. */
readonly pieces: readonly MazePiece[]
/**
* Overrides the `KillEdge` value derived from {@link pieces}.
*
* 1.13c takes the flag from the maze map's own `LvlPrest` record
* (`pMazeMap->pLvlPrestTxtRecord->dwKillEdge`), which in the shipped table
* always agrees with the level type's piece rows. When this is omitted the
* value is derived from the pieces instead, so a caller that already parsed
* `LvlPrest.txt` needs to do nothing extra.
*/
readonly killEdge?: boolean
/**
* `LvlTypes.txt` name, e.g. `Act 1 - Cave`.
*
* Optional because it is derivable from {@link pieces} — every piece name
* starts with it — and the derivation is attempted when it is absent.
*/
readonly levelTypeName?: string
/**
* `Levels.txt` `Id` of the tomb holding the Horadric Staff, when the caller
* knows it. In the shipped game this is one of the seven Tal Rasha tombs and
* that tomb's `LvlMaze.Rooms` is tripled.
*/
readonly staffTombLevelId?: number
/** Boss tomb (`LvlMaze.Rooms` doubled); see {@link staffTombLevelId}. */
readonly bossTombLevelId?: number
/**
* Explicit override for waypoint placement.
* If true, forces a waypoint room to be placed (using waypoint pieces).
* If false, skips waypoint room placement even if the level profile includes it.
* If omitted, defaults to whether the level id has a waypoint in the profile.
*/
readonly hasWaypoint?: boolean
/**
* Maximum allowed width of the entire maze in cells (`Levels.txt` `SizeX`).
* When specified, any room placement that would expand the overall maze bounding box
* beyond this width will be rejected.
*/
readonly maxCellsX?: number | undefined
/**
* Maximum allowed height of the entire maze in cells (`Levels.txt` `SizeY`).
* When specified, any room placement that would expand the overall maze bounding box
* beyond this height will be rejected.
*/
readonly maxCellsY?: number | undefined
/**
* Maximum allowed sections along X axis.
* If provided and `maxCellsX` is not specified, `maxCellsX` is computed as `maxSectionsX * sectionSize`.
*/
readonly maxSectionsX?: number | undefined
/**
* Maximum allowed sections along Y axis.
* If provided and `maxCellsY` is not specified, `maxCellsY` is computed as `maxSectionsY * (sectionHeight ?? sectionSize)`.
*/
readonly maxSectionsY?: number | undefined
/** Dynamic dungeon object seeding options. */
readonly objectOptions?: MazeObjectPopulationOptions
/** Configurable object density multiplier or options. */
readonly objectDensity?: number | MazeObjectPopulationOptions
}
/** The generated level. */
export interface MazeResult {
/** A single synthesized map covering every placed section. */
readonly level: Ds1
/** Dynamically seeded interactive objects (shrines, chests, racks, breakables). */
readonly objects: readonly PlacedMazeObject[]
/** What the generator did, for reporting and tests. */
readonly stats: Record<string, unknown>
/** Animated tile playback speed from `LvlPrest.txt` `Animate` column. */
readonly animSpeed?: number
}
/* ------------------------------------------------------------------------- *
* Classification
* ------------------------------------------------------------------------- */
/** Roles whose names are spelled `<role> <sides>` after the level type. */
const KIND_PREFIXES: readonly (readonly [string, MazePieceKind])[] = [
['Den Of Evil', 'quest'],
['Coldcrow', 'quest'],
['Pitspawn', 'quest'],
['Bonebreak', 'quest'],
['Chest', 'treasure'],
['Treasure', 'treasure'],
['Cath', 'down'],
['Drain', 'next'],
['Warp', 'prev'],
['Down', 'down'],
['Next', 'next'],
['Prev', 'prev'],
['Theme', 'theme'],
['Waypoint', 'waypoint'],
['waypoint', 'waypoint'],
['Talrasha', 'quest'],
["Radament's Lair", 'quest'],
['Radament', 'quest'],
['Tight Spot', 'quest'],
['Forge', 'quest'],
['Court Connect', 'next'],
['Bridge', 'next'],
['Complex', 'quest'],
['Final Room', 'quest'],
['Cube', 'treasure'],
['Leatherarm', 'quest'],
['Kaa', 'quest'],
]
/**
* Relationship between Levels.txt, LvlTypes.txt, and LvlPrest.txt:
*
* In Diablo II's data architecture, map pieces and environments are resolved
* across three interconnected tables:
*
* 1. `Levels.txt` contains level declarations. Each row has an `Id` (1..136) and
* a `LevelType` foreign key pointing to `LvlTypes.txt` (or 0 for preset-only
* levels like towns).
* 2. `LvlTypes.txt` defines visual and audio themes (e.g. `Act 1 - Cave`,
* `Act 1 - Catacombs`, `Act 2 - Harem`, `Act 3 - Kurast`). It specifies up to
* 32 DT1 tile libraries (`File 1` .. `File 32`) containing the graphic art.
* 3. `LvlPrest.txt` catalogs the authored DS1 map presets and maze room pieces.
* Each row contains `Def` (the preset ID), `Name`, `LevelId`, `Dt1Mask`,
* `SizeX`, `SizeY`, and up to 6 DS1 file paths (`File1` .. `File6`).
*
* ### Why Catacombs / Sewers index pieces via `Def` / `LevelId = 0` / first `LevelId`:
*
* - Preset levels (like Towns, Tristram, Countess Tower) have fixed layouts and
* their rows in `LvlPrest.txt` set `LevelId` to their exact `Levels.txt.Id`
* (e.g., Rogue Encampment has `LevelId = 1`, Lut Gholein has `LevelId = 40`).
* - Maze levels (Catacombs 1-4 [Levels 32-35], Sewers Act 2 [Levels 41-43],
* Sewers Act 3 [Levels 92-93], Caves 1-2 [Levels 2-3]), in contrast, are generated
* dynamically by stitching dozens of modular 25x25 cell DS1 rooms (with 1..4 doors,
* stairs up/down, waypoints, quest rooms, themed variants).
* - Blizzard North did not duplicate 30+ room rows for each level layer in
* `LvlPrest.txt`. Instead:
* - Room pieces are shared across all levels sharing the theme, stored with
* `LevelId = 0` or set to the first `LevelId` where that theme appears (e.g. 32
* for Catacombs).
* - In D2MOO and the native Blizzard engine (`DRLGMAZE_PickRoomPreset`,
* `DRLGMAZE_RollBasicPresets`, `DRLGMAZE_ScanReplaceSpecialPreset`), pieces
* are indexed either by hardcoded `Def` base offsets (`base + door_bitmask`)
* or discovered by prefix matching against the piece's `Name` column.
*
* ### Why composite themes have sub-families:
*
* Certain levels share a primary tile theme (`LevelType`) but introduce distinct
* visual zones, boss chambers, or quest rooms authored under different `Name`
* prefixes in `LvlPrest.txt`:
* - `Act 2 - Harem`: Combines normal palace rooms (`Act 2 - Harem ...`) with
* corrupted harem cellar and basement pieces (`Act 2 - Corrupt Harem ...`).
* - `Act 3 - Kurast`: Standard Kurast dungeon rooms (`Act 3 - Kurast ...`) are
* supplemented in Durance of Hate Level 3 with Mephisto's sanctum pieces
* (`Act 3 - Mephisto ...`).
* - `Act 3 - Dungeon`: Jungle dungeon rooms (`Act 3 - Dungeon ...`) incorporate
* temple quest chambers (`Act 3 - Temple ...`).
* - `Act 4 - Lava`: River of Flame and Chaos Sanctuary lava island pieces
* (`Act 4 - Lava ...`) are linked by preset bridge pieces (`Act 4 - Bridge ...`).
* - `Act 5 - Ice Caves`: Contains both full-name (`Act 5 - Ice Caves ...`) and
* abbreviated (`Act 5 - Ice ...`) piece definitions in `LvlPrest.txt`.
* - `Act 5 - Baal`: Worldstone Keep pieces (`Act 5 - Baal ...`) terminate in the
* Throne Room of Destruction (`Act 5 - ThroneRoom ...`).
*
* Because `LvlPrest.txt` lacks a `LevelType` column, these sub-families cannot be
* joined by database foreign key alone; they reflect Blizzard's C++ DRLG engine
* conventions and are captured canonically in {@link MAZE_PIECE_FAMILIES}.
*/
export const MAZE_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 1 - Cave': ['Act 1 - Cave'],
'Act 1 - Crypt': ['Act 1 - Crypt'],
'Act 1 - Courtyard': ['Act 1 - Courtyard'],
'Act 1 - Barracks': ['Act 1 - Barracks'],
'Act 1 - Jail': ['Act 1 - Jail'],
'Act 1 - Catacombs': ['Act 1 - Catacombs'],
'Act 2 - Sewer': ['Act 2 - Sewer'],
'Act 2 - Harem': ['Act 2 - Corrupt Harem', 'Act 2 - Harem'],
'Act 2 - Basement': ['Act 2 - Basement'],
'Act 2 - Tomb': ['Act 2 - Tomb'],
'Act 2 - Lair': ['Act 2 - Lair'],
'Act 2 - Sanctuary': ['Act 2 - Sanctuary'],
'Act 3 - Sewer': ['Act 3 - Sewer'],
'Act 3 - Kurast': ['Act 3 - Mephisto', 'Act 3 - Kurast'],
'Act 3 - Dungeon': ['Act 3 - Dungeon', 'Act 3 - Temple'],
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Bridge'],
'Act 5 - Ice Caves': ['Act 5 - Ice Caves', 'Act 5 - Ice'],
'Act 5 - Temple': ['Act 5 - Temple'],
'Act 5 - Baal': ['Act 5 - Baal', 'Act 5 - ThroneRoom'],
}
/**
* Discover the `LvlPrest` name families belonging to a maze level type.
*
* If `lvlPrestTable` is supplied, validates and discovers family prefixes present
* in the table that correspond to the given `levelTypeName`.
* If `lvlPrestTable` is omitted or contains no matching family rows, falls back to
* {@link MAZE_PIECE_FAMILIES}.
*
* @param levelTypeName - the owning `LvlTypes.txt` name (e.g. `'Act 2 - Harem'`).
* @param lvlPrestTable - optional loaded `LvlPrest.txt` table.
* @returns list of family name prefixes in precedence order (longer/more specific prefixes first).
*/
export function discoverMazePieceFamilies(
levelTypeName: string,
lvlPrestTable?: D2Table,
): readonly string[] {
const staticFamilies = MAZE_PIECE_FAMILIES[levelTypeName] ?? [levelTypeName]
if (lvlPrestTable === undefined) {
return staticFamilies
}
const nameCol = lvlPrestTable.header.indexOf('Name')
if (nameCol === -1) {
return staticFamilies
}
const discovered = new Set<string>()
for (const fam of staticFamilies) {
const lowerFam = fam.toLowerCase()
for (const row of lvlPrestTable.rows) {
const rowName = (row[nameCol] ?? '').trim().toLowerCase()
if (rowName.startsWith(lowerFam)) {
discovered.add(fam)
break
}
}
}
if (discovered.size === 0) {
return staticFamilies
}
const result = staticFamilies.filter(fam => discovered.has(fam))
return result.length > 0 ? result : staticFamilies
}
/**
* Classify one `LvlPrest.txt` piece name.
*
* The grammar is `<level type name> [<role>] [<sides>]`: `Act 1 - Cave NSEW` is a
* plain room, `Act 1 - Cave Theme NS` a themed variant, `Act 1 - Cave Prev W` a
* staircase, `Act 1 - Cave Treasure 3` a numbered treasure room. A name that does
* not start with the level type's name is not a piece of that type and yields
* `null`, which is how `Act 1 - DOE Entrance` stays out of the `Act 1 - Cave`
* group even though D2MOO treats its row as that group's base.
*
* @param name - the `LvlPrest.txt` `Name`.
* @param levelTypeName - the owning `LvlTypes.txt` name.
* @param lvlPrestTable - optional loaded `LvlPrest.txt` table for dynamic family discovery.
* @returns the kind and side token, or `null` when the name is not a piece.
*/
export function classifyMazePieceName(
name: string,
levelTypeName: string,
lvlPrestTable?: D2Table,
): { kind: MazePieceKind; sides: string } | null {
const families = discoverMazePieceFamilies(levelTypeName, lvlPrestTable)
const matchedFamily = families.find(family => name.toLowerCase().startsWith(family.toLowerCase()))
if (matchedFamily === undefined) return null
const rest = name.slice(matchedFamily.length).trim()
if (rest === '' || rest.toLowerCase() === 'entrance') {
if (matchedFamily.toLowerCase().endsWith('throneroom')) return { kind: 'quest', sides: '' }
return { kind: 'entrance', sides: '' }
}
for (const [prefix, kind] of KIND_PREFIXES) {
if (rest.toLowerCase().startsWith(prefix.toLowerCase())) {
const tail = rest.slice(prefix.length).trim()
// `Treasure 1`, `Temple 1`, `Bridge 1` are numbered rather than sided; everything else uses sides.
if ((kind === 'treasure' || kind === 'quest' || kind === 'next') && /^\d+$/.test(tail)) {
return { kind, sides: '' }
}
return { kind, sides: tail }
}
}
if (/^\d+$/.test(rest)) {
if (matchedFamily.toLowerCase().endsWith('bridge')) return { kind: 'next', sides: '' }
if (matchedFamily.toLowerCase().endsWith('temple')) return { kind: 'quest', sides: '' }
if (matchedFamily.toLowerCase().endsWith('treasure')) return { kind: 'treasure', sides: '' }
}
const reversedMatch = /^([NSEW]+)\s+(up|down|waypoint)$/i.exec(rest)
if (reversedMatch !== null) {
const sides = reversedMatch[1]!.toUpperCase()
const role = reversedMatch[2]!.toLowerCase()
return { kind: role === 'up' ? 'prev' : role === 'down' ? 'next' : 'waypoint', sides }
}
// Anything left is a bare side token: `W`, `EW`, `NSEW`, or `X` (blank filler)
if (/^([NSEW]+|X)$/i.test(rest)) return { kind: 'room', sides: rest.toUpperCase() === 'X' ? '' : rest.toUpperCase() }
if (/^(River|Pool|Tainted Sun)/i.test(rest)) return { kind: 'room', sides: '' }
return null
}
/**
* Derive a level type's name from its pieces.
*
* Used when {@link MazeRequest.levelTypeName} is omitted: the name is the longest
* prefix shared by every piece name, trimmed back to a word boundary, because
* `Act 1 - Cave NSEW` and `Act 1 - Cave Theme W` agree on `Act 1 - Cave ` and the
* trailing space is the boundary.
*
* @param pieces - the level's pieces.
* @returns the inferred name, or `null` when the pieces disagree.
*/
export function inferLevelTypeName(pieces: readonly MazePiece[]): string | null {
const first = pieces[0]
if (first === undefined) return null
let prefix = first.name
for (const piece of pieces) {
while (prefix.length > 0 && !piece.name.startsWith(prefix)) prefix = prefix.slice(0, -1)
}
const trimmed = prefix.replace(/[\s-]+$/, '')
if (trimmed === '') return null
if (trimmed === 'Act 5 - Ice') return 'Act 5 - Ice Caves'
return trimmed
}
/* ------------------------------------------------------------------------- *
* Level-type profiles
* ------------------------------------------------------------------------- */
/**
* One `DRLGMAZE_ScanReplaceSpecialPreset` call site.
*
* That function has two modes and both are reproduced: it first looks for the
* earliest room still holding the *plain* piece for this side pattern and swaps
* the piece in place; only if no such room exists does it grow a brand-new room
* off the first available room in `direction` and put the special piece there.
*
* `DRLGMAZE_ReplaceRoomPreset`, used by Act 3's Sewer 1, is the in-place-only
* variant and is modelled with {@link inPlaceOnly}.
*/
interface SpecialStep {
/** Which kind of special piece to place. */
readonly kind: 'prev' | 'next' | 'down' | 'quest' | 'treasure' | 'waypoint'
/** Side token of the plain room this step replaces, e.g. `N`. */
readonly plainSides: string
/** Side token of the special piece. */
readonly sides: string
/** Direction used when a new room must be created (`nDirection`). */
readonly direction: number
/** Only replace an existing room; never grow one (D2MOO's `ReplaceRoomPreset`). */
readonly inPlaceOnly?: boolean
}
/**
* A group of four candidate steps selected by one rotating counter.
*
* D2MOO draws `nRand = SEED_RollRandomNumber(&pLevel->pSeed) & 3` once and then
* indexes each hardcoded table with it, incrementing the counter after every
* call that actually happens. So the *n*-th group applied to a level uses entry
* `(nRand + n) % 4`, not the first entry. Modelling the tables as groups with a
* shared cursor reproduces that exactly; flattening them into one ordered list
* would not.
*/
interface SpecialGroup {
readonly kind: SpecialStep['kind']
/** Four entries, in D2MOO's table order (N, E, S, W). */
readonly entries: readonly Omit<SpecialStep, 'kind'>[]
/** Restrict the group to these `Levels.txt` ids; absent means every level. */
readonly levelIds?: readonly number[]
/** Apply to every level *except* these ids (D2MOO's `else` arms). */
readonly exceptLevelIds?: readonly number[]
/** Only replace; never grow (D2MOO's `ReplaceRoomPreset`). */
readonly inPlaceOnly?: boolean
}
/**
* How one maze level type is laid out.
*
* D2MOO's `DRLGMAZE_GenerateLevel` `switch (pLevel->nLevelType)` turned into
* data. Where that function has both a `while (pLevel->nRooms < nRooms)` loop and
* a `DRLGMAZE_BuildBasicMaze` call, the two are the same growth primitive spelled
* twice — see {@link growMaze} — so both are represented by `grow: true`.
*/
export interface MazeLevelProfile {
/** `LvlTypes.txt` name this profile applies to. */
readonly levelTypeName: string
/**
* `DRLGMAZE_InitBasicMazeLayout(nRoomsPerDirection)`: pre-place a closed ring of
* `4n - 4` rooms, then grow from it. Act 3's Sewer 1 overrides this with 5.
*/
readonly ring?: number
/** Grow to `LvlMaze.Rooms` sections with the random-adjacency loop. */
readonly grow: boolean
/**
* `DRLGMAZE_RollAct_1_2_3_BasicPresets`: convert up to
* `max(rooms / 5 + 1, 2)` plain rooms to their `Theme` variant.
*/
readonly theme: boolean
/** `'catacombs'`: the Act 1 Catacombs opening moves. */
readonly opening?: 'catacombs'
/** Chain of three rooms plus a `Prev` piece on the entrance. */
readonly prevChain?: 'tomb' | 'baal'
/** `DRLGMAZE_PlaceArcaneSanctuary`: four 15-room spiral branches. */
readonly arcane?: boolean
/** `DRLGMAZE_PlaceAct5LavaPresets`: two fixed pieces plus blank filling. */
readonly lavaPairs?: boolean
/**
* Levels that are a single fixed `LvlPrest.txt` row rather than a maze, with
* the exact names to try, in order. Used for the three Act 5 ice set pieces and
* Claw Viper Temple level 2.
*/
readonly singleRoom?: Readonly<Record<number, readonly string[]>>
/** `Prev`/`Next`/quest/treasure replacement groups, in call order. */
readonly specials: readonly SpecialGroup[]
/** Optional official dungeon special pass from D2MOO. */
readonly specialPass?: SpecialPassName
/** Animated tile speed override from LvlPrest.txt (default 80 if 0 or omitted). */
readonly animSpeed?: number
}
/** The four single-side layouts, in the order the D2MOO tables list them. */
const NESW = ['N', 'E', 'S', 'W'] as const
/** Direction paired with each of {@link NESW} in the D2MOO tables. */
const NESW_DIRECTIONS = [DIRECTION_SOUTH, DIRECTION_WEST, DIRECTION_NORTH, DIRECTION_EAST] as const
/**
* Build the four side entries of a D2MOO table.
*
* @param plainSides - the four plain side tokens, in table order.
* @returns the entries.
*/
function entries(plainSides: readonly string[] = NESW): Omit<SpecialStep, 'kind'>[] {
return plainSides.map((sides, index) => ({ plainSides: sides, sides, direction: NESW_DIRECTIONS[index]! }))
}
/**
* The per-level-type layout table, transcribed from `DRLGMAZE_GenerateLevel`.
*
* `specials` covers the replacement tables that live inline in that function.
* The per-act decoration passes that follow it in D2MOO — `DRLGMAZE_PlaceAct2TombStuff`,
* `DRLGMAZE_PlaceAct3DungeonStuff`, `DRLGMAZE_PlaceAct5IceStuff` and the rest —
* live in `maze-special-passes.ts` and are named by a row's `specialPass`; each
* one that runs is listed in `stats.specialPassesApplied`. A row with an empty
* `specials` table has no transcribed staircase pass at all, and
* {@link unimplementedPassesFor} reports that gap in `stats.unimplementedPasses`
* so the omission is visible rather than silent.
*/
export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
{
levelTypeName: 'Act 1 - Cave',
grow: true,
theme: true,
specials: [
{ kind: 'prev', entries: entries() },
{ kind: 'quest', entries: entries(), levelIds: [8] },
{ kind: 'down', entries: entries(), exceptLevelIds: [8] },
{ kind: 'quest', entries: entries(), levelIds: [9] },
{ kind: 'next', entries: entries(), levelIds: [10] },
],
},
{
levelTypeName: 'Act 1 - Crypt',
grow: true,
theme: true,
specials: [
{ kind: 'prev', entries: entries() },
{ kind: 'quest', entries: entries(), levelIds: [18] },
{ kind: 'treasure', entries: entries(), levelIds: [19] },
{ kind: 'next', entries: entries(), levelIds: [21, 22, 23, 24] },
],
},
{
levelTypeName: 'Act 1 - Barracks',
ring: 2,
grow: true,
theme: true,
specials: [
{ kind: 'next', entries: entries() },
],
specialPass: 'DRLGMAZE_PlaceAct1Barracks',
},
{
levelTypeName: 'Act 1 - Jail',
ring: 2,
grow: true,
theme: true,
specials: [
{ kind: 'prev', entries: entries() },
{ kind: 'waypoint', entries: entries(), levelIds: [29] },
{ kind: 'quest', entries: entries(), levelIds: [30] },
// D2MOO's `else` hangs off the `JAILLEV3` test, so levels 1 and 2 get a
// `Next` staircase and level 3 gets the Cathedral descent instead.
{ kind: 'next', entries: entries(), levelIds: [29, 30] },
{ kind: 'down', entries: entries(), levelIds: [31] },
],
},
{
levelTypeName: 'Act 1 - Catacombs',
grow: true,
theme: true,
opening: 'catacombs',
specials: [
{ kind: 'next', entries: entries() },
{ kind: 'waypoint', entries: entries(), levelIds: [35] },
],
},
{
levelTypeName: 'Act 2 - Sewer',
ring: 2,
grow: true,
theme: true,
specials: [
{ kind: 'prev', entries: entries() },
{ kind: 'next', entries: entries(), levelIds: [47, 48] },
],
specialPass: 'DRLGMAZE_ScanReplaceSpecialAct2SewersPresets',
},
{
levelTypeName: 'Act 2 - Tomb',
grow: true,
theme: true,
prevChain: 'tomb',
singleRoom: { 61: ['Act 2 - Tomb Tainted Sun X'] },
specials: [
{ kind: 'next', entries: entries(), levelIds: [55, 56, 57, 58] },
],
specialPass: 'DRLGMAZE_PlaceAct2TombStuff',
},
{
levelTypeName: 'Act 2 - Lair',
ring: 2,
grow: true,
theme: false,
specials: [
{ kind: 'prev', entries: entries() },
{ kind: 'next', entries: entries(), levelIds: [62, 63] },
],
specialPass: 'DRLGMAZE_PlaceAct2LairStuff',
},
{
levelTypeName: 'Act 2 - Arcane',
grow: false,
theme: false,
arcane: true,
specials: [{ kind: 'quest', entries: entries() }],
},
{
levelTypeName: 'Act 2 - Harem',
ring: 2,
grow: false,
theme: false,
specials: [
{ kind: 'prev', entries: entries(), levelIds: [51] },
{ kind: 'next', entries: entries(), levelIds: [51] },
],
},
{
levelTypeName: 'Act 2 - Basement',
ring: 2,
grow: false,
theme: false,
specials: [
{ kind: 'prev', entries: entries(), levelIds: [52, 53, 54] },
{ kind: 'waypoint', entries: entries(), levelIds: [52] },
{ kind: 'next', entries: entries(), levelIds: [52, 53] },
],
},
{
levelTypeName: 'Act 3 - Spider',
ring: 2,
grow: false,
theme: false,
specials: [
{ kind: 'prev', entries: entries(), levelIds: [84, 85] },
{ kind: 'treasure', entries: entries(), levelIds: [84, 85] },
],
},
{
levelTypeName: 'Act 3 - Kurast',
ring: 2,
grow: true,
theme: true,
specials: [
{ kind: 'prev', entries: entries(), levelIds: [100, 101] },
{ kind: 'next', entries: entries(), levelIds: [100, 101] },
],
specialPass: 'DRLGMAZE_PlaceAct3MephistoStuff',
},
{
levelTypeName: 'Act 3 - Dungeon',
ring: 2,
grow: true,
theme: true,
specials: [
{ kind: 'prev', entries: entries(), levelIds: [86, 87, 88, 89] },
{ kind: 'next', entries: entries(), levelIds: [86, 87, 88, 89] },
],
specialPass: 'DRLGMAZE_PlaceAct3DungeonStuff',
},
{
levelTypeName: 'Act 3 - Sewer',
ring: 2,
grow: true,
theme: true,
specials: [
// Sewer 1 runs `InitBasicMazeLayout(5)` and places corner staircases for
// both surface exits (Kurast Bazaar via NW/NE and Upper Kurast via SW/SE),
// plus the drain descent to Sewer Level 2.
{
kind: 'prev',
entries: [
{ plainSides: 'NW', sides: 'NW', direction: DIRECTION_NORTH },
{ plainSides: 'NE', sides: 'NE', direction: DIRECTION_NORTH },
{ plainSides: 'NW', sides: 'NW', direction: DIRECTION_NORTH },
{ plainSides: 'NE', sides: 'NE', direction: DIRECTION_NORTH },
],
levelIds: [92],
inPlaceOnly: true,
},
{
kind: 'prev',
entries: [
{ plainSides: 'SW', sides: 'SW', direction: DIRECTION_SOUTH },
{ plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH },
{ plainSides: 'SW', sides: 'SW', direction: DIRECTION_SOUTH },
{ plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH },
],
levelIds: [92],
inPlaceOnly: true,
},
{
kind: 'next',
entries: entries(),
levelIds: [92],
},
],
specialPass: 'DRLGMAZE_PlaceAct3SewerStuff',
},
{
levelTypeName: 'Act 4 - Lava',
grow: true,
theme: false,
animSpeed: 1,
specials: [],
specialPass: 'DRLGMAZE_PlaceAct4Lava',
},
{
levelTypeName: 'Act 5 - Ice Caves',
ring: 2,
grow: true,
theme: false,
singleRoom: {
114: ['Act 5 - Ice River A', 'Act 5 - Ice River B'],
116: ['Act 5 - Ice Pool A'],
119: ['Act 5 - Ice Pool B'],
},
specials: [
{ kind: 'prev', entries: entries(), levelIds: [113, 115, 118] },
{ kind: 'next', entries: entries(), levelIds: [113, 115, 118] },
{ kind: 'down', entries: entries(), levelIds: [113, 115, 118] },
],
specialPass: 'DRLGMAZE_PlaceAct5IceStuff',
},
{
levelTypeName: 'Act 5 - Temple',
ring: 2,
grow: false,
theme: false,
specials: [
{
kind: 'prev',
entries: [
{ plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH },
{ plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH },
{ plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH },
{ plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH },
],
levelIds: [122, 123],
inPlaceOnly: true,
},
{
kind: 'next',
entries: [
{ plainSides: 'NE', sides: 'NE', direction: DIRECTION_NORTH },
{ plainSides: 'NW', sides: 'NW', direction: DIRECTION_NORTH },
{ plainSides: 'SW', sides: 'SW', direction: DIRECTION_SOUTH },
{ plainSides: 'NE', sides: 'NE', direction: DIRECTION_NORTH },
],
levelIds: [122, 123],
inPlaceOnly: true,
},
],
specialPass: 'DRLGMAZE_PlaceAct5TempleStuff',
},
{
levelTypeName: 'Act 5 - Baal',
grow: true,
theme: false,
prevChain: 'baal',
specials: [
{ kind: 'next', entries: entries(), levelIds: [128, 129, 130] },
],
specialPass: 'DRLGMAZE_PlaceAct5BaalStuff',
},
{
levelTypeName: 'Act 5 - Lava',
grow: false,
theme: false,
lavaPairs: true,
specials: [
{ kind: 'prev', entries: entries(), levelIds: [125, 126, 127] },
{ kind: 'treasure', entries: entries(), levelIds: [125, 126, 127] },
],
},
]
/**
* `LvlPrest.txt` names a level type needs from outside its own name prefix.
*
* Only Act 5's Lava levels do this: `DRLGMAZE_PlaceAct5LavaPresets` fills the
* blank space around the Infernal Pit's two set pieces with the *Act 4* lava
* filler row, so a caller that groups pieces strictly by name prefix must fetch
* these extra rows too.
*/
export const MAZE_SHARED_PRESET_NAMES: readonly string[] = ['Act 4 - Lava X']
/**
* The gaps one level type still has, derived from its own profile row.
*
* This used to be a module constant hardcoded to `[]` with a comment claiming
* every pass was implemented, which made `stats.unimplementedPasses` report
* "no gaps" for a level that stamps no staircase at all. The list is now
* *measured* from {@link MAZE_LEVEL_TYPE_PROFILES}, so it cannot drift away
* from the table it describes:
*
* - **no profile row** — `generateMaze` cannot build the type at all.
* - **an empty `specials` table** — the `Prev`/`Next`/quest replacement rows
* that `DRLGMAZE_GenerateLevel` runs inline have not been transcribed, so the
* level stamps no staircase room and the packer has to invent a fallback warp
* (see `scripts/pack-act-assets.ts`). A `singleRoom` type is exempt: it is one
* fixed `LvlPrest.txt` row whose stairs are part of the artwork.
*
* The 11 per-act decoration passes named in D2MOO (`DRLGMAZE_PlaceAct2TombStuff`
* and the rest) *are* reproduced, in `maze-special-passes.ts`; a type that runs
* one names it in `stats.specialPassesApplied` instead.
*
* @param levelTypeName - the `LvlTypes.txt` name.
* @returns the gaps, empty when the type is fully transcribed.
*/
export function unimplementedPassesFor(levelTypeName: string): readonly string[] {
const profile = MAZE_LEVEL_TYPE_PROFILES.find(candidate => candidate.levelTypeName === levelTypeName)
if (profile === undefined) return [`DRLGMAZE_GenerateLevel profile (${levelTypeName})`]
const gaps: string[] = []
if (profile.specials.length === 0 && profile.singleRoom === undefined && profile.specialPass === undefined) {
gaps.push(`DRLGMAZE_GenerateLevel specials table (${levelTypeName})`)
}
return gaps
}
/**
* Every gap {@link unimplementedPassesFor} can name, across all level types.
*
* Non-empty while any level type is still missing its replacement table. Prefer
* the per-level-type function: a caller that reports this constant for one level
* is reporting other levels' gaps as its own.
*/
export const UNIMPLEMENTED_PASSES: readonly string[] = MAZE_LEVEL_TYPE_PROFILES
.flatMap(profile => unimplementedPassesFor(profile.levelTypeName))
/* ------------------------------------------------------------------------- *
* Geometry
* ------------------------------------------------------------------------- */
/** A rectangle in cells. Mirrors the `nTileXPos`/`nTileYPos`/`nTileWidth`/
* `nTileHeight` view of `D2DrlgRoomStrc`, which D2MOO also reads as
* `D2DrlgCoordStrc` through a union. */
interface Rect {
x: number
y: number
width: number
height: number
}
/**
* Signed gap between two cell rectangles, per axis.
*
* `DRLG_ComputeManhattanDistance` verbatim, including its one-axis-at-a-time
* formulation: a negative value means the rectangles overlap on that axis, and
* two rooms sharing a face give one gap of 0 and one of `-height`, which is the
* signature every caller tests for.
*
* @param a - first rectangle.
* @param b - second rectangle.
* @returns the per-axis gaps.
*/
function manhattan(a: Rect, b: Rect): { dx: number; dy: number } {
const dx = a.x >= b.x ? a.x - b.width - b.x : b.x - a.width - a.x
const dy = a.y >= b.y ? a.y - b.height - b.y : b.y - a.height - a.y
return { dx, dy }
}
/**
* Whether two rectangles are clear of each other by at least `margin` cells.
*
* `DRLG_GetRectanglesManhattanDistanceAndCheckNotOverlapping`: true when either
* axis is separated by `margin` or more. With `margin = 0` a pair of face-to-face
* rooms has `dx = 0` and therefore *passes*, so sharing a face counts as "not
* overlapping" — which is what lets rooms tile a grid.
*
* @param a - first rectangle.
* @param b - second rectangle.
* @param margin - required separation on at least one axis.
* @returns true when they are clear.
*/
function notOverlapping(a: Rect, b: Rect, margin: number): boolean {
const { dx, dy } = manhattan(a, b)
return dx >= margin || dy >= margin
}
/**
* The quarter direction from `from` to `to`, or `-1` when they are not
* face-to-face neighbours.
*
* `DRLG_GetDirectionFromCoordinates`. Only the four quarters are produced; the
* diagonals exist solely as placement offsets in `FillBlankMazeSpaces`.
*
* @param from - the origin rectangle.
* @param to - the candidate neighbour.
* @returns a direction index 0..3, or -1.
*/
function directionFrom(from: Rect, to: Rect): number {
if (from.x <= to.x) {
if (to.x === from.x + from.width) return DIRECTION_EAST
} else if (from.x === to.x + to.width) {
return DIRECTION_WEST
}
if (from.y <= to.y) {
if (to.y === from.y + from.height) return DIRECTION_SOUTH
} else if (from.y === to.y + to.height) {
return DIRECTION_NORTH
}
return -1
}
/* ------------------------------------------------------------------------- *
* Rooms
* ------------------------------------------------------------------------- */
/** One maze section. */
export interface Room extends Rect {
/** Doorways to neighbouring rooms, in insertion order. */
readonly orths: { room: Room; direction: number }[]
/** Creation index, so diagnostics can name a room stably. */
readonly id: number
/** The chosen piece; `null` until a preset pass runs. */
piece: MazePiece | null
/** Which variant of `piece` to stamp. */
variant: number
/**
* `DRLGPRESETROOMFLAG_HAS_MAP_DS1`.
*
* Set by the passes that call `DRLGMAZE_SetPickedFileAndPresetId` with
* `bResetFlag = FALSE`; cleared by every `DRLGMAZE_PickRoomPreset` call, which
* passes 1. A set flag means "this room's artwork is final", so the merge pass
* skips it and no later preset pass may overwrite it.
*/
hasDs1: boolean
/** Per-room random stream, seeded from the level's (`DRLGROOM_AllocRoomEx`). */
readonly rng: Rng
}
/** The level under construction. */
export interface MazeLevel {
/** Rooms newest-first, matching `DRLGROOM_AddRoomExToLevel`'s prepend. */
readonly rooms: Room[]
/** The first room created; D2MOO keeps this pointer across prepends. */
readonly first: Room
readonly width: number
readonly height: number
readonly rng: Rng
readonly mergePerMille: number
/** Next room id. */
nextId: number
/** Doorways added by the merge pass, for reporting. */
merges: number
readonly maxCellsX?: number | undefined
readonly maxCellsY?: number | undefined
readonly stats?: MazeStats | undefined
}
/**
* Report collector shared by the passes.
*
* Dense on purpose: the generator's job is to be inspectable, and the interesting
* facts are which piece answered which side pattern and where the exact pattern
* was missing.
*/
export interface MazeStats {
readonly patterns: Record<string, Record<string, number>>
readonly fallbacks: { room: number; requested: string; used: string }[]
readonly specialsApplied: { room: number; kind: string; sides: string }[]
readonly specialPassesApplied: string[]
readonly unresolvedRoles: string[]
readonly notes: string[]
ringRooms: number
placementAttempts: number
boundsRejected?: number
/** Stair rooms, with the coordinates only `stamp` can work out. */
warps: MazeWarp[]
/** Waypoint rooms, with the coordinates and entity positions only `stamp` can work out. */
waypoints: MazeWaypoint[]
}
/**
* A staircase, as reported in `stats.warps`.
*
* `specialsApplied` already records which room got a stair piece, but a room id
* is not a place: rooms are positioned in an unbounded coordinate space that
* `stamp` shifts by `minX`/`minY` to produce the final map. The coordinates
* below are in that final map's cells, so they can be baked straight into the
* pack.
*/
export interface MazeWarp {
/** The room the piece went into, matching `specialsApplied`. */
readonly room: number
/**
* Which way it goes:
* - `up` — `Prev`, back towards the level above.
* - `down` — `Next` or `Down`, deeper in. `Down` is the Act 1 cave descent,
* which uses a different piece but means the same thing here.
*/
readonly direction: 'up' | 'down'
/** The piece kind it came from, for tracing back to the artwork. */
readonly kind: 'prev' | 'next' | 'down'
/** The room's top-left corner in the final map, in cells. */
readonly x: number
readonly y: number
/** The room's extent, in cells. */
readonly width: number
readonly height: number
/** The room's centre, where the player lands or the warp is clicked. */
readonly centreX: number
readonly centreY: number
}
/**
* Create a room with no position or piece.
*
* @param level - the level.
* @returns the room.
*/
function makeRoom(level: MazeLevel): Room {
const room: Room = {
x: 0, y: 0, width: level.width, height: level.height,
orths: [], id: level.nextId, piece: null, variant: 0, hasDs1: false,
rng: new Rng(level.rng.int(0, 0x7fffffff)),
}
level.nextId += 1
return room
}
/**
* Add a doorway between two rooms.
*
* `DRLGROOM_AllocDrlgOrthsForRooms`: idempotent per room pair, and the reciprocal
* orth points the opposite way, `(direction - 2) & 3`.
*
* @param a - first room.
* @param b - second room.
* @param direction - direction from `a` to `b`.
*/
function linkRooms(a: Room, b: Room, direction: number): void {
if (!a.orths.some(orth => orth.room === b)) a.orths.push({ room: b, direction })
const opposite = (direction - 2) & 3
if (!b.orths.some(orth => orth.room === a)) b.orths.push({ room: a, direction: opposite })
}
/**
* The 4-bit side mask a room's doorways produce.
*
* `DRLGMAZE_PickRoomPreset` ORs {@link DIRECTION_SIDE_BIT} for every doorway. A
* room with no doorways gets 0, which in D2MOO selects the level type's base row
* and here selects the `entrance` piece.
*
* @param room - the room.
* @returns the mask, 0..15.
*/
export function roomSideMask(room: Room): number {
let mask = 0
for (const orth of room.orths) mask |= DIRECTION_SIDE_BIT[orth.direction]!
return mask
}
/**
* Whether placing `room` keeps the overall maze within `maxCellsX` and `maxCellsY`.
*
* In Diablo II, maze room growth is constrained by the level's global SizeX and SizeY limits
* from `Levels.txt`. If growing a candidate room would expand the bounding box of all placed
* rooms beyond the allowed dimensions, the placement attempt is rejected, forcing the maze
* to grow in other available directions and cluster compactly.
*
* @param level - the level.
* @param room - the candidate room with proposed coordinates and dimensions.
* @returns true if within bounds or bounds are unconstrained.
*/
function checkRoomWithinBounds(level: MazeLevel, room: Rect): boolean {
if (level.maxCellsX === undefined && level.maxCellsY === undefined) return true
if (level.rooms.length === 0) return true
if (level.maxCellsX !== undefined && level.maxCellsX > 0) {
let minX = room.x
let maxX = room.x + room.width
for (const other of level.rooms) {
if (other.x < minX) minX = other.x
if (other.x + other.width > maxX) maxX = other.x + other.width
}
if (maxX - minX > level.maxCellsX) {
if (level.stats) {
level.stats.boundsRejected = (level.stats.boundsRejected ?? 0) + 1
}
return false
}
}
if (level.maxCellsY !== undefined && level.maxCellsY > 0) {
let minY = room.y
let maxY = room.y + room.height
for (const other of level.rooms) {
if (other.y < minY) minY = other.y
if (other.y + other.height > maxY) maxY = other.y + other.height
}
if (maxY - minY > level.maxCellsY) {
if (level.stats) {
level.stats.boundsRejected = (level.stats.boundsRejected ?? 0) + 1
}
return false
}
}
return true
}
/**
* Whether a room overlaps anything other than itself and one ignored room, and fits within level bounds.
*
* `DRLGMAZE_CheckRoomNotOverlaping`. The margin is 0, so face-to-face neighbours
* are fine and only genuine overlap fails.
*
* @param level - the level.
* @param room - the candidate.
* @param ignored - a room to skip, usually its parent.
* @returns true when the room is clear and within global level bounds.
*/
function checkRoomNotOverlapping(level: MazeLevel, room: Rect, ignored: Room | null): boolean {
if (!checkRoomWithinBounds(level, room)) return false
for (const other of level.rooms) {
if (other === room || other === ignored) continue
if (!notOverlapping(room, other, 0)) return false
}
return true
}
/**
* Position `room` against `anchor` in `direction` and validate the result.
*
* `DRLGMAZE_LinkMazeRooms`: the coordinate assignment happens first, so a failed
* link leaves the caller to discard the room.
*
* @param level - the level.
* @param room - the room to move.
* @param anchor - the room to sit against.
* @param direction - which side of the anchor to occupy.
* @returns true when the position is legal.
*/
function linkMazeRooms(level: MazeLevel, room: Room, anchor: Room, direction: number): boolean {
const { dx, dy } = step(direction, anchor.width, anchor.height)
room.x = anchor.x + dx
room.y = anchor.y + dy
for (const orth of anchor.orths) {
// Anything already touching the anchor must not be run into.
if (!notOverlapping(room, orth.room, 0)) return false
}
return checkRoomNotOverlapping(level, room, anchor)
}
/**
* The earliest room that still needs a piece, optionally of a given shape.
*
* @param level - the level, newest-first.
* @param mask - required side mask, or `null` for any.
* @returns the room, or null.
*/
function findUnfinishedRoom(level: MazeLevel, mask: number | null): Room | null {
for (const room of level.rooms) {
if (room.hasDs1) continue
if (mask === null || roomSideMask(room) === mask) return room
}
return null
}
/**
* Grow one room off `parent`, or return null when the spot is taken.
*
* `DRLGMAZE_AddAdjacentMazeRoom`, with its inline copy of the merge loop factored
* into {@link mergeMazeRooms}. The fold is safe because in D2MOO the new room has
* not been added to the roster when either spelling runs, so both iterate exactly
* the same candidates in the same order; the only difference is that
* `DRLGMAZE_BuildBasicMaze` calls `MergeMazeRooms` unconditionally while
* `AddAdjacentMazeRoom` guards it with `bMergeRooms`, and every caller that
* passes `FALSE` has already established that there is nothing to merge.
*
* @param level - the level.
* @param parent - the room to grow from.
* @param direction - which side to grow towards, 0..7.
* @param merge - whether the accidental-adjacency join pass runs.
* @param repickParent - whether to re-derive the parent's piece afterwards.
* `DRLGMAZE_FillBlankMazeSpaces` deliberately does not.
* @returns the new room, or null.
*/
export function addAdjacentMazeRoom(
level: MazeLevel,
parent: Room,
direction: number,
merge: boolean,
repickParent: boolean,
index: PieceIndex,
stats: MazeStats,
): Room | null {
const room = makeRoom(level)
const { dx, dy } = step(direction, parent.width, parent.height)
room.x = parent.x + dx
room.y = parent.y + dy
for (const orth of parent.orths) {
if (!notOverlapping(room, orth.room, 0)) return null
}
if (!checkRoomNotOverlapping(level, room, parent)) return null
linkRooms(parent, room, direction)
if (merge) mergeMazeRooms(level, room, index, stats)
level.rooms.unshift(room)
if (repickParent) pickRoomPreset(parent, index, stats)
pickRoomPreset(room, index, stats)
return room
}
/**
* Join a newly placed room to every already-placed room it happens to touch.
*
* **This is what `LvlMaze.Merge` does.** `DRLGMAZE_MergeMazeRooms` (and the
* inline copy inside `DRLGMAZE_AddAdjacentMazeRoom`) walks the whole roster for
* rooms no more than one cell away where `dx !== dy` — which, given that overlap
* has already been ruled out, can only mean a face-to-face neighbour — and, when
* the two are not already connected, rolls
* `SEED_RollRandomNumber(&i->pSeed) % 1000 < dwMerge`, on the *candidate's* seed
* rather than the pivot's. A success adds a doorway and re-picks that room's
* piece, because its side pattern just changed.
*
* The consequence is why `Merge` matters: the growth loop attaches a room on one
* side only, but the section grid is coarse enough that a new room frequently
* lands flush against an unrelated older room. With `Merge = 0` those contacts
* stay walls and the level is a bare tree; with `Merge = 1000` every one of them
* becomes a door and the level acquires loops.
*
* @param level - the level.
* @param pivot - the room just placed.
* @param index - the piece index, for re-picking.
* @param stats - report collector.
*/
function mergeMazeRooms(level: MazeLevel, pivot: Room, index: PieceIndex, stats: MazeStats): void {
if (pivot.hasDs1) return
for (const other of level.rooms) {
if (other === pivot || other.hasDs1) continue
const { dx, dy } = manhattan(pivot, other)
// `!(dx >= 1 || dy >= 1)`, then `dx !== dy`.
if (dx >= 1 || dy >= 1) continue
if (dx === dy) continue
if (pivot.orths.some(orth => orth.room === other)) continue
if (other.rng.int(0, 999) >= level.mergePerMille) continue
const direction = directionFrom(other, pivot)
if (direction === -1) continue
linkRooms(other, pivot, direction)
level.merges += 1
pickRoomPreset(other, index, stats)
}
}
/* ------------------------------------------------------------------------- *
* Piece lookup
* ------------------------------------------------------------------------- */
/** Indexed pieces, so a side mask is one map lookup. */
export interface PieceIndex {
/**
* The piece for a room's doorway mask.
*
* @param mask - the room's doorway mask.
* @param stats - report collector for fallbacks.
* @param roomId - the room being served, for the report.
* @returns the piece to stamp.
*/
choose(mask: number, stats: MazeStats, roomId: number): MazePiece
}
/**
* Count set bits.
*
* @param value - the value.
* @returns the population count.
*/
function popcount(value: number): number {
let count = 0
let rest = value
while (rest !== 0) { count += rest & 1; rest >>>= 1 }
return count
}
/**
* Index a level type's pieces by kind and side mask.
*
* `DRLGMAZE_PickRoomPreset` resolves a mask by reading the level type's
* `LvlPrest` row at `base + mask`, where `base` is a hardcoded per-type preset
* id. This port has no preset ids, so it resolves by *kind and mask* instead —
* the same thing expressed in the data the caller supplies.
*
* A level type may genuinely lack a piece for a side pattern (Act 1's Catacombs
* ships no four-sided room). D2MOO would read whatever row happens to sit at
* `base + mask` — a neighbouring level type's artwork — and produce a map whose
* doors do not line up. Here the closest piece is substituted and the mismatch is
* recorded in `stats.fallbacks`, because a map that cannot be walked is worse for
* this project than a map with one wrong room. This is a deliberate divergence.
*
* @param pieces - the level type's pieces.
* @returns the index.
*/
function buildPieceIndex(pieces: readonly MazePiece[]): PieceIndex {
const rooms = new Map<number, MazePiece>()
let entrance: MazePiece | undefined
for (const piece of pieces) {
if (piece.kind === 'entrance' && entrance === undefined) entrance = piece
if (piece.kind !== 'room') continue
const mask = sideMaskFromToken(piece.sides)
if (!rooms.has(mask)) rooms.set(mask, piece)
}
for (const piece of pieces) {
if (piece.kind !== 'prev') continue
const mask = sideMaskFromToken(piece.sides)
if (mask !== 0 && !rooms.has(mask)) rooms.set(mask, piece)
}
const entrancePiece = entrance
/**
* Pick the closest available room piece to a mask.
*
* @param mask - the wanted mask.
* @returns the piece and whether it is exact.
*/
const closest = (mask: number): { piece: MazePiece; exact: boolean } | null => {
const exact = rooms.get(mask)
if (exact !== undefined) return { piece: exact, exact: true }
let best: MazePiece | null = null
let bestCost = Number.POSITIVE_INFINITY
for (const [candidateMask, piece] of rooms) {
// A missing doorway seals the room off completely; an extra one merely
// adds a dead end. Weight the two accordingly.
const cost = popcount(mask & ~candidateMask) * 100 + popcount(candidateMask & ~mask)
if (cost < bestCost) { bestCost = cost; best = piece }
}
return best === null ? null : { piece: best, exact: false }
}
return {
choose: (mask: number, stats: MazeStats, roomId: number): MazePiece => {
const result = closest(mask)
if (result === null) {
if (entrancePiece !== undefined) return entrancePiece
const any = pieces[0]
if (any === undefined) throw new Error('maze piece list is empty')
return any
}
if (!result.exact) {
stats.fallbacks.push({
room: roomId,
requested: sideTokenFromMask(mask),
used: `${result.piece.name} (${sideTokenFromMask(sideMaskFromToken(result.piece.sides)) || 'no side'})`,
})
}
return result.piece
},
}
}
/**
* Look up a piece by kind and side token.
*
* @param pieces - the level's pieces.
* @param kind - the kind wanted.
* @param sides - the side token; `''` accepts any piece of that kind.
* @returns the piece, or undefined.
*/
export function findSpecial(pieces: readonly MazePiece[], kind: MazePieceKind, sides: string): MazePiece | undefined {
return pieces.find(piece => piece.kind === kind
&& (sides === '' || sideMaskFromToken(piece.sides) === sideMaskFromToken(sides)))
}
/**
* Give a room the piece its doorways call for.
*
* `DRLGMAZE_PickRoomPreset`. Rooms whose artwork is already final (`hasDs1`) are
* left alone, mirroring the passes that call it with `bResetFlag = FALSE`; every
* other call clears the flag, which is why the flag cannot be used as a "was
* visited" marker.
*
* @param room - the room to give a piece.
* @param index - the piece lookup.
* @param stats - the report collector.
*/
function pickRoomPreset(room: Room, index: PieceIndex, stats: MazeStats): void {
if (room.hasDs1) return
const mask = roomSideMask(room)
const chosen = index.choose(mask, stats, room.id)
room.piece = chosen
room.variant = 0
room.hasDs1 = false
const token = sideTokenFromMask(mask) === '' ? 'no side' : sideTokenFromMask(mask)
stats.patterns[chosen.kind] ??= {}
const bucket = stats.patterns[chosen.kind]!
bucket[token] = (bucket[token] ?? 0) + 1
}
/**
* Record that a special piece was placed.
*
* @param room - the room it went into.
* @param piece - the piece.
* @param token - the side token it answers.
* @param stats - report collector.
*/
export function noteSpecial(room: Room, piece: MazePiece, token: string, stats: MazeStats): void {
stats.specialsApplied.push({ room: room.id, kind: piece.kind, sides: token })
stats.patterns[piece.kind] ??= {}
stats.patterns[piece.kind]![token] = (stats.patterns[piece.kind]![token] ?? 0) + 1
}
/* ------------------------------------------------------------------------- *
* Layout passes
* ------------------------------------------------------------------------- */
/**
* Pre-place a closed ring of `4n - 4` rooms.
*
* `DRLGMAZE_InitBasicMazeLayout`. Four legs walk north, west, south and east, and
* the last room is then linked back to the first — so `n = 2` gives a 2×2 square
* and `n = 5` (Act 3's Sewer 1) a 4×4 ring of 16 rooms. The east leg runs one
* iteration short because the first room already occupies that corner. Each leg
* calls `DRLGMAZE_MergeMazeRooms` just like the growth loop does, so the
* accidental contacts a ring makes are subject to `Merge` too.
*
* @param level - the level.
* @param roomsPerDirection - `n`; `4n - 4` rooms are created.
* @param index - the piece index.
* @param stats - report collector.
*/
function ringLayout(level: MazeLevel, roomsPerDirection: number, index: PieceIndex, stats: MazeStats): void {
const legs: readonly number[] = [DIRECTION_NORTH, DIRECTION_WEST, DIRECTION_SOUTH, DIRECTION_EAST]
let cursor = level.first
for (const direction of legs) {
const count = direction === DIRECTION_EAST ? roomsPerDirection - 2 : roomsPerDirection - 1
for (let remaining = count; remaining > 0; remaining -= 1) {
const room = addAdjacentMazeRoom(level, cursor, direction, true, true, index, stats)
if (room !== null) stats.ringRooms += 1
cursor = room ?? cursor
}
}
linkRooms(cursor, level.first, DIRECTION_EAST)
pickRoomPreset(cursor, index, stats)
pickRoomPreset(level.first, index, stats)
}
/**
* Grow the level to its target room count.
*
* The shared body of `DRLGMAZE_BuildBasicMaze` and of the
* `while (pLevel->nRooms < nRooms)` loop `DRLGMAZE_GenerateLevel` inlines for the
* Act 1 dungeons: pick a room from the roster at a uniformly random index (the
* roster is newest-first, so recent rooms are favoured exactly as in the game),
* roll a direction from *that room's* seed, and try to attach a neighbour.
* Failure is routine — the target square may already be occupied — so the loop
* simply tries again. That also means it can only be bounded by a retry cap
* rather than by construction, and D2MOO would spin forever where this throws.
*
* @param level - the level.
* @param target - rooms wanted.
* @param index - the piece index.
* @param stats - report collector.
* @returns the number of attempts consumed.
*/
function growMaze(level: MazeLevel, target: number, index: PieceIndex, stats: MazeStats): number {
const cap = target * 400 + 20_000
let attempts = 0
while (level.rooms.length < target) {
attempts += 1
if (attempts > cap) {
throw new Error(`layout stalled at ${String(level.rooms.length)}/${String(target)} rooms after ${String(attempts)} attempts`)
}
const room = level.rooms[level.rng.int(0, level.rooms.length - 1)]!
const direction = room.rng.int(0, 3)
if (room.hasDs1) continue
addAdjacentMazeRoom(level, room, direction, true, true, index, stats)
}
return attempts
}
/**
* The opening moves Act 1's Catacombs get before growth.
*
* `DRLGMAZE_GenerateLevel`'s `LVLTYPE_ACT1_CATACOMBS` arm. Level 1 opens in all
* four directions and pins the entrance to the `NSEW` staircase piece; the rest
* pick an axis at random and open both ways, pinning `EW` or `NS`. These are the
* only places the entrance room is finalised with `bResetFlag = FALSE`, i.e. the
* only rooms `DRLGMAZE_MergeMazeRooms` will later refuse to touch.
*
* @param level - the level.
* @param horizontal - whether the non-first levels open west/east rather than
* north/south.
* @param pieces - the level's pieces.
* @param index - the piece index.
* @param stats - report collector.
*/
function catacombsOpening(level: MazeLevel, horizontal: boolean, pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void {
const directions = horizontal ? [DIRECTION_WEST, DIRECTION_EAST] : [DIRECTION_NORTH, DIRECTION_SOUTH]
for (const direction of directions) addAdjacentMazeRoom(level, level.first, direction, true, true, index, stats)
const token = horizontal ? 'EW' : 'NS'
const piece = findSpecial(pieces, 'prev', token)
if (piece === undefined) {
stats.unresolvedRoles.push(`Act 1 - Catacombs Prev ${token}`)
return
}
level.first.piece = piece
level.first.hasDs1 = true
noteSpecial(level.first, piece, token, stats)
}
/**
* The three-room prologue Act 2's tombs and Act 5's Baal levels share.
*
* `DRLGMAZE_PlaceAct2TombPrev_Act5BaalPrev`: three rooms are chained off the
* entrance, one per direction starting from a random one, each merging as it
* lands; the entrance then takes the `Prev` piece whose three free sides face the
* rooms that actually got built. That piece is chosen with the direction counter
* *after* the loop, which is why it is the successor of the last direction tried
* rather than the last direction used.
*
* @param level - the level.
* @param kind - which act's `Prev` set to use.
* @param pieces - the level's pieces.
* @param index - the piece index.
* @param stats - report collector.
*/
function prevChain(level: MazeLevel, kind: 'tomb' | 'baal', pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void {
let direction = level.rng.int(0, 3)
for (let attempt = 0; attempt < 3; attempt += 1) {
const room = makeRoom(level)
if (linkMazeRooms(level, room, level.first, direction)) {
linkRooms(level.first, room, direction)
mergeMazeRooms(level, room, index, stats)
level.rooms.unshift(room)
pickRoomPreset(level.first, index, stats)
pickRoomPreset(room, index, stats)
}
direction = (direction + 1) % 4
}
const tokens = ['NSE', 'SEW', 'NSW', 'NEW']
const token = tokens[direction]!
const piece = findSpecial(pieces, 'prev', token)
if (piece === undefined) {
stats.unresolvedRoles.push(`${kind === 'tomb' ? 'Act 2 - Tomb' : 'Act 5 - Baal'} Prev ${token}`)
return
}
level.first.piece = piece
level.first.hasDs1 = true
noteSpecial(level.first, piece, token, stats)
}
/**
* `DRLGMAZE_PlaceArcaneSanctuary`: four 15-room spiral branches.
*
* Each branch starts at the level's first room and walks the pattern D2MOO
* documents in a comment, encoded in
* `DRLGMAZE_ArcaneSanctuaryDirectionFromRoomIdx`: one room forward, then a side
* step, then a long run, then a side step back. Rooms 8 and 12 are placed but
* deliberately *not* recorded and do not advance the parent, which D2MOO flags
* with "Is this a mistake?" and then reproduces anyway; so does this port, since
* the object is fidelity to the shipped maze rather than to the diagram.
*
* The four branches then take four different variants of the same pieces,
* `(nRand + branch) % 4`, which is what gives the Sanctuary its noticeably varied
* arms; the entry room takes variant 4.
*
* @param level - the level.
* @param pieces - the level's pieces.
* @param index - the piece index.
* @param stats - report collector.
*/
function arcaneSanctuary(level: MazeLevel, pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void {
const rand = level.rng.int(0, 3)
const branches: Room[] = []
for (let branch = 0; branch < 4; branch += 1) {
let parent = level.first
for (let slot = 0; slot < 15; slot += 1) {
let direction = branch
if (slot === 2 || slot === 12) direction = branch + 3
else if (slot === 7 || slot === 9) direction = branch + 1
else if (slot === 10 || slot === 11 || slot === 13 || slot === 14) direction = branch + 2
direction %= 4
const room = addAdjacentMazeRoom(level, parent, direction, true, true, index, stats)
if (room === null) continue
if (slot !== 8 && slot !== 12) {
branches.push(room)
parent = room
}
}
}
branches.forEach((room, position) => {
room.variant = (rand + Math.floor(position / 15)) % 4
})
level.first.variant = 4
void pieces
}
/**
* `DRLGMAZE_PlaceAct5LavaPresets`: the Infernal Pit's two fixed pieces.
*
* The level is not a maze at all. It is an entrance plus exactly two lava pieces,
* chosen as a pair by a single random draw, after which every still-empty
* adjacent square is filled with the shared `Act 4 - Lava X` filler — see
* {@link fillBlankSpaces}.
*
* @param level - the level.
* @param pieces - the level's pieces.
* @param index - the piece index.
* @param stats - report collector.
*/
function lavaPairs(level: MazeLevel, pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void {
// `dword_6FDCE850`, transcribed as (piece name, direction, variant).
const table: readonly (readonly [string, number, number])[] = [
['Act 5 - Lava S', DIRECTION_SOUTH, 0],
['Act 5 - Lava N', DIRECTION_NORTH, 1],
['Act 5 - Lava S', DIRECTION_SOUTH, 1],
['Act 5 - Lava N', DIRECTION_NORTH, 0],
['Act 5 - Lava E', DIRECTION_EAST, 1],
['Act 5 - Lava W', DIRECTION_WEST, 0],
['Act 5 - Lava E', DIRECTION_EAST, 0],
['Act 5 - Lava W', DIRECTION_WEST, 1],
]
const set = 2 * level.first.rng.int(0, 3)
for (const [name, direction, variant] of [table[set]!, table[set + 1]!]) {
const room = makeRoom(level)
room.variant = variant
// The table's direction is the direction *from the new room to the entrance*,
// so the new room sits on the opposite side.
const { dx, dy } = step(direction, level.first.width, level.first.height)
room.x = level.first.x + dx
room.y = level.first.y + dy
if (!checkRoomNotOverlapping(level, room, level.first)) continue
linkRooms(level.first, room, direction)
level.rooms.unshift(room)
pickRoomPreset(level.first, index, stats)
const piece = pieces.find(candidate => candidate.name === name)
if (piece === undefined) {
stats.unresolvedRoles.push(name)
pickRoomPreset(room, index, stats)
continue
}
room.piece = piece
room.hasDs1 = true
noteSpecial(room, piece, 'no side', stats)
}
fillBlankSpaces(level, pieces, index, stats)
}
/**
* `DRLGMAZE_FillBlankMazeSpaces`: wall a fixed-tile level in with filler rooms.
*
* The roster is snapshotted first, then every room in it tries all eight compass
* directions in order and keeps whatever fits. The filler never takes a role, so
* this only ever adds area.
*
* D2MOO does not re-derive the parent's piece here, which would leave a wall
* where the filler attaches and make the filler unreachable; this port does
* re-derive it, because an unreachable room is a defect rather than a detail.
*
* @param level - the level.
* @param pieces - the level's pieces.
* @param index - the piece index.
* @param stats - report collector.
*/
export function fillBlankSpaces(
level: MazeLevel,
pieces: readonly MazePiece[],
index: PieceIndex,
stats: MazeStats,
ignoreRoom?: Room | null,
): void {
const filler = pieces.find(piece => piece.name === 'Act 4 - Lava X')
if (filler === undefined) {
stats.unresolvedRoles.push('Act 4 - Lava X')
return
}
for (const room of [...level.rooms]) {
if (ignoreRoom !== null && ignoreRoom !== undefined && room === ignoreRoom) continue
for (let direction = 0; direction < 8; direction += 1) {
const created = addAdjacentMazeRoom(level, room, direction, false, true, index, stats)
if (created === null) continue
created.piece = filler
created.hasDs1 = true
noteSpecial(created, filler, 'filler', stats)
}
}
}
/**
* `DRLGMAZE_RollAct_1_2_3_BasicPresets`: the `Theme` decoration pass.
*
* A shuffled list of the fifteen side patterns is walked cyclically and the first
* plain room matching each pattern is converted to that pattern's `Theme`
* variant, until `max(rooms / 5 + 1, 2)` rooms have been converted or the attempt
* budget of `2 × rooms` is spent. D2MOO shuffles the offset list with fifteen
* Fisher-Yates swaps drawn from the level seed, which is reproduced here.
*
* A level type with no `Theme` pieces for a pattern is skipped rather than given
* a neighbouring type's artwork, and the shortfall is reported.
*
* @param level - the level.
* @param pieces - the level's pieces.
* @param stats - report collector.
*/
function themeRoll(level: MazeLevel, pieces: readonly MazePiece[], stats: MazeStats): void {
const offsets = Array.from({ length: 15 }, (_, index) => index)
let cursor = level.rng.int(0, 14)
for (let swap = 0; swap < 15; swap += 1) {
const a = level.rng.int(0, 14)
const b = level.rng.int(0, 14)
const temporary = offsets[a]!
offsets[a] = offsets[b]!
offsets[b] = temporary
}
let wanted = Math.max(Math.floor(level.rooms.length / 5) + 1, 2)
let budget = 2 * level.rooms.length
while (wanted > 0 && budget > 0) {
budget -= 1
const mask = offsets[cursor]! + 1
cursor = (cursor + 1) % 15
const token = sideTokenFromMask(mask)
const room = findUnfinishedRoom(level, mask)
if (room === null) continue
const themed = findSpecial(pieces, 'theme', token)
if (themed === undefined) {
// D2MOO would write a neighbouring level type's row; here the room keeps
// its plain piece and the gap is reported.
if (!stats.unresolvedRoles.includes(`Theme ${token}`)) stats.unresolvedRoles.push(`Theme ${token}`)
continue
}
room.piece = themed
room.variant = 0
room.hasDs1 = true
noteSpecial(room, themed, token, stats)
wanted -= 1
}
}
/**
* Compute BFS topological distance of every room from the level's entrance room
* (the room holding a 'prev' or 'entrance' piece, or `level.first`).
*/
export function bfsDistances(level: MazeLevel): Map<Room, number> {
const start = level.rooms.find(r => r.piece?.kind === 'prev' || r.piece?.kind === 'entrance') ?? level.first
const dist = new Map<Room, number>([[start, 0]])
const queue: Room[] = [start]
while (queue.length > 0) {
const current = queue.shift()!
const d = dist.get(current)!
for (const orth of current.orths) {
if (!dist.has(orth.room)) {
dist.set(orth.room, d + 1)
queue.push(orth.room)
}
}
}
return dist
}
/**
* Place a special room group following standard D2 scanning rules:
* scans room candidates against valid wall connections, LvlPrest file definitions,
* and room flags, rather than imposing an artificial global maximum BFS distance.
*
* @param level - the level.
* @param pieces - the level's pieces.
* @param group - which special group to place.
* @param startCursor - initial table offset.
* @param index - the piece index.
* @param stats - report collector.
*/
function placeSpecialGroup(
level: MazeLevel,
pieces: readonly MazePiece[],
group: SpecialGroup,
startCursor: number,
index: PieceIndex,
stats: MazeStats,
): void {
// Phase 1: Try to replace an existing unfinished room in place, scanning
// room candidates against valid wall connections, LvlPrest definitions,
// and room flags in standard D2 roster order.
for (let offset = 0; offset < group.entries.length; offset += 1) {
const entry = group.entries[(startCursor + offset) % group.entries.length]!
const wanted = sideMaskFromToken(entry.plainSides)
const token = sideTokenFromMask(wanted)
const special = findSpecial(pieces, group.kind, entry.sides)
if (special === undefined) continue
const chosen = level.rooms.find(r => {
if (roomSideMask(r) !== wanted) return false
if (!r.hasDs1) return true
if (group.inPlaceOnly === true && (r.piece === special || r.piece?.kind === 'theme')) return true
return false
})
if (chosen !== undefined) {
chosen.piece = special
chosen.variant = 0
chosen.hasDs1 = true
noteSpecial(chosen, special, token, stats)
return
}
}
if (group.inPlaceOnly === true) {
const firstEntry = group.entries[startCursor]!
stats.notes.push(`no ${firstEntry.plainSides} room left for a ${group.kind} ${firstEntry.sides} replacement`)
return
}
// Phase 2: Grow a new dead-end room off an available parent room in D2
// roster order (plain rooms without DS1 scanned first, followed by theme rooms).
const parentCandidates = [
...level.rooms.filter(r => !r.hasDs1),
...level.rooms.filter(r => r.hasDs1 && r.piece?.kind === 'theme'),
]
for (const parent of parentCandidates) {
for (let offset = 0; offset < group.entries.length; offset += 1) {
const entry = group.entries[(startCursor + offset) % group.entries.length]!
const wanted = sideMaskFromToken(entry.plainSides)
const token = sideTokenFromMask(wanted)
const special = findSpecial(pieces, group.kind, entry.sides)
if (special === undefined) continue
const wasHasDs1 = parent.hasDs1
parent.hasDs1 = false
const created = addAdjacentMazeRoom(level, parent, entry.direction, false, true, index, stats)
if (created !== null) {
created.piece = special
created.variant = 0
created.hasDs1 = true
noteSpecial(created, special, token, stats)
return
}
parent.hasDs1 = wasHasDs1
}
}
const fallbackEntry = group.entries[startCursor]!
stats.notes.push(`could not place ${group.kind} ${fallbackEntry.sides}: no room could host it`)
}
/* ------------------------------------------------------------------------- *
* Stamping
* ------------------------------------------------------------------------- */
/** A blank wall cell, matching the decoder's placeholder. */
function emptyWall(): Ds1Wall {
return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }
}
/** A blank floor/shadow cell. */
function emptyFloor(): Ds1Floor {
return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
}
/* ------------------------------------------------------------------------- *
* Edge suppression and doorway thresholds (1.13c `DrlgRoomTile.cpp`)
* ------------------------------------------------------------------------- */
/** `TILETYPE_WALL_LEFT_DOOR` — a wall cell that a door unit occupies. */
const TILETYPE_WALL_LEFT_DOOR = 8
/** `TILETYPE_WALL_RIGHT_DOOR` — the mirrored door wall. */
const TILETYPE_WALL_RIGHT_DOOR = 9
/** `TILETYPE_WALL_LEFT_EXIT` — a warp marker, not artwork. */
const TILETYPE_WALL_LEFT_EXIT = 10
/** `TILETYPE_WALL_RIGHT_EXIT` — the mirrored warp marker. */
const TILETYPE_WALL_RIGHT_EXIT = 11
/**
* Highest `style` an exit tile may carry before 1.13c ignores it outright.
*
* `DRLGROOMTILE_LoadInitRoomTiles` opens with
* `if ((nTileType == TILETYPE_WALL_LEFT_EXIT || nTileType == TILETYPE_WALL_RIGHT_EXIT)
* && nTileStyle >= 8) continue;` — the style is the `Levels.txt` `Warp0..7` slot,
* so anything at 8 or above is not a warp and the cell is dropped.
*/
const MAX_EXIT_WARP_SLOT = 7
/**
* The 2×2 offsets `DRLGROOMTILE_LoadFloorWarpTiles` writes its threshold under.
*
* Verbatim `gWarpTileOffsets_6FDD1320`; each is applied to `(nX - 1, nY - 1)`, so
* the patch covers the doorway cell and the three cells up-left of it.
*/
const WARP_TILE_OFFSETS: readonly (readonly [number, number])[] = [
[0, 0], [1, 0], [0, 1], [1, 1],
]
/**
* Decide whether a wall cell survives 1.13c's load pass.
*
* Three rules from `DRLGROOMTILE_LoadInitRoomTiles`, in the order it applies
* them:
*
* 1. An exit tile whose `style` is above {@link MAX_EXIT_WARP_SLOT} is skipped
* before anything else looks at it (line 466).
* 2. A *hidden* door tile spawns a door unit and `continue`s, so its wall
* artwork is never emitted (line 485).
* 3. A *hidden* exit tile registers a warp and `continue`s likewise (line 500).
*
* Everything else is ordinary artwork and is kept.
*
* @param wall - the wall cell as the piece authored it.
* @returns whether the wall should be drawn.
*/
function wallSurvivesLoad(wall: Ds1Wall): boolean {
const isExit = wall.type === TILETYPE_WALL_LEFT_EXIT || wall.type === TILETYPE_WALL_RIGHT_EXIT
if (isExit && wall.style > MAX_EXIT_WARP_SLOT) return false
if (!wall.hidden) return true
const isDoor = wall.type === TILETYPE_WALL_LEFT_DOOR || wall.type === TILETYPE_WALL_RIGHT_DOOR
return !(isDoor || isExit)
}
/**
* Whether a dropped wall is the kind that gets a threshold patch.
*
* 1.13c calls `DRLGROOMTILE_LoadFloorWarpTiles` from exactly one place
* (`DrlgRoomTile.cpp:505`): a *hidden exit* tile whose style is a real
* `Warp0..7` slot. A hidden *door* takes the branch above it and only spawns a
* door unit, and an out-of-range exit is skipped before either runs — neither
* lays down floor, so neither may widen the patch here.
*
* @param wall - the wall that `wallSurvivesLoad` rejected.
* @returns whether its cell needs the 2×2 floor patch.
*/
function needsThresholdBackfill(wall: Ds1Wall): boolean {
const isExit = wall.type === TILETYPE_WALL_LEFT_EXIT || wall.type === TILETYPE_WALL_RIGHT_EXIT
return isExit && wall.hidden && wall.style <= MAX_EXIT_WARP_SLOT
}
/**
* Whether a floor cell actually draws something.
*
* A slot left at style 0 / sequence 0 is the decoder's placeholder, which is the
* hole the threshold backfill exists to cover.
*
* @param floor - the floor cell.
* @returns whether it references a tile.
*/
function floorIsBlank(floor: Ds1Floor | undefined): boolean {
return floor === undefined || (floor.style === 0 && floor.sequence === 0 && floor.prop1 === 0)
}
/**
* Backfill the threshold floor under a doorway, the way 1.13c does.
*
* `DRLGROOMTILE_LoadFloorWarpTiles` reserves six floor slots per hidden exit
* (`DRLGROOMTILE_CountWallWarpTiles` does `pTiles.nFloors += 6`) and fills a 2×2
* block anchored one cell up-left of the doorway, flagging each tile hidden so
* it renders under the warp artwork rather than over it. Without it the cells a
* doorway punches through the wall line keep the piece's blank floor slot and
* the ground shows a hole at the threshold.
*
* The donor tile is the nearest non-blank floor around the doorway, which is the
* practical stand-in for `DRLGROOMTILE_GetTileCache(TILETYPE_FLOOR, ...)`: the
* room's own floor style is exactly what the tile cache would have returned.
*
* @param cells - the composite grid, mutated in place.
* @param doorX - the doorway cell's x in the composite grid.
* @param doorY - the doorway cell's y in the composite grid.
* @returns how many blank cells were filled.
*/
function backfillThresholdFloor(cells: Ds1Cell[][], doorX: number, doorY: number): number {
const donor = findDonorFloor(cells, doorX, doorY)
if (donor === null) return 0
let filled = 0
for (const [dx, dy] of WARP_TILE_OFFSETS) {
const x = doorX - 1 + dx
const y = doorY - 1 + dy
const row = cells[y]
if (row === undefined) continue
const cell = row[x]
if (cell === undefined) continue
if (!floorIsBlank(cell.floors[0])) continue
const floors = [...cell.floors]
// 1.13c marks these `MAPTILE_HIDDEN` so they sit beneath the warp artwork.
floors[0] = { ...donor, hidden: true }
row[x] = { ...cell, floors }
filled += 1
}
return filled
}
/**
* Find a floor tile to copy into a doorway threshold.
*
* Searches the doorway cell first and then rings outwards, so the patch always
* picks up the floor of the room the doorway belongs to rather than a default.
*
* @param cells - the composite grid.
* @param atX - the doorway cell's x.
* @param atY - the doorway cell's y.
* @returns the donor floor, or `null` when nothing nearby draws a floor.
*/
function findDonorFloor(cells: readonly (readonly Ds1Cell[])[], atX: number, atY: number): Ds1Floor | null {
for (let radius = 0; radius <= 2; 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 floor = cells[atY + dy]?.[atX + dx]?.floors[0]
if (!floorIsBlank(floor)) return floor!
}
}
}
return null
}
/**
* Choose each room's variant the way `DRLGMAZE_RollBasicPresets` does.
*
* For the plain side pieces D2MOO keeps a per-preset counter initialised to a
* random file index and then increments it modulo the file count, so two
* consecutive rooms of the same shape never get the same artwork. Specials and
* themed pieces keep whatever index the earlier pass assigned — for the Arcane
* Sanctuary that is the branch number, and for the Infernal Pit an explicit 0/1.
*
* @param level - the level.
* @param levelId - `Levels.txt` ID.
* @param stats - report collector.
*/
function assignVariants(level: MazeLevel, levelId: number, stats: MazeStats): void {
const counters = new Map<string, number>()
const usage = new Map<string, number>()
// D2MOO walks the roster from its head, i.e. newest room first.
for (const room of level.rooms) {
const piece = room.piece
if (piece === null) continue
usage.set(piece.name, (usage.get(piece.name) ?? 0) + 1)
if (piece.kind !== 'room' || piece.levels.length <= 1) continue
// On Palace Cellar Levels 1 & 2 (52, 53), SE and NW variants 0..2 carry the
// down-staircase warp tiles while variant 3 (CelSE3/CelNW3) is reserved for
// Level 54 (Palace Cellar Level 3) which has no descent to a fourth cellar.
const count = piece.name.startsWith('Act 2 - Basement') && levelId !== 54
? Math.min(piece.levels.length, 3)
: piece.levels.length
let counter = counters.get(piece.name)
if (counter === undefined) counter = level.rng.int(0, count - 1)
counters.set(piece.name, (counter + 1) % count)
room.variant = counter
}
for (const [name, count] of usage) stats.notes.push(`piece "${name}" used ${String(count)} time(s)`)
}
/**
* Flatten the room list into one synthesized map.
*
* The size is the union of the stamped piece extents — `(span of the room
* origins) + (piece size)`. Because a piece is one cell larger than the stride,
* consecutive rooms overlap by exactly one cell and the piece stamped later owns
* that border, reproducing `DRLGPRESET_InitPresetRoomGrids`, which fills
* `nTileWidth + 1` cells per room. D2MOO stamps in roster order (newest first),
* so the level's *first* room is stamped last and wins every overlap it takes
* part in; the same order is used here.
*
* Objects keep their per-piece sub-tile coordinates and gain the room origin
* converted from cells at 5 sub-tiles per cell.
*
* Two 1.13c load-time behaviours are reproduced here rather than at draw time,
* because this is the only pass that sees both the piece and its place:
*
* - **`bKillEdge`** (`DrlgPreset.cpp:1105`, `DrlgRoomTile.cpp:449`). When the
* level type's `LvlPrest.KillEdge` is set, the room flush against the map's
* outer edge stamps `nTileWidth` cells instead of `nTileWidth + 1`, so the
* shared-border overhang that has no neighbour to be overwritten by is
* dropped instead of duplicating the wall line one cell past the map.
* - **Hidden door and exit walls** (`DrlgRoomTile.cpp:466`, `479`). A hidden
* door or exit tile is a marker, not artwork: 1.13c spawns a door unit or
* registers a warp and `continue`s, then backfills the threshold with the 2×2
* floor patch `DRLGROOMTILE_LoadFloorWarpTiles` writes. Emitting the wall
* instead leaves the doorway blocked and the threshold floor blank.
*
* @param level - the level.
* @param stats - report collector.
* @param killEdge - the level type's `LvlPrest.KillEdge`.
* @returns the synthesized map.
*/
function stamp(level: MazeLevel, stats: MazeStats, killEdge: boolean): Ds1 {
const minX = Math.min(...level.rooms.map(room => room.x))
const minY = Math.min(...level.rooms.map(room => room.y))
const maxX = Math.max(...level.rooms.map(room => room.x))
const maxY = Math.max(...level.rooms.map(room => room.y))
let wallLayers = 1
let floorLayers = 1
let substitutionType = 0
let version = 0
let act = 1
let widest = 1
let highest = 1
for (const room of level.rooms) {
const piece = room.piece
if (piece === null) continue
const variant = piece.levels[room.variant % piece.levels.length]
if (variant === undefined) throw new Error(`piece "${piece.name}" has no decoded map to stamp`)
wallLayers = Math.max(wallLayers, variant.wallLayers)
floorLayers = Math.max(floorLayers, variant.floorLayers)
substitutionType = Math.max(substitutionType, variant.substitutionType)
version = Math.max(version, variant.version)
act = variant.act
widest = Math.max(widest, variant.width)
highest = Math.max(highest, variant.height)
}
// `KillEdge` shortens the map by the overhang column and row that the
// right-most and bottom-most rooms would otherwise contribute. A piece is one
// cell wider than its section so that neighbours share a border; at the outer
// edge there is no neighbour to share with, so 1.13c drops it rather than
// letting the piece spill past `pDrlgCoord.nWidth`
// (`DrlgPreset.cpp:1109`, `DrlgRoomTile.cpp:449`).
const edgeTrim = killEdge ? 1 : 0
const width = (maxX - minX) + Math.max(1, widest - edgeTrim)
const height = (maxY - minY) + Math.max(1, highest - edgeTrim)
if (width <= 0 || height <= 0 || width > MAX_CELLS_PER_SIDE || height > MAX_CELLS_PER_SIDE
|| width * height > MAX_CELLS) {
throw new Error(`synthesized map is ${String(width)}x${String(height)} cells, outside the supported bound`)
}
const substitutionLayers = substitutionType === 1 || substitutionType === 2 ? 1 : 0
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: Array.from({ length: wallLayers }, emptyWall),
floors: Array.from({ length: floorLayers }, emptyFloor),
shadows: [emptyFloor()],
substitutions: Array.from({ length: substitutionLayers }, () => ({ value: 0 })),
})
}
cells.push(row)
}
const objects: Ds1Object[] = []
// Doorway cells discovered while blitting; their thresholds are backfilled
// after every room is down, so a neighbour's floor can serve as the donor.
const thresholds: { x: number; y: number }[] = []
let stampedRooms = 0
let killedEdgeX = 0
let killedEdgeY = 0
let hiddenWalls = 0
for (const room of level.rooms) {
const piece = room.piece
if (piece === null) continue
const variant = piece.levels[room.variant % piece.levels.length]!
const originX = room.x - minX
const originY = room.y - minY
// `bKillEdgeX = pDrlgRoom->nTileXPos + pDrlgRoom->nTileWidth == pDrlgCoord.nPosX + nWidth`
// — true only for the room flush against the map's outer edge, which is the
// one whose overhang has no neighbour to be overwritten by.
const killEdgeX = killEdge && originX + room.width >= width
const killEdgeY = killEdge && originY + room.height >= height
if (killEdgeX) killedEdgeX += 1
if (killEdgeY) killedEdgeY += 1
// `nTileCountX = pDrlgRoom->nTileWidth + (bKillEdgeX == 0)`. The clamp keeps
// an oddly-sized piece from running off the grid rather than trusting that
// every piece is exactly one cell wider than its section.
const spanX = Math.min(variant.width - (killEdgeX ? 1 : 0), width - originX)
const spanY = Math.min(variant.height - (killEdgeY ? 1 : 0), height - originY)
if (originX + spanX > width || originY + spanY > height) {
throw new Error(`piece "${piece.name}" overflows the ${String(width)}x${String(height)} map`)
}
for (let y = 0; y < spanY; y += 1) {
const source = variant.cells[y]
if (source === undefined) continue
const target = cells[originY + y]!
for (let x = 0; x < spanX; x += 1) {
const cell = source[x]
if (cell === undefined) continue
// 1.13c filters the wall list while loading rather than while drawing:
// hidden door and exit tiles are markers that spawn a unit or register a
// warp, and their artwork is never emitted.
const kept = cell.walls.filter(wallSurvivesLoad)
if (kept.length === cell.walls.length) {
target[originX + x] = cell
} else {
hiddenWalls += cell.walls.length - kept.length
while (kept.length < cell.walls.length) kept.push(emptyWall())
target[originX + x] = { ...cell, walls: kept }
if (cell.walls.some(needsThresholdBackfill)) {
thresholds.push({ x: originX + x, y: originY + y })
}
}
}
}
for (const object of variant.objects) {
objects.push({
type: object.type,
id: object.id,
x: originX * SUB_TILES_PER_TILE + object.x,
y: originY * SUB_TILES_PER_TILE + object.y,
flags: object.flags,
})
}
// This is the only pass that knows both which piece a room got and where
// that room ends up, so it is also the only place the stairs can be
// located. `Down` is the Act 1 cave descent and `Next` the ordinary one;
// they differ in artwork, not in meaning.
if (piece.kind === 'prev' || piece.kind === 'next' || piece.kind === 'down') {
stats.warps.push({
room: room.id,
direction: piece.kind === 'prev' ? 'up' : 'down',
kind: piece.kind,
x: originX,
y: originY,
width: variant.width,
height: variant.height,
centreX: originX + Math.floor(variant.width / 2),
centreY: originY + Math.floor(variant.height / 2),
})
}
if (piece.kind === 'waypoint') {
const wpObj = variant.objects.find(obj => obj.type === 2 && (obj.id === 119 || obj.id === 145 || obj.id === 156 || obj.id === 157 || obj.id === 237 || obj.id === 238 || obj.id === 288 || obj.id === 323 || obj.id === 324 || obj.id === 398 || obj.id === 402 || obj.id === 429)) ?? variant.objects.find(obj => obj.type === 2)
let entityX: number
let entityY: number
if (wpObj !== undefined) {
entityX = originX * SUB_TILES_PER_TILE + wpObj.x
entityY = originY * SUB_TILES_PER_TILE + wpObj.y
} else {
entityX = (originX + Math.floor(variant.width / 2)) * SUB_TILES_PER_TILE + 2
entityY = (originY + Math.floor(variant.height / 2)) * SUB_TILES_PER_TILE + 2
objects.push({
type: 2,
id: 119,
x: entityX,
y: entityY,
flags: 0,
})
}
stats.waypoints.push({
room: room.id,
x: originX,
y: originY,
width: variant.width,
height: variant.height,
centreX: originX + Math.floor(variant.width / 2),
centreY: originY + Math.floor(variant.height / 2),
entityX,
entityY,
})
}
stampedRooms += 1
}
// Thresholds are filled only after every room is down: a doorway sits on the
// border between two rooms, so the donor floor may belong to the neighbour
// that had not been stamped yet when the doorway was found.
let thresholdCells = 0
for (const at of thresholds) thresholdCells += backfillThresholdFloor(cells, at.x, at.y)
stats.notes.push(`stamped ${String(stampedRooms)} of ${String(level.rooms.length)} rooms`)
if (killEdge) {
stats.notes.push(`killEdge trimmed ${String(killedEdgeX)} east and ${String(killedEdgeY)} south room edges`)
}
if (hiddenWalls > 0) {
stats.notes.push(`hid ${String(hiddenWalls)} door/exit walls, backfilled ${String(thresholdCells)} threshold floor cells`)
}
return {
version: version === 0 ? 18 : version,
width,
height,
act,
substitutionType,
wallLayers,
floorLayers,
cells,
objects,
// The synthesized map is written into an asset pack rather than re-encoded,
// so there is no NPC-path section to point at.
npcPathOffset: null,
}
}
/** Largest synthesized map side, in cells. Well above the widest shipped maze. */
const MAX_CELLS_PER_SIDE = 4096
/** Largest synthesized map area, matching the decoder's own sanity limit. */
const MAX_CELLS = 1 << 22
/* ------------------------------------------------------------------------- *
* Connectivity
* ------------------------------------------------------------------------- */
/**
* Flood fill the doorway graph from the first room.
*
* The generator's correctness claim is "every placed room is reachable", so it is
* verified rather than asserted: growth attaches each room to an existing one and
* `Merge` only adds doorways, so this must always reach everything.
*
* @param level - the level.
* @returns the number of rooms reached.
*/
function reachableRooms(level: MazeLevel): number {
const seen = new Set<Room>([level.first])
const queue: Room[] = [level.first]
while (queue.length > 0) {
const room = queue.pop()!
for (const orth of room.orths) {
if (seen.has(orth.room)) continue
seen.add(orth.room)
queue.push(orth.room)
}
}
return seen.size
}
/* ------------------------------------------------------------------------- *
* Entry point
* ------------------------------------------------------------------------- */
/**
* Generate one random-maze level.
*
* The pipeline is D2MOO's `DRLGMAZE_GenerateLevel`: an opening (ring and/or the
* Catacombs arms and/or the tomb prologue), optional growth to `LvlMaze.Rooms`
* sections, a preset for every room from its side pattern, the `Theme` pass, the
* `Prev`/`Next`/quest replacement passes, and finally one synthesized map.
*
* @param request - the real table data for one `DrlgType == 1` level.
* @returns the synthesized map plus a report of what was done.
* @throws when the level cannot be built — an unknown level type, a placement
* stall, or rooms that ended up unreachable. The message always names the level,
* because an empty map silently baked into an asset pack is worse than a crash.
*/
export function generateMaze(request: MazeRequest): MazeResult {
const width = request.sectionSize
const height = request.sectionHeight ?? request.sectionSize
const where = `level ${String(request.levelId)} (${request.levelName})`
if (!Number.isFinite(width) || width <= 0) throw new Error(`${where}: LvlMaze.SizeX is ${String(width)}`)
if (!Number.isFinite(height) || height <= 0) throw new Error(`${where}: LvlMaze.SizeY is ${String(height)}`)
if (request.pieces.length === 0) throw new Error(`${where}: no usable LvlPrest pieces`)
if (!Number.isFinite(request.minRooms) || request.minRooms < 1) {
throw new Error(`${where}: LvlMaze.Rooms is ${String(request.minRooms)}`)
}
const levelTypeName = request.levelTypeName ?? inferLevelTypeName(request.pieces)
if (levelTypeName === null) throw new Error(`${where}: cannot infer the level type from the piece names`)
const profile = MAZE_LEVEL_TYPE_PROFILES.find(candidate => candidate.levelTypeName === levelTypeName)
const animSpeed = request.pieces.find(p => p.animSpeed && p.animSpeed > 0)?.animSpeed ?? profile?.animSpeed
if (profile === undefined) throw new Error(`${where}: no maze profile for level type "${levelTypeName}"`)
const stats: MazeStats = {
patterns: {}, fallbacks: [], specialsApplied: [], specialPassesApplied: [], unresolvedRoles: [], notes: [],
ringRooms: 0, placementAttempts: 0, warps: [], waypoints: [],
}
const rng = new Rng(request.seed)
const mergePerMille = Math.max(0, Math.min(1000, Math.floor(request.merge)))
const index = buildPieceIndex(request.pieces)
const maxCellsX = request.maxCellsX !== undefined
? request.maxCellsX
: (request.maxSectionsX !== undefined ? request.maxSectionsX * width : undefined)
const maxCellsY = request.maxCellsY !== undefined
? request.maxCellsY
: (request.maxSectionsY !== undefined ? request.maxSectionsY * height : undefined)
const level: MazeLevel = {
rooms: [], first: null as unknown as Room, width, height, rng, mergePerMille, nextId: 0, merges: 0,
maxCellsX, maxCellsY, stats,
}
const first = makeRoom(level)
const levelRef: MazeLevel = { ...level, rooms: [first], first }
// `first` was built against `level`, whose `rooms` array is the live one; copy
// the identity over rather than duplicating state.
Object.assign(level, levelRef)
// `DRLGMAZE_GetRandomRoomExFromLevel` draws uniformly over the roster and the
// level seed advances on every call, so the seed must be consumed in the same
// order as the passes below.
let target = request.minRooms
if (request.staffTombLevelId === request.levelId) target *= 3
else if (request.bossTombLevelId === request.levelId) target *= 2
const singleNames = profile.singleRoom?.[request.levelId]
if (singleNames !== undefined) {
const piece = singleNames.map(name => request.pieces.find(candidate => candidate.name === name))
.find(found => found !== undefined)
if (piece === undefined) {
for (const name of singleNames) stats.unresolvedRoles.push(name)
stats.notes.push('single-room level: no named preset resolved, using the first piece')
const fallback = request.pieces[0]!
first.piece = fallback
first.hasDs1 = true
noteSpecial(first, fallback, 'single room', stats)
} else {
first.piece = piece
first.hasDs1 = true
noteSpecial(first, piece, 'single room', stats)
}
} else {
if (profile.ring !== undefined) {
const roomsPerDirection = levelTypeName === 'Act 3 - Sewer' && request.levelId === 92 ? 5 : profile.ring
ringLayout(level, roomsPerDirection, index, stats)
}
if (profile.opening === 'catacombs') {
// D2MOO branches on the level id before rolling, so level 1 consumes no
// random number here.
const horizontal = request.levelId === 34 ? false : rng.int(0, 1) === 1
catacombsOpening(level, horizontal, request.pieces, index, stats)
}
if (profile.prevChain !== undefined) prevChain(level, profile.prevChain, request.pieces, index, stats)
if (profile.arcane === true) arcaneSanctuary(level, request.pieces, index, stats)
if (profile.lavaPairs === true) lavaPairs(level, request.pieces, index, stats)
if (profile.grow) stats.placementAttempts = growMaze(level, target, index, stats)
// Every room needs a piece before the replacement passes can look for the
// plain ones to convert.
for (const room of level.rooms) {
if (room.piece === null) pickRoomPreset(room, index, stats)
}
if (profile.theme) themeRoll(level, request.pieces, stats)
// One counter shared by every table, advanced once per call that happens.
let cursor = rng.int(0, 3)
for (const group of profile.specials) {
if (group.kind === 'waypoint') {
if (request.hasWaypoint === false) continue
if (request.hasWaypoint !== true) {
if (group.levelIds !== undefined && !group.levelIds.includes(request.levelId)) continue
if (group.exceptLevelIds !== undefined && group.exceptLevelIds.includes(request.levelId)) continue
}
} else {
if (group.levelIds !== undefined && !group.levelIds.includes(request.levelId)) continue
if (group.exceptLevelIds !== undefined && group.exceptLevelIds.includes(request.levelId)) continue
}
placeSpecialGroup(level, request.pieces, group, cursor, index, stats)
cursor = (cursor + 1) % 4
}
}
if (profile.specialPass !== undefined) {
runMazeSpecialPasses(profile.specialPass, {
level,
request,
pieces: request.pieces,
index,
stats,
rng,
})
}
const unreachable = level.rooms.length - reachableRooms(level)
if (unreachable !== 0) {
throw new Error(`${where}: ${String(unreachable)} of ${String(level.rooms.length)} rooms are unreachable`)
}
const orphans = level.rooms.filter(room => room.piece === null).length
if (orphans !== 0) throw new Error(`${where}: ${String(orphans)} rooms ended up with no piece`)
assignVariants(level, request.levelId, stats)
// 1.13c reads `dwKillEdge` off the maze map's own `LvlPrest` record. The
// shipped table sets the column uniformly across a level type's piece rows, so
// deriving it from the pieces gives the same answer without asking callers to
// look the maze map up separately.
const killEdge = request.killEdge ?? request.pieces.some(piece => piece.killEdge === true)
const stamped = stamp(level, stats, killEdge)
const minX = Math.min(...level.rooms.map(room => room.x))
const minY = Math.min(...level.rooms.map(room => room.y))
const roomDescriptors: MazeRoomDescriptor[] = level.rooms.map(room => {
const variant = room.piece ? room.piece.levels[room.variant % room.piece.levels.length] : undefined
return {
id: room.id,
x: room.x,
y: room.y,
width: variant?.width ?? width,
height: variant?.height ?? height,
orths: room.orths,
piece: room.piece,
}
})
const popOptions: MazeObjectPopulationOptions | undefined =
typeof request.objectDensity === 'number'
? { density: request.objectDensity, ...request.objectOptions }
: { ...request.objectDensity, ...request.objectOptions }
const objectRng = popOptions?.objectSeed !== undefined
? new Rng(popOptions.objectSeed)
: rng.fork('dungeon-objects')
const popResult = populateDungeonObjects(
roomDescriptors,
minX,
minY,
stamped,
levelTypeName,
request.levelId,
popOptions,
objectRng,
)
const finalLevel: Ds1 = {
...stamped,
objects: [...stamped.objects, ...popResult.placedObjects],
}
return {
level: finalLevel,
objects: popResult.placedObjects,
stats: {
levelId: request.levelId,
levelName: request.levelName,
levelTypeName,
seed: request.seed,
sectionX: width,
sectionY: height,
targetRooms: target,
roomsPlaced: level.rooms.length,
ringRooms: stats.ringRooms,
placementAttempts: stats.placementAttempts,
doorways: level.rooms.reduce((sum, room) => sum + room.orths.length, 0) / 2,
mergePerMille,
mergedDoorways: level.merges,
gridSize: { width: stamped.width, height: stamped.height },
gridSizeSubtiles: { width: stamped.width * SUB_TILES_PER_TILE, height: stamped.height * SUB_TILES_PER_TILE },
objects: finalLevel.objects.length,
dynamicObjects: popResult.placedObjects,
dynamicObjectsCount: popResult.stats.dynamicObjectsCount,
shrinesSpawned: popResult.stats.shrinesSpawned,
chestsSpawned: popResult.stats.chestsSpawned,
superChestsSpawned: popResult.stats.superChestsSpawned,
racksSpawned: popResult.stats.racksSpawned,
containersSpawned: popResult.stats.containersSpawned,
cells: stamped.width * stamped.height,
sidePatterns: stats.patterns,
fallbacks: stats.fallbacks,
specialsApplied: stats.specialsApplied,
specialPassesApplied: stats.specialPassesApplied,
warps: stats.warps,
waypoints: stats.waypoints,
waypoint: stats.waypoints[0] ?? null,
waypointTile: stats.waypoints[0]
? {
x: Math.floor(stats.waypoints[0].entityX / SUB_TILES_PER_TILE),
y: Math.floor(stats.waypoints[0].entityY / SUB_TILES_PER_TILE),
}
: null,
unresolvedRoles: stats.unresolvedRoles,
unimplementedPasses: unimplementedPassesFor(levelTypeName),
maxCellsX,
maxCellsY,
boundsRejected: stats.boundsRejected ?? 0,
notes: stats.notes,
...(animSpeed !== undefined ? { animSpeed } : {}),
},
...(animSpeed !== undefined ? { animSpeed } : {}),
}
}