Merge branch 'fix/issue-55'

This commit is contained in:
troytt 2026-09-17 08:58:45 +00:00
commit dfce2f6cef
2 changed files with 900 additions and 61 deletions

View File

@ -1399,9 +1399,18 @@ export function getPerimeterOpenings(
return openings
}
// 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)) {
// Outdoor wilderness levels across Acts 1, 2, 3, 4, 5:
// Act 1 (2..7), Act 2 desert (41..46), Act 3 Kurast (79..82),
// Act 4 Mesa (104..106), Act 5 outdoor (111..112, 117):
// Default entrance on South edge, exit on East edge
if (
(levelId >= 2 && levelId <= 7) ||
(levelId >= 41 && levelId <= 46) ||
(levelId >= 79 && levelId <= 82) ||
(levelId >= 104 && levelId <= 106) ||
(levelId >= 111 && levelId <= 112) ||
levelId === 117
) {
const inX = Math.floor(gridWidth / 2)
openings.add(key(inX, gridHeight - 1))
const outY = Math.floor(gridHeight / 2)
@ -2974,12 +2983,25 @@ function stampAct3Jungle(
return DRLG_GenerateJungles(canvas, levelId, pieces, rng, stats, stampDs1)
}
/** Theme configuration for outdoor road rasterization. */
export interface OutdoorRoadTheme {
readonly roadFloor: (rng: Rng) => Ds1Floor
readonly isProtectedPreset?: (presetName: string) => boolean
readonly isIgnoredSpurPreset?: (presetName: string) => boolean
}
/** Theme configuration for outdoor stone wall divider networks. */
export interface OutdoorDividerTheme {
readonly wallStyle: number
readonly wallProp1?: number
readonly roadStyles?: readonly number[]
}
/**
* 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.
* General outdoor path and highway rasterizer connecting entrance, central crossroads,
* exit gates, and landmark presets across wilderness maps.
*/
function stampAct1DirtRoads(
export function stampThemedRoads(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
@ -2987,19 +3009,14 @@ function stampAct1DirtRoads(
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
theme: OutdoorRoadTheme,
openGates?: ReadonlyMap<string, number>,
): void {
const makeRoadFloor = (): Ds1Floor => ({
prop1: 194,
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 (theme.isProtectedPreset ? !theme.isProtectedPreset(r.name) : /River|Bridge/i.test(r.name)) {
continue
}
if (x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) {
return true
}
@ -3025,15 +3042,25 @@ function stampAct1DirtRoads(
return
}
}
;(cell.floors as Ds1Floor[])[0] = makeRoadFloor()
;(cell.floors as Ds1Floor[])[0] = theme.roadFloor(rng)
}
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))
let northEnd = Math.max(12, Math.floor(canvas.height * 0.2))
let westEnd = Math.max(12, Math.floor(canvas.width * 0.2))
if (openGates !== undefined) {
for (const key of openGates.keys()) {
const [bxStr, byStr] = key.split(',')
const bx = Number(bxStr)
const by = Number(byStr)
if (by === 0) northEnd = 4
if (bx === 0) westEnd = 4
}
}
// 1. 3-tile-wide South-to-North main highway (`x = hubX - 1, hubX, hubX + 1`)
for (let y = northEnd; y <= southGateY; y += 1) {
@ -3049,6 +3076,34 @@ function stampAct1DirtRoads(
}
}
// If openGates contains non-standard gate blocks, connect them into the highway network
if (openGates !== undefined) {
for (const key of openGates.keys()) {
const [bxStr, byStr] = key.split(',')
const bx = Number(bxStr)
const by = Number(byStr)
const gateX = bx * TILES_PER_BLOCK + 4
const gateY = by * TILES_PER_BLOCK + 4
if (by === 0 && Math.abs(gateX - hubX) > 2) {
// North gate branch to hubY
for (let y = 4; y <= hubY; y += 1) {
for (let dx = -1; dx <= 1; dx += 1) paintRoadTile(gateX + dx, y, true)
}
for (let x = Math.min(gateX, hubX); x <= Math.max(gateX, hubX); x += 1) {
for (let dy = -1; dy <= 1; dy += 1) paintRoadTile(x, hubY + dy, true)
}
} else if (bx === 0 && Math.abs(gateY - hubY) > 2) {
// West gate branch to hubX
for (let x = 4; x <= hubX; x += 1) {
for (let dy = -1; dy <= 1; dy += 1) paintRoadTile(x, gateY + dy, true)
}
for (let y = Math.min(gateY, hubY); y <= Math.max(gateY, hubY); y += 1) {
for (let dx = -1; dx <= 1; dx += 1) paintRoadTile(hubX + dx, y, 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
@ -3068,10 +3123,7 @@ function stampAct1DirtRoads(
}
for (const r of placedRects) {
if (
r.name === 'Act 1 - River' ||
/Stone Fill|Swamp Fill|Tree Fill/i.test(r.name)
) {
if (theme.isIgnoredSpurPreset ? theme.isIgnoredSpurPreset(r.name) : /River|Bridge|Fill|Berms/i.test(r.name)) {
continue
}
const centerX = Math.floor(r.x + r.w / 2)
@ -3092,15 +3144,10 @@ function stampAct1DirtRoads(
}
/**
* Lay connected stone wall divider lines (`style=2, prop1=129`) across the interior of Act 1
* 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)
* General outdoor divider network laying connected stone walls, sandstone berms,
* or balustrades with proper corner joins, gate archways, and endcaps.
*/
function stampAct1InteriorStoneWalls(
export function stampThemedDividers(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
@ -3108,13 +3155,17 @@ function stampAct1InteriorStoneWalls(
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
theme: OutdoorDividerTheme,
): void {
if (gridWidth < 8 || gridHeight < 8) return
if (gridWidth < 6 || gridHeight < 6) return
const wallStyle = theme.wallStyle
const wallProp1 = theme.wallProp1 ?? 129
const makeWall = (type: number, sequence: number): Ds1Wall => ({
prop1: 129,
prop1: wallProp1,
sequence,
style: 2,
style: wallStyle,
type,
unknown1: 0,
unknown2: 0,
@ -3132,26 +3183,26 @@ function stampAct1InteriorStoneWalls(
}
}
// Offset stone wall dividers by 2 blocks (16 tiles) from the main road spine
// Offset divider lines 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))
const wallY = Math.max(12, Math.min(canvas.height - 12, (outY + offsetBlockY) * TILES_PER_BLOCK))
const wallX = Math.max(12, Math.min(canvas.width - 12, (inX + offsetBlockX) * TILES_PER_BLOCK))
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)
if (x < 6 || x >= canvas.width - 6 || y < 6 || y >= canvas.height - 6) return true
// Leave 2-tile margin around all placed special presets
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
}
}
// Leave 3-tile wide archway gate openings wherever a dirt road (style 5), bridge (style 4), or water (style 2/3) passes
// Leave 3-tile wide archway gate openings wherever a road, bridge, or water 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))) {
if (c.floors.some(f => !f.hidden && (f.style === 5 || f.style === 1 || f.style === 4 || f.style === 2 || f.style === 3 || theme.roadStyles?.includes(f.style)))) {
return true
}
}
@ -3196,37 +3247,358 @@ function stampAct1InteriorStoneWalls(
const nsCoversCorner = nsRuns.some(r => wallY >= r.start && wallY <= r.end)
const hasCornerJoin = ewCoversCorner && nsCoversCorner
// Stamp East-West stone wall segments (`type = 2`)
// Stamp East-West divider 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`)
placeWall(x, wallY, makeWall(3, 0)) // Corner / Cross join (`type=3`)
} else if (x === run.start) {
placeWall(x, wallY, makeWall(2, 1)) // West / Right-of-gap End-Cap (`s2.q1.t2`)
placeWall(x, wallY, makeWall(2, 1)) // West / Right-of-gap End-Cap (`type=2, seq=1`)
} else if (x === run.end) {
placeWall(x, wallY, makeWall(2, 2)) // East / Left-of-gap End-Cap (`s2.q2.t2`)
placeWall(x, wallY, makeWall(2, 2)) // East / Left-of-gap End-Cap (`type=2, seq=2`)
} else {
placeWall(x, wallY, makeWall(2, 0)) // Continuous EW Stone Wall (`s2.q0.t2`)
placeWall(x, wallY, makeWall(2, 0)) // Continuous EW Wall (`type=2, seq=0`)
}
}
}
// Stamp North-South stone wall segments (`type = 1`)
// Stamp North-South divider 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`)
placeWall(wallX, y, makeWall(3, 0)) // Corner / Cross join (`type=3`)
} else if (y === run.start) {
placeWall(wallX, y, makeWall(1, 1)) // North / Bottom-of-gap End-Cap (`s2.q1.t1`)
placeWall(wallX, y, makeWall(1, 1)) // North / Bottom-of-gap End-Cap (`type=1, seq=1`)
} else if (y === run.end) {
placeWall(wallX, y, makeWall(1, 2)) // South / Top-of-gap End-Cap (`s2.q2.t1`)
placeWall(wallX, y, makeWall(1, 2)) // South / Top-of-gap End-Cap (`type=1, seq=2`)
} else {
placeWall(wallX, y, makeWall(1, 0)) // Continuous NS Stone Wall (`s2.q0.t1`)
placeWall(wallX, y, makeWall(1, 0)) // Continuous NS Wall (`type=1, seq=0`)
}
}
}
}
/**
* Act 1: Lay a 3-tile-wide dirt highway connecting the South Border Gate, central crossroads,
* river bridge, and East Border Gate, plus 2-tile-wide spur paths to landmarks.
*/
export function stampAct1DirtRoads(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
openGates?: ReadonlyMap<string, number>,
): void {
stampThemedRoads(
canvas,
gridWidth,
gridHeight,
inX,
outY,
placedRects,
rng,
{
roadFloor: (r) => ({
prop1: 194,
sequence: r.int(0, 6) === 0 ? 25 : 0,
style: 5,
unknown1: 0,
unknown2: 0,
hidden: false,
}),
isProtectedPreset: (name) => name !== 'Act 1 - River',
isIgnoredSpurPreset: (name) => name === 'Act 1 - River' || /Stone Fill|Swamp Fill|Tree Fill|Fence Fill|Corral Fill/i.test(name),
},
openGates,
)
}
/**
* Act 1: Lay connected stone wall divider lines (`style=2, prop1=129`) across the interior
* meadow of Act 1 wilderness levels (LevelId 2..7).
*/
export function stampAct1InteriorStoneWalls(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
): void {
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
wallStyle: 2,
wallProp1: 129,
roadStyles: [5],
})
}
/**
* Act 2: Desert sandstone caravan routes connecting entrance to exit gates across dunes.
*/
export function stampAct2DesertRoads(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
openGates?: ReadonlyMap<string, number>,
): void {
stampThemedRoads(
canvas,
gridWidth,
gridHeight,
inX,
outY,
placedRects,
rng,
{
roadFloor: (r) => ({
prop1: 194,
sequence: r.int(0, 5) === 0 ? 1 : 0,
style: 1,
unknown1: 0,
unknown2: 0,
hidden: false,
}),
isProtectedPreset: (name) => !/River|Bridge/i.test(name),
isIgnoredSpurPreset: (name) => /Fill|Berms/i.test(name),
},
openGates,
)
}
/**
* Act 2: Sandstone divider berms (style=1, prop1=129) separating desert dunes.
*/
export function stampAct2SandstoneBerms(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
): void {
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
wallStyle: 1,
wallProp1: 129,
roadStyles: [1],
})
}
/**
* Act 3: Kurast flagstone avenues across temples, plazas, and causeways.
*/
export function stampAct3KurastAvenues(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
openGates?: ReadonlyMap<string, number>,
): void {
stampThemedRoads(
canvas,
gridWidth,
gridHeight,
inX,
outY,
placedRects,
rng,
{
roadFloor: (r) => ({
prop1: 194,
sequence: r.int(0, 5) === 0 ? 1 : 0,
style: 1,
unknown1: 0,
unknown2: 0,
hidden: false,
}),
isProtectedPreset: (name) => !/River|Bridge/i.test(name),
isIgnoredSpurPreset: (name) => /Fill/i.test(name),
},
openGates,
)
}
/**
* Act 3: Kurast balustrades across temples and causeways (style=1, prop1=129).
*/
export function stampAct3KurastBalustrades(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
): void {
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
wallStyle: 1,
wallProp1: 129,
roadStyles: [1],
})
}
/**
* Act 5: Snow march roads in Frigid Highlands & Arreat Plateau.
*/
export function stampAct5SnowRoads(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
openGates?: ReadonlyMap<string, number>,
): void {
stampThemedRoads(
canvas,
gridWidth,
gridHeight,
inX,
outY,
placedRects,
rng,
{
roadFloor: (r) => ({
prop1: 194,
sequence: r.int(0, 5) === 0 ? 1 : 0,
style: 1,
unknown1: 0,
unknown2: 0,
hidden: false,
}),
isProtectedPreset: (name) => !/River|Bridge/i.test(name),
isIgnoredSpurPreset: (name) => /Fill|Filler/i.test(name),
},
openGates,
)
}
/**
* Act 5: Frozen trail divides in Frigid Highlands & Arreat Plateau (style=2, prop1=129).
*/
export function stampAct5FrozenTrailDivides(
canvas: Canvas,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
): void {
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
wallStyle: 2,
wallProp1: 129,
roadStyles: [1],
})
}
/**
* Dispatch outdoor road networks based on act, levelId, and levelTypeName.
*/
export function stampOutdoorRoads(
canvas: Canvas,
act: number,
levelId: number,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
levelTypeName?: string,
openGates?: ReadonlyMap<string, number>,
): void {
if (levelId === 39) return // Cow Level has no roads
// Level ID takes highest precedence
if (levelId >= 41 && levelId <= 46) {
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
} else if (levelId >= 79 && levelId <= 82) {
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
} else if (levelId === 111 || levelId === 112 || levelId === 117) {
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
} else if (levelId >= 2 && levelId <= 7) {
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
// Level type name takes second precedence
} else if (levelTypeName === 'Act 2 - Desert') {
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
} else if (levelTypeName === 'Act 3 - Kurast') {
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
} else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') {
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
} else if (levelTypeName === 'Act 1 - Wilderness') {
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
// Act number takes fallback precedence
} else if (act === 2) {
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
} else if (act === 3) {
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
} else if (act === 5) {
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
} else if (act === 1) {
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
}
}
/**
* Dispatch outdoor boundary divider networks based on act, levelId, and levelTypeName.
*/
export function stampOutdoorDividers(
canvas: Canvas,
act: number,
levelId: number,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
levelTypeName?: string,
): void {
if (levelId === 39) return // Cow Level has no dividers
// Level ID takes highest precedence
if (levelId >= 41 && levelId <= 46) {
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
} else if (levelId >= 79 && levelId <= 82) {
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
} else if (levelId === 111 || levelId === 112 || levelId === 117) {
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
} else if (levelId >= 2 && levelId <= 7) {
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
// Level type name takes second precedence
} else if (levelTypeName === 'Act 2 - Desert') {
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
} else if (levelTypeName === 'Act 3 - Kurast') {
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
} else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') {
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
} else if (levelTypeName === 'Act 1 - Wilderness') {
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
// Act number takes fallback precedence
} else if (act === 2) {
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
} else if (act === 3) {
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
} else if (act === 5) {
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
} else if (act === 1) {
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
}
}
/* ------------------------------------------------------------------------- *
* Outdoor Population Phase: Waypoints, Roadside Shrines/Chests, SuperUniques & Flavie
* (`DRLGOUTDOORS_SpawnAct12Waypoint`, `DRLGOUTDOORS_SpawnAct12Shrines`)
@ -3779,7 +4151,17 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
let floorLayers = 1
let substitutionType = 0
let version = 0
let act = 1
let inferredAct = 1
if (request.levelId >= 40 && request.levelId < 75) inferredAct = 2
else if (request.levelId >= 75 && request.levelId < 103) inferredAct = 3
else if (request.levelId >= 103 && request.levelId < 109) inferredAct = 4
else if (request.levelId >= 109) inferredAct = 5
else if (/Act 2/i.test(request.levelTypeName)) inferredAct = 2
else if (/Act 3/i.test(request.levelTypeName)) inferredAct = 3
else if (/Act 4/i.test(request.levelTypeName)) inferredAct = 4
else if (/Act 5/i.test(request.levelTypeName)) inferredAct = 5
let act = inferredAct
const allLevels: Ds1[] = []
for (const piece of request.pieces) for (const level of piece.levels) allLevels.push(level)
for (const row of [...request.substitutions, ...(request.shrineSubstitutions ?? [])]) {
@ -3790,7 +4172,10 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
floorLayers = Math.max(floorLayers, level.floorLayers)
substitutionType = Math.max(substitutionType, level.substitutionType)
version = Math.max(version, level.version)
act = level.act
if (level.act > 0) act = level.act
}
if (act === 1 && inferredAct > 1) {
act = inferredAct
}
const substitutionLayers = substitutionType === 1 || substitutionType === 2 ? 1 : 0
@ -4013,7 +4398,24 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
const inX = Math.floor(gridWidth / 2)
const outY = Math.floor(gridHeight / 2)
const reservedRoadBlocks = new Set<string>()
if (request.levelId >= 2 && request.levelId <= 7) {
if (
request.levelId !== 39 &&
((request.levelId >= 2 && request.levelId <= 7) ||
(request.levelId >= 41 && request.levelId <= 46) ||
(request.levelId >= 79 && request.levelId <= 82) ||
request.levelId === 111 ||
request.levelId === 112 ||
request.levelId === 117 ||
request.levelTypeName === 'Act 1 - Wilderness' ||
request.levelTypeName === 'Act 2 - Desert' ||
request.levelTypeName === 'Act 3 - Kurast' ||
request.levelTypeName === 'Act 5 - Barricade' ||
request.levelTypeName === 'Act 5 - Siege' ||
act === 1 ||
act === 2 ||
act === 3 ||
act === 5)
) {
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)}`)
}
@ -4056,12 +4458,33 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
dirtPathGrid,
)
// 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, allPlacedRects, rng)
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, allPlacedRects, rng)
}
// Stamp outdoor road networks and boundary divider lines across the wilderness
// after random clutter substitutions.
stampOutdoorRoads(
canvas,
act,
request.levelId,
gridWidth,
gridHeight,
inX,
outY,
allPlacedRects,
rng,
request.levelTypeName,
act1Plan?.openGates,
)
stampOutdoorDividers(
canvas,
act,
request.levelId,
gridWidth,
gridHeight,
inX,
outY,
allPlacedRects,
rng,
request.levelTypeName,
)
// Act 2 Desert Topography: Canyons, oasis pools, sand caravan trails, tomb entrances (DRLGOUTDESR_InitAct2OutdoorLevel)
if (

View File

@ -0,0 +1,416 @@
import { describe, it, expect } from 'vitest'
import {
createCanvas,
stampAct1DirtRoads,
stampAct1InteriorStoneWalls,
stampAct2DesertRoads,
stampAct2SandstoneBerms,
stampAct3KurastAvenues,
stampAct3KurastBalustrades,
stampAct5SnowRoads,
stampAct5FrozenTrailDivides,
stampOutdoorRoads,
stampOutdoorDividers,
generateWilderness,
TILES_PER_BLOCK,
} from '../src/game/wilderness.ts'
import type { WildernessPiece, PlacedPresetRect, Canvas } from '../src/game/wilderness.ts'
import { Rng } from '../src/game/rng.ts'
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
function makeMockDs1(width: number, height: number): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 2, sequence: 0, style: 1, 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 countFloorTiles(canvas: Canvas, style: number, prop1: number): number {
let count = 0
for (let y = 0; y < canvas.height; y += 1) {
const row = canvas.cells[y]
if (!row) continue
for (let x = 0; x < canvas.width; x += 1) {
const cell = row[x]
if (cell && cell.floors.some(f => f.style === style && f.prop1 === prop1)) {
count += 1
}
}
}
return count
}
function countWallTiles(canvas: Canvas, style: number, prop1: number): number {
let count = 0
for (let y = 0; y < canvas.height; y += 1) {
const row = canvas.cells[y]
if (!row) continue
for (let x = 0; x < canvas.width; x += 1) {
const cell = row[x]
if (cell && cell.walls.some(w => w.style === style && w.prop1 === prop1)) {
count += 1
}
}
}
return count
}
describe('Multi-Act Outdoor Roads & Boundary Divider Networks', () => {
const gridW = 10
const gridH = 10
const width = gridW * TILES_PER_BLOCK
const height = gridH * TILES_PER_BLOCK
const inX = Math.floor(gridW / 2)
const outY = Math.floor(gridH / 2)
describe('Act 2 Desert Roads & Sandstone Berms', () => {
it('stampAct2DesertRoads paints caravan routes with style: 1, prop1: 194 and connects spurs', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(42)
const placedRects: PlacedPresetRect[] = [
{ name: 'Act 2 - Desert Tomb 1', x: 24, y: 24, w: 16, h: 16 },
]
stampAct2DesertRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
const roadFloors = countFloorTiles(canvas, 1, 194)
expect(roadFloors).toBeGreaterThan(50)
// Verify the main highway corridor has caravan road floors
const highwayTile = canvas.cells[outY * TILES_PER_BLOCK + 4]?.[inX * TILES_PER_BLOCK + 4]
expect(highwayTile).toBeDefined()
expect(highwayTile?.floors.some(f => f.style === 1 && f.prop1 === 194)).toBe(true)
// Verify spur path connected towards the Tomb preset
let spurFound = false
for (let y = 30; y <= 34; y += 1) {
for (let x = 38; x <= 44; x += 1) {
const c = canvas.cells[y]?.[x]
if (c?.floors.some(f => f.style === 1 && f.prop1 === 194)) {
spurFound = true
break
}
}
}
expect(spurFound).toBe(true)
})
it('stampAct2SandstoneBerms paints sandstone berms with style: 1, prop1: 129, corner joins, and road archway breaks', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(42)
const placedRects: PlacedPresetRect[] = []
// First stamp roads so the divider can create 3-tile archway breaks across them
stampAct2DesertRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
stampAct2SandstoneBerms(canvas, gridW, gridH, inX, outY, placedRects, rng)
const bermWalls = countWallTiles(canvas, 1, 129)
expect(bermWalls).toBeGreaterThan(20)
// Berms should not block the road where the road crosses the divider
// Highway crosses dividing lines at (inX * 8 + 4, outY * 8 + 4)
const crossRoad = canvas.cells[outY * TILES_PER_BLOCK + 4]?.[inX * TILES_PER_BLOCK + 4]
expect(crossRoad?.walls.some(w => w.style === 1 && w.prop1 === 129)).toBe(false)
})
})
describe('Act 3 Kurast Avenues & Balustrades', () => {
it('stampAct3KurastAvenues paints flagstone avenues with style: 1, prop1: 194', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(100)
const placedRects: PlacedPresetRect[] = [
{ name: 'Act 3 - Burbs Temple', x: 20, y: 20, w: 16, h: 16 },
]
stampAct3KurastAvenues(canvas, gridW, gridH, inX, outY, placedRects, rng)
const avenueFloors = countFloorTiles(canvas, 1, 194)
expect(avenueFloors).toBeGreaterThan(50)
})
it('stampAct3KurastBalustrades paints balustrades with style: 1, prop1: 129', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(100)
const placedRects: PlacedPresetRect[] = []
stampAct3KurastAvenues(canvas, gridW, gridH, inX, outY, placedRects, rng)
stampAct3KurastBalustrades(canvas, gridW, gridH, inX, outY, placedRects, rng)
const balustradeWalls = countWallTiles(canvas, 1, 129)
expect(balustradeWalls).toBeGreaterThan(20)
})
})
describe('Act 5 Snow Roads & Frozen Trail Divides', () => {
it('stampAct5SnowRoads paints snow march roads with style: 1, prop1: 194', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(555)
const placedRects: PlacedPresetRect[] = [
{ name: 'Act 5 - Barricade Building', x: 20, y: 20, w: 16, h: 16 },
]
stampAct5SnowRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
const snowRoadFloors = countFloorTiles(canvas, 1, 194)
expect(snowRoadFloors).toBeGreaterThan(50)
})
it('stampAct5FrozenTrailDivides paints frozen trail divides with style: 2, prop1: 129', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(555)
const placedRects: PlacedPresetRect[] = []
stampAct5SnowRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
stampAct5FrozenTrailDivides(canvas, gridW, gridH, inX, outY, placedRects, rng)
const frozenDivides = countWallTiles(canvas, 2, 129)
expect(frozenDivides).toBeGreaterThan(20)
})
})
describe('Unified Dispatchers: stampOutdoorRoads & stampOutdoorDividers', () => {
it('dispatches Act 2 Desert based on levelId 41', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(123)
stampOutdoorRoads(canvas, 1, 41, gridW, gridH, inX, outY, [], rng)
stampOutdoorDividers(canvas, 1, 41, gridW, gridH, inX, outY, [], rng)
expect(countFloorTiles(canvas, 1, 194)).toBeGreaterThan(40)
expect(countWallTiles(canvas, 1, 129)).toBeGreaterThan(15)
})
it('dispatches Act 3 Kurast based on levelId 80', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(123)
stampOutdoorRoads(canvas, 1, 80, gridW, gridH, inX, outY, [], rng)
stampOutdoorDividers(canvas, 1, 80, gridW, gridH, inX, outY, [], rng)
expect(countFloorTiles(canvas, 1, 194)).toBeGreaterThan(40)
expect(countWallTiles(canvas, 1, 129)).toBeGreaterThan(15)
})
it('dispatches Act 5 Snow based on levelId 111', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(123)
stampOutdoorRoads(canvas, 1, 111, gridW, gridH, inX, outY, [], rng)
stampOutdoorDividers(canvas, 1, 111, gridW, gridH, inX, outY, [], rng)
expect(countFloorTiles(canvas, 1, 194)).toBeGreaterThan(40)
expect(countWallTiles(canvas, 2, 129)).toBeGreaterThan(15)
})
it('dispatches Act 1 Dirt Roads based on levelId 3', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(123)
stampOutdoorRoads(canvas, 1, 3, gridW, gridH, inX, outY, [], rng)
stampOutdoorDividers(canvas, 1, 3, gridW, gridH, inX, outY, [], rng)
expect(countFloorTiles(canvas, 5, 194)).toBeGreaterThan(40)
expect(countWallTiles(canvas, 2, 129)).toBeGreaterThan(15)
})
it('dispatches based on levelTypeName when levelId is generic', () => {
const c2 = createCanvas(width, height, 1, 1, 0)
stampOutdoorRoads(c2, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 2 - Desert')
stampOutdoorDividers(c2, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 2 - Desert')
expect(countFloorTiles(c2, 1, 194)).toBeGreaterThan(40)
expect(countWallTiles(c2, 1, 129)).toBeGreaterThan(15)
const c3 = createCanvas(width, height, 1, 1, 0)
stampOutdoorRoads(c3, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 3 - Kurast')
stampOutdoorDividers(c3, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 3 - Kurast')
expect(countFloorTiles(c3, 1, 194)).toBeGreaterThan(40)
expect(countWallTiles(c3, 1, 129)).toBeGreaterThan(15)
const c5 = createCanvas(width, height, 1, 1, 0)
stampOutdoorRoads(c5, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 5 - Barricade')
stampOutdoorDividers(c5, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 5 - Barricade')
expect(countFloorTiles(c5, 1, 194)).toBeGreaterThan(40)
expect(countWallTiles(c5, 2, 129)).toBeGreaterThan(15)
})
it('dispatches based on act number fallback', () => {
const c2 = createCanvas(width, height, 1, 1, 0)
stampOutdoorRoads(c2, 2, 999, gridW, gridH, inX, outY, [], new Rng(1))
stampOutdoorDividers(c2, 2, 999, gridW, gridH, inX, outY, [], new Rng(1))
expect(countFloorTiles(c2, 1, 194)).toBeGreaterThan(40)
expect(countWallTiles(c2, 1, 129)).toBeGreaterThan(15)
})
it('strictly skips Cow Level (levelId 39)', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(42)
stampOutdoorRoads(canvas, 1, 39, gridW, gridH, inX, outY, [], rng, 'Act 1 - Wilderness')
stampOutdoorDividers(canvas, 1, 39, gridW, gridH, inX, outY, [], rng, 'Act 1 - Wilderness')
expect(countFloorTiles(canvas, 1, 194)).toBe(0)
expect(countFloorTiles(canvas, 5, 194)).toBe(0)
expect(countWallTiles(canvas, 1, 129)).toBe(0)
expect(countWallTiles(canvas, 2, 129)).toBe(0)
})
})
describe('generateWilderness Integration for Acts 2, 3, 5', () => {
const borderDs1 = makeMockDs1(8, 8)
const borderPiece: WildernessPiece = {
name: 'Act 2 - Desert Border 1',
border: true,
levels: [borderDs1],
}
it('generates Act 2 Rocky Waste (Level 41) with caravan routes and sandstone berms', () => {
const result = generateWilderness({
levelId: 41,
levelName: 'Rocky Waste',
levelTypeName: 'Act 2 - Desert',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 77777,
pieces: [borderPiece],
substitutions: [],
})
expect(result.level.act).toBe(2)
let caravanRoads = 0
let sandstoneBerms = 0
for (let y = 8; y < 72; y += 1) {
for (let x = 8; x < 72; x += 1) {
const cell = result.level.cells[y]?.[x]
if (!cell) continue
if (cell.floors.some(f => f.style === 1 && f.prop1 === 194)) caravanRoads += 1
if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) sandstoneBerms += 1
}
}
expect(caravanRoads).toBeGreaterThan(30)
expect(sandstoneBerms).toBeGreaterThan(15)
})
it('generates Act 3 Kurast Bazaar (Level 80) with Kurast avenues and balustrades', () => {
const act3Border: WildernessPiece = {
name: 'Act 3 - Kurast Border',
border: true,
levels: [borderDs1],
}
const result = generateWilderness({
levelId: 80,
levelName: 'Kurast Bazaar',
levelTypeName: 'Act 3 - Kurast',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 88888,
pieces: [act3Border],
substitutions: [],
})
expect(result.level.act).toBe(3)
let avenues = 0
let balustrades = 0
for (let y = 8; y < 72; y += 1) {
for (let x = 8; x < 72; x += 1) {
const cell = result.level.cells[y]?.[x]
if (!cell) continue
if (cell.floors.some(f => f.style === 1 && f.prop1 === 194)) avenues += 1
if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) balustrades += 1
}
}
expect(avenues).toBeGreaterThan(30)
expect(balustrades).toBeGreaterThan(15)
})
it('generates Act 5 Frigid Highlands (Level 111) with snow roads and frozen trail divides', () => {
const act5Border: WildernessPiece = {
name: 'Act 5 - Barricade Border',
border: true,
levels: [borderDs1],
}
const result = generateWilderness({
levelId: 111,
levelName: 'Frigid Highlands',
levelTypeName: 'Act 5 - Barricade',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 99999,
pieces: [act5Border],
substitutions: [],
})
expect(result.level.act).toBe(5)
let snowRoads = 0
let frozenDivides = 0
for (let y = 8; y < 72; y += 1) {
for (let x = 8; x < 72; x += 1) {
const cell = result.level.cells[y]?.[x]
if (!cell) continue
if (cell.floors.some(f => f.style === 1 && f.prop1 === 194)) snowRoads += 1
if (cell.walls.some(w => w.style === 2 && w.prop1 === 129)) frozenDivides += 1
}
}
expect(snowRoads).toBeGreaterThan(30)
expect(frozenDivides).toBeGreaterThan(15)
})
it('generates Cow Level (Level 39) without roads or stone divider networks', () => {
const cowBorder: WildernessPiece = {
name: 'Act 1 - Wild Border 1',
border: true,
levels: [borderDs1],
}
const result = generateWilderness({
levelId: 39,
levelName: 'Moo Moo Farm',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 12345,
pieces: [cowBorder],
substitutions: [],
})
let roadFloors = 0
let dividerWalls = 0
for (let y = 8; y < 72; y += 1) {
for (let x = 8; x < 72; x += 1) {
const cell = result.level.cells[y]?.[x]
if (!cell) continue
if (cell.floors.some(f => (f.style === 1 || f.style === 5) && f.prop1 === 194)) roadFloors += 1
if (cell.walls.some(w => (w.style === 1 || w.style === 2) && w.prop1 === 129)) dividerWalls += 1
}
}
expect(roadFloors).toBe(0)
expect(dividerWalls).toBe(0)
})
})
})