From d7defd8da37e1531b125718c85407814ef798434 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 03:09:19 +0000 Subject: [PATCH] =?UTF-8?q?feat(wilderness):=20=E8=BF=98=E5=8E=9F=20DRLGOU?= =?UTF-8?q?TDOORS=5FSpawnAct12Waypoint=20=E5=B0=8F=E7=AB=99=E4=B8=8E?= =?UTF-8?q?=E7=A5=9E=E6=AE=BF=E6=8B=93=E6=89=91=E6=94=BE=E7=BD=AE=E7=AE=97?= =?UTF-8?q?=E6=B3=95=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 src/game/wilderness.ts 中实现并导出 DRLGOUTDOORS_SpawnAct12Waypoint 与 DRLGOUTDOORS_SpawnAct12Shrines - 确立宏拓扑三级候选优先级:预置小站对象 (Priority 1) -> 沿样条道路路旁空地 (Priority 2) -> 远离峭壁的营地空地 (Priority 3) - 实现 validateWaypointLandingBuffer 确保小站 2-tile 落地安全缓冲区无障碍物与墙体 - 神殿沿次级探索支路离散分布,规避主道扎堆并维持 >= 10 tiles 间距 - 重构 src/game/level-links.ts 中 findWaypointSpot / DRLGOUTDOORS_SpawnAct12Waypoint,采用切比雪夫距离变换评估 >= 3x3 净空与拓扑开阔度,剔除狭窄走廊与死胡同 - 在 tests/wilderness-waypoints-shrines.test.ts 补充拓扑选址与落地安全区验证测试 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/level-links.ts | 122 +++++++++- src/game/wilderness.ts | 145 +++++++---- tests/wilderness-waypoints-shrines.test.ts | 265 +++++++++++++++++++++ 3 files changed, 473 insertions(+), 59 deletions(-) diff --git a/src/game/level-links.ts b/src/game/level-links.ts index 66bb7e0..b020089 100644 --- a/src/game/level-links.ts +++ b/src/game/level-links.ts @@ -516,20 +516,120 @@ export function findBorderOpening( } /** - * Choose somewhere to stand a waypoint. + * Choose a topologically suitable spot to place an outdoor or indoor waypoint + * (`DRLGOUTDOORS_SpawnAct12Waypoint`). * - * A stand-in for `DRLGOUTDOORS_SpawnAct12Waypoint`, which decides this properly - * from the level's room list and is not implemented. Until it is, the middle of - * the level is the least surprising answer: it is roughly where the roads meet, - * it is reachable from every seam, and it does not collide with the border - * presets the way a corner would. + * Evaluates candidate clearance (at least 3x3 sub-tiles of clear walkable space) + * and topological suitability, avoiding map edges and narrow dead-ends. * * @param grid - the collision map. * @param region - when given, only sub-tiles inside it count. - * @returns the sub-tile, or null when the level has no open ground at all. + * @param options - optional clearance and edge margin thresholds. + * @returns the chosen sub-tile coordinate, or null when the level has no open ground at all. */ -export function findWaypointSpot(grid: LinkGrid, region?: WalkableRegion): { x: number; y: number } | null { - const centreX = Math.floor(grid.gridWidth / 2) - const centreY = Math.floor(grid.gridHeight / 2) - return nearestWalkable(grid, centreX, centreY, Math.max(grid.gridWidth, grid.gridHeight), region) +export function findWaypointSpot( + grid: LinkGrid, + region?: WalkableRegion, + options?: { + minClearance?: number + edgeMargin?: number + }, +): { x: number; y: number } | null { + const W = grid.gridWidth + const H = grid.gridHeight + if (W <= 0 || H <= 0) return null + + const centreX = Math.floor(W / 2) + const centreY = Math.floor(H / 2) + + // Distance transform: calculate clearance (Chebyshev distance to blocked / non-region tile) + // dist = 1 means 1x1 walkable, dist = 2 means 3x3 walkable, dist = 3 means 5x5 walkable, etc. + const dist = new Int32Array(W * H) + for (let y = 0; y < H; y += 1) { + for (let x = 0; x < W; x += 1) { + dist[y * W + x] = inRegion(grid, region, x, y) ? 999999 : 0 + } + } + + // Forward pass + for (let y = 0; y < H; y += 1) { + for (let x = 0; x < W; x += 1) { + const idx = y * W + x + if (dist[idx] === 0) continue + let minVal = dist[idx] + if (y > 0 && x > 0) minVal = Math.min(minVal, dist[(y - 1) * W + (x - 1)] + 1) + else minVal = 1 + if (y > 0) minVal = Math.min(minVal, dist[(y - 1) * W + x] + 1) + else minVal = 1 + if (y > 0 && x < W - 1) minVal = Math.min(minVal, dist[(y - 1) * W + (x + 1)] + 1) + else minVal = 1 + if (x > 0) minVal = Math.min(minVal, dist[y * W + (x - 1)] + 1) + else minVal = 1 + + dist[idx] = minVal + } + } + + // Backward pass + for (let y = H - 1; y >= 0; y -= 1) { + for (let x = W - 1; x >= 0; x -= 1) { + const idx = y * W + x + if (dist[idx] === 0) continue + let minVal = dist[idx] + if (x < W - 1) minVal = Math.min(minVal, dist[y * W + (x + 1)] + 1) + else minVal = 1 + if (y < H - 1 && x > 0) minVal = Math.min(minVal, dist[(y + 1) * W + (x - 1)] + 1) + else minVal = 1 + if (y < H - 1) minVal = Math.min(minVal, dist[(y + 1) * W + x] + 1) + else minVal = 1 + if (y < H - 1 && x < W - 1) minVal = Math.min(minVal, dist[(y + 1) * W + (x + 1)] + 1) + else minVal = 1 + + dist[idx] = minVal + } + } + + // Required clearance: default 2 (meaning >= 3x3 clear sub-tiles) + const reqClearance = options?.minClearance !== undefined + ? Math.max(1, options.minClearance >= 3 && options.minClearance % 2 === 1 ? Math.floor(options.minClearance / 2) + 1 : options.minClearance) + : 2 + + // Default edge margin: at least 3, up to 15 or 1/8 of level + const defaultMargin = Math.min(15, Math.max(3, Math.floor(Math.min(W, H) / 8))) + const edgeMargin = options?.edgeMargin ?? defaultMargin + + // Candidate search: try with edgeMargin first, then relax if no candidates found + const marginsToTry = [edgeMargin, Math.min(3, edgeMargin), 1] + const clearancesToTry = [Math.max(reqClearance, 3), reqClearance, 1] + + for (const minC of clearancesToTry) { + for (const m of marginsToTry) { + let bestSpot: { x: number; y: number } | null = null + let bestScore = -Infinity + + for (let y = m; y < H - m; y += 1) { + for (let x = m; x < W - m; x += 1) { + const c = dist[y * W + x]! + if (c < minC) continue + + // Topological suitability score: prioritize higher clearance, penalize distance to map center + const distToCenter = Math.hypot(x - centreX, y - centreY) + const score = c * 1000 - distToCenter + + if (score > bestScore) { + bestScore = score + bestSpot = { x, y } + } + } + } + + if (bestSpot !== null) { + return bestSpot + } + } + } + + return nearestWalkable(grid, centreX, centreY, Math.max(W, H), region) } + +export const DRLGOUTDOORS_SpawnAct12Waypoint = findWaypointSpot diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 5ec1248..f9a38d3 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -4374,13 +4374,57 @@ function isNearDirtRoad( return minDistFound >= minDist && minDistFound <= maxDist } +function validateWaypointLandingBuffer( + canvas: Canvas, + tx: number, + ty: number, + placedRects: readonly PlacedPresetRect[], + padding = 2, +): boolean { + if (tx < padding || ty < padding || tx >= canvas.width - padding || ty >= canvas.height - padding) { + return false + } + + for (const r of placedRects) { + const isOpenPreset = /(clearing|camp|bivouac)/i.test(r.name) + if (!isOpenPreset) { + if (tx >= r.x - padding && tx < r.x + r.w + padding && ty >= r.y - padding && ty < r.y + r.h + padding) { + return false + } + } + } + + for (let dy = -padding; dy <= padding; dy += 1) { + const row = canvas.cells[ty + dy] + if (!row) return false + for (let dx = -padding; dx <= padding; dx += 1) { + const cell = row[tx + dx] + if (!cell) return false + if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) return false + if (!cell.floors.some(f => !f.hidden && f.prop1 !== 0)) return false + } + } + + for (const obj of canvas.objects) { + const ox = Math.floor(obj.x / SUB_TILES_PER_TILE) + const oy = Math.floor(obj.y / SUB_TILES_PER_TILE) + if (Math.abs(ox - tx) <= padding && Math.abs(oy - ty) <= padding) { + return false + } + } + + return true +} + /** * Automatically locate and place an outdoor Waypoint (`DRLGOUTDOORS_SpawnAct12Waypoint`) - * supporting authentic thematic clustering across Acts 1..5. + * supporting authentic topological candidate evaluation across Acts 1..5. * - * In roadless wilderness (e.g. Far Oasis level 43, Flayer Jungle level 78, Arreat Plateau level 112), - * waypoints prioritize proximity to clearings, ruins, oases, or the central hub. - * In roadside wilderness, roadside candidates near landmarks or center are preferred. + * Evaluates candidates by strict macro topology priority: + * Priority 1: DS1 presets containing dedicated waypoint objects. + * Priority 2: Clearings directly adjacent to the main spline road with safe landing buffer (>= 2 tiles clear perimeter). + * Priority 3: Open campsite / clearing rooms safely distanced from border cliffs (>= 2 tiles clear perimeter). + * Guarantees player landing buffer (free of walls and collision obstacles). */ export function spawnOutdoorWaypoint( canvas: Canvas, @@ -4395,7 +4439,7 @@ export function spawnOutdoorWaypoint( ): void { if (!hasOutdoorWaypoint(levelId, levelsTable)) return - // Check if a preset already placed a Waypoint object + // Priority 1: Check if a preset already placed a Waypoint object const ALL_WAYPOINT_IDS = new Set([37, 52, 269, 10, 306, 1, 387, 0, 388, 12, 188]) const existingWp = canvas.objects.find( o => o.type === 2 && ALL_WAYPOINT_IDS.has(o.id), @@ -4414,8 +4458,8 @@ export function spawnOutdoorWaypoint( const wpId = config.waypointId const isJungle = levelId >= 76 && levelId <= 78 - const marginX = isJungle ? 4 : Math.min(16, Math.floor(canvas.width / 4)) - const marginY = Math.min(16, Math.floor(canvas.height / 4)) + const marginX = isJungle ? 4 : Math.min(16, Math.max(8, Math.floor(canvas.width / 4))) + const marginY = Math.min(16, Math.max(8, Math.floor(canvas.height / 4))) const cx = isJungle ? 16 : Math.floor(canvas.width / 2) const cy = Math.floor(canvas.height / 2) const hubRadius = Math.max(8, Math.floor(Math.min(canvas.width, canvas.height) / 3)) @@ -4423,75 +4467,71 @@ export function spawnOutdoorWaypoint( const LANDMARK_REGEX = /(clearing|ruin|oasis|cottage|camp|building|temple|tomb|bivouac|shrine)/i const landmarkRects = placedRects.filter(r => LANDMARK_REGEX.test(r.name)) - const clearingCandidates: { x: number; y: number }[] = [] - const centralHubCandidates: { x: number; y: number }[] = [] - const roadsideCandidates: { x: number; y: number }[] = [] - const openCandidates: { x: number; y: number }[] = [] - const isNearLandmark = (tx: number, ty: number): boolean => { for (const r of landmarkRects) { const dx = Math.max(r.x - tx, 0, tx - (r.x + r.w - 1)) const dy = Math.max(r.y - ty, 0, ty - (r.y + r.h - 1)) const d = Math.max(dx, dy) - if (d >= 1 && d <= 7) return true + if (d <= 7) return true } // For Act 3 Jungle clearings (when stamped without placedRects) - if (isJungle && landmarkRects.length === 0) { + if (isJungle && (landmarkRects.length === 0 || tx < 32)) { if (tx >= 4 && tx < 30 && ty >= 32 && ty < 160) return true } return false } + const roadsideClearings: { x: number; y: number }[] = [] + const campsiteCandidates: { x: number; y: number }[] = [] + const centralHubCandidates: { x: number; y: number }[] = [] + const openCandidates: { x: number; y: number }[] = [] + + // Candidate scan with >= 2 tiles clear perimeter landing safety buffer for (let y = marginY; y < canvas.height - marginY; y += 2) { for (let x = marginX; x < canvas.width - marginX; x += 2) { - if (!isTileClear(canvas, x, y, placedRects, 2)) continue + if (!validateWaypointLandingBuffer(canvas, x, y, placedRects, 2)) continue const nearLandmark = isNearLandmark(x, y) const nearRoad = isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, x, y, 2, 6) const inCentralHub = Math.hypot(x - cx, y - cy) <= hubRadius - if (nearLandmark) clearingCandidates.push({ x, y }) - if (nearRoad) roadsideCandidates.push({ x, y }) + if (nearRoad) roadsideClearings.push({ x, y }) + if (nearLandmark) campsiteCandidates.push({ x, y }) if (inCentralHub) centralHubCandidates.push({ x, y }) openCandidates.push({ x, y }) } } + // Fallback to padding = 1 if constrained if (openCandidates.length === 0) { for (let y = marginY; y < canvas.height - marginY; y += 1) { for (let x = marginX; x < canvas.width - marginX; x += 1) { - if (isTileClear(canvas, x, y, placedRects, 1)) { - openCandidates.push({ x, y }) - if (isNearLandmark(x, y)) clearingCandidates.push({ x, y }) - if (Math.hypot(x - cx, y - cy) <= hubRadius) centralHubCandidates.push({ x, y }) - } + if (!validateWaypointLandingBuffer(canvas, x, y, placedRects, 1)) continue + + const nearLandmark = isNearLandmark(x, y) + const nearRoad = isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, x, y, 2, 6) + const inCentralHub = Math.hypot(x - cx, y - cy) <= hubRadius + + if (nearRoad) roadsideClearings.push({ x, y }) + if (nearLandmark) campsiteCandidates.push({ x, y }) + if (inCentralHub) centralHubCandidates.push({ x, y }) + openCandidates.push({ x, y }) } } } let pool: { x: number; y: number }[] - if (dirtPathGrid !== undefined) { - const landmarkRoad = clearingCandidates.filter(c => isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, c.x, c.y, 2, 6)) - if (landmarkRoad.length > 0) { - pool = landmarkRoad - } else if (clearingCandidates.length > 0) { - pool = clearingCandidates - } else if (roadsideCandidates.length > 0) { - const roadHub = roadsideCandidates.filter(c => Math.hypot(c.x - cx, c.y - cy) <= hubRadius) - pool = roadHub.length > 0 ? roadHub : roadsideCandidates - } else if (centralHubCandidates.length > 0) { - pool = centralHubCandidates - } else { - pool = openCandidates - } + if (dirtPathGrid !== undefined && roadsideClearings.length > 0) { + // Priority 2: Clearings directly adjacent to the main spline road + const roadsideLandmarks = roadsideClearings.filter(c => isNearLandmark(c.x, c.y)) + pool = roadsideLandmarks.length > 0 ? roadsideLandmarks : roadsideClearings + } else if (campsiteCandidates.length > 0) { + // Priority 3: Open campsite / clearing blocks safely distanced from border cliffs + pool = campsiteCandidates + } else if (centralHubCandidates.length > 0) { + pool = centralHubCandidates } else { - if (clearingCandidates.length > 0) { - pool = clearingCandidates - } else if (centralHubCandidates.length > 0) { - pool = centralHubCandidates - } else { - pool = openCandidates - } + pool = openCandidates } const chosen = pool.length > 0 @@ -4598,8 +4638,13 @@ export function spawnOutdoorShrines( } } - const pickShrineSpot = (minDist: number): { x: number; y: number } | null => { - const indices = Array.from({ length: candidates.length }, (_, i) => i) + // Secondary exploration paths: avoid the main road and its immediate shoulder (within 3 tiles) + const secondaryCandidates = dirtPathGrid !== undefined + ? candidates.filter(c => !isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, c.x, c.y, 0, 3)) + : candidates + + const pickShrineSpot = (candidateList: readonly { x: number; y: number }[], minDist: number): { x: number; y: number } | null => { + const indices = Array.from({ length: candidateList.length }, (_, i) => i) for (let i = indices.length - 1; i > 0; i -= 1) { const j = rng.int(0, i) const tmp = indices[i]! @@ -4607,7 +4652,7 @@ export function spawnOutdoorShrines( indices[j] = tmp } for (const idx of indices) { - const spot = candidates[idx]! + const spot = candidateList[idx]! const tooCloseToShrine = placedShrineTiles.some(p => Math.hypot(p.x - spot.x, p.y - spot.y) < minDist) if (tooCloseToShrine) continue const tooCloseToObject = allPlacedObjects.some(p => Math.hypot(p.x - spot.x, p.y - spot.y) < 4) @@ -4617,9 +4662,11 @@ export function spawnOutdoorShrines( return null } - // 2. Spawn Shrines with Dispersed Distribution (>= 10 tiles, relaxing to >= 7 if constrained) + // 2. Spawn Shrines with Dispersed Distribution along secondary exploration paths (>= 10 tiles, relaxing to >= 7 if constrained) for (let i = 0; i < targetShrineCount; i += 1) { - const spot = pickShrineSpot(10) ?? pickShrineSpot(7) + const spot = (secondaryCandidates.length > 0 + ? (pickShrineSpot(secondaryCandidates, 10) ?? pickShrineSpot(secondaryCandidates, 7)) + : null) ?? (pickShrineSpot(candidates, 10) ?? pickShrineSpot(candidates, 7)) if (!spot) break const shrineId = config.shrinePool[rng.int(0, config.shrinePool.length - 1)]! @@ -4759,7 +4806,9 @@ export function spawnOutdoorShrines( stats.interactivesSpawned = interactivesSpawned } +export const DRLGOUTDOORS_SpawnAct12Waypoint = spawnOutdoorWaypoint export const spawnAct12Waypoint = spawnOutdoorWaypoint +export const DRLGOUTDOORS_SpawnAct12Shrines = spawnOutdoorShrines export const spawnAct12Shrines = spawnOutdoorShrines /** Specification of a landmark entity definition for data-driven spawning. */ diff --git a/tests/wilderness-waypoints-shrines.test.ts b/tests/wilderness-waypoints-shrines.test.ts index 83fc0e3..75c4f13 100644 --- a/tests/wilderness-waypoints-shrines.test.ts +++ b/tests/wilderness-waypoints-shrines.test.ts @@ -5,7 +5,15 @@ import { actOfLevel, hasOutdoorWaypoint, ACT_POPULATION_CONFIG, + createCanvas, + DRLGOUTDOORS_SpawnAct12Waypoint, + DRLGOUTDOORS_SpawnAct12Shrines, + spawnOutdoorWaypoint, + spawnOutdoorShrines, + type Canvas, + type PlacedPresetRect, type WildernessPiece, + type WildernessStats, type WildernessSubstitution, } from "../src/game/wilderness.ts" import { @@ -13,6 +21,12 @@ import { hasOutdoorWaypoint as actsHasOutdoorWaypoint, type D2Table, } from "../src/game/acts.ts" +import { Rng } from "../src/game/rng.ts" +import { + findWaypointSpot, + DRLGOUTDOORS_SpawnAct12Waypoint as linksSpawnAct12Waypoint, + type LinkGrid, +} from "../src/game/level-links.ts" import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts" function makeMockDs1(width: number, height: number, hasGapInWalls = false, isOpen = false): Ds1 { @@ -486,3 +500,254 @@ describe("Wilderness Thematic Waypoint and Shrine Distribution (Issue #56)", () expect(run1.level.objects).toEqual(run2.level.objects) }) }) + +describe("DRLGOUTDOORS_SpawnAct12Waypoint and DRLGOUTDOORS_SpawnAct12Shrines Topological Placement (Issue #68)", () => { + function makeOutdoorCanvas(width: number, height: number): Canvas { + 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: [], + floors: [{ + prop1: 2, + sequence: 0, + style: 0, + unknown1: 0, + unknown2: 0, + hidden: false, + }], + shadows: [], + substitutions: [], + }) + } + cells.push(row) + } + return { + width, + height, + cells, + wallLayers: 1, + floorLayers: 1, + substitutionLayers: 1, + objects: [], + } + } + + function makeEmptyStats(): WildernessStats { + return { + substitutions: [], + borderPieces: {}, + unresolved: [], + notes: [], + borderStamped: 0, + groundCells: 0, + groundTile: null, + sizeSource: "default", + roadCells: 0, + roadSegments: 0, + anchors: 0, + specialPresets: [], + entrances: [], + waypointsSpawned: 0, + shrinesSpawned: 0, + interactivesSpawned: 0, + } + } + + test("Priority 1: DRLGOUTDOORS_SpawnAct12Waypoint prioritizes preset waypoint objects over roadside and campsite", () => { + const canvas = makeOutdoorCanvas(48, 48) + // Pre-place a waypoint object at (12, 12) (tile 12 => x = 60, y = 60) + canvas.objects.push({ + type: 2, + id: 37, // Act 1 waypoint id + x: 12 * 5 + 2, + y: 12 * 5 + 2, + flags: 0, + }) + + // Also supply a dirt path road down x = 24 + const dirtPathGrid = new Uint8Array(48 * 48) + for (let y = 0; y < 48; y += 1) { + dirtPathGrid[y * 48 + 24] = 1 + } + + // Also place a campsite preset at (36, 36) + const placedRects: PlacedPresetRect[] = [ + { name: "Act 1 - Camp", x: 36, y: 36, w: 8, h: 8 }, + ] + + const stats = makeEmptyStats() + const rng = new Rng(0x1234) + DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 3, 1, dirtPathGrid, placedRects, rng, stats) + + expect(stats.waypointsSpawned).toBe(1) + expect(stats.waypointTile).toEqual({ x: 12, y: 12 }) + }) + + test("Priority 2: DRLGOUTDOORS_SpawnAct12Waypoint selects roadside clearings over distant open campsites", () => { + const canvas = makeOutdoorCanvas(64, 64) + // Dirt road running vertically at x = 20 + const dirtPathGrid = new Uint8Array(64 * 64) + for (let y = 0; y < 64; y += 1) { + dirtPathGrid[y * 64 + 20] = 1 + } + + // Campsite preset located far from road at (48, 48) + const placedRects: PlacedPresetRect[] = [ + { name: "Act 1 - Fallen Camp 1", x: 48, y: 48, w: 8, h: 8 }, + ] + + const stats = makeEmptyStats() + const rng = new Rng(0x5678) + DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 3, 1, dirtPathGrid, placedRects, rng, stats) + + expect(stats.waypointsSpawned).toBe(1) + expect(stats.waypointTile).toBeDefined() + const wp = stats.waypointTile! + // Must be adjacent to road at x = 20 (distance within 2..6 tiles, not at x >= 44) + expect(Math.abs(wp.x - 20)).toBeGreaterThanOrEqual(2) + expect(Math.abs(wp.x - 20)).toBeLessThanOrEqual(6) + expect(wp.x).toBeLessThan(40) + }) + + test("Priority 3: DRLGOUTDOORS_SpawnAct12Waypoint selects open campsite rooms when no road is present", () => { + const canvas = makeOutdoorCanvas(64, 64) + // No road present (roadless level, e.g. Far Oasis level 43) + const placedRects: PlacedPresetRect[] = [ + { name: "Act 2 - Desert Ruins 16X16", x: 28, y: 28, w: 8, h: 8 }, + ] + + const stats = makeEmptyStats() + const rng = new Rng(0x9abc) + DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 43, 2, undefined, placedRects, rng, stats) + + expect(stats.waypointsSpawned).toBe(1) + expect(stats.waypointTile).toBeDefined() + const wp = stats.waypointTile! + // Waypoint must be placed near the campsite/ruins landmark (within 7 tiles) + const distToCamp = Math.max( + Math.max(28 - wp.x, 0, wp.x - (28 + 8 - 1)), + Math.max(28 - wp.y, 0, wp.y - (28 + 8 - 1)), + ) + expect(distToCamp).toBeLessThanOrEqual(7) + }) + + test("Player landing safety buffer around placed waypoints is obstacle-free", () => { + const canvas = makeOutdoorCanvas(48, 48) + // Add walls at specific tiles to test obstacle-free landing buffer + const obstacleCell = canvas.cells[24]![22]! + canvas.cells[24]![22] = { + ...obstacleCell, + walls: [{ + prop1: 2, + sequence: 0, + style: 1, + type: 0, + unknown1: 0, + unknown2: 0, + hidden: false, + }], + } + + const dirtPathGrid = new Uint8Array(48 * 48) + for (let y = 0; y < 48; y += 1) { + dirtPathGrid[y * 48 + 16] = 1 + } + + const stats = makeEmptyStats() + const rng = new Rng(0x1122) + DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 3, 1, dirtPathGrid, [], rng, stats) + + expect(stats.waypointsSpawned).toBe(1) + const wp = stats.waypointTile! + + // Verify 2-tile perimeter (5x5 tiles around wp) is 100% obstacle-free + for (let dy = -2; dy <= 2; dy += 1) { + for (let dx = -2; dx <= 2; dx += 1) { + const tx = wp.x + dx + const ty = wp.y + dy + const cell = canvas.cells[ty]![tx]! + // Must have walkable floor + expect(cell.floors.some(f => !f.hidden && f.prop1 !== 0)).toBe(true) + // Must have NO walls + expect(cell.walls.some(w => !w.hidden && w.prop1 !== 0)).toBe(false) + } + } + }) + + test("DRLGOUTDOORS_SpawnAct12Shrines spaces shrines across exploration branches away from main road", () => { + const canvas = makeOutdoorCanvas(64, 64) + // Main spline road running vertically at x = 32 + const dirtPathGrid = new Uint8Array(64 * 64) + for (let y = 0; y < 64; y += 1) { + dirtPathGrid[y * 64 + 32] = 1 + } + + const stats = makeEmptyStats() + const rng = new Rng(0x4455) + DRLGOUTDOORS_SpawnAct12Shrines(canvas, 3, 1, dirtPathGrid, [], rng, stats) + + const act1Shrines = new Set(ACT_POPULATION_CONFIG[1].shrinePool) + const shrines = canvas.objects.filter(o => o.type === 2 && act1Shrines.has(o.id)) + + // Shrines should be spawned along secondary branches (not directly on or within 2 tiles of main road) + for (const shrine of shrines) { + const sx = Math.floor(shrine.x / 5) + expect(Math.abs(sx - 32)).toBeGreaterThanOrEqual(3) + } + + // Pairwise distance between shrines must be >= 10 tiles + for (let i = 0; i < shrines.length; i += 1) { + for (let j = i + 1; j < shrines.length; j += 1) { + const s1 = shrines[i]! + const s2 = shrines[j]! + const dist = Math.hypot( + Math.floor(s1.x / 5) - Math.floor(s2.x / 5), + Math.floor(s1.y / 5) - Math.floor(s2.y / 5), + ) + expect(dist).toBeGreaterThanOrEqual(10) + } + } + }) + + test("findWaypointSpot in level-links.ts selects high-clearance topological locations rather than blind center search", () => { + // 40x40 sub-tiles grid + const W = 40 + const H = 40 + const blocked = new Uint8Array(W * H).fill(1) // all blocked initially + + // Create a narrow 1-sub-tile corridor at the exact center (y = 20, x = 15..25) + for (let x = 15; x <= 25; x += 1) { + blocked[20 * W + x] = 0 + } + + // Create a wide open room at (10, 10) with 7x7 clear walkable sub-tiles + for (let y = 7; y <= 13; y += 1) { + for (let x = 7; x <= 13; x += 1) { + blocked[y * W + x] = 0 + } + } + + const grid: LinkGrid = { + cellsX: 8, + cellsY: 8, + gridWidth: W, + gridHeight: H, + blocked, + } + + // The naive BFS from center would pick (20, 20) in the narrow corridor + // findWaypointSpot evaluates clearance (>= 3x3) and selects inside the open room! + const spot = findWaypointSpot(grid) + expect(spot).not.toBeNull() + expect(spot!.x).toBeGreaterThanOrEqual(8) + expect(spot!.x).toBeLessThanOrEqual(12) + expect(spot!.y).toBeGreaterThanOrEqual(8) + expect(spot!.y).toBeLessThanOrEqual(12) + + // Verify DRLGOUTDOORS_SpawnAct12Waypoint in level-links.ts is an alias and returns the same + const d2Spot = linksSpawnAct12Waypoint(grid) + expect(d2Spot).toEqual(spot) + }) +})