feat(map-gen/maze): implement 11 official dungeon special passes (fixes #49)
This commit is contained in:
parent
1fa3f8b7a2
commit
81d2172cca
|
|
@ -0,0 +1,994 @@
|
|||
/**
|
||||
* 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
|
||||
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 === 59) {
|
||||
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
|
||||
}
|
||||
}
|
||||
112
src/game/maze.ts
112
src/game/maze.ts
|
|
@ -80,6 +80,7 @@
|
|||
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.ts'
|
||||
import { Rng } from './rng.ts'
|
||||
import { SUB_TILES_PER_TILE } from './map.ts'
|
||||
import { type SpecialPassName, runMazeSpecialPasses } from './maze-special-passes.ts'
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Directions
|
||||
|
|
@ -192,6 +193,7 @@ export type MazePieceKind =
|
|||
| 'down'
|
||||
| 'quest'
|
||||
| 'treasure'
|
||||
| 'waypoint'
|
||||
|
||||
/**
|
||||
* One `LvlPrest.txt` row that can be stamped into a maze level.
|
||||
|
|
@ -294,6 +296,19 @@ const KIND_PREFIXES: readonly (readonly [string, MazePieceKind])[] = [
|
|||
['Next', 'next'],
|
||||
['Prev', 'prev'],
|
||||
['Theme', 'theme'],
|
||||
['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. */
|
||||
|
|
@ -312,9 +327,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'],
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -333,26 +350,38 @@ 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.startsWith(prefix)) continue
|
||||
if (rest.toLowerCase().startsWith(prefix.toLowerCase())) {
|
||||
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: '' }
|
||||
// `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 }
|
||||
}
|
||||
const reversedMatch = /^([NSEW]+)\s+(up|down)$/i.exec(rest)
|
||||
}
|
||||
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) {
|
||||
const sides = reversedMatch[1]!.toUpperCase()
|
||||
const role = reversedMatch[2]!.toLowerCase()
|
||||
if (role === 'waypoint') return { kind: 'waypoint', sides }
|
||||
return { kind: role === 'up' ? 'prev' : 'next', 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
|
||||
}
|
||||
|
||||
|
|
@ -467,6 +496,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. */
|
||||
|
|
@ -526,6 +557,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
specials: [
|
||||
{ kind: 'next', entries: entries() },
|
||||
],
|
||||
specialPass: 'DRLGMAZE_PlaceAct1Barracks',
|
||||
},
|
||||
{
|
||||
levelTypeName: 'Act 1 - Jail',
|
||||
|
|
@ -561,6 +593,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
{ kind: 'prev', entries: entries() },
|
||||
{ kind: 'next', entries: entries(), levelIds: [47, 48] },
|
||||
],
|
||||
specialPass: 'DRLGMAZE_ScanReplaceSpecialAct2SewersPresets',
|
||||
},
|
||||
{
|
||||
levelTypeName: 'Act 2 - Tomb',
|
||||
|
|
@ -571,6 +604,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
specials: [
|
||||
{ kind: 'next', entries: entries(), levelIds: [55, 56, 57, 58] },
|
||||
],
|
||||
specialPass: 'DRLGMAZE_PlaceAct2TombStuff',
|
||||
},
|
||||
{
|
||||
levelTypeName: 'Act 2 - Lair',
|
||||
|
|
@ -581,6 +615,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',
|
||||
|
|
@ -601,6 +636,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
{ kind: 'prev', entries: entries(), levelIds: [100, 101] },
|
||||
{ kind: 'next', entries: entries(), levelIds: [100, 101] },
|
||||
],
|
||||
specialPass: 'DRLGMAZE_PlaceAct3MephistoStuff',
|
||||
},
|
||||
{
|
||||
levelTypeName: 'Act 3 - Dungeon',
|
||||
|
|
@ -611,6 +647,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',
|
||||
|
|
@ -649,6 +686,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
levelIds: [92],
|
||||
},
|
||||
],
|
||||
specialPass: 'DRLGMAZE_PlaceAct3SewerStuff',
|
||||
},
|
||||
{
|
||||
levelTypeName: 'Act 4 - Lava',
|
||||
|
|
@ -666,6 +704,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
levelIds: [107],
|
||||
},
|
||||
],
|
||||
specialPass: 'DRLGMAZE_PlaceAct4Lava',
|
||||
},
|
||||
{
|
||||
levelTypeName: 'Act 5 - Ice Caves',
|
||||
|
|
@ -682,6 +721,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
{ kind: 'next', entries: entries(), levelIds: [113, 115, 118] },
|
||||
{ kind: 'down', entries: entries(), levelIds: [113, 115, 118] },
|
||||
],
|
||||
specialPass: 'DRLGMAZE_PlaceAct5IceStuff',
|
||||
},
|
||||
{
|
||||
levelTypeName: 'Act 5 - Temple',
|
||||
|
|
@ -712,6 +752,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
inPlaceOnly: true,
|
||||
},
|
||||
],
|
||||
specialPass: 'DRLGMAZE_PlaceAct5TempleStuff',
|
||||
},
|
||||
{
|
||||
levelTypeName: 'Act 5 - Baal',
|
||||
|
|
@ -721,6 +762,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
|||
specials: [
|
||||
{ kind: 'next', entries: entries(), levelIds: [128, 129, 130] },
|
||||
],
|
||||
specialPass: 'DRLGMAZE_PlaceAct5BaalStuff',
|
||||
},
|
||||
{ levelTypeName: 'Act 5 - Lava', grow: false, theme: false, lavaPairs: true, specials: [] },
|
||||
]
|
||||
|
|
@ -736,22 +778,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
|
||||
|
|
@ -833,7 +863,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. */
|
||||
|
|
@ -856,7 +886,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. */
|
||||
|
|
@ -878,10 +908,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
|
||||
|
|
@ -964,7 +995,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
|
||||
|
|
@ -1046,7 +1077,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,
|
||||
|
|
@ -1118,7 +1149,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.
|
||||
*
|
||||
|
|
@ -1226,7 +1257,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)))
|
||||
}
|
||||
|
|
@ -1264,7 +1295,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
|
||||
|
|
@ -1523,7 +1554,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')
|
||||
|
|
@ -1594,7 +1625,7 @@ 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`).
|
||||
*/
|
||||
function bfsDistances(level: MazeLevel): Map<Room, number> {
|
||||
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]
|
||||
|
|
@ -1959,7 +1990,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: [],
|
||||
}
|
||||
const rng = new Rng(request.seed)
|
||||
|
|
@ -2030,6 +2061,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)
|
||||
|
|
@ -2065,6 +2106,7 @@ export function generateMaze(request: MazeRequest): MazeResult {
|
|||
sidePatterns: stats.patterns,
|
||||
fallbacks: stats.fallbacks,
|
||||
specialsApplied: stats.specialsApplied,
|
||||
specialPassesApplied: stats.specialPassesApplied,
|
||||
warps: stats.warps,
|
||||
unresolvedRoles: stats.unresolvedRoles,
|
||||
unimplementedPasses: UNIMPLEMENTED_PASSES,
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue