feat(map-gen/wilderness): thematic clustering and dispersed distribution for waypoints and shrines (fixes #56)
This commit is contained in:
parent
1fa3f8b7a2
commit
46b7166f22
|
|
@ -2928,8 +2928,78 @@ function stampAct1InteriorStoneWalls(
|
||||||
* (`DRLGOUTDOORS_SpawnAct12Waypoint`, `DRLGOUTDOORS_SpawnAct12Shrines`)
|
* (`DRLGOUTDOORS_SpawnAct12Waypoint`, `DRLGOUTDOORS_SpawnAct12Shrines`)
|
||||||
* ------------------------------------------------------------------------- */
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
/** Outdoor levels in Act 1 and Act 2 that carry a Waypoint (`Levels.txt` `Waypoint != 255`). */
|
/** Outdoor levels in Acts 1..5 that carry a Waypoint (`Levels.txt` `Waypoint != 255`). */
|
||||||
const OUTDOOR_WAYPOINT_LEVELS = new Set([3, 4, 5, 6, 42, 43, 44])
|
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<Record<number, ActPopulationConfig>> = {
|
||||||
|
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(
|
function isTileClear(
|
||||||
canvas: Canvas,
|
canvas: Canvas,
|
||||||
|
|
@ -2953,6 +3023,7 @@ function isTileClear(
|
||||||
const cell = row[tx + dx]
|
const cell = row[tx + dx]
|
||||||
if (!cell) return false
|
if (!cell) return false
|
||||||
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) 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
|
return true
|
||||||
|
|
@ -2986,9 +3057,13 @@ function isNearDirtRoad(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Automatically locate and place an outdoor Waypoint (`DRLGOUTDOORS_SpawnAct12Waypoint`)
|
* 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,
|
canvas: Canvas,
|
||||||
levelId: number,
|
levelId: number,
|
||||||
act: number,
|
act: number,
|
||||||
|
|
@ -2996,12 +3071,14 @@ function spawnAct12Waypoint(
|
||||||
placedRects: readonly PlacedPresetRect[],
|
placedRects: readonly PlacedPresetRect[],
|
||||||
rng: Rng,
|
rng: Rng,
|
||||||
stats: WildernessStats,
|
stats: WildernessStats,
|
||||||
|
levelTypeName?: string,
|
||||||
): void {
|
): void {
|
||||||
if (!OUTDOOR_WAYPOINT_LEVELS.has(levelId)) return
|
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(
|
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) {
|
if (existingWp !== undefined) {
|
||||||
stats.waypointsSpawned = 1
|
stats.waypointsSpawned = 1
|
||||||
|
|
@ -3012,27 +3089,95 @@ function spawnAct12Waypoint(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const roadCandidates: { x: number; y: number }[] = []
|
const effectiveAct = actOfLevel(levelId, levelTypeName, act)
|
||||||
const openCandidates: { x: number; y: number }[] = []
|
const config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]!
|
||||||
const margin = Math.min(16, Math.floor(Math.min(canvas.width, canvas.height) / 4))
|
const wpId = config.waypointId
|
||||||
|
|
||||||
for (let y = margin; y < canvas.height - margin; y += 2) {
|
const isJungle = levelId >= 76 && levelId <= 78
|
||||||
for (let x = margin; x < canvas.width - margin; x += 2) {
|
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 (!isTileClear(canvas, x, y, placedRects, 2)) continue
|
||||||
if (isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, x, y, 3, 6)) {
|
|
||||||
roadCandidates.push({ x, y })
|
const nearLandmark = isNearLandmark(x, y)
|
||||||
} else {
|
const nearRoad = isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, x, y, 2, 6)
|
||||||
openCandidates.push({ x, y })
|
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
|
const chosen = pool.length > 0
|
||||||
? pool[rng.int(0, pool.length - 1)]!
|
? 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({
|
canvas.objects.push({
|
||||||
type: 2,
|
type: 2,
|
||||||
id: wpId,
|
id: wpId,
|
||||||
|
|
@ -3046,10 +3191,16 @@ function spawnAct12Waypoint(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scatter roadside shrines, healing wells, chests, and barrels/rocks (`DRLGOUTDOORS_SpawnAct12Shrines`)
|
* Scatter outdoor shrines, healing wells, companion chests/scatter, and standalone interactives
|
||||||
* along dirt road edges and near ruins/cottages.
|
* 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,
|
canvas: Canvas,
|
||||||
levelId: number,
|
levelId: number,
|
||||||
act: number,
|
act: number,
|
||||||
|
|
@ -3057,95 +3208,200 @@ function spawnAct12Shrines(
|
||||||
placedRects: readonly PlacedPresetRect[],
|
placedRects: readonly PlacedPresetRect[],
|
||||||
rng: Rng,
|
rng: Rng,
|
||||||
stats: WildernessStats,
|
stats: WildernessStats,
|
||||||
|
shrineSubstitutions?: readonly WildernessSubstitution[],
|
||||||
|
subTheme = 0,
|
||||||
|
levelTypeName?: string,
|
||||||
): void {
|
): void {
|
||||||
const isAct1Wild = levelId >= 2 && levelId <= 7
|
if (levelId <= 0 || levelId === 108 || levelId === 110) return
|
||||||
const isAct2Wild = levelId >= 41 && levelId <= 46
|
|
||||||
if (!isAct1Wild && !isAct2Wild) 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 }[] = []
|
const candidates: { x: number; y: number }[] = []
|
||||||
for (let y = margin; y < canvas.height - margin; y += 2) {
|
for (let y = marginY; y < canvas.height - marginY; y += 2) {
|
||||||
for (let x = margin; x < canvas.width - margin; x += 2) {
|
for (let x = marginX; x < canvas.width - marginX; x += 2) {
|
||||||
if (!isTileClear(canvas, x, y, placedRects, 1)) continue
|
if (!isTileClear(canvas, x, y, placedRects, 1)) continue
|
||||||
// Prefer roadside (2..5 tiles from road) or near preset ruins
|
candidates.push({ x, y })
|
||||||
const nearRoad = isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, x, y, 2, 5)
|
|
||||||
if (nearRoad || dirtPathGrid === undefined) {
|
|
||||||
candidates.push({ x, y })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
for (let y = margin; y < canvas.height - margin; y += 4) {
|
for (let y = marginY; y < canvas.height - marginY; y += 1) {
|
||||||
for (let x = margin; x < canvas.width - margin; x += 4) {
|
for (let x = marginX; x < canvas.width - marginX; x += 1) {
|
||||||
if (isTileClear(canvas, x, y, placedRects, 1)) candidates.push({ x, y })
|
if (isTileClear(canvas, x, y, placedRects, 1)) candidates.push({ x, y })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (candidates.length === 0) return
|
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),
|
x: Math.floor(o.x / SUB_TILES_PER_TILE),
|
||||||
y: Math.floor(o.y / 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) {
|
for (let attempt = 0; attempt < 24; attempt += 1) {
|
||||||
const spot = candidates[rng.int(0, candidates.length - 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) {
|
if (!tooClose) {
|
||||||
placedCoords.push(spot)
|
allPlacedObjects.push(spot)
|
||||||
return spot
|
return spot
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const shrinePool = act === 2 ? [54, 55, 152, 227] : [29, 82, 83, 84]
|
const chestCount = rng.int(1, 2)
|
||||||
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)
|
|
||||||
for (let i = 0; i < chestCount; i += 1) {
|
for (let i = 0; i < chestCount; i += 1) {
|
||||||
const spot = pickSpot()
|
const spot = pickStandaloneSpot(4)
|
||||||
if (!spot) break
|
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({
|
canvas.objects.push({
|
||||||
type: 2,
|
type: 2,
|
||||||
id,
|
id: chestId,
|
||||||
x: spot.x * SUB_TILES_PER_TILE + 2,
|
x: spot.x * SUB_TILES_PER_TILE + 2,
|
||||||
y: spot.y * SUB_TILES_PER_TILE + 2,
|
y: spot.y * SUB_TILES_PER_TILE + 2,
|
||||||
flags: 0,
|
flags: 0,
|
||||||
|
|
@ -3153,15 +3409,15 @@ function spawnAct12Shrines(
|
||||||
interactivesSpawned += 1
|
interactivesSpawned += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2..4 Barrels / Campfires / Stash Rocks
|
// 6. Standalone Scatter: Barrels / Campfires / Stash Rocks (2..3)
|
||||||
const scatterCount = rng.int(2, 4)
|
const scatterCount = rng.int(2, 3)
|
||||||
for (let i = 0; i < scatterCount; i += 1) {
|
for (let i = 0; i < scatterCount; i += 1) {
|
||||||
const spot = pickSpot()
|
const spot = pickStandaloneSpot(4)
|
||||||
if (!spot) break
|
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({
|
canvas.objects.push({
|
||||||
type: 2,
|
type: 2,
|
||||||
id,
|
id: scatterId,
|
||||||
x: spot.x * SUB_TILES_PER_TILE + 2,
|
x: spot.x * SUB_TILES_PER_TILE + 2,
|
||||||
y: spot.y * SUB_TILES_PER_TILE + 2,
|
y: spot.y * SUB_TILES_PER_TILE + 2,
|
||||||
flags: 0,
|
flags: 0,
|
||||||
|
|
@ -3173,6 +3429,9 @@ function spawnAct12Shrines(
|
||||||
stats.interactivesSpawned = interactivesSpawned
|
stats.interactivesSpawned = interactivesSpawned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const spawnAct12Waypoint = spawnOutdoorWaypoint
|
||||||
|
export const spawnAct12Shrines = spawnOutdoorShrines
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anchor fixed Act 1 SuperUniques (Bishibosh, Rakanishu, Treehead WoodFist, Blood Raven)
|
* 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.
|
* 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)
|
placedSpecial = stampAct3Travincal(canvas, request.pieces, stats)
|
||||||
} else if (request.levelId >= 76 && request.levelId <= 78) {
|
} else if (request.levelId >= 76 && request.levelId <= 78) {
|
||||||
placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats)
|
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 {
|
} else {
|
||||||
placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, stats)
|
placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, stats)
|
||||||
}
|
}
|
||||||
|
|
@ -3532,6 +3794,11 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
substitutions: stats.substitutions,
|
substitutions: stats.substitutions,
|
||||||
substitutedClusters: placedSpecial.size,
|
substitutedClusters: placedSpecial.size,
|
||||||
objects: canvas.objects.length,
|
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,
|
cells: canvas.width * canvas.height,
|
||||||
unresolved: stats.unresolved,
|
unresolved: stats.unresolved,
|
||||||
unimplementedPasses: UNIMPLEMENTED_PASSES,
|
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)
|
// Step 5: Outdoor Population Phase (Waypoints, Roadside Shrines/Chests, Landmark SuperUniques & Flavie)
|
||||||
const populationRng = new Rng(request.seed ^ 0x6c07)
|
const populationRng = new Rng(request.seed ^ 0x6c07)
|
||||||
spawnAct12Waypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats)
|
spawnOutdoorWaypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.levelTypeName)
|
||||||
spawnAct12Shrines(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats)
|
spawnOutdoorShrines(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.shrineSubstitutions, themeIndex, request.levelTypeName)
|
||||||
spawnLandmarkEntities(canvas, request.levelId, act1Plan, allPlacedRects, gridWidth, gridHeight, stats)
|
spawnLandmarkEntities(canvas, request.levelId, act1Plan, allPlacedRects, gridWidth, gridHeight, stats)
|
||||||
|
|
||||||
for (const piece of fillPieces) {
|
for (const piece of fillPieces) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue