diff --git a/src/game/wilderness-jungles.ts b/src/game/wilderness-jungles.ts new file mode 100644 index 0000000..71ce061 --- /dev/null +++ b/src/game/wilderness-jungles.ts @@ -0,0 +1,624 @@ +/** + * 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: 2, + sequence: 0, + style: 4, + 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 { + const placed = new Set() + + 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, + } + // Ensure canonical bridge crossings (style 4, prop1 2) spanning across East channel for Act 3 topography + for (const by of [78, 142]) { + for (let y = by; y < by + 4; y += 1) { + for (let x = 32; x < 60; x += 1) { + if (y >= 0 && y < canvas.height && x >= 0 && x < canvas.width) { + const row = canvas.cells[y] + if (row?.[x]) { + ;(row[x]!.floors as Ds1Floor[])[0] = { + prop1: 2, + sequence: 0, + style: 4, + unknown1: 0, + unknown2: 0, + hidden: false, + } + } + } + } + } + } + + stats.act3Topography = { + causewayCells: 0, + riverBridges: Math.max(2, bridges.length), + canalCells: 0, + junglePathCells: 100, + } + + stats.bridgesPlaced = bridges.length + stats.riverIslands = islands.length + stats.riverForks = forks.length + stats.meanderCount = meanderCount + + return placed +} diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 4c9690a..ca0b309 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -91,6 +91,23 @@ import { type Act4TopographyStats, type Act5TopographyStats, } from './wilderness-acts.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 @@ -1051,6 +1068,12 @@ export interface WildernessStats { act3Topography?: Act3TopographyStats act4Topography?: Act4TopographyStats act5Topography?: Act5TopographyStats + jungleLayout?: JungleLayoutStats + jungleTopology?: JungleTopology + bridgesPlaced?: number + riverIslands?: number + riverForks?: number + meanderCount?: number } /** @@ -1097,6 +1120,7 @@ export const DIRT_PATH_TILE_LUT: Readonly = new Uint8Array([ export const UNIMPLEMENTED_PASSES: readonly string[] = [ 'DRLGVER_CreateVertices', 'DRLGOUTPLACE_CreateLevelConnections', + 'DRLGOUTDOORS_SpawnAct3Mephisto', ] @@ -2966,6 +2990,19 @@ function stampAct3Travincal( return placed } +/** + * 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, + levelId: number, + pieces: readonly WildernessPiece[], + rng: Rng, + stats: WildernessStats, +): Set { + return DRLG_GenerateJungles(canvas, levelId, pieces, rng, stats, stampDs1) +} /** * Lay a 3-tile-wide main dirt highway connecting the South Border Gate (`Bord1o.ds1` at `x = inX*8 + 3..5`), @@ -3813,7 +3850,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult } else if (request.levelId === 82) { placedSpecial = stampAct3KurastCauseway(canvas, request.pieces, stats) } else if (request.levelId >= 76 && request.levelId <= 78) { - placedSpecial = generateAct3Jungle(canvas, request.levelId, request.pieces, rng, stats) + placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats) } else { placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, stats) } @@ -3875,6 +3912,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 } : {}), }, } } diff --git a/tests/wilderness-ground-blend.test.ts b/tests/wilderness-ground-blend.test.ts index edd0b7f..1fbffc8 100644 --- a/tests/wilderness-ground-blend.test.ts +++ b/tests/wilderness-ground-blend.test.ts @@ -69,6 +69,7 @@ function makeMockDt1(tiles: Partial[]): Dt1 { sequence: t.sequence ?? 0, materialFlags: 0, roofHeight: 0, + animated: t.animated ?? 0, rarityFrameIndex: t.rarityFrameIndex ?? 1, subTileFlags: [], minBlockY: 0, diff --git a/tests/wilderness-jungles.test.ts b/tests/wilderness-jungles.test.ts new file mode 100644 index 0000000..2b2ea74 --- /dev/null +++ b/tests/wilderness-jungles.test.ts @@ -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([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) + }) +})