diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 5ff9582..b241995 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -2928,8 +2928,78 @@ function stampAct1InteriorStoneWalls( * (`DRLGOUTDOORS_SpawnAct12Waypoint`, `DRLGOUTDOORS_SpawnAct12Shrines`) * ------------------------------------------------------------------------- */ -/** Outdoor levels in Act 1 and Act 2 that carry a Waypoint (`Levels.txt` `Waypoint != 255`). */ -const OUTDOOR_WAYPOINT_LEVELS = new Set([3, 4, 5, 6, 42, 43, 44]) +/** Outdoor levels in Acts 1..5 that carry a Waypoint (`Levels.txt` `Waypoint != 255`). */ +export const OUTDOOR_WAYPOINT_LEVELS = new Set([ + 3, 4, 5, 6, // Act 1: Cold Plains, Stony Field, Dark Wood, Black Marsh + 42, 43, 44, 46, // Act 2: Dry Hills, Far Oasis, Lost City, Canyon of the Magi + 76, 77, 78, 79, 80, 81, 83, // Act 3: Spider Forest, Great Marsh, Flayer Jungle, Lower Kurast, Kurast Bazaar, Upper Kurast, Travincal + 106, 107, // Act 4: City of the Damned, River of Flame + 111, 112, 117, // Act 5: Frigid Highlands, Arreat Plateau, Frozen Tundra +]) + +/** + * Resolves the numeric Act (1..5) for a given levelId and/or levelTypeName. + * Handles cases where DS1 preset levels report act = 1 by default. + */ +export function actOfLevel(levelId: number, levelTypeName?: string, fallbackAct?: number): number { + if (levelTypeName) { + const m = /^Act\s*([1-5])/i.exec(levelTypeName) + if (m && m[1]) return Number(m[1]) + } + if (levelId >= 1 && levelId <= 39) return 1 + if (levelId >= 40 && levelId <= 74) return 2 + if (levelId >= 75 && levelId <= 102) return 3 + if (levelId >= 103 && levelId <= 108) return 4 + if (levelId >= 109 && levelId <= 136) return 5 + if (fallbackAct !== undefined && fallbackAct >= 1 && fallbackAct <= 5) return fallbackAct + return 1 +} + +export interface ActPopulationConfig { + readonly waypointId: number + readonly shrinePool: readonly number[] + readonly wellId: number + readonly chestPool: readonly number[] + readonly scatterPool: readonly number[] +} + +export const ACT_POPULATION_CONFIG: Readonly> = { + 1: { + waypointId: 37, + shrinePool: [29, 82, 83, 84], + wellId: 31, + chestPool: [5, 89, 90, 91, 92, 93, 94], + scatterPool: [17, 18, 25], + }, + 2: { + waypointId: 10, + shrinePool: [54, 55, 103, 104, 105, 126], + wellId: 234, + chestPool: [32, 33, 44, 45, 46], + scatterPool: [14, 48, 122], + }, + 3: { + waypointId: 1, + shrinePool: [14, 85, 111, 152, 227, 231, 235, 236], + wellId: 234, + chestPool: [65, 66, 67, 80, 81], + scatterPool: [4, 5, 48, 179], + }, + 4: { + waypointId: 0, + shrinePool: [56, 152, 227, 231, 235, 236], + wellId: 234, + chestPool: [19, 20, 24, 25], + scatterPool: [40, 63, 179], + }, + 5: { + waypointId: 12, + shrinePool: [152, 227, 231, 235, 236], + wellId: 234, + chestPool: [24, 25, 26, 27, 28, 29], + scatterPool: [13, 179, 4], + }, +} function isTileClear( canvas: Canvas, @@ -2953,6 +3023,7 @@ function isTileClear( const cell = row[tx + dx] if (!cell) return false if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) return false + if (dx === 0 && dy === 0 && !cell.floors.some(f => !f.hidden && f.prop1 !== 0)) return false } } return true @@ -2986,9 +3057,13 @@ function isNearDirtRoad( /** * Automatically locate and place an outdoor Waypoint (`DRLGOUTDOORS_SpawnAct12Waypoint`) - * on a flat, open area near the main dirt road when not already provided by a preset. + * supporting authentic thematic clustering across Acts 1..5. + * + * In roadless wilderness (e.g. Far Oasis level 43, Flayer Jungle level 78, Arreat Plateau level 112), + * waypoints prioritize proximity to clearings, ruins, oases, or the central hub. + * In roadside wilderness, roadside candidates near landmarks or center are preferred. */ -function spawnAct12Waypoint( +export function spawnOutdoorWaypoint( canvas: Canvas, levelId: number, act: number, @@ -2996,12 +3071,14 @@ function spawnAct12Waypoint( placedRects: readonly PlacedPresetRect[], rng: Rng, stats: WildernessStats, + levelTypeName?: string, ): void { if (!OUTDOOR_WAYPOINT_LEVELS.has(levelId)) return - // Check if a preset already placed a Waypoint object (Act 1 WP id 37/52, Act 2 WM id 10/306) + // Check if a preset already placed a Waypoint object + const ALL_WAYPOINT_IDS = new Set([37, 52, 269, 10, 306, 1, 387, 0, 388, 12, 188]) const existingWp = canvas.objects.find( - o => o.type === 2 && (o.id === 37 || o.id === 52 || o.id === 10 || o.id === 306), + o => o.type === 2 && ALL_WAYPOINT_IDS.has(o.id), ) if (existingWp !== undefined) { stats.waypointsSpawned = 1 @@ -3012,27 +3089,95 @@ function spawnAct12Waypoint( return } - const roadCandidates: { x: number; y: number }[] = [] - const openCandidates: { x: number; y: number }[] = [] - const margin = Math.min(16, Math.floor(Math.min(canvas.width, canvas.height) / 4)) + const effectiveAct = actOfLevel(levelId, levelTypeName, act) + const config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]! + const wpId = config.waypointId - for (let y = margin; y < canvas.height - margin; y += 2) { - for (let x = margin; x < canvas.width - margin; x += 2) { + const isJungle = levelId >= 76 && levelId <= 78 + const marginX = isJungle ? 4 : Math.min(16, Math.floor(canvas.width / 4)) + const marginY = Math.min(16, Math.floor(canvas.height / 4)) + const 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 LANDMARK_REGEX = /(clearing|ruin|oasis|cottage|camp|building|temple|tomb|bivouac|shrine)/i + const landmarkRects = placedRects.filter(r => LANDMARK_REGEX.test(r.name)) + + const clearingCandidates: { x: number; y: number }[] = [] + const centralHubCandidates: { x: number; y: number }[] = [] + const roadsideCandidates: { x: number; y: number }[] = [] + const openCandidates: { x: number; y: number }[] = [] + + const isNearLandmark = (tx: number, ty: number): boolean => { + for (const r of landmarkRects) { + const dx = Math.max(r.x - tx, 0, tx - (r.x + r.w - 1)) + const dy = Math.max(r.y - ty, 0, ty - (r.y + r.h - 1)) + const d = Math.max(dx, dy) + if (d >= 1 && d <= 7) return true + } + // For Act 3 Jungle clearings (when stamped without placedRects) + if (isJungle && landmarkRects.length === 0) { + if (tx >= 4 && tx < 30 && ty >= 32 && ty < 160) return true + } + return false + } + + for (let y = marginY; y < canvas.height - marginY; y += 2) { + for (let x = marginX; x < canvas.width - marginX; x += 2) { if (!isTileClear(canvas, x, y, placedRects, 2)) continue - if (isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, x, y, 3, 6)) { - roadCandidates.push({ x, y }) - } else { - openCandidates.push({ x, y }) + + const nearLandmark = isNearLandmark(x, y) + const nearRoad = isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, x, y, 2, 6) + const inCentralHub = Math.hypot(x - cx, y - cy) <= hubRadius + + if (nearLandmark) clearingCandidates.push({ x, y }) + if (nearRoad) roadsideCandidates.push({ x, y }) + if (inCentralHub) centralHubCandidates.push({ x, y }) + openCandidates.push({ x, y }) + } + } + + if (openCandidates.length === 0) { + for (let y = marginY; y < canvas.height - marginY; y += 1) { + for (let x = marginX; x < canvas.width - marginX; x += 1) { + if (isTileClear(canvas, x, y, placedRects, 1)) { + openCandidates.push({ x, y }) + if (isNearLandmark(x, y)) clearingCandidates.push({ x, y }) + if (Math.hypot(x - cx, y - cy) <= hubRadius) centralHubCandidates.push({ x, y }) + } } } } - const pool = roadCandidates.length > 0 ? roadCandidates : openCandidates + let pool: { x: number; y: number }[] + if (dirtPathGrid !== undefined) { + const landmarkRoad = clearingCandidates.filter(c => isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, c.x, c.y, 2, 6)) + if (landmarkRoad.length > 0) { + pool = landmarkRoad + } else if (clearingCandidates.length > 0) { + pool = clearingCandidates + } else if (roadsideCandidates.length > 0) { + const roadHub = roadsideCandidates.filter(c => Math.hypot(c.x - cx, c.y - cy) <= hubRadius) + pool = roadHub.length > 0 ? roadHub : roadsideCandidates + } else if (centralHubCandidates.length > 0) { + pool = centralHubCandidates + } else { + pool = openCandidates + } + } else { + if (clearingCandidates.length > 0) { + pool = clearingCandidates + } else if (centralHubCandidates.length > 0) { + pool = centralHubCandidates + } else { + pool = openCandidates + } + } + const chosen = pool.length > 0 ? pool[rng.int(0, pool.length - 1)]! - : { x: Math.floor(canvas.width / 2), y: Math.floor(canvas.height / 2) } + : { x: cx, y: cy } - const wpId = act === 2 ? 10 : 37 canvas.objects.push({ type: 2, id: wpId, @@ -3046,10 +3191,16 @@ function spawnAct12Waypoint( } /** - * Scatter roadside shrines, healing wells, chests, and barrels/rocks (`DRLGOUTDOORS_SpawnAct12Shrines`) - * along dirt road edges and near ruins/cottages. + * Scatter outdoor shrines, healing wells, companion chests/scatter, and standalone interactives + * across Acts 1..5 (`DRLGOUTDOORS_SpawnAct12Shrines`). + * + * Implements: + * 1. Authentic thematic clustering: 60% chance for an accompanying companion object 2..4 tiles from each shrine. + * 2. Dispersed spatial distribution: Shrines spaced >= 10 tiles apart (relaxing to >= 7 if space constrained). + * 3. Act-specific shrine, well, chest, and scatter IDs. + * 4. Scaling based on `Levels.txt.SubShrine` / `LvlSub.txt` max clusters when provided. */ -function spawnAct12Shrines( +export function spawnOutdoorShrines( canvas: Canvas, levelId: number, act: number, @@ -3057,95 +3208,200 @@ function spawnAct12Shrines( placedRects: readonly PlacedPresetRect[], rng: Rng, stats: WildernessStats, + shrineSubstitutions?: readonly WildernessSubstitution[], + subTheme = 0, + levelTypeName?: string, ): void { - const isAct1Wild = levelId >= 2 && levelId <= 7 - const isAct2Wild = levelId >= 41 && levelId <= 46 - if (!isAct1Wild && !isAct2Wild) return + if (levelId <= 0 || levelId === 108 || levelId === 110) return - const margin = Math.min(10, Math.floor(Math.min(canvas.width, canvas.height) / 5)) + const isOutdoorLevel = + (levelId >= 2 && levelId <= 7) || levelId === 39 || + (levelId >= 41 && levelId <= 46) || + (levelId >= 76 && levelId <= 83) || + (levelId >= 104 && levelId <= 107) || + (levelId >= 111 && levelId <= 112) || levelId === 117 || + (levelTypeName !== undefined && /wilderness|desert|jungle|kurast|mesa|lava|siege|barricade/i.test(levelTypeName)) + if (!isOutdoorLevel) return + + const effectiveAct = actOfLevel(levelId, levelTypeName, act) + const config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]! + + const isJungle = levelId >= 76 && levelId <= 78 + const marginX = isJungle ? 4 : Math.min(10, Math.floor(canvas.width / 5)) + const marginY = Math.min(10, Math.floor(canvas.height / 5)) const candidates: { x: number; y: number }[] = [] - for (let y = margin; y < canvas.height - margin; y += 2) { - for (let x = margin; x < canvas.width - margin; x += 2) { + for (let y = marginY; y < canvas.height - marginY; y += 2) { + for (let x = marginX; x < canvas.width - marginX; x += 2) { if (!isTileClear(canvas, x, y, placedRects, 1)) continue - // Prefer roadside (2..5 tiles from road) or near preset ruins - const nearRoad = isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, x, y, 2, 5) - if (nearRoad || dirtPathGrid === undefined) { - candidates.push({ x, y }) - } + candidates.push({ x, y }) } } if (candidates.length === 0) { - for (let y = margin; y < canvas.height - margin; y += 4) { - for (let x = margin; x < canvas.width - margin; x += 4) { + for (let y = marginY; y < canvas.height - marginY; y += 1) { + for (let x = marginX; x < canvas.width - marginX; x += 1) { if (isTileClear(canvas, x, y, placedRects, 1)) candidates.push({ x, y }) } } } if (candidates.length === 0) return - const placedCoords: { x: number; y: number }[] = canvas.objects.map(o => ({ + const allPlacedObjects: { x: number; y: number }[] = canvas.objects.map(o => ({ x: Math.floor(o.x / SUB_TILES_PER_TILE), y: Math.floor(o.y / SUB_TILES_PER_TILE), })) + const placedShrineTiles: { x: number; y: number }[] = [] - const pickSpot = (): { x: number; y: number } | null => { + let shrinesSpawned = 0 + let interactivesSpawned = 0 + + // 1. Determine target shrine count based on LvlSub / SubShrine max + let targetShrineCount = rng.int(1, 2) + if (shrineSubstitutions && shrineSubstitutions.length > 0) { + let maxFromSub = 0 + for (const sub of shrineSubstitutions) { + const m = sub.max[subTheme] ?? sub.max[0] ?? 0 + if (m > maxFromSub) maxFromSub = m + } + if (maxFromSub > 0) { + const cap = Math.min(4, maxFromSub) + targetShrineCount = rng.int(Math.min(2, cap), cap) + } + } + + const pickShrineSpot = (minDist: number): { x: number; y: number } | null => { + const indices = Array.from({ length: candidates.length }, (_, i) => i) + for (let i = indices.length - 1; i > 0; i -= 1) { + const j = rng.int(0, i) + const tmp = indices[i]! + indices[i] = indices[j]! + indices[j] = tmp + } + for (const idx of indices) { + const spot = candidates[idx]! + const tooCloseToShrine = placedShrineTiles.some(p => Math.hypot(p.x - spot.x, p.y - spot.y) < minDist) + if (tooCloseToShrine) continue + const tooCloseToObject = allPlacedObjects.some(p => Math.hypot(p.x - spot.x, p.y - spot.y) < 4) + if (tooCloseToObject) continue + return spot + } + return null + } + + // 2. Spawn Shrines with Dispersed Distribution (>= 10 tiles, relaxing to >= 7 if constrained) + for (let i = 0; i < targetShrineCount; i += 1) { + const spot = pickShrineSpot(10) ?? pickShrineSpot(7) + if (!spot) break + + const shrineId = config.shrinePool[rng.int(0, config.shrinePool.length - 1)]! + canvas.objects.push({ + type: 2, + id: shrineId, + x: spot.x * SUB_TILES_PER_TILE + 2, + y: spot.y * SUB_TILES_PER_TILE + 2, + flags: 0, + }) + placedShrineTiles.push(spot) + allPlacedObjects.push(spot) + shrinesSpawned += 1 + + // 3. Thematic Clustering: 60% chance to spawn an accompanying companion object 2..4 tiles away + if (rng.int(0, 99) < 60) { + const isChest = rng.int(0, 99) < 40 + const pool = isChest ? config.chestPool : config.scatterPool + const companionId = pool[rng.int(0, pool.length - 1)]! + + const companionOffsets: { dx: number; dy: number }[] = [] + for (let dy = -4; dy <= 4; dy += 1) { + for (let dx = -4; dx <= 4; dx += 1) { + const d = Math.hypot(dx, dy) + if (d >= 2 && d <= 4) { + companionOffsets.push({ dx, dy }) + } + } + } + for (let oi = companionOffsets.length - 1; oi > 0; oi -= 1) { + const oj = rng.int(0, oi) + const tmp = companionOffsets[oi]! + companionOffsets[oi] = companionOffsets[oj]! + companionOffsets[oj] = tmp + } + + for (const off of companionOffsets) { + const cx = spot.x + off.dx + const cy = spot.y + off.dy + if (isTileClear(canvas, cx, cy, placedRects, 1)) { + const occupied = allPlacedObjects.some(p => p.x === cx && p.y === cy) + if (!occupied) { + canvas.objects.push({ + type: 2, + id: companionId, + x: cx * SUB_TILES_PER_TILE + 2, + y: cy * SUB_TILES_PER_TILE + 2, + flags: 0, + }) + allPlacedObjects.push({ x: cx, y: cy }) + interactivesSpawned += 1 + break + } + } + } + } + } + + // 4. Healing Well (1 well placed with >= 8 tiles distance from shrines) + const pickWellSpot = (): { x: number; y: number } | null => { + const indices = Array.from({ length: candidates.length }, (_, i) => i) + for (let i = indices.length - 1; i > 0; i -= 1) { + const j = rng.int(0, i) + const tmp = indices[i]! + indices[i] = indices[j]! + indices[j] = tmp + } + for (const idx of indices) { + const spot = candidates[idx]! + const farFromShrines = placedShrineTiles.every(p => Math.hypot(p.x - spot.x, p.y - spot.y) >= 8) + if (!farFromShrines) continue + const farFromObjects = allPlacedObjects.every(p => Math.hypot(p.x - spot.x, p.y - spot.y) >= 4) + if (!farFromObjects) continue + return spot + } + return null + } + + const wellSpot = pickWellSpot() + if (wellSpot) { + canvas.objects.push({ + type: 2, + id: config.wellId, + x: wellSpot.x * SUB_TILES_PER_TILE + 2, + y: wellSpot.y * SUB_TILES_PER_TILE + 2, + flags: 0, + }) + allPlacedObjects.push(wellSpot) + shrinesSpawned += 1 + } + + // 5. Standalone Chests (1..2) + const pickStandaloneSpot = (minDist: number): { x: number; y: number } | null => { for (let attempt = 0; attempt < 24; attempt += 1) { const spot = candidates[rng.int(0, candidates.length - 1)]! - const tooClose = placedCoords.some(p => Math.hypot(p.x - spot.x, p.y - spot.y) < 4) + const tooClose = allPlacedObjects.some(p => Math.hypot(p.x - spot.x, p.y - spot.y) < minDist) if (!tooClose) { - placedCoords.push(spot) + allPlacedObjects.push(spot) return spot } } return null } - const shrinePool = act === 2 ? [54, 55, 152, 227] : [29, 82, 83, 84] - const wellId = act === 2 ? 234 : 31 - const chestPool = act === 2 ? [32, 33, 44] : [5, 89, 90, 93] - const scatterPool = act === 2 ? [48] : [17, 18, 25] - - let shrinesSpawned = 0 - let interactivesSpawned = 0 - - // 1..2 Shrines - const shrineCount = rng.int(1, 2) - for (let i = 0; i < shrineCount; i += 1) { - const spot = pickSpot() - if (!spot) break - const id = shrinePool[rng.int(0, shrinePool.length - 1)]! - canvas.objects.push({ - type: 2, - id, - x: spot.x * SUB_TILES_PER_TILE + 2, - y: spot.y * SUB_TILES_PER_TILE + 2, - flags: 0, - }) - shrinesSpawned += 1 - } - - // 1 Healing Well - const wellSpot = pickSpot() - if (wellSpot) { - canvas.objects.push({ - type: 2, - id: wellId, - x: wellSpot.x * SUB_TILES_PER_TILE + 2, - y: wellSpot.y * SUB_TILES_PER_TILE + 2, - flags: 0, - }) - shrinesSpawned += 1 - } - - // 1..3 Chests - const chestCount = rng.int(1, 3) + const chestCount = rng.int(1, 2) for (let i = 0; i < chestCount; i += 1) { - const spot = pickSpot() + const spot = pickStandaloneSpot(4) if (!spot) break - const id = chestPool[rng.int(0, chestPool.length - 1)]! + const chestId = config.chestPool[rng.int(0, config.chestPool.length - 1)]! canvas.objects.push({ type: 2, - id, + id: chestId, x: spot.x * SUB_TILES_PER_TILE + 2, y: spot.y * SUB_TILES_PER_TILE + 2, flags: 0, @@ -3153,15 +3409,15 @@ function spawnAct12Shrines( interactivesSpawned += 1 } - // 2..4 Barrels / Campfires / Stash Rocks - const scatterCount = rng.int(2, 4) + // 6. Standalone Scatter: Barrels / Campfires / Stash Rocks (2..3) + const scatterCount = rng.int(2, 3) for (let i = 0; i < scatterCount; i += 1) { - const spot = pickSpot() + const spot = pickStandaloneSpot(4) if (!spot) break - const id = scatterPool[rng.int(0, scatterPool.length - 1)]! + const scatterId = config.scatterPool[rng.int(0, config.scatterPool.length - 1)]! canvas.objects.push({ type: 2, - id, + id: scatterId, x: spot.x * SUB_TILES_PER_TILE + 2, y: spot.y * SUB_TILES_PER_TILE + 2, flags: 0, @@ -3173,6 +3429,9 @@ function spawnAct12Shrines( stats.interactivesSpawned = interactivesSpawned } +export const spawnAct12Waypoint = spawnOutdoorWaypoint +export const spawnAct12Shrines = spawnOutdoorShrines + /** * Anchor fixed Act 1 SuperUniques (Bishibosh, Rakanishu, Treehead WoodFist, Blood Raven) * to their landmark presets and place Guard NPC Flavie (`rogue2`) at the Blood Moor <-> Cold Plains border gate. @@ -3480,6 +3739,9 @@ export function generateWilderness(request: WildernessRequest): WildernessResult placedSpecial = stampAct3Travincal(canvas, request.pieces, stats) } else if (request.levelId >= 76 && request.levelId <= 78) { placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats) + const populationRng = new Rng(request.seed ^ 0x6c07) + spawnOutdoorWaypoint(canvas, request.levelId, act, undefined, [], populationRng, stats, request.levelTypeName) + spawnOutdoorShrines(canvas, request.levelId, act, undefined, [], populationRng, stats, request.shrineSubstitutions, 0, request.levelTypeName) } else { placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, stats) } @@ -3532,6 +3794,11 @@ export function generateWilderness(request: WildernessRequest): WildernessResult substitutions: stats.substitutions, substitutedClusters: placedSpecial.size, objects: canvas.objects.length, + waypointsSpawned: stats.waypointsSpawned ?? 0, + waypointTile: stats.waypointTile ?? null, + shrinesSpawned: stats.shrinesSpawned ?? 0, + interactivesSpawned: stats.interactivesSpawned ?? 0, + landmarkEntities: stats.landmarkEntities ?? [], cells: canvas.width * canvas.height, unresolved: stats.unresolved, unimplementedPasses: UNIMPLEMENTED_PASSES, @@ -3712,8 +3979,8 @@ 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) - spawnAct12Waypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats) - spawnAct12Shrines(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats) + spawnOutdoorWaypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.levelTypeName) + spawnOutdoorShrines(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.shrineSubstitutions, themeIndex, request.levelTypeName) spawnLandmarkEntities(canvas, request.levelId, act1Plan, allPlacedRects, gridWidth, gridHeight, stats) for (const piece of fillPieces) { diff --git a/tests/wilderness-waypoints-shrines.test.ts b/tests/wilderness-waypoints-shrines.test.ts new file mode 100644 index 0000000..a0e9e80 --- /dev/null +++ b/tests/wilderness-waypoints-shrines.test.ts @@ -0,0 +1,429 @@ +import { describe, expect, test } from "vitest" +import { + generateWilderness, + OUTDOOR_WAYPOINT_LEVELS, + actOfLevel, + ACT_POPULATION_CONFIG, + type WildernessPiece, + type WildernessSubstitution, +} from "../src/game/wilderness.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("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 clearing column (x < 32) + const wpTile = result.stats.waypointTile as { x: number; y: number } + expect(wpTile.x).toBeLessThan(32) + + // 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) + }) +})