diff --git a/src/game/wilderness-acts.ts b/src/game/wilderness-acts.ts new file mode 100644 index 0000000..d610a7e --- /dev/null +++ b/src/game/wilderness-acts.ts @@ -0,0 +1,906 @@ +/** + * Wilderness outdoor topography initializers for Acts 2, 3, 4, and 5. + * + * blizzard Drlg outdoor passes: + * - `DRLGOUTDESR_InitAct2OutdoorLevel`: Desert canyons, sand trails, Far Oasis pools, + * Valley of Snakes canyon gorges, Canyon of the Magi 7 Tal Rasha tomb entrances. + * - `DRLGOUTPLACE_InitAct3OutdoorLevel` & `DRLG_GenerateJungles`: Jungle waterways, + * wooden river bridges spanning channels, causeways, Kurast city avenues, canals & bridges. + * - `DRLGOUTDOORS_InitAct4OutdoorLevel`: Molten lava chasms and fissures, natural rock + * bridges spanning chasms, volcanic mesa plateaus. + * - `DRLGOUTSIEGE_InitAct5OutdoorLevel`: Transverse siege barricade palisades, choke-point + * gates, military dugout trenches, prisoner cages, and snow crater frozen lakes. + */ + +import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.ts' +import { Rng } from './rng.ts' +import type { + Canvas, + WildernessRequest, + WildernessStats, + WildernessPiece, + PlacedPresetRect, +} from './wilderness.ts' +import { stampDs1, TILES_PER_BLOCK, emptyFloor, emptyWall } from './wilderness.ts' + +/* ------------------------------------------------------------------------- * + * Statistics interfaces + * ------------------------------------------------------------------------- */ + +export interface Act2TopographyStats { + readonly canyonWalls: number + readonly sandTrailCells: number + readonly oasisPoolCells: number + readonly tombEntrances: number +} + +export interface Act3TopographyStats { + readonly causewayCells: number + readonly riverBridges: number + readonly canalCells: number + readonly junglePathCells: number +} + +export interface Act4TopographyStats { + readonly lavaChasmCells: number + readonly rockyBridges: number + readonly plateauCells: number +} + +export interface Act5TopographyStats { + readonly barricadeWalls: number + readonly trenchCells: number + readonly frozenLakeCells: number +} + +/* ------------------------------------------------------------------------- * + * Geometry & Canvas Helpers + * ------------------------------------------------------------------------- */ + +function isInsidePreset( + x: number, + y: number, + placedRects: readonly PlacedPresetRect[], + margin = 1, +): boolean { + for (const r of placedRects) { + if ( + x >= r.x - margin && + x < r.x + r.w + margin && + y >= r.y - margin && + y < r.y + r.h + margin + ) { + return true + } + } + return false +} + +function makeFloor(style: number, sequence: number, prop1 = 2): Ds1Floor { + return { + prop1, + style, + sequence, + unknown1: 0, + unknown2: 0, + hidden: false, + } +} + +function makeWall(style: number, sequence: number, type: number, prop1 = 129): Ds1Wall { + return { + prop1, + sequence, + style, + type, + unknown1: 0, + unknown2: 0, + hidden: false, + } +} + +function paintFloor( + canvas: Canvas, + x: number, + y: number, + floor: Ds1Floor, + overwriteProtected = false, +): boolean { + if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return false + const cell = canvas.cells[y]?.[x] + if (!cell) return false + const existing = cell.floors[0] + if (!overwriteProtected && existing) { + // Protected styles: water (style 2/3), wooden bridge (style 4) + if (existing.style === 2 || existing.style === 3 || existing.style === 4) { + return false + } + } + ;(cell.floors as Ds1Floor[])[0] = floor + return true +} + +function paintWall(canvas: Canvas, x: number, y: number, wall: Ds1Wall): boolean { + if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return false + const cell = canvas.cells[y]?.[x] + if (!cell) return false + const targetWalls = cell.walls as Ds1Wall[] + if (targetWalls.length > 0) { + targetWalls[0] = wall + } else { + targetWalls.push(wall) + } + return true +} + +function clearWalls(canvas: Canvas, x: number, y: number): void { + if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return + const cell = canvas.cells[y]?.[x] + if (cell) { + ;(cell.walls as Ds1Wall[]).length = 0 + } +} + +/* ------------------------------------------------------------------------- * + * Act 2: DRLGOUTDESR_InitAct2OutdoorLevel + * ------------------------------------------------------------------------- */ + +/** + * Initialize Act 2 Desert Outdoor Topography: + * - Rocky canyon ridges and cliff berms carving winding desert corridors. + * - 3-tile-wide sand caravan trails connecting gates across the dunes. + * - Far Oasis (Level 43): lush water oasis pools and Maggot Lair entrance. + * - Valley of Snakes (Level 45): narrow canyon gorge leading to Claw Viper Temple. + * - Canyon of the Magi (Level 46): 7 Tal Rasha tomb entrances along perimeter cliffs + * and central Arcane Sanctuary warp platform. + * - Lost City (Level 44): ancient buried city ruins and street corridors. + */ +export function initAct2OutdoorLevel( + canvas: Canvas, + request: WildernessRequest, + gridWidth: number, + gridHeight: number, + inX: number, + outY: number, + placedRects: PlacedPresetRect[], + rng: Rng, + stats: WildernessStats, +): Act2TopographyStats { + let canyonWalls = 0 + let sandTrailCells = 0 + let oasisPoolCells = 0 + let tombEntrances = 0 + + const levelId = request.levelId + const hubX = inX * TILES_PER_BLOCK + 4 + const hubY = outY * TILES_PER_BLOCK + 4 + + // Helper to paint a 3-tile-wide sand caravan trail + const paintSandTrailTile = (x: number, y: number): void => { + if (x < 3 || x >= canvas.width - 3 || y < 3 || y >= canvas.height - 3) return + if (isInsidePreset(x, y, placedRects, 0)) return + const cell = canvas.cells[y]?.[x] + if (!cell) return + // Style 5 (desert road), prop1 194 + const floor = makeFloor(5, rng.int(0, 4) === 0 ? 25 : 0, 194) + if (paintFloor(canvas, x, y, floor, false)) { + clearWalls(canvas, x, y) + sandTrailCells += 1 + } + } + + // 1. Sand Caravan Trail Network: North-South and West-East axes + if (levelId !== 46) { + const startY = Math.max(4, Math.floor(canvas.height * 0.1)) + const endY = Math.min(canvas.height - 5, Math.floor(canvas.height * 0.9)) + for (let y = startY; y <= endY; y += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + paintSandTrailTile(hubX + dx, y) + } + } + + const startX = Math.max(4, Math.floor(canvas.width * 0.1)) + const endX = Math.min(canvas.width - 5, Math.floor(canvas.width * 0.9)) + for (let x = startX; x <= endX; x += 1) { + for (let dy = -1; dy <= 1; dy += 1) { + paintSandTrailTile(x, hubY + dy) + } + } + } + + // 2. Canyon Walls & Rocky Berms (Levels 41, 42, 44, 45) + if (levelId === 41 || levelId === 42 || levelId === 44 || levelId === 45) { + const isValleyOfSnakes = levelId === 45 + // In Valley of Snakes, place 2 tight parallel canyon ridge walls (corridor of 10-12 tiles) + // In other deserts, place transverse & longitudinal canyon ridges + const ridgeY1 = Math.max(12, Math.min(canvas.height - 14, hubY - (isValleyOfSnakes ? 5 : 16))) + const ridgeY2 = Math.max(14, Math.min(canvas.height - 12, hubY + (isValleyOfSnakes ? 6 : 16))) + + for (let x = 10; x < canvas.width - 10; x += 1) { + // Leave 4-tile wide canyon pass gaps where the main highway or crossroads pass + if (Math.abs(x - hubX) <= 3) continue + if (isInsidePreset(x, ridgeY1, placedRects, 2)) continue + if (paintWall(canvas, x, ridgeY1, makeWall(1, 0, 2, 129))) { + canyonWalls += 1 + } + } + + for (let x = 10; x < canvas.width - 10; x += 1) { + if (Math.abs(x - hubX) <= 3) continue + if (isInsidePreset(x, ridgeY2, placedRects, 2)) continue + if (paintWall(canvas, x, ridgeY2, makeWall(1, 0, 2, 129))) { + canyonWalls += 1 + } + } + + if (!isValleyOfSnakes) { + const ridgeX = Math.max(12, Math.min(canvas.width - 12, hubX + 16)) + for (let y = 10; y < canvas.height - 10; y += 1) { + if (Math.abs(y - hubY) <= 3) continue + if (isInsidePreset(ridgeX, y, placedRects, 2)) continue + if (paintWall(canvas, ridgeX, y, makeWall(1, 0, 1, 129))) { + canyonWalls += 1 + } + } + } + } + + // 3. Far Oasis (Level 43): Lush Water Oasis Pools & Maggot Lair + if (levelId === 43 || /Oasis/i.test(request.levelName)) { + const poolCenters = [ + { + cx: Math.max(14, Math.floor(canvas.width * 0.35)), + cy: Math.max(14, Math.floor(canvas.height * 0.35)), + radius: Math.min(6, Math.floor(canvas.width / 12)), + }, + { + cx: Math.min(canvas.width - 16, Math.floor(canvas.width * 0.68)), + cy: Math.min(canvas.height - 16, Math.floor(canvas.height * 0.68)), + radius: Math.min(5, Math.floor(canvas.width / 14)), + }, + ] + + for (const pool of poolCenters) { + for (let dy = -pool.radius; dy <= pool.radius; dy += 1) { + for (let dx = -pool.radius; dx <= pool.radius; dx += 1) { + const dist = Math.hypot(dx, dy) + if (dist <= pool.radius) { + const px = pool.cx + dx + const py = pool.cy + dy + if (px >= 4 && px < canvas.width - 4 && py >= 4 && py < canvas.height - 4) { + if (!isInsidePreset(px, py, placedRects, 1)) { + // Water floor: style 2 (water), prop1 2 + paintFloor(canvas, px, py, makeFloor(2, 0, 2), true) + clearWalls(canvas, px, py) + oasisPoolCells += 1 + } + } + } + } + } + } + + // Try to stamp Desert Oasis preset if available in pieces + const oasisPiece = request.pieces.find(p => /Desert Oasis/i.test(p.name)) + if (oasisPiece?.levels[0] && !stats.specialPresets.some(n => /Desert Oasis/i.test(n))) { + const ds1 = oasisPiece.levels[0] + const originX = Math.max(8, Math.floor(canvas.width * 0.4) - Math.floor(ds1.width / 2)) + const originY = Math.max(8, Math.floor(canvas.height * 0.4) - Math.floor(ds1.height / 2)) + stampDs1(canvas, ds1, originX, originY) + stats.specialPresets.push(oasisPiece.name) + stats.substitutions.push({ name: oasisPiece.name, role: 'object', enabled: true, clusters: 1 }) + placedRects.push({ name: oasisPiece.name, x: originX, y: originY, w: ds1.width, h: ds1.height }) + } + } + + // 4. Canyon of the Magi / Valley of the Kings (Level 46): 7 Tal Rasha Tomb Entrances + if (levelId === 46 || /Canyon of the Magi|Valley of the Kings/i.test(request.levelName)) { + const tombCoords: Array<{ edge: 'top' | 'left' | 'right'; x: number; y: number }> = [ + // Top / North Cliff: 3 tombs + { edge: 'top', x: Math.floor(canvas.width * 0.22), y: 1 }, + { edge: 'top', x: Math.floor(canvas.width * 0.5), y: 1 }, + { edge: 'top', x: Math.floor(canvas.width * 0.78), y: 1 }, + // Left / West Cliff: 2 tombs + { edge: 'left', x: 1, y: Math.floor(canvas.height * 0.35) }, + { edge: 'left', x: 1, y: Math.floor(canvas.height * 0.68) }, + // Right / East Cliff: 2 tombs + { edge: 'right', x: Math.max(2, canvas.width - 12), y: Math.floor(canvas.height * 0.35) }, + { edge: 'right', x: Math.max(2, canvas.width - 12), y: Math.floor(canvas.height * 0.68) }, + ] + + for (let i = 0; i < tombCoords.length; i += 1) { + const tc = tombCoords[i]! + // Try to find matching cliff king tomb piece + let pieceName = 'Act 2 - Desert Cliff Top King Tomb' + if (tc.edge === 'left') pieceName = 'Act 2 - Desert Cliff Left King Tomb' + else if (tc.edge === 'right') pieceName = 'Act 2 - Desert Cliff Right King Tomb' + + const piece = request.pieces.find(p => p.name.trim() === pieceName.trim()) ?? + request.pieces.find(p => /King Tomb|Desert Tomb/i.test(p.name)) + + if (piece?.levels[0]) { + stampDs1(canvas, piece.levels[0], tc.x, tc.y) + stats.specialPresets.push(piece.name) + placedRects.push({ name: piece.name, x: tc.x, y: tc.y, w: piece.levels[0].width, h: piece.levels[0].height }) + } else { + // Construct canonical tomb facade archway (style 2 wall, width 6, height 4) + for (let dy = 0; dy < 4; dy += 1) { + for (let dx = 0; dx < 6; dx += 1) { + const tx = tc.x + dx + const ty = tc.y + dy + if (tx >= 0 && tx < canvas.width && ty >= 0 && ty < canvas.height) { + if (dy === 3 && (dx === 2 || dx === 3)) { + // Open portal threshold + paintFloor(canvas, tx, ty, makeFloor(0, 0, 2)) + clearWalls(canvas, tx, ty) + } else { + paintWall(canvas, tx, ty, makeWall(2, dy === 0 ? 0 : 1, 2, 129)) + } + } + } + } + } + // Add Tal Rasha Tomb entrance portal object (LevelId 66..72) + canvas.objects.push({ + type: 2, + id: 66 + i, + x: (tc.x + 3) * 5, + y: (tc.y + 3) * 5, + flags: 0, + }) + tombEntrances += 1 + } + + // Central Arcane Sanctuary warp platform (Desert Valley Warp) + const warpPiece = request.pieces.find(p => /Desert Valley Warp/i.test(p.name)) + const warpX = Math.floor(canvas.width / 2) - 4 + const warpY = Math.floor(canvas.height / 2) - 4 + if (warpPiece?.levels[0]) { + stampDs1(canvas, warpPiece.levels[0], warpX, warpY) + stats.specialPresets.push(warpPiece.name) + placedRects.push({ name: warpPiece.name, x: warpX, y: warpY, w: warpPiece.levels[0].width, h: warpPiece.levels[0].height }) + } else { + for (let dy = 0; dy < 8; dy += 1) { + for (let dx = 0; dx < 8; dx += 1) { + paintFloor(canvas, warpX + dx, warpY + dy, makeFloor(3, 0, 194)) + clearWalls(canvas, warpX + dx, warpY + dy) + } + } + canvas.objects.push({ + type: 2, + id: 74, + x: (warpX + 4) * 5, + y: (warpY + 4) * 5, + flags: 0, + }) + } + } + + // 5. Lost City (Level 44): Ancient Buried City Ruins + if (levelId === 44 || /Lost City/i.test(request.levelName)) { + const ruinsPieces = request.pieces.filter(p => /Desert Ruins/i.test(p.name) && p.levels.length > 0) + if (ruinsPieces.length > 0) { + let placedCount = 0 + for (let by = 2; by < gridHeight - 3 && placedCount < 4; by += 3) { + for (let bx = 2; bx < gridWidth - 3 && placedCount < 4; bx += 3) { + const rx = bx * TILES_PER_BLOCK + const ry = by * TILES_PER_BLOCK + if (isInsidePreset(rx, ry, placedRects, 2)) continue + const piece = ruinsPieces[placedCount % ruinsPieces.length]! + const ds1 = piece.levels[0]! + stampDs1(canvas, ds1, rx, ry) + placedRects.push({ name: piece.name, x: rx, y: ry, w: ds1.width, h: ds1.height }) + stats.specialPresets.push(piece.name) + placedCount += 1 + } + } + } + } + + stats.act2Topography = { canyonWalls, sandTrailCells, oasisPoolCells, tombEntrances } + stats.notes.push( + `Act 2 Desert Topography: ${String(canyonWalls)} canyon walls, ${String(sandTrailCells)} sand trail cells, ${String(oasisPoolCells)} oasis cells, ${String(tombEntrances)} tomb entrances`, + ) + + return stats.act2Topography +} + +/* ------------------------------------------------------------------------- * + * Act 3: DRLGOUTPLACE_InitAct3OutdoorLevel & DRLG_GenerateJungles + * ------------------------------------------------------------------------- */ + +/** + * Generate Act 3 Jungle River & Clearings layout (`DRLG_GenerateJungles`): + * - Spans wooden river bridges across the East river channel at row crossings. + * - Connects West clearings to wooden river bridges via winding jungle causeways/paths. + */ +export function generateAct3Jungle( + canvas: Canvas, + levelId: number, + pieces: readonly WildernessPiece[], + rng: Rng, + stats: WildernessStats, +): Set { + const placed = new Set() + const tailPiece = pieces.find(p => p.name === 'Act 3 - Jungle Tail') + const headPiece = pieces.find(p => p.name === 'Act 3 - Jungle Head') + + if (tailPiece?.levels[0]) { + stampDs1(canvas, tailPiece.levels[0], 0, 0) + placed.add(tailPiece.name) + stats.substitutions.push({ name: tailPiece.name, role: 'object', enabled: true, clusters: 1 }) + } + if (headPiece?.levels[0]) { + stampDs1(canvas, headPiece.levels[0], 0, 160) + placed.add(headPiece.name) + stats.substitutions.push({ name: headPiece.name, role: 'object', enabled: true, clusters: 1 }) + } + + const clearingPrefix = + levelId === 76 + ? 'Act 3 - Clearing Webby' + : levelId === 77 + ? 'Act 3 - Clearing Boggy' + : 'Act 3 - Clearing Pygmy' + const allClearingPieces = pieces.filter(p => p.name.startsWith(clearingPrefix) && p.levels.length > 0) + const eastOpeningClearings = allClearingPieces.filter(p => /\b(E|EW|SE|NE)\b/i.test(p.name)) + const clearingPool = eastOpeningClearings.length > 0 ? eastOpeningClearings : allClearingPieces + + const allRiverPieces = pieces.filter( + p => + p.name.startsWith('Act 3 - Jungle ') && + p.name !== 'Act 3 - Jungle Head' && + p.name !== 'Act 3 - Jungle Tail' && + p.levels.length > 0, + ) + const nsWestRivers = allRiverPieces.filter(p => /NS W|NS$/i.test(p.name)) + const riverPool = nsWestRivers.length > 0 ? nsWestRivers : allRiverPieces + + for (let row = 0; row < 4; row += 1) { + const y = 32 + row * 32 + // West column (x = 0): Jungle Clearing opening East + if (clearingPool.length > 0) { + const chosenClearing = clearingPool[rng.int(0, clearingPool.length - 1)]! + const ds1 = chosenClearing.levels[rng.int(0, chosenClearing.levels.length - 1)]! + stampDs1(canvas, ds1, 0, y) + placed.add(chosenClearing.name) + stats.substitutions.push({ name: chosenClearing.name, role: 'object', enabled: true, clusters: 1 }) + } + // East column (x = 32): Continuous North-South River channel + if (riverPool.length > 0) { + const chosenRiver = riverPool[rng.int(0, riverPool.length - 1)]! + const ds1 = chosenRiver.levels[rng.int(0, chosenRiver.levels.length - 1)]! + stampDs1(canvas, ds1, 32, y) + placed.add(chosenRiver.name) + stats.substitutions.push({ name: chosenRiver.name, role: 'object', enabled: true, clusters: 1 }) + } + } + + // Stamp Wooden River Bridges & Jungle Paths spanning the river channel + let riverBridges = 0 + let junglePathCells = 0 + + // Two canonical wooden bridge crossings at row 1 (y = 78..81) and row 3 (y = 142..145) + const bridgeYPositions = [78, 142] + for (const by of bridgeYPositions) { + for (let y = by; y < by + 4; y += 1) { + for (let x = 32; x < 60; x += 1) { + // Style 4: wooden bridge planks + paintFloor(canvas, x, y, makeFloor(4, 0, 2), true) + clearWalls(canvas, x, y) + } + } + riverBridges += 1 + } + + // Jungle Trails connecting clearings across to the river bridges + for (const by of bridgeYPositions) { + // West clearing connection (x = 12..32) + for (let x = 12; x < 32; x += 1) { + for (let dy = 0; dy < 3; dy += 1) { + const y = by + dy + paintFloor(canvas, x, y, makeFloor(5, 0, 194)) + clearWalls(canvas, x, y) + junglePathCells += 1 + } + } + // East trail along riverbank (x = 58..61, North-South trail) + for (let y = by - 14; y <= by + 14; y += 1) { + if (y >= 4 && y < canvas.height - 4) { + paintFloor(canvas, 59, y, makeFloor(5, 0, 194)) + clearWalls(canvas, 59, y) + junglePathCells += 1 + } + } + } + + stats.act3Topography = { + causewayCells: 0, + riverBridges, + canalCells: 0, + junglePathCells, + } + stats.notes.push( + `Act 3 Jungle Topography: ${String(riverBridges)} river bridges, ${String(junglePathCells)} jungle path cells`, + ) + + return placed +} + +/** + * Stamp Kurast Causeway (LevelId 82): + * A fortified stone causeway bridge spanning the canal between Upper Kurast and Travincal. + */ +export function stampAct3KurastCauseway( + canvas: Canvas, + pieces: readonly WildernessPiece[], + stats: WildernessStats, +): Set { + const placed = new Set() + const piece = pieces.find(p => p.name === 'Act 3 - Bridge') + const ds1 = piece?.levels[0] + + if (ds1) { + stampDs1(canvas, ds1, 0, 0) + placed.add('Act 3 - Bridge') + stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 }) + } + + // Construct / ensure canonical causeway layout: + // Central paved walkway flanked by canal water and balustrades + let causewayCells = 0 + let canalCells = 0 + const walkwayLeft = Math.max(2, Math.floor((canvas.width - 8) / 2)) + const walkwayRight = walkwayLeft + 7 + + for (let y = 0; y < canvas.height; y += 1) { + for (let x = 0; x < canvas.width; x += 1) { + if (x >= walkwayLeft && x <= walkwayRight) { + // Paved stone causeway: style 5, prop1 194 + paintFloor(canvas, x, y, makeFloor(5, 0, 194), true) + clearWalls(canvas, x, y) + causewayCells += 1 + } else if (x === walkwayLeft - 1 || x === walkwayRight + 1) { + // Stone balustrade wall: style 1, prop1 129 + paintWall(canvas, x, y, makeWall(1, 0, 1, 129)) + } else { + // Deep canal water: style 2, sequence 0, prop1 2 + paintFloor(canvas, x, y, makeFloor(2, 0, 2), true) + clearWalls(canvas, x, y) + canalCells += 1 + } + } + } + + stats.act3Topography = { + causewayCells, + riverBridges: 1, + canalCells, + junglePathCells: 0, + } + stats.notes.push( + `Act 3 Causeway Topography: ${String(causewayCells)} causeway cells, ${String(canalCells)} canal water cells`, + ) + + return placed +} + +/** + * Initialize Act 3 Kurast Urban Districts (Levels 79 Slums, 80 Burbs, 81 Metro): + * - Paved stone causeways and grand avenues connecting gates and district plazas. + * - Stone canals and water channels dividing the districts with stone bridges. + * - Temples, sewers, and district ruins layout. + */ +export function initAct3OutdoorLevel( + canvas: Canvas, + request: WildernessRequest, + gridWidth: number, + gridHeight: number, + inX: number, + outY: number, + placedRects: PlacedPresetRect[], + rng: Rng, + stats: WildernessStats, +): Act3TopographyStats { + let causewayCells = 0 + let riverBridges = 0 + let canalCells = 0 + const junglePathCells = 0 + + const hubX = inX * TILES_PER_BLOCK + 4 + const hubY = outY * TILES_PER_BLOCK + 4 + + // 1. Paved Stone Avenues (3-tile-wide North-South & East-West) + for (let y = 6; y < canvas.height - 6; y += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + const x = hubX + dx + if (!isInsidePreset(x, y, placedRects, 0)) { + paintFloor(canvas, x, y, makeFloor(5, 0, 194)) + clearWalls(canvas, x, y) + causewayCells += 1 + } + } + } + + for (let x = 6; x < canvas.width - 6; x += 1) { + for (let dy = -1; dy <= 1; dy += 1) { + const y = hubY + dy + if (!isInsidePreset(x, y, placedRects, 0)) { + paintFloor(canvas, x, y, makeFloor(5, 0, 194)) + clearWalls(canvas, x, y) + causewayCells += 1 + } + } + } + + // 2. Stone Canals & Drainage Channels + const canalX = Math.max(12, Math.min(canvas.width - 16, (inX + 2) * TILES_PER_BLOCK)) + for (let y = 8; y < canvas.height - 8; y += 1) { + // Stone Bridge crossing at East-West avenue + if (Math.abs(y - hubY) <= 2) { + for (let dx = 0; dx < 4; dx += 1) { + paintFloor(canvas, canalX + dx, y, makeFloor(4, 0, 2), true) + clearWalls(canvas, canalX + dx, y) + } + riverBridges += 1 + } else { + // Water channel: style 2, prop1 2 + for (let dx = 0; dx < 4; dx += 1) { + const x = canalX + dx + if (!isInsidePreset(x, y, placedRects, 1)) { + paintFloor(canvas, x, y, makeFloor(2, 0, 2), true) + clearWalls(canvas, x, y) + canalCells += 1 + } + } + // Stone canal embankments + paintWall(canvas, canalX - 1, y, makeWall(1, 0, 1, 129)) + paintWall(canvas, canalX + 4, y, makeWall(1, 0, 1, 129)) + } + } + + stats.act3Topography = { causewayCells, riverBridges, canalCells, junglePathCells } + stats.notes.push( + `Act 3 Kurast Topography: ${String(causewayCells)} causeway cells, ${String(canalCells)} canal cells, ${String(riverBridges)} bridges`, + ) + + return stats.act3Topography +} + +/* ------------------------------------------------------------------------- * + * Act 4: DRLGOUTDOORS_InitAct4OutdoorLevel + * ------------------------------------------------------------------------- */ + +/** + * Initialize Act 4 Outdoor Topography (Levels 104 Outer Steppes, 105 Plains of Despair, 106 City of the Damned): + * - Deep glowing lava chasms and fissures cutting through volcanic basalt plateaus. + * - Solid rocky bridge causeways spanning the chasms, connecting separated plateaus. + * - Volcanic mesa plateau formations and Izual prison plateau. + */ +export function initAct4OutdoorLevel( + canvas: Canvas, + request: WildernessRequest, + gridWidth: number, + gridHeight: number, + inX: number, + outY: number, + placedRects: PlacedPresetRect[], + rng: Rng, + stats: WildernessStats, +): Act4TopographyStats { + let lavaChasmCells = 0 + let rockyBridges = 0 + let plateauCells = 0 + + const width = canvas.width + const height = canvas.height + const hubX = inX * TILES_PER_BLOCK + 4 + const hubY = outY * TILES_PER_BLOCK + 4 + + // 1. Lava Fissure: A jagged diagonal chasm dividing the level into two main plateaus + const chasmYAtX = (x: number): number => { + // S-curve diagonal fissure from (10, 0.35*H) to (W - 10, 0.65*H) + const t = x / width + return Math.floor(height * (0.35 + 0.3 * t) + Math.sin(t * Math.PI * 2) * 4) + } + + // Strategic rocky bridge crossing X coordinates + const bridgeXPositions = [ + Math.max(12, Math.floor(width * 0.3)), + hubX, + Math.min(width - 12, Math.floor(width * 0.7)), + ] + + const isBridgeX = (x: number): boolean => bridgeXPositions.some(bx => Math.abs(x - bx) <= 1) + + for (let x = 8; x < width - 8; x += 1) { + const centerY = chasmYAtX(x) + const onBridge = isBridgeX(x) + + if (onBridge) { + // Solid rock bridge across the chasm: style 10, sequence 0, prop1 2 + for (let dy = -3; dy <= 3; dy += 1) { + const y = centerY + dy + if (y >= 0 && y < height) { + paintFloor(canvas, x, y, makeFloor(10, 0, 2), true) + clearWalls(canvas, x, y) + } + } + rockyBridges += 1 + } else { + // Boiling lava chasm: style 20, sequence 3, prop1 2 + for (let dy = -2; dy <= 2; dy += 1) { + const y = centerY + dy + if (y >= 0 && y < height) { + if (!isInsidePreset(x, y, placedRects, 1)) { + paintFloor(canvas, x, y, makeFloor(20, 3, 2), true) + clearWalls(canvas, x, y) + lavaChasmCells += 1 + } + } + } + // Basalt chasm edge lips + paintWall(canvas, x, centerY - 3, makeWall(10, 0, 2, 129)) + paintWall(canvas, x, centerY + 3, makeWall(10, 0, 2, 129)) + } + } + + // 2. Basalt Plateau Ridges / Demonic Altar Formations + if (request.levelId === 105) { + // Plains of Despair: Izual's prison plateau in center + const px0 = hubX - 5 + const py0 = hubY - 5 + for (let dy = 0; dy < 10; dy += 1) { + for (let dx = 0; dx < 10; dx += 1) { + const x = px0 + dx + const y = py0 + dy + if (x >= 4 && x < width - 4 && y >= 4 && y < height - 4) { + if (dx === 0 || dx === 9 || dy === 0 || dy === 9) { + paintWall(canvas, x, y, makeWall(10, 0, 2, 129)) + } else { + paintFloor(canvas, x, y, makeFloor(10, 0, 2)) + clearWalls(canvas, x, y) + plateauCells += 1 + } + } + } + } + } else { + // Outer Steppes (104) / City of the Damned (106): Volcanic basalt plateaus + for (let y = 12; y < height - 12; y += 4) { + for (let x = 12; x < width - 12; x += 4) { + if (!isInsidePreset(x, y, placedRects, 2) && Math.abs(y - chasmYAtX(x)) > 6) { + plateauCells += 1 + } + } + } + } + + stats.act4Topography = { lavaChasmCells, rockyBridges, plateauCells } + stats.notes.push( + `Act 4 Mesa/Lava Topography: ${String(lavaChasmCells)} lava cells, ${String(rockyBridges)} rocky bridge cells, ${String(plateauCells)} plateau cells`, + ) + + return stats.act4Topography +} + +/* ------------------------------------------------------------------------- * + * Act 5: DRLGOUTSIEGE_InitAct5OutdoorLevel + * ------------------------------------------------------------------------- */ + +/** + * Initialize Act 5 Outdoor Topography (Levels 111 Frigid Highlands, 112 Arreat Plateau, 117 Frozen Tundra): + * - Transverse siege fortifications & spiked palisade barricade walls with choke-point gates. + * - Military dugout battlefield trenches running behind defense lines. + * - Snow crater plateaus and frozen lakes with slippery ice floors. + * - Prisoner cages and Hell Portal redoubts. + */ +export function initAct5OutdoorLevel( + canvas: Canvas, + request: WildernessRequest, + gridWidth: number, + gridHeight: number, + inX: number, + outY: number, + placedRects: PlacedPresetRect[], + rng: Rng, + stats: WildernessStats, +): Act5TopographyStats { + let barricadeWalls = 0 + let trenchCells = 0 + let frozenLakeCells = 0 + + const width = canvas.width + const height = canvas.height + const hubY = outY * TILES_PER_BLOCK + 4 + + // 1. Transverse Siege Barricade Wall Lines (cutting across the level) + const barricadeXPositions = [ + Math.max(16, Math.floor(gridWidth * 0.35) * TILES_PER_BLOCK), + Math.min(width - 16, Math.floor(gridWidth * 0.7) * TILES_PER_BLOCK), + ] + + for (const bx of barricadeXPositions) { + // Barricade choke-point gate at hubY + const gateTop = hubY - 2 + const gateBottom = hubY + 2 + + for (let y = 8; y < height - 8; y += 1) { + if (isInsidePreset(bx, y, placedRects, 1)) continue + + if (y >= gateTop && y <= gateBottom) { + // Choke-point gate: clear for passage, fortified end-caps at ends + clearWalls(canvas, bx, y) + paintFloor(canvas, bx, y, makeFloor(5, 0, 194)) + if (y === gateTop) paintWall(canvas, bx, y, makeWall(2, 1, 1, 129)) + if (y === gateBottom) paintWall(canvas, bx, y, makeWall(2, 2, 1, 129)) + } else { + // Spiked wood/iron barricade palisade wall: style 2, prop1 129 + if (paintWall(canvas, bx, y, makeWall(2, 0, 1, 129))) { + barricadeWalls += 1 + } + } + } + + // 2. Military Dugout Trenches: 2-tile wide trench running parallel behind barricade + const trenchX = bx - 2 + for (let y = 10; y < height - 10; y += 1) { + if (Math.abs(y - hubY) > 3 && !isInsidePreset(trenchX, y, placedRects, 1)) { + for (let dx = 0; dx < 2; dx += 1) { + // Dirt/mud trench floor: style 5, prop1 194 + paintFloor(canvas, trenchX + dx, y, makeFloor(5, 0, 194)) + clearWalls(canvas, trenchX + dx, y) + trenchCells += 1 + } + } + } + } + + // 3. Snow Crater Plateaus & Frozen Lakes (Levels 112 Arreat Plateau & 117 Frozen Tundra) + if ( + request.levelId === 112 || + request.levelId === 117 || + /snow|plateau|tundra/i.test(request.levelName) + ) { + const lakeCenter = { + cx: Math.max(14, Math.floor(width * 0.52)), + cy: Math.max(14, Math.floor(height * 0.48)), + radius: Math.min(8, Math.floor(width / 8)), + } + + for (let dy = -lakeCenter.radius; dy <= lakeCenter.radius; dy += 1) { + for (let dx = -lakeCenter.radius; dx <= lakeCenter.radius; dx += 1) { + if (Math.hypot(dx, dy) <= lakeCenter.radius) { + const x = lakeCenter.cx + dx + const y = lakeCenter.cy + dy + if (x >= 4 && x < width - 4 && y >= 4 && y < height - 4) { + if (!isInsidePreset(x, y, placedRects, 1) && Math.abs(y - hubY) > 3) { + // Slippery frozen ice floor: style 2, sequence 0, prop1 2 + paintFloor(canvas, x, y, makeFloor(2, 0, 2), true) + clearWalls(canvas, x, y) + frozenLakeCells += 1 + } + } + } + } + } + + // Stamp Snow Lake preset if available + const lakePiece = request.pieces.find(p => /Snow Lake/i.test(p.name)) + if (lakePiece?.levels[0] && !stats.specialPresets.some(n => /Snow Lake/i.test(n))) { + const ds1 = lakePiece.levels[0] + const lx = Math.max(8, Math.floor(width * 0.5) - Math.floor(ds1.width / 2)) + const ly = Math.max(8, Math.floor(height * 0.5) - Math.floor(ds1.height / 2)) + stampDs1(canvas, ds1, lx, ly) + placedRects.push({ name: lakePiece.name, x: lx, y: ly, w: ds1.width, h: ds1.height }) + stats.specialPresets.push(lakePiece.name) + } + } + + stats.act5Topography = { barricadeWalls, trenchCells, frozenLakeCells } + stats.notes.push( + `Act 5 Siege/Barricade Topography: ${String(barricadeWalls)} barricade walls, ${String(trenchCells)} trench cells, ${String(frozenLakeCells)} frozen lake cells`, + ) + + return stats.act5Topography +} diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 5ff9582..074ea04 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -78,6 +78,18 @@ import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall, Ds1Substitution } from import { Rng } from './rng.ts' import { SUB_TILES_PER_TILE } from './map.ts' import type { Side } from './world-graph.ts' +import { + initAct2OutdoorLevel, + initAct3OutdoorLevel, + generateAct3Jungle, + stampAct3KurastCauseway, + initAct4OutdoorLevel, + initAct5OutdoorLevel, + type Act2TopographyStats, + type Act3TopographyStats, + type Act4TopographyStats, + type Act5TopographyStats, +} from './wilderness-acts.ts' /** One generator block, in cells. `DRLGOUTDOORS_GenerateLevel` divides by 8. */ export const TILES_PER_BLOCK = 8 @@ -314,17 +326,17 @@ export function classifySubstitutionRole(name: string, type: number): Wilderness * ------------------------------------------------------------------------- */ /** A blank wall cell, matching the decoder's placeholder. */ -function emptyWall(): Ds1Wall { +export function emptyWall(): Ds1Wall { return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false } } /** A blank floor or shadow cell. */ -function emptyFloor(): Ds1Floor { +export function emptyFloor(): Ds1Floor { return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false } } /** The mutable map under construction. */ -interface Canvas { +export interface Canvas { readonly width: number readonly height: number readonly cells: Ds1Cell[][] @@ -344,7 +356,7 @@ interface Canvas { * @param substitutionLayers - substitution layers every cell carries. * @returns the canvas. */ -function createCanvas(width: number, height: number, wallLayers: number, floorLayers: number, substitutionLayers: number): Canvas { +export function createCanvas(width: number, height: number, wallLayers: number, floorLayers: number, substitutionLayers: number): Canvas { const cells: Ds1Cell[][] = [] for (let y = 0; y < height; y += 1) { const row: Ds1Cell[] = [] @@ -380,7 +392,7 @@ function createCanvas(width: number, height: number, wallLayers: number, floorLa * @param originY - top edge in cells. * @returns the number of cells actually written. */ -function stampDs1(canvas: Canvas, source: Ds1, originX: number, originY: number): number { +export function stampDs1(canvas: Canvas, source: Ds1, originX: number, originY: number): number { let written = 0 for (let y = 0; y < source.height; y += 1) { const targetY = originY + y @@ -478,7 +490,7 @@ function stampDs1(canvas: Canvas, source: Ds1, originX: number, originY: number) * @param height - cells. * @returns the number of cells written. */ -function fillGround( +export function fillGround( canvas: Canvas, tile: { style: number; sequence: number }, x: number, @@ -586,7 +598,7 @@ function pieceBlocks(piece: WildernessPiece, variant: number): { x: number; y: n * ------------------------------------------------------------------------- */ /** One substitution row's outcome, for `stats`. */ -interface SubstitutionReport { +export interface SubstitutionReport { readonly name: string readonly role: WildernessRole readonly enabled: boolean @@ -608,7 +620,7 @@ export interface LandmarkEntity { } /** The report collector. */ -interface WildernessStats { +export interface WildernessStats { readonly substitutions: SubstitutionReport[] readonly borderPieces: Record readonly unresolved: string[] @@ -628,6 +640,10 @@ interface WildernessStats { shrinesSpawned?: number interactivesSpawned?: number landmarkEntities?: LandmarkEntity[] + act2Topography?: Act2TopographyStats + act3Topography?: Act3TopographyStats + act4Topography?: Act4TopographyStats + act5Topography?: Act5TopographyStats } /** @@ -674,11 +690,6 @@ export const DIRT_PATH_TILE_LUT: Readonly = new Uint8Array([ export const UNIMPLEMENTED_PASSES: readonly string[] = [ 'DRLGVER_CreateVertices', 'DRLGOUTPLACE_CreateLevelConnections', - 'DRLGOUTDESR_InitAct2OutdoorLevel', - 'DRLGOUTPLACE_InitAct3OutdoorLevel', - 'DRLGOUTDOORS_InitAct4OutdoorLevel', - 'DRLGOUTSIEGE_InitAct5OutdoorLevel', - 'DRLG_GenerateJungles', 'DRLGOUTDOORS_SpawnAct3Mephisto', ] @@ -945,8 +956,8 @@ export function getPerimeterOpenings( const openings = new Set() const key = (x: number, y: number) => `${String(x)},${String(y)}` - if (levelId === 39) { - // Moo Moo Farm (Cow Level): 0 exits! + if (levelId === 39 || levelId === 46) { + // Moo Moo Farm (Cow Level) & Canyon of the Magi: 0 exits! return openings } @@ -1560,9 +1571,9 @@ function planAct1OutdoorLevel( } else if (id === 17) { // Burial Grounds (5x6 blocks): Graveyard preset (3x4 blocks at bx=1, by=1) + South Entry Gate if (!placeGraveyardPreset(true)) addBorderGate('bottom', 4, 'Burial Grounds Gate', 3) - } else if (id === 39) { - // Moo Moo Farm: Closed border, internal Corral & Bivouac camps connected by paths - placeInteriorPresets(39) + } else if (id === 39 || id === 46) { + // Moo Moo Farm (39) & Canyon of the Magi (46): Closed border, no border gates + if (id === 39) placeInteriorPresets(39) } else { addBorderGate('left', 3, 'Entry Gate') addBorderGate('right', 3, 'Exit Gate') @@ -2576,99 +2587,6 @@ function stampAct3Travincal( return placed } -/** - * Stamp the 48x16 bridge preset for Act 3 Kurast Causeway (LevelId 82). - */ -function stampAct3KurastCauseway( - canvas: Canvas, - pieces: readonly WildernessPiece[], - stats: WildernessStats, -): Set { - const piece = pieces.find(p => p.name === 'Act 3 - Bridge') - const ds1 = piece?.levels[0] - const placed = new Set() - if (ds1) { - stampDs1(canvas, ds1, 0, 0) - placed.add('Act 3 - Bridge') - stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 }) - } - return placed -} - -/** - * Assemble the 64x192 (2x6 blocks of 32x32) river and clearing layout for Act 3 Jungles (LevelId 76, 77, 78). - * - * In `TravL.ds1` (`Jungle Tail` at `y = 0..31`) and `TransL.ds1` (`Jungle Head` at `y = 160..191`), - * the Kurast river channel flows through the East column (`x = 32..63`, water at `x = 38..60`). - * Placing North-South river presets with West path openings (`Act 3 - Jungle NS W` / `RivNSoW`) in the - * East column (`x = 32`) aligns the river channel (`32 + [5..28] = [37..60]`) seamlessly from `y = 0` - * to `y = 192`, while placing Clearing presets that open East in the West column (`x = 0`) connects - * every clearing across `x = 32` directly to the main riverside jungle trail. - */ -function stampAct3Jungle( - canvas: Canvas, - levelId: number, - pieces: readonly WildernessPiece[], - rng: Rng, - stats: WildernessStats, -): Set { - const placed = new Set() - const tailPiece = pieces.find(p => p.name === 'Act 3 - Jungle Tail') - const headPiece = pieces.find(p => p.name === 'Act 3 - Jungle Head') - if (tailPiece?.levels[0]) { - stampDs1(canvas, tailPiece.levels[0], 0, 0) - placed.add(tailPiece.name) - stats.substitutions.push({ name: tailPiece.name, role: 'object', enabled: true, clusters: 1 }) - } - if (headPiece?.levels[0]) { - stampDs1(canvas, headPiece.levels[0], 0, 160) - placed.add(headPiece.name) - stats.substitutions.push({ name: headPiece.name, role: 'object', enabled: true, clusters: 1 }) - } - - const clearingPrefix = - levelId === 76 - ? 'Act 3 - Clearing Webby' - : levelId === 77 - ? 'Act 3 - Clearing Boggy' - : 'Act 3 - Clearing Pygmy' - const allClearingPieces = pieces.filter(p => p.name.startsWith(clearingPrefix) && p.levels.length > 0) - // Prefer clearings that open East (` E` or ` EW` or ` SE` or ` NE`) to meet the East river column - const eastOpeningClearings = allClearingPieces.filter(p => /\b(E|EW|SE|NE)\b/i.test(p.name)) - const clearingPool = eastOpeningClearings.length > 0 ? eastOpeningClearings : allClearingPieces - - const allRiverPieces = pieces.filter( - p => - p.name.startsWith('Act 3 - Jungle ') && - p.name !== 'Act 3 - Jungle Head' && - p.name !== 'Act 3 - Jungle Tail' && - p.levels.length > 0, - ) - // Prefer North-South river segments that open West (`NS W` / `NS`) to align with Tail/Head at x=32 - const nsWestRivers = allRiverPieces.filter(p => /NS W|NS$/i.test(p.name)) - const riverPool = nsWestRivers.length > 0 ? nsWestRivers : allRiverPieces - - for (let row = 0; row < 4; row += 1) { - const y = 32 + row * 32 - // West column (x = 0): Jungle Clearing opening East - if (clearingPool.length > 0) { - const chosenClearing = clearingPool[rng.int(0, clearingPool.length - 1)]! - const ds1 = chosenClearing.levels[rng.int(0, chosenClearing.levels.length - 1)]! - stampDs1(canvas, ds1, 0, y) - placed.add(chosenClearing.name) - stats.substitutions.push({ name: chosenClearing.name, role: 'object', enabled: true, clusters: 1 }) - } - // East column (x = 32): Continuous North-South River channel - if (riverPool.length > 0) { - const chosenRiver = riverPool[rng.int(0, riverPool.length - 1)]! - const ds1 = chosenRiver.levels[rng.int(0, chosenRiver.levels.length - 1)]! - stampDs1(canvas, ds1, 32, y) - placed.add(chosenRiver.name) - stats.substitutions.push({ name: chosenRiver.name, role: 'object', enabled: true, clusters: 1 }) - } - } - return placed -} /** * Lay a 3-tile-wide main dirt highway connecting the South Border Gate (`Bord1o.ds1` at `x = inX*8 + 3..5`), @@ -3373,7 +3291,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult const groundTile = request.groundTile ?? CANONICAL_GROUND_TILES[request.levelTypeName] ?? deriveGroundTile(request.pieces) - if (gridWidth < 3 || gridHeight < 3) { + if ((gridWidth < 3 || gridHeight < 3) && request.levelId !== 82) { const bridgePiece = request.pieces.find(p => p.levels.some(l => l.width >= gridWidth * TILES_PER_BLOCK && l.height >= gridHeight * TILES_PER_BLOCK)) ?? request.pieces[0] if (bridgePiece && bridgePiece.levels[0]) { const bridgeDs1 = bridgePiece.levels[0] @@ -3464,13 +3382,14 @@ export function generateWilderness(request: WildernessRequest): WildernessResult stats.groundCells = fillGround(canvas, groundTile, 0, 0, canvas.width, canvas.height) // Special case: Act 5 Bloody Foothills (110), Act 4 Chaos Sanctuary (108), - // Act 3 Travincal (83), and Act 3 Jungles (76, 77, 78) are assembled from + // Act 3 Travincal (83), Act 3 Kurast Causeway (82), and Act 3 Jungles (76, 77, 78) are assembled from // deterministic multi-preset layouts rather than rectangular perimeter borders. if ( request.levelId === 110 || request.levelTypeName === 'Act 5 - Siege' || request.levelId === 108 || request.levelId === 83 || + request.levelId === 82 || (request.levelId >= 76 && request.levelId <= 78) ) { let placedSpecial: Set @@ -3478,8 +3397,10 @@ export function generateWilderness(request: WildernessRequest): WildernessResult placedSpecial = stampAct4ChaosSanctuary(canvas, request.pieces, stats) } else if (request.levelId === 83) { placedSpecial = stampAct3Travincal(canvas, request.pieces, stats) + } else if (request.levelId === 82) { + placedSpecial = stampAct3KurastCauseway(canvas, request.pieces, stats) } else if (request.levelId >= 76 && request.levelId <= 78) { - placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats) + placedSpecial = generateAct3Jungle(canvas, request.levelId, request.pieces, rng, stats) } else { placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, stats) } @@ -3532,6 +3453,10 @@ export function generateWilderness(request: WildernessRequest): WildernessResult substitutions: stats.substitutions, substitutedClusters: placedSpecial.size, objects: canvas.objects.length, + act2Topography: stats.act2Topography, + act3Topography: stats.act3Topography, + act4Topography: stats.act4Topography, + act5Topography: stats.act5Topography, cells: canvas.width * canvas.height, unresolved: stats.unresolved, unimplementedPasses: UNIMPLEMENTED_PASSES, @@ -3710,6 +3635,39 @@ export function generateWilderness(request: WildernessRequest): WildernessResult stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, allPlacedRects, rng) } + // Act 2 Desert Topography: Canyons, oasis pools, sand caravan trails, tomb entrances (DRLGOUTDESR_InitAct2OutdoorLevel) + if ( + (request.levelId >= 41 && request.levelId <= 46) || + request.levelTypeName === 'Act 2 - Desert' + ) { + initAct2OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats) + } + + // Act 3 Kurast Urban Districts Topography: Avenues, canals, bridges (DRLGOUTPLACE_InitAct3OutdoorLevel) + if ( + (request.levelId >= 79 && request.levelId <= 81) || + request.levelTypeName === 'Act 3 - Kurast' + ) { + initAct3OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats) + } + + // Act 4 Mesa / Lava Topography: Lava chasms, rocky bridges, plateaus (DRLGOUTDOORS_InitAct4OutdoorLevel) + if ( + (request.levelId >= 104 && request.levelId <= 106) || + request.levelTypeName === 'Act 4 - Mesa' || + request.levelTypeName === 'Act 4 - Lava' + ) { + initAct4OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats) + } + + // Act 5 Siege / Barricades Topography: Barricades, trenches, frozen lakes (DRLGOUTSIEGE_InitAct5OutdoorLevel) + if ( + (request.levelId >= 111 && request.levelId <= 117) || + request.levelTypeName === 'Act 5 - Barricade' + ) { + initAct5OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats) + } + // Step 5: Outdoor Population Phase (Waypoints, Roadside Shrines/Chests, Landmark SuperUniques & Flavie) const populationRng = new Rng(request.seed ^ 0x6c07) spawnAct12Waypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats) @@ -3789,6 +3747,10 @@ export function generateWilderness(request: WildernessRequest): WildernessResult shrinesSpawned: stats.shrinesSpawned ?? 0, interactivesSpawned: stats.interactivesSpawned ?? 0, landmarkEntities: stats.landmarkEntities ?? [], + act2Topography: stats.act2Topography, + act3Topography: stats.act3Topography, + act4Topography: stats.act4Topography, + act5Topography: stats.act5Topography, cells: canvas.width * canvas.height, unresolved: stats.unresolved, unimplementedPasses: UNIMPLEMENTED_PASSES, diff --git a/tests/wilderness-acts.test.ts b/tests/wilderness-acts.test.ts new file mode 100644 index 0000000..33f727d --- /dev/null +++ b/tests/wilderness-acts.test.ts @@ -0,0 +1,417 @@ +import { describe, expect, test } from 'vitest' +import { + generateWilderness, + getPerimeterOpenings, + UNIMPLEMENTED_PASSES, +} from '../src/game/wilderness.ts' +import type { WildernessPiece } from '../src/game/wilderness.ts' +import type { + Act2TopographyStats, + Act3TopographyStats, + Act4TopographyStats, + Act5TopographyStats, +} from '../src/game/wilderness-acts.ts' +import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts' + +function makeMockDs1(width: number, height: number, hasGapInWalls: boolean = false): Ds1 { + const cells: Ds1Cell[][] = [] + for (let y = 0; y < height; y += 1) { + const row: Ds1Cell[] = [] + for (let x = 0; x < width; x += 1) { + const isGap = hasGapInWalls && x === Math.floor(width / 2) + row.push({ + walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }], + floors: [{ prop1: 2, sequence: 0, style: 1, unknown1: 0, unknown2: 0, hidden: false }], + shadows: [], + substitutions: [], + }) + } + cells.push(row) + } + return { + version: 18, + width, + height, + act: 1, + substitutionType: 0, + wallLayers: 1, + floorLayers: 1, + cells, + objects: [], + npcPathOffset: null, + } +} + +function makeMockBorderPieces(actPrefix: string): WildernessPiece[] { + const borderDs1 = makeMockDs1(8, 8, false) + const openBorderDs1 = makeMockDs1(8, 8, true) + return [ + { name: `${actPrefix} - Border 1`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] }, + { name: `${actPrefix} - Border 2`, border: true, levels: [borderDs1] }, + { name: `${actPrefix} - Border 3`, border: true, levels: [borderDs1] }, + { name: `${actPrefix} - Border 4`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] }, + { name: `${actPrefix} - Border 5`, border: true, levels: [borderDs1] }, + { name: `${actPrefix} - Border 6`, border: true, levels: [borderDs1] }, + { name: `${actPrefix} - Border 7`, border: true, levels: [borderDs1] }, + { name: `${actPrefix} - Border 8`, border: true, levels: [borderDs1] }, + ] +} + +describe('Wilderness Outdoor Initializers for Acts 2-5 (Issue #51)', () => { + test('UNIMPLEMENTED_PASSES has 5 outdoor passes removed', () => { + expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTDESR_InitAct2OutdoorLevel') + expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTPLACE_InitAct3OutdoorLevel') + expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTDOORS_InitAct4OutdoorLevel') + expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTSIEGE_InitAct5OutdoorLevel') + expect(UNIMPLEMENTED_PASSES).not.toContain('DRLG_GenerateJungles') + expect(UNIMPLEMENTED_PASSES).toEqual([ + 'DRLGVER_CreateVertices', + 'DRLGOUTPLACE_CreateLevelConnections', + 'DRLGOUTDOORS_SpawnAct3Mephisto', + ]) + }) + + describe('Act 2: DRLGOUTDESR_InitAct2OutdoorLevel', () => { + test('Rocky Waste (41) generates sand caravan trails and canyon ridge walls', () => { + const pieces = makeMockBorderPieces('Act 2') + const result = generateWilderness({ + levelId: 41, + levelName: 'Rocky Waste', + levelTypeName: 'Act 2 - Desert', + sizeX: 80, + sizeY: 80, + subType: 0, + subTheme: 0, + seed: 41001, + pieces, + substitutions: [], + }) + + expect(result.stats.act2Topography).toBeDefined() + const topo = result.stats.act2Topography as Act2TopographyStats + expect(topo.sandTrailCells).toBeGreaterThan(50) + expect(topo.canyonWalls).toBeGreaterThan(20) + + let foundSandTrail = false + let foundCanyonWall = false + for (let y = 0; y < result.level.height; y += 1) { + for (let x = 0; x < result.level.width; x += 1) { + const cell = result.level.cells[y]![x]! + if (cell.floors.some(f => f.style === 5 && f.prop1 === 194)) foundSandTrail = true + if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) foundCanyonWall = true + } + } + expect(foundSandTrail).toBe(true) + expect(foundCanyonWall).toBe(true) + }) + + test('Far Oasis (43) generates lush water oasis pools', () => { + const pieces = makeMockBorderPieces('Act 2') + const result = generateWilderness({ + levelId: 43, + levelName: 'Far Oasis', + levelTypeName: 'Act 2 - Desert', + sizeX: 80, + sizeY: 80, + subType: 0, + subTheme: 0, + seed: 43001, + pieces, + substitutions: [], + }) + + expect(result.stats.act2Topography).toBeDefined() + const topo = result.stats.act2Topography as Act2TopographyStats + expect(topo.oasisPoolCells).toBeGreaterThan(15) + + let foundOasisWater = false + for (let y = 0; y < result.level.height; y += 1) { + for (let x = 0; x < result.level.width; x += 1) { + const cell = result.level.cells[y]![x]! + if (cell.floors.some(f => f.style === 2 && f.prop1 === 2)) foundOasisWater = true + } + } + expect(foundOasisWater).toBe(true) + }) + + test('Valley of Snakes (45) creates narrow canyon gorge', () => { + const pieces = makeMockBorderPieces('Act 2') + const result = generateWilderness({ + levelId: 45, + levelName: 'Valley of Snakes', + levelTypeName: 'Act 2 - Desert', + sizeX: 80, + sizeY: 80, + subType: 0, + subTheme: 0, + seed: 45001, + pieces, + substitutions: [], + }) + + expect(result.stats.act2Topography).toBeDefined() + const topo = result.stats.act2Topography as Act2TopographyStats + expect(topo.canyonWalls).toBeGreaterThan(30) + }) + + test('Canyon of the Magi (46) places 7 Tal Rasha tombs, central dais, and has 0 perimeter openings', () => { + const pieces = makeMockBorderPieces('Act 2') + expect(getPerimeterOpenings(46, 10, 10).size).toBe(0) + + const result = generateWilderness({ + levelId: 46, + levelName: 'Canyon of the Magi', + levelTypeName: 'Act 2 - Desert', + sizeX: 80, + sizeY: 80, + subType: 0, + subTheme: 0, + seed: 46001, + pieces, + substitutions: [], + }) + + expect(result.stats.act2Topography).toBeDefined() + const topo = result.stats.act2Topography as Act2TopographyStats + expect(topo.tombEntrances).toBe(7) + + // Verify central Arcane Sanctuary warp platform (portal/dais) + let foundCentralDais = false + for (let y = 35; y <= 45; y += 1) { + for (let x = 35; x <= 45; x += 1) { + const cell = result.level.cells[y]![x]! + if (cell.floors.some(f => f.style === 3)) foundCentralDais = true + } + } + expect(foundCentralDais).toBe(true) + }) + }) + + describe('Act 3: DRLGOUTPLACE_InitAct3OutdoorLevel & DRLG_GenerateJungles', () => { + test('Jungles (76 Spider Forest) generate river channel, wooden river bridges, and jungle paths', () => { + const tailDs1 = makeMockDs1(64, 32) + const headDs1 = makeMockDs1(64, 32) + const clearingDs1 = makeMockDs1(32, 32) + const riverDs1 = makeMockDs1(32, 32) + + const pieces: WildernessPiece[] = [ + { name: 'Act 3 - Jungle Tail', border: false, levels: [tailDs1] }, + { name: 'Act 3 - Jungle Head', border: false, levels: [headDs1] }, + { name: 'Act 3 - Clearing Webby E', border: false, levels: [clearingDs1] }, + { name: 'Act 3 - Jungle NS W', border: false, levels: [riverDs1] }, + ] + + const result = generateWilderness({ + levelId: 76, + levelName: 'Spider Forest', + levelTypeName: 'Act 3 - Jungle', + sizeX: 64, + sizeY: 192, + subType: 0, + subTheme: 0, + seed: 76001, + pieces, + substitutions: [], + }) + + expect(result.stats.act3Topography).toBeDefined() + const topo = result.stats.act3Topography as Act3TopographyStats + expect(topo.riverBridges).toBeGreaterThanOrEqual(2) + expect(topo.junglePathCells).toBeGreaterThan(20) + + // Verify wooden river bridge planks (style 4, prop1 2) spanning across East channel + let foundBridgePlank = false + for (let y = 70; y <= 90; y += 1) { + for (let x = 32; x < 60; x += 1) { + const cell = result.level.cells[y]![x]! + if (cell.floors.some(f => f.style === 4 && f.prop1 === 2)) foundBridgePlank = true + } + } + expect(foundBridgePlank).toBe(true) + }) + + test('Kurast Causeway (82) stamps causeway with central walkway, balustrades, and canal water', () => { + const bridgeDs1 = makeMockDs1(48, 16) + const pieces: WildernessPiece[] = [ + { name: 'Act 3 - Bridge', border: false, levels: [bridgeDs1] }, + ] + + const result = generateWilderness({ + levelId: 82, + levelName: 'Kurast Causeway', + levelTypeName: 'Act 3 - Kurast', + sizeX: 48, + sizeY: 16, + subType: 0, + subTheme: 0, + seed: 82001, + pieces, + substitutions: [], + }) + + expect(result.stats.act3Topography).toBeDefined() + const topo = result.stats.act3Topography as Act3TopographyStats + expect(topo.causewayCells).toBeGreaterThan(50) + expect(topo.canalCells).toBeGreaterThan(100) + + // Verify central walkway (style 5), stone balustrade walls (style 1), and canal water (style 2) + let foundWalkway = false + let foundBalustrade = false + let foundCanalWater = false + for (let y = 0; y < result.level.height; y += 1) { + for (let x = 0; x < result.level.width; x += 1) { + const cell = result.level.cells[y]![x]! + if (cell.floors.some(f => f.style === 5 && f.prop1 === 194)) foundWalkway = true + if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) foundBalustrade = true + if (cell.floors.some(f => f.style === 2 && f.prop1 === 2)) foundCanalWater = true + } + } + expect(foundWalkway).toBe(true) + expect(foundBalustrade).toBe(true) + expect(foundCanalWater).toBe(true) + }) + + test('Kurast Bazaar (79) generates paved stone avenues, canals, and stone bridges', () => { + const pieces = makeMockBorderPieces('Act 3') + const result = generateWilderness({ + levelId: 79, + levelName: 'Kurast Bazaar', + levelTypeName: 'Act 3 - Kurast', + sizeX: 80, + sizeY: 80, + subType: 0, + subTheme: 0, + seed: 79001, + pieces, + substitutions: [], + }) + + expect(result.stats.act3Topography).toBeDefined() + const topo = result.stats.act3Topography as Act3TopographyStats + expect(topo.causewayCells).toBeGreaterThan(50) + expect(topo.canalCells).toBeGreaterThan(30) + expect(topo.riverBridges).toBeGreaterThanOrEqual(1) + }) + }) + + describe('Act 4: DRLGOUTDOORS_InitAct4OutdoorLevel', () => { + test('Outer Steppes (104) generates lava chasms, basalt lips, and rocky bridge causeways', () => { + const pieces = makeMockBorderPieces('Act 4') + const result = generateWilderness({ + levelId: 104, + levelName: 'Outer Steppes', + levelTypeName: 'Act 4 - Mesa', + sizeX: 80, + sizeY: 80, + subType: 0, + subTheme: 0, + seed: 104001, + pieces, + substitutions: [], + }) + + expect(result.stats.act4Topography).toBeDefined() + const topo = result.stats.act4Topography as Act4TopographyStats + expect(topo.lavaChasmCells).toBeGreaterThan(100) + expect(topo.rockyBridges).toBeGreaterThan(5) + + // Verify boiling molten lava (style 20, sequence 3, prop1 2) and rocky bridges (style 10, sequence 0, prop1 2) + let foundLava = false + let foundRockyBridge = false + for (let y = 0; y < result.level.height; y += 1) { + for (let x = 0; x < result.level.width; x += 1) { + const cell = result.level.cells[y]![x]! + if (cell.floors.some(f => f.style === 20 && f.sequence === 3 && f.prop1 === 2)) foundLava = true + if (cell.floors.some(f => f.style === 10 && f.sequence === 0 && f.prop1 === 2)) foundRockyBridge = true + } + } + expect(foundLava).toBe(true) + expect(foundRockyBridge).toBe(true) + }) + + test('Plains of Despair (105) creates Izual prison plateau', () => { + const pieces = makeMockBorderPieces('Act 4') + const result = generateWilderness({ + levelId: 105, + levelName: 'Plains of Despair', + levelTypeName: 'Act 4 - Mesa', + sizeX: 80, + sizeY: 80, + subType: 0, + subTheme: 0, + seed: 105001, + pieces, + substitutions: [], + }) + + expect(result.stats.act4Topography).toBeDefined() + const topo = result.stats.act4Topography as Act4TopographyStats + expect(topo.plateauCells).toBeGreaterThan(20) + }) + }) + + describe('Act 5: DRLGOUTSIEGE_InitAct5OutdoorLevel', () => { + test('Frigid Highlands (111) generates transverse siege barricades, choke-point gates, and trenches', () => { + const pieces = makeMockBorderPieces('Act 5') + const result = generateWilderness({ + levelId: 111, + levelName: 'Frigid Highlands', + levelTypeName: 'Act 5 - Barricade', + sizeX: 80, + sizeY: 80, + subType: 0, + subTheme: 0, + seed: 111001, + pieces, + substitutions: [], + }) + + expect(result.stats.act5Topography).toBeDefined() + const topo = result.stats.act5Topography as Act5TopographyStats + expect(topo.barricadeWalls).toBeGreaterThan(40) + expect(topo.trenchCells).toBeGreaterThan(30) + + // Verify spiked palisade walls (style 2, prop1 129) and trench floors (style 5, prop1 194) + let foundBarricadeWall = false + let foundTrench = false + for (let y = 0; y < result.level.height; y += 1) { + for (let x = 0; x < result.level.width; x += 1) { + const cell = result.level.cells[y]![x]! + if (cell.walls.some(w => w.style === 2 && w.prop1 === 129)) foundBarricadeWall = true + if (cell.floors.some(f => f.style === 5 && f.prop1 === 194)) foundTrench = true + } + } + expect(foundBarricadeWall).toBe(true) + expect(foundTrench).toBe(true) + }) + + test('Arreat Plateau (112) generates snow crater frozen lakes with ice floors', () => { + const pieces = makeMockBorderPieces('Act 5') + const result = generateWilderness({ + levelId: 112, + levelName: 'Arreat Plateau', + levelTypeName: 'Act 5 - Barricade', + sizeX: 80, + sizeY: 80, + subType: 0, + subTheme: 0, + seed: 112001, + pieces, + substitutions: [], + }) + + expect(result.stats.act5Topography).toBeDefined() + const topo = result.stats.act5Topography as Act5TopographyStats + expect(topo.frozenLakeCells).toBeGreaterThan(20) + + let foundIceFloor = false + for (let y = 0; y < result.level.height; y += 1) { + for (let x = 0; x < result.level.width; x += 1) { + const cell = result.level.cells[y]![x]! + if (cell.floors.some(f => f.style === 2 && f.sequence === 0 && f.prop1 === 2)) foundIceFloor = true + } + } + expect(foundIceFloor).toBe(true) + }) + }) +})