feat(wilderness): 还原 DRLGOUTDOORS_SpawnAct12Waypoint 小站与神殿拓扑放置算法 (#68)

- 在 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
This commit is contained in:
troytt 2026-09-18 03:09:19 +00:00
parent 0e8778f31a
commit d7defd8da3
3 changed files with 473 additions and 59 deletions

View File

@ -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

View File

@ -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. */

View File

@ -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)
})
})