fix(wilderness): prevent sealed borders on outdoor levels and track preset failures

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
troytt 2026-09-17 13:43:51 +00:00
parent b8810072ce
commit fea677b0b4
2 changed files with 149 additions and 35 deletions

View File

@ -1405,6 +1405,7 @@ export function getPerimeterOpenings(
levelId: number,
gridWidth: number,
gridHeight: number,
gates?: readonly PlannedGate[],
): Set<string> {
const openings = new Set<string>()
const key = (x: number, y: number) => `${String(x)},${String(y)}`
@ -1414,25 +1415,32 @@ export function getPerimeterOpenings(
return openings
}
// Outdoor wilderness levels across Acts 1, 2, 3, 4, 5:
// Act 1 (2..7), Act 2 desert (41..46), Act 3 Kurast (79..82),
// Act 4 Mesa (104..106), Act 5 outdoor (111..112, 117):
// Default entrance on South edge, exit on East edge
if (
(levelId >= 2 && levelId <= 7) ||
(levelId >= 41 && levelId <= 46) ||
(levelId >= 79 && levelId <= 82) ||
(levelId >= 104 && levelId <= 106) ||
(levelId >= 111 && levelId <= 112) ||
levelId === 117
) {
const inX = Math.floor(gridWidth / 2)
openings.add(key(inX, gridHeight - 1))
const outY = Math.floor(gridHeight / 2)
openings.add(key(gridWidth - 1, outY))
if (gates !== undefined && gates.length > 0) {
for (const gate of gates) {
switch (gate.side) {
case 'north':
openings.add(key(Math.floor(gridWidth / 2), 0))
break
case 'south':
openings.add(key(Math.floor(gridWidth / 2), gridHeight - 1))
break
case 'west':
openings.add(key(0, Math.floor(gridHeight / 2)))
break
case 'east':
openings.add(key(gridWidth - 1, Math.floor(gridHeight / 2)))
break
}
}
return openings
}
// Outdoor wilderness levels across Acts 1..5:
// Default entrance on South edge, exit on East edge so borders are never sealed shut
const inX = Math.floor(gridWidth / 2)
openings.add(key(inX, gridHeight - 1))
const outY = Math.floor(gridHeight / 2)
openings.add(key(gridWidth - 1, outY))
return openings
}
@ -1458,6 +1466,7 @@ export function getPerimeterOpenings(
* @param levelId - optional level id for perimeter opening rules.
* @param openGates - optional map from `${x},${y}` block key to forced variant index.
* @param claimedBorderBlocks - optional set of `${x},${y}` keys to skip (e.g. Town Transitions).
* @param gates - optional planned gates from the world graph.
* @returns the number of pieces stamped.
*/
function layBorder(
@ -1470,6 +1479,7 @@ function layBorder(
levelId?: number,
openGates?: ReadonlyMap<string, number>,
claimedBorderBlocks?: ReadonlySet<string>,
gates?: readonly PlannedGate[],
): number {
if (pieces.length === 0) {
stats.unresolved.push('no border piece for this level type')
@ -1512,7 +1522,12 @@ function layBorder(
{ x: 0, y: gridHeight - 1 }, // SW
]
const openings = levelId !== undefined ? getPerimeterOpenings(levelId, gridWidth, gridHeight) : new Set<string>()
const openings = levelId !== undefined ? getPerimeterOpenings(levelId, gridWidth, gridHeight, gates) : new Set<string>()
if (openGates !== undefined) {
for (const gateKey of openGates.keys()) {
openings.add(gateKey)
}
}
let cursor = rng.int(0, ordered.length - 1)
let stamped = 0
@ -1673,6 +1688,7 @@ function planAct1OutdoorLevel(
gridWidth: number,
gridHeight: number,
rng: Rng,
stats?: WildernessStats,
): Act1OutdoorPlan {
const openGates = new Map<string, number>()
const claimedBorderBlocks = new Set<string>()
@ -1802,13 +1818,21 @@ function planAct1OutdoorLevel(
toLevelId?: number,
): boolean => {
const piece = findPiece(prefix)
if (piece === undefined || piece.levels.length === 0) return false
if (piece === undefined || piece.levels.length === 0) {
stats?.unresolved.push(`preset piece not found: ${prefix}`)
stats?.notes.push(`failed to place preset: ${label} (missing piece ${prefix})`)
return false
}
const variant = rng.int(0, piece.levels.length - 1)
const ds1 = piece.levels[variant]!
const blocksX = Math.max(1, Math.floor(ds1.width / TILES_PER_BLOCK))
const blocksY = Math.max(1, Math.floor(ds1.height / TILES_PER_BLOCK))
const pos = pickFreeInteriorBox(blocksX, blocksY, 2)
if (pos === null) return false
if (pos === null) {
stats?.unresolved.push(`no free interior box for preset: ${label} (${prefix})`)
stats?.notes.push(`failed to place preset: ${label} (no free interior box ${String(blocksX)}x${String(blocksY)})`)
return false
}
const { bx, by } = pos
claimBox(bx, by, blocksX, blocksY, true)
specialPresets.push({ name: piece.name, ds1, bx, by, blocksX, blocksY })
@ -1900,7 +1924,11 @@ function planAct1OutdoorLevel(
*/
const placeGraveyardPreset = (withGate: boolean): boolean => {
const gyPiece = findPiece('Act 1 - Graveyard')
if (gyPiece?.levels[0] === undefined || gridWidth < 5 || gridHeight < 6) return false
if (gyPiece?.levels[0] === undefined || gridWidth < 5 || gridHeight < 6) {
stats?.unresolved.push('failed to place Graveyard preset')
stats?.notes.push('failed to place Graveyard preset (missing piece or grid too small)')
return false
}
const ds1 = gyPiece.levels[0]
claimBox(1, 1, 3, 4, true)
specialPresets.push({ name: gyPiece.name, ds1, bx: 1, by: 1, blocksX: 3, blocksY: 4 })
@ -1941,7 +1969,9 @@ function planAct1OutdoorLevel(
*/
const placeInteriorPresets = (levelId: number): void => {
if (levelId === 2) {
addInteriorPresetWithRoad('Act 1 - DOE Entrance', 'Den of Evil Entrance', undefined, 8)
if (!addInteriorPresetWithRoad('Act 1 - DOE Entrance', 'Den of Evil Entrance', undefined, 8)) {
stats?.notes.push('Den of Evil Entrance preset failed to place')
}
const bivouac = findPiece('Act 1 - Bivouac') ?? findPiece('Act 1 - Cottages')
if (bivouac?.levels[0] !== undefined) {
const ds1 = bivouac.levels[0]
@ -1951,28 +1981,52 @@ function planAct1OutdoorLevel(
if (pos !== null) {
claimBox(pos.bx, pos.by, w, h, true)
specialPresets.push({ name: bivouac.name, ds1, bx: pos.bx, by: pos.by, blocksX: w, blocksY: h })
} else {
stats?.notes.push('Bivouac preset failed to place (no free box)')
}
}
} else if (levelId === 3) {
addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Cave Level 1 Entrance', undefined, 9)
addInteriorPresetWithRoad('Act 1 - Bivouac', 'Bivouac Camp', 'south')
if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Cave Level 1 Entrance', undefined, 9)) {
stats?.notes.push('Cave Level 1 Entrance preset failed to place')
}
if (!addInteriorPresetWithRoad('Act 1 - Bivouac', 'Bivouac Camp', 'south')) {
stats?.notes.push('Bivouac Camp preset failed to place')
}
} else if (levelId === 4) {
addInteriorPresetWithRoad('Act 1 - Cairn Stones', 'Cairn Stones', 'south', 38)
addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Entrance', undefined, 10)
if (!addInteriorPresetWithRoad('Act 1 - Cairn Stones', 'Cairn Stones', 'south', 38)) {
stats?.notes.push('Cairn Stones preset failed to place')
}
if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Entrance', undefined, 10)) {
stats?.notes.push('Underground Passage Entrance preset failed to place')
}
} else if (levelId === 5) {
addInteriorPresetWithRoad('Act 1 - Inifus', 'Tree of Inifuss', 'south')
addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Exit', undefined, 10)
if (!addInteriorPresetWithRoad('Act 1 - Inifus', 'Tree of Inifuss', 'south')) {
stats?.notes.push('Tree of Inifuss preset failed to place')
}
if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Underground Passage Exit', undefined, 10)) {
stats?.notes.push('Underground Passage Exit preset failed to place')
}
} else if (levelId === 6) {
// The Forgotten Tower is always beside one of the Black Marsh's roads,
// never dropped on open ground, which is why it goes through the
// road-joining path rather than the plain scatter.
addInteriorPresetWithRoad('Act 1 - Tower 1', 'Forgotten Tower', 'south', 20)
addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Hole Level 1 Entrance', undefined, 11)
if (!addInteriorPresetWithRoad('Act 1 - Tower 1', 'Forgotten Tower', 'south', 20)) {
stats?.notes.push('Forgotten Tower preset failed to place')
}
if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Hole Level 1 Entrance', undefined, 11)) {
stats?.notes.push('Hole Level 1 Entrance preset failed to place')
}
} else if (levelId === 7) {
addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Pit Level 1 Entrance', undefined, 12)
if (!addInteriorPresetWithRoad('Act 1 - Cave Entrance', 'Pit Level 1 Entrance', undefined, 12)) {
stats?.notes.push('Pit Level 1 Entrance preset failed to place')
}
} else if (levelId === 39) {
addInteriorPresetWithRoad('Act 1 - Corral Fill', 'Cow Corral', 'south')
addInteriorPresetWithRoad('Act 1 - Bivouac', 'Cow Bivouac', 'east')
if (!addInteriorPresetWithRoad('Act 1 - Corral Fill', 'Cow Corral', 'south')) {
stats?.notes.push('Cow Corral preset failed to place')
}
if (!addInteriorPresetWithRoad('Act 1 - Bivouac', 'Cow Bivouac', 'east')) {
stats?.notes.push('Cow Bivouac preset failed to place')
}
}
}
@ -1989,7 +2043,12 @@ function planAct1OutdoorLevel(
if (placedTown && gate === townSeam) continue
addBorderGate(SIDE_TO_EDGE[gate.side], gate.variant ?? 3, gate.label, gate.toLevelId)
}
if (id === 17) placeGraveyardPreset(false)
if (id === 17) {
if (!placeGraveyardPreset(false)) {
stats?.unresolved.push('Burial Grounds Graveyard preset failed to place')
stats?.notes.push('Burial Grounds Graveyard preset failed to place')
}
}
placeInteriorPresets(id)
} else if (id === 2) {
// Blood Moor: Town Transition + Exit Gate to Cold Plains + Den of Evil Entrance
@ -2032,7 +2091,10 @@ function planAct1OutdoorLevel(
placeInteriorPresets(7)
} else if (id === 17) {
// Burial Grounds (5x6 blocks): Graveyard preset (3x4 blocks at bx=1, by=1) + South Entry Gate
if (!placeGraveyardPreset(true)) addBorderGate('bottom', 4, 'Burial Grounds Gate', 3)
if (!placeGraveyardPreset(true)) {
stats?.notes.push('Burial Grounds Graveyard preset failed, falling back to border gate')
addBorderGate('bottom', 4, 'Burial Grounds Gate', 3)
}
} else if (id === 39 || id === 46) {
// Moo Moo Farm (39) & Canyon of the Magi (46): Closed border, no border gates
if (id === 39) placeInteriorPresets(39)
@ -5117,7 +5179,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
let act1Plan: Act1OutdoorPlan | undefined
let dirtPathGrid: Uint8Array | undefined
if (request.levelTypeName === 'Act 1 - Wilderness' || request.gates !== undefined) {
act1Plan = planAct1OutdoorLevel(request, gridWidth, gridHeight, rng)
act1Plan = planAct1OutdoorLevel(request, gridWidth, gridHeight, rng, stats)
stats.anchors = act1Plan.anchors.length
stats.entrances = toEntrances(act1Plan.anchors)
const polylines = buildRoadNetworkPolylines(act1Plan, gridWidth, gridHeight, rng)
@ -5140,6 +5202,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
request.levelId,
act1Plan?.openGates,
act1Plan?.claimedBorderBlocks,
request.gates,
)
// Step 4B: Stamp anchored special presets (Town Transition, Den of Evil, Cave Entrance, Cairn Stones, etc.)

View File

@ -60,6 +60,29 @@ describe("Wilderness Features & Border Rules", () => {
expect(bloodMoorOpenings.has("9,5")).toBe(true)
})
test("getPerimeterOpenings defaults to open borders for Act 3 Jungles and outdoor levels", () => {
// Act 3: Spider Forest (76), Great Marsh (77), Flayer Jungle (78)
const spiderForestOpenings = getPerimeterOpenings(76, 10, 10)
expect(spiderForestOpenings.size).toBe(2)
expect(spiderForestOpenings.has("5,9")).toBe(true)
expect(spiderForestOpenings.has("9,5")).toBe(true)
// Act 5: Arreat Plateau (113)
const arreatOpenings = getPerimeterOpenings(113, 10, 10)
expect(arreatOpenings.size).toBe(2)
})
test("getPerimeterOpenings maps planned gates to perimeter block coordinates", () => {
const plannedGates = [
{ side: 'north' as const, toLevelId: 75, label: 'To Town' },
{ side: 'east' as const, toLevelId: 77, label: 'To Marsh' },
]
const openings = getPerimeterOpenings(76, 12, 14, plannedGates)
expect(openings.size).toBe(2)
expect(openings.has("6,0")).toBe(true) // north: (12/2, 0)
expect(openings.has("11,7")).toBe(true) // east: (12-1, 14/2)
})
test("selectBorderVariant picks open variant for openings and closed variant for closed walls", () => {
const closedDs1 = makeMockDs1(8, 8, false)
const openDs1 = makeMockDs1(8, 8, true)
@ -356,4 +379,32 @@ describe("Wilderness Features & Border Rules", () => {
expect(foundContinuousEW).toBe(true)
expect(foundEndCapEW).toBe(true)
})
test("records unresolved presets and notes when requested preset piece is missing", () => {
const borderDs1 = makeMockDs1(8, 8, false)
const piece: WildernessPiece = {
name: "Act 1 - Wild Border 1",
border: true,
levels: [borderDs1],
}
const result = generateWilderness({
levelId: 2,
levelName: "Blood Moor",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 9999,
pieces: [piece], // Missing Den of Evil Entrance preset!
substitutions: [],
})
const unresolved = result.stats.unresolved as string[]
const notes = result.stats.notes as string[]
expect(unresolved.length).toBeGreaterThan(0)
expect(unresolved.some((u: string) => u.includes('Act 1 - DOE Entrance'))).toBe(true)
expect(notes.some((n: string) => n.includes('Den of Evil Entrance'))).toBe(true)
})
})