fix(map-gen): fix Act 1 road/stone wall connectivity, Blood Moor river+bridge layout & Act 3 jungle river alignment

This commit is contained in:
troytt 2026-09-16 04:31:10 +00:00
parent 602565030f
commit 1f2f19641f
2 changed files with 489 additions and 190 deletions

View File

@ -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<Record<number, readonly string[]
* @param stats - report collector.
* @returns set of piece names that were placed.
*/
/** Bounding rectangle of a placed special preset in canvas tile coordinates. */
export interface PlacedPresetRect {
readonly name: string
readonly x: number
readonly y: number
readonly w: number
readonly h: number
}
/**
* Stamp a continuous 2-block-wide (16-tile) North-South river stream across the level with
* a 2-block wooden bridge crossing at `by = outY` where the main East-West road passes.
*/
function stampAct1RiverAndBridge(
canvas: Canvas,
pieces: readonly WildernessPiece[],
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
occupied: boolean[][],
placedPieces: Set<string>,
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<string> {
reservedBlocks?: ReadonlySet<string>,
): { placedPieces: Set<string>; placedRects: PlacedPresetRect[] } {
const innerWidth = gridWidth - 2
const innerHeight = gridHeight - 2
const totalSlots = innerWidth * innerHeight
const placedPieces = new Set<string>()
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<string>()
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`)

View File

@ -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)
})
})