Merge branch 'fix/issue-49'

# Conflicts:
#	src/game/maze.ts
This commit is contained in:
troytt 2026-09-17 08:49:32 +00:00
commit 4c584db273
3 changed files with 1860 additions and 52 deletions

View File

@ -0,0 +1,997 @@
/**
* 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 {
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
}
const DIRS = ['N', 'E', 'S', 'W'] as const
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 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 = ctx.request.seed % 4
} else {
nDirection = ctx.request.seed % 4
}
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
}
// 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
if (levelId === staffTomb) {
placeTombPiece('quest', 'Talrasha')
}
// 9. Boss Tomb gets Ancient Kaa the Soulless
if (bossTomb !== undefined && levelId === bossTomb) {
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 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 = ctx.request.seed % 4
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 levelId = ctx.request.levelId
const nDirection = ctx.request.seed % 4
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.
*
* Level 1 places the drain descent lever and staircase down to Level 2.
*/
export function placeAct3SewerStuff(ctx: SpecialPassContext): void {
const levelId = ctx.request.levelId
const nDirection = ctx.request.seed % 4
if (levelId === 92) {
// Sewer Level 1: Drain lever / stairs down to Sewer 2
placeNamedPiece(ctx, {
kind: 'next',
namePrefix: 'Act 3 - Sewer Drain',
sides: DIRS[nDirection],
plainSides: DIRS[nDirection],
direction: DIRECTION_NORTH,
preferFurthest: true,
})
// Chest room
placeNamedPiece(ctx, {
kind: 'treasure',
namePrefix: 'Act 3 - Sewer Chest',
sides: DIRS[(nDirection + 1) % 4],
plainSides: DIRS[(nDirection + 1) % 4],
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 levelId = ctx.request.levelId
const nRand = ctx.request.seed % 4
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.
*
* Halls of Vaught (level 124) places Nihlathak's altar (Final Room).
* Halls of Pain (level 123) places Waypoint and descent stairs.
*/
export function placeAct5TempleStuff(ctx: SpecialPassContext): void {
const levelId = ctx.request.levelId
if (levelId === 124) {
// Halls of Vaught: Nihlathak Final Room
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)
return
}
}
placeNamedPiece(ctx, {
kind: 'quest',
name: 'Act 5 - Temple Final Room',
preferFurthest: true,
})
} else {
// Levels 122 and 123
const templeDownDirs = ['NE', 'NW', 'SW']
const nDirection = (ctx.request.seed >>> 0) % 3
const downSide = templeDownDirs[nDirection]!
placeNamedPiece(ctx, {
kind: 'down',
name: `Act 5 - Temple ${downSide} Down`,
sides: downSide,
plainSides: downSide,
preferFurthest: true,
})
if (levelId === 123) {
const templeWaypointDirs = ['NE', 'NW', 'SW', 'SE']
const wpSide = templeWaypointDirs[(nDirection + 1) % 4]!
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 levelId = ctx.request.levelId
const nDirection = ctx.request.seed % 4
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.
*
* Frozen River (level 114) places Anya's frozen platform (River A or River B).
* Drifter Cavern (116) and Icy Cellar (119) place Pool A / Pool B.
* Glacial Trail (115) places Theme and Waypoint.
*/
export function placeAct5IceStuff(ctx: SpecialPassContext): void {
const levelId = ctx.request.levelId
if (levelId === 114) {
// Frozen River (Anya platform)
const variant = (ctx.request.seed % 2) === 0 ? 'A' : '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)
return
}
}
placeNamedPiece(ctx, {
kind: 'quest',
name: `Act 5 - Ice River ${variant}`,
preferFurthest: true,
})
} 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)
return
}
}
placeNamedPiece(ctx, { 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)
return
}
}
placeNamedPiece(ctx, { kind: 'quest', name: 'Act 5 - Ice Pool B', preferFurthest: true })
} else {
// Levels 113, 115, 118
let nDirection = ctx.request.seed % 4
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) {
nDirection = (nDirection + 1) % 4
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 {
// Connect towards Outer Cloister
placeNamedPiece(ctx, {
kind: 'prev',
name: 'Act 1 - Barracks Court Connect',
direction: DIRECTION_WEST,
preferFurthest: false,
})
const nDirection = ctx.request.seed % 4
const roll = (ctx.request.seed >>> 2) % 2
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).
*
* Places Warp N (towards City of Damned), Bridges 1 & 2 towards Chaos Sanctuary,
* Hephasto the Armorer / Hellforge, and fills blanks with Lava X.
*/
export function placeAct4Lava(ctx: SpecialPassContext): void {
// 1. Warp N (entrance from City of Damned)
placeNamedPiece(ctx, {
kind: 'prev',
namePrefix: 'Act 4 - Lava Warp',
sides: 'N',
plainSides: 'N',
direction: DIRECTION_SOUTH,
preferFurthest: false,
})
// 2. Bridges towards Chaos Sanctuary
placeNamedPiece(ctx, {
kind: 'quest',
name: 'Act 4 - Bridge 1',
direction: DIRECTION_NORTH,
preferFurthest: true,
})
placeNamedPiece(ctx, {
kind: 'quest',
name: 'Act 4 - Bridge 2',
direction: DIRECTION_NORTH,
preferFurthest: true,
})
// 3. Hellforge (Hephasto the Armorer)
const forgeSide = (ctx.request.seed % 2) === 0 ? 'W' : 'E'
placeNamedPiece(ctx, {
kind: 'quest',
name: `Act 4 - Lava Forge ${forgeSide}`,
plainSides: forgeSide,
preferFurthest: true,
})
// 4. Fill blank maze spaces with Act 4 - Lava X
fillBlankSpaces(ctx.level, ctx.pieces, ctx.index, ctx.stats)
}
/* ------------------------------------------------------------------------- *
* 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 levelId = ctx.request.levelId
let nRand = ctx.request.seed % 4
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
}
}

View File

@ -81,6 +81,7 @@ import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.
import { Rng } from './rng.ts'
import { SUB_TILES_PER_TILE } from './map.ts'
export { SUB_TILES_PER_TILE }
import { type SpecialPassName, runMazeSpecialPasses } from './maze-special-passes.ts'
/* ------------------------------------------------------------------------- *
* Directions
@ -194,7 +195,6 @@ export type MazePieceKind =
| 'quest'
| 'treasure'
| 'waypoint'
/**
* A waypoint room, as reported in `stats.waypoints`.
*/
@ -287,6 +287,7 @@ export interface MazeRequest {
readonly staffTombLevelId?: number
/** Boss tomb (`LvlMaze.Rooms` doubled); see {@link staffTombLevelId}. */
readonly bossTombLevelId?: number
/**
* Explicit override for waypoint placement.
* If true, forces a waypoint room to be placed (using waypoint pieces).
* If false, skips waypoint room placement even if the level profile includes it.
@ -346,6 +347,18 @@ const KIND_PREFIXES: readonly (readonly [string, MazePieceKind])[] = [
['Theme', 'theme'],
['Waypoint', 'waypoint'],
['waypoint', 'waypoint'],
['Talrasha', 'quest'],
["Radament's Lair", 'quest'],
['Radament', 'quest'],
['Tight Spot', 'quest'],
['Forge', 'quest'],
['Court Connect', 'next'],
['Bridge', 'next'],
['Complex', 'quest'],
['Final Room', 'quest'],
['Cube', 'treasure'],
['Leatherarm', 'quest'],
['Kaa', 'quest'],
]
/** Which `LvlPrest` name families belong to which maze level type. */
@ -364,9 +377,11 @@ export const MAZE_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> =
'Act 2 - Sanctuary': ['Act 2 - Sanctuary'],
'Act 3 - Sewer': ['Act 3 - Sewer'],
'Act 3 - Kurast': ['Act 3 - Mephisto', 'Act 3 - Kurast'],
'Act 3 - Dungeon': ['Act 3 - Dungeon', 'Act 3 - Temple'],
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Bridge'],
'Act 5 - Ice Caves': ['Act 5 - Ice Caves', 'Act 5 - Ice'],
'Act 5 - Temple': ['Act 5 - Temple'],
'Act 5 - Baal': ['Act 5 - Baal'],
'Act 5 - Baal': ['Act 5 - Baal', 'Act 5 - ThroneRoom'],
}
/**
@ -385,16 +400,27 @@ export const MAZE_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> =
*/
export function classifyMazePieceName(name: string, levelTypeName: string): { kind: MazePieceKind; sides: string } | null {
const families = MAZE_PIECE_FAMILIES[levelTypeName] ?? [levelTypeName]
const matchedFamily = families.find(family => name.startsWith(family))
const matchedFamily = families.find(family => name.toLowerCase().startsWith(family.toLowerCase()))
if (matchedFamily === undefined) return null
const rest = name.slice(matchedFamily.length).trim()
if (rest === '') return { kind: 'entrance', sides: '' }
if (rest === '' || rest.toLowerCase() === 'entrance') {
if (matchedFamily.toLowerCase().endsWith('throneroom')) return { kind: 'quest', sides: '' }
return { kind: 'entrance', sides: '' }
}
for (const [prefix, kind] of KIND_PREFIXES) {
if (!rest.toLowerCase().startsWith(prefix.toLowerCase())) continue
const tail = rest.slice(prefix.length).trim()
// `Treasure 1` is numbered rather than sided; everything else uses sides.
if (kind === 'treasure' && /^\d+$/.test(tail)) return { kind, sides: '' }
return { kind, sides: tail }
if (rest.toLowerCase().startsWith(prefix.toLowerCase())) {
const tail = rest.slice(prefix.length).trim()
// `Treasure 1`, `Temple 1`, `Bridge 1` are numbered rather than sided; everything else uses sides.
if ((kind === 'treasure' || kind === 'quest' || kind === 'next') && /^\d+$/.test(tail)) {
return { kind, sides: '' }
}
return { kind, sides: tail }
}
}
if (/^\d+$/.test(rest)) {
if (matchedFamily.toLowerCase().endsWith('bridge')) return { kind: 'next', sides: '' }
if (matchedFamily.toLowerCase().endsWith('temple')) return { kind: 'quest', sides: '' }
if (matchedFamily.toLowerCase().endsWith('treasure')) return { kind: 'treasure', sides: '' }
}
const reversedMatch = /^([NSEW]+)\s+(up|down|waypoint)$/i.exec(rest)
if (reversedMatch !== null) {
@ -403,8 +429,8 @@ export function classifyMazePieceName(name: string, levelTypeName: string): { ki
return { kind: role === 'up' ? 'prev' : role === 'down' ? 'next' : 'waypoint', sides }
}
// Anything left is a bare side token: `W`, `EW`, `NSEW`, ...
if (/^[NSEW]+$/.test(rest)) return { kind: 'room', sides: rest }
if (/^(River|Pool|Tainted Sun)/.test(rest)) return { kind: 'room', sides: '' }
if (/^[NSEW]+$/i.test(rest)) return { kind: 'room', sides: rest.toUpperCase() }
if (/^(River|Pool|Tainted Sun)/i.test(rest)) return { kind: 'room', sides: '' }
return null
}
@ -519,6 +545,8 @@ export interface MazeLevelProfile {
readonly singleRoom?: Readonly<Record<number, readonly string[]>>
/** `Prev`/`Next`/quest/treasure replacement groups, in call order. */
readonly specials: readonly SpecialGroup[]
/** Optional official dungeon special pass from D2MOO. */
readonly specialPass?: SpecialPassName
}
/** The four single-side layouts, in the order the D2MOO tables list them. */
@ -578,6 +606,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
specials: [
{ kind: 'next', entries: entries() },
],
specialPass: 'DRLGMAZE_PlaceAct1Barracks',
},
{
levelTypeName: 'Act 1 - Jail',
@ -612,8 +641,8 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
specials: [
{ kind: 'prev', entries: entries() },
{ kind: 'next', entries: entries(), levelIds: [47, 48] },
{ kind: 'waypoint', entries: entries(), levelIds: [48] },
],
specialPass: 'DRLGMAZE_ScanReplaceSpecialAct2SewersPresets',
},
{
levelTypeName: 'Act 2 - Tomb',
@ -623,8 +652,8 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
singleRoom: { 61: ['Act 2 - Tomb Tainted Sun X'] },
specials: [
{ kind: 'next', entries: entries(), levelIds: [55, 56, 57, 58] },
{ kind: 'waypoint', entries: entries(), levelIds: [57] },
],
specialPass: 'DRLGMAZE_PlaceAct2TombStuff',
},
{
levelTypeName: 'Act 2 - Lair',
@ -635,6 +664,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
{ kind: 'prev', entries: entries() },
{ kind: 'next', entries: entries(), levelIds: [62, 63] },
],
specialPass: 'DRLGMAZE_PlaceAct2LairStuff',
},
{
levelTypeName: 'Act 2 - Arcane',
@ -654,8 +684,8 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
specials: [
{ kind: 'prev', entries: entries(), levelIds: [100, 101] },
{ kind: 'next', entries: entries(), levelIds: [100, 101] },
{ kind: 'waypoint', entries: entries(), levelIds: [101] },
],
specialPass: 'DRLGMAZE_PlaceAct3MephistoStuff',
},
{
levelTypeName: 'Act 3 - Dungeon',
@ -666,6 +696,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
{ kind: 'prev', entries: entries(), levelIds: [86, 87, 88, 89] },
{ kind: 'next', entries: entries(), levelIds: [86, 87, 88, 89] },
],
specialPass: 'DRLGMAZE_PlaceAct3DungeonStuff',
},
{
levelTypeName: 'Act 3 - Sewer',
@ -704,6 +735,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
levelIds: [92],
},
],
specialPass: 'DRLGMAZE_PlaceAct3SewerStuff',
},
{
levelTypeName: 'Act 4 - Lava',
@ -721,6 +753,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
levelIds: [107],
},
],
specialPass: 'DRLGMAZE_PlaceAct4Lava',
},
{
levelTypeName: 'Act 5 - Ice Caves',
@ -736,8 +769,8 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
{ kind: 'prev', entries: entries(), levelIds: [113, 115, 118] },
{ kind: 'next', entries: entries(), levelIds: [113, 115, 118] },
{ kind: 'down', entries: entries(), levelIds: [113, 115, 118] },
{ kind: 'waypoint', entries: entries(), levelIds: [113, 115, 118] },
],
specialPass: 'DRLGMAZE_PlaceAct5IceStuff',
},
{
levelTypeName: 'Act 5 - Temple',
@ -767,18 +800,8 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
levelIds: [122, 123],
inPlaceOnly: true,
},
{
kind: 'waypoint',
entries: [
{ plainSides: 'NE', sides: 'NE', direction: DIRECTION_NORTH },
{ plainSides: 'NW', sides: 'NW', direction: DIRECTION_NORTH },
{ plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH },
{ plainSides: 'SW', sides: 'SW', direction: DIRECTION_SOUTH },
],
levelIds: [123],
inPlaceOnly: true,
},
],
specialPass: 'DRLGMAZE_PlaceAct5TempleStuff',
},
{
levelTypeName: 'Act 5 - Baal',
@ -787,8 +810,8 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
prevChain: 'baal',
specials: [
{ kind: 'next', entries: entries(), levelIds: [128, 129, 130] },
{ kind: 'waypoint', entries: entries(), levelIds: [129] },
],
specialPass: 'DRLGMAZE_PlaceAct5BaalStuff',
},
{ levelTypeName: 'Act 5 - Lava', grow: false, theme: false, lavaPairs: true, specials: [] },
]
@ -804,22 +827,10 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
export const MAZE_SHARED_PRESET_NAMES: readonly string[] = ['Act 4 - Lava X']
/**
* Per-act decoration passes D2MOO runs after the layout, which this port does not
* reproduce. Reported verbatim in every result's `stats.unimplementedPasses`.
* Per-act decoration passes D2MOO runs after the layout.
* All 11 core dungeon passes are now implemented in `maze-special-passes.ts`.
*/
const UNIMPLEMENTED_PASSES: readonly string[] = [
'DRLGMAZE_PlaceAct2TombStuff',
'DRLGMAZE_PlaceAct2LairStuff',
'DRLGMAZE_PlaceAct3DungeonStuff',
'DRLGMAZE_PlaceAct3SewerStuff',
'DRLGMAZE_PlaceAct3MephistoStuff',
'DRLGMAZE_PlaceAct5TempleStuff',
'DRLGMAZE_PlaceAct5BaalStuff',
'DRLGMAZE_PlaceAct5IceStuff',
'DRLGMAZE_PlaceAct1Barracks',
'DRLGMAZE_PlaceAct4Lava',
'DRLGMAZE_ScanReplaceSpecialAct2SewersPresets',
]
export const UNIMPLEMENTED_PASSES: readonly string[] = []
/* ------------------------------------------------------------------------- *
* Geometry
@ -901,7 +912,7 @@ function directionFrom(from: Rect, to: Rect): number {
* ------------------------------------------------------------------------- */
/** One maze section. */
interface Room extends Rect {
export interface Room extends Rect {
/** Doorways to neighbouring rooms, in insertion order. */
readonly orths: { room: Room; direction: number }[]
/** Creation index, so diagnostics can name a room stably. */
@ -924,7 +935,7 @@ interface Room extends Rect {
}
/** The level under construction. */
interface MazeLevel {
export interface MazeLevel {
/** Rooms newest-first, matching `DRLGROOM_AddRoomExToLevel`'s prepend. */
readonly rooms: Room[]
/** The first room created; D2MOO keeps this pointer across prepends. */
@ -949,10 +960,11 @@ interface MazeLevel {
* facts are which piece answered which side pattern and where the exact pattern
* was missing.
*/
interface MazeStats {
export interface MazeStats {
readonly patterns: Record<string, Record<string, number>>
readonly fallbacks: { room: number; requested: string; used: string }[]
readonly specialsApplied: { room: number; kind: string; sides: string }[]
readonly specialPassesApplied: string[]
readonly unresolvedRoles: string[]
readonly notes: string[]
ringRooms: number
@ -1038,7 +1050,7 @@ function linkRooms(a: Room, b: Room, direction: number): void {
* @param room - the room.
* @returns the mask, 0..15.
*/
function roomSideMask(room: Room): number {
export function roomSideMask(room: Room): number {
let mask = 0
for (const orth of room.orths) mask |= DIRECTION_SIDE_BIT[orth.direction]!
return mask
@ -1170,7 +1182,7 @@ function findUnfinishedRoom(level: MazeLevel, mask: number | null): Room | null
* `DRLGMAZE_FillBlankMazeSpaces` deliberately does not.
* @returns the new room, or null.
*/
function addAdjacentMazeRoom(
export function addAdjacentMazeRoom(
level: MazeLevel,
parent: Room,
direction: number,
@ -1242,7 +1254,7 @@ function mergeMazeRooms(level: MazeLevel, pivot: Room, index: PieceIndex, stats:
* ------------------------------------------------------------------------- */
/** Indexed pieces, so a side mask is one map lookup. */
interface PieceIndex {
export interface PieceIndex {
/**
* The piece for a room's doorway mask.
*
@ -1350,7 +1362,7 @@ function buildPieceIndex(pieces: readonly MazePiece[]): PieceIndex {
* @param sides - the side token; `''` accepts any piece of that kind.
* @returns the piece, or undefined.
*/
function findSpecial(pieces: readonly MazePiece[], kind: MazePieceKind, sides: string): MazePiece | undefined {
export function findSpecial(pieces: readonly MazePiece[], kind: MazePieceKind, sides: string): MazePiece | undefined {
return pieces.find(piece => piece.kind === kind
&& (sides === '' || sideMaskFromToken(piece.sides) === sideMaskFromToken(sides)))
}
@ -1388,7 +1400,7 @@ function pickRoomPreset(room: Room, index: PieceIndex, stats: MazeStats): void {
* @param token - the side token it answers.
* @param stats - report collector.
*/
function noteSpecial(room: Room, piece: MazePiece, token: string, stats: MazeStats): void {
export function noteSpecial(room: Room, piece: MazePiece, token: string, stats: MazeStats): void {
stats.specialsApplied.push({ room: room.id, kind: piece.kind, sides: token })
stats.patterns[piece.kind] ??= {}
stats.patterns[piece.kind]![token] = (stats.patterns[piece.kind]![token] ?? 0) + 1
@ -1647,7 +1659,7 @@ function lavaPairs(level: MazeLevel, pieces: readonly MazePiece[], index: PieceI
* @param index - the piece index.
* @param stats - report collector.
*/
function fillBlankSpaces(level: MazeLevel, pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void {
export function fillBlankSpaces(level: MazeLevel, pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void {
const filler = pieces.find(piece => piece.name === 'Act 4 - Lava X')
if (filler === undefined) {
stats.unresolvedRoles.push('Act 4 - Lava X')
@ -1714,6 +1726,27 @@ function themeRoll(level: MazeLevel, pieces: readonly MazePiece[], stats: MazeSt
}
}
/**
* Compute BFS topological distance of every room from the level's entrance room
* (the room holding a 'prev' or 'entrance' piece, or `level.first`).
*/
export function bfsDistances(level: MazeLevel): Map<Room, number> {
const start = level.rooms.find(r => r.piece?.kind === 'prev' || r.piece?.kind === 'entrance') ?? level.first
const dist = new Map<Room, number>([[start, 0]])
const queue: Room[] = [start]
while (queue.length > 0) {
const current = queue.shift()!
const d = dist.get(current)!
for (const orth of current.orths) {
if (!dist.has(orth.room)) {
dist.set(orth.room, d + 1)
queue.push(orth.room)
}
}
}
return dist
}
/**
* Place a special room group following standard D2 scanning rules:
* scans room candidates against valid wall connections, LvlPrest file definitions,
@ -2081,7 +2114,7 @@ export function generateMaze(request: MazeRequest): MazeResult {
if (profile === undefined) throw new Error(`${where}: no maze profile for level type "${levelTypeName}"`)
const stats: MazeStats = {
patterns: {}, fallbacks: [], specialsApplied: [], unresolvedRoles: [], notes: [],
patterns: {}, fallbacks: [], specialsApplied: [], specialPassesApplied: [], unresolvedRoles: [], notes: [],
ringRooms: 0, placementAttempts: 0, warps: [], waypoints: [],
}
const rng = new Rng(request.seed)
@ -2167,6 +2200,16 @@ export function generateMaze(request: MazeRequest): MazeResult {
placeSpecialGroup(level, request.pieces, group, cursor, index, stats)
cursor = (cursor + 1) % 4
}
if (profile.specialPass !== undefined) {
runMazeSpecialPasses(profile.specialPass, {
level,
request,
pieces: request.pieces,
index,
stats,
})
}
}
const unreachable = level.rooms.length - reachableRooms(level)
@ -2202,6 +2245,7 @@ export function generateMaze(request: MazeRequest): MazeResult {
sidePatterns: stats.patterns,
fallbacks: stats.fallbacks,
specialsApplied: stats.specialsApplied,
specialPassesApplied: stats.specialPassesApplied,
warps: stats.warps,
waypoints: stats.waypoints,
waypoint: stats.waypoints[0] ?? null,

View File

@ -0,0 +1,767 @@
/**
* Unit tests for the 11 Diablo II DRLG Maze Special Passes.
*
* Covers:
* 1. DRLGMAZE_PlaceAct2TombStuff (Tal Rasha True/False Tombs, Kaa, Cube, Leatherarm, Waypoint)
* 2. DRLGMAZE_PlaceAct2LairStuff (Maggot Lair Coldworm Tight Spot & Staff of Kings Treasure)
* 3. DRLGMAZE_PlaceAct3DungeonStuff (Flayer Dungeon Brain chest, Kurast Temples Lam Esen's Tome)
* 4. DRLGMAZE_PlaceAct3SewerStuff (Kurast Sewers Drain lever descent & Chest)
* 5. DRLGMAZE_PlaceAct3MephistoStuff (Durance of Hate 3 Mephisto Complex, Durance 2 Waypoint)
* 6. DRLGMAZE_PlaceAct5TempleStuff (Halls of Vaught Nihlathak Final Room, Halls of Pain Waypoint)
* 7. DRLGMAZE_PlaceAct5BaalStuff (Throne of Destruction Baal Entrance/ThroneRoom, Worldstone Waypoint)
* 8. DRLGMAZE_PlaceAct5IceStuff (Frozen River Anya platform, Drifter Cavern / Icy Cellar Pools, Waypoints)
* 9. DRLGMAZE_PlaceAct1Barracks (Monastery Barracks Smith Forge & Court Connect)
* 10. DRLGMAZE_PlaceAct4Lava (River of Flame Hellforge, Bridges 1 & 2, Warp, Lava X fillers)
* 11. DRLGMAZE_ScanReplaceSpecialAct2SewersPresets (Lut Gholein Sewers Radament's Lair, Dual Entrances, Waypoint)
* 12. Full generateMaze integration tests verifying specialPassesApplied and UNIMPLEMENTED_PASSES == [].
*/
import { describe, expect, test } from 'vitest'
import type { Ds1, Ds1Cell, Ds1Object } from '../src/formats/ds1.ts'
import {
generateMaze,
classifyMazePieceName,
UNIMPLEMENTED_PASSES,
type MazePiece,
type MazePieceKind,
type MazeRequest,
} from '../src/game/maze.ts'
import {
runMazeSpecialPasses,
type SpecialPassName,
} from '../src/game/maze-special-passes.ts'
/**
* Construct a synthetic minimal Ds1 map.
*/
function mockDs1(width = 10, height = 10, objects: Ds1Object[] = []): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y++) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x++) {
row.push({
walls: [{ prop1: 1, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 1, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [{ prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects,
npcPathOffset: null,
}
}
/**
* Construct a synthetic MazePiece.
*/
function mockPiece(
name: string,
kind: MazePieceKind,
sides = '',
objects: Ds1Object[] = [],
width = 10,
height = 10,
): MazePiece {
return {
name,
kind,
sides,
levels: [mockDs1(width, height, objects)],
}
}
/**
* Create a full standard piece set for a level type so maze generation has
* all 15 basic room shapes plus entrance and common directions.
*/
function createStandardPieceSet(levelTypeName: string, extraPieces: MazePiece[] = []): MazePiece[] {
const SIDES = [
'N', 'S', 'E', 'W',
'NE', 'NW', 'SE', 'SW',
'NS', 'EW',
'NSE', 'NSW', 'NEW', 'SEW',
'NSEW',
]
const pieces: MazePiece[] = [
mockPiece(`${levelTypeName} Entrance`, 'entrance', ''),
...SIDES.map(s => mockPiece(`${levelTypeName} ${s}`, 'room', s)),
...['N', 'S', 'E', 'W'].map(s => mockPiece(`${levelTypeName} Prev ${s}`, 'prev', s, [{ type: 2, id: 1, x: 25, y: 25, flags: 0 }])),
...['N', 'S', 'E', 'W'].map(s => mockPiece(`${levelTypeName} Next ${s}`, 'next', s, [{ type: 2, id: 2, x: 25, y: 25, flags: 0 }])),
...['N', 'S', 'E', 'W'].map(s => mockPiece(`${levelTypeName} Down ${s}`, 'down', s, [{ type: 2, id: 3, x: 25, y: 25, flags: 0 }])),
...['N', 'S', 'E', 'W'].map(s => mockPiece(`${levelTypeName} Theme ${s}`, 'theme', s)),
...extraPieces,
]
return pieces
}
describe('Diablo II DRLG Maze Special Passes', () => {
test('UNIMPLEMENTED_PASSES is completely empty', () => {
expect(UNIMPLEMENTED_PASSES).toEqual([])
expect(UNIMPLEMENTED_PASSES.length).toBe(0)
})
/* ----------------------------------------------------------------------- *
* 1. Act 2 - Tomb
* ----------------------------------------------------------------------- */
describe('Pass 1: DRLGMAZE_PlaceAct2TombStuff', () => {
const tombExtraPieces = [
mockPiece('Act 2 - Tomb Talrasha N', 'quest', 'N', [{ type: 2, id: 152, x: 25, y: 25, flags: 0 }]), // Horadric Staff Orifice
mockPiece('Act 2 - Tomb Chest N', 'treasure', 'N', [{ type: 2, id: 397, x: 25, y: 25, flags: 0 }]), // Golden chest
mockPiece('Act 2 - Tomb Kaa N', 'quest', 'N', [{ type: 1, id: 250, x: 25, y: 25, flags: 0 }]), // Ancient Kaa
mockPiece('Act 2 - Tomb Cube N', 'treasure', 'N', [{ type: 2, id: 354, x: 25, y: 25, flags: 0 }]), // Horadric Cube
mockPiece('Act 2 - Tomb Leatherarm N', 'quest', 'N'),
mockPiece('Act 2 - Tomb Treasure N', 'treasure', 'N'),
mockPiece('Act 2 - Tomb Waypoint N', 'waypoint', 'N', [{ type: 2, id: 156, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 2 - Tomb Prev NSW', 'prev', 'NSW'),
mockPiece('Act 2 - Tomb Prev NEW', 'prev', 'NEW'),
mockPiece('Act 2 - Tomb Prev NSE', 'prev', 'NSE'),
mockPiece('Act 2 - Tomb Prev SEW', 'prev', 'SEW'),
]
test('True Tomb (staffTombLevelId): places Talrasha Horadric Staff orifice', () => {
const pieces = createStandardPieceSet('Act 2 - Tomb', tombExtraPieces)
const res = generateMaze({
levelId: 66,
levelName: 'Tal Rashas Tomb 1',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 12345,
pieces,
staffTombLevelId: 66,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2TombStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
const hasTalrasha = specials.some(s => s.kind === 'quest')
expect(hasTalrasha).toBe(true)
// Level size is tripled for staff tomb
expect(res.stats.targetRooms).toBe(15)
})
test('False Tombs (67..72): places golden chest room', () => {
const pieces = createStandardPieceSet('Act 2 - Tomb', tombExtraPieces)
const res = generateMaze({
levelId: 67,
levelName: 'Tal Rashas Tomb 2',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 54321,
pieces,
staffTombLevelId: 66,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2TombStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'treasure')).toBe(true)
})
test('Halls of the Dead 2 (level 59): places Waypoint', () => {
const pieces = createStandardPieceSet('Act 2 - Tomb', tombExtraPieces)
const res = generateMaze({
levelId: 59,
levelName: 'Halls of the Dead 2',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 99999,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2TombStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'waypoint')).toBe(true)
})
test('Halls of the Dead 3 (level 60): places Horadric Cube chest', () => {
const pieces = createStandardPieceSet('Act 2 - Tomb', tombExtraPieces)
const res = generateMaze({
levelId: 60,
levelName: 'Halls of the Dead 3',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 88888,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2TombStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'quest' || s.kind === 'treasure')).toBe(true)
})
test('Boss Tomb: places Ancient Kaa the Soulless', () => {
const pieces = createStandardPieceSet('Act 2 - Tomb', tombExtraPieces)
const res = generateMaze({
levelId: 68,
levelName: 'Tal Rashas Tomb 3',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 77777,
pieces,
staffTombLevelId: 66,
bossTombLevelId: 68,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2TombStuff')
expect(res.stats.targetRooms).toBe(10) // Doubled for boss tomb
})
})
/* ----------------------------------------------------------------------- *
* 2. Act 2 - Lair
* ----------------------------------------------------------------------- */
describe('Pass 2: DRLGMAZE_PlaceAct2LairStuff', () => {
const lairExtraPieces = [
mockPiece('Act 2 - Lair Tight Spot S', 'quest', 'S', [{ type: 1, id: 284, x: 25, y: 25, flags: 0 }]), // Coldworm
mockPiece('Act 2 - Lair Treasure W', 'treasure', 'W', [{ type: 2, id: 356, x: 25, y: 25, flags: 0 }]), // Staff of Kings chest
]
test('Maggot Lair 3 (level 64): places Coldworm Tight Spot and Staff of Kings chest', () => {
const pieces = createStandardPieceSet('Act 2 - Lair', lairExtraPieces)
const res = generateMaze({
levelId: 64,
levelName: 'Maggot Lair 3',
sectionSize: 10,
minRooms: 6,
merge: 500,
seed: 42,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2LairStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'quest')).toBe(true)
expect(specials.some(s => s.kind === 'treasure')).toBe(true)
expect(res.level.objects.some(o => o.type === 1 && o.id === 284)).toBe(true)
expect(res.level.objects.some(o => o.type === 2 && o.id === 356)).toBe(true)
})
test('Maggot Lair 1 (level 62): places regular next and prev', () => {
const pieces = createStandardPieceSet('Act 2 - Lair', lairExtraPieces)
const res = generateMaze({
levelId: 62,
levelName: 'Maggot Lair 1',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 101,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2LairStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'next')).toBe(true)
})
})
/* ----------------------------------------------------------------------- *
* 3. Act 3 - Dungeon
* ----------------------------------------------------------------------- */
describe('Pass 3: DRLGMAZE_PlaceAct3DungeonStuff', () => {
const dungeonExtraPieces = [
mockPiece('Act 3 - Dungeon Treasure 2', 'treasure', '', [{ type: 2, id: 406, x: 25, y: 25, flags: 0 }]), // Khalim's Brain chest
mockPiece('Act 3 - Dungeon Treasure 1', 'treasure', ''),
mockPiece('Act 3 - Temple 1', 'quest', '', [{ type: 2, id: 193, x: 25, y: 25, flags: 0 }]), // Lam Esen's Tome altar
]
test("Flayer Dungeon 3 (level 91): places Khalim's Brain chest (Treasure 2)", () => {
const pieces = createStandardPieceSet('Act 3 - Dungeon', dungeonExtraPieces)
const res = generateMaze({
levelId: 91,
levelName: 'Flayer Dungeon 3',
levelTypeName: 'Act 3 - Dungeon',
sectionSize: 10,
minRooms: 6,
merge: 500,
seed: 31415,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct3DungeonStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'treasure')).toBe(true)
expect(res.level.objects.some(o => o.type === 2 && o.id === 406)).toBe(true)
})
test("Ruined Temple (level 94): places Lam Esen's Tome altar (Temple 1)", () => {
const pieces = createStandardPieceSet('Act 3 - Dungeon', dungeonExtraPieces)
const res = generateMaze({
levelId: 94,
levelName: 'Ruined Temple',
levelTypeName: 'Act 3 - Dungeon',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 27182,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct3DungeonStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'quest')).toBe(true)
expect(res.level.objects.some(o => o.type === 2 && o.id === 193)).toBe(true)
})
})
/* ----------------------------------------------------------------------- *
* 4. Act 3 - Sewer
* ----------------------------------------------------------------------- */
describe('Pass 4: DRLGMAZE_PlaceAct3SewerStuff', () => {
const sewerExtraPieces = [
mockPiece('Act 3 - Sewer Drain N', 'next', 'N', [{ type: 2, id: 366, x: 25, y: 25, flags: 0 }]), // Drain lever to Level 2
mockPiece('Act 3 - Sewer Chest S', 'treasure', 'S'),
mockPiece('Act 3 - Sewer Treasure X', 'treasure', ''),
mockPiece('Act 3 - Sewer NW', 'room', 'NW'),
mockPiece('Act 3 - Sewer NE', 'room', 'NE'),
mockPiece('Act 3 - Sewer SW', 'room', 'SW'),
mockPiece('Act 3 - Sewer SE', 'room', 'SE'),
mockPiece('Act 3 - Sewer Prev NW', 'prev', 'NW'),
mockPiece('Act 3 - Sewer Prev NE', 'prev', 'NE'),
mockPiece('Act 3 - Sewer Prev SW', 'prev', 'SW'),
mockPiece('Act 3 - Sewer Prev SE', 'prev', 'SE'),
]
test('Kurast Sewers 1 (level 92): places Drain lever and Chest', () => {
const pieces = createStandardPieceSet('Act 3 - Sewer', sewerExtraPieces)
const res = generateMaze({
levelId: 92,
levelName: 'Kurast Sewers 1',
sectionSize: 10,
minRooms: 18,
merge: 500,
seed: 777,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct3SewerStuff')
expect(res.level.objects.some(o => o.type === 2 && o.id === 366)).toBe(true)
})
})
/* ----------------------------------------------------------------------- *
* 5. Act 3 - Mephisto
* ----------------------------------------------------------------------- */
describe('Pass 5: DRLGMAZE_PlaceAct3MephistoStuff', () => {
const mephistoExtraPieces = [
mockPiece('Act 3 - Mephisto Complex', 'quest', '', [{ type: 2, id: 251, x: 50, y: 50, flags: 0 }]), // Red portal to Act 4
mockPiece('Act 3 - Mephisto Waypoint N', 'waypoint', 'N', [{ type: 2, id: 237, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 3 - Mephisto Prev N', 'prev', 'N'),
mockPiece('Act 3 - Mephisto Next N', 'next', 'N'),
]
test('Durance of Hate 3 (level 102): places Mephisto Complex', () => {
const pieces = createStandardPieceSet('Act 3 - Kurast', mephistoExtraPieces)
const res = generateMaze({
levelId: 102,
levelName: 'Durance of Hate 3',
levelTypeName: 'Act 3 - Kurast',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 666,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct3MephistoStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'quest')).toBe(true)
expect(res.level.objects.some(o => o.type === 2 && o.id === 251)).toBe(true)
})
test('Durance of Hate 2 (level 101): places Waypoint', () => {
const pieces = createStandardPieceSet('Act 3 - Kurast', mephistoExtraPieces)
const res = generateMaze({
levelId: 101,
levelName: 'Durance of Hate 2',
levelTypeName: 'Act 3 - Kurast',
sectionSize: 10,
minRooms: 6,
merge: 500,
seed: 10101,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct3MephistoStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'waypoint')).toBe(true)
})
})
/* ----------------------------------------------------------------------- *
* 6. Act 5 - Temple
* ----------------------------------------------------------------------- */
describe('Pass 6: DRLGMAZE_PlaceAct5TempleStuff', () => {
const templeExtraPieces = [
mockPiece('Act 5 - Temple Final Room', 'quest', '', [{ type: 1, id: 526, x: 25, y: 25, flags: 0 }]), // Nihlathak
mockPiece('Act 5 - Temple NE Waypoint', 'waypoint', 'NE'),
mockPiece('Act 5 - Temple NW Waypoint', 'waypoint', 'NW'),
mockPiece('Act 5 - Temple SW Waypoint', 'waypoint', 'SW'),
mockPiece('Act 5 - Temple SE Waypoint', 'waypoint', 'SE'),
mockPiece('Act 5 - Temple NE Down', 'down', 'NE'),
mockPiece('Act 5 - Temple NW Down', 'down', 'NW'),
mockPiece('Act 5 - Temple SW Down', 'down', 'SW'),
mockPiece('Act 5 - Temple SE up', 'prev', 'SE'),
mockPiece('Act 5 - Temple SE', 'room', 'SE'),
mockPiece('Act 5 - Temple NE', 'room', 'NE'),
mockPiece('Act 5 - Temple NW', 'room', 'NW'),
mockPiece('Act 5 - Temple SW', 'room', 'SW'),
]
test('Halls of Vaught (level 124): places Nihlathak Final Room altar', () => {
const pieces = createStandardPieceSet('Act 5 - Temple', templeExtraPieces)
const res = generateMaze({
levelId: 124,
levelName: 'Halls of Vaught',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 124124,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct5TempleStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'quest')).toBe(true)
expect(res.level.objects.some(o => o.type === 1 && o.id === 526)).toBe(true)
})
test('Halls of Pain (level 123): places Waypoint and stairs', () => {
const pieces = createStandardPieceSet('Act 5 - Temple', templeExtraPieces)
const res = generateMaze({
levelId: 123,
levelName: 'Halls of Pain',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 123123,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct5TempleStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'waypoint')).toBe(true)
})
})
/* ----------------------------------------------------------------------- *
* 7. Act 5 - Baal
* ----------------------------------------------------------------------- */
describe('Pass 7: DRLGMAZE_PlaceAct5BaalStuff', () => {
const baalExtraPieces = [
mockPiece('Act 5 - ThroneRoom', 'quest', '', [{ type: 2, id: 563, x: 25, y: 25, flags: 0 }]), // Baal Throne
mockPiece('Act 5 - Baal Waypoint N', 'waypoint', 'N'),
mockPiece('Act 5 - Baal Prev NSW', 'prev', 'NSW'),
mockPiece('Act 5 - Baal Prev NEW', 'prev', 'NEW'),
mockPiece('Act 5 - Baal Prev NSE', 'prev', 'NSE'),
mockPiece('Act 5 - Baal Prev SEW', 'prev', 'SEW'),
]
test('Throne of Destruction (level 131): places ThroneRoom', () => {
const pieces = createStandardPieceSet('Act 5 - Baal', baalExtraPieces)
const res = generateMaze({
levelId: 131,
levelName: 'Throne of Destruction',
levelTypeName: 'Act 5 - Baal',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 131131,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct5BaalStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'quest')).toBe(true)
expect(res.level.objects.some(o => o.type === 2 && o.id === 563)).toBe(true)
})
test('Worldstone Keep 2 (level 129): places Waypoint', () => {
const pieces = createStandardPieceSet('Act 5 - Baal', baalExtraPieces)
const res = generateMaze({
levelId: 129,
levelName: 'Worldstone Keep 2',
levelTypeName: 'Act 5 - Baal',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 129129,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct5BaalStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'waypoint')).toBe(true)
})
})
/* ----------------------------------------------------------------------- *
* 8. Act 5 - Ice
* ----------------------------------------------------------------------- */
describe('Pass 8: DRLGMAZE_PlaceAct5IceStuff', () => {
const iceExtraPieces = [
mockPiece('Act 5 - Ice River A', 'quest', '', [{ type: 2, id: 460, x: 25, y: 25, flags: 0 }]), // Frozen Anya
mockPiece('Act 5 - Ice River B', 'quest', '', [{ type: 2, id: 460, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 5 - Ice Pool A', 'quest', ''),
mockPiece('Act 5 - Ice Pool B', 'quest', ''),
mockPiece('Act 5 - Ice waypoint N', 'waypoint', 'N'),
]
test('Frozen River (level 114): places Anya platform', () => {
const pieces = createStandardPieceSet('Act 5 - Ice Caves', iceExtraPieces)
const res = generateMaze({
levelId: 114,
levelName: 'Frozen River',
levelTypeName: 'Act 5 - Ice Caves',
sectionSize: 10,
minRooms: 1, // Single-room preset in profile
merge: 500,
seed: 114114,
pieces,
})
// Level 114 resolves single room preset Act 5 - Ice River A
expect(res.level.objects.some(o => o.type === 2 && o.id === 460)).toBe(true)
})
test('Glacial Trail (level 115): places Waypoint', () => {
const pieces = createStandardPieceSet('Act 5 - Ice Caves', iceExtraPieces)
const res = generateMaze({
levelId: 115,
levelName: 'Glacial Trail',
levelTypeName: 'Act 5 - Ice Caves',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 115115,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct5IceStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'waypoint')).toBe(true)
})
})
/* ----------------------------------------------------------------------- *
* 9. Act 1 - Barracks
* ----------------------------------------------------------------------- */
describe('Pass 9: DRLGMAZE_PlaceAct1Barracks', () => {
const barracksExtraPieces = [
mockPiece('Act 1 - Barracks Forge N', 'quest', 'N', [{ type: 2, id: 108, x: 25, y: 25, flags: 0 }]), // Charsi's Malus forge
mockPiece('Act 1 - Barracks Forge S', 'quest', 'S', [{ type: 2, id: 108, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 1 - Barracks Forge E', 'quest', 'E', [{ type: 2, id: 108, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 1 - Barracks Forge W', 'quest', 'W', [{ type: 2, id: 108, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 1 - Barracks Court Connect', 'prev', '', [{ type: 2, id: 1, x: 10, y: 10, flags: 0 }]), // Entrance from Cloister
]
test('Monastery Barracks (level 28): places Court Connect and Smith Forge', () => {
const pieces = createStandardPieceSet('Act 1 - Barracks', barracksExtraPieces)
const res = generateMaze({
levelId: 28,
levelName: 'Barracks',
sectionSize: 10,
sectionHeight: 14,
minRooms: 6,
merge: 500,
seed: 2828,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct1Barracks')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'quest')).toBe(true)
expect(specials.some(s => s.kind === 'prev')).toBe(true)
expect(res.level.objects.some(o => o.type === 2 && o.id === 108)).toBe(true)
})
})
/* ----------------------------------------------------------------------- *
* 10. Act 4 - Lava
* ----------------------------------------------------------------------- */
describe('Pass 10: DRLGMAZE_PlaceAct4Lava', () => {
const lavaExtraPieces = [
mockPiece('Act 4 - Lava Warp N', 'prev', 'N', [{ type: 2, id: 255, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 4 - Bridge 1', 'next', '', [{ type: 2, id: 10, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 4 - Bridge 2', 'next', '', [{ type: 2, id: 11, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 4 - Lava Forge W', 'quest', 'W', [{ type: 2, id: 376, x: 25, y: 25, flags: 0 }]), // Hellforge
mockPiece('Act 4 - Lava Forge E', 'quest', 'E', [{ type: 2, id: 376, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 4 - Lava X', 'room', ''), // Filler
]
test('River of Flame (level 107): places Hellforge, Bridges, Warp and Lava X filler', () => {
const pieces = createStandardPieceSet('Act 4 - Lava', lavaExtraPieces)
const res = generateMaze({
levelId: 107,
levelName: 'River of Flame',
levelTypeName: 'Act 4 - Lava',
sectionSize: 10,
minRooms: 6,
merge: 500,
seed: 107107,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct4Lava')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'quest')).toBe(true)
expect(res.level.objects.some(o => o.type === 2 && o.id === 376)).toBe(true)
// Confirm Lava X filler spaces were placed
expect(specials.some(s => s.sides === 'filler')).toBe(true)
})
})
/* ----------------------------------------------------------------------- *
* 11. Act 2 - Sewers
* ----------------------------------------------------------------------- */
describe('Pass 11: DRLGMAZE_ScanReplaceSpecialAct2SewersPresets', () => {
const sewer2ExtraPieces = [
mockPiece("Act 2 - Sewer Radament's Lair N", 'quest', 'N', [{ type: 1, id: 229, x: 25, y: 25, flags: 0 }]), // Radament
mockPiece("Act 2 - Sewer Radament's Lair S", 'quest', 'S', [{ type: 1, id: 229, x: 25, y: 25, flags: 0 }]),
mockPiece("Act 2 - Sewer Radament's Lair E", 'quest', 'E', [{ type: 1, id: 229, x: 25, y: 25, flags: 0 }]),
mockPiece("Act 2 - Sewer Radament's Lair W", 'quest', 'W', [{ type: 1, id: 229, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 2 - Sewer Prev E', 'prev', 'E'), // City entrance
mockPiece('Act 2 - Sewer Prev NS', 'prev', 'NS'), // Dock entrance
mockPiece('Act 2 - Sewer Waypoint N', 'waypoint', 'N', [{ type: 2, id: 156, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 2 - Sewer Chest N', 'treasure', 'N', [{ type: 2, id: 355, x: 25, y: 25, flags: 0 }]),
]
test("Sewers Level 3 (level 49): places Radament's Lair", () => {
const pieces = createStandardPieceSet('Act 2 - Sewer', sewer2ExtraPieces)
const res = generateMaze({
levelId: 49,
levelName: 'Sewers Level 3',
sectionSize: 10,
minRooms: 6,
merge: 500,
seed: 4949,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_ScanReplaceSpecialAct2SewersPresets')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'quest')).toBe(true)
expect(res.level.objects.some(o => o.type === 1 && o.id === 229)).toBe(true)
})
test('Sewers Level 1 (level 47): places dock and city entrances', () => {
const pieces = createStandardPieceSet('Act 2 - Sewer', sewer2ExtraPieces)
const res = generateMaze({
levelId: 47,
levelName: 'Sewers Level 1',
sectionSize: 10,
minRooms: 6,
merge: 500,
seed: 4747,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_ScanReplaceSpecialAct2SewersPresets')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
const prevs = specials.filter(s => s.kind === 'prev')
expect(prevs.length).toBeGreaterThanOrEqual(1)
})
test('Sewers Level 2 (level 48): places Waypoint', () => {
const pieces = createStandardPieceSet('Act 2 - Sewer', sewer2ExtraPieces)
const res = generateMaze({
levelId: 48,
levelName: 'Sewers Level 2',
sectionSize: 10,
minRooms: 6,
merge: 500,
seed: 4848,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_ScanReplaceSpecialAct2SewersPresets')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'waypoint')).toBe(true)
})
test('Ancient Tunnels (level 50): places golden chest', () => {
const pieces = createStandardPieceSet('Act 2 - Sewer', sewer2ExtraPieces)
const res = generateMaze({
levelId: 50,
levelName: 'Ancient Tunnels',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 5050,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_ScanReplaceSpecialAct2SewersPresets')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'treasure')).toBe(true)
})
})
/* ----------------------------------------------------------------------- *
* Classification tests
* ----------------------------------------------------------------------- */
describe('Piece Classification for Special Presets', () => {
test('classifies waypoint pieces', () => {
expect(classifyMazePieceName('Act 2 - Tomb Waypoint N', 'Act 2 - Tomb')).toEqual({
kind: 'waypoint',
sides: 'N',
})
expect(classifyMazePieceName('Act 5 - Temple NE Waypoint', 'Act 5 - Temple')).toEqual({
kind: 'waypoint',
sides: 'NE',
})
expect(classifyMazePieceName('Act 5 - Ice waypoint N', 'Act 5 - Ice Caves')).toEqual({
kind: 'waypoint',
sides: 'N',
})
})
test('classifies quest and special pieces', () => {
expect(classifyMazePieceName('Act 2 - Tomb Talrasha N', 'Act 2 - Tomb')).toEqual({
kind: 'quest',
sides: 'N',
})
expect(classifyMazePieceName("Act 2 - Sewer Radament's Lair S", 'Act 2 - Sewer')).toEqual({
kind: 'quest',
sides: 'S',
})
expect(classifyMazePieceName('Act 2 - Lair Tight Spot S', 'Act 2 - Lair')).toEqual({
kind: 'quest',
sides: 'S',
})
expect(classifyMazePieceName('Act 1 - Barracks Forge W', 'Act 1 - Barracks')).toEqual({
kind: 'quest',
sides: 'W',
})
expect(classifyMazePieceName('Act 4 - Bridge 1', 'Act 4 - Lava')).toEqual({
kind: 'next',
sides: '',
})
expect(classifyMazePieceName('Act 3 - Mephisto Complex', 'Act 3 - Kurast')).toEqual({
kind: 'quest',
sides: '',
})
expect(classifyMazePieceName('Act 5 - Temple Final Room', 'Act 5 - Temple')).toEqual({
kind: 'quest',
sides: '',
})
expect(classifyMazePieceName('Act 5 - ThroneRoom Entrance', 'Act 5 - Baal')).toEqual({
kind: 'quest',
sides: '',
})
})
})
})