From 27e0db38c8d271a71e673706a232f6e5982beb10 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 13:27:27 +0000 Subject: [PATCH 1/5] fix(level-links): prevent infinite teleport bounce at seam arrival Eliminate fallback to trigger tile (x, y) when no walkable tile is found within initial radius. Expand search to full grid extent and guarantee that the arrival destination is strictly offset inward away from the seam trigger box. TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/level-links.ts | 12 +++++-- tests/world-graph.test.ts | 68 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/game/level-links.ts b/src/game/level-links.ts index 9ddd762..b7b8b63 100644 --- a/src/game/level-links.ts +++ b/src/game/level-links.ts @@ -394,7 +394,8 @@ export function seamArrivalSpot( const step = inwardStep(side) const targetX = x + step.x * (SEAM_TRIGGER_SUBTILES + 3) const targetY = y + step.y * (SEAM_TRIGGER_SUBTILES + 3) - for (let radius = 0; radius <= 32; radius += 1) { + const maxRadius = Math.max(grid.gridWidth, grid.gridHeight, 64) + for (let radius = 0; radius <= maxRadius; radius += 1) { for (let dy = -radius; dy <= radius; dy += 1) { for (let dx = -radius; dx <= radius; dx += 1) { if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue @@ -406,7 +407,14 @@ export function seamArrivalSpot( } } } - return nearestWalkable(grid, targetX, targetY, 32, region) ?? { x, y } + // Never place the player directly back on or inside the trigger box around (x, y), + // which would trigger an immediate bounce back in an infinite ping-pong loop. + const clampedX = Math.max(0, Math.min(grid.gridWidth - 1, targetX)) + const clampedY = Math.max(0, Math.min(grid.gridHeight - 1, targetY)) + if (Math.abs(clampedX - x) <= SEAM_TRIGGER_SUBTILES && Math.abs(clampedY - y) <= SEAM_TRIGGER_SUBTILES) { + return { x: targetX, y: targetY } + } + return { x: clampedX, y: clampedY } } /** Where an edge of a level opens, and how convincing the opening is. */ diff --git a/tests/world-graph.test.ts b/tests/world-graph.test.ts index 55ce4f9..efe14ac 100644 --- a/tests/world-graph.test.ts +++ b/tests/world-graph.test.ts @@ -16,6 +16,11 @@ import { DROPPED_VIS_EDGES, } from '../src/game/world-graph.ts' import type { Side } from '../src/game/world-graph.ts' +import { + seamArrivalSpot, + SEAM_TRIGGER_SUBTILES, + type LinkGrid, +} from '../src/game/level-links.ts' /** * Build a `Levels.txt`-shaped table from sparse overrides. @@ -376,3 +381,66 @@ describe('parseWarpGeometry', () => { expect(findWarpGeometry(parseWarpGeometry(lvlwarp), 71)?.id).toBe(71) }) }) + +describe('seamArrivalSpot', () => { + function makeGrid(width: number, height: number, blockedIndices: Set = new Set()): LinkGrid { + const blocked = new Uint8Array(width * height) + for (const idx of blockedIndices) { + blocked[idx] = 1 + } + return { + cellsX: Math.ceil(width / 5), + cellsY: Math.ceil(height / 5), + gridWidth: width, + gridHeight: height, + blocked, + } + } + + it('places arrival strictly outside trigger box on all 4 sides', () => { + const grid = makeGrid(50, 50) + const sides: Side[] = ['north', 'south', 'east', 'west'] + const x = 25 + const y = 25 + + for (const side of sides) { + const spot = seamArrivalSpot(grid, x, y, side) + const insideTriggerBox = + Math.abs(spot.x - x) <= SEAM_TRIGGER_SUBTILES && + Math.abs(spot.y - y) <= SEAM_TRIGGER_SUBTILES + expect(insideTriggerBox).toBe(false) + expect(spot).not.toEqual({ x, y }) + } + }) + + it('never bounces back to trigger tile (x, y) even when completely blocked', () => { + // A grid where every single tile is impassable + const blockedSet = new Set() + for (let i = 0; i < 50 * 50; i++) blockedSet.add(i) + const grid = makeGrid(50, 50, blockedSet) + + const spot = seamArrivalSpot(grid, 20, 0, 'north') + // Must never fall back to trigger tile (20, 0) + expect(spot).not.toEqual({ x: 20, y: 0 }) + const insideTriggerBox = + Math.abs(spot.x - 20) <= SEAM_TRIGGER_SUBTILES && + Math.abs(spot.y - 0) <= SEAM_TRIGGER_SUBTILES + expect(insideTriggerBox).toBe(false) + }) + + it('ignores walkable tiles inside trigger radius and finds the nearest valid tile outside', () => { + // Only the trigger tile itself and its immediate neighbors (radius 2) are walkable + const blockedSet = new Set() + for (let i = 0; i < 50 * 50; i++) blockedSet.add(i) + // Clear trigger tile and one tile far inward + blockedSet.delete(0 * 50 + 20) // trigger (20, 0) + blockedSet.delete(1 * 50 + 20) // (20, 1) inside trigger box + blockedSet.delete(10 * 50 + 20) // (20, 10) valid outside trigger box + + const grid = makeGrid(50, 50, blockedSet) + const spot = seamArrivalSpot(grid, 20, 0, 'north') + + expect(spot).toEqual({ x: 20, y: 10 }) + expect(Math.abs(spot.y - 0)).toBeGreaterThan(SEAM_TRIGGER_SUBTILES) + }) +}) From a3a3e448385ed47622514619ecaa20185d26c800 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 13:31:07 +0000 Subject: [PATCH 2/5] refactor(acts): unify ACT_TOWNS and townLevelForAct single source of truth TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- scripts/pack-act-assets.ts | 5 +---- scripts/verify-world-graph.ts | 4 +--- src/game/acts.ts | 22 +++++++++++++++++++++ src/game/portal.ts | 25 +----------------------- src/game/wilderness.ts | 11 +---------- tests/world-graph.test.ts | 36 +++++++++++++++++++++++++++++++++++ 6 files changed, 62 insertions(+), 41 deletions(-) diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index ebb47fc..b0ba3db 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -30,7 +30,7 @@ import { join } from 'node:path' import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' import { MountedArchives } from '../src/mpq/mount.ts' -import { cell, loadActTables, parseTable, resolveLevel, resolveLevelLibraries, tileMemberPath } from '../src/game/acts.ts' +import { cell, loadActTables, parseTable, resolveLevel, resolveLevelLibraries, tileMemberPath, ACT_TOWNS } from '../src/game/acts.ts' import type { ActTables, LevelInfo, D2Table } from '../src/game/acts.ts' import { decodeDs1 } from '../src/formats/ds1.ts' import type { Ds1 } from '../src/formats/ds1.ts' @@ -366,9 +366,6 @@ const monstersTable = { */ const ACT_LAYOUT_SEED = 0x5eed_2000 -/** Where each act's town is, for working out which way is "up" in a dungeon. */ -const ACT_TOWNS: readonly number[] = [1, 40, 75, 103, 109] - const worldGraph: WorldGraph = assignGateSides( buildWorldGraph(parseLevelRows(tables.levels)), ACT_LAYOUT_SEED, diff --git a/scripts/verify-world-graph.ts b/scripts/verify-world-graph.ts index 9d83983..f6bccb6 100644 --- a/scripts/verify-world-graph.ts +++ b/scripts/verify-world-graph.ts @@ -33,7 +33,7 @@ import { readFileSync } from 'node:fs' import { MountedArchives } from '../src/mpq/mount.ts' import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' -import { loadActTables } from '../src/game/acts.ts' +import { loadActTables, ACT_TOWNS } from '../src/game/acts.ts' import { parseLevelRows, buildWorldGraph, @@ -56,8 +56,6 @@ const dir = process.argv[2] ?? 'samples/d2' const MOUNTS = ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] /** The baked asset packs. */ const PACK_INDEX = 'samples/d2-packs/index.json' -/** The town of each act, keyed by the 0-based `Act` column. */ -const ACT_TOWNS: readonly number[] = [1, 40, 75, 103, 109] /** Waypoint ids run 0..38 with no gaps. */ const WAYPOINT_COUNT = 39 /** How many seeds to exercise the side solver with. */ diff --git a/src/game/acts.ts b/src/game/acts.ts index 6d10208..6d3dc41 100644 --- a/src/game/acts.ts +++ b/src/game/acts.ts @@ -183,6 +183,28 @@ export interface LevelInfo { */ export type ActTown = LevelInfo +/** + * Town level IDs for Acts 1..5 in order: + * Act 1: Rogue Encampment (1) + * Act 2: Lut Gholein (40) + * Act 3: Kurast Docks (75) + * Act 4: The Pandemonium Fortress (103) + * Act 5: Harrogath (109) + */ +export const ACT_TOWNS: readonly number[] = [1, 40, 75, 103, 109] + +/** + * Returns the town level ID for an act (1..5). + * Defaults to Act 5 Harrogath (109) for acts >= 5, or Act 1 Rogue Encampment (1) for acts <= 1. + */ +export function townLevelForAct(act: number): number { + if (act >= 1 && act <= 5) { + return ACT_TOWNS[act - 1]! + } + if (act <= 1) return ACT_TOWNS[0]! + return ACT_TOWNS[4]! +} + /** Turn a table-relative tile path into a member name. */ export function tileMemberPath(relative: string): string { return `${TILES}${relative.replaceAll('/', '\\')}` diff --git a/src/game/portal.ts b/src/game/portal.ts index da4ded1..f59db5c 100644 --- a/src/game/portal.ts +++ b/src/game/portal.ts @@ -213,27 +213,4 @@ export class TownPortalSlot { } } -/** - * The town for an act. - * - * Hard-coded because `Levels.txt` does not mark towns: the Rogue Encampment's - * row looks like any other preset. The ids are stable across every version of - * the game. - * - * @param act - the act, 1..5. - * @returns the town's level id. - */ -export function townLevelForAct(act: number): number { - switch (act) { - case 1: - return 1 - case 2: - return 40 - case 3: - return 75 - case 4: - return 103 - default: - return 109 - } -} +export { ACT_TOWNS, townLevelForAct } from './acts.ts' diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 05e7df1..dc79fb1 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -4941,16 +4941,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult let floorLayers = 1 let substitutionType = 0 let version = 0 - let inferredAct = 1 - if (request.levelId >= 40 && request.levelId < 75) inferredAct = 2 - else if (request.levelId >= 75 && request.levelId < 103) inferredAct = 3 - else if (request.levelId >= 103 && request.levelId < 109) inferredAct = 4 - else if (request.levelId >= 109) inferredAct = 5 - else if (/Act 2/i.test(request.levelTypeName)) inferredAct = 2 - else if (/Act 3/i.test(request.levelTypeName)) inferredAct = 3 - else if (/Act 4/i.test(request.levelTypeName)) inferredAct = 4 - else if (/Act 5/i.test(request.levelTypeName)) inferredAct = 5 - + const inferredAct = actOfLevel(request.levelId, request.levelTypeName) let act = inferredAct const allLevels: Ds1[] = [] for (const piece of request.pieces) for (const level of piece.levels) allLevels.push(level) diff --git a/tests/world-graph.test.ts b/tests/world-graph.test.ts index efe14ac..458445f 100644 --- a/tests/world-graph.test.ts +++ b/tests/world-graph.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest' import type { D2Table } from '../src/game/acts.ts' +import { ACT_TOWNS, townLevelForAct } from '../src/game/acts.ts' +import { actOfLevel } from '../src/game/wilderness.ts' import { parseLevelRows, buildWorldGraph, @@ -444,3 +446,37 @@ describe('seamArrivalSpot', () => { expect(Math.abs(spot.y - 0)).toBeGreaterThan(SEAM_TRIGGER_SUBTILES) }) }) + +describe('ACT_TOWNS and townLevelForAct single source of truth', () => { + it('defines the correct canonical town level IDs for Acts 1..5', () => { + expect(ACT_TOWNS).toEqual([1, 40, 75, 103, 109]) + }) + + it('resolves townLevelForAct for all 5 acts correctly', () => { + expect(townLevelForAct(1)).toBe(1) + expect(townLevelForAct(2)).toBe(40) + expect(townLevelForAct(3)).toBe(75) + expect(townLevelForAct(4)).toBe(103) + expect(townLevelForAct(5)).toBe(109) + }) + + it('handles boundary / out-of-range acts gracefully', () => { + expect(townLevelForAct(0)).toBe(1) + expect(townLevelForAct(-5)).toBe(1) + expect(townLevelForAct(6)).toBe(109) + expect(townLevelForAct(99)).toBe(109) + }) + + it('actOfLevel correctly partitions level IDs matching ACT_TOWNS', () => { + expect(actOfLevel(1)).toBe(1) + expect(actOfLevel(39)).toBe(1) + expect(actOfLevel(40)).toBe(2) + expect(actOfLevel(74)).toBe(2) + expect(actOfLevel(75)).toBe(3) + expect(actOfLevel(102)).toBe(3) + expect(actOfLevel(103)).toBe(4) + expect(actOfLevel(108)).toBe(4) + expect(actOfLevel(109)).toBe(5) + expect(actOfLevel(136)).toBe(5) + }) +}) 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 3/5] 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') + }) }) /* ----------------------------------------------------------------------- * From fea677b0b441a7cd0cfd3e7ce995c043173bcf2b Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 13:43:51 +0000 Subject: [PATCH 4/5] fix(wilderness): prevent sealed borders on outdoor levels and track preset failures TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/wilderness.ts | 133 ++++++++++++++++++++++-------- tests/wilderness-features.test.ts | 51 ++++++++++++ 2 files changed, 149 insertions(+), 35 deletions(-) diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index dc79fb1..d42c4d9 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -1405,6 +1405,7 @@ export function getPerimeterOpenings( levelId: number, gridWidth: number, gridHeight: number, + gates?: readonly PlannedGate[], ): Set { const openings = new Set() const key = (x: number, y: number) => `${String(x)},${String(y)}` @@ -1414,25 +1415,32 @@ export function getPerimeterOpenings( return openings } - // Outdoor wilderness levels across Acts 1, 2, 3, 4, 5: - // Act 1 (2..7), Act 2 desert (41..46), Act 3 Kurast (79..82), - // Act 4 Mesa (104..106), Act 5 outdoor (111..112, 117): - // Default entrance on South edge, exit on East edge - if ( - (levelId >= 2 && levelId <= 7) || - (levelId >= 41 && levelId <= 46) || - (levelId >= 79 && levelId <= 82) || - (levelId >= 104 && levelId <= 106) || - (levelId >= 111 && levelId <= 112) || - levelId === 117 - ) { - const inX = Math.floor(gridWidth / 2) - openings.add(key(inX, gridHeight - 1)) - const outY = Math.floor(gridHeight / 2) - openings.add(key(gridWidth - 1, outY)) + if (gates !== undefined && gates.length > 0) { + for (const gate of gates) { + switch (gate.side) { + case 'north': + openings.add(key(Math.floor(gridWidth / 2), 0)) + break + case 'south': + openings.add(key(Math.floor(gridWidth / 2), gridHeight - 1)) + break + case 'west': + openings.add(key(0, Math.floor(gridHeight / 2))) + break + case 'east': + openings.add(key(gridWidth - 1, Math.floor(gridHeight / 2))) + break + } + } return openings } + // Outdoor wilderness levels across Acts 1..5: + // Default entrance on South edge, exit on East edge so borders are never sealed shut + const inX = Math.floor(gridWidth / 2) + openings.add(key(inX, gridHeight - 1)) + const outY = Math.floor(gridHeight / 2) + openings.add(key(gridWidth - 1, outY)) return openings } @@ -1458,6 +1466,7 @@ export function getPerimeterOpenings( * @param levelId - optional level id for perimeter opening rules. * @param openGates - optional map from `${x},${y}` block key to forced variant index. * @param claimedBorderBlocks - optional set of `${x},${y}` keys to skip (e.g. Town Transitions). + * @param gates - optional planned gates from the world graph. * @returns the number of pieces stamped. */ function layBorder( @@ -1470,6 +1479,7 @@ function layBorder( levelId?: number, openGates?: ReadonlyMap, claimedBorderBlocks?: ReadonlySet, + gates?: readonly PlannedGate[], ): number { if (pieces.length === 0) { stats.unresolved.push('no border piece for this level type') @@ -1512,7 +1522,12 @@ function layBorder( { x: 0, y: gridHeight - 1 }, // SW ] - const openings = levelId !== undefined ? getPerimeterOpenings(levelId, gridWidth, gridHeight) : new Set() + const openings = levelId !== undefined ? getPerimeterOpenings(levelId, gridWidth, gridHeight, gates) : new Set() + if (openGates !== undefined) { + for (const gateKey of openGates.keys()) { + openings.add(gateKey) + } + } let cursor = rng.int(0, ordered.length - 1) let stamped = 0 @@ -1673,6 +1688,7 @@ function planAct1OutdoorLevel( gridWidth: number, gridHeight: number, rng: Rng, + stats?: WildernessStats, ): Act1OutdoorPlan { const openGates = new Map() const claimedBorderBlocks = new Set() @@ -1802,13 +1818,21 @@ function planAct1OutdoorLevel( toLevelId?: number, ): boolean => { const piece = findPiece(prefix) - if (piece === undefined || piece.levels.length === 0) return false + if (piece === undefined || piece.levels.length === 0) { + stats?.unresolved.push(`preset piece not found: ${prefix}`) + stats?.notes.push(`failed to place preset: ${label} (missing piece ${prefix})`) + return false + } const variant = rng.int(0, piece.levels.length - 1) const ds1 = piece.levels[variant]! const blocksX = Math.max(1, Math.floor(ds1.width / TILES_PER_BLOCK)) const blocksY = Math.max(1, Math.floor(ds1.height / TILES_PER_BLOCK)) const pos = pickFreeInteriorBox(blocksX, blocksY, 2) - if (pos === null) return false + if (pos === null) { + stats?.unresolved.push(`no free interior box for preset: ${label} (${prefix})`) + stats?.notes.push(`failed to place preset: ${label} (no free interior box ${String(blocksX)}x${String(blocksY)})`) + return false + } const { bx, by } = pos claimBox(bx, by, blocksX, blocksY, true) specialPresets.push({ name: piece.name, ds1, bx, by, blocksX, blocksY }) @@ -1900,7 +1924,11 @@ function planAct1OutdoorLevel( */ const placeGraveyardPreset = (withGate: boolean): boolean => { const gyPiece = findPiece('Act 1 - Graveyard') - if (gyPiece?.levels[0] === undefined || gridWidth < 5 || gridHeight < 6) return false + if (gyPiece?.levels[0] === undefined || gridWidth < 5 || gridHeight < 6) { + stats?.unresolved.push('failed to place Graveyard preset') + stats?.notes.push('failed to place Graveyard preset (missing piece or grid too small)') + return false + } const ds1 = gyPiece.levels[0] claimBox(1, 1, 3, 4, true) specialPresets.push({ name: gyPiece.name, ds1, bx: 1, by: 1, blocksX: 3, blocksY: 4 }) @@ -1941,7 +1969,9 @@ function planAct1OutdoorLevel( */ const placeInteriorPresets = (levelId: number): void => { if (levelId === 2) { - addInteriorPresetWithRoad('Act 1 - DOE Entrance', 'Den of Evil Entrance', undefined, 8) + if (!addInteriorPresetWithRoad('Act 1 - DOE Entrance', 'Den of Evil Entrance', undefined, 8)) { + stats?.notes.push('Den of Evil Entrance preset failed to place') + } const bivouac = findPiece('Act 1 - Bivouac') ?? findPiece('Act 1 - Cottages') if (bivouac?.levels[0] !== undefined) { const ds1 = bivouac.levels[0] @@ -1951,28 +1981,52 @@ function planAct1OutdoorLevel( if (pos !== null) { claimBox(pos.bx, pos.by, w, h, true) specialPresets.push({ name: bivouac.name, ds1, bx: pos.bx, by: pos.by, blocksX: w, blocksY: h }) + } else { + stats?.notes.push('Bivouac preset failed to place (no free box)') } } } else if (levelId === 3) { - addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Cave Level 1 Entrance', undefined, 9) - addInteriorPresetWithRoad('Act 1 - Bivouac', 'Bivouac Camp', 'south') + if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Cave Level 1 Entrance', undefined, 9)) { + stats?.notes.push('Cave Level 1 Entrance preset failed to place') + } + if (!addInteriorPresetWithRoad('Act 1 - Bivouac', 'Bivouac Camp', 'south')) { + stats?.notes.push('Bivouac Camp preset failed to place') + } } else if (levelId === 4) { - addInteriorPresetWithRoad('Act 1 - Cairn Stones', 'Cairn Stones', 'south', 38) - addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Entrance', undefined, 10) + if (!addInteriorPresetWithRoad('Act 1 - Cairn Stones', 'Cairn Stones', 'south', 38)) { + stats?.notes.push('Cairn Stones preset failed to place') + } + if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Entrance', undefined, 10)) { + stats?.notes.push('Underground Passage Entrance preset failed to place') + } } else if (levelId === 5) { - addInteriorPresetWithRoad('Act 1 - Inifus', 'Tree of Inifuss', 'south') - addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Exit', undefined, 10) + if (!addInteriorPresetWithRoad('Act 1 - Inifus', 'Tree of Inifuss', 'south')) { + stats?.notes.push('Tree of Inifuss preset failed to place') + } + if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Exit', undefined, 10)) { + stats?.notes.push('Underground Passage Exit preset failed to place') + } } else if (levelId === 6) { // The Forgotten Tower is always beside one of the Black Marsh's roads, // never dropped on open ground, which is why it goes through the // road-joining path rather than the plain scatter. - addInteriorPresetWithRoad('Act 1 - Tower 1', 'Forgotten Tower', 'south', 20) - addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Hole Level 1 Entrance', undefined, 11) + if (!addInteriorPresetWithRoad('Act 1 - Tower 1', 'Forgotten Tower', 'south', 20)) { + stats?.notes.push('Forgotten Tower preset failed to place') + } + if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Hole Level 1 Entrance', undefined, 11)) { + stats?.notes.push('Hole Level 1 Entrance preset failed to place') + } } else if (levelId === 7) { - addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Pit Level 1 Entrance', undefined, 12) + if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Pit Level 1 Entrance', undefined, 12)) { + stats?.notes.push('Pit Level 1 Entrance preset failed to place') + } } else if (levelId === 39) { - addInteriorPresetWithRoad('Act 1 - Corral Fill', 'Cow Corral', 'south') - addInteriorPresetWithRoad('Act 1 - Bivouac', 'Cow Bivouac', 'east') + if (!addInteriorPresetWithRoad('Act 1 - Corral Fill', 'Cow Corral', 'south')) { + stats?.notes.push('Cow Corral preset failed to place') + } + if (!addInteriorPresetWithRoad('Act 1 - Bivouac', 'Cow Bivouac', 'east')) { + stats?.notes.push('Cow Bivouac preset failed to place') + } } } @@ -1989,7 +2043,12 @@ function planAct1OutdoorLevel( if (placedTown && gate === townSeam) continue addBorderGate(SIDE_TO_EDGE[gate.side], gate.variant ?? 3, gate.label, gate.toLevelId) } - if (id === 17) placeGraveyardPreset(false) + if (id === 17) { + if (!placeGraveyardPreset(false)) { + stats?.unresolved.push('Burial Grounds Graveyard preset failed to place') + stats?.notes.push('Burial Grounds Graveyard preset failed to place') + } + } placeInteriorPresets(id) } else if (id === 2) { // Blood Moor: Town Transition + Exit Gate to Cold Plains + Den of Evil Entrance @@ -2032,7 +2091,10 @@ function planAct1OutdoorLevel( placeInteriorPresets(7) } else if (id === 17) { // Burial Grounds (5x6 blocks): Graveyard preset (3x4 blocks at bx=1, by=1) + South Entry Gate - if (!placeGraveyardPreset(true)) addBorderGate('bottom', 4, 'Burial Grounds Gate', 3) + if (!placeGraveyardPreset(true)) { + stats?.notes.push('Burial Grounds Graveyard preset failed, falling back to border gate') + addBorderGate('bottom', 4, 'Burial Grounds Gate', 3) + } } else if (id === 39 || id === 46) { // Moo Moo Farm (39) & Canyon of the Magi (46): Closed border, no border gates if (id === 39) placeInteriorPresets(39) @@ -5117,7 +5179,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult let act1Plan: Act1OutdoorPlan | undefined let dirtPathGrid: Uint8Array | undefined if (request.levelTypeName === 'Act 1 - Wilderness' || request.gates !== undefined) { - act1Plan = planAct1OutdoorLevel(request, gridWidth, gridHeight, rng) + act1Plan = planAct1OutdoorLevel(request, gridWidth, gridHeight, rng, stats) stats.anchors = act1Plan.anchors.length stats.entrances = toEntrances(act1Plan.anchors) const polylines = buildRoadNetworkPolylines(act1Plan, gridWidth, gridHeight, rng) @@ -5140,6 +5202,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult request.levelId, act1Plan?.openGates, act1Plan?.claimedBorderBlocks, + request.gates, ) // Step 4B: Stamp anchored special presets (Town Transition, Den of Evil, Cave Entrance, Cairn Stones, etc.) diff --git a/tests/wilderness-features.test.ts b/tests/wilderness-features.test.ts index a37a7d1..14f20f0 100644 --- a/tests/wilderness-features.test.ts +++ b/tests/wilderness-features.test.ts @@ -60,6 +60,29 @@ describe("Wilderness Features & Border Rules", () => { expect(bloodMoorOpenings.has("9,5")).toBe(true) }) + test("getPerimeterOpenings defaults to open borders for Act 3 Jungles and outdoor levels", () => { + // Act 3: Spider Forest (76), Great Marsh (77), Flayer Jungle (78) + const spiderForestOpenings = getPerimeterOpenings(76, 10, 10) + expect(spiderForestOpenings.size).toBe(2) + expect(spiderForestOpenings.has("5,9")).toBe(true) + expect(spiderForestOpenings.has("9,5")).toBe(true) + + // Act 5: Arreat Plateau (113) + const arreatOpenings = getPerimeterOpenings(113, 10, 10) + expect(arreatOpenings.size).toBe(2) + }) + + test("getPerimeterOpenings maps planned gates to perimeter block coordinates", () => { + const plannedGates = [ + { side: 'north' as const, toLevelId: 75, label: 'To Town' }, + { side: 'east' as const, toLevelId: 77, label: 'To Marsh' }, + ] + const openings = getPerimeterOpenings(76, 12, 14, plannedGates) + expect(openings.size).toBe(2) + expect(openings.has("6,0")).toBe(true) // north: (12/2, 0) + expect(openings.has("11,7")).toBe(true) // east: (12-1, 14/2) + }) + test("selectBorderVariant picks open variant for openings and closed variant for closed walls", () => { const closedDs1 = makeMockDs1(8, 8, false) const openDs1 = makeMockDs1(8, 8, true) @@ -356,4 +379,32 @@ describe("Wilderness Features & Border Rules", () => { expect(foundContinuousEW).toBe(true) expect(foundEndCapEW).toBe(true) }) + + test("records unresolved presets and notes when requested preset piece is missing", () => { + const borderDs1 = makeMockDs1(8, 8, false) + const piece: WildernessPiece = { + name: "Act 1 - Wild Border 1", + border: true, + levels: [borderDs1], + } + + const result = generateWilderness({ + levelId: 2, + levelName: "Blood Moor", + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: 0, + subTheme: 0, + seed: 9999, + pieces: [piece], // Missing Den of Evil Entrance preset! + substitutions: [], + }) + + const unresolved = result.stats.unresolved as string[] + const notes = result.stats.notes as string[] + expect(unresolved.length).toBeGreaterThan(0) + expect(unresolved.some((u: string) => u.includes('Act 1 - DOE Entrance'))).toBe(true) + expect(notes.some((n: string) => n.includes('Den of Evil Entrance'))).toBe(true) + }) }) From 0b08b4e125098173da035cb9a47f4a341c18fbfb Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 13:55:56 +0000 Subject: [PATCH 5/5] fix(assets): track fallback warps in stats.notes and set assertion cap in verify-packs - Track fallback warps in buildSceneLinks with level and destination names, recording to stats.notes in scene.json - Expose notes and unresolved in WildernessResult.stats and SceneLinks.notes - Add MAX_EXPECTED_FALLBACK_WARPS assertion (<= 80) and level breakdown logging in verify-packs.ts to prevent silent proliferation of invented staircases TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- scripts/pack-act-assets.ts | 12 +++++++++++- scripts/verify-packs.ts | 22 ++++++++++++++++++---- src/game/level-links.ts | 2 ++ src/game/wilderness.ts | 2 ++ 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index b0ba3db..638a55f 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -541,6 +541,7 @@ function buildSceneLinks( const outEntrances: SceneEntrance[] = [] const outWarps: SceneWarp[] = [] const unplaced: { toLevelId: number; reason: string }[] = [] + const notes: string[] = [] const placedSeams = new Set() const placedWarps = new Set() @@ -751,6 +752,9 @@ function buildSceneLinks( unplaced.push({ toLevelId: edge.to, reason: 'no walkable ground for a fallback warp' }) return } + const fromName = worldGraph.levels.get(levelId)?.name ?? `level ${String(levelId)}` + const toName = worldGraph.levels.get(edge.to)?.name ?? `level ${String(edge.to)}` + notes.push(`fallback warp placed for edge ${fromName} -> ${toName}`) outWarps.push(makeWarp( edge, directionTo(edge.to), @@ -797,7 +801,7 @@ function buildSceneLinks( } } - return { entrances: outEntrances, warps: outWarps, waypoints, unplacedEdges: unplaced } + return { entrances: outEntrances, warps: outWarps, waypoints, unplacedEdges: unplaced, notes } } const allNames = await archives.listFiles() @@ -1157,6 +1161,7 @@ async function bakeDs1Variant( readonly mazeWarps: readonly MazeWarp[] readonly mazeWaypoints?: readonly MazeWaypoint[] readonly landmarks?: readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[] + readonly notes?: readonly string[] | undefined } = { entrances: [], mazeWarps: [] }, ): Promise { const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed) @@ -1445,6 +1450,10 @@ async function bakeDs1Variant( // Edges the world graph has that this copy of the level has nowhere to // put. Reported rather than dropped: each one is a hole in the world. unplacedEdges: sceneLinks.unplacedEdges, + notes: [ + ...(generatorLinks.notes ?? []), + ...(sceneLinks.notes ?? []), + ], }, } const sceneBytes = new TextEncoder().encode(JSON.stringify(sceneJson)) @@ -1649,6 +1658,7 @@ for (const entry of LEVELS) { ...(Array.isArray(result.stats.landmarkEntities) ? { landmarks: result.stats.landmarkEntities as readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[] } : {}), + ...(Array.isArray(result.stats.notes) ? { notes: result.stats.notes } : {}), }, ) } catch (err) { diff --git a/scripts/verify-packs.ts b/scripts/verify-packs.ts index a08ffb8..3d9c4bb 100644 --- a/scripts/verify-packs.ts +++ b/scripts/verify-packs.ts @@ -295,7 +295,10 @@ interface PackedLinks { readonly source?: 'tile' | 'room' | 'fallback' }[] readonly waypoints?: readonly { readonly waypointId: number }[] - readonly stats?: { readonly unplacedEdges?: readonly { readonly toLevelId: number; readonly reason: string }[] } + readonly stats?: { + readonly unplacedEdges?: readonly { readonly toLevelId: number; readonly reason: string }[] + readonly notes?: readonly string[] + } } const graph = JSON.parse(await readFile(join(packDir, 'world-graph.json'), 'utf8')) as PackedWorldGraph @@ -385,13 +388,24 @@ for (const key of orphanEdges.slice(0, 20)) { } check(orphanEdges.length === 0, `world: no graph edge left without an opening (${String(orphanEdges.length)} orphans)`) -// Not an assertion: an approximate staircase is traversable, just not authentic. -// The number is printed every run so it cannot creep up unnoticed, and it only -// falls when a level type's DRLG staircase pass gets transcribed. +// Cap fallback staircases at the known baseline (80 across 15 levels) so invented +// positions cannot silently proliferate without being accounted for. +const MAX_EXPECTED_FALLBACK_WARPS = 80 +check( + warpSources.fallback <= MAX_EXPECTED_FALLBACK_WARPS, + `world: fallback warps (${String(warpSources.fallback)}) within expected threshold (<= ${String(MAX_EXPECTED_FALLBACK_WARPS)})`, +) + console.log( `\n楼梯来源:美术瓦片 ${String(warpSources.tile)},迷宫楼梯间 ${String(warpSources.room)},` + `兜底 ${String(warpSources.fallback)}(涉及 ${String(approximateLevels.size)} 个关卡)`, ) +if (approximateLevels.size > 0) { + const breakdown = [...approximateLevels] + .map(id => `${String(id)} (${graphLevelName.get(id) ?? 'unknown'})`) + .join(', ') + console.log(` 兜底关卡列表: ${breakdown}`) +} console.log(`\n${String(checks - failures)}/${String(checks)} 项断言通过(逐像素比对了 ${(comparedPixels / 1048576).toFixed(1)} MB 的索引数据)`) if (failures > 0) process.exit(1) diff --git a/src/game/level-links.ts b/src/game/level-links.ts index b7b8b63..7d32da6 100644 --- a/src/game/level-links.ts +++ b/src/game/level-links.ts @@ -180,6 +180,8 @@ export interface SceneLinks { * hole in the world, and the verifier reports on this list. */ readonly unplacedEdges: readonly { readonly toLevelId: number; readonly reason: string }[] + /** Diagnostic notes and warnings from link resolution (e.g. fallback warps placed). */ + readonly notes?: readonly string[] } /** diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index d42c4d9..55e3ba1 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -325,6 +325,8 @@ export interface WildernessResult { /** What the generator did, for reporting and tests. */ readonly stats: Record & { readonly landmarkEntities?: LandmarkEntity[] + readonly notes?: string[] + readonly unresolved?: string[] } }