diablo2-web/src/game/wilderness.ts

7801 lines
269 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,
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 { cell, actOfLevel, hasOutdoorWaypoint, OUTDOOR_WAYPOINT_LEVELS } from './acts.ts'
import type { D2Table } from './acts.ts'
export { actOfLevel, hasOutdoorWaypoint, OUTDOOR_WAYPOINT_LEVELS } from './acts.ts'
import { lookupObject, isWaypointDs1Object } from './object-lookup.ts'
import type { ObjectsTable } from './objects.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, subType?: number, levelName?: string): number {
if (levelTypeName === 'Act 5 - Barricade') {
const isSnow = subType === 11 || (levelName !== undefined && /snow|tundra/i.test(levelName))
return isSnow ? 0x10 : 0x01
}
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
/** Animated tile playback speed from `LvlPrest.txt` `Animate` column. */
readonly animSpeed?: number
}
/**
* 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[]
/** Optional loaded Levels.txt table to drive act resolution, waypoints, and object queries. */
readonly levelsTable?: D2Table
/**
* 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 seam constraints from predecessor/connected levels for 1:1 spatial alignment. */
readonly seamConstraints?: readonly SeamConstraint[]
/** Optional forced gate variant indices per block key (`${x},${y}`). */
readonly openGates?: ReadonlyMap<string, number>
/** Optional set of block keys (`${x},${y}`) claimed by transition/facade presets to skip during border placement. */
readonly claimedBorderBlocks?: ReadonlySet<string>
/** 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 | undefined
/** Exact block coordinate along the perimeter edge (0..gridWidth-1 or 0..gridHeight-1) */
readonly blockCoord?: number | undefined
/** Optional exact tile coordinate within the level */
readonly tileCoord?: { readonly x: number; readonly y: number } | undefined
}
/** Explicit spatial alignment constraint across a level seam. */
export interface SeamConstraint {
readonly fromLevelId?: number | undefined
readonly toLevelId?: number | undefined
readonly side: Side
readonly blockCoord: number
readonly tileCoord?: { readonly x: number; readonly y: number } | undefined
}
/** Placed gate opening along the level's perimeter. */
export interface GateOpening {
readonly side: Side
readonly toLevelId: number
readonly blockCoord: number
readonly tileCoord: { readonly x: number; readonly y: number }
}
/** The generated level. */
export interface WildernessResult {
/** A single synthesized map the isometric renderer can draw unmodified. */
readonly level: Ds1
/** What the generator did, for reporting and tests. */
readonly stats: Record<string, unknown> & {
readonly landmarkEntities?: LandmarkEntity[]
readonly notes?: string[]
readonly unresolved?: string[]
readonly gateOpenings?: readonly GateOpening[]
readonly groundTransitions?: GroundTransitionResult
readonly groundTile?: { readonly style: number; readonly sequence: number } | null | undefined
readonly groundTiles?: readonly WeightedGroundTile[] | undefined
readonly animSpeed?: number
}
/** Animated tile playback speed from `LvlPrest.txt` `Animate` column. */
readonly animSpeed?: number
}
/** 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: 1, 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 },
'Act 5 - Barricade Snow': { style: 6, 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: 25 },
{ style: 0, sequence: 2, weight: 20 },
{ style: 0, sequence: 50, weight: 8 },
{ style: 0, sequence: 0, weight: 6 },
],
'Act 3 - Jungle': [
{ style: 0, sequence: 0, weight: 12 },
{ style: 0, sequence: 1, weight: 8 },
{ style: 0, sequence: 3, weight: 2 },
{ style: 0, sequence: 4, weight: 2 },
],
'Act 3 - Kurast': [
{ style: 1, sequence: 0, weight: 16 },
{ style: 1, sequence: 11, weight: 2 },
{ style: 1, sequence: 12, weight: 2 },
{ style: 1, sequence: 13, weight: 2 },
],
'Act 4 - Mesa': [
{ style: 10, sequence: 2, weight: 10 },
{ style: 10, sequence: 0, weight: 9 },
{ style: 10, sequence: 1, weight: 9 },
{ style: 10, sequence: 12, weight: 9 },
],
'Act 4 - Lava': [
{ style: 20, sequence: 3, weight: 45 },
{ style: 20, sequence: 0, weight: 45 },
{ style: 20, sequence: 1, weight: 45 },
{ style: 20, sequence: 2, weight: 45 },
{ style: 20, sequence: 4, weight: 45 },
{ style: 20, sequence: 5, weight: 45 },
{ style: 20, sequence: 6, weight: 45 },
{ style: 20, sequence: 7, weight: 45 },
],
'Act 5 - Siege': [
{ style: 0, sequence: 0, weight: 10 },
{ style: 0, sequence: 1, weight: 4 },
],
'Act 5 - Barricade': [
{ style: 0, sequence: 0, weight: 10 },
{ style: 0, sequence: 1, weight: 4 },
],
'Act 5 - Barricade Snow': [
{ style: 6, sequence: 0, weight: 73 },
{ style: 6, sequence: 1, weight: 42 },
{ style: 6, sequence: 2, weight: 42 },
{ style: 6, sequence: 4, weight: 42 },
{ style: 6, sequence: 3, weight: 40 },
],
}
/* ------------------------------------------------------------------------- *
* 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`, `Rarity`, or piece counts).
* Defaults to 1 (or `rarityFrameIndex` / `rarity` / `Rarity`) if omitted or <= 0.
*/
readonly weight?: number
/**
* Direct DT1 `rarityFrameIndex`, if sourced from a DT1 tile.
*/
readonly rarityFrameIndex?: number
/**
* Direct DT1 `rarity` (alternative field name used in some loaders/representations).
*/
readonly rarity?: number
/**
* Direct DT1 `Rarity` (uppercase field name used in native structs).
*/
readonly Rarity?: number
/** Optional library slot index where the tile originated. */
readonly libraryIndex?: number
/** Optional tile index within the DT1 library. */
readonly tileIndex?: number
/** Optional tile type (0 for floor). */
readonly type?: 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; libraryIndex: number; tileIndex: number }
>()
for (let libIndex = 0; libIndex < libraries.length; libIndex += 1) {
if ((dt1Mask & (1 << libIndex)) === 0) continue
const lib = libraries[libIndex]
if (!lib) continue
for (let tIndex = 0; tIndex < lib.tiles.length; tIndex += 1) {
const tile = lib.tiles[tIndex]!
if (tile.type !== 0) continue
const rawRarity =
(tile as unknown as { rarity?: number; Rarity?: number }).rarity ??
(tile as unknown as { rarity?: number; Rarity?: number }).Rarity ??
tile.rarityFrameIndex
const rarity = Math.max(0, rawRarity | 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, libraryIndex: libIndex, tileIndex: tIndex })
} 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 (let tIndex = 0; tIndex < lib.tiles.length; tIndex += 1) {
const tile = lib.tiles[tIndex]!
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, libraryIndex: libIndex, tileIndex: tIndex })
}
}
}
}
const result: WeightedGroundTile[] = Array.from(counts.values()).map(e => ({
style: e.style,
sequence: e.sequence,
weight: e.weight,
rarityFrameIndex: e.weight,
rarity: e.weight,
Rarity: e.weight,
libraryIndex: e.libraryIndex,
tileIndex: e.tileIndex,
type: 0,
}))
result.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0))
return result
}
/**
* 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
}
/** Procedural terrain pass telemetry counters (Issue #62). */
export interface WildernessTelemetry {
readonly proceduralRoadCells: number
readonly proceduralDividerCells: number
readonly proceduralWaterCells: number
readonly proceduralWallCells: 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
subTheme?: number
proceduralRoadCells?: number
proceduralDividerCells?: number
proceduralWaterCells?: number
proceduralWallCells?: number
drlgVertices?: readonly DrlgVertex[] | undefined
roadPolylines?: readonly (readonly { readonly x: number; readonly y: number }[])[] | undefined
gateOpenings?: GateOpening[]
groundTransitions?: GroundTransitionResult | undefined
}
/**
* 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,
])
/* ------------------------------------------------------------------------- *
* 8-Neighbor Bitmask Auto-Tiling & Ground Transitions (Issue #70)
* ------------------------------------------------------------------------- */
export const NEIGHBOR_MASK_NE = 0x80
export const NEIGHBOR_MASK_E = 0x40
export const NEIGHBOR_MASK_SE = 0x20
export const NEIGHBOR_MASK_N = 0x10
export const NEIGHBOR_MASK_S = 0x08
export const NEIGHBOR_MASK_NW = 0x04
export const NEIGHBOR_MASK_W = 0x02
export const NEIGHBOR_MASK_SW = 0x01
export interface NeighborOffset {
readonly dx: number
readonly dy: number
readonly mask: number
readonly name: 'NE' | 'E' | 'SE' | 'N' | 'S' | 'NW' | 'W' | 'SW'
}
export const NEIGHBOR_OFFSETS: readonly NeighborOffset[] = [
{ dx: 1, dy: -1, mask: NEIGHBOR_MASK_NE, name: 'NE' },
{ dx: 1, dy: 0, mask: NEIGHBOR_MASK_E, name: 'E' },
{ dx: 1, dy: 1, mask: NEIGHBOR_MASK_SE, name: 'SE' },
{ dx: 0, dy: -1, mask: NEIGHBOR_MASK_N, name: 'N' },
{ dx: 0, dy: 1, mask: NEIGHBOR_MASK_S, name: 'S' },
{ dx: -1, dy: -1, mask: NEIGHBOR_MASK_NW, name: 'NW' },
{ dx: -1, dy: 0, mask: NEIGHBOR_MASK_W, name: 'W' },
{ dx: -1, dy: 1, mask: NEIGHBOR_MASK_SW, name: 'SW' },
] as const
/**
* Computes the 8-neighbor bitmask (0..255) for cell (x, y) on a canvas
* using the given predicate to test whether each neighbor matches.
*
* Web port custom algorithm: NOT a Blizzard original DRLG API. Blizzard's native engine
* handled room tile neighbor blending via compiled lookup routines and hardcoded tile transition rules.
*
* Coordinates outside the canvas bounds safely evaluate to false.
*/
export function computeNeighborMask(
canvas: Canvas,
x: number,
y: number,
predicate: (cell: Canvas['cells'][number][number] | undefined, nx: number, ny: number) => boolean,
): number {
let mask = 0
const width = canvas.width
const height = canvas.height
for (let i = 0; i < NEIGHBOR_OFFSETS.length; i += 1) {
const { dx, dy, mask: bit } = NEIGHBOR_OFFSETS[i]!
const nx = x + dx
const ny = y + dy
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
const cell = canvas.cells[ny]?.[nx]
if (predicate(cell, nx, ny)) {
mask |= bit
}
}
}
return mask
}
/**
* 256-entry 8-neighbor bitmask lookup table for water edge/shore transition tiles.
*
* Maps neighbor bitmask of water cells:
* - 0xFF (all 8 neighbors are water): interior deep water (sequence 24 in River.dt1 style 2).
* - Other bitmasks: boundary water tiles (sequences 0..23 in River.dt1 style 2).
*/
function createWaterEdgeTileLut(): Readonly<Uint8Array> {
const lut = new Uint8Array(256)
for (let mask = 0; mask < 256; mask += 1) {
if (mask === 0xFF) {
lut[mask] = 24
continue
}
const hasN = (mask & NEIGHBOR_MASK_N) !== 0
const hasS = (mask & NEIGHBOR_MASK_S) !== 0
const hasE = (mask & NEIGHBOR_MASK_E) !== 0
const hasW = (mask & NEIGHBOR_MASK_W) !== 0
const hasNE = (mask & NEIGHBOR_MASK_NE) !== 0
const hasNW = (mask & NEIGHBOR_MASK_NW) !== 0
const hasSE = (mask & NEIGHBOR_MASK_SE) !== 0
const hasSW = (mask & NEIGHBOR_MASK_SW) !== 0
// Inner corners (concave diagonal inlets where all 4 cardinals are water, but a diagonal corner is land)
if (hasN && hasS && hasE && hasW) {
if (!hasNW) { lut[mask] = 16; continue }
if (!hasNE) { lut[mask] = 17; continue }
if (!hasSW) { lut[mask] = 18; continue }
if (!hasSE) { lut[mask] = 19; continue }
lut[mask] = 24
continue
}
// Outer corners (convex corners)
if (hasS && hasE && !hasN && !hasW) { lut[mask] = 1; continue } // NW outer corner
if (hasS && hasW && !hasN && !hasE) { lut[mask] = 5; continue } // NE outer corner
if (hasN && hasE && !hasS && !hasW) { lut[mask] = 9; continue } // SW outer corner
if (hasN && hasW && !hasS && !hasE) { lut[mask] = 13; continue } // SE outer corner
// Cardinal straight shores
if (hasE && !hasW) { lut[mask] = 0; continue } // West shore (water to East)
if (hasW && !hasE) { lut[mask] = 4; continue } // East shore (water to West)
if (hasS && !hasN) { lut[mask] = 8; continue } // North shore (water to South)
if (hasN && !hasS) { lut[mask] = 12; continue } // South shore (water to North)
// Channels / peninsulas
if (hasN && hasS) { lut[mask] = 20; continue } // Vertical channel
if (hasE && hasW) { lut[mask] = 21; continue } // Horizontal channel
// Caps / isolated
if (hasS) { lut[mask] = 22; continue }
if (hasN) { lut[mask] = 23; continue }
if (hasE) { lut[mask] = 2; continue }
if (hasW) { lut[mask] = 6; continue }
// Fallback
lut[mask] = 0
}
return lut
}
export const WATER_EDGE_TILE_LUT: Readonly<Uint8Array> = createWaterEdgeTileLut()
/** Query parameters for DRLGROOMTILE_GetTileCache. */
export interface TileCacheQuery {
/** Tile type (0 = floor, 1 = left wall, etc.). Defaults to 0 (floor). */
readonly type?: number | undefined
/** Filter by specific style if provided. */
readonly style?: number | undefined
/** Filter by specific sequence if provided. */
readonly sequence?: number | undefined
/** Seed or PRNG roll in [0, 1) or integer seed for deterministic weighted selection. */
readonly seed: number | Rng
/** Optional cell coordinates (x, y) to derive a deterministic hash if seed is provided. */
readonly x?: number | undefined
readonly y?: number | undefined
}
/** Result returned by DRLGROOMTILE_GetTileCache. */
export interface TileCacheResult {
readonly style: number
readonly sequence: number
readonly type: number
readonly libraryIndex: number
readonly tileIndex: number
readonly weight: number
readonly rarityFrameIndex: number
}
/**
* Blizzard DRLGROOMTILE_GetTileCache reimplementation.
*
* Scans active DT1 libraries according to dwDt1Mask and retrieves candidate
* tiles matching the query, performing a weighted selection proportional to tile
* rarity (`rarityFrameIndex` / `rarity` / `Rarity`).
*/
export function DRLGROOMTILE_GetTileCache(
libraries: readonly Dt1[],
dt1Mask: number,
query: TileCacheQuery,
): TileCacheResult | null {
const targetType = query.type ?? 0
const targetStyle = query.style
const targetSequence = query.sequence
const candidates: TileCacheResult[] = []
let totalWeight = 0
for (let libIndex = 0; libIndex < libraries.length; libIndex += 1) {
if ((dt1Mask & (1 << libIndex)) === 0) continue
const lib = libraries[libIndex]
if (!lib) continue
for (let tIndex = 0; tIndex < lib.tiles.length; tIndex += 1) {
const tile = lib.tiles[tIndex]!
if (tile.type !== targetType) continue
if (targetStyle !== undefined && tile.style !== targetStyle) continue
if (targetSequence !== undefined && tile.sequence !== targetSequence) continue
const rawRarity =
(tile as unknown as { rarity?: number; Rarity?: number }).rarity ??
(tile as unknown as { rarity?: number; Rarity?: number }).Rarity ??
tile.rarityFrameIndex
const weight = Math.max(1, rawRarity | 0)
candidates.push({
style: tile.style,
sequence: tile.sequence,
type: tile.type,
libraryIndex: libIndex,
tileIndex: tIndex,
weight,
rarityFrameIndex: weight,
})
totalWeight += weight
}
}
if (candidates.length === 0) return null
// Determine roll in [0, 1)
let roll: number
if (typeof query.seed === 'number') {
if (query.seed >= 0 && query.seed < 1 && !Number.isInteger(query.seed)) {
roll = query.seed
} else if (query.x !== undefined && query.y !== undefined) {
roll = cellHash(query.x, query.y, query.seed)
} else {
roll = cellHash(candidates.length, totalWeight, query.seed)
}
} else {
roll = query.seed.next()
}
let target = roll * totalWeight
for (let i = 0; i < candidates.length; i += 1) {
const candidate = candidates[i]!
if (target < candidate.weight) {
return candidate
}
target -= candidate.weight
}
return candidates[candidates.length - 1] ?? null
}
export interface GroundTransitionOptions {
readonly libraries?: readonly Dt1[] | undefined
readonly dt1Mask?: number | undefined
readonly levelTypeName?: string | undefined
readonly levelId?: number | undefined
readonly rng?: Rng | undefined
readonly placedRects?: readonly PlacedPresetRect[] | undefined
}
export interface GroundTransitionResult {
readonly transitionCells: number
readonly roadTransitions: number
readonly waterTransitions: number
}
function isRoadCell(floor: Ds1Floor | undefined): boolean {
if (!floor || floor.hidden || floor.prop1 === 0) return false
return (
floor.prop1 === 194 ||
(floor.style === 0 && floor.sequence >= 1 && floor.sequence <= 46) ||
(floor.style === 1 && floor.prop1 === 194)
)
}
function isWaterCell(floor: Ds1Floor | undefined): boolean {
if (!floor || floor.hidden || floor.prop1 === 0) return false
return (
floor.style === 2 ||
floor.style === 3 ||
floor.style === 16 ||
floor.style === 29 ||
floor.style === 38 ||
floor.style === 39 ||
floor.style === 40 ||
(floor.style === 20 && floor.sequence === 3)
)
}
/**
* Analyzes adjacency between contrasting floor styles (road vs grass/ground, water vs shore)
* across the canvas, computes 8-neighbor bitmasks, and applies transition edge tiles to smooth
* boundaries.
*
* Web port custom algorithm: NOT a Blizzard original DRLG API. This two-phase transition
* smoother was engineered specifically for the TypeScript canvas representation to eliminate
* visual tile seams.
*
* Employs two-phase snapshot execution (collect updates first, then apply) to avoid order-dependent
* mutation cascade. Guarantees 0 missing tiles when DT1 libraries are supplied by strictly verifying
* tile availability against active DT1 libraries.
*/
export function applyGroundTransitions(
canvas: Canvas,
options?: GroundTransitionOptions,
): GroundTransitionResult {
// Ground transition auto-tiling LUTs (DIRT_PATH_TILE_LUT, WATER_EDGE_TILE_LUT) are specific
// to Act 1 Wilderness dirt roads and river water. Skip for other level types and non-Act-1 levels.
if (options?.levelTypeName !== undefined && options.levelTypeName !== 'Act 1 - Wilderness') {
return { transitionCells: 0, roadTransitions: 0, waterTransitions: 0 }
}
if (options?.levelId !== undefined && (options.levelId < 2 || options.levelId > 7)) {
return { transitionCells: 0, roadTransitions: 0, waterTransitions: 0 }
}
// Require DT1 libraries to confirm tile availability. If not provided or empty, do not apply transitions.
if (!options?.libraries || options.libraries.length === 0) {
return { transitionCells: 0, roadTransitions: 0, waterTransitions: 0 }
}
// Build set of available floor tiles from active DT1 libraries
const availableTiles = new Set<string>()
const mask = options.dt1Mask ?? 0xFFFFFFFF
for (let libIndex = 0; libIndex < options.libraries.length; libIndex += 1) {
if ((mask & (1 << libIndex)) === 0) continue
const lib = options.libraries[libIndex]
if (!lib) continue
for (const tile of lib.tiles) {
if (tile.type === 0) {
availableTiles.add(`${String(tile.style)}:${String(tile.sequence)}`)
}
}
}
const isTileAvailable = (style: number, sequence: number): boolean => {
return availableTiles.has(`${String(style)}:${String(sequence)}`)
}
const isProtected = (x: number, y: number): boolean => {
if (!options?.placedRects || options.placedRects.length === 0) return false
for (const r of options.placedRects) {
// River and Bridge presets are intended to have water/road blended
if (/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
}
interface PendingUpdate {
readonly x: number
readonly y: number
readonly style: number
readonly sequence: number
readonly prop1?: number
readonly isRoad?: boolean
readonly isWater?: boolean
}
const pendingUpdates: PendingUpdate[] = []
const width = canvas.width
const height = canvas.height
// Phase 1: Snapshot scan
for (let y = 0; y < height; y += 1) {
const row = canvas.cells[y]
if (!row) continue
for (let x = 0; x < width; x += 1) {
if (isProtected(x, y)) continue
const cell = row[x]
const floor = cell?.floors[0]
if (!floor || floor.hidden || floor.prop1 === 0) continue
if (isRoadCell(floor)) {
// Limit road transition (DIRT_PATH_TILE_LUT) to environments that actually use style 0 dirt roads
if (floor.style === 0 && (options.levelTypeName === undefined || options.levelTypeName === 'Act 1 - Wilderness')) {
const roadMask = computeNeighborMask(canvas, x, y, (nCell) => {
return isRoadCell(nCell?.floors[0])
})
if (roadMask === 0xFF) {
// Interior road cell
if (floor.sequence !== 1) {
if (isTileAvailable(0, 1)) {
pendingUpdates.push({ x, y, style: 0, sequence: 1, prop1: 2, isRoad: true })
}
}
} else {
// Boundary road cell
const targetSeq = DIRT_PATH_TILE_LUT[roadMask] ?? 0
if (targetSeq !== 0 && targetSeq !== floor.sequence) {
if (isTileAvailable(0, targetSeq)) {
pendingUpdates.push({ x, y, style: 0, sequence: targetSeq, prop1: 2, isRoad: true })
}
}
}
}
} else if (isWaterCell(floor)) {
// Limit water transition (WATER_EDGE_TILE_LUT) to environments that actually use style 2 water
if (floor.style === 2 && (options.levelTypeName === undefined || options.levelTypeName === 'Act 1 - Wilderness')) {
const waterMask = computeNeighborMask(canvas, x, y, (nCell) => {
return isWaterCell(nCell?.floors[0])
})
if (waterMask === 0xFF) {
// Interior deep water
if (floor.sequence !== 24) {
if (isTileAvailable(2, 24)) {
pendingUpdates.push({ x, y, style: 2, sequence: 24, prop1: 2, isWater: true })
}
}
} else {
// Boundary water cell
const targetSeq = WATER_EDGE_TILE_LUT[waterMask] ?? 0
if (targetSeq !== floor.sequence) {
if (isTileAvailable(2, targetSeq)) {
pendingUpdates.push({ x, y, style: 2, sequence: targetSeq, prop1: 2, isWater: true })
}
}
}
}
}
}
}
// Phase 2: Apply updates
let roadTransitions = 0
let waterTransitions = 0
for (const update of pendingUpdates) {
// Check if the target tile actually exists in the active DT1 libraries before applying
if (!isTileAvailable(update.style, update.sequence)) continue
const cell = canvas.cells[update.y]?.[update.x]
if (!cell) continue
const floor = cell.floors[0]
if (!floor) continue
if (floor.style === update.style && floor.sequence === update.sequence) continue
Object.assign(floor, {
style: update.style,
sequence: update.sequence,
prop1: update.prop1 ?? floor.prop1,
hidden: false,
})
if (update.isRoad) roadTransitions += 1
if (update.isWater) waterTransitions += 1
}
return {
transitionCells: roadTransitions + waterTransitions,
roadTransitions,
waterTransitions,
}
}
/**
* 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[] = [
'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`.
*
* Corresponds to Blizzard DRLG subsystem algorithms:
* - Act 2 Desert: `DRLGOUTDESR_PlaceBorders` (D2Common.0x6FD7D9B0) placing numeric borders 1..8.
* - Act 3 Kurast: `DRLGOUTJUNG_BuildLowerKurast` (D2Common.0x6FD7FE50),
* `DRLGOUTJUNG_BuildKurastBazaar` (D2Common.0x6FD7FFA0), and
* `DRLGOUTJUNG_BuildUpperKurast` (D2Common.0x6FD800E0) placing cardinal compass borders N..SW.
*
* 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 (9=SW, 10=NW, 11=NE, 12=SE)
*
* Act 2:
* - Desert Border 1..12 (1..4 straight edges, 5..12 corners)
* - Desert Cliff Left Wall/Path/Ends/King Tomb -> West edge
* - Desert Cliff Right Wall/Path/Ends/King Tomb -> East edge
* - Desert Cliff Top / Top King Tomb -> North edge
*
* Act 3:
* - Slums/Burbs/Metro Border N, S, E, W, NE, NW, SE, SW
*
* Act 4:
* - Mesa Border 1..12 (1..4 straight edges, 5..12 corners)
*
* Act 5:
* - Barricade Cliff Border 1..12 & Snow variants (isCliff: true)
* - Barricade Ravine Border 1..12 & Snow variants (isCliff: false)
* - Barricade Ravine-Cliff Border 5 (SW corner transition)
* - Barricade Cliff Ravine Border 7 (NE corner transition)
* - Snow Border 1..12
*
* Cave entrances ("clfcave", "clfcave2", "Cave Right", "Cave Left") 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(/\b(?:border|edge|brdr|cliff)\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 = South edge
// 2 = West edge
// 3 = North edge
// 4 = East edge
// 5, 9 = SW corner
// 6, 10 = NW corner (including 6A, 6B, 6C)
// 7, 11 = NE corner
// 8, 12 = SE corner
const numMatch = name.match(/(?:border|edge|brdr_?snow|brdr|cliff|ravine)\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 || num === 9) return { orientation: 'corner-sw', isCliff }
if (num === 6 || num === 10) return { orientation: 'corner-nw', isCliff }
if (num === 7 || num === 11) return { orientation: 'corner-ne', isCliff }
if (num === 8 || num === 12) return { orientation: 'corner-se', isCliff }
}
// Directional names (Act 2 Desert Cliff Left/Right/Top, entrance/exit btm/top, cardinal words)
if (/\b(?:north[\s-]?west|nw)\b/i.test(name)) return { orientation: 'corner-nw', isCliff }
if (/\b(?:north[\s-]?east|ne)\b/i.test(name)) return { orientation: 'corner-ne', isCliff }
if (/\b(?:south[\s-]?west|sw)\b/i.test(name)) return { orientation: 'corner-sw', isCliff }
if (/\b(?:south[\s-]?east|se)\b/i.test(name)) return { orientation: 'corner-se', isCliff }
if (/\b(?:north|top)\b/i.test(name)) return { orientation: 'north', isCliff }
if (/\b(?:south|bottom|btm)\b/i.test(name)) return { orientation: 'south', isCliff }
if (/\b(?:east|right|rght)\b/i.test(name)) return { orientation: 'east', isCliff }
if (/\b(?:west|left)\b/i.test(name)) return { orientation: 'west', isCliff }
return { orientation: null, isCliff }
}
/**
* Determine the perimeter orientation for a block cell on a `gridWidth x gridHeight` grid.
*/
export 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.
*/
export 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 === true) {
const cliffMatches = matches.filter(c => c.isCliff)
if (cliffMatches.length > 0) {
return cliffMatches[rng.int(0, cliffMatches.length - 1)]!.piece
}
} else if (preferCliff === false) {
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,
gates?: readonly PlannedGate[],
seamConstraints?: readonly SeamConstraint[],
): 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
}
const minX = gridWidth > 2 ? 1 : 0
const maxX = gridWidth > 2 ? gridWidth - 2 : gridWidth - 1
const minY = gridHeight > 2 ? 1 : 0
const maxY = gridHeight > 2 ? gridHeight - 2 : gridHeight - 1
if (gates !== undefined && gates.length > 0) {
for (const gate of gates) {
let coord = gate.blockCoord
if (coord === undefined && seamConstraints !== undefined) {
const match = seamConstraints.find(
c =>
c.side === gate.side &&
(c.toLevelId === gate.toLevelId ||
c.fromLevelId === gate.toLevelId ||
c.toLevelId === levelId ||
c.fromLevelId === levelId),
) ?? seamConstraints.find(c => c.side === gate.side)
if (match !== undefined) {
coord = match.blockCoord
}
}
if (coord === undefined && gate.tileCoord !== undefined) {
coord = (gate.side === 'north' || gate.side === 'south')
? Math.floor(gate.tileCoord.x / TILES_PER_BLOCK)
: Math.floor(gate.tileCoord.y / TILES_PER_BLOCK)
}
switch (gate.side) {
case 'north': {
const bx = coord !== undefined
? Math.max(minX, Math.min(maxX, coord))
: Math.floor(gridWidth / 2)
openings.add(key(bx, 0))
break
}
case 'south': {
const bx = coord !== undefined
? Math.max(minX, Math.min(maxX, coord))
: Math.floor(gridWidth / 2)
openings.add(key(bx, gridHeight - 1))
break
}
case 'west': {
const by = coord !== undefined
? Math.max(minY, Math.min(maxY, coord))
: Math.floor(gridHeight / 2)
openings.add(key(0, by))
break
}
case 'east': {
const by = coord !== undefined
? Math.max(minY, Math.min(maxY, coord))
: Math.floor(gridHeight / 2)
openings.add(key(gridWidth - 1, by))
break
}
}
}
return openings
}
if (seamConstraints !== undefined && seamConstraints.length > 0) {
for (const c of seamConstraints) {
switch (c.side) {
case 'north': {
const bx = Math.max(minX, Math.min(maxX, c.blockCoord))
openings.add(key(bx, 0))
break
}
case 'south': {
const bx = Math.max(minX, Math.min(maxX, c.blockCoord))
openings.add(key(bx, gridHeight - 1))
break
}
case 'west': {
const by = Math.max(minY, Math.min(maxY, c.blockCoord))
openings.add(key(0, by))
break
}
case 'east': {
const by = Math.max(minY, Math.min(maxY, c.blockCoord))
openings.add(key(gridWidth - 1, by))
break
}
}
}
return openings
}
// Outdoor wilderness levels across Acts 1..5:
// Default entrance on South edge, exit on East edge so borders are never sealed shut
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
}
/**
* 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, Facades).
* @param gates - optional planned gates from the world graph.
* @param seamConstraints - optional seam constraints for spatial alignment.
* @returns the number of pieces stamped.
*/
export function DRLGOUTPLACE_PlaceAct1245OutdoorBorders(
canvas: Canvas,
pieces: readonly WildernessPiece[],
gridWidth: number,
gridHeight: number,
rng: Rng,
stats: WildernessStats,
levelId?: number,
openGates?: ReadonlyMap<string, number>,
claimedBorderBlocks?: ReadonlySet<string>,
gates?: readonly PlannedGate[],
seamConstraints?: readonly SeamConstraint[],
): number {
if (stats.proceduralWallCells === undefined) {
stats.proceduralWallCells = 0
}
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 in clockwise ring walk, then corners last,
// matching D2MOO DRLGOUTPLACE_PlaceAct1245OutdoorBorders where corner presets
// overwrite straight edge presets at vertices to form seamless joins.
const straightCells: { x: number; y: number }[] = []
// North edge (moving East)
for (let x = 1; x < gridWidth - 1; x += 1) straightCells.push({ x, y: 0 })
// East edge (moving South)
for (let y = 1; y < gridHeight - 1; y += 1) straightCells.push({ x: gridWidth - 1, y })
// South edge (moving West)
for (let x = gridWidth - 2; x >= 1; x -= 1) straightCells.push({ x, y: gridHeight - 1 })
// West edge (moving North)
for (let y = gridHeight - 2; y >= 1; y -= 1) straightCells.push({ x: 0, y })
// Corner join pieces stamped last: NW, NE, SE, SW
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, gates, seamConstraints) : new Set<string>()
if (openGates !== undefined) {
for (const gateKey of openGates.keys()) {
openings.add(gateKey)
}
}
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)
const isOpen = !isCorner && openings.has(key)
// North, West, NW, NE, SW prefer cliff pieces if available (e.g. Act 1 wilderness mountain boundaries),
// but openings must not prefer cliffs to ensure open gate variants are selected.
const preferCliff =
!isOpen &&
(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 {
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
}
export const layBorder = DRLGOUTPLACE_PlaceAct1245OutdoorBorders
/* ------------------------------------------------------------------------- *
* 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.
*/
export 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'
}
export interface SpecialPresetPlacement {
readonly name: string
readonly ds1: Ds1
readonly bx: number
readonly by: number
readonly blocksX: number
readonly blocksY: number
}
export interface Act1OutdoorPlan {
readonly openGates: Map<string, number>
readonly claimedBorderBlocks: Set<string>
readonly claimedBlocks: boolean[][]
readonly roadObstacleBlocks: boolean[][]
readonly specialPresets: SpecialPresetPlacement[]
readonly anchors: RoadAnchor[]
readonly gateOpenings?: GateOpening[]
}
/** 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.
*/
export function planAct1OutdoorLevel(
request: WildernessRequest,
gridWidth: number,
gridHeight: number,
rng: Rng,
stats?: WildernessStats,
): 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 gateOpenings: GateOpening[] = []
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,
forcedBlockCoord?: number,
forcedTileCoord?: { readonly x: number; readonly y: number },
): void => {
const side: Side = edge === 'top' ? 'north' : edge === 'bottom' ? 'south' : edge === 'left' ? 'west' : 'east'
let blockCoord = forcedBlockCoord
let tileCoord = forcedTileCoord
if (blockCoord === undefined && request.seamConstraints !== undefined) {
const match = request.seamConstraints.find(c => {
if (c.side !== side) return false
if (toLevelId !== undefined && toLevelId !== -1) {
return c.toLevelId === toLevelId || c.fromLevelId === toLevelId
}
return c.toLevelId === request.levelId || c.fromLevelId === request.levelId
}) ?? request.seamConstraints.find(c => c.side === side)
if (match !== undefined) {
blockCoord = match.blockCoord
tileCoord = tileCoord ?? match.tileCoord
}
}
if (blockCoord === undefined && tileCoord !== undefined) {
blockCoord = (side === 'north' || side === 'south')
? Math.floor(tileCoord.x / TILES_PER_BLOCK)
: Math.floor(tileCoord.y / TILES_PER_BLOCK)
}
if (edge === 'left') {
const minY = gridHeight > 2 ? 1 : 0
const maxY = gridHeight > 2 ? gridHeight - 2 : gridHeight - 1
let by: number
if (blockCoord !== undefined) {
by = Math.max(minY, Math.min(maxY, blockCoord))
if (claimedBorderBlocks.has(`0,${String(by)}`)) {
for (let tries = 0; tries < gridHeight; tries += 1) {
if (!claimedBorderBlocks.has(`0,${String(by)}`) && !openGates.has(`0,${String(by)}`)) break
by = minY + ((by - minY + 1) % (maxY - minY + 1))
}
}
} else {
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
const anchorCell = tileCoord ?? { x: 3, y: 8 * by + 3 }
const interiorCell = tileCoord ? { x: tileCoord.x + 8, y: tileCoord.y } : { x: 11, y: 8 * by + 3 }
anchors.push({
label,
anchorCell,
interiorCell,
toLevelId,
side: 'west',
kind: 'gate',
})
gateOpenings.push({
side: 'west',
toLevelId: toLevelId ?? -1,
blockCoord: by,
tileCoord: anchorCell,
})
} else if (edge === 'right') {
const minY = gridHeight > 2 ? 1 : 0
const maxY = gridHeight > 2 ? gridHeight - 2 : gridHeight - 1
let by: number
if (blockCoord !== undefined) {
by = Math.max(minY, Math.min(maxY, blockCoord))
if (claimedBorderBlocks.has(`${String(gridWidth - 1)},${String(by)}`)) {
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 = minY + ((by - minY + 1) % (maxY - minY + 1))
}
}
} else {
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
const anchorCell = tileCoord ?? { x: 8 * (gridWidth - 1) + 3, y: 8 * by + 3 }
const interiorCell = tileCoord ? { x: tileCoord.x - 8, y: tileCoord.y } : { x: 8 * (gridWidth - 2) + 3, y: 8 * by + 3 }
anchors.push({
label,
anchorCell,
interiorCell,
toLevelId,
side: 'east',
kind: 'gate',
})
gateOpenings.push({
side: 'east',
toLevelId: toLevelId ?? -1,
blockCoord: by,
tileCoord: anchorCell,
})
} else if (edge === 'top') {
const minX = gridWidth > 2 ? 1 : 0
const maxX = gridWidth > 2 ? gridWidth - 2 : gridWidth - 1
let bx: number
if (blockCoord !== undefined) {
bx = Math.max(minX, Math.min(maxX, blockCoord))
if (claimedBorderBlocks.has(`${String(bx)},0`)) {
for (let tries = 0; tries < gridWidth; tries += 1) {
if (!claimedBorderBlocks.has(`${String(bx)},0`) && !openGates.has(`${String(bx)},0`)) break
bx = minX + ((bx - minX + 1) % (maxX - minX + 1))
}
}
} else {
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
const anchorCell = tileCoord ?? { x: 8 * bx + 3, y: 3 }
const interiorCell = tileCoord ? { x: tileCoord.x, y: tileCoord.y + 8 } : { x: 8 * bx + 3, y: 11 }
anchors.push({
label,
anchorCell,
interiorCell,
toLevelId,
side: 'north',
kind: 'gate',
})
gateOpenings.push({
side: 'north',
toLevelId: toLevelId ?? -1,
blockCoord: bx,
tileCoord: anchorCell,
})
} else {
const minX = gridWidth > 2 ? 1 : 0
const maxX = gridWidth > 2 ? gridWidth - 2 : gridWidth - 1
let bx: number
if (blockCoord !== undefined) {
bx = Math.max(minX, Math.min(maxX, blockCoord))
if (claimedBorderBlocks.has(`${String(bx)},${String(gridHeight - 1)}`)) {
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 = minX + ((bx - minX + 1) % (maxX - minX + 1))
}
}
} else {
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
const anchorCell = tileCoord ?? { x: 8 * bx + 3, y: 8 * (gridHeight - 1) + 3 }
const interiorCell = tileCoord ? { x: tileCoord.x, y: tileCoord.y - 8 } : { x: 8 * bx + 3, y: 8 * (gridHeight - 2) + 3 }
anchors.push({
label,
anchorCell,
interiorCell,
toLevelId,
side: 'south',
kind: 'gate',
})
gateOpenings.push({
side: 'south',
toLevelId: toLevelId ?? -1,
blockCoord: bx,
tileCoord: anchorCell,
})
}
}
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) {
stats?.unresolved.push(`preset piece not found: ${prefix}`)
stats?.notes.push(`failed to place preset: ${label} (missing piece ${prefix})`)
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) {
stats?.unresolved.push(`no free interior box for preset: ${label} (${prefix})`)
stats?.notes.push(`failed to place preset: ${label} (no free interior box ${String(blocksX)}x${String(blocksY)})`)
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.
*
* Note on Blizzard Act 2 transition:
* Blizzard original DRLGOUTDESR_PlaceDesertTransitionToTown (D2Common.0x6FD7D430 inlined)
* handles the Act 2 transition between Rocky Waste (41) and Lut Gholein (40) via
* LVLPREST_ACT2_DESERT_TRANSITION_N or LVLPREST_ACT2_DESERT_TRANSITION_W. In this web port,
* Act 2 transitions are resolved via seamless border connections rather than dedicated transition presets.
*/
const placeTownTransition = (side: Side, toLevelId?: number, forcedBlockCoord?: 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 = forcedBlockCoord !== undefined
? Math.max(1, Math.min(gridHeight - 6, forcedBlockCoord))
: 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 })
const anchorCell = { x: 7, y: 8 * by + 19 }
anchors.push({
label: 'Town E Transition',
anchorCell,
interiorCell: { x: 11, y: 8 * by + 19 },
toLevelId,
side: 'west',
kind: 'gate',
})
gateOpenings.push({
side: 'west',
toLevelId: toLevelId ?? 1,
blockCoord: by,
tileCoord: anchorCell,
})
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 = forcedBlockCoord !== undefined
? Math.max(1, Math.min(gridWidth - 8, forcedBlockCoord))
: 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 })
const anchorCell = { x: 8 * bx + 29, y: 7 }
anchors.push({
label: 'Town S Transition',
anchorCell,
interiorCell: { x: 8 * bx + 29, y: 11 },
toLevelId,
side: 'north',
kind: 'gate',
})
gateOpenings.push({
side: 'north',
toLevelId: toLevelId ?? 1,
blockCoord: bx,
tileCoord: anchorCell,
})
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) {
stats?.unresolved.push('failed to place Graveyard preset')
stats?.notes.push('failed to place Graveyard preset (missing piece or grid too small)')
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
const anchorCell = { x: 2 * 8 + 3, y: 8 * (gridHeight - 1) + 3 }
anchors.push({
label: 'Burial Grounds Gate',
anchorCell,
interiorCell: { x: 2 * 8 + 3, y: 8 * (gridHeight - 2) + 3 },
toLevelId: 3,
side: 'south',
kind: 'gate',
})
gateOpenings.push({
side: 'south',
toLevelId: 3,
blockCoord: 2,
tileCoord: anchorCell,
})
}
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) {
if (!addInteriorPresetWithRoad('Act 1 - DOE Entrance', 'Den of Evil Entrance', undefined, 8)) {
stats?.notes.push('Den of Evil Entrance preset failed to place')
}
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 {
stats?.notes.push('Bivouac preset failed to place (no free box)')
}
}
} else if (levelId === 3) {
if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Cave Level 1 Entrance', undefined, 9)) {
stats?.notes.push('Cave Level 1 Entrance preset failed to place')
}
if (!addInteriorPresetWithRoad('Act 1 - Bivouac', 'Bivouac Camp', 'south')) {
stats?.notes.push('Bivouac Camp preset failed to place')
}
} else if (levelId === 4) {
if (!addInteriorPresetWithRoad('Act 1 - Cairn Stones', 'Cairn Stones', 'south', 38)) {
stats?.notes.push('Cairn Stones preset failed to place')
}
if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Entrance', undefined, 10)) {
stats?.notes.push('Underground Passage Entrance preset failed to place')
}
} else if (levelId === 5) {
if (!addInteriorPresetWithRoad('Act 1 - Inifus', 'Tree of Inifuss', 'south')) {
stats?.notes.push('Tree of Inifuss preset failed to place')
}
if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Exit', undefined, 10)) {
stats?.notes.push('Underground Passage Exit preset failed to place')
}
} 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.
if (!addInteriorPresetWithRoad('Act 1 - Tower 1', 'Forgotten Tower', 'south', 20)) {
stats?.notes.push('Forgotten Tower preset failed to place')
}
if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Hole Level 1 Entrance', undefined, 11)) {
stats?.notes.push('Hole Level 1 Entrance preset failed to place')
}
} else if (levelId === 7) {
if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Pit Level 1 Entrance', undefined, 12)) {
stats?.notes.push('Pit Level 1 Entrance preset failed to place')
}
} else if (levelId === 39) {
if (!addInteriorPresetWithRoad('Act 1 - Corral Fill', 'Cow Corral', 'south')) {
stats?.notes.push('Cow Corral preset failed to place')
}
if (!addInteriorPresetWithRoad('Act 1 - Bivouac', 'Cow Bivouac', 'east')) {
stats?.notes.push('Cow Bivouac preset failed to place')
}
}
}
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, townSeam.blockCoord)
for (const gate of plannedGates) {
if (placedTown && gate === townSeam) continue
let blockCoord = gate.blockCoord
let tileCoord = gate.tileCoord
if (blockCoord === undefined && request.seamConstraints !== undefined) {
const match = request.seamConstraints.find(c => {
if (c.side !== gate.side) return false
return c.toLevelId === gate.toLevelId || c.fromLevelId === gate.toLevelId
}) ?? request.seamConstraints.find(c => c.side === gate.side)
if (match !== undefined) {
blockCoord = match.blockCoord
tileCoord = tileCoord ?? match.tileCoord
}
}
addBorderGate(SIDE_TO_EDGE[gate.side], gate.variant ?? 3, gate.label, gate.toLevelId, blockCoord, tileCoord)
}
if (id === 17) {
if (!placeGraveyardPreset(false)) {
stats?.unresolved.push('Burial Grounds Graveyard preset failed to place')
stats?.notes.push('Burial Grounds Graveyard preset failed to place')
}
}
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
const coldPlainsConstraint = request.seamConstraints?.find(c => c.toLevelId === 3 || c.fromLevelId === 3)
let exitEdge: 'right' | 'bottom'
if (coldPlainsConstraint?.side === 'east') {
exitEdge = 'right'
} else if (coldPlainsConstraint?.side === 'south') {
exitEdge = 'bottom'
} else {
exitEdge = rng.int(0, 1) === 0 ? 'right' : 'bottom'
}
if (useWest && placeTownTransition('west', 1)) {
addBorderGate(exitEdge, 3, 'Cold Plains Exit', 3, coldPlainsConstraint?.blockCoord, coldPlainsConstraint?.tileCoord)
} else if (placeTownTransition('north', 1)) {
addBorderGate(exitEdge, 3, 'Cold Plains Exit', 3, coldPlainsConstraint?.blockCoord, coldPlainsConstraint?.tileCoord)
} else {
addBorderGate('left', 3, 'Town Entry', 1)
addBorderGate(exitEdge, 3, 'Cold Plains Exit', 3, coldPlainsConstraint?.blockCoord, coldPlainsConstraint?.tileCoord)
}
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)) {
stats?.notes.push('Burial Grounds Graveyard preset failed, falling back to border gate')
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')
}
if (stats !== undefined) {
stats.gateOpenings = gateOpenings
}
return {
openGates,
claimedBorderBlocks,
claimedBlocks,
roadObstacleBlocks,
specialPresets,
anchors,
gateOpenings,
}
}
export interface DrlgVertex {
x: number
y: number
type: 'gate' | 'preset' | 'hub' | 'waypoint' | 'inflection' | 'corner'
label?: string | undefined
id?: number | undefined
direction?: number | undefined
dwFlags?: number | undefined
next?: DrlgVertex | null | undefined
prev?: DrlgVertex | null | undefined
}
export interface DrlgRoadNetwork {
readonly vertices: DrlgVertex[]
readonly polylines: { x: number; y: number }[][]
}
export type DrlgVertexResult = DrlgVertex[] & {
readonly vertices: DrlgVertex[]
readonly polylines: { x: number; y: number }[][]
readonly lines: { x: number; y: number }[][]
readonly cyclicVertices?: DrlgVertex[] | undefined
readonly head?: DrlgVertex | undefined
}
/**
* Catmull-Rom spline interpolation between 4 control points at parameter t [0, 1].
*/
export function catmullRomPoint(
p0: { readonly x: number; readonly y: number },
p1: { readonly x: number; readonly y: number },
p2: { readonly x: number; readonly y: number },
p3: { readonly x: number; readonly y: number },
t: number,
): { x: number; y: number } {
const t2 = t * t
const t3 = t2 * t
const x = 0.5 * (
(2 * p1.x) +
(-p0.x + p2.x) * t +
(2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 +
(-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3
)
const y = 0.5 * (
(2 * p1.y) +
(-p0.y + p2.y) * t +
(2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 +
(-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3
)
return { x, y }
}
/**
* Sample a Catmull-Rom spline through a sequence of control points,
* steering clear of obstacle blocks.
*/
export function sampleCatmullRomSpline(
controlPoints: readonly { readonly x: number; readonly y: number }[],
bounds: { readonly width: number; readonly height: number },
roadObstacleBlocks?: boolean[][],
): { x: number; y: number }[] {
if (controlPoints.length === 0) return []
if (controlPoints.length === 1) return [{ x: controlPoints[0]!.x, y: controlPoints[0]!.y }]
const result: { x: number; y: number }[] = []
const n = controlPoints.length
const isObstacle = (cx: number, cy: number): boolean => {
if (!roadObstacleBlocks) return false
const bx = Math.floor(cx / TILES_PER_BLOCK)
const by = Math.floor(cy / TILES_PER_BLOCK)
return roadObstacleBlocks[by]?.[bx] === true
}
const addPoint = (rawX: number, rawY: number) => {
let px = Math.max(0, Math.min(bounds.width - 1, Math.round(rawX)))
let py = Math.max(0, Math.min(bounds.height - 1, Math.round(rawY)))
if (isObstacle(px, py)) {
const obx = Math.floor(px / TILES_PER_BLOCK)
const oby = Math.floor(py / TILES_PER_BLOCK)
const centerTileX = obx * TILES_PER_BLOCK + 4
const centerTileY = oby * TILES_PER_BLOCK + 4
const dirX = px - centerTileX
const dirY = py - centerTileY
const stepX = dirX >= 0 ? 1 : -1
const stepY = dirY >= 0 ? 1 : -1
if (Math.abs(dirX) >= Math.abs(dirY)) {
px = stepX > 0 ? (obx + 1) * TILES_PER_BLOCK : obx * TILES_PER_BLOCK - 1
} else {
py = stepY > 0 ? (oby + 1) * TILES_PER_BLOCK : oby * TILES_PER_BLOCK - 1
}
px = Math.max(0, Math.min(bounds.width - 1, px))
py = Math.max(0, Math.min(bounds.height - 1, py))
}
const last = result[result.length - 1]
if (!last || last.x !== px || last.y !== py) {
result.push({ x: px, y: py })
}
}
for (let i = 0; i < n - 1; i += 1) {
const p0 = i > 0 ? controlPoints[i - 1]! : {
x: 2 * controlPoints[0]!.x - controlPoints[1]!.x,
y: 2 * controlPoints[0]!.y - controlPoints[1]!.y,
}
const p1 = controlPoints[i]!
const p2 = controlPoints[i + 1]!
const p3 = i + 2 < n ? controlPoints[i + 2]! : {
x: 2 * controlPoints[n - 1]!.x - controlPoints[n - 2]!.x,
y: 2 * controlPoints[n - 1]!.y - controlPoints[n - 2]!.y,
}
const dist = Math.hypot(p2.x - p1.x, p2.y - p1.y)
const steps = Math.max(3, Math.ceil(dist / 1.0))
for (let s = 0; s <= steps; s += 1) {
const t = s / steps
const pt = catmullRomPoint(p0, p1, p2, p3, t)
addPoint(pt.x, pt.y)
}
}
return result
}
/**
* Find clear block path connecting start to end block avoiding obstacle blocks.
*/
function findClearBlockCorridor(
startBx: number,
startBy: number,
endBx: number,
endBy: number,
gridWidth: number,
gridHeight: number,
roadObstacleBlocks: boolean[][],
): { bx: number; by: number }[] {
if (startBx === endBx && startBy === endBy) {
return [{ bx: startBx, by: startBy }]
}
interface Node {
bx: number
by: number
g: number
h: number
f: number
parent: Node | null
}
const openSet: Node[] = []
const closedSet = new Set<string>()
const keyOf = (bx: number, by: number) => `${String(bx)},${String(by)}`
const h = (bx: number, by: number) => Math.hypot(endBx - bx, endBy - by)
const startNode: Node = {
bx: startBx,
by: startBy,
g: 0,
h: h(startBx, startBy),
f: h(startBx, startBy),
parent: null,
}
openSet.push(startNode)
const DIRS = [
{ dx: 1, dy: 0 },
{ dx: -1, dy: 0 },
{ dx: 0, dy: 1 },
{ dx: 0, dy: -1 },
{ dx: 1, dy: 1 },
{ dx: -1, dy: 1 },
{ dx: 1, dy: -1 },
{ dx: -1, dy: -1 },
]
let bestNode = startNode
let bestDist = h(startBx, startBy)
while (openSet.length > 0) {
let bestIdx = 0
for (let i = 1; i < openSet.length; i += 1) {
if (openSet[i]!.f < openSet[bestIdx]!.f) {
bestIdx = i
}
}
const current = openSet.splice(bestIdx, 1)[0]!
if (current.bx === endBx && current.by === endBy) {
const path: { bx: number; by: number }[] = []
let curr: Node | null = current
while (curr !== null) {
path.unshift({ bx: curr.bx, by: curr.by })
curr = curr.parent
}
return path
}
closedSet.add(keyOf(current.bx, current.by))
if (current.h < bestDist) {
bestDist = current.h
bestNode = current
}
for (const dir of DIRS) {
const nbx = current.bx + dir.dx
const nby = current.by + dir.dy
if (nbx < 1 || nbx >= gridWidth - 1 || nby < 1 || nby >= gridHeight - 1) continue
if (closedSet.has(keyOf(nbx, nby))) continue
if (
!(nbx === endBx && nby === endBy) &&
roadObstacleBlocks[nby]?.[nbx] === true
) {
continue
}
if (dir.dx !== 0 && dir.dy !== 0) {
if (
roadObstacleBlocks[current.by]?.[current.bx + dir.dx] === true &&
roadObstacleBlocks[current.by + dir.dy]?.[current.bx] === true
) {
continue
}
}
const moveCost = dir.dx !== 0 && dir.dy !== 0 ? 1.414 : 1.0
const g = current.g + moveCost
const nodeH = h(nbx, nby)
const existing = openSet.find(n => n.bx === nbx && n.by === nby)
if (existing) {
if (g < existing.g) {
existing.g = g
existing.f = g + existing.h
existing.parent = current
}
} else {
openSet.push({
bx: nbx,
by: nby,
g,
h: nodeH,
f: g + nodeH,
parent: current,
})
}
}
}
const path: { bx: number; by: number }[] = []
let curr: Node | null = bestNode
while (curr !== null) {
path.unshift({ bx: curr.bx, by: curr.by })
curr = curr.parent
}
return path
}
/** Direction constants in D2MOO D2AltDirections order. */
export const ALTDIR_WEST = 0
export const ALTDIR_NORTH = 1
export const ALTDIR_EAST = 2
export const ALTDIR_SOUTH = 3
/**
* Bresenham line algorithm connecting two points on integer grid coordinates (0 RNG rolls).
*/
export function bresenhamLine(
x0: number,
y0: number,
x1: number,
y1: number,
): { x: number; y: number }[] {
const points: { x: number; y: number }[] = []
let x = Math.round(x0)
let y = Math.round(y0)
const targetX = Math.round(x1)
const targetY = Math.round(y1)
const dx = Math.abs(targetX - x)
const dy = Math.abs(targetY - y)
const sx = x < targetX ? 1 : -1
const sy = y < targetY ? 1 : -1
let err = dx - dy
while (true) {
points.push({ x, y })
if (x === targetX && y === targetY) break
const e2 = 2 * err
if (e2 > -dy) {
err -= dy
x += sx
}
if (e2 < dx) {
err += dx
y += sy
}
}
return points
}
/**
* Check whether an outdoor grid cell is valid for spawning / hub placement
* (`DRLGOUTDOORS_TestGridCellSpawnValid`, mask 0x1B81).
*/
export function DRLGOUTDOORS_TestGridCellSpawnValid(
x: number,
y: number,
gridOrPlan?: { readonly roadObstacleBlocks?: boolean[][]; readonly gridFlags?: number[][] } | number[][],
): boolean {
if (!gridOrPlan) return true
if (Array.isArray(gridOrPlan)) {
const val = gridOrPlan[y]?.[x] ?? 0
return (val & 0x1B81) === 0
}
if (gridOrPlan.gridFlags) {
const val = gridOrPlan.gridFlags[y]?.[x] ?? 0
if ((val & 0x1B81) !== 0) return false
}
if (gridOrPlan.roadObstacleBlocks) {
if (gridOrPlan.roadObstacleBlocks[y]?.[x] === true) return false
}
return true
}
/**
* Snap vertex to 8-cell grid based on border direction (`DRLGOUTDOORS_CalculatePathCoordinates`,
* D2Common.0x6FD7F500).
*
* Supports both TypeScript `(vertex1, origin)` and D2MOO `(level, vertex1, vertex2)` call styles.
*/
export function DRLGOUTDOORS_CalculatePathCoordinates(
arg1: any,
arg2?: any,
arg3?: any,
): { x: number; y: number } {
let origin = { x: 0, y: 0 }
let v1: any
let v2: any = null
if (arg3 !== undefined || (arg2 !== undefined && typeof arg2 === 'object' && ('direction' in arg2 || 'nDirection' in arg2))) {
const origX = arg1?.nPosX ?? arg1?.x ?? 0
const origY = arg1?.nPosY ?? arg1?.y ?? 0
origin = { x: origX, y: origY }
v1 = arg2
v2 = arg3
} else {
v1 = arg1
if (arg2 && typeof arg2 === 'object') {
origin = { x: arg2.x ?? arg2.nPosX ?? 0, y: arg2.y ?? arg2.nPosY ?? 0 }
}
}
const v1X = v1.nPosX ?? v1.x ?? 0
const v1Y = v1.nPosY ?? v1.y ?? 0
const dir = v1.nDirection ?? v1.direction ?? -1
let relX = v1X - origin.x
let relY = v1Y - origin.y
switch (dir) {
case ALTDIR_WEST:
relX = 8 * Math.trunc(relX / 8) + 11
break
case ALTDIR_NORTH:
relY = 8 * Math.trunc(relY / 8) + 11
break
case ALTDIR_EAST:
relX = 8 * Math.trunc(relX / 8) - 5
break
case ALTDIR_SOUTH:
relY = 8 * Math.trunc(relY / 8) - 5
break
default:
break
}
const result = {
x: relX + origin.x,
y: relY + origin.y,
}
if (v2 && typeof v2 === 'object') {
v2.x = result.x
v2.y = result.y
v2.nPosX = result.x
v2.nPosY = result.y
}
return result
}
/**
* Calculate central crossroads hub coordinate (`sub_6FD7F5B0`, D2Common.0x6FD7F5B0).
*
* If bridge (dwFlags & 0x10): anchor to bridge coordinates.
* Else if nVertices === 1: hub = (Math.floor(nGridWidth / 2), Math.floor(nGridHeight / 2)).
* Else:
* avgX = Math.floor(sumX / (8 * nVertices))
* avgY = Math.floor(sumY / (8 * nVertices))
* Spiral outward with radius 0..7 and 4 directions: [-1, 0], [0, 1], [0, -1], [1, 0].
* Pick first cell passing TestGridCellSpawnValid (cell & 0x1B81 === 0).
* Consumes 0 RNG rolls!
*/
export function DRLGOUTDOORS_CalculateHubCoordinates(
plan: Act1OutdoorPlan,
gridWidth: number,
gridHeight: number,
origin: { x: number; y: number } = { x: 0, y: 0 },
): { x: number; y: number; bx: number; by: number } {
const dwFlags = (plan as any).dwFlags ?? 0
const hasBridge = (dwFlags & 0x10) !== 0 || (plan as any).hasBridge === true ||
plan.specialPresets.some(p => /bridge/i.test(p.name))
const bridgePreset = plan.specialPresets.find(p => /bridge/i.test(p.name))
const bridgeCoords = (plan as any).bridgeCoords ?? (bridgePreset ? { x: bridgePreset.bx, y: bridgePreset.by } : undefined)
if (hasBridge && bridgeCoords && bridgeCoords.x !== -1) {
const bx = bridgeCoords.x
const by = bridgeCoords.y
return {
x: 8 * bx + 3 + origin.x,
y: 8 * by + 3 + origin.y,
bx,
by,
}
}
const nVertices = plan.anchors.length
if (nVertices === 1) {
const bx = Math.floor(gridWidth / 2)
const by = Math.floor(gridHeight / 2)
return {
x: bx * TILES_PER_BLOCK + 4 + origin.x,
y: by * TILES_PER_BLOCK + 4 + origin.y,
bx,
by,
}
}
let sumX = 0
let sumY = 0
for (const anchor of plan.anchors) {
sumX += anchor.anchorCell.x - origin.x
sumY += anchor.anchorCell.y - origin.y
}
const count = Math.max(1, nVertices)
let avgX = Math.floor(sumX / (8 * count))
let avgY = Math.floor(sumY / (8 * count))
avgX = Math.max(0, Math.min(gridWidth - 1, avgX))
avgY = Math.max(0, Math.min(gridHeight - 1, avgY))
const DIR_OFFSETS = [
{ x: -1, y: 0 },
{ x: 0, y: 1 },
{ x: 0, y: -1 },
{ x: 1, y: 0 },
]
let chosenBx = avgX
let chosenBy = avgY
let found = false
for (let r = 0; r < 8 && !found; r += 1) {
for (const dir of DIR_OFFSETS) {
const testX = avgX + r * dir.x
const testY = avgY + r * dir.y
if (testX >= 0 && testX < gridWidth && testY >= 0 && testY < gridHeight) {
if (DRLGOUTDOORS_TestGridCellSpawnValid(testX, testY, plan)) {
chosenBx = testX
chosenBy = testY
found = true
break
}
}
}
}
if (!found) {
for (let r = 8; r < Math.max(gridWidth, gridHeight) && !found; r += 1) {
for (const dir of DIR_OFFSETS) {
const testX = avgX + r * dir.x
const testY = avgY + r * dir.y
if (testX >= 0 && testX < gridWidth && testY >= 0 && testY < gridHeight) {
if (DRLGOUTDOORS_TestGridCellSpawnValid(testX, testY, plan)) {
chosenBx = testX
chosenBy = testY
found = true
break
}
}
}
}
}
return {
x: chosenBx * TILES_PER_BLOCK + 4 + origin.x,
y: chosenBy * TILES_PER_BLOCK + 4 + origin.y,
bx: chosenBx,
by: chosenBy,
}
}
/**
* Constructs level bounding box closed cyclic linked list and deterministic grid-level paths
* (`DRLGVER_CreateVertices` / `DRLGOUTDOORS_CalculatePathCoordinates`, D2MOO DrlgDrlgVer.cpp:15-226).
*
* 1. Constructs level bounding box closed cyclic linked list (4 corner vertices: (0,0), (w,0), (w,h), (0,h)).
* 2. For each orthogonal connection, inserts 1-2 vertices into that edge at connection span endpoints (v15/v16).
* 3. Flags: inserted vertex `dwFlags |= 1` (connection); if preset room `dwFlags |= 2`.
* 4. Coordinates: subtract level origin to convert to relative coordinates.
* 5. Consumes 0 RNG rolls!
*/
export function createDrlgVertices(
plan: Act1OutdoorPlan,
gridWidth: number,
gridHeight: number,
rng?: Rng,
origin: { x: number; y: number } = { x: 0, y: 0 },
): DrlgVertexResult {
const tileW = gridWidth <= 32 ? gridWidth * TILES_PER_BLOCK : gridWidth
const tileH = gridHeight <= 32 ? gridHeight * TILES_PER_BLOCK : gridHeight
const gridW = gridWidth <= 32 ? gridWidth : Math.floor(gridWidth / TILES_PER_BLOCK)
const gridH = gridHeight <= 32 ? gridHeight : Math.floor(gridHeight / TILES_PER_BLOCK)
// 1. Level bounding box closed cyclic linked list 4 corner vertices in clockwise order:
// V0: (0, 0)
// V1: (width, 0)
// V2: (width, height)
// V3: (0, height)
const v0: DrlgVertex = {
x: 0,
y: 0,
direction: ALTDIR_NORTH,
dwFlags: 0,
type: 'corner',
label: 'Corner V0 (NW)',
}
const v1: DrlgVertex = {
x: tileW,
y: 0,
direction: ALTDIR_EAST,
dwFlags: 0,
type: 'corner',
label: 'Corner V1 (NE)',
}
const v2: DrlgVertex = {
x: tileW,
y: tileH,
direction: ALTDIR_SOUTH,
dwFlags: 0,
type: 'corner',
label: 'Corner V2 (SE)',
}
const v3: DrlgVertex = {
x: 0,
y: tileH,
direction: ALTDIR_WEST,
dwFlags: 0,
type: 'corner',
label: 'Corner V3 (SW)',
}
// 2. For each exterior connection / gate / orth connection on each of the 4 borders:
// Compute connection entry/exit span on that edge.
// Insert 1-2 vertices into that edge at connection span endpoints (v15/v16).
// dwFlags |= 1 (connection); if preset room dwFlags |= 2.
// Coordinates: subtract level origin to convert to relative coordinates.
const northVertices: DrlgVertex[] = []
const eastVertices: DrlgVertex[] = []
const southVertices: DrlgVertex[] = []
const westVertices: DrlgVertex[] = []
const borderConnections = (plan as any).connections ?? plan.anchors ?? []
for (const conn of borderConnections) {
const rawX = (conn.anchorCell ? conn.anchorCell.x : (conn.x ?? 0)) - origin.x
const rawY = (conn.anchorCell ? conn.anchorCell.y : (conn.y ?? 0)) - origin.y
const side = conn.side ?? (
rawY <= 0 ? 'north' :
rawX >= tileW - 1 ? 'east' :
rawY >= tileH - 1 ? 'south' :
rawX <= 0 ? 'west' : undefined
)
if (!side) continue
const isPreset = conn.kind === 'preset' || (conn as any).isPreset === true || (conn.label && /preset/i.test(conn.label))
const dwFlags = 1 | (isPreset ? 2 : 0)
const vType = isPreset ? 'preset' : 'gate'
const label = conn.label ?? (isPreset ? 'Preset Connection' : 'Gate Connection')
const id = conn.toLevelId ?? conn.id
const span = (conn as any).span ?? (
((conn as any).v15 !== undefined && (conn as any).v16 !== undefined)
? [(conn as any).v15, (conn as any).v16]
: undefined
)
if (side === 'north') {
if (span && span[0] !== span[1]) {
northVertices.push({
x: Math.max(0, Math.min(tileW, span[0] - origin.x)),
y: 0,
direction: ALTDIR_NORTH,
dwFlags,
type: vType,
label: `${label} (Start)`,
id,
})
northVertices.push({
x: Math.max(0, Math.min(tileW, span[1] - origin.x)),
y: 0,
direction: ALTDIR_NORTH,
dwFlags,
type: vType,
label: `${label} (End)`,
id,
})
} else {
const x = Math.max(0, Math.min(tileW, span ? span[0] - origin.x : rawX))
northVertices.push({
x,
y: 0,
direction: ALTDIR_NORTH,
dwFlags,
type: vType,
label,
id,
})
}
} else if (side === 'east') {
if (span && span[0] !== span[1]) {
eastVertices.push({
x: tileW,
y: Math.max(0, Math.min(tileH, span[0] - origin.y)),
direction: ALTDIR_EAST,
dwFlags,
type: vType,
label: `${label} (Start)`,
id,
})
eastVertices.push({
x: tileW,
y: Math.max(0, Math.min(tileH, span[1] - origin.y)),
direction: ALTDIR_EAST,
dwFlags,
type: vType,
label: `${label} (End)`,
id,
})
} else {
const y = Math.max(0, Math.min(tileH, span ? span[0] - origin.y : rawY))
eastVertices.push({
x: tileW,
y,
direction: ALTDIR_EAST,
dwFlags,
type: vType,
label,
id,
})
}
} else if (side === 'south') {
if (span && span[0] !== span[1]) {
southVertices.push({
x: Math.max(0, Math.min(tileW, span[0] - origin.x)),
y: tileH,
direction: ALTDIR_SOUTH,
dwFlags,
type: vType,
label: `${label} (Start)`,
id,
})
southVertices.push({
x: Math.max(0, Math.min(tileW, span[1] - origin.x)),
y: tileH,
direction: ALTDIR_SOUTH,
dwFlags,
type: vType,
label: `${label} (End)`,
id,
})
} else {
const x = Math.max(0, Math.min(tileW, span ? span[0] - origin.x : rawX))
southVertices.push({
x,
y: tileH,
direction: ALTDIR_SOUTH,
dwFlags,
type: vType,
label,
id,
})
}
} else if (side === 'west') {
if (span && span[0] !== span[1]) {
westVertices.push({
x: 0,
y: Math.max(0, Math.min(tileH, span[0] - origin.y)),
direction: ALTDIR_WEST,
dwFlags,
type: vType,
label: `${label} (Start)`,
id,
})
westVertices.push({
x: 0,
y: Math.max(0, Math.min(tileH, span[1] - origin.y)),
direction: ALTDIR_WEST,
dwFlags,
type: vType,
label: `${label} (End)`,
id,
})
} else {
const y = Math.max(0, Math.min(tileH, span ? span[0] - origin.y : rawY))
westVertices.push({
x: 0,
y,
direction: ALTDIR_WEST,
dwFlags,
type: vType,
label,
id,
})
}
}
}
// Sort edges in clockwise order:
// North edge (V0 -> V1): X ascending
northVertices.sort((a, b) => a.x - b.x)
// East edge (V1 -> V2): Y ascending
eastVertices.sort((a, b) => a.y - b.y)
// South edge (V2 -> V3): X descending
southVertices.sort((a, b) => b.x - a.x)
// West edge (V3 -> V0): Y descending
westVertices.sort((a, b) => b.y - a.y)
// Construct cyclic doubly linked list in clockwise order:
const cyclicVertices: DrlgVertex[] = [
v0,
...northVertices,
v1,
...eastVertices,
v2,
...southVertices,
v3,
...westVertices,
]
for (let i = 0; i < cyclicVertices.length; i += 1) {
const curr = cyclicVertices[i]!
const next = cyclicVertices[(i + 1) % cyclicVertices.length]!
const prev = cyclicVertices[(i - 1 + cyclicVertices.length) % cyclicVertices.length]!
curr.next = next
curr.prev = prev
}
// 3. Central Hub calculation (0 RNG rolls)
const hubInfo = DRLGOUTDOORS_CalculateHubCoordinates(plan, gridW, gridH, origin)
const hubCell = { x: hubInfo.x, y: hubInfo.y }
const hubVertex: DrlgVertex = {
x: hubCell.x,
y: hubCell.y,
type: 'hub',
label: 'Central Hub',
direction: 4,
dwFlags: 0,
}
// 4. Collect all vertices: cyclic boundary vertices + hub + interior presets
const allVertices: DrlgVertex[] = [...cyclicVertices, hubVertex]
// Add interior presets (if not already on the border)
for (const preset of plan.specialPresets) {
const px = preset.bx * TILES_PER_BLOCK + Math.floor((preset.blocksX * TILES_PER_BLOCK) / 2) + origin.x
const py = preset.by * TILES_PER_BLOCK + Math.floor((preset.blocksY * TILES_PER_BLOCK) / 2) + origin.y
const alreadyPresent = allVertices.some(v => v.label === preset.name || Math.hypot(v.x - px, v.y - py) < 4)
if (!alreadyPresent) {
allVertices.push({
x: px,
y: py,
type: /waypoint/i.test(preset.name) ? 'waypoint' : 'preset',
label: preset.name,
})
}
}
for (const anchor of plan.anchors) {
if (anchor.kind === 'preset') {
const px = anchor.interiorCell.x - origin.x
const py = anchor.interiorCell.y - origin.y
const alreadyPresent = allVertices.some(v => Math.hypot(v.x - px, v.y - py) < 4)
if (!alreadyPresent) {
allVertices.push({
x: px,
y: py,
type: /waypoint/i.test(anchor.label) ? 'waypoint' : 'preset',
label: anchor.label,
id: anchor.toLevelId,
})
}
}
}
// 5. Grid Path Connection (replacing sampleCatmullRomSpline, 0 RNG):
const polylines: { x: number; y: number }[][] = []
for (const anchor of plan.anchors) {
// Snap border vertex to 8-cell grid:
const dir = anchor.side === 'west' ? ALTDIR_WEST :
anchor.side === 'north' ? ALTDIR_NORTH :
anchor.side === 'east' ? ALTDIR_EAST :
anchor.side === 'south' ? ALTDIR_SOUTH : undefined
let snappedStart = anchor.interiorCell
if (dir !== undefined) {
snappedStart = DRLGOUTDOORS_CalculatePathCoordinates({
x: anchor.anchorCell.x,
y: anchor.anchorCell.y,
direction: dir,
}, origin)
}
const startBx = Math.floor(snappedStart.x / TILES_PER_BLOCK)
const startBy = Math.floor(snappedStart.y / TILES_PER_BLOCK)
// Deterministic A* block corridor avoiding obstacles
const blockPath = findClearBlockCorridor(
startBx,
startBy,
hubInfo.bx,
hubInfo.by,
gridW,
gridH,
plan.roadObstacleBlocks,
)
const waypoints: { x: number; y: number }[] = [
anchor.anchorCell,
snappedStart,
]
if (blockPath.length > 2) {
for (let i = 1; i < blockPath.length - 1; i += 1) {
const b = blockPath[i]!
waypoints.push({
x: b.bx * TILES_PER_BLOCK + 4 + origin.x,
y: b.by * TILES_PER_BLOCK + 4 + origin.y,
})
}
}
waypoints.push(hubCell)
// Rasterize waypoints with Bresenham lines into a continuous grid-level line (0 RNG)
const pathLine: { x: number; y: number }[] = []
for (let i = 0; i < waypoints.length - 1; i += 1) {
const p0 = waypoints[i]!
const p1 = waypoints[i + 1]!
const seg = bresenhamLine(p0.x, p0.y, p1.x, p1.y)
for (const pt of seg) {
const clampedX = Math.max(0, Math.min(tileW - 1, pt.x))
const clampedY = Math.max(0, Math.min(tileH - 1, pt.y))
const last = pathLine[pathLine.length - 1]
if (!last || last.x !== clampedX || last.y !== clampedY) {
pathLine.push({ x: clampedX, y: clampedY })
}
}
}
polylines.push(pathLine)
}
return Object.assign([...allVertices], {
vertices: allVertices,
polylines,
lines: polylines,
cyclicVertices,
head: v0,
}) as DrlgVertexResult
}
export const DRLGVER_CreateVertices = createDrlgVertices
/**
* Build deterministic road polylines connecting all anchors to a central hub
* (`DRLGVER_CreateVertices` / `DRLGOUTDOORS_SpawnAct1DirtPaths`).
*/
export function buildOutdoorRoadPolylines(
plan: Act1OutdoorPlan,
gridWidth: number,
gridHeight: number,
rng?: Rng,
): { x: number; y: number }[][] {
return createDrlgVertices(plan, gridWidth, gridHeight, rng).polylines
}
export const buildRoadNetworkPolylines = buildOutdoorRoadPolylines
/**
* Rasterize a 2-3 cell wide line segment onto the dirt path grid (`sub_6FD75F60`).
*/
export function rasterizeDirtPathSegment(
grid: Uint8Array,
width: number,
height: number,
x1: number,
y1: number,
x2: number,
y2: number,
roadWidth: number = 2,
roadObstacleBlocks?: boolean[][],
): void {
const markWidth = (x: number, y: number): void => {
const limit = Math.max(2, Math.min(3, roadWidth))
for (let dy = 0; dy < limit; dy += 1) {
for (let dx = 0; dx < limit; dx += 1) {
const px = x + dx
const py = y + dy
if (px >= 0 && px < width && py >= 0 && py < height) {
if (roadObstacleBlocks) {
const obx = Math.floor(px / TILES_PER_BLOCK)
const oby = Math.floor(py / TILES_PER_BLOCK)
if (roadObstacleBlocks[oby]?.[obx] === true) {
continue
}
}
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
markWidth(x, y)
if (dx >= dy) {
let err = 0
for (let i = 0; i < dx; i += 1) {
err += dy
if (err > dx) {
y += sy
markWidth(x, y)
err -= dx
}
x += sx
markWidth(x, y)
}
} else {
let err = 0
for (let i = 0; i < dy; i += 1) {
err += dx
if (err > dy) {
x += sx
markWidth(x, y)
err -= dy
}
y += sy
markWidth(x, y)
}
}
}
/**
* Rasterize all road polylines into a 2D bitmask grid and apply
* `DIRT_PATH_TILE_LUT` (`DRLG_OUTDOORS_GenerateDirtPath`).
*/
export function generateDirtPaths(
canvas: Canvas,
polylines: readonly (readonly { x: number; y: number }[])[],
roadObstacleBlocks?: boolean[][],
): { 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, 2, roadObstacleBlocks)
roadSegments += 1
}
}
const isRoad = (nx: number, ny: number): boolean => {
if (nx < 0 || nx >= width || ny < 0 || ny >= height) return false
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.
*/
/**
* Atomically replace a target canvas region with a substitution DS1 (`DRLGTILESUB_ReplaceSubPreset`).
*
* Synchronously updates all 4 visual/collision layers (floors, walls, roofs `type === 15`, and shadows)
* plus the substitution group tags and DS1 objects, clearing stale procedural clutter on cells
* modified by the substitution patch.
*/
export function stampSubstitutionPatch(
canvas: Canvas,
patch: Ds1,
originX: number,
originY: number,
): number {
let written = 0
for (let y = 0; y < patch.height; y += 1) {
const cy = originY + y
if (cy < 0 || cy >= canvas.height) continue
const srcRow = patch.cells[y]
const dstRow = canvas.cells[cy]
if (srcRow === undefined || dstRow === undefined) continue
for (let x = 0; x < patch.width; x += 1) {
const cx = originX + x
if (cx < 0 || cx >= canvas.width) continue
const srcCell = srcRow[x]
const dstCell = dstRow[cx]
if (srcCell === undefined || dstCell === undefined) continue
const hasFloor = srcCell.floors.some(f => !f.hidden && f.prop1 !== 0)
const hasWallOrRoof = srcCell.walls.some(w => !w.hidden && w.prop1 !== 0)
const hasShadow = srcCell.shadows.some(s => !s.hidden && s.prop1 !== 0)
const hasSub = srcCell.substitutions.some(s => s.value !== 0)
if (!hasFloor && !hasWallOrRoof && !hasShadow && !hasSub) continue
const targetFloors = dstCell.floors as Ds1Floor[]
const targetWalls = dstCell.walls as Ds1Wall[]
const targetShadows = dstCell.shadows as Ds1Floor[]
const targetSubs = dstCell.substitutions as Ds1Substitution[]
// Atomic 4-layer sync (Floor, Wall, Roof, Shadow): when a substitution cell overwrites
// a tile, replace existing floor/wall/roof/shadow slots cleanly so underlying procedural
// walls or shadows do not bleed into the substituted structure.
if (hasFloor) {
for (let i = 0; i < srcCell.floors.length; i += 1) {
const floor = srcCell.floors[i]
if (floor !== undefined && !floor.hidden && floor.prop1 !== 0) {
if (i < targetFloors.length) targetFloors[i] = floor
else targetFloors.push(floor)
}
}
}
if (hasWallOrRoof || hasFloor) {
// Clear pre-existing procedural walls/roofs on this substituted cell before applying patch walls/roofs
for (let i = 0; i < targetWalls.length; i += 1) {
targetWalls[i] = { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }
}
for (let i = 0; i < srcCell.walls.length; i += 1) {
const wall = srcCell.walls[i]
if (wall !== undefined && !wall.hidden && wall.prop1 !== 0) {
if (i < targetWalls.length) targetWalls[i] = wall
else targetWalls.push(wall)
}
}
}
if (hasShadow || hasFloor) {
for (let i = 0; i < targetShadows.length; i += 1) {
targetShadows[i] = { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
}
for (let i = 0; i < srcCell.shadows.length; i += 1) {
const shadow = srcCell.shadows[i]
if (shadow !== undefined && !shadow.hidden && shadow.prop1 !== 0) {
if (i < targetShadows.length) targetShadows[i] = shadow
else targetShadows.push(shadow)
}
}
}
if (hasSub) {
for (let i = 0; i < srcCell.substitutions.length; i += 1) {
const sub = srcCell.substitutions[i]
if (sub !== undefined && sub.value !== 0) {
if (i < targetSubs.length) targetSubs[i] = sub
else targetSubs.push(sub)
}
}
}
written += 1
}
}
// Copy DS1 objects from the substitution patch
const subOriginX = originX * SUB_TILES_PER_TILE
const subOriginY = originY * SUB_TILES_PER_TILE
const maxSubX = canvas.width * SUB_TILES_PER_TILE
const maxSubY = canvas.height * SUB_TILES_PER_TILE
for (const obj of patch.objects) {
const ox = subOriginX + obj.x
const oy = subOriginY + obj.y
if (ox >= 0 && ox < maxSubX && oy >= 0 && oy < maxSubY) {
canvas.objects.push({ ...obj, x: ox, y: oy })
}
}
return written
}
/**
* Build a Fisher-Yates randomized permutation of all grid-aligned candidate offsets
* within `(spanX, spanY)` for `Trials == -1` exhaustive substitution scan (`DRLGTILESUB_DoSubstitutions`).
*/
export function buildSubstitutionCandidatePermutation(
spanX: number,
spanY: number,
gridSize: number,
rng: Rng,
): { ox: number; oy: number }[] {
const step = Math.max(1, Math.floor(gridSize))
const candidates: { ox: number; oy: number }[] = []
const snap = (value: number, span: number): number => {
const limit = Math.max(Math.floor((span - 1) / step), 0)
return Math.min(Math.floor(value / step), limit) * step
}
for (let oy = 0; oy < spanY; oy += step) {
for (let ox = 0; ox < spanX; ox += step) {
candidates.push({ ox: snap(ox, spanX), oy: snap(oy, spanY) })
}
}
// Fisher-Yates shuffle using the level's deterministic PRNG
for (let i = candidates.length - 1; i > 0; i -= 1) {
const j = rng.int(0, i)
const tmp = candidates[i]!
candidates[i] = candidates[j]!
candidates[j] = tmp
}
return candidates
}
/**
* Scatter one `LvlSub` row's pieces across the level's interior (`DRLGTILESUB_DoSubstitutions`).
*
* Implements Blizzard D2 1.13c `DrlgTileSub.cpp`:
* 1. Reads theme-indexed `Max[themeIndex]` and `Trials[themeIndex]` (`0..4`).
* 2. When `Trials == -1`, shuffles all candidate grid positions via Fisher-Yates permutation
* and scans until up to `wantedMax` non-overlapping clusters are placed.
* 3. When `Trials > 0`, performs up to `Trials` random coordinate draws per cluster.
* 4. Selects randomly among `row.levels` variants (`DRLGTILESUB_PickSubPreset`) and atomically
* replaces floor, wall, roof, and shadow layers (`stampSubstitutionPatch`).
*/
export function applySubstitution(
canvas: Canvas,
row: WildernessSubstitution,
themeIndex: number,
gridWidth: number,
gridHeight: number,
rng: Rng,
claimedBlocks?: boolean[][],
dirtPathGrid?: Uint8Array,
maxOverride?: number,
): number {
if (row.levels.length === 0) return 0
const baseLevel = row.levels[0]!
const rawMax = Math.max(0, Math.floor(row.max[themeIndex] ?? 0))
const max = maxOverride !== undefined ? Math.min(rawMax, maxOverride) : rawMax
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(baseLevel.width / TILES_PER_BLOCK), 1)
const blocksY = Math.max(Math.floor(baseLevel.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, bw: number, bh: number): void => {
if (claimedBlocks === undefined) return
for (let dy = 0; dy < bh; dy += 1) {
for (let dx = 0; dx < bw; dx += 1) {
if (claimedBlocks[by + dy] !== undefined) {
claimedBlocks[by + dy]![bx + dx] = true
}
}
}
}
const pickVariant = (): Ds1 => {
if (row.levels.length === 1) return row.levels[0]!
return row.levels[rng.int(0, row.levels.length - 1)]!
}
let stamped = 0
if (trials === -1) {
// 1.13c DRLGTILESUB_DoSubstitutions: when nTrials == -1, permute all candidate positions
// and place up to `max` clusters without repeating failed slots.
const candidates = buildSubstitutionCandidatePermutation(spanX, spanY, gridSize, rng)
let candidateIdx = 0
for (let cluster = 0; cluster < max && candidateIdx < candidates.length; cluster += 1) {
let placed = false
while (candidateIdx < candidates.length && !placed) {
const cand = candidates[candidateIdx]!
candidateIdx += 1
const bx = 1 + cand.ox
const by = 1 + cand.oy
if (!canPlaceSubstitutionCluster(bx, by, blocksX, blocksY, claimedBlocks, dirtPathGrid, canvas.width)) {
continue
}
const chosenPatch = pickVariant()
stampSubstitutionPatch(canvas, chosenPatch, bx * TILES_PER_BLOCK, by * TILES_PER_BLOCK)
recordClaim(bx, by, blocksX, blocksY)
placed = true
}
if (placed) stamped += 1
}
return stamped
}
for (let cluster = 0; cluster < max; cluster += 1) {
let placed = false
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
}
const chosenPatch = pickVariant()
stampSubstitutionPatch(canvas, chosenPatch, bx * TILES_PER_BLOCK, by * TILES_PER_BLOCK)
recordClaim(bx, by, blocksX, blocksY)
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` (`0..4`).
* @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.
*/
export 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
let sharedBudget = 1
const clampedTheme = Math.max(0, Math.min(4, Math.floor(themeIndex)))
for (const row of rows) {
const role = classifySubstitutionRole(row.name, row.type)
const chance = row.prob[clampedTheme] ?? 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[clampedTheme] ?? 0))
const wanted = Math.min(rowMax, allowance)
if (wanted > 0) {
const single = applySubstitution(canvas, row, clampedTheme, gridWidth, gridHeight, rng, claimedBlocks, dirtPathGrid, wanted)
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 BLOOD_MOOR_RIVER_PRESETS: readonly string[] = [
'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',
]
export const BLOOD_MOOR_POND_PRESETS: readonly string[] = [
'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',
]
/**
* Special preset piece lists stamped per level ID.
*
* Traceability to Blizzard DRLG outdoor logic:
* - Act 2 Desert levels (41-46):
* Corresponds to D2MOO `DRLGOUTDESR_AddExits` (0x6FD7D9F0) for level exits:
* - Level 41 (Rocky Waste): Stony Tomb (`Act 2 - Desert Tomb 1`)
* - Level 42 (Dry Hills): Halls of the Dead (`Act 2 - Desert Tomb 1`)
* - Level 43 (Far Oasis): Maggot Lair (`Act 2 - Desert Lair 1`, plus `DRLGOUTDESR_PlaceFillsInFarOasis`)
* - Level 44 (Lost City): Ancient Tunnels (`Act 2 - Desert Ruins Sewer`, plus `DRLGOUTDESR_PlaceRuinsInLostCity` Elder / 16x16 / 16x08 / 08x16)
* - Level 45 (Valley of Snakes): Claw Viper Temple (`Act 2 - Desert Tomb 2`)
* - Level 46 (Canyon of the Magi): Tal Rasha Tombs & Arcane Sanctuary warp (`DRLGOUTDESR_PlaceTombEntriesInCanyon`)
* - Act 3 Kurast levels (79-81):
* Corresponds to D2MOO `DRLGOUTJUNG_SpawnRandomPreset` (0x6FD80230) and Kurast interior presets
* called within `DRLGOUTJUNG_BuildLowerKurast` (0x6FD7FE50), `DRLGOUTJUNG_BuildKurastBazaar` (0x6FD7FFA0),
* and `DRLGOUTJUNG_BuildUpperKurast` (0x6FD800E0).
*/
export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]>> = {
2: BLOOD_MOOR_RIVER_PRESETS,
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
* ------------------------------------------------------------------------- */
export class UnresolvedLevelSizeError extends Error {
constructor(levelId: number, levelName: string) {
super(`UnresolvedLevelSizeError: Level ${levelId} (${levelName}) has no pieces to derive size`)
this.name = 'UnresolvedLevelSizeError'
}
}
/**
* Derive the block grid for a level whose `Levels.txt` size is unset.
*/
export 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 barricadePieces = request.pieces.filter(p => /barricade/i.test(p.name))
if (barricadePieces.length === 0 && request.levelTypeName !== 'Act 5 - Barricade') {
throw new UnresolvedLevelSizeError(request.levelId, request.levelName)
}
if (request.pieces.length === 0 || barricadePieces.length === 0) {
throw new UnresolvedLevelSizeError(request.levelId, request.levelName)
}
// Derive vertical corridor dimensions from request.pieces:
// - Entrance width (16) + border cliffs (16 each side) gives width sizeX in [48..64] (6..8 blocks of 8 tiles).
// - Entrance (32) + march sections + barricade walls (16 each) + exit (32) gives height sizeY in [160..192] (20..24 blocks of 8 tiles).
// - Ensure sizeY > sizeX unconditionally holds for Levels 111 and 112 (aspect ratio ~3:1).
const entrance = barricadePieces.find(p => /entrance/i.test(p.name))?.levels[0]
const exit = barricadePieces.find(p => /exit/i.test(p.name))?.levels[0]
const baseWidth = entrance ? Math.max(entrance.width, 32) : 32
const sizeX = Math.min(64, Math.max(48, Math.ceil((baseWidth + 32) / 8) * 8))
const entranceH = entrance ? Math.max(entrance.height, 32) : 32
const exitH = exit ? Math.max(exit.height, 32) : 32
const wallSpacing = 32
const numBarricadeWalls = 2
const rawHeight = entranceH + exitH + numBarricadeWalls * (16 + wallSpacing) + 32
const sizeY = Math.min(192, Math.max(160, Math.ceil(rawHeight / 8) * 8))
return { sizeX, sizeY, source: 'LvlPrest Barricade corridor dynamic extent' }
}
/**
* 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, placedRects)
}
/** 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>,
stats?: WildernessStats,
): void {
const paintedRoadCells = new Set<string>()
const isInsideProtectedPreset = (x: number, y: number): boolean => {
for (const r of placedRects) {
if (theme.isProtectedPreset ? !theme.isProtectedPreset(r.name) : false) {
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
}
const belongsToPreset = placedRects.some(r => x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h)
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) {
if (belongsToPreset) {
return
}
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)
paintedRoadCells.add(`${x},${y}`)
}
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
}
}
const paintSplinePath = (
controlPoints: readonly { readonly x: number; readonly y: number }[],
brushRadius: number,
clearStrayClutter: boolean,
): void => {
if (controlPoints.length < 2) return
for (let i = 0; i < controlPoints.length - 1; i += 1) {
const p0 = i > 0 ? controlPoints[i - 1]! : {
x: 2 * controlPoints[0]!.x - controlPoints[1]!.x,
y: 2 * controlPoints[0]!.y - controlPoints[1]!.y,
}
const p1 = controlPoints[i]!
const p2 = controlPoints[i + 1]!
const p3 = i + 2 < controlPoints.length ? controlPoints[i + 2]! : {
x: 2 * controlPoints[controlPoints.length - 1]!.x - controlPoints[controlPoints.length - 2]!.x,
y: 2 * controlPoints[controlPoints.length - 1]!.y - controlPoints[controlPoints.length - 2]!.y,
}
const dist = Math.hypot(p2.x - p1.x, p2.y - p1.y)
const steps = Math.max(3, Math.ceil(dist / 1.0))
for (let s = 0; s <= steps; s += 1) {
const t = s / steps
const pt = catmullRomPoint(p0, p1, p2, p3, t)
const cx = Math.round(pt.x)
const cy = Math.round(pt.y)
if (brushRadius >= 1.5) {
for (let dy = -1; dy <= 1; dy += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
paintRoadTile(cx + dx, cy + dy, clearStrayClutter)
}
}
} else {
for (let dy = 0; dy <= 1; dy += 1) {
for (let dx = 0; dx <= 1; dx += 1) {
paintRoadTile(cx + dx, cy + dy, clearStrayClutter)
}
}
}
}
}
}
// 1. 3-tile-wide South-to-North main highway curved via Catmull-Rom spline
const midNY = Math.round((northEnd + hubY) / 2)
const mNorth = rng.int(-2, 2)
const cpNorth = { x: hubX + mNorth, y: midNY }
if (isInsideProtectedPreset(cpNorth.x, cpNorth.y)) cpNorth.x = hubX
const midSY = Math.round((hubY + southGateY) / 2)
const mSouth = rng.int(-2, 2)
const cpSouth = { x: hubX + mSouth, y: midSY }
if (isInsideProtectedPreset(cpSouth.x, cpSouth.y)) cpSouth.x = hubX
paintSplinePath([
{ x: hubX, y: northEnd },
cpNorth,
{ x: hubX, y: hubY },
cpSouth,
{ x: hubX, y: southGateY },
], 1.5, true)
// 2. 3-tile-wide West-to-East main highway curved via Catmull-Rom spline
const midWX = Math.round((westEnd + hubX) / 2)
const mWest = rng.int(-2, 2)
const cpWest = { x: midWX, y: hubY + mWest }
if (isInsideProtectedPreset(cpWest.x, cpWest.y)) cpWest.y = hubY
const midEX = Math.round((hubX + eastGateX) / 2)
const mEast = rng.int(-2, 2)
const cpEast = { x: midEX, y: hubY + mEast }
if (isInsideProtectedPreset(cpEast.x, cpEast.y)) cpEast.y = hubY
paintSplinePath([
{ x: westEnd, y: hubY },
cpWest,
{ x: hubX, y: hubY },
cpEast,
{ x: eastGateX, y: hubY },
], 1.5, true)
// If openGates contains non-standard gate blocks, connect them into the highway network via smooth spline
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 hub
const midX = Math.round((gateX + hubX) / 2) + rng.int(-1, 1)
const midY = Math.round((gateY + hubY) / 2) + rng.int(-1, 1)
paintSplinePath([
{ x: gateX, y: 4 },
{ x: midX, y: midY },
{ x: hubX, y: hubY },
], 1.5, true)
} else if (bx === 0 && Math.abs(gateY - hubY) > 2) {
// West gate branch to hub
const midX = Math.round((gateX + hubX) / 2) + rng.int(-1, 1)
const midY = Math.round((gateY + hubY) / 2) + rng.int(-1, 1)
paintSplinePath([
{ x: 4, y: gateY },
{ x: midX, y: midY },
{ x: hubX, y: hubY },
], 1.5, true)
}
}
}
// 3. 2-tile-wide spur roads connecting each major spawned landmark to the main highway via smooth spline
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)
let doorX: number
let doorY: number
let targetX: number
let targetY: number
if (distToNS <= distToEW) {
doorX = centerX < hubX ? r.x + r.w : r.x - 1
doorY = Math.max(r.y + 2, Math.min(r.y + r.h - 3, centerY))
targetX = hubX
targetY = doorY
} else {
doorX = Math.max(r.x + 2, Math.min(r.x + r.w - 3, centerX))
doorY = centerY < hubY ? r.y + r.h : r.y - 1
targetX = doorX
targetY = hubY
}
const midX = Math.round((doorX + targetX) / 2) + rng.int(-1, 1)
const midY = Math.round((doorY + targetY) / 2) + rng.int(-1, 1)
paintSplinePath([
{ x: doorX, y: doorY },
{ x: midX, y: midY },
{ x: targetX, y: targetY },
], 1.0, false)
}
if (stats) {
stats.proceduralRoadCells = (stats.proceduralRoadCells ?? 0) + paintedRoadCells.size
}
}
/**
* General outdoor divider network.
* @deprecated Procedural cross divider networks were removed in Issue #65 to restore
* Blizzard DRLG fidelity. In original Diablo II, wilderness barriers are formed naturally
* by cliff borders, water bodies, and LvlSub substitutions without artificial cross stone walls.
*/
export function stampThemedDividers(
_canvas: Canvas,
_gridWidth: number,
_gridHeight: number,
_inX: number,
_outY: number,
_placedRects: readonly PlacedPresetRect[],
_rng: Rng,
_theme: OutdoorDividerTheme,
stats?: WildernessStats,
): void {
if (stats) {
stats.proceduralDividerCells = 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>,
stats?: WildernessStats,
): void {
stampThemedRoads(
canvas,
gridWidth,
gridHeight,
inX,
outY,
placedRects,
rng,
{
roadFloor: (r) => ({
prop1: 194,
sequence: r.int(0, 5) === 0 ? 1 : 0,
style: 0,
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,
stats,
)
}
/**
* 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,
stats?: WildernessStats,
): void {
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
wallStyle: 2,
wallProp1: 129,
roadStyles: [0],
}, stats)
}
/**
* 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>,
stats?: WildernessStats,
): 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) => name !== 'Act 1 - River',
isIgnoredSpurPreset: (name) => /Fill|Berms/i.test(name),
},
openGates,
stats,
)
}
/**
* 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,
stats?: WildernessStats,
): void {
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
wallStyle: 1,
wallProp1: 129,
roadStyles: [1],
}, stats)
}
/**
* 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>,
stats?: WildernessStats,
): 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) => name !== 'Act 1 - River',
isIgnoredSpurPreset: (name) => /Fill/i.test(name),
},
openGates,
stats,
)
}
/**
* 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,
stats?: WildernessStats,
): void {
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
wallStyle: 1,
wallProp1: 129,
roadStyles: [1],
}, stats)
}
/**
* 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>,
stats?: WildernessStats,
): 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) => name !== 'Act 1 - River',
isIgnoredSpurPreset: (name) => /Fill|Filler/i.test(name),
},
openGates,
stats,
)
}
/**
* 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,
stats?: WildernessStats,
): void {
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
wallStyle: 2,
wallProp1: 129,
roadStyles: [1],
}, stats)
}
/**
* 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>,
levelsTable?: D2Table,
stats?: WildernessStats,
): 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, stats)
return
} else if (levelId >= 79 && levelId <= 82) {
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
return
} else if (levelId === 111 || levelId === 112 || levelId === 117) {
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
return
} else if (levelId >= 2 && levelId <= 7) {
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
return
// Level type name takes second precedence
} else if (levelTypeName === 'Act 2 - Desert') {
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
return
} else if (levelTypeName === 'Act 3 - Kurast') {
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
return
} else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') {
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
return
} else if (levelTypeName === 'Act 1 - Wilderness') {
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
return
}
// Unified actOfLevel fallback
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
if (effectiveAct === 2) {
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
} else if (effectiveAct === 3) {
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
} else if (effectiveAct === 5) {
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
} else if (effectiveAct === 1) {
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
}
}
/**
* 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,
levelsTable?: D2Table,
stats?: WildernessStats,
): 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, stats)
return
} else if (levelId >= 79 && levelId <= 82) {
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
return
} else if (levelId === 111 || levelId === 112 || levelId === 117) {
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
return
} else if (levelId >= 2 && levelId <= 7) {
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
return
}
// Level type name takes second precedence
if (levelTypeName === 'Act 2 - Desert') {
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
return
} else if (levelTypeName === 'Act 3 - Kurast') {
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
return
} else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') {
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
return
} else if (levelTypeName === 'Act 1 - Wilderness') {
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
return
}
// Unified actOfLevel fallback
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
if (effectiveAct === 2) {
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
} else if (effectiveAct === 3) {
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
} else if (effectiveAct === 5) {
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
} else if (effectiveAct === 1) {
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
}
}
/* ------------------------------------------------------------------------- *
* Outdoor Population Phase: Waypoints, Roadside Shrines/Chests, SuperUniques & Flavie
* (`DRLGOUTDOORS_SpawnAct12Waypoint`, `DRLGOUTDOORS_SpawnAct12Shrines`)
* ------------------------------------------------------------------------- */
export interface ActPopulationConfig {
readonly waypointId: number
readonly shrinePool: readonly number[]
readonly wellId: number
readonly chestPool: readonly number[]
readonly scatterPool: readonly number[]
}
/**
* Population object ID pool per Act for outdoor wilderness generation (`DRLGOUTDOORS_SpawnAct12Waypoint`, `DRLGOUTDOORS_SpawnAct12Shrines`).
*
* Each ID is a local DS1 Type 2 object record ID mapped through `object-lookup-data.ts` to Blizzard's `Objects.txt`:
* - `waypointId`:
* - Act 1: 37 -> Objects.txt row 119 (`Waypoint`, token WP)
* - Act 2: 10 -> Objects.txt row 156 (`Waypoint`, token WM)
* - Act 3: 1 -> Objects.txt row 237 (`Waypoint`, token WZ)
* - Act 4: 0 -> Objects.txt row 238 (`Waypoint`, token WV)
* - Act 5: 12 -> Objects.txt row 38 (`Waypoint`, token WT)
* - `wellId`:
* - Act 1: 31 -> Objects.txt row 84 (Healing Well, token HW)
* - Acts 2..5: 234 -> Objects.txt row 234 (Outdoor Well, token QY)
* - `shrinePool`:
* - Act 1: 29 (Shrine SF), 82 (Shrine SH), 83 (Shrine BC), 84 (Dummy SG)
* - Act 2: 54 (Z1), 55 (Z2), 103 (FL), 104 (A3), 105 (A4), 126 (IS)
* - Act 3: 14 (S1), 85 (BC), 111 (F3), 152 (HA), 227 (QX), 231 (HT), 235 (QZ), 236 (HO)
* - Act 4: 56 (Z5), 152 (HA), 227 (QX), 231 (HT), 235 (QZ), 236 (HO)
* - Act 5: 152 (HA), 227 (QX), 231 (HT), 235 (QZ), 236 (HO)
* - `chestPool`:
* - Act 1: 5 (chest L1), 89..94 (caskets C1..C6)
* - Act 2: 32 (CL), 33 (A1), 44..46 (R5, S1, CT)
* - Act 3: 65 (X1), 66 (X2), 67 (1R), 80 (OB), 81 (AF)
* - Act 4: 19 (XQ), 20 (QS), 24 (HO), 25 (HN)
* - Act 5: 24 (YP), 25 (6A), 26 (YZ), 27 (6Q), 28 (6B), 29 (6C)
* - `scatterPool`:
* - Act 1: 17 (BR), 18 (BF), 25 (TT)
* - Act 2: 14 (TT), 48 (BR), 122 (TT)
* - Act 3: 4 (FB), 5 (TO), 48 (DE), 179 (BR)
* - Act 4: 40 (HG), 63 (FB), 179 (BR)
* - Act 5: 13 (FB), 179 (BR), 4 (B6)
*/
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],
},
}
/**
* Validates outdoor population configuration against object-lookup metadata or an optional loaded Objects.txt table.
*/
export function validateActPopulationConfig(
config: ActPopulationConfig,
act: number,
objectsTable?: ObjectsTable | D2Table,
): { valid: boolean; totalChecked: number; unmapped: number[]; errors: string[] } {
const unmapped: number[] = []
let totalChecked = 0
const check = (id: number) => {
totalChecked += 1
const entry = lookupObject(act, 2, id)
if (!entry) {
unmapped.push(id)
} else if (objectsTable) {
if ('byId' in objectsTable) {
if (entry.objectsTxtId >= 0 && !objectsTable.byId.has(entry.objectsTxtId)) {
unmapped.push(id)
}
} else if ('header' in objectsTable && 'rows' in objectsTable) {
const idCol = objectsTable.header.indexOf('Id')
const found = idCol !== -1 && objectsTable.rows.some(row => Number(row[idCol]) === entry.objectsTxtId)
if (entry.objectsTxtId >= 0 && !found) {
unmapped.push(id)
}
}
}
}
check(config.waypointId)
check(config.wellId)
for (const id of config.shrinePool) check(id)
for (const id of config.chestPool) check(id)
for (const id of config.scatterPool) check(id)
const errors = unmapped.map(id => `DS1 Type 2 object ID ${id} is unmapped or missing in Objects.txt`)
return { valid: unmapped.length === 0, totalChecked, unmapped, errors }
}
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
}
function validateWaypointLandingBuffer(
canvas: Canvas,
tx: number,
ty: number,
placedRects: readonly PlacedPresetRect[],
padding = 2,
): boolean {
if (tx < padding || ty < padding || tx >= canvas.width - padding || ty >= canvas.height - padding) {
return false
}
for (const r of placedRects) {
const isOpenPreset = /(clearing|camp|bivouac)/i.test(r.name)
if (!isOpenPreset) {
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 (!cell.floors.some(f => !f.hidden && f.prop1 !== 0)) return false
}
}
for (const obj of canvas.objects) {
const ox = Math.floor(obj.x / SUB_TILES_PER_TILE)
const oy = Math.floor(obj.y / SUB_TILES_PER_TILE)
if (Math.abs(ox - tx) <= padding && Math.abs(oy - ty) <= padding) {
return false
}
}
return true
}
/**
* 1.13c DRLGOUTDOORS cell validity mask for waypoint spawning (D2Common.dll ≈0x6FD92700).
* Cell flags tested against 0x1B81: (flags & 0x1B81) === 0 indicates cell is valid.
*/
export const DRLG_GRID_SPAWN_INVALID_MASK = 0x1b81
/** Flag set on pGrid[1] when a general outdoor waypoint is placed (0x10000). */
export const DRLG_GRID_FLAG_WAYPOINT = 0x10000
/** Flag set on pGrid[1] when Cold Plains special waypoint is placed (0x20000). */
export const DRLG_GRID_FLAG_WAYPOINT_COLDPLAINS = 0x20000
/**
* Rolls a limited random number in [0, max - 1] matching 1.13c SEED_RollLimitedRandomNumber.
* Each call consumes exactly 1 RNG roll.
*/
export function rollLimited(rng: Rng, max: number): number {
if (max <= 0) return 0
return Math.floor(rng.next() * max)
}
/**
* Tests if an 8x8 cell grid block is valid for spawning an outdoor waypoint
* matching 1.13c DRLGOUTDOORS inline test (D2Common.dll ≈0x6FD92700):
* `test [grid], 0x1b81` => `(flags & 0x1B81) === 0`.
*
* @param grid - Grid containing per-cell 32-bit flags (2D number array, 1D array/Uint32Array, or grid object).
* @param x - Grid block X coordinate (1..nGridWidth-2).
* @param y - Grid block Y coordinate (1..nGridHeight-2).
* @param width - Optional width for 1D grid layouts.
* @returns true if valid for spawn ((flags & 0x1B81) === 0), false otherwise.
*/
export function TestGridCellSpawnValid(
grid: { width?: number; height?: number; flags?: Uint32Array | readonly number[] | readonly (readonly number[])[] } | readonly (readonly number[])[] | Uint32Array | readonly number[],
x: number,
y: number,
width?: number,
): boolean {
let flags = 0
if (Array.isArray(grid)) {
if (grid.length > 0 && Array.isArray(grid[0])) {
const row = (grid as readonly (readonly number[])[])[y]
if (!row || x < 0 || x >= row.length) return false
flags = row[x] ?? 0
} else {
const w = width ?? 1
if (x < 0 || y < 0) return false
const idx = y * w + x
flags = (grid as readonly number[])[idx] ?? 0
}
} else if (grid instanceof Uint32Array) {
const w = width ?? 1
if (x < 0 || y < 0) return false
const idx = y * w + x
flags = grid[idx] ?? 0
} else if (grid && typeof grid === 'object' && ('flags' in grid || 'width' in grid)) {
const g = grid as { width?: number; height?: number; flags?: Uint32Array | readonly number[] | readonly (readonly number[])[] }
const w = g.width ?? width ?? 1
const gFlags = g.flags
if (Array.isArray(gFlags)) {
if (gFlags.length > 0 && Array.isArray(gFlags[0])) {
const row = (gFlags as readonly (readonly number[])[])[y]
if (!row || x < 0 || x >= row.length) return false
flags = row[x] ?? 0
} else {
if (x < 0 || y < 0) return false
flags = (gFlags as readonly number[])[y * w + x] ?? 0
}
} else if (gFlags instanceof Uint32Array) {
if (x < 0 || y < 0) return false
flags = gFlags[y * w + x] ?? 0
}
}
// 1.13c inline mask: test [grid], 0x1b81 => (flags & 0x1B81) === 0
return (flags & DRLG_GRID_SPAWN_INVALID_MASK) === 0
}
/**
* Automatically locate and place an outdoor Waypoint (`DRLGOUTDOORS_SpawnAct12Waypoint`)
* matching 1.13c disassembly (PE timestamp 0x4B95C439, D2Common.dll ≈0x6FD92700).
*
* Priority 1: Check if a preset already placed a Waypoint object.
*
* Branch A (Cold Plains special, levelId == 3, LEVEL_COLDPLAINS):
* - Find connection to LEVEL_BLOODMOOR (levelId 2).
* - Find the boundary grid cell linking to Blood Moor.
* - Boundary clamp:
* if (j == 0) j = 1;
* if (i == 0) i = 1;
* if (j == nGridWidth - 1) j -= 1;
* if (i == nGridHeight - 1) i -= 1;
* - Set flag 0x20000 on pGrid[1].
* - Return immediately, consuming exactly 0 RNG rolls.
*
* Branch B (General path):
* - nWidth = nGridWidth - 2; nArea = nWidth * (nGridHeight - 2);
* - Build coordinate array: pCoord[i] = (i % nWidth, Math.floor(i / nWidth))
* - Shuffle: exactly nArea rounds, each round 2 rolls:
* r1 = rollLimited(rng, nArea)
* r2 = rollLimited(rng, nArea)
* swap(pCoord[r1], pCoord[r2])
* (Notice: this is NOT Fisher-Yates, it is Blizzard's 2*nArea swap shuffle!
* Do not change it to Fisher-Yates, comment that it's intentional for 1.13c parity.)
* - Iterate through shuffled pCoord:
* nX = pCoord[i].x + 1; nY = pCoord[i].y + 1;
* if (TestGridCellSpawnValid(grid, nX, nY)):
* Place waypoint here (flag 0x10000);
* break;
*/
export function spawnOutdoorWaypoint(
canvas: Canvas,
levelId: number,
act: number,
dirtPathGrid: Uint8Array | undefined,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
stats: WildernessStats,
levelTypeName?: string,
levelsTable?: D2Table,
seamConstraints?: readonly SeamConstraint[],
): void {
if (!hasOutdoorWaypoint(levelId, levelsTable)) return
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
// Priority 1: Check if a preset already placed a Waypoint object
const existingWp = canvas.objects.find(
o => isWaypointDs1Object(effectiveAct, o.type, 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 config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]!
const wpId = config.waypointId
const nGridWidth = Math.floor(canvas.width / TILES_PER_BLOCK)
const nGridHeight = Math.floor(canvas.height / TILES_PER_BLOCK)
if (nGridWidth < 3 || nGridHeight < 3) return
// Allocate 8x8 cell grid (pGrid[1] flags)
const grid: number[][] = Array.from({ length: nGridHeight }, () => new Array(nGridWidth).fill(0))
// Mark outer perimeter border blocks with 0x0080 (border flag, part of 0x1B81)
for (let x = 0; x < nGridWidth; x += 1) {
grid[0]![x] |= 0x0080
grid[nGridHeight - 1]![x] |= 0x0080
}
for (let y = 0; y < nGridHeight; y += 1) {
grid[y]![0] |= 0x0080
grid[y]![nGridWidth - 1] |= 0x0080
}
// Mark blocks overlapping placed preset rectangles with 0x0001 (occupied by preset)
for (const rect of placedRects) {
const bx0 = Math.max(0, Math.floor(rect.x / TILES_PER_BLOCK))
const by0 = Math.max(0, Math.floor(rect.y / TILES_PER_BLOCK))
const bx1 = Math.min(nGridWidth - 1, Math.floor((rect.x + rect.w - 1) / TILES_PER_BLOCK))
const by1 = Math.min(nGridHeight - 1, Math.floor((rect.y + rect.h - 1) / TILES_PER_BLOCK))
for (let by = by0; by <= by1; by += 1) {
for (let bx = bx0; bx <= bx1; bx += 1) {
grid[by]![bx] |= 0x0001
}
}
}
// Mark blocks with walls or missing floors as 0x1000 (impassable/collision)
for (let by = 1; by < nGridHeight - 1; by += 1) {
for (let bx = 1; bx < nGridWidth - 1; bx += 1) {
let blocked = false
for (let dy = 0; dy < TILES_PER_BLOCK; dy += 1) {
const ty = by * TILES_PER_BLOCK + dy
const row = canvas.cells[ty]
if (!row) {
blocked = true
break
}
for (let dx = 0; dx < TILES_PER_BLOCK; dx += 1) {
const tx = bx * TILES_PER_BLOCK + dx
const cell = row[tx]
if (!cell) {
blocked = true
break
}
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) {
blocked = true
break
}
if (!cell.floors.some(f => !f.hidden && f.prop1 !== 0)) {
blocked = true
break
}
}
if (blocked) break
}
if (blocked) {
grid[by]![bx] |= 0x1000
}
}
}
// Mark blocks containing pre-existing objects as 0x0001
for (const obj of canvas.objects) {
const obx = Math.floor(obj.x / (SUB_TILES_PER_TILE * TILES_PER_BLOCK))
const oby = Math.floor(obj.y / (SUB_TILES_PER_TILE * TILES_PER_BLOCK))
if (oby >= 0 && oby < nGridHeight && obx >= 0 && obx < nGridWidth) {
grid[oby]![obx] |= 0x0001
}
}
// Branch A: Cold Plains special (levelId == 3, LEVEL_COLDPLAINS)
if (levelId === 3) {
// 1. Find connection to LEVEL_BLOODMOOR (levelId 2).
// 2. Find the boundary grid cell linking to Blood Moor.
let j = 0
let i = Math.floor(nGridHeight / 2)
const gate = stats.gateOpenings?.find(g => g.toLevelId === 2)
if (gate !== undefined) {
if (gate.side === 'west') {
j = 0
i = gate.blockCoord ?? (gate.tileCoord ? Math.floor(gate.tileCoord.y / TILES_PER_BLOCK) : Math.floor(nGridHeight / 2))
} else if (gate.side === 'east') {
j = nGridWidth - 1
i = gate.blockCoord ?? (gate.tileCoord ? Math.floor(gate.tileCoord.y / TILES_PER_BLOCK) : Math.floor(nGridHeight / 2))
} else if (gate.side === 'north') {
i = 0
j = gate.blockCoord ?? (gate.tileCoord ? Math.floor(gate.tileCoord.x / TILES_PER_BLOCK) : Math.floor(nGridWidth / 2))
} else if (gate.side === 'south') {
i = nGridHeight - 1
j = gate.blockCoord ?? (gate.tileCoord ? Math.floor(gate.tileCoord.x / TILES_PER_BLOCK) : Math.floor(nGridWidth / 2))
}
} else {
const sc = seamConstraints?.find(c => c.toLevelId === 2 || c.fromLevelId === 2)
if (sc !== undefined) {
if (sc.side === 'west') {
j = 0
i = sc.blockCoord ?? (sc.tileCoord ? Math.floor(sc.tileCoord.y / TILES_PER_BLOCK) : Math.floor(nGridHeight / 2))
} else if (sc.side === 'east') {
j = nGridWidth - 1
i = sc.blockCoord ?? (sc.tileCoord ? Math.floor(sc.tileCoord.y / TILES_PER_BLOCK) : Math.floor(nGridHeight / 2))
} else if (sc.side === 'north') {
i = 0
j = sc.blockCoord ?? (sc.tileCoord ? Math.floor(sc.tileCoord.x / TILES_PER_BLOCK) : Math.floor(nGridWidth / 2))
} else if (sc.side === 'south') {
i = nGridHeight - 1
j = sc.blockCoord ?? (sc.tileCoord ? Math.floor(sc.tileCoord.x / TILES_PER_BLOCK) : Math.floor(nGridWidth / 2))
}
}
}
// 3. Boundary clamp:
if (j === 0) j = 1
if (i === 0) i = 1
if (j === nGridWidth - 1) j -= 1
if (i === nGridHeight - 1) i -= 1
j = Math.max(1, Math.min(nGridWidth - 2, j))
i = Math.max(1, Math.min(nGridHeight - 2, i))
// 4. Set flag 0x20000 on pGrid[1]
grid[i]![j] |= DRLG_GRID_FLAG_WAYPOINT_COLDPLAINS
let chosenX = j * TILES_PER_BLOCK + 4
let chosenY = i * TILES_PER_BLOCK + 4
if (!validateWaypointLandingBuffer(canvas, chosenX, chosenY, placedRects, 2)) {
let found = false
for (let dy = 2; dy <= 5; dy += 1) {
for (let dx = 2; dx <= 5; dx += 1) {
const tx = j * TILES_PER_BLOCK + dx
const ty = i * TILES_PER_BLOCK + dy
if (validateWaypointLandingBuffer(canvas, tx, ty, placedRects, 1)) {
chosenX = tx
chosenY = ty
found = true
break
}
}
if (found) break
}
}
canvas.objects.push({
type: 2,
id: wpId,
x: chosenX * SUB_TILES_PER_TILE + 2,
y: chosenY * SUB_TILES_PER_TILE + 2,
flags: 0,
})
stats.waypointsSpawned = 1
stats.waypointTile = { x: chosenX, y: chosenY }
stats.notes.push(
`spawned outdoor waypoint (id=${String(wpId)}) at tile (${String(chosenX)}, ${String(chosenY)}) [Cold Plains special grid cell (${String(j)}, ${String(i)})]`,
)
// 5. Return immediately! This consumes 0 RNG rolls.
return
}
// Branch B: General path
const nWidth = nGridWidth - 2
const nHeight = nGridHeight - 2
const nArea = nWidth * nHeight
if (nArea <= 0) return
// Build coordinate array: for i in 0..nArea-1: pCoord[i] = (i % nWidth, Math.floor(i / nWidth))
const pCoord: { x: number; y: number }[] = []
for (let i = 0; i < nArea; i += 1) {
pCoord.push({
x: i % nWidth,
y: Math.floor(i / nWidth),
})
}
// Shuffle: exactly nArea rounds, each round 2 rolls:
// r1 = rollLimited(rng, nArea)
// r2 = rollLimited(rng, nArea)
// swap(pCoord[r1], pCoord[r2])
// (Notice: this is NOT Fisher-Yates, it is Blizzard's 2*nArea swap shuffle!
// Do not change it to Fisher-Yates, comment that it's intentional for 1.13c parity.)
for (let round = 0; round < nArea; round += 1) {
const r1 = rollLimited(rng, nArea)
const r2 = rollLimited(rng, nArea)
const temp = pCoord[r1]!
pCoord[r1] = pCoord[r2]!
pCoord[r2] = temp
}
// Iterate through shuffled pCoord:
// nX = pCoord[i].x + 1; nY = pCoord[i].y + 1;
// if (TestGridCellSpawnValid(grid, nX, nY)): // 1.13c inline mask: test [grid], 0x1b81 => (flags & 0x1B81) === 0
// Place waypoint here (flag 0x10000);
// break;
let placed = false
for (let i = 0; i < nArea; i += 1) {
const nX = pCoord[i]!.x + 1
const nY = pCoord[i]!.y + 1
if (TestGridCellSpawnValid(grid, nX, nY)) {
grid[nY]![nX] |= DRLG_GRID_FLAG_WAYPOINT
let chosenX = nX * TILES_PER_BLOCK + 4
let chosenY = nY * TILES_PER_BLOCK + 4
if (!validateWaypointLandingBuffer(canvas, chosenX, chosenY, placedRects, 2)) {
let found = false
for (let dy = 2; dy <= 5; dy += 1) {
for (let dx = 2; dx <= 5; dx += 1) {
const tx = nX * TILES_PER_BLOCK + dx
const ty = nY * TILES_PER_BLOCK + dy
if (validateWaypointLandingBuffer(canvas, tx, ty, placedRects, 1)) {
chosenX = tx
chosenY = ty
found = true
break
}
}
if (found) break
}
}
canvas.objects.push({
type: 2,
id: wpId,
x: chosenX * SUB_TILES_PER_TILE + 2,
y: chosenY * SUB_TILES_PER_TILE + 2,
flags: 0,
})
stats.waypointsSpawned = 1
stats.waypointTile = { x: chosenX, y: chosenY }
stats.notes.push(
`spawned outdoor waypoint (id=${String(wpId)}) at tile (${String(chosenX)}, ${String(chosenY)}) [grid cell (${String(nX)}, ${String(nY)})]`,
)
placed = true
break
}
}
// Fallback if no cell passed TestGridCellSpawnValid
if (!placed && pCoord.length > 0) {
const fallbackCoord = pCoord[0]!
const nX = fallbackCoord.x + 1
const nY = fallbackCoord.y + 1
grid[nY]![nX] |= DRLG_GRID_FLAG_WAYPOINT
const chosenX = nX * TILES_PER_BLOCK + 4
const chosenY = nY * TILES_PER_BLOCK + 4
canvas.objects.push({
type: 2,
id: wpId,
x: chosenX * SUB_TILES_PER_TILE + 2,
y: chosenY * SUB_TILES_PER_TILE + 2,
flags: 0,
})
stats.waypointsSpawned = 1
stats.waypointTile = { x: chosenX, y: chosenY }
}
}
/**
* 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,
levelsTable?: D2Table,
): void {
if (levelId <= 0 || levelId === 108 || levelId === 110) return
let 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 (levelsTable) {
const row = levelsTable.rows.find(candidate => Number(cell(levelsTable, candidate, 'Id')) === levelId)
if (row && cell(levelsTable, row, 'DrlgType') === '3') {
const isTown = [1, 40, 75, 103, 109].includes(levelId)
if (!isTown) isOutdoorLevel = true
}
}
if (!isOutdoorLevel) return
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
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)
}
}
// Secondary exploration paths: avoid the main road and its immediate shoulder (within 3 tiles)
const secondaryCandidates = dirtPathGrid !== undefined
? candidates.filter(c => !isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, c.x, c.y, 0, 3))
: candidates
const pickShrineSpot = (candidateList: readonly { x: number; y: number }[], minDist: number): { x: number; y: number } | null => {
const indices = Array.from({ length: candidateList.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 = candidateList[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 along secondary exploration paths (>= 10 tiles, relaxing to >= 7 if constrained)
for (let i = 0; i < targetShrineCount; i += 1) {
const spot = (secondaryCandidates.length > 0
? (pickShrineSpot(secondaryCandidates, 10) ?? pickShrineSpot(secondaryCandidates, 7))
: null) ?? (pickShrineSpot(candidates, 10) ?? pickShrineSpot(candidates, 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 DRLGOUTDOORS_SpawnAct12Waypoint = spawnOutdoorWaypoint
export const spawnAct12Waypoint = spawnOutdoorWaypoint
export const DRLGOUTDOORS_SpawnAct12Shrines = spawnOutdoorShrines
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: 'pantherwoman1',
presetMatch: /Desert Tomb 2|Claw Viper|Valley Ruin 1/i,
defaultLandmark: 'Act 2 - Desert Tomb 2',
modifiers: ['cursed', 'strong'],
minionCount: [4, 6],
minionMonsterId: 'pantherwoman1',
objectId: 76,
defaultRelativePos: { x: 0.48, y: 0.52 },
},
{
id: 'Beetleburst',
name: 'Beetleburst',
role: 'superunique',
act: 2,
levelIds: [43],
monsterId: 'scarab2',
presetMatch: /Desert Oasis|Desert Lair/i,
defaultLandmark: 'Act 2 - Desert Oasis 1',
modifiers: ['magicresistant'],
minionCount: [4, 6],
minionMonsterId: 'scarab2',
objectId: 77,
defaultRelativePos: { x: 0.52, y: 0.48 },
},
{
id: 'Dark Elder',
name: 'Dark Elder',
role: 'superunique',
act: 2,
levelIds: [44],
monsterId: 'zombie5',
presetMatch: /Desert Ruins Elder/i,
defaultLandmark: 'Act 2 - Desert Ruins Elder',
modifiers: ['fast', 'magicresistant'],
minionCount: [4, 6],
minionMonsterId: 'zombie5',
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: 'arach4',
presetMatch: /Spider Cavern|Spider/i,
defaultLandmark: 'Act 3 - Spider Cavern',
modifiers: ['strong', 'cursed'],
minionCount: [4, 6],
minionMonsterId: 'arach4',
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: 'fetishshaman4',
presetMatch: /Flayer Dungeon/i,
defaultLandmark: 'Act 3 - Flayer Dungeon',
modifiers: ['magicresistant', 'fireenchant'],
minionCount: [4, 6],
minionMonsterId: 'fetish4',
objectId: 82,
defaultRelativePos: { x: 0.52, y: 0.48 },
},
{
id: 'Battlemaid Sarina',
name: 'Battlemaid Sarina',
role: 'superunique',
act: 3,
levelIds: [80, 94],
monsterId: 'corruptrogue5',
presetMatch: /Burbs Temple|Ruined Temple|MetroTemple/i,
defaultLandmark: 'Act 3 - Burbs Temple',
modifiers: ['fast', 'spectralhit'],
minionCount: [4, 6],
minionMonsterId: 'corruptrogue5',
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: 'imp3',
presetMatch: /Barricade Prison 1|Barricade Ruins/i,
defaultLandmark: 'Act 5 - Barricade Prison 1',
modifiers: ['coldenchant'],
minionCount: [4, 6],
minionMonsterId: 'imp3',
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: 'minion1',
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: 'snowyeti4',
presetMatch: /Frozen River|Anya/i,
defaultLandmark: 'Act 5 - Frozen River',
modifiers: ['coldenchant', 'manahit'],
minionCount: [4, 6],
minionMonsterId: 'snowyeti4',
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: 'minion6',
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`)
const animSpeed = request.pieces.find(p => p.animSpeed && p.animSpeed > 0)?.animSpeed
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
const isSnowBarricade = request.subType === 11 || /snow|tundra/i.test(request.levelName)
const effectiveMask = wildernessDt1Mask(request.levelTypeName, request.subType, request.levelName)
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 fromDt1 = deriveGroundTilesFromDt1Libraries(request.dt1Libraries, effectiveMask)
if (fromDt1.length > 0) groundTiles = fromDt1
}
if (groundTiles === null || groundTiles.length === 0) {
const isSnow = request.levelTypeName === 'Act 5 - Barricade' && isSnowBarricade
const poolKey = isSnow ? 'Act 5 - Barricade Snow' : request.levelTypeName
const canonicalPool = CANONICAL_GROUND_POOLS[poolKey] ?? CANONICAL_GROUND_POOLS[request.levelTypeName]
if (canonicalPool && canonicalPool.length > 0) {
groundTiles = [...canonicalPool]
} else {
const canonical = isSnow
? (CANONICAL_GROUND_TILES['Act 5 - Barricade Snow'] ?? { style: 6, sequence: 0 })
: 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: effectiveMask,
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: [],
gateOpenings: [],
proceduralRoadCells: 0,
proceduralDividerCells: 0,
proceduralWaterCells: 0,
proceduralWallCells: 0,
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: [],
proceduralRoadCells: 0, proceduralDividerCells: 0, proceduralWaterCells: 0, proceduralWallCells: 0,
gateOpenings: [],
}
let wallLayers = 1
let floorLayers = 1
let substitutionType = 0
let version = 0
const inferredAct = actOfLevel(request.levelId, request.levelTypeName, undefined, request.levelsTable)
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, rng, stats, specialPlacedRects)
} 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, request.levelsTable, request.seamConstraints)
spawnOutdoorShrines(canvas, request.levelId, act, undefined, [], populationRng, stats, request.shrineSubstitutions, 0, request.levelTypeName, request.levelsTable)
} else {
placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, rng, stats)
}
spawnLandmarkEntities(
canvas,
request.levelId,
undefined,
specialPlacedRects,
gridWidth,
gridHeight,
stats,
landmarkDefs,
)
const groundTransitions = applyGroundTransitions(canvas, {
libraries: request.dt1Libraries,
dt1Mask: wildernessDt1Mask(request.levelTypeName),
levelTypeName: request.levelTypeName,
levelId: request.levelId,
rng,
placedRects: specialPlacedRects,
})
stats.groundTransitions = groundTransitions
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: effectiveMask,
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,
groundTransitions: stats.groundTransitions,
borderPiecesAvailable: 0,
borderBlocks: 0,
borderUsage: {},
roadCells: stats.roadCells,
roadSegments: stats.roadSegments,
anchors: stats.anchors,
entrances: stats.entrances,
specialPresets: stats.specialPresets,
proceduralRoadCells: stats.proceduralRoadCells ?? 0,
proceduralDividerCells: stats.proceduralDividerCells ?? 0,
proceduralWaterCells: stats.proceduralWaterCells ?? 0,
proceduralWallCells: stats.proceduralWallCells ?? 0,
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 || request.levelId === 112) {
levelPieces = request.pieces.filter(p => !/snow/i.test(p.name))
} else if (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 || request.seamConstraints !== undefined) {
act1Plan = planAct1OutdoorLevel(request, gridWidth, gridHeight, rng, stats)
stats.anchors = act1Plan.anchors.length
stats.entrances = toEntrances(act1Plan.anchors)
if (act1Plan.gateOpenings !== undefined) {
stats.gateOpenings = act1Plan.gateOpenings
}
const vertexResult = createDrlgVertices(act1Plan, gridWidth, gridHeight, rng)
stats.drlgVertices = vertexResult.vertices
stats.roadPolylines = vertexResult.polylines
const polylines = vertexResult.polylines
if (polylines.length > 0 && request.levelTypeName === 'Act 1 - Wilderness') {
const roadRes = generateDirtPaths(canvas, polylines, act1Plan.roadObstacleBlocks)
dirtPathGrid = roadRes.dirtPathGrid
stats.roadCells = roadRes.roadCells
stats.roadSegments = roadRes.roadSegments
stats.proceduralRoadCells = (stats.proceduralRoadCells ?? 0) + roadRes.roadCells
}
}
// Step 4A: Lay directional border around perimeter (respecting open gates & Town Transitions)
DRLGOUTPLACE_PlaceAct1245OutdoorBorders(
canvas,
borderPieces,
gridWidth,
gridHeight,
rng,
stats,
request.levelId,
request.openGates ?? act1Plan?.openGates,
request.claimedBorderBlocks ?? act1Plan?.claimedBorderBlocks,
request.gates,
request.seamConstraints,
)
// 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 ? BLOOD_MOOR_RIVER_PRESETS : BLOOD_MOOR_POND_PRESETS
} 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,
request.levelsTable,
stats,
)
// 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 Barricade Topography: Barricades, trenches, frozen lakes (DRLGOUTSIEGE_InitAct5OutdoorLevel).
// Ground Truth Invariant (D2Common.dll VA 0x6fdb98b5):
// cmp edx, 0x6f (111); jl -> jump to 0x6fdb89c4 (Level 110 Bloody Foothills 15-strip assembly, handled above).
// Levels 111 (Frigid Highlands), 112 (Arreat Plateau), and 117 (Frozen Tundra) enter 0x6fdb98df here.
// Level 110 and Levels 111/112/117 are strictly mutually exclusive branches in 1.13c.
if (
(request.levelId >= 111 && request.levelId <= 117) ||
request.levelTypeName === 'Act 5 - Barricade'
) {
initAct5OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats)
}
// Step 4D: Smooth boundaries between contrasting ground types (roads, water, shore)
// using DRLGROOMTILE auto-tiling bitmasks.
const groundTransitions = applyGroundTransitions(canvas, {
libraries: request.dt1Libraries,
dt1Mask: effectiveMask,
levelTypeName: request.levelTypeName,
levelId: request.levelId,
rng,
placedRects: allPlacedRects,
})
stats.groundTransitions = groundTransitions
// 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, request.levelsTable, request.seamConstraints)
spawnOutdoorShrines(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.shrineSubstitutions, themeIndex, request.levelTypeName, request.levelsTable)
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: effectiveMask,
subType: request.subType,
subTheme: themeIndex,
groundTile: primaryGroundTile,
groundTiles: groundTiles ?? undefined,
groundCells: stats.groundCells,
groundTransitions: stats.groundTransitions,
borderPiecesAvailable: borderPieces.length,
borderBlocks: stats.borderStamped,
borderUsage: stats.borderPieces,
roadCells: stats.roadCells,
roadSegments: stats.roadSegments,
anchors: stats.anchors,
entrances: stats.entrances,
gateOpenings: stats.gateOpenings ?? act1Plan?.gateOpenings ?? [],
specialPresets: stats.specialPresets,
drlgVertices: stats.drlgVertices,
roadPolylines: stats.roadPolylines,
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,
proceduralRoadCells: stats.proceduralRoadCells ?? 0,
proceduralDividerCells: stats.proceduralDividerCells ?? 0,
proceduralWaterCells: stats.proceduralWaterCells ?? 0,
proceduralWallCells: stats.proceduralWallCells ?? 0,
cells: canvas.width * canvas.height,
unresolved: stats.unresolved,
unimplementedPasses: UNIMPLEMENTED_PASSES,
notes: stats.notes,
...(stats.siegeTopography ? { siegeTopography: stats.siegeTopography } : {}),
...(animSpeed !== undefined ? { animSpeed } : {}),
},
...(animSpeed !== undefined ? { animSpeed } : {}),
}
}