From 1f2f19641f3ec7c99190f53eb2c5260d9e9b54cf Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Wed, 16 Sep 2026 04:31:10 +0000 Subject: [PATCH] fix(map-gen): fix Act 1 road/stone wall connectivity, Blood Moor river+bridge layout & Act 3 jungle river alignment --- src/game/wilderness.ts | 612 ++++++++++++++++++++---------- tests/wilderness-features.test.ts | 67 ++++ 2 files changed, 489 insertions(+), 190 deletions(-) diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index eb08a8f..dd75b67 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -769,8 +769,17 @@ export function selectBorderVariant( isOpen: boolean, rng: Rng, ): Ds1 { - const closedLevels = piece.levels.filter(l => isClosedBorder(l, orientation)) - const openLevels = piece.levels.filter(l => !isClosedBorder(l, orientation)) + // In Blizzard LvlPrest.txt tables with >= 4 variants (Act 1 Bord1..4 and Act 4 Border1..4), + // slots 0..2 are closed borders (a/b/c) and slots 3+ are open exit/gate borders (o/oe). + let closedLevels: readonly Ds1[] + let openLevels: readonly Ds1[] + if (piece.levels.length >= 4) { + closedLevels = piece.levels.slice(0, 3) + openLevels = piece.levels.slice(3) + } else { + closedLevels = piece.levels.filter(l => isClosedBorder(l, orientation)) + openLevels = piece.levels.filter(l => !isClosedBorder(l, orientation)) + } if (isOpen && openLevels.length > 0) { return openLevels[rng.int(0, openLevels.length - 1)]! @@ -797,17 +806,9 @@ export function getPerimeterOpenings( return openings } - if (levelId === 2) { - // Blood Moor: exactly 1 entrance (South edge) and 1 exit (East edge) - 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 - } - - // Act 1 wilderness levels (3..7): entrance on South, exit on East - if (levelId >= 3 && levelId <= 7) { + // Act 1 wilderness levels (2..7) & Act 4 Mesa wilderness levels (104..106): + // entrance on South edge, exit on East edge + if ((levelId >= 2 && levelId <= 7) || (levelId >= 104 && levelId <= 106)) { const inX = Math.floor(gridWidth / 2) openings.add(key(inX, gridHeight - 1)) const outY = Math.floor(gridHeight / 2) @@ -1311,6 +1312,97 @@ export const SPECIAL_PRESETS_BY_LEVEL: Readonly, + placedRects: PlacedPresetRect[], + stats: WildernessStats, +): boolean { + const upperPiece = pieces.find(p => p.name === 'Act 1 - River Upper' || p.name.startsWith('Act 1 - River Upper')) + const lowerPiece = pieces.find(p => p.name === 'Act 1 - River Lower' || p.name.startsWith('Act 1 - River Lower')) + const bridgePiece = pieces.find(p => p.name === 'Act 1 - Bridge' || p.name.startsWith('Act 1 - Bridge')) + if (!upperPiece?.levels[0] || !lowerPiece?.levels[0] || !bridgePiece?.levels[0]) return false + + // Place the 2-column vertical river stream just East of the central crossroads (e.g. blocks 6 & 7 on 10x10 grid) + const riverBlockX = Math.min(gridWidth - 3, inX + 1) + if (riverBlockX < 1 || riverBlockX + 1 >= gridWidth - 1) return false + + for (let by = 1; by <= gridHeight - 2; by += 1) { + const tileX0 = riverBlockX * TILES_PER_BLOCK + const tileX1 = (riverBlockX + 1) * TILES_PER_BLOCK + const tileY = by * TILES_PER_BLOCK + + if (by === outY) { + // Main East-West road crossing: stamp BridgeU (west half) + BridgeL (east half) + const bridgeU = bridgePiece.levels[0]! + const bridgeL = bridgePiece.levels[1] ?? bridgePiece.levels[0]! + stampDs1(canvas, bridgeU, tileX0, tileY) + stampDs1(canvas, bridgeL, tileX1, tileY) + } else if (by === 1) { + // North river source/mouth (URiverN + LRiverN) + const uNorth = upperPiece.levels[1] ?? upperPiece.levels[0]! + const lNorth = lowerPiece.levels[1] ?? lowerPiece.levels[0]! + stampDs1(canvas, uNorth, tileX0, tileY) + stampDs1(canvas, lNorth, tileX1, tileY) + } else if (by === gridHeight - 2) { + // South river mouth (URiverS + LRiverS) + const uSouth = upperPiece.levels[2] ?? upperPiece.levels[0]! + const lSouth = lowerPiece.levels[2] ?? lowerPiece.levels[0]! + stampDs1(canvas, uSouth, tileX0, tileY) + stampDs1(canvas, lSouth, tileX1, tileY) + } else { + // Center continuous river channel (URiverC + LRiverC) + stampDs1(canvas, upperPiece.levels[0]!, tileX0, tileY) + stampDs1(canvas, lowerPiece.levels[0]!, tileX1, tileY) + } + + occupied[by]![riverBlockX] = true + occupied[by]![riverBlockX + 1] = true + } + + for (const name of [upperPiece.name, lowerPiece.name, bridgePiece.name]) { + placedPieces.add(name) + stats.substitutions.push({ name, role: 'object', enabled: true, clusters: 1 }) + } + placedRects.push({ + name: 'Act 1 - River', + x: riverBlockX * TILES_PER_BLOCK, + y: TILES_PER_BLOCK, + w: 2 * TILES_PER_BLOCK, + h: (gridHeight - 2) * TILES_PER_BLOCK, + }) + return true +} + +/** + * Scatter special presets across the level's interior. + * + * Follows D2MOO's `DRLGOUTWILD_SpawnSpecialPresets` and + * `DRLGOUTDOORS_SpawnOutdoorLevelPreset`: candidate interior grid coordinates + * (excluding the outer 1-block border and any reserved main road corridors) are + * shuffled deterministically with the level's RNG, and each preset in sequence + * scans for the first free block rectangle where it can fit without overlapping + * already-occupied blocks or the perimeter. + */ function spawnSpecialPresets( canvas: Canvas, pieces: readonly WildernessPiece[], @@ -1319,12 +1411,14 @@ function spawnSpecialPresets( gridHeight: number, rng: Rng, stats: WildernessStats, -): Set { + reservedBlocks?: ReadonlySet, +): { placedPieces: Set; placedRects: PlacedPresetRect[] } { const innerWidth = gridWidth - 2 const innerHeight = gridHeight - 2 const totalSlots = innerWidth * innerHeight const placedPieces = new Set() - if (totalSlots <= 0 || presetNames.length === 0) return placedPieces + const placedRects: PlacedPresetRect[] = [] + if (totalSlots <= 0 || presetNames.length === 0) return { placedPieces, placedRects } const occupied: boolean[][] = Array.from({ length: gridHeight }, () => Array.from({ length: gridWidth }, () => false)) for (let x = 0; x < gridWidth; x += 1) { @@ -1335,8 +1429,52 @@ function spawnSpecialPresets( occupied[y]![0] = true occupied[y]![gridWidth - 1] = true } + if (reservedBlocks && gridWidth >= 8 && gridHeight >= 8) { + for (const key of reservedBlocks) { + const [bxStr, byStr] = key.split(',') + const bx = Number(bxStr) + const by = Number(byStr) + if (occupied[by]?.[bx] !== undefined) { + occupied[by]![bx] = true + } + } + } - for (const presetName of presetNames) { + // Handle continuous river + bridge layout for Blood Moor when requested on standard grids + let remainingPresets = [...presetNames] + const wantsRiver = + gridWidth >= 8 && + gridHeight >= 6 && + remainingPresets.some(n => n.startsWith('Act 1 - River Upper')) && + remainingPresets.some(n => n.startsWith('Act 1 - River Lower')) && + remainingPresets.some(n => n.startsWith('Act 1 - Bridge')) + if (wantsRiver) { + const inX = Math.floor(gridWidth / 2) + const outY = Math.floor(gridHeight / 2) + if ( + stampAct1RiverAndBridge( + canvas, + pieces, + gridWidth, + gridHeight, + inX, + outY, + occupied, + placedPieces, + placedRects, + stats, + ) + ) { + remainingPresets = remainingPresets.filter( + n => + !n.startsWith('Act 1 - River Upper') && + !n.startsWith('Act 1 - River Lower') && + !n.startsWith('Act 1 - Bridge'), + ) + } + } + + for (const presetName of remainingPresets) { const piece = pieces.find(p => p.name === presetName || p.name.startsWith(presetName)) if (!piece || piece.levels.length === 0) continue @@ -1376,7 +1514,9 @@ function spawnSpecialPresets( } if (blocked) continue - stampDs1(canvas, ds1, bx * TILES_PER_BLOCK, by * TILES_PER_BLOCK) + const tileX = bx * TILES_PER_BLOCK + const tileY = by * TILES_PER_BLOCK + stampDs1(canvas, ds1, tileX, tileY) for (let dy = 0; dy < blocksY; dy += 1) { for (let dx = 0; dx < blocksX; dx += 1) { @@ -1391,6 +1531,13 @@ function spawnSpecialPresets( clusters: 1, }) placedPieces.add(piece.name) + placedRects.push({ + name: piece.name, + x: tileX, + y: tileY, + w: ds1.width, + h: ds1.height, + }) placed = true break } @@ -1400,7 +1547,7 @@ function spawnSpecialPresets( } } - return placedPieces + return { placedPieces, placedRects } } /* ------------------------------------------------------------------------- * @@ -1409,17 +1556,6 @@ function spawnSpecialPresets( /** * Derive the block grid for a level whose `Levels.txt` size is unset. - * - * `Act 5 - Barricade 1` and `2` ship `SizeX = SizeY = -1`. The engine does not - * read a size for them: `DRLGOUTSIEGE_ConnectBarricadeAndSiege` positions the - * barricade level against the siege level and sizes it from the barricade piece - * itself. The barricade piece is the `LvlSub` row with `GridSize = 2` whose name - * is `Barricade`, so its DS1's extent is the level's extent — that is what this - * reproduces. - * - * @param request - the request. - * @returns the size in cells and where it came from. - * @throws when no barricade piece is available to size the level from. */ function resolveUnsetSize(request: WildernessRequest): { sizeX: number; sizeY: number; source: string } { if (request.sizeOverride !== undefined) { @@ -1433,30 +1569,8 @@ function resolveUnsetSize(request: WildernessRequest): { sizeX: number; sizeY: n return { sizeX: 160, sizeY: 64, source: 'D2MOO canonical barricade size' } } -/** - * Generate one wilderness level. - * - * The pipeline follows `DRLGOUTDOORS_GenerateLevel`: derive the block grid from - * the level's declared size, lay the ground, walk the outer ring stamping the - * border pieces, then scatter the `LvlSub` substitutions according to - * `SubType`/`SubTheme`. What it does **not** do is the per-act work that decides - * the level's outline and where it meets its neighbours — see - * {@link UNIMPLEMENTED_PASSES} and the module header. - * - * @param request - the real table data for one `DrlgType == 3` level. - * @returns the synthesized map plus a report of what was done. - * @throws when the level cannot be built: a size that yields no usable grid, or - * an unset size with nothing to derive one from. The message always names the - * level, because an empty map silently baked into an asset pack is worse than a - * crash. - */ /** * Assemble the 15 sequential 16x48 siege strips for Act 5 - Siege 1 (Bloody Foothills, LevelId 110). - * - * In Diablo II (`DRLGOUTSIEGE_InitAct5Siege`), Bloody Foothills is a 240x48 uphill battleground - * constructed from 15 sequential 16x48 DS1 strips: `Siege To Barricade` at x=0 (West/North exit to - * Frigid Highlands), `Siege Strip 13` (Shenk the Overseer's boss platform) at x=16, down through - * `Siege Strip 1` at x=208, and `Siege To Town` (Harrogath gate) at x=224. */ function stampAct5SiegeStrips( canvas: Canvas, @@ -1571,6 +1685,13 @@ function stampAct3KurastCauseway( /** * Assemble the 64x192 (2x6 blocks of 32x32) river and clearing layout for Act 3 Jungles (LevelId 76, 77, 78). + * + * In `TravL.ds1` (`Jungle Tail` at `y = 0..31`) and `TransL.ds1` (`Jungle Head` at `y = 160..191`), + * the Kurast river channel flows through the East column (`x = 32..63`, water at `x = 38..60`). + * Placing North-South river presets with West path openings (`Act 3 - Jungle NS W` / `RivNSoW`) in the + * East column (`x = 32`) aligns the river channel (`32 + [5..28] = [37..60]`) seamlessly from `y = 0` + * to `y = 192`, while placing Clearing presets that open East in the West column (`x = 0`) connects + * every clearing across `x = 32` directly to the main riverside jungle trail. */ function stampAct3Jungle( canvas: Canvas, @@ -1599,145 +1720,190 @@ function stampAct3Jungle( : levelId === 77 ? 'Act 3 - Clearing Boggy' : 'Act 3 - Clearing Pygmy' - const clearingPieces = pieces.filter(p => p.name.startsWith(clearingPrefix) && p.levels.length > 0) - const riverPieces = pieces.filter( + const allClearingPieces = pieces.filter(p => p.name.startsWith(clearingPrefix) && p.levels.length > 0) + // Prefer clearings that open East (` E` or ` EW` or ` SE` or ` NE`) to meet the East river column + const eastOpeningClearings = allClearingPieces.filter(p => /\b(E|EW|SE|NE)\b/i.test(p.name)) + const clearingPool = eastOpeningClearings.length > 0 ? eastOpeningClearings : allClearingPieces + + const allRiverPieces = pieces.filter( p => p.name.startsWith('Act 3 - Jungle ') && p.name !== 'Act 3 - Jungle Head' && p.name !== 'Act 3 - Jungle Tail' && p.levels.length > 0, ) + // Prefer North-South river segments that open West (`NS W` / `NS`) to align with Tail/Head at x=32 + const nsWestRivers = allRiverPieces.filter(p => /NS W|NS$/i.test(p.name)) + const riverPool = nsWestRivers.length > 0 ? nsWestRivers : allRiverPieces for (let row = 0; row < 4; row += 1) { const y = 32 + row * 32 - const riverOnLeft = (row + rng.int(0, 1)) % 2 === 0 - for (let col = 0; col < 2; col += 1) { - const x = col * 32 - const isRiverSlot = (col === 0 && riverOnLeft) || (col === 1 && !riverOnLeft) - const pool = isRiverSlot || clearingPieces.length === 0 ? riverPieces : clearingPieces - if (pool.length === 0) continue - const chosen = pool[rng.int(0, pool.length - 1)]! - const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]! - stampDs1(canvas, ds1, x, y) - placed.add(chosen.name) - stats.substitutions.push({ name: chosen.name, role: 'object', enabled: true, clusters: 1 }) + // West column (x = 0): Jungle Clearing opening East + if (clearingPool.length > 0) { + const chosenClearing = clearingPool[rng.int(0, clearingPool.length - 1)]! + const ds1 = chosenClearing.levels[rng.int(0, chosenClearing.levels.length - 1)]! + stampDs1(canvas, ds1, 0, y) + placed.add(chosenClearing.name) + stats.substitutions.push({ name: chosenClearing.name, role: 'object', enabled: true, clusters: 1 }) + } + // East column (x = 32): Continuous North-South River channel + if (riverPool.length > 0) { + const chosenRiver = riverPool[rng.int(0, riverPool.length - 1)]! + const ds1 = chosenRiver.levels[rng.int(0, chosenRiver.levels.length - 1)]! + stampDs1(canvas, ds1, 32, y) + placed.add(chosenRiver.name) + stats.substitutions.push({ name: chosenRiver.name, role: 'object', enabled: true, clusters: 1 }) } } return placed } /** - * Lay a 2-tile-wide meandering dirt road network connecting the South entrance, East exit, - * and interior landmarks in Act 1 wilderness levels (LevelId 2..7). + * Lay a 3-tile-wide main dirt highway connecting the South Border Gate (`Bord1o.ds1` at `x = inX*8 + 3..5`), + * the Central Crossroads (`hubX, hubY`), the River Bridge (if present), and the East Border Gate + * (`Bord4o.ds1` at `y = outY*8 + 3..5`), plus 2-tile-wide spur paths to every spawned landmark. */ -function stampAct1DirtRoads(canvas: Canvas, rng: Rng): void { - const roadFloor: Ds1Floor = { +function stampAct1DirtRoads( + canvas: Canvas, + gridWidth: number, + gridHeight: number, + inX: number, + outY: number, + placedRects: readonly PlacedPresetRect[], + rng: Rng, +): void { + const makeRoadFloor = (): Ds1Floor => ({ prop1: 194, - sequence: 0, + sequence: rng.int(0, 6) === 0 ? 25 : 0, style: 5, unknown1: 0, unknown2: 0, hidden: false, + }) + + const isInsideProtectedPreset = (x: number, y: number): boolean => { + for (const r of placedRects) { + if (r.name === 'Act 1 - River') continue + if (x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) { + return true + } + } + return false } - const paintRoadCell = (cx: number, cy: number): void => { - for (let dy = 0; dy <= 1; dy += 1) { - for (let dx = 0; dx <= 1; dx += 1) { - const x = cx + dx - const y = cy + dy - if (x < 8 || x >= canvas.width - 8 || y < 8 || y >= canvas.height - 8) continue - const cell = canvas.cells[y]?.[x] - if (!cell) continue - // Do not overwrite cells that already carry walls or water/bridge floors - if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) continue - const existingFloor = cell.floors[0] - if (existingFloor && existingFloor.style !== 0) continue - ;(cell.floors as Ds1Floor[])[0] = roadFloor + const paintRoadTile = (x: number, y: number, clearStrayClutter: boolean): void => { + if (x < 4 || x >= canvas.width - 4 || y < 4 || y >= canvas.height - 4) return + if (isInsideProtectedPreset(x, y)) return + const cell = canvas.cells[y]?.[x] + if (!cell) return + const existingFloor = cell.floors[0] + // Never overwrite water (`style 2/3`) or wooden bridge planks (`style 4`) + if (existingFloor && (existingFloor.style === 2 || existingFloor.style === 3 || existingFloor.style === 4)) { + return + } + if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) { + if (clearStrayClutter) { + // Clear random LvlSub bush/tree clutter on the main highway spine + ;(cell.walls as Ds1Wall[]).length = 0 + } else { + return } } + ;(cell.floors as Ds1Floor[])[0] = makeRoadFloor() + } + + const hubX = inX * TILES_PER_BLOCK + 4 + const hubY = outY * TILES_PER_BLOCK + 4 + const southGateY = (gridHeight - 1) * TILES_PER_BLOCK + 3 + const eastGateX = (gridWidth - 1) * TILES_PER_BLOCK + 3 + const northEnd = Math.max(12, Math.floor(canvas.height * 0.2)) + const westEnd = Math.max(12, Math.floor(canvas.width * 0.2)) + + // 1. 3-tile-wide South-to-North main highway (`x = hubX - 1, hubX, hubX + 1`) + for (let y = northEnd; y <= southGateY; y += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + paintRoadTile(hubX + dx, y, true) + } } - const connectPoints = (x0: number, y0: number, x1: number, y1: number): void => { + // 2. 3-tile-wide West-to-East main highway (`y = hubY - 1, hubY, hubY + 1`) + for (let x = westEnd; x <= eastGateX; x += 1) { + for (let dy = -1; dy <= 1; dy += 1) { + paintRoadTile(x, hubY + dy, true) + } + } + + // 3. 2-tile-wide spur roads connecting each major spawned landmark to the main highway + const paintSpurSegment = (x0: number, y0: number, x1: number, y1: number): void => { let curX = x0 let curY = y0 let steps = 0 - const maxSteps = canvas.width + canvas.height + 40 - while ((Math.abs(curX - x1) > 1 || Math.abs(curY - y1) > 1) && steps < maxSteps) { - paintRoadCell(curX, curY) - const dx = x1 - curX - const dy = y1 - curY - if (Math.abs(dx) > Math.abs(dy)) { - curX += dx > 0 ? 1 : -1 - if (rng.int(0, 3) === 0 && dy !== 0) curY += dy > 0 ? 1 : -1 - } else { - curY += dy > 0 ? 1 : -1 - if (rng.int(0, 3) === 0 && dx !== 0) curX += dx > 0 ? 1 : -1 + const maxSteps = canvas.width + canvas.height + while ((curX !== x1 || curY !== y1) && steps < maxSteps) { + for (let dy = 0; dy <= 1; dy += 1) { + for (let dx = 0; dx <= 1; dx += 1) { + paintRoadTile(curX + dx, curY + dy, false) + } } + if (curX !== x1) curX += x1 > curX ? 1 : -1 + else if (curY !== y1) curY += y1 > curY ? 1 : -1 steps += 1 } - paintRoadCell(x1, y1) } - const hubX = Math.floor(canvas.width / 2) + rng.int(-5, 5) - const hubY = Math.floor(canvas.height / 2) + rng.int(-5, 5) - const southX = Math.floor(canvas.width / 2) - const southY = canvas.height - 9 - const eastX = canvas.width - 9 - const eastY = Math.floor(canvas.height / 2) - const nwX = Math.floor(canvas.width * 0.28) - const nwY = Math.floor(canvas.height * 0.28) - const swX = Math.floor(canvas.width * 0.28) - const swY = Math.floor(canvas.height * 0.72) - - connectPoints(southX, southY, hubX, hubY) - connectPoints(hubX, hubY, eastX, eastY) - connectPoints(hubX, hubY, nwX, nwY) - connectPoints(hubX, hubY, swX, swY) + for (const r of placedRects) { + if ( + r.name === 'Act 1 - River' || + /Stone Fill|Swamp Fill|Tree Fill/i.test(r.name) + ) { + continue + } + const centerX = Math.floor(r.x + r.w / 2) + const centerY = Math.floor(r.y + r.h / 2) + // Pick the edge of the preset closest to the main crossroads + const distToNS = Math.abs(centerX - hubX) + const distToEW = Math.abs(centerY - hubY) + if (distToNS <= distToEW) { + const doorX = centerX < hubX ? r.x + r.w : r.x - 1 + const doorY = Math.max(r.y + 2, Math.min(r.y + r.h - 3, centerY)) + paintSpurSegment(doorX, doorY, hubX, doorY) + } else { + const doorX = Math.max(r.x + 2, Math.min(r.x + r.w - 3, centerX)) + const doorY = centerY < hubY ? r.y + r.h : r.y - 1 + paintSpurSegment(doorX, doorY, doorX, hubY) + } + } } /** * Lay connected stone wall divider lines (`style=2, prop1=129`) across the interior of Act 1 - * wilderness levels (LevelId 2..7), creating classic stone-walled courtyards and meadows with - * archway gate openings where roads pass through. + * wilderness levels (LevelId 2..7), using exact Blizzard tile sequences: + * - Continuous East-West wall: `style=2, sequence=0, type=2` (`s2.q0.t2`) + * - Continuous North-South wall: `style=2, sequence=0, type=1` (`s2.q0.t1`) + * - Corner intersection: `style=2, sequence=0, type=3` (`s2.q0.t3`) + * - East-West gate end-caps: `sequence=1, type=2` (West/Right-of-gap) & `sequence=2, type=2` (East/Left-of-gap) + * - North-South gate end-caps: `sequence=1, type=1` (North/Bottom-of-gap) & `sequence=2, type=1` (South/Top-of-gap) */ -function stampAct1InteriorStoneWalls(canvas: Canvas, gridWidth: number, gridHeight: number, rng: Rng): void { - const ewWall = (seq: number): Ds1Wall => ({ - prop1: 129, - sequence: seq % 3, - style: 2, - type: 2, - unknown1: 0, - unknown2: 0, - hidden: false, - }) - const nsWall = (seq: number): Ds1Wall => ({ - prop1: 129, - sequence: seq % 3, - style: 2, - type: 1, - unknown1: 0, - unknown2: 0, - hidden: false, - }) - const cornerPillar: Ds1Wall = { - prop1: 129, - sequence: 0, - style: 2, - type: 3, - unknown1: 0, - unknown2: 0, - hidden: false, - } +function stampAct1InteriorStoneWalls( + canvas: Canvas, + gridWidth: number, + gridHeight: number, + inX: number, + outY: number, + placedRects: readonly PlacedPresetRect[], + rng: Rng, +): void { + if (gridWidth < 8 || gridHeight < 8) return - const canPlaceWall = (x: number, y: number): boolean => { - if (x < 10 || x >= canvas.width - 10 || y < 10 || y >= canvas.height - 10) return false - const cell = canvas.cells[y]?.[x] - if (!cell) return false - if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) return false - // Leave gate openings wherever a dirt road (`style === 5`) or water crosses - if (cell.floors.some(f => !f.hidden && f.style !== 0)) return false - return true - } + const makeWall = (type: number, sequence: number): Ds1Wall => ({ + prop1: 129, + sequence, + style: 2, + type, + unknown1: 0, + unknown2: 0, + hidden: false, + }) const placeWall = (x: number, y: number, wall: Ds1Wall): void => { const cell = canvas.cells[y]?.[x] @@ -1750,50 +1916,98 @@ function stampAct1InteriorStoneWalls(canvas: Canvas, gridWidth: number, gridHeig } } - // Primary horizontal (East-West) stone wall divider near the middle of the map - const midBlockY = Math.floor(gridHeight / 2) + (rng.int(0, 1) === 0 ? -1 : 1) - const wallY = Math.max(16, Math.min(canvas.height - 16, midBlockY * TILES_PER_BLOCK)) + // Offset stone wall dividers by 2 blocks (16 tiles) from the main road spine + const offsetBlockY = rng.int(0, 1) === 0 ? -2 : 2 + const offsetBlockX = rng.int(0, 1) === 0 ? -2 : 2 + const wallY = Math.max(16, Math.min(canvas.height - 16, (outY + offsetBlockY) * TILES_PER_BLOCK)) + const wallX = Math.max(16, Math.min(canvas.width - 16, (inX + offsetBlockX) * TILES_PER_BLOCK)) - // Primary vertical (North-South) stone wall divider - const midBlockX = Math.floor(gridWidth / 2) + (rng.int(0, 1) === 0 ? -1 : 1) - const wallX = Math.max(16, Math.min(canvas.width - 16, midBlockX * TILES_PER_BLOCK)) - - // Draw East-West stone wall line with periodic gate gaps - let prevPlacedEW = false - for (let x = 11; x < canvas.width - 11; x += 1) { - const isGate = (x % 18) >= 7 && (x % 18) <= 10 - if (isGate || Math.abs(x - wallX) <= 1 || !canPlaceWall(x, wallY)) { - if (prevPlacedEW && canPlaceWall(x - 1, wallY)) { - placeWall(x - 1, wallY, cornerPillar) + const isCellBlockedForWall = (x: number, y: number): boolean => { + if (x < 10 || x >= canvas.width - 10 || y < 10 || y >= canvas.height - 10) return true + // Leave 2-tile margin around all placed special presets (houses, caves, rivers, corrals) + for (const r of placedRects) { + if (x >= r.x - 2 && x < r.x + r.w + 2 && y >= r.y - 2 && y < r.y + r.h + 2) { + return true } - prevPlacedEW = false - continue } - if (!prevPlacedEW) { - placeWall(x, wallY, cornerPillar) - } else { - placeWall(x, wallY, ewWall(x)) + // Leave 3-tile wide archway gate openings wherever a dirt road (style 5), bridge (style 4), or water (style 2/3) passes + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + const c = canvas.cells[y + dy]?.[x + dx] + if (!c) continue + if (c.floors.some(f => !f.hidden && (f.style === 5 || f.style === 4 || f.style === 2 || f.style === 3))) { + return true + } + } } - prevPlacedEW = true + const cell = canvas.cells[y]?.[x] + if (!cell) return true + if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) return true + return false } - // Draw North-South stone wall line with periodic gate gaps - let prevPlacedNS = false - for (let y = 11; y < canvas.height - 11; y += 1) { - const isGate = (y % 18) >= 7 && (y % 18) <= 10 - if (isGate || Math.abs(y - wallY) <= 1 || !canPlaceWall(wallX, y)) { - if (prevPlacedNS && canPlaceWall(wallX, y - 1)) { - placeWall(wallX, y - 1, cornerPillar) + // Build valid tile masks along wallY (EW) and wallX (NS), inserting periodic pasture gates + const validEW: boolean[] = Array.from({ length: canvas.width }, (_, x) => { + const isPastureGate = (x % 24) >= 11 && (x % 24) <= 13 && Math.abs(x - wallX) > 3 + return !isPastureGate && !isCellBlockedForWall(x, wallY) + }) + const validNS: boolean[] = Array.from({ length: canvas.height }, (_, y) => { + const isPastureGate = (y % 24) >= 11 && (y % 24) <= 13 && Math.abs(y - wallY) > 3 + return !isPastureGate && !isCellBlockedForWall(wallX, y) + }) + + const extractRuns = (mask: readonly boolean[]): Array<{ start: number; end: number }> => { + const runs: Array<{ start: number; end: number }> = [] + let start = -1 + for (let i = 0; i < mask.length; i += 1) { + if (mask[i]) { + if (start === -1) start = i + } else if (start !== -1) { + if (i - start >= 4) runs.push({ start, end: i - 1 }) + start = -1 } - prevPlacedNS = false - continue } - if (!prevPlacedNS) { - placeWall(wallX, y, cornerPillar) - } else { - placeWall(wallX, y, nsWall(y)) + if (start !== -1 && mask.length - start >= 4) { + runs.push({ start, end: mask.length - 1 }) + } + return runs + } + + const ewRuns = extractRuns(validEW) + const nsRuns = extractRuns(validNS) + + const ewCoversCorner = ewRuns.some(r => wallX >= r.start && wallX <= r.end) + const nsCoversCorner = nsRuns.some(r => wallY >= r.start && wallY <= r.end) + const hasCornerJoin = ewCoversCorner && nsCoversCorner + + // Stamp East-West stone wall segments (`type = 2`) + for (const run of ewRuns) { + for (let x = run.start; x <= run.end; x += 1) { + if (x === wallX && hasCornerJoin) { + placeWall(x, wallY, makeWall(3, 0)) // Corner / Cross join (`s2.q0.t3`) + } else if (x === run.start) { + placeWall(x, wallY, makeWall(2, 1)) // West / Right-of-gap End-Cap (`s2.q1.t2`) + } else if (x === run.end) { + placeWall(x, wallY, makeWall(2, 2)) // East / Left-of-gap End-Cap (`s2.q2.t2`) + } else { + placeWall(x, wallY, makeWall(2, 0)) // Continuous EW Stone Wall (`s2.q0.t2`) + } + } + } + + // Stamp North-South stone wall segments (`type = 1`) + for (const run of nsRuns) { + for (let y = run.start; y <= run.end; y += 1) { + if (y === wallY && hasCornerJoin) { + placeWall(wallX, y, makeWall(3, 0)) // Corner / Cross join (`s2.q0.t3`) + } else if (y === run.start) { + placeWall(wallX, y, makeWall(1, 1)) // North / Bottom-of-gap End-Cap (`s2.q1.t1`) + } else if (y === run.end) { + placeWall(wallX, y, makeWall(1, 2)) // South / Top-of-gap End-Cap (`s2.q2.t1`) + } else { + placeWall(wallX, y, makeWall(1, 0)) // Continuous NS Stone Wall (`s2.q0.t1`) + } } - prevPlacedNS = true } } @@ -2034,15 +2248,25 @@ export function generateWilderness(request: WildernessRequest): WildernessResult specialPresetNames = SPECIAL_PRESETS_BY_LEVEL[request.levelId] ?? [] } } - const placedSpecialPieces = spawnSpecialPresets(canvas, fillPieces, specialPresetNames, gridWidth, gridHeight, rng, stats) - - // For Act 1 outdoor wilderness maps (LevelId 2..7), lay dirt road networks and - // interior stone wall divider lines across the meadow. + const inX = Math.floor(gridWidth / 2) + const outY = Math.floor(gridHeight / 2) + const reservedRoadBlocks = new Set() if (request.levelId >= 2 && request.levelId <= 7) { - stampAct1DirtRoads(canvas, rng) - stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, rng) + for (let by = 1; by < gridHeight - 1; by += 1) reservedRoadBlocks.add(`${String(inX)},${String(by)}`) + for (let bx = 1; bx < gridWidth - 1; bx += 1) reservedRoadBlocks.add(`${String(bx)},${String(outY)}`) } + const { placedPieces: placedSpecialPieces, placedRects } = spawnSpecialPresets( + canvas, + fillPieces, + specialPresetNames, + gridWidth, + gridHeight, + rng, + stats, + reservedRoadBlocks, + ) + const themeIndex = Math.max(0, Math.min(4, Math.floor(request.subTheme))) const objectRows = request.substitutions.filter(row => classifySubstitutionRole(row.name, row.type) !== 'border') const borderRows = [...request.substitutions, ...(request.shrineSubstitutions ?? [])] @@ -2058,6 +2282,14 @@ export function generateWilderness(request: WildernessRequest): WildernessResult rng, stats, ) + + // For Act 1 outdoor wilderness maps (LevelId 2..7), lay dirt road networks and + // interior stone wall divider lines across the meadow after random clutter substitutions. + if (request.levelId >= 2 && request.levelId <= 7) { + stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng) + stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng) + } + for (const piece of fillPieces) { if (!placedSpecialPieces.has(piece.name)) { stats.notes.push(`fill piece "${piece.name}" is not placed by this port`) diff --git a/tests/wilderness-features.test.ts b/tests/wilderness-features.test.ts index d26236c..5981e73 100644 --- a/tests/wilderness-features.test.ts +++ b/tests/wilderness-features.test.ts @@ -325,4 +325,71 @@ describe("Wilderness Features & Border Rules", () => { expect(result.stats.substitutedClusters).toBe(6) expect(result.level.objects.length).toBe(6) }) + + test("Act 1 Blood Moor (Level 2) connects South/East border gates with 3-tile road and uses authentic stone wall sequences", () => { + const borderDs1 = makeMockDs1(8, 8, false) + const openBorderDs1 = makeMockDs1(8, 8, true) + const presetDs1 = makeMockDs1(16, 16, false) + const riverDs1 = makeMockDs1(8, 8, false) + + const pieces: WildernessPiece[] = [ + { name: "Act 1 - Wild Border 1", border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] }, + { name: "Act 1 - Wild Border 2", border: true, levels: [borderDs1] }, + { name: "Act 1 - Wild Border 3", border: true, levels: [borderDs1] }, + { name: "Act 1 - Wild Border 4", border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] }, + { name: "Act 1 - Wild Border 5", border: true, levels: [borderDs1] }, + { name: "Act 1 - Wild Border 6", border: true, levels: [borderDs1] }, + { name: "Act 1 - Wild Border 7", border: true, levels: [borderDs1] }, + { name: "Act 1 - Wild Border 8", border: true, levels: [borderDs1] }, + { name: "Act 1 - DOE Entrance", border: false, levels: [presetDs1] }, + { name: "Act 1 - River Upper", border: false, levels: [riverDs1, riverDs1, riverDs1] }, + { name: "Act 1 - River Lower", border: false, levels: [riverDs1, riverDs1, riverDs1] }, + { name: "Act 1 - Bridge", border: false, levels: [riverDs1, riverDs1] }, + { name: "Act 1 - Cottages 1", border: false, levels: [presetDs1] }, + ] + + const result = generateWilderness({ + levelId: 2, + levelName: "Blood Moor", + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: -1, + subTheme: -1, + seed: 1234, + pieces, + substitutions: [], + specialPresets: [ + "Act 1 - DOE Entrance", + "Act 1 - River Upper", + "Act 1 - River Lower", + "Act 1 - Bridge", + "Act 1 - Cottages 1", + ], + }) + + // 1. Verify 3-tile wide main highway at central crossroads (44, 44) + expect(result.level.cells[44]![44]!.floors[0]!.style).toBe(5) + expect(result.level.cells[44]![43]!.floors[0]!.style).toBe(5) + expect(result.level.cells[44]![45]!.floors[0]!.style).toBe(5) + + // 2. Verify South border gate road connection at (44, 75) and East border gate connection at (75, 44) + expect(result.level.cells[75]![44]!.floors[0]!.style).toBe(5) + expect(result.level.cells[44]![75]!.floors[0]!.style).toBe(5) + + // 3. Verify interior stone wall segments (style=2, prop1=129) use continuous sequence=0 and end-caps sequence=1/2 + let foundContinuousEW = false + let foundEndCapEW = false + for (let y = 10; y < 70; y += 1) { + for (let x = 10; x < 70; x += 1) { + const wall = result.level.cells[y]![x]!.walls.find(w => w.style === 2 && w.prop1 === 129) + if (wall && wall.type === 2) { + if (wall.sequence === 0) foundContinuousEW = true + if (wall.sequence === 1 || wall.sequence === 2) foundEndCapEW = true + } + } + } + expect(foundContinuousEW).toBe(true) + expect(foundEndCapEW).toBe(true) + }) })