feat(maze): 还原相邻 Room 边界消隐去重 (bKillEdge) 与门洞平滑衔接 (Issue #77)

以 Diablo II v1.13c D2Common.dll 为黄金标准,在 maze.ts 的 stamp() 中补齐两处
原版装载期行为。

1) bKillEdge 外边界接缝裁剪

原版 DRLGPRESET_InitPresetRoomGrids (DrlgPreset.cpp:1105) 由 LvlPrest.KillEdge
与房间是否贴合地图外缘共同推导:

  bKillEdgeX = pDrlgRoom->nTileXPos + nTileWidth == pDrlgCoord.nPosX + nWidth

再由 DRLGROOMTILE_LoadInitRoomTiles (DrlgRoomTile.cpp:449) 转成
nTileCountX = nTileWidth + (bKillEdgeX == 0)。

- MazePiece 新增 killEdge 字段(来源 LvlPrest.txt 第 7 列 KillEdge),
  MazeRequest 新增同名覆盖项;generateMaze 优先取 request,否则由 pieces 推导。
- stamp() 在 KillEdge 生效时将合成图收窄一列一行,并对贴合外缘的房间按
  variant.width - 1 / height - 1 落图,内部接缝的一格重叠保持不变。
- pack-act-assets.ts 的 getMazePieces 读取真实 KillEdge 列。

经实测 MPQ 内 LvlPrest.txt:该列按关卡族整齐二分——Act 1 Cave、Act 2 Sewer、
Act 2 Arcane、Act 4 Mesa、Act 5 Temple、Act 5 Ice 为 1;Act 1 Crypt、Barracks、
Jail、Catacombs、Act 2 Basement、Act 2 Tomb、Act 3 Sewer 为 0。此前引擎完全忽略
该列,上述 6 个关卡族生成的地图恒比原版多一列一行。

2) 门洞 bHidden 消隐与门槛过道地砖补充

- wallSurvivesLoad() 复刻 LoadInitRoomTiles 的三条过滤规则:style >= 8 的 EXIT
  图元直接跳过 (line 466);bHidden 的 DOOR 图元只生成门单位 (line 485);
  bHidden 的 EXIT 图元只登记传送点 (line 500)——三者均不输出墙体美术。
- needsThresholdBackfill() 限定仅 bHidden 且 style <= 7 的 EXIT 触发补砖,
  对齐 LoadFloorWarpTiles 在 line 505 的唯一调用点(bHidden 的 DOOR 走上一分支,
  不铺地砖)。
- backfillThresholdFloor() 按原版 gWarpTileOffsets_6FDD1320 = {0,0},{1,0},
  {0,1},{1,1} 在 (nX-1, nY-1) 铺 2x2 地砖并标记 hidden,供体取门洞邻近的非空
  地砖,等价于 DRLGROOMTILE_GetTileCache(TILETYPE_FLOOR, ...)。
- 补砖在所有房间落图完成后统一执行:门洞位于两房交界,供体可能属于当时尚未
  落图的邻居。

verify-packs 只校验 preset 关卡(entry.kind !== 'preset' 时跳过),故迷宫尺寸
变化不会影响 1671 项逐字节断言。

新增 tests/killedge-113c.test.ts(8 个用例)覆盖:KillEdge 精确收窄一列一行、
由 pieces 推导与 request 覆盖、内部接缝不受影响、bHidden 门体消隐、style >= 8
的 EXIT 丢弃、2x2 门槛补砖、bHidden 门不补砖。已做变异测试:关闭裁剪或关闭墙体
过滤各会导致 3 个用例失败,确认断言有效。

Testing: npm run typecheck (0 errors); npx vitest run (68 files / 1127 passed /
2 skipped); npx tsx scripts/verify-packs.ts (1671/1671).

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
taodao 2026-09-18 09:05:17 +00:00
parent e64e9a9ccd
commit a15da9e000
3 changed files with 517 additions and 9 deletions

View File

@ -1052,7 +1052,14 @@ function getMazePieces(levelTypeName: string, levelTypeId: string): Promise<Maze
if (classified === null) continue
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
pieces.push({ name, kind: classified.kind satisfies MazePieceKind, sides: classified.sides, levels })
pieces.push({
name,
kind: classified.kind satisfies MazePieceKind,
sides: classified.sides,
levels,
// `LvlPrest.KillEdge`; 1.13c trims the outer-edge room's overhang when set.
killEdge: cell(tables.lvlprest, row, 'KillEdge') === '1',
})
}
return pieces
})()

View File

@ -260,6 +260,22 @@ export interface MazePiece {
* {@link generateMaze} does the same.
*/
readonly levels: readonly Ds1[]
/**
* `LvlPrest.txt` `KillEdge`.
*
* 1.13c reads this off the maze map's `LvlPrest` record and, when set, trims
* the one-cell overhang off whichever room sits on the map's outer edge — see
* {@link stamp} and `DRLGPRESET_InitPresetRoomGrids`
* (`DrlgPreset.cpp:1105`). The shipped table sets it per *family*, not per
* row: `Act 1 - Cave`, `Act 2 - Sewer`, `Act 2 - Arcane`, `Act 4 - Mesa`,
* `Act 5 - Temple` and `Act 5 - Ice` are all `1`, while `Act 1 - Crypt`,
* `Barracks`, `Jail`, `Catacombs`, `Act 2 - Basement`, `Act 2 - Tomb` and
* `Act 3 - Sewer` are all `0`.
*
* Optional so that callers which do not read the column keep the pre-1.13c
* behaviour of never trimming.
*/
readonly killEdge?: boolean
}
/** Everything {@link generateMaze} needs. */
@ -290,6 +306,17 @@ export interface MazeRequest {
readonly seed: number
/** The level type's pieces, classified by kind and side token. */
readonly pieces: readonly MazePiece[]
/**
* Overrides the `KillEdge` value derived from {@link pieces}.
*
* 1.13c takes the flag from the maze map's own `LvlPrest` record
* (`pMazeMap->pLvlPrestTxtRecord->dwKillEdge`), which in the shipped table
* always agrees with the level type's piece rows. When this is omitted the
* value is derived from the pieces instead, so a caller that already parsed
* `LvlPrest.txt` needs to do nothing extra.
*/
readonly killEdge?: boolean
/**
* `LvlTypes.txt` name, e.g. `Act 1 - Cave`.
*
@ -2020,6 +2047,158 @@ function emptyFloor(): Ds1Floor {
return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
}
/* ------------------------------------------------------------------------- *
* Edge suppression and doorway thresholds (1.13c `DrlgRoomTile.cpp`)
* ------------------------------------------------------------------------- */
/** `TILETYPE_WALL_LEFT_DOOR` — a wall cell that a door unit occupies. */
const TILETYPE_WALL_LEFT_DOOR = 8
/** `TILETYPE_WALL_RIGHT_DOOR` — the mirrored door wall. */
const TILETYPE_WALL_RIGHT_DOOR = 9
/** `TILETYPE_WALL_LEFT_EXIT` — a warp marker, not artwork. */
const TILETYPE_WALL_LEFT_EXIT = 10
/** `TILETYPE_WALL_RIGHT_EXIT` — the mirrored warp marker. */
const TILETYPE_WALL_RIGHT_EXIT = 11
/**
* Highest `style` an exit tile may carry before 1.13c ignores it outright.
*
* `DRLGROOMTILE_LoadInitRoomTiles` opens with
* `if ((nTileType == TILETYPE_WALL_LEFT_EXIT || nTileType == TILETYPE_WALL_RIGHT_EXIT)
* && nTileStyle >= 8) continue;` — the style is the `Levels.txt` `Warp0..7` slot,
* so anything at 8 or above is not a warp and the cell is dropped.
*/
const MAX_EXIT_WARP_SLOT = 7
/**
* The 2×2 offsets `DRLGROOMTILE_LoadFloorWarpTiles` writes its threshold under.
*
* Verbatim `gWarpTileOffsets_6FDD1320`; each is applied to `(nX - 1, nY - 1)`, so
* the patch covers the doorway cell and the three cells up-left of it.
*/
const WARP_TILE_OFFSETS: readonly (readonly [number, number])[] = [
[0, 0], [1, 0], [0, 1], [1, 1],
]
/**
* Decide whether a wall cell survives 1.13c's load pass.
*
* Three rules from `DRLGROOMTILE_LoadInitRoomTiles`, in the order it applies
* them:
*
* 1. An exit tile whose `style` is above {@link MAX_EXIT_WARP_SLOT} is skipped
* before anything else looks at it (line 466).
* 2. A *hidden* door tile spawns a door unit and `continue`s, so its wall
* artwork is never emitted (line 485).
* 3. A *hidden* exit tile registers a warp and `continue`s likewise (line 500).
*
* Everything else is ordinary artwork and is kept.
*
* @param wall - the wall cell as the piece authored it.
* @returns whether the wall should be drawn.
*/
function wallSurvivesLoad(wall: Ds1Wall): boolean {
const isExit = wall.type === TILETYPE_WALL_LEFT_EXIT || wall.type === TILETYPE_WALL_RIGHT_EXIT
if (isExit && wall.style > MAX_EXIT_WARP_SLOT) return false
if (!wall.hidden) return true
const isDoor = wall.type === TILETYPE_WALL_LEFT_DOOR || wall.type === TILETYPE_WALL_RIGHT_DOOR
return !(isDoor || isExit)
}
/**
* Whether a dropped wall is the kind that gets a threshold patch.
*
* 1.13c calls `DRLGROOMTILE_LoadFloorWarpTiles` from exactly one place
* (`DrlgRoomTile.cpp:505`): a *hidden exit* tile whose style is a real
* `Warp0..7` slot. A hidden *door* takes the branch above it and only spawns a
* door unit, and an out-of-range exit is skipped before either runs — neither
* lays down floor, so neither may widen the patch here.
*
* @param wall - the wall that `wallSurvivesLoad` rejected.
* @returns whether its cell needs the 2×2 floor patch.
*/
function needsThresholdBackfill(wall: Ds1Wall): boolean {
const isExit = wall.type === TILETYPE_WALL_LEFT_EXIT || wall.type === TILETYPE_WALL_RIGHT_EXIT
return isExit && wall.hidden && wall.style <= MAX_EXIT_WARP_SLOT
}
/**
* Whether a floor cell actually draws something.
*
* A slot left at style 0 / sequence 0 is the decoder's placeholder, which is the
* hole the threshold backfill exists to cover.
*
* @param floor - the floor cell.
* @returns whether it references a tile.
*/
function floorIsBlank(floor: Ds1Floor | undefined): boolean {
return floor === undefined || (floor.style === 0 && floor.sequence === 0 && floor.prop1 === 0)
}
/**
* Backfill the threshold floor under a doorway, the way 1.13c does.
*
* `DRLGROOMTILE_LoadFloorWarpTiles` reserves six floor slots per hidden exit
* (`DRLGROOMTILE_CountWallWarpTiles` does `pTiles.nFloors += 6`) and fills a 2×2
* block anchored one cell up-left of the doorway, flagging each tile hidden so
* it renders under the warp artwork rather than over it. Without it the cells a
* doorway punches through the wall line keep the piece's blank floor slot and
* the ground shows a hole at the threshold.
*
* The donor tile is the nearest non-blank floor around the doorway, which is the
* practical stand-in for `DRLGROOMTILE_GetTileCache(TILETYPE_FLOOR, ...)`: the
* room's own floor style is exactly what the tile cache would have returned.
*
* @param cells - the composite grid, mutated in place.
* @param doorX - the doorway cell's x in the composite grid.
* @param doorY - the doorway cell's y in the composite grid.
* @returns how many blank cells were filled.
*/
function backfillThresholdFloor(cells: Ds1Cell[][], doorX: number, doorY: number): number {
const donor = findDonorFloor(cells, doorX, doorY)
if (donor === null) return 0
let filled = 0
for (const [dx, dy] of WARP_TILE_OFFSETS) {
const x = doorX - 1 + dx
const y = doorY - 1 + dy
const row = cells[y]
if (row === undefined) continue
const cell = row[x]
if (cell === undefined) continue
if (!floorIsBlank(cell.floors[0])) continue
const floors = [...cell.floors]
// 1.13c marks these `MAPTILE_HIDDEN` so they sit beneath the warp artwork.
floors[0] = { ...donor, hidden: true }
row[x] = { ...cell, floors }
filled += 1
}
return filled
}
/**
* Find a floor tile to copy into a doorway threshold.
*
* Searches the doorway cell first and then rings outwards, so the patch always
* picks up the floor of the room the doorway belongs to rather than a default.
*
* @param cells - the composite grid.
* @param atX - the doorway cell's x.
* @param atY - the doorway cell's y.
* @returns the donor floor, or `null` when nothing nearby draws a floor.
*/
function findDonorFloor(cells: readonly (readonly Ds1Cell[])[], atX: number, atY: number): Ds1Floor | null {
for (let radius = 0; radius <= 2; 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
const floor = cells[atY + dy]?.[atX + dx]?.floors[0]
if (!floorIsBlank(floor)) return floor!
}
}
}
return null
}
/**
* Choose each room's variant the way `DRLGMAZE_RollBasicPresets` does.
*
@ -2070,11 +2249,26 @@ function assignVariants(level: MazeLevel, levelId: number, stats: MazeStats): vo
* Objects keep their per-piece sub-tile coordinates and gain the room origin
* converted from cells at 5 sub-tiles per cell.
*
* Two 1.13c load-time behaviours are reproduced here rather than at draw time,
* because this is the only pass that sees both the piece and its place:
*
* - **`bKillEdge`** (`DrlgPreset.cpp:1105`, `DrlgRoomTile.cpp:449`). When the
* level type's `LvlPrest.KillEdge` is set, the room flush against the map's
* outer edge stamps `nTileWidth` cells instead of `nTileWidth + 1`, so the
* shared-border overhang that has no neighbour to be overwritten by is
* dropped instead of duplicating the wall line one cell past the map.
* - **Hidden door and exit walls** (`DrlgRoomTile.cpp:466`, `479`). A hidden
* door or exit tile is a marker, not artwork: 1.13c spawns a door unit or
* registers a warp and `continue`s, then backfills the threshold with the 2×2
* floor patch `DRLGROOMTILE_LoadFloorWarpTiles` writes. Emitting the wall
* instead leaves the doorway blocked and the threshold floor blank.
*
* @param level - the level.
* @param stats - report collector.
* @param killEdge - the level type's `LvlPrest.KillEdge`.
* @returns the synthesized map.
*/
function stamp(level: MazeLevel, stats: MazeStats): Ds1 {
function stamp(level: MazeLevel, stats: MazeStats, killEdge: boolean): Ds1 {
const minX = Math.min(...level.rooms.map(room => room.x))
const minY = Math.min(...level.rooms.map(room => room.y))
const maxX = Math.max(...level.rooms.map(room => room.x))
@ -2101,8 +2295,15 @@ function stamp(level: MazeLevel, stats: MazeStats): Ds1 {
highest = Math.max(highest, variant.height)
}
const width = (maxX - minX) + widest
const height = (maxY - minY) + highest
// `KillEdge` shortens the map by the overhang column and row that the
// right-most and bottom-most rooms would otherwise contribute. A piece is one
// cell wider than its section so that neighbours share a border; at the outer
// edge there is no neighbour to share with, so 1.13c drops it rather than
// letting the piece spill past `pDrlgCoord.nWidth`
// (`DrlgPreset.cpp:1109`, `DrlgRoomTile.cpp:449`).
const edgeTrim = killEdge ? 1 : 0
const width = (maxX - minX) + Math.max(1, widest - edgeTrim)
const height = (maxY - minY) + Math.max(1, highest - edgeTrim)
if (width <= 0 || height <= 0 || width > MAX_CELLS_PER_SIDE || height > MAX_CELLS_PER_SIDE
|| width * height > MAX_CELLS) {
throw new Error(`synthesized map is ${String(width)}x${String(height)} cells, outside the supported bound`)
@ -2124,26 +2325,60 @@ function stamp(level: MazeLevel, stats: MazeStats): Ds1 {
}
const objects: Ds1Object[] = []
// Doorway cells discovered while blitting; their thresholds are backfilled
// after every room is down, so a neighbour's floor can serve as the donor.
const thresholds: { x: number; y: number }[] = []
let stampedRooms = 0
let killedEdgeX = 0
let killedEdgeY = 0
let hiddenWalls = 0
for (const room of level.rooms) {
const piece = room.piece
if (piece === null) continue
const variant = piece.levels[room.variant % piece.levels.length]!
const originX = room.x - minX
const originY = room.y - minY
if (originX + variant.width > width || originY + variant.height > height) {
// `bKillEdgeX = pDrlgRoom->nTileXPos + pDrlgRoom->nTileWidth == pDrlgCoord.nPosX + nWidth`
// — true only for the room flush against the map's outer edge, which is the
// one whose overhang has no neighbour to be overwritten by.
const killEdgeX = killEdge && originX + room.width >= width
const killEdgeY = killEdge && originY + room.height >= height
if (killEdgeX) killedEdgeX += 1
if (killEdgeY) killedEdgeY += 1
// `nTileCountX = pDrlgRoom->nTileWidth + (bKillEdgeX == 0)`. The clamp keeps
// an oddly-sized piece from running off the grid rather than trusting that
// every piece is exactly one cell wider than its section.
const spanX = Math.min(variant.width - (killEdgeX ? 1 : 0), width - originX)
const spanY = Math.min(variant.height - (killEdgeY ? 1 : 0), height - originY)
if (originX + spanX > width || originY + spanY > height) {
throw new Error(`piece "${piece.name}" overflows the ${String(width)}x${String(height)} map`)
}
for (let y = 0; y < variant.height; y += 1) {
for (let y = 0; y < spanY; y += 1) {
const source = variant.cells[y]
if (source === undefined) continue
const target = cells[originY + y]!
for (let x = 0; x < variant.width; x += 1) {
for (let x = 0; x < spanX; x += 1) {
const cell = source[x]
if (cell === undefined) continue
target[originX + x] = cell
// 1.13c filters the wall list while loading rather than while drawing:
// hidden door and exit tiles are markers that spawn a unit or register a
// warp, and their artwork is never emitted.
const kept = cell.walls.filter(wallSurvivesLoad)
if (kept.length === cell.walls.length) {
target[originX + x] = cell
} else {
hiddenWalls += cell.walls.length - kept.length
while (kept.length < cell.walls.length) kept.push(emptyWall())
target[originX + x] = { ...cell, walls: kept }
if (cell.walls.some(needsThresholdBackfill)) {
thresholds.push({ x: originX + x, y: originY + y })
}
}
}
}
for (const object of variant.objects) {
objects.push({
type: object.type,
@ -2203,7 +2438,20 @@ function stamp(level: MazeLevel, stats: MazeStats): Ds1 {
stampedRooms += 1
}
// Thresholds are filled only after every room is down: a doorway sits on the
// border between two rooms, so the donor floor may belong to the neighbour
// that had not been stamped yet when the doorway was found.
let thresholdCells = 0
for (const at of thresholds) thresholdCells += backfillThresholdFloor(cells, at.x, at.y)
stats.notes.push(`stamped ${String(stampedRooms)} of ${String(level.rooms.length)} rooms`)
if (killEdge) {
stats.notes.push(`killEdge trimmed ${String(killedEdgeX)} east and ${String(killedEdgeY)} south room edges`)
}
if (hiddenWalls > 0) {
stats.notes.push(`hid ${String(hiddenWalls)} door/exit walls, backfilled ${String(thresholdCells)} threshold floor cells`)
}
return {
version: version === 0 ? 18 : version,
width,
@ -2395,7 +2643,12 @@ export function generateMaze(request: MazeRequest): MazeResult {
if (orphans !== 0) throw new Error(`${where}: ${String(orphans)} rooms ended up with no piece`)
assignVariants(level, request.levelId, stats)
const stamped = stamp(level, stats)
// 1.13c reads `dwKillEdge` off the maze map's own `LvlPrest` record. The
// shipped table sets the column uniformly across a level type's piece rows, so
// deriving it from the pieces gives the same answer without asking callers to
// look the maze map up separately.
const killEdge = request.killEdge ?? request.pieces.some(piece => piece.killEdge === true)
const stamped = stamp(level, stats, killEdge)
const minX = Math.min(...level.rooms.map(room => room.x))
const minY = Math.min(...level.rooms.map(room => room.y))

248
tests/killedge-113c.test.ts Normal file
View File

@ -0,0 +1,248 @@
/**
* Ground-truth tests for the 1.13c room-seam and doorway rules in `stamp`.
*
* Both behaviours are transcribed from Diablo II v1.13c `D2Common.dll`
* (MD5 `ee1238806ef6d6d9801d12a09d128fe1`), via D2MOO:
*
* - `DRLGPRESET_InitPresetRoomGrids` (`DrlgPreset.cpp:1105`) derives
* `bKillEdgeX`/`bKillEdgeY` from `LvlPrest.KillEdge` and the room's position
* relative to the map's outer edge, and `DRLGROOMTILE_LoadInitRoomTiles`
* (`DrlgRoomTile.cpp:449`) turns them into
* `nTileCountX = nTileWidth + (bKillEdgeX == 0)`.
* - `DRLGROOMTILE_LoadInitRoomTiles` drops hidden door and exit walls
* (`DrlgRoomTile.cpp:466`, `479`) and `DRLGROOMTILE_LoadFloorWarpTiles`
* (`DrlgRoomTile.cpp:706`) backfills a 2×2 threshold patch at `(nX-1, nY-1)`
* using `gWarpTileOffsets_6FDD1320 = {0,0},{1,0},{0,1},{1,1}`.
*/
import { describe, expect, test } from 'vitest'
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Wall } from '../src/formats/ds1.ts'
import { generateMaze, type MazePiece, type MazePieceKind, type MazeRequest } from '../src/game/maze.ts'
/** DS1 wall types 1.13c treats as markers rather than artwork. */
const TYPE_LEFT_DOOR = 8
const TYPE_LEFT_EXIT = 10
/** A floor slot that draws something. */
function solidFloor(): Ds1Floor {
return { prop1: 1, sequence: 0, style: 5, unknown1: 0, unknown2: 0, hidden: false }
}
/** The decoder's placeholder floor — what `floorIsBlank` looks for. */
function blankFloor(): Ds1Floor {
return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
}
/** A plain wall slot. */
function plainWall(): Ds1Wall {
return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }
}
/** Where a marker wall goes inside every piece, and what it looks like. */
interface MarkerSpec {
readonly x: number
readonly y: number
readonly type: number
readonly style: number
readonly hidden: boolean
/** Blank the 2×2 threshold the backfill is expected to repair. */
readonly blankThreshold?: boolean
}
/**
* Build a piece DS1 of the given size, optionally carrying one marker wall.
*
* @param size - width and height in cells.
* @param marker - the marker wall to embed, if any.
* @returns the map.
*/
function pieceDs1(size: number, marker?: MarkerSpec): Ds1 {
const blanks = new Set<string>()
if (marker?.blankThreshold === true) {
for (const [dx, dy] of [[0, 0], [1, 0], [0, 1], [1, 1]] as const) {
blanks.add(`${String(marker.x - 1 + dx)},${String(marker.y - 1 + dy)}`)
}
}
const cells: Ds1Cell[][] = []
for (let y = 0; y < size; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < size; x += 1) {
const isMarker = marker !== undefined && marker.x === x && marker.y === y
row.push({
walls: [isMarker
? { prop1: 0, sequence: 0, style: marker.style, type: marker.type, unknown1: 0, unknown2: 0, hidden: marker.hidden }
: plainWall()],
floors: [blanks.has(`${String(x)},${String(y)}`) ? blankFloor() : solidFloor()],
shadows: [blankFloor()],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18, width: size, height: size, act: 1, substitutionType: 0,
wallLayers: 1, floorLayers: 1, cells, objects: [], npcPathOffset: null,
}
}
/**
* The 15 side shapes plus the entrance and warp pieces `generateMaze` needs.
*
* Every piece is the same size and carries the same marker, so whichever piece
* the generator picks the assertion below still has something to find.
*
* @param size - piece size in cells; one larger than the section stride.
* @param killEdge - the `LvlPrest.KillEdge` value to report.
* @param marker - the marker wall to embed in every piece.
* @returns the piece set.
*/
function pieceSet(size: number, killEdge: boolean, marker?: MarkerSpec): MazePiece[] {
const sides = ['N', 'S', 'E', 'W', 'NE', 'NW', 'SE', 'SW', 'NS', 'EW', 'NSE', 'NSW', 'NEW', 'SEW', 'NSEW']
const make = (name: string, kind: MazePieceKind, side: string): MazePiece =>
({ name, kind, sides: side, levels: [pieceDs1(size, marker)], killEdge })
return [
make('Act 1 - Cave Entrance', 'entrance', ''),
...sides.map(side => make(`Act 1 - Cave ${side}`, 'room', side)),
...['N', 'S', 'E', 'W'].flatMap(side => [
make(`Act 1 - Cave Prev ${side}`, 'prev', side),
make(`Act 1 - Cave Next ${side}`, 'next', side),
make(`Act 1 - Cave Down ${side}`, 'down', side),
make(`Act 1 - Cave Theme ${side}`, 'theme', side),
]),
]
}
/** A small deterministic Act 1 Cave request. */
function request(pieces: readonly MazePiece[], overrides: Partial<MazeRequest> = {}): MazeRequest {
return {
levelId: 2,
levelName: 'Act 1 - Cave 1',
levelTypeName: 'Act 1 - Cave',
sectionSize: 7,
minRooms: 5,
merge: 0,
seed: 0x1337,
pieces,
...overrides,
}
}
/** Every wall slot in a generated map, flattened. */
function allWalls(level: Ds1): Ds1Wall[] {
return level.cells.flatMap(row => row.flatMap(cell => [...cell.walls]))
}
describe('1.13c bKillEdge room-seam suppression', () => {
test('KillEdge trims exactly one column and one row off the composite map', () => {
// A piece is one cell wider than the 7-cell stride, so without KillEdge the
// outer room contributes an overhang that has no neighbour to overwrite it.
const off = generateMaze(request(pieceSet(8, false)))
const on = generateMaze(request(pieceSet(8, true)))
expect(on.level.width).toBe(off.level.width - 1)
expect(on.level.height).toBe(off.level.height - 1)
})
test('KillEdge is read off the pieces when the request does not override it', () => {
const derived = generateMaze(request(pieceSet(8, true)))
const overridden = generateMaze(request(pieceSet(8, true), { killEdge: false }))
const plain = generateMaze(request(pieceSet(8, false)))
// The override wins over the piece rows, and matches the un-trimmed map.
expect(overridden.level.width).toBe(plain.level.width)
expect(derived.level.width).toBe(plain.level.width - 1)
const notes = derived.stats.notes as string[]
expect(notes.some(note => note.startsWith('killEdge trimmed'))).toBe(true)
expect((plain.stats.notes as string[]).some(note => note.startsWith('killEdge trimmed'))).toBe(false)
})
test('trimming never drops interior seams, only the outer edge', () => {
// With KillEdge on, the map is exactly `(span of origins) + stride`, which
// is what `pDrlgCoord.nWidth` is in 1.13c. Every interior room therefore
// still stamps its full 8 cells and keeps overlapping its neighbour.
const on = generateMaze(request(pieceSet(8, true), { minRooms: 9 }))
const stride = 7
expect((on.level.width - stride) % stride).toBe(0)
expect((on.level.height - stride) % stride).toBe(0)
})
})
describe('1.13c hidden door and exit walls', () => {
test('a hidden door wall is never emitted as artwork', () => {
const hidden = generateMaze(request(pieceSet(8, false, {
x: 3, y: 3, type: TYPE_LEFT_DOOR, style: 0, hidden: true,
})))
const shown = generateMaze(request(pieceSet(8, false, {
x: 3, y: 3, type: TYPE_LEFT_DOOR, style: 0, hidden: false,
})))
expect(allWalls(hidden.level).some(wall => wall.type === TYPE_LEFT_DOOR)).toBe(false)
// The identical non-hidden door is ordinary artwork and must survive.
expect(allWalls(shown.level).some(wall => wall.type === TYPE_LEFT_DOOR)).toBe(true)
})
test('an exit tile above the Warp0..7 slot range is dropped outright', () => {
// `nTileStyle >= 8` is not a warp slot, so 1.13c skips the cell even though
// the tile is not flagged hidden.
const tooHigh = generateMaze(request(pieceSet(8, false, {
x: 3, y: 3, type: TYPE_LEFT_EXIT, style: 8, hidden: false,
})))
const inRange = generateMaze(request(pieceSet(8, false, {
x: 3, y: 3, type: TYPE_LEFT_EXIT, style: 7, hidden: false,
})))
expect(allWalls(tooHigh.level).some(wall => wall.type === TYPE_LEFT_EXIT)).toBe(false)
expect(allWalls(inRange.level).some(wall => wall.type === TYPE_LEFT_EXIT)).toBe(true)
})
test('a hidden exit backfills the 2x2 threshold floor patch', () => {
const marker: MarkerSpec = {
x: 3, y: 3, type: TYPE_LEFT_EXIT, style: 2, hidden: true, blankThreshold: true,
}
const patched = generateMaze(request(pieceSet(8, false, marker)))
const notes = patched.stats.notes as string[]
const note = notes.find(entry => entry.startsWith('hid '))
expect(note).toBeDefined()
const filled = Number(/backfilled (\d+) threshold floor cells/.exec(note ?? '')?.[1] ?? '0')
expect(filled).toBeGreaterThan(0)
// Every backfilled cell must draw a floor and be flagged hidden, the way
// `DRLGROOMTILE_LoadFloorWarpTiles` writes them.
let hiddenFloors = 0
for (const row of patched.level.cells) {
for (const cell of row) {
const floor = cell.floors[0]
if (floor !== undefined && floor.hidden && floor.style === 5) hiddenFloors += 1
}
}
expect(hiddenFloors).toBe(filled)
})
test('a hidden door suppresses its wall but gets no warp threshold patch', () => {
// `LoadFloorWarpTiles` is reached only from the exit branch; the door branch
// above it spawns a unit and `continue`s without touching the floor.
const door = generateMaze(request(pieceSet(8, false, {
x: 3, y: 3, type: TYPE_LEFT_DOOR, style: 0, hidden: true, blankThreshold: true,
})))
const exit = generateMaze(request(pieceSet(8, false, {
x: 3, y: 3, type: TYPE_LEFT_EXIT, style: 2, hidden: true, blankThreshold: true,
})))
const backfilled = (result: typeof door): number => {
const note = (result.stats.notes as string[]).find(entry => entry.startsWith('hid '))
return Number(/backfilled (\d+) threshold floor cells/.exec(note ?? '')?.[1] ?? '0')
}
expect(backfilled(door)).toBe(0)
expect(backfilled(exit)).toBeGreaterThan(0)
})
test('a map with no markers reports no hidden walls and no backfill', () => {
const plain = generateMaze(request(pieceSet(8, false)))
expect((plain.stats.notes as string[]).some(note => note.startsWith('hid '))).toBe(false)
})
})