Merge branch 'fix/issue-48'

# Conflicts:
#	src/game/maze.ts
This commit is contained in:
troytt 2026-09-17 08:41:40 +00:00
commit 893e9b8550
3 changed files with 289 additions and 3 deletions

View File

@ -1561,6 +1561,8 @@ for (const entry of LEVELS) {
const sectionY = Number(cell(lvlmaze, row, 'SizeY'))
const minRooms = Number(cell(lvlmaze, row, 'Rooms'))
const merge = Number(cell(lvlmaze, row, 'Merge'))
const sizeX = Number(cell(tables.levels, levelRow, 'SizeX'))
const sizeY = Number(cell(tables.levels, levelRow, 'SizeY'))
const pieces = await getMazePieces(type.name, type.id)
if (pieces.length === 0) {
console.warn(`skipping maze ${entry.name}: no maze pieces`)
@ -1584,6 +1586,8 @@ for (const entry of LEVELS) {
merge,
seed,
pieces,
maxCellsX: sizeX > sectionX ? sizeX : undefined,
maxCellsY: sizeY > sectionY ? sizeY : undefined,
})
await bakeDs1Variant(
entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries,

View File

@ -287,13 +287,34 @@ export interface MazeRequest {
readonly staffTombLevelId?: number
/** Boss tomb (`LvlMaze.Rooms` doubled); see {@link staffTombLevelId}. */
readonly bossTombLevelId?: number
/**
* Explicit override for waypoint placement.
* If true, forces a waypoint room to be placed (using waypoint pieces).
* If false, skips waypoint room placement even if the level profile includes it.
* If omitted, defaults to whether the level id has a waypoint in the profile.
*/
readonly hasWaypoint?: boolean
/**
* Maximum allowed width of the entire maze in cells (`Levels.txt` `SizeX`).
* When specified, any room placement that would expand the overall maze bounding box
* beyond this width will be rejected.
*/
readonly maxCellsX?: number | undefined
/**
* Maximum allowed height of the entire maze in cells (`Levels.txt` `SizeY`).
* When specified, any room placement that would expand the overall maze bounding box
* beyond this height will be rejected.
*/
readonly maxCellsY?: number | undefined
/**
* Maximum allowed sections along X axis.
* If provided and `maxCellsX` is not specified, `maxCellsX` is computed as `maxSectionsX * sectionSize`.
*/
readonly maxSectionsX?: number | undefined
/**
* Maximum allowed sections along Y axis.
* If provided and `maxCellsY` is not specified, `maxCellsY` is computed as `maxSectionsY * (sectionHeight ?? sectionSize)`.
*/
readonly maxSectionsY?: number | undefined
}
/** The generated level. */
@ -916,6 +937,9 @@ interface MazeLevel {
nextId: number
/** Doorways added by the merge pass, for reporting. */
merges: number
readonly maxCellsX?: number | undefined
readonly maxCellsY?: number | undefined
readonly stats?: MazeStats | undefined
}
/**
@ -933,6 +957,7 @@ interface MazeStats {
readonly notes: string[]
ringRooms: number
placementAttempts: number
boundsRejected?: number
/** Stair rooms, with the coordinates only `stamp` can work out. */
warps: MazeWarp[]
/** Waypoint rooms, with the coordinates and entity positions only `stamp` can work out. */
@ -1020,7 +1045,56 @@ function roomSideMask(room: Room): number {
}
/**
* Whether a room overlaps anything other than itself and one ignored room.
* Whether placing `room` keeps the overall maze within `maxCellsX` and `maxCellsY`.
*
* In Diablo II, maze room growth is constrained by the level's global SizeX and SizeY limits
* from `Levels.txt`. If growing a candidate room would expand the bounding box of all placed
* rooms beyond the allowed dimensions, the placement attempt is rejected, forcing the maze
* to grow in other available directions and cluster compactly.
*
* @param level - the level.
* @param room - the candidate room with proposed coordinates and dimensions.
* @returns true if within bounds or bounds are unconstrained.
*/
function checkRoomWithinBounds(level: MazeLevel, room: Rect): boolean {
if (level.maxCellsX === undefined && level.maxCellsY === undefined) return true
if (level.rooms.length === 0) return true
if (level.maxCellsX !== undefined && level.maxCellsX > 0) {
let minX = room.x
let maxX = room.x + room.width
for (const other of level.rooms) {
if (other.x < minX) minX = other.x
if (other.x + other.width > maxX) maxX = other.x + other.width
}
if (maxX - minX > level.maxCellsX) {
if (level.stats) {
level.stats.boundsRejected = (level.stats.boundsRejected ?? 0) + 1
}
return false
}
}
if (level.maxCellsY !== undefined && level.maxCellsY > 0) {
let minY = room.y
let maxY = room.y + room.height
for (const other of level.rooms) {
if (other.y < minY) minY = other.y
if (other.y + other.height > maxY) maxY = other.y + other.height
}
if (maxY - minY > level.maxCellsY) {
if (level.stats) {
level.stats.boundsRejected = (level.stats.boundsRejected ?? 0) + 1
}
return false
}
}
return true
}
/**
* Whether a room overlaps anything other than itself and one ignored room, and fits within level bounds.
*
* `DRLGMAZE_CheckRoomNotOverlaping`. The margin is 0, so face-to-face neighbours
* are fine and only genuine overlap fails.
@ -1028,9 +1102,10 @@ function roomSideMask(room: Room): number {
* @param level - the level.
* @param room - the candidate.
* @param ignored - a room to skip, usually its parent.
* @returns true when the room is clear.
* @returns true when the room is clear and within global level bounds.
*/
function checkRoomNotOverlapping(level: MazeLevel, room: Rect, ignored: Room | null): boolean {
if (!checkRoomWithinBounds(level, room)) return false
for (const other of level.rooms) {
if (other === room || other === ignored) continue
if (!notOverlapping(room, other, 0)) return false
@ -2012,9 +2087,16 @@ export function generateMaze(request: MazeRequest): MazeResult {
const rng = new Rng(request.seed)
const mergePerMille = Math.max(0, Math.min(1000, Math.floor(request.merge)))
const index = buildPieceIndex(request.pieces)
const maxCellsX = request.maxCellsX !== undefined
? request.maxCellsX
: (request.maxSectionsX !== undefined ? request.maxSectionsX * width : undefined)
const maxCellsY = request.maxCellsY !== undefined
? request.maxCellsY
: (request.maxSectionsY !== undefined ? request.maxSectionsY * height : undefined)
const level: MazeLevel = {
rooms: [], first: null as unknown as Room, width, height, rng, mergePerMille, nextId: 0, merges: 0,
maxCellsX, maxCellsY, stats,
}
const first = makeRoom(level)
const levelRef: MazeLevel = { ...level, rooms: [first], first }
@ -2131,6 +2213,9 @@ export function generateMaze(request: MazeRequest): MazeResult {
: null,
unresolvedRoles: stats.unresolvedRoles,
unimplementedPasses: UNIMPLEMENTED_PASSES,
maxCellsX,
maxCellsY,
boundsRejected: stats.boundsRejected ?? 0,
notes: stats.notes,
},
}

197
tests/maze-bounds.test.ts Normal file
View File

@ -0,0 +1,197 @@
import { describe, expect, test } from 'vitest'
import type { Ds1, Ds1Cell } from '../src/formats/ds1'
import { generateMaze, type MazePiece, type MazePieceKind, type MazeRequest } from '../src/game/maze'
function makeMockDs1(width = 10, height = 10): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [{ prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function makeMockPiece(name: string, kind: MazePieceKind, sides: string): MazePiece {
return {
name,
kind,
sides,
levels: [makeMockDs1(10, 10)],
}
}
function makeCavePieceSet(): MazePiece[] {
const ds1 = makeMockDs1(10, 10)
const tokens = [
'NSEW',
'NSE', 'NSW', 'NEW', 'SEW',
'NS', 'EW', 'NE', 'NW', 'SE', 'SW',
'N', 'S', 'E', 'W',
]
const pieces: MazePiece[] = tokens.map(token => ({
name: `Act 1 - Cave ${token}`,
kind: 'room',
sides: token,
levels: [ds1],
}))
pieces.push({
name: 'Act 1 - Cave Prev NS',
kind: 'prev',
sides: 'NS',
levels: [ds1],
})
pieces.push({
name: 'Act 1 - Cave Prev EW',
kind: 'prev',
sides: 'EW',
levels: [ds1],
})
pieces.push({
name: 'Act 1 - Cave Down NS',
kind: 'down',
sides: 'NS',
levels: [ds1],
})
pieces.push({
name: 'Act 1 - Cave Down EW',
kind: 'down',
sides: 'EW',
levels: [ds1],
})
return pieces
}
describe('Maze Global Bounding Box Constraints (Issue #48)', () => {
test('generateMaze respects maxSectionsX and maxSectionsY limits', () => {
const pieces = makeCavePieceSet()
const request: MazeRequest = {
levelId: 2,
levelName: 'Act 1 - Cave 1',
levelTypeName: 'Act 1 - Cave',
sectionSize: 10,
sectionHeight: 10,
minRooms: 9,
merge: 0,
seed: 12345,
pieces,
maxSectionsX: 3,
maxSectionsY: 3,
}
const result = generateMaze(request)
expect(result.level.width).toBeLessThanOrEqual(30)
expect(result.level.height).toBeLessThanOrEqual(30)
expect(result.stats.maxCellsX).toBe(30)
expect(result.stats.maxCellsY).toBe(30)
expect(Number(result.stats.roomsPlaced)).toBeGreaterThanOrEqual(9)
})
test('generateMaze respects maxCellsX and maxCellsY directly', () => {
const pieces = makeCavePieceSet()
const request: MazeRequest = {
levelId: 2,
levelName: 'Act 1 - Cave 1',
levelTypeName: 'Act 1 - Cave',
sectionSize: 10,
sectionHeight: 10,
minRooms: 8,
merge: 500,
seed: 99999,
pieces,
maxCellsX: 30,
maxCellsY: 30,
}
const result = generateMaze(request)
expect(result.level.width).toBeLessThanOrEqual(30)
expect(result.level.height).toBeLessThanOrEqual(30)
expect(result.stats.maxCellsX).toBe(30)
expect(result.stats.maxCellsY).toBe(30)
})
test('forces maze to fold vertically when maxSectionsX is narrow (e.g. 2 sections wide)', () => {
const pieces = makeCavePieceSet()
const request: MazeRequest = {
levelId: 2,
levelName: 'Act 1 - Cave 1',
levelTypeName: 'Act 1 - Cave',
sectionSize: 10,
sectionHeight: 10,
minRooms: 8,
merge: 0,
seed: 42,
pieces,
maxSectionsX: 2,
maxSectionsY: 6,
}
const result = generateMaze(request)
expect(result.level.width).toBeLessThanOrEqual(20)
expect(result.level.height).toBeLessThanOrEqual(60)
expect(Number(result.stats.roomsPlaced)).toBeGreaterThanOrEqual(8)
expect(Number(result.stats.boundsRejected)).toBeGreaterThanOrEqual(1)
})
test('records boundsRejected when room growth attempts cross the configured boundary', () => {
const pieces = makeCavePieceSet()
const request: MazeRequest = {
levelId: 2,
levelName: 'Act 1 - Cave 1',
levelTypeName: 'Act 1 - Cave',
sectionSize: 10,
sectionHeight: 10,
minRooms: 9,
merge: 800,
seed: 54321,
pieces,
maxSectionsX: 3,
maxSectionsY: 3,
}
const result = generateMaze(request)
expect(result.stats.boundsRejected).toBeDefined()
expect(typeof result.stats.boundsRejected).toBe('number')
expect(result.level.width).toBeLessThanOrEqual(30)
expect(result.level.height).toBeLessThanOrEqual(30)
})
test('unconstrained maze allows wide sprawl when no bounds are specified', () => {
const pieces = makeCavePieceSet()
const request: MazeRequest = {
levelId: 2,
levelName: 'Act 1 - Cave 1',
levelTypeName: 'Act 1 - Cave',
sectionSize: 10,
sectionHeight: 10,
minRooms: 9,
merge: 0,
seed: 12345,
pieces,
}
const result = generateMaze(request)
expect(result.stats.maxCellsX).toBeUndefined()
expect(result.stats.maxCellsY).toBeUndefined()
expect(result.stats.boundsRejected).toBe(0)
expect(Number(result.stats.roomsPlaced)).toBeGreaterThanOrEqual(9)
})
})