fix(maze): use PRNG stream in special passes and fix Act 5 temple NE waypoint generation

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
troytt 2026-09-17 13:36:02 +00:00
parent a3a3e44838
commit b8810072ce
3 changed files with 60 additions and 15 deletions

View File

@ -15,6 +15,7 @@
* - `DRLGMAZE_ScanReplaceSpecialAct2SewersPresets`: Lut Gholein Sewers (Radament's Lair & Atma's quest). * - `DRLGMAZE_ScanReplaceSpecialAct2SewersPresets`: Lut Gholein Sewers (Radament's Lair & Atma's quest).
*/ */
import { Rng } from './rng.ts'
import { import {
DIRECTION_WEST, DIRECTION_WEST,
DIRECTION_NORTH, DIRECTION_NORTH,
@ -56,6 +57,11 @@ export interface SpecialPassContext {
readonly pieces: readonly MazePiece[] readonly pieces: readonly MazePiece[]
readonly index: PieceIndex readonly index: PieceIndex
readonly stats: MazeStats readonly stats: MazeStats
readonly rng?: Rng
}
function getPassRng(ctx: SpecialPassContext): Rng {
return ctx.rng ?? ctx.level?.rng ?? new Rng(ctx.request.seed)
} }
const DIRS = ['N', 'E', 'S', 'W'] as const const DIRS = ['N', 'E', 'S', 'W'] as const
@ -189,6 +195,7 @@ export function placeNamedPiece(
* Horadric Cube chest, Leatherarm, and staircases. * Horadric Cube chest, Leatherarm, and staircases.
*/ */
export function placeAct2TombStuff(ctx: SpecialPassContext): void { export function placeAct2TombStuff(ctx: SpecialPassContext): void {
const rng = getPassRng(ctx)
const levelId = ctx.request.levelId const levelId = ctx.request.levelId
const staffTomb = ctx.request.staffTombLevelId ?? 66 const staffTomb = ctx.request.staffTombLevelId ?? 66
const bossTomb = ctx.request.bossTombLevelId const bossTomb = ctx.request.bossTombLevelId
@ -202,9 +209,9 @@ export function placeAct2TombStuff(ctx: SpecialPassContext): void {
else if (name.includes('NEW')) nDirection = 1 else if (name.includes('NEW')) nDirection = 1
else if (name.includes('NSE')) nDirection = 2 else if (name.includes('NSE')) nDirection = 2
else if (name.includes('SEW')) nDirection = 3 else if (name.includes('SEW')) nDirection = 3
else nDirection = ctx.request.seed % 4 else nDirection = rng.int(0, 3)
} else { } else {
nDirection = ctx.request.seed % 4 nDirection = rng.int(0, 3)
} }
const GROW_DIRS = [DIRECTION_SOUTH, DIRECTION_WEST, DIRECTION_NORTH, DIRECTION_EAST] const GROW_DIRS = [DIRECTION_SOUTH, DIRECTION_WEST, DIRECTION_NORTH, DIRECTION_EAST]
@ -279,6 +286,7 @@ export function placeAct2TombStuff(ctx: SpecialPassContext): void {
* Level 3 places Coldworm the Burrower (Tight Spot S) and the Staff of Kings chest (Treasure W). * Level 3 places Coldworm the Burrower (Tight Spot S) and the Staff of Kings chest (Treasure W).
*/ */
export function placeAct2LairStuff(ctx: SpecialPassContext): void { export function placeAct2LairStuff(ctx: SpecialPassContext): void {
const rng = getPassRng(ctx)
const levelId = ctx.request.levelId const levelId = ctx.request.levelId
if (levelId === 64) { if (levelId === 64) {
@ -309,7 +317,7 @@ export function placeAct2LairStuff(ctx: SpecialPassContext): void {
preferFurthest: false, preferFurthest: false,
}) })
} else { } else {
const nRand = ctx.request.seed % 4 const nRand = rng.int(0, 3)
placeNamedPiece(ctx, { placeNamedPiece(ctx, {
kind: 'next', kind: 'next',
namePrefix: 'Act 2 - Lair Next', namePrefix: 'Act 2 - Lair Next',
@ -339,8 +347,9 @@ export function placeAct2LairStuff(ctx: SpecialPassContext): void {
* Temples (levels 94..99) place Lam Esen's Tome altar. * Temples (levels 94..99) place Lam Esen's Tome altar.
*/ */
export function placeAct3DungeonStuff(ctx: SpecialPassContext): void { export function placeAct3DungeonStuff(ctx: SpecialPassContext): void {
const rng = getPassRng(ctx)
const levelId = ctx.request.levelId const levelId = ctx.request.levelId
const nDirection = ctx.request.seed % 4 const nDirection = rng.int(0, 3)
if (levelId === 91) { if (levelId === 91) {
// Flayer Dungeon Level 3: Khalim's Brain chest // Flayer Dungeon Level 3: Khalim's Brain chest
@ -404,8 +413,9 @@ export function placeAct3DungeonStuff(ctx: SpecialPassContext): void {
* Level 1 places the drain descent lever and staircase down to Level 2. * Level 1 places the drain descent lever and staircase down to Level 2.
*/ */
export function placeAct3SewerStuff(ctx: SpecialPassContext): void { export function placeAct3SewerStuff(ctx: SpecialPassContext): void {
const rng = getPassRng(ctx)
const levelId = ctx.request.levelId const levelId = ctx.request.levelId
const nDirection = ctx.request.seed % 4 const nDirection = rng.int(0, 3)
if (levelId === 92) { if (levelId === 92) {
// Sewer Level 1: Drain lever / stairs down to Sewer 2 // Sewer Level 1: Drain lever / stairs down to Sewer 2
@ -454,8 +464,9 @@ export function placeAct3SewerStuff(ctx: SpecialPassContext): void {
* Level 2 places Waypoint, Next and Prev. * Level 2 places Waypoint, Next and Prev.
*/ */
export function placeAct3MephistoStuff(ctx: SpecialPassContext): void { export function placeAct3MephistoStuff(ctx: SpecialPassContext): void {
const rng = getPassRng(ctx)
const levelId = ctx.request.levelId const levelId = ctx.request.levelId
const nRand = ctx.request.seed % 4 const nRand = rng.int(0, 3)
if (levelId === 102) { if (levelId === 102) {
// Durance of Hate Level 3: Mephisto Complex // Durance of Hate Level 3: Mephisto Complex
@ -526,6 +537,7 @@ export function placeAct3MephistoStuff(ctx: SpecialPassContext): void {
* Halls of Pain (level 123) places Waypoint and descent stairs. * Halls of Pain (level 123) places Waypoint and descent stairs.
*/ */
export function placeAct5TempleStuff(ctx: SpecialPassContext): void { export function placeAct5TempleStuff(ctx: SpecialPassContext): void {
const rng = getPassRng(ctx)
const levelId = ctx.request.levelId const levelId = ctx.request.levelId
if (levelId === 124) { if (levelId === 124) {
@ -547,7 +559,7 @@ export function placeAct5TempleStuff(ctx: SpecialPassContext): void {
} else { } else {
// Levels 122 and 123 // Levels 122 and 123
const templeDownDirs = ['NE', 'NW', 'SW'] const templeDownDirs = ['NE', 'NW', 'SW']
const nDirection = (ctx.request.seed >>> 0) % 3 const nDirection = rng.int(0, templeDownDirs.length - 1)
const downSide = templeDownDirs[nDirection]! const downSide = templeDownDirs[nDirection]!
placeNamedPiece(ctx, { placeNamedPiece(ctx, {
@ -560,7 +572,7 @@ export function placeAct5TempleStuff(ctx: SpecialPassContext): void {
if (levelId === 123) { if (levelId === 123) {
const templeWaypointDirs = ['NE', 'NW', 'SW', 'SE'] const templeWaypointDirs = ['NE', 'NW', 'SW', 'SE']
const wpSide = templeWaypointDirs[(nDirection + 1) % 4]! const wpSide = templeWaypointDirs[rng.int(0, templeWaypointDirs.length - 1)]!
placeNamedPiece(ctx, { placeNamedPiece(ctx, {
kind: 'waypoint', kind: 'waypoint',
name: `Act 5 - Temple ${wpSide} Waypoint`, name: `Act 5 - Temple ${wpSide} Waypoint`,
@ -591,8 +603,9 @@ export function placeAct5TempleStuff(ctx: SpecialPassContext): void {
* Worldstone Keep 2 places Waypoint and Next stairs. * Worldstone Keep 2 places Waypoint and Next stairs.
*/ */
export function placeAct5BaalStuff(ctx: SpecialPassContext): void { export function placeAct5BaalStuff(ctx: SpecialPassContext): void {
const rng = getPassRng(ctx)
const levelId = ctx.request.levelId const levelId = ctx.request.levelId
const nDirection = ctx.request.seed % 4 const nDirection = rng.int(0, 3)
if (levelId === 131) { if (levelId === 131) {
// Throne of Destruction // Throne of Destruction
@ -657,11 +670,12 @@ export function placeAct5BaalStuff(ctx: SpecialPassContext): void {
* Glacial Trail (115) places Theme and Waypoint. * Glacial Trail (115) places Theme and Waypoint.
*/ */
export function placeAct5IceStuff(ctx: SpecialPassContext): void { export function placeAct5IceStuff(ctx: SpecialPassContext): void {
const rng = getPassRng(ctx)
const levelId = ctx.request.levelId const levelId = ctx.request.levelId
if (levelId === 114) { if (levelId === 114) {
// Frozen River (Anya platform) // Frozen River (Anya platform)
const variant = (ctx.request.seed % 2) === 0 ? 'A' : 'B' const variant = rng.int(0, 1) === 0 ? 'A' : 'B'
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) { if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
const riverPiece = ctx.pieces.find(p => p.name === `Act 5 - Ice River ${variant}`) const riverPiece = ctx.pieces.find(p => p.name === `Act 5 - Ice River ${variant}`)
?? ctx.pieces.find(p => p.name.startsWith('Act 5 - Ice River')) ?? ctx.pieces.find(p => p.name.startsWith('Act 5 - Ice River'))
@ -701,7 +715,7 @@ export function placeAct5IceStuff(ctx: SpecialPassContext): void {
placeNamedPiece(ctx, { kind: 'quest', name: 'Act 5 - Ice Pool B', preferFurthest: true }) placeNamedPiece(ctx, { kind: 'quest', name: 'Act 5 - Ice Pool B', preferFurthest: true })
} else { } else {
// Levels 113, 115, 118 // Levels 113, 115, 118
let nDirection = ctx.request.seed % 4 let nDirection = rng.int(0, 3)
placeNamedPiece(ctx, { placeNamedPiece(ctx, {
kind: 'prev', kind: 'prev',
namePrefix: 'Act 5 - Ice Prev', namePrefix: 'Act 5 - Ice Prev',
@ -760,6 +774,7 @@ export function placeAct5IceStuff(ctx: SpecialPassContext): void {
* Places Court Connect, the Smith / Charsi's Malus forge, and stairs to Jail. * Places Court Connect, the Smith / Charsi's Malus forge, and stairs to Jail.
*/ */
export function placeAct1Barracks(ctx: SpecialPassContext): void { export function placeAct1Barracks(ctx: SpecialPassContext): void {
const rng = getPassRng(ctx)
// Connect towards Outer Cloister // Connect towards Outer Cloister
placeNamedPiece(ctx, { placeNamedPiece(ctx, {
kind: 'prev', kind: 'prev',
@ -768,8 +783,8 @@ export function placeAct1Barracks(ctx: SpecialPassContext): void {
preferFurthest: false, preferFurthest: false,
}) })
const nDirection = ctx.request.seed % 4 const nDirection = rng.int(0, 3)
const roll = (ctx.request.seed >>> 2) % 2 const roll = rng.int(0, 1)
if (roll === 1) { if (roll === 1) {
placeNamedPiece(ctx, { placeNamedPiece(ctx, {
@ -815,6 +830,7 @@ export function placeAct1Barracks(ctx: SpecialPassContext): void {
* Hephasto the Armorer / Hellforge, and fills blanks with Lava X. * Hephasto the Armorer / Hellforge, and fills blanks with Lava X.
*/ */
export function placeAct4Lava(ctx: SpecialPassContext): void { export function placeAct4Lava(ctx: SpecialPassContext): void {
const rng = getPassRng(ctx)
// 1. Warp N (entrance from City of Damned) // 1. Warp N (entrance from City of Damned)
placeNamedPiece(ctx, { placeNamedPiece(ctx, {
kind: 'prev', kind: 'prev',
@ -840,7 +856,7 @@ export function placeAct4Lava(ctx: SpecialPassContext): void {
}) })
// 3. Hellforge (Hephasto the Armorer) // 3. Hellforge (Hephasto the Armorer)
const forgeSide = (ctx.request.seed % 2) === 0 ? 'W' : 'E' const forgeSide = rng.int(0, 1) === 0 ? 'W' : 'E'
placeNamedPiece(ctx, { placeNamedPiece(ctx, {
kind: 'quest', kind: 'quest',
name: `Act 4 - Lava Forge ${forgeSide}`, name: `Act 4 - Lava Forge ${forgeSide}`,
@ -865,8 +881,9 @@ export function placeAct4Lava(ctx: SpecialPassContext): void {
* Ancient Tunnels (level 50) places Chest and Prev. * Ancient Tunnels (level 50) places Chest and Prev.
*/ */
export function scanReplaceSpecialAct2SewersPresets(ctx: SpecialPassContext): void { export function scanReplaceSpecialAct2SewersPresets(ctx: SpecialPassContext): void {
const rng = getPassRng(ctx)
const levelId = ctx.request.levelId const levelId = ctx.request.levelId
let nRand = ctx.request.seed % 4 let nRand = rng.int(0, 3)
if (levelId === 49) { if (levelId === 49) {
// Sewers Level 3: Radament's Lair // Sewers Level 3: Radament's Lair

View File

@ -2271,6 +2271,7 @@ export function generateMaze(request: MazeRequest): MazeResult {
pieces: request.pieces, pieces: request.pieces,
index, index,
stats, stats,
rng,
}) })
} }
} }

View File

@ -511,6 +511,33 @@ describe('Diablo II DRLG Maze Special Passes', () => {
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'waypoint')).toBe(true) expect(specials.some(s => s.kind === 'waypoint')).toBe(true)
}) })
test('Halls of Pain (level 123): generates all 4 waypoint directions including NE across seeds', () => {
const pieces = createStandardPieceSet('Act 5 - Temple', templeExtraPieces)
const observedDirs = new Set<string>()
for (let seed = 1; seed <= 50; seed++) {
const res = generateMaze({
levelId: 123,
levelName: 'Halls of Pain',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed,
pieces,
})
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
const wp = specials.find(s => s.kind === 'waypoint')
if (wp !== undefined) {
observedDirs.add(wp.sides)
}
}
expect(observedDirs).toContain('NE')
expect(observedDirs).toContain('NW')
expect(observedDirs).toContain('SW')
expect(observedDirs).toContain('SE')
})
}) })
/* ----------------------------------------------------------------------- * /* ----------------------------------------------------------------------- *