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:
parent
a1a549e8a0
commit
7187154ac8
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue