From a15da9e000c0775c4158bff36a3ca65236eaec5c Mon Sep 17 00:00:00 2001 From: taodao <47798984@qq.com> Date: Fri, 18 Sep 2026 09:05:17 +0000 Subject: [PATCH] =?UTF-8?q?feat(maze):=20=E8=BF=98=E5=8E=9F=E7=9B=B8?= =?UTF-8?q?=E9=82=BB=20Room=20=E8=BE=B9=E7=95=8C=E6=B6=88=E9=9A=90?= =?UTF-8?q?=E5=8E=BB=E9=87=8D=20(bKillEdge)=20=E4=B8=8E=E9=97=A8=E6=B4=9E?= =?UTF-8?q?=E5=B9=B3=E6=BB=91=E8=A1=94=E6=8E=A5=20(Issue=20#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以 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 --- scripts/pack-act-assets.ts | 9 +- src/game/maze.ts | 269 ++++++++++++++++++++++++++++++++++-- tests/killedge-113c.test.ts | 248 +++++++++++++++++++++++++++++++++ 3 files changed, 517 insertions(+), 9 deletions(-) create mode 100644 tests/killedge-113c.test.ts diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index 638a55f..87c3b3e 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -1052,7 +1052,14 @@ function getMazePieces(levelTypeName: string, levelTypeId: string): PromisepLvlPrestTxtRecord->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)) diff --git a/tests/killedge-113c.test.ts b/tests/killedge-113c.test.ts new file mode 100644 index 0000000..9be426f --- /dev/null +++ b/tests/killedge-113c.test.ts @@ -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() + 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 { + 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) + }) +}) +