feat(wilderness): 还原 DRLGOUTDOORS_SpawnAct12Waypoint 原版 8x8 单元网格打乱与有效性判定 (Issue #80)

- 还原 1.13c 原版 DRLGOUTDOORS_SpawnAct12Waypoint (D2Common.dll ≈0x6FD92700) 算法
- Branch A: Cold Plains (level 3) 特例:寻址连接到 Blood Moor (level 2) 的边界网格,执行四向边界 clamp 并写入 0x20000 标记,精准消耗 0 次 RNG rolls
- Branch B: 常规路径:构建 (nGridWidth-2)*(nGridHeight-2) 区域坐标数组,执行 2*nArea 轮随机对调打乱(暴雪原版 swap shuffle,非 Fisher-Yates),逐项使用 TestGridCellSpawnValid ((flags & 0x1B81) === 0) 判定有效性格子并写入 0x10000 标记
- 清理旧有试探性评分启发逻辑(LANDMARK_REGEX、isNearDirtRoad、hubRadius 及硬编码 Act 3 判定区)
- 添加 tests/outdoor-waypoint-113c.test.ts 单元测试并对齐已有测试用例

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
troytt 2026-09-18 11:14:52 +00:00
parent a1a549e8a0
commit 7187154ac8
3 changed files with 721 additions and 118 deletions

View File

@ -5305,14 +5305,114 @@ function validateWaypointLandingBuffer(
}
/**
* Automatically locate and place an outdoor Waypoint (`DRLGOUTDOORS_SpawnAct12Waypoint`)
* supporting authentic topological candidate evaluation across Acts 1..5.
* 1.13c DRLGOUTDOORS cell validity mask for waypoint spawning (D2Common.dll ≈0x6FD92700).
* Cell flags tested against 0x1B81: (flags & 0x1B81) === 0 indicates cell is valid.
*/
export const DRLG_GRID_SPAWN_INVALID_MASK = 0x1b81
/** Flag set on pGrid[1] when a general outdoor waypoint is placed (0x10000). */
export const DRLG_GRID_FLAG_WAYPOINT = 0x10000
/** Flag set on pGrid[1] when Cold Plains special waypoint is placed (0x20000). */
export const DRLG_GRID_FLAG_WAYPOINT_COLDPLAINS = 0x20000
/**
* Rolls a limited random number in [0, max - 1] matching 1.13c SEED_RollLimitedRandomNumber.
* Each call consumes exactly 1 RNG roll.
*/
export function rollLimited(rng: Rng, max: number): number {
if (max <= 0) return 0
return Math.floor(rng.next() * max)
}
/**
* Tests if an 8x8 cell grid block is valid for spawning an outdoor waypoint
* matching 1.13c DRLGOUTDOORS inline test (D2Common.dll ≈0x6FD92700):
* `test [grid], 0x1b81` => `(flags & 0x1B81) === 0`.
*
* 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).
* @param grid - Grid containing per-cell 32-bit flags (2D number array, 1D array/Uint32Array, or grid object).
* @param x - Grid block X coordinate (1..nGridWidth-2).
* @param y - Grid block Y coordinate (1..nGridHeight-2).
* @param width - Optional width for 1D grid layouts.
* @returns true if valid for spawn ((flags & 0x1B81) === 0), false otherwise.
*/
export function TestGridCellSpawnValid(
grid: { width?: number; height?: number; flags?: Uint32Array | readonly number[] | readonly (readonly number[])[] } | readonly (readonly number[])[] | Uint32Array | readonly number[],
x: number,
y: number,
width?: number,
): boolean {
let flags = 0
if (Array.isArray(grid)) {
if (grid.length > 0 && Array.isArray(grid[0])) {
const row = (grid as readonly (readonly number[])[])[y]
if (!row || x < 0 || x >= row.length) return false
flags = row[x] ?? 0
} else {
const w = width ?? 1
if (x < 0 || y < 0) return false
const idx = y * w + x
flags = (grid as readonly number[])[idx] ?? 0
}
} else if (grid instanceof Uint32Array) {
const w = width ?? 1
if (x < 0 || y < 0) return false
const idx = y * w + x
flags = grid[idx] ?? 0
} else if (grid && typeof grid === 'object' && ('flags' in grid || 'width' in grid)) {
const g = grid as { width?: number; height?: number; flags?: Uint32Array | readonly number[] | readonly (readonly number[])[] }
const w = g.width ?? width ?? 1
const gFlags = g.flags
if (Array.isArray(gFlags)) {
if (gFlags.length > 0 && Array.isArray(gFlags[0])) {
const row = (gFlags as readonly (readonly number[])[])[y]
if (!row || x < 0 || x >= row.length) return false
flags = row[x] ?? 0
} else {
if (x < 0 || y < 0) return false
flags = (gFlags as readonly number[])[y * w + x] ?? 0
}
} else if (gFlags instanceof Uint32Array) {
if (x < 0 || y < 0) return false
flags = gFlags[y * w + x] ?? 0
}
}
// 1.13c inline mask: test [grid], 0x1b81 => (flags & 0x1B81) === 0
return (flags & DRLG_GRID_SPAWN_INVALID_MASK) === 0
}
/**
* Automatically locate and place an outdoor Waypoint (`DRLGOUTDOORS_SpawnAct12Waypoint`)
* matching 1.13c disassembly (PE timestamp 0x4B95C439, D2Common.dll ≈0x6FD92700).
*
* Priority 1: Check if a preset already placed a Waypoint object.
*
* Branch A (Cold Plains special, levelId == 3, LEVEL_COLDPLAINS):
* - Find connection to LEVEL_BLOODMOOR (levelId 2).
* - Find the boundary grid cell linking to Blood Moor.
* - Boundary clamp:
* if (j == 0) j = 1;
* if (i == 0) i = 1;
* if (j == nGridWidth - 1) j -= 1;
* if (i == nGridHeight - 1) i -= 1;
* - Set flag 0x20000 on pGrid[1].
* - Return immediately, consuming exactly 0 RNG rolls.
*
* Branch B (General path):
* - nWidth = nGridWidth - 2; nArea = nWidth * (nGridHeight - 2);
* - Build coordinate array: pCoord[i] = (i % nWidth, Math.floor(i / nWidth))
* - Shuffle: exactly nArea rounds, each round 2 rolls:
* r1 = rollLimited(rng, nArea)
* r2 = rollLimited(rng, nArea)
* swap(pCoord[r1], pCoord[r2])
* (Notice: this is NOT Fisher-Yates, it is Blizzard's 2*nArea swap shuffle!
* Do not change it to Fisher-Yates, comment that it's intentional for 1.13c parity.)
* - Iterate through shuffled pCoord:
* nX = pCoord[i].x + 1; nY = pCoord[i].y + 1;
* if (TestGridCellSpawnValid(grid, nX, nY)):
* Place waypoint here (flag 0x10000);
* break;
*/
export function spawnOutdoorWaypoint(
canvas: Canvas,
@ -5324,6 +5424,7 @@ export function spawnOutdoorWaypoint(
stats: WildernessStats,
levelTypeName?: string,
levelsTable?: D2Table,
seamConstraints?: readonly SeamConstraint[],
): void {
if (!hasOutdoorWaypoint(levelId, levelsTable)) return
@ -5345,97 +5446,263 @@ export function spawnOutdoorWaypoint(
const config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]!
const wpId = config.waypointId
const isJungle = levelId >= 76 && levelId <= 78
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))
const nGridWidth = Math.floor(canvas.width / TILES_PER_BLOCK)
const nGridHeight = Math.floor(canvas.height / TILES_PER_BLOCK)
if (nGridWidth < 3 || nGridHeight < 3) return
const LANDMARK_REGEX = /(clearing|ruin|oasis|cottage|camp|building|temple|tomb|bivouac|shrine)/i
const landmarkRects = placedRects.filter(r => LANDMARK_REGEX.test(r.name))
// Allocate 8x8 cell grid (pGrid[1] flags)
const grid: number[][] = Array.from({ length: nGridHeight }, () => new Array(nGridWidth).fill(0))
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 <= 7) return true
// Mark outer perimeter border blocks with 0x0080 (border flag, part of 0x1B81)
for (let x = 0; x < nGridWidth; x += 1) {
grid[0]![x] |= 0x0080
grid[nGridHeight - 1]![x] |= 0x0080
}
// For Act 3 Jungle clearings (when stamped without placedRects)
if (isJungle && (landmarkRects.length === 0 || tx < 32)) {
if (tx >= 4 && tx < 30 && ty >= 32 && ty < 160) return true
}
return false
for (let y = 0; y < nGridHeight; y += 1) {
grid[y]![0] |= 0x0080
grid[y]![nGridWidth - 1] |= 0x0080
}
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 (!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 (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 (!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 })
// Mark blocks overlapping placed preset rectangles with 0x0001 (occupied by preset)
for (const rect of placedRects) {
const bx0 = Math.max(0, Math.floor(rect.x / TILES_PER_BLOCK))
const by0 = Math.max(0, Math.floor(rect.y / TILES_PER_BLOCK))
const bx1 = Math.min(nGridWidth - 1, Math.floor((rect.x + rect.w - 1) / TILES_PER_BLOCK))
const by1 = Math.min(nGridHeight - 1, Math.floor((rect.y + rect.h - 1) / TILES_PER_BLOCK))
for (let by = by0; by <= by1; by += 1) {
for (let bx = bx0; bx <= bx1; bx += 1) {
grid[by]![bx] |= 0x0001
}
}
}
let pool: { x: number; y: number }[]
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
// Mark blocks with walls or missing floors as 0x1000 (impassable/collision)
for (let by = 1; by < nGridHeight - 1; by += 1) {
for (let bx = 1; bx < nGridWidth - 1; bx += 1) {
let blocked = false
for (let dy = 0; dy < TILES_PER_BLOCK; dy += 1) {
const ty = by * TILES_PER_BLOCK + dy
const row = canvas.cells[ty]
if (!row) {
blocked = true
break
}
for (let dx = 0; dx < TILES_PER_BLOCK; dx += 1) {
const tx = bx * TILES_PER_BLOCK + dx
const cell = row[tx]
if (!cell) {
blocked = true
break
}
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) {
blocked = true
break
}
if (!cell.floors.some(f => !f.hidden && f.prop1 !== 0)) {
blocked = true
break
}
}
if (blocked) break
}
if (blocked) {
grid[by]![bx] |= 0x1000
}
}
}
// Mark blocks containing pre-existing objects as 0x0001
for (const obj of canvas.objects) {
const obx = Math.floor(obj.x / (SUB_TILES_PER_TILE * TILES_PER_BLOCK))
const oby = Math.floor(obj.y / (SUB_TILES_PER_TILE * TILES_PER_BLOCK))
if (oby >= 0 && oby < nGridHeight && obx >= 0 && obx < nGridWidth) {
grid[oby]![obx] |= 0x0001
}
}
// Branch A: Cold Plains special (levelId == 3, LEVEL_COLDPLAINS)
if (levelId === 3) {
// 1. Find connection to LEVEL_BLOODMOOR (levelId 2).
// 2. Find the boundary grid cell linking to Blood Moor.
let j = 0
let i = Math.floor(nGridHeight / 2)
const gate = stats.gateOpenings?.find(g => g.toLevelId === 2)
if (gate !== undefined) {
if (gate.side === 'west') {
j = 0
i = gate.blockCoord ?? (gate.tileCoord ? Math.floor(gate.tileCoord.y / TILES_PER_BLOCK) : Math.floor(nGridHeight / 2))
} else if (gate.side === 'east') {
j = nGridWidth - 1
i = gate.blockCoord ?? (gate.tileCoord ? Math.floor(gate.tileCoord.y / TILES_PER_BLOCK) : Math.floor(nGridHeight / 2))
} else if (gate.side === 'north') {
i = 0
j = gate.blockCoord ?? (gate.tileCoord ? Math.floor(gate.tileCoord.x / TILES_PER_BLOCK) : Math.floor(nGridWidth / 2))
} else if (gate.side === 'south') {
i = nGridHeight - 1
j = gate.blockCoord ?? (gate.tileCoord ? Math.floor(gate.tileCoord.x / TILES_PER_BLOCK) : Math.floor(nGridWidth / 2))
}
} else {
pool = openCandidates
const sc = seamConstraints?.find(c => c.toLevelId === 2 || c.fromLevelId === 2)
if (sc !== undefined) {
if (sc.side === 'west') {
j = 0
i = sc.blockCoord ?? (sc.tileCoord ? Math.floor(sc.tileCoord.y / TILES_PER_BLOCK) : Math.floor(nGridHeight / 2))
} else if (sc.side === 'east') {
j = nGridWidth - 1
i = sc.blockCoord ?? (sc.tileCoord ? Math.floor(sc.tileCoord.y / TILES_PER_BLOCK) : Math.floor(nGridHeight / 2))
} else if (sc.side === 'north') {
i = 0
j = sc.blockCoord ?? (sc.tileCoord ? Math.floor(sc.tileCoord.x / TILES_PER_BLOCK) : Math.floor(nGridWidth / 2))
} else if (sc.side === 'south') {
i = nGridHeight - 1
j = sc.blockCoord ?? (sc.tileCoord ? Math.floor(sc.tileCoord.x / TILES_PER_BLOCK) : Math.floor(nGridWidth / 2))
}
}
}
const chosen = pool.length > 0
? pool[rng.int(0, pool.length - 1)]!
: { x: cx, y: cy }
// 3. Boundary clamp:
if (j === 0) j = 1
if (i === 0) i = 1
if (j === nGridWidth - 1) j -= 1
if (i === nGridHeight - 1) i -= 1
j = Math.max(1, Math.min(nGridWidth - 2, j))
i = Math.max(1, Math.min(nGridHeight - 2, i))
// 4. Set flag 0x20000 on pGrid[1]
grid[i]![j] |= DRLG_GRID_FLAG_WAYPOINT_COLDPLAINS
let chosenX = j * TILES_PER_BLOCK + 4
let chosenY = i * TILES_PER_BLOCK + 4
if (!validateWaypointLandingBuffer(canvas, chosenX, chosenY, placedRects, 2)) {
let found = false
for (let dy = 2; dy <= 5; dy += 1) {
for (let dx = 2; dx <= 5; dx += 1) {
const tx = j * TILES_PER_BLOCK + dx
const ty = i * TILES_PER_BLOCK + dy
if (validateWaypointLandingBuffer(canvas, tx, ty, placedRects, 1)) {
chosenX = tx
chosenY = ty
found = true
break
}
}
if (found) break
}
}
canvas.objects.push({
type: 2,
id: wpId,
x: chosen.x * SUB_TILES_PER_TILE + 2,
y: chosen.y * SUB_TILES_PER_TILE + 2,
x: chosenX * SUB_TILES_PER_TILE + 2,
y: chosenY * SUB_TILES_PER_TILE + 2,
flags: 0,
})
stats.waypointsSpawned = 1
stats.waypointTile = { x: chosen.x, y: chosen.y }
stats.notes.push(`spawned outdoor waypoint (id=${String(wpId)}) at tile (${String(chosen.x)}, ${String(chosen.y)})`)
stats.waypointTile = { x: chosenX, y: chosenY }
stats.notes.push(
`spawned outdoor waypoint (id=${String(wpId)}) at tile (${String(chosenX)}, ${String(chosenY)}) [Cold Plains special grid cell (${String(j)}, ${String(i)})]`,
)
// 5. Return immediately! This consumes 0 RNG rolls.
return
}
// Branch B: General path
const nWidth = nGridWidth - 2
const nHeight = nGridHeight - 2
const nArea = nWidth * nHeight
if (nArea <= 0) return
// Build coordinate array: for i in 0..nArea-1: pCoord[i] = (i % nWidth, Math.floor(i / nWidth))
const pCoord: { x: number; y: number }[] = []
for (let i = 0; i < nArea; i += 1) {
pCoord.push({
x: i % nWidth,
y: Math.floor(i / nWidth),
})
}
// Shuffle: exactly nArea rounds, each round 2 rolls:
// r1 = rollLimited(rng, nArea)
// r2 = rollLimited(rng, nArea)
// swap(pCoord[r1], pCoord[r2])
// (Notice: this is NOT Fisher-Yates, it is Blizzard's 2*nArea swap shuffle!
// Do not change it to Fisher-Yates, comment that it's intentional for 1.13c parity.)
for (let round = 0; round < nArea; round += 1) {
const r1 = rollLimited(rng, nArea)
const r2 = rollLimited(rng, nArea)
const temp = pCoord[r1]!
pCoord[r1] = pCoord[r2]!
pCoord[r2] = temp
}
// Iterate through shuffled pCoord:
// nX = pCoord[i].x + 1; nY = pCoord[i].y + 1;
// if (TestGridCellSpawnValid(grid, nX, nY)): // 1.13c inline mask: test [grid], 0x1b81 => (flags & 0x1B81) === 0
// Place waypoint here (flag 0x10000);
// break;
let placed = false
for (let i = 0; i < nArea; i += 1) {
const nX = pCoord[i]!.x + 1
const nY = pCoord[i]!.y + 1
if (TestGridCellSpawnValid(grid, nX, nY)) {
grid[nY]![nX] |= DRLG_GRID_FLAG_WAYPOINT
let chosenX = nX * TILES_PER_BLOCK + 4
let chosenY = nY * TILES_PER_BLOCK + 4
if (!validateWaypointLandingBuffer(canvas, chosenX, chosenY, placedRects, 2)) {
let found = false
for (let dy = 2; dy <= 5; dy += 1) {
for (let dx = 2; dx <= 5; dx += 1) {
const tx = nX * TILES_PER_BLOCK + dx
const ty = nY * TILES_PER_BLOCK + dy
if (validateWaypointLandingBuffer(canvas, tx, ty, placedRects, 1)) {
chosenX = tx
chosenY = ty
found = true
break
}
}
if (found) break
}
}
canvas.objects.push({
type: 2,
id: wpId,
x: chosenX * SUB_TILES_PER_TILE + 2,
y: chosenY * SUB_TILES_PER_TILE + 2,
flags: 0,
})
stats.waypointsSpawned = 1
stats.waypointTile = { x: chosenX, y: chosenY }
stats.notes.push(
`spawned outdoor waypoint (id=${String(wpId)}) at tile (${String(chosenX)}, ${String(chosenY)}) [grid cell (${String(nX)}, ${String(nY)})]`,
)
placed = true
break
}
}
// Fallback if no cell passed TestGridCellSpawnValid
if (!placed && pCoord.length > 0) {
const fallbackCoord = pCoord[0]!
const nX = fallbackCoord.x + 1
const nY = fallbackCoord.y + 1
grid[nY]![nX] |= DRLG_GRID_FLAG_WAYPOINT
const chosenX = nX * TILES_PER_BLOCK + 4
const chosenY = nY * TILES_PER_BLOCK + 4
canvas.objects.push({
type: 2,
id: wpId,
x: chosenX * SUB_TILES_PER_TILE + 2,
y: chosenY * SUB_TILES_PER_TILE + 2,
flags: 0,
})
stats.waypointsSpawned = 1
stats.waypointTile = { x: chosenX, y: chosenY }
}
}
/**
@ -6520,7 +6787,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
} else if (request.levelId >= 76 && request.levelId <= 78) {
placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats, specialPlacedRects)
const populationRng = new Rng(request.seed ^ 0x6c07)
spawnOutdoorWaypoint(canvas, request.levelId, act, undefined, [], populationRng, stats, request.levelTypeName, request.levelsTable)
spawnOutdoorWaypoint(canvas, request.levelId, act, undefined, [], populationRng, stats, request.levelTypeName, request.levelsTable, request.seamConstraints)
spawnOutdoorShrines(canvas, request.levelId, act, undefined, [], populationRng, stats, request.shrineSubstitutions, 0, request.levelTypeName, request.levelsTable)
} else {
placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, rng, stats)
@ -6853,7 +7120,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
// Step 5: Outdoor Population Phase (Waypoints, Roadside Shrines/Chests, Landmark SuperUniques & Flavie)
const populationRng = new Rng(request.seed ^ 0x6c07)
spawnOutdoorWaypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.levelTypeName, request.levelsTable)
spawnOutdoorWaypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.levelTypeName, request.levelsTable, request.seamConstraints)
spawnOutdoorShrines(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.shrineSubstitutions, themeIndex, request.levelTypeName, request.levelsTable)
spawnLandmarkEntities(canvas, request.levelId, act1Plan, allPlacedRects, gridWidth, gridHeight, stats, landmarkDefs)

View File

@ -0,0 +1,350 @@
import { describe, expect, test } from "vitest"
import {
type Canvas,
DRLG_GRID_FLAG_WAYPOINT,
DRLG_GRID_FLAG_WAYPOINT_COLDPLAINS,
DRLG_GRID_SPAWN_INVALID_MASK,
DRLGOUTDOORS_SpawnAct12Waypoint,
type PlacedPresetRect,
rollLimited,
type SeamConstraint,
TestGridCellSpawnValid,
type WildernessStats,
} from "../src/game/wilderness.ts"
import { Rng } from "../src/game/rng.ts"
import type { Ds1Cell } from "../src/formats/ds1.ts"
class TrackingRng extends Rng {
rollCount = 0
override next(): number {
this.rollCount += 1
return super.next()
}
}
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,
}
}
describe("DRLGOUTDOORS_SpawnAct12Waypoint 1.13c Parity (Issue #80)", () => {
describe("TestGridCellSpawnValid and DRLG_GRID_SPAWN_INVALID_MASK", () => {
test("DRLG_GRID_SPAWN_INVALID_MASK matches 1.13c disassembly 0x1B81", () => {
expect(DRLG_GRID_SPAWN_INVALID_MASK).toBe(0x1b81)
})
test("cell flags with no 0x1B81 bits pass validation", () => {
const validFlags = [
0,
0x0002,
0x0004,
0x0008,
0x0010,
0x0020,
0x0040,
0x0400,
0x2000,
0x4000,
0x8000,
DRLG_GRID_FLAG_WAYPOINT, // 0x10000
DRLG_GRID_FLAG_WAYPOINT_COLDPLAINS, // 0x20000
0x0002 | 0x0010 | 0x20000,
]
for (const flag of validFlags) {
const grid2D = [[flag]]
expect(TestGridCellSpawnValid(grid2D, 0, 0)).toBe(true)
const grid1D = [flag]
expect(TestGridCellSpawnValid(grid1D, 0, 0, 1)).toBe(true)
const gridU32 = new Uint32Array([flag])
expect(TestGridCellSpawnValid(gridU32, 0, 0, 1)).toBe(true)
const gridObj = { width: 1, height: 1, flags: [flag] }
expect(TestGridCellSpawnValid(gridObj, 0, 0)).toBe(true)
}
})
test("cell flags with any 0x1B81 bits fail validation", () => {
const invalidFlags = [
0x0001, // preset occupied
0x0080, // border cell
0x0100, // collision bit
0x0200, // collision bit
0x0800, // collision bit
0x1000, // walls / impassable
0x1b81, // full mask
0x1000 | 0x0002, // mixed
0x0001 | DRLG_GRID_FLAG_WAYPOINT,
]
for (const flag of invalidFlags) {
const grid2D = [[flag]]
expect(TestGridCellSpawnValid(grid2D, 0, 0)).toBe(false)
const grid1D = [flag]
expect(TestGridCellSpawnValid(grid1D, 0, 0, 1)).toBe(false)
const gridU32 = new Uint32Array([flag])
expect(TestGridCellSpawnValid(gridU32, 0, 0, 1)).toBe(false)
}
})
test("out of bounds coordinates safely return false", () => {
const grid = [[0, 0], [0, 0]]
expect(TestGridCellSpawnValid(grid, -1, 0)).toBe(false)
expect(TestGridCellSpawnValid(grid, 0, -1)).toBe(false)
expect(TestGridCellSpawnValid(grid, 2, 0)).toBe(false)
expect(TestGridCellSpawnValid(grid, 0, 2)).toBe(false)
})
})
describe("rollLimited helper", () => {
test("rolls within [0, max - 1] and consumes exactly 1 roll per call", () => {
const rng = new TrackingRng(0x12345678)
const max = 36
for (let i = 0; i < 100; i += 1) {
const prevRolls = rng.rollCount
const val = rollLimited(rng, max)
expect(val).toBeGreaterThanOrEqual(0)
expect(val).toBeLessThan(max)
expect(rng.rollCount).toBe(prevRolls + 1)
}
})
test("handles edge case max <= 0 without consuming or erroring", () => {
const rng = new TrackingRng(0x12345678)
expect(rollLimited(rng, 0)).toBe(0)
expect(rollLimited(rng, -5)).toBe(0)
expect(rng.rollCount).toBe(0)
})
})
describe("Branch A: Cold Plains Special (levelId === 3)", () => {
test("consumes exactly 0 RNG rolls", () => {
const canvas = makeOutdoorCanvas(64, 64)
const stats = makeEmptyStats()
const rng = new TrackingRng(0xabcdef)
const seamConstraints: SeamConstraint[] = [
{ fromLevelId: 3, toLevelId: 2, side: "west", blockCoord: 0 },
]
DRLGOUTDOORS_SpawnAct12Waypoint(
canvas,
3,
1,
undefined,
[],
rng,
stats,
"Act 1 - Wilderness",
undefined,
seamConstraints,
)
expect(stats.waypointsSpawned).toBe(1)
expect(stats.waypointTile).toBeDefined()
// Exactly 0 RNG rolls consumed!
expect(rng.rollCount).toBe(0)
})
test("clamps connection to Blood Moor on all 4 boundary sides and sets 0x20000 flag", () => {
const testCases: { side: "west" | "east" | "north" | "south"; expectedBlockX: number; expectedBlockY: number }[] = [
{ side: "west", expectedBlockX: 1, expectedBlockY: 4 }, // j=0 clamped to 1, i=4
{ side: "east", expectedBlockX: 6, expectedBlockY: 4 }, // j=7 clamped to 6, i=4
{ side: "north", expectedBlockX: 4, expectedBlockY: 1 }, // i=0 clamped to 1, j=4
{ side: "south", expectedBlockX: 4, expectedBlockY: 6 }, // i=7 clamped to 6, j=4
]
for (const tc of testCases) {
const canvas = makeOutdoorCanvas(64, 64) // 8x8 blocks (0..7)
const stats = makeEmptyStats()
const rng = new TrackingRng(0x9999)
const seamConstraints: SeamConstraint[] = [
{ fromLevelId: 3, toLevelId: 2, side: tc.side, blockCoord: 4 },
]
DRLGOUTDOORS_SpawnAct12Waypoint(
canvas,
3,
1,
undefined,
[],
rng,
stats,
"Act 1 - Wilderness",
undefined,
seamConstraints,
)
expect(stats.waypointsSpawned).toBe(1)
expect(stats.waypointTile).toBeDefined()
const wpTile = stats.waypointTile!
const blockX = Math.floor(wpTile.x / 8)
const blockY = Math.floor(wpTile.y / 8)
expect(blockX).toBe(tc.expectedBlockX)
expect(blockY).toBe(tc.expectedBlockY)
const note = stats.notes.find(n => n.includes("Cold Plains special grid cell"))
expect(note).toBeDefined()
expect(note).toContain(`(${tc.expectedBlockX}, ${tc.expectedBlockY})`)
expect(rng.rollCount).toBe(0)
}
})
test("clamps extreme boundary corners (e.g. j=0 clamped to 1, i=0 clamped to 1)", () => {
const canvas = makeOutdoorCanvas(64, 64)
const stats = makeEmptyStats()
const rng = new TrackingRng(0x4242)
// West gate at extreme top (blockCoord 0)
const seamConstraints: SeamConstraint[] = [
{ fromLevelId: 3, toLevelId: 2, side: "west", blockCoord: 0 },
]
DRLGOUTDOORS_SpawnAct12Waypoint(
canvas,
3,
1,
undefined,
[],
rng,
stats,
"Act 1 - Wilderness",
undefined,
seamConstraints,
)
expect(stats.waypointTile).toBeDefined()
const blockX = Math.floor(stats.waypointTile!.x / 8)
const blockY = Math.floor(stats.waypointTile!.y / 8)
expect(blockX).toBe(1)
expect(blockY).toBe(1)
expect(rng.rollCount).toBe(0)
})
})
describe("Branch B: General Path (e.g. Stony Field, levelId === 4)", () => {
test("consumes exactly 2 * nArea RNG rolls for coordinate shuffle", () => {
// 64x64 tiles -> 8x8 blocks of 8x8 tiles
// nGridWidth = 8, nGridHeight = 8
// nWidth = 8 - 2 = 6, nHeight = 8 - 2 = 6
// nArea = 6 * 6 = 36
// Exactly 2 * nArea = 72 rolls for shuffle, and 0 rolls during placement search
const canvas = makeOutdoorCanvas(64, 64)
const stats = makeEmptyStats()
const rng = new TrackingRng(0x778899)
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 4, 1, undefined, [], rng, stats)
expect(stats.waypointsSpawned).toBe(1)
expect(stats.waypointTile).toBeDefined()
const expectedRolls = 2 * 36
expect(rng.rollCount).toBe(expectedRolls)
})
test("consumes exactly 2 * nArea rolls for larger map (80x80 tiles -> 10x10 blocks -> 8x8 area = 64 cells -> 128 rolls)", () => {
const canvas = makeOutdoorCanvas(80, 80)
const stats = makeEmptyStats()
const rng = new TrackingRng(0x112233)
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 4, 1, undefined, [], rng, stats)
expect(stats.waypointsSpawned).toBe(1)
expect(stats.waypointTile).toBeDefined()
// nWidth = 10 - 2 = 8, nHeight = 10 - 2 = 8, nArea = 64
// 2 * 64 = 128 rolls
expect(rng.rollCount).toBe(128)
})
test("skips cells occupied by preset rectangles (obstacle-free placement via TestGridCellSpawnValid)", () => {
const canvas = makeOutdoorCanvas(64, 64)
const stats = makeEmptyStats()
const rng = new TrackingRng(0x5555)
// Mark preset covering block (1, 1) -> tiles [8..15, 8..15]
const placedPresets: PlacedPresetRect[] = [
{ name: "PresetObstacle", x: 8, y: 8, w: 8, h: 8 },
]
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 4, 1, undefined, placedPresets, rng, stats)
expect(stats.waypointsSpawned).toBe(1)
expect(stats.waypointTile).toBeDefined()
const wp = stats.waypointTile!
const blockX = Math.floor(wp.x / 8)
const blockY = Math.floor(wp.y / 8)
// Block (1, 1) had an obstacle so waypoint must not be in (1, 1)
expect(blockX === 1 && blockY === 1).toBe(false)
// Must be within inner bounds [1..6, 1..6]
expect(blockX).toBeGreaterThanOrEqual(1)
expect(blockX).toBeLessThanOrEqual(6)
expect(blockY).toBeGreaterThanOrEqual(1)
expect(blockY).toBeLessThanOrEqual(6)
})
test("is deterministic: identical seeds produce identical waypoint coordinates", () => {
const canvas1 = makeOutdoorCanvas(64, 64)
const stats1 = makeEmptyStats()
const rng1 = new TrackingRng(0xfeedbeef)
DRLGOUTDOORS_SpawnAct12Waypoint(canvas1, 4, 1, undefined, [], rng1, stats1)
const canvas2 = makeOutdoorCanvas(64, 64)
const stats2 = makeEmptyStats()
const rng2 = new TrackingRng(0xfeedbeef)
DRLGOUTDOORS_SpawnAct12Waypoint(canvas2, 4, 1, undefined, [], rng2, stats2)
expect(stats1.waypointTile).toEqual(stats2.waypointTile)
expect(rng1.rollCount).toBe(rng2.rollCount)
})
})
})

View File

@ -272,9 +272,10 @@ describe("Wilderness Thematic Waypoint and Shrine Distribution (Issue #56)", ()
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 1)
expect(wpObj).toBeDefined()
// Waypoint is inside the clearing column (x < 32)
// Waypoint is inside the level bounds
const wpTile = result.stats.waypointTile as { x: number; y: number }
expect(wpTile.x).toBeLessThan(32)
expect(wpTile.x).toBeGreaterThan(0)
expect(wpTile.x).toBeLessThan(64)
// Act 3 shrines spawned
const act3ShrinePool = new Set(ACT_POPULATION_CONFIG[3].shrinePool)
@ -584,37 +585,25 @@ describe("DRLGOUTDOORS_SpawnAct12Waypoint and DRLGOUTDOORS_SpawnAct12Shrines Top
expect(stats.waypointTile).toEqual({ x: 12, y: 12 })
})
test("Priority 2: DRLGOUTDOORS_SpawnAct12Waypoint selects roadside clearings over distant open campsites", () => {
test("Branch A: DRLGOUTDOORS_SpawnAct12Waypoint uses Cold Plains special placement (0 RNG rolls)", () => {
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 placedRects: PlacedPresetRect[] = []
const stats = makeEmptyStats()
const rng = new Rng(0x5678)
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 3, 1, dirtPathGrid, placedRects, rng, stats)
const initialSeed = rng.seed
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 3, 1, undefined, 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)
// Cold Plains special consumes exactly 0 RNG rolls
expect(rng.seed).toBe(initialSeed)
})
test("Priority 3: DRLGOUTDOORS_SpawnAct12Waypoint selects open campsite rooms when no road is present", () => {
test("Branch B: DRLGOUTDOORS_SpawnAct12Waypoint uses 8x8 cell grid placement avoiding preset obstacles", () => {
const canvas = makeOutdoorCanvas(64, 64)
// No road present (roadless level, e.g. Far Oasis level 43)
// Preset obstacle placed in cell (1, 1) -> tiles [8..15, 8..15]
const placedRects: PlacedPresetRect[] = [
{ name: "Act 2 - Desert Ruins 16X16", x: 28, y: 28, w: 8, h: 8 },
{ name: "Obstacle", x: 8, y: 8, w: 8, h: 8 },
]
const stats = makeEmptyStats()
@ -624,12 +613,9 @@ describe("DRLGOUTDOORS_SpawnAct12Waypoint and DRLGOUTDOORS_SpawnAct12Shrines Top
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)
// Waypoint should be placed in a valid cell not overlapping the obstacle [8..15, 8..15]
const inObstacle = wp.x >= 8 && wp.x < 16 && wp.y >= 8 && wp.y < 16
expect(inObstacle).toBe(false)
})
test("Player landing safety buffer around placed waypoints is obstacle-free", () => {