diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index f9a38d3..378b26f 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -298,6 +298,8 @@ export interface WildernessRequest { * the tests and `verify-generators` use. */ readonly gates?: readonly PlannedGate[] + /** Optional seam constraints from predecessor/connected levels for 1:1 spatial alignment. */ + readonly seamConstraints?: readonly SeamConstraint[] /** Optional forced gate variant indices per block key (`${x},${y}`). */ readonly openGates?: ReadonlyMap /** Optional set of block keys (`${x},${y}`) claimed by transition/facade presets to skip during border placement. */ @@ -324,7 +326,28 @@ export interface PlannedGate { * Three is the ordinary open gate; four is the variant the Burial Grounds and * the Cold Plains use for the graveyard seam. */ - readonly variant?: number + readonly variant?: number | undefined + /** Exact block coordinate along the perimeter edge (0..gridWidth-1 or 0..gridHeight-1) */ + readonly blockCoord?: number | undefined + /** Optional exact tile coordinate within the level */ + readonly tileCoord?: { readonly x: number; readonly y: number } | undefined +} + +/** Explicit spatial alignment constraint across a level seam. */ +export interface SeamConstraint { + readonly fromLevelId?: number | undefined + readonly toLevelId?: number | undefined + readonly side: Side + readonly blockCoord: number + readonly tileCoord?: { readonly x: number; readonly y: number } | undefined +} + +/** Placed gate opening along the level's perimeter. */ +export interface GateOpening { + readonly side: Side + readonly toLevelId: number + readonly blockCoord: number + readonly tileCoord: { readonly x: number; readonly y: number } } /** The generated level. */ @@ -336,6 +359,7 @@ export interface WildernessResult { readonly landmarkEntities?: LandmarkEntity[] readonly notes?: string[] readonly unresolved?: string[] + readonly gateOpenings?: readonly GateOpening[] } } @@ -1122,6 +1146,7 @@ export interface WildernessStats { proceduralWallCells?: number drlgVertices?: readonly DrlgVertex[] | undefined roadPolylines?: readonly (readonly { readonly x: number; readonly y: number }[])[] | undefined + gateOpenings?: GateOpening[] } /** @@ -1469,6 +1494,7 @@ export function getPerimeterOpenings( gridWidth: number, gridHeight: number, gates?: readonly PlannedGate[], + seamConstraints?: readonly SeamConstraint[], ): Set { const openings = new Set() const key = (x: number, y: number) => `${String(x)},${String(y)}` @@ -1478,21 +1504,90 @@ export function getPerimeterOpenings( return openings } + const minX = gridWidth > 2 ? 1 : 0 + const maxX = gridWidth > 2 ? gridWidth - 2 : gridWidth - 1 + const minY = gridHeight > 2 ? 1 : 0 + const maxY = gridHeight > 2 ? gridHeight - 2 : gridHeight - 1 + if (gates !== undefined && gates.length > 0) { for (const gate of gates) { + let coord = gate.blockCoord + if (coord === undefined && seamConstraints !== undefined) { + const match = seamConstraints.find( + c => + c.side === gate.side && + (c.toLevelId === gate.toLevelId || + c.fromLevelId === gate.toLevelId || + c.toLevelId === levelId || + c.fromLevelId === levelId), + ) ?? seamConstraints.find(c => c.side === gate.side) + if (match !== undefined) { + coord = match.blockCoord + } + } + if (coord === undefined && gate.tileCoord !== undefined) { + coord = (gate.side === 'north' || gate.side === 'south') + ? Math.floor(gate.tileCoord.x / TILES_PER_BLOCK) + : Math.floor(gate.tileCoord.y / TILES_PER_BLOCK) + } + switch (gate.side) { - case 'north': - openings.add(key(Math.floor(gridWidth / 2), 0)) + case 'north': { + const bx = coord !== undefined + ? Math.max(minX, Math.min(maxX, coord)) + : Math.floor(gridWidth / 2) + openings.add(key(bx, 0)) break - case 'south': - openings.add(key(Math.floor(gridWidth / 2), gridHeight - 1)) + } + case 'south': { + const bx = coord !== undefined + ? Math.max(minX, Math.min(maxX, coord)) + : Math.floor(gridWidth / 2) + openings.add(key(bx, gridHeight - 1)) break - case 'west': - openings.add(key(0, Math.floor(gridHeight / 2))) + } + case 'west': { + const by = coord !== undefined + ? Math.max(minY, Math.min(maxY, coord)) + : Math.floor(gridHeight / 2) + openings.add(key(0, by)) break - case 'east': - openings.add(key(gridWidth - 1, Math.floor(gridHeight / 2))) + } + case 'east': { + const by = coord !== undefined + ? Math.max(minY, Math.min(maxY, coord)) + : Math.floor(gridHeight / 2) + openings.add(key(gridWidth - 1, by)) break + } + } + } + return openings + } + + if (seamConstraints !== undefined && seamConstraints.length > 0) { + for (const c of seamConstraints) { + switch (c.side) { + case 'north': { + const bx = Math.max(minX, Math.min(maxX, c.blockCoord)) + openings.add(key(bx, 0)) + break + } + case 'south': { + const bx = Math.max(minX, Math.min(maxX, c.blockCoord)) + openings.add(key(bx, gridHeight - 1)) + break + } + case 'west': { + const by = Math.max(minY, Math.min(maxY, c.blockCoord)) + openings.add(key(0, by)) + break + } + case 'east': { + const by = Math.max(minY, Math.min(maxY, c.blockCoord)) + openings.add(key(gridWidth - 1, by)) + break + } } } return openings @@ -1530,6 +1625,7 @@ export function getPerimeterOpenings( * @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, Facades). * @param gates - optional planned gates from the world graph. + * @param seamConstraints - optional seam constraints for spatial alignment. * @returns the number of pieces stamped. */ export function DRLGOUTPLACE_PlaceAct1245OutdoorBorders( @@ -1543,6 +1639,7 @@ export function DRLGOUTPLACE_PlaceAct1245OutdoorBorders( openGates?: ReadonlyMap, claimedBorderBlocks?: ReadonlySet, gates?: readonly PlannedGate[], + seamConstraints?: readonly SeamConstraint[], ): number { if (stats.proceduralWallCells === undefined) { stats.proceduralWallCells = 0 @@ -1591,7 +1688,7 @@ export function DRLGOUTPLACE_PlaceAct1245OutdoorBorders( { x: 0, y: gridHeight - 1 }, // SW ] - const openings = levelId !== undefined ? getPerimeterOpenings(levelId, gridWidth, gridHeight, gates) : new Set() + const openings = levelId !== undefined ? getPerimeterOpenings(levelId, gridWidth, gridHeight, gates, seamConstraints) : new Set() if (openGates !== undefined) { for (const gateKey of openGates.keys()) { openings.add(gateKey) @@ -1724,6 +1821,7 @@ export interface Act1OutdoorPlan { readonly roadObstacleBlocks: boolean[][] readonly specialPresets: SpecialPresetPlacement[] readonly anchors: RoadAnchor[] + readonly gateOpenings?: GateOpening[] } /** Map a compass side onto the generator's own edge names. */ @@ -1774,6 +1872,8 @@ export function planAct1OutdoorLevel( const specialPresets: SpecialPresetPlacement[] = [] const anchors: RoadAnchor[] = [] + const gateOpenings: GateOpening[] = [] + const claimBox = (bx: number, by: number, w: number, h: number, isObstacle = true): void => { for (let dy = 0; dy < h; dy += 1) { for (let dx = 0; dx < w; dx += 1) { @@ -1795,71 +1895,183 @@ export function planAct1OutdoorLevel( variant: number, label: string, toLevelId?: number, + forcedBlockCoord?: number, + forcedTileCoord?: { readonly x: number; readonly y: number }, ): void => { + const side: Side = edge === 'top' ? 'north' : edge === 'bottom' ? 'south' : edge === 'left' ? 'west' : 'east' + + let blockCoord = forcedBlockCoord + let tileCoord = forcedTileCoord + + if (blockCoord === undefined && request.seamConstraints !== undefined) { + const match = request.seamConstraints.find(c => { + if (c.side !== side) return false + if (toLevelId !== undefined && toLevelId !== -1) { + return c.toLevelId === toLevelId || c.fromLevelId === toLevelId + } + return c.toLevelId === request.levelId || c.fromLevelId === request.levelId + }) ?? request.seamConstraints.find(c => c.side === side) + + if (match !== undefined) { + blockCoord = match.blockCoord + tileCoord = tileCoord ?? match.tileCoord + } + } + + if (blockCoord === undefined && tileCoord !== undefined) { + blockCoord = (side === 'north' || side === 'south') + ? Math.floor(tileCoord.x / TILES_PER_BLOCK) + : Math.floor(tileCoord.y / TILES_PER_BLOCK) + } + if (edge === 'left') { - let by = rng.int(2, Math.max(2, gridHeight - 3)) - for (let tries = 0; tries < gridHeight; tries += 1) { - if (!claimedBorderBlocks.has(`0,${String(by)}`) && !openGates.has(`0,${String(by)}`)) break - by = 2 + ((by - 1) % Math.max(1, gridHeight - 4)) + const minY = gridHeight > 2 ? 1 : 0 + const maxY = gridHeight > 2 ? gridHeight - 2 : gridHeight - 1 + let by: number + if (blockCoord !== undefined) { + by = Math.max(minY, Math.min(maxY, blockCoord)) + if (claimedBorderBlocks.has(`0,${String(by)}`)) { + for (let tries = 0; tries < gridHeight; tries += 1) { + if (!claimedBorderBlocks.has(`0,${String(by)}`) && !openGates.has(`0,${String(by)}`)) break + by = minY + ((by - minY + 1) % (maxY - minY + 1)) + } + } + } else { + by = rng.int(2, Math.max(2, gridHeight - 3)) + for (let tries = 0; tries < gridHeight; tries += 1) { + if (!claimedBorderBlocks.has(`0,${String(by)}`) && !openGates.has(`0,${String(by)}`)) break + by = 2 + ((by - 1) % Math.max(1, gridHeight - 4)) + } } openGates.set(`0,${String(by)}`, variant) claimedBlocks[by]![0] = true + const anchorCell = tileCoord ?? { x: 3, y: 8 * by + 3 } + const interiorCell = tileCoord ? { x: tileCoord.x + 8, y: tileCoord.y } : { x: 11, y: 8 * by + 3 } anchors.push({ label, - anchorCell: { x: 3, y: 8 * by + 3 }, - interiorCell: { x: 11, y: 8 * by + 3 }, + anchorCell, + interiorCell, toLevelId, side: 'west', kind: 'gate', }) + gateOpenings.push({ + side: 'west', + toLevelId: toLevelId ?? -1, + blockCoord: by, + tileCoord: anchorCell, + }) } else if (edge === 'right') { - let by = rng.int(2, Math.max(2, gridHeight - 3)) - for (let tries = 0; tries < gridHeight; tries += 1) { - if (!claimedBorderBlocks.has(`${String(gridWidth - 1)},${String(by)}`) && !openGates.has(`${String(gridWidth - 1)},${String(by)}`)) break - by = 2 + ((by - 1) % Math.max(1, gridHeight - 4)) + const minY = gridHeight > 2 ? 1 : 0 + const maxY = gridHeight > 2 ? gridHeight - 2 : gridHeight - 1 + let by: number + if (blockCoord !== undefined) { + by = Math.max(minY, Math.min(maxY, blockCoord)) + if (claimedBorderBlocks.has(`${String(gridWidth - 1)},${String(by)}`)) { + for (let tries = 0; tries < gridHeight; tries += 1) { + if (!claimedBorderBlocks.has(`${String(gridWidth - 1)},${String(by)}`) && !openGates.has(`${String(gridWidth - 1)},${String(by)}`)) break + by = minY + ((by - minY + 1) % (maxY - minY + 1)) + } + } + } else { + by = rng.int(2, Math.max(2, gridHeight - 3)) + for (let tries = 0; tries < gridHeight; tries += 1) { + if (!claimedBorderBlocks.has(`${String(gridWidth - 1)},${String(by)}`) && !openGates.has(`${String(gridWidth - 1)},${String(by)}`)) break + by = 2 + ((by - 1) % Math.max(1, gridHeight - 4)) + } } openGates.set(`${String(gridWidth - 1)},${String(by)}`, variant) claimedBlocks[by]![gridWidth - 1] = true + const anchorCell = tileCoord ?? { x: 8 * (gridWidth - 1) + 3, y: 8 * by + 3 } + const interiorCell = tileCoord ? { x: tileCoord.x - 8, y: tileCoord.y } : { x: 8 * (gridWidth - 2) + 3, y: 8 * by + 3 } anchors.push({ label, - anchorCell: { x: 8 * (gridWidth - 1) + 3, y: 8 * by + 3 }, - interiorCell: { x: 8 * (gridWidth - 2) + 3, y: 8 * by + 3 }, + anchorCell, + interiorCell, toLevelId, side: 'east', kind: 'gate', }) + gateOpenings.push({ + side: 'east', + toLevelId: toLevelId ?? -1, + blockCoord: by, + tileCoord: anchorCell, + }) } else if (edge === 'top') { - let bx = rng.int(2, Math.max(2, gridWidth - 3)) - for (let tries = 0; tries < gridWidth; tries += 1) { - if (!claimedBorderBlocks.has(`${String(bx)},0`) && !openGates.has(`${String(bx)},0`)) break - bx = 2 + ((bx - 1) % Math.max(1, gridWidth - 4)) + const minX = gridWidth > 2 ? 1 : 0 + const maxX = gridWidth > 2 ? gridWidth - 2 : gridWidth - 1 + let bx: number + if (blockCoord !== undefined) { + bx = Math.max(minX, Math.min(maxX, blockCoord)) + if (claimedBorderBlocks.has(`${String(bx)},0`)) { + for (let tries = 0; tries < gridWidth; tries += 1) { + if (!claimedBorderBlocks.has(`${String(bx)},0`) && !openGates.has(`${String(bx)},0`)) break + bx = minX + ((bx - minX + 1) % (maxX - minX + 1)) + } + } + } else { + bx = rng.int(2, Math.max(2, gridWidth - 3)) + for (let tries = 0; tries < gridWidth; tries += 1) { + if (!claimedBorderBlocks.has(`${String(bx)},0`) && !openGates.has(`${String(bx)},0`)) break + bx = 2 + ((bx - 1) % Math.max(1, gridWidth - 4)) + } } openGates.set(`${String(bx)},0`, variant) claimedBlocks[0]![bx] = true + const anchorCell = tileCoord ?? { x: 8 * bx + 3, y: 3 } + const interiorCell = tileCoord ? { x: tileCoord.x, y: tileCoord.y + 8 } : { x: 8 * bx + 3, y: 11 } anchors.push({ label, - anchorCell: { x: 8 * bx + 3, y: 3 }, - interiorCell: { x: 8 * bx + 3, y: 11 }, + anchorCell, + interiorCell, toLevelId, side: 'north', kind: 'gate', }) + gateOpenings.push({ + side: 'north', + toLevelId: toLevelId ?? -1, + blockCoord: bx, + tileCoord: anchorCell, + }) } else { - let bx = rng.int(2, Math.max(2, gridWidth - 3)) - for (let tries = 0; tries < gridWidth; tries += 1) { - if (!claimedBorderBlocks.has(`${String(bx)},${String(gridHeight - 1)}`) && !openGates.has(`${String(bx)},${String(gridHeight - 1)}`)) break - bx = 2 + ((bx - 1) % Math.max(1, gridWidth - 4)) + const minX = gridWidth > 2 ? 1 : 0 + const maxX = gridWidth > 2 ? gridWidth - 2 : gridWidth - 1 + let bx: number + if (blockCoord !== undefined) { + bx = Math.max(minX, Math.min(maxX, blockCoord)) + if (claimedBorderBlocks.has(`${String(bx)},${String(gridHeight - 1)}`)) { + for (let tries = 0; tries < gridWidth; tries += 1) { + if (!claimedBorderBlocks.has(`${String(bx)},${String(gridHeight - 1)}`) && !openGates.has(`${String(bx)},${String(gridHeight - 1)}`)) break + bx = minX + ((bx - minX + 1) % (maxX - minX + 1)) + } + } + } else { + bx = rng.int(2, Math.max(2, gridWidth - 3)) + for (let tries = 0; tries < gridWidth; tries += 1) { + if (!claimedBorderBlocks.has(`${String(bx)},${String(gridHeight - 1)}`) && !openGates.has(`${String(bx)},${String(gridHeight - 1)}`)) break + bx = 2 + ((bx - 1) % Math.max(1, gridWidth - 4)) + } } openGates.set(`${String(bx)},${String(gridHeight - 1)}`, variant) claimedBlocks[gridHeight - 1]![bx] = true + const anchorCell = tileCoord ?? { x: 8 * bx + 3, y: 8 * (gridHeight - 1) + 3 } + const interiorCell = tileCoord ? { x: tileCoord.x, y: tileCoord.y - 8 } : { x: 8 * bx + 3, y: 8 * (gridHeight - 2) + 3 } anchors.push({ label, - anchorCell: { x: 8 * bx + 3, y: 8 * (gridHeight - 1) + 3 }, - interiorCell: { x: 8 * bx + 3, y: 8 * (gridHeight - 2) + 3 }, + anchorCell, + interiorCell, toLevelId, side: 'south', kind: 'gate', }) + gateOpenings.push({ + side: 'south', + toLevelId: toLevelId ?? -1, + blockCoord: bx, + tileCoord: anchorCell, + }) } } @@ -1944,45 +2156,63 @@ export function planAct1OutdoorLevel( * @param toLevelId - the town's level id. * @returns whether a preset was placed. */ - const placeTownTransition = (side: Side, toLevelId?: number): boolean => { + const placeTownTransition = (side: Side, toLevelId?: number, forcedBlockCoord?: number): boolean => { if (side === 'west') { const townE = findPiece('Act 1 - Town 1 Transition E') if (townE?.levels[0] === undefined) return false const ds1 = townE.levels[0] - const by = Math.max(1, Math.floor((gridHeight - 5) / 2)) + const by = forcedBlockCoord !== undefined + ? Math.max(1, Math.min(gridHeight - 6, forcedBlockCoord)) + : Math.max(1, Math.floor((gridHeight - 5) / 2)) for (let dy = 0; dy < 5; dy += 1) { claimedBorderBlocks.add(`0,${String(by + dy)}`) claimedBlocks[by + dy]![0] = true } specialPresets.push({ name: townE.name, ds1, bx: 0, by, blocksX: 1, blocksY: 5 }) + const anchorCell = { x: 7, y: 8 * by + 19 } anchors.push({ label: 'Town E Transition', - anchorCell: { x: 7, y: 8 * by + 19 }, + anchorCell, interiorCell: { x: 11, y: 8 * by + 19 }, toLevelId, side: 'west', kind: 'gate', }) + gateOpenings.push({ + side: 'west', + toLevelId: toLevelId ?? 1, + blockCoord: by, + tileCoord: anchorCell, + }) return true } if (side === 'north') { const townS = findPiece('Act 1 - Town 1 Transition S') if (townS?.levels[0] === undefined) return false const ds1 = townS.levels[0] - const bx = Math.max(1, Math.floor((gridWidth - 7) / 2)) + const bx = forcedBlockCoord !== undefined + ? Math.max(1, Math.min(gridWidth - 8, forcedBlockCoord)) + : Math.max(1, Math.floor((gridWidth - 7) / 2)) for (let dx = 0; dx < 7; dx += 1) { claimedBorderBlocks.add(`${String(bx + dx)},0`) claimedBlocks[0]![bx + dx] = true } specialPresets.push({ name: townS.name, ds1, bx, by: 0, blocksX: 7, blocksY: 1 }) + const anchorCell = { x: 8 * bx + 29, y: 7 } anchors.push({ label: 'Town S Transition', - anchorCell: { x: 8 * bx + 29, y: 7 }, + anchorCell, interiorCell: { x: 8 * bx + 29, y: 11 }, toLevelId, side: 'north', kind: 'gate', }) + gateOpenings.push({ + side: 'north', + toLevelId: toLevelId ?? 1, + blockCoord: bx, + tileCoord: anchorCell, + }) return true } return false @@ -2010,14 +2240,21 @@ export function planAct1OutdoorLevel( if (withGate) { openGates.set(`2,${String(gridHeight - 1)}`, 4) claimedBlocks[gridHeight - 1]![2] = true + const anchorCell = { x: 2 * 8 + 3, y: 8 * (gridHeight - 1) + 3 } anchors.push({ label: 'Burial Grounds Gate', - anchorCell: { x: 2 * 8 + 3, y: 8 * (gridHeight - 1) + 3 }, + anchorCell, interiorCell: { x: 2 * 8 + 3, y: 8 * (gridHeight - 2) + 3 }, toLevelId: 3, side: 'south', kind: 'gate', }) + gateOpenings.push({ + side: 'south', + toLevelId: 3, + blockCoord: 2, + tileCoord: anchorCell, + }) } anchors.push({ label: 'Graveyard Entrance', @@ -2111,10 +2348,22 @@ export function planAct1OutdoorLevel( // it records one legal roll of the outdoor placer as if it were fixed, and // it only ever covered Act 1. const townSeam = id === 2 ? plannedGates.find(gate => gate.toLevelId === 1) : undefined - const placedTown = townSeam !== undefined && placeTownTransition(townSeam.side, townSeam.toLevelId) + const placedTown = townSeam !== undefined && placeTownTransition(townSeam.side, townSeam.toLevelId, townSeam.blockCoord) for (const gate of plannedGates) { if (placedTown && gate === townSeam) continue - addBorderGate(SIDE_TO_EDGE[gate.side], gate.variant ?? 3, gate.label, gate.toLevelId) + let blockCoord = gate.blockCoord + let tileCoord = gate.tileCoord + if (blockCoord === undefined && request.seamConstraints !== undefined) { + const match = request.seamConstraints.find(c => { + if (c.side !== gate.side) return false + return c.toLevelId === gate.toLevelId || c.fromLevelId === gate.toLevelId + }) ?? request.seamConstraints.find(c => c.side === gate.side) + if (match !== undefined) { + blockCoord = match.blockCoord + tileCoord = tileCoord ?? match.tileCoord + } + } + addBorderGate(SIDE_TO_EDGE[gate.side], gate.variant ?? 3, gate.label, gate.toLevelId, blockCoord, tileCoord) } if (id === 17) { if (!placeGraveyardPreset(false)) { @@ -2127,13 +2376,22 @@ export function planAct1OutdoorLevel( // Blood Moor: Town Transition + Exit Gate to Cold Plains + Den of Evil Entrance const townE = findPiece('Act 1 - Town 1 Transition E') const useWest = rng.int(0, 1) === 0 && townE !== undefined + const coldPlainsConstraint = request.seamConstraints?.find(c => c.toLevelId === 3 || c.fromLevelId === 3) + let exitEdge: 'right' | 'bottom' + if (coldPlainsConstraint?.side === 'east') { + exitEdge = 'right' + } else if (coldPlainsConstraint?.side === 'south') { + exitEdge = 'bottom' + } else { + exitEdge = rng.int(0, 1) === 0 ? 'right' : 'bottom' + } if (useWest && placeTownTransition('west', 1)) { - addBorderGate(rng.int(0, 1) === 0 ? 'right' : 'bottom', 3, 'Cold Plains Exit', 3) + addBorderGate(exitEdge, 3, 'Cold Plains Exit', 3, coldPlainsConstraint?.blockCoord, coldPlainsConstraint?.tileCoord) } else if (placeTownTransition('north', 1)) { - addBorderGate(rng.int(0, 1) === 0 ? 'bottom' : 'right', 3, 'Cold Plains Exit', 3) + addBorderGate(exitEdge, 3, 'Cold Plains Exit', 3, coldPlainsConstraint?.blockCoord, coldPlainsConstraint?.tileCoord) } else { addBorderGate('left', 3, 'Town Entry', 1) - addBorderGate('right', 3, 'Cold Plains Exit', 3) + addBorderGate(exitEdge, 3, 'Cold Plains Exit', 3, coldPlainsConstraint?.blockCoord, coldPlainsConstraint?.tileCoord) } placeInteriorPresets(2) } else if (id === 3) { @@ -2176,6 +2434,10 @@ export function planAct1OutdoorLevel( addBorderGate('right', 3, 'Exit Gate') } + if (stats !== undefined) { + stats.gateOpenings = gateOpenings + } + return { openGates, claimedBorderBlocks, @@ -2183,6 +2445,7 @@ export function planAct1OutdoorLevel( roadObstacleBlocks, specialPresets, anchors, + gateOpenings, } } @@ -5548,6 +5811,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult anchors: 0, specialPresets: [], entrances: [], + gateOpenings: [], proceduralRoadCells: 0, proceduralDividerCells: 0, proceduralWaterCells: 0, @@ -5570,6 +5834,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult borderStamped: 0, groundCells: 0, groundTile: null, groundTiles: undefined, sizeSource, roadCells: 0, roadSegments: 0, anchors: 0, specialPresets: [], entrances: [], proceduralRoadCells: 0, proceduralDividerCells: 0, proceduralWaterCells: 0, proceduralWallCells: 0, + gateOpenings: [], } let wallLayers = 1 @@ -5755,10 +6020,13 @@ export function generateWilderness(request: WildernessRequest): WildernessResult // existing packs and the generator verifier reproduce byte for byte. let act1Plan: Act1OutdoorPlan | undefined let dirtPathGrid: Uint8Array | undefined - if (request.levelTypeName === 'Act 1 - Wilderness' || request.gates !== undefined) { + if (request.levelTypeName === 'Act 1 - Wilderness' || request.gates !== undefined || request.seamConstraints !== undefined) { act1Plan = planAct1OutdoorLevel(request, gridWidth, gridHeight, rng, stats) stats.anchors = act1Plan.anchors.length stats.entrances = toEntrances(act1Plan.anchors) + if (act1Plan.gateOpenings !== undefined) { + stats.gateOpenings = act1Plan.gateOpenings + } const vertexResult = createDrlgVertices(act1Plan, gridWidth, gridHeight, rng) stats.drlgVertices = vertexResult.vertices stats.roadPolylines = vertexResult.polylines @@ -5784,6 +6052,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult request.openGates ?? act1Plan?.openGates, request.claimedBorderBlocks ?? act1Plan?.claimedBorderBlocks, request.gates, + request.seamConstraints, ) // Step 4B: Stamp anchored special presets (Town Transition, Den of Evil, Cave Entrance, Cairn Stones, etc.) @@ -6007,6 +6276,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult roadSegments: stats.roadSegments, anchors: stats.anchors, entrances: stats.entrances, + gateOpenings: stats.gateOpenings ?? act1Plan?.gateOpenings ?? [], specialPresets: stats.specialPresets, drlgVertices: stats.drlgVertices, roadPolylines: stats.roadPolylines, diff --git a/tests/wilderness-seams.test.ts b/tests/wilderness-seams.test.ts new file mode 100644 index 0000000..79fb013 --- /dev/null +++ b/tests/wilderness-seams.test.ts @@ -0,0 +1,412 @@ +import { describe, expect, test } from "vitest" +import { + generateWilderness, + getPerimeterOpenings, + type WildernessPiece, + type SeamConstraint, + type GateOpening, + type PlannedGate, +} from "../src/game/wilderness.ts" +import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts" + +function makeMockDs1(width: number, height: number, hasGapInWalls = false): Ds1 { + const cells: Ds1Cell[][] = [] + for (let y = 0; y < height; y += 1) { + const row: Ds1Cell[] = [] + for (let x = 0; x < width; x += 1) { + const isGap = hasGapInWalls && x === Math.floor(width / 2) + row.push({ + walls: isGap ? [] : [{ 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 makeTestPieces(): WildernessPiece[] { + return [ + { name: "Act 1 - Wild Border 1", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] }, + { name: "Act 1 - Wild Border 2", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] }, + { name: "Act 1 - Wild Border 3", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] }, + { name: "Act 1 - Wild Border 4", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] }, + { name: "Act 1 - Wild Border 5", border: true, levels: [makeMockDs1(8, 8, false)] }, + { name: "Act 1 - Wild Border 6", border: true, levels: [makeMockDs1(8, 8, false)] }, + { name: "Act 1 - Wild Border 7", border: true, levels: [makeMockDs1(8, 8, false)] }, + { name: "Act 1 - Wild Border 8", border: true, levels: [makeMockDs1(8, 8, false)] }, + { name: "Act 1 - Wild Border 9", border: true, levels: [makeMockDs1(8, 8, false)] }, + { name: "Act 1 - Wild Border 10", border: true, levels: [makeMockDs1(8, 8, false)] }, + { name: "Act 1 - Wild Border 11", border: true, levels: [makeMockDs1(8, 8, false)] }, + { name: "Act 1 - Wild Border 12", border: true, levels: [makeMockDs1(8, 8, false)] }, + { name: "Act 1 - Town Transition East", border: true, levels: [makeMockDs1(8, 8, true)] }, + { name: "Act 1 - Town Transition South", border: true, levels: [makeMockDs1(8, 8, true)] }, + { name: "Act 1 - Fallen Camp Bishibosh", border: false, levels: [makeMockDs1(8, 8, true)] }, + { name: "Act 1 - Cairn Stones", border: false, levels: [makeMockDs1(8, 8, true)] }, + { name: "Act 1 - Inifus", border: false, levels: [makeMockDs1(8, 8, true)] }, + ] +} + +describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", () => { + describe("getPerimeterOpenings unit tests", () => { + test("uses forced gate.blockCoord directly", () => { + const gates: PlannedGate[] = [ + { label: "Cold Plains", side: "east", toLevelId: 3, blockCoord: 5 }, + ] + const openings = getPerimeterOpenings(2, 10, 10, gates) + // East side is at x = 9, y = 5 + expect(openings.has("9,5")).toBe(true) + }) + + test("clamps gate.blockCoord away from corners [1, size - 2]", () => { + const gates: PlannedGate[] = [ + { label: "Stony Field", side: "north", toLevelId: 4, blockCoord: 0 }, // Out of bounds / corner -> clamp to 1 + { label: "Dark Wood", side: "south", toLevelId: 5, blockCoord: 9 }, // Out of bounds / corner -> clamp to 8 + ] + const openings = getPerimeterOpenings(3, 10, 10, gates) + // North opening at x = 1, y = 0 + expect(openings.has("1,0")).toBe(true) + // South opening at x = 8, y = 9 + expect(openings.has("8,9")).toBe(true) + }) + + test("matches seamConstraints by side and level ID", () => { + const gates: PlannedGate[] = [ + { label: "Blood Moor", side: "west", toLevelId: 2 }, + ] + const seamConstraints: SeamConstraint[] = [ + { side: "west", fromLevelId: 2, toLevelId: 3, blockCoord: 4 }, + ] + const openings = getPerimeterOpenings(3, 10, 10, gates, seamConstraints) + // West opening at x = 0, y = 4 + expect(openings.has("0,4")).toBe(true) + }) + + test("converts tileCoord to blockCoord when blockCoord is not specified", () => { + const gates: PlannedGate[] = [ + { label: "Stony Field", side: "north", toLevelId: 4, tileCoord: { x: 48, y: 0 } }, + ] + // tile x: 48 -> block x: 48 / 8 = 6 + const openings = getPerimeterOpenings(3, 10, 10, gates) + expect(openings.has("6,0")).toBe(true) + }) + + test("handles extreme out-of-bounds coordinates safely", () => { + const gates: PlannedGate[] = [ + { label: "Blood Moor", side: "west", toLevelId: 2, blockCoord: -100 }, + { label: "Cold Plains", side: "east", toLevelId: 3, blockCoord: 1000 }, + ] + const openings = getPerimeterOpenings(2, 10, 10, gates) + expect(openings.has("0,1")).toBe(true) + expect(openings.has("9,8")).toBe(true) + }) + }) + + describe("Sequential generation with seam interlocking (Blood Moor -> Cold Plains -> Stony Field)", () => { + test("interlocks Blood Moor East exit with Cold Plains West entrance", () => { + const pieces = makeTestPieces() + + // Step 1: Generate Blood Moor (level 2) + const bloodMoorResult = generateWilderness({ + levelId: 2, + levelName: "Blood Moor", + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: 6, + subTheme: 0, + seed: 0x123456, + pieces, + substitutions: [], + }) + + const bloodMoorOpenings = bloodMoorResult.stats.gateOpenings + expect(bloodMoorOpenings).toBeDefined() + expect(bloodMoorOpenings!.length).toBeGreaterThan(0) + + // Find East exit to Cold Plains (level 3) + const eastGate = bloodMoorOpenings!.find(g => g.side === "east" && g.toLevelId === 3) + expect(eastGate).toBeDefined() + const sharedYBlock = eastGate!.blockCoord + expect(sharedYBlock).toBeGreaterThanOrEqual(1) + expect(sharedYBlock).toBeLessThanOrEqual(8) + + // Step 2: Generate Cold Plains (level 3) with seam constraint matching Blood Moor's East exit + const coldPlainsResult = generateWilderness({ + levelId: 3, + levelName: "Cold Plains", + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: 6, + subTheme: 0, + seed: 0x654321, + pieces, + substitutions: [], + seamConstraints: [ + { + side: "west", + fromLevelId: 2, + toLevelId: 3, + blockCoord: sharedYBlock, + ...(eastGate!.tileCoord ? { tileCoord: { x: 0, y: eastGate!.tileCoord.y } } : {}), + }, + ], + }) + + const coldPlainsOpenings = coldPlainsResult.stats.gateOpenings + expect(coldPlainsOpenings).toBeDefined() + + // Find West entrance from Blood Moor (level 2) + const westGate = coldPlainsOpenings!.find(g => g.side === "west" && g.toLevelId === 2) + expect(westGate).toBeDefined() + expect(westGate!.blockCoord).toBe(sharedYBlock) + + // If tileCoord was provided, verify along-seam coordinate matches + if (eastGate!.tileCoord && westGate!.tileCoord) { + expect(westGate!.tileCoord.y).toBe(eastGate!.tileCoord.y) + } + + // Step 3: Cold Plains has exit to Stony Field (level 4) on North + const northGate = coldPlainsOpenings!.find(g => g.side === "north" && g.toLevelId === 4) + expect(northGate).toBeDefined() + const sharedXBlock = northGate!.blockCoord + expect(sharedXBlock).toBeGreaterThanOrEqual(1) + expect(sharedXBlock).toBeLessThanOrEqual(8) + + // Step 4: Generate Stony Field (level 4) with seam constraint on South matching Cold Plains North exit + const stonyFieldResult = generateWilderness({ + levelId: 4, + levelName: "Stony Field", + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: 6, + subTheme: 0, + seed: 0xabcdef, + pieces, + substitutions: [], + seamConstraints: [ + { + side: "south", + fromLevelId: 3, + toLevelId: 4, + blockCoord: sharedXBlock, + ...(northGate!.tileCoord ? { tileCoord: { x: northGate!.tileCoord.x, y: 79 } } : {}), + }, + ], + }) + + const stonyFieldOpenings = stonyFieldResult.stats.gateOpenings + expect(stonyFieldOpenings).toBeDefined() + + const southGate = stonyFieldOpenings!.find(g => g.side === "south" && g.toLevelId === 3) + expect(southGate).toBeDefined() + expect(southGate!.blockCoord).toBe(sharedXBlock) + + if (northGate!.tileCoord && southGate!.tileCoord) { + expect(southGate!.tileCoord.x).toBe(northGate!.tileCoord.x) + } + }) + }) + + describe("PlannedGate forced blockCoord and tileCoord in generateWilderness", () => { + test("honors gates with explicit blockCoord", () => { + const pieces = makeTestPieces() + const result = generateWilderness({ + levelId: 3, + levelName: "Cold Plains", + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: 6, + subTheme: 0, + seed: 0x999999, + pieces, + substitutions: [], + gates: [ + { label: "Blood Moor", side: "west", toLevelId: 2, blockCoord: 3 }, + { label: "Stony Field", side: "north", toLevelId: 4, blockCoord: 6 }, + { label: "Underground", side: "east", toLevelId: 10, blockCoord: 7 }, + ], + }) + + const openings = result.stats.gateOpenings + expect(openings).toBeDefined() + + const west = openings!.find(g => g.side === "west") + expect(west?.blockCoord).toBe(3) + + const north = openings!.find(g => g.side === "north") + expect(north?.blockCoord).toBe(6) + + const east = openings!.find(g => g.side === "east") + expect(east?.blockCoord).toBe(7) + }) + + test("honors gates with explicit tileCoord and positions road anchors", () => { + const pieces = makeTestPieces() + const targetTileY = 8 * 4 + 3 // block 4, tile 35 + const result = generateWilderness({ + levelId: 3, + levelName: "Cold Plains", + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: 6, + subTheme: 0, + seed: 0x777777, + pieces, + substitutions: [], + gates: [ + { label: "Blood Moor", side: "west", toLevelId: 2, tileCoord: { x: 3, y: targetTileY } }, + ], + }) + + const openings = result.stats.gateOpenings + expect(openings).toBeDefined() + const west = openings!.find(g => g.side === "west") + expect(west?.blockCoord).toBe(4) + expect(west?.tileCoord).toEqual({ x: 3, y: targetTileY }) + }) + }) + + describe("Bidirectional level links & seam alignment consistency", () => { + test("level A (East) and level B (West) have matching seam and opening block", () => { + const pieces = makeTestPieces() + const targetSeamY = 5 + + const levelA = generateWilderness({ + levelId: 2, + levelName: "Level A", + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: 6, + subTheme: 0, + seed: 0x111, + pieces, + substitutions: [], + gates: [{ label: "Level B", side: "east", toLevelId: 3, blockCoord: targetSeamY }], + }) + + const levelB = generateWilderness({ + levelId: 3, + levelName: "Level B", + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: 6, + subTheme: 0, + seed: 0x222, + pieces, + substitutions: [], + seamConstraints: [{ side: "west", fromLevelId: 2, toLevelId: 3, blockCoord: targetSeamY }], + }) + + const gateA = levelA.stats.gateOpenings!.find(g => g.side === "east")! + const gateB = levelB.stats.gateOpenings!.find(g => g.side === "west")! + + expect(gateA.blockCoord).toBe(targetSeamY) + expect(gateB.blockCoord).toBe(targetSeamY) + // Along-seam tile coordinate: Y coordinate matches + expect(gateA.tileCoord!.y).toBe(gateB.tileCoord!.y) + }) + + test("level B (North) and level C (South) have matching seam and opening block", () => { + const pieces = makeTestPieces() + const targetSeamX = 6 + + const levelB = generateWilderness({ + levelId: 3, + levelName: "Level B", + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: 6, + subTheme: 0, + seed: 0x333, + pieces, + substitutions: [], + gates: [{ label: "Level C", side: "north", toLevelId: 4, blockCoord: targetSeamX }], + }) + + const levelC = generateWilderness({ + levelId: 4, + levelName: "Level C", + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: 6, + subTheme: 0, + seed: 0x444, + pieces, + substitutions: [], + seamConstraints: [{ side: "south", fromLevelId: 3, toLevelId: 4, blockCoord: targetSeamX }], + }) + + const gateB = levelB.stats.gateOpenings!.find(g => g.side === "north")! + const gateC = levelC.stats.gateOpenings!.find(g => g.side === "south")! + + expect(gateB.blockCoord).toBe(targetSeamX) + expect(gateC.blockCoord).toBe(targetSeamX) + // Along-seam tile coordinate: X coordinate matches + expect(gateB.tileCoord!.x).toBe(gateC.tileCoord!.x) + }) + }) + + describe("Out of bounds clamping gracefully preserves valid playable borders", () => { + test("clamps negative blockCoord to 1 without crashing", () => { + const pieces = makeTestPieces() + const result = generateWilderness({ + levelId: 3, + levelName: "Cold Plains", + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: 6, + subTheme: 0, + seed: 0x555, + pieces, + substitutions: [], + seamConstraints: [{ side: "west", fromLevelId: 2, toLevelId: 3, blockCoord: -10 }], + }) + + const west = result.stats.gateOpenings!.find(g => g.side === "west") + expect(west?.blockCoord).toBe(1) + }) + + test("clamps excessive blockCoord to gridWidth - 2 without crashing", () => { + const pieces = makeTestPieces() + const result = generateWilderness({ + levelId: 3, + levelName: "Cold Plains", + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: 6, + subTheme: 0, + seed: 0x666, + pieces, + substitutions: [], + seamConstraints: [{ side: "north", fromLevelId: 4, toLevelId: 3, blockCoord: 999 }], + }) + + const north = result.stats.gateOpenings!.find(g => g.side === "north") + expect(north?.blockCoord).toBe(8) // 80 / 8 = 10 blocks -> max is 10 - 2 = 8 + }) + }) +})