/** * 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 /** Animated tile playback speed from `LvlPrest.txt` `Animate` column. */ readonly animSpeed?: number } /* ------------------------------------------------------------------------- * * Classification * ------------------------------------------------------------------------- */ /** Roles whose names are spelled ` ` 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> = { '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() 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 ` [] []`: `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[] /** 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> /** `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[] { 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> 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() 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 { const start = level.rooms.find(r => r.piece?.kind === 'prev' || r.piece?.kind === 'entrance') ?? level.first const dist = new Map([[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() const usage = new Map() // 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([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 } : {}), } }