diff --git a/src/game/maze-special-passes.ts b/src/game/maze-special-passes.ts new file mode 100644 index 0000000..d10f12c --- /dev/null +++ b/src/game/maze-special-passes.ts @@ -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 + } +} diff --git a/src/game/maze.ts b/src/game/maze.ts index 1976e64..945891e 100644 --- a/src/game/maze.ts +++ b/src/game/maze.ts @@ -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> = '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> = */ 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> /** `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> 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 { + const start = level.rooms.find(r => r.piece?.kind === 'prev' || r.piece?.kind === 'entrance') ?? level.first + const dist = new Map([[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, diff --git a/tests/maze-special-passes.test.ts b/tests/maze-special-passes.test.ts new file mode 100644 index 0000000..c79a5da --- /dev/null +++ b/tests/maze-special-passes.test.ts @@ -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: '', + }) + }) + }) +})