diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index dc79fb1..d42c4d9 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -1405,6 +1405,7 @@ export function getPerimeterOpenings( levelId: number, gridWidth: number, gridHeight: number, + gates?: readonly PlannedGate[], ): Set { const openings = new Set() 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, claimedBorderBlocks?: ReadonlySet, + 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() + const openings = levelId !== undefined ? getPerimeterOpenings(levelId, gridWidth, gridHeight, gates) : new Set() + 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() const claimedBorderBlocks = new Set() @@ -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.) diff --git a/tests/wilderness-features.test.ts b/tests/wilderness-features.test.ts index a37a7d1..14f20f0 100644 --- a/tests/wilderness-features.test.ts +++ b/tests/wilderness-features.test.ts @@ -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) + }) })