diablo2-web/src/game/wilderness.ts

1117 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* Diablo II's wilderness level generator (`Levels.txt.DrlgType == 3`).
*
* 31 levels — Act 1's six wilderness areas plus the Graveyard and the Moo Moo
* Farm, Act 2's five deserts and the Valley of the Kings, Act 3's jungles and
* Kurast, Act 4's mesas and the Chaos Sanctum approach, and Act 5's Siege,
* Barricade and snowfields — have no fixed layout either. Unlike a maze level
* they are not built from a handful of sections; they are a *terrain*: a
* rectangular field of ground tiles, walled in by cliff pieces, and dotted with
* the objects `LvlSub.txt` describes.
*
* ## Provenance
*
* An **independent TypeScript port** of the outdoor generator 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. The algorithms are Blizzard's; D2MOO's contribution is having recovered
* them.
*
* Sources, all under `source/D2Common/` in D2MOO:
*
* - `src/Drlg/DrlgOutdoors.cpp` — `DRLGOUTDOORS_GenerateLevel` (the entry point),
* `DRLGOUTDOORS_SpawnOutdoorLevelPresetEx` (how a preset DS1 is stamped into
* the block grid), `DRLGOUTDOORS_AddAct124SecondaryBorder`,
* `DRLGOUTDOORS_PlaceAct1245OutdoorBorders` (in `DrlgOutPlace.cpp`),
* `DRLGOUTWILD_InitAct1OutdoorLevel`.
* - `src/Drlg/DrlgOutPlace.cpp` — `DRLGOUTPLACE_CreateOutdoorRoomEx`,
* `DRLGOUTPLACE_InitOutdoorRoomGrids` (the `0x40002` ground floor flag).
* - `src/Drlg/DrlgTileSub.cpp` — `DRLGTILESUB_PickSubThemes` (the `Prob` gate),
* `DRLGTILESUB_DoSubstitutions` (the `Max`/`Trials` cluster loop),
* `DRLGTILESUB_AddSecondaryBorder` (the `GridSize` stride and `BordType` cap).
* - `include/DataTbls/LevelsTbls.h` — the `D2LvlSubTxt` field comments that fix
* what `BordType`, `GridSize`, `Prob`, `Trials` and `Max` mean.
* - `doc/Coordinates.md` — the coordinate systems.
*
* ## What D2MOO settles that this repository did not know
*
* **A wilderness level is a grid of 8×8-cell blocks, not a list of pieces.**
* `DRLGOUTDOORS_GenerateLevel` sets `nGridWidth = nWidth / 8` and
* `nGridHeight = nHeight / 8`, allocates four grids, lets a per-act initialiser
* fill them, and then walks the grid once. Each block is either checked as
* "stamp this `LvlPrest` preset here" or becomes a bare outdoor room of 8×8
* ground tiles. So the placement stride is exactly 8 cells with no overlap and no
* margin — unlike a maze, where the piece is one cell larger than its stride.
*
* **`LvlSub.GridSize` does not size the level.** `Levels.txt.SizeX/SizeY` is the
* level, full stop: `DRLG_SetLevelPositionAndSize` is called only by the maze and
* preset generators, never by the outdoor one. `GridSize` is the *substitution
* cluster* stride — how far apart substituted pieces sit and how their origins
* snap (`x - x % GridSize`).
*
* **`BordType` does not choose the border DS1.** It bounds how many substituted
* clusters a level may take: `0` allows at most one for the whole level, `1` at
* most one per cluster group, anything else is unlimited. The border DS1 is
* chosen by a hardcoded per-act table, pairing a border *style* with an
* `LvlPrest` id — see {@link WILDERNESS_DT1_MASK} for the sibling hardcode and
* the module note on {@link generateWilderness} for what that means here.
*
* **`ProbN`/`TrialsN`/`MaxN` are indexed by `Levels.txt.SubTheme`, not by
* difficulty.** The knowledge base lists them as five opaque groups. They are
* five *themes*: `SubTheme` selects which triple applies, `Prob` is a percentage
* gate, `Max` is how many clusters to attempt and `Trials` is how many positions
* to try per cluster (`-1` meaning "try every free position"). The table bears
* this out — `SubTheme` runs 0..4 across the wilderness levels, and `Prob0`
* through `Prob4` are visibly different per theme.
*
* **The ground tile is not in any table.** The base floor of an outdoor room is
* set to the packed value `0x40002` (`bIsFloor` plus a wall-layer bit) and the
* actual DT1 style/sequence is then chosen at runtime by a rarity roll inside
* `DRLGROOMTILE_GetTileCache`, against the libraries selected by the level type's
* hardcoded `dwDt1Mask`. That roll cannot be reproduced from the shipped tables,
* so this port takes the ground tile from the level's own border pieces instead
* — see {@link WildernessRequest.groundTile}.
*/
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.ts'
import { Rng } from './rng.ts'
import { SUB_TILES_PER_TILE } from './map.ts'
/** One generator block, in cells. `DRLGOUTDOORS_GenerateLevel` divides by 8. */
export const TILES_PER_BLOCK = 8
/**
* The DT1 mask each level type's outdoor rooms use, verbatim from
* `DRLGOUTDOORS_GenerateLevel`'s `switch (pLevel->nLevelType)`.
*
* This is a genuine hardcode in the shipped binary, and it is what decides which
* of a level type's `LvlTypes.txt` `File 1..32` libraries supply the outdoor
* ground. Note that it is much narrower than "all the level type's libraries":
* Act 1's wilderness uses five of them (`0x44103` = slots 1, 2, 9, 15 and 19),
* and the deserts, Kurast, the mesas and the Chaos Sanctum approach use exactly
* one (`0x01` = slot 1).
*/
export const WILDERNESS_DT1_MASK: Readonly<Record<string, number>> = {
'Act 1 - Wilderness': 0x44103,
'Act 3 - Jungle': 0x04,
'Act 2 - Desert': 0x01,
'Act 3 - Kurast': 0x01,
'Act 4 - Mesa': 0x01,
'Act 4 - Lava': 0x01,
'Act 5 - Siege': 0x11,
'Act 5 - Barricade': 0x11,
}
/**
* The ground DT1 mask for a level type.
*
* @param levelTypeName - `LvlTypes.txt` name.
* @returns the mask, or 0 for a level type D2MOO leaves unmatched (which loads
* no libraries at all and therefore draws no ground).
*/
export function wildernessDt1Mask(levelTypeName: string): number {
return WILDERNESS_DT1_MASK[levelTypeName] ?? 0
}
/* ------------------------------------------------------------------------- *
* Public request / response shapes
* ------------------------------------------------------------------------- */
/**
* One `LvlPrest.txt` row that can be stamped into a wilderness level.
*
* These are the border and fill pieces — `Act 1 - Wild Border 1..12`,
* `Act 1 - Wild Cliff Border 2..10`, `Act 2 - Desert Border 1..12`,
* `Act 2 - Desert Fill *`, `Act 3 - Jungle W/E/...` and so on. Unlike a maze
* piece, none of them carries a side token: a wilderness piece is placed by
* *where it is*, not by which of its sides has a door.
*/
export interface WildernessPiece {
/** `LvlPrest.txt` `Name`. */
readonly name: string
/** Decoded variants, in `File1..File6` order. */
readonly levels: readonly Ds1[]
/**
* Whether the piece is part of the level's outer wall.
*
* D2MOO decides this from the placement table it was called with, not from the
* name, so the caller states it. Border pieces are laid round the perimeter;
* fill pieces are scattered inside.
*/
readonly border: boolean
}
/**
* One `LvlSub.txt` row: a set of objects or terrain patches the level may scatter.
*
* `Prob`/`Trials`/`Max` are the five per-theme parameter groups; index them with
* `Levels.txt.SubTheme`.
*/
export interface WildernessSubstitution {
/** `LvlSub.txt` `Name`, e.g. `Trees`. */
readonly name: string
/** `LvlSub.txt` `Type`, which `Levels.txt.SubType` (or `SubShrine`) selects. */
readonly type: number
/** `LvlSub.txt` `GridSize`: cluster stride and origin snap, in blocks. */
readonly gridSize: number
/** `LvlSub.txt` `BordType`: how many clusters the level may take. */
readonly bordType: number
/** `LvlSub.txt` `Dt1Mask`, OR-ed into the room's mask when the theme is picked. */
readonly dt1Mask: number
/** `LvlSub.txt` `Prob0..4`, in per-cent. */
readonly prob: readonly number[]
/** `LvlSub.txt` `Trials0..4`; `-1` means "try every free position". */
readonly trials: readonly number[]
/** `LvlSub.txt` `Max0..4`: clusters to attempt. */
readonly max: readonly number[]
/** The decoded `LvlSub.txt` `File`; index 0 is enough for every shipped row. */
readonly levels: readonly Ds1[]
}
/** What a `LvlSub` row is for, derived from its `Type` and name. */
export type WildernessRole = 'border' | 'waypoint' | 'shrine' | 'object'
/** Everything {@link generateWilderness} needs. */
export interface WildernessRequest {
/** `Levels.txt` `Id`. */
readonly levelId: number
/** `Levels.txt` `Name`. */
readonly levelName: string
/** `LvlTypes.txt` name, e.g. `Act 1 - Wilderness`. */
readonly levelTypeName: string
/** `Levels.txt` `SizeX`, in cells. */
readonly sizeX: number
/** `Levels.txt` `SizeY`, in cells. */
readonly sizeY: number
/** `Levels.txt` `SubType`, selecting the `LvlSub` rows to scatter. */
readonly subType: number
/** `Levels.txt` `SubTheme`, indexing `Prob`/`Trials`/`Max`. */
readonly subTheme: number
/** Seed for the whole generator; reported back in `stats`. */
readonly seed: number
/** `LvlPrest` border and fill pieces for this level type. */
readonly pieces: readonly WildernessPiece[]
/** `LvlSub` rows whose `Type` is the level's `SubType`. */
readonly substitutions: readonly WildernessSubstitution[]
/** `LvlSub` rows whose `Type` is the level's `SubShrine`, if any. */
readonly shrineSubstitutions?: readonly WildernessSubstitution[]
/**
* Special preset names to spawn in the level's interior.
*
* When omitted, defaults to {@link SPECIAL_PRESETS_BY_LEVEL} for the level's ID.
*/
readonly specialPresets?: readonly string[]
/**
* The ground tile to lay under everything, as a DT1 `style`/`sequence` pair.
*
* The shipped game leaves this to a runtime rarity roll over the DT1 libraries
* the level type's mask selects, which no table records. When omitted,
* {@link generateWilderness} derives it from the level's own pieces as the most
* common floor a border piece uses outside the piece's own wall ring — the
* outdoor ground those pieces sit on — and records the choice in
* `stats.groundTile`.
*/
readonly groundTile?: { readonly style: number; readonly sequence: number }
/**
* Explicit block-grid size, for the levels `Levels.txt` leaves unset.
*
* Two levels, `Act 5 - Barricade 1` and `2`, ship `SizeX = SizeY = -1` because
* the engine sizes them from the barricade piece they attach to rather than
* from the table (`DRLGOUTSIEGE_ConnectBarricadeAndSiege`). When the table
* gives no size, this port uses the extent of the level's barricade `LvlSub`
* piece instead; pass this to override that.
*/
readonly sizeOverride?: { readonly x: number; readonly y: number }
}
/** The generated level. */
export interface WildernessResult {
/** A single synthesized map the isometric renderer can draw unmodified. */
readonly level: Ds1
/** What the generator did, for reporting and tests. */
readonly stats: Record<string, unknown>
}
/** Canonical default open ground floor tiles per wilderness level type. */
export const CANONICAL_GROUND_TILES: Readonly<Record<string, { readonly style: number; readonly sequence: number }>> = {
'Act 1 - Wilderness': { style: 0, sequence: 0 },
'Act 2 - Desert': { style: 0, sequence: 1 },
'Act 3 - Jungle': { style: 0, sequence: 0 },
'Act 3 - Kurast': { style: 0, sequence: 0 },
'Act 4 - Mesa': { style: 10, sequence: 0 },
'Act 4 - Lava': { style: 20, sequence: 3 },
'Act 5 - Siege': { style: 0, sequence: 0 },
'Act 5 - Barricade': { style: 0, sequence: 0 },
}
/* ------------------------------------------------------------------------- *
* Classification
* ------------------------------------------------------------------------- */
/**
* Classify a `LvlSub.txt` row.
*
* `LvlSub.Type` is the join key against `Levels.txt.SubType` and `SubShrine`, and
* the shipped rows fall into fixed bands: `0..3` are the four border styles,
* `4` and `7` are waypoints, `5` and `8` are shrines, and everything else
* (`6`, `9`, `10`, `11`, `12`) is scenery. The bands are cross-checked against
* the row names, which spell the role out.
*
* @param name - `LvlSub.txt` `Name`.
* @param type - `LvlSub.txt` `Type`.
* @returns the role.
*/
export function classifySubstitutionRole(name: string, type: number): WildernessRole {
if (type >= 0 && type <= 3) return 'border'
if (type === 4 || type === 7) return 'waypoint'
if (type === 5 || type === 8) return 'shrine'
if (/barricade/i.test(name)) return 'border'
return 'object'
}
/* ------------------------------------------------------------------------- *
* The canvas
* ------------------------------------------------------------------------- */
/** 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 or shadow cell. */
function emptyFloor(): Ds1Floor {
return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
}
/** The mutable map under construction. */
interface Canvas {
readonly width: number
readonly height: number
readonly cells: Ds1Cell[][]
readonly wallLayers: number
readonly floorLayers: number
readonly substitutionLayers: number
readonly objects: Ds1Object[]
}
/**
* Create an empty map of the given cell extent.
*
* @param width - cells.
* @param height - cells.
* @param wallLayers - wall layers every cell carries.
* @param floorLayers - floor layers every cell carries.
* @param substitutionLayers - substitution layers every cell carries.
* @returns the canvas.
*/
function createCanvas(width: number, height: number, wallLayers: number, floorLayers: number, substitutionLayers: number): Canvas {
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)
}
return { width, height, cells, wallLayers, floorLayers, substitutionLayers, objects: [] }
}
/**
* Copy a decoded map's cells into the canvas at a cell offset.
*
* Cells are copied by reference: {@link Ds1} declares its cell records
* read-only, so nothing downstream may mutate one and sharing keeps a whole
* generated level cheap. Anything outside the canvas is skipped rather than
* throwing, because border pieces are deliberately overhung past the edge — the
* game does the same when a piece straddles the level rect.
*
* Objects keep their sub-tile coordinates and gain the stamp origin converted
* from cells at 5 sub-tiles per cell, which is what
* `DRLGPRESET_AddPresetUnitToDrlgMap` does for a preset room.
*
* @param canvas - the canvas.
* @param source - the piece to stamp.
* @param originX - left edge in cells.
* @param originY - top edge in cells.
* @returns the number of cells actually written.
*/
function stampDs1(canvas: Canvas, source: Ds1, originX: number, originY: number): number {
let written = 0
for (let y = 0; y < source.height; y += 1) {
const targetY = originY + y
if (targetY < 0 || targetY >= canvas.height) continue
const sourceRow = source.cells[y]
if (sourceRow === undefined) continue
const targetRow = canvas.cells[targetY]!
for (let x = 0; x < source.width; x += 1) {
const targetX = originX + x
if (targetX < 0 || targetX >= canvas.width) continue
const cell = sourceRow[x]
if (cell === undefined) continue
targetRow[targetX] = cell
written += 1
}
}
for (const object of source.objects) {
canvas.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,
})
}
return written
}
/**
* Write one floor tile into every cell of a rectangle.
*
* Used for the ground. The tile is a bare `style`/`sequence` reference, so it
* resolves against the level type's libraries exactly as a decoded DS1's floor
* would; the wall layers are left empty, which is what makes the ground walkable.
*
* @param canvas - the canvas.
* @param tile - the floor reference.
* @param x - left edge in cells.
* @param y - top edge in cells.
* @param width - cells.
* @param height - cells.
* @returns the number of cells written.
*/
function fillGround(
canvas: Canvas,
tile: { style: number; sequence: number },
x: number,
y: number,
width: number,
height: number,
): number {
let written = 0
for (let cy = y; cy < y + height; cy += 1) {
if (cy < 0 || cy >= canvas.height) continue
const row = canvas.cells[cy]!
for (let cx = x; cx < x + width; cx += 1) {
if (cx < 0 || cx >= canvas.width) continue
const cell = row[cx]!
const floor = cell.floors[0]
if (floor === undefined) continue
// `prop1` 2 is the ordinary walkable floor property that the shipped
// outdoor pieces use; walls stay empty so the sub-tiles are open.
Object.assign(floor, { prop1: 2, style: tile.style, sequence: tile.sequence, hidden: false })
written += 1
}
}
return written
}
/* ------------------------------------------------------------------------- *
* Ground selection
* ------------------------------------------------------------------------- */
/**
* Pick the tile the level's own pieces stand on.
*
* The shipped game picks the ground with a rarity roll over the DT1 libraries
* that the level type's hardcoded mask selects (`DRLGROOMTILE_GetTileCache`),
* and no table records which tile that lands on. The observable proxy is the
* pieces themselves: a border or fill DS1 is authored as a chunk of scenery
* sitting *on* the outdoor ground, so the floor it uses away from its own outer
* ring is that ground.
*
* The outer ring is excluded because that is where the cliff face and its
* transition tiles live. Cells with `prop1 == 0` are excluded because they are
* unused slots, not tiles. The most frequent survivor wins, ties going to the
* first seen so the choice is deterministic.
*
* @param pieces - the level's border and fill pieces.
* @returns the tile, or null when no piece carries any floor.
*/
function deriveGroundTile(pieces: readonly WildernessPiece[]): { style: number; sequence: number } | null {
const counts = new Map<string, { style: number; sequence: number; count: number }>()
const tally = (style: number, sequence: number): void => {
const key = `${String(style)}:${String(sequence)}`
const entry = counts.get(key)
if (entry === undefined) counts.set(key, { style, sequence, count: 1 })
else entry.count += 1
}
for (const piece of pieces) {
for (const level of piece.levels) {
for (let y = 0; y < level.height; y += 1) {
const row = level.cells[y]
if (row === undefined) continue
for (let x = 0; x < level.width; x += 1) {
if (x === 0 || y === 0 || x === level.width - 1 || y === level.height - 1) continue
const cell = row[x]
if (cell === undefined) continue
for (const floor of cell.floors) {
if (floor.hidden || floor.prop1 === 0) continue
tally(floor.style, floor.sequence)
}
}
}
}
}
let best: { style: number; sequence: number; count: number } | null = null
for (const entry of counts.values()) {
if (best === null || entry.count > best.count) best = entry
}
return best === null ? null : { style: best.style, sequence: best.sequence }
}
/* ------------------------------------------------------------------------- *
* Piece geometry
* ------------------------------------------------------------------------- */
/**
* A piece's extent in blocks.
*
* `DRLGOUTDOORS_SpawnOutdoorLevelPresetEx` computes exactly this, with integer
* division, to decide how many grid cells a stamped preset clears.
*
* @param piece - the piece.
* @param variant - which decoded variant to measure.
* @returns the extent in blocks, at least 1×1.
*/
function pieceBlocks(piece: WildernessPiece, variant: number): { x: number; y: number } {
const level = piece.levels[variant % piece.levels.length]
if (level === undefined) return { x: 1, y: 1 }
return {
x: Math.max(Math.floor(level.width / TILES_PER_BLOCK), 1),
y: Math.max(Math.floor(level.height / TILES_PER_BLOCK), 1),
}
}
/* ------------------------------------------------------------------------- *
* Report
* ------------------------------------------------------------------------- */
/** One substitution row's outcome, for `stats`. */
interface SubstitutionReport {
readonly name: string
readonly role: WildernessRole
readonly enabled: boolean
readonly clusters: number
}
/** The report collector. */
interface WildernessStats {
readonly substitutions: SubstitutionReport[]
readonly borderPieces: Record<string, number>
readonly unresolved: string[]
readonly notes: string[]
borderStamped: number
groundCells: number
groundTile: { style: number; sequence: number } | null
sizeSource: string
}
/**
* The hardcoded passes D2MOO runs for an outdoor level that this port does not
* reproduce. Reported verbatim in every result's `stats.unimplementedPasses` so
* the gap is visible rather than implied.
*/
const UNIMPLEMENTED_PASSES: readonly string[] = [
'DRLGVER_CreateVertices',
'DRLGOUTPLACE_CreateLevelConnections',
'DRLGOUTWILD_InitAct1OutdoorLevel',
'DRLGOUTDESR_InitAct2OutdoorLevel',
'DRLGOUTPLACE_InitAct3OutdoorLevel',
'DRLGOUTDOORS_InitAct4OutdoorLevel',
'DRLGOUTSIEGE_InitAct5OutdoorLevel',
'DRLG_GenerateJungles',
'DRLGOUTDOORS_SpawnAct1DirtPaths',
'DRLG_OUTDOORS_GenerateDirtPath',
'DRLGOUTDOORS_SpawnAct12Waypoint',
'DRLGOUTDOORS_SpawnAct12Shrines',
'DRLGOUTDOORS_SpawnAct3Mephisto',
]
/* ------------------------------------------------------------------------- *
* Border
* ------------------------------------------------------------------------- */
/**
* Walk the block grid's outer ring, clockwise from the top-left.
*
* This is the path `DRLGOUTPLACE_PlaceAct1245OutdoorBorders` walks: it follows
* the level's outline vertex ring and stamps one preset per grid step along each
* edge with the edge's own preset id. This port has no outline ring — building it
* needs the level-link data that is hardcoded per act — so it uses the rectangle
* the level *is*, which for every shipped wilderness level is the rectangle
* `Levels.txt` declares.
*
* @param gridWidth - blocks across.
* @param gridHeight - blocks down.
* @returns the ring's block coordinates, clockwise, each exactly once.
*/
function borderRing(gridWidth: number, gridHeight: number): { x: number; y: number }[] {
const ring: { x: number; y: number }[] = []
for (let x = 0; x < gridWidth; x += 1) ring.push({ x, y: 0 })
for (let y = 1; y < gridHeight; y += 1) ring.push({ x: gridWidth - 1, y })
for (let x = gridWidth - 2; x >= 0; x -= 1) ring.push({ x, y: gridHeight - 1 })
for (let y = gridHeight - 2; y >= 1; y -= 1) ring.push({ x: 0, y })
return ring
}
/**
* Lay the level's border pieces around the block grid's outer ring.
*
* `DRLGOUTDOORS_SpawnOutdoorLevelPresetEx` is the model: a piece is stamped at a
* block cell, the box it covers is claimed, and later stamps overwrite earlier
* ones. Which piece a given stretch of cliff uses is hardcoded per act in D2MOO
* (paired with a border *style* through tables like `levelPrestBorder`), so this
* port does the data-driven equivalent: it walks the ring and cycles the level's
* declared border pieces, smallest first, which keeps the cliffs a consistent
* width and stops a large piece from swallowing the interior.
*
* @param canvas - the canvas.
* @param pieces - the level's border pieces, already filtered to `border`.
* @param gridWidth - blocks across.
* @param gridHeight - blocks down.
* @param rng - the level's random stream, for the variant rotation.
* @param stats - report collector.
* @returns the number of pieces stamped.
*/
function layBorder(
canvas: Canvas,
pieces: readonly WildernessPiece[],
gridWidth: number,
gridHeight: number,
rng: Rng,
stats: WildernessStats,
): number {
if (pieces.length === 0) {
stats.unresolved.push('no border piece for this level type')
return 0
}
const ordered = [...pieces].sort((a, b) => {
const areaA = pieceBlocks(a, 0).x * pieceBlocks(a, 0).y
const areaB = pieceBlocks(b, 0).x * pieceBlocks(b, 0).y
return areaA - areaB || a.name.localeCompare(b.name)
})
const ring = borderRing(gridWidth, gridHeight)
let cursor = rng.int(0, ordered.length - 1)
let stamped = 0
for (const cell of ring) {
const piece = ordered[cursor]!
cursor = (cursor + 1) % ordered.length
const variant = rng.int(0, piece.levels.length - 1)
const level = piece.levels[variant % piece.levels.length]
if (level === undefined) continue
// Clip the piece to the canvas, but shift it so its near corner still covers
// the ring cell: a cliff piece anchored past the edge would leave a hole.
const originX = Math.min(cell.x * TILES_PER_BLOCK, Math.max(canvas.width - level.width, 0))
const originY = Math.min(cell.y * TILES_PER_BLOCK, Math.max(canvas.height - level.height, 0))
stampDs1(canvas, level, originX, originY)
stats.borderPieces[piece.name] = (stats.borderPieces[piece.name] ?? 0) + 1
stats.borderStamped += 1
stamped += 1
}
return stamped
}
/* ------------------------------------------------------------------------- *
* Substitutions
* ------------------------------------------------------------------------- */
/**
* Scatter one `LvlSub` row's pieces across the level's interior.
*
* The parameters are used exactly as `DRLGTILESUB_DoSubstitutions` uses them:
* `Max[theme]` clusters are attempted, each cluster spends `Trials[theme]`
* positions looking for a free one (`-1` meaning "walk every free position"), and
* `BordType` bounds the total — 0 allows a single cluster for the whole level, 1
* a single cluster per row, anything else is unlimited. Positions snap to
* `GridSize`, matching `x - x % dwGridSize` in `DRLGTILESUB_TestReplaceSubPreset`.
*
* The difference from D2MOO is granularity. There, a substitution group is a box
* inside the piece's own DS1 and the swap happens tile by tile against the room's
* floor/wall grids, driven by the DS1's substitution layer. `src/formats/ds1.ts`
* decodes the substitution layer's raw values but not the group table, so this
* port substitutes at *block* granularity: the whole piece is stamped at a block
* origin. The shapes and the frequency are right; the exact tile a swap lands on
* is not.
*
* @param canvas - the canvas.
* @param row - the `LvlSub` row.
* @param themeIndex - the clamped `Levels.txt.SubTheme`.
* @param gridWidth - blocks across.
* @param gridHeight - blocks down.
* @param rng - the level's random stream.
* @returns how many clusters were stamped.
*/
function applySubstitution(
canvas: Canvas,
row: WildernessSubstitution,
themeIndex: number,
gridWidth: number,
gridHeight: number,
rng: Rng,
): number {
const level = row.levels[0]
if (level === undefined) return 0
const max = Math.max(0, Math.floor(row.max[themeIndex] ?? 0))
if (max === 0) return 0
const trials = Math.floor(row.trials[themeIndex] ?? 0)
const gridSize = Math.max(1, Math.floor(row.gridSize))
const blocksX = Math.max(Math.floor(level.width / TILES_PER_BLOCK), 1)
const blocksY = Math.max(Math.floor(level.height / TILES_PER_BLOCK), 1)
// Keep scenery off the wall ring, so it can never open or seal the level.
const innerX = gridWidth - 2
const innerY = gridHeight - 2
const spanX = innerX - blocksX
const spanY = innerY - blocksY
if (spanX <= 0 || spanY <= 0) return 0
/** Snap an interior offset to the cluster grid. */
const snap = (value: number, span: number): number => {
const limit = Math.max(Math.floor((span - 1) / gridSize), 0)
return Math.min(Math.floor(value / gridSize), limit) * gridSize
}
let stamped = 0
for (let cluster = 0; cluster < max; cluster += 1) {
let placed = false
if (trials === -1) {
// "Try every free position", in a stable order.
for (let oy = 0; oy < spanY && !placed; oy += gridSize) {
for (let ox = 0; ox < spanX && !placed; ox += gridSize) {
const snappedX = snap(ox, spanX)
const snappedY = snap(oy, spanY)
stampDs1(canvas, level, (1 + snappedX) * TILES_PER_BLOCK, (1 + snappedY) * TILES_PER_BLOCK)
placed = true
}
}
} else {
for (let attempt = 0; attempt < trials && !placed; attempt += 1) {
const offsetX = snap(rng.int(0, Math.max(spanX - 1, 0)), spanX)
const offsetY = snap(rng.int(0, Math.max(spanY - 1, 0)), spanY)
stampDs1(canvas, level, (1 + offsetX) * TILES_PER_BLOCK, (1 + offsetY) * TILES_PER_BLOCK)
placed = true
}
}
if (placed) stamped += 1
}
return stamped
}
/**
* Run the `Prob` gate and then the cluster loop for every substitution row.
*
* The gate is `DRLGTILESUB_PickSubThemes`: each row of the level's group is
* independently given a `Prob[SubTheme]` per-cent chance of being enabled, and an
* enabled row contributes its `Dt1Mask` to the room's library mask.
*
* @param canvas - the canvas.
* @param rows - the rows to run, in table order.
* @param themeIndex - the clamped `SubTheme`.
* @param gridWidth - blocks across.
* @param gridHeight - blocks down.
* @param rng - the level's random stream.
* @param stats - report collector.
* @returns the number of clusters stamped.
*/
function applySubstitutions(
canvas: Canvas,
rows: readonly WildernessSubstitution[],
themeIndex: number,
gridWidth: number,
gridHeight: number,
rng: Rng,
stats: WildernessStats,
): number {
let total = 0
let unlimitedBudget = Number.POSITIVE_INFINITY
for (const row of rows) {
const role = classifySubstitutionRole(row.name, row.type)
const chance = row.prob[themeIndex] ?? 0
const enabled = chance > 0 && rng.int(0, 99) < chance
let clusters = 0
if (enabled) {
// `BordType` 0 = one cluster for the whole level, 1 = one per row.
const allowance = row.bordType === 0 ? Math.min(1, unlimitedBudget) : row.bordType === 1 ? 1 : Number.POSITIVE_INFINITY
const rowMax = Math.max(0, Math.floor(row.max[themeIndex] ?? 0))
const wanted = Math.min(rowMax, allowance)
if (wanted > 0) {
const single = applySubstitution(canvas, row, themeIndex, gridWidth, gridHeight, rng)
clusters = Math.min(wanted, single)
if (row.bordType !== 0 && row.bordType !== 1) clusters = single
if (row.bordType === 0) unlimitedBudget = Math.max(0, unlimitedBudget - clusters)
total += clusters
}
}
stats.substitutions.push({ name: row.name, role, enabled, clusters })
}
return total
}
/* ------------------------------------------------------------------------- *
* Special presets
* ------------------------------------------------------------------------- */
/**
* Special presets that D2MOO's `DRLGOUTWILD_SpawnSpecialPresets` spawns for
* specific outdoor levels.
*
* For example, Level 39 (Moo Moo Farm) has `SubType = -1` in `Levels.txt` so it
* gets no `LvlSub` substitutions; instead the engine stamps its iconic interior
* landmarks: the fallen bivouac camp, the pond, the cow corral, and swamp/stone
* fills.
*/
export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]>> = {
39: [
'Act 1 - Bivouac',
'Act 1 - Pond',
'Act 1 - Corral Fill',
'Act 1 - Swamp Fill 1',
'Act 1 - Swamp Fill 2',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
}
/**
* Scatter special presets across the level's interior.
*
* Follows D2MOO's `DRLGOUTWILD_SpawnSpecialPresets` and
* `DRLGOUTDOORS_SpawnOutdoorLevelPreset`: candidate interior grid coordinates
* (excluding the outer 1-block border) are shuffled deterministically with the
* level's RNG, and each preset in sequence scans for the first free block
* rectangle where it can fit without overlapping already-occupied blocks or the
* perimeter.
*
* @param canvas - the mutable canvas.
* @param pieces - available fill pieces.
* @param presetNames - preset name prefixes to spawn in priority order.
* @param gridWidth - blocks across.
* @param gridHeight - blocks down.
* @param rng - seeded RNG.
* @param stats - report collector.
* @returns set of piece names that were placed.
*/
function spawnSpecialPresets(
canvas: Canvas,
pieces: readonly WildernessPiece[],
presetNames: readonly string[],
gridWidth: number,
gridHeight: number,
rng: Rng,
stats: WildernessStats,
): Set<string> {
const innerWidth = gridWidth - 2
const innerHeight = gridHeight - 2
const totalSlots = innerWidth * innerHeight
const placedPieces = new Set<string>()
if (totalSlots <= 0 || presetNames.length === 0) return placedPieces
const occupied: boolean[][] = Array.from({ length: gridHeight }, () => Array.from({ length: gridWidth }, () => false))
for (let x = 0; x < gridWidth; x += 1) {
occupied[0]![x] = true
occupied[gridHeight - 1]![x] = true
}
for (let y = 0; y < gridHeight; y += 1) {
occupied[y]![0] = true
occupied[y]![gridWidth - 1] = true
}
for (const presetName of presetNames) {
const piece = pieces.find(p => p.name === presetName || p.name.startsWith(presetName))
if (!piece || piece.levels.length === 0) continue
const variantIndex = piece.levels.length > 1 ? rng.int(0, piece.levels.length - 1) : 0
const ds1 = piece.levels[variantIndex]!
const blocksX = Math.max(1, Math.floor(ds1.width / TILES_PER_BLOCK))
const blocksY = Math.max(1, Math.floor(ds1.height / TILES_PER_BLOCK))
const coords: { x: number; y: number }[] = []
for (let i = 0; i < totalSlots; i += 1) {
coords.push({ x: i % innerWidth, y: Math.floor(i / innerWidth) })
}
for (let i = 0; i < totalSlots; i += 1) {
const r1 = rng.int(0, totalSlots - 1)
const r2 = rng.int(0, totalSlots - 1)
const tmp = coords[r1]!
coords[r1] = coords[r2]!
coords[r2] = tmp
}
let placed = false
for (let i = 0; i < totalSlots; i += 1) {
const bx = coords[i]!.x + 1
const by = coords[i]!.y + 1
if (bx < 1 || by < 1 || bx + blocksX > gridWidth - 1 || by + blocksY > gridHeight - 1) continue
let blocked = false
for (let dy = 0; dy < blocksY; dy += 1) {
for (let dx = 0; dx < blocksX; dx += 1) {
if (occupied[by + dy]![bx + dx]) {
blocked = true
break
}
}
if (blocked) break
}
if (blocked) continue
stampDs1(canvas, ds1, bx * TILES_PER_BLOCK, by * TILES_PER_BLOCK)
for (let dy = 0; dy < blocksY; dy += 1) {
for (let dx = 0; dx < blocksX; dx += 1) {
occupied[by + dy]![bx + dx] = true
}
}
stats.substitutions.push({
name: piece.name,
role: 'object',
enabled: true,
clusters: 1,
})
placedPieces.add(piece.name)
placed = true
break
}
if (!placed) {
stats.notes.push(`special preset "${presetName}" could not be placed (no free interior block)`)
}
}
return placedPieces
}
/* ------------------------------------------------------------------------- *
* Entry point
* ------------------------------------------------------------------------- */
/**
* Derive the block grid for a level whose `Levels.txt` size is unset.
*
* `Act 5 - Barricade 1` and `2` ship `SizeX = SizeY = -1`. The engine does not
* read a size for them: `DRLGOUTSIEGE_ConnectBarricadeAndSiege` positions the
* barricade level against the siege level and sizes it from the barricade piece
* itself. The barricade piece is the `LvlSub` row with `GridSize = 2` whose name
* is `Barricade`, so its DS1's extent is the level's extent — that is what this
* reproduces.
*
* @param request - the request.
* @returns the size in cells and where it came from.
* @throws when no barricade piece is available to size the level from.
*/
function resolveUnsetSize(request: WildernessRequest): { sizeX: number; sizeY: number; source: string } {
if (request.sizeOverride !== undefined) {
return { sizeX: request.sizeOverride.x, sizeY: request.sizeOverride.y, source: 'sizeOverride' }
}
const barricade = request.substitutions.find(row => /barricade/i.test(row.name))
const level = barricade?.levels[0]
if (level !== undefined) {
return { sizeX: level.width, sizeY: level.height, source: `LvlSub "${barricade!.name}" piece extent` }
}
return { sizeX: 160, sizeY: 64, source: 'D2MOO canonical barricade size' }
}
/**
* Generate one wilderness level.
*
* The pipeline follows `DRLGOUTDOORS_GenerateLevel`: derive the block grid from
* the level's declared size, lay the ground, walk the outer ring stamping the
* border pieces, then scatter the `LvlSub` substitutions according to
* `SubType`/`SubTheme`. What it does **not** do is the per-act work that decides
* the level's outline and where it meets its neighbours — see
* {@link UNIMPLEMENTED_PASSES} and the module header.
*
* @param request - the real table data for one `DrlgType == 3` level.
* @returns the synthesized map plus a report of what was done.
* @throws when the level cannot be built: a size that yields no usable grid, or
* an unset size with nothing to derive one from. The message always names the
* level, because an empty map silently baked into an asset pack is worse than a
* crash.
*/
export function generateWilderness(request: WildernessRequest): WildernessResult {
const where = `level ${String(request.levelId)} (${request.levelName})`
if (request.pieces.length === 0) throw new Error(`${where}: no LvlPrest pieces for this level type`)
let sizeX = request.sizeX
let sizeY = request.sizeY
let sizeSource = 'Levels.txt'
if (!Number.isFinite(sizeX) || !Number.isFinite(sizeY) || sizeX <= 0 || sizeY <= 0) {
const resolved = resolveUnsetSize(request)
sizeX = resolved.sizeX
sizeY = resolved.sizeY
sizeSource = resolved.source
}
const gridWidth = Math.floor(sizeX / TILES_PER_BLOCK)
const gridHeight = Math.floor(sizeY / TILES_PER_BLOCK)
const groundTile = request.groundTile ?? CANONICAL_GROUND_TILES[request.levelTypeName] ?? deriveGroundTile(request.pieces)
if (gridWidth < 3 || gridHeight < 3) {
const bridgePiece = request.pieces.find(p => p.levels.some(l => l.width >= gridWidth * TILES_PER_BLOCK && l.height >= gridHeight * TILES_PER_BLOCK)) ?? request.pieces[0]
if (bridgePiece && bridgePiece.levels[0]) {
const bridgeDs1 = bridgePiece.levels[0]
const canvas = createCanvas(gridWidth * TILES_PER_BLOCK, gridHeight * TILES_PER_BLOCK, bridgeDs1.wallLayers, bridgeDs1.floorLayers, bridgeDs1.substitutionType === 1 || bridgeDs1.substitutionType === 2 ? 1 : 0)
if (groundTile !== null) fillGround(canvas, groundTile, 0, 0, canvas.width, canvas.height)
stampDs1(canvas, bridgeDs1, 0, 0)
return {
level: {
version: bridgeDs1.version,
width: canvas.width,
height: canvas.height,
act: bridgeDs1.act,
substitutionType: bridgeDs1.substitutionType,
wallLayers: canvas.wallLayers,
floorLayers: canvas.floorLayers,
cells: canvas.cells,
objects: canvas.objects,
npcPathOffset: null,
},
stats: {
levelId: request.levelId,
levelName: request.levelName,
levelTypeName: request.levelTypeName,
seed: request.seed,
sizeX,
sizeY,
sizeSource,
tilesPerBlock: TILES_PER_BLOCK,
blockGrid: { width: gridWidth, height: gridHeight },
dt1Mask: wildernessDt1Mask(request.levelTypeName),
subType: request.subType,
subTheme: Math.max(0, Math.min(4, Math.floor(request.subTheme))),
groundTile,
groundCells: canvas.width * canvas.height,
borderPiecesAvailable: 0,
borderBlocks: 0,
borderUsage: {},
substitutions: [],
substitutedClusters: 0,
objects: canvas.objects.length,
cells: canvas.width * canvas.height,
unresolved: [],
unimplementedPasses: UNIMPLEMENTED_PASSES,
notes: ['stamped full connector piece for narrow level'],
},
}
}
throw new Error(`${where}: ${String(sizeX)}x${String(sizeY)} cells is only ${String(gridWidth)}x${String(gridHeight)} blocks, too small for a bordered level`)
}
const stats: WildernessStats = {
substitutions: [], borderPieces: {}, unresolved: [], notes: [],
borderStamped: 0, groundCells: 0, groundTile: null, sizeSource,
}
// Layer counts must be known before the canvas exists, because a cell's layer
// arrays are fixed at creation.
let wallLayers = 1
let floorLayers = 1
let substitutionType = 0
let version = 0
let act = 1
const allLevels: Ds1[] = []
for (const piece of request.pieces) for (const level of piece.levels) allLevels.push(level)
for (const row of [...request.substitutions, ...(request.shrineSubstitutions ?? [])]) {
for (const level of row.levels) allLevels.push(level)
}
for (const level of allLevels) {
wallLayers = Math.max(wallLayers, level.wallLayers)
floorLayers = Math.max(floorLayers, level.floorLayers)
substitutionType = Math.max(substitutionType, level.substitutionType)
version = Math.max(version, level.version)
act = level.act
}
const substitutionLayers = substitutionType === 1 || substitutionType === 2 ? 1 : 0
const canvas = createCanvas(gridWidth * TILES_PER_BLOCK, gridHeight * TILES_PER_BLOCK, wallLayers, floorLayers, substitutionLayers)
const rng = new Rng(request.seed)
stats.groundTile = groundTile
if (groundTile === null) {
throw new Error(`${where}: no piece carries a floor tile to use as ground`)
}
stats.groundCells = fillGround(canvas, groundTile, 0, 0, canvas.width, canvas.height)
const borderPieces = request.pieces.filter(piece => piece.border)
const fillPieces = request.pieces.filter(piece => !piece.border)
layBorder(canvas, borderPieces, gridWidth, gridHeight, rng, stats)
const specialPresetNames = request.specialPresets ?? SPECIAL_PRESETS_BY_LEVEL[request.levelId] ?? []
const placedSpecialPieces = spawnSpecialPresets(canvas, fillPieces, specialPresetNames, gridWidth, gridHeight, rng, stats)
const themeIndex = Math.max(0, Math.min(4, Math.floor(request.subTheme)))
const objectRows = request.substitutions.filter(row => classifySubstitutionRole(row.name, row.type) !== 'border')
const borderRows = [...request.substitutions, ...(request.shrineSubstitutions ?? [])]
.filter(row => classifySubstitutionRole(row.name, row.type) === 'border')
// Secondary border pieces (`BordType` rows) are laid through the same cluster
// machinery, which is where their `GridSize`/`BordType` parameters belong.
const substituted = applySubstitutions(
canvas,
[...borderRows, ...objectRows, ...(request.shrineSubstitutions ?? []).filter(row => classifySubstitutionRole(row.name, row.type) !== 'border')],
themeIndex,
gridWidth,
gridHeight,
rng,
stats,
)
for (const piece of fillPieces) {
if (!placedSpecialPieces.has(piece.name)) {
stats.notes.push(`fill piece "${piece.name}" is not placed by this port`)
}
}
if (!Number.isFinite(canvas.width * canvas.height) || canvas.width * canvas.height > (1 << 22)) {
throw new Error(`${where}: synthesized map is ${String(canvas.width)}x${String(canvas.height)} cells, outside the supported bound`)
}
return {
level: {
version: version === 0 ? 18 : version,
width: canvas.width,
height: canvas.height,
act,
substitutionType,
wallLayers: canvas.wallLayers,
floorLayers: canvas.floorLayers,
cells: canvas.cells,
objects: canvas.objects,
npcPathOffset: null,
},
stats: {
levelId: request.levelId,
levelName: request.levelName,
levelTypeName: request.levelTypeName,
seed: request.seed,
sizeX,
sizeY,
sizeSource,
tilesPerBlock: TILES_PER_BLOCK,
blockGrid: { width: gridWidth, height: gridHeight },
dt1Mask: wildernessDt1Mask(request.levelTypeName),
subType: request.subType,
subTheme: themeIndex,
groundTile,
groundCells: stats.groundCells,
borderPiecesAvailable: borderPieces.length,
borderBlocks: stats.borderStamped,
borderUsage: stats.borderPieces,
substitutions: stats.substitutions,
substitutedClusters: substituted + placedSpecialPieces.size,
objects: canvas.objects.length,
cells: canvas.width * canvas.height,
unresolved: stats.unresolved,
unimplementedPasses: UNIMPLEMENTED_PASSES,
notes: stats.notes,
},
}
}