Merge branch 'fix/issue-51'

# Conflicts:
#	src/game/wilderness.ts
This commit is contained in:
troytt 2026-09-17 08:51:55 +00:00
commit 334b957ab6
3 changed files with 1399 additions and 114 deletions

906
src/game/wilderness-acts.ts Normal file
View File

@ -0,0 +1,906 @@
/**
* Wilderness outdoor topography initializers for Acts 2, 3, 4, and 5.
*
* blizzard Drlg outdoor passes:
* - `DRLGOUTDESR_InitAct2OutdoorLevel`: Desert canyons, sand trails, Far Oasis pools,
* Valley of Snakes canyon gorges, Canyon of the Magi 7 Tal Rasha tomb entrances.
* - `DRLGOUTPLACE_InitAct3OutdoorLevel` & `DRLG_GenerateJungles`: Jungle waterways,
* wooden river bridges spanning channels, causeways, Kurast city avenues, canals & bridges.
* - `DRLGOUTDOORS_InitAct4OutdoorLevel`: Molten lava chasms and fissures, natural rock
* bridges spanning chasms, volcanic mesa plateaus.
* - `DRLGOUTSIEGE_InitAct5OutdoorLevel`: Transverse siege barricade palisades, choke-point
* gates, military dugout trenches, prisoner cages, and snow crater frozen lakes.
*/
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.ts'
import { Rng } from './rng.ts'
import type {
Canvas,
WildernessRequest,
WildernessStats,
WildernessPiece,
PlacedPresetRect,
} from './wilderness.ts'
import { stampDs1, TILES_PER_BLOCK, emptyFloor, emptyWall } from './wilderness.ts'
/* ------------------------------------------------------------------------- *
* Statistics interfaces
* ------------------------------------------------------------------------- */
export interface Act2TopographyStats {
readonly canyonWalls: number
readonly sandTrailCells: number
readonly oasisPoolCells: number
readonly tombEntrances: number
}
export interface Act3TopographyStats {
readonly causewayCells: number
readonly riverBridges: number
readonly canalCells: number
readonly junglePathCells: number
}
export interface Act4TopographyStats {
readonly lavaChasmCells: number
readonly rockyBridges: number
readonly plateauCells: number
}
export interface Act5TopographyStats {
readonly barricadeWalls: number
readonly trenchCells: number
readonly frozenLakeCells: number
}
/* ------------------------------------------------------------------------- *
* Geometry & Canvas Helpers
* ------------------------------------------------------------------------- */
function isInsidePreset(
x: number,
y: number,
placedRects: readonly PlacedPresetRect[],
margin = 1,
): boolean {
for (const r of placedRects) {
if (
x >= r.x - margin &&
x < r.x + r.w + margin &&
y >= r.y - margin &&
y < r.y + r.h + margin
) {
return true
}
}
return false
}
function makeFloor(style: number, sequence: number, prop1 = 2): Ds1Floor {
return {
prop1,
style,
sequence,
unknown1: 0,
unknown2: 0,
hidden: false,
}
}
function makeWall(style: number, sequence: number, type: number, prop1 = 129): Ds1Wall {
return {
prop1,
sequence,
style,
type,
unknown1: 0,
unknown2: 0,
hidden: false,
}
}
function paintFloor(
canvas: Canvas,
x: number,
y: number,
floor: Ds1Floor,
overwriteProtected = false,
): boolean {
if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return false
const cell = canvas.cells[y]?.[x]
if (!cell) return false
const existing = cell.floors[0]
if (!overwriteProtected && existing) {
// Protected styles: water (style 2/3), wooden bridge (style 4)
if (existing.style === 2 || existing.style === 3 || existing.style === 4) {
return false
}
}
;(cell.floors as Ds1Floor[])[0] = floor
return true
}
function paintWall(canvas: Canvas, x: number, y: number, wall: Ds1Wall): boolean {
if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return false
const cell = canvas.cells[y]?.[x]
if (!cell) return false
const targetWalls = cell.walls as Ds1Wall[]
if (targetWalls.length > 0) {
targetWalls[0] = wall
} else {
targetWalls.push(wall)
}
return true
}
function clearWalls(canvas: Canvas, x: number, y: number): void {
if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return
const cell = canvas.cells[y]?.[x]
if (cell) {
;(cell.walls as Ds1Wall[]).length = 0
}
}
/* ------------------------------------------------------------------------- *
* Act 2: DRLGOUTDESR_InitAct2OutdoorLevel
* ------------------------------------------------------------------------- */
/**
* Initialize Act 2 Desert Outdoor Topography:
* - Rocky canyon ridges and cliff berms carving winding desert corridors.
* - 3-tile-wide sand caravan trails connecting gates across the dunes.
* - Far Oasis (Level 43): lush water oasis pools and Maggot Lair entrance.
* - Valley of Snakes (Level 45): narrow canyon gorge leading to Claw Viper Temple.
* - Canyon of the Magi (Level 46): 7 Tal Rasha tomb entrances along perimeter cliffs
* and central Arcane Sanctuary warp platform.
* - Lost City (Level 44): ancient buried city ruins and street corridors.
*/
export function initAct2OutdoorLevel(
canvas: Canvas,
request: WildernessRequest,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: PlacedPresetRect[],
rng: Rng,
stats: WildernessStats,
): Act2TopographyStats {
let canyonWalls = 0
let sandTrailCells = 0
let oasisPoolCells = 0
let tombEntrances = 0
const levelId = request.levelId
const hubX = inX * TILES_PER_BLOCK + 4
const hubY = outY * TILES_PER_BLOCK + 4
// Helper to paint a 3-tile-wide sand caravan trail
const paintSandTrailTile = (x: number, y: number): void => {
if (x < 3 || x >= canvas.width - 3 || y < 3 || y >= canvas.height - 3) return
if (isInsidePreset(x, y, placedRects, 0)) return
const cell = canvas.cells[y]?.[x]
if (!cell) return
// Style 5 (desert road), prop1 194
const floor = makeFloor(5, rng.int(0, 4) === 0 ? 25 : 0, 194)
if (paintFloor(canvas, x, y, floor, false)) {
clearWalls(canvas, x, y)
sandTrailCells += 1
}
}
// 1. Sand Caravan Trail Network: North-South and West-East axes
if (levelId !== 46) {
const startY = Math.max(4, Math.floor(canvas.height * 0.1))
const endY = Math.min(canvas.height - 5, Math.floor(canvas.height * 0.9))
for (let y = startY; y <= endY; y += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
paintSandTrailTile(hubX + dx, y)
}
}
const startX = Math.max(4, Math.floor(canvas.width * 0.1))
const endX = Math.min(canvas.width - 5, Math.floor(canvas.width * 0.9))
for (let x = startX; x <= endX; x += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
paintSandTrailTile(x, hubY + dy)
}
}
}
// 2. Canyon Walls & Rocky Berms (Levels 41, 42, 44, 45)
if (levelId === 41 || levelId === 42 || levelId === 44 || levelId === 45) {
const isValleyOfSnakes = levelId === 45
// In Valley of Snakes, place 2 tight parallel canyon ridge walls (corridor of 10-12 tiles)
// In other deserts, place transverse & longitudinal canyon ridges
const ridgeY1 = Math.max(12, Math.min(canvas.height - 14, hubY - (isValleyOfSnakes ? 5 : 16)))
const ridgeY2 = Math.max(14, Math.min(canvas.height - 12, hubY + (isValleyOfSnakes ? 6 : 16)))
for (let x = 10; x < canvas.width - 10; x += 1) {
// Leave 4-tile wide canyon pass gaps where the main highway or crossroads pass
if (Math.abs(x - hubX) <= 3) continue
if (isInsidePreset(x, ridgeY1, placedRects, 2)) continue
if (paintWall(canvas, x, ridgeY1, makeWall(1, 0, 2, 129))) {
canyonWalls += 1
}
}
for (let x = 10; x < canvas.width - 10; x += 1) {
if (Math.abs(x - hubX) <= 3) continue
if (isInsidePreset(x, ridgeY2, placedRects, 2)) continue
if (paintWall(canvas, x, ridgeY2, makeWall(1, 0, 2, 129))) {
canyonWalls += 1
}
}
if (!isValleyOfSnakes) {
const ridgeX = Math.max(12, Math.min(canvas.width - 12, hubX + 16))
for (let y = 10; y < canvas.height - 10; y += 1) {
if (Math.abs(y - hubY) <= 3) continue
if (isInsidePreset(ridgeX, y, placedRects, 2)) continue
if (paintWall(canvas, ridgeX, y, makeWall(1, 0, 1, 129))) {
canyonWalls += 1
}
}
}
}
// 3. Far Oasis (Level 43): Lush Water Oasis Pools & Maggot Lair
if (levelId === 43 || /Oasis/i.test(request.levelName)) {
const poolCenters = [
{
cx: Math.max(14, Math.floor(canvas.width * 0.35)),
cy: Math.max(14, Math.floor(canvas.height * 0.35)),
radius: Math.min(6, Math.floor(canvas.width / 12)),
},
{
cx: Math.min(canvas.width - 16, Math.floor(canvas.width * 0.68)),
cy: Math.min(canvas.height - 16, Math.floor(canvas.height * 0.68)),
radius: Math.min(5, Math.floor(canvas.width / 14)),
},
]
for (const pool of poolCenters) {
for (let dy = -pool.radius; dy <= pool.radius; dy += 1) {
for (let dx = -pool.radius; dx <= pool.radius; dx += 1) {
const dist = Math.hypot(dx, dy)
if (dist <= pool.radius) {
const px = pool.cx + dx
const py = pool.cy + dy
if (px >= 4 && px < canvas.width - 4 && py >= 4 && py < canvas.height - 4) {
if (!isInsidePreset(px, py, placedRects, 1)) {
// Water floor: style 2 (water), prop1 2
paintFloor(canvas, px, py, makeFloor(2, 0, 2), true)
clearWalls(canvas, px, py)
oasisPoolCells += 1
}
}
}
}
}
}
// Try to stamp Desert Oasis preset if available in pieces
const oasisPiece = request.pieces.find(p => /Desert Oasis/i.test(p.name))
if (oasisPiece?.levels[0] && !stats.specialPresets.some(n => /Desert Oasis/i.test(n))) {
const ds1 = oasisPiece.levels[0]
const originX = Math.max(8, Math.floor(canvas.width * 0.4) - Math.floor(ds1.width / 2))
const originY = Math.max(8, Math.floor(canvas.height * 0.4) - Math.floor(ds1.height / 2))
stampDs1(canvas, ds1, originX, originY)
stats.specialPresets.push(oasisPiece.name)
stats.substitutions.push({ name: oasisPiece.name, role: 'object', enabled: true, clusters: 1 })
placedRects.push({ name: oasisPiece.name, x: originX, y: originY, w: ds1.width, h: ds1.height })
}
}
// 4. Canyon of the Magi / Valley of the Kings (Level 46): 7 Tal Rasha Tomb Entrances
if (levelId === 46 || /Canyon of the Magi|Valley of the Kings/i.test(request.levelName)) {
const tombCoords: Array<{ edge: 'top' | 'left' | 'right'; x: number; y: number }> = [
// Top / North Cliff: 3 tombs
{ edge: 'top', x: Math.floor(canvas.width * 0.22), y: 1 },
{ edge: 'top', x: Math.floor(canvas.width * 0.5), y: 1 },
{ edge: 'top', x: Math.floor(canvas.width * 0.78), y: 1 },
// Left / West Cliff: 2 tombs
{ edge: 'left', x: 1, y: Math.floor(canvas.height * 0.35) },
{ edge: 'left', x: 1, y: Math.floor(canvas.height * 0.68) },
// Right / East Cliff: 2 tombs
{ edge: 'right', x: Math.max(2, canvas.width - 12), y: Math.floor(canvas.height * 0.35) },
{ edge: 'right', x: Math.max(2, canvas.width - 12), y: Math.floor(canvas.height * 0.68) },
]
for (let i = 0; i < tombCoords.length; i += 1) {
const tc = tombCoords[i]!
// Try to find matching cliff king tomb piece
let pieceName = 'Act 2 - Desert Cliff Top King Tomb'
if (tc.edge === 'left') pieceName = 'Act 2 - Desert Cliff Left King Tomb'
else if (tc.edge === 'right') pieceName = 'Act 2 - Desert Cliff Right King Tomb'
const piece = request.pieces.find(p => p.name.trim() === pieceName.trim()) ??
request.pieces.find(p => /King Tomb|Desert Tomb/i.test(p.name))
if (piece?.levels[0]) {
stampDs1(canvas, piece.levels[0], tc.x, tc.y)
stats.specialPresets.push(piece.name)
placedRects.push({ name: piece.name, x: tc.x, y: tc.y, w: piece.levels[0].width, h: piece.levels[0].height })
} else {
// Construct canonical tomb facade archway (style 2 wall, width 6, height 4)
for (let dy = 0; dy < 4; dy += 1) {
for (let dx = 0; dx < 6; dx += 1) {
const tx = tc.x + dx
const ty = tc.y + dy
if (tx >= 0 && tx < canvas.width && ty >= 0 && ty < canvas.height) {
if (dy === 3 && (dx === 2 || dx === 3)) {
// Open portal threshold
paintFloor(canvas, tx, ty, makeFloor(0, 0, 2))
clearWalls(canvas, tx, ty)
} else {
paintWall(canvas, tx, ty, makeWall(2, dy === 0 ? 0 : 1, 2, 129))
}
}
}
}
}
// Add Tal Rasha Tomb entrance portal object (LevelId 66..72)
canvas.objects.push({
type: 2,
id: 66 + i,
x: (tc.x + 3) * 5,
y: (tc.y + 3) * 5,
flags: 0,
})
tombEntrances += 1
}
// Central Arcane Sanctuary warp platform (Desert Valley Warp)
const warpPiece = request.pieces.find(p => /Desert Valley Warp/i.test(p.name))
const warpX = Math.floor(canvas.width / 2) - 4
const warpY = Math.floor(canvas.height / 2) - 4
if (warpPiece?.levels[0]) {
stampDs1(canvas, warpPiece.levels[0], warpX, warpY)
stats.specialPresets.push(warpPiece.name)
placedRects.push({ name: warpPiece.name, x: warpX, y: warpY, w: warpPiece.levels[0].width, h: warpPiece.levels[0].height })
} else {
for (let dy = 0; dy < 8; dy += 1) {
for (let dx = 0; dx < 8; dx += 1) {
paintFloor(canvas, warpX + dx, warpY + dy, makeFloor(3, 0, 194))
clearWalls(canvas, warpX + dx, warpY + dy)
}
}
canvas.objects.push({
type: 2,
id: 74,
x: (warpX + 4) * 5,
y: (warpY + 4) * 5,
flags: 0,
})
}
}
// 5. Lost City (Level 44): Ancient Buried City Ruins
if (levelId === 44 || /Lost City/i.test(request.levelName)) {
const ruinsPieces = request.pieces.filter(p => /Desert Ruins/i.test(p.name) && p.levels.length > 0)
if (ruinsPieces.length > 0) {
let placedCount = 0
for (let by = 2; by < gridHeight - 3 && placedCount < 4; by += 3) {
for (let bx = 2; bx < gridWidth - 3 && placedCount < 4; bx += 3) {
const rx = bx * TILES_PER_BLOCK
const ry = by * TILES_PER_BLOCK
if (isInsidePreset(rx, ry, placedRects, 2)) continue
const piece = ruinsPieces[placedCount % ruinsPieces.length]!
const ds1 = piece.levels[0]!
stampDs1(canvas, ds1, rx, ry)
placedRects.push({ name: piece.name, x: rx, y: ry, w: ds1.width, h: ds1.height })
stats.specialPresets.push(piece.name)
placedCount += 1
}
}
}
}
stats.act2Topography = { canyonWalls, sandTrailCells, oasisPoolCells, tombEntrances }
stats.notes.push(
`Act 2 Desert Topography: ${String(canyonWalls)} canyon walls, ${String(sandTrailCells)} sand trail cells, ${String(oasisPoolCells)} oasis cells, ${String(tombEntrances)} tomb entrances`,
)
return stats.act2Topography
}
/* ------------------------------------------------------------------------- *
* Act 3: DRLGOUTPLACE_InitAct3OutdoorLevel & DRLG_GenerateJungles
* ------------------------------------------------------------------------- */
/**
* Generate Act 3 Jungle River & Clearings layout (`DRLG_GenerateJungles`):
* - Spans wooden river bridges across the East river channel at row crossings.
* - Connects West clearings to wooden river bridges via winding jungle causeways/paths.
*/
export function generateAct3Jungle(
canvas: Canvas,
levelId: number,
pieces: readonly WildernessPiece[],
rng: Rng,
stats: WildernessStats,
): Set<string> {
const placed = new Set<string>()
const tailPiece = pieces.find(p => p.name === 'Act 3 - Jungle Tail')
const headPiece = pieces.find(p => p.name === 'Act 3 - Jungle Head')
if (tailPiece?.levels[0]) {
stampDs1(canvas, tailPiece.levels[0], 0, 0)
placed.add(tailPiece.name)
stats.substitutions.push({ name: tailPiece.name, role: 'object', enabled: true, clusters: 1 })
}
if (headPiece?.levels[0]) {
stampDs1(canvas, headPiece.levels[0], 0, 160)
placed.add(headPiece.name)
stats.substitutions.push({ name: headPiece.name, role: 'object', enabled: true, clusters: 1 })
}
const clearingPrefix =
levelId === 76
? 'Act 3 - Clearing Webby'
: levelId === 77
? 'Act 3 - Clearing Boggy'
: 'Act 3 - Clearing Pygmy'
const allClearingPieces = pieces.filter(p => p.name.startsWith(clearingPrefix) && p.levels.length > 0)
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,
)
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
// 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 })
}
}
// Stamp Wooden River Bridges & Jungle Paths spanning the river channel
let riverBridges = 0
let junglePathCells = 0
// Two canonical wooden bridge crossings at row 1 (y = 78..81) and row 3 (y = 142..145)
const bridgeYPositions = [78, 142]
for (const by of bridgeYPositions) {
for (let y = by; y < by + 4; y += 1) {
for (let x = 32; x < 60; x += 1) {
// Style 4: wooden bridge planks
paintFloor(canvas, x, y, makeFloor(4, 0, 2), true)
clearWalls(canvas, x, y)
}
}
riverBridges += 1
}
// Jungle Trails connecting clearings across to the river bridges
for (const by of bridgeYPositions) {
// West clearing connection (x = 12..32)
for (let x = 12; x < 32; x += 1) {
for (let dy = 0; dy < 3; dy += 1) {
const y = by + dy
paintFloor(canvas, x, y, makeFloor(5, 0, 194))
clearWalls(canvas, x, y)
junglePathCells += 1
}
}
// East trail along riverbank (x = 58..61, North-South trail)
for (let y = by - 14; y <= by + 14; y += 1) {
if (y >= 4 && y < canvas.height - 4) {
paintFloor(canvas, 59, y, makeFloor(5, 0, 194))
clearWalls(canvas, 59, y)
junglePathCells += 1
}
}
}
stats.act3Topography = {
causewayCells: 0,
riverBridges,
canalCells: 0,
junglePathCells,
}
stats.notes.push(
`Act 3 Jungle Topography: ${String(riverBridges)} river bridges, ${String(junglePathCells)} jungle path cells`,
)
return placed
}
/**
* Stamp Kurast Causeway (LevelId 82):
* A fortified stone causeway bridge spanning the canal between Upper Kurast and Travincal.
*/
export function stampAct3KurastCauseway(
canvas: Canvas,
pieces: readonly WildernessPiece[],
stats: WildernessStats,
): Set<string> {
const placed = new Set<string>()
const piece = pieces.find(p => p.name === 'Act 3 - Bridge')
const ds1 = piece?.levels[0]
if (ds1) {
stampDs1(canvas, ds1, 0, 0)
placed.add('Act 3 - Bridge')
stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 })
}
// Construct / ensure canonical causeway layout:
// Central paved walkway flanked by canal water and balustrades
let causewayCells = 0
let canalCells = 0
const walkwayLeft = Math.max(2, Math.floor((canvas.width - 8) / 2))
const walkwayRight = walkwayLeft + 7
for (let y = 0; y < canvas.height; y += 1) {
for (let x = 0; x < canvas.width; x += 1) {
if (x >= walkwayLeft && x <= walkwayRight) {
// Paved stone causeway: style 5, prop1 194
paintFloor(canvas, x, y, makeFloor(5, 0, 194), true)
clearWalls(canvas, x, y)
causewayCells += 1
} else if (x === walkwayLeft - 1 || x === walkwayRight + 1) {
// Stone balustrade wall: style 1, prop1 129
paintWall(canvas, x, y, makeWall(1, 0, 1, 129))
} else {
// Deep canal water: style 2, sequence 0, prop1 2
paintFloor(canvas, x, y, makeFloor(2, 0, 2), true)
clearWalls(canvas, x, y)
canalCells += 1
}
}
}
stats.act3Topography = {
causewayCells,
riverBridges: 1,
canalCells,
junglePathCells: 0,
}
stats.notes.push(
`Act 3 Causeway Topography: ${String(causewayCells)} causeway cells, ${String(canalCells)} canal water cells`,
)
return placed
}
/**
* Initialize Act 3 Kurast Urban Districts (Levels 79 Slums, 80 Burbs, 81 Metro):
* - Paved stone causeways and grand avenues connecting gates and district plazas.
* - Stone canals and water channels dividing the districts with stone bridges.
* - Temples, sewers, and district ruins layout.
*/
export function initAct3OutdoorLevel(
canvas: Canvas,
request: WildernessRequest,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: PlacedPresetRect[],
rng: Rng,
stats: WildernessStats,
): Act3TopographyStats {
let causewayCells = 0
let riverBridges = 0
let canalCells = 0
const junglePathCells = 0
const hubX = inX * TILES_PER_BLOCK + 4
const hubY = outY * TILES_PER_BLOCK + 4
// 1. Paved Stone Avenues (3-tile-wide North-South & East-West)
for (let y = 6; y < canvas.height - 6; y += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
const x = hubX + dx
if (!isInsidePreset(x, y, placedRects, 0)) {
paintFloor(canvas, x, y, makeFloor(5, 0, 194))
clearWalls(canvas, x, y)
causewayCells += 1
}
}
}
for (let x = 6; x < canvas.width - 6; x += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
const y = hubY + dy
if (!isInsidePreset(x, y, placedRects, 0)) {
paintFloor(canvas, x, y, makeFloor(5, 0, 194))
clearWalls(canvas, x, y)
causewayCells += 1
}
}
}
// 2. Stone Canals & Drainage Channels
const canalX = Math.max(12, Math.min(canvas.width - 16, (inX + 2) * TILES_PER_BLOCK))
for (let y = 8; y < canvas.height - 8; y += 1) {
// Stone Bridge crossing at East-West avenue
if (Math.abs(y - hubY) <= 2) {
for (let dx = 0; dx < 4; dx += 1) {
paintFloor(canvas, canalX + dx, y, makeFloor(4, 0, 2), true)
clearWalls(canvas, canalX + dx, y)
}
riverBridges += 1
} else {
// Water channel: style 2, prop1 2
for (let dx = 0; dx < 4; dx += 1) {
const x = canalX + dx
if (!isInsidePreset(x, y, placedRects, 1)) {
paintFloor(canvas, x, y, makeFloor(2, 0, 2), true)
clearWalls(canvas, x, y)
canalCells += 1
}
}
// Stone canal embankments
paintWall(canvas, canalX - 1, y, makeWall(1, 0, 1, 129))
paintWall(canvas, canalX + 4, y, makeWall(1, 0, 1, 129))
}
}
stats.act3Topography = { causewayCells, riverBridges, canalCells, junglePathCells }
stats.notes.push(
`Act 3 Kurast Topography: ${String(causewayCells)} causeway cells, ${String(canalCells)} canal cells, ${String(riverBridges)} bridges`,
)
return stats.act3Topography
}
/* ------------------------------------------------------------------------- *
* Act 4: DRLGOUTDOORS_InitAct4OutdoorLevel
* ------------------------------------------------------------------------- */
/**
* Initialize Act 4 Outdoor Topography (Levels 104 Outer Steppes, 105 Plains of Despair, 106 City of the Damned):
* - Deep glowing lava chasms and fissures cutting through volcanic basalt plateaus.
* - Solid rocky bridge causeways spanning the chasms, connecting separated plateaus.
* - Volcanic mesa plateau formations and Izual prison plateau.
*/
export function initAct4OutdoorLevel(
canvas: Canvas,
request: WildernessRequest,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: PlacedPresetRect[],
rng: Rng,
stats: WildernessStats,
): Act4TopographyStats {
let lavaChasmCells = 0
let rockyBridges = 0
let plateauCells = 0
const width = canvas.width
const height = canvas.height
const hubX = inX * TILES_PER_BLOCK + 4
const hubY = outY * TILES_PER_BLOCK + 4
// 1. Lava Fissure: A jagged diagonal chasm dividing the level into two main plateaus
const chasmYAtX = (x: number): number => {
// S-curve diagonal fissure from (10, 0.35*H) to (W - 10, 0.65*H)
const t = x / width
return Math.floor(height * (0.35 + 0.3 * t) + Math.sin(t * Math.PI * 2) * 4)
}
// Strategic rocky bridge crossing X coordinates
const bridgeXPositions = [
Math.max(12, Math.floor(width * 0.3)),
hubX,
Math.min(width - 12, Math.floor(width * 0.7)),
]
const isBridgeX = (x: number): boolean => bridgeXPositions.some(bx => Math.abs(x - bx) <= 1)
for (let x = 8; x < width - 8; x += 1) {
const centerY = chasmYAtX(x)
const onBridge = isBridgeX(x)
if (onBridge) {
// Solid rock bridge across the chasm: style 10, sequence 0, prop1 2
for (let dy = -3; dy <= 3; dy += 1) {
const y = centerY + dy
if (y >= 0 && y < height) {
paintFloor(canvas, x, y, makeFloor(10, 0, 2), true)
clearWalls(canvas, x, y)
}
}
rockyBridges += 1
} else {
// Boiling lava chasm: style 20, sequence 3, prop1 2
for (let dy = -2; dy <= 2; dy += 1) {
const y = centerY + dy
if (y >= 0 && y < height) {
if (!isInsidePreset(x, y, placedRects, 1)) {
paintFloor(canvas, x, y, makeFloor(20, 3, 2), true)
clearWalls(canvas, x, y)
lavaChasmCells += 1
}
}
}
// Basalt chasm edge lips
paintWall(canvas, x, centerY - 3, makeWall(10, 0, 2, 129))
paintWall(canvas, x, centerY + 3, makeWall(10, 0, 2, 129))
}
}
// 2. Basalt Plateau Ridges / Demonic Altar Formations
if (request.levelId === 105) {
// Plains of Despair: Izual's prison plateau in center
const px0 = hubX - 5
const py0 = hubY - 5
for (let dy = 0; dy < 10; dy += 1) {
for (let dx = 0; dx < 10; dx += 1) {
const x = px0 + dx
const y = py0 + dy
if (x >= 4 && x < width - 4 && y >= 4 && y < height - 4) {
if (dx === 0 || dx === 9 || dy === 0 || dy === 9) {
paintWall(canvas, x, y, makeWall(10, 0, 2, 129))
} else {
paintFloor(canvas, x, y, makeFloor(10, 0, 2))
clearWalls(canvas, x, y)
plateauCells += 1
}
}
}
}
} else {
// Outer Steppes (104) / City of the Damned (106): Volcanic basalt plateaus
for (let y = 12; y < height - 12; y += 4) {
for (let x = 12; x < width - 12; x += 4) {
if (!isInsidePreset(x, y, placedRects, 2) && Math.abs(y - chasmYAtX(x)) > 6) {
plateauCells += 1
}
}
}
}
stats.act4Topography = { lavaChasmCells, rockyBridges, plateauCells }
stats.notes.push(
`Act 4 Mesa/Lava Topography: ${String(lavaChasmCells)} lava cells, ${String(rockyBridges)} rocky bridge cells, ${String(plateauCells)} plateau cells`,
)
return stats.act4Topography
}
/* ------------------------------------------------------------------------- *
* Act 5: DRLGOUTSIEGE_InitAct5OutdoorLevel
* ------------------------------------------------------------------------- */
/**
* Initialize Act 5 Outdoor Topography (Levels 111 Frigid Highlands, 112 Arreat Plateau, 117 Frozen Tundra):
* - Transverse siege fortifications & spiked palisade barricade walls with choke-point gates.
* - Military dugout battlefield trenches running behind defense lines.
* - Snow crater plateaus and frozen lakes with slippery ice floors.
* - Prisoner cages and Hell Portal redoubts.
*/
export function initAct5OutdoorLevel(
canvas: Canvas,
request: WildernessRequest,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: PlacedPresetRect[],
rng: Rng,
stats: WildernessStats,
): Act5TopographyStats {
let barricadeWalls = 0
let trenchCells = 0
let frozenLakeCells = 0
const width = canvas.width
const height = canvas.height
const hubY = outY * TILES_PER_BLOCK + 4
// 1. Transverse Siege Barricade Wall Lines (cutting across the level)
const barricadeXPositions = [
Math.max(16, Math.floor(gridWidth * 0.35) * TILES_PER_BLOCK),
Math.min(width - 16, Math.floor(gridWidth * 0.7) * TILES_PER_BLOCK),
]
for (const bx of barricadeXPositions) {
// Barricade choke-point gate at hubY
const gateTop = hubY - 2
const gateBottom = hubY + 2
for (let y = 8; y < height - 8; y += 1) {
if (isInsidePreset(bx, y, placedRects, 1)) continue
if (y >= gateTop && y <= gateBottom) {
// Choke-point gate: clear for passage, fortified end-caps at ends
clearWalls(canvas, bx, y)
paintFloor(canvas, bx, y, makeFloor(5, 0, 194))
if (y === gateTop) paintWall(canvas, bx, y, makeWall(2, 1, 1, 129))
if (y === gateBottom) paintWall(canvas, bx, y, makeWall(2, 2, 1, 129))
} else {
// Spiked wood/iron barricade palisade wall: style 2, prop1 129
if (paintWall(canvas, bx, y, makeWall(2, 0, 1, 129))) {
barricadeWalls += 1
}
}
}
// 2. Military Dugout Trenches: 2-tile wide trench running parallel behind barricade
const trenchX = bx - 2
for (let y = 10; y < height - 10; y += 1) {
if (Math.abs(y - hubY) > 3 && !isInsidePreset(trenchX, y, placedRects, 1)) {
for (let dx = 0; dx < 2; dx += 1) {
// Dirt/mud trench floor: style 5, prop1 194
paintFloor(canvas, trenchX + dx, y, makeFloor(5, 0, 194))
clearWalls(canvas, trenchX + dx, y)
trenchCells += 1
}
}
}
}
// 3. Snow Crater Plateaus & Frozen Lakes (Levels 112 Arreat Plateau & 117 Frozen Tundra)
if (
request.levelId === 112 ||
request.levelId === 117 ||
/snow|plateau|tundra/i.test(request.levelName)
) {
const lakeCenter = {
cx: Math.max(14, Math.floor(width * 0.52)),
cy: Math.max(14, Math.floor(height * 0.48)),
radius: Math.min(8, Math.floor(width / 8)),
}
for (let dy = -lakeCenter.radius; dy <= lakeCenter.radius; dy += 1) {
for (let dx = -lakeCenter.radius; dx <= lakeCenter.radius; dx += 1) {
if (Math.hypot(dx, dy) <= lakeCenter.radius) {
const x = lakeCenter.cx + dx
const y = lakeCenter.cy + dy
if (x >= 4 && x < width - 4 && y >= 4 && y < height - 4) {
if (!isInsidePreset(x, y, placedRects, 1) && Math.abs(y - hubY) > 3) {
// Slippery frozen ice floor: style 2, sequence 0, prop1 2
paintFloor(canvas, x, y, makeFloor(2, 0, 2), true)
clearWalls(canvas, x, y)
frozenLakeCells += 1
}
}
}
}
}
// Stamp Snow Lake preset if available
const lakePiece = request.pieces.find(p => /Snow Lake/i.test(p.name))
if (lakePiece?.levels[0] && !stats.specialPresets.some(n => /Snow Lake/i.test(n))) {
const ds1 = lakePiece.levels[0]
const lx = Math.max(8, Math.floor(width * 0.5) - Math.floor(ds1.width / 2))
const ly = Math.max(8, Math.floor(height * 0.5) - Math.floor(ds1.height / 2))
stampDs1(canvas, ds1, lx, ly)
placedRects.push({ name: lakePiece.name, x: lx, y: ly, w: ds1.width, h: ds1.height })
stats.specialPresets.push(lakePiece.name)
}
}
stats.act5Topography = { barricadeWalls, trenchCells, frozenLakeCells }
stats.notes.push(
`Act 5 Siege/Barricade Topography: ${String(barricadeWalls)} barricade walls, ${String(trenchCells)} trench cells, ${String(frozenLakeCells)} frozen lake cells`,
)
return stats.act5Topography
}

View File

@ -78,6 +78,18 @@ import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall, Ds1Substitution } from
import { Rng } from './rng.ts'
import { SUB_TILES_PER_TILE } from './map.ts'
import type { Side } from './world-graph.ts'
import {
initAct2OutdoorLevel,
initAct3OutdoorLevel,
generateAct3Jungle,
stampAct3KurastCauseway,
initAct4OutdoorLevel,
initAct5OutdoorLevel,
type Act2TopographyStats,
type Act3TopographyStats,
type Act4TopographyStats,
type Act5TopographyStats,
} from './wilderness-acts.ts'
/** One generator block, in cells. `DRLGOUTDOORS_GenerateLevel` divides by 8. */
export const TILES_PER_BLOCK = 8
@ -314,17 +326,17 @@ export function classifySubstitutionRole(name: string, type: number): Wilderness
* ------------------------------------------------------------------------- */
/** A blank wall cell, matching the decoder's placeholder. */
function emptyWall(): Ds1Wall {
export function emptyWall(): Ds1Wall {
return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }
}
/** A blank floor or shadow cell. */
function emptyFloor(): Ds1Floor {
export function emptyFloor(): Ds1Floor {
return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
}
/** The mutable map under construction. */
interface Canvas {
export interface Canvas {
readonly width: number
readonly height: number
readonly cells: Ds1Cell[][]
@ -344,7 +356,7 @@ interface Canvas {
* @param substitutionLayers - substitution layers every cell carries.
* @returns the canvas.
*/
function createCanvas(width: number, height: number, wallLayers: number, floorLayers: number, substitutionLayers: number): Canvas {
export function createCanvas(width: number, height: number, wallLayers: number, floorLayers: number, substitutionLayers: number): Canvas {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
@ -380,7 +392,7 @@ function createCanvas(width: number, height: number, wallLayers: number, floorLa
* @param originY - top edge in cells.
* @returns the number of cells actually written.
*/
function stampDs1(canvas: Canvas, source: Ds1, originX: number, originY: number): number {
export function stampDs1(canvas: Canvas, source: Ds1, originX: number, originY: number): number {
let written = 0
for (let y = 0; y < source.height; y += 1) {
const targetY = originY + y
@ -478,7 +490,7 @@ function stampDs1(canvas: Canvas, source: Ds1, originX: number, originY: number)
* @param height - cells.
* @returns the number of cells written.
*/
function fillGround(
export function fillGround(
canvas: Canvas,
tile: { style: number; sequence: number },
x: number,
@ -586,7 +598,7 @@ function pieceBlocks(piece: WildernessPiece, variant: number): { x: number; y: n
* ------------------------------------------------------------------------- */
/** One substitution row's outcome, for `stats`. */
interface SubstitutionReport {
export interface SubstitutionReport {
readonly name: string
readonly role: WildernessRole
readonly enabled: boolean
@ -608,7 +620,7 @@ export interface LandmarkEntity {
}
/** The report collector. */
interface WildernessStats {
export interface WildernessStats {
readonly substitutions: SubstitutionReport[]
readonly borderPieces: Record<string, number>
readonly unresolved: string[]
@ -628,6 +640,10 @@ interface WildernessStats {
shrinesSpawned?: number
interactivesSpawned?: number
landmarkEntities?: LandmarkEntity[]
act2Topography?: Act2TopographyStats
act3Topography?: Act3TopographyStats
act4Topography?: Act4TopographyStats
act5Topography?: Act5TopographyStats
}
/**
@ -674,11 +690,6 @@ export const DIRT_PATH_TILE_LUT: Readonly<Uint8Array> = new Uint8Array([
export const UNIMPLEMENTED_PASSES: readonly string[] = [
'DRLGVER_CreateVertices',
'DRLGOUTPLACE_CreateLevelConnections',
'DRLGOUTDESR_InitAct2OutdoorLevel',
'DRLGOUTPLACE_InitAct3OutdoorLevel',
'DRLGOUTDOORS_InitAct4OutdoorLevel',
'DRLGOUTSIEGE_InitAct5OutdoorLevel',
'DRLG_GenerateJungles',
'DRLGOUTDOORS_SpawnAct3Mephisto',
]
@ -945,8 +956,8 @@ export function getPerimeterOpenings(
const openings = new Set<string>()
const key = (x: number, y: number) => `${String(x)},${String(y)}`
if (levelId === 39) {
// Moo Moo Farm (Cow Level): 0 exits!
if (levelId === 39 || levelId === 46) {
// Moo Moo Farm (Cow Level) & Canyon of the Magi: 0 exits!
return openings
}
@ -1560,9 +1571,9 @@ function planAct1OutdoorLevel(
} else if (id === 17) {
// Burial Grounds (5x6 blocks): Graveyard preset (3x4 blocks at bx=1, by=1) + South Entry Gate
if (!placeGraveyardPreset(true)) addBorderGate('bottom', 4, 'Burial Grounds Gate', 3)
} else if (id === 39) {
// Moo Moo Farm: Closed border, internal Corral & Bivouac camps connected by paths
placeInteriorPresets(39)
} else if (id === 39 || id === 46) {
// Moo Moo Farm (39) & Canyon of the Magi (46): Closed border, no border gates
if (id === 39) placeInteriorPresets(39)
} else {
addBorderGate('left', 3, 'Entry Gate')
addBorderGate('right', 3, 'Exit Gate')
@ -2548,99 +2559,6 @@ function stampAct3Travincal(
return placed
}
/**
* Stamp the 48x16 bridge preset for Act 3 Kurast Causeway (LevelId 82).
*/
function stampAct3KurastCauseway(
canvas: Canvas,
pieces: readonly WildernessPiece[],
stats: WildernessStats,
): Set<string> {
const piece = pieces.find(p => p.name === 'Act 3 - Bridge')
const ds1 = piece?.levels[0]
const placed = new Set<string>()
if (ds1) {
stampDs1(canvas, ds1, 0, 0)
placed.add('Act 3 - Bridge')
stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 })
}
return placed
}
/**
* 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,
levelId: number,
pieces: readonly WildernessPiece[],
rng: Rng,
stats: WildernessStats,
): Set<string> {
const placed = new Set<string>()
const tailPiece = pieces.find(p => p.name === 'Act 3 - Jungle Tail')
const headPiece = pieces.find(p => p.name === 'Act 3 - Jungle Head')
if (tailPiece?.levels[0]) {
stampDs1(canvas, tailPiece.levels[0], 0, 0)
placed.add(tailPiece.name)
stats.substitutions.push({ name: tailPiece.name, role: 'object', enabled: true, clusters: 1 })
}
if (headPiece?.levels[0]) {
stampDs1(canvas, headPiece.levels[0], 0, 160)
placed.add(headPiece.name)
stats.substitutions.push({ name: headPiece.name, role: 'object', enabled: true, clusters: 1 })
}
const clearingPrefix =
levelId === 76
? 'Act 3 - Clearing Webby'
: levelId === 77
? 'Act 3 - Clearing Boggy'
: 'Act 3 - Clearing Pygmy'
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
// 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 3-tile-wide main dirt highway connecting the South Border Gate (`Bord1o.ds1` at `x = inX*8 + 3..5`),
@ -3345,7 +3263,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
const groundTile = request.groundTile ?? CANONICAL_GROUND_TILES[request.levelTypeName] ?? deriveGroundTile(request.pieces)
if (gridWidth < 3 || gridHeight < 3) {
if ((gridWidth < 3 || gridHeight < 3) && request.levelId !== 82) {
const bridgePiece = request.pieces.find(p => p.levels.some(l => l.width >= gridWidth * TILES_PER_BLOCK && l.height >= gridHeight * TILES_PER_BLOCK)) ?? request.pieces[0]
if (bridgePiece && bridgePiece.levels[0]) {
const bridgeDs1 = bridgePiece.levels[0]
@ -3436,19 +3354,22 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
stats.groundCells = fillGround(canvas, groundTile, 0, 0, canvas.width, canvas.height)
// Special case: Act 5 Bloody Foothills (110),
// Act 3 Travincal (83), and Act 3 Jungles (76, 77, 78) are assembled from
// Act 3 Travincal (83), Act 3 Kurast Causeway (82), and Act 3 Jungles (76, 77, 78) are assembled from
// deterministic multi-preset layouts rather than rectangular perimeter borders.
if (
request.levelId === 110 ||
request.levelTypeName === 'Act 5 - Siege' ||
request.levelId === 83 ||
request.levelId === 82 ||
(request.levelId >= 76 && request.levelId <= 78)
) {
let placedSpecial: Set<string>
if (request.levelId === 83) {
placedSpecial = stampAct3Travincal(canvas, request.pieces, stats)
} else if (request.levelId === 82) {
placedSpecial = stampAct3KurastCauseway(canvas, request.pieces, stats)
} else if (request.levelId >= 76 && request.levelId <= 78) {
placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats)
placedSpecial = generateAct3Jungle(canvas, request.levelId, request.pieces, rng, stats)
} else {
placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, stats)
}
@ -3501,6 +3422,10 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
substitutions: stats.substitutions,
substitutedClusters: placedSpecial.size,
objects: canvas.objects.length,
act2Topography: stats.act2Topography,
act3Topography: stats.act3Topography,
act4Topography: stats.act4Topography,
act5Topography: stats.act5Topography,
cells: canvas.width * canvas.height,
unresolved: stats.unresolved,
unimplementedPasses: UNIMPLEMENTED_PASSES,
@ -3679,6 +3604,39 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, allPlacedRects, rng)
}
// Act 2 Desert Topography: Canyons, oasis pools, sand caravan trails, tomb entrances (DRLGOUTDESR_InitAct2OutdoorLevel)
if (
(request.levelId >= 41 && request.levelId <= 46) ||
request.levelTypeName === 'Act 2 - Desert'
) {
initAct2OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats)
}
// Act 3 Kurast Urban Districts Topography: Avenues, canals, bridges (DRLGOUTPLACE_InitAct3OutdoorLevel)
if (
(request.levelId >= 79 && request.levelId <= 81) ||
request.levelTypeName === 'Act 3 - Kurast'
) {
initAct3OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats)
}
// Act 4 Mesa / Lava Topography: Lava chasms, rocky bridges, plateaus (DRLGOUTDOORS_InitAct4OutdoorLevel)
if (
(request.levelId >= 104 && request.levelId <= 106) ||
request.levelTypeName === 'Act 4 - Mesa' ||
request.levelTypeName === 'Act 4 - Lava'
) {
initAct4OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats)
}
// Act 5 Siege / Barricades Topography: Barricades, trenches, frozen lakes (DRLGOUTSIEGE_InitAct5OutdoorLevel)
if (
(request.levelId >= 111 && request.levelId <= 117) ||
request.levelTypeName === 'Act 5 - Barricade'
) {
initAct5OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats)
}
// Step 5: Outdoor Population Phase (Waypoints, Roadside Shrines/Chests, Landmark SuperUniques & Flavie)
const populationRng = new Rng(request.seed ^ 0x6c07)
spawnAct12Waypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats)
@ -3758,6 +3716,10 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
shrinesSpawned: stats.shrinesSpawned ?? 0,
interactivesSpawned: stats.interactivesSpawned ?? 0,
landmarkEntities: stats.landmarkEntities ?? [],
act2Topography: stats.act2Topography,
act3Topography: stats.act3Topography,
act4Topography: stats.act4Topography,
act5Topography: stats.act5Topography,
cells: canvas.width * canvas.height,
unresolved: stats.unresolved,
unimplementedPasses: UNIMPLEMENTED_PASSES,

View File

@ -0,0 +1,417 @@
import { describe, expect, test } from 'vitest'
import {
generateWilderness,
getPerimeterOpenings,
UNIMPLEMENTED_PASSES,
} from '../src/game/wilderness.ts'
import type { WildernessPiece } from '../src/game/wilderness.ts'
import type {
Act2TopographyStats,
Act3TopographyStats,
Act4TopographyStats,
Act5TopographyStats,
} from '../src/game/wilderness-acts.ts'
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
function makeMockDs1(width: number, height: number, hasGapInWalls: boolean = false): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
const isGap = hasGapInWalls && x === Math.floor(width / 2)
row.push({
walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 2, sequence: 0, style: 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 makeMockBorderPieces(actPrefix: string): WildernessPiece[] {
const borderDs1 = makeMockDs1(8, 8, false)
const openBorderDs1 = makeMockDs1(8, 8, true)
return [
{ name: `${actPrefix} - Border 1`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] },
{ name: `${actPrefix} - Border 2`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 3`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 4`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] },
{ name: `${actPrefix} - Border 5`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 6`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 7`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 8`, border: true, levels: [borderDs1] },
]
}
describe('Wilderness Outdoor Initializers for Acts 2-5 (Issue #51)', () => {
test('UNIMPLEMENTED_PASSES has 5 outdoor passes removed', () => {
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTDESR_InitAct2OutdoorLevel')
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTPLACE_InitAct3OutdoorLevel')
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTDOORS_InitAct4OutdoorLevel')
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTSIEGE_InitAct5OutdoorLevel')
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLG_GenerateJungles')
expect(UNIMPLEMENTED_PASSES).toEqual([
'DRLGVER_CreateVertices',
'DRLGOUTPLACE_CreateLevelConnections',
'DRLGOUTDOORS_SpawnAct3Mephisto',
])
})
describe('Act 2: DRLGOUTDESR_InitAct2OutdoorLevel', () => {
test('Rocky Waste (41) generates sand caravan trails and canyon ridge walls', () => {
const pieces = makeMockBorderPieces('Act 2')
const result = generateWilderness({
levelId: 41,
levelName: 'Rocky Waste',
levelTypeName: 'Act 2 - Desert',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 41001,
pieces,
substitutions: [],
})
expect(result.stats.act2Topography).toBeDefined()
const topo = result.stats.act2Topography as Act2TopographyStats
expect(topo.sandTrailCells).toBeGreaterThan(50)
expect(topo.canyonWalls).toBeGreaterThan(20)
let foundSandTrail = false
let foundCanyonWall = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 5 && f.prop1 === 194)) foundSandTrail = true
if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) foundCanyonWall = true
}
}
expect(foundSandTrail).toBe(true)
expect(foundCanyonWall).toBe(true)
})
test('Far Oasis (43) generates lush water oasis pools', () => {
const pieces = makeMockBorderPieces('Act 2')
const result = generateWilderness({
levelId: 43,
levelName: 'Far Oasis',
levelTypeName: 'Act 2 - Desert',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 43001,
pieces,
substitutions: [],
})
expect(result.stats.act2Topography).toBeDefined()
const topo = result.stats.act2Topography as Act2TopographyStats
expect(topo.oasisPoolCells).toBeGreaterThan(15)
let foundOasisWater = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 2 && f.prop1 === 2)) foundOasisWater = true
}
}
expect(foundOasisWater).toBe(true)
})
test('Valley of Snakes (45) creates narrow canyon gorge', () => {
const pieces = makeMockBorderPieces('Act 2')
const result = generateWilderness({
levelId: 45,
levelName: 'Valley of Snakes',
levelTypeName: 'Act 2 - Desert',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 45001,
pieces,
substitutions: [],
})
expect(result.stats.act2Topography).toBeDefined()
const topo = result.stats.act2Topography as Act2TopographyStats
expect(topo.canyonWalls).toBeGreaterThan(30)
})
test('Canyon of the Magi (46) places 7 Tal Rasha tombs, central dais, and has 0 perimeter openings', () => {
const pieces = makeMockBorderPieces('Act 2')
expect(getPerimeterOpenings(46, 10, 10).size).toBe(0)
const result = generateWilderness({
levelId: 46,
levelName: 'Canyon of the Magi',
levelTypeName: 'Act 2 - Desert',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 46001,
pieces,
substitutions: [],
})
expect(result.stats.act2Topography).toBeDefined()
const topo = result.stats.act2Topography as Act2TopographyStats
expect(topo.tombEntrances).toBe(7)
// Verify central Arcane Sanctuary warp platform (portal/dais)
let foundCentralDais = false
for (let y = 35; y <= 45; y += 1) {
for (let x = 35; x <= 45; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 3)) foundCentralDais = true
}
}
expect(foundCentralDais).toBe(true)
})
})
describe('Act 3: DRLGOUTPLACE_InitAct3OutdoorLevel & DRLG_GenerateJungles', () => {
test('Jungles (76 Spider Forest) generate river channel, wooden river bridges, and jungle paths', () => {
const tailDs1 = makeMockDs1(64, 32)
const headDs1 = makeMockDs1(64, 32)
const clearingDs1 = makeMockDs1(32, 32)
const riverDs1 = makeMockDs1(32, 32)
const pieces: WildernessPiece[] = [
{ name: 'Act 3 - Jungle Tail', border: false, levels: [tailDs1] },
{ name: 'Act 3 - Jungle Head', border: false, levels: [headDs1] },
{ name: 'Act 3 - Clearing Webby E', border: false, levels: [clearingDs1] },
{ name: 'Act 3 - Jungle NS W', border: false, levels: [riverDs1] },
]
const result = generateWilderness({
levelId: 76,
levelName: 'Spider Forest',
levelTypeName: 'Act 3 - Jungle',
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
seed: 76001,
pieces,
substitutions: [],
})
expect(result.stats.act3Topography).toBeDefined()
const topo = result.stats.act3Topography as Act3TopographyStats
expect(topo.riverBridges).toBeGreaterThanOrEqual(2)
expect(topo.junglePathCells).toBeGreaterThan(20)
// Verify wooden river bridge planks (style 4, prop1 2) spanning across East channel
let foundBridgePlank = false
for (let y = 70; y <= 90; y += 1) {
for (let x = 32; x < 60; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 4 && f.prop1 === 2)) foundBridgePlank = true
}
}
expect(foundBridgePlank).toBe(true)
})
test('Kurast Causeway (82) stamps causeway with central walkway, balustrades, and canal water', () => {
const bridgeDs1 = makeMockDs1(48, 16)
const pieces: WildernessPiece[] = [
{ name: 'Act 3 - Bridge', border: false, levels: [bridgeDs1] },
]
const result = generateWilderness({
levelId: 82,
levelName: 'Kurast Causeway',
levelTypeName: 'Act 3 - Kurast',
sizeX: 48,
sizeY: 16,
subType: 0,
subTheme: 0,
seed: 82001,
pieces,
substitutions: [],
})
expect(result.stats.act3Topography).toBeDefined()
const topo = result.stats.act3Topography as Act3TopographyStats
expect(topo.causewayCells).toBeGreaterThan(50)
expect(topo.canalCells).toBeGreaterThan(100)
// Verify central walkway (style 5), stone balustrade walls (style 1), and canal water (style 2)
let foundWalkway = false
let foundBalustrade = false
let foundCanalWater = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 5 && f.prop1 === 194)) foundWalkway = true
if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) foundBalustrade = true
if (cell.floors.some(f => f.style === 2 && f.prop1 === 2)) foundCanalWater = true
}
}
expect(foundWalkway).toBe(true)
expect(foundBalustrade).toBe(true)
expect(foundCanalWater).toBe(true)
})
test('Kurast Bazaar (79) generates paved stone avenues, canals, and stone bridges', () => {
const pieces = makeMockBorderPieces('Act 3')
const result = generateWilderness({
levelId: 79,
levelName: 'Kurast Bazaar',
levelTypeName: 'Act 3 - Kurast',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 79001,
pieces,
substitutions: [],
})
expect(result.stats.act3Topography).toBeDefined()
const topo = result.stats.act3Topography as Act3TopographyStats
expect(topo.causewayCells).toBeGreaterThan(50)
expect(topo.canalCells).toBeGreaterThan(30)
expect(topo.riverBridges).toBeGreaterThanOrEqual(1)
})
})
describe('Act 4: DRLGOUTDOORS_InitAct4OutdoorLevel', () => {
test('Outer Steppes (104) generates lava chasms, basalt lips, and rocky bridge causeways', () => {
const pieces = makeMockBorderPieces('Act 4')
const result = generateWilderness({
levelId: 104,
levelName: 'Outer Steppes',
levelTypeName: 'Act 4 - Mesa',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 104001,
pieces,
substitutions: [],
})
expect(result.stats.act4Topography).toBeDefined()
const topo = result.stats.act4Topography as Act4TopographyStats
expect(topo.lavaChasmCells).toBeGreaterThan(100)
expect(topo.rockyBridges).toBeGreaterThan(5)
// Verify boiling molten lava (style 20, sequence 3, prop1 2) and rocky bridges (style 10, sequence 0, prop1 2)
let foundLava = false
let foundRockyBridge = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 20 && f.sequence === 3 && f.prop1 === 2)) foundLava = true
if (cell.floors.some(f => f.style === 10 && f.sequence === 0 && f.prop1 === 2)) foundRockyBridge = true
}
}
expect(foundLava).toBe(true)
expect(foundRockyBridge).toBe(true)
})
test('Plains of Despair (105) creates Izual prison plateau', () => {
const pieces = makeMockBorderPieces('Act 4')
const result = generateWilderness({
levelId: 105,
levelName: 'Plains of Despair',
levelTypeName: 'Act 4 - Mesa',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 105001,
pieces,
substitutions: [],
})
expect(result.stats.act4Topography).toBeDefined()
const topo = result.stats.act4Topography as Act4TopographyStats
expect(topo.plateauCells).toBeGreaterThan(20)
})
})
describe('Act 5: DRLGOUTSIEGE_InitAct5OutdoorLevel', () => {
test('Frigid Highlands (111) generates transverse siege barricades, choke-point gates, and trenches', () => {
const pieces = makeMockBorderPieces('Act 5')
const result = generateWilderness({
levelId: 111,
levelName: 'Frigid Highlands',
levelTypeName: 'Act 5 - Barricade',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 111001,
pieces,
substitutions: [],
})
expect(result.stats.act5Topography).toBeDefined()
const topo = result.stats.act5Topography as Act5TopographyStats
expect(topo.barricadeWalls).toBeGreaterThan(40)
expect(topo.trenchCells).toBeGreaterThan(30)
// Verify spiked palisade walls (style 2, prop1 129) and trench floors (style 5, prop1 194)
let foundBarricadeWall = false
let foundTrench = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.walls.some(w => w.style === 2 && w.prop1 === 129)) foundBarricadeWall = true
if (cell.floors.some(f => f.style === 5 && f.prop1 === 194)) foundTrench = true
}
}
expect(foundBarricadeWall).toBe(true)
expect(foundTrench).toBe(true)
})
test('Arreat Plateau (112) generates snow crater frozen lakes with ice floors', () => {
const pieces = makeMockBorderPieces('Act 5')
const result = generateWilderness({
levelId: 112,
levelName: 'Arreat Plateau',
levelTypeName: 'Act 5 - Barricade',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 112001,
pieces,
substitutions: [],
})
expect(result.stats.act5Topography).toBeDefined()
const topo = result.stats.act5Topography as Act5TopographyStats
expect(topo.frozenLakeCells).toBeGreaterThan(20)
let foundIceFloor = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 2 && f.sequence === 0 && f.prop1 === 2)) foundIceFloor = true
}
}
expect(foundIceFloor).toBe(true)
})
})
})