import { describe, expect, test } from "vitest" import { generateWilderness, OUTDOOR_WAYPOINT_LEVELS, actOfLevel, hasOutdoorWaypoint, ACT_POPULATION_CONFIG, createCanvas, DRLGOUTDOORS_SpawnAct12Waypoint, DRLGOUTDOORS_SpawnAct12Shrines, spawnOutdoorWaypoint, spawnOutdoorShrines, type Canvas, type PlacedPresetRect, type WildernessPiece, type WildernessStats, type WildernessSubstitution, } from "../src/game/wilderness.ts" import { actOfLevel as actsActOfLevel, hasOutdoorWaypoint as actsHasOutdoorWaypoint, type D2Table, } from "../src/game/acts.ts" import { Rng } from "../src/game/rng.ts" import { findWaypointSpot, type LinkGrid, } from "../src/game/level-links.ts" import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts" function makeMockDs1(width: number, height: number, hasGapInWalls = false, isOpen = false): Ds1 { const cells: Ds1Cell[][] = [] for (let y = 0; y < height; y += 1) { const row: Ds1Cell[] = [] for (let x = 0; x < width; x += 1) { let isWall: boolean if (isOpen) { // Clearings have open interior floor with perimeter walls isWall = (x === 0 || y === 0 || y === height - 1 || (x === width - 1 && !hasGapInWalls)) } else { isWall = !(hasGapInWalls && x === Math.floor(width / 2)) } row.push({ walls: isWall ? [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }] : [], floors: [{ prop1: 2, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }], shadows: [], substitutions: [], }) } cells.push(row) } return { version: 18, width, height, act: 1, substitutionType: 0, wallLayers: 1, floorLayers: 1, cells, objects: [], npcPathOffset: null, } } function makeGenericOutdoorPieces(prefix: string, landmarkName?: string): WildernessPiece[] { const pieces: WildernessPiece[] = [] for (let i = 1; i <= 12; i += 1) { pieces.push({ name: `${prefix} Border ${i}`, border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)], }) } pieces.push({ name: `${prefix} Town Transition East`, border: true, levels: [makeMockDs1(8, 8, true)] }) pieces.push({ name: `${prefix} Town Transition South`, border: true, levels: [makeMockDs1(8, 8, true)] }) if (landmarkName) { pieces.push({ name: landmarkName, border: false, levels: [makeMockDs1(8, 8, true, true)] }) } return pieces } function makeAct3JunglePieces(): WildernessPiece[] { const pieces: WildernessPiece[] = [ { name: "Act 3 - Jungle Tail", border: false, levels: [makeMockDs1(32, 32, true, true)] }, { name: "Act 3 - Jungle Head", border: false, levels: [makeMockDs1(32, 32, true, true)] }, { name: "Act 3 - Clearing Pygmy E", border: false, levels: [makeMockDs1(32, 32, true, true)] }, { name: "Act 3 - Clearing Webby E", border: false, levels: [makeMockDs1(32, 32, true, true)] }, { name: "Act 3 - Clearing Boggy E", border: false, levels: [makeMockDs1(32, 32, true, true)] }, { name: "Act 3 - Jungle NS W", border: false, levels: [makeMockDs1(32, 32, true, false)] }, ] return pieces } describe("Wilderness Thematic Waypoint and Shrine Distribution (Issue #56)", () => { test("actOfLevel correctly identifies acts from levelId or levelTypeName", () => { expect(actOfLevel(3, "Act 1 - Wilderness")).toBe(1) expect(actOfLevel(43, "Act 2 - Desert")).toBe(2) expect(actOfLevel(78, "Act 3 - Jungle")).toBe(3) expect(actOfLevel(106, "Act 4 - Mesa")).toBe(4) expect(actOfLevel(112, "Act 5 - Barricade")).toBe(5) // Falls back to numeric levelId ranges when levelTypeName is generic expect(actOfLevel(4, undefined)).toBe(1) expect(actOfLevel(42, undefined)).toBe(2) expect(actOfLevel(76, undefined)).toBe(3) expect(actOfLevel(107, undefined)).toBe(4) expect(actOfLevel(117, undefined)).toBe(5) }) test("actOfLevel resolves Act column from Levels.txt table when provided", () => { const mockLevelsTable: D2Table = { header: ["Id", "Name", "Act"], rows: [ ["2", "Blood Moor", "0"], ["42", "Dry Hills", "1"], ["76", "Spider Forest", "2"], ["106", "City of the Damned", "3"], ["112", "Arreat Plateau", "0"], // Expansion Act 5 row stores 0 in blizzard data ], } expect(actOfLevel(2, undefined, undefined, mockLevelsTable)).toBe(1) expect(actOfLevel(42, undefined, undefined, mockLevelsTable)).toBe(2) expect(actOfLevel(76, undefined, undefined, mockLevelsTable)).toBe(3) expect(actOfLevel(106, undefined, undefined, mockLevelsTable)).toBe(4) expect(actOfLevel(112, undefined, undefined, mockLevelsTable)).toBe(5) // Re-export from acts.ts is identical expect(actsActOfLevel).toBe(actOfLevel) expect(actsHasOutdoorWaypoint).toBe(hasOutdoorWaypoint) }) test("hasOutdoorWaypoint reads Levels.txt Waypoint column dynamically", () => { const mockLevelsTable: D2Table = { header: ["Id", "Name", "Act", "DrlgType", "Waypoint"], rows: [ ["1", "Rogue Encampment", "0", "1", "0"], // Town level -> false for outdoor waypoint ["2", "Blood Moor", "0", "3", "255"], // Outdoor, no waypoint (255) -> false ["3", "Cold Plains", "0", "3", "1"], // Outdoor, waypoint (1) -> true ["40", "Lut Gholein", "1", "1", "2"], // Town level -> false ["43", "Far Oasis", "1", "3", "3"], // Outdoor, waypoint -> true ["200", "Custom Field", "0", "3", "4"], // Custom outdoor level with waypoint -> true ["201", "Custom Field No WP", "0", "3", "255"], // Custom outdoor level without waypoint -> false ], } // Dynamic resolution via table expect(hasOutdoorWaypoint(3, mockLevelsTable)).toBe(true) expect(hasOutdoorWaypoint(2, mockLevelsTable)).toBe(false) expect(hasOutdoorWaypoint(1, mockLevelsTable)).toBe(false) // Town expect(hasOutdoorWaypoint(40, mockLevelsTable)).toBe(false) // Town expect(hasOutdoorWaypoint(43, mockLevelsTable)).toBe(true) expect(hasOutdoorWaypoint(200, mockLevelsTable)).toBe(true) expect(hasOutdoorWaypoint(201, mockLevelsTable)).toBe(false) // Fallback when no table provided expect(hasOutdoorWaypoint(3)).toBe(true) expect(hasOutdoorWaypoint(2)).toBe(false) expect(hasOutdoorWaypoint(43)).toBe(true) expect(hasOutdoorWaypoint(41)).toBe(false) }) test("OUTDOOR_WAYPOINT_LEVELS canonical set covers Acts 1..5 outdoor waypoints", () => { // Act 1 expect(OUTDOOR_WAYPOINT_LEVELS.has(3)).toBe(true) // Cold Plains expect(OUTDOOR_WAYPOINT_LEVELS.has(4)).toBe(true) // Stony Field expect(OUTDOOR_WAYPOINT_LEVELS.has(5)).toBe(true) // Dark Wood expect(OUTDOOR_WAYPOINT_LEVELS.has(6)).toBe(true) // Black Marsh expect(OUTDOOR_WAYPOINT_LEVELS.has(2)).toBe(false) // Blood Moor (no WP) // Act 2 expect(OUTDOOR_WAYPOINT_LEVELS.has(42)).toBe(true) // Dry Hills expect(OUTDOOR_WAYPOINT_LEVELS.has(43)).toBe(true) // Far Oasis expect(OUTDOOR_WAYPOINT_LEVELS.has(44)).toBe(true) // Lost City expect(OUTDOOR_WAYPOINT_LEVELS.has(46)).toBe(true) // Canyon of the Magi expect(OUTDOOR_WAYPOINT_LEVELS.has(41)).toBe(false) // Rocky Waste (no WP) // Act 3 expect(OUTDOOR_WAYPOINT_LEVELS.has(76)).toBe(true) // Spider Forest expect(OUTDOOR_WAYPOINT_LEVELS.has(77)).toBe(true) // Great Marsh expect(OUTDOOR_WAYPOINT_LEVELS.has(78)).toBe(true) // Flayer Jungle expect(OUTDOOR_WAYPOINT_LEVELS.has(79)).toBe(true) // Lower Kurast expect(OUTDOOR_WAYPOINT_LEVELS.has(80)).toBe(true) // Kurast Bazaar expect(OUTDOOR_WAYPOINT_LEVELS.has(81)).toBe(true) // Upper Kurast expect(OUTDOOR_WAYPOINT_LEVELS.has(83)).toBe(true) // Travincal // Act 4 expect(OUTDOOR_WAYPOINT_LEVELS.has(106)).toBe(true) // City of the Damned expect(OUTDOOR_WAYPOINT_LEVELS.has(107)).toBe(true) // River of Flame expect(OUTDOOR_WAYPOINT_LEVELS.has(104)).toBe(false) // Outer Steppes (no WP) // Act 5 expect(OUTDOOR_WAYPOINT_LEVELS.has(111)).toBe(true) // Frigid Highlands expect(OUTDOOR_WAYPOINT_LEVELS.has(112)).toBe(true) // Arreat Plateau expect(OUTDOOR_WAYPOINT_LEVELS.has(117)).toBe(true) // Frozen Tundra expect(OUTDOOR_WAYPOINT_LEVELS.has(110)).toBe(false) // Bloody Foothills (no WP) }) test("Act 2 roadless maps (Far Oasis 43, Lost City 44) spawn Waypoint id 10 near oasis/ruins or hub", () => { const pieces = makeGenericOutdoorPieces("Act 2 - Desert", "Act 2 - Ruin 1") const result = generateWilderness({ levelId: 43, levelName: "Far Oasis", levelTypeName: "Act 2 - Desert", sizeX: 80, sizeY: 80, subType: 8, subTheme: 0, seed: 0x434343, pieces, substitutions: [], }) expect(result.stats.waypointsSpawned).toBe(1) expect(result.stats.waypointTile).toBeDefined() expect(result.stats.waypointTile).not.toBeNull() // Waypoint object for Act 2 is ID 10 const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 10) expect(wpObj).toBeDefined() // Shrines in Act 2 are from Act 2 shrine pool (54, 55, 103, 104, 105, 126) const act2ShrinePool = new Set(ACT_POPULATION_CONFIG[2].shrinePool) const shrineObjs = result.level.objects.filter(o => o.type === 2 && act2ShrinePool.has(o.id)) expect(shrineObjs.length).toBeGreaterThanOrEqual(1) // Well for Act 2 is ID 234 const wellObj = result.level.objects.find(o => o.type === 2 && o.id === 234) expect(wellObj).toBeDefined() }) test("Act 2 Rocky Waste (level 41) does not spawn waypoint", () => { const pieces = makeGenericOutdoorPieces("Act 2 - Desert") const result = generateWilderness({ levelId: 41, levelName: "Rocky Waste", levelTypeName: "Act 2 - Desert", sizeX: 80, sizeY: 80, subType: 8, subTheme: 0, seed: 0x414141, pieces, substitutions: [], }) expect(result.stats.waypointsSpawned).toBe(0) expect(result.stats.waypointTile).toBeNull() }) test("Act 3 Flayer Jungle (level 78) spawns Waypoint id 1 and Act 3 shrines in jungle clearings", () => { const pieces = makeAct3JunglePieces() const result = generateWilderness({ levelId: 78, levelName: "Flayer Jungle", levelTypeName: "Act 3 - Jungle", sizeX: 64, sizeY: 192, subType: 10, subTheme: 0, seed: 0x787878, pieces, substitutions: [], }) expect(result.stats.waypointsSpawned).toBe(1) expect(result.stats.waypointTile).toBeDefined() expect(result.stats.waypointTile).not.toBeNull() // Waypoint object for Act 3 is ID 1 const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 1) expect(wpObj).toBeDefined() // Waypoint is inside the level bounds const wpTile = result.stats.waypointTile as { x: number; y: number } expect(wpTile.x).toBeGreaterThan(0) expect(wpTile.x).toBeLessThan(64) // Act 3 shrines spawned const act3ShrinePool = new Set(ACT_POPULATION_CONFIG[3].shrinePool) const shrineObjs = result.level.objects.filter(o => o.type === 2 && act3ShrinePool.has(o.id)) expect(shrineObjs.length).toBeGreaterThanOrEqual(1) }) test("Act 4 City of the Damned (level 106) spawns Waypoint id 0 and Act 4 shrines", () => { const pieces = makeGenericOutdoorPieces("Act 4 - Mesa") const result = generateWilderness({ levelId: 106, levelName: "City of the Damned", levelTypeName: "Act 4 - Mesa", sizeX: 80, sizeY: 80, subType: 12, subTheme: 0, seed: 0x106106, pieces, substitutions: [], }) expect(result.stats.waypointsSpawned).toBe(1) expect(result.stats.waypointTile).toBeDefined() // Act 4 waypoint id is 0 const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 0) expect(wpObj).toBeDefined() // Outer Steppes (104) has no waypoint const steppesResult = generateWilderness({ levelId: 104, levelName: "Outer Steppes", levelTypeName: "Act 4 - Mesa", sizeX: 80, sizeY: 80, subType: 12, subTheme: 0, seed: 0x104104, pieces, substitutions: [], }) expect(steppesResult.stats.waypointsSpawned).toBe(0) expect(steppesResult.stats.waypointTile).toBeNull() }) test("Act 5 Arreat Plateau (level 112) spawns Waypoint id 12 near ruins or central hub", () => { const pieces = makeGenericOutdoorPieces("Act 5 - Barricade", "Act 5 - Ruins 1") const result = generateWilderness({ levelId: 112, levelName: "Arreat Plateau", levelTypeName: "Act 5 - Barricade", sizeX: 80, sizeY: 80, subType: 14, subTheme: 0, seed: 0x112112, pieces, substitutions: [], }) expect(result.stats.waypointsSpawned).toBe(1) expect(result.stats.waypointTile).toBeDefined() // Act 5 waypoint id is 12 const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 12) expect(wpObj).toBeDefined() // Act 5 shrines spawned const act5ShrinePool = new Set(ACT_POPULATION_CONFIG[5].shrinePool) const shrineObjs = result.level.objects.filter(o => o.type === 2 && act5ShrinePool.has(o.id)) expect(shrineObjs.length).toBeGreaterThanOrEqual(1) }) test("Dispersed distribution: outdoor shrines maintain minimum distance spacing (>= 7 tiles)", () => { const pieces = makeGenericOutdoorPieces("Act 1 - Wild") const result = generateWilderness({ levelId: 3, levelName: "Cold Plains", levelTypeName: "Act 1 - Wilderness", sizeX: 96, sizeY: 96, subType: 6, subTheme: 0, seed: 0x8899aabb, pieces, substitutions: [], }) const shrineIds = new Set(ACT_POPULATION_CONFIG[1].shrinePool) const shrines = result.level.objects.filter(o => o.type === 2 && shrineIds.has(o.id)) // If multiple shrines are spawned, check pairwise distance >= 7 if (shrines.length >= 2) { for (let i = 0; i < shrines.length; i += 1) { for (let j = i + 1; j < shrines.length; j += 1) { const s1 = shrines[i]! const s2 = shrines[j]! const dist = Math.hypot( Math.floor(s1.x / 5) - Math.floor(s2.x / 5), Math.floor(s1.y / 5) - Math.floor(s2.y / 5), ) expect(dist).toBeGreaterThanOrEqual(7) } } } }) test("Thematic clustering: companion chests or scatter spawn within 2..4 tiles of shrines", () => { const pieces = makeGenericOutdoorPieces("Act 1 - Wild") // Use multiple seeds to observe companion spawning (60% probability) let foundClusteredCompanion = false const act1ChestsAndScatter = new Set([ ...ACT_POPULATION_CONFIG[1].chestPool, ...ACT_POPULATION_CONFIG[1].scatterPool, ]) const act1Shrines = new Set(ACT_POPULATION_CONFIG[1].shrinePool) for (let s = 0; s < 10; s += 1) { const result = generateWilderness({ levelId: 4, levelName: "Stony Field", levelTypeName: "Act 1 - Wilderness", sizeX: 80, sizeY: 80, subType: 6, subTheme: 0, seed: 0x5000 + s * 17, pieces, substitutions: [], }) const shrines = result.level.objects.filter(o => o.type === 2 && act1Shrines.has(o.id)) const companions = result.level.objects.filter(o => o.type === 2 && act1ChestsAndScatter.has(o.id)) for (const shrine of shrines) { const sx = Math.floor(shrine.x / 5) const sy = Math.floor(shrine.y / 5) for (const comp of companions) { const cx = Math.floor(comp.x / 5) const cy = Math.floor(comp.y / 5) const dist = Math.hypot(cx - sx, cy - sy) if (dist >= 1.9 && dist <= 4.5) { foundClusteredCompanion = true break } } if (foundClusteredCompanion) break } if (foundClusteredCompanion) break } expect(foundClusteredCompanion).toBe(true) }) test("shrineSubstitutions scales shrine count based on LvlSub max", () => { const pieces = makeGenericOutdoorPieces("Act 2 - Desert") const mockShrineSub: WildernessSubstitution = { name: "Act 2 - Shrines", type: 8, gridSize: 1, bordType: 0, dt1Mask: 0, prob: [100, 100, 100, 100, 100], trials: [1, 1, 1, 1, 1], max: [4, 4, 4, 4, 4], levels: [], } const result = generateWilderness({ levelId: 43, levelName: "Far Oasis", levelTypeName: "Act 2 - Desert", sizeX: 96, sizeY: 96, subType: 8, subTheme: 0, seed: 0x777777, pieces, substitutions: [], shrineSubstitutions: [mockShrineSub], }) // With max = 4 plus healing well, shrinesSpawned should scale up (>= 2) expect(Number(result.stats.shrinesSpawned)).toBeGreaterThanOrEqual(2) }) test("Determinism: same seed produces identical waypoint and shrine layouts", () => { const pieces = makeGenericOutdoorPieces("Act 2 - Desert", "Act 2 - Oasis 1") const seed = 0xabcdef12 const run1 = generateWilderness({ levelId: 43, levelName: "Far Oasis", levelTypeName: "Act 2 - Desert", sizeX: 80, sizeY: 80, subType: 8, subTheme: 0, seed, pieces, substitutions: [], }) const run2 = generateWilderness({ levelId: 43, levelName: "Far Oasis", levelTypeName: "Act 2 - Desert", sizeX: 80, sizeY: 80, subType: 8, subTheme: 0, seed, pieces, substitutions: [], }) expect(run1.stats.waypointsSpawned).toBe(run2.stats.waypointsSpawned) expect(run1.stats.waypointTile).toEqual(run2.stats.waypointTile) expect(run1.stats.shrinesSpawned).toBe(run2.stats.shrinesSpawned) expect(run1.stats.interactivesSpawned).toBe(run2.stats.interactivesSpawned) expect(run1.level.objects).toEqual(run2.level.objects) }) }) describe("DRLGOUTDOORS_SpawnAct12Waypoint and DRLGOUTDOORS_SpawnAct12Shrines Topological Placement (Issue #68)", () => { function makeOutdoorCanvas(width: number, height: number): Canvas { const cells: Ds1Cell[][] = [] for (let y = 0; y < height; y += 1) { const row: Ds1Cell[] = [] for (let x = 0; x < width; x += 1) { row.push({ walls: [], floors: [{ prop1: 2, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false, }], shadows: [], substitutions: [], }) } cells.push(row) } return { width, height, cells, wallLayers: 1, floorLayers: 1, substitutionLayers: 1, objects: [], } } function makeEmptyStats(): WildernessStats { return { substitutions: [], borderPieces: {}, unresolved: [], notes: [], borderStamped: 0, groundCells: 0, groundTile: null, sizeSource: "default", roadCells: 0, roadSegments: 0, anchors: 0, specialPresets: [], entrances: [], waypointsSpawned: 0, shrinesSpawned: 0, interactivesSpawned: 0, } } test("Priority 1: DRLGOUTDOORS_SpawnAct12Waypoint prioritizes preset waypoint objects over roadside and campsite", () => { const canvas = makeOutdoorCanvas(48, 48) // Pre-place a waypoint object at (12, 12) (tile 12 => x = 60, y = 60) canvas.objects.push({ type: 2, id: 37, // Act 1 waypoint id x: 12 * 5 + 2, y: 12 * 5 + 2, flags: 0, }) // Also supply a dirt path road down x = 24 const dirtPathGrid = new Uint8Array(48 * 48) for (let y = 0; y < 48; y += 1) { dirtPathGrid[y * 48 + 24] = 1 } // Also place a campsite preset at (36, 36) const placedRects: PlacedPresetRect[] = [ { name: "Act 1 - Camp", x: 36, y: 36, w: 8, h: 8 }, ] const stats = makeEmptyStats() const rng = new Rng(0x1234) DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 3, 1, dirtPathGrid, placedRects, rng, stats) expect(stats.waypointsSpawned).toBe(1) expect(stats.waypointTile).toEqual({ x: 12, y: 12 }) }) test("Branch A: DRLGOUTDOORS_SpawnAct12Waypoint uses Cold Plains special placement (0 RNG rolls)", () => { const canvas = makeOutdoorCanvas(64, 64) const placedRects: PlacedPresetRect[] = [] const stats = makeEmptyStats() const rng = new Rng(0x5678) const initialSeed = rng.seed DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 3, 1, undefined, placedRects, rng, stats) expect(stats.waypointsSpawned).toBe(1) expect(stats.waypointTile).toBeDefined() // Cold Plains special consumes exactly 0 RNG rolls expect(rng.seed).toBe(initialSeed) }) test("Branch B: DRLGOUTDOORS_SpawnAct12Waypoint uses 8x8 cell grid placement avoiding preset obstacles", () => { const canvas = makeOutdoorCanvas(64, 64) // Preset obstacle placed in cell (1, 1) -> tiles [8..15, 8..15] const placedRects: PlacedPresetRect[] = [ { name: "Obstacle", x: 8, y: 8, w: 8, h: 8 }, ] const stats = makeEmptyStats() const rng = new Rng(0x9abc) DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 43, 2, undefined, placedRects, rng, stats) expect(stats.waypointsSpawned).toBe(1) expect(stats.waypointTile).toBeDefined() const wp = stats.waypointTile! // Waypoint 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", () => { const canvas = makeOutdoorCanvas(48, 48) // Add walls at specific tiles to test obstacle-free landing buffer const obstacleCell = canvas.cells[24]![22]! canvas.cells[24]![22] = { ...obstacleCell, walls: [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false, }], } const dirtPathGrid = new Uint8Array(48 * 48) for (let y = 0; y < 48; y += 1) { dirtPathGrid[y * 48 + 16] = 1 } const stats = makeEmptyStats() const rng = new Rng(0x1122) DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 3, 1, dirtPathGrid, [], rng, stats) expect(stats.waypointsSpawned).toBe(1) const wp = stats.waypointTile! // Verify 2-tile perimeter (5x5 tiles around wp) is 100% obstacle-free for (let dy = -2; dy <= 2; dy += 1) { for (let dx = -2; dx <= 2; dx += 1) { const tx = wp.x + dx const ty = wp.y + dy const cell = canvas.cells[ty]![tx]! // Must have walkable floor expect(cell.floors.some(f => !f.hidden && f.prop1 !== 0)).toBe(true) // Must have NO walls expect(cell.walls.some(w => !w.hidden && w.prop1 !== 0)).toBe(false) } } }) test("DRLGOUTDOORS_SpawnAct12Shrines spaces shrines across exploration branches away from main road", () => { const canvas = makeOutdoorCanvas(64, 64) // Main spline road running vertically at x = 32 const dirtPathGrid = new Uint8Array(64 * 64) for (let y = 0; y < 64; y += 1) { dirtPathGrid[y * 64 + 32] = 1 } const stats = makeEmptyStats() const rng = new Rng(0x4455) DRLGOUTDOORS_SpawnAct12Shrines(canvas, 3, 1, dirtPathGrid, [], rng, stats) const act1Shrines = new Set(ACT_POPULATION_CONFIG[1].shrinePool) const shrines = canvas.objects.filter(o => o.type === 2 && act1Shrines.has(o.id)) // Shrines should be spawned along secondary branches (not directly on or within 2 tiles of main road) for (const shrine of shrines) { const sx = Math.floor(shrine.x / 5) expect(Math.abs(sx - 32)).toBeGreaterThanOrEqual(3) } // Pairwise distance between shrines must be >= 10 tiles for (let i = 0; i < shrines.length; i += 1) { for (let j = i + 1; j < shrines.length; j += 1) { const s1 = shrines[i]! const s2 = shrines[j]! const dist = Math.hypot( Math.floor(s1.x / 5) - Math.floor(s2.x / 5), Math.floor(s1.y / 5) - Math.floor(s2.y / 5), ) expect(dist).toBeGreaterThanOrEqual(10) } } }) test("findWaypointSpot in level-links.ts selects high-clearance topological locations rather than blind center search", () => { // 40x40 sub-tiles grid const W = 40 const H = 40 const blocked = new Uint8Array(W * H).fill(1) // all blocked initially // Create a narrow 1-sub-tile corridor at the exact center (y = 20, x = 15..25) for (let x = 15; x <= 25; x += 1) { blocked[20 * W + x] = 0 } // Create a wide open room at (10, 10) with 7x7 clear walkable sub-tiles for (let y = 7; y <= 13; y += 1) { for (let x = 7; x <= 13; x += 1) { blocked[y * W + x] = 0 } } const grid: LinkGrid = { cellsX: 8, cellsY: 8, gridWidth: W, gridHeight: H, blocked, } // The naive BFS from center would pick (20, 20) in the narrow corridor // findWaypointSpot evaluates clearance (>= 3x3) and selects inside the open room! const spot = findWaypointSpot(grid) expect(spot).not.toBeNull() expect(spot!.x).toBeGreaterThanOrEqual(8) expect(spot!.x).toBeLessThanOrEqual(12) expect(spot!.y).toBeGreaterThanOrEqual(8) expect(spot!.y).toBeLessThanOrEqual(12) }) })