1355 lines
47 KiB
TypeScript
1355 lines
47 KiB
TypeScript
/**
|
|
* Diablo II DRLG (Dungeon Random Level Generator) Maze Special Passes.
|
|
*
|
|
* Implements the 11 official Blizzard / D2MOO core dungeon special passes:
|
|
* - `DRLGMAZE_PlaceAct2TombStuff`: Tal Rasha's Tombs (True tomb Horadric staff receptacle, 6 false tombs golden chests, Kaa, Cube, Leatherarm).
|
|
* - `DRLGMAZE_PlaceAct2LairStuff`: Maggot Lair Level 3 (Coldworm the Burrower & Staff of Kings chest).
|
|
* - `DRLGMAZE_PlaceAct3DungeonStuff`: Flayer Dungeon (Brain chest) & Ruined Temple (Lam Esen's Tome altar).
|
|
* - `DRLGMAZE_PlaceAct3SewerStuff`: Kurast Sewers (drain lever & descent to Level 2).
|
|
* - `DRLGMAZE_PlaceAct3MephistoStuff`: Durance of Hate Level 3 (blood moat, council hall, red portal to Act 4).
|
|
* - `DRLGMAZE_PlaceAct5TempleStuff`: Halls of Vaught (4-way branch & Nihlathak altar) & Halls of Pain.
|
|
* - `DRLGMAZE_PlaceAct5BaalStuff`: Throne of Destruction & Worldstone Keep.
|
|
* - `DRLGMAZE_PlaceAct5IceStuff`: Frozen River (Anya frozen platform) & Glacial Trail.
|
|
* - `DRLGMAZE_PlaceAct1Barracks`: Monastery Barracks (Smith & Charsi's Malus forge/anvil).
|
|
* - `DRLGMAZE_PlaceAct4Lava`: River of Flame (Hephasto the Armorer & Hellforge, Bridge connecting to Chaos Sanctuary).
|
|
* - `DRLGMAZE_ScanReplaceSpecialAct2SewersPresets`: Lut Gholein Sewers (Radament's Lair & Atma's quest).
|
|
*/
|
|
|
|
import { Rng } from './rng.ts'
|
|
import {
|
|
DIRECTION_WEST,
|
|
DIRECTION_NORTH,
|
|
DIRECTION_EAST,
|
|
DIRECTION_SOUTH,
|
|
sideMaskFromToken,
|
|
sideTokenFromMask,
|
|
type MazeLevel,
|
|
type Room,
|
|
type MazePiece,
|
|
type MazePieceKind,
|
|
type MazeRequest,
|
|
type MazeStats,
|
|
type PieceIndex,
|
|
bfsDistances,
|
|
addAdjacentMazeRoom,
|
|
noteSpecial,
|
|
findSpecial,
|
|
roomSideMask,
|
|
fillBlankSpaces,
|
|
} from './maze.ts'
|
|
|
|
export type SpecialPassName =
|
|
| 'DRLGMAZE_PlaceAct2TombStuff'
|
|
| 'DRLGMAZE_PlaceAct2LairStuff'
|
|
| 'DRLGMAZE_PlaceAct3DungeonStuff'
|
|
| 'DRLGMAZE_PlaceAct3SewerStuff'
|
|
| 'DRLGMAZE_PlaceAct3MephistoStuff'
|
|
| 'DRLGMAZE_PlaceAct5TempleStuff'
|
|
| 'DRLGMAZE_PlaceAct5BaalStuff'
|
|
| 'DRLGMAZE_PlaceAct5IceStuff'
|
|
| 'DRLGMAZE_PlaceAct1Barracks'
|
|
| 'DRLGMAZE_PlaceAct4Lava'
|
|
| 'DRLGMAZE_ScanReplaceSpecialAct2SewersPresets'
|
|
|
|
export interface SpecialPassContext {
|
|
readonly level: MazeLevel
|
|
readonly request: MazeRequest
|
|
readonly pieces: readonly MazePiece[]
|
|
readonly index: PieceIndex
|
|
readonly stats: MazeStats
|
|
readonly rng?: Rng
|
|
}
|
|
|
|
function getPassRng(ctx: SpecialPassContext): Rng {
|
|
return ctx.rng ?? ctx.level?.rng ?? new Rng(ctx.request.seed)
|
|
}
|
|
|
|
const DIRS = ['N', 'E', 'S', 'W'] as const
|
|
|
|
/**
|
|
* Blizzard D2 1.13c `DrlgMaze.cpp` 4-bit Room Door Bitmask (`West=1, East=2, South=4, North=8`).
|
|
* In 1.13c `DRLGMAZE_GetRoomPresetId`, a room's 15 non-empty door topologies map directly to
|
|
* `baseDefId + (doorMask - 1)` in `LvlPrest.txt`.
|
|
*/
|
|
export const MAZE_DOOR_WEST = 0x01
|
|
export const MAZE_DOOR_EAST = 0x02
|
|
export const MAZE_DOOR_SOUTH = 0x04
|
|
export const MAZE_DOOR_NORTH = 0x08
|
|
|
|
/**
|
|
* Compute the canonical 1.13c `DrlgMaze.cpp` 4-bit door mask (`1..15`) for a maze room.
|
|
*/
|
|
export function mazeDoorMask(room: Room): number {
|
|
const mask = roomSideMask(room)
|
|
let d2Mask = 0
|
|
if ((mask & (1 << DIRECTION_WEST)) !== 0) d2Mask |= MAZE_DOOR_WEST
|
|
if ((mask & (1 << DIRECTION_EAST)) !== 0) d2Mask |= MAZE_DOOR_EAST
|
|
if ((mask & (1 << DIRECTION_SOUTH)) !== 0) d2Mask |= MAZE_DOOR_SOUTH
|
|
if ((mask & (1 << DIRECTION_NORTH)) !== 0) d2Mask |= MAZE_DOOR_NORTH
|
|
return d2Mask
|
|
}
|
|
|
|
/**
|
|
* Compute the exact `LvlPrest.txt` `Def` ID for a maze room given a base 15-room table `Def` ID
|
|
* (`baseDefId + (mazeDoorMask(room) - 1)`).
|
|
*/
|
|
export function mazePresetDefIdForDoorMask(baseDefId: number, room: Room): number {
|
|
const doorMask = mazeDoorMask(room)
|
|
if (doorMask <= 0 || doorMask > 15) return baseDefId
|
|
return baseDefId + (doorMask - 1)
|
|
}
|
|
|
|
/**
|
|
* Canonical Blizzard D2 1.13c `LvlPrest.txt` `Def` IDs used by `DrlgMaze.cpp` special passes.
|
|
*/
|
|
export const LVLPREST_ACT2_TOMB_TALRASHA_BASE = 625
|
|
export const LVLPREST_ACT2_TOMB_KAA_BASE = 629
|
|
export const LVLPREST_ACT2_TOMB_TAINTED_SUN_X = 633
|
|
export const LVLPREST_ACT3_SEWER_SW = 585
|
|
export const LVLPREST_ACT3_SEWER_SE = 586
|
|
export const LVLPREST_ACT3_SEWER_NW = 587
|
|
export const LVLPREST_ACT3_SEWER_NE = 588
|
|
export const LVLPREST_ACT3_SEWER_DRAIN_BASE = 589
|
|
export const LVLPREST_ACT3_SEWER_CHEST_BASE = 593
|
|
export const LVLPREST_ACT5_ICE_PREV_BASE = 1018
|
|
export const LVLPREST_ACT5_ICE_WAYPOINT_BASE = 1026
|
|
export const LVLPREST_ACT5_ICE_RIVER_A = 1030
|
|
export const LVLPREST_ACT5_ICE_RIVER_B = 1031
|
|
export const LVLPREST_ACT5_ICE_POOL_A = 1032
|
|
export const LVLPREST_ACT5_ICE_POOL_B = 1033
|
|
export const LVLPREST_ACT5_TEMPLE_REMAPPING_BASE = 1044
|
|
export const LVLPREST_ACT5_TEMPLE_FINAL_ROOM = 1059
|
|
export const LVLPREST_ACT5_TEMPLE_NIHLATHAK_WING_BASE = 1060
|
|
|
|
export const LVLPREST_ACT2_CORRUPT_HAREM_NE = 354
|
|
export const LVLPREST_ACT2_CORRUPT_HAREM_SE = 355
|
|
export const LVLPREST_ACT2_CORRUPT_HAREM_SW = 356
|
|
export const LVLPREST_ACT2_CORRUPT_HAREM_NW = 357
|
|
export const LVLPREST_ACT2_BASEMENT_NE = 358
|
|
export const LVLPREST_ACT2_BASEMENT_SE = 359
|
|
export const LVLPREST_ACT2_BASEMENT_SW = 360
|
|
export const LVLPREST_ACT2_BASEMENT_NW = 361
|
|
export const LVLPREST_ACT3_SPIDER_SW = 659
|
|
export const LVLPREST_ACT3_SPIDER_SE = 660
|
|
export const LVLPREST_ACT3_SPIDER_NW = 661
|
|
export const LVLPREST_ACT3_SPIDER_NE = 662
|
|
export const LVLPREST_ACT3_SPIDER_CHEST_NW = 663
|
|
export const LVLPREST_ACT3_SPIDER_CHEST_NE = 664
|
|
export const LVLPREST_ACT5_LAVA_N = 1053
|
|
export const LVLPREST_ACT5_LAVA_S = 1054
|
|
export const LVLPREST_ACT5_LAVA_E = 1055
|
|
export const LVLPREST_ACT5_LAVA_W = 1056
|
|
export const LVLPREST_ACT5_LAVA_EW = 1057
|
|
export const LVLPREST_ACT5_LAVA_NS = 1058
|
|
|
|
/**
|
|
* 1.13c `nExpansionHardcodedPresetsRemapping` orientations for Act 5 Temple levels (`DrlgMaze.cpp`).
|
|
*/
|
|
export const ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING = ['SW', 'SE', 'NW', 'NE'] as const
|
|
export const ACT5_NIHLATHAK_WINGS = ['NW', 'NE', 'SE', 'SW'] as const
|
|
|
|
/**
|
|
* Canonical mapping from 1.13c `LvlPrest.txt` `Def` IDs to `LvlPrest` piece names and roles.
|
|
*/
|
|
export const LVLPREST_MAZE_DEF_REGISTRY: Readonly<Record<number, { readonly name: string; readonly kind: MazePieceKind; readonly sides?: string }>> = {
|
|
[LVLPREST_ACT3_SEWER_SW]: { name: 'Act 3 - Sewer SW', kind: 'prev', sides: 'S' },
|
|
[LVLPREST_ACT3_SEWER_SE]: { name: 'Act 3 - Sewer SE', kind: 'prev', sides: 'E' },
|
|
[LVLPREST_ACT3_SEWER_NW]: { name: 'Act 3 - Sewer NW', kind: 'prev', sides: 'W' },
|
|
[LVLPREST_ACT3_SEWER_NE]: { name: 'Act 3 - Sewer NE', kind: 'prev', sides: 'N' },
|
|
[LVLPREST_ACT2_TOMB_TAINTED_SUN_X]: { name: 'Act 2 - Tomb Tainted Sun X', kind: 'quest' },
|
|
[LVLPREST_ACT5_ICE_RIVER_A]: { name: 'Act 5 - Ice River A', kind: 'quest' },
|
|
[LVLPREST_ACT5_ICE_RIVER_B]: { name: 'Act 5 - Ice River B', kind: 'quest' },
|
|
[LVLPREST_ACT5_ICE_POOL_A]: { name: 'Act 5 - Ice Pool A', kind: 'quest' },
|
|
[LVLPREST_ACT5_ICE_POOL_B]: { name: 'Act 5 - Ice Pool B', kind: 'quest' },
|
|
[LVLPREST_ACT5_TEMPLE_FINAL_ROOM]: { name: 'Act 5 - Temple Final Room', kind: 'quest' },
|
|
[LVLPREST_ACT3_SPIDER_CHEST_NW]: { name: 'Act 3 - Spider Chest NW', kind: 'treasure', sides: 'NW' },
|
|
[LVLPREST_ACT3_SPIDER_CHEST_NE]: { name: 'Act 3 - Spider Chest NE', kind: 'treasure', sides: 'NE' },
|
|
}
|
|
|
|
/**
|
|
* Place or replace a maze room by its 1.13c `LvlPrest.txt` numeric `Def` ID.
|
|
*/
|
|
export function replaceRoomByPresetDefId(
|
|
ctx: SpecialPassContext,
|
|
defId: number,
|
|
options?: Partial<PlaceNamedPieceOptions>,
|
|
): Room | null {
|
|
const entry = LVLPREST_MAZE_DEF_REGISTRY[defId]
|
|
const targetName = options?.name ?? entry?.name
|
|
const targetKind = options?.kind ?? entry?.kind ?? 'quest'
|
|
const placed = placeNamedPiece(ctx, {
|
|
kind: targetKind,
|
|
...(targetName !== undefined ? { name: targetName } : {}),
|
|
...(options?.namePrefix !== undefined ? { namePrefix: options.namePrefix } : {}),
|
|
...(options?.sides !== undefined ? { sides: options.sides } : entry?.sides !== undefined ? { sides: entry.sides } : {}),
|
|
...(options?.plainSides !== undefined ? { plainSides: options.plainSides } : {}),
|
|
...(options?.direction !== undefined ? { direction: options.direction } : {}),
|
|
...(options?.inPlaceOnly !== undefined ? { inPlaceOnly: options.inPlaceOnly } : {}),
|
|
...(options?.preferFurthest !== undefined ? { preferFurthest: options.preferFurthest } : {}),
|
|
})
|
|
if (placed !== null) {
|
|
ctx.stats.notes.push(`lvlPrestDef=${String(defId)} (${placed.piece?.name ?? targetName ?? 'special'})`)
|
|
}
|
|
return placed
|
|
}
|
|
|
|
export interface PlaceNamedPieceOptions {
|
|
readonly kind: MazePieceKind
|
|
readonly name?: string
|
|
readonly namePrefix?: string
|
|
readonly sides?: string
|
|
readonly plainSides?: string
|
|
readonly direction?: number
|
|
readonly inPlaceOnly?: boolean
|
|
readonly preferFurthest?: boolean
|
|
}
|
|
|
|
/**
|
|
* Locate and place a special piece into the maze level.
|
|
*
|
|
* Tries in-place replacement first (preferring rooms matching `plainSides` and optimal BFS distance),
|
|
* then falls back to growing a new dead-end room off an existing room if allowed.
|
|
*/
|
|
export function placeNamedPiece(
|
|
ctx: SpecialPassContext,
|
|
options: PlaceNamedPieceOptions,
|
|
): Room | null {
|
|
const { kind, name, namePrefix, sides, plainSides, inPlaceOnly = false } = options
|
|
if (kind === 'waypoint' && ctx.request.hasWaypoint === false) {
|
|
return null
|
|
}
|
|
const isExit = options.preferFurthest ?? (kind === 'next' || kind === 'down' || kind === 'quest' || kind === 'treasure' || kind === 'waypoint')
|
|
|
|
// 1. Find the target piece in ctx.pieces
|
|
let piece: MazePiece | undefined
|
|
if (name !== undefined) {
|
|
piece = ctx.pieces.find(p => p.name === name)
|
|
?? ctx.pieces.find(p => p.name.toLowerCase() === name.toLowerCase())
|
|
}
|
|
if (piece === undefined && namePrefix !== undefined) {
|
|
if (sides !== undefined && sides !== '') {
|
|
const fullName = `${namePrefix} ${sides}`
|
|
piece = ctx.pieces.find(p => p.name === fullName)
|
|
?? ctx.pieces.find(p => p.name.toLowerCase() === fullName.toLowerCase())
|
|
}
|
|
if (piece === undefined) {
|
|
piece = ctx.pieces.find(p => p.name.startsWith(namePrefix))
|
|
}
|
|
}
|
|
if (piece === undefined && sides !== undefined && sides !== '') {
|
|
piece = findSpecial(ctx.pieces, kind, sides)
|
|
}
|
|
if (piece === undefined) {
|
|
piece = ctx.pieces.find(p => p.kind === kind && (sides === undefined || sides === '' || p.sides === sides))
|
|
}
|
|
|
|
if (piece === undefined) {
|
|
const missingIdentifier = name ?? (namePrefix !== undefined ? `${namePrefix} ${sides ?? ''}`.trim() : `${kind} ${sides ?? ''}`.trim())
|
|
ctx.stats.unresolvedRoles.push(missingIdentifier)
|
|
ctx.stats.notes.push(`special piece not found: ${missingIdentifier}`)
|
|
return null
|
|
}
|
|
|
|
const token = sides ?? piece.sides ?? ''
|
|
const distMap = bfsDistances(ctx.level)
|
|
|
|
// 2. Phase 1: In-place replacement
|
|
const wantedMask = plainSides !== undefined && plainSides !== '' ? sideMaskFromToken(plainSides) : null
|
|
const candidates = ctx.level.rooms.filter(r => {
|
|
if (r.hasDs1) return false
|
|
if (wantedMask !== null && roomSideMask(r) !== wantedMask) return false
|
|
return true
|
|
})
|
|
|
|
if (candidates.length > 0) {
|
|
candidates.sort((a, b) => {
|
|
const da = distMap.get(a) ?? 0
|
|
const db = distMap.get(b) ?? 0
|
|
return isExit ? db - da : da - db
|
|
})
|
|
const chosen = candidates[0]!
|
|
chosen.piece = piece
|
|
chosen.variant = 0
|
|
chosen.hasDs1 = true
|
|
noteSpecial(chosen, piece, token !== '' ? token : sideTokenFromMask(roomSideMask(chosen)), ctx.stats)
|
|
return chosen
|
|
}
|
|
|
|
if (inPlaceOnly) {
|
|
ctx.stats.notes.push(`no matching room for in-place special piece: ${piece.name}`)
|
|
return null
|
|
}
|
|
|
|
// 3. Phase 2: Grow an adjacent room
|
|
const parents = ctx.level.rooms.filter(r => !r.hasDs1 || r.piece?.kind === 'theme')
|
|
parents.sort((a, b) => {
|
|
const da = distMap.get(a) ?? 0
|
|
const db = distMap.get(b) ?? 0
|
|
return isExit ? db - da : da - db
|
|
})
|
|
|
|
const primaryDir = options.direction ?? DIRECTION_NORTH
|
|
const tryDirs = [primaryDir, (primaryDir + 1) % 4, (primaryDir + 2) % 4, (primaryDir + 3) % 4]
|
|
|
|
for (const parent of parents) {
|
|
for (const dir of tryDirs) {
|
|
const wasHasDs1 = parent.hasDs1
|
|
parent.hasDs1 = false
|
|
const created = addAdjacentMazeRoom(ctx.level, parent, dir, false, true, ctx.index, ctx.stats)
|
|
if (created !== null) {
|
|
created.piece = piece
|
|
created.variant = 0
|
|
created.hasDs1 = true
|
|
noteSpecial(created, piece, token, ctx.stats)
|
|
return created
|
|
}
|
|
parent.hasDs1 = wasHasDs1
|
|
}
|
|
}
|
|
|
|
ctx.stats.notes.push(`could not place special piece ${piece.name}: no adjacent space clear`)
|
|
return null
|
|
}
|
|
|
|
/* ------------------------------------------------------------------------- *
|
|
* 1. Act 2 - Tomb
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
/**
|
|
* Tal Rasha's Tombs, Stony Tomb, Halls of the Dead, Claw Viper Temple.
|
|
*
|
|
* Places True Tomb Horadric Staff orifice, False Tomb golden chest rooms, Ancient Kaa the Soulless,
|
|
* Horadric Cube chest, Leatherarm, and staircases.
|
|
*/
|
|
export function placeAct2TombStuff(ctx: SpecialPassContext): void {
|
|
const rng = getPassRng(ctx)
|
|
const levelId = ctx.request.levelId
|
|
const staffTomb = ctx.request.staffTombLevelId ?? 66
|
|
const bossTomb = ctx.request.bossTombLevelId
|
|
|
|
// Determine initial entrance direction from warp room
|
|
let nDirection = 0
|
|
const warpRoom = ctx.level.rooms.find(r => r.piece?.kind === 'prev')
|
|
if (warpRoom?.piece !== null && warpRoom?.piece !== undefined) {
|
|
const name = warpRoom.piece.name.toUpperCase()
|
|
if (name.includes('NSW')) nDirection = 0
|
|
else if (name.includes('NEW')) nDirection = 1
|
|
else if (name.includes('NSE')) nDirection = 2
|
|
else if (name.includes('SEW')) nDirection = 3
|
|
else nDirection = rng.int(0, 3)
|
|
} else {
|
|
nDirection = rng.int(0, 3)
|
|
}
|
|
|
|
const GROW_DIRS = [DIRECTION_SOUTH, DIRECTION_WEST, DIRECTION_NORTH, DIRECTION_EAST]
|
|
|
|
const placeTombPiece = (kind: MazePieceKind, role: string) => {
|
|
const side = DIRS[nDirection]!
|
|
const growDir = GROW_DIRS[nDirection]!
|
|
placeNamedPiece(ctx, {
|
|
kind,
|
|
namePrefix: `Act 2 - Tomb ${role}`,
|
|
sides: side,
|
|
plainSides: side,
|
|
direction: growDir,
|
|
preferFurthest: role !== 'Prev',
|
|
})
|
|
nDirection = (nDirection + 1) % 4
|
|
}
|
|
|
|
// 0. Claw Viper Temple Level 2 (levelId === 61): 1.13c fixed Tainted Sun Altar chamber (Def 633)
|
|
if (levelId === 61) {
|
|
const altarCandidates = ['Act 2 - Tomb Tainted Sun X', 'Act 2 - Tomb Sanctuary', 'Act 2 - Tomb Talrasha']
|
|
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
|
|
const singlePiece = ctx.pieces.find(p => altarCandidates.some(c => p.name.startsWith(c)))
|
|
if (singlePiece !== undefined) {
|
|
ctx.level.first.piece = singlePiece
|
|
ctx.level.first.hasDs1 = true
|
|
noteSpecial(ctx.level.first, singlePiece, '', ctx.stats)
|
|
ctx.stats.notes.push(`lvlPrestDef=${String(LVLPREST_ACT2_TOMB_TAINTED_SUN_X)} (${singlePiece.name})`)
|
|
return
|
|
}
|
|
}
|
|
const matchedAltarPiece = ctx.pieces.find(p => altarCandidates.some(c => p.name === c || p.name.startsWith(c)))
|
|
const placedAltar = replaceRoomByPresetDefId(ctx, LVLPREST_ACT2_TOMB_TAINTED_SUN_X, {
|
|
kind: 'quest',
|
|
...(matchedAltarPiece !== undefined ? { name: matchedAltarPiece.name } : { name: 'Act 2 - Tomb Tainted Sun X' }),
|
|
preferFurthest: true,
|
|
})
|
|
if (placedAltar === null) {
|
|
placeTombPiece('quest', 'Talrasha')
|
|
}
|
|
if (
|
|
ctx.level.rooms.length > 1 &&
|
|
!ctx.level.rooms.some(r => r.piece?.kind === 'prev') &&
|
|
ctx.pieces.some(p => p.name.startsWith('Act 2 - Tomb Prev'))
|
|
) {
|
|
placeTombPiece('prev', 'Prev')
|
|
}
|
|
return
|
|
}
|
|
|
|
// 1. Stony Tomb 1, Stony Tomb 2, Halls of the Dead 1, Claw Viper Temple 1 get Next stairs
|
|
if (levelId >= 55 && levelId <= 58) {
|
|
placeTombPiece('next', 'Next')
|
|
}
|
|
|
|
// 2. Halls of the Dead 2 gets Waypoint
|
|
if (levelId === 57 || levelId === 59 || ctx.request.hasWaypoint === true) {
|
|
placeTombPiece('waypoint', 'Waypoint')
|
|
}
|
|
|
|
// 3. Stony Tomb 2 gets golden chest
|
|
if (levelId === 56) {
|
|
placeTombPiece('treasure', 'Chest')
|
|
}
|
|
|
|
// 4. False Tal Rasha Tombs (66..72 except staff tomb) get golden chest
|
|
if (levelId >= 66 && levelId <= 72 && levelId !== staffTomb) {
|
|
placeTombPiece('treasure', 'Chest')
|
|
}
|
|
|
|
// 5. Stony Tomb 2 gets Leatherarm
|
|
if (levelId === 56) {
|
|
placeTombPiece('quest', 'Leatherarm')
|
|
}
|
|
|
|
// 6. Halls of the Dead 3 gets Horadric Cube
|
|
if (levelId === 60) {
|
|
placeTombPiece('quest', 'Cube')
|
|
}
|
|
|
|
// 7. Stony Tomb 2 gets Treasure
|
|
if (levelId === 56) {
|
|
placeTombPiece('treasure', 'Treasure')
|
|
}
|
|
|
|
// 8. True Tal Rasha Tomb gets Horadric Staff Orifice (Def 625..628)
|
|
if (levelId === staffTomb) {
|
|
const talrashaDef = LVLPREST_ACT2_TOMB_TALRASHA_BASE + (nDirection % 4)
|
|
ctx.stats.notes.push(`lvlPrestDef=${String(talrashaDef)} (Act 2 - Tomb Talrasha)`)
|
|
placeTombPiece('quest', 'Talrasha')
|
|
}
|
|
|
|
// 9. Boss Tomb gets Ancient Kaa the Soulless (Def 629..632)
|
|
if (bossTomb !== undefined && levelId === bossTomb) {
|
|
const kaaDef = LVLPREST_ACT2_TOMB_KAA_BASE + (nDirection % 4)
|
|
ctx.stats.notes.push(`lvlPrestDef=${String(kaaDef)} (Act 2 - Tomb Kaa)`)
|
|
placeTombPiece('quest', 'Kaa')
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------------- *
|
|
* 2. Act 2 - Lair
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
/**
|
|
* Maggot Lair Level 1, 2, 3.
|
|
*
|
|
* Level 3 places Coldworm the Burrower (Tight Spot S) and the Staff of Kings chest (Treasure W).
|
|
*/
|
|
export function placeAct2LairStuff(ctx: SpecialPassContext): void {
|
|
const rng = getPassRng(ctx)
|
|
const levelId = ctx.request.levelId
|
|
|
|
if (levelId === 64) {
|
|
// Coldworm the Burrower room
|
|
placeNamedPiece(ctx, {
|
|
kind: 'quest',
|
|
name: 'Act 2 - Lair Tight Spot S',
|
|
plainSides: 'S',
|
|
direction: DIRECTION_NORTH,
|
|
preferFurthest: true,
|
|
})
|
|
|
|
// Staff of Kings chest room
|
|
placeNamedPiece(ctx, {
|
|
kind: 'treasure',
|
|
name: 'Act 2 - Lair Treasure W',
|
|
plainSides: 'W',
|
|
direction: DIRECTION_EAST,
|
|
preferFurthest: true,
|
|
})
|
|
|
|
// Up staircase
|
|
placeNamedPiece(ctx, {
|
|
kind: 'prev',
|
|
namePrefix: 'Act 2 - Lair Prev',
|
|
sides: 'N',
|
|
direction: DIRECTION_SOUTH,
|
|
preferFurthest: false,
|
|
})
|
|
} else {
|
|
const nRand = rng.int(0, 3)
|
|
placeNamedPiece(ctx, {
|
|
kind: 'next',
|
|
namePrefix: 'Act 2 - Lair Next',
|
|
sides: DIRS[nRand],
|
|
plainSides: DIRS[nRand],
|
|
preferFurthest: true,
|
|
})
|
|
placeNamedPiece(ctx, {
|
|
kind: 'prev',
|
|
namePrefix: 'Act 2 - Lair Prev',
|
|
sides: DIRS[(nRand + 1) % 4],
|
|
plainSides: DIRS[(nRand + 1) % 4],
|
|
preferFurthest: false,
|
|
})
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------------- *
|
|
* 3. Act 3 - Dungeon
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
/**
|
|
* Spider Cavern, Arachnid Lair, Swampy Pit 1..3, Flayer Dungeon 1..3, and Kurast Temples.
|
|
*
|
|
* Flayer Dungeon 3 places Khalim's Brain chest (Treasure 2).
|
|
* Swampy Pit 3 places Treasure 1.
|
|
* Temples (levels 94..99) place Lam Esen's Tome altar.
|
|
*/
|
|
export function placeAct3DungeonStuff(ctx: SpecialPassContext): void {
|
|
const rng = getPassRng(ctx)
|
|
const levelId = ctx.request.levelId
|
|
const nDirection = rng.int(0, 3)
|
|
|
|
if (levelId === 91) {
|
|
// Flayer Dungeon Level 3: Khalim's Brain chest
|
|
placeNamedPiece(ctx, {
|
|
kind: 'treasure',
|
|
name: 'Act 3 - Dungeon Treasure 2',
|
|
preferFurthest: true,
|
|
})
|
|
placeNamedPiece(ctx, {
|
|
kind: 'prev',
|
|
namePrefix: 'Act 3 - Dungeon Prev',
|
|
sides: DIRS[nDirection],
|
|
preferFurthest: false,
|
|
})
|
|
} else if (levelId === 90) {
|
|
// Swampy Pit Level 3
|
|
placeNamedPiece(ctx, {
|
|
kind: 'treasure',
|
|
name: 'Act 3 - Dungeon Treasure 1',
|
|
preferFurthest: true,
|
|
})
|
|
placeNamedPiece(ctx, {
|
|
kind: 'prev',
|
|
namePrefix: 'Act 3 - Dungeon Prev',
|
|
sides: DIRS[nDirection],
|
|
preferFurthest: false,
|
|
})
|
|
} else if (levelId >= 94 && levelId <= 99) {
|
|
// Ruined Temple, Disused Fane, Forgotten Reliquary, etc. (Lam Esen's Tome)
|
|
placeNamedPiece(ctx, {
|
|
kind: 'quest',
|
|
name: `Act 3 - Temple ${levelId - 93}`,
|
|
preferFurthest: true,
|
|
})
|
|
} else {
|
|
// Levels 86..89
|
|
placeNamedPiece(ctx, {
|
|
kind: 'prev',
|
|
namePrefix: 'Act 3 - Dungeon Prev',
|
|
sides: DIRS[nDirection],
|
|
plainSides: DIRS[nDirection],
|
|
preferFurthest: false,
|
|
})
|
|
placeNamedPiece(ctx, {
|
|
kind: 'next',
|
|
namePrefix: 'Act 3 - Dungeon Next',
|
|
sides: DIRS[(nDirection + 1) % 4],
|
|
plainSides: DIRS[(nDirection + 1) % 4],
|
|
preferFurthest: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------------- *
|
|
* 4. Act 3 - Sewer
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
/**
|
|
* Kurast Sewers Level 1 & 2 (`DRLGMAZE_PlaceAct3SewerStuff`).
|
|
*
|
|
* In 1.13c, Level 1 (levelId === 92) places the 4 corner entrance stair rooms (`LVLPREST_ACT3_SEWER_SW/SE/NW/NE`, Def 585..588),
|
|
* the drain descent lever (`LVLPREST_ACT3_SEWER_DRAIN`, Def 589..592), and the chest room (`Def 593..596`).
|
|
*/
|
|
export function placeAct3SewerStuff(ctx: SpecialPassContext): void {
|
|
const rng = getPassRng(ctx)
|
|
const levelId = ctx.request.levelId
|
|
const nDirection = rng.int(0, 3)
|
|
|
|
if (levelId === 92) {
|
|
// 1.13c 4 corner entrance stair rooms (SW=585, SE=586, NW=587, NE=588)
|
|
const cornerDefs = [
|
|
LVLPREST_ACT3_SEWER_SW,
|
|
LVLPREST_ACT3_SEWER_SE,
|
|
LVLPREST_ACT3_SEWER_NW,
|
|
LVLPREST_ACT3_SEWER_NE,
|
|
] as const
|
|
for (const defId of cornerDefs) {
|
|
const entry = LVLPREST_MAZE_DEF_REGISTRY[defId]!
|
|
if (ctx.pieces.some(p => p.name.toLowerCase() === entry.name.toLowerCase())) {
|
|
replaceRoomByPresetDefId(ctx, defId, { preferFurthest: false })
|
|
} else {
|
|
ctx.stats.notes.push(`lvlPrestDef=${String(defId)} (${entry.name})`)
|
|
}
|
|
}
|
|
|
|
// Sewer Level 1: Drain lever / stairs down to Sewer 2 (Def 589..592)
|
|
const drainDef = LVLPREST_ACT3_SEWER_DRAIN_BASE + nDirection
|
|
ctx.stats.notes.push(`lvlPrestDef=${String(drainDef)} (Act 3 - Sewer Drain ${DIRS[nDirection]})`)
|
|
placeNamedPiece(ctx, {
|
|
kind: 'next',
|
|
namePrefix: 'Act 3 - Sewer Drain',
|
|
sides: DIRS[nDirection],
|
|
plainSides: DIRS[nDirection],
|
|
direction: DIRECTION_NORTH,
|
|
preferFurthest: true,
|
|
})
|
|
// Chest room (Def 593..596)
|
|
const chestDir = (nDirection + 1) % 4
|
|
const chestDef = LVLPREST_ACT3_SEWER_CHEST_BASE + chestDir
|
|
ctx.stats.notes.push(`lvlPrestDef=${String(chestDef)} (Act 3 - Sewer Chest ${DIRS[chestDir]})`)
|
|
placeNamedPiece(ctx, {
|
|
kind: 'treasure',
|
|
namePrefix: 'Act 3 - Sewer Chest',
|
|
sides: DIRS[chestDir],
|
|
plainSides: DIRS[chestDir],
|
|
direction: DIRECTION_SOUTH,
|
|
preferFurthest: true,
|
|
})
|
|
} else {
|
|
// Sewer Level 2 (or other)
|
|
placeNamedPiece(ctx, {
|
|
kind: 'treasure',
|
|
namePrefix: 'Act 3 - Sewer Chest',
|
|
sides: DIRS[nDirection],
|
|
plainSides: DIRS[nDirection],
|
|
preferFurthest: true,
|
|
})
|
|
placeNamedPiece(ctx, {
|
|
kind: 'treasure',
|
|
name: 'Act 3 - Sewer Treasure X',
|
|
preferFurthest: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------------- *
|
|
* 5. Act 3 - Mephisto
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
/**
|
|
* Durance of Hate Level 1, 2, 3.
|
|
*
|
|
* Level 3 places the Mephisto Complex (Council Hall, blood moat, red portal to Act 4).
|
|
* Level 2 places Waypoint, Next and Prev.
|
|
*/
|
|
export function placeAct3MephistoStuff(ctx: SpecialPassContext): void {
|
|
const rng = getPassRng(ctx)
|
|
const levelId = ctx.request.levelId
|
|
const nRand = rng.int(0, 3)
|
|
|
|
if (levelId === 102) {
|
|
// Durance of Hate Level 3: Mephisto Complex
|
|
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
|
|
const complexPiece = ctx.pieces.find(p => p.name === 'Act 3 - Mephisto Complex')
|
|
if (complexPiece !== undefined) {
|
|
ctx.level.first.piece = complexPiece
|
|
ctx.level.first.hasDs1 = true
|
|
noteSpecial(ctx.level.first, complexPiece, '', ctx.stats)
|
|
return
|
|
}
|
|
}
|
|
placeNamedPiece(ctx, {
|
|
kind: 'quest',
|
|
name: 'Act 3 - Mephisto Complex',
|
|
preferFurthest: true,
|
|
})
|
|
} else if (levelId === 101) {
|
|
// Durance of Hate Level 2: Prev, Waypoint, Next
|
|
placeNamedPiece(ctx, {
|
|
kind: 'prev',
|
|
namePrefix: 'Act 3 - Mephisto Prev',
|
|
sides: DIRS[nRand],
|
|
plainSides: DIRS[nRand],
|
|
preferFurthest: false,
|
|
})
|
|
placeNamedPiece(ctx, {
|
|
kind: 'waypoint',
|
|
namePrefix: 'Act 3 - Mephisto Waypoint',
|
|
sides: DIRS[(nRand + 1) % 4],
|
|
plainSides: DIRS[(nRand + 1) % 4],
|
|
preferFurthest: true,
|
|
})
|
|
placeNamedPiece(ctx, {
|
|
kind: 'next',
|
|
namePrefix: 'Act 3 - Mephisto Next',
|
|
sides: DIRS[(nRand + 2) % 4],
|
|
plainSides: DIRS[(nRand + 2) % 4],
|
|
preferFurthest: true,
|
|
})
|
|
} else {
|
|
// Durance of Hate Level 1: Prev, Next
|
|
placeNamedPiece(ctx, {
|
|
kind: 'prev',
|
|
namePrefix: 'Act 3 - Mephisto Prev',
|
|
sides: DIRS[nRand],
|
|
plainSides: DIRS[nRand],
|
|
preferFurthest: false,
|
|
})
|
|
placeNamedPiece(ctx, {
|
|
kind: 'next',
|
|
namePrefix: 'Act 3 - Mephisto Next',
|
|
sides: DIRS[(nRand + 1) % 4],
|
|
plainSides: DIRS[(nRand + 1) % 4],
|
|
preferFurthest: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------------- *
|
|
* 6. Act 5 - Temple
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
/**
|
|
* Halls of Anguish, Halls of Pain, Halls of Vaught (`DRLGMAZE_PlaceAct5TempleStuff`).
|
|
*
|
|
* Enforces 1.13c `nExpansionHardcodedPresetsRemapping` for SW/SE/NW/NE orientations (`Def 1044..1047`).
|
|
* Halls of Vaught (level 124) places Nihlathak's altar (`Def 1059`) and deterministically rolls
|
|
* Nihlathak's active wing orientation (`NW/NE/SE/SW`, `Def 1060..1063`).
|
|
*/
|
|
export function placeAct5TempleStuff(ctx: SpecialPassContext): void {
|
|
const rng = getPassRng(ctx)
|
|
const levelId = ctx.request.levelId
|
|
|
|
if (levelId === 124) {
|
|
// Halls of Vaught: Nihlathak Final Room (Def 1059) + 1 of 4 Nihlathak wing orientations (Def 1060..1063)
|
|
const wingIdx = rng.int(0, 3)
|
|
const nihlathakWing = ACT5_NIHLATHAK_WINGS[wingIdx]!
|
|
const wingDefId = LVLPREST_ACT5_TEMPLE_NIHLATHAK_WING_BASE + wingIdx
|
|
ctx.stats.notes.push(`lvlPrestDef=${String(wingDefId)} (nihlathakWing=${nihlathakWing})`)
|
|
|
|
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
|
|
const finalPiece = ctx.pieces.find(p => p.name === 'Act 5 - Temple Final Room')
|
|
if (finalPiece !== undefined) {
|
|
ctx.level.first.piece = finalPiece
|
|
ctx.level.first.hasDs1 = true
|
|
noteSpecial(ctx.level.first, finalPiece, '', ctx.stats)
|
|
ctx.stats.notes.push(`lvlPrestDef=${String(LVLPREST_ACT5_TEMPLE_FINAL_ROOM)} (Act 5 - Temple Final Room)`)
|
|
return
|
|
}
|
|
}
|
|
replaceRoomByPresetDefId(ctx, LVLPREST_ACT5_TEMPLE_FINAL_ROOM, {
|
|
kind: 'quest',
|
|
name: 'Act 5 - Temple Final Room',
|
|
preferFurthest: true,
|
|
})
|
|
} else {
|
|
// Levels 122 and 123: apply 1.13c nExpansionHardcodedPresetsRemapping (SW=1044, SE=1045, NW=1046, NE=1047)
|
|
const templeDownDirs = ['NE', 'NW', 'SW'] as const
|
|
const nDirection = rng.int(0, templeDownDirs.length - 1)
|
|
const downSide = templeDownDirs[nDirection]!
|
|
const remapIdx = ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING.indexOf(downSide)
|
|
const remappedDefId = LVLPREST_ACT5_TEMPLE_REMAPPING_BASE + (remapIdx >= 0 ? remapIdx : 0)
|
|
ctx.stats.notes.push(`lvlPrestDef=${String(remappedDefId)} (Act 5 - Temple ${downSide} Down)`)
|
|
|
|
placeNamedPiece(ctx, {
|
|
kind: 'down',
|
|
name: `Act 5 - Temple ${downSide} Down`,
|
|
sides: downSide,
|
|
plainSides: downSide,
|
|
preferFurthest: true,
|
|
})
|
|
|
|
if (levelId === 123) {
|
|
const wpSide = ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING[rng.int(0, ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING.length - 1)]!
|
|
placeNamedPiece(ctx, {
|
|
kind: 'waypoint',
|
|
name: `Act 5 - Temple ${wpSide} Waypoint`,
|
|
sides: wpSide,
|
|
plainSides: wpSide,
|
|
preferFurthest: true,
|
|
})
|
|
}
|
|
|
|
placeNamedPiece(ctx, {
|
|
kind: 'prev',
|
|
name: 'Act 5 - Temple SE up',
|
|
sides: 'SE',
|
|
plainSides: 'SE',
|
|
preferFurthest: false,
|
|
})
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------------- *
|
|
* 7. Act 5 - Baal
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
/**
|
|
* Worldstone Keep Level 1, 2, 3 and Throne of Destruction.
|
|
*
|
|
* Throne of Destruction (level 131) places Baal Entrance / ThroneRoom.
|
|
* Worldstone Keep 2 places Waypoint and Next stairs.
|
|
*/
|
|
export function placeAct5BaalStuff(ctx: SpecialPassContext): void {
|
|
const rng = getPassRng(ctx)
|
|
const levelId = ctx.request.levelId
|
|
const nDirection = rng.int(0, 3)
|
|
|
|
if (levelId === 131) {
|
|
// Throne of Destruction
|
|
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
|
|
const thronePiece = ctx.pieces.find(p => p.name === 'Act 5 - ThroneRoom' || p.name === 'Act 5 - Baal Entrance')
|
|
if (thronePiece !== undefined) {
|
|
ctx.level.first.piece = thronePiece
|
|
ctx.level.first.hasDs1 = true
|
|
noteSpecial(ctx.level.first, thronePiece, '', ctx.stats)
|
|
return
|
|
}
|
|
}
|
|
const placed = placeNamedPiece(ctx, {
|
|
kind: 'quest',
|
|
name: 'Act 5 - ThroneRoom',
|
|
preferFurthest: true,
|
|
})
|
|
if (placed === null) {
|
|
placeNamedPiece(ctx, {
|
|
kind: 'quest',
|
|
name: 'Act 5 - Baal Entrance',
|
|
preferFurthest: true,
|
|
})
|
|
}
|
|
} else if (levelId === 129) {
|
|
// Worldstone Keep Level 2: Next and Waypoint
|
|
placeNamedPiece(ctx, {
|
|
kind: 'next',
|
|
namePrefix: 'Act 5 - Baal Next',
|
|
sides: DIRS[nDirection],
|
|
plainSides: DIRS[nDirection],
|
|
preferFurthest: true,
|
|
})
|
|
placeNamedPiece(ctx, {
|
|
kind: 'waypoint',
|
|
namePrefix: 'Act 5 - Baal Waypoint',
|
|
sides: DIRS[(nDirection + 1) % 4],
|
|
plainSides: DIRS[(nDirection + 1) % 4],
|
|
preferFurthest: true,
|
|
})
|
|
} else {
|
|
// Worldstone Keep Levels 1, 3
|
|
placeNamedPiece(ctx, {
|
|
kind: 'next',
|
|
namePrefix: 'Act 5 - Baal Next',
|
|
sides: DIRS[nDirection],
|
|
plainSides: DIRS[nDirection],
|
|
preferFurthest: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------------- *
|
|
* 8. Act 5 - Ice
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
/**
|
|
* Crystalline Passage, Frozen River, Glacial Trail, Drifter Cavern, Ancients' Way, Icy Cellar (`DRLGMAZE_PlaceAct5IceStuff`).
|
|
*
|
|
* Frozen River (level 114) rolls Anya's frozen platform (`LVLPREST_ACT5_ICE_RIVER_A = 1030` vs `B = 1031`) and places entry stairs.
|
|
* Drifter Cavern (116) places `LVLPREST_ACT5_ICE_POOL_A = 1032`; Icy Cellar (119) places `LVLPREST_ACT5_ICE_POOL_B = 1033`.
|
|
* Crystalline Passage (113), Glacial Trail (115), and Ancients' Way (118) place Prev, Next, Down, Theme, and Waypoint.
|
|
*/
|
|
export function placeAct5IceStuff(ctx: SpecialPassContext): void {
|
|
const rng = getPassRng(ctx)
|
|
const levelId = ctx.request.levelId
|
|
|
|
if (levelId === 114) {
|
|
// Frozen River (Anya platform): 1.13c rolls Def 1030 (River A) vs Def 1031 (River B)
|
|
const variantIdx = rng.int(0, 1)
|
|
const variant = variantIdx === 0 ? 'A' : 'B'
|
|
const riverDefId = variantIdx === 0 ? LVLPREST_ACT5_ICE_RIVER_A : LVLPREST_ACT5_ICE_RIVER_B
|
|
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
|
|
const riverPiece = ctx.pieces.find(p => p.name === `Act 5 - Ice River ${variant}`)
|
|
?? ctx.pieces.find(p => p.name.startsWith('Act 5 - Ice River'))
|
|
if (riverPiece !== undefined) {
|
|
ctx.level.first.piece = riverPiece
|
|
ctx.level.first.hasDs1 = true
|
|
noteSpecial(ctx.level.first, riverPiece, '', ctx.stats)
|
|
ctx.stats.notes.push(`lvlPrestDef=${String(riverDefId)} (${riverPiece.name})`)
|
|
return
|
|
}
|
|
}
|
|
replaceRoomByPresetDefId(ctx, riverDefId, {
|
|
kind: 'quest',
|
|
name: `Act 5 - Ice River ${variant}`,
|
|
preferFurthest: true,
|
|
})
|
|
if (
|
|
ctx.level.rooms.length > 1 &&
|
|
!ctx.level.rooms.some(r => r.piece?.kind === 'prev') &&
|
|
ctx.pieces.some(p => p.name.startsWith('Act 5 - Ice Prev'))
|
|
) {
|
|
const prevDir = rng.int(0, 3)
|
|
placeNamedPiece(ctx, {
|
|
kind: 'prev',
|
|
namePrefix: 'Act 5 - Ice Prev',
|
|
sides: DIRS[prevDir],
|
|
plainSides: DIRS[prevDir],
|
|
preferFurthest: false,
|
|
})
|
|
}
|
|
} else if (levelId === 116) {
|
|
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
|
|
const poolPiece = ctx.pieces.find(p => p.name === 'Act 5 - Ice Pool A')
|
|
if (poolPiece !== undefined) {
|
|
ctx.level.first.piece = poolPiece
|
|
ctx.level.first.hasDs1 = true
|
|
noteSpecial(ctx.level.first, poolPiece, '', ctx.stats)
|
|
ctx.stats.notes.push(`lvlPrestDef=${String(LVLPREST_ACT5_ICE_POOL_A)} (${poolPiece.name})`)
|
|
return
|
|
}
|
|
}
|
|
replaceRoomByPresetDefId(ctx, LVLPREST_ACT5_ICE_POOL_A, {
|
|
kind: 'quest',
|
|
name: 'Act 5 - Ice Pool A',
|
|
preferFurthest: true,
|
|
})
|
|
} else if (levelId === 119) {
|
|
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
|
|
const poolPiece = ctx.pieces.find(p => p.name === 'Act 5 - Ice Pool B')
|
|
if (poolPiece !== undefined) {
|
|
ctx.level.first.piece = poolPiece
|
|
ctx.level.first.hasDs1 = true
|
|
noteSpecial(ctx.level.first, poolPiece, '', ctx.stats)
|
|
ctx.stats.notes.push(`lvlPrestDef=${String(LVLPREST_ACT5_ICE_POOL_B)} (${poolPiece.name})`)
|
|
return
|
|
}
|
|
}
|
|
replaceRoomByPresetDefId(ctx, LVLPREST_ACT5_ICE_POOL_B, {
|
|
kind: 'quest',
|
|
name: 'Act 5 - Ice Pool B',
|
|
preferFurthest: true,
|
|
})
|
|
} else {
|
|
// Levels 113, 115, 118
|
|
let nDirection = rng.int(0, 3)
|
|
placeNamedPiece(ctx, {
|
|
kind: 'prev',
|
|
namePrefix: 'Act 5 - Ice Prev',
|
|
sides: DIRS[nDirection],
|
|
plainSides: DIRS[nDirection],
|
|
preferFurthest: false,
|
|
})
|
|
nDirection = (nDirection + 1) % 4
|
|
placeNamedPiece(ctx, {
|
|
kind: 'next',
|
|
namePrefix: 'Act 5 - Ice Next',
|
|
sides: DIRS[nDirection],
|
|
plainSides: DIRS[nDirection],
|
|
preferFurthest: true,
|
|
})
|
|
nDirection = (nDirection + 1) % 4
|
|
placeNamedPiece(ctx, {
|
|
kind: 'down',
|
|
namePrefix: 'Act 5 - Ice Down',
|
|
sides: DIRS[nDirection],
|
|
plainSides: DIRS[nDirection],
|
|
preferFurthest: true,
|
|
})
|
|
|
|
if (levelId === 115) {
|
|
nDirection = (nDirection + 1) % 4
|
|
placeNamedPiece(ctx, {
|
|
kind: 'theme',
|
|
namePrefix: 'Act 5 - Ice Theme',
|
|
sides: DIRS[nDirection],
|
|
plainSides: DIRS[nDirection],
|
|
preferFurthest: true,
|
|
})
|
|
}
|
|
|
|
if (levelId === 113 || levelId === 115 || levelId === 118) {
|
|
nDirection = (nDirection + 1) % 4
|
|
const wpDef = LVLPREST_ACT5_ICE_WAYPOINT_BASE + nDirection
|
|
ctx.stats.notes.push(`lvlPrestDef=${String(wpDef)} (Act 5 - Ice waypoint ${DIRS[nDirection]})`)
|
|
placeNamedPiece(ctx, {
|
|
kind: 'waypoint',
|
|
namePrefix: 'Act 5 - Ice waypoint',
|
|
sides: DIRS[nDirection],
|
|
plainSides: DIRS[nDirection],
|
|
preferFurthest: true,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------------- *
|
|
* 9. Act 1 - Barracks
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
/**
|
|
* Monastery Barracks (level 28).
|
|
*
|
|
* Places Court Connect, the Smith / Charsi's Malus forge, and stairs to Jail.
|
|
*/
|
|
export function placeAct1Barracks(ctx: SpecialPassContext): void {
|
|
const rng = getPassRng(ctx)
|
|
// Connect towards Outer Cloister
|
|
placeNamedPiece(ctx, {
|
|
kind: 'prev',
|
|
name: 'Act 1 - Barracks Court Connect',
|
|
direction: DIRECTION_WEST,
|
|
preferFurthest: false,
|
|
})
|
|
|
|
const nDirection = rng.int(0, 3)
|
|
const roll = rng.int(0, 1)
|
|
|
|
if (roll === 1) {
|
|
placeNamedPiece(ctx, {
|
|
kind: 'next',
|
|
namePrefix: 'Act 1 - Barracks Next',
|
|
sides: DIRS[nDirection],
|
|
plainSides: DIRS[nDirection],
|
|
preferFurthest: true,
|
|
})
|
|
placeNamedPiece(ctx, {
|
|
kind: 'quest',
|
|
namePrefix: 'Act 1 - Barracks Forge',
|
|
sides: DIRS[(nDirection + 1) % 4],
|
|
plainSides: DIRS[(nDirection + 1) % 4],
|
|
preferFurthest: true,
|
|
})
|
|
} else {
|
|
placeNamedPiece(ctx, {
|
|
kind: 'quest',
|
|
namePrefix: 'Act 1 - Barracks Forge',
|
|
sides: DIRS[nDirection],
|
|
plainSides: DIRS[nDirection],
|
|
preferFurthest: true,
|
|
})
|
|
placeNamedPiece(ctx, {
|
|
kind: 'next',
|
|
namePrefix: 'Act 1 - Barracks Next',
|
|
sides: DIRS[(nDirection + 1) % 4],
|
|
plainSides: DIRS[(nDirection + 1) % 4],
|
|
preferFurthest: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------------- *
|
|
* 10. Act 4 - Lava
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
/**
|
|
* River of Flame (level 107).
|
|
*
|
|
* 1.13c D2MOO DRLGMAZE_PlaceAct4Lava parity:
|
|
* 1. Places Warp N (entrance from City of Damned) at max Y going DIRECTION_SOUTH.
|
|
* 2. Chains 3 bridge rooms going DIRECTION_NORTH towards Chaos Sanctuary:
|
|
* - Bridge 1 (LVLPREST_ACT4_BRIDGE_1)
|
|
* - Bridge 2 segment 1 (LVLPREST_ACT4_BRIDGE_2)
|
|
* - Bridge 2 segment 2 (LVLPREST_ACT4_BRIDGE_2)
|
|
* 3. Hellforge (Hephasto the Armorer):
|
|
* - Roll forgeSide ('W' or 'E').
|
|
* - Scan & Replace: In-place replace matching plain room (sides === forgeSide) if found.
|
|
* - Fallback: Grow adjacent room in DIRECTION_EAST (for W) or DIRECTION_WEST (for E).
|
|
* 4. Fill blank maze spaces with Act 4 - Lava X, IGNORING Bridge 2 segment 2 (bridge3)
|
|
* so the threshold to Chaos Sanctuary is not walled off.
|
|
*/
|
|
export function placeAct4Lava(ctx: SpecialPassContext): void {
|
|
const rng = getPassRng(ctx)
|
|
|
|
// Target pieces
|
|
const warpNPiece = ctx.pieces.find(p => p.name === 'Act 4 - Lava Warp N')
|
|
?? ctx.pieces.find(p => p.name.startsWith('Act 4 - Lava Warp'))
|
|
?? findSpecial(ctx.pieces, 'prev', 'N')
|
|
const bridge1Piece = ctx.pieces.find(p => p.name === 'Act 4 - Bridge 1')
|
|
?? ctx.pieces.find(p => p.name.toLowerCase() === 'act 4 - bridge 1')
|
|
const bridge2Piece = ctx.pieces.find(p => p.name === 'Act 4 - Bridge 2')
|
|
?? ctx.pieces.find(p => p.name.toLowerCase() === 'act 4 - bridge 2')
|
|
|
|
// 1. Warp N (entrance from City of Damned)
|
|
// D2MOO DRLGMAZE_GetFreeLocationForRoomNorth: find room with max Y that can place DIRECTION_SOUTH
|
|
const southCandidates = [...ctx.level.rooms].sort((a, b) => b.y - a.y)
|
|
let warpRoom: Room | null = null
|
|
for (const parent of southCandidates) {
|
|
warpRoom = addAdjacentMazeRoom(ctx.level, parent, DIRECTION_SOUTH, false, true, ctx.index, ctx.stats)
|
|
if (warpRoom !== null) break
|
|
}
|
|
if (warpRoom === null && warpNPiece !== undefined) {
|
|
warpRoom = placeNamedPiece(ctx, {
|
|
kind: 'prev',
|
|
namePrefix: 'Act 4 - Lava Warp',
|
|
sides: 'N',
|
|
plainSides: 'N',
|
|
direction: DIRECTION_SOUTH,
|
|
preferFurthest: false,
|
|
})
|
|
} else if (warpRoom !== null && warpNPiece !== undefined) {
|
|
warpRoom.piece = warpNPiece
|
|
warpRoom.variant = 0
|
|
warpRoom.hasDs1 = true
|
|
noteSpecial(warpRoom, warpNPiece, 'N', ctx.stats)
|
|
}
|
|
|
|
// 2. Bridges towards Chaos Sanctuary
|
|
// D2MOO DRLGMAZE_GetFreeLocationForRoomSouth: find room with min Y that can place DIRECTION_NORTH
|
|
// Chains 3 bridge rooms: Bridge 1, Bridge 2 (seg 1), Bridge 2 (seg 2)
|
|
const northCandidates = [...ctx.level.rooms].filter(r => r !== warpRoom).sort((a, b) => a.y - b.y)
|
|
let bridge1: Room | null = null
|
|
let bridge2: Room | null = null
|
|
let bridge3: Room | null = null
|
|
|
|
for (const parent of northCandidates) {
|
|
bridge1 = addAdjacentMazeRoom(ctx.level, parent, DIRECTION_NORTH, false, true, ctx.index, ctx.stats)
|
|
if (bridge1 !== null) {
|
|
bridge2 = addAdjacentMazeRoom(ctx.level, bridge1, DIRECTION_NORTH, false, false, ctx.index, ctx.stats)
|
|
if (bridge2 !== null) {
|
|
bridge3 = addAdjacentMazeRoom(ctx.level, bridge2, DIRECTION_NORTH, false, false, ctx.index, ctx.stats)
|
|
if (bridge3 !== null) break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback if tight space prevented placing all 3 chained bridges
|
|
if (bridge1 === null) {
|
|
bridge1 = placeNamedPiece(ctx, {
|
|
kind: 'quest',
|
|
name: 'Act 4 - Bridge 1',
|
|
direction: DIRECTION_NORTH,
|
|
preferFurthest: true,
|
|
})
|
|
} else if (bridge1Piece !== undefined) {
|
|
bridge1.piece = bridge1Piece
|
|
bridge1.variant = 0
|
|
bridge1.hasDs1 = true
|
|
noteSpecial(bridge1, bridge1Piece, '', ctx.stats)
|
|
}
|
|
|
|
if (bridge2 === null) {
|
|
bridge2 = placeNamedPiece(ctx, {
|
|
kind: 'quest',
|
|
name: 'Act 4 - Bridge 2',
|
|
direction: DIRECTION_NORTH,
|
|
preferFurthest: true,
|
|
})
|
|
} else if (bridge2Piece !== undefined) {
|
|
bridge2.piece = bridge2Piece
|
|
bridge2.variant = 0
|
|
bridge2.hasDs1 = true
|
|
noteSpecial(bridge2, bridge2Piece, '', ctx.stats)
|
|
}
|
|
|
|
if (bridge3 === null && bridge2 !== null) {
|
|
bridge3 = addAdjacentMazeRoom(ctx.level, bridge2, DIRECTION_NORTH, false, false, ctx.index, ctx.stats)
|
|
}
|
|
if (bridge3 !== null && bridge2Piece !== undefined) {
|
|
bridge3.piece = bridge2Piece
|
|
bridge3.variant = 0
|
|
bridge3.hasDs1 = true
|
|
noteSpecial(bridge3, bridge2Piece, '', ctx.stats)
|
|
}
|
|
|
|
// 3. Hellforge (Hephasto the Armorer)
|
|
// D2MOO DRLGMAZE_ScanReplaceSpecialPreset with nAct4LavaForgeIds:
|
|
// Forge W (Def 853) replaces Lava W (Def 837) or attaches in direction 2 (East)
|
|
// Forge E (Def 854) replaces Lava E (Def 838) or attaches in direction 0 (West)
|
|
const forgeSide = rng.int(0, 1) === 0 ? 'W' : 'E'
|
|
const forgeName = `Act 4 - Lava Forge ${forgeSide}`
|
|
const forgePiece = ctx.pieces.find(p => p.name === forgeName)
|
|
?? ctx.pieces.find(p => p.name.startsWith('Act 4 - Lava Forge') && p.sides === forgeSide)
|
|
?? findSpecial(ctx.pieces, 'quest', forgeSide)
|
|
?? ctx.pieces.find(p => p.kind === 'quest' && p.sides === forgeSide)
|
|
|
|
if (forgePiece !== undefined) {
|
|
const targetMask = sideMaskFromToken(forgeSide)
|
|
// Scan existing rooms: first check if a room already matches the single-door side
|
|
const replaceableRooms = ctx.level.rooms.filter(r => !r.hasDs1 && roomSideMask(r) === targetMask)
|
|
let forgeRoom: Room | null = replaceableRooms[0] ?? null
|
|
|
|
if (forgeRoom !== null) {
|
|
// In-place replace
|
|
forgeRoom.piece = forgePiece
|
|
forgeRoom.variant = 0
|
|
forgeRoom.hasDs1 = true
|
|
noteSpecial(forgeRoom, forgePiece, forgeSide, ctx.stats)
|
|
} else {
|
|
// Not found: attempt to attach in the designated direction (East for W, West for E)
|
|
const attachDir = forgeSide === 'W' ? DIRECTION_EAST : DIRECTION_WEST
|
|
const attachCandidates = ctx.level.rooms.filter(r => !r.hasDs1)
|
|
for (const parent of attachCandidates) {
|
|
forgeRoom = addAdjacentMazeRoom(ctx.level, parent, attachDir, false, true, ctx.index, ctx.stats)
|
|
if (forgeRoom !== null) break
|
|
}
|
|
if (forgeRoom === null) {
|
|
// Fallback to general placement
|
|
forgeRoom = placeNamedPiece(ctx, {
|
|
kind: 'quest',
|
|
name: forgeName,
|
|
plainSides: forgeSide,
|
|
preferFurthest: true,
|
|
})
|
|
} else {
|
|
forgeRoom.piece = forgePiece
|
|
forgeRoom.variant = 0
|
|
forgeRoom.hasDs1 = true
|
|
noteSpecial(forgeRoom, forgePiece, forgeSide, ctx.stats)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Fill blank maze spaces with Act 4 - Lava X, ignoring bridge3
|
|
fillBlankSpaces(ctx.level, ctx.pieces, ctx.index, ctx.stats, bridge3)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------------- *
|
|
* 11. Act 2 - Sewers
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
/**
|
|
* Lut Gholein Sewers Level 1, 2, 3 and Ancient Tunnels.
|
|
*
|
|
* Level 3 places Radament's Lair (Book of Skill / Atma's quest).
|
|
* Level 1 places Prev E & Prev NS.
|
|
* Level 2 places Waypoint, Prev, Next.
|
|
* Ancient Tunnels (level 50) places Chest and Prev.
|
|
*/
|
|
export function scanReplaceSpecialAct2SewersPresets(ctx: SpecialPassContext): void {
|
|
const rng = getPassRng(ctx)
|
|
const levelId = ctx.request.levelId
|
|
let nRand = rng.int(0, 3)
|
|
|
|
if (levelId === 49) {
|
|
// Sewers Level 3: Radament's Lair
|
|
placeNamedPiece(ctx, {
|
|
kind: 'prev',
|
|
namePrefix: 'Act 2 - Sewer Prev',
|
|
sides: DIRS[nRand],
|
|
plainSides: DIRS[nRand],
|
|
preferFurthest: false,
|
|
})
|
|
nRand = (nRand + 1) % 4
|
|
placeNamedPiece(ctx, {
|
|
kind: 'quest',
|
|
namePrefix: "Act 2 - Sewer Radament's Lair",
|
|
sides: DIRS[nRand],
|
|
plainSides: DIRS[nRand],
|
|
preferFurthest: true,
|
|
})
|
|
} else if (levelId === 47) {
|
|
// Sewers Level 1: Dock and City entrances
|
|
placeNamedPiece(ctx, {
|
|
kind: 'prev',
|
|
name: 'Act 2 - Sewer Prev E',
|
|
sides: 'E',
|
|
direction: DIRECTION_WEST,
|
|
preferFurthest: false,
|
|
})
|
|
placeNamedPiece(ctx, {
|
|
kind: 'prev',
|
|
name: 'Act 2 - Sewer Prev NS',
|
|
sides: 'NS',
|
|
direction: DIRECTION_NORTH,
|
|
preferFurthest: false,
|
|
})
|
|
placeNamedPiece(ctx, {
|
|
kind: 'next',
|
|
namePrefix: 'Act 2 - Sewer Next',
|
|
sides: DIRS[nRand],
|
|
plainSides: DIRS[nRand],
|
|
preferFurthest: true,
|
|
})
|
|
} else if (levelId === 48) {
|
|
// Sewers Level 2: Prev, Waypoint, Next
|
|
placeNamedPiece(ctx, {
|
|
kind: 'prev',
|
|
namePrefix: 'Act 2 - Sewer Prev',
|
|
sides: DIRS[nRand],
|
|
plainSides: DIRS[nRand],
|
|
preferFurthest: false,
|
|
})
|
|
nRand = (nRand + 1) % 4
|
|
placeNamedPiece(ctx, {
|
|
kind: 'waypoint',
|
|
namePrefix: 'Act 2 - Sewer Waypoint',
|
|
sides: DIRS[nRand],
|
|
plainSides: DIRS[nRand],
|
|
preferFurthest: true,
|
|
})
|
|
nRand = (nRand + 1) % 4
|
|
placeNamedPiece(ctx, {
|
|
kind: 'next',
|
|
namePrefix: 'Act 2 - Sewer Next',
|
|
sides: DIRS[nRand],
|
|
plainSides: DIRS[nRand],
|
|
preferFurthest: true,
|
|
})
|
|
} else {
|
|
// Ancient Tunnels (level 50) or other
|
|
placeNamedPiece(ctx, {
|
|
kind: 'prev',
|
|
namePrefix: 'Act 2 - Sewer Prev',
|
|
sides: DIRS[nRand],
|
|
plainSides: DIRS[nRand],
|
|
preferFurthest: false,
|
|
})
|
|
nRand = (nRand + 1) % 4
|
|
placeNamedPiece(ctx, {
|
|
kind: 'treasure',
|
|
namePrefix: 'Act 2 - Sewer Chest',
|
|
sides: DIRS[nRand],
|
|
plainSides: DIRS[nRand],
|
|
preferFurthest: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------------- *
|
|
* Master Runner
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
export function runMazeSpecialPasses(passName: SpecialPassName, ctx: SpecialPassContext): void {
|
|
ctx.stats.specialPassesApplied.push(passName)
|
|
switch (passName) {
|
|
case 'DRLGMAZE_PlaceAct2TombStuff':
|
|
placeAct2TombStuff(ctx)
|
|
break
|
|
case 'DRLGMAZE_PlaceAct2LairStuff':
|
|
placeAct2LairStuff(ctx)
|
|
break
|
|
case 'DRLGMAZE_PlaceAct3DungeonStuff':
|
|
placeAct3DungeonStuff(ctx)
|
|
break
|
|
case 'DRLGMAZE_PlaceAct3SewerStuff':
|
|
placeAct3SewerStuff(ctx)
|
|
break
|
|
case 'DRLGMAZE_PlaceAct3MephistoStuff':
|
|
placeAct3MephistoStuff(ctx)
|
|
break
|
|
case 'DRLGMAZE_PlaceAct5TempleStuff':
|
|
placeAct5TempleStuff(ctx)
|
|
break
|
|
case 'DRLGMAZE_PlaceAct5BaalStuff':
|
|
placeAct5BaalStuff(ctx)
|
|
break
|
|
case 'DRLGMAZE_PlaceAct5IceStuff':
|
|
placeAct5IceStuff(ctx)
|
|
break
|
|
case 'DRLGMAZE_PlaceAct1Barracks':
|
|
placeAct1Barracks(ctx)
|
|
break
|
|
case 'DRLGMAZE_PlaceAct4Lava':
|
|
placeAct4Lava(ctx)
|
|
break
|
|
case 'DRLGMAZE_ScanReplaceSpecialAct2SewersPresets':
|
|
scanReplaceSpecialAct2SewersPresets(ctx)
|
|
break
|
|
}
|
|
}
|