From 7187154ac8addd07900ff39a30e4dc15f7fae5e6 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 11:14:52 +0000 Subject: [PATCH] =?UTF-8?q?feat(wilderness):=20=E8=BF=98=E5=8E=9F=20DRLGOU?= =?UTF-8?q?TDOORS=5FSpawnAct12Waypoint=20=E5=8E=9F=E7=89=88=208x8=20?= =?UTF-8?q?=E5=8D=95=E5=85=83=E7=BD=91=E6=A0=BC=E6=89=93=E4=B9=B1=E4=B8=8E?= =?UTF-8?q?=E6=9C=89=E6=95=88=E6=80=A7=E5=88=A4=E5=AE=9A=20(Issue=20#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 还原 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 --- src/game/wilderness.ts | 445 ++++++++++++++++----- tests/outdoor-waypoint-113c.test.ts | 350 ++++++++++++++++ tests/wilderness-waypoints-shrines.test.ts | 44 +- 3 files changed, 721 insertions(+), 118 deletions(-) create mode 100644 tests/outdoor-waypoint-113c.test.ts diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 0458c9a..3847810 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -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 - } - // 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 + // 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 (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 - } else { - pool = openCandidates + // 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 + } + } } - const chosen = pool.length > 0 - ? pool[rng.int(0, pool.length - 1)]! - : { x: cx, y: cy } + // 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 + } + } - canvas.objects.push({ - type: 2, - id: wpId, - x: chosen.x * SUB_TILES_PER_TILE + 2, - y: chosen.y * 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)})`) + // 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 { + 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)) + } + } + } + + // 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: 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)}) [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) diff --git a/tests/outdoor-waypoint-113c.test.ts b/tests/outdoor-waypoint-113c.test.ts new file mode 100644 index 0000000..f8b5ae7 --- /dev/null +++ b/tests/outdoor-waypoint-113c.test.ts @@ -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) + }) + }) +}) diff --git a/tests/wilderness-waypoints-shrines.test.ts b/tests/wilderness-waypoints-shrines.test.ts index 218625e..8e1a31c 100644 --- a/tests/wilderness-waypoints-shrines.test.ts +++ b/tests/wilderness-waypoints-shrines.test.ts @@ -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", () => {