diablo2-web/src/game/wilderness.ts

5425 lines
188 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`. This port supports multi-DT1 weighted ground tile blending
* via `fillGround` using `dwDt1Mask` / `RarityFrameIndex` or candidate ground tiles,
* with single-tile fallback for backward compatibility — see {@link WildernessRequest.groundTile}.
*/
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall, Ds1Substitution } from '../formats/ds1.ts'
import type { Dt1 } from '../formats/dt1.ts'
import { Rng } from './rng.ts'
import { SUB_TILES_PER_TILE } from './map.ts'
import type { Side } from './world-graph.ts'
import {
initAct2OutdoorLevel,
initAct3OutdoorLevel,
generateAct3Jungle,
stampAct3KurastCauseway,
initAct4OutdoorLevel,
initAct5OutdoorLevel,
type Act2TopographyStats,
type Act3TopographyStats,
type Act4TopographyStats,
type Act5TopographyStats,
} from './wilderness-acts.ts'
import {
DRLG_GenerateJungles,
buildJungleTopology,
type JungleTopology,
type JungleZoneConnection,
type JungleLayoutStats,
type JungleRiverNetwork,
} from './wilderness-jungles.ts'
import {
stampAct5SiegeStrips,
type SiegeTopographyStats,
} from './wilderness-siege.ts'
import type { D2Table } from './acts.ts'
import { readSuperUniques, CANONICAL_ELITE_MODIFIERS } from './monsters.ts'
import type { SuperUnique } from './monsters.ts'
export {
DRLG_GenerateJungles,
buildJungleTopology,
type JungleTopology,
type JungleZoneConnection,
type JungleLayoutStats,
type JungleRiverNetwork,
stampAct5SiegeStrips,
type SiegeTopographyStats,
}
/** 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.
* Can also accept an array of {@link WeightedGroundTile} candidates for multi-tile blending.
*
* 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 or DT1 libraries,
* recording the choice in `stats.groundTile` (and `stats.groundTiles`).
*/
readonly groundTile?: { readonly style: number; readonly sequence: number } | readonly WeightedGroundTile[]
/**
* Array of candidate ground tiles with relative weights for multi-tile ground blending.
* When provided, `fillGround` blends these tiles across the canvas.
*/
readonly groundTiles?: readonly WeightedGroundTile[]
/**
* DT1 libraries loaded for the level. When supplied without explicit groundTile(s),
* candidate ground tiles are derived using dwDt1Mask and RarityFrameIndex.
*/
readonly dt1Libraries?: readonly Dt1[]
/**
* 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 cross-level openings this level must have, and which edge each is on.
*
* Supplied by the world graph rather than decided here, for two reasons.
*
* The first is that the sides are not a property of the level: Diablo II's
* outdoor placer re-picks them for every seed, subject to the constraint that
* two openings of one level may not share an edge. Hard-coding "the Cold
* Plains exit is on the north edge" states one legal roll as if it were a
* law.
*
* The second is that a seam has two ends. If this level puts its exit on the
* east edge, its neighbour must put the matching entrance on its west edge,
* and neither level can work that out alone. `assignGateSides` in
* `world-graph.ts` solves the whole act at once and hands each level the
* answer.
*
* When omitted the generator falls back to its own Act 1 table, which is what
* the tests and `verify-generators` use.
*/
readonly gates?: readonly PlannedGate[]
/** Optional `SuperUniques.txt` table for data-driven outdoor boss and landmark generation. */
readonly superuniques?: D2Table
/** Optional `MonPreset.txt` table for data-driven DS1 spawner object IDs. */
readonly monpreset?: D2Table
/** Optional custom landmark entity definitions overriding CANONICAL_LANDMARK_ENTITIES. */
readonly customLandmarks?: readonly LandmarkEntityDef[]
}
/** One cross-level opening, as decided by the act layout. */
export interface PlannedGate {
/** Which edge of this level the opening sits on. */
readonly side: Side
/** The level on the other side. */
readonly toLevelId: number
/** A human-readable name, used for the road anchor. */
readonly label: string
/**
* Which border-piece variant to open, if the caller has a preference.
*
* Three is the ordinary open gate; four is the variant the Burial Grounds and
* the Cold Plains use for the graveyard seam.
*/
readonly variant?: 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> & {
readonly landmarkEntities?: LandmarkEntity[]
}
}
/** 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 },
}
/** Canonical default open ground floor tile pools per wilderness level type. */
export const CANONICAL_GROUND_POOLS: Readonly<Record<string, readonly WeightedGroundTile[]>> = {
'Act 1 - Wilderness': [{ style: 0, sequence: 0, weight: 1 }],
'Act 2 - Desert': [{ style: 0, sequence: 1, weight: 1 }],
'Act 3 - Jungle': [{ style: 0, sequence: 0, weight: 1 }],
'Act 3 - Kurast': [{ style: 0, sequence: 0, weight: 1 }],
'Act 4 - Mesa': [{ style: 10, sequence: 0, weight: 1 }],
'Act 4 - Lava': [{ style: 20, sequence: 3, weight: 1 }],
'Act 5 - Siege': [{ style: 0, sequence: 0, weight: 1 }],
'Act 5 - Barricade': [{ style: 0, sequence: 0, weight: 1 }],
}
/* ------------------------------------------------------------------------- *
* 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. */
export function emptyWall(): Ds1Wall {
return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }
}
/** A blank floor or shadow cell. */
export function emptyFloor(): Ds1Floor {
return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
}
/** The mutable map under construction. */
export 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.
*/
export 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.
*/
export 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
const targetCell = targetRow[targetX]!
// Floors: copy any non-empty floor layer from source. If source cell has no
// non-empty floors, target's ground floor is preserved intact.
for (let i = 0; i < cell.floors.length; i += 1) {
const floor = cell.floors[i]
if (floor !== undefined && !floor.hidden && floor.prop1 !== 0) {
const targetFloors = targetCell.floors as Ds1Floor[]
if (i < targetFloors.length) {
targetFloors[i] = floor
} else {
targetFloors.push(floor)
}
}
}
// Walls: copy any non-empty wall layer from source.
for (let i = 0; i < cell.walls.length; i += 1) {
const wall = cell.walls[i]
if (wall !== undefined && !wall.hidden && wall.prop1 !== 0) {
const targetWalls = targetCell.walls as Ds1Wall[]
if (i < targetWalls.length) {
targetWalls[i] = wall
} else {
targetWalls.push(wall)
}
}
}
// Shadows: copy any non-empty shadow layer from source.
for (let i = 0; i < cell.shadows.length; i += 1) {
const shadow = cell.shadows[i]
if (shadow !== undefined && !shadow.hidden && shadow.prop1 !== 0) {
const targetShadows = targetCell.shadows as Ds1Floor[]
if (i < targetShadows.length) {
targetShadows[i] = shadow
} else {
targetShadows.push(shadow)
}
}
}
// Substitutions: copy non-zero substitution layer values.
for (let i = 0; i < cell.substitutions.length; i += 1) {
const sub = cell.substitutions[i]
if (sub !== undefined && sub.value !== 0) {
const targetSubs = targetCell.substitutions as Ds1Substitution[]
if (i < targetSubs.length) {
targetSubs[i] = sub
} else {
targetSubs.push(sub)
}
}
}
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
}
/** A candidate ground tile with an optional weighting/rarity. */
export interface WeightedGroundTile {
readonly style: number
readonly sequence: number
/**
* Relative selection weight (e.g. from `RarityFrameIndex` or piece counts).
* Defaults to 1 (or `rarityFrameIndex`) if omitted or <= 0.
*/
readonly weight?: number
/**
* Direct DT1 `rarityFrameIndex`, if sourced from a DT1 tile.
*/
readonly rarityFrameIndex?: number
}
/** Configuration options for ground tile blending. */
export interface FillGroundOptions {
/** PRNG seed or Rng instance for deterministic tile generation. */
readonly seed?: number | Rng
/** Blending mode: 'cdf' (cell-by-cell weighted draw) or 'spatial' (coherent noise patches). Default: 'spatial'. */
readonly mode?: 'cdf' | 'spatial'
/** Spatial noise scale in cells (default: 6). Higher values produce broader patches. */
readonly spatialScale?: number
/** Weight of spatial coherence vs cell jitter in [0, 1] (default: 0.6). */
readonly spatialCoherence?: number
}
/**
* Fast, high-quality 32-bit integer hash for 2D coordinates and a seed.
* Returns a uniform pseudo-random number in [0, 1).
*/
export function cellHash(x: number, y: number, seed: number): number {
let h = (seed ^ Math.imul(x, 0x1b873593) ^ Math.imul(y, 0x85ebca6b)) >>> 0
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0
h = (h ^ (h >>> 16)) >>> 0
return h / 4294967296
}
/**
* 2D smooth value noise with cubic Hermite interpolation.
* Returns a value in [0, 1).
*/
export function smoothNoise2D(x: number, y: number, scale: number, seed: number): number {
const invScale = 1 / Math.max(1, scale)
const gx = Math.floor(x * invScale)
const gy = Math.floor(y * invScale)
const fx = x * invScale - gx
const fy = y * invScale - gy
// Cubic Hermite smoothstep: 3t^2 - 2t^3
const sx = fx * fx * (3 - 2 * fx)
const sy = fy * fy * (3 - 2 * fy)
const n00 = cellHash(gx, gy, seed)
const n10 = cellHash(gx + 1, gy, seed)
const n01 = cellHash(gx, gy + 1, seed)
const n11 = cellHash(gx + 1, gy + 1, seed)
const nx0 = n00 + sx * (n10 - n00)
const nx1 = n01 + sx * (n11 - n01)
return nx0 + sy * (nx1 - nx0)
}
function isTileArray(
val: unknown,
): val is readonly unknown[] {
return Array.isArray(val)
}
/**
* Sample a single ground tile at (x, y) from weighted candidate tiles.
*/
export function sampleGroundTile(
tileOrTiles: WeightedGroundTile | readonly WeightedGroundTile[],
x: number,
y: number,
optionsOrSeed?: number | Rng | FillGroundOptions,
): WeightedGroundTile {
if (!isTileArray(tileOrTiles)) return tileOrTiles
if (tileOrTiles.length === 0) return { style: 0, sequence: 0 }
const tiles = tileOrTiles as readonly WeightedGroundTile[]
if (tiles.length === 1) return tiles[0]!
let seed = 0
let mode: 'cdf' | 'spatial' = 'spatial'
let spatialScale = 6
let spatialCoherence = 0.6
if (typeof optionsOrSeed === 'number') {
seed = optionsOrSeed >>> 0
} else if (optionsOrSeed instanceof Rng) {
seed = optionsOrSeed.seed >>> 0
} else if (optionsOrSeed && typeof optionsOrSeed === 'object') {
if (typeof optionsOrSeed.seed === 'number') seed = optionsOrSeed.seed >>> 0
else if (optionsOrSeed.seed instanceof Rng) seed = optionsOrSeed.seed.seed >>> 0
if (optionsOrSeed.mode !== undefined) mode = optionsOrSeed.mode
if (typeof optionsOrSeed.spatialScale === 'number' && optionsOrSeed.spatialScale > 0) {
spatialScale = optionsOrSeed.spatialScale
}
if (typeof optionsOrSeed.spatialCoherence === 'number') {
spatialCoherence = Math.max(0, Math.min(1, optionsOrSeed.spatialCoherence))
}
}
const valid = tileOrTiles.filter(t => {
const w = t.weight !== undefined ? t.weight : t.rarityFrameIndex !== undefined ? t.rarityFrameIndex : 1
return w > 0
})
const active = valid.length > 0 ? valid : tileOrTiles
if (active.length === 1) return active[0]!
let totalWeight = 0
for (const c of active) {
const w = c.weight !== undefined ? c.weight : c.rarityFrameIndex !== undefined ? c.rarityFrameIndex : 1
totalWeight += Math.max(0, w)
}
if (totalWeight <= 0) return active[0]!
const cdfs: number[] = []
let running = 0
for (let i = 0; i < active.length; i += 1) {
const w = active[i]!.weight !== undefined
? active[i]!.weight!
: active[i]!.rarityFrameIndex !== undefined
? active[i]!.rarityFrameIndex!
: 1
running += Math.max(0, w)
cdfs.push(running / totalWeight)
}
cdfs[cdfs.length - 1] = 1.0
let u: number
if (mode === 'cdf') {
u = cellHash(x, y, seed)
} else {
const smooth = smoothNoise2D(x, y, spatialScale, seed)
const jitter = cellHash(x, y, seed ^ 0x9e3779b9)
u = spatialCoherence * smooth + (1 - spatialCoherence) * jitter
}
for (let i = 0; i < cdfs.length; i += 1) {
if (u < cdfs[i]!) return active[i]!
}
return active[active.length - 1]!
}
/**
* Write one or more floor tiles into every cell of a rectangle with weighted random blending.
*
* Supports:
* 1. Single tile fallback (backward compatibility): All cells receive the single tile.
* 2. Multi-tile weighted distribution: Tiles are sampled via CDF sampling or spatial noise,
* naturally blending variations (e.g. pure grass, pebbled dirt, weeds) across the ground
* without jarring discontinuities.
*
* @param canvas - the canvas.
* @param tileOrTiles - single tile or array of weighted candidate tiles.
* @param x - left edge in cells.
* @param y - top edge in cells.
* @param width - cells.
* @param height - cells.
* @param optionsOrSeed - PRNG seed, Rng instance, or blending options.
* @returns the number of cells written.
*/
export function fillGround(
canvas: Canvas,
tileOrTiles: WeightedGroundTile | readonly WeightedGroundTile[],
x: number,
y: number,
width: number,
height: number,
optionsOrSeed?: number | Rng | FillGroundOptions,
): number {
if (!isTileArray(tileOrTiles)) {
const tile = tileOrTiles
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
Object.assign(floor, { prop1: 2, style: tile.style, sequence: tile.sequence, hidden: false })
written += 1
}
}
return written
}
if (tileOrTiles.length === 0) return 0
const tiles = tileOrTiles as readonly WeightedGroundTile[]
if (tiles.length === 1) {
const tile = tiles[0]!
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
Object.assign(floor, { prop1: 2, style: tile.style, sequence: tile.sequence, hidden: false })
written += 1
}
}
return written
}
const validCandidates: { style: number; sequence: number; weight: number }[] = []
for (const t of tileOrTiles) {
const w = t.weight !== undefined
? Math.max(0, t.weight)
: t.rarityFrameIndex !== undefined
? Math.max(0, t.rarityFrameIndex)
: 1
if (w > 0) {
validCandidates.push({ style: t.style, sequence: t.sequence, weight: w })
}
}
const active = validCandidates.length > 0
? validCandidates
: tileOrTiles.map(t => ({ style: t.style, sequence: t.sequence, weight: 1 }))
if (active.length === 1) {
const tile = active[0]!
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
Object.assign(floor, { prop1: 2, style: tile.style, sequence: tile.sequence, hidden: false })
written += 1
}
}
return written
}
let totalWeight = 0
for (const c of active) totalWeight += c.weight
const cdfThresholds: number[] = []
let running = 0
for (let i = 0; i < active.length; i += 1) {
running += active[i]!.weight
cdfThresholds.push(running / totalWeight)
}
cdfThresholds[cdfThresholds.length - 1] = 1.0
const pickCandidate = (u: number): { style: number; sequence: number } => {
for (let i = 0; i < cdfThresholds.length; i += 1) {
if (u < cdfThresholds[i]!) return active[i]!
}
return active[active.length - 1]!
}
let seed = 0
let mode: 'cdf' | 'spatial' = 'spatial'
let spatialScale = 6
let spatialCoherence = 0.6
if (typeof optionsOrSeed === 'number') {
seed = optionsOrSeed >>> 0
} else if (optionsOrSeed instanceof Rng) {
seed = optionsOrSeed.seed >>> 0
} else if (optionsOrSeed && typeof optionsOrSeed === 'object') {
if (typeof optionsOrSeed.seed === 'number') seed = optionsOrSeed.seed >>> 0
else if (optionsOrSeed.seed instanceof Rng) seed = optionsOrSeed.seed.seed >>> 0
if (optionsOrSeed.mode !== undefined) mode = optionsOrSeed.mode
if (typeof optionsOrSeed.spatialScale === 'number' && optionsOrSeed.spatialScale > 0) {
spatialScale = optionsOrSeed.spatialScale
}
if (typeof optionsOrSeed.spatialCoherence === 'number') {
spatialCoherence = Math.max(0, Math.min(1, optionsOrSeed.spatialCoherence))
}
}
if (mode === 'cdf') {
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
const u = cellHash(cx, cy, seed)
const picked = pickCandidate(u)
Object.assign(floor, { prop1: 2, style: picked.style, sequence: picked.sequence, hidden: false })
written += 1
}
}
return written
}
interface CellEntry {
floor: Ds1Floor
v: number
index: number
}
const cellsToWrite: CellEntry[] = []
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
const smooth = smoothNoise2D(cx, cy, spatialScale, seed)
const jitter = cellHash(cx, cy, seed ^ 0x9e3779b9)
const v = spatialCoherence * smooth + (1 - spatialCoherence) * jitter
cellsToWrite.push({
floor,
v,
index: cy * canvas.width + cx,
})
}
}
if (cellsToWrite.length === 0) return 0
cellsToWrite.sort((a, b) => (a.v !== b.v ? a.v - b.v : a.index - b.index))
const totalCells = cellsToWrite.length
for (let r = 0; r < totalCells; r += 1) {
const u = (r + 0.5) / totalCells
const picked = pickCandidate(u)
Object.assign(cellsToWrite[r]!.floor, {
prop1: 2,
style: picked.style,
sequence: picked.sequence,
hidden: false,
})
}
return totalCells
}
/* ------------------------------------------------------------------------- *
* Ground selection
* ------------------------------------------------------------------------- */
/**
* Extract candidate ground floor tiles from DT1 libraries filtered by dwDt1Mask.
*
* In Diablo II outdoor levels, DRLGROOMTILE_GetTileCache scans the libraries
* activated by the level type's dwDt1Mask (switch on nLevelType in DRLGOUTDOORS_GenerateLevel).
* Floor tiles (tile.type === 0) with positive RarityFrameIndex are accumulated
* into candidate ground tiles weighted by their rarity.
*
* @param libraries - All DT1 libraries resolved for the level (in slot order).
* @param dt1Mask - The bitmask selecting which DT1 slots are active for ground.
* @returns Array of weighted ground tiles.
*/
export function deriveGroundTilesFromDt1Libraries(
libraries: readonly Dt1[],
dt1Mask: number,
): WeightedGroundTile[] {
const counts = new Map<string, { style: number; sequence: number; weight: number }>()
for (let libIndex = 0; libIndex < libraries.length; libIndex += 1) {
if ((dt1Mask & (1 << libIndex)) === 0) continue
const lib = libraries[libIndex]
if (!lib) continue
for (const tile of lib.tiles) {
if (tile.type !== 0) continue
const rarity = Math.max(0, tile.rarityFrameIndex | 0)
if (rarity <= 0) continue
const key = `${String(tile.style)}:${String(tile.sequence)}`
const existing = counts.get(key)
if (existing === undefined) {
counts.set(key, { style: tile.style, sequence: tile.sequence, weight: rarity })
} else {
existing.weight += rarity
}
}
}
if (counts.size === 0) {
for (let libIndex = 0; libIndex < libraries.length; libIndex += 1) {
if ((dt1Mask & (1 << libIndex)) === 0) continue
const lib = libraries[libIndex]
if (!lib) continue
for (const tile of lib.tiles) {
if (tile.type !== 0) continue
const key = `${String(tile.style)}:${String(tile.sequence)}`
if (!counts.has(key)) {
counts.set(key, { style: tile.style, sequence: tile.sequence, weight: 1 })
}
}
}
}
return Array.from(counts.values()).map(e => ({
style: e.style,
sequence: e.sequence,
weight: e.weight,
rarityFrameIndex: e.weight,
}))
}
/**
* Derive candidate ground tiles from the level's own border and fill pieces.
*
* Scans interior cells of each piece variant for visible floor tiles, tallying
* their occurrences to use as relative weights.
*
* @param pieces - Border and fill pieces.
* @returns Array of weighted ground tiles sorted by weight descending.
*/
export function deriveGroundTiles(pieces: readonly WildernessPiece[]): WeightedGroundTile[] {
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)
}
}
}
}
}
const result = Array.from(counts.values()).map(e => ({
style: e.style,
sequence: e.sequence,
weight: e.count,
}))
result.sort((a, b) => b.weight - a.weight)
return result
}
/**
* 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.
*/
export function deriveGroundTile(pieces: readonly WildernessPiece[]): { style: number; sequence: number } | null {
const tiles = deriveGroundTiles(pieces)
if (tiles.length === 0) return null
return { style: tiles[0]!.style, sequence: tiles[0]!.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`. */
export interface SubstitutionReport {
readonly name: string
readonly role: WildernessRole
readonly enabled: boolean
readonly clusters: number
}
/** A fixed SuperUnique boss or guard NPC anchored to a wilderness landmark or border gate. */
export interface LandmarkEntity {
readonly role: 'superunique' | 'npc_guard'
readonly id: string
readonly monsterId: string
readonly name: string
readonly landmark: string
readonly tileX: number
readonly tileY: number
readonly subtileX?: number
readonly subtileY?: number
readonly levelId?: number
readonly modifiers: readonly string[]
readonly minionCount: readonly [number, number]
readonly minionMonsterId: string
readonly objectId?: number
}
/** The report collector. */
export 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
groundTiles?: readonly WeightedGroundTile[] | undefined
sizeSource: string
roadCells: number
roadSegments: number
anchors: number
specialPresets: string[]
/** Every way into or out of the level, in cells. See `WildernessEntrance`. */
entrances: WildernessEntrance[]
waypointsSpawned?: number
waypointTile?: { readonly x: number; readonly y: number }
shrinesSpawned?: number
interactivesSpawned?: number
landmarkEntities?: LandmarkEntity[]
act2Topography?: Act2TopographyStats
act3Topography?: Act3TopographyStats
act4Topography?: Act4TopographyStats
act5Topography?: Act5TopographyStats
jungleLayout?: JungleLayoutStats
jungleTopology?: JungleTopology
bridgesPlaced?: number
riverIslands?: number
riverForks?: number
meanderCount?: number
siegeTopography?: SiegeTopographyStats
}
/**
* Exact 256-entry 8-neighbor bitmask lookup table for Act 1 dirt path tiles
* (`byte_6FDCF958` in `DrlgOutdoors.cpp` / `DRLG_OUTDOORS_GenerateDirtPath`).
*
* Given a cell `(x, y)` marked as part of a dirt path, sample its 8 neighbors:
* - bit 7 (`0x80`): `(x + 1, y - 1)`
* - bit 6 (`0x40`): `(x + 1, y )`
* - bit 5 (`0x20`): `(x + 1, y + 1)`
* - bit 4 (`0x10`): `(x , y - 1)`
* - bit 3 (`0x08`): `(x , y + 1)`
* - bit 2 (`0x04`): `(x - 1, y - 1)`
* - bit 1 (`0x02`): `(x - 1, y )`
* - bit 0 (`0x01`): `(x - 1, y + 1)`
*
* Non-zero entries give the DT1 `sequence` (with `style = 0`, `prop1 = 2`) for
* the resulting straight, corner, or transition dirt road tile.
*/
export const DIRT_PATH_TILE_LUT: Readonly<Uint8Array> = new Uint8Array([
0x00, 0x00, 0x10, 0x10, 0x00, 0x00, 0x10, 0x10, 0x0E, 0x0E, 0x06, 0x13, 0x0E, 0x0E, 0x06, 0x13,
0x0F, 0x0F, 0x05, 0x05, 0x0F, 0x0F, 0x15, 0x15, 0x08, 0x08, 0x0A, 0x26, 0x08, 0x08, 0x28, 0x14,
0x00, 0x00, 0x10, 0x10, 0x00, 0x00, 0x10, 0x10, 0x0E, 0x0E, 0x06, 0x13, 0x0E, 0x0E, 0x06, 0x13,
0x0F, 0x0F, 0x05, 0x05, 0x0F, 0x0F, 0x15, 0x15, 0x08, 0x08, 0x0A, 0x26, 0x08, 0x08, 0x28, 0x14,
0x0D, 0x0D, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x07, 0x04, 0x04, 0x0B, 0x25, 0x04, 0x04, 0x0B, 0x2B,
0x03, 0x03, 0x0C, 0x0C, 0x03, 0x03, 0x27, 0x27, 0x09, 0x09, 0x02, 0x2B, 0x09, 0x09, 0x2C, 0x1A,
0x0D, 0x0D, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x07, 0x17, 0x17, 0x29, 0x11, 0x17, 0x17, 0x29, 0x11,
0x03, 0x03, 0x0C, 0x0C, 0x03, 0x03, 0x27, 0x27, 0x2A, 0x2A, 0x2E, 0x2A, 0x2A, 0x2A, 0x21, 0x1F,
0x00, 0x00, 0x10, 0x10, 0x00, 0x00, 0x10, 0x10, 0x0E, 0x0E, 0x06, 0x13, 0x0E, 0x0E, 0x06, 0x13,
0x0F, 0x0F, 0x05, 0x05, 0x0F, 0x0F, 0x15, 0x15, 0x08, 0x08, 0x0A, 0x26, 0x08, 0x08, 0x23, 0x14,
0x00, 0x00, 0x10, 0x10, 0x00, 0x00, 0x10, 0x10, 0x0E, 0x0E, 0x06, 0x13, 0x0E, 0x0E, 0x06, 0x13,
0x0F, 0x0F, 0x05, 0x05, 0x0F, 0x0F, 0x15, 0x15, 0x08, 0x08, 0x0A, 0x26, 0x08, 0x08, 0x28, 0x14,
0x0D, 0x0D, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x07, 0x04, 0x04, 0x0B, 0x25, 0x04, 0x04, 0x0B, 0x25,
0x12, 0x12, 0x23, 0x23, 0x12, 0x12, 0x16, 0x16, 0x24, 0x24, 0x2D, 0x22, 0x24, 0x24, 0x1C, 0x1D,
0x0D, 0x0D, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x07, 0x17, 0x17, 0x29, 0x11, 0x17, 0x17, 0x29, 0x11,
0x12, 0x12, 0x23, 0x23, 0x12, 0x12, 0x16, 0x16, 0x18, 0x18, 0x19, 0x20, 0x18, 0x18, 0x1E, 0x01,
])
/**
* 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.
*/
export const UNIMPLEMENTED_PASSES: readonly string[] = [
'DRLGVER_CreateVertices',
'DRLGOUTPLACE_CreateLevelConnections',
'DRLGOUTDOORS_SpawnAct3Mephisto',
]
/* ------------------------------------------------------------------------- *
* Border
* ------------------------------------------------------------------------- */
type BorderRole = 'tl' | 'tr' | 'br' | 'bl' | 'top' | 'right' | 'bottom' | 'left'
function borderRoleAt(x: number, y: number, gridWidth: number, gridHeight: number): BorderRole {
if (x === 0 && y === 0) return 'tl'
if (x === gridWidth - 1 && y === 0) return 'tr'
if (x === gridWidth - 1 && y === gridHeight - 1) return 'br'
if (x === 0 && y === gridHeight - 1) return 'bl'
if (y === 0) return 'top'
if (x === gridWidth - 1) return 'right'
if (y === gridHeight - 1) return 'bottom'
return 'left'
}
/**
* Find the directional border piece matching `DRLGOUTPLACE_PlaceAct1245OutdoorBorders`.
*
* In Diablo II:
* - Numeric `Border 1..8` map to: 1=Bottom, 2=Left, 3=Top, 4=Right,
* 5=Bottom-Left, 6=Top-Left, 7=Top-Right, 8=Bottom-Right.
* - Compass `Border N/S/E/W/NE/NW/SE/SW` (Act 3 Kurast) map directly by cardinal direction.
*/
function findDirectionalBorderPiece(pieces: readonly WildernessPiece[], role: BorderRole): WildernessPiece | null {
const numericPatterns: Record<BorderRole, RegExp> = {
bottom: /Border\s+1\b/i,
left: /Border\s+2\b/i,
top: /Border\s+3\b/i,
right: /Border\s+4\b/i,
bl: /Border\s+5\b/i,
tl: /Border\s+6\b/i,
tr: /Border\s+7\b/i,
br: /Border\s+8\b/i,
}
const compassPatterns: Record<BorderRole, RegExp> = {
top: /Border\s+N\b/i,
bottom: /Border\s+S\b/i,
right: /Border\s+E\b/i,
left: /Border\s+W\b/i,
tr: /Border\s+NE\b/i,
tl: /Border\s+NW\b/i,
br: /Border\s+SE\b/i,
bl: /Border\s+SW\b/i,
}
const hasExtendedNumericBorders = pieces.some(p => /Border\s+(?:9|1\d)\b/i.test(p.name))
if (!hasExtendedNumericBorders) {
const numPat = numericPatterns[role]
const matchingNum = pieces.find(p => numPat.test(p.name) && !/Cliff Border/i.test(p.name))
?? pieces.find(p => numPat.test(p.name))
if (matchingNum !== undefined) return matchingNum
}
const compPat = compassPatterns[role]
const matchingComp = pieces.find(p => compPat.test(p.name))
if (matchingComp !== undefined) return matchingComp
return null
}
/**
* 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.
*
* @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
}
/** Orientation of a border piece along the outer edge of a level. */
export type BorderOrientation =
| 'north'
| 'south'
| 'east'
| 'west'
| 'corner-nw'
| 'corner-ne'
| 'corner-se'
| 'corner-sw'
export interface ClassifiedBorderPiece {
readonly piece: WildernessPiece
readonly orientation: BorderOrientation | null
readonly isCliff: boolean
}
/**
* Classify a border piece by its orientation and whether it is a stone cliff.
*
* Blizzard North uses a universal numbering convention across outdoor border presets:
* - 1 = South edge (horizontal straight)
* - 2 = West edge (vertical straight)
* - 3 = North edge (horizontal straight)
* - 4 = East edge (vertical straight)
* - 5 = SW corner
* - 6 = NW corner
* - 7 = NE corner
* - 8 = SE corner
* - 9..12 = corner / transition variations
*
* Act 3 outdoor tiles use cardinal suffixes ("Border N", "Border S", "Border NE", etc.).
* Cave entrances ("clfcave", "clfcave2") are dedicated structures, never generic border pieces.
*/
export function classifyBorderPiece(name: string): { orientation: BorderOrientation | null; isCliff: boolean } {
if (/cave/i.test(name)) return { orientation: null, isCliff: false }
const isCliff = /cliff|stnclf/i.test(name)
// Act 3 style: "Border N", "Border S", "Border NE", etc.
const cardinal = name.match(/Border\s+([NESW]{1,2})\b/i)
if (cardinal) {
const dir = cardinal[1]!.toUpperCase()
if (dir === 'N') return { orientation: 'north', isCliff }
if (dir === 'S') return { orientation: 'south', isCliff }
if (dir === 'E') return { orientation: 'east', isCliff }
if (dir === 'W') return { orientation: 'west', isCliff }
if (dir === 'NW') return { orientation: 'corner-nw', isCliff }
if (dir === 'NE') return { orientation: 'corner-ne', isCliff }
if (dir === 'SE') return { orientation: 'corner-se', isCliff }
if (dir === 'SW') return { orientation: 'corner-sw', isCliff }
}
// Act 1, 2, 4, 5 style: 1=S, 2=W, 3=N, 4=E, 5=SW, 6=NW, 7=NE, 8=SE
const numMatch = name.match(/(?:border|edge|brdr_?snow|brdr)\s*0?(\d+)/i)
if (numMatch) {
const num = Number(numMatch[1])
if (num === 1) return { orientation: 'south', isCliff }
if (num === 2) return { orientation: 'west', isCliff }
if (num === 3) return { orientation: 'north', isCliff }
if (num === 4) return { orientation: 'east', isCliff }
if (num === 5) return { orientation: 'corner-sw', isCliff }
if (num === 6) return { orientation: 'corner-nw', isCliff }
if (num === 7) return { orientation: 'corner-ne', isCliff }
if (num === 8) return { orientation: 'corner-se', isCliff }
}
return { orientation: null, isCliff }
}
/**
* Determine the perimeter orientation for a block cell on a `gridWidth x gridHeight` grid.
*/
function getCellOrientation(x: number, y: number, gridWidth: number, gridHeight: number): BorderOrientation {
if (x === 0 && y === 0) return 'corner-nw'
if (x === gridWidth - 1 && y === 0) return 'corner-ne'
if (x === gridWidth - 1 && y === gridHeight - 1) return 'corner-se'
if (x === 0 && y === gridHeight - 1) return 'corner-sw'
if (y === 0) return 'north'
if (x === gridWidth - 1) return 'east'
if (y === gridHeight - 1) return 'south'
return 'west'
}
/**
* Select a matching piece for the given orientation.
*/
function selectBorderPiece(
orientation: BorderOrientation,
classified: readonly ClassifiedBorderPiece[],
rng: Rng,
preferCliff: boolean,
): WildernessPiece | null {
const matches = classified.filter(c => c.orientation === orientation)
if (matches.length === 0) return null
if (preferCliff) {
const cliffMatches = matches.filter(c => c.isCliff)
if (cliffMatches.length > 0) {
return cliffMatches[rng.int(0, cliffMatches.length - 1)]!.piece
}
} else {
const nonCliffMatches = matches.filter(c => !c.isCliff)
if (nonCliffMatches.length > 0) {
return nonCliffMatches[rng.int(0, nonCliffMatches.length - 1)]!.piece
}
}
return matches[rng.int(0, matches.length - 1)]!.piece
}
/**
* Check if a border DS1 variant has a continuous wall along the border orientation.
*/
export function isClosedBorder(level: Ds1, orientation: BorderOrientation): boolean {
if (orientation === 'north' || orientation === 'south') {
for (let y = 0; y < level.height; y += 1) {
let continuous = true
for (let x = 0; x < level.width; x += 1) {
if (!level.cells[y]?.[x]?.walls.some(w => !w.hidden && w.prop1 !== 0)) {
continuous = false
break
}
}
if (continuous) return true
}
return false
}
if (orientation === 'west' || orientation === 'east') {
for (let x = 0; x < level.width; x += 1) {
let continuous = true
for (let y = 0; y < level.height; y += 1) {
if (!level.cells[y]?.[x]?.walls.some(w => !w.hidden && w.prop1 !== 0)) {
continuous = false
break
}
}
if (continuous) return true
}
return false
}
return true
}
/**
* Select a specific border variant based on whether this perimeter position is an opening (exit/entrance).
*/
export function selectBorderVariant(
piece: WildernessPiece,
orientation: BorderOrientation,
isOpen: boolean,
rng: Rng,
): Ds1 {
// In Blizzard LvlPrest.txt tables with >= 4 variants (Act 1 Bord1..4 and Act 4 Border1..4),
// slots 0..2 are closed borders (a/b/c) and slots 3+ are open exit/gate borders (o/oe).
let closedLevels: readonly Ds1[]
let openLevels: readonly Ds1[]
if (piece.levels.length >= 4) {
closedLevels = piece.levels.slice(0, 3)
openLevels = piece.levels.slice(3)
} else {
closedLevels = piece.levels.filter(l => isClosedBorder(l, orientation))
openLevels = piece.levels.filter(l => !isClosedBorder(l, orientation))
}
if (isOpen && openLevels.length > 0) {
return openLevels[rng.int(0, openLevels.length - 1)]!
}
if (!isOpen && closedLevels.length > 0) {
return closedLevels[rng.int(0, closedLevels.length - 1)]!
}
return piece.levels[rng.int(0, piece.levels.length - 1)]!
}
/**
* Determine perimeter opening (exit/entrance) coordinates for a wilderness level.
*/
export function getPerimeterOpenings(
levelId: number,
gridWidth: number,
gridHeight: number,
): Set<string> {
const openings = new Set<string>()
const key = (x: number, y: number) => `${String(x)},${String(y)}`
if (levelId === 39 || levelId === 46) {
// Moo Moo Farm (Cow Level) & Canyon of the Magi: 0 exits!
return openings
}
// Outdoor wilderness levels across Acts 1, 2, 3, 4, 5:
// Act 1 (2..7), Act 2 desert (41..46), Act 3 Kurast (79..82),
// Act 4 Mesa (104..106), Act 5 outdoor (111..112, 117):
// Default entrance on South edge, exit on East edge
if (
(levelId >= 2 && levelId <= 7) ||
(levelId >= 41 && levelId <= 46) ||
(levelId >= 79 && levelId <= 82) ||
(levelId >= 104 && levelId <= 106) ||
(levelId >= 111 && levelId <= 112) ||
levelId === 117
) {
const inX = Math.floor(gridWidth / 2)
openings.add(key(inX, gridHeight - 1))
const outY = Math.floor(gridHeight / 2)
openings.add(key(gridWidth - 1, outY))
return openings
}
return openings
}
/**
* Lay the level's border pieces around the block grid's outer ring.
*
* `DRLGOUTDOORS_SpawnOutdoorLevelPresetEx` is the model: straight edge pieces are
* placed according to edge orientation (North, South, East, West), followed by corner
* join pieces (NW, NE, SE, SW). In Act 1 wilderness levels, North and West boundaries
* prefer solid rock cliffs (`StnClf`), while South and East boundaries use fence borders,
* matching original Diablo II DRLG rules.
*
* Perimeter openings (exits/entrances) select open variants (e.g. `Bord1o`/`Bord4o`),
* while all other perimeter pieces use solid/closed wall variants (`isClosedBorder`).
* Supports forced gate variants (`openGates`) and claimed border blocks (`claimedBorderBlocks`).
*
* @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.
* @param levelId - optional level id for perimeter opening rules.
* @param openGates - optional map from `${x},${y}` block key to forced variant index.
* @param claimedBorderBlocks - optional set of `${x},${y}` keys to skip (e.g. Town Transitions).
* @returns the number of pieces stamped.
*/
function layBorder(
canvas: Canvas,
pieces: readonly WildernessPiece[],
gridWidth: number,
gridHeight: number,
rng: Rng,
stats: WildernessStats,
levelId?: number,
openGates?: ReadonlyMap<string, number>,
claimedBorderBlocks?: ReadonlySet<string>,
): number {
if (pieces.length === 0) {
stats.unresolved.push('no border piece for this level type')
return 0
}
// Filter out cave entrances from generic border placement
const validPieces = pieces.filter(p => !/cave/i.test(p.name))
const candidatePieces = validPieces.length > 0 ? validPieces : pieces
const classified: ClassifiedBorderPiece[] = candidatePieces
.map(piece => {
const { orientation, isCliff } = classifyBorderPiece(piece.name)
return { piece, orientation, isCliff }
})
.filter((item): item is ClassifiedBorderPiece & { orientation: BorderOrientation } => item.orientation !== null)
const ordered = [...candidatePieces].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)
})
// Stamp straight edges first, then corners last, matching D2MOO DRLGOUTPLACE_PlaceAct1245OutdoorBorders
// where corner presets overwrite edge presets to form seamless joins.
const straightCells: { x: number; y: number }[] = []
// North edge
for (let x = 1; x < gridWidth - 1; x += 1) straightCells.push({ x, y: 0 })
// East edge
for (let y = 1; y < gridHeight - 1; y += 1) straightCells.push({ x: gridWidth - 1, y })
// South edge
for (let x = gridWidth - 2; x >= 1; x -= 1) straightCells.push({ x, y: gridHeight - 1 })
// West edge
for (let y = gridHeight - 2; y >= 1; y -= 1) straightCells.push({ x: 0, y })
const cornerCells: { x: number; y: number }[] = [
{ x: 0, y: 0 }, // NW
{ x: gridWidth - 1, y: 0 }, // NE
{ x: gridWidth - 1, y: gridHeight - 1 }, // SE
{ x: 0, y: gridHeight - 1 }, // SW
]
const openings = levelId !== undefined ? getPerimeterOpenings(levelId, gridWidth, gridHeight) : new Set<string>()
let cursor = rng.int(0, ordered.length - 1)
let stamped = 0
const stampCell = (cell: { x: number; y: number }, isCorner: boolean) => {
const key = `${String(cell.x)},${String(cell.y)}`
if (claimedBorderBlocks?.has(key) === true) {
return
}
const orientation = getCellOrientation(cell.x, cell.y, gridWidth, gridHeight)
// North, West, NW, NE, SW prefer cliff pieces if available (e.g. Act 1 wilderness mountain boundaries)
const preferCliff =
orientation === 'north' ||
orientation === 'west' ||
orientation === 'corner-nw' ||
orientation === 'corner-ne' ||
orientation === 'corner-sw'
let piece = selectBorderPiece(orientation, classified, rng, preferCliff)
if (!piece) {
piece = ordered[cursor]!
cursor = (cursor + 1) % ordered.length
}
if (!piece || piece.levels.length === 0) return
let level: Ds1 | undefined
const forcedVariant = openGates?.get(key)
if (forcedVariant !== undefined) {
level = piece.levels[Math.min(forcedVariant, piece.levels.length - 1)]
} else {
const isOpen = !isCorner && openings.has(key)
level = selectBorderVariant(piece, orientation, isOpen, rng)
}
if (level === undefined) return
const maxOriginX = Math.max(canvas.width - level.width, 0)
const maxOriginY = Math.max(canvas.height - level.height, 0)
const originX = level.width <= TILES_PER_BLOCK + 1
? cell.x * TILES_PER_BLOCK
: Math.min(cell.x * TILES_PER_BLOCK, maxOriginX)
const originY = level.height <= TILES_PER_BLOCK + 1
? cell.y * TILES_PER_BLOCK
: Math.min(cell.y * TILES_PER_BLOCK, maxOriginY)
stampDs1(canvas, level, originX, originY)
stats.borderPieces[piece.name] = (stats.borderPieces[piece.name] ?? 0) + 1
stats.borderStamped += 1
stamped += 1
}
for (const cell of straightCells) stampCell(cell, false)
for (const cell of cornerCells) stampCell(cell, true)
return stamped
}
/* ------------------------------------------------------------------------- *
* Act 1 Outdoor Anchors, Presets & Dirt Road Generation
* (`DRLGOUTWILD_InitAct1OutdoorLevel`, `DRLGOUTDOORS_SpawnAct1DirtPaths`,
* `DRLG_OUTDOORS_GenerateDirtPath`)
* ------------------------------------------------------------------------- */
/**
* Where a road ends.
*
* Until now these were purely internal: the road network needs endpoints to
* draw between and nothing more. They are also, though, the exact world
* coordinates of every way into and out of the level, which is what the world
* graph needs and what was previously being computed and thrown away — only
* `stats.anchors`, a count, escaped the generator.
*
* Coordinates are in cells, with `(0, 0)` at the final map's origin. Unlike the
* maze generator there is no `minX` shift to undo.
*/
interface RoadAnchor {
readonly label: string
/** The opening itself: the cell the player crosses. */
readonly anchorCell: { readonly x: number; readonly y: number }
/** One step inside the level from `anchorCell`, where the road starts. */
readonly interiorCell: { readonly x: number; readonly y: number }
/** The level on the other side, when this anchor is a way out. */
readonly toLevelId?: number | undefined
/** Which edge of the map this anchor sits on, for border gates. */
readonly side?: Side | undefined
/** Whether this is a border seam or the mouth of an interior preset. */
readonly kind?: 'gate' | 'preset' | undefined
}
/** A level entrance or exit, as reported in `stats.entrances`. */
export interface WildernessEntrance {
/** The anchor's name, e.g. `Cold Plains Exit`. */
readonly label: string
/** The crossing point, in cells. */
readonly x: number
readonly y: number
/** Where the player stands after crossing, in cells. */
readonly interiorX: number
readonly interiorY: number
/** The level on the other side, or -1 when the generator does not know. */
readonly toLevelId: number
/** The map edge, for border seams. */
readonly side: Side | null
/** Border seam or interior preset mouth. */
readonly kind: 'gate' | 'preset'
}
interface SpecialPresetPlacement {
readonly name: string
readonly ds1: Ds1
readonly bx: number
readonly by: number
readonly blocksX: number
readonly blocksY: number
}
interface Act1OutdoorPlan {
readonly openGates: Map<string, number>
readonly claimedBorderBlocks: Set<string>
readonly claimedBlocks: boolean[][]
readonly roadObstacleBlocks: boolean[][]
readonly specialPresets: SpecialPresetPlacement[]
readonly anchors: RoadAnchor[]
}
/** Map a compass side onto the generator's own edge names. */
const SIDE_TO_EDGE: Readonly<Record<Side, 'top' | 'bottom' | 'left' | 'right'>> = {
north: 'top',
south: 'bottom',
east: 'right',
west: 'left',
}
/**
* Turn the plan's anchors into the entrance list the packer bakes.
*
* @param anchors - the plan's anchors.
* @returns one entrance per anchor.
*/
function toEntrances(anchors: readonly RoadAnchor[]): WildernessEntrance[] {
return anchors.map(anchor => ({
label: anchor.label,
x: anchor.anchorCell.x,
y: anchor.anchorCell.y,
interiorX: anchor.interiorCell.x,
interiorY: anchor.interiorCell.y,
toLevelId: anchor.toLevelId ?? -1,
side: anchor.side ?? null,
kind: anchor.kind ?? 'preset',
}))
}
/**
* Plan Act 1 wilderness special presets, border road gates, and road anchors.
*/
function planAct1OutdoorLevel(
request: WildernessRequest,
gridWidth: number,
gridHeight: number,
rng: Rng,
): Act1OutdoorPlan {
const openGates = new Map<string, number>()
const claimedBorderBlocks = new Set<string>()
const claimedBlocks: boolean[][] = Array.from({ length: gridHeight }, () =>
Array.from({ length: gridWidth }, () => false),
)
const roadObstacleBlocks: boolean[][] = Array.from({ length: gridHeight }, () =>
Array.from({ length: gridWidth }, () => false),
)
const specialPresets: SpecialPresetPlacement[] = []
const anchors: RoadAnchor[] = []
const claimBox = (bx: number, by: number, w: number, h: number, isObstacle = true): void => {
for (let dy = 0; dy < h; dy += 1) {
for (let dx = 0; dx < w; dx += 1) {
const y = by + dy
const x = bx + dx
if (y >= 0 && y < gridHeight && x >= 0 && x < gridWidth) {
claimedBlocks[y]![x] = true
if (isObstacle) roadObstacleBlocks[y]![x] = true
}
}
}
}
const findPiece = (prefix: string): WildernessPiece | undefined =>
request.pieces.find(p => p.name.toLowerCase().startsWith(prefix.toLowerCase()))
const addBorderGate = (
edge: 'top' | 'bottom' | 'left' | 'right',
variant: number,
label: string,
toLevelId?: number,
): void => {
if (edge === 'left') {
let by = rng.int(2, Math.max(2, gridHeight - 3))
for (let tries = 0; tries < gridHeight; tries += 1) {
if (!claimedBorderBlocks.has(`0,${String(by)}`) && !openGates.has(`0,${String(by)}`)) break
by = 2 + ((by - 1) % Math.max(1, gridHeight - 4))
}
openGates.set(`0,${String(by)}`, variant)
claimedBlocks[by]![0] = true
anchors.push({
label,
anchorCell: { x: 3, y: 8 * by + 3 },
interiorCell: { x: 11, y: 8 * by + 3 },
toLevelId,
side: 'west',
kind: 'gate',
})
} else if (edge === 'right') {
let by = rng.int(2, Math.max(2, gridHeight - 3))
for (let tries = 0; tries < gridHeight; tries += 1) {
if (!claimedBorderBlocks.has(`${String(gridWidth - 1)},${String(by)}`) && !openGates.has(`${String(gridWidth - 1)},${String(by)}`)) break
by = 2 + ((by - 1) % Math.max(1, gridHeight - 4))
}
openGates.set(`${String(gridWidth - 1)},${String(by)}`, variant)
claimedBlocks[by]![gridWidth - 1] = true
anchors.push({
label,
anchorCell: { x: 8 * (gridWidth - 1) + 3, y: 8 * by + 3 },
interiorCell: { x: 8 * (gridWidth - 2) + 3, y: 8 * by + 3 },
toLevelId,
side: 'east',
kind: 'gate',
})
} else if (edge === 'top') {
let bx = rng.int(2, Math.max(2, gridWidth - 3))
for (let tries = 0; tries < gridWidth; tries += 1) {
if (!claimedBorderBlocks.has(`${String(bx)},0`) && !openGates.has(`${String(bx)},0`)) break
bx = 2 + ((bx - 1) % Math.max(1, gridWidth - 4))
}
openGates.set(`${String(bx)},0`, variant)
claimedBlocks[0]![bx] = true
anchors.push({
label,
anchorCell: { x: 8 * bx + 3, y: 3 },
interiorCell: { x: 8 * bx + 3, y: 11 },
toLevelId,
side: 'north',
kind: 'gate',
})
} else {
let bx = rng.int(2, Math.max(2, gridWidth - 3))
for (let tries = 0; tries < gridWidth; tries += 1) {
if (!claimedBorderBlocks.has(`${String(bx)},${String(gridHeight - 1)}`) && !openGates.has(`${String(bx)},${String(gridHeight - 1)}`)) break
bx = 2 + ((bx - 1) % Math.max(1, gridWidth - 4))
}
openGates.set(`${String(bx)},${String(gridHeight - 1)}`, variant)
claimedBlocks[gridHeight - 1]![bx] = true
anchors.push({
label,
anchorCell: { x: 8 * bx + 3, y: 8 * (gridHeight - 1) + 3 },
interiorCell: { x: 8 * bx + 3, y: 8 * (gridHeight - 2) + 3 },
toLevelId,
side: 'south',
kind: 'gate',
})
}
}
const pickFreeInteriorBox = (w: number, h: number, minMargin = 2): { bx: number; by: number } | null => {
const minX = Math.min(minMargin, Math.max(1, gridWidth - w - 1))
const minY = Math.min(minMargin, Math.max(1, gridHeight - h - 1))
const maxX = Math.max(minX, gridWidth - minMargin - w)
const maxY = Math.max(minY, gridHeight - minMargin - h)
const candidates: { bx: number; by: number }[] = []
for (let by = minY; by <= maxY; by += 1) {
for (let bx = minX; bx <= maxX; bx += 1) {
let free = true
for (let dy = 0; dy < h && free; dy += 1) {
for (let dx = 0; dx < w && free; dx += 1) {
if (claimedBlocks[by + dy]?.[bx + dx] === true) free = false
}
}
if (free) candidates.push({ bx, by })
}
}
if (candidates.length === 0) return null
return candidates[rng.int(0, candidates.length - 1)]!
}
const addInteriorPresetWithRoad = (
prefix: string,
label: string,
forceExitSide?: 'east' | 'south',
toLevelId?: number,
): boolean => {
const piece = findPiece(prefix)
if (piece === undefined || piece.levels.length === 0) return false
const variant = rng.int(0, piece.levels.length - 1)
const ds1 = piece.levels[variant]!
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 pos = pickFreeInteriorBox(blocksX, blocksY, 2)
if (pos === null) return false
const { bx, by } = pos
claimBox(bx, by, blocksX, blocksY, true)
specialPresets.push({ name: piece.name, ds1, bx, by, blocksX, blocksY })
const exitSide = forceExitSide ?? (variant === 0 ? 'east' : 'south')
if (exitSide === 'east' && bx + blocksX < gridWidth - 1) {
roadObstacleBlocks[by]![bx + blocksX - 1] = false
anchors.push({
label,
anchorCell: { x: 8 * (bx + blocksX - 1) + 7, y: 8 * by + 3 },
interiorCell: { x: 8 * (bx + blocksX) + 3, y: 8 * by + 3 },
toLevelId,
kind: 'preset',
})
} else if (by + blocksY < gridHeight - 1) {
roadObstacleBlocks[by + blocksY - 1]![bx] = false
anchors.push({
label,
anchorCell: { x: 8 * bx + 3, y: 8 * (by + blocksY - 1) + 7 },
interiorCell: { x: 8 * bx + 3, y: 8 * (by + blocksY) + 3 },
toLevelId,
kind: 'preset',
})
}
return true
}
/**
* Stamp the Rogue Encampment's gate preset onto one edge.
*
* There are two authored pieces, one for the camp lying west of the Blood
* Moor and one for it lying north; the rest of the compass has no art, so a
* side we have no piece for falls back to an ordinary border gate.
*
* @param side - which edge of this level the camp is on.
* @param toLevelId - the town's level id.
* @returns whether a preset was placed.
*/
const placeTownTransition = (side: Side, toLevelId?: number): boolean => {
if (side === 'west') {
const townE = findPiece('Act 1 - Town 1 Transition E')
if (townE?.levels[0] === undefined) return false
const ds1 = townE.levels[0]
const by = Math.max(1, Math.floor((gridHeight - 5) / 2))
for (let dy = 0; dy < 5; dy += 1) {
claimedBorderBlocks.add(`0,${String(by + dy)}`)
claimedBlocks[by + dy]![0] = true
}
specialPresets.push({ name: townE.name, ds1, bx: 0, by, blocksX: 1, blocksY: 5 })
anchors.push({
label: 'Town E Transition',
anchorCell: { x: 7, y: 8 * by + 19 },
interiorCell: { x: 11, y: 8 * by + 19 },
toLevelId,
side: 'west',
kind: 'gate',
})
return true
}
if (side === 'north') {
const townS = findPiece('Act 1 - Town 1 Transition S')
if (townS?.levels[0] === undefined) return false
const ds1 = townS.levels[0]
const bx = Math.max(1, Math.floor((gridWidth - 7) / 2))
for (let dx = 0; dx < 7; dx += 1) {
claimedBorderBlocks.add(`${String(bx + dx)},0`)
claimedBlocks[0]![bx + dx] = true
}
specialPresets.push({ name: townS.name, ds1, bx, by: 0, blocksX: 7, blocksY: 1 })
anchors.push({
label: 'Town S Transition',
anchorCell: { x: 8 * bx + 29, y: 7 },
interiorCell: { x: 8 * bx + 29, y: 11 },
toLevelId,
side: 'north',
kind: 'gate',
})
return true
}
return false
}
/**
* Stamp the walled graveyard onto the Burial Grounds.
*
* @param withGate - whether to cut the seam back to the Cold Plains here too.
* False when the caller is placing that seam itself from the world graph.
* @returns whether the preset fitted.
*/
const placeGraveyardPreset = (withGate: boolean): boolean => {
const gyPiece = findPiece('Act 1 - Graveyard')
if (gyPiece?.levels[0] === undefined || gridWidth < 5 || gridHeight < 6) return false
const ds1 = gyPiece.levels[0]
claimBox(1, 1, 3, 4, true)
specialPresets.push({ name: gyPiece.name, ds1, bx: 1, by: 1, blocksX: 3, blocksY: 4 })
roadObstacleBlocks[4]![1] = false
roadObstacleBlocks[4]![2] = false
if (withGate) {
openGates.set(`2,${String(gridHeight - 1)}`, 4)
claimedBlocks[gridHeight - 1]![2] = true
anchors.push({
label: 'Burial Grounds Gate',
anchorCell: { x: 2 * 8 + 3, y: 8 * (gridHeight - 1) + 3 },
interiorCell: { x: 2 * 8 + 3, y: 8 * (gridHeight - 2) + 3 },
toLevelId: 3,
side: 'south',
kind: 'gate',
})
}
anchors.push({
label: 'Graveyard Entrance',
anchorCell: { x: 1 * 8 + 6, y: 1 * 8 + 31 },
interiorCell: { x: 2 * 8 + 3, y: 8 * (gridHeight - 2) + 3 },
toLevelId: 19,
kind: 'preset',
})
return true
}
/**
* Scatter the level's landmarks and the caves that open off it.
*
* These are separate from the seams because they do not care which edge
* anything is on: the Forgotten Tower, the Cairn Stones and the Tree of
* Inifuss all stand beside one of the level's roads, wherever the roads end
* up running, and `addInteriorPresetWithRoad` is what joins them to the
* network.
*
* @param levelId - the level being planned.
*/
const placeInteriorPresets = (levelId: number): void => {
if (levelId === 2) {
addInteriorPresetWithRoad('Act 1 - DOE Entrance', 'Den of Evil Entrance', undefined, 8)
const bivouac = findPiece('Act 1 - Bivouac') ?? findPiece('Act 1 - Cottages')
if (bivouac?.levels[0] !== undefined) {
const ds1 = bivouac.levels[0]
const w = Math.max(1, Math.floor(ds1.width / TILES_PER_BLOCK))
const h = Math.max(1, Math.floor(ds1.height / TILES_PER_BLOCK))
const pos = pickFreeInteriorBox(w, h, 2)
if (pos !== null) {
claimBox(pos.bx, pos.by, w, h, true)
specialPresets.push({ name: bivouac.name, ds1, bx: pos.bx, by: pos.by, blocksX: w, blocksY: h })
}
}
} else if (levelId === 3) {
addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Cave Level 1 Entrance', undefined, 9)
addInteriorPresetWithRoad('Act 1 - Bivouac', 'Bivouac Camp', 'south')
} else if (levelId === 4) {
addInteriorPresetWithRoad('Act 1 - Cairn Stones', 'Cairn Stones', 'south', 38)
addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Entrance', undefined, 10)
} else if (levelId === 5) {
addInteriorPresetWithRoad('Act 1 - Inifus', 'Tree of Inifuss', 'south')
addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Exit', undefined, 10)
} else if (levelId === 6) {
// The Forgotten Tower is always beside one of the Black Marsh's roads,
// never dropped on open ground, which is why it goes through the
// road-joining path rather than the plain scatter.
addInteriorPresetWithRoad('Act 1 - Tower 1', 'Forgotten Tower', 'south', 20)
addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Hole Level 1 Entrance', undefined, 11)
} else if (levelId === 7) {
addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Pit Level 1 Entrance', undefined, 12)
} else if (levelId === 39) {
addInteriorPresetWithRoad('Act 1 - Corral Fill', 'Cow Corral', 'south')
addInteriorPresetWithRoad('Act 1 - Bivouac', 'Cow Bivouac', 'east')
}
}
const id = request.levelId
const plannedGates = request.gates
if (plannedGates !== undefined) {
// Seams come from the act layout solve. The table below is not consulted:
// it records one legal roll of the outdoor placer as if it were fixed, and
// it only ever covered Act 1.
const townSeam = id === 2 ? plannedGates.find(gate => gate.toLevelId === 1) : undefined
const placedTown = townSeam !== undefined && placeTownTransition(townSeam.side, townSeam.toLevelId)
for (const gate of plannedGates) {
if (placedTown && gate === townSeam) continue
addBorderGate(SIDE_TO_EDGE[gate.side], gate.variant ?? 3, gate.label, gate.toLevelId)
}
if (id === 17) placeGraveyardPreset(false)
placeInteriorPresets(id)
} else if (id === 2) {
// Blood Moor: Town Transition + Exit Gate to Cold Plains + Den of Evil Entrance
const townE = findPiece('Act 1 - Town 1 Transition E')
const useWest = rng.int(0, 1) === 0 && townE !== undefined
if (useWest && placeTownTransition('west', 1)) {
addBorderGate(rng.int(0, 1) === 0 ? 'right' : 'bottom', 3, 'Cold Plains Exit', 3)
} else if (placeTownTransition('north', 1)) {
addBorderGate(rng.int(0, 1) === 0 ? 'bottom' : 'right', 3, 'Cold Plains Exit', 3)
} else {
addBorderGate('left', 3, 'Town Entry', 1)
addBorderGate('right', 3, 'Cold Plains Exit', 3)
}
placeInteriorPresets(2)
} else if (id === 3) {
// Cold Plains: Blood Moor Entry + Stony Field Exit + Burial Grounds Exit (variant 4) + Cave Level 1 Entrance
addBorderGate('left', 3, 'Blood Moor Entry', 2)
addBorderGate('top', 3, 'Stony Field Exit', 4)
addBorderGate('right', 4, 'Burial Grounds Exit', 17)
placeInteriorPresets(3)
} else if (id === 4) {
// Stony Field: Cold Plains Entry + Exit Gate + Cairn Stones + Underground Passage Entrance
addBorderGate('bottom', 3, 'Cold Plains Entry', 3)
addBorderGate('top', 3, 'Wilderness Exit', 5)
placeInteriorPresets(4)
} else if (id === 5) {
// Dark Wood: Entry Gate + Black Marsh Exit + Tree of Inifuss + Underground Passage Exit
addBorderGate('left', 3, 'Wilderness Entry', 4)
addBorderGate('right', 3, 'Black Marsh Exit', 6)
placeInteriorPresets(5)
} else if (id === 6) {
// Black Marsh: Dark Wood Entry + Tamoe Highland Exit + Forgotten Tower + Hole Level 1 Entrance
addBorderGate('bottom', 3, 'Dark Wood Entry', 5)
addBorderGate('top', 3, 'Tamoe Highland Exit', 7)
placeInteriorPresets(6)
} else if (id === 7) {
// Tamoe Highland: Black Marsh Entry + Monastery Gate Exit + Pit Level 1 Entrance
addBorderGate('left', 3, 'Black Marsh Entry', 6)
addBorderGate('right', 3, 'Monastery Exit', 26)
placeInteriorPresets(7)
} else if (id === 17) {
// Burial Grounds (5x6 blocks): Graveyard preset (3x4 blocks at bx=1, by=1) + South Entry Gate
if (!placeGraveyardPreset(true)) addBorderGate('bottom', 4, 'Burial Grounds Gate', 3)
} else if (id === 39 || id === 46) {
// Moo Moo Farm (39) & Canyon of the Magi (46): Closed border, no border gates
if (id === 39) placeInteriorPresets(39)
} else {
addBorderGate('left', 3, 'Entry Gate')
addBorderGate('right', 3, 'Exit Gate')
}
return {
openGates,
claimedBorderBlocks,
claimedBlocks,
roadObstacleBlocks,
specialPresets,
anchors,
}
}
/**
* Build stepped block-grid road polylines connecting all anchors to a central hub
* (`DRLGOUTDOORS_SpawnAct1DirtPaths` / `sub_6FD7F5B0`).
*/
function buildRoadNetworkPolylines(
plan: Act1OutdoorPlan,
gridWidth: number,
gridHeight: number,
rng: Rng,
): { x: number; y: number }[][] {
if (plan.anchors.length < 2) return []
let sumBx = 0
let sumBy = 0
for (const anchor of plan.anchors) {
sumBx += Math.floor(anchor.interiorCell.x / TILES_PER_BLOCK)
sumBy += Math.floor(anchor.interiorCell.y / TILES_PER_BLOCK)
}
let hubBx = Math.max(2, Math.min(gridWidth - 3, Math.round(sumBx / plan.anchors.length) + rng.int(-1, 1)))
let hubBy = Math.max(2, Math.min(gridHeight - 3, Math.round(sumBy / plan.anchors.length) + rng.int(-1, 1)))
if (plan.roadObstacleBlocks[hubBy]?.[hubBx] === true) {
let found = false
for (let r = 1; r < Math.max(gridWidth, gridHeight) && !found; r += 1) {
for (let dy = -r; dy <= r && !found; dy += 1) {
for (let dx = -r; dx <= r && !found; dx += 1) {
const nx = hubBx + dx
const ny = hubBy + dy
if (nx >= 1 && nx < gridWidth - 1 && ny >= 1 && ny < gridHeight - 1) {
if (plan.roadObstacleBlocks[ny]?.[nx] !== true) {
hubBx = nx
hubBy = ny
found = true
}
}
}
}
}
}
const hubCell = { x: hubBx * TILES_PER_BLOCK + 3, y: hubBy * TILES_PER_BLOCK + 3 }
const polylines: { x: number; y: number }[][] = []
for (const anchor of plan.anchors) {
const line: { x: number; y: number }[] = [
anchor.anchorCell,
anchor.interiorCell,
]
let currBx = Math.floor(anchor.interiorCell.x / TILES_PER_BLOCK)
let currBy = Math.floor(anchor.interiorCell.y / TILES_PER_BLOCK)
const maxSteps = gridWidth + gridHeight + 8
for (let step = 0; step < maxSteps && (currBx !== hubBx || currBy !== hubBy); step += 1) {
const dx = hubBx - currBx
const dy = hubBy - currBy
const stepX = Math.sign(dx)
const stepY = Math.sign(dy)
const candidates: { bx: number; by: number }[] = []
if (Math.abs(dx) >= 2 && Math.abs(dy) >= 2) {
const mode = rng.int(0, 2)
if (mode === 0) {
candidates.push({ bx: currBx + stepX, by: currBy }, { bx: currBx, by: currBy + stepY }, { bx: currBx + stepX, by: currBy + stepY })
} else if (mode === 1) {
candidates.push({ bx: currBx, by: currBy + stepY }, { bx: currBx + stepX, by: currBy }, { bx: currBx + stepX, by: currBy + stepY })
} else {
candidates.push({ bx: currBx + stepX, by: currBy + stepY }, { bx: currBx + stepX, by: currBy }, { bx: currBx, by: currBy + stepY })
}
} else if (Math.abs(dx) > Math.abs(dy)) {
candidates.push({ bx: currBx + stepX, by: currBy })
if (stepY !== 0) candidates.push({ bx: currBx, by: currBy + stepY })
} else {
if (stepY !== 0) candidates.push({ bx: currBx, by: currBy + stepY })
if (stepX !== 0) candidates.push({ bx: currBx + stepX, by: currBy })
}
// Detour candidates if primary directions hit an obstacle preset
if (stepX !== 0) {
candidates.push({ bx: currBx + stepX, by: currBy - 1 }, { bx: currBx + stepX, by: currBy + 1 })
}
if (stepY !== 0) {
candidates.push({ bx: currBx - 1, by: currBy + stepY }, { bx: currBx + 1, by: currBy + stepY })
}
let chosen = candidates[0]!
for (const cand of candidates) {
if (cand.bx >= 1 && cand.bx < gridWidth - 1 && cand.by >= 1 && cand.by < gridHeight - 1) {
if (plan.roadObstacleBlocks[cand.by]?.[cand.bx] !== true) {
chosen = cand
break
}
}
}
currBx = chosen.bx
currBy = chosen.by
line.push({ x: currBx * TILES_PER_BLOCK + 3, y: currBy * TILES_PER_BLOCK + 3 })
}
line.push(hubCell)
polylines.push(line)
}
return polylines
}
/**
* Rasterize a 2-cell-wide line segment onto the dirt path grid (`sub_6FD75F60`).
*/
function rasterizeDirtPathSegment(
grid: Uint8Array,
width: number,
height: number,
x1: number,
y1: number,
x2: number,
y2: number,
): void {
const mark2x2 = (x: number, y: number): void => {
for (let dy = 0; dy < 2; dy += 1) {
for (let dx = 0; dx < 2; dx += 1) {
const px = x + dx
const py = y + dy
if (px >= 0 && px < width && py >= 0 && py < height) {
grid[py * width + px] = 1
}
}
}
}
let x = x1
let y = y1
const dx = Math.abs(x2 - x1)
const dy = Math.abs(y2 - y1)
const sx = x2 >= x1 ? 1 : -1
const sy = y2 >= y1 ? 1 : -1
mark2x2(x, y)
if (dx >= dy) {
let err = 0
for (let i = 0; i < dx; i += 1) {
err += dy
if (err > dx) {
y += sy
mark2x2(x, y)
err -= dx
}
x += sx
mark2x2(x, y)
}
} else {
let err = 0
for (let i = 0; i < dy; i += 1) {
err += dx
if (err > dy) {
x += sx
mark2x2(x, y)
err -= dy
}
y += sy
mark2x2(x, y)
}
}
}
/**
* Rasterize all road polylines into a 2D bitmask grid and apply
* `DIRT_PATH_TILE_LUT` (`DRLG_OUTDOORS_GenerateDirtPath`).
*/
function generateDirtPaths(
canvas: Canvas,
polylines: readonly (readonly { x: number; y: number }[])[],
): { dirtPathGrid: Uint8Array; roadCells: number; roadSegments: number } {
const width = canvas.width
const height = canvas.height
const dirtPathGrid = new Uint8Array(width * height)
let roadSegments = 0
for (const line of polylines) {
for (let i = 0; i + 1 < line.length; i += 1) {
const p0 = line[i]!
const p1 = line[i + 1]!
rasterizeDirtPathSegment(dirtPathGrid, width, height, p0.x, p0.y, p1.x, p1.y)
roadSegments += 1
}
}
const isRoad = (nx: number, ny: number): boolean => {
if (nx < 0 || nx >= width || ny < 0 || ny >= height) return true
return dirtPathGrid[ny * width + nx] === 1
}
let roadCells = 0
for (let y = 0; y < height; y += 1) {
const row = canvas.cells[y]
if (row === undefined) continue
for (let x = 0; x < width; x += 1) {
if (dirtPathGrid[y * width + x] === 0) continue
let mask = 0
if (isRoad(x + 1, y - 1)) mask |= 0x80
if (isRoad(x + 1, y )) mask |= 0x40
if (isRoad(x + 1, y + 1)) mask |= 0x20
if (isRoad(x , y - 1)) mask |= 0x10
if (isRoad(x , y + 1)) mask |= 0x08
if (isRoad(x - 1, y - 1)) mask |= 0x04
if (isRoad(x - 1, y )) mask |= 0x02
if (isRoad(x - 1, y + 1)) mask |= 0x01
const seq = DIRT_PATH_TILE_LUT[mask] ?? 0
if (seq !== 0) {
const cell = row[x]
const floor = cell?.floors[0]
if (floor !== undefined) {
Object.assign(floor, { prop1: 2, style: 0, sequence: seq, hidden: false })
roadCells += 1
}
}
}
}
return { dirtPathGrid, roadCells, roadSegments }
}
/* ------------------------------------------------------------------------- *
* Substitutions
* ------------------------------------------------------------------------- */
/**
* Check whether placing a substitution cluster at `(bx, by)` of size
* `(blocksX, blocksY)` would collide with claimed special presets or dirt roads.
*/
function canPlaceSubstitutionCluster(
bx: number,
by: number,
blocksX: number,
blocksY: number,
claimedBlocks: boolean[][] | undefined,
dirtPathGrid: Uint8Array | undefined,
canvasWidth: number,
): boolean {
if (claimedBlocks !== undefined) {
for (let dy = 0; dy < blocksY; dy += 1) {
for (let dx = 0; dx < blocksX; dx += 1) {
if (claimedBlocks[by + dy]?.[bx + dx] === true) return false
}
}
}
if (dirtPathGrid !== undefined) {
const startX = bx * TILES_PER_BLOCK
const startY = by * TILES_PER_BLOCK
const endX = (bx + blocksX) * TILES_PER_BLOCK
const endY = (by + blocksY) * TILES_PER_BLOCK
for (let cy = startY; cy < endY; cy += 1) {
for (let cx = startX; cx < endX; cx += 1) {
if (dirtPathGrid[cy * canvasWidth + cx] === 1) return false
}
}
}
return true
}
/**
* Scatter one `LvlSub` row's pieces across the level's interior.
*
* Avoids overwriting blocks claimed by special presets (`claimedBlocks`) or
* cells traversed by generated dirt roads (`dirtPathGrid`).
*
* @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.
* @param claimedBlocks - optional 2D block claim grid.
* @param dirtPathGrid - optional cell-level dirt road bitmask grid.
* @returns how many clusters were stamped.
*/
function applySubstitution(
canvas: Canvas,
row: WildernessSubstitution,
themeIndex: number,
gridWidth: number,
gridHeight: number,
rng: Rng,
claimedBlocks?: boolean[][],
dirtPathGrid?: Uint8Array,
): 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
}
const recordClaim = (bx: number, by: number): void => {
if (claimedBlocks === undefined) return
for (let dy = 0; dy < blocksY; dy += 1) {
for (let dx = 0; dx < blocksX; dx += 1) {
if (claimedBlocks[by + dy] !== undefined) {
claimedBlocks[by + dy]![bx + dx] = true
}
}
}
}
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)
const bx = 1 + snappedX
const by = 1 + snappedY
if (!canPlaceSubstitutionCluster(bx, by, blocksX, blocksY, claimedBlocks, dirtPathGrid, canvas.width)) {
continue
}
stampDs1(canvas, level, bx * TILES_PER_BLOCK, by * TILES_PER_BLOCK)
recordClaim(bx, by)
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)
const bx = 1 + offsetX
const by = 1 + offsetY
if (!canPlaceSubstitutionCluster(bx, by, blocksX, blocksY, claimedBlocks, dirtPathGrid, canvas.width)) {
continue
}
stampDs1(canvas, level, bx * TILES_PER_BLOCK, by * TILES_PER_BLOCK)
recordClaim(bx, by)
placed = true
}
}
if (placed) stamped += 1
}
return stamped
}
/**
* Run the `Prob` gate and then the cluster loop for every substitution row.
*
* @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.
* @param claimedBlocks - optional 2D block claim grid.
* @param dirtPathGrid - optional cell-level dirt road bitmask grid.
* @returns the number of clusters stamped.
*/
function applySubstitutions(
canvas: Canvas,
rows: readonly WildernessSubstitution[],
themeIndex: number,
gridWidth: number,
gridHeight: number,
rng: Rng,
stats: WildernessStats,
claimedBlocks?: boolean[][],
dirtPathGrid?: Uint8Array,
): number {
let total = 0
/**
* `BordType` 0 rows share one cluster across the whole level.
*
* This started at `Infinity`, so `Math.min(1, budget)` was always 1 and the
* subtraction below never bit: every `BordType` 0 row got its own cluster,
* which is the `BordType` 1 rule. Starting at 1 makes the cap real.
*/
let sharedBudget = 1
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 ? sharedBudget : 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, claimedBlocks, dirtPathGrid)
clusters = Math.min(wanted, single)
if (row.bordType !== 0 && row.bordType !== 1) clusters = single
if (row.bordType === 0) sharedBudget = Math.max(0, sharedBudget - 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[]>> = {
2: [
'Act 1 - DOE Entrance',
'Act 1 - River Upper',
'Act 1 - River Lower',
'Act 1 - Bridge',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Cottages 1',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Fence Fill 5',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
3: [
'Act 1 - Cave Entrance',
'Act 1 - Fallen Camp Bishibosh',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 2',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Cottages 2',
'Act 1 - Cottages 3',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Fence Fill 6',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
4: [
'Act 1 - Cairn Stones',
'Act 1 - Cave Entrance',
'Act 1 - Tower Tome',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Camp',
'Act 1 - Cottages 1',
'Act 1 - Cottages 3',
'Act 1 - Fallen Camp 1',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Fence Fill 5',
],
5: [
'Act 1 - Inifus',
'Act 1 - Cave Entrance',
'Act 1 - Ruin',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 2',
'Act 1 - Fence Fill 3',
'Act 1 - Tree Fill',
'Act 1 - Cottages 2',
'Act 1 - Cottages 3',
'Act 1 - Fallen Camp 2',
'Act 1 - Fence Fill 5',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
6: [
'Act 1 - Tower 1',
'Act 1 - Cave Entrance',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Swamp Fill 1',
'Act 1 - Swamp Fill 2',
'Act 1 - Cottages 1',
'Act 1 - Cottages 3',
'Act 1 - Fallen Camp 1',
'Act 1 - Fence Fill 6',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
7: [
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Cottages 2',
'Act 1 - Fallen Camp 2',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Fence Fill 5',
],
17: [
'Act 1 - Graveyard',
],
39: [
'Act 1 - Bivouac',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Corral Fill',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Fence Fill 5',
'Act 1 - Pond',
'Act 1 - Swamp Fill 1',
'Act 1 - Swamp Fill 2',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
41: [
'Act 2 - Desert Tomb 1',
'Act 2 - Desert Fill Wagon 1',
'Act 2 - Desert Fill Bone 1',
'Act 2 - Desert Fill Bone 2',
'Act 2 - Desert Fill Berms 1',
'Act 2 - Desert Fill Berms 2',
],
42: [
'Act 2 - Desert Tomb 1',
'Act 2 - Desert Ruins 16X16',
'Act 2 - Desert Fill Mesa 1',
'Act 2 - Desert Fill Head 1',
'Act 2 - Desert Fill Head 2',
],
43: [
'Act 2 - Desert Oasis 2',
'Act 2 - Desert Oasis 3',
'Act 2 - Desert Oasis 1',
'Act 2 - Desert Lair 1',
'Act 2 - Desert Fill Bone 1',
'Act 2 - Desert Fill Berms 3',
],
44: [
'Act 2 - Desert Ruins Sewer',
'Act 2 - Desert Ruins Elder',
'Act 2 - Desert Ruins 16X16',
'Act 2 - Desert Ruins 16X08',
'Act 2 - Desert Ruins 08X16',
],
45: [
'Act 2 - Desert Tomb 2',
'Act 2 - Desert Valley Ruin 1',
'Act 2 - Desert Valley Ruin 2',
],
46: [
'Act 2 - Desert Valley Warp',
'Act 2 - Desert Cliff Top King Tomb',
'Act 2 - Desert Cliff Left King Tomb',
'Act 2 - Desert Cliff Right King Tomb',
'Act 2 - Desert Fill Mesa 1',
],
76: [
'Act 3 - Spider Cavern',
'Act 3 - Spider Temple Entrance',
],
78: [
'Act 3 - Flayer Jungle Entrance',
'Act 3 - Flayer Dungeon',
],
79: [
'Act 3 - Slums 16x16',
'Act 3 - Slums 16x16',
'Act 3 - Slums 16x08',
'Act 3 - Slums 08x16',
'Act 3 - Slums 08x08',
],
80: [
'Act 3 - Burbs Temple',
'Act 3 - Burbs Temple',
'Act 3 - Burbs Sewer',
'Act 3 - Burbs Waypoint',
'Act 3 - Burbs 16x16',
'Act 3 - Burbs 16x08',
],
81: [
'Act 3 - MetroTemple',
'Act 3 - MetroTemple',
'Act 3 - Metro Sewer',
'Act 3 - Metro 16x16',
'Act 3 - Metro 16x08',
],
88: [
'Act 3 - Flayer Dungeon',
],
91: [
'Act 3 - Flayer Dungeon',
],
94: [
'Act 3 - Ruined Temple',
],
105: [
'Act 4 - Plains of Despair',
'Act 4 - Izual',
],
106: [
'Act 4 - Hellforge',
'Act 4 - Chaos Sanctuary Entrance',
],
107: [
'Act 4 - Hellforge',
'Act 4 - Chaos Sanctuary Entrance',
],
108: [
'Act 4 - Chaos Sanctuary Entrance',
'Act 4 - Hadriel',
],
110: [
'Act 5 - Barricade Prison 1',
'Act 5 - Barricade Building',
],
111: [
'Act 5 - Barricade Waypoint Dirt',
'Act 5 - Barricade Hell Portal N',
'Act 5 - Barricade Prison 1',
'Act 5 - Barricade Prison 2',
'Act 5 - Barricade Prison 3',
'Act 5 - Barricade 1',
'Act 5 - Barricade 2',
'Act 5 - Barricade 3',
'Act 5 - Barricade 4',
'Act 5 - Barricade Building',
'Act 5 - Barricade Filler Treasure',
'Act 5 - Barricade Ruins N',
'Act 5 - Barricade Ruins W',
],
112: [
'Act 5 - Barricade Waypoint Snow',
'Act 5 - Barricade Hell Portal N',
'Act 5 - Barricade Snow Unique',
'Act 5 - Barricade 1 Snow',
'Act 5 - Barricade 2 Snow',
'Act 5 - Barricade 3 Snow',
'Act 5 - Barricade 4 Snow',
'Act 5 - Barricade Snow Lake 1',
'Act 5 - Barricade Snow Lake 2',
'Act 5 - Barricade Ruins N Treasure',
'Act 5 - Barricade Ruins W Treasure',
],
114: [
'Act 5 - Frozen River',
'Act 5 - Anya',
],
115: [
'Act 5 - Glacial Trail',
'Act 5 - Drifter Cavern',
],
117: [
'Act 5 - Barricade To Cave 32x16 Snow',
'Act 5 - Barricade Hell Portal N',
'Act 5 - Barricade Waypoint Snow',
'Act 5 - Barricade 1 Snow',
'Act 5 - Barricade 2 Snow',
'Act 5 - Barricade 3 Snow',
'Act 5 - Barricade Snow Lake 1',
],
124: [
'Act 5 - Halls of Vaught',
'Act 5 - Nihlathak',
],
}
/**
* 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.
*/
/** Bounding rectangle of a placed special preset in canvas tile coordinates. */
export interface PlacedPresetRect {
readonly name: string
readonly x: number
readonly y: number
readonly w: number
readonly h: number
}
/**
* Stamp a continuous 2-block-wide (16-tile) North-South river stream across the level with
* a 2-block wooden bridge crossing at `by = outY` where the main East-West road passes.
*/
function stampAct1RiverAndBridge(
canvas: Canvas,
pieces: readonly WildernessPiece[],
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
occupied: boolean[][],
placedPieces: Set<string>,
placedRects: PlacedPresetRect[],
stats: WildernessStats,
): boolean {
const upperPiece = pieces.find(p => p.name === 'Act 1 - River Upper' || p.name.startsWith('Act 1 - River Upper'))
const lowerPiece = pieces.find(p => p.name === 'Act 1 - River Lower' || p.name.startsWith('Act 1 - River Lower'))
const bridgePiece = pieces.find(p => p.name === 'Act 1 - Bridge' || p.name.startsWith('Act 1 - Bridge'))
if (!upperPiece?.levels[0] || !lowerPiece?.levels[0] || !bridgePiece?.levels[0]) return false
// Place the 2-column vertical river stream just East of the central crossroads (e.g. blocks 6 & 7 on 10x10 grid)
const riverBlockX = Math.min(gridWidth - 3, inX + 1)
if (riverBlockX < 1 || riverBlockX + 1 >= gridWidth - 1) return false
for (let by = 1; by <= gridHeight - 2; by += 1) {
const tileX0 = riverBlockX * TILES_PER_BLOCK
const tileX1 = (riverBlockX + 1) * TILES_PER_BLOCK
const tileY = by * TILES_PER_BLOCK
if (by === outY) {
// Main East-West road crossing: stamp BridgeU (west half) + BridgeL (east half)
const bridgeU = bridgePiece.levels[0]!
const bridgeL = bridgePiece.levels[1] ?? bridgePiece.levels[0]!
stampDs1(canvas, bridgeU, tileX0, tileY)
stampDs1(canvas, bridgeL, tileX1, tileY)
} else if (by === 1) {
// North river source/mouth (URiverN + LRiverN)
const uNorth = upperPiece.levels[1] ?? upperPiece.levels[0]!
const lNorth = lowerPiece.levels[1] ?? lowerPiece.levels[0]!
stampDs1(canvas, uNorth, tileX0, tileY)
stampDs1(canvas, lNorth, tileX1, tileY)
} else if (by === gridHeight - 2) {
// South river mouth (URiverS + LRiverS)
const uSouth = upperPiece.levels[2] ?? upperPiece.levels[0]!
const lSouth = lowerPiece.levels[2] ?? lowerPiece.levels[0]!
stampDs1(canvas, uSouth, tileX0, tileY)
stampDs1(canvas, lSouth, tileX1, tileY)
} else {
// Center continuous river channel (URiverC + LRiverC)
stampDs1(canvas, upperPiece.levels[0]!, tileX0, tileY)
stampDs1(canvas, lowerPiece.levels[0]!, tileX1, tileY)
}
occupied[by]![riverBlockX] = true
occupied[by]![riverBlockX + 1] = true
}
for (const name of [upperPiece.name, lowerPiece.name, bridgePiece.name]) {
placedPieces.add(name)
stats.substitutions.push({ name, role: 'object', enabled: true, clusters: 1 })
}
placedRects.push({
name: 'Act 1 - River',
x: riverBlockX * TILES_PER_BLOCK,
y: TILES_PER_BLOCK,
w: 2 * TILES_PER_BLOCK,
h: (gridHeight - 2) * TILES_PER_BLOCK,
})
return true
}
/**
* 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 and any reserved main road corridors) 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.
*/
function spawnSpecialPresets(
canvas: Canvas,
pieces: readonly WildernessPiece[],
presetNames: readonly string[],
gridWidth: number,
gridHeight: number,
rng: Rng,
stats: WildernessStats,
reservedBlocks?: ReadonlySet<string>,
): { placedPieces: Set<string>; placedRects: PlacedPresetRect[] } {
const innerWidth = gridWidth - 2
const innerHeight = gridHeight - 2
const totalSlots = innerWidth * innerHeight
const placedPieces = new Set<string>()
const placedRects: PlacedPresetRect[] = []
if (totalSlots <= 0 || presetNames.length === 0) return { placedPieces, placedRects }
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
}
if (reservedBlocks && gridWidth >= 8 && gridHeight >= 8) {
for (const key of reservedBlocks) {
const [bxStr, byStr] = key.split(',')
const bx = Number(bxStr)
const by = Number(byStr)
if (occupied[by]?.[bx] !== undefined) {
occupied[by]![bx] = true
}
}
}
// Handle continuous river + bridge layout for Blood Moor when requested on standard grids
let remainingPresets = [...presetNames]
const wantsRiver =
gridWidth >= 8 &&
gridHeight >= 6 &&
remainingPresets.some(n => n.startsWith('Act 1 - River Upper')) &&
remainingPresets.some(n => n.startsWith('Act 1 - River Lower')) &&
remainingPresets.some(n => n.startsWith('Act 1 - Bridge'))
if (wantsRiver) {
const inX = Math.floor(gridWidth / 2)
const outY = Math.floor(gridHeight / 2)
if (
stampAct1RiverAndBridge(
canvas,
pieces,
gridWidth,
gridHeight,
inX,
outY,
occupied,
placedPieces,
placedRects,
stats,
)
) {
remainingPresets = remainingPresets.filter(
n =>
!n.startsWith('Act 1 - River Upper') &&
!n.startsWith('Act 1 - River Lower') &&
!n.startsWith('Act 1 - Bridge'),
)
}
}
for (const presetName of remainingPresets) {
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
const tileX = bx * TILES_PER_BLOCK
const tileY = by * TILES_PER_BLOCK
stampDs1(canvas, ds1, tileX, tileY)
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)
placedRects.push({
name: piece.name,
x: tileX,
y: tileY,
w: ds1.width,
h: ds1.height,
})
placed = true
break
}
if (!placed) {
stats.notes.push(`special preset "${presetName}" could not be placed (no free interior block)`)
}
}
return { placedPieces, placedRects }
}
/* ------------------------------------------------------------------------- *
* Entry point
* ------------------------------------------------------------------------- */
/**
* Derive the block grid for a level whose `Levels.txt` size is unset.
*/
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' }
}
/**
* Assemble the 6 presets for Act 3 Travincal (LevelId 83, 64x64).
*/
function stampAct3Travincal(
canvas: Canvas,
pieces: readonly WildernessPiece[],
stats: WildernessStats,
placedRects?: PlacedPresetRect[],
): Set<string> {
const placements: Array<{ name: string; x: number; y: number }> = [
{ name: 'Act 3 - Travincal NW', x: 0, y: 0 },
{ name: 'Act 3 - Travincal N', x: 16, y: 0 },
{ name: 'Act 3 - Travincal NE', x: 48, y: 0 },
{ name: 'Act 3 - Travincal SW', x: 0, y: 32 },
{ name: 'Act 3 - Travincal S', x: 16, y: 32 },
{ name: 'Act 3 - Travincal SE', x: 48, y: 32 },
]
const placed = new Set<string>()
for (const item of placements) {
const piece = pieces.find(p => p.name.trim() === item.name.trim())
const ds1 = piece?.levels[0]
if (!ds1) continue
stampDs1(canvas, ds1, item.x, item.y)
placed.add(item.name)
placedRects?.push({ name: item.name, x: item.x, y: item.y, w: ds1.width, h: ds1.height })
stats.substitutions.push({ name: item.name, role: 'object', enabled: true, clusters: 1 })
}
return placed
}
/**
* Assemble Act 3 Jungles (Spider Forest 76, Great Marsh 77, Flayer Jungle 78) using
* the authentic `DRLG_GenerateJungles` river network and branching topology pipeline.
*/
function stampAct3Jungle(
canvas: Canvas,
levelId: number,
pieces: readonly WildernessPiece[],
rng: Rng,
stats: WildernessStats,
placedRects?: PlacedPresetRect[],
): Set<string> {
return DRLG_GenerateJungles(canvas, levelId, pieces, rng, stats, stampDs1)
}
/** Theme configuration for outdoor road rasterization. */
export interface OutdoorRoadTheme {
readonly roadFloor: (rng: Rng) => Ds1Floor
readonly isProtectedPreset?: (presetName: string) => boolean
readonly isIgnoredSpurPreset?: (presetName: string) => boolean
}
/** Theme configuration for outdoor stone wall divider networks. */
export interface OutdoorDividerTheme {
readonly wallStyle: number
readonly wallProp1?: number
readonly roadStyles?: readonly number[]
}
/**
* General outdoor path and highway rasterizer connecting entrance, central crossroads,
* exit gates, and landmark presets across wilderness maps.
*/
export function stampThemedRoads(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
theme: OutdoorRoadTheme,
openGates?: ReadonlyMap<string, number>,
): void {
const isInsideProtectedPreset = (x: number, y: number): boolean => {
for (const r of placedRects) {
if (theme.isProtectedPreset ? !theme.isProtectedPreset(r.name) : /River|Bridge/i.test(r.name)) {
continue
}
if (x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) {
return true
}
}
return false
}
const paintRoadTile = (x: number, y: number, clearStrayClutter: boolean): void => {
if (x < 4 || x >= canvas.width - 4 || y < 4 || y >= canvas.height - 4) return
if (isInsideProtectedPreset(x, y)) return
const cell = canvas.cells[y]?.[x]
if (!cell) return
const existingFloor = cell.floors[0]
// Never overwrite water (`style 2/3`) or wooden bridge planks (`style 4`)
if (existingFloor && (existingFloor.style === 2 || existingFloor.style === 3 || existingFloor.style === 4)) {
return
}
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) {
if (clearStrayClutter) {
// Clear random LvlSub bush/tree clutter on the main highway spine
;(cell.walls as Ds1Wall[]).length = 0
} else {
return
}
}
;(cell.floors as Ds1Floor[])[0] = theme.roadFloor(rng)
}
const hubX = inX * TILES_PER_BLOCK + 4
const hubY = outY * TILES_PER_BLOCK + 4
const southGateY = (gridHeight - 1) * TILES_PER_BLOCK + 3
const eastGateX = (gridWidth - 1) * TILES_PER_BLOCK + 3
let northEnd = Math.max(12, Math.floor(canvas.height * 0.2))
let westEnd = Math.max(12, Math.floor(canvas.width * 0.2))
if (openGates !== undefined) {
for (const key of openGates.keys()) {
const [bxStr, byStr] = key.split(',')
const bx = Number(bxStr)
const by = Number(byStr)
if (by === 0) northEnd = 4
if (bx === 0) westEnd = 4
}
}
// 1. 3-tile-wide South-to-North main highway (`x = hubX - 1, hubX, hubX + 1`)
for (let y = northEnd; y <= southGateY; y += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
paintRoadTile(hubX + dx, y, true)
}
}
// 2. 3-tile-wide West-to-East main highway (`y = hubY - 1, hubY, hubY + 1`)
for (let x = westEnd; x <= eastGateX; x += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
paintRoadTile(x, hubY + dy, true)
}
}
// If openGates contains non-standard gate blocks, connect them into the highway network
if (openGates !== undefined) {
for (const key of openGates.keys()) {
const [bxStr, byStr] = key.split(',')
const bx = Number(bxStr)
const by = Number(byStr)
const gateX = bx * TILES_PER_BLOCK + 4
const gateY = by * TILES_PER_BLOCK + 4
if (by === 0 && Math.abs(gateX - hubX) > 2) {
// North gate branch to hubY
for (let y = 4; y <= hubY; y += 1) {
for (let dx = -1; dx <= 1; dx += 1) paintRoadTile(gateX + dx, y, true)
}
for (let x = Math.min(gateX, hubX); x <= Math.max(gateX, hubX); x += 1) {
for (let dy = -1; dy <= 1; dy += 1) paintRoadTile(x, hubY + dy, true)
}
} else if (bx === 0 && Math.abs(gateY - hubY) > 2) {
// West gate branch to hubX
for (let x = 4; x <= hubX; x += 1) {
for (let dy = -1; dy <= 1; dy += 1) paintRoadTile(x, gateY + dy, true)
}
for (let y = Math.min(gateY, hubY); y <= Math.max(gateY, hubY); y += 1) {
for (let dx = -1; dx <= 1; dx += 1) paintRoadTile(hubX + dx, y, true)
}
}
}
}
// 3. 2-tile-wide spur roads connecting each major spawned landmark to the main highway
const paintSpurSegment = (x0: number, y0: number, x1: number, y1: number): void => {
let curX = x0
let curY = y0
let steps = 0
const maxSteps = canvas.width + canvas.height
while ((curX !== x1 || curY !== y1) && steps < maxSteps) {
for (let dy = 0; dy <= 1; dy += 1) {
for (let dx = 0; dx <= 1; dx += 1) {
paintRoadTile(curX + dx, curY + dy, false)
}
}
if (curX !== x1) curX += x1 > curX ? 1 : -1
else if (curY !== y1) curY += y1 > curY ? 1 : -1
steps += 1
}
}
for (const r of placedRects) {
if (theme.isIgnoredSpurPreset ? theme.isIgnoredSpurPreset(r.name) : /River|Bridge|Fill|Berms/i.test(r.name)) {
continue
}
const centerX = Math.floor(r.x + r.w / 2)
const centerY = Math.floor(r.y + r.h / 2)
// Pick the edge of the preset closest to the main crossroads
const distToNS = Math.abs(centerX - hubX)
const distToEW = Math.abs(centerY - hubY)
if (distToNS <= distToEW) {
const doorX = centerX < hubX ? r.x + r.w : r.x - 1
const doorY = Math.max(r.y + 2, Math.min(r.y + r.h - 3, centerY))
paintSpurSegment(doorX, doorY, hubX, doorY)
} else {
const doorX = Math.max(r.x + 2, Math.min(r.x + r.w - 3, centerX))
const doorY = centerY < hubY ? r.y + r.h : r.y - 1
paintSpurSegment(doorX, doorY, doorX, hubY)
}
}
}
/**
* General outdoor divider network laying connected stone walls, sandstone berms,
* or balustrades with proper corner joins, gate archways, and endcaps.
*/
export function stampThemedDividers(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
theme: OutdoorDividerTheme,
): void {
if (gridWidth < 6 || gridHeight < 6) return
const wallStyle = theme.wallStyle
const wallProp1 = theme.wallProp1 ?? 129
const makeWall = (type: number, sequence: number): Ds1Wall => ({
prop1: wallProp1,
sequence,
style: wallStyle,
type,
unknown1: 0,
unknown2: 0,
hidden: false,
})
const placeWall = (x: number, y: number, wall: Ds1Wall): void => {
const cell = canvas.cells[y]?.[x]
if (!cell) return
const targetWalls = cell.walls as Ds1Wall[]
if (targetWalls.length > 0) {
targetWalls[0] = wall
} else {
targetWalls.push(wall)
}
}
// Offset divider lines by 2 blocks (16 tiles) from the main road spine
const offsetBlockY = rng.int(0, 1) === 0 ? -2 : 2
const offsetBlockX = rng.int(0, 1) === 0 ? -2 : 2
const wallY = Math.max(12, Math.min(canvas.height - 12, (outY + offsetBlockY) * TILES_PER_BLOCK))
const wallX = Math.max(12, Math.min(canvas.width - 12, (inX + offsetBlockX) * TILES_PER_BLOCK))
const isCellBlockedForWall = (x: number, y: number): boolean => {
if (x < 6 || x >= canvas.width - 6 || y < 6 || y >= canvas.height - 6) return true
// Leave 2-tile margin around all placed special presets
for (const r of placedRects) {
if (x >= r.x - 2 && x < r.x + r.w + 2 && y >= r.y - 2 && y < r.y + r.h + 2) {
return true
}
}
// Leave 3-tile wide archway gate openings wherever a road, bridge, or water passes
for (let dy = -1; dy <= 1; dy += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
const c = canvas.cells[y + dy]?.[x + dx]
if (!c) continue
if (c.floors.some(f => !f.hidden && (f.style === 5 || f.style === 1 || f.style === 4 || f.style === 2 || f.style === 3 || theme.roadStyles?.includes(f.style)))) {
return true
}
}
}
const cell = canvas.cells[y]?.[x]
if (!cell) return true
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) return true
return false
}
// Build valid tile masks along wallY (EW) and wallX (NS), inserting periodic pasture gates
const validEW: boolean[] = Array.from({ length: canvas.width }, (_, x) => {
const isPastureGate = (x % 24) >= 11 && (x % 24) <= 13 && Math.abs(x - wallX) > 3
return !isPastureGate && !isCellBlockedForWall(x, wallY)
})
const validNS: boolean[] = Array.from({ length: canvas.height }, (_, y) => {
const isPastureGate = (y % 24) >= 11 && (y % 24) <= 13 && Math.abs(y - wallY) > 3
return !isPastureGate && !isCellBlockedForWall(wallX, y)
})
const extractRuns = (mask: readonly boolean[]): Array<{ start: number; end: number }> => {
const runs: Array<{ start: number; end: number }> = []
let start = -1
for (let i = 0; i < mask.length; i += 1) {
if (mask[i]) {
if (start === -1) start = i
} else if (start !== -1) {
if (i - start >= 4) runs.push({ start, end: i - 1 })
start = -1
}
}
if (start !== -1 && mask.length - start >= 4) {
runs.push({ start, end: mask.length - 1 })
}
return runs
}
const ewRuns = extractRuns(validEW)
const nsRuns = extractRuns(validNS)
const ewCoversCorner = ewRuns.some(r => wallX >= r.start && wallX <= r.end)
const nsCoversCorner = nsRuns.some(r => wallY >= r.start && wallY <= r.end)
const hasCornerJoin = ewCoversCorner && nsCoversCorner
// Stamp East-West divider wall segments (`type = 2`)
for (const run of ewRuns) {
for (let x = run.start; x <= run.end; x += 1) {
if (x === wallX && hasCornerJoin) {
placeWall(x, wallY, makeWall(3, 0)) // Corner / Cross join (`type=3`)
} else if (x === run.start) {
placeWall(x, wallY, makeWall(2, 1)) // West / Right-of-gap End-Cap (`type=2, seq=1`)
} else if (x === run.end) {
placeWall(x, wallY, makeWall(2, 2)) // East / Left-of-gap End-Cap (`type=2, seq=2`)
} else {
placeWall(x, wallY, makeWall(2, 0)) // Continuous EW Wall (`type=2, seq=0`)
}
}
}
// Stamp North-South divider wall segments (`type = 1`)
for (const run of nsRuns) {
for (let y = run.start; y <= run.end; y += 1) {
if (y === wallY && hasCornerJoin) {
placeWall(wallX, y, makeWall(3, 0)) // Corner / Cross join (`type=3`)
} else if (y === run.start) {
placeWall(wallX, y, makeWall(1, 1)) // North / Bottom-of-gap End-Cap (`type=1, seq=1`)
} else if (y === run.end) {
placeWall(wallX, y, makeWall(1, 2)) // South / Top-of-gap End-Cap (`type=1, seq=2`)
} else {
placeWall(wallX, y, makeWall(1, 0)) // Continuous NS Wall (`type=1, seq=0`)
}
}
}
}
/**
* Act 1: Lay a 3-tile-wide dirt highway connecting the South Border Gate, central crossroads,
* river bridge, and East Border Gate, plus 2-tile-wide spur paths to landmarks.
*/
export function stampAct1DirtRoads(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
openGates?: ReadonlyMap<string, number>,
): void {
stampThemedRoads(
canvas,
gridWidth,
gridHeight,
inX,
outY,
placedRects,
rng,
{
roadFloor: (r) => ({
prop1: 194,
sequence: r.int(0, 6) === 0 ? 25 : 0,
style: 5,
unknown1: 0,
unknown2: 0,
hidden: false,
}),
isProtectedPreset: (name) => name !== 'Act 1 - River',
isIgnoredSpurPreset: (name) => name === 'Act 1 - River' || /Stone Fill|Swamp Fill|Tree Fill|Fence Fill|Corral Fill/i.test(name),
},
openGates,
)
}
/**
* Act 1: Lay connected stone wall divider lines (`style=2, prop1=129`) across the interior
* meadow of Act 1 wilderness levels (LevelId 2..7).
*/
export function stampAct1InteriorStoneWalls(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
): void {
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
wallStyle: 2,
wallProp1: 129,
roadStyles: [5],
})
}
/**
* Act 2: Desert sandstone caravan routes connecting entrance to exit gates across dunes.
*/
export function stampAct2DesertRoads(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
openGates?: ReadonlyMap<string, number>,
): void {
stampThemedRoads(
canvas,
gridWidth,
gridHeight,
inX,
outY,
placedRects,
rng,
{
roadFloor: (r) => ({
prop1: 194,
sequence: r.int(0, 5) === 0 ? 1 : 0,
style: 1,
unknown1: 0,
unknown2: 0,
hidden: false,
}),
isProtectedPreset: (name) => !/River|Bridge/i.test(name),
isIgnoredSpurPreset: (name) => /Fill|Berms/i.test(name),
},
openGates,
)
}
/**
* Act 2: Sandstone divider berms (style=1, prop1=129) separating desert dunes.
*/
export function stampAct2SandstoneBerms(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
): void {
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
wallStyle: 1,
wallProp1: 129,
roadStyles: [1],
})
}
/**
* Act 3: Kurast flagstone avenues across temples, plazas, and causeways.
*/
export function stampAct3KurastAvenues(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
openGates?: ReadonlyMap<string, number>,
): void {
stampThemedRoads(
canvas,
gridWidth,
gridHeight,
inX,
outY,
placedRects,
rng,
{
roadFloor: (r) => ({
prop1: 194,
sequence: r.int(0, 5) === 0 ? 1 : 0,
style: 1,
unknown1: 0,
unknown2: 0,
hidden: false,
}),
isProtectedPreset: (name) => !/River|Bridge/i.test(name),
isIgnoredSpurPreset: (name) => /Fill/i.test(name),
},
openGates,
)
}
/**
* Act 3: Kurast balustrades across temples and causeways (style=1, prop1=129).
*/
export function stampAct3KurastBalustrades(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
): void {
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
wallStyle: 1,
wallProp1: 129,
roadStyles: [1],
})
}
/**
* Act 5: Snow march roads in Frigid Highlands & Arreat Plateau.
*/
export function stampAct5SnowRoads(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
openGates?: ReadonlyMap<string, number>,
): void {
stampThemedRoads(
canvas,
gridWidth,
gridHeight,
inX,
outY,
placedRects,
rng,
{
roadFloor: (r) => ({
prop1: 194,
sequence: r.int(0, 5) === 0 ? 1 : 0,
style: 1,
unknown1: 0,
unknown2: 0,
hidden: false,
}),
isProtectedPreset: (name) => !/River|Bridge/i.test(name),
isIgnoredSpurPreset: (name) => /Fill|Filler/i.test(name),
},
openGates,
)
}
/**
* Act 5: Frozen trail divides in Frigid Highlands & Arreat Plateau (style=2, prop1=129).
*/
export function stampAct5FrozenTrailDivides(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
): void {
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
wallStyle: 2,
wallProp1: 129,
roadStyles: [1],
})
}
/**
* Dispatch outdoor road networks based on act, levelId, and levelTypeName.
*/
export function stampOutdoorRoads(
canvas: Canvas,
act: number,
levelId: number,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
levelTypeName?: string,
openGates?: ReadonlyMap<string, number>,
): void {
if (levelId === 39) return // Cow Level has no roads
// Level ID takes highest precedence
if (levelId >= 41 && levelId <= 46) {
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
} else if (levelId >= 79 && levelId <= 82) {
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
} else if (levelId === 111 || levelId === 112 || levelId === 117) {
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
} else if (levelId >= 2 && levelId <= 7) {
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
// Level type name takes second precedence
} else if (levelTypeName === 'Act 2 - Desert') {
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
} else if (levelTypeName === 'Act 3 - Kurast') {
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
} else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') {
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
} else if (levelTypeName === 'Act 1 - Wilderness') {
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
// Act number takes fallback precedence
} else if (act === 2) {
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
} else if (act === 3) {
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
} else if (act === 5) {
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
} else if (act === 1) {
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
}
}
/**
* Dispatch outdoor boundary divider networks based on act, levelId, and levelTypeName.
*/
export function stampOutdoorDividers(
canvas: Canvas,
act: number,
levelId: number,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
levelTypeName?: string,
): void {
if (levelId === 39) return // Cow Level has no dividers
// Level ID takes highest precedence
if (levelId >= 41 && levelId <= 46) {
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
} else if (levelId >= 79 && levelId <= 82) {
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
} else if (levelId === 111 || levelId === 112 || levelId === 117) {
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
} else if (levelId >= 2 && levelId <= 7) {
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
// Level type name takes second precedence
} else if (levelTypeName === 'Act 2 - Desert') {
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
} else if (levelTypeName === 'Act 3 - Kurast') {
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
} else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') {
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
} else if (levelTypeName === 'Act 1 - Wilderness') {
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
// Act number takes fallback precedence
} else if (act === 2) {
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
} else if (act === 3) {
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
} else if (act === 5) {
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
} else if (act === 1) {
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
}
}
/* ------------------------------------------------------------------------- *
* Outdoor Population Phase: Waypoints, Roadside Shrines/Chests, SuperUniques & Flavie
* (`DRLGOUTDOORS_SpawnAct12Waypoint`, `DRLGOUTDOORS_SpawnAct12Shrines`)
* ------------------------------------------------------------------------- */
/** Outdoor levels in Acts 1..5 that carry a Waypoint (`Levels.txt` `Waypoint != 255`). */
export const OUTDOOR_WAYPOINT_LEVELS = new Set([
3, 4, 5, 6, // Act 1: Cold Plains, Stony Field, Dark Wood, Black Marsh
42, 43, 44, 46, // Act 2: Dry Hills, Far Oasis, Lost City, Canyon of the Magi
76, 77, 78, 79, 80, 81, 83, // Act 3: Spider Forest, Great Marsh, Flayer Jungle, Lower Kurast, Kurast Bazaar, Upper Kurast, Travincal
106, 107, // Act 4: City of the Damned, River of Flame
111, 112, 117, // Act 5: Frigid Highlands, Arreat Plateau, Frozen Tundra
])
/**
* Resolves the numeric Act (1..5) for a given levelId and/or levelTypeName.
* Handles cases where DS1 preset levels report act = 1 by default.
*/
export function actOfLevel(levelId: number, levelTypeName?: string, fallbackAct?: number): number {
if (levelTypeName) {
const m = /^Act\s*([1-5])/i.exec(levelTypeName)
if (m && m[1]) return Number(m[1])
}
if (levelId >= 1 && levelId <= 39) return 1
if (levelId >= 40 && levelId <= 74) return 2
if (levelId >= 75 && levelId <= 102) return 3
if (levelId >= 103 && levelId <= 108) return 4
if (levelId >= 109 && levelId <= 136) return 5
if (fallbackAct !== undefined && fallbackAct >= 1 && fallbackAct <= 5) return fallbackAct
return 1
}
export interface ActPopulationConfig {
readonly waypointId: number
readonly shrinePool: readonly number[]
readonly wellId: number
readonly chestPool: readonly number[]
readonly scatterPool: readonly number[]
}
export const ACT_POPULATION_CONFIG: Readonly<Record<number, ActPopulationConfig>> = {
1: {
waypointId: 37,
shrinePool: [29, 82, 83, 84],
wellId: 31,
chestPool: [5, 89, 90, 91, 92, 93, 94],
scatterPool: [17, 18, 25],
},
2: {
waypointId: 10,
shrinePool: [54, 55, 103, 104, 105, 126],
wellId: 234,
chestPool: [32, 33, 44, 45, 46],
scatterPool: [14, 48, 122],
},
3: {
waypointId: 1,
shrinePool: [14, 85, 111, 152, 227, 231, 235, 236],
wellId: 234,
chestPool: [65, 66, 67, 80, 81],
scatterPool: [4, 5, 48, 179],
},
4: {
waypointId: 0,
shrinePool: [56, 152, 227, 231, 235, 236],
wellId: 234,
chestPool: [19, 20, 24, 25],
scatterPool: [40, 63, 179],
},
5: {
waypointId: 12,
shrinePool: [152, 227, 231, 235, 236],
wellId: 234,
chestPool: [24, 25, 26, 27, 28, 29],
scatterPool: [13, 179, 4],
},
}
function isTileClear(
canvas: Canvas,
tx: number,
ty: number,
placedRects: readonly PlacedPresetRect[],
padding = 1,
): boolean {
if (tx < padding || ty < padding || tx >= canvas.width - padding || ty >= canvas.height - padding) {
return false
}
for (const r of placedRects) {
if (tx >= r.x - padding && tx < r.x + r.w + padding && ty >= r.y - padding && ty < r.y + r.h + padding) {
return false
}
}
for (let dy = -padding; dy <= padding; dy += 1) {
const row = canvas.cells[ty + dy]
if (!row) return false
for (let dx = -padding; dx <= padding; dx += 1) {
const cell = row[tx + dx]
if (!cell) return false
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) return false
if (dx === 0 && dy === 0 && !cell.floors.some(f => !f.hidden && f.prop1 !== 0)) return false
}
}
return true
}
function isNearDirtRoad(
dirtPathGrid: Uint8Array | undefined,
width: number,
height: number,
tx: number,
ty: number,
minDist: number,
maxDist: number,
): boolean {
if (!dirtPathGrid) return false
let minDistFound = 999
for (let dy = -maxDist; dy <= maxDist; dy += 1) {
const ny = ty + dy
if (ny < 0 || ny >= height) continue
for (let dx = -maxDist; dx <= maxDist; dx += 1) {
const nx = tx + dx
if (nx < 0 || nx >= width) continue
if (dirtPathGrid[ny * width + nx] !== 0) {
const d = Math.max(Math.abs(dx), Math.abs(dy))
if (d < minDistFound) minDistFound = d
}
}
}
return minDistFound >= minDist && minDistFound <= maxDist
}
/**
* Automatically locate and place an outdoor Waypoint (`DRLGOUTDOORS_SpawnAct12Waypoint`)
* supporting authentic thematic clustering across Acts 1..5.
*
* In roadless wilderness (e.g. Far Oasis level 43, Flayer Jungle level 78, Arreat Plateau level 112),
* waypoints prioritize proximity to clearings, ruins, oases, or the central hub.
* In roadside wilderness, roadside candidates near landmarks or center are preferred.
*/
export function spawnOutdoorWaypoint(
canvas: Canvas,
levelId: number,
act: number,
dirtPathGrid: Uint8Array | undefined,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
stats: WildernessStats,
levelTypeName?: string,
): void {
if (!OUTDOOR_WAYPOINT_LEVELS.has(levelId)) return
// Check if a preset already placed a Waypoint object
const ALL_WAYPOINT_IDS = new Set([37, 52, 269, 10, 306, 1, 387, 0, 388, 12, 188])
const existingWp = canvas.objects.find(
o => o.type === 2 && ALL_WAYPOINT_IDS.has(o.id),
)
if (existingWp !== undefined) {
stats.waypointsSpawned = 1
stats.waypointTile = {
x: Math.floor(existingWp.x / SUB_TILES_PER_TILE),
y: Math.floor(existingWp.y / SUB_TILES_PER_TILE),
}
return
}
const effectiveAct = actOfLevel(levelId, levelTypeName, act)
const config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]!
const wpId = config.waypointId
const isJungle = levelId >= 76 && levelId <= 78
const marginX = isJungle ? 4 : Math.min(16, Math.floor(canvas.width / 4))
const marginY = Math.min(16, Math.floor(canvas.height / 4))
const cx = isJungle ? 16 : Math.floor(canvas.width / 2)
const cy = Math.floor(canvas.height / 2)
const hubRadius = Math.max(8, Math.floor(Math.min(canvas.width, canvas.height) / 3))
const LANDMARK_REGEX = /(clearing|ruin|oasis|cottage|camp|building|temple|tomb|bivouac|shrine)/i
const landmarkRects = placedRects.filter(r => LANDMARK_REGEX.test(r.name))
const clearingCandidates: { x: number; y: number }[] = []
const centralHubCandidates: { x: number; y: number }[] = []
const roadsideCandidates: { x: number; y: number }[] = []
const openCandidates: { x: number; y: number }[] = []
const isNearLandmark = (tx: number, ty: number): boolean => {
for (const r of landmarkRects) {
const dx = Math.max(r.x - tx, 0, tx - (r.x + r.w - 1))
const dy = Math.max(r.y - ty, 0, ty - (r.y + r.h - 1))
const d = Math.max(dx, dy)
if (d >= 1 && d <= 7) return true
}
// For Act 3 Jungle clearings (when stamped without placedRects)
if (isJungle && landmarkRects.length === 0) {
if (tx >= 4 && tx < 30 && ty >= 32 && ty < 160) return true
}
return false
}
for (let y = marginY; y < canvas.height - marginY; y += 2) {
for (let x = marginX; x < canvas.width - marginX; x += 2) {
if (!isTileClear(canvas, x, y, placedRects, 2)) continue
const nearLandmark = isNearLandmark(x, y)
const nearRoad = isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, x, y, 2, 6)
const inCentralHub = Math.hypot(x - cx, y - cy) <= hubRadius
if (nearLandmark) clearingCandidates.push({ x, y })
if (nearRoad) roadsideCandidates.push({ x, y })
if (inCentralHub) centralHubCandidates.push({ x, y })
openCandidates.push({ x, y })
}
}
if (openCandidates.length === 0) {
for (let y = marginY; y < canvas.height - marginY; y += 1) {
for (let x = marginX; x < canvas.width - marginX; x += 1) {
if (isTileClear(canvas, x, y, placedRects, 1)) {
openCandidates.push({ x, y })
if (isNearLandmark(x, y)) clearingCandidates.push({ x, y })
if (Math.hypot(x - cx, y - cy) <= hubRadius) centralHubCandidates.push({ x, y })
}
}
}
}
let pool: { x: number; y: number }[]
if (dirtPathGrid !== undefined) {
const landmarkRoad = clearingCandidates.filter(c => isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, c.x, c.y, 2, 6))
if (landmarkRoad.length > 0) {
pool = landmarkRoad
} else if (clearingCandidates.length > 0) {
pool = clearingCandidates
} else if (roadsideCandidates.length > 0) {
const roadHub = roadsideCandidates.filter(c => Math.hypot(c.x - cx, c.y - cy) <= hubRadius)
pool = roadHub.length > 0 ? roadHub : roadsideCandidates
} else if (centralHubCandidates.length > 0) {
pool = centralHubCandidates
} else {
pool = openCandidates
}
} else {
if (clearingCandidates.length > 0) {
pool = clearingCandidates
} else if (centralHubCandidates.length > 0) {
pool = centralHubCandidates
} else {
pool = openCandidates
}
}
const chosen = pool.length > 0
? pool[rng.int(0, pool.length - 1)]!
: { x: cx, y: cy }
canvas.objects.push({
type: 2,
id: wpId,
x: chosen.x * SUB_TILES_PER_TILE + 2,
y: chosen.y * SUB_TILES_PER_TILE + 2,
flags: 0,
})
stats.waypointsSpawned = 1
stats.waypointTile = { x: chosen.x, y: chosen.y }
stats.notes.push(`spawned outdoor waypoint (id=${String(wpId)}) at tile (${String(chosen.x)}, ${String(chosen.y)})`)
}
/**
* Scatter outdoor shrines, healing wells, companion chests/scatter, and standalone interactives
* across Acts 1..5 (`DRLGOUTDOORS_SpawnAct12Shrines`).
*
* Implements:
* 1. Authentic thematic clustering: 60% chance for an accompanying companion object 2..4 tiles from each shrine.
* 2. Dispersed spatial distribution: Shrines spaced >= 10 tiles apart (relaxing to >= 7 if space constrained).
* 3. Act-specific shrine, well, chest, and scatter IDs.
* 4. Scaling based on `Levels.txt.SubShrine` / `LvlSub.txt` max clusters when provided.
*/
export function spawnOutdoorShrines(
canvas: Canvas,
levelId: number,
act: number,
dirtPathGrid: Uint8Array | undefined,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
stats: WildernessStats,
shrineSubstitutions?: readonly WildernessSubstitution[],
subTheme = 0,
levelTypeName?: string,
): void {
if (levelId <= 0 || levelId === 108 || levelId === 110) return
const isOutdoorLevel =
(levelId >= 2 && levelId <= 7) || levelId === 39 ||
(levelId >= 41 && levelId <= 46) ||
(levelId >= 76 && levelId <= 83) ||
(levelId >= 104 && levelId <= 107) ||
(levelId >= 111 && levelId <= 112) || levelId === 117 ||
(levelTypeName !== undefined && /wilderness|desert|jungle|kurast|mesa|lava|siege|barricade/i.test(levelTypeName))
if (!isOutdoorLevel) return
const effectiveAct = actOfLevel(levelId, levelTypeName, act)
const config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]!
const isJungle = levelId >= 76 && levelId <= 78
const marginX = isJungle ? 4 : Math.min(10, Math.floor(canvas.width / 5))
const marginY = Math.min(10, Math.floor(canvas.height / 5))
const candidates: { x: number; y: number }[] = []
for (let y = marginY; y < canvas.height - marginY; y += 2) {
for (let x = marginX; x < canvas.width - marginX; x += 2) {
if (!isTileClear(canvas, x, y, placedRects, 1)) continue
candidates.push({ x, y })
}
}
if (candidates.length === 0) {
for (let y = marginY; y < canvas.height - marginY; y += 1) {
for (let x = marginX; x < canvas.width - marginX; x += 1) {
if (isTileClear(canvas, x, y, placedRects, 1)) candidates.push({ x, y })
}
}
}
if (candidates.length === 0) return
const allPlacedObjects: { x: number; y: number }[] = canvas.objects.map(o => ({
x: Math.floor(o.x / SUB_TILES_PER_TILE),
y: Math.floor(o.y / SUB_TILES_PER_TILE),
}))
const placedShrineTiles: { x: number; y: number }[] = []
let shrinesSpawned = 0
let interactivesSpawned = 0
// 1. Determine target shrine count based on LvlSub / SubShrine max
let targetShrineCount = rng.int(1, 2)
if (shrineSubstitutions && shrineSubstitutions.length > 0) {
let maxFromSub = 0
for (const sub of shrineSubstitutions) {
const m = sub.max[subTheme] ?? sub.max[0] ?? 0
if (m > maxFromSub) maxFromSub = m
}
if (maxFromSub > 0) {
const cap = Math.min(4, maxFromSub)
targetShrineCount = rng.int(Math.min(2, cap), cap)
}
}
const pickShrineSpot = (minDist: number): { x: number; y: number } | null => {
const indices = Array.from({ length: candidates.length }, (_, i) => i)
for (let i = indices.length - 1; i > 0; i -= 1) {
const j = rng.int(0, i)
const tmp = indices[i]!
indices[i] = indices[j]!
indices[j] = tmp
}
for (const idx of indices) {
const spot = candidates[idx]!
const tooCloseToShrine = placedShrineTiles.some(p => Math.hypot(p.x - spot.x, p.y - spot.y) < minDist)
if (tooCloseToShrine) continue
const tooCloseToObject = allPlacedObjects.some(p => Math.hypot(p.x - spot.x, p.y - spot.y) < 4)
if (tooCloseToObject) continue
return spot
}
return null
}
// 2. Spawn Shrines with Dispersed Distribution (>= 10 tiles, relaxing to >= 7 if constrained)
for (let i = 0; i < targetShrineCount; i += 1) {
const spot = pickShrineSpot(10) ?? pickShrineSpot(7)
if (!spot) break
const shrineId = config.shrinePool[rng.int(0, config.shrinePool.length - 1)]!
canvas.objects.push({
type: 2,
id: shrineId,
x: spot.x * SUB_TILES_PER_TILE + 2,
y: spot.y * SUB_TILES_PER_TILE + 2,
flags: 0,
})
placedShrineTiles.push(spot)
allPlacedObjects.push(spot)
shrinesSpawned += 1
// 3. Thematic Clustering: 60% chance to spawn an accompanying companion object 2..4 tiles away
if (rng.int(0, 99) < 60) {
const isChest = rng.int(0, 99) < 40
const pool = isChest ? config.chestPool : config.scatterPool
const companionId = pool[rng.int(0, pool.length - 1)]!
const companionOffsets: { dx: number; dy: number }[] = []
for (let dy = -4; dy <= 4; dy += 1) {
for (let dx = -4; dx <= 4; dx += 1) {
const d = Math.hypot(dx, dy)
if (d >= 2 && d <= 4) {
companionOffsets.push({ dx, dy })
}
}
}
for (let oi = companionOffsets.length - 1; oi > 0; oi -= 1) {
const oj = rng.int(0, oi)
const tmp = companionOffsets[oi]!
companionOffsets[oi] = companionOffsets[oj]!
companionOffsets[oj] = tmp
}
for (const off of companionOffsets) {
const cx = spot.x + off.dx
const cy = spot.y + off.dy
if (isTileClear(canvas, cx, cy, placedRects, 1)) {
const occupied = allPlacedObjects.some(p => p.x === cx && p.y === cy)
if (!occupied) {
canvas.objects.push({
type: 2,
id: companionId,
x: cx * SUB_TILES_PER_TILE + 2,
y: cy * SUB_TILES_PER_TILE + 2,
flags: 0,
})
allPlacedObjects.push({ x: cx, y: cy })
interactivesSpawned += 1
break
}
}
}
}
}
// 4. Healing Well (1 well placed with >= 8 tiles distance from shrines)
const pickWellSpot = (): { x: number; y: number } | null => {
const indices = Array.from({ length: candidates.length }, (_, i) => i)
for (let i = indices.length - 1; i > 0; i -= 1) {
const j = rng.int(0, i)
const tmp = indices[i]!
indices[i] = indices[j]!
indices[j] = tmp
}
for (const idx of indices) {
const spot = candidates[idx]!
const farFromShrines = placedShrineTiles.every(p => Math.hypot(p.x - spot.x, p.y - spot.y) >= 8)
if (!farFromShrines) continue
const farFromObjects = allPlacedObjects.every(p => Math.hypot(p.x - spot.x, p.y - spot.y) >= 4)
if (!farFromObjects) continue
return spot
}
return null
}
const wellSpot = pickWellSpot()
if (wellSpot) {
canvas.objects.push({
type: 2,
id: config.wellId,
x: wellSpot.x * SUB_TILES_PER_TILE + 2,
y: wellSpot.y * SUB_TILES_PER_TILE + 2,
flags: 0,
})
allPlacedObjects.push(wellSpot)
shrinesSpawned += 1
}
// 5. Standalone Chests (1..2)
const pickStandaloneSpot = (minDist: number): { x: number; y: number } | null => {
for (let attempt = 0; attempt < 24; attempt += 1) {
const spot = candidates[rng.int(0, candidates.length - 1)]!
const tooClose = allPlacedObjects.some(p => Math.hypot(p.x - spot.x, p.y - spot.y) < minDist)
if (!tooClose) {
allPlacedObjects.push(spot)
return spot
}
}
return null
}
const chestCount = rng.int(1, 2)
for (let i = 0; i < chestCount; i += 1) {
const spot = pickStandaloneSpot(4)
if (!spot) break
const chestId = config.chestPool[rng.int(0, config.chestPool.length - 1)]!
canvas.objects.push({
type: 2,
id: chestId,
x: spot.x * SUB_TILES_PER_TILE + 2,
y: spot.y * SUB_TILES_PER_TILE + 2,
flags: 0,
})
interactivesSpawned += 1
}
// 6. Standalone Scatter: Barrels / Campfires / Stash Rocks (2..3)
const scatterCount = rng.int(2, 3)
for (let i = 0; i < scatterCount; i += 1) {
const spot = pickStandaloneSpot(4)
if (!spot) break
const scatterId = config.scatterPool[rng.int(0, config.scatterPool.length - 1)]!
canvas.objects.push({
type: 2,
id: scatterId,
x: spot.x * SUB_TILES_PER_TILE + 2,
y: spot.y * SUB_TILES_PER_TILE + 2,
flags: 0,
})
interactivesSpawned += 1
}
stats.shrinesSpawned = shrinesSpawned
stats.interactivesSpawned = interactivesSpawned
}
export const spawnAct12Waypoint = spawnOutdoorWaypoint
export const spawnAct12Shrines = spawnOutdoorShrines
/** Specification of a landmark entity definition for data-driven spawning. */
export interface LandmarkEntityDef {
readonly id: string
readonly name: string
readonly role: 'superunique' | 'npc_guard'
readonly act: number
readonly levelIds: readonly number[]
readonly monsterId: string
readonly presetMatch?: RegExp
readonly defaultLandmark: string
readonly modifiers: readonly string[]
readonly minionCount: readonly [number, number]
readonly minionMonsterId: string
readonly objectId: number
readonly defaultRelativePos?: { readonly x: number; readonly y: number }
readonly resolvePosition?: (
canvas: Canvas,
levelId: number,
act1Plan: Act1OutdoorPlan | undefined,
placedRects: readonly PlacedPresetRect[],
gridWidth: number,
gridHeight: number,
) => { tileX: number; tileY: number; landmark: string } | null
}
/**
* Canonical landmark SuperUniques and special NPCs across Acts 1-5.
*/
export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
// Act 1
{
id: 'Flavie',
name: 'Flavie',
role: 'npc_guard',
act: 1,
levelIds: [2, 3],
monsterId: 'rogue2',
defaultLandmark: 'Border Gate (Blood Moor <-> Cold Plains)',
modifiers: [],
minionCount: [0, 0],
minionMonsterId: '',
objectId: 52,
resolvePosition: (canvas, levelId, act1Plan, _placedRects, gridWidth, gridHeight) => {
if (levelId !== 2 && levelId !== 3) return null
const cx = Math.floor(canvas.width / 2)
const cy = Math.floor(canvas.height / 2)
const targetGateLevel = levelId === 2 ? 3 : 2
const anchor = act1Plan?.anchors.find(
a => a.toLevelId === targetGateLevel || /Cold Plains|Blood Moor/i.test(a.label),
)
let fx: number
let fy: number
if (anchor !== undefined) {
const dx = Math.sign(cx - anchor.interiorCell.x) * 2
const dy = Math.sign(cy - anchor.interiorCell.y) * 2
fx = Math.max(4, Math.min(canvas.width - 5, anchor.interiorCell.x + dx))
fy = Math.max(4, Math.min(canvas.height - 5, anchor.interiorCell.y + dy))
} else {
const openings = getPerimeterOpenings(levelId, gridWidth, gridHeight)
let chosenKey: string | undefined
for (const k of openings) {
const [bx, by] = k.split(',').map(Number)
if (levelId === 2 && bx === gridWidth - 1) chosenKey = k
if (levelId === 3 && by === gridHeight - 1) chosenKey = k
}
if (!chosenKey) chosenKey = openings.values().next().value
if (chosenKey) {
const [bx, by] = chosenKey.split(',').map(Number)
const rawX = (bx ?? 0) * TILES_PER_BLOCK + 4
const rawY = (by ?? 0) * TILES_PER_BLOCK + 4
const dx = Math.sign(cx - rawX) * 3
const dy = Math.sign(cy - rawY) * 3
fx = Math.max(4, Math.min(canvas.width - 5, rawX + dx))
fy = Math.max(4, Math.min(canvas.height - 5, rawY + dy))
} else {
fx = cx
fy = cy
}
}
return {
tileX: fx,
tileY: fy,
landmark: 'Border Gate (Blood Moor <-> Cold Plains)',
}
},
},
{
id: 'Bishibosh',
name: 'Bishibosh',
role: 'superunique',
act: 1,
levelIds: [3],
monsterId: 'fallenshaman1',
presetMatch: /Fallen Camp Bishibosh|Bivouac/i,
defaultLandmark: 'Act 1 - Fallen Camp Bishibosh',
modifiers: ['magicresistant', 'fireenchant'],
minionCount: [5, 7],
minionMonsterId: 'fallen1',
objectId: 70,
defaultRelativePos: { x: 0.45, y: 0.45 },
},
{
id: 'Rakanishu',
name: 'Rakanishu',
role: 'superunique',
act: 1,
levelIds: [4],
monsterId: 'fallen1',
presetMatch: /Cairn Stones/i,
defaultLandmark: 'Act 1 - Cairn Stones',
modifiers: ['lightenchant', 'fast'],
minionCount: [6, 8],
minionMonsterId: 'fallen1',
objectId: 71,
defaultRelativePos: { x: 0.52, y: 0.48 },
},
{
id: 'Treehead WoodFist',
name: 'Treehead WoodFist',
role: 'superunique',
act: 1,
levelIds: [5],
monsterId: 'brute1',
presetMatch: /Inifus/i,
defaultLandmark: 'Act 1 - Inifus',
modifiers: ['strong', 'fast'],
minionCount: [3, 4],
minionMonsterId: 'brute1',
objectId: 72,
defaultRelativePos: { x: 0.48, y: 0.52 },
},
{
id: 'Blood Raven',
name: 'Blood Raven',
role: 'superunique',
act: 1,
levelIds: [17],
monsterId: 'corruptrogue1',
presetMatch: /Graveyard/i,
defaultLandmark: 'Act 1 - Graveyard',
modifiers: ['fireenchant', 'fast'],
minionCount: [6, 8],
minionMonsterId: 'zombie1',
objectId: 73,
defaultRelativePos: { x: 0.5, y: 0.5 },
},
{
id: 'The Countess',
name: 'The Countess',
role: 'superunique',
act: 1,
levelIds: [6, 25],
monsterId: 'corruptrogue5',
presetMatch: /Tower/i,
defaultLandmark: 'Act 1 - Tower 1',
modifiers: ['fireenchant'],
minionCount: [4, 6],
minionMonsterId: 'corruptrogue5',
objectId: 74,
defaultRelativePos: { x: 0.5, y: 0.5 },
},
// Act 2
{
id: 'Creeping Feature',
name: 'Creeping Feature',
role: 'superunique',
act: 2,
levelIds: [41, 55, 59],
monsterId: 'mummy1',
presetMatch: /Desert Tomb 1|Stony Tomb/i,
defaultLandmark: 'Act 2 - Desert Tomb 1',
modifiers: ['strong', 'coldenchant'],
minionCount: [4, 6],
minionMonsterId: 'mummy1',
objectId: 75,
defaultRelativePos: { x: 0.5, y: 0.5 },
},
{
id: 'Bloodwitch the Wild',
name: 'Bloodwitch the Wild',
role: 'superunique',
act: 2,
levelIds: [45, 58, 60],
monsterId: 'huntress2',
presetMatch: /Desert Tomb 2|Claw Viper|Valley Ruin 1/i,
defaultLandmark: 'Act 2 - Desert Tomb 2',
modifiers: ['cursed', 'strong'],
minionCount: [4, 6],
minionMonsterId: 'huntress2',
objectId: 76,
defaultRelativePos: { x: 0.48, y: 0.52 },
},
{
id: 'Beetleburst',
name: 'Beetleburst',
role: 'superunique',
act: 2,
levelIds: [43],
monsterId: 'beetle2',
presetMatch: /Desert Oasis|Desert Lair/i,
defaultLandmark: 'Act 2 - Desert Oasis 1',
modifiers: ['magicresistant'],
minionCount: [4, 6],
minionMonsterId: 'beetle2',
objectId: 77,
defaultRelativePos: { x: 0.52, y: 0.48 },
},
{
id: 'Dark Elder',
name: 'Dark Elder',
role: 'superunique',
act: 2,
levelIds: [44],
monsterId: 'plaguebearer1',
presetMatch: /Desert Ruins Elder/i,
defaultLandmark: 'Act 2 - Desert Ruins Elder',
modifiers: ['fast', 'magicresistant'],
minionCount: [4, 6],
minionMonsterId: 'plaguebearer1',
objectId: 78,
defaultRelativePos: { x: 0.5, y: 0.5 },
},
{
id: 'Fangskin',
name: 'Fangskin',
role: 'superunique',
act: 2,
levelIds: [45, 61],
monsterId: 'clawviper3',
presetMatch: /Desert Valley Ruin 2|Valley Warp|Claw Viper Altar/i,
defaultLandmark: 'Act 2 - Desert Valley Ruin 2',
modifiers: ['lightenchant', 'fast'],
minionCount: [4, 6],
minionMonsterId: 'clawviper3',
objectId: 79,
defaultRelativePos: { x: 0.55, y: 0.45 },
},
// Act 3
{
id: 'Stormtree',
name: 'Stormtree',
role: 'superunique',
act: 3,
levelIds: [78],
monsterId: 'thornhulk3',
presetMatch: /Flayer Jungle Entrance|Jungle Gate|Flayer Entrance/i,
defaultLandmark: 'Act 3 - Flayer Jungle Entrance',
modifiers: ['lightenchant', 'fast'],
minionCount: [4, 6],
minionMonsterId: 'thornhulk3',
objectId: 80,
defaultRelativePos: { x: 0.5, y: 0.5 },
},
{
id: 'Sszark the Burning',
name: 'Sszark the Burning',
role: 'superunique',
act: 3,
levelIds: [76, 84],
monsterId: 'spider2',
presetMatch: /Spider Cavern|Spider/i,
defaultLandmark: 'Act 3 - Spider Cavern',
modifiers: ['strong', 'cursed'],
minionCount: [4, 6],
minionMonsterId: 'spider2',
objectId: 81,
defaultRelativePos: { x: 0.48, y: 0.52 },
},
{
id: 'Witch Doctor Endugu',
name: 'Witch Doctor Endugu',
role: 'superunique',
act: 3,
levelIds: [78, 88, 91],
monsterId: 'flayershaman4',
presetMatch: /Flayer Dungeon/i,
defaultLandmark: 'Act 3 - Flayer Dungeon',
modifiers: ['magicresistant', 'fireenchant'],
minionCount: [4, 6],
minionMonsterId: 'flayer4',
objectId: 82,
defaultRelativePos: { x: 0.52, y: 0.48 },
},
{
id: 'Battlemaid Sarina',
name: 'Battlemaid Sarina',
role: 'superunique',
act: 3,
levelIds: [80, 94],
monsterId: 'corruptrogue8',
presetMatch: /Burbs Temple|Ruined Temple|MetroTemple/i,
defaultLandmark: 'Act 3 - Burbs Temple',
modifiers: ['fast', 'spectralhit'],
minionCount: [4, 6],
minionMonsterId: 'corruptrogue8',
objectId: 83,
defaultRelativePos: { x: 0.5, y: 0.5 },
},
// Act 4
{
id: 'Izual',
name: 'Izual',
role: 'superunique',
act: 4,
levelIds: [105],
monsterId: 'izual',
presetMatch: /Plains of Despair|Izual/i,
defaultLandmark: 'Act 4 - Plains of Despair',
modifiers: ['coldenchant'],
minionCount: [0, 0],
minionMonsterId: 'izual',
objectId: 84,
defaultRelativePos: { x: 0.5, y: 0.5 },
},
{
id: 'Hephasto the Armorer',
name: 'Hephasto the Armorer',
role: 'superunique',
act: 4,
levelIds: [106, 107],
monsterId: 'hephasto',
presetMatch: /Hellforge/i,
defaultLandmark: 'Act 4 - Hellforge',
modifiers: ['spectralhit', 'aura'],
minionCount: [0, 0],
minionMonsterId: 'hephasto',
objectId: 85,
defaultRelativePos: { x: 0.52, y: 0.48 },
},
{
id: 'Hadriel',
name: 'Hadriel',
role: 'npc_guard',
act: 4,
levelIds: [106, 107, 108],
monsterId: 'hadriel',
presetMatch: /Chaos Sanctuary Entrance|Hadriel/i,
defaultLandmark: 'Act 4 - Chaos Sanctuary Entrance',
modifiers: [],
minionCount: [0, 0],
minionMonsterId: '',
objectId: 86,
defaultRelativePos: { x: 0.48, y: 0.52 },
},
// Act 5
{
id: 'Dac Farren',
name: 'Dac Farren',
role: 'superunique',
act: 5,
levelIds: [110],
monsterId: 'demonimp1',
presetMatch: /Barricade Prison 1|Barricade Ruins/i,
defaultLandmark: 'Act 5 - Barricade Prison 1',
modifiers: ['coldenchant'],
minionCount: [4, 6],
minionMonsterId: 'demonimp1',
objectId: 87,
defaultRelativePos: { x: 0.35, y: 0.5 },
},
{
id: 'Shenk the Overseer',
name: 'Shenk the Overseer',
role: 'superunique',
act: 5,
levelIds: [110, 111],
monsterId: 'overseer2',
presetMatch: /Barricade Building|Shenk|Siege Strip 15|Siege To Barricade/i,
defaultLandmark: 'Act 5 - Barricade Building',
modifiers: ['strong'],
minionCount: [15, 20],
minionMonsterId: 'enslaved1',
objectId: 88,
defaultRelativePos: { x: 0.75, y: 0.5 },
},
{
id: 'Eyeback the Unleashed',
name: 'Eyeback the Unleashed',
role: 'superunique',
act: 5,
levelIds: [111],
monsterId: 'bloodlord2',
presetMatch: /Barricade Hell Portal N|Barricade 1/i,
defaultLandmark: 'Act 5 - Barricade Hell Portal N',
modifiers: ['fast', 'strong'],
minionCount: [3, 5],
minionMonsterId: 'bloodlord2',
objectId: 89,
defaultRelativePos: { x: 0.45, y: 0.55 },
},
{
id: 'Thresh Socket',
name: 'Thresh Socket',
role: 'superunique',
act: 5,
levelIds: [112],
monsterId: 'siegebeast1',
presetMatch: /Barricade Snow Unique/i,
defaultLandmark: 'Act 5 - Barricade Snow Unique',
modifiers: ['cursed'],
minionCount: [2, 4],
minionMonsterId: 'siegebeast1',
objectId: 90,
defaultRelativePos: { x: 0.5, y: 0.5 },
},
{
id: 'Bonesaw Breaker',
name: 'Bonesaw Breaker',
role: 'superunique',
act: 5,
levelIds: [115],
monsterId: 'reanimatedhorde2',
presetMatch: /Glacial Trail|Drifter Cavern/i,
defaultLandmark: 'Act 5 - Glacial Trail',
modifiers: ['strong', 'magicresistant'],
minionCount: [4, 6],
minionMonsterId: 'reanimatedhorde2',
objectId: 91,
defaultRelativePos: { x: 0.52, y: 0.48 },
},
{
id: 'Frozenstein',
name: 'Frozenstein',
role: 'superunique',
act: 5,
levelIds: [114],
monsterId: 'yeti2',
presetMatch: /Frozen River|Anya/i,
defaultLandmark: 'Act 5 - Frozen River',
modifiers: ['coldenchant', 'manahit'],
minionCount: [4, 6],
minionMonsterId: 'yeti2',
objectId: 92,
defaultRelativePos: { x: 0.5, y: 0.5 },
},
{
id: 'Nihlathak',
name: 'Nihlathak',
role: 'superunique',
act: 5,
levelIds: [124],
monsterId: 'nihlathakboss',
presetMatch: /Halls of Vaught|Nihlathak/i,
defaultLandmark: 'Act 5 - Halls of Vaught',
modifiers: ['coldenchant'],
minionCount: [6, 8],
minionMonsterId: 'iceprowler2',
objectId: 93,
defaultRelativePos: { x: 0.5, y: 0.5 },
},
]
/**
* Refactor landmark and outdoor boss generation into a table/data-driven pipeline
* based on `SuperUniques.txt` and `MonPreset.txt`.
*/
export function buildLandmarkEntitiesFromTables(
superuniques?: D2Table,
monpreset?: D2Table,
baseDefs: readonly LandmarkEntityDef[] = CANONICAL_LANDMARK_ENTITIES,
): LandmarkEntityDef[] {
if (!superuniques && !monpreset) {
return [...baseDefs]
}
const suMap = new Map<string, SuperUnique>()
if (superuniques) {
const suList = readSuperUniques(superuniques)
for (const su of suList) {
suMap.set(su.id.toLowerCase(), su)
if (su.nameKey) {
suMap.set(su.nameKey.toLowerCase(), su)
}
}
}
const actPlaceIndex = new Map<string, number>()
if (monpreset) {
const actIdx = monpreset.header.findIndex(h => /^act$/i.test(h.trim()))
const placeIdx = monpreset.header.findIndex(h => /^place$/i.test(h.trim()))
const objectIdIdx = monpreset.header.findIndex(h => /^(objectid|id|object)$/i.test(h.trim()))
if (actIdx !== -1 && placeIdx !== -1) {
const perActCount = new Map<number, number>()
for (const row of monpreset.rows) {
const actVal = Number(row[actIdx])
const placeVal = row[placeIdx]?.trim()
if (Number.isFinite(actVal) && placeVal) {
const currentIdx = perActCount.get(actVal) ?? 0
perActCount.set(actVal, currentIdx + 1)
const customId = objectIdIdx !== -1 ? Number(row[objectIdIdx]) : currentIdx
actPlaceIndex.set(
`${actVal}:${placeVal.toLowerCase()}`,
Number.isFinite(customId) ? customId : currentIdx,
)
}
}
}
}
return baseDefs.map(def => {
let monsterId = def.monsterId
let name = def.name
let modifiers = def.modifiers
let minionCount = def.minionCount
let minionMonsterId = def.minionMonsterId
let objectId = def.objectId
if (superuniques) {
const su = suMap.get(def.id.toLowerCase()) ?? suMap.get(def.name.toLowerCase())
if (su) {
if (su.monsterId) {
monsterId = su.monsterId
if (!minionMonsterId) {
minionMonsterId = su.monsterId
}
}
if (su.nameKey && su.nameKey.trim() !== '') {
name = su.nameKey.trim()
}
if (su.modifiers.length > 0) {
const mappedMods: string[] = []
for (const modId of su.modifiers) {
const found = CANONICAL_ELITE_MODIFIERS.find(m => m.id === modId)
if (found) mappedMods.push(found.name)
}
if (mappedMods.length > 0) {
modifiers = mappedMods
}
}
minionCount = [su.minMinions, su.maxMinions]
}
}
if (monpreset) {
const candidates = [
def.id.toLowerCase(),
def.name.toLowerCase(),
def.monsterId.toLowerCase(),
def.defaultLandmark.toLowerCase(),
]
for (const [key, id] of actPlaceIndex) {
const [actStr, placeName] = key.split(':')
if (Number(actStr) === def.act) {
if (
candidates.some(c => c.includes(placeName) || placeName.includes(c)) ||
(def.presetMatch && def.presetMatch.test(placeName))
) {
objectId = id
break
}
}
}
}
return {
...def,
monsterId,
name,
modifiers,
minionCount,
minionMonsterId,
objectId,
}
})
}
/**
* Anchor outdoor SuperUniques and special NPCs to their landmark presets or border gates.
*/
export function spawnLandmarkEntities(
canvas: Canvas,
levelId: number,
act1Plan: Act1OutdoorPlan | undefined,
placedRects: readonly PlacedPresetRect[],
gridWidth: number,
gridHeight: number,
stats: WildernessStats,
defs: readonly LandmarkEntityDef[] = CANONICAL_LANDMARK_ENTITIES,
): void {
const entities: LandmarkEntity[] = []
for (const def of defs) {
let tileX: number
let tileY: number
let landmark: string
if (def.resolvePosition) {
const resolved = def.resolvePosition(canvas, levelId, act1Plan, placedRects, gridWidth, gridHeight)
if (!resolved) continue
tileX = resolved.tileX
tileY = resolved.tileY
landmark = resolved.landmark
} else {
const rect = def.presetMatch ? placedRects.find(r => def.presetMatch!.test(r.name)) : undefined
if (rect !== undefined) {
tileX = Math.floor(rect.x + rect.w / 2)
tileY = Math.floor(rect.y + rect.h / 2)
landmark = rect.name
} else if (def.levelIds.includes(levelId)) {
tileX = Math.floor(canvas.width * (def.defaultRelativePos?.x ?? 0.5))
tileY = Math.floor(canvas.height * (def.defaultRelativePos?.y ?? 0.5))
landmark = def.defaultLandmark
} else {
continue
}
}
const subtileX = tileX * SUB_TILES_PER_TILE + 2
const subtileY = tileY * SUB_TILES_PER_TILE + 2
entities.push({
role: def.role,
id: def.id,
monsterId: def.monsterId,
name: def.name,
landmark,
tileX,
tileY,
subtileX,
subtileY,
levelId,
modifiers: def.modifiers,
minionCount: def.minionCount,
minionMonsterId: def.minionMonsterId,
objectId: def.objectId,
})
canvas.objects.push({
type: 1,
id: def.objectId,
x: subtileX,
y: subtileY,
flags: 0,
})
}
stats.landmarkEntities = entities
}
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)
let groundTiles: WeightedGroundTile[] | null = null
if (request.groundTiles && request.groundTiles.length > 0) {
groundTiles = [...request.groundTiles]
} else if (request.groundTile) {
if (isTileArray(request.groundTile)) {
groundTiles = [...(request.groundTile as readonly WeightedGroundTile[])]
} else {
const single = request.groundTile as { readonly style: number; readonly sequence: number }
groundTiles = [{ style: single.style, sequence: single.sequence, weight: 1 }]
}
} else if (request.dt1Libraries && request.dt1Libraries.length > 0) {
const mask = wildernessDt1Mask(request.levelTypeName)
const fromDt1 = deriveGroundTilesFromDt1Libraries(request.dt1Libraries, mask)
if (fromDt1.length > 0) groundTiles = fromDt1
}
if (groundTiles === null || groundTiles.length === 0) {
const canonicalPool = CANONICAL_GROUND_POOLS[request.levelTypeName]
if (canonicalPool && canonicalPool.length > 0) {
groundTiles = [...canonicalPool]
} else {
const canonical = CANONICAL_GROUND_TILES[request.levelTypeName]
if (canonical !== undefined) {
groundTiles = [{ style: canonical.style, sequence: canonical.sequence, weight: 1 }]
} else {
const fromPieces = deriveGroundTiles(request.pieces)
if (fromPieces.length > 0) groundTiles = fromPieces
}
}
}
const primaryGroundTile = groundTiles && groundTiles.length > 0
? { style: groundTiles[0]!.style, sequence: groundTiles[0]!.sequence }
: null
const groundTile = primaryGroundTile
if ((gridWidth < 3 || gridHeight < 3) && request.levelId !== 82) {
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 (groundTiles !== null) fillGround(canvas, groundTiles, 0, 0, canvas.width, canvas.height, { seed: request.seed })
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: primaryGroundTile,
groundTiles: groundTiles ?? undefined,
groundCells: canvas.width * canvas.height,
borderPiecesAvailable: 0,
borderBlocks: 0,
borderUsage: {},
roadCells: 0,
roadSegments: 0,
anchors: 0,
specialPresets: [],
entrances: [],
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, groundTiles: undefined, sizeSource,
roadCells: 0, roadSegments: 0, anchors: 0, specialPresets: [], entrances: [],
}
let wallLayers = 1
let floorLayers = 1
let substitutionType = 0
let version = 0
const inferredAct = actOfLevel(request.levelId, request.levelTypeName)
let act = inferredAct
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)
if (level.act > 0) act = level.act
}
if (act === 1 && inferredAct > 1) {
act = inferredAct
}
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)
const landmarkDefs = buildLandmarkEntitiesFromTables(
request.superuniques,
request.monpreset,
request.customLandmarks ?? CANONICAL_LANDMARK_ENTITIES,
)
stats.groundTile = primaryGroundTile
stats.groundTiles = groundTiles ?? undefined
if (primaryGroundTile === null || groundTiles === null || groundTiles.length === 0) {
throw new Error(`${where}: no piece carries a floor tile to use as ground`)
}
stats.groundCells = fillGround(canvas, groundTiles, 0, 0, canvas.width, canvas.height, { seed: request.seed })
// Special case: Act 5 Bloody Foothills (110),
// Act 3 Travincal (83), Act 3 Kurast Causeway (82), and Act 3 Jungles (76, 77, 78) are assembled from
// deterministic multi-preset layouts rather than rectangular perimeter borders.
if (
request.levelId === 110 ||
request.levelTypeName === 'Act 5 - Siege' ||
request.levelId === 83 ||
request.levelId === 82 ||
(request.levelId >= 76 && request.levelId <= 78)
) {
const specialPlacedRects: PlacedPresetRect[] = []
let placedSpecial: Set<string>
if (request.levelId === 83) {
placedSpecial = stampAct3Travincal(canvas, request.pieces, stats, specialPlacedRects)
} else if (request.levelId === 82) {
placedSpecial = stampAct3KurastCauseway(canvas, request.pieces, stats)
} else if (request.levelId >= 76 && request.levelId <= 78) {
placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats, specialPlacedRects)
const populationRng = new Rng(request.seed ^ 0x6c07)
spawnOutdoorWaypoint(canvas, request.levelId, act, undefined, [], populationRng, stats, request.levelTypeName)
spawnOutdoorShrines(canvas, request.levelId, act, undefined, [], populationRng, stats, request.shrineSubstitutions, 0, request.levelTypeName)
} else {
placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, rng, stats)
}
spawnLandmarkEntities(
canvas,
request.levelId,
undefined,
specialPlacedRects,
gridWidth,
gridHeight,
stats,
landmarkDefs,
)
let finalGroundCells = 0
for (let y = 0; y < canvas.height; y += 1) {
const row = canvas.cells[y]
if (row === undefined) continue
for (let x = 0; x < canvas.width; x += 1) {
const c = row[x]
if (c !== undefined && c.floors.some(f => !f.hidden && f.prop1 !== 0)) finalGroundCells += 1
}
}
stats.groundCells = finalGroundCells
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,
// The special-preset path clamps `SubTheme` exactly like the main path
// does; it used to report a flat 0, so every special level looked like
// theme 0 in the verification reports.
subTheme: Math.max(0, Math.min(4, Math.floor(request.subTheme))),
groundTile: primaryGroundTile,
groundTiles: groundTiles ?? undefined,
groundCells: stats.groundCells,
borderPiecesAvailable: 0,
borderBlocks: 0,
borderUsage: {},
roadCells: stats.roadCells,
roadSegments: stats.roadSegments,
anchors: stats.anchors,
entrances: stats.entrances,
specialPresets: stats.specialPresets,
substitutions: stats.substitutions,
substitutedClusters: placedSpecial.size,
objects: canvas.objects.length,
act2Topography: stats.act2Topography,
act3Topography: stats.act3Topography,
act4Topography: stats.act4Topography,
act5Topography: stats.act5Topography,
waypointsSpawned: stats.waypointsSpawned ?? 0,
waypointTile: stats.waypointTile ?? null,
shrinesSpawned: stats.shrinesSpawned ?? 0,
interactivesSpawned: stats.interactivesSpawned ?? 0,
landmarkEntities: stats.landmarkEntities ?? [],
cells: canvas.width * canvas.height,
unresolved: stats.unresolved,
unimplementedPasses: UNIMPLEMENTED_PASSES,
notes: stats.notes,
...(stats.jungleLayout ? { jungleLayout: stats.jungleLayout } : {}),
...(stats.jungleTopology ? { jungleTopology: stats.jungleTopology } : {}),
...(stats.bridgesPlaced !== undefined ? { bridgesPlaced: stats.bridgesPlaced } : {}),
...(stats.riverIslands !== undefined ? { riverIslands: stats.riverIslands } : {}),
...(stats.riverForks !== undefined ? { riverForks: stats.riverForks } : {}),
...(stats.meanderCount !== undefined ? { meanderCount: stats.meanderCount } : {}),
...(stats.siegeTopography ? { siegeTopography: stats.siegeTopography } : {}),
},
}
}
// Filter pieces for Act 3 Kurast districts (79 Slums, 80 Burbs, 81 Metro) and Act 5 dirt (111) vs snow (112, 117)
let levelPieces = request.pieces
if (request.levelId === 79) {
const slumsOnly = request.pieces.filter(p => /slums/i.test(p.name))
if (slumsOnly.length > 0) levelPieces = slumsOnly
} else if (request.levelId === 80) {
const burbsOnly = request.pieces.filter(p => /burbs/i.test(p.name))
if (burbsOnly.length > 0) levelPieces = burbsOnly
} else if (request.levelId === 81) {
const metroOnly = request.pieces.filter(p => /metro/i.test(p.name))
if (metroOnly.length > 0) levelPieces = metroOnly
} else if (request.levelId === 111) {
levelPieces = request.pieces.filter(p => !/snow/i.test(p.name))
} else if (request.levelId === 112 || request.levelId === 117) {
const snowOnly = request.pieces.filter(p => /snow/i.test(p.name) || /ruins/i.test(p.name))
if (snowOnly.length > 0) levelPieces = snowOnly
}
const borderPieces = levelPieces.filter(piece => piece.border)
const fillPieces = levelPieces.filter(piece => !piece.border)
// Step 1-3: Act 1 Outdoor anchor & road planning + dirt road bitmask rasterization
//
// The planner was written for Act 1 and its hard-coded per-level table only
// covers Act 1, but nothing in it is Act 1 specific once the caller supplies
// the seams: `request.gates` comes from the world graph, so every act can be
// planned the same way. Without gates we keep the old Act 1 only behaviour so
// existing packs and the generator verifier reproduce byte for byte.
let act1Plan: Act1OutdoorPlan | undefined
let dirtPathGrid: Uint8Array | undefined
if (request.levelTypeName === 'Act 1 - Wilderness' || request.gates !== undefined) {
act1Plan = planAct1OutdoorLevel(request, gridWidth, gridHeight, rng)
stats.anchors = act1Plan.anchors.length
stats.entrances = toEntrances(act1Plan.anchors)
const polylines = buildRoadNetworkPolylines(act1Plan, gridWidth, gridHeight, rng)
if (polylines.length > 0) {
const roadRes = generateDirtPaths(canvas, polylines)
dirtPathGrid = roadRes.dirtPathGrid
stats.roadCells = roadRes.roadCells
stats.roadSegments = roadRes.roadSegments
}
}
// Step 4A: Lay directional border around perimeter (respecting open gates & Town Transitions)
layBorder(
canvas,
borderPieces,
gridWidth,
gridHeight,
rng,
stats,
request.levelId,
act1Plan?.openGates,
act1Plan?.claimedBorderBlocks,
)
// Step 4B: Stamp anchored special presets (Town Transition, Den of Evil, Cave Entrance, Cairn Stones, etc.)
const allPlacedRects: PlacedPresetRect[] = []
if (act1Plan !== undefined) {
for (const preset of act1Plan.specialPresets) {
const originX = Math.min(preset.bx * TILES_PER_BLOCK, Math.max(canvas.width - preset.ds1.width, 0))
const originY = Math.min(preset.by * TILES_PER_BLOCK, Math.max(canvas.height - preset.ds1.height, 0))
stampDs1(canvas, preset.ds1, originX, originY)
stats.specialPresets.push(preset.name)
stats.substitutions.push({ name: preset.name, role: 'object', enabled: true, clusters: 1 })
allPlacedRects.push({
name: preset.name,
x: originX,
y: originY,
w: preset.ds1.width,
h: preset.ds1.height,
})
}
}
let specialPresetNames = request.specialPresets
if (specialPresetNames === undefined) {
if (request.levelId === 2) {
// Blood Moor has a chance (50%) of spawning river water + bridge, or pond
const hasRiver = rng.int(0, 1) === 0
specialPresetNames = hasRiver
? [
'Act 1 - DOE Entrance',
'Act 1 - River Upper',
'Act 1 - River Lower',
'Act 1 - Bridge',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Cottages 1',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Fence Fill 5',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
]
: [
'Act 1 - DOE Entrance',
'Act 1 - Pond',
'Act 1 - Fence Fill 1',
'Act 1 - Fence Fill 3',
'Act 1 - Ruin',
'Act 1 - Cottages 1',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill 4',
'Act 1 - Fence Fill 5',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
]
} else {
specialPresetNames = SPECIAL_PRESETS_BY_LEVEL[request.levelId] ?? []
}
}
if (act1Plan !== undefined) {
specialPresetNames = specialPresetNames.filter(name => !stats.specialPresets.includes(name))
}
const inX = Math.floor(gridWidth / 2)
const outY = Math.floor(gridHeight / 2)
const reservedRoadBlocks = new Set<string>()
if (
request.levelId !== 39 &&
((request.levelId >= 2 && request.levelId <= 7) ||
(request.levelId >= 41 && request.levelId <= 46) ||
(request.levelId >= 79 && request.levelId <= 82) ||
request.levelId === 111 ||
request.levelId === 112 ||
request.levelId === 117 ||
request.levelTypeName === 'Act 1 - Wilderness' ||
request.levelTypeName === 'Act 2 - Desert' ||
request.levelTypeName === 'Act 3 - Kurast' ||
request.levelTypeName === 'Act 5 - Barricade' ||
request.levelTypeName === 'Act 5 - Siege' ||
act === 1 ||
act === 2 ||
act === 3 ||
act === 5)
) {
for (let by = 1; by < gridHeight - 1; by += 1) reservedRoadBlocks.add(`${String(inX)},${String(by)}`)
for (let bx = 1; bx < gridWidth - 1; bx += 1) reservedRoadBlocks.add(`${String(bx)},${String(outY)}`)
}
if (act1Plan !== undefined) {
for (let by = 0; by < gridHeight; by += 1) {
for (let bx = 0; bx < gridWidth; bx += 1) {
if (act1Plan.claimedBlocks[by]?.[bx]) {
reservedRoadBlocks.add(`${String(bx)},${String(by)}`)
}
}
}
}
const { placedPieces: placedSpecialPieces, placedRects } = spawnSpecialPresets(
canvas,
fillPieces,
specialPresetNames,
gridWidth,
gridHeight,
rng,
stats,
reservedRoadBlocks,
)
allPlacedRects.push(...placedRects)
// Step 4C: Run LvlSub substitutions with strict road and preset obstacle avoidance
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')
const substituted = applySubstitutions(
canvas,
[...borderRows, ...objectRows, ...(request.shrineSubstitutions ?? []).filter(row => classifySubstitutionRole(row.name, row.type) !== 'border')],
themeIndex,
gridWidth,
gridHeight,
rng,
stats,
act1Plan?.claimedBlocks,
dirtPathGrid,
)
// Stamp outdoor road networks and boundary divider lines across the wilderness
// after random clutter substitutions.
stampOutdoorRoads(
canvas,
act,
request.levelId,
gridWidth,
gridHeight,
inX,
outY,
allPlacedRects,
rng,
request.levelTypeName,
act1Plan?.openGates,
)
stampOutdoorDividers(
canvas,
act,
request.levelId,
gridWidth,
gridHeight,
inX,
outY,
allPlacedRects,
rng,
request.levelTypeName,
)
// Act 2 Desert Topography: Canyons, oasis pools, sand caravan trails, tomb entrances (DRLGOUTDESR_InitAct2OutdoorLevel)
if (
(request.levelId >= 41 && request.levelId <= 46) ||
request.levelTypeName === 'Act 2 - Desert'
) {
initAct2OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats)
}
// Act 3 Kurast Urban Districts Topography: Avenues, canals, bridges (DRLGOUTPLACE_InitAct3OutdoorLevel)
if (
(request.levelId >= 79 && request.levelId <= 81) ||
request.levelTypeName === 'Act 3 - Kurast'
) {
initAct3OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats)
}
// Act 4 Mesa / Lava Topography: Lava chasms, rocky bridges, plateaus (DRLGOUTDOORS_InitAct4OutdoorLevel)
if (
(request.levelId >= 104 && request.levelId <= 106) ||
request.levelTypeName === 'Act 4 - Mesa' ||
request.levelTypeName === 'Act 4 - Lava'
) {
initAct4OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats)
}
// Act 5 Siege / Barricades Topography: Barricades, trenches, frozen lakes (DRLGOUTSIEGE_InitAct5OutdoorLevel)
if (
(request.levelId >= 111 && request.levelId <= 117) ||
request.levelTypeName === 'Act 5 - Barricade'
) {
initAct5OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats)
}
// Step 5: Outdoor Population Phase (Waypoints, Roadside Shrines/Chests, Landmark SuperUniques & Flavie)
const populationRng = new Rng(request.seed ^ 0x6c07)
spawnOutdoorWaypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.levelTypeName)
spawnOutdoorShrines(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.shrineSubstitutions, themeIndex, request.levelTypeName)
spawnLandmarkEntities(canvas, request.levelId, act1Plan, allPlacedRects, gridWidth, gridHeight, stats, landmarkDefs)
for (const piece of fillPieces) {
if (!placedSpecialPieces.has(piece.name)) {
stats.notes.push(`fill piece "${piece.name}" is not placed by this port`)
}
}
let finalGroundCells = 0
for (let y = 0; y < canvas.height; y += 1) {
const row = canvas.cells[y]
if (row === undefined) continue
for (let x = 0; x < canvas.width; x += 1) {
const c = row[x]
if (c !== undefined) {
if (!c.floors.some(f => !f.hidden && f.prop1 !== 0)) {
const floor = c.floors[0]
if (floor !== undefined) {
const tile = groundTiles && groundTiles.length > 1
? sampleGroundTile(groundTiles, x, y, { seed: request.seed })
: primaryGroundTile!
Object.assign(floor, { prop1: 2, style: tile.style, sequence: tile.sequence, hidden: false })
}
}
finalGroundCells += 1
}
}
}
stats.groundCells = finalGroundCells
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: primaryGroundTile,
groundTiles: groundTiles ?? undefined,
groundCells: stats.groundCells,
borderPiecesAvailable: borderPieces.length,
borderBlocks: stats.borderStamped,
borderUsage: stats.borderPieces,
roadCells: stats.roadCells,
roadSegments: stats.roadSegments,
anchors: stats.anchors,
entrances: stats.entrances,
specialPresets: stats.specialPresets,
substitutions: stats.substitutions,
substitutedClusters: substituted + placedSpecialPieces.size,
objects: canvas.objects.length,
waypointsSpawned: stats.waypointsSpawned ?? 0,
waypointTile: stats.waypointTile ?? null,
shrinesSpawned: stats.shrinesSpawned ?? 0,
interactivesSpawned: stats.interactivesSpawned ?? 0,
landmarkEntities: stats.landmarkEntities ?? [],
act2Topography: stats.act2Topography,
act3Topography: stats.act3Topography,
act4Topography: stats.act4Topography,
act5Topography: stats.act5Topography,
cells: canvas.width * canvas.height,
unresolved: stats.unresolved,
unimplementedPasses: UNIMPLEMENTED_PASSES,
notes: stats.notes,
...(stats.siegeTopography ? { siegeTopography: stats.siegeTopography } : {}),
},
}
}