/** * 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 } 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> = { 'Act 1 - Wilderness': 0x44103, 'Act 3 - Jungle': 0x04, 'Act 2 - Desert': 0x01, 'Act 3 - Kurast': 0x01, 'Act 4 - Mesa': 0x01, 'Act 4 - Lava': 0x01, 'Act 5 - Siege': 0x11, 'Act 5 - Barricade': 0x11, } /** * The ground DT1 mask for a level type. * * @param levelTypeName - `LvlTypes.txt` name. * @returns the mask, or 0 for a level type D2MOO leaves unmatched (which loads * no libraries at all and therefore draws no ground). */ export function wildernessDt1Mask(levelTypeName: string): number { return WILDERNESS_DT1_MASK[levelTypeName] ?? 0 } /* ------------------------------------------------------------------------- * * Public request / response shapes * ------------------------------------------------------------------------- */ /** * One `LvlPrest.txt` row that can be stamped into a wilderness level. * * These are the border and fill pieces — `Act 1 - Wild Border 1..12`, * `Act 1 - Wild Cliff Border 2..10`, `Act 2 - Desert Border 1..12`, * `Act 2 - Desert Fill *`, `Act 3 - Jungle W/E/...` and so on. Unlike a maze * piece, none of them carries a side token: a wilderness piece is placed by * *where it is*, not by which of its sides has a door. */ export interface WildernessPiece { /** `LvlPrest.txt` `Name`. */ readonly name: string /** Decoded variants, in `File1..File6` order. */ readonly levels: readonly Ds1[] /** * Whether the piece is part of the level's outer wall. * * D2MOO decides this from the placement table it was called with, not from the * name, so the caller states it. Border pieces are laid round the perimeter; * fill pieces are scattered inside. */ readonly border: boolean /** 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 /** Optional set of block keys (`${x},${y}`) claimed by transition/facade presets to skip during border placement. */ readonly claimedBorderBlocks?: ReadonlySet /** 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 & { readonly landmarkEntities?: LandmarkEntity[] readonly notes?: string[] readonly unresolved?: string[] readonly gateOpenings?: readonly GateOpening[] readonly groundTransitions?: GroundTransitionResult 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> = { 'Act 1 - Wilderness': { style: 0, sequence: 0 }, 'Act 2 - Desert': { style: 0, sequence: 1 }, 'Act 3 - Jungle': { style: 0, sequence: 0 }, 'Act 3 - Kurast': { style: 0, sequence: 0 }, 'Act 4 - Mesa': { style: 10, sequence: 0 }, 'Act 4 - Lava': { style: 20, sequence: 3 }, 'Act 5 - Siege': { style: 0, sequence: 0 }, 'Act 5 - Barricade': { style: 0, sequence: 0 }, } /** Canonical default open ground floor tile pools per wilderness level type. */ export const CANONICAL_GROUND_POOLS: Readonly> = { 'Act 1 - Wilderness': [{ style: 0, sequence: 0, weight: 1 }], 'Act 2 - Desert': [{ style: 0, sequence: 1, weight: 1 }], 'Act 3 - Jungle': [{ style: 0, sequence: 0, weight: 1 }], 'Act 3 - Kurast': [{ style: 0, sequence: 0, weight: 1 }], 'Act 4 - Mesa': [{ style: 10, sequence: 0, weight: 1 }], 'Act 4 - Lava': [{ style: 20, sequence: 3, weight: 1 }], 'Act 5 - Siege': [{ style: 0, sequence: 0, weight: 1 }], 'Act 5 - Barricade': [{ style: 0, sequence: 0, weight: 1 }], } /* ------------------------------------------------------------------------- * * Classification * ------------------------------------------------------------------------- */ /** * Classify a `LvlSub.txt` row. * * `LvlSub.Type` is the join key against `Levels.txt.SubType` and `SubShrine`, and * the shipped rows fall into fixed bands: `0..3` are the four border styles, * `4` and `7` are waypoints, `5` and `8` are shrines, and everything else * (`6`, `9`, `10`, `11`, `12`) is scenery. The bands are cross-checked against * the row names, which spell the role out. * * @param name - `LvlSub.txt` `Name`. * @param type - `LvlSub.txt` `Type`. * @returns the role. */ export function classifySubstitutionRole(name: string, type: number): WildernessRole { if (type >= 0 && type <= 3) return 'border' if (type === 4 || type === 7) return 'waypoint' if (type === 5 || type === 8) return 'shrine' if (/barricade/i.test(name)) return 'border' return 'object' } /* ------------------------------------------------------------------------- * * The canvas * ------------------------------------------------------------------------- */ /** A blank wall cell, matching the decoder's placeholder. */ export function emptyWall(): Ds1Wall { return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false } } /** A blank floor or shadow cell. */ export function emptyFloor(): Ds1Floor { return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false } } /** The mutable map under construction. */ export interface Canvas { readonly width: number readonly height: number readonly cells: Ds1Cell[][] readonly wallLayers: number readonly floorLayers: number readonly substitutionLayers: number readonly objects: Ds1Object[] } /** * Create an empty map of the given cell extent. * * @param width - cells. * @param height - cells. * @param wallLayers - wall layers every cell carries. * @param floorLayers - floor layers every cell carries. * @param substitutionLayers - substitution layers every cell carries. * @returns the canvas. */ export function createCanvas(width: number, height: number, wallLayers: number, floorLayers: number, substitutionLayers: number): Canvas { const cells: Ds1Cell[][] = [] for (let y = 0; y < height; y += 1) { const row: Ds1Cell[] = [] for (let x = 0; x < width; x += 1) { row.push({ walls: Array.from({ length: wallLayers }, emptyWall), floors: Array.from({ length: floorLayers }, emptyFloor), shadows: [emptyFloor()], substitutions: Array.from({ length: substitutionLayers }, () => ({ value: 0 })), }) } cells.push(row) } return { width, height, cells, wallLayers, floorLayers, substitutionLayers, objects: [] } } /** * Copy a decoded map's cells into the canvas at a cell offset. * * Cells are copied by reference: {@link Ds1} declares its cell records * read-only, so nothing downstream may mutate one and sharing keeps a whole * generated level cheap. Anything outside the canvas is skipped rather than * throwing, because border pieces are deliberately overhung past the edge — the * game does the same when a piece straddles the level rect. * * Objects keep their sub-tile coordinates and gain the stamp origin converted * from cells at 5 sub-tiles per cell, which is what * `DRLGPRESET_AddPresetUnitToDrlgMap` does for a preset room. * * @param canvas - the canvas. * @param source - the piece to stamp. * @param originX - left edge in cells. * @param originY - top edge in cells. * @returns the number of cells actually written. */ export function stampDs1(canvas: Canvas, source: Ds1, originX: number, originY: number): number { let written = 0 for (let y = 0; y < source.height; y += 1) { const targetY = originY + y if (targetY < 0 || targetY >= canvas.height) continue const sourceRow = source.cells[y] if (sourceRow === undefined) continue const targetRow = canvas.cells[targetY]! for (let x = 0; x < source.width; x += 1) { const targetX = originX + x if (targetX < 0 || targetX >= canvas.width) continue const cell = sourceRow[x] if (cell === undefined) continue const targetCell = targetRow[targetX]! // Floors: copy any non-empty floor layer from source. If source cell has no // non-empty floors, target's ground floor is preserved intact. for (let i = 0; i < cell.floors.length; i += 1) { const floor = cell.floors[i] if (floor !== undefined && !floor.hidden && floor.prop1 !== 0) { const targetFloors = targetCell.floors as Ds1Floor[] if (i < targetFloors.length) { targetFloors[i] = floor } else { targetFloors.push(floor) } } } // Walls: copy any non-empty wall layer from source. for (let i = 0; i < cell.walls.length; i += 1) { const wall = cell.walls[i] if (wall !== undefined && !wall.hidden && wall.prop1 !== 0) { const targetWalls = targetCell.walls as Ds1Wall[] if (i < targetWalls.length) { targetWalls[i] = wall } else { targetWalls.push(wall) } } } // Shadows: copy any non-empty shadow layer from source. for (let i = 0; i < cell.shadows.length; i += 1) { const shadow = cell.shadows[i] if (shadow !== undefined && !shadow.hidden && shadow.prop1 !== 0) { const targetShadows = targetCell.shadows as Ds1Floor[] if (i < targetShadows.length) { targetShadows[i] = shadow } else { targetShadows.push(shadow) } } } // Substitutions: copy non-zero substitution layer values. for (let i = 0; i < cell.substitutions.length; i += 1) { const sub = cell.substitutions[i] if (sub !== undefined && sub.value !== 0) { const targetSubs = targetCell.substitutions as Ds1Substitution[] if (i < targetSubs.length) { targetSubs[i] = sub } else { targetSubs.push(sub) } } } written += 1 } } for (const object of source.objects) { canvas.objects.push({ type: object.type, id: object.id, x: originX * SUB_TILES_PER_TILE + object.x, y: originY * SUB_TILES_PER_TILE + object.y, flags: object.flags, }) } return written } /** A candidate ground tile with an optional weighting/rarity. */ export interface WeightedGroundTile { readonly style: number readonly sequence: number /** * Relative selection weight (e.g. from `RarityFrameIndex`, `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() 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 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 = 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 { 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 = 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. if (options?.levelTypeName !== undefined && options.levelTypeName !== 'Act 1 - Wilderness') { return { transitionCells: 0, roadTransitions: 0, waterTransitions: 0 } } // Build set of available floor tiles if libraries are provided const availableTiles = new Set() if (options?.libraries && options.libraries.length > 0) { 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 => { if (availableTiles.size === 0) return true 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)) { if (floor.style === 0) { 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)) { const waterMask = computeNeighborMask(canvas, x, y, (nCell) => { return isWaterCell(nCell?.floors[0]) }) if (waterMask === 0xFF) { // Interior deep water if (floor.style === 2 && floor.sequence !== 24) { if (isTileAvailable(2, 24)) { pendingUpdates.push({ x, y, style: 2, sequence: 24, prop1: 2, isWater: true }) } } } else { // Boundary water cell if (floor.style === 2) { 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) { 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[] = [ 'DRLGVER_CreateVertices', 'DRLGOUTPLACE_CreateLevelConnections', 'DRLGOUTDOORS_SpawnAct3Mephisto', ] /* ------------------------------------------------------------------------- * * Border * ------------------------------------------------------------------------- */ type BorderRole = 'tl' | 'tr' | 'br' | 'bl' | 'top' | 'right' | 'bottom' | 'left' function borderRoleAt(x: number, y: number, gridWidth: number, gridHeight: number): BorderRole { if (x === 0 && y === 0) return 'tl' if (x === gridWidth - 1 && y === 0) return 'tr' if (x === gridWidth - 1 && y === gridHeight - 1) return 'br' if (x === 0 && y === gridHeight - 1) return 'bl' if (y === 0) return 'top' if (x === gridWidth - 1) return 'right' if (y === gridHeight - 1) return 'bottom' return 'left' } /** * Find the directional border piece matching `DRLGOUTPLACE_PlaceAct1245OutdoorBorders`. * * 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 = { 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 = { 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 { const openings = new Set() 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, claimedBorderBlocks?: ReadonlySet, 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() 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 readonly claimedBorderBlocks: Set 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> = { 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() const claimedBorderBlocks = new Set() 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 { readonly x: number readonly y: number readonly type: 'gate' | 'preset' | 'hub' | 'waypoint' | 'inflection' readonly label?: string | undefined readonly id?: number | 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 }[][] } /** * 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() 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 } /** * Build macro control vertex graph and smooth Catmull-Rom spline polylines * connecting anchors to a central hub (`DRLGVER_CreateVertices` / `DRLGOUTDOORS_SpawnAct1DirtPaths`). */ export function createDrlgVertices( plan: Act1OutdoorPlan, gridWidth: number, gridHeight: number, rng: Rng, ): DrlgVertexResult { if (plan.anchors.length < 2) { const empty: DrlgVertex[] = [] return Object.assign(empty, { vertices: empty, polylines: [], lines: [], }) as DrlgVertexResult } // 1. Central Crossroads Hub let sumBx = 0 let sumBy = 0 for (const anchor of plan.anchors) { sumBx += Math.floor(anchor.interiorCell.x / TILES_PER_BLOCK) sumBy += Math.floor(anchor.interiorCell.y / TILES_PER_BLOCK) } let hubBx = Math.max(2, Math.min(gridWidth - 3, Math.round(sumBx / plan.anchors.length) + rng.int(-1, 1))) let hubBy = Math.max(2, Math.min(gridHeight - 3, Math.round(sumBy / plan.anchors.length) + rng.int(-1, 1))) if (plan.roadObstacleBlocks[hubBy]?.[hubBx] === true) { let found = false for (let r = 1; r < Math.max(gridWidth, gridHeight) && !found; r += 1) { for (let dy = -r; dy <= r && !found; dy += 1) { for (let dx = -r; dx <= r && !found; dx += 1) { const nx = hubBx + dx const ny = hubBy + dy if (nx >= 1 && nx < gridWidth - 1 && ny >= 1 && ny < gridHeight - 1) { if (plan.roadObstacleBlocks[ny]?.[nx] !== true) { hubBx = nx hubBy = ny found = true } } } } } } const hubCell = { x: hubBx * TILES_PER_BLOCK + 4, y: hubBy * TILES_PER_BLOCK + 4 } const hubVertex: DrlgVertex = { x: hubCell.x, y: hubCell.y, type: 'hub', label: 'Central Hub', } const allVertices: DrlgVertex[] = [hubVertex] // 2. Add anchors and special presets for (const anchor of plan.anchors) { let vType: DrlgVertex['type'] = 'gate' if (/waypoint/i.test(anchor.label)) { vType = 'waypoint' } else if (anchor.kind === 'preset') { vType = 'preset' } allVertices.push({ x: anchor.interiorCell.x, y: anchor.interiorCell.y, type: vType, label: anchor.label, id: anchor.toLevelId, }) } for (const preset of plan.specialPresets) { const covered = plan.anchors.some(a => a.label.toLowerCase().includes(preset.name.toLowerCase()) || preset.name.toLowerCase().includes(a.label.toLowerCase()), ) if (!covered) { const px = preset.bx * TILES_PER_BLOCK + Math.floor((preset.blocksX * TILES_PER_BLOCK) / 2) const py = preset.by * TILES_PER_BLOCK + Math.floor((preset.blocksY * TILES_PER_BLOCK) / 2) allVertices.push({ x: px, y: py, type: /waypoint/i.test(preset.name) ? 'waypoint' : 'preset', label: preset.name, }) } } // 3. Connect each anchor to the central hub via Catmull-Rom spline with meander and obstacle steering const polylines: { x: number; y: number }[][] = [] const canvasBounds = { width: gridWidth * TILES_PER_BLOCK, height: gridHeight * TILES_PER_BLOCK } for (const anchor of plan.anchors) { const startCell = anchor.interiorCell const startBx = Math.floor(startCell.x / TILES_PER_BLOCK) const startBy = Math.floor(startCell.y / TILES_PER_BLOCK) const blockPath = findClearBlockCorridor( startBx, startBy, hubBx, hubBy, gridWidth, gridHeight, plan.roadObstacleBlocks, ) const controlPoints: { x: number; y: number }[] = [ anchor.anchorCell, anchor.interiorCell, ] if (blockPath.length > 2) { let lastDirX = blockPath[1]!.bx - blockPath[0]!.bx let lastDirY = blockPath[1]!.by - blockPath[0]!.by let blocksSinceControl = 1 for (let i = 1; i < blockPath.length - 1; i += 1) { const curr = blockPath[i]! const next = blockPath[i + 1]! const dirX = next.bx - curr.bx const dirY = next.by - curr.by const isTurn = dirX !== lastDirX || dirY !== lastDirY blocksSinceControl += 1 if (isTurn || blocksSinceControl >= 3) { const mx = rng.int(-1, 1) const my = rng.int(-1, 1) let cx = curr.bx * TILES_PER_BLOCK + 4 + mx let cy = curr.by * TILES_PER_BLOCK + 4 + my if (plan.roadObstacleBlocks[Math.floor(cy / TILES_PER_BLOCK)]?.[Math.floor(cx / TILES_PER_BLOCK)] === true) { cx = curr.bx * TILES_PER_BLOCK + 4 cy = curr.by * TILES_PER_BLOCK + 4 } const inflectionVertex: DrlgVertex = { x: cx, y: cy, type: 'inflection', label: `Inflection (${anchor.label})`, } allVertices.push(inflectionVertex) controlPoints.push({ x: cx, y: cy }) lastDirX = dirX lastDirY = dirY blocksSinceControl = 0 } } } else { const dist = Math.hypot(hubCell.x - startCell.x, hubCell.y - startCell.y) if (dist >= 14) { const midX = Math.round((startCell.x + hubCell.x) / 2) const midY = Math.round((startCell.y + hubCell.y) / 2) const perpX = -(hubCell.y - startCell.y) / dist const perpY = (hubCell.x - startCell.x) / dist const meanderMag = rng.int(-2, 2) let cx = Math.round(midX + perpX * meanderMag) let cy = Math.round(midY + perpY * meanderMag) if (plan.roadObstacleBlocks[Math.floor(cy / TILES_PER_BLOCK)]?.[Math.floor(cx / TILES_PER_BLOCK)] === true) { cx = midX cy = midY } const inflectionVertex: DrlgVertex = { x: cx, y: cy, type: 'inflection', label: `Inflection (${anchor.label})`, } allVertices.push(inflectionVertex) controlPoints.push({ x: cx, y: cy }) } } controlPoints.push(hubCell) const splineLine = sampleCatmullRomSpline(controlPoints, canvasBounds, plan.roadObstacleBlocks) polylines.push(splineLine) } return Object.assign([...allVertices], { vertices: allVertices, polylines, lines: polylines, }) as DrlgVertexResult } export const DRLGVER_CreateVertices = createDrlgVertices /** * Build smooth Catmull-Rom spline 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> = { 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, 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, ): { placedPieces: Set; placedRects: PlacedPresetRect[] } { const innerWidth = gridWidth - 2 const innerHeight = gridHeight - 2 const totalSlots = innerWidth * innerHeight const placedPieces = new Set() const placedRects: PlacedPresetRect[] = [] if (totalSlots <= 0 || presetNames.length === 0) return { placedPieces, placedRects } const occupied: boolean[][] = Array.from({ length: gridHeight }, () => Array.from({ length: gridWidth }, () => false)) for (let x = 0; x < gridWidth; x += 1) { occupied[0]![x] = true occupied[gridHeight - 1]![x] = true } for (let y = 0; y < gridHeight; y += 1) { occupied[y]![0] = true occupied[y]![gridWidth - 1] = true } if (reservedBlocks && gridWidth >= 8 && gridHeight >= 8) { for (const key of reservedBlocks) { const [bxStr, byStr] = key.split(',') const bx = Number(bxStr) const by = Number(byStr) if (occupied[by]?.[bx] !== undefined) { occupied[by]![bx] = true } } } // Handle continuous river + bridge layout for Blood Moor when requested on standard grids let remainingPresets = [...presetNames] const wantsRiver = gridWidth >= 8 && gridHeight >= 6 && remainingPresets.some(n => n.startsWith('Act 1 - River Upper')) && remainingPresets.some(n => n.startsWith('Act 1 - River Lower')) && remainingPresets.some(n => n.startsWith('Act 1 - Bridge')) if (wantsRiver) { const inX = Math.floor(gridWidth / 2) const outY = Math.floor(gridHeight / 2) if ( stampAct1RiverAndBridge( canvas, pieces, gridWidth, gridHeight, inX, outY, occupied, placedPieces, placedRects, stats, ) ) { remainingPresets = remainingPresets.filter( n => !n.startsWith('Act 1 - River Upper') && !n.startsWith('Act 1 - River Lower') && !n.startsWith('Act 1 - Bridge'), ) } } for (const presetName of remainingPresets) { const piece = pieces.find(p => p.name === presetName || p.name.startsWith(presetName)) if (!piece || piece.levels.length === 0) continue const variantIndex = piece.levels.length > 1 ? rng.int(0, piece.levels.length - 1) : 0 const ds1 = piece.levels[variantIndex]! const blocksX = Math.max(1, Math.floor(ds1.width / TILES_PER_BLOCK)) const blocksY = Math.max(1, Math.floor(ds1.height / TILES_PER_BLOCK)) const coords: { x: number; y: number }[] = [] for (let i = 0; i < totalSlots; i += 1) { coords.push({ x: i % innerWidth, y: Math.floor(i / innerWidth) }) } for (let i = 0; i < totalSlots; i += 1) { const r1 = rng.int(0, totalSlots - 1) const r2 = rng.int(0, totalSlots - 1) const tmp = coords[r1]! coords[r1] = coords[r2]! coords[r2] = tmp } let placed = false for (let i = 0; i < totalSlots; i += 1) { const bx = coords[i]!.x + 1 const by = coords[i]!.y + 1 if (bx < 1 || by < 1 || bx + blocksX > gridWidth - 1 || by + blocksY > gridHeight - 1) continue let blocked = false for (let dy = 0; dy < blocksY; dy += 1) { for (let dx = 0; dx < blocksX; dx += 1) { if (occupied[by + dy]![bx + dx]) { blocked = true break } } if (blocked) break } if (blocked) continue const tileX = bx * TILES_PER_BLOCK const tileY = by * TILES_PER_BLOCK stampDs1(canvas, ds1, tileX, tileY) for (let dy = 0; dy < blocksY; dy += 1) { for (let dx = 0; dx < blocksX; dx += 1) { occupied[by + dy]![bx + dx] = true } } stats.substitutions.push({ name: piece.name, role: 'object', enabled: true, clusters: 1, }) placedPieces.add(piece.name) placedRects.push({ name: piece.name, x: tileX, y: tileY, w: ds1.width, h: ds1.height, }) placed = true break } if (!placed) { stats.notes.push(`special preset "${presetName}" could not be placed (no free interior block)`) } } return { placedPieces, placedRects } } /* ------------------------------------------------------------------------- * * Entry point * ------------------------------------------------------------------------- */ /** * Derive the block grid for a level whose `Levels.txt` size is unset. */ function resolveUnsetSize(request: WildernessRequest): { sizeX: number; sizeY: number; source: string } { if (request.sizeOverride !== undefined) { return { sizeX: request.sizeOverride.x, sizeY: request.sizeOverride.y, source: 'sizeOverride' } } const barricade = request.substitutions.find(row => /barricade/i.test(row.name)) const level = barricade?.levels[0] if (level !== undefined) { return { sizeX: level.width, sizeY: level.height, source: `LvlSub "${barricade!.name}" piece extent` } } return { sizeX: 160, sizeY: 64, source: 'D2MOO canonical barricade size' } } /** * Assemble the 6 presets for Act 3 Travincal (LevelId 83, 64x64). */ function stampAct3Travincal( canvas: Canvas, pieces: readonly WildernessPiece[], stats: WildernessStats, placedRects?: PlacedPresetRect[], ): Set { 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() 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 { 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, stats?: WildernessStats, ): void { const paintedRoadCells = new Set() 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, 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, 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, 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, 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, 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> = { 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 // Priority 1: Check if a preset already placed a Waypoint object const ALL_WAYPOINT_IDS = new Set([37, 52, 269, 10, 306, 1, 387, 0, 388, 12, 188]) const existingWp = canvas.objects.find( o => o.type === 2 && ALL_WAYPOINT_IDS.has(o.id), ) if (existingWp !== undefined) { stats.waypointsSpawned = 1 stats.waypointTile = { x: Math.floor(existingWp.x / SUB_TILES_PER_TILE), y: Math.floor(existingWp.y / SUB_TILES_PER_TILE), } return } const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable) 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: 'huntress2', presetMatch: /Desert Tomb 2|Claw Viper|Valley Ruin 1/i, defaultLandmark: 'Act 2 - Desert Tomb 2', modifiers: ['cursed', 'strong'], minionCount: [4, 6], minionMonsterId: 'huntress2', objectId: 76, defaultRelativePos: { x: 0.48, y: 0.52 }, }, { id: 'Beetleburst', name: 'Beetleburst', role: 'superunique', act: 2, levelIds: [43], monsterId: 'beetle2', presetMatch: /Desert Oasis|Desert Lair/i, defaultLandmark: 'Act 2 - Desert Oasis 1', modifiers: ['magicresistant'], minionCount: [4, 6], minionMonsterId: 'beetle2', objectId: 77, defaultRelativePos: { x: 0.52, y: 0.48 }, }, { id: 'Dark Elder', name: 'Dark Elder', role: 'superunique', act: 2, levelIds: [44], monsterId: 'plaguebearer1', presetMatch: /Desert Ruins Elder/i, defaultLandmark: 'Act 2 - Desert Ruins Elder', modifiers: ['fast', 'magicresistant'], minionCount: [4, 6], minionMonsterId: 'plaguebearer1', objectId: 78, defaultRelativePos: { x: 0.5, y: 0.5 }, }, { id: 'Fangskin', name: 'Fangskin', role: 'superunique', act: 2, levelIds: [45, 61], monsterId: 'clawviper3', presetMatch: /Desert Valley Ruin 2|Valley Warp|Claw Viper Altar/i, defaultLandmark: 'Act 2 - Desert Valley Ruin 2', modifiers: ['lightenchant', 'fast'], minionCount: [4, 6], minionMonsterId: 'clawviper3', objectId: 79, defaultRelativePos: { x: 0.55, y: 0.45 }, }, // Act 3 { id: 'Stormtree', name: 'Stormtree', role: 'superunique', act: 3, levelIds: [78], monsterId: 'thornhulk3', presetMatch: /Flayer Jungle Entrance|Jungle Gate|Flayer Entrance/i, defaultLandmark: 'Act 3 - Flayer Jungle Entrance', modifiers: ['lightenchant', 'fast'], minionCount: [4, 6], minionMonsterId: 'thornhulk3', objectId: 80, defaultRelativePos: { x: 0.5, y: 0.5 }, }, { id: 'Sszark the Burning', name: 'Sszark the Burning', role: 'superunique', act: 3, levelIds: [76, 84], monsterId: 'spider2', presetMatch: /Spider Cavern|Spider/i, defaultLandmark: 'Act 3 - Spider Cavern', modifiers: ['strong', 'cursed'], minionCount: [4, 6], minionMonsterId: 'spider2', objectId: 81, defaultRelativePos: { x: 0.48, y: 0.52 }, }, { id: 'Witch Doctor Endugu', name: 'Witch Doctor Endugu', role: 'superunique', act: 3, levelIds: [78, 88, 91], monsterId: 'flayershaman4', presetMatch: /Flayer Dungeon/i, defaultLandmark: 'Act 3 - Flayer Dungeon', modifiers: ['magicresistant', 'fireenchant'], minionCount: [4, 6], minionMonsterId: 'flayer4', objectId: 82, defaultRelativePos: { x: 0.52, y: 0.48 }, }, { id: 'Battlemaid Sarina', name: 'Battlemaid Sarina', role: 'superunique', act: 3, levelIds: [80, 94], monsterId: 'corruptrogue8', presetMatch: /Burbs Temple|Ruined Temple|MetroTemple/i, defaultLandmark: 'Act 3 - Burbs Temple', modifiers: ['fast', 'spectralhit'], minionCount: [4, 6], minionMonsterId: 'corruptrogue8', objectId: 83, defaultRelativePos: { x: 0.5, y: 0.5 }, }, // Act 4 { id: 'Izual', name: 'Izual', role: 'superunique', act: 4, levelIds: [105], monsterId: 'izual', presetMatch: /Plains of Despair|Izual/i, defaultLandmark: 'Act 4 - Plains of Despair', modifiers: ['coldenchant'], minionCount: [0, 0], minionMonsterId: 'izual', objectId: 84, defaultRelativePos: { x: 0.5, y: 0.5 }, }, { id: 'Hephasto the Armorer', name: 'Hephasto the Armorer', role: 'superunique', act: 4, levelIds: [106, 107], monsterId: 'hephasto', presetMatch: /Hellforge/i, defaultLandmark: 'Act 4 - Hellforge', modifiers: ['spectralhit', 'aura'], minionCount: [0, 0], minionMonsterId: 'hephasto', objectId: 85, defaultRelativePos: { x: 0.52, y: 0.48 }, }, { id: 'Hadriel', name: 'Hadriel', role: 'npc_guard', act: 4, levelIds: [106, 107, 108], monsterId: 'hadriel', presetMatch: /Chaos Sanctuary Entrance|Hadriel/i, defaultLandmark: 'Act 4 - Chaos Sanctuary Entrance', modifiers: [], minionCount: [0, 0], minionMonsterId: '', objectId: 86, defaultRelativePos: { x: 0.48, y: 0.52 }, }, // Act 5 { id: 'Dac Farren', name: 'Dac Farren', role: 'superunique', act: 5, levelIds: [110], monsterId: 'demonimp1', presetMatch: /Barricade Prison 1|Barricade Ruins/i, defaultLandmark: 'Act 5 - Barricade Prison 1', modifiers: ['coldenchant'], minionCount: [4, 6], minionMonsterId: 'demonimp1', objectId: 87, defaultRelativePos: { x: 0.35, y: 0.5 }, }, { id: 'Shenk the Overseer', name: 'Shenk the Overseer', role: 'superunique', act: 5, levelIds: [110, 111], monsterId: 'overseer2', presetMatch: /Barricade Building|Shenk|Siege Strip 15|Siege To Barricade/i, defaultLandmark: 'Act 5 - Barricade Building', modifiers: ['strong'], minionCount: [15, 20], minionMonsterId: 'enslaved1', objectId: 88, defaultRelativePos: { x: 0.75, y: 0.5 }, }, { id: 'Eyeback the Unleashed', name: 'Eyeback the Unleashed', role: 'superunique', act: 5, levelIds: [111], monsterId: 'bloodlord2', presetMatch: /Barricade Hell Portal N|Barricade 1/i, defaultLandmark: 'Act 5 - Barricade Hell Portal N', modifiers: ['fast', 'strong'], minionCount: [3, 5], minionMonsterId: 'bloodlord2', objectId: 89, defaultRelativePos: { x: 0.45, y: 0.55 }, }, { id: 'Thresh Socket', name: 'Thresh Socket', role: 'superunique', act: 5, levelIds: [112], monsterId: 'siegebeast1', presetMatch: /Barricade Snow Unique/i, defaultLandmark: 'Act 5 - Barricade Snow Unique', modifiers: ['cursed'], minionCount: [2, 4], minionMonsterId: 'siegebeast1', objectId: 90, defaultRelativePos: { x: 0.5, y: 0.5 }, }, { id: 'Bonesaw Breaker', name: 'Bonesaw Breaker', role: 'superunique', act: 5, levelIds: [115], monsterId: 'reanimatedhorde2', presetMatch: /Glacial Trail|Drifter Cavern/i, defaultLandmark: 'Act 5 - Glacial Trail', modifiers: ['strong', 'magicresistant'], minionCount: [4, 6], minionMonsterId: 'reanimatedhorde2', objectId: 91, defaultRelativePos: { x: 0.52, y: 0.48 }, }, { id: 'Frozenstein', name: 'Frozenstein', role: 'superunique', act: 5, levelIds: [114], monsterId: 'yeti2', presetMatch: /Frozen River|Anya/i, defaultLandmark: 'Act 5 - Frozen River', modifiers: ['coldenchant', 'manahit'], minionCount: [4, 6], minionMonsterId: 'yeti2', objectId: 92, defaultRelativePos: { x: 0.5, y: 0.5 }, }, { id: 'Nihlathak', name: 'Nihlathak', role: 'superunique', act: 5, levelIds: [124], monsterId: 'nihlathakboss', presetMatch: /Halls of Vaught|Nihlathak/i, defaultLandmark: 'Act 5 - Halls of Vaught', modifiers: ['coldenchant'], minionCount: [6, 8], minionMonsterId: 'iceprowler2', objectId: 93, defaultRelativePos: { x: 0.5, y: 0.5 }, }, ] /** * Refactor landmark and outdoor boss generation into a table/data-driven pipeline * based on `SuperUniques.txt` and `MonPreset.txt`. */ export function buildLandmarkEntitiesFromTables( superuniques?: D2Table, monpreset?: D2Table, baseDefs: readonly LandmarkEntityDef[] = CANONICAL_LANDMARK_ENTITIES, ): LandmarkEntityDef[] { if (!superuniques && !monpreset) { return [...baseDefs] } const suMap = new Map() 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() 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() 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 if (request.groundTiles && request.groundTiles.length > 0) { groundTiles = [...request.groundTiles] } else if (request.groundTile) { if (isTileArray(request.groundTile)) { groundTiles = [...(request.groundTile as readonly WeightedGroundTile[])] } else { const single = request.groundTile as { readonly style: number; readonly sequence: number } groundTiles = [{ style: single.style, sequence: single.sequence, weight: 1 }] } } else if (request.dt1Libraries && request.dt1Libraries.length > 0) { const mask = wildernessDt1Mask(request.levelTypeName) const fromDt1 = deriveGroundTilesFromDt1Libraries(request.dt1Libraries, mask) if (fromDt1.length > 0) groundTiles = fromDt1 } if (groundTiles === null || groundTiles.length === 0) { const canonicalPool = CANONICAL_GROUND_POOLS[request.levelTypeName] if (canonicalPool && canonicalPool.length > 0) { groundTiles = [...canonicalPool] } else { const canonical = CANONICAL_GROUND_TILES[request.levelTypeName] if (canonical !== undefined) { groundTiles = [{ style: canonical.style, sequence: canonical.sequence, weight: 1 }] } else { const fromPieces = deriveGroundTiles(request.pieces) if (fromPieces.length > 0) groundTiles = fromPieces } } } const primaryGroundTile = groundTiles && groundTiles.length > 0 ? { style: groundTiles[0]!.style, sequence: groundTiles[0]!.sequence } : null const groundTile = primaryGroundTile if ((gridWidth < 3 || gridHeight < 3) && request.levelId !== 82) { const bridgePiece = request.pieces.find(p => p.levels.some(l => l.width >= gridWidth * TILES_PER_BLOCK && l.height >= gridHeight * TILES_PER_BLOCK)) ?? request.pieces[0] if (bridgePiece && bridgePiece.levels[0]) { const bridgeDs1 = bridgePiece.levels[0] const canvas = createCanvas(gridWidth * TILES_PER_BLOCK, gridHeight * TILES_PER_BLOCK, bridgeDs1.wallLayers, bridgeDs1.floorLayers, bridgeDs1.substitutionType === 1 || bridgeDs1.substitutionType === 2 ? 1 : 0) if (groundTiles !== null) fillGround(canvas, groundTiles, 0, 0, canvas.width, canvas.height, { seed: request.seed }) stampDs1(canvas, bridgeDs1, 0, 0) return { level: { version: bridgeDs1.version, width: canvas.width, height: canvas.height, act: bridgeDs1.act, substitutionType: bridgeDs1.substitutionType, wallLayers: canvas.wallLayers, floorLayers: canvas.floorLayers, cells: canvas.cells, objects: canvas.objects, npcPathOffset: null, }, stats: { levelId: request.levelId, levelName: request.levelName, levelTypeName: request.levelTypeName, seed: request.seed, sizeX, sizeY, sizeSource, tilesPerBlock: TILES_PER_BLOCK, blockGrid: { width: gridWidth, height: gridHeight }, dt1Mask: wildernessDt1Mask(request.levelTypeName), subType: request.subType, subTheme: Math.max(0, Math.min(4, Math.floor(request.subTheme))), groundTile: primaryGroundTile, groundTiles: groundTiles ?? undefined, groundCells: canvas.width * canvas.height, borderPiecesAvailable: 0, borderBlocks: 0, borderUsage: {}, roadCells: 0, roadSegments: 0, anchors: 0, specialPresets: [], entrances: [], 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 if (request.levelId === 83) { placedSpecial = stampAct3Travincal(canvas, request.pieces, stats, specialPlacedRects) } else if (request.levelId === 82) { placedSpecial = stampAct3KurastCauseway(canvas, request.pieces, 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: wildernessDt1Mask(request.levelTypeName), subType: request.subType, // The special-preset path clamps `SubTheme` exactly like the main path // does; it used to report a flat 0, so every special level looked like // theme 0 in the verification reports. subTheme: Math.max(0, Math.min(4, Math.floor(request.subTheme))), groundTile: primaryGroundTile, groundTiles: groundTiles ?? undefined, groundCells: stats.groundCells, 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) { levelPieces = request.pieces.filter(p => !/snow/i.test(p.name)) } else if (request.levelId === 112 || request.levelId === 117) { const snowOnly = request.pieces.filter(p => /snow/i.test(p.name) || /ruins/i.test(p.name)) if (snowOnly.length > 0) levelPieces = snowOnly } const borderPieces = levelPieces.filter(piece => piece.border) const fillPieces = levelPieces.filter(piece => !piece.border) // Step 1-3: Act 1 Outdoor anchor & road planning + dirt road bitmask rasterization // // The planner was written for Act 1 and its hard-coded per-level table only // covers Act 1, but nothing in it is Act 1 specific once the caller supplies // the seams: `request.gates` comes from the world graph, so every act can be // planned the same way. Without gates we keep the old Act 1 only behaviour so // existing packs and the generator verifier reproduce byte for byte. let act1Plan: Act1OutdoorPlan | undefined let dirtPathGrid: Uint8Array | undefined if (request.levelTypeName === 'Act 1 - Wilderness' || request.gates !== undefined || 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) { 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() 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 Siege / Barricades Topography: Barricades, trenches, frozen lakes (DRLGOUTSIEGE_InitAct5OutdoorLevel) if ( (request.levelId >= 111 && request.levelId <= 117) || request.levelTypeName === 'Act 5 - Barricade' ) { initAct5OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats) } // Step 4D: Smooth boundaries between contrasting ground types (roads, water, shore) // using DRLGROOMTILE auto-tiling bitmasks. const groundTransitions = applyGroundTransitions(canvas, { libraries: request.dt1Libraries, dt1Mask: wildernessDt1Mask(request.levelTypeName), 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: wildernessDt1Mask(request.levelTypeName), 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 } : {}), } }