From b8810072ce7a5848ed74528a75d466943c368b50 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 13:36:02 +0000 Subject: [PATCH] fix(maze): use PRNG stream in special passes and fix Act 5 temple NE waypoint generation TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/maze-special-passes.ts | 47 +++++++++++++++++++++---------- src/game/maze.ts | 1 + tests/maze-special-passes.test.ts | 27 ++++++++++++++++++ 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/src/game/maze-special-passes.ts b/src/game/maze-special-passes.ts index d10f12c..471125e 100644 --- a/src/game/maze-special-passes.ts +++ b/src/game/maze-special-passes.ts @@ -15,6 +15,7 @@ * - `DRLGMAZE_ScanReplaceSpecialAct2SewersPresets`: Lut Gholein Sewers (Radament's Lair & Atma's quest). */ +import { Rng } from './rng.ts' import { DIRECTION_WEST, DIRECTION_NORTH, @@ -56,6 +57,11 @@ export interface SpecialPassContext { readonly pieces: readonly MazePiece[] readonly index: PieceIndex readonly stats: MazeStats + readonly rng?: Rng +} + +function getPassRng(ctx: SpecialPassContext): Rng { + return ctx.rng ?? ctx.level?.rng ?? new Rng(ctx.request.seed) } const DIRS = ['N', 'E', 'S', 'W'] as const @@ -189,6 +195,7 @@ export function placeNamedPiece( * Horadric Cube chest, Leatherarm, and staircases. */ export function placeAct2TombStuff(ctx: SpecialPassContext): void { + const rng = getPassRng(ctx) const levelId = ctx.request.levelId const staffTomb = ctx.request.staffTombLevelId ?? 66 const bossTomb = ctx.request.bossTombLevelId @@ -202,9 +209,9 @@ export function placeAct2TombStuff(ctx: SpecialPassContext): void { 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 = rng.int(0, 3) } else { - nDirection = ctx.request.seed % 4 + nDirection = rng.int(0, 3) } 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). */ export function placeAct2LairStuff(ctx: SpecialPassContext): void { + const rng = getPassRng(ctx) const levelId = ctx.request.levelId if (levelId === 64) { @@ -309,7 +317,7 @@ export function placeAct2LairStuff(ctx: SpecialPassContext): void { preferFurthest: false, }) } else { - const nRand = ctx.request.seed % 4 + const nRand = rng.int(0, 3) placeNamedPiece(ctx, { kind: '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. */ export function placeAct3DungeonStuff(ctx: SpecialPassContext): void { + const rng = getPassRng(ctx) const levelId = ctx.request.levelId - const nDirection = ctx.request.seed % 4 + const nDirection = rng.int(0, 3) if (levelId === 91) { // 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. */ export function placeAct3SewerStuff(ctx: SpecialPassContext): void { + const rng = getPassRng(ctx) const levelId = ctx.request.levelId - const nDirection = ctx.request.seed % 4 + const nDirection = rng.int(0, 3) if (levelId === 92) { // 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. */ export function placeAct3MephistoStuff(ctx: SpecialPassContext): void { + const rng = getPassRng(ctx) const levelId = ctx.request.levelId - const nRand = ctx.request.seed % 4 + const nRand = rng.int(0, 3) if (levelId === 102) { // 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. */ export function placeAct5TempleStuff(ctx: SpecialPassContext): void { + const rng = getPassRng(ctx) const levelId = ctx.request.levelId if (levelId === 124) { @@ -547,7 +559,7 @@ export function placeAct5TempleStuff(ctx: SpecialPassContext): void { } else { // Levels 122 and 123 const templeDownDirs = ['NE', 'NW', 'SW'] - const nDirection = (ctx.request.seed >>> 0) % 3 + const nDirection = rng.int(0, templeDownDirs.length - 1) const downSide = templeDownDirs[nDirection]! placeNamedPiece(ctx, { @@ -560,7 +572,7 @@ export function placeAct5TempleStuff(ctx: SpecialPassContext): void { if (levelId === 123) { const templeWaypointDirs = ['NE', 'NW', 'SW', 'SE'] - const wpSide = templeWaypointDirs[(nDirection + 1) % 4]! + const wpSide = templeWaypointDirs[rng.int(0, templeWaypointDirs.length - 1)]! placeNamedPiece(ctx, { kind: '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. */ export function placeAct5BaalStuff(ctx: SpecialPassContext): void { + const rng = getPassRng(ctx) const levelId = ctx.request.levelId - const nDirection = ctx.request.seed % 4 + const nDirection = rng.int(0, 3) if (levelId === 131) { // Throne of Destruction @@ -657,11 +670,12 @@ export function placeAct5BaalStuff(ctx: SpecialPassContext): void { * Glacial Trail (115) places Theme and Waypoint. */ export function placeAct5IceStuff(ctx: SpecialPassContext): void { + const rng = getPassRng(ctx) const levelId = ctx.request.levelId if (levelId === 114) { // Frozen River (Anya platform) - 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) { const riverPiece = ctx.pieces.find(p => p.name === `Act 5 - Ice River ${variant}`) ?? 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 }) } else { // Levels 113, 115, 118 - let nDirection = ctx.request.seed % 4 + let nDirection = rng.int(0, 3) placeNamedPiece(ctx, { kind: '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. */ export function placeAct1Barracks(ctx: SpecialPassContext): void { + const rng = getPassRng(ctx) // Connect towards Outer Cloister placeNamedPiece(ctx, { kind: 'prev', @@ -768,8 +783,8 @@ export function placeAct1Barracks(ctx: SpecialPassContext): void { preferFurthest: false, }) - const nDirection = ctx.request.seed % 4 - const roll = (ctx.request.seed >>> 2) % 2 + const nDirection = rng.int(0, 3) + const roll = rng.int(0, 1) if (roll === 1) { placeNamedPiece(ctx, { @@ -815,6 +830,7 @@ export function placeAct1Barracks(ctx: SpecialPassContext): void { * Hephasto the Armorer / Hellforge, and fills blanks with Lava X. */ export function placeAct4Lava(ctx: SpecialPassContext): void { + const rng = getPassRng(ctx) // 1. Warp N (entrance from City of Damned) placeNamedPiece(ctx, { kind: 'prev', @@ -840,7 +856,7 @@ export function placeAct4Lava(ctx: SpecialPassContext): void { }) // 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, { kind: 'quest', name: `Act 4 - Lava Forge ${forgeSide}`, @@ -865,8 +881,9 @@ export function placeAct4Lava(ctx: SpecialPassContext): void { * Ancient Tunnels (level 50) places Chest and Prev. */ export function scanReplaceSpecialAct2SewersPresets(ctx: SpecialPassContext): void { + const rng = getPassRng(ctx) const levelId = ctx.request.levelId - let nRand = ctx.request.seed % 4 + let nRand = rng.int(0, 3) if (levelId === 49) { // Sewers Level 3: Radament's Lair diff --git a/src/game/maze.ts b/src/game/maze.ts index c6b70fd..bfc2cf6 100644 --- a/src/game/maze.ts +++ b/src/game/maze.ts @@ -2271,6 +2271,7 @@ export function generateMaze(request: MazeRequest): MazeResult { pieces: request.pieces, index, stats, + rng, }) } } diff --git a/tests/maze-special-passes.test.ts b/tests/maze-special-passes.test.ts index 9931269..23f32ae 100644 --- a/tests/maze-special-passes.test.ts +++ b/tests/maze-special-passes.test.ts @@ -511,6 +511,33 @@ describe('Diablo II DRLG Maze Special Passes', () => { const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] 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() + + 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') + }) }) /* ----------------------------------------------------------------------- *