diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index ebb47fc..638a55f 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, @@ -544,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() @@ -754,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), @@ -800,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() @@ -1160,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) @@ -1448,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)) @@ -1652,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/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/level-links.ts b/src/game/level-links.ts index 9ddd762..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[] } /** @@ -394,7 +396,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 +409,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/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/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..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[] } } @@ -1405,6 +1407,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 +1417,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 +1468,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 +1481,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 +1524,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 +1690,7 @@ function planAct1OutdoorLevel( gridWidth: number, gridHeight: number, rng: Rng, + stats?: WildernessStats, ): Act1OutdoorPlan { const openGates = new Map() const claimedBorderBlocks = new Set() @@ -1802,13 +1820,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 +1926,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 +1971,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 +1983,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 +2045,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 +2093,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) @@ -4941,16 +5005,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) @@ -5126,7 +5181,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) @@ -5149,6 +5204,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/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') + }) }) /* ----------------------------------------------------------------------- * 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) + }) }) diff --git a/tests/world-graph.test.ts b/tests/world-graph.test.ts index 55ce4f9..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, @@ -16,6 +18,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 +383,100 @@ 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) + }) +}) + +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) + }) +})