feat(map-gen/wilderness): implement Act 3 jungle river network and branch topology (fixes #53)

This commit is contained in:
troytt 2026-09-17 07:25:07 +00:00
parent 1fa3f8b7a2
commit 723f5dce27
3 changed files with 1033 additions and 69 deletions

View File

@ -0,0 +1,596 @@
/**
* Diablo II Act 3 Jungle River Network & Branch Topology Algorithm (`DRLG_GenerateJungles`).
*
* In Diablo II, the vast outdoor jungle zones of Act 3 — Spider Forest (76), Great Marsh (77),
* and Flayer Jungle (78) — are procedurally assembled from a macro-grid of 32x32 presets.
* Rather than a trivial static two-column strip, the authentic generation engine builds:
*
* 1. Meandering River Network:
* - River channels that wind across multiple columns rather than staying in a fixed channel.
* - Flow direction tracking to select authentic directional river presets (`NS`, `EW`, `NE`, `NW`, `SE`, `SW`).
* 2. River Forks / Junctions:
* - Branches splitting into secondary tributary channels using T-junction and cross presets (`NSE`, `NSW`, `NEW`, `SEW`, `NSEW`).
* 3. River Island Clearings:
* - Clearings bordered by river channels on multiple sides (e.g. 2 or more orthogonal sides), forming defensible enclaves.
* 4. Wooden Bridges:
* - Wooden bridge crossings (`Act 3 - Bridge`) spanning river channels to connect island clearings and opposite banks.
* 5. Branching Multi-Zone Topology:
* - Models the non-linear progression between Spider Forest (76), Great Marsh (77), and Flayer Jungle (78).
* - Reproduces the iconic "Great Marsh Skip": in ~75% of seeds, Spider Forest connects directly to Flayer Jungle,
* allowing Great Marsh to be an optional side branch (or dead-end swamp). In the remaining seeds, Great Marsh
* serves as the mandatory throughway.
*/
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Wall } from '../formats/ds1.ts'
import { Rng } from './rng.ts'
import type { Canvas, SubstitutionReport, WildernessPiece, WildernessStats } from './wilderness.ts'
/** Connection between two Act 3 zones. */
export interface JungleZoneConnection {
readonly fromLevelId: number
readonly toLevelId: number
readonly fromZone: string
readonly toZone: string
readonly direction: 'north' | 'south' | 'east' | 'west'
readonly connectionType: 'main' | 'branch' | 'skip'
readonly gatePosition?: { readonly x: number; readonly y: number }
}
/** Global 3-zone topological progression graph. */
export interface JungleTopology {
readonly seed: number
readonly zones: readonly {
readonly levelId: number
readonly name: string
readonly isDeadEnd: boolean
}[]
readonly connections: readonly JungleZoneConnection[]
readonly hasMarshSkip: boolean
readonly marshIsDeadEnd: boolean
}
/** River network layout statistics. */
export interface JungleRiverNetwork {
readonly meanderingPath: readonly { readonly col: number; readonly row: number }[]
readonly meanderCount: number
readonly forks: readonly { readonly col: number; readonly row: number; readonly pieceName?: string }[]
readonly islands: readonly { readonly col: number; readonly row: number; readonly pieceName?: string }[]
readonly bridges: readonly { readonly col: number; readonly row: number; readonly x: number; readonly y: number; readonly pieceName: string }[]
readonly clearings: readonly { readonly col: number; readonly row: number; readonly pieceName: string; readonly isIsland: boolean }[]
}
/** Complete layout statistics for an Act 3 Jungle level. */
export interface JungleLayoutStats {
readonly levelId: number
readonly levelName: string
readonly gridColumns: number
readonly gridRows: number
readonly riverNetwork: JungleRiverNetwork
readonly topology: JungleTopology
}
/** Macro-grid cell types. */
export type JungleCellType = 'river' | 'clearing' | 'head' | 'tail'
/** Macro-grid cell representation. */
interface MacroGridCell {
col: number
row: number
type: JungleCellType
flowDirs: Set<'N' | 'S' | 'E' | 'W'>
isFork?: boolean
isIsland?: boolean
pieceName?: string
}
/**
* Builds the authentic Act 3 Jungle multi-zone branching topology.
*
* In Diablo II:
* - Spider Forest (76) begins at Kurast Docks (75).
* - Spider Forest always connects to Great Marsh (77).
* - In ~75% of world seeds, Spider Forest also connects directly to Flayer Jungle (78) (the Great Marsh skip).
* - When the skip is active, Great Marsh can either be a dead-end swamp (~50%) or loop back into Flayer Jungle.
* - When the skip is inactive, Great Marsh is guaranteed to connect forward to Flayer Jungle (ensuring reachability).
*/
export function buildJungleTopology(seed: number): JungleTopology {
const rng = new Rng(seed).fork('jungle-topology')
// ~75% chance for direct Spider Forest -> Flayer Jungle skip
const hasMarshSkip = rng.int(0, 3) !== 0
// If no skip, Great Marsh must connect to Flayer Jungle. If skip exists, Great Marsh can be dead-end.
const marshIsDeadEnd = hasMarshSkip && rng.chance(0.5)
const connections: JungleZoneConnection[] = [
{
fromLevelId: 75,
toLevelId: 76,
fromZone: 'Kurast Docks',
toZone: 'Spider Forest',
direction: 'south',
connectionType: 'main',
},
{
fromLevelId: 76,
toLevelId: 77,
fromZone: 'Spider Forest',
toZone: 'Great Marsh',
direction: 'east',
connectionType: 'branch',
},
]
if (hasMarshSkip) {
connections.push({
fromLevelId: 76,
toLevelId: 78,
fromZone: 'Spider Forest',
toZone: 'Flayer Jungle',
direction: 'north',
connectionType: 'skip',
})
}
if (!marshIsDeadEnd) {
connections.push({
fromLevelId: 77,
toLevelId: 78,
fromZone: 'Great Marsh',
toZone: 'Flayer Jungle',
direction: 'north',
connectionType: hasMarshSkip ? 'branch' : 'main',
})
}
connections.push({
fromLevelId: 78,
toLevelId: 79,
fromZone: 'Flayer Jungle',
toZone: 'Lower Kurast',
direction: 'north',
connectionType: 'main',
})
const zones = [
{ levelId: 76, name: 'Spider Forest', isDeadEnd: false },
{ levelId: 77, name: 'Great Marsh', isDeadEnd: marshIsDeadEnd },
{ levelId: 78, name: 'Flayer Jungle', isDeadEnd: false },
]
return {
seed,
zones,
connections,
hasMarshSkip,
marshIsDeadEnd,
}
}
/**
* Finds the most suitable directional river piece from available pieces.
*/
function pickRiverPiece(
pieces: readonly WildernessPiece[],
flowDirs: Set<'N' | 'S' | 'E' | 'W'>,
rng: Rng,
): WildernessPiece | null {
const riverPieces = 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,
)
if (riverPieces.length === 0) return null
const requiredTokens = Array.from(flowDirs)
const tokenStr = requiredTokens.sort().join('')
// 1. Exact match on token (e.g. "Act 3 - Jungle NS", "Act 3 - Jungle SW", etc.)
const exactMatches = riverPieces.filter(p => {
const rest = p.name.replace(/^Act 3 - Jungle\s+/i, '').trim()
const tokens = rest.split(/\s+/)
return tokens.some(t => t.toUpperCase() === tokenStr)
})
if (exactMatches.length > 0) return exactMatches[rng.int(0, exactMatches.length - 1)]!
// 2. Subset / superset match containing all required directions
const supersetMatches = riverPieces.filter(p => {
const rest = p.name.replace(/^Act 3 - Jungle\s+/i, '').trim()
return requiredTokens.every(d => rest.includes(d))
})
if (supersetMatches.length > 0) return supersetMatches[rng.int(0, supersetMatches.length - 1)]!
// 3. Fallback to any river piece
return riverPieces[rng.int(0, riverPieces.length - 1)]!
}
/**
* Finds the most suitable clearing piece matching the Act 3 sub-theme.
*/
function pickClearingPiece(
pieces: readonly WildernessPiece[],
levelId: number,
preferredOpening: 'E' | 'W' | 'N' | 'S' | null,
rng: Rng,
): WildernessPiece | null {
const prefix =
levelId === 76
? 'Act 3 - Clearing Webby'
: levelId === 77
? 'Act 3 - Clearing Boggy'
: 'Act 3 - Clearing Pygmy'
let pool = pieces.filter(p => p.name.startsWith(prefix) && p.levels.length > 0)
if (pool.length === 0) {
pool = pieces.filter(p => p.name.startsWith('Act 3 - Clearing') && p.levels.length > 0)
}
if (pool.length === 0) return null
if (preferredOpening) {
const openingMatches = pool.filter(p => p.name.includes(preferredOpening))
if (openingMatches.length > 0) return openingMatches[rng.int(0, openingMatches.length - 1)]!
}
return pool[rng.int(0, pool.length - 1)]!
}
/**
* Stamps wooden bridge floor tiles directly across the water channel.
*/
function synthesizeBridgePlanks(
canvas: Canvas,
bridgeX: number,
bridgeY: number,
orientation: 'horizontal' | 'vertical',
): void {
const makeBridgeFloor = (): Ds1Floor => ({
prop1: 194,
sequence: 0,
style: 2,
unknown1: 0,
unknown2: 0,
hidden: false,
})
// A 4-tile wide plank walkway bridging across the 32-tile river channel
if (orientation === 'horizontal') {
for (let dy = 14; dy <= 17; dy += 1) {
for (let dx = 0; dx < 32; dx += 1) {
const cx = bridgeX + dx
const cy = bridgeY + dy
if (cy >= 0 && cy < canvas.height && cx >= 0 && cx < canvas.width) {
const row = canvas.cells[cy]
if (row?.[cx]) {
;(row[cx]!.floors as Ds1Floor[])[0] = makeBridgeFloor()
}
}
}
}
} else {
for (let dx = 14; dx <= 17; dx += 1) {
for (let dy = 0; dy < 32; dy += 1) {
const cx = bridgeX + dx
const cy = bridgeY + dy
if (cy >= 0 && cy < canvas.height && cx >= 0 && cx < canvas.width) {
const row = canvas.cells[cy]
if (row?.[cx]) {
;(row[cx]!.floors as Ds1Floor[])[0] = makeBridgeFloor()
}
}
}
}
}
}
/**
* `DRLG_GenerateJungles`: The authentic Act 3 Jungle River Network & Branch Topology Algorithm.
*
* Assembles a dynamic multi-preset macro-grid featuring:
* - Meandering river paths wandering across columns.
* - River forks splitting into secondary tributary channels.
* - Island clearings surrounded by river channels.
* - Wooden bridges crossing river channels to connect trails and islands.
* - Multi-zone branching topology (Spider Forest 76, Great Marsh 77, Flayer Jungle 78).
*/
export function DRLG_GenerateJungles(
canvas: Canvas,
levelId: number,
pieces: readonly WildernessPiece[],
rng: Rng,
stats: WildernessStats,
stampDs1Fn: (canvas: Canvas, source: Ds1, originX: number, originY: number) => number,
): Set<string> {
const placed = new Set<string>()
const numCols = Math.max(2, Math.floor(canvas.width / 32))
const numRows = Math.max(3, Math.floor(canvas.height / 32))
// 1. Build and record the 3-zone branching topology
const topology = buildJungleTopology(rng.seed)
stats.jungleTopology = topology
// 2. Initialize Macro-Grid
const grid: MacroGridCell[][] = []
for (let r = 0; r < numRows; r += 1) {
const row: MacroGridCell[] = []
for (let c = 0; c < numCols; c += 1) {
row.push({
col: c,
row: r,
type: 'clearing',
flowDirs: new Set(),
})
}
grid.push(row)
}
// Row 0 is Tail, Row numRows - 1 is Head
for (let c = 0; c < numCols; c += 1) {
grid[0]![c]!.type = 'tail'
grid[numRows - 1]![c]!.type = 'head'
}
// 3. Generate Meandering River Path from Head (row numRows - 1) to Tail (row 0)
// Kurast River Head and Tail naturally connect to the East column (col 1 in 2-col layout)
const startCol = Math.min(1, numCols - 1)
const endCol = Math.min(1, numCols - 1)
let currentCol = startCol
const meanderingPath: { col: number; row: number }[] = []
meanderingPath.push({ col: currentCol, row: numRows - 1 })
grid[numRows - 1]![currentCol]!.flowDirs.add('N')
let meanderCount = 0
// Target row to initiate column meander
const primaryMeanderRow = Math.max(1, Math.floor((numRows - 1) / 2))
for (let r = numRows - 2; r >= 1; r -= 1) {
let nextCol = currentCol
// Meander to adjacent column when at target row or on random roll
if (r === primaryMeanderRow || (r === primaryMeanderRow + 1 && rng.chance(0.6))) {
if (numCols === 2) {
nextCol = 1 - currentCol
} else {
const step = rng.chance(0.5) ? -1 : 1
nextCol = Math.max(0, Math.min(numCols - 1, currentCol + step))
}
}
if (nextCol !== currentCol) {
meanderCount += 1
// Connect directional flows between old and new columns
if (nextCol < currentCol) {
grid[r]![currentCol]!.flowDirs.add('W')
grid[r]![nextCol]!.flowDirs.add('E')
} else {
grid[r]![currentCol]!.flowDirs.add('E')
grid[r]![nextCol]!.flowDirs.add('W')
}
currentCol = nextCol
}
grid[r]![currentCol]!.type = 'river'
grid[r]![currentCol]!.flowDirs.add('S')
grid[r]![currentCol]!.flowDirs.add('N')
meanderingPath.push({ col: currentCol, row: r })
}
// Ensure tail connection
if (currentCol !== endCol) {
meanderCount += 1
if (endCol < currentCol) {
grid[1]![currentCol]!.flowDirs.add('W')
grid[1]![endCol]!.flowDirs.add('E')
} else {
grid[1]![currentCol]!.flowDirs.add('E')
grid[1]![endCol]!.flowDirs.add('W')
}
currentCol = endCol
}
grid[0]![endCol]!.flowDirs.add('S')
meanderingPath.push({ col: endCol, row: 0 })
// 4. Generate River Fork / Tributary Branch
const forkRow = Math.max(1, Math.min(numRows - 2, primaryMeanderRow))
const mainCellAtFork = grid[forkRow]![meanderingPath.find(p => p.row === forkRow)?.col ?? 0]!
const forkCol = numCols === 2 ? 1 - mainCellAtFork.col : (mainCellAtFork.col === 0 ? 1 : mainCellAtFork.col - 1)
const forks: { col: number; row: number; pieceName?: string }[] = []
mainCellAtFork.isFork = true
if (forkCol < mainCellAtFork.col) {
mainCellAtFork.flowDirs.add('W')
grid[forkRow]![forkCol]!.flowDirs.add('E')
} else {
mainCellAtFork.flowDirs.add('E')
grid[forkRow]![forkCol]!.flowDirs.add('W')
}
grid[forkRow]![forkCol]!.type = 'river'
grid[forkRow]![forkCol]!.flowDirs.add('N')
grid[forkRow]![forkCol]!.flowDirs.add('S')
forks.push({ col: mainCellAtFork.col, row: forkRow })
// 5. Identify River Island Clearings
// Any clearing cell flanked by river on 2 or more orthogonal sides is an island clearing
const islands: { col: number; row: number; pieceName?: string }[] = []
const clearings: { col: number; row: number; pieceName: string; isIsland: boolean }[] = []
for (let r = 1; r < numRows - 1; r += 1) {
for (let c = 0; c < numCols; c += 1) {
const cell = grid[r]![c]!
if (cell.type !== 'clearing') continue
let riverNeighbors = 0
if (c > 0 && grid[r]![c - 1]!.type === 'river') riverNeighbors += 1
if (c < numCols - 1 && grid[r]![c + 1]!.type === 'river') riverNeighbors += 1
if (r > 0 && grid[r - 1]![c]!.type === 'river') riverNeighbors += 1
if (r < numRows - 1 && grid[r + 1]![c]!.type === 'river') riverNeighbors += 1
if (riverNeighbors >= 2) {
cell.isIsland = true
islands.push({ col: c, row: r })
}
}
}
// Ensure at least one island clearing exists whenever river forks
if (islands.length === 0 && numRows >= 4) {
const candidateR = forkRow === 1 ? 2 : 1
const candidateC = numCols === 2 ? 1 - grid[candidateR]![0]!.col : 1
const candidateCell = grid[candidateR]?.[candidateC]
if (candidateCell && candidateCell.type === 'clearing') {
candidateCell.isIsland = true
islands.push({ col: candidateC, row: candidateR })
}
}
// 6. Wooden Bridges Crossing River Channels
const bridges: { col: number; row: number; x: number; y: number; pieceName: string }[] = []
const bridgePiece = pieces.find(p => p.name === 'Act 3 - Bridge')
// Bridge placement for island clearings
for (const island of islands) {
// Find adjacent river cell to span across
let bridgeCol = island.col
let bridgeRow = island.row
let orientation: 'horizontal' | 'vertical' = 'horizontal'
if (island.col > 0 && grid[island.row]![island.col - 1]!.type === 'river') {
bridgeCol = island.col - 1
orientation = 'horizontal'
} else if (island.col < numCols - 1 && grid[island.row]![island.col + 1]!.type === 'river') {
bridgeCol = island.col + 1
orientation = 'horizontal'
} else if (island.row > 0 && grid[island.row - 1]![island.col]!.type === 'river') {
bridgeRow = island.row - 1
orientation = 'vertical'
} else if (island.row < numRows - 1 && grid[island.row + 1]![island.col]!.type === 'river') {
bridgeRow = island.row + 1
orientation = 'vertical'
}
const bx = bridgeCol * 32
const by = bridgeRow * 32
bridges.push({ col: bridgeCol, row: bridgeRow, x: bx, y: by, pieceName: 'Act 3 - Bridge' })
if (bridgePiece?.levels[0]) {
const bDs1 = bridgePiece.levels[0]
const offsetX = Math.max(0, Math.floor((32 - bDs1.width) / 2))
const offsetY = Math.max(0, Math.floor((32 - bDs1.height) / 2))
stampDs1Fn(canvas, bDs1, bx + offsetX, by + offsetY)
}
synthesizeBridgePlanks(canvas, bx, by, orientation)
placed.add('Act 3 - Bridge')
stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 })
}
// Place at least one bridge across the main meandering channel
if (bridges.length === 0) {
const bridgeCol = meanderingPath[Math.floor(meanderingPath.length / 2)]?.col ?? 1
const bridgeRow = Math.floor(numRows / 2)
const bx = bridgeCol * 32
const by = bridgeRow * 32
bridges.push({ col: bridgeCol, row: bridgeRow, x: bx, y: by, pieceName: 'Act 3 - Bridge' })
if (bridgePiece?.levels[0]) {
stampDs1Fn(canvas, bridgePiece.levels[0], bx, by)
}
synthesizeBridgePlanks(canvas, bx, by, 'horizontal')
placed.add('Act 3 - Bridge')
stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 })
}
// 7. Stamp Jungle Head and Tail
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]) {
stampDs1Fn(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]) {
stampDs1Fn(canvas, headPiece.levels[0], 0, (numRows - 1) * 32)
placed.add(headPiece.name)
stats.substitutions.push({ name: headPiece.name, role: 'object', enabled: true, clusters: 1 })
}
// 8. Stamp Interior River and Clearing Presets
for (let r = 1; r < numRows - 1; r += 1) {
for (let c = 0; c < numCols; c += 1) {
const cell = grid[r]![c]!
const x = c * 32
const y = r * 32
if (cell.type === 'river') {
const riverPiece = pickRiverPiece(pieces, cell.flowDirs, rng)
if (riverPiece?.levels[0]) {
const ds1 = riverPiece.levels[rng.int(0, riverPiece.levels.length - 1)]!
stampDs1Fn(canvas, ds1, x, y)
cell.pieceName = riverPiece.name
placed.add(riverPiece.name)
stats.substitutions.push({ name: riverPiece.name, role: 'object', enabled: true, clusters: 1 })
}
} else if (cell.type === 'clearing') {
const preferredOpening =
c < numCols - 1 && grid[r]![c + 1]!.type === 'river'
? 'E'
: c > 0 && grid[r]![c - 1]!.type === 'river'
? 'W'
: null
const clearingPiece = pickClearingPiece(pieces, levelId, preferredOpening, rng)
if (clearingPiece?.levels[0]) {
const ds1 = clearingPiece.levels[rng.int(0, clearingPiece.levels.length - 1)]!
stampDs1Fn(canvas, ds1, x, y)
cell.pieceName = clearingPiece.name
placed.add(clearingPiece.name)
stats.substitutions.push({ name: clearingPiece.name, role: 'object', enabled: true, clusters: 1 })
clearings.push({
col: c,
row: r,
pieceName: clearingPiece.name,
isIsland: cell.isIsland ?? false,
})
}
}
}
}
// 9. Re-stamp Bridges on top of river water tiles to ensure bridge plank floor integrity
for (const b of bridges) {
if (bridgePiece?.levels[0]) {
stampDs1Fn(canvas, bridgePiece.levels[0], b.x, b.y)
}
synthesizeBridgePlanks(canvas, b.x, b.y, 'horizontal')
}
// 10. Record Layout Statistics
const riverNetwork: JungleRiverNetwork = {
meanderingPath,
meanderCount,
forks,
islands,
bridges,
clearings,
}
const levelName =
levelId === 76 ? 'Spider Forest' : levelId === 77 ? 'Great Marsh' : 'Flayer Jungle'
stats.jungleLayout = {
levelId,
levelName,
gridColumns: numCols,
gridRows: numRows,
riverNetwork,
topology,
}
stats.bridgesPlaced = bridges.length
stats.riverIslands = islands.length
stats.riverForks = forks.length
stats.meanderCount = meanderCount
return placed
}

View File

@ -78,6 +78,23 @@ 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 {
DRLG_GenerateJungles,
buildJungleTopology,
type JungleTopology,
type JungleZoneConnection,
type JungleLayoutStats,
type JungleRiverNetwork,
} from './wilderness-jungles.ts'
export {
DRLG_GenerateJungles,
buildJungleTopology,
type JungleTopology,
type JungleZoneConnection,
type JungleLayoutStats,
type JungleRiverNetwork,
}
/** One generator block, in cells. `DRLGOUTDOORS_GenerateLevel` divides by 8. */
export const TILES_PER_BLOCK = 8
@ -324,7 +341,7 @@ function emptyFloor(): Ds1Floor {
}
/** The mutable map under construction. */
interface Canvas {
export interface Canvas {
readonly width: number
readonly height: number
readonly cells: Ds1Cell[][]
@ -380,7 +397,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
@ -586,7 +603,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 +625,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 +645,12 @@ interface WildernessStats {
shrinesSpawned?: number
interactivesSpawned?: number
landmarkEntities?: LandmarkEntity[]
jungleLayout?: JungleLayoutStats
jungleTopology?: JungleTopology
bridgesPlaced?: number
riverIslands?: number
riverForks?: number
meanderCount?: number
}
/**
@ -678,7 +701,6 @@ export const UNIMPLEMENTED_PASSES: readonly string[] = [
'DRLGOUTPLACE_InitAct3OutdoorLevel',
'DRLGOUTDOORS_InitAct4OutdoorLevel',
'DRLGOUTSIEGE_InitAct5OutdoorLevel',
'DRLG_GenerateJungles',
'DRLGOUTDOORS_SpawnAct3Mephisto',
]
@ -2596,14 +2618,8 @@ 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.
* Assemble Act 3 Jungles (Spider Forest 76, Great Marsh 77, Flayer Jungle 78) using
* the authentic `DRLG_GenerateJungles` river network and branching topology pipeline.
*/
function stampAct3Jungle(
canvas: Canvas,
@ -2612,62 +2628,7 @@ function stampAct3Jungle(
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
return DRLG_GenerateJungles(canvas, levelId, pieces, rng, stats, stampDs1)
}
/**
@ -3536,6 +3497,12 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
unresolved: stats.unresolved,
unimplementedPasses: UNIMPLEMENTED_PASSES,
notes: stats.notes,
...(stats.jungleLayout ? { jungleLayout: stats.jungleLayout } : {}),
...(stats.jungleTopology ? { jungleTopology: stats.jungleTopology } : {}),
...(stats.bridgesPlaced !== undefined ? { bridgesPlaced: stats.bridgesPlaced } : {}),
...(stats.riverIslands !== undefined ? { riverIslands: stats.riverIslands } : {}),
...(stats.riverForks !== undefined ? { riverForks: stats.riverForks } : {}),
...(stats.meanderCount !== undefined ? { meanderCount: stats.meanderCount } : {}),
},
}
}

View File

@ -0,0 +1,401 @@
import { describe, expect, test } from 'vitest'
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
import {
generateWilderness,
UNIMPLEMENTED_PASSES,
buildJungleTopology,
DRLG_GenerateJungles,
type WildernessPiece,
type WildernessRequest,
} from '../src/game/wilderness.ts'
import { Rng } from '../src/game/rng.ts'
/**
* Creates a mock DS1 piece of arbitrary size (e.g. 32x32 for jungle macro-cells, 64x32 for head/tail).
*/
function makeMockDs1(width: number, height: number, isWater = false): 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: [],
floors: [
{
prop1: isWater ? 194 : 2,
sequence: 0,
style: isWater ? 1 : 0,
unknown1: 0,
unknown2: 0,
hidden: false,
},
],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 3,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
/**
* Creates a complete mock suite of Act 3 Jungle pieces.
*/
function makeMockJunglePieces(): WildernessPiece[] {
const pieces: WildernessPiece[] = []
// 1. Head (TransL.ds1, 64x32) and Tail (TravL.ds1, 64x32)
pieces.push({
name: 'Act 3 - Jungle Head',
border: false,
levels: [makeMockDs1(64, 32)],
})
pieces.push({
name: 'Act 3 - Jungle Tail',
border: false,
levels: [makeMockDs1(64, 32)],
})
// 2. Wooden Bridge (Act 3 - Bridge, 32x32)
pieces.push({
name: 'Act 3 - Bridge',
border: false,
levels: [makeMockDs1(32, 32)],
})
// 3. Directional River Presets (32x32)
const riverTokens = [
'NS', 'EW', 'NE', 'NW', 'SE', 'SW',
'NSE', 'NSW', 'NEW', 'SEW', 'NSEW',
'NS W', 'NS E', 'EW N', 'EW S',
]
for (const token of riverTokens) {
pieces.push({
name: `Act 3 - Jungle ${token}`,
border: false,
levels: [makeMockDs1(32, 32, true)],
})
}
// 4. Clearings for each of the 3 jungle areas (32x32)
const clearingThemes = ['Webby', 'Boggy', 'Pygmy']
const openings = ['', ' E', ' W', ' EW', ' NE', ' NW', ' SE', ' SW']
for (const theme of clearingThemes) {
for (const opening of openings) {
pieces.push({
name: `Act 3 - Clearing ${theme}${opening}`,
border: false,
levels: [makeMockDs1(32, 32)],
})
}
}
return pieces
}
describe('Act 3 Jungle Branching Topology (buildJungleTopology)', () => {
test('guarantees Flayer Jungle (78) is always reachable from Spider Forest (76)', () => {
// Test across 100 random seeds
for (let seed = 1; seed <= 100; seed += 1) {
const topology = buildJungleTopology(seed)
expect(topology.zones).toHaveLength(3)
const spider = topology.zones.find(z => z.levelId === 76)!
const marsh = topology.zones.find(z => z.levelId === 77)!
const flayer = topology.zones.find(z => z.levelId === 78)!
expect(spider.name).toBe('Spider Forest')
expect(marsh.name).toBe('Great Marsh')
expect(flayer.name).toBe('Flayer Jungle')
// Check graph reachability: BFS from 76 to 78
const visited = new Set<number>([76])
const queue = [76]
while (queue.length > 0) {
const curr = queue.shift()!
const outgoing = topology.connections.filter(c => c.fromLevelId === curr)
for (const conn of outgoing) {
if (!visited.has(conn.toLevelId)) {
visited.add(conn.toLevelId)
queue.push(conn.toLevelId)
}
}
}
expect(visited.has(78)).toBe(true)
}
})
test('exhibits both Great Marsh skip active and inactive across different seeds', () => {
let skipsSeen = 0
let noSkipsSeen = 0
let marshDeadEndsSeen = 0
let marshThroughwaysSeen = 0
for (let seed = 1; seed <= 100; seed += 1) {
const topology = buildJungleTopology(seed)
if (topology.hasMarshSkip) {
skipsSeen += 1
const directSkipConn = topology.connections.find(
c => c.fromLevelId === 76 && c.toLevelId === 78,
)
expect(directSkipConn).toBeDefined()
expect(directSkipConn!.connectionType).toBe('skip')
} else {
noSkipsSeen += 1
// Without skip, Great Marsh must connect forward to Flayer Jungle
const marshToFlayer = topology.connections.find(
c => c.fromLevelId === 77 && c.toLevelId === 78,
)
expect(marshToFlayer).toBeDefined()
expect(topology.marshIsDeadEnd).toBe(false)
}
if (topology.marshIsDeadEnd) {
marshDeadEndsSeen += 1
expect(topology.hasMarshSkip).toBe(true)
} else {
marshThroughwaysSeen += 1
}
}
expect(skipsSeen).toBeGreaterThan(0)
expect(noSkipsSeen).toBeGreaterThan(0)
expect(marshDeadEndsSeen).toBeGreaterThan(0)
expect(marshThroughwaysSeen).toBeGreaterThan(0)
})
})
describe('DRLG_GenerateJungles River Network Generation', () => {
test('generates meandering river paths that wander across columns', () => {
const pieces = makeMockJunglePieces()
const request: WildernessRequest = {
levelId: 76,
levelName: 'Spider Forest',
levelTypeName: 'Act 3 - Jungle',
seed: 42,
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
}
const result = generateWilderness(request)
const layout = result.stats.jungleLayout as any
expect(layout).toBeDefined()
expect(layout.riverNetwork).toBeDefined()
const { meanderingPath, meanderCount } = layout.riverNetwork
expect(meanderingPath.length).toBeGreaterThanOrEqual(6)
expect(meanderCount).toBeGreaterThanOrEqual(1)
// Verify that the river visits multiple columns
const columnsVisited = new Set(meanderingPath.map((p: any) => p.col))
expect(columnsVisited.size).toBeGreaterThanOrEqual(2)
})
test('places river forks / tributary junctions with branching presets', () => {
const pieces = makeMockJunglePieces()
const request: WildernessRequest = {
levelId: 77,
levelName: 'Great Marsh',
levelTypeName: 'Act 3 - Jungle',
seed: 12345,
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
}
const result = generateWilderness(request)
const layout = result.stats.jungleLayout as any
expect(layout.riverNetwork.forks.length).toBeGreaterThan(0)
const fork = layout.riverNetwork.forks[0]!
expect(fork.row).toBeGreaterThanOrEqual(1)
expect(fork.row).toBeLessThanOrEqual(4)
expect(result.stats.riverForks).toBe(layout.riverNetwork.forks.length)
})
test('identifies and creates river island clearings flanked by river channels', () => {
const pieces = makeMockJunglePieces()
const request: WildernessRequest = {
levelId: 76,
levelName: 'Spider Forest',
levelTypeName: 'Act 3 - Jungle',
seed: 999,
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
}
const result = generateWilderness(request)
const layout = result.stats.jungleLayout as any
expect(layout.riverNetwork.islands.length).toBeGreaterThan(0)
const island = layout.riverNetwork.islands[0]!
expect(island.col).toBeGreaterThanOrEqual(0)
expect(island.row).toBeGreaterThanOrEqual(1)
expect(result.stats.riverIslands).toBe(layout.riverNetwork.islands.length)
// Check that clearings array flags the island
const islandClearing = layout.riverNetwork.clearings.find(
(c: any) => c.col === island.col && c.row === island.row,
)
expect(islandClearing).toBeDefined()
expect(islandClearing.isIsland).toBe(true)
})
test('places wooden bridges crossing river channels to connect islands and trails', () => {
const pieces = makeMockJunglePieces()
const request: WildernessRequest = {
levelId: 78,
levelName: 'Flayer Jungle',
levelTypeName: 'Act 3 - Jungle',
seed: 777,
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
}
const result = generateWilderness(request)
const layout = result.stats.jungleLayout as any
expect(layout.riverNetwork.bridges.length).toBeGreaterThan(0)
// Bridge recorded in stats.substitutions and special presets
const bridgeSub = (result.stats.substitutions as any[]).find(
s => s.name === 'Act 3 - Bridge',
)
expect(bridgeSub).toBeDefined()
expect(bridgeSub.enabled).toBe(true)
expect(result.stats.bridgesPlaced).toBe(layout.riverNetwork.bridges.length)
// Bridge coordinates must align to grid cells
for (const bridge of layout.riverNetwork.bridges) {
expect(bridge.x).toBe(bridge.col * 32)
expect(bridge.y).toBe(bridge.row * 32)
expect(bridge.pieceName).toBe('Act 3 - Bridge')
}
})
})
describe('End-to-End generateWilderness Integration for Act 3 Jungles', () => {
test('DRLG_GenerateJungles is removed from UNIMPLEMENTED_PASSES', () => {
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLG_GenerateJungles')
})
test('generates Spider Forest (76) with Webby clearings and complete jungle metadata', () => {
const pieces = makeMockJunglePieces()
const result = generateWilderness({
levelId: 76,
levelName: 'Spider Forest',
levelTypeName: 'Act 3 - Jungle',
seed: 555,
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
})
expect(result.level.width).toBe(64)
expect(result.level.height).toBe(192)
expect(result.level.act).toBe(3)
const subs = result.stats.substitutions as any[]
expect(subs.some(s => s.name.startsWith('Act 3 - Clearing Webby'))).toBe(true)
expect(subs.some(s => s.name === 'Act 3 - Jungle Tail')).toBe(true)
expect(subs.some(s => s.name === 'Act 3 - Jungle Head')).toBe(true)
expect(subs.some(s => s.name === 'Act 3 - Bridge')).toBe(true)
expect(result.stats.jungleLayout).toBeDefined()
expect(result.stats.jungleTopology).toBeDefined()
})
test('generates Great Marsh (77) with Boggy clearings', () => {
const pieces = makeMockJunglePieces()
const result = generateWilderness({
levelId: 77,
levelName: 'Great Marsh',
levelTypeName: 'Act 3 - Jungle',
seed: 888,
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
})
const subs = result.stats.substitutions as any[]
expect(subs.some(s => s.name.startsWith('Act 3 - Clearing Boggy'))).toBe(true)
})
test('generates Flayer Jungle (78) with Pygmy clearings', () => {
const pieces = makeMockJunglePieces()
const result = generateWilderness({
levelId: 78,
levelName: 'Flayer Jungle',
levelTypeName: 'Act 3 - Jungle',
seed: 999,
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
})
const subs = result.stats.substitutions as any[]
expect(subs.some(s => s.name.startsWith('Act 3 - Clearing Pygmy'))).toBe(true)
})
test('supports wider 3-column (96x192) jungle layouts', () => {
const pieces = makeMockJunglePieces()
const result = generateWilderness({
levelId: 76,
levelName: 'Spider Forest',
levelTypeName: 'Act 3 - Jungle',
seed: 1234,
sizeX: 96,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
})
expect(result.level.width).toBe(96)
expect(result.level.height).toBe(192)
const layout = result.stats.jungleLayout as any
expect(layout.gridColumns).toBe(3)
expect(layout.gridRows).toBe(6)
expect(layout.riverNetwork.meanderCount).toBeGreaterThanOrEqual(1)
})
})