2043 lines
79 KiB
TypeScript
2043 lines
79 KiB
TypeScript
/**
|
||
* Diablo II's random-maze level generator (`Levels.txt.DrlgType == 1`).
|
||
*
|
||
* 70 of the game's 136 levels have no fixed layout: their shape is produced at
|
||
* load time from `LvlMaze.txt` (how many sections, how big each section is, and
|
||
* how eagerly neighbouring sections are joined) plus the level type's
|
||
* `LvlPrest.txt` *piece* rows — each piece a DS1 that knows which of its four
|
||
* sides has a doorway. This module rebuilds that generator and emits a single
|
||
* synthesized {@link Ds1} the existing isometric renderer can draw unmodified.
|
||
*
|
||
* ## Provenance
|
||
*
|
||
* This is an **independent TypeScript port** of the level-generation logic in
|
||
* **ThePhrozenKeep/D2MOO** ("Diablo II Method and Ordinal Overhaul", a C++
|
||
* re-implementation of Diablo II 1.10f). D2MOO ships no licence file, so no code
|
||
* was copied: every algorithm below was read from that project and re-expressed
|
||
* here in this repository's own structure and naming, with the source function
|
||
* cited at each step. The algorithms themselves are Blizzard's, and D2MOO's
|
||
* contribution is having recovered them from the shipped binary.
|
||
*
|
||
* Primary sources, all under `source/D2Common/` in D2MOO:
|
||
*
|
||
* - `src/Drlg/DrlgMaze.cpp` — `DRLGMAZE_GenerateLevel` (the per-level-type
|
||
* dispatch), `DRLGMAZE_AddAdjacentMazeRoom` + `DRLGMAZE_BuildBasicMaze` +
|
||
* `DRLGMAZE_MergeMazeRooms` (placement and the `Merge` roll),
|
||
* `DRLGMAZE_InitBasicMazeLayout` (the ring layout), `DRLGMAZE_PickRoomPreset`
|
||
* (side bitmask → piece), `DRLGMAZE_RollAct_1_2_3_BasicPresets` (the `Theme`
|
||
* pass), `DRLGMAZE_ScanReplaceSpecialPreset` (`Prev`/`Next`/quest replacement),
|
||
* `DRLGMAZE_PlaceArcaneSanctuary`, `DRLGMAZE_PlaceAct5LavaPresets`,
|
||
* `DRLGMAZE_FillBlankMazeSpaces`, `DRLGMAZE_PlaceAct2TombPrev_Act5BaalPrev`.
|
||
* - `src/Drlg/DrlgDrlgRoom.cpp` — `DRLG_ComputeManhattanDistance`,
|
||
* `DRLG_GetRectanglesManhattanDistanceAndCheckNotOverlapping`,
|
||
* `DRLGMAZE_CheckRoomNotOverlaping`, `DRLGROOM_AllocDrlgOrthsForRooms`.
|
||
* - `src/Drlg/DrlgDrlg.cpp` — `DRLG_GetDirectionFromCoordinates`,
|
||
* `DRLG_UpdateRoomExCoordinates`.
|
||
* - `src/Drlg/DrlgPreset.cpp` — `DRLGPRESET_InitPresetRoomGrids` and
|
||
* `DRLGPRESET_AddPresetUnitToDrlgMap`, which fix the stamping geometry and the
|
||
* unit of a DS1 object's coordinates.
|
||
* - `doc/Coordinates.md` — the coordinate systems.
|
||
*
|
||
* ## What D2MOO settles that this repository did not know
|
||
*
|
||
* **`LvlMaze.Merge` is a per-mille probability, not a flag.** Both
|
||
* `DRLGMAZE_AddAdjacentMazeRoom` (inline) and `DRLGMAZE_MergeMazeRooms` contain
|
||
* `if (SEED_RollRandomNumber(&i->pSeed) % 1000 < pLevel->pMaze->dwMerge)` before
|
||
* adding an extra doorway. So while a level grows, every *accidental* adjacency
|
||
* between the room just placed and some other already-placed room (orthogonally
|
||
* touching, not already connected) is joined with probability `Merge / 1000`.
|
||
* `Merge = 0` yields a tree (Act 1's cave treasures), `Merge = 500` joins about
|
||
* half of those contacts, and `Merge = 1000` (Act 2's Harem, Basement and the
|
||
* Arcane Sanctuary) joins all of them. The shipped values are 0, 500, 800 and
|
||
* 1000, which is why a per-mille reading is the only one that fits. The knowledge
|
||
* base's "possibly related to how adjacent ds1s are connected, but what the
|
||
* different values are for is unknown" is answered: it controls how many *loops*
|
||
* the maze has, i.e. how many alternate routes exist between rooms.
|
||
*
|
||
* **Connectivity is structural, not luck.** Growth only ever attaches a new room
|
||
* to an existing one and immediately records a doorway between them
|
||
* (`DRLGROOM_AllocDrlgOrthsForRooms`), so the doorway graph always contains a
|
||
* spanning tree rooted at the first room. `Merge` only ever *adds* doorways.
|
||
* {@link generateMaze} re-derives that with a flood fill and throws if it ever
|
||
* fails, so the guarantee is checked rather than asserted.
|
||
*
|
||
* **A maze piece is exactly one cell larger than its section.** `LvlMaze.SizeX`
|
||
* is the placement stride (24 in Act 1) while the piece DS1 is 25×25 — verified
|
||
* against the shipped archives: `Act1/Caves/CaveW.ds1` is 25×25 with
|
||
* `LvlPrest.SizeX = 24`. `DRLGPRESET_InitPresetRoomGrids` confirms the intent: it
|
||
* fills a room grid of `nTileWidth + 1` by `nTileHeight + 1` cells from the
|
||
* piece, so adjacent pieces overlap by exactly one cell and the later-stamped
|
||
* piece owns the shared border. Both halves of that border are authored to match,
|
||
* so the overwrite is harmless. The repository's assumption was correct.
|
||
*
|
||
* **Objects are in sub-tiles, cells are in cells.**
|
||
* `DRLGPRESET_AddPresetUnitToDrlgMap` converts the room's *tile* origin with
|
||
* `DUNGEON_GameTileToSubtileCoords` (×5) and *then* adds the DS1 object's own
|
||
* coordinates, which are therefore already sub-tiles relative to the piece. A
|
||
* stamp offset of `N` cells shifts an object by `N * 5` sub-tiles, exactly as
|
||
* this repository assumed.
|
||
*/
|
||
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.ts'
|
||
import { Rng } from './rng.ts'
|
||
import { SUB_TILES_PER_TILE } from './map.ts'
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Directions
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* Direction indices, in D2MOO's `D2AltDirections` order.
|
||
*
|
||
* `DRLG_GetDirectionFromCoordinates` returns the `D2Directions` *names* but the
|
||
* same numbers, and D2MOO relies on that coincidence: `DIRECTION_SOUTHWEST` is 0
|
||
* and `ALTDIR_WEST` is 0, `DIRECTION_NORTHWEST`/`ALTDIR_NORTH` are 1,
|
||
* `DIRECTION_SOUTHEAST`/`ALTDIR_EAST` are 2 and `DIRECTION_NORTHEAST`/`ALTDIR_SOUTH`
|
||
* are 3. Both are reproduced here as one space, since a maze direction is always
|
||
* an offset on the cell grid.
|
||
*
|
||
* The signs are the ones `doc/Coordinates.md` states: East is +X, West is -X,
|
||
* South is +Y, North is -Y. Note that D2's *names* for the quarter directions do
|
||
* not mean screen directions — `DIRECTION_NORTHEAST` is +Y, i.e. towards the
|
||
* bottom-left of the isometric view. Only the numbers are meaningful.
|
||
*/
|
||
export const DIRECTION_WEST = 0
|
||
/** North is -Y in game coordinates. */
|
||
export const DIRECTION_NORTH = 1
|
||
/** East is +X in game coordinates. */
|
||
export const DIRECTION_EAST = 2
|
||
/** South is +Y in game coordinates. */
|
||
export const DIRECTION_SOUTH = 3
|
||
|
||
/**
|
||
* The side bit `DRLGMAZE_PickRoomPreset` sets for each direction.
|
||
*
|
||
* It ORs 1 for direction 0, 8 for 1, 2 for 2 and 4 for 3, so the resulting 4-bit
|
||
* value is **W=1, E=2, S=4, N=8**. That looks arbitrary until it is compared with
|
||
* `LvlPrest.txt`: side tokens are written with their letters in N,S,E,W order and
|
||
* read as a bitfield most-significant-bit first, so `NS` = 8+4 = 12, `EW` = 2+1 =
|
||
* 3 and `NSEW` = 15. Verified against the real table — `Act 1 - Cave
|
||
* W/E/EW/S/SW/SE/SEW/N/NW/NE/NEW/NS/NSW/NSE/NSEW` are fifteen consecutive rows
|
||
* following the level type's base row, so a piece's row number is `base + mask`.
|
||
*/
|
||
const DIRECTION_SIDE_BIT = [1, 8, 2, 4] as const
|
||
|
||
/**
|
||
* Cell offset for one step in a direction.
|
||
*
|
||
* Directions 4..7 are the diagonals, which only `DRLGMAZE_FillBlankMazeSpaces`
|
||
* uses (it tries all eight). Quarters and diagonals were cross-checked against
|
||
* `DRLGMAZE_LinkMazeRooms`' eight `case`s.
|
||
*
|
||
* @param direction - 0..7.
|
||
* @param width - one section's width in cells.
|
||
* @param height - one section's height in cells.
|
||
* @returns the offset.
|
||
*/
|
||
function step(direction: number, width: number, height: number): { dx: number; dy: number } {
|
||
const dx = (direction === DIRECTION_WEST || direction === 4 || direction === 7 ? -width
|
||
: direction === DIRECTION_EAST || direction === 5 || direction === 6 ? width : 0)
|
||
const dy = (direction === DIRECTION_NORTH || direction === 4 || direction === 5 ? -height
|
||
: direction === DIRECTION_SOUTH || direction === 6 || direction === 7 ? height : 0)
|
||
return { dx, dy }
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Side tokens
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/** The four side letters, in the order `LvlPrest.txt` writes them. */
|
||
const SIDE_LETTERS = ['N', 'S', 'E', 'W'] as const
|
||
|
||
/**
|
||
* Decode a side token such as `NSEW`, `EW` or `N` into a bitmask.
|
||
*
|
||
* @param token - the token; characters other than `NSEW` are ignored, so a whole
|
||
* piece name may be passed.
|
||
* @returns the mask, 0..15.
|
||
*/
|
||
export function sideMaskFromToken(token: string): number {
|
||
let mask = 0
|
||
for (const letter of token.toUpperCase()) {
|
||
const index = SIDE_LETTERS.indexOf(letter as (typeof SIDE_LETTERS)[number])
|
||
if (index !== -1) mask |= 1 << (3 - index)
|
||
}
|
||
return mask
|
||
}
|
||
|
||
/**
|
||
* Encode a bitmask the way `LvlPrest.txt` spells it.
|
||
*
|
||
* @param mask - the mask, 0..15.
|
||
* @returns the token, e.g. `NSEW`; an empty string for 0.
|
||
*/
|
||
export function sideTokenFromMask(mask: number): string {
|
||
let token = ''
|
||
SIDE_LETTERS.forEach((letter, index) => {
|
||
if ((mask & (1 << (3 - index))) !== 0) token += letter
|
||
})
|
||
return token
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Public request / response shapes
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/** How a maze piece participates in generation. */
|
||
export type MazePieceKind =
|
||
| 'entrance'
|
||
| 'room'
|
||
| 'theme'
|
||
| 'prev'
|
||
| 'next'
|
||
| 'down'
|
||
| 'quest'
|
||
| 'treasure'
|
||
|
||
/**
|
||
* One `LvlPrest.txt` row that can be stamped into a maze level.
|
||
*
|
||
* The caller owns the classification, because it is the only party that knows
|
||
* which DS1 files sit behind each row. {@link classifyMazePieceName} does that
|
||
* classification for callers that want it, since the trailing-token grammar is
|
||
* subtle enough to be worth centralising.
|
||
*/
|
||
export interface MazePiece {
|
||
/** `LvlPrest.txt` `Name`, e.g. `Act 1 - Cave NSEW`. */
|
||
readonly name: string
|
||
/** Which generator pass may place this piece. */
|
||
readonly kind: MazePieceKind
|
||
/**
|
||
* The side token decoded from the name — `NSEW`, `EW`, `N`, or `''` for a piece
|
||
* with no doorways (`Entrance`).
|
||
*/
|
||
readonly sides: string
|
||
/**
|
||
* Decoded variants, in `File1..File6` order.
|
||
*
|
||
* A piece usually has several: `Act 1 - Cave W` ships `CaveW.ds1` and
|
||
* `CaveW2.ds1`. D2MOO walks them round-robin per piece so consecutive rooms of
|
||
* the same shape do not look identical (`DRLGMAZE_RollBasicPresets`), and
|
||
* {@link generateMaze} does the same.
|
||
*/
|
||
readonly levels: readonly Ds1[]
|
||
}
|
||
|
||
/** Everything {@link generateMaze} needs. */
|
||
export interface MazeRequest {
|
||
/** `Levels.txt` `Id`; used in diagnostics and for a few per-level specials. */
|
||
readonly levelId: number
|
||
/** `Levels.txt` `Name`, e.g. `Act 1 - Cave 1`. */
|
||
readonly levelName: string
|
||
/** `LvlMaze.txt` `SizeX`: the section stride in cells. */
|
||
readonly sectionSize: number
|
||
/**
|
||
* `LvlMaze.txt` `SizeY`.
|
||
*
|
||
* Defaults to {@link sectionSize}. It is a separate field because the two
|
||
* genuinely differ for Act 1's Barracks (`SizeX = 10`, `SizeY = 14`), and a
|
||
* single number would silently produce a wrong layout there.
|
||
*/
|
||
readonly sectionHeight?: number
|
||
/** `LvlMaze.txt` `Rooms`: the target number of sections. */
|
||
readonly minRooms: number
|
||
/**
|
||
* `LvlMaze.txt` `Merge`, in per-mille.
|
||
*
|
||
* Values in the shipped table are 0, 500, 800 and 1000.
|
||
*/
|
||
readonly merge: number
|
||
/** Seed for the whole generator; reported back in `stats`. */
|
||
readonly seed: number
|
||
/** The level type's pieces, classified by kind and side token. */
|
||
readonly pieces: readonly MazePiece[]
|
||
/**
|
||
* `LvlTypes.txt` name, e.g. `Act 1 - Cave`.
|
||
*
|
||
* Optional because it is derivable from {@link pieces} — every piece name
|
||
* starts with it — and the derivation is attempted when it is absent.
|
||
*/
|
||
readonly levelTypeName?: string
|
||
/**
|
||
* `Levels.txt` `Id` of the tomb holding the Horadric Staff, when the caller
|
||
* knows it. In the shipped game this is one of the seven Tal Rasha tombs and
|
||
* that tomb's `LvlMaze.Rooms` is tripled.
|
||
*/
|
||
readonly staffTombLevelId?: number
|
||
/** Boss tomb (`LvlMaze.Rooms` doubled); see {@link staffTombLevelId}. */
|
||
readonly bossTombLevelId?: number
|
||
}
|
||
|
||
/** The generated level. */
|
||
export interface MazeResult {
|
||
/** A single synthesized map covering every placed section. */
|
||
readonly level: Ds1
|
||
/** What the generator did, for reporting and tests. */
|
||
readonly stats: Record<string, unknown>
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Classification
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/** Roles whose names are spelled `<role> <sides>` after the level type. */
|
||
const KIND_PREFIXES: readonly (readonly [string, MazePieceKind])[] = [
|
||
['Den Of Evil', 'quest'],
|
||
['Coldcrow', 'quest'],
|
||
['Pitspawn', 'quest'],
|
||
['Bonebreak', 'quest'],
|
||
['Chest', 'treasure'],
|
||
['Treasure', 'treasure'],
|
||
['Cath', 'down'],
|
||
['Drain', 'next'],
|
||
['Warp', 'prev'],
|
||
['Down', 'down'],
|
||
['Next', 'next'],
|
||
['Prev', 'prev'],
|
||
['Theme', 'theme'],
|
||
]
|
||
|
||
/** Which `LvlPrest` name families belong to which maze level type. */
|
||
export const MAZE_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
||
'Act 1 - Cave': ['Act 1 - Cave'],
|
||
'Act 1 - Crypt': ['Act 1 - Crypt'],
|
||
'Act 1 - Courtyard': ['Act 1 - Courtyard'],
|
||
'Act 1 - Barracks': ['Act 1 - Barracks'],
|
||
'Act 1 - Jail': ['Act 1 - Jail'],
|
||
'Act 1 - Catacombs': ['Act 1 - Catacombs'],
|
||
'Act 2 - Sewer': ['Act 2 - Sewer'],
|
||
'Act 2 - Harem': ['Act 2 - Corrupt Harem', 'Act 2 - Harem'],
|
||
'Act 2 - Basement': ['Act 2 - Basement'],
|
||
'Act 2 - Tomb': ['Act 2 - Tomb'],
|
||
'Act 2 - Lair': ['Act 2 - Lair'],
|
||
'Act 2 - Sanctuary': ['Act 2 - Sanctuary'],
|
||
'Act 3 - Sewer': ['Act 3 - Sewer'],
|
||
'Act 3 - Kurast': ['Act 3 - Mephisto', 'Act 3 - Kurast'],
|
||
'Act 5 - Ice Caves': ['Act 5 - Ice Caves', 'Act 5 - Ice'],
|
||
'Act 5 - Temple': ['Act 5 - Temple'],
|
||
'Act 5 - Baal': ['Act 5 - Baal'],
|
||
}
|
||
|
||
/**
|
||
* Classify one `LvlPrest.txt` piece name.
|
||
*
|
||
* The grammar is `<level type name> [<role>] [<sides>]`: `Act 1 - Cave NSEW` is a
|
||
* plain room, `Act 1 - Cave Theme NS` a themed variant, `Act 1 - Cave Prev W` a
|
||
* staircase, `Act 1 - Cave Treasure 3` a numbered treasure room. A name that does
|
||
* not start with the level type's name is not a piece of that type and yields
|
||
* `null`, which is how `Act 1 - DOE Entrance` stays out of the `Act 1 - Cave`
|
||
* group even though D2MOO treats its row as that group's base.
|
||
*
|
||
* @param name - the `LvlPrest.txt` `Name`.
|
||
* @param levelTypeName - the owning `LvlTypes.txt` name.
|
||
* @returns the kind and side token, or `null` when the name is not a piece.
|
||
*/
|
||
export function classifyMazePieceName(name: string, levelTypeName: string): { kind: MazePieceKind; sides: string } | null {
|
||
const families = MAZE_PIECE_FAMILIES[levelTypeName] ?? [levelTypeName]
|
||
const matchedFamily = families.find(family => name.startsWith(family))
|
||
if (matchedFamily === undefined) return null
|
||
const rest = name.slice(matchedFamily.length).trim()
|
||
if (rest === '') return { kind: 'entrance', sides: '' }
|
||
for (const [prefix, kind] of KIND_PREFIXES) {
|
||
if (!rest.startsWith(prefix)) continue
|
||
const tail = rest.slice(prefix.length).trim()
|
||
// `Treasure 1` is numbered rather than sided; everything else uses sides.
|
||
if (kind === 'treasure' && /^\d+$/.test(tail)) return { kind, sides: '' }
|
||
return { kind, sides: tail }
|
||
}
|
||
const reversedMatch = /^([NSEW]+)\s+(up|down)$/i.exec(rest)
|
||
if (reversedMatch !== null) {
|
||
const sides = reversedMatch[1]!.toUpperCase()
|
||
const role = reversedMatch[2]!.toLowerCase()
|
||
return { kind: role === 'up' ? 'prev' : 'next', sides }
|
||
}
|
||
// Anything left is a bare side token: `W`, `EW`, `NSEW`, ...
|
||
if (/^[NSEW]+$/.test(rest)) return { kind: 'room', sides: rest }
|
||
if (/^(River|Pool|Tainted Sun)/.test(rest)) return { kind: 'room', sides: '' }
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Derive a level type's name from its pieces.
|
||
*
|
||
* Used when {@link MazeRequest.levelTypeName} is omitted: the name is the longest
|
||
* prefix shared by every piece name, trimmed back to a word boundary, because
|
||
* `Act 1 - Cave NSEW` and `Act 1 - Cave Theme W` agree on `Act 1 - Cave ` and the
|
||
* trailing space is the boundary.
|
||
*
|
||
* @param pieces - the level's pieces.
|
||
* @returns the inferred name, or `null` when the pieces disagree.
|
||
*/
|
||
export function inferLevelTypeName(pieces: readonly MazePiece[]): string | null {
|
||
const first = pieces[0]
|
||
if (first === undefined) return null
|
||
let prefix = first.name
|
||
for (const piece of pieces) {
|
||
while (prefix.length > 0 && !piece.name.startsWith(prefix)) prefix = prefix.slice(0, -1)
|
||
}
|
||
const trimmed = prefix.replace(/[\s-]+$/, '')
|
||
return trimmed === '' ? null : trimmed
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Level-type profiles
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* One `DRLGMAZE_ScanReplaceSpecialPreset` call site.
|
||
*
|
||
* That function has two modes and both are reproduced: it first looks for the
|
||
* earliest room still holding the *plain* piece for this side pattern and swaps
|
||
* the piece in place; only if no such room exists does it grow a brand-new room
|
||
* off the first available room in `direction` and put the special piece there.
|
||
*
|
||
* `DRLGMAZE_ReplaceRoomPreset`, used by Act 3's Sewer 1, is the in-place-only
|
||
* variant and is modelled with {@link inPlaceOnly}.
|
||
*/
|
||
interface SpecialStep {
|
||
/** Which kind of special piece to place. */
|
||
readonly kind: 'prev' | 'next' | 'down' | 'quest' | 'treasure'
|
||
/** Side token of the plain room this step replaces, e.g. `N`. */
|
||
readonly plainSides: string
|
||
/** Side token of the special piece. */
|
||
readonly sides: string
|
||
/** Direction used when a new room must be created (`nDirection`). */
|
||
readonly direction: number
|
||
/** Only replace an existing room; never grow one (D2MOO's `ReplaceRoomPreset`). */
|
||
readonly inPlaceOnly?: boolean
|
||
}
|
||
|
||
/**
|
||
* A group of four candidate steps selected by one rotating counter.
|
||
*
|
||
* D2MOO draws `nRand = SEED_RollRandomNumber(&pLevel->pSeed) & 3` once and then
|
||
* indexes each hardcoded table with it, incrementing the counter after every
|
||
* call that actually happens. So the *n*-th group applied to a level uses entry
|
||
* `(nRand + n) % 4`, not the first entry. Modelling the tables as groups with a
|
||
* shared cursor reproduces that exactly; flattening them into one ordered list
|
||
* would not.
|
||
*/
|
||
interface SpecialGroup {
|
||
readonly kind: SpecialStep['kind']
|
||
/** Four entries, in D2MOO's table order (N, E, S, W). */
|
||
readonly entries: readonly Omit<SpecialStep, 'kind'>[]
|
||
/** Restrict the group to these `Levels.txt` ids; absent means every level. */
|
||
readonly levelIds?: readonly number[]
|
||
/** Apply to every level *except* these ids (D2MOO's `else` arms). */
|
||
readonly exceptLevelIds?: readonly number[]
|
||
/** Only replace; never grow (D2MOO's `ReplaceRoomPreset`). */
|
||
readonly inPlaceOnly?: boolean
|
||
}
|
||
|
||
/**
|
||
* How one maze level type is laid out.
|
||
*
|
||
* D2MOO's `DRLGMAZE_GenerateLevel` `switch (pLevel->nLevelType)` turned into
|
||
* data. Where that function has both a `while (pLevel->nRooms < nRooms)` loop and
|
||
* a `DRLGMAZE_BuildBasicMaze` call, the two are the same growth primitive spelled
|
||
* twice — see {@link growMaze} — so both are represented by `grow: true`.
|
||
*/
|
||
export interface MazeLevelProfile {
|
||
/** `LvlTypes.txt` name this profile applies to. */
|
||
readonly levelTypeName: string
|
||
/**
|
||
* `DRLGMAZE_InitBasicMazeLayout(nRoomsPerDirection)`: pre-place a closed ring of
|
||
* `4n - 4` rooms, then grow from it. Act 3's Sewer 1 overrides this with 5.
|
||
*/
|
||
readonly ring?: number
|
||
/** Grow to `LvlMaze.Rooms` sections with the random-adjacency loop. */
|
||
readonly grow: boolean
|
||
/**
|
||
* `DRLGMAZE_RollAct_1_2_3_BasicPresets`: convert up to
|
||
* `max(rooms / 5 + 1, 2)` plain rooms to their `Theme` variant.
|
||
*/
|
||
readonly theme: boolean
|
||
/** `'catacombs'`: the Act 1 Catacombs opening moves. */
|
||
readonly opening?: 'catacombs'
|
||
/** Chain of three rooms plus a `Prev` piece on the entrance. */
|
||
readonly prevChain?: 'tomb' | 'baal'
|
||
/** `DRLGMAZE_PlaceArcaneSanctuary`: four 15-room spiral branches. */
|
||
readonly arcane?: boolean
|
||
/** `DRLGMAZE_PlaceAct5LavaPresets`: two fixed pieces plus blank filling. */
|
||
readonly lavaPairs?: boolean
|
||
/**
|
||
* Levels that are a single fixed `LvlPrest.txt` row rather than a maze, with
|
||
* the exact names to try, in order. Used for the three Act 5 ice set pieces and
|
||
* Claw Viper Temple level 2.
|
||
*/
|
||
readonly singleRoom?: Readonly<Record<number, readonly string[]>>
|
||
/** `Prev`/`Next`/quest/treasure replacement groups, in call order. */
|
||
readonly specials: readonly SpecialGroup[]
|
||
}
|
||
|
||
/** The four single-side layouts, in the order the D2MOO tables list them. */
|
||
const NESW = ['N', 'E', 'S', 'W'] as const
|
||
|
||
/** Direction paired with each of {@link NESW} in the D2MOO tables. */
|
||
const NESW_DIRECTIONS = [DIRECTION_SOUTH, DIRECTION_WEST, DIRECTION_NORTH, DIRECTION_EAST] as const
|
||
|
||
/**
|
||
* Build the four side entries of a D2MOO table.
|
||
*
|
||
* @param plainSides - the four plain side tokens, in table order.
|
||
* @returns the entries.
|
||
*/
|
||
function entries(plainSides: readonly string[] = NESW): Omit<SpecialStep, 'kind'>[] {
|
||
return plainSides.map((sides, index) => ({ plainSides: sides, sides, direction: NESW_DIRECTIONS[index]! }))
|
||
}
|
||
|
||
/**
|
||
* The per-level-type layout table, transcribed from `DRLGMAZE_GenerateLevel`.
|
||
*
|
||
* `specials` covers the replacement tables that live inline in that function.
|
||
* The per-act decoration passes that follow it in D2MOO — `DRLGMAZE_PlaceAct2TombStuff`,
|
||
* `DRLGMAZE_PlaceAct3DungeonStuff`, `DRLGMAZE_PlaceAct5IceStuff` and the rest —
|
||
* are **not** reproduced; they are listed in `stats.unimplementedPasses` so the
|
||
* omission is visible rather than silent.
|
||
*/
|
||
export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
||
{
|
||
levelTypeName: 'Act 1 - Cave',
|
||
grow: true,
|
||
theme: true,
|
||
specials: [
|
||
{ kind: 'prev', entries: entries() },
|
||
{ kind: 'quest', entries: entries(), levelIds: [8] },
|
||
{ kind: 'down', entries: entries(), exceptLevelIds: [8] },
|
||
{ kind: 'quest', entries: entries(), levelIds: [9] },
|
||
{ kind: 'next', entries: entries(), levelIds: [10] },
|
||
],
|
||
},
|
||
{
|
||
levelTypeName: 'Act 1 - Crypt',
|
||
grow: true,
|
||
theme: true,
|
||
specials: [
|
||
{ kind: 'prev', entries: entries() },
|
||
{ kind: 'quest', entries: entries(), levelIds: [18] },
|
||
{ kind: 'treasure', entries: entries(), levelIds: [19] },
|
||
{ kind: 'next', entries: entries(), levelIds: [21, 22, 23, 24] },
|
||
],
|
||
},
|
||
{
|
||
levelTypeName: 'Act 1 - Barracks',
|
||
ring: 2,
|
||
grow: true,
|
||
theme: true,
|
||
specials: [
|
||
{ kind: 'next', entries: entries() },
|
||
],
|
||
},
|
||
{
|
||
levelTypeName: 'Act 1 - Jail',
|
||
ring: 2,
|
||
grow: true,
|
||
theme: true,
|
||
specials: [
|
||
{ kind: 'prev', entries: entries() },
|
||
{ kind: 'treasure', entries: entries(), levelIds: [29] },
|
||
{ kind: 'quest', entries: entries(), levelIds: [30] },
|
||
// D2MOO's `else` hangs off the `JAILLEV3` test, so levels 1 and 2 get a
|
||
// `Next` staircase and level 3 gets the Cathedral descent instead.
|
||
{ kind: 'next', entries: entries(), levelIds: [29, 30] },
|
||
{ kind: 'down', entries: entries(), levelIds: [31] },
|
||
],
|
||
},
|
||
{
|
||
levelTypeName: 'Act 1 - Catacombs',
|
||
grow: true,
|
||
theme: true,
|
||
opening: 'catacombs',
|
||
specials: [
|
||
{ kind: 'next', entries: entries() },
|
||
{ kind: 'treasure', entries: entries(), levelIds: [35] },
|
||
],
|
||
},
|
||
{
|
||
levelTypeName: 'Act 2 - Sewer',
|
||
ring: 2,
|
||
grow: true,
|
||
theme: true,
|
||
specials: [
|
||
{ kind: 'prev', entries: entries() },
|
||
{ kind: 'next', entries: entries(), levelIds: [47, 48] },
|
||
],
|
||
},
|
||
{
|
||
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] },
|
||
],
|
||
},
|
||
{
|
||
levelTypeName: 'Act 2 - Lair',
|
||
ring: 2,
|
||
grow: true,
|
||
theme: false,
|
||
specials: [
|
||
{ kind: 'prev', entries: entries() },
|
||
{ kind: 'next', entries: entries(), levelIds: [62, 63] },
|
||
],
|
||
},
|
||
{
|
||
levelTypeName: 'Act 2 - Arcane',
|
||
grow: false,
|
||
theme: false,
|
||
arcane: true,
|
||
specials: [{ kind: 'quest', entries: entries() }],
|
||
},
|
||
{ levelTypeName: 'Act 2 - Harem', ring: 2, grow: false, theme: false, specials: [] },
|
||
{ levelTypeName: 'Act 2 - Basement', ring: 2, grow: false, theme: false, specials: [] },
|
||
{ levelTypeName: 'Act 3 - Spider', ring: 2, grow: false, theme: false, specials: [] },
|
||
{
|
||
levelTypeName: 'Act 3 - Kurast',
|
||
ring: 2,
|
||
grow: true,
|
||
theme: true,
|
||
specials: [
|
||
{ kind: 'prev', entries: entries(), levelIds: [100, 101] },
|
||
{ kind: 'next', entries: entries(), levelIds: [100, 101] },
|
||
],
|
||
},
|
||
{
|
||
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] },
|
||
],
|
||
},
|
||
{
|
||
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],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
levelTypeName: 'Act 4 - Lava',
|
||
grow: true,
|
||
theme: false,
|
||
specials: [
|
||
{
|
||
kind: 'prev',
|
||
entries: [
|
||
{ plainSides: 'N', sides: 'N', direction: DIRECTION_SOUTH },
|
||
{ plainSides: 'N', sides: 'N', direction: DIRECTION_SOUTH },
|
||
{ plainSides: 'N', sides: 'N', direction: DIRECTION_SOUTH },
|
||
{ plainSides: 'N', sides: 'N', direction: DIRECTION_SOUTH },
|
||
],
|
||
levelIds: [107],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
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] },
|
||
],
|
||
},
|
||
{
|
||
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,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
levelTypeName: 'Act 5 - Baal',
|
||
grow: true,
|
||
theme: false,
|
||
prevChain: 'baal',
|
||
specials: [
|
||
{ kind: 'next', entries: entries(), levelIds: [128, 129, 130] },
|
||
],
|
||
},
|
||
{ levelTypeName: 'Act 5 - Lava', grow: false, theme: false, lavaPairs: true, specials: [] },
|
||
]
|
||
|
||
/**
|
||
* `LvlPrest.txt` names a level type needs from outside its own name prefix.
|
||
*
|
||
* Only Act 5's Lava levels do this: `DRLGMAZE_PlaceAct5LavaPresets` fills the
|
||
* blank space around the Infernal Pit's two set pieces with the *Act 4* lava
|
||
* filler row, so a caller that groups pieces strictly by name prefix must fetch
|
||
* these extra rows too.
|
||
*/
|
||
export const MAZE_SHARED_PRESET_NAMES: readonly string[] = ['Act 4 - Lava X']
|
||
|
||
/**
|
||
* Per-act decoration passes D2MOO runs after the layout, which this port does not
|
||
* reproduce. Reported verbatim in every result's `stats.unimplementedPasses`.
|
||
*/
|
||
const UNIMPLEMENTED_PASSES: readonly string[] = [
|
||
'DRLGMAZE_PlaceAct2TombStuff',
|
||
'DRLGMAZE_PlaceAct2LairStuff',
|
||
'DRLGMAZE_PlaceAct3DungeonStuff',
|
||
'DRLGMAZE_PlaceAct3SewerStuff',
|
||
'DRLGMAZE_PlaceAct3MephistoStuff',
|
||
'DRLGMAZE_PlaceAct5TempleStuff',
|
||
'DRLGMAZE_PlaceAct5BaalStuff',
|
||
'DRLGMAZE_PlaceAct5IceStuff',
|
||
'DRLGMAZE_PlaceAct1Barracks',
|
||
'DRLGMAZE_PlaceAct4Lava',
|
||
'DRLGMAZE_ScanReplaceSpecialAct2SewersPresets',
|
||
]
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Geometry
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/** A rectangle in cells. Mirrors the `nTileXPos`/`nTileYPos`/`nTileWidth`/
|
||
* `nTileHeight` view of `D2DrlgRoomStrc`, which D2MOO also reads as
|
||
* `D2DrlgCoordStrc` through a union. */
|
||
interface Rect {
|
||
x: number
|
||
y: number
|
||
width: number
|
||
height: number
|
||
}
|
||
|
||
/**
|
||
* Signed gap between two cell rectangles, per axis.
|
||
*
|
||
* `DRLG_ComputeManhattanDistance` verbatim, including its one-axis-at-a-time
|
||
* formulation: a negative value means the rectangles overlap on that axis, and
|
||
* two rooms sharing a face give one gap of 0 and one of `-height`, which is the
|
||
* signature every caller tests for.
|
||
*
|
||
* @param a - first rectangle.
|
||
* @param b - second rectangle.
|
||
* @returns the per-axis gaps.
|
||
*/
|
||
function manhattan(a: Rect, b: Rect): { dx: number; dy: number } {
|
||
const dx = a.x >= b.x ? a.x - b.width - b.x : b.x - a.width - a.x
|
||
const dy = a.y >= b.y ? a.y - b.height - b.y : b.y - a.height - a.y
|
||
return { dx, dy }
|
||
}
|
||
|
||
/**
|
||
* Whether two rectangles are clear of each other by at least `margin` cells.
|
||
*
|
||
* `DRLG_GetRectanglesManhattanDistanceAndCheckNotOverlapping`: true when either
|
||
* axis is separated by `margin` or more. With `margin = 0` a pair of face-to-face
|
||
* rooms has `dx = 0` and therefore *passes*, so sharing a face counts as "not
|
||
* overlapping" — which is what lets rooms tile a grid.
|
||
*
|
||
* @param a - first rectangle.
|
||
* @param b - second rectangle.
|
||
* @param margin - required separation on at least one axis.
|
||
* @returns true when they are clear.
|
||
*/
|
||
function notOverlapping(a: Rect, b: Rect, margin: number): boolean {
|
||
const { dx, dy } = manhattan(a, b)
|
||
return dx >= margin || dy >= margin
|
||
}
|
||
|
||
/**
|
||
* The quarter direction from `from` to `to`, or `-1` when they are not
|
||
* face-to-face neighbours.
|
||
*
|
||
* `DRLG_GetDirectionFromCoordinates`. Only the four quarters are produced; the
|
||
* diagonals exist solely as placement offsets in `FillBlankMazeSpaces`.
|
||
*
|
||
* @param from - the origin rectangle.
|
||
* @param to - the candidate neighbour.
|
||
* @returns a direction index 0..3, or -1.
|
||
*/
|
||
function directionFrom(from: Rect, to: Rect): number {
|
||
if (from.x <= to.x) {
|
||
if (to.x === from.x + from.width) return DIRECTION_EAST
|
||
} else if (from.x === to.x + to.width) {
|
||
return DIRECTION_WEST
|
||
}
|
||
if (from.y <= to.y) {
|
||
if (to.y === from.y + from.height) return DIRECTION_SOUTH
|
||
} else if (from.y === to.y + to.height) {
|
||
return DIRECTION_NORTH
|
||
}
|
||
return -1
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Rooms
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/** One maze section. */
|
||
interface Room extends Rect {
|
||
/** Doorways to neighbouring rooms, in insertion order. */
|
||
readonly orths: { room: Room; direction: number }[]
|
||
/** Creation index, so diagnostics can name a room stably. */
|
||
readonly id: number
|
||
/** The chosen piece; `null` until a preset pass runs. */
|
||
piece: MazePiece | null
|
||
/** Which variant of `piece` to stamp. */
|
||
variant: number
|
||
/**
|
||
* `DRLGPRESETROOMFLAG_HAS_MAP_DS1`.
|
||
*
|
||
* Set by the passes that call `DRLGMAZE_SetPickedFileAndPresetId` with
|
||
* `bResetFlag = FALSE`; cleared by every `DRLGMAZE_PickRoomPreset` call, which
|
||
* passes 1. A set flag means "this room's artwork is final", so the merge pass
|
||
* skips it and no later preset pass may overwrite it.
|
||
*/
|
||
hasDs1: boolean
|
||
/** Per-room random stream, seeded from the level's (`DRLGROOM_AllocRoomEx`). */
|
||
readonly rng: Rng
|
||
}
|
||
|
||
/** The level under construction. */
|
||
interface MazeLevel {
|
||
/** Rooms newest-first, matching `DRLGROOM_AddRoomExToLevel`'s prepend. */
|
||
readonly rooms: Room[]
|
||
/** The first room created; D2MOO keeps this pointer across prepends. */
|
||
readonly first: Room
|
||
readonly width: number
|
||
readonly height: number
|
||
readonly rng: Rng
|
||
readonly mergePerMille: number
|
||
/** Next room id. */
|
||
nextId: number
|
||
/** Doorways added by the merge pass, for reporting. */
|
||
merges: number
|
||
}
|
||
|
||
/**
|
||
* Report collector shared by the passes.
|
||
*
|
||
* Dense on purpose: the generator's job is to be inspectable, and the interesting
|
||
* facts are which piece answered which side pattern and where the exact pattern
|
||
* was missing.
|
||
*/
|
||
interface MazeStats {
|
||
readonly patterns: Record<string, Record<string, number>>
|
||
readonly fallbacks: { room: number; requested: string; used: string }[]
|
||
readonly specialsApplied: { room: number; kind: string; sides: string }[]
|
||
readonly unresolvedRoles: string[]
|
||
readonly notes: string[]
|
||
ringRooms: number
|
||
placementAttempts: number
|
||
/** Stair rooms, with the coordinates only `stamp` can work out. */
|
||
warps: MazeWarp[]
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
function roomSideMask(room: Room): number {
|
||
let mask = 0
|
||
for (const orth of room.orths) mask |= DIRECTION_SIDE_BIT[orth.direction]!
|
||
return mask
|
||
}
|
||
|
||
/**
|
||
* Whether a room overlaps anything other than itself and one ignored room.
|
||
*
|
||
* `DRLGMAZE_CheckRoomNotOverlaping`. The margin is 0, so face-to-face neighbours
|
||
* are fine and only genuine overlap fails.
|
||
*
|
||
* @param level - the level.
|
||
* @param room - the candidate.
|
||
* @param ignored - a room to skip, usually its parent.
|
||
* @returns true when the room is clear.
|
||
*/
|
||
function checkRoomNotOverlapping(level: MazeLevel, room: Rect, ignored: Room | null): boolean {
|
||
for (const other of level.rooms) {
|
||
if (other === room || other === ignored) continue
|
||
if (!notOverlapping(room, other, 0)) return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* Position `room` against `anchor` in `direction` and validate the result.
|
||
*
|
||
* `DRLGMAZE_LinkMazeRooms`: the coordinate assignment happens first, so a failed
|
||
* link leaves the caller to discard the room.
|
||
*
|
||
* @param level - the level.
|
||
* @param room - the room to move.
|
||
* @param anchor - the room to sit against.
|
||
* @param direction - which side of the anchor to occupy.
|
||
* @returns true when the position is legal.
|
||
*/
|
||
function linkMazeRooms(level: MazeLevel, room: Room, anchor: Room, direction: number): boolean {
|
||
const { dx, dy } = step(direction, anchor.width, anchor.height)
|
||
room.x = anchor.x + dx
|
||
room.y = anchor.y + dy
|
||
for (const orth of anchor.orths) {
|
||
// Anything already touching the anchor must not be run into.
|
||
if (!notOverlapping(room, orth.room, 0)) return false
|
||
}
|
||
return checkRoomNotOverlapping(level, room, anchor)
|
||
}
|
||
|
||
/**
|
||
* The earliest room that still needs a piece, optionally of a given shape.
|
||
*
|
||
* @param level - the level, newest-first.
|
||
* @param mask - required side mask, or `null` for any.
|
||
* @returns the room, or null.
|
||
*/
|
||
function findUnfinishedRoom(level: MazeLevel, mask: number | null): Room | null {
|
||
for (const room of level.rooms) {
|
||
if (room.hasDs1) continue
|
||
if (mask === null || roomSideMask(room) === mask) return room
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Grow one room off `parent`, or return null when the spot is taken.
|
||
*
|
||
* `DRLGMAZE_AddAdjacentMazeRoom`, with its inline copy of the merge loop factored
|
||
* into {@link mergeMazeRooms}. The fold is safe because in D2MOO the new room has
|
||
* not been added to the roster when either spelling runs, so both iterate exactly
|
||
* the same candidates in the same order; the only difference is that
|
||
* `DRLGMAZE_BuildBasicMaze` calls `MergeMazeRooms` unconditionally while
|
||
* `AddAdjacentMazeRoom` guards it with `bMergeRooms`, and every caller that
|
||
* passes `FALSE` has already established that there is nothing to merge.
|
||
*
|
||
* @param level - the level.
|
||
* @param parent - the room to grow from.
|
||
* @param direction - which side to grow towards, 0..7.
|
||
* @param merge - whether the accidental-adjacency join pass runs.
|
||
* @param repickParent - whether to re-derive the parent's piece afterwards.
|
||
* `DRLGMAZE_FillBlankMazeSpaces` deliberately does not.
|
||
* @returns the new room, or null.
|
||
*/
|
||
function addAdjacentMazeRoom(
|
||
level: MazeLevel,
|
||
parent: Room,
|
||
direction: number,
|
||
merge: boolean,
|
||
repickParent: boolean,
|
||
index: PieceIndex,
|
||
stats: MazeStats,
|
||
): Room | null {
|
||
const room = makeRoom(level)
|
||
const { dx, dy } = step(direction, parent.width, parent.height)
|
||
room.x = parent.x + dx
|
||
room.y = parent.y + dy
|
||
for (const orth of parent.orths) {
|
||
if (!notOverlapping(room, orth.room, 0)) return null
|
||
}
|
||
if (!checkRoomNotOverlapping(level, room, parent)) return null
|
||
|
||
linkRooms(parent, room, direction)
|
||
if (merge) mergeMazeRooms(level, room, index, stats)
|
||
level.rooms.unshift(room)
|
||
if (repickParent) pickRoomPreset(parent, index, stats)
|
||
pickRoomPreset(room, index, stats)
|
||
return room
|
||
}
|
||
|
||
/**
|
||
* Join a newly placed room to every already-placed room it happens to touch.
|
||
*
|
||
* **This is what `LvlMaze.Merge` does.** `DRLGMAZE_MergeMazeRooms` (and the
|
||
* inline copy inside `DRLGMAZE_AddAdjacentMazeRoom`) walks the whole roster for
|
||
* rooms no more than one cell away where `dx !== dy` — which, given that overlap
|
||
* has already been ruled out, can only mean a face-to-face neighbour — and, when
|
||
* the two are not already connected, rolls
|
||
* `SEED_RollRandomNumber(&i->pSeed) % 1000 < dwMerge`, on the *candidate's* seed
|
||
* rather than the pivot's. A success adds a doorway and re-picks that room's
|
||
* piece, because its side pattern just changed.
|
||
*
|
||
* The consequence is why `Merge` matters: the growth loop attaches a room on one
|
||
* side only, but the section grid is coarse enough that a new room frequently
|
||
* lands flush against an unrelated older room. With `Merge = 0` those contacts
|
||
* stay walls and the level is a bare tree; with `Merge = 1000` every one of them
|
||
* becomes a door and the level acquires loops.
|
||
*
|
||
* @param level - the level.
|
||
* @param pivot - the room just placed.
|
||
* @param index - the piece index, for re-picking.
|
||
* @param stats - report collector.
|
||
*/
|
||
function mergeMazeRooms(level: MazeLevel, pivot: Room, index: PieceIndex, stats: MazeStats): void {
|
||
if (pivot.hasDs1) return
|
||
for (const other of level.rooms) {
|
||
if (other === pivot || other.hasDs1) continue
|
||
const { dx, dy } = manhattan(pivot, other)
|
||
// `!(dx >= 1 || dy >= 1)`, then `dx !== dy`.
|
||
if (dx >= 1 || dy >= 1) continue
|
||
if (dx === dy) continue
|
||
if (pivot.orths.some(orth => orth.room === other)) continue
|
||
if (other.rng.int(0, 999) >= level.mergePerMille) continue
|
||
const direction = directionFrom(other, pivot)
|
||
if (direction === -1) continue
|
||
linkRooms(other, pivot, direction)
|
||
level.merges += 1
|
||
pickRoomPreset(other, index, stats)
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Piece lookup
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/** Indexed pieces, so a side mask is one map lookup. */
|
||
interface PieceIndex {
|
||
/**
|
||
* The piece for a room's doorway mask.
|
||
*
|
||
* @param mask - the room's doorway mask.
|
||
* @param stats - report collector for fallbacks.
|
||
* @param roomId - the room being served, for the report.
|
||
* @returns the piece to stamp.
|
||
*/
|
||
choose(mask: number, stats: MazeStats, roomId: number): MazePiece
|
||
}
|
||
|
||
/**
|
||
* Count set bits.
|
||
*
|
||
* @param value - the value.
|
||
* @returns the population count.
|
||
*/
|
||
function popcount(value: number): number {
|
||
let count = 0
|
||
let rest = value
|
||
while (rest !== 0) { count += rest & 1; rest >>>= 1 }
|
||
return count
|
||
}
|
||
|
||
/**
|
||
* Index a level type's pieces by kind and side mask.
|
||
*
|
||
* `DRLGMAZE_PickRoomPreset` resolves a mask by reading the level type's
|
||
* `LvlPrest` row at `base + mask`, where `base` is a hardcoded per-type preset
|
||
* id. This port has no preset ids, so it resolves by *kind and mask* instead —
|
||
* the same thing expressed in the data the caller supplies.
|
||
*
|
||
* A level type may genuinely lack a piece for a side pattern (Act 1's Catacombs
|
||
* ships no four-sided room). D2MOO would read whatever row happens to sit at
|
||
* `base + mask` — a neighbouring level type's artwork — and produce a map whose
|
||
* doors do not line up. Here the closest piece is substituted and the mismatch is
|
||
* recorded in `stats.fallbacks`, because a map that cannot be walked is worse for
|
||
* this project than a map with one wrong room. This is a deliberate divergence.
|
||
*
|
||
* @param pieces - the level type's pieces.
|
||
* @returns the index.
|
||
*/
|
||
function buildPieceIndex(pieces: readonly MazePiece[]): PieceIndex {
|
||
const rooms = new Map<number, MazePiece>()
|
||
let entrance: MazePiece | undefined
|
||
for (const piece of pieces) {
|
||
if (piece.kind === 'entrance' && entrance === undefined) entrance = piece
|
||
if (piece.kind !== 'room') continue
|
||
const mask = sideMaskFromToken(piece.sides)
|
||
if (!rooms.has(mask)) rooms.set(mask, piece)
|
||
}
|
||
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.
|
||
*/
|
||
function findSpecial(pieces: readonly MazePiece[], kind: MazePieceKind, sides: string): MazePiece | undefined {
|
||
return pieces.find(piece => piece.kind === kind
|
||
&& (sides === '' || sideMaskFromToken(piece.sides) === sideMaskFromToken(sides)))
|
||
}
|
||
|
||
/**
|
||
* Give a room the piece its doorways call for.
|
||
*
|
||
* `DRLGMAZE_PickRoomPreset`. Rooms whose artwork is already final (`hasDs1`) are
|
||
* left alone, mirroring the passes that call it with `bResetFlag = FALSE`; every
|
||
* other call clears the flag, which is why the flag cannot be used as a "was
|
||
* visited" marker.
|
||
*
|
||
* @param room - the room to give a piece.
|
||
* @param index - the piece lookup.
|
||
* @param stats - the report collector.
|
||
*/
|
||
function pickRoomPreset(room: Room, index: PieceIndex, stats: MazeStats): void {
|
||
if (room.hasDs1) return
|
||
const mask = roomSideMask(room)
|
||
const chosen = index.choose(mask, stats, room.id)
|
||
room.piece = chosen
|
||
room.variant = 0
|
||
room.hasDs1 = false
|
||
const token = sideTokenFromMask(mask) === '' ? 'no side' : sideTokenFromMask(mask)
|
||
stats.patterns[chosen.kind] ??= {}
|
||
const bucket = stats.patterns[chosen.kind]!
|
||
bucket[token] = (bucket[token] ?? 0) + 1
|
||
}
|
||
|
||
/**
|
||
* Record that a special piece was placed.
|
||
*
|
||
* @param room - the room it went into.
|
||
* @param piece - the piece.
|
||
* @param token - the side token it answers.
|
||
* @param stats - report collector.
|
||
*/
|
||
function noteSpecial(room: Room, piece: MazePiece, token: string, stats: MazeStats): void {
|
||
stats.specialsApplied.push({ room: room.id, kind: piece.kind, sides: token })
|
||
stats.patterns[piece.kind] ??= {}
|
||
stats.patterns[piece.kind]![token] = (stats.patterns[piece.kind]![token] ?? 0) + 1
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Layout passes
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* Pre-place a closed ring of `4n - 4` rooms.
|
||
*
|
||
* `DRLGMAZE_InitBasicMazeLayout`. Four legs walk north, west, south and east, and
|
||
* the last room is then linked back to the first — so `n = 2` gives a 2×2 square
|
||
* and `n = 5` (Act 3's Sewer 1) a 4×4 ring of 16 rooms. The east leg runs one
|
||
* iteration short because the first room already occupies that corner. Each leg
|
||
* calls `DRLGMAZE_MergeMazeRooms` just like the growth loop does, so the
|
||
* accidental contacts a ring makes are subject to `Merge` too.
|
||
*
|
||
* @param level - the level.
|
||
* @param roomsPerDirection - `n`; `4n - 4` rooms are created.
|
||
* @param index - the piece index.
|
||
* @param stats - report collector.
|
||
*/
|
||
function ringLayout(level: MazeLevel, roomsPerDirection: number, index: PieceIndex, stats: MazeStats): void {
|
||
const legs: readonly number[] = [DIRECTION_NORTH, DIRECTION_WEST, DIRECTION_SOUTH, DIRECTION_EAST]
|
||
let cursor = level.first
|
||
for (const direction of legs) {
|
||
const count = direction === DIRECTION_EAST ? roomsPerDirection - 2 : roomsPerDirection - 1
|
||
for (let remaining = count; remaining > 0; remaining -= 1) {
|
||
const room = addAdjacentMazeRoom(level, cursor, direction, true, true, index, stats)
|
||
if (room !== null) stats.ringRooms += 1
|
||
cursor = room ?? cursor
|
||
}
|
||
}
|
||
linkRooms(cursor, level.first, DIRECTION_EAST)
|
||
pickRoomPreset(cursor, index, stats)
|
||
pickRoomPreset(level.first, index, stats)
|
||
}
|
||
|
||
/**
|
||
* Grow the level to its target room count.
|
||
*
|
||
* The shared body of `DRLGMAZE_BuildBasicMaze` and of the
|
||
* `while (pLevel->nRooms < nRooms)` loop `DRLGMAZE_GenerateLevel` inlines for the
|
||
* Act 1 dungeons: pick a room from the roster at a uniformly random index (the
|
||
* roster is newest-first, so recent rooms are favoured exactly as in the game),
|
||
* roll a direction from *that room's* seed, and try to attach a neighbour.
|
||
* Failure is routine — the target square may already be occupied — so the loop
|
||
* simply tries again. That also means it can only be bounded by a retry cap
|
||
* rather than by construction, and D2MOO would spin forever where this throws.
|
||
*
|
||
* @param level - the level.
|
||
* @param target - rooms wanted.
|
||
* @param index - the piece index.
|
||
* @param stats - report collector.
|
||
* @returns the number of attempts consumed.
|
||
*/
|
||
function growMaze(level: MazeLevel, target: number, index: PieceIndex, stats: MazeStats): number {
|
||
const cap = target * 400 + 20_000
|
||
let attempts = 0
|
||
while (level.rooms.length < target) {
|
||
attempts += 1
|
||
if (attempts > cap) {
|
||
throw new Error(`layout stalled at ${String(level.rooms.length)}/${String(target)} rooms after ${String(attempts)} attempts`)
|
||
}
|
||
const room = level.rooms[level.rng.int(0, level.rooms.length - 1)]!
|
||
const direction = room.rng.int(0, 3)
|
||
if (room.hasDs1) continue
|
||
addAdjacentMazeRoom(level, room, direction, true, true, index, stats)
|
||
}
|
||
return attempts
|
||
}
|
||
|
||
/**
|
||
* The opening moves Act 1's Catacombs get before growth.
|
||
*
|
||
* `DRLGMAZE_GenerateLevel`'s `LVLTYPE_ACT1_CATACOMBS` arm. Level 1 opens in all
|
||
* four directions and pins the entrance to the `NSEW` staircase piece; the rest
|
||
* pick an axis at random and open both ways, pinning `EW` or `NS`. These are the
|
||
* only places the entrance room is finalised with `bResetFlag = FALSE`, i.e. the
|
||
* only rooms `DRLGMAZE_MergeMazeRooms` will later refuse to touch.
|
||
*
|
||
* @param level - the level.
|
||
* @param horizontal - whether the non-first levels open west/east rather than
|
||
* north/south.
|
||
* @param pieces - the level's pieces.
|
||
* @param index - the piece index.
|
||
* @param stats - report collector.
|
||
*/
|
||
function catacombsOpening(level: MazeLevel, horizontal: boolean, pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void {
|
||
const directions = horizontal ? [DIRECTION_WEST, DIRECTION_EAST] : [DIRECTION_NORTH, DIRECTION_SOUTH]
|
||
for (const direction of directions) addAdjacentMazeRoom(level, level.first, direction, true, true, index, stats)
|
||
const token = horizontal ? 'EW' : 'NS'
|
||
const piece = findSpecial(pieces, 'prev', token)
|
||
if (piece === undefined) {
|
||
stats.unresolvedRoles.push(`Act 1 - Catacombs Prev ${token}`)
|
||
return
|
||
}
|
||
level.first.piece = piece
|
||
level.first.hasDs1 = true
|
||
noteSpecial(level.first, piece, token, stats)
|
||
}
|
||
|
||
/**
|
||
* The three-room prologue Act 2's tombs and Act 5's Baal levels share.
|
||
*
|
||
* `DRLGMAZE_PlaceAct2TombPrev_Act5BaalPrev`: three rooms are chained off the
|
||
* entrance, one per direction starting from a random one, each merging as it
|
||
* lands; the entrance then takes the `Prev` piece whose three free sides face the
|
||
* rooms that actually got built. That piece is chosen with the direction counter
|
||
* *after* the loop, which is why it is the successor of the last direction tried
|
||
* rather than the last direction used.
|
||
*
|
||
* @param level - the level.
|
||
* @param kind - which act's `Prev` set to use.
|
||
* @param pieces - the level's pieces.
|
||
* @param index - the piece index.
|
||
* @param stats - report collector.
|
||
*/
|
||
function prevChain(level: MazeLevel, kind: 'tomb' | 'baal', pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void {
|
||
let direction = level.rng.int(0, 3)
|
||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||
const room = makeRoom(level)
|
||
if (linkMazeRooms(level, room, level.first, direction)) {
|
||
linkRooms(level.first, room, direction)
|
||
mergeMazeRooms(level, room, index, stats)
|
||
level.rooms.unshift(room)
|
||
pickRoomPreset(level.first, index, stats)
|
||
pickRoomPreset(room, index, stats)
|
||
}
|
||
direction = (direction + 1) % 4
|
||
}
|
||
const tokens = ['NSE', 'SEW', 'NSW', 'NEW']
|
||
const token = tokens[direction]!
|
||
const piece = findSpecial(pieces, 'prev', token)
|
||
if (piece === undefined) {
|
||
stats.unresolvedRoles.push(`${kind === 'tomb' ? 'Act 2 - Tomb' : 'Act 5 - Baal'} Prev ${token}`)
|
||
return
|
||
}
|
||
level.first.piece = piece
|
||
level.first.hasDs1 = true
|
||
noteSpecial(level.first, piece, token, stats)
|
||
}
|
||
|
||
/**
|
||
* `DRLGMAZE_PlaceArcaneSanctuary`: four 15-room spiral branches.
|
||
*
|
||
* Each branch starts at the level's first room and walks the pattern D2MOO
|
||
* documents in a comment, encoded in
|
||
* `DRLGMAZE_ArcaneSanctuaryDirectionFromRoomIdx`: one room forward, then a side
|
||
* step, then a long run, then a side step back. Rooms 8 and 12 are placed but
|
||
* deliberately *not* recorded and do not advance the parent, which D2MOO flags
|
||
* with "Is this a mistake?" and then reproduces anyway; so does this port, since
|
||
* the object is fidelity to the shipped maze rather than to the diagram.
|
||
*
|
||
* The four branches then take four different variants of the same pieces,
|
||
* `(nRand + branch) % 4`, which is what gives the Sanctuary its noticeably varied
|
||
* arms; the entry room takes variant 4.
|
||
*
|
||
* @param level - the level.
|
||
* @param pieces - the level's pieces.
|
||
* @param index - the piece index.
|
||
* @param stats - report collector.
|
||
*/
|
||
function arcaneSanctuary(level: MazeLevel, pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void {
|
||
const rand = level.rng.int(0, 3)
|
||
const branches: Room[] = []
|
||
for (let branch = 0; branch < 4; branch += 1) {
|
||
let parent = level.first
|
||
for (let slot = 0; slot < 15; slot += 1) {
|
||
let direction = branch
|
||
if (slot === 2 || slot === 12) direction = branch + 3
|
||
else if (slot === 7 || slot === 9) direction = branch + 1
|
||
else if (slot === 10 || slot === 11 || slot === 13 || slot === 14) direction = branch + 2
|
||
direction %= 4
|
||
const room = addAdjacentMazeRoom(level, parent, direction, true, true, index, stats)
|
||
if (room === null) continue
|
||
if (slot !== 8 && slot !== 12) {
|
||
branches.push(room)
|
||
parent = room
|
||
}
|
||
}
|
||
}
|
||
branches.forEach((room, position) => {
|
||
room.variant = (rand + Math.floor(position / 15)) % 4
|
||
})
|
||
level.first.variant = 4
|
||
void pieces
|
||
}
|
||
|
||
/**
|
||
* `DRLGMAZE_PlaceAct5LavaPresets`: the Infernal Pit's two fixed pieces.
|
||
*
|
||
* The level is not a maze at all. It is an entrance plus exactly two lava pieces,
|
||
* chosen as a pair by a single random draw, after which every still-empty
|
||
* adjacent square is filled with the shared `Act 4 - Lava X` filler — see
|
||
* {@link fillBlankSpaces}.
|
||
*
|
||
* @param level - the level.
|
||
* @param pieces - the level's pieces.
|
||
* @param index - the piece index.
|
||
* @param stats - report collector.
|
||
*/
|
||
function lavaPairs(level: MazeLevel, pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void {
|
||
// `dword_6FDCE850`, transcribed as (piece name, direction, variant).
|
||
const table: readonly (readonly [string, number, number])[] = [
|
||
['Act 5 - Lava S', DIRECTION_SOUTH, 0],
|
||
['Act 5 - Lava N', DIRECTION_NORTH, 1],
|
||
['Act 5 - Lava S', DIRECTION_SOUTH, 1],
|
||
['Act 5 - Lava N', DIRECTION_NORTH, 0],
|
||
['Act 5 - Lava E', DIRECTION_EAST, 1],
|
||
['Act 5 - Lava W', DIRECTION_WEST, 0],
|
||
['Act 5 - Lava E', DIRECTION_EAST, 0],
|
||
['Act 5 - Lava W', DIRECTION_WEST, 1],
|
||
]
|
||
const set = 2 * level.first.rng.int(0, 3)
|
||
for (const [name, direction, variant] of [table[set]!, table[set + 1]!]) {
|
||
const room = makeRoom(level)
|
||
room.variant = variant
|
||
// The table's direction is the direction *from the new room to the entrance*,
|
||
// so the new room sits on the opposite side.
|
||
const { dx, dy } = step(direction, level.first.width, level.first.height)
|
||
room.x = level.first.x + dx
|
||
room.y = level.first.y + dy
|
||
if (!checkRoomNotOverlapping(level, room, level.first)) continue
|
||
linkRooms(level.first, room, direction)
|
||
level.rooms.unshift(room)
|
||
pickRoomPreset(level.first, index, stats)
|
||
const piece = pieces.find(candidate => candidate.name === name)
|
||
if (piece === undefined) {
|
||
stats.unresolvedRoles.push(name)
|
||
pickRoomPreset(room, index, stats)
|
||
continue
|
||
}
|
||
room.piece = piece
|
||
room.hasDs1 = true
|
||
noteSpecial(room, piece, 'no side', stats)
|
||
}
|
||
fillBlankSpaces(level, pieces, index, stats)
|
||
}
|
||
|
||
/**
|
||
* `DRLGMAZE_FillBlankMazeSpaces`: wall a fixed-tile level in with filler rooms.
|
||
*
|
||
* The roster is snapshotted first, then every room in it tries all eight compass
|
||
* directions in order and keeps whatever fits. The filler never takes a role, so
|
||
* this only ever adds area.
|
||
*
|
||
* D2MOO does not re-derive the parent's piece here, which would leave a wall
|
||
* where the filler attaches and make the filler unreachable; this port does
|
||
* re-derive it, because an unreachable room is a defect rather than a detail.
|
||
*
|
||
* @param level - the level.
|
||
* @param pieces - the level's pieces.
|
||
* @param index - the piece index.
|
||
* @param stats - report collector.
|
||
*/
|
||
function fillBlankSpaces(level: MazeLevel, pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void {
|
||
const filler = pieces.find(piece => piece.name === 'Act 4 - Lava X')
|
||
if (filler === undefined) {
|
||
stats.unresolvedRoles.push('Act 4 - Lava X')
|
||
return
|
||
}
|
||
for (const room of [...level.rooms]) {
|
||
for (let direction = 0; direction < 8; direction += 1) {
|
||
const created = addAdjacentMazeRoom(level, room, direction, false, true, index, stats)
|
||
if (created === null) continue
|
||
created.piece = filler
|
||
created.hasDs1 = true
|
||
noteSpecial(created, filler, 'filler', stats)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* `DRLGMAZE_RollAct_1_2_3_BasicPresets`: the `Theme` decoration pass.
|
||
*
|
||
* A shuffled list of the fifteen side patterns is walked cyclically and the first
|
||
* plain room matching each pattern is converted to that pattern's `Theme`
|
||
* variant, until `max(rooms / 5 + 1, 2)` rooms have been converted or the attempt
|
||
* budget of `2 × rooms` is spent. D2MOO shuffles the offset list with fifteen
|
||
* Fisher-Yates swaps drawn from the level seed, which is reproduced here.
|
||
*
|
||
* A level type with no `Theme` pieces for a pattern is skipped rather than given
|
||
* a neighbouring type's artwork, and the shortfall is reported.
|
||
*
|
||
* @param level - the level.
|
||
* @param pieces - the level's pieces.
|
||
* @param stats - report collector.
|
||
*/
|
||
function themeRoll(level: MazeLevel, pieces: readonly MazePiece[], stats: MazeStats): void {
|
||
const offsets = Array.from({ length: 15 }, (_, index) => index)
|
||
let cursor = level.rng.int(0, 14)
|
||
for (let swap = 0; swap < 15; swap += 1) {
|
||
const a = level.rng.int(0, 14)
|
||
const b = level.rng.int(0, 14)
|
||
const temporary = offsets[a]!
|
||
offsets[a] = offsets[b]!
|
||
offsets[b] = temporary
|
||
}
|
||
let wanted = Math.max(Math.floor(level.rooms.length / 5) + 1, 2)
|
||
let budget = 2 * level.rooms.length
|
||
while (wanted > 0 && budget > 0) {
|
||
budget -= 1
|
||
const mask = offsets[cursor]! + 1
|
||
cursor = (cursor + 1) % 15
|
||
const token = sideTokenFromMask(mask)
|
||
const room = findUnfinishedRoom(level, mask)
|
||
if (room === null) continue
|
||
const themed = findSpecial(pieces, 'theme', token)
|
||
if (themed === undefined) {
|
||
// D2MOO would write a neighbouring level type's row; here the room keeps
|
||
// its plain piece and the gap is reported.
|
||
if (!stats.unresolvedRoles.includes(`Theme ${token}`)) stats.unresolvedRoles.push(`Theme ${token}`)
|
||
continue
|
||
}
|
||
room.piece = themed
|
||
room.variant = 0
|
||
room.hasDs1 = true
|
||
noteSpecial(room, themed, token, stats)
|
||
wanted -= 1
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 }
|
||
}
|
||
|
||
/**
|
||
* Choose each room's variant the way `DRLGMAZE_RollBasicPresets` does.
|
||
*
|
||
* For the plain side pieces D2MOO keeps a per-preset counter initialised to a
|
||
* random file index and then increments it modulo the file count, so two
|
||
* consecutive rooms of the same shape never get the same artwork. Specials and
|
||
* themed pieces keep whatever index the earlier pass assigned — for the Arcane
|
||
* Sanctuary that is the branch number, and for the Infernal Pit an explicit 0/1.
|
||
*
|
||
* @param level - the level.
|
||
* @param levelId - `Levels.txt` ID.
|
||
* @param stats - report collector.
|
||
*/
|
||
function assignVariants(level: MazeLevel, levelId: number, stats: MazeStats): void {
|
||
const counters = new Map<string, number>()
|
||
const usage = new Map<string, number>()
|
||
// D2MOO walks the roster from its head, i.e. newest room first.
|
||
for (const room of level.rooms) {
|
||
const piece = room.piece
|
||
if (piece === null) continue
|
||
usage.set(piece.name, (usage.get(piece.name) ?? 0) + 1)
|
||
if (piece.kind !== 'room' || piece.levels.length <= 1) continue
|
||
// On Palace Cellar Levels 1 & 2 (52, 53), SE and NW variants 0..2 carry the
|
||
// down-staircase warp tiles while variant 3 (CelSE3/CelNW3) is reserved for
|
||
// Level 54 (Palace Cellar Level 3) which has no descent to a fourth cellar.
|
||
const count = piece.name.startsWith('Act 2 - Basement') && levelId !== 54
|
||
? Math.min(piece.levels.length, 3)
|
||
: piece.levels.length
|
||
let counter = counters.get(piece.name)
|
||
if (counter === undefined) counter = level.rng.int(0, count - 1)
|
||
counters.set(piece.name, (counter + 1) % count)
|
||
room.variant = counter
|
||
}
|
||
for (const [name, count] of usage) stats.notes.push(`piece "${name}" used ${String(count)} time(s)`)
|
||
}
|
||
|
||
/**
|
||
* Flatten the room list into one synthesized map.
|
||
*
|
||
* The size is the union of the stamped piece extents — `(span of the room
|
||
* origins) + (piece size)`. Because a piece is one cell larger than the stride,
|
||
* consecutive rooms overlap by exactly one cell and the piece stamped later owns
|
||
* that border, reproducing `DRLGPRESET_InitPresetRoomGrids`, which fills
|
||
* `nTileWidth + 1` cells per room. D2MOO stamps in roster order (newest first),
|
||
* so the level's *first* room is stamped last and wins every overlap it takes
|
||
* part in; the same order is used here.
|
||
*
|
||
* Objects keep their per-piece sub-tile coordinates and gain the room origin
|
||
* converted from cells at 5 sub-tiles per cell.
|
||
*
|
||
* @param level - the level.
|
||
* @param stats - report collector.
|
||
* @returns the synthesized map.
|
||
*/
|
||
function stamp(level: MazeLevel, stats: MazeStats): Ds1 {
|
||
const minX = Math.min(...level.rooms.map(room => room.x))
|
||
const minY = Math.min(...level.rooms.map(room => room.y))
|
||
const maxX = Math.max(...level.rooms.map(room => room.x))
|
||
const maxY = Math.max(...level.rooms.map(room => room.y))
|
||
|
||
let wallLayers = 1
|
||
let floorLayers = 1
|
||
let substitutionType = 0
|
||
let version = 0
|
||
let act = 1
|
||
let widest = 1
|
||
let highest = 1
|
||
for (const room of level.rooms) {
|
||
const piece = room.piece
|
||
if (piece === null) continue
|
||
const variant = piece.levels[room.variant % piece.levels.length]
|
||
if (variant === undefined) throw new Error(`piece "${piece.name}" has no decoded map to stamp`)
|
||
wallLayers = Math.max(wallLayers, variant.wallLayers)
|
||
floorLayers = Math.max(floorLayers, variant.floorLayers)
|
||
substitutionType = Math.max(substitutionType, variant.substitutionType)
|
||
version = Math.max(version, variant.version)
|
||
act = variant.act
|
||
widest = Math.max(widest, variant.width)
|
||
highest = Math.max(highest, variant.height)
|
||
}
|
||
|
||
const width = (maxX - minX) + widest
|
||
const height = (maxY - minY) + highest
|
||
if (width <= 0 || height <= 0 || width > MAX_CELLS_PER_SIDE || height > MAX_CELLS_PER_SIDE
|
||
|| width * height > MAX_CELLS) {
|
||
throw new Error(`synthesized map is ${String(width)}x${String(height)} cells, outside the supported bound`)
|
||
}
|
||
|
||
const substitutionLayers = substitutionType === 1 || substitutionType === 2 ? 1 : 0
|
||
const cells: Ds1Cell[][] = []
|
||
for (let y = 0; y < height; y += 1) {
|
||
const row: Ds1Cell[] = []
|
||
for (let x = 0; x < width; x += 1) {
|
||
row.push({
|
||
walls: Array.from({ length: wallLayers }, emptyWall),
|
||
floors: Array.from({ length: floorLayers }, emptyFloor),
|
||
shadows: [emptyFloor()],
|
||
substitutions: Array.from({ length: substitutionLayers }, () => ({ value: 0 })),
|
||
})
|
||
}
|
||
cells.push(row)
|
||
}
|
||
|
||
const objects: Ds1Object[] = []
|
||
let stampedRooms = 0
|
||
for (const room of level.rooms) {
|
||
const piece = room.piece
|
||
if (piece === null) continue
|
||
const variant = piece.levels[room.variant % piece.levels.length]!
|
||
const originX = room.x - minX
|
||
const originY = room.y - minY
|
||
if (originX + variant.width > width || originY + variant.height > height) {
|
||
throw new Error(`piece "${piece.name}" overflows the ${String(width)}x${String(height)} map`)
|
||
}
|
||
for (let y = 0; y < variant.height; y += 1) {
|
||
const source = variant.cells[y]
|
||
if (source === undefined) continue
|
||
const target = cells[originY + y]!
|
||
for (let x = 0; x < variant.width; x += 1) {
|
||
const cell = source[x]
|
||
if (cell === undefined) continue
|
||
target[originX + x] = cell
|
||
}
|
||
}
|
||
for (const object of variant.objects) {
|
||
objects.push({
|
||
type: object.type,
|
||
id: object.id,
|
||
x: originX * SUB_TILES_PER_TILE + object.x,
|
||
y: originY * SUB_TILES_PER_TILE + object.y,
|
||
flags: object.flags,
|
||
})
|
||
}
|
||
// 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),
|
||
})
|
||
}
|
||
stampedRooms += 1
|
||
}
|
||
|
||
stats.notes.push(`stamped ${String(stampedRooms)} of ${String(level.rooms.length)} rooms`)
|
||
return {
|
||
version: version === 0 ? 18 : version,
|
||
width,
|
||
height,
|
||
act,
|
||
substitutionType,
|
||
wallLayers,
|
||
floorLayers,
|
||
cells,
|
||
objects,
|
||
// The synthesized map is written into an asset pack rather than re-encoded,
|
||
// so there is no NPC-path section to point at.
|
||
npcPathOffset: null,
|
||
}
|
||
}
|
||
|
||
/** Largest synthesized map side, in cells. Well above the widest shipped maze. */
|
||
const MAX_CELLS_PER_SIDE = 4096
|
||
/** Largest synthesized map area, matching the decoder's own sanity limit. */
|
||
const MAX_CELLS = 1 << 22
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Connectivity
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* Flood fill the doorway graph from the first room.
|
||
*
|
||
* The generator's correctness claim is "every placed room is reachable", so it is
|
||
* verified rather than asserted: growth attaches each room to an existing one and
|
||
* `Merge` only adds doorways, so this must always reach everything.
|
||
*
|
||
* @param level - the level.
|
||
* @returns the number of rooms reached.
|
||
*/
|
||
function reachableRooms(level: MazeLevel): number {
|
||
const seen = new Set<Room>([level.first])
|
||
const queue: Room[] = [level.first]
|
||
while (queue.length > 0) {
|
||
const room = queue.pop()!
|
||
for (const orth of room.orths) {
|
||
if (seen.has(orth.room)) continue
|
||
seen.add(orth.room)
|
||
queue.push(orth.room)
|
||
}
|
||
}
|
||
return seen.size
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------- *
|
||
* Entry point
|
||
* ------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* Generate one random-maze level.
|
||
*
|
||
* The pipeline is D2MOO's `DRLGMAZE_GenerateLevel`: an opening (ring and/or the
|
||
* Catacombs arms and/or the tomb prologue), optional growth to `LvlMaze.Rooms`
|
||
* sections, a preset for every room from its side pattern, the `Theme` pass, the
|
||
* `Prev`/`Next`/quest replacement passes, and finally one synthesized map.
|
||
*
|
||
* @param request - the real table data for one `DrlgType == 1` level.
|
||
* @returns the synthesized map plus a report of what was done.
|
||
* @throws when the level cannot be built — an unknown level type, a placement
|
||
* stall, or rooms that ended up unreachable. The message always names the level,
|
||
* because an empty map silently baked into an asset pack is worse than a crash.
|
||
*/
|
||
export function generateMaze(request: MazeRequest): MazeResult {
|
||
const width = request.sectionSize
|
||
const height = request.sectionHeight ?? request.sectionSize
|
||
const where = `level ${String(request.levelId)} (${request.levelName})`
|
||
if (!Number.isFinite(width) || width <= 0) throw new Error(`${where}: LvlMaze.SizeX is ${String(width)}`)
|
||
if (!Number.isFinite(height) || height <= 0) throw new Error(`${where}: LvlMaze.SizeY is ${String(height)}`)
|
||
if (request.pieces.length === 0) throw new Error(`${where}: no usable LvlPrest pieces`)
|
||
if (!Number.isFinite(request.minRooms) || request.minRooms < 1) {
|
||
throw new Error(`${where}: LvlMaze.Rooms is ${String(request.minRooms)}`)
|
||
}
|
||
|
||
const levelTypeName = request.levelTypeName ?? inferLevelTypeName(request.pieces)
|
||
if (levelTypeName === null) throw new Error(`${where}: cannot infer the level type from the piece names`)
|
||
const profile = MAZE_LEVEL_TYPE_PROFILES.find(candidate => candidate.levelTypeName === levelTypeName)
|
||
if (profile === undefined) throw new Error(`${where}: no maze profile for level type "${levelTypeName}"`)
|
||
|
||
const stats: MazeStats = {
|
||
patterns: {}, fallbacks: [], specialsApplied: [], unresolvedRoles: [], notes: [],
|
||
ringRooms: 0, placementAttempts: 0, warps: [],
|
||
}
|
||
const rng = new Rng(request.seed)
|
||
const mergePerMille = Math.max(0, Math.min(1000, Math.floor(request.merge)))
|
||
const index = buildPieceIndex(request.pieces)
|
||
|
||
const level: MazeLevel = {
|
||
rooms: [], first: null as unknown as Room, width, height, rng, mergePerMille, nextId: 0, merges: 0,
|
||
}
|
||
const first = makeRoom(level)
|
||
const levelRef: MazeLevel = { ...level, rooms: [first], first }
|
||
// `first` was built against `level`, whose `rooms` array is the live one; copy
|
||
// the identity over rather than duplicating state.
|
||
Object.assign(level, levelRef)
|
||
|
||
// `DRLGMAZE_GetRandomRoomExFromLevel` draws uniformly over the roster and the
|
||
// level seed advances on every call, so the seed must be consumed in the same
|
||
// order as the passes below.
|
||
let target = request.minRooms
|
||
if (request.staffTombLevelId === request.levelId) target *= 3
|
||
else if (request.bossTombLevelId === request.levelId) target *= 2
|
||
|
||
const singleNames = profile.singleRoom?.[request.levelId]
|
||
if (singleNames !== undefined) {
|
||
const piece = singleNames.map(name => request.pieces.find(candidate => candidate.name === name))
|
||
.find(found => found !== undefined)
|
||
if (piece === undefined) {
|
||
for (const name of singleNames) stats.unresolvedRoles.push(name)
|
||
stats.notes.push('single-room level: no named preset resolved, using the first piece')
|
||
const fallback = request.pieces[0]!
|
||
first.piece = fallback
|
||
first.hasDs1 = true
|
||
noteSpecial(first, fallback, 'single room', stats)
|
||
} else {
|
||
first.piece = piece
|
||
first.hasDs1 = true
|
||
noteSpecial(first, piece, 'single room', stats)
|
||
}
|
||
} else {
|
||
if (profile.ring !== undefined) {
|
||
const roomsPerDirection = levelTypeName === 'Act 3 - Sewer' && request.levelId === 92 ? 5 : profile.ring
|
||
ringLayout(level, roomsPerDirection, index, stats)
|
||
}
|
||
if (profile.opening === 'catacombs') {
|
||
// D2MOO branches on the level id before rolling, so level 1 consumes no
|
||
// random number here.
|
||
const horizontal = request.levelId === 34 ? false : rng.int(0, 1) === 1
|
||
catacombsOpening(level, horizontal, request.pieces, index, stats)
|
||
}
|
||
if (profile.prevChain !== undefined) prevChain(level, profile.prevChain, request.pieces, index, stats)
|
||
if (profile.arcane === true) arcaneSanctuary(level, request.pieces, index, stats)
|
||
if (profile.lavaPairs === true) lavaPairs(level, request.pieces, index, stats)
|
||
if (profile.grow) stats.placementAttempts = growMaze(level, target, index, stats)
|
||
|
||
// Every room needs a piece before the replacement passes can look for the
|
||
// plain ones to convert.
|
||
for (const room of level.rooms) {
|
||
if (room.piece === null) pickRoomPreset(room, index, stats)
|
||
}
|
||
|
||
if (profile.theme) themeRoll(level, request.pieces, stats)
|
||
|
||
// One counter shared by every table, advanced once per call that happens.
|
||
let cursor = rng.int(0, 3)
|
||
for (const group of profile.specials) {
|
||
if (group.levelIds !== undefined && !group.levelIds.includes(request.levelId)) continue
|
||
if (group.exceptLevelIds !== undefined && group.exceptLevelIds.includes(request.levelId)) continue
|
||
placeSpecialGroup(level, request.pieces, group, cursor, index, stats)
|
||
cursor = (cursor + 1) % 4
|
||
}
|
||
}
|
||
|
||
const unreachable = level.rooms.length - reachableRooms(level)
|
||
if (unreachable !== 0) {
|
||
throw new Error(`${where}: ${String(unreachable)} of ${String(level.rooms.length)} rooms are unreachable`)
|
||
}
|
||
const orphans = level.rooms.filter(room => room.piece === null).length
|
||
if (orphans !== 0) throw new Error(`${where}: ${String(orphans)} rooms ended up with no piece`)
|
||
|
||
assignVariants(level, request.levelId, stats)
|
||
const stamped = stamp(level, stats)
|
||
|
||
return {
|
||
level: stamped,
|
||
stats: {
|
||
levelId: request.levelId,
|
||
levelName: request.levelName,
|
||
levelTypeName,
|
||
seed: request.seed,
|
||
sectionX: width,
|
||
sectionY: height,
|
||
targetRooms: target,
|
||
roomsPlaced: level.rooms.length,
|
||
ringRooms: stats.ringRooms,
|
||
placementAttempts: stats.placementAttempts,
|
||
doorways: level.rooms.reduce((sum, room) => sum + room.orths.length, 0) / 2,
|
||
mergePerMille,
|
||
mergedDoorways: level.merges,
|
||
gridSize: { width: stamped.width, height: stamped.height },
|
||
gridSizeSubtiles: { width: stamped.width * SUB_TILES_PER_TILE, height: stamped.height * SUB_TILES_PER_TILE },
|
||
objects: stamped.objects.length,
|
||
cells: stamped.width * stamped.height,
|
||
sidePatterns: stats.patterns,
|
||
fallbacks: stats.fallbacks,
|
||
specialsApplied: stats.specialsApplied,
|
||
warps: stats.warps,
|
||
unresolvedRoles: stats.unresolvedRoles,
|
||
unimplementedPasses: UNIMPLEMENTED_PASSES,
|
||
notes: stats.notes,
|
||
},
|
||
}
|
||
}
|