feat(map-gen/wilderness): implement Act 3 jungle river network and branch topology (fixes #53)
This commit is contained in:
parent
1fa3f8b7a2
commit
723f5dce27
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue