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:
parent
b8810072ce
commit
fea677b0b4
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue