From 0e8778f31af189bb653561768bd7b1c3534af237 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 02:51:30 +0000 Subject: [PATCH] =?UTF-8?q?feat(wilderness):=20=E8=BF=98=E5=8E=9F=20DRLGOU?= =?UTF-8?q?TPLACE=5FPlaceAct1245OutdoorBorders=20=E8=BE=B9=E7=95=8C?= =?UTF-8?q?=E5=B3=AD=E5=A3=81=E4=B8=8E=E9=97=A8=E6=B4=9E=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 扩展 classifyBorderPiece 支持 1~5 幕所有预置边界切片命名(1..12 编号、方位后缀、Act 2 峭壁、Act 5 崖壁/雪地/峡谷与过渡转角) - 导出 getCellOrientation 与 selectBorderPiece,支持精确的峭壁偏好 (preferCliff: true/false/undefined) 与开洞优先降级 - 实现并导出 DRLGOUTPLACE_PlaceAct1245OutdoorBorders(保留 layBorder 别名),先顺时针放置四边直边界再盖转角封口,保证 100% 覆盖率与 0 过程回退墙 - 支持 claimedBorderBlocks 城镇过渡区与外立面避让接驳 - 新增 tests/wilderness-borders.test.ts 完整测试全 5 幕边界识别、开闭门洞选择、外圈覆盖与接驳避让 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/wilderness.ts | 117 ++++++--- tests/wilderness-borders.test.ts | 426 +++++++++++++++++++++++++++++++ 2 files changed, 510 insertions(+), 33 deletions(-) create mode 100644 tests/wilderness-borders.test.ts diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 4c5337d..5ec1248 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -298,6 +298,10 @@ export interface WildernessRequest { * the tests and `verify-generators` use. */ readonly gates?: readonly PlannedGate[] + /** Optional forced gate variant indices per block key (`${x},${y}`). */ + readonly openGates?: ReadonlyMap + /** Optional set of block keys (`${x},${y}`) claimed by transition/facade presets to skip during border placement. */ + readonly claimedBorderBlocks?: ReadonlySet /** Optional `SuperUniques.txt` table for data-driven outdoor boss and landmark generation. */ readonly superuniques?: D2Table /** Optional `MonPreset.txt` table for data-driven DS1 spawner object IDs. */ @@ -1276,17 +1280,36 @@ export interface ClassifiedBorderPiece { * - 6 = NW corner * - 7 = NE corner * - 8 = SE corner - * - 9..12 = corner / transition variations + * - 9..12 = corner / transition variations (9=SW, 10=NW, 11=NE, 12=SE) * - * Act 3 outdoor tiles use cardinal suffixes ("Border N", "Border S", "Border NE", etc.). - * Cave entrances ("clfcave", "clfcave2") are dedicated structures, never generic border pieces. + * Act 2: + * - Desert Border 1..12 (1..4 straight edges, 5..12 corners) + * - Desert Cliff Left Wall/Path/Ends/King Tomb -> West edge + * - Desert Cliff Right Wall/Path/Ends/King Tomb -> East edge + * - Desert Cliff Top / Top King Tomb -> North edge + * + * Act 3: + * - Slums/Burbs/Metro Border N, S, E, W, NE, NW, SE, SW + * + * Act 4: + * - Mesa Border 1..12 (1..4 straight edges, 5..12 corners) + * + * Act 5: + * - Barricade Cliff Border 1..12 & Snow variants (isCliff: true) + * - Barricade Ravine Border 1..12 & Snow variants (isCliff: false) + * - Barricade Ravine-Cliff Border 5 (SW corner transition) + * - Barricade Cliff Ravine Border 7 (NE corner transition) + * - Snow Border 1..12 + * + * Cave entrances ("clfcave", "clfcave2", "Cave Right", "Cave Left") are dedicated structures, + * never generic border pieces. */ export function classifyBorderPiece(name: string): { orientation: BorderOrientation | null; isCliff: boolean } { if (/cave/i.test(name)) return { orientation: null, isCliff: false } const isCliff = /cliff|stnclf/i.test(name) // Act 3 style: "Border N", "Border S", "Border NE", etc. - const cardinal = name.match(/Border\s+([NESW]{1,2})\b/i) + const cardinal = name.match(/\b(?:border|edge|brdr|cliff)\s+([NESW]{1,2})\b/i) if (cardinal) { const dir = cardinal[1]!.toUpperCase() if (dir === 'N') return { orientation: 'north', isCliff } @@ -1299,27 +1322,45 @@ export function classifyBorderPiece(name: string): { orientation: BorderOrientat if (dir === 'SW') return { orientation: 'corner-sw', isCliff } } - // Act 1, 2, 4, 5 style: 1=S, 2=W, 3=N, 4=E, 5=SW, 6=NW, 7=NE, 8=SE - const numMatch = name.match(/(?:border|edge|brdr_?snow|brdr)\s*0?(\d+)/i) + // Act 1, 2, 4, 5 style: + // 1 = South edge + // 2 = West edge + // 3 = North edge + // 4 = East edge + // 5, 9 = SW corner + // 6, 10 = NW corner (including 6A, 6B, 6C) + // 7, 11 = NE corner + // 8, 12 = SE corner + const numMatch = name.match(/(?:border|edge|brdr_?snow|brdr|cliff|ravine)\s*0?(\d+)/i) if (numMatch) { const num = Number(numMatch[1]) if (num === 1) return { orientation: 'south', isCliff } if (num === 2) return { orientation: 'west', isCliff } if (num === 3) return { orientation: 'north', isCliff } if (num === 4) return { orientation: 'east', isCliff } - if (num === 5) return { orientation: 'corner-sw', isCliff } - if (num === 6) return { orientation: 'corner-nw', isCliff } - if (num === 7) return { orientation: 'corner-ne', isCliff } - if (num === 8) return { orientation: 'corner-se', isCliff } + if (num === 5 || num === 9) return { orientation: 'corner-sw', isCliff } + if (num === 6 || num === 10) return { orientation: 'corner-nw', isCliff } + if (num === 7 || num === 11) return { orientation: 'corner-ne', isCliff } + if (num === 8 || num === 12) return { orientation: 'corner-se', isCliff } } + // Directional names (Act 2 Desert Cliff Left/Right/Top, entrance/exit btm/top, cardinal words) + if (/\b(?:north[\s-]?west|nw)\b/i.test(name)) return { orientation: 'corner-nw', isCliff } + if (/\b(?:north[\s-]?east|ne)\b/i.test(name)) return { orientation: 'corner-ne', isCliff } + if (/\b(?:south[\s-]?west|sw)\b/i.test(name)) return { orientation: 'corner-sw', isCliff } + if (/\b(?:south[\s-]?east|se)\b/i.test(name)) return { orientation: 'corner-se', isCliff } + if (/\b(?:north|top)\b/i.test(name)) return { orientation: 'north', isCliff } + if (/\b(?:south|bottom|btm)\b/i.test(name)) return { orientation: 'south', isCliff } + if (/\b(?:east|right|rght)\b/i.test(name)) return { orientation: 'east', isCliff } + if (/\b(?:west|left)\b/i.test(name)) return { orientation: 'west', isCliff } + return { orientation: null, isCliff } } /** * Determine the perimeter orientation for a block cell on a `gridWidth x gridHeight` grid. */ -function getCellOrientation(x: number, y: number, gridWidth: number, gridHeight: number): BorderOrientation { +export function getCellOrientation(x: number, y: number, gridWidth: number, gridHeight: number): BorderOrientation { if (x === 0 && y === 0) return 'corner-nw' if (x === gridWidth - 1 && y === 0) return 'corner-ne' if (x === gridWidth - 1 && y === gridHeight - 1) return 'corner-se' @@ -1333,21 +1374,21 @@ function getCellOrientation(x: number, y: number, gridWidth: number, gridHeight: /** * Select a matching piece for the given orientation. */ -function selectBorderPiece( +export function selectBorderPiece( orientation: BorderOrientation, classified: readonly ClassifiedBorderPiece[], rng: Rng, - preferCliff: boolean, + preferCliff?: boolean, ): WildernessPiece | null { const matches = classified.filter(c => c.orientation === orientation) if (matches.length === 0) return null - if (preferCliff) { + if (preferCliff === true) { const cliffMatches = matches.filter(c => c.isCliff) if (cliffMatches.length > 0) { return cliffMatches[rng.int(0, cliffMatches.length - 1)]!.piece } - } else { + } else if (preferCliff === false) { const nonCliffMatches = matches.filter(c => !c.isCliff) if (nonCliffMatches.length > 0) { return nonCliffMatches[rng.int(0, nonCliffMatches.length - 1)]!.piece @@ -1487,11 +1528,11 @@ export function getPerimeterOpenings( * @param stats - report collector. * @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 claimedBorderBlocks - optional set of `${x},${y}` keys to skip (e.g. Town Transitions, Facades). * @param gates - optional planned gates from the world graph. * @returns the number of pieces stamped. */ -function layBorder( +export function DRLGOUTPLACE_PlaceAct1245OutdoorBorders( canvas: Canvas, pieces: readonly WildernessPiece[], gridWidth: number, @@ -1503,6 +1544,10 @@ function layBorder( claimedBorderBlocks?: ReadonlySet, gates?: readonly PlannedGate[], ): number { + if (stats.proceduralWallCells === undefined) { + stats.proceduralWallCells = 0 + } + if (pieces.length === 0) { stats.unresolved.push('no border piece for this level type') return 0 @@ -1525,18 +1570,20 @@ function layBorder( return areaA - areaB || a.name.localeCompare(b.name) }) - // Stamp straight edges first, then corners last, matching D2MOO DRLGOUTPLACE_PlaceAct1245OutdoorBorders - // where corner presets overwrite edge presets to form seamless joins. + // Stamp straight edges first in clockwise ring walk, then corners last, + // matching D2MOO DRLGOUTPLACE_PlaceAct1245OutdoorBorders where corner presets + // overwrite straight edge presets at vertices to form seamless joins. const straightCells: { x: number; y: number }[] = [] - // North edge + // North edge (moving East) for (let x = 1; x < gridWidth - 1; x += 1) straightCells.push({ x, y: 0 }) - // East edge + // East edge (moving South) for (let y = 1; y < gridHeight - 1; y += 1) straightCells.push({ x: gridWidth - 1, y }) - // South edge + // South edge (moving West) for (let x = gridWidth - 2; x >= 1; x -= 1) straightCells.push({ x, y: gridHeight - 1 }) - // West edge + // West edge (moving North) for (let y = gridHeight - 2; y >= 1; y -= 1) straightCells.push({ x: 0, y }) + // Corner join pieces stamped last: NW, NE, SE, SW const cornerCells: { x: number; y: number }[] = [ { x: 0, y: 0 }, // NW { x: gridWidth - 1, y: 0 }, // NE @@ -1561,13 +1608,16 @@ function layBorder( } const orientation = getCellOrientation(cell.x, cell.y, gridWidth, gridHeight) - // North, West, NW, NE, SW prefer cliff pieces if available (e.g. Act 1 wilderness mountain boundaries) + const isOpen = !isCorner && openings.has(key) + // North, West, NW, NE, SW prefer cliff pieces if available (e.g. Act 1 wilderness mountain boundaries), + // but openings must not prefer cliffs to ensure open gate variants are selected. const preferCliff = - orientation === 'north' || - orientation === 'west' || - orientation === 'corner-nw' || - orientation === 'corner-ne' || - orientation === 'corner-sw' + !isOpen && + (orientation === 'north' || + orientation === 'west' || + orientation === 'corner-nw' || + orientation === 'corner-ne' || + orientation === 'corner-sw') let piece = selectBorderPiece(orientation, classified, rng, preferCliff) if (!piece) { @@ -1581,7 +1631,6 @@ function layBorder( if (forcedVariant !== undefined) { level = piece.levels[Math.min(forcedVariant, piece.levels.length - 1)] } else { - const isOpen = !isCorner && openings.has(key) level = selectBorderVariant(piece, orientation, isOpen, rng) } if (level === undefined) return @@ -1607,6 +1656,8 @@ function layBorder( return stamped } +export const layBorder = DRLGOUTPLACE_PlaceAct1245OutdoorBorders + /* ------------------------------------------------------------------------- * * Act 1 Outdoor Anchors, Presets & Dirt Road Generation * (`DRLGOUTWILD_InitAct1OutdoorLevel`, `DRLGOUTDOORS_SpawnAct1DirtPaths`, @@ -5673,7 +5724,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult } // Step 4A: Lay directional border around perimeter (respecting open gates & Town Transitions) - layBorder( + DRLGOUTPLACE_PlaceAct1245OutdoorBorders( canvas, borderPieces, gridWidth, @@ -5681,8 +5732,8 @@ export function generateWilderness(request: WildernessRequest): WildernessResult rng, stats, request.levelId, - act1Plan?.openGates, - act1Plan?.claimedBorderBlocks, + request.openGates ?? act1Plan?.openGates, + request.claimedBorderBlocks ?? act1Plan?.claimedBorderBlocks, request.gates, ) diff --git a/tests/wilderness-borders.test.ts b/tests/wilderness-borders.test.ts new file mode 100644 index 0000000..1906945 --- /dev/null +++ b/tests/wilderness-borders.test.ts @@ -0,0 +1,426 @@ +import { describe, expect, test } from "vitest" +import { + classifyBorderPiece, + getCellOrientation, + selectBorderPiece, + selectBorderVariant, + isClosedBorder, + getPerimeterOpenings, + DRLGOUTPLACE_PlaceAct1245OutdoorBorders, + layBorder, + generateWilderness, + type WildernessPiece, + type WildernessStats, + type BorderOrientation, +} from "../src/game/wilderness.ts" +import { createCanvas } from "../src/game/wilderness.ts" +import { Rng } from "../src/game/rng.ts" +import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts" + +function makeMockBorderDs1(width: number, height: number, orientation: BorderOrientation, isOpen: boolean): Ds1 { + const cells: Ds1Cell[][] = [] + for (let y = 0; y < height; y += 1) { + const row: Ds1Cell[] = [] + for (let x = 0; x < width; x += 1) { + let isWall: boolean + if (isOpen) { + // Open variant: gap in the middle of the border edge + if (orientation === "south" || orientation === "north") { + isWall = x !== Math.floor(width / 2) + } else if (orientation === "west" || orientation === "east") { + isWall = y !== Math.floor(height / 2) + } else { + isWall = true + } + } else { + // Closed variant: continuous wall along the border + isWall = true + } + row.push({ + walls: isWall ? [{ prop1: 129, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }] : [], + floors: [{ prop1: 2, sequence: 0, style: 1, 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 createEmptyStats(): WildernessStats { + return { + substitutions: [], + borderPieces: {}, + unresolved: [], + notes: [], + borderStamped: 0, + groundCells: 0, + groundTile: null, + sizeSource: "test", + roadCells: 0, + roadSegments: 0, + anchors: 0, + specialPresets: [], + entrances: [], + proceduralRoadCells: 0, + proceduralDividerCells: 0, + proceduralWaterCells: 0, + proceduralWallCells: 0, + } +} + +describe("Issue #67: DRLGOUTPLACE_PlaceAct1245OutdoorBorders & Multi-Act Border Classification", () => { + describe("Task 1A: classifyBorderPiece across Acts 1, 2, 3, 4, 5", () => { + test("Act 1: Wild Border 1..12 and Wild Cliff Border 2..10 (including 6A-C and corner 10)", () => { + // Edges: 1=S, 2=W, 3=N, 4=E + expect(classifyBorderPiece("Act 1 - Wild Border 1")).toEqual({ orientation: "south", isCliff: false }) + expect(classifyBorderPiece("Act 1 - Wild Border 2")).toEqual({ orientation: "west", isCliff: false }) + expect(classifyBorderPiece("Act 1 - Wild Border 3")).toEqual({ orientation: "north", isCliff: false }) + expect(classifyBorderPiece("Act 1 - Wild Border 4")).toEqual({ orientation: "east", isCliff: false }) + + // Corners: 5/9=SW, 6/10=NW, 7/11=NE, 8/12=SE + expect(classifyBorderPiece("Act 1 - Wild Border 5")).toEqual({ orientation: "corner-sw", isCliff: false }) + expect(classifyBorderPiece("Act 1 - Wild Border 6")).toEqual({ orientation: "corner-nw", isCliff: false }) + expect(classifyBorderPiece("Act 1 - Wild Border 7")).toEqual({ orientation: "corner-ne", isCliff: false }) + expect(classifyBorderPiece("Act 1 - Wild Border 8")).toEqual({ orientation: "corner-se", isCliff: false }) + expect(classifyBorderPiece("Act 1 - Wild Border 9")).toEqual({ orientation: "corner-sw", isCliff: false }) + expect(classifyBorderPiece("Act 1 - Wild Border 10")).toEqual({ orientation: "corner-nw", isCliff: false }) + expect(classifyBorderPiece("Act 1 - Wild Border 11")).toEqual({ orientation: "corner-ne", isCliff: false }) + expect(classifyBorderPiece("Act 1 - Wild Border 12")).toEqual({ orientation: "corner-se", isCliff: false }) + + // Wild Cliff borders: isCliff: true + expect(classifyBorderPiece("Act 1 - Wild Cliff Border 2")).toEqual({ orientation: "west", isCliff: true }) + expect(classifyBorderPiece("Act 1 - Wild Cliff Border 3")).toEqual({ orientation: "north", isCliff: true }) + expect(classifyBorderPiece("Act 1 - Wild Cliff Border 5")).toEqual({ orientation: "corner-sw", isCliff: true }) + expect(classifyBorderPiece("Act 1 - Wild Cliff Border 6A")).toEqual({ orientation: "corner-nw", isCliff: true }) + expect(classifyBorderPiece("Act 1 - Wild Cliff Border 6B")).toEqual({ orientation: "corner-nw", isCliff: true }) + expect(classifyBorderPiece("Act 1 - Wild Cliff Border 6C")).toEqual({ orientation: "corner-nw", isCliff: true }) + expect(classifyBorderPiece("Act 1 - Wild Cliff Border 7")).toEqual({ orientation: "corner-ne", isCliff: true }) + expect(classifyBorderPiece("Act 1 - Wild Cliff Border 10")).toEqual({ orientation: "corner-nw", isCliff: true }) + + // Caves are excluded from generic borders + expect(classifyBorderPiece("Act 1 - Wild Cliff Cave Right")).toEqual({ orientation: null, isCliff: false }) + expect(classifyBorderPiece("Act 1 - Wild Cliff Cave Left")).toEqual({ orientation: null, isCliff: false }) + }) + + test("Act 2: Desert Border 1..12 and Desert Cliff Left/Right/Top/Tomb variants", () => { + // Desert Border 1..12 + expect(classifyBorderPiece("Act 2 - Desert Border 1")).toEqual({ orientation: "south", isCliff: false }) + expect(classifyBorderPiece("Act 2 - Desert Border 2")).toEqual({ orientation: "west", isCliff: false }) + expect(classifyBorderPiece("Act 2 - Desert Border 3")).toEqual({ orientation: "north", isCliff: false }) + expect(classifyBorderPiece("Act 2 - Desert Border 4")).toEqual({ orientation: "east", isCliff: false }) + expect(classifyBorderPiece("Act 2 - Desert Border 5")).toEqual({ orientation: "corner-sw", isCliff: false }) + expect(classifyBorderPiece("Act 2 - Desert Border 6")).toEqual({ orientation: "corner-nw", isCliff: false }) + expect(classifyBorderPiece("Act 2 - Desert Border 7")).toEqual({ orientation: "corner-ne", isCliff: false }) + expect(classifyBorderPiece("Act 2 - Desert Border 8")).toEqual({ orientation: "corner-se", isCliff: false }) + expect(classifyBorderPiece("Act 2 - Desert Border 9")).toEqual({ orientation: "corner-sw", isCliff: false }) + expect(classifyBorderPiece("Act 2 - Desert Border 10")).toEqual({ orientation: "corner-nw", isCliff: false }) + expect(classifyBorderPiece("Act 2 - Desert Border 11")).toEqual({ orientation: "corner-ne", isCliff: false }) + expect(classifyBorderPiece("Act 2 - Desert Border 12")).toEqual({ orientation: "corner-se", isCliff: false }) + + // Desert Cliff Left / Right / Top variants + expect(classifyBorderPiece("Act 2 - Desert Cliff Left Wall")).toEqual({ orientation: "west", isCliff: true }) + expect(classifyBorderPiece("Act 2 - Desert Cliff Left Path")).toEqual({ orientation: "west", isCliff: true }) + expect(classifyBorderPiece("Act 2 - Desert Cliff Left Ends")).toEqual({ orientation: "west", isCliff: true }) + expect(classifyBorderPiece("Act 2 - Desert Cliff Left King Tomb")).toEqual({ orientation: "west", isCliff: true }) + expect(classifyBorderPiece("Act 2 - Desert Cliff Right Wall")).toEqual({ orientation: "east", isCliff: true }) + expect(classifyBorderPiece("Act 2 - Desert Cliff Right Path")).toEqual({ orientation: "east", isCliff: true }) + expect(classifyBorderPiece("Act 2 - Desert Cliff Right Ends")).toEqual({ orientation: "east", isCliff: true }) + expect(classifyBorderPiece("Act 2 - Desert Cliff Right King Tomb")).toEqual({ orientation: "east", isCliff: true }) + expect(classifyBorderPiece("Act 2 - Desert Cliff Top")).toEqual({ orientation: "north", isCliff: true }) + expect(classifyBorderPiece("Act 2 - Desert Cliff Top King Tomb")).toEqual({ orientation: "north", isCliff: true }) + }) + + test("Act 3: Slums/Burbs/Metro Border cardinal directions (N, S, E, W, NE, NW, SE, SW)", () => { + expect(classifyBorderPiece("Act 3 - Slums Border N")).toEqual({ orientation: "north", isCliff: false }) + expect(classifyBorderPiece("Act 3 - Slums Border S")).toEqual({ orientation: "south", isCliff: false }) + expect(classifyBorderPiece("Act 3 - Slums Border E")).toEqual({ orientation: "east", isCliff: false }) + expect(classifyBorderPiece("Act 3 - Slums Border W")).toEqual({ orientation: "west", isCliff: false }) + expect(classifyBorderPiece("Act 3 - Slums Border NE")).toEqual({ orientation: "corner-ne", isCliff: false }) + expect(classifyBorderPiece("Act 3 - Slums Border NW")).toEqual({ orientation: "corner-nw", isCliff: false }) + expect(classifyBorderPiece("Act 3 - Slums Border SE")).toEqual({ orientation: "corner-se", isCliff: false }) + expect(classifyBorderPiece("Act 3 - Slums Border SW")).toEqual({ orientation: "corner-sw", isCliff: false }) + + expect(classifyBorderPiece("Act 3 - Burbs Border N")).toEqual({ orientation: "north", isCliff: false }) + expect(classifyBorderPiece("Act 3 - Burbs Border SW")).toEqual({ orientation: "corner-sw", isCliff: false }) + + expect(classifyBorderPiece("Act 3 - Metro Border E")).toEqual({ orientation: "east", isCliff: false }) + expect(classifyBorderPiece("Act 3 - Metro Border SE")).toEqual({ orientation: "corner-se", isCliff: false }) + }) + + test("Act 4: Mesa Border 1..12", () => { + expect(classifyBorderPiece("Act 4 - Mesa Border 1")).toEqual({ orientation: "south", isCliff: false }) + expect(classifyBorderPiece("Act 4 - Mesa Border 2")).toEqual({ orientation: "west", isCliff: false }) + expect(classifyBorderPiece("Act 4 - Mesa Border 3")).toEqual({ orientation: "north", isCliff: false }) + expect(classifyBorderPiece("Act 4 - Mesa Border 4")).toEqual({ orientation: "east", isCliff: false }) + expect(classifyBorderPiece("Act 4 - Mesa Border 5")).toEqual({ orientation: "corner-sw", isCliff: false }) + expect(classifyBorderPiece("Act 4 - Mesa Border 6")).toEqual({ orientation: "corner-nw", isCliff: false }) + expect(classifyBorderPiece("Act 4 - Mesa Border 7")).toEqual({ orientation: "corner-ne", isCliff: false }) + expect(classifyBorderPiece("Act 4 - Mesa Border 8")).toEqual({ orientation: "corner-se", isCliff: false }) + expect(classifyBorderPiece("Act 4 - Mesa Border 9")).toEqual({ orientation: "corner-sw", isCliff: false }) + expect(classifyBorderPiece("Act 4 - Mesa Border 10")).toEqual({ orientation: "corner-nw", isCliff: false }) + expect(classifyBorderPiece("Act 4 - Mesa Border 11")).toEqual({ orientation: "corner-ne", isCliff: false }) + expect(classifyBorderPiece("Act 4 - Mesa Border 12")).toEqual({ orientation: "corner-se", isCliff: false }) + }) + + test("Act 5: Barricade Cliff Border 1..12, Barricade Ravine Border 1..6, and Snow variants", () => { + // Barricade Cliff: isCliff: true + expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 1")).toEqual({ orientation: "south", isCliff: true }) + expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 2")).toEqual({ orientation: "west", isCliff: true }) + expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 3")).toEqual({ orientation: "north", isCliff: true }) + expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 4")).toEqual({ orientation: "east", isCliff: true }) + expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 5")).toEqual({ orientation: "corner-sw", isCliff: true }) + expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 6")).toEqual({ orientation: "corner-nw", isCliff: true }) + expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 7")).toEqual({ orientation: "corner-ne", isCliff: true }) + expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 8")).toEqual({ orientation: "corner-se", isCliff: true }) + expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 9")).toEqual({ orientation: "corner-sw", isCliff: true }) + expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 10")).toEqual({ orientation: "corner-nw", isCliff: true }) + expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 11")).toEqual({ orientation: "corner-ne", isCliff: true }) + expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 12")).toEqual({ orientation: "corner-se", isCliff: true }) + + // Barricade Ravine: isCliff: false + expect(classifyBorderPiece("Act 5 - Barricade Ravine Border 1")).toEqual({ orientation: "south", isCliff: false }) + expect(classifyBorderPiece("Act 5 - Barricade Ravine Border 2")).toEqual({ orientation: "west", isCliff: false }) + expect(classifyBorderPiece("Act 5 - Barricade Ravine Border 3")).toEqual({ orientation: "north", isCliff: false }) + expect(classifyBorderPiece("Act 5 - Barricade Ravine Border 4")).toEqual({ orientation: "east", isCliff: false }) + expect(classifyBorderPiece("Act 5 - Barricade Ravine Border 5")).toEqual({ orientation: "corner-sw", isCliff: false }) + expect(classifyBorderPiece("Act 5 - Barricade Ravine Border 6")).toEqual({ orientation: "corner-nw", isCliff: false }) + + // Corner join / transition pieces + expect(classifyBorderPiece("Act 5 - Barricade Ravine-Cliff Border 5")).toEqual({ orientation: "corner-sw", isCliff: true }) + expect(classifyBorderPiece("Act 5 - Barricade Cliff Ravine Border 7")).toEqual({ orientation: "corner-ne", isCliff: true }) + + // Snow variants + expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 1 Snow")).toEqual({ orientation: "south", isCliff: true }) + expect(classifyBorderPiece("Act 5 - Barricade Ravine Border 1 Snow")).toEqual({ orientation: "south", isCliff: false }) + expect(classifyBorderPiece("Act 5 - Snow Border 1")).toEqual({ orientation: "south", isCliff: false }) + expect(classifyBorderPiece("Act 5 - Snow Border 2")).toEqual({ orientation: "west", isCliff: false }) + expect(classifyBorderPiece("Act 5 - Snow Border 3")).toEqual({ orientation: "north", isCliff: false }) + expect(classifyBorderPiece("Act 5 - Snow Border 4")).toEqual({ orientation: "east", isCliff: false }) + expect(classifyBorderPiece("Act 5 - Snow Border 6")).toEqual({ orientation: "corner-nw", isCliff: false }) + }) + }) + + describe("Task 1B: selectBorderPiece cliff preference", () => { + test("prefers cliff pieces when preferCliff is true and non-cliff when false", () => { + const cliffPiece: WildernessPiece = { + name: "Act 1 - Wild Cliff Border 3", + border: true, + levels: [makeMockBorderDs1(8, 8, "north", false)], + } + const fencePiece: WildernessPiece = { + name: "Act 1 - Wild Border 3", + border: true, + levels: [makeMockBorderDs1(8, 8, "north", false)], + } + const classified = [ + { piece: cliffPiece, orientation: "north" as const, isCliff: true }, + { piece: fencePiece, orientation: "north" as const, isCliff: false }, + ] + const rng = new Rng(100) + + const pickedCliff = selectBorderPiece("north", classified, rng, true) + expect(pickedCliff?.name).toBe("Act 1 - Wild Cliff Border 3") + + const pickedFence = selectBorderPiece("north", classified, rng, false) + expect(pickedFence?.name).toBe("Act 1 - Wild Border 3") + }) + + test("falls back gracefully when only one cliff status is available", () => { + const fencePiece: WildernessPiece = { + name: "Act 4 - Mesa Border 3", + border: true, + levels: [makeMockBorderDs1(8, 8, "north", false)], + } + const classified = [{ piece: fencePiece, orientation: "north" as const, isCliff: false }] + const rng = new Rng(200) + + // Even if preferCliff is true, falls back to fencePiece + const picked = selectBorderPiece("north", classified, rng, true) + expect(picked?.name).toBe("Act 4 - Mesa Border 3") + }) + }) + + describe("Task 1C & 2: DRLGOUTPLACE_PlaceAct1245OutdoorBorders & layBorder outer ring coverage", () => { + function buildMultiActPieces(prefix: string, hasCliffs = false): WildernessPiece[] { + const pieces: WildernessPiece[] = [] + // Numbers 1..8 + for (let i = 1; i <= 8; i += 1) { + const orientation = i === 1 ? "south" : i === 2 ? "west" : i === 3 ? "north" : i === 4 ? "east" : + i === 5 ? "corner-sw" : i === 6 ? "corner-nw" : i === 7 ? "corner-ne" : "corner-se" + const closedDs1 = makeMockBorderDs1(8, 8, orientation, false) + const openDs1 = makeMockBorderDs1(8, 8, orientation, true) + pieces.push({ + name: `${prefix} Border ${i}`, + border: true, + // 4 variants: 0..2 closed, 3 open + levels: [closedDs1, closedDs1, closedDs1, openDs1], + }) + } + if (hasCliffs) { + // Add cliff pieces for north and west + pieces.push({ + name: `${prefix} Cliff Border 2`, + border: true, + levels: [makeMockBorderDs1(8, 8, "west", false)], + }) + pieces.push({ + name: `${prefix} Cliff Border 3`, + border: true, + levels: [makeMockBorderDs1(8, 8, "north", false)], + }) + } + return pieces + } + + test.each([ + { act: 1, name: "Act 1 - Wild", hasCliffs: true }, + { act: 2, name: "Act 2 - Desert", hasCliffs: false }, + { act: 4, name: "Act 4 - Mesa", hasCliffs: false }, + { act: 5, name: "Act 5 - Barricade", hasCliffs: true }, + ])("Act $act ($name): 100% outer ring coverage with 0 procedural fallback walls", ({ name, hasCliffs }) => { + const gridWidth = 8 + const gridHeight = 8 + const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 1, 1, 0) + const pieces = buildMultiActPieces(name, hasCliffs) + const stats = createEmptyStats() + const rng = new Rng(42) + + const stamped = DRLGOUTPLACE_PlaceAct1245OutdoorBorders( + canvas, + pieces, + gridWidth, + gridHeight, + rng, + stats, + 2, // Blood Moor level ID with South and East openings + ) + + // Expected ring block count: 2 * (w + h) - 4 = 2 * (8 + 8) - 4 = 28 blocks + const expectedRingBlocks = 2 * (gridWidth + gridHeight) - 4 + expect(stamped).toBe(expectedRingBlocks) + expect(stats.borderStamped).toBe(expectedRingBlocks) + expect(stats.proceduralWallCells).toBe(0) + + // Verify every perimeter cell on the canvas has floor and valid wall placement + for (let x = 0; x < canvas.width; x += 1) { + // North edge (y=0) and South edge (y=canvas.height-1) + expect(canvas.cells[0]?.[x]?.floors.length).toBeGreaterThan(0) + expect(canvas.cells[canvas.height - 1]?.[x]?.floors.length).toBeGreaterThan(0) + } + for (let y = 0; y < canvas.height; y += 1) { + // West edge (x=0) and East edge (x=canvas.width-1) + expect(canvas.cells[y]?.[0]?.floors.length).toBeGreaterThan(0) + expect(canvas.cells[y]?.[canvas.width - 1]?.floors.length).toBeGreaterThan(0) + } + }) + + test("Opening blocks select Open DS1 variants while closed blocks select Closed DS1 variants", () => { + const gridWidth = 8 + const gridHeight = 8 + const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 1, 1, 0) + const pieces = buildMultiActPieces("Act 1 - Wild", true) + const stats = createEmptyStats() + const rng = new Rng(12345) + + // Level 2 has default entrance on South edge (mid-X, y=7) and East edge (x=7, mid-Y) + DRLGOUTPLACE_PlaceAct1245OutdoorBorders(canvas, pieces, gridWidth, gridHeight, rng, stats, 2) + + const midX = Math.floor(gridWidth / 2) // block (4, 7) + const midY = Math.floor(gridHeight / 2) // block (7, 4) + + // South opening block: cell (4*8 + 4, 7*8 + y) should have the open gateway gap + const southOpenCenterCell = canvas.cells[7 * 8 + 4]?.[midX * 8 + 4] + expect(southOpenCenterCell).toBeDefined() + // Gateway center in open variant has no wall + expect(southOpenCenterCenterWallCount(canvas, midX * 8, 7 * 8, 8, 8)).toBe(false) + + // Closed South block: cell (1*8, 7*8) should have solid continuous wall + expect(isBlockClosed(canvas, 1 * 8, 7 * 8, 8, 8)).toBe(true) + }) + + function southOpenCenterCenterWallCount(canvas: any, bx: number, by: number, w: number, h: number): boolean { + const centerX = bx + Math.floor(w / 2) + // Check if all cells in column centerX of this block are walls + for (let y = 0; y < h; y += 1) { + if (!canvas.cells[by + y]?.[centerX]?.walls.some((w: any) => !w.hidden && w.prop1 !== 0)) { + return false // There is an open path through this column! + } + } + return true + } + + function isBlockClosed(canvas: any, bx: number, by: number, w: number, h: number): boolean { + for (let y = 0; y < h; y += 1) { + let hasWallRow = true + for (let x = 0; x < w; x += 1) { + if (!canvas.cells[by + y]?.[bx + x]?.walls.some((w: any) => !w.hidden && w.prop1 !== 0)) { + hasWallRow = false + break + } + } + if (hasWallRow) return true + } + return false + } + + test("Docking support: claimedBorderBlocks are skipped during border stamping", () => { + const gridWidth = 8 + const gridHeight = 8 + const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 1, 1, 0) + const pieces = buildMultiActPieces("Act 1 - Wild", false) + const stats = createEmptyStats() + const rng = new Rng(777) + + // Claim 3 blocks along the West edge for a Town Transition / Facade docking + const claimed = new Set(["0,2", "0,3", "0,4"]) + + const stamped = DRLGOUTPLACE_PlaceAct1245OutdoorBorders( + canvas, + pieces, + gridWidth, + gridHeight, + rng, + stats, + 2, + undefined, + claimed, + ) + + // 28 total perimeter blocks - 3 claimed blocks = 25 stamped blocks + expect(stamped).toBe(25) + expect(stats.borderStamped).toBe(25) + + // Verified claimed blocks were skipped: canvas floor/walls at block (0, 3) were NOT painted by border + expect(canvas.cells[3 * 8]?.[0]?.walls[0]?.style).toBe(0) + expect(canvas.cells[1 * 8]?.[0]?.walls[0]?.style).toBe(1) + }) + + test("layBorder is an exported alias for DRLGOUTPLACE_PlaceAct1245OutdoorBorders", () => { + expect(layBorder).toBe(DRLGOUTPLACE_PlaceAct1245OutdoorBorders) + }) + + test("getCellOrientation correctly maps 4 corners and 4 edges", () => { + expect(getCellOrientation(0, 0, 10, 10)).toBe("corner-nw") + expect(getCellOrientation(9, 0, 10, 10)).toBe("corner-ne") + expect(getCellOrientation(9, 9, 10, 10)).toBe("corner-se") + expect(getCellOrientation(0, 9, 10, 10)).toBe("corner-sw") + expect(getCellOrientation(5, 0, 10, 10)).toBe("north") + expect(getCellOrientation(9, 5, 10, 10)).toBe("east") + expect(getCellOrientation(5, 9, 10, 10)).toBe("south") + expect(getCellOrientation(0, 5, 10, 10)).toBe("west") + }) + }) +})