diablo2-web/src/game/maze.ts

1821 lines
71 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 { Rng } from './rng.ts'
import { SUB_TILES_PER_TILE } from './map.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'
/**
* 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[]
}
/** 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[]
/**
* `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
}
/** The generated level. */
export interface MazeResult {
/** A single synthesized map covering every placed section. */
readonly level: Ds1
/** What the generator did, for reporting and tests. */
readonly stats: Record<string, unknown>
}
/* ------------------------------------------------------------------------- *
* 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'],
['Chest', 'treasure'],
['Treasure', 'treasure'],
['Down', 'down'],
['Next', 'next'],
['Prev', 'prev'],
['Theme', 'theme'],
]
/** Which `LvlPrest` name families belong to which maze level type. */
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 5 - Ice Caves': ['Act 5 - Ice Caves', 'Act 5 - Ice'],
'Act 5 - Temple': ['Act 5 - Temple'],
'Act 5 - Baal': ['Act 5 - Baal'],
}
/**
* 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.
* @returns the kind and side token, or `null` when the name is not a piece.
*/
export function classifyMazePieceName(name: string, levelTypeName: string): { kind: MazePieceKind; sides: string } | null {
const families = MAZE_PIECE_FAMILIES[levelTypeName] ?? [levelTypeName]
const matchedFamily = families.find(family => name.startsWith(family))
if (matchedFamily === undefined) return null
const rest = name.slice(matchedFamily.length).trim()
if (rest === '') return { kind: 'entrance', sides: '' }
for (const [prefix, kind] of KIND_PREFIXES) {
if (!rest.startsWith(prefix)) continue
const tail = rest.slice(prefix.length).trim()
// `Treasure 1` is numbered rather than sided; everything else uses sides.
if (kind === 'treasure' && /^\d+$/.test(tail)) return { kind, sides: '' }
return { kind, sides: tail }
}
// Anything left is a bare side token: `W`, `EW`, `NSEW`, ...
if (/^[NSEW]+$/.test(rest)) return { kind: 'room', sides: rest }
if (/^(River|Pool|Tainted Sun)/.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-]+$/, '')
return trimmed === '' ? null : 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'
/** 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[]
}
/** 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 —
* are **not** reproduced; they are listed 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: [],
},
{
levelTypeName: 'Act 1 - Jail',
ring: 2,
grow: true,
theme: true,
specials: [
{ kind: 'prev', entries: entries() },
{ kind: 'treasure', 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: 'quest', entries: entries(), levelIds: [31] },
],
},
{
levelTypeName: 'Act 1 - Catacombs',
grow: true,
theme: true,
opening: 'catacombs',
specials: [
{ kind: 'next', entries: entries() },
{ kind: 'treasure', entries: entries(), levelIds: [35] },
],
},
{ levelTypeName: 'Act 2 - Sewer', ring: 2, grow: true, theme: true, specials: [] },
{
levelTypeName: 'Act 2 - Tomb',
grow: true,
theme: true,
prevChain: 'tomb',
singleRoom: { 61: ['Act 2 - Tomb Tainted Sun X'] },
specials: [],
},
{ levelTypeName: 'Act 2 - Lair', ring: 2, grow: true, theme: false, specials: [] },
{
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: [] },
{ levelTypeName: 'Act 2 - Basement', ring: 2, grow: false, theme: false, specials: [] },
{ levelTypeName: 'Act 3 - Spider', ring: 2, grow: false, theme: false, specials: [] },
{ levelTypeName: 'Act 3 - Kurast', ring: 2, grow: true, theme: true, specials: [] },
{ levelTypeName: 'Act 3 - Dungeon', ring: 2, grow: true, theme: true, specials: [] },
{
levelTypeName: 'Act 3 - Sewer',
ring: 2,
grow: true,
theme: true,
specials: [
// Sewer 1 runs `InitBasicMazeLayout(5)` and then four `ReplaceRoomPreset`
// calls that turn the ring's corners into staircases.
{
kind: 'prev',
entries: [
{ plainSides: 'SW', sides: 'SW', direction: DIRECTION_SOUTH },
{ plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH },
{ plainSides: 'NW', sides: 'NW', direction: DIRECTION_NORTH },
{ plainSides: 'NE', sides: 'NE', direction: DIRECTION_NORTH },
],
levelIds: [92],
inPlaceOnly: true,
},
],
},
{ levelTypeName: 'Act 4 - Lava', grow: true, theme: false, specials: [] },
{
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: [],
},
{ levelTypeName: 'Act 5 - Temple', ring: 2, grow: false, theme: false, specials: [] },
{ levelTypeName: 'Act 5 - Baal', grow: true, theme: false, prevChain: 'baal', specials: [] },
{ levelTypeName: 'Act 5 - Lava', grow: false, theme: false, lavaPairs: true, specials: [] },
]
/**
* `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']
/**
* Per-act decoration passes D2MOO runs after the layout, which this port does not
* reproduce. Reported verbatim in every result's `stats.unimplementedPasses`.
*/
const UNIMPLEMENTED_PASSES: readonly string[] = [
'DRLGMAZE_PlaceAct2TombStuff',
'DRLGMAZE_PlaceAct2LairStuff',
'DRLGMAZE_PlaceAct3DungeonStuff',
'DRLGMAZE_PlaceAct3SewerStuff',
'DRLGMAZE_PlaceAct3MephistoStuff',
'DRLGMAZE_PlaceAct5TempleStuff',
'DRLGMAZE_PlaceAct5BaalStuff',
'DRLGMAZE_PlaceAct5IceStuff',
'DRLGMAZE_PlaceAct1Barracks',
'DRLGMAZE_PlaceAct4Lava',
'DRLGMAZE_ScanReplaceSpecialAct2SewersPresets',
]
/* ------------------------------------------------------------------------- *
* 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. */
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. */
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
}
/**
* 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.
*/
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 unresolvedRoles: string[]
readonly notes: string[]
ringRooms: number
placementAttempts: 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.
*/
function roomSideMask(room: Room): number {
let mask = 0
for (const orth of room.orths) mask |= DIRECTION_SIDE_BIT[orth.direction]!
return mask
}
/**
* Whether a room overlaps anything other than itself and one ignored room.
*
* `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.
*/
function checkRoomNotOverlapping(level: MazeLevel, room: Rect, ignored: Room | null): boolean {
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.
*/
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. */
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)
}
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.
*/
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.
*/
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.
*/
function fillBlankSpaces(level: MazeLevel, pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): 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]) {
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
}
}
/**
* One `DRLGMAZE_ScanReplaceSpecialPreset` (or `DRLGMAZE_ReplaceRoomPreset`) call.
*
* @param level - the level.
* @param pieces - the level's pieces.
* @param step - which special to place and where.
* @param index - the piece index.
* @param stats - report collector.
*/
function scanReplaceSpecial(
level: MazeLevel,
pieces: readonly MazePiece[],
step: SpecialStep,
index: PieceIndex,
stats: MazeStats,
): void {
const wanted = sideMaskFromToken(step.plainSides)
const token = sideTokenFromMask(wanted)
const special = findSpecial(pieces, step.kind, step.sides)
if (special === undefined) {
if (!stats.unresolvedRoles.includes(`${step.kind} ${step.sides}`)) {
stats.unresolvedRoles.push(`${step.kind} ${step.sides}`)
}
return
}
const existing = findUnfinishedRoom(level, wanted)
if (existing !== null) {
existing.piece = special
existing.variant = 0
existing.hasDs1 = true
noteSpecial(existing, special, token, stats)
return
}
if (step.inPlaceOnly === true) {
stats.notes.push(`no ${token} room left for a ${step.kind} ${step.sides} replacement`)
return
}
// Nothing of that shape was left, so a room is grown to hold the special.
for (const candidate of [...level.rooms]) {
if (candidate.hasDs1) continue
const created = addAdjacentMazeRoom(level, candidate, step.direction, false, true, index, stats)
if (created === null) continue
created.piece = special
created.variant = 0
created.hasDs1 = true
noteSpecial(created, special, token, stats)
return
}
stats.notes.push(`could not place ${step.kind} ${step.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 }
}
/**
* 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 stats - report collector.
*/
function assignVariants(level: MazeLevel, 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
let counter = counters.get(piece.name)
if (counter === undefined) counter = level.rng.int(0, piece.levels.length - 1)
counters.set(piece.name, (counter + 1) % piece.levels.length)
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.
*
* @param level - the level.
* @param stats - report collector.
* @returns the synthesized map.
*/
function stamp(level: MazeLevel, stats: MazeStats): 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)
}
const width = (maxX - minX) + widest
const height = (maxY - minY) + highest
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[] = []
let stampedRooms = 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
if (originX + variant.width > width || originY + variant.height > height) {
throw new Error(`piece "${piece.name}" overflows the ${String(width)}x${String(height)} map`)
}
for (let y = 0; y < variant.height; y += 1) {
const source = variant.cells[y]
if (source === undefined) continue
const target = cells[originY + y]!
for (let x = 0; x < variant.width; x += 1) {
const cell = source[x]
if (cell === undefined) continue
target[originX + x] = cell
}
}
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,
})
}
stampedRooms += 1
}
stats.notes.push(`stamped ${String(stampedRooms)} of ${String(level.rooms.length)} rooms`)
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)
if (profile === undefined) throw new Error(`${where}: no maze profile for level type "${levelTypeName}"`)
const stats: MazeStats = {
patterns: {}, fallbacks: [], specialsApplied: [], unresolvedRoles: [], notes: [],
ringRooms: 0, placementAttempts: 0,
}
const rng = new Rng(request.seed)
const mergePerMille = Math.max(0, Math.min(1000, Math.floor(request.merge)))
const index = buildPieceIndex(request.pieces)
const level: MazeLevel = {
rooms: [], first: null as unknown as Room, width, height, rng, mergePerMille, nextId: 0, merges: 0,
}
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.levelIds !== undefined && !group.levelIds.includes(request.levelId)) continue
if (group.exceptLevelIds !== undefined && group.exceptLevelIds.includes(request.levelId)) continue
const entry = group.entries[cursor]!
scanReplaceSpecial(level, request.pieces, { ...entry, kind: group.kind, ...(group.inPlaceOnly === true ? { inPlaceOnly: true } : {}) }, index, stats)
cursor = (cursor + 1) % 4
}
}
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, stats)
const stamped = stamp(level, stats)
return {
level: stamped,
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: stamped.objects.length,
cells: stamped.width * stamped.height,
sidePatterns: stats.patterns,
fallbacks: stats.fallbacks,
specialsApplied: stats.specialsApplied,
unresolvedRoles: stats.unresolvedRoles,
unimplementedPasses: UNIMPLEMENTED_PASSES,
notes: stats.notes,
},
}
}