From 3f463ba7775c1a6e9059d8f9ee038e49ec346aa4 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 07:28:07 +0000 Subject: [PATCH] feat(map-gen/wilderness): extend road and stone divider networks to Acts 2, 3, and 5 (fixes #55) --- src/game/wilderness.ts | 549 ++++++++++++++++++++--- tests/wilderness-roads-multi-act.test.ts | 416 +++++++++++++++++ 2 files changed, 902 insertions(+), 63 deletions(-) create mode 100644 tests/wilderness-roads-multi-act.test.ts diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 5ff9582..92cc190 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -324,7 +324,7 @@ function emptyFloor(): Ds1Floor { } /** The mutable map under construction. */ -interface Canvas { +export interface Canvas { readonly width: number readonly height: number readonly cells: Ds1Cell[][] @@ -344,7 +344,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[] = [] @@ -950,9 +950,18 @@ export function getPerimeterOpenings( return openings } - // Act 1 wilderness levels (2..7) & Act 4 Mesa wilderness levels (104..106): - // entrance on South edge, exit on East edge - if ((levelId >= 2 && levelId <= 7) || (levelId >= 104 && levelId <= 106)) { + // Outdoor wilderness levels across Acts 1, 2, 3, 4, 5: + // Act 1 (2..7), Act 2 desert (41..46), Act 3 Kurast (79..82), + // Act 4 Mesa (104..106), Act 5 outdoor (111..112, 117): + // Default entrance on South edge, exit on East edge + if ( + (levelId >= 2 && levelId <= 7) || + (levelId >= 41 && levelId <= 46) || + (levelId >= 79 && levelId <= 82) || + (levelId >= 104 && levelId <= 106) || + (levelId >= 111 && levelId <= 112) || + levelId === 117 + ) { const inX = Math.floor(gridWidth / 2) openings.add(key(inX, gridHeight - 1)) const outY = Math.floor(gridHeight / 2) @@ -2670,12 +2679,25 @@ function stampAct3Jungle( return placed } +/** Theme configuration for outdoor road rasterization. */ +export interface OutdoorRoadTheme { + readonly roadFloor: (rng: Rng) => Ds1Floor + readonly isProtectedPreset?: (presetName: string) => boolean + readonly isIgnoredSpurPreset?: (presetName: string) => boolean +} + +/** Theme configuration for outdoor stone wall divider networks. */ +export interface OutdoorDividerTheme { + readonly wallStyle: number + readonly wallProp1?: number + readonly roadStyles?: readonly number[] +} + /** - * Lay a 3-tile-wide main dirt highway connecting the South Border Gate (`Bord1o.ds1` at `x = inX*8 + 3..5`), - * the Central Crossroads (`hubX, hubY`), the River Bridge (if present), and the East Border Gate - * (`Bord4o.ds1` at `y = outY*8 + 3..5`), plus 2-tile-wide spur paths to every spawned landmark. + * General outdoor path and highway rasterizer connecting entrance, central crossroads, + * exit gates, and landmark presets across wilderness maps. */ -function stampAct1DirtRoads( +export function stampThemedRoads( canvas: Canvas, gridWidth: number, gridHeight: number, @@ -2683,19 +2705,14 @@ function stampAct1DirtRoads( outY: number, placedRects: readonly PlacedPresetRect[], rng: Rng, + theme: OutdoorRoadTheme, + openGates?: ReadonlyMap, ): void { - const makeRoadFloor = (): Ds1Floor => ({ - prop1: 194, - sequence: rng.int(0, 6) === 0 ? 25 : 0, - style: 5, - unknown1: 0, - unknown2: 0, - hidden: false, - }) - const isInsideProtectedPreset = (x: number, y: number): boolean => { for (const r of placedRects) { - if (r.name === 'Act 1 - River') continue + if (theme.isProtectedPreset ? !theme.isProtectedPreset(r.name) : /River|Bridge/i.test(r.name)) { + continue + } if (x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) { return true } @@ -2721,15 +2738,25 @@ function stampAct1DirtRoads( return } } - ;(cell.floors as Ds1Floor[])[0] = makeRoadFloor() + ;(cell.floors as Ds1Floor[])[0] = theme.roadFloor(rng) } const hubX = inX * TILES_PER_BLOCK + 4 const hubY = outY * TILES_PER_BLOCK + 4 const southGateY = (gridHeight - 1) * TILES_PER_BLOCK + 3 const eastGateX = (gridWidth - 1) * TILES_PER_BLOCK + 3 - const northEnd = Math.max(12, Math.floor(canvas.height * 0.2)) - const westEnd = Math.max(12, Math.floor(canvas.width * 0.2)) + let northEnd = Math.max(12, Math.floor(canvas.height * 0.2)) + let westEnd = Math.max(12, Math.floor(canvas.width * 0.2)) + + if (openGates !== undefined) { + for (const key of openGates.keys()) { + const [bxStr, byStr] = key.split(',') + const bx = Number(bxStr) + const by = Number(byStr) + if (by === 0) northEnd = 4 + if (bx === 0) westEnd = 4 + } + } // 1. 3-tile-wide South-to-North main highway (`x = hubX - 1, hubX, hubX + 1`) for (let y = northEnd; y <= southGateY; y += 1) { @@ -2745,6 +2772,34 @@ function stampAct1DirtRoads( } } + // If openGates contains non-standard gate blocks, connect them into the highway network + if (openGates !== undefined) { + for (const key of openGates.keys()) { + const [bxStr, byStr] = key.split(',') + const bx = Number(bxStr) + const by = Number(byStr) + const gateX = bx * TILES_PER_BLOCK + 4 + const gateY = by * TILES_PER_BLOCK + 4 + if (by === 0 && Math.abs(gateX - hubX) > 2) { + // North gate branch to hubY + for (let y = 4; y <= hubY; y += 1) { + for (let dx = -1; dx <= 1; dx += 1) paintRoadTile(gateX + dx, y, true) + } + for (let x = Math.min(gateX, hubX); x <= Math.max(gateX, hubX); x += 1) { + for (let dy = -1; dy <= 1; dy += 1) paintRoadTile(x, hubY + dy, true) + } + } else if (bx === 0 && Math.abs(gateY - hubY) > 2) { + // West gate branch to hubX + for (let x = 4; x <= hubX; x += 1) { + for (let dy = -1; dy <= 1; dy += 1) paintRoadTile(x, gateY + dy, true) + } + for (let y = Math.min(gateY, hubY); y <= Math.max(gateY, hubY); y += 1) { + for (let dx = -1; dx <= 1; dx += 1) paintRoadTile(hubX + dx, y, true) + } + } + } + } + // 3. 2-tile-wide spur roads connecting each major spawned landmark to the main highway const paintSpurSegment = (x0: number, y0: number, x1: number, y1: number): void => { let curX = x0 @@ -2764,10 +2819,7 @@ function stampAct1DirtRoads( } for (const r of placedRects) { - if ( - r.name === 'Act 1 - River' || - /Stone Fill|Swamp Fill|Tree Fill/i.test(r.name) - ) { + if (theme.isIgnoredSpurPreset ? theme.isIgnoredSpurPreset(r.name) : /River|Bridge|Fill|Berms/i.test(r.name)) { continue } const centerX = Math.floor(r.x + r.w / 2) @@ -2788,15 +2840,10 @@ function stampAct1DirtRoads( } /** - * Lay connected stone wall divider lines (`style=2, prop1=129`) across the interior of Act 1 - * wilderness levels (LevelId 2..7), using exact Blizzard tile sequences: - * - Continuous East-West wall: `style=2, sequence=0, type=2` (`s2.q0.t2`) - * - Continuous North-South wall: `style=2, sequence=0, type=1` (`s2.q0.t1`) - * - Corner intersection: `style=2, sequence=0, type=3` (`s2.q0.t3`) - * - East-West gate end-caps: `sequence=1, type=2` (West/Right-of-gap) & `sequence=2, type=2` (East/Left-of-gap) - * - North-South gate end-caps: `sequence=1, type=1` (North/Bottom-of-gap) & `sequence=2, type=1` (South/Top-of-gap) + * General outdoor divider network laying connected stone walls, sandstone berms, + * or balustrades with proper corner joins, gate archways, and endcaps. */ -function stampAct1InteriorStoneWalls( +export function stampThemedDividers( canvas: Canvas, gridWidth: number, gridHeight: number, @@ -2804,13 +2851,17 @@ function stampAct1InteriorStoneWalls( outY: number, placedRects: readonly PlacedPresetRect[], rng: Rng, + theme: OutdoorDividerTheme, ): void { - if (gridWidth < 8 || gridHeight < 8) return + if (gridWidth < 6 || gridHeight < 6) return + + const wallStyle = theme.wallStyle + const wallProp1 = theme.wallProp1 ?? 129 const makeWall = (type: number, sequence: number): Ds1Wall => ({ - prop1: 129, + prop1: wallProp1, sequence, - style: 2, + style: wallStyle, type, unknown1: 0, unknown2: 0, @@ -2828,26 +2879,26 @@ function stampAct1InteriorStoneWalls( } } - // Offset stone wall dividers by 2 blocks (16 tiles) from the main road spine + // Offset divider lines by 2 blocks (16 tiles) from the main road spine const offsetBlockY = rng.int(0, 1) === 0 ? -2 : 2 const offsetBlockX = rng.int(0, 1) === 0 ? -2 : 2 - const wallY = Math.max(16, Math.min(canvas.height - 16, (outY + offsetBlockY) * TILES_PER_BLOCK)) - const wallX = Math.max(16, Math.min(canvas.width - 16, (inX + offsetBlockX) * TILES_PER_BLOCK)) + const wallY = Math.max(12, Math.min(canvas.height - 12, (outY + offsetBlockY) * TILES_PER_BLOCK)) + const wallX = Math.max(12, Math.min(canvas.width - 12, (inX + offsetBlockX) * TILES_PER_BLOCK)) const isCellBlockedForWall = (x: number, y: number): boolean => { - if (x < 10 || x >= canvas.width - 10 || y < 10 || y >= canvas.height - 10) return true - // Leave 2-tile margin around all placed special presets (houses, caves, rivers, corrals) + if (x < 6 || x >= canvas.width - 6 || y < 6 || y >= canvas.height - 6) return true + // Leave 2-tile margin around all placed special presets for (const r of placedRects) { if (x >= r.x - 2 && x < r.x + r.w + 2 && y >= r.y - 2 && y < r.y + r.h + 2) { return true } } - // Leave 3-tile wide archway gate openings wherever a dirt road (style 5), bridge (style 4), or water (style 2/3) passes + // Leave 3-tile wide archway gate openings wherever a road, bridge, or water passes for (let dy = -1; dy <= 1; dy += 1) { for (let dx = -1; dx <= 1; dx += 1) { const c = canvas.cells[y + dy]?.[x + dx] if (!c) continue - if (c.floors.some(f => !f.hidden && (f.style === 5 || f.style === 4 || f.style === 2 || f.style === 3))) { + if (c.floors.some(f => !f.hidden && (f.style === 5 || f.style === 1 || f.style === 4 || f.style === 2 || f.style === 3 || theme.roadStyles?.includes(f.style)))) { return true } } @@ -2892,37 +2943,358 @@ function stampAct1InteriorStoneWalls( const nsCoversCorner = nsRuns.some(r => wallY >= r.start && wallY <= r.end) const hasCornerJoin = ewCoversCorner && nsCoversCorner - // Stamp East-West stone wall segments (`type = 2`) + // Stamp East-West divider wall segments (`type = 2`) for (const run of ewRuns) { for (let x = run.start; x <= run.end; x += 1) { if (x === wallX && hasCornerJoin) { - placeWall(x, wallY, makeWall(3, 0)) // Corner / Cross join (`s2.q0.t3`) + placeWall(x, wallY, makeWall(3, 0)) // Corner / Cross join (`type=3`) } else if (x === run.start) { - placeWall(x, wallY, makeWall(2, 1)) // West / Right-of-gap End-Cap (`s2.q1.t2`) + placeWall(x, wallY, makeWall(2, 1)) // West / Right-of-gap End-Cap (`type=2, seq=1`) } else if (x === run.end) { - placeWall(x, wallY, makeWall(2, 2)) // East / Left-of-gap End-Cap (`s2.q2.t2`) + placeWall(x, wallY, makeWall(2, 2)) // East / Left-of-gap End-Cap (`type=2, seq=2`) } else { - placeWall(x, wallY, makeWall(2, 0)) // Continuous EW Stone Wall (`s2.q0.t2`) + placeWall(x, wallY, makeWall(2, 0)) // Continuous EW Wall (`type=2, seq=0`) } } } - // Stamp North-South stone wall segments (`type = 1`) + // Stamp North-South divider wall segments (`type = 1`) for (const run of nsRuns) { for (let y = run.start; y <= run.end; y += 1) { if (y === wallY && hasCornerJoin) { - placeWall(wallX, y, makeWall(3, 0)) // Corner / Cross join (`s2.q0.t3`) + placeWall(wallX, y, makeWall(3, 0)) // Corner / Cross join (`type=3`) } else if (y === run.start) { - placeWall(wallX, y, makeWall(1, 1)) // North / Bottom-of-gap End-Cap (`s2.q1.t1`) + placeWall(wallX, y, makeWall(1, 1)) // North / Bottom-of-gap End-Cap (`type=1, seq=1`) } else if (y === run.end) { - placeWall(wallX, y, makeWall(1, 2)) // South / Top-of-gap End-Cap (`s2.q2.t1`) + placeWall(wallX, y, makeWall(1, 2)) // South / Top-of-gap End-Cap (`type=1, seq=2`) } else { - placeWall(wallX, y, makeWall(1, 0)) // Continuous NS Stone Wall (`s2.q0.t1`) + placeWall(wallX, y, makeWall(1, 0)) // Continuous NS Wall (`type=1, seq=0`) } } } } +/** + * Act 1: Lay a 3-tile-wide dirt highway connecting the South Border Gate, central crossroads, + * river bridge, and East Border Gate, plus 2-tile-wide spur paths to landmarks. + */ +export function stampAct1DirtRoads( + canvas: Canvas, + gridWidth: number, + gridHeight: number, + inX: number, + outY: number, + placedRects: readonly PlacedPresetRect[], + rng: Rng, + openGates?: ReadonlyMap, +): void { + stampThemedRoads( + canvas, + gridWidth, + gridHeight, + inX, + outY, + placedRects, + rng, + { + roadFloor: (r) => ({ + prop1: 194, + sequence: r.int(0, 6) === 0 ? 25 : 0, + style: 5, + unknown1: 0, + unknown2: 0, + hidden: false, + }), + isProtectedPreset: (name) => name !== 'Act 1 - River', + isIgnoredSpurPreset: (name) => name === 'Act 1 - River' || /Stone Fill|Swamp Fill|Tree Fill|Fence Fill|Corral Fill/i.test(name), + }, + openGates, + ) +} + +/** + * Act 1: Lay connected stone wall divider lines (`style=2, prop1=129`) across the interior + * meadow of Act 1 wilderness levels (LevelId 2..7). + */ +export function stampAct1InteriorStoneWalls( + canvas: Canvas, + gridWidth: number, + gridHeight: number, + inX: number, + outY: number, + placedRects: readonly PlacedPresetRect[], + rng: Rng, +): void { + stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, { + wallStyle: 2, + wallProp1: 129, + roadStyles: [5], + }) +} + +/** + * Act 2: Desert sandstone caravan routes connecting entrance to exit gates across dunes. + */ +export function stampAct2DesertRoads( + canvas: Canvas, + gridWidth: number, + gridHeight: number, + inX: number, + outY: number, + placedRects: readonly PlacedPresetRect[], + rng: Rng, + openGates?: ReadonlyMap, +): void { + stampThemedRoads( + canvas, + gridWidth, + gridHeight, + inX, + outY, + placedRects, + rng, + { + roadFloor: (r) => ({ + prop1: 194, + sequence: r.int(0, 5) === 0 ? 1 : 0, + style: 1, + unknown1: 0, + unknown2: 0, + hidden: false, + }), + isProtectedPreset: (name) => !/River|Bridge/i.test(name), + isIgnoredSpurPreset: (name) => /Fill|Berms/i.test(name), + }, + openGates, + ) +} + +/** + * Act 2: Sandstone divider berms (style=1, prop1=129) separating desert dunes. + */ +export function stampAct2SandstoneBerms( + canvas: Canvas, + gridWidth: number, + gridHeight: number, + inX: number, + outY: number, + placedRects: readonly PlacedPresetRect[], + rng: Rng, +): void { + stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, { + wallStyle: 1, + wallProp1: 129, + roadStyles: [1], + }) +} + +/** + * Act 3: Kurast flagstone avenues across temples, plazas, and causeways. + */ +export function stampAct3KurastAvenues( + canvas: Canvas, + gridWidth: number, + gridHeight: number, + inX: number, + outY: number, + placedRects: readonly PlacedPresetRect[], + rng: Rng, + openGates?: ReadonlyMap, +): void { + stampThemedRoads( + canvas, + gridWidth, + gridHeight, + inX, + outY, + placedRects, + rng, + { + roadFloor: (r) => ({ + prop1: 194, + sequence: r.int(0, 5) === 0 ? 1 : 0, + style: 1, + unknown1: 0, + unknown2: 0, + hidden: false, + }), + isProtectedPreset: (name) => !/River|Bridge/i.test(name), + isIgnoredSpurPreset: (name) => /Fill/i.test(name), + }, + openGates, + ) +} + +/** + * Act 3: Kurast balustrades across temples and causeways (style=1, prop1=129). + */ +export function stampAct3KurastBalustrades( + canvas: Canvas, + gridWidth: number, + gridHeight: number, + inX: number, + outY: number, + placedRects: readonly PlacedPresetRect[], + rng: Rng, +): void { + stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, { + wallStyle: 1, + wallProp1: 129, + roadStyles: [1], + }) +} + +/** + * Act 5: Snow march roads in Frigid Highlands & Arreat Plateau. + */ +export function stampAct5SnowRoads( + canvas: Canvas, + gridWidth: number, + gridHeight: number, + inX: number, + outY: number, + placedRects: readonly PlacedPresetRect[], + rng: Rng, + openGates?: ReadonlyMap, +): void { + stampThemedRoads( + canvas, + gridWidth, + gridHeight, + inX, + outY, + placedRects, + rng, + { + roadFloor: (r) => ({ + prop1: 194, + sequence: r.int(0, 5) === 0 ? 1 : 0, + style: 1, + unknown1: 0, + unknown2: 0, + hidden: false, + }), + isProtectedPreset: (name) => !/River|Bridge/i.test(name), + isIgnoredSpurPreset: (name) => /Fill|Filler/i.test(name), + }, + openGates, + ) +} + +/** + * Act 5: Frozen trail divides in Frigid Highlands & Arreat Plateau (style=2, prop1=129). + */ +export function stampAct5FrozenTrailDivides( + canvas: Canvas, + gridWidth: number, + gridHeight: number, + inX: number, + outY: number, + placedRects: readonly PlacedPresetRect[], + rng: Rng, +): void { + stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, { + wallStyle: 2, + wallProp1: 129, + roadStyles: [1], + }) +} + +/** + * Dispatch outdoor road networks based on act, levelId, and levelTypeName. + */ +export function stampOutdoorRoads( + canvas: Canvas, + act: number, + levelId: number, + gridWidth: number, + gridHeight: number, + inX: number, + outY: number, + placedRects: readonly PlacedPresetRect[], + rng: Rng, + levelTypeName?: string, + openGates?: ReadonlyMap, +): void { + if (levelId === 39) return // Cow Level has no roads + + // Level ID takes highest precedence + if (levelId >= 41 && levelId <= 46) { + stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates) + } else if (levelId >= 79 && levelId <= 82) { + stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates) + } else if (levelId === 111 || levelId === 112 || levelId === 117) { + stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates) + } else if (levelId >= 2 && levelId <= 7) { + stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates) + // Level type name takes second precedence + } else if (levelTypeName === 'Act 2 - Desert') { + stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates) + } else if (levelTypeName === 'Act 3 - Kurast') { + stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates) + } else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') { + stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates) + } else if (levelTypeName === 'Act 1 - Wilderness') { + stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates) + // Act number takes fallback precedence + } else if (act === 2) { + stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates) + } else if (act === 3) { + stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates) + } else if (act === 5) { + stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates) + } else if (act === 1) { + stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates) + } +} + +/** + * Dispatch outdoor boundary divider networks based on act, levelId, and levelTypeName. + */ +export function stampOutdoorDividers( + canvas: Canvas, + act: number, + levelId: number, + gridWidth: number, + gridHeight: number, + inX: number, + outY: number, + placedRects: readonly PlacedPresetRect[], + rng: Rng, + levelTypeName?: string, +): void { + if (levelId === 39) return // Cow Level has no dividers + + // Level ID takes highest precedence + if (levelId >= 41 && levelId <= 46) { + stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng) + } else if (levelId >= 79 && levelId <= 82) { + stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng) + } else if (levelId === 111 || levelId === 112 || levelId === 117) { + stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng) + } else if (levelId >= 2 && levelId <= 7) { + stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng) + // Level type name takes second precedence + } else if (levelTypeName === 'Act 2 - Desert') { + stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng) + } else if (levelTypeName === 'Act 3 - Kurast') { + stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng) + } else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') { + stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng) + } else if (levelTypeName === 'Act 1 - Wilderness') { + stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng) + // Act number takes fallback precedence + } else if (act === 2) { + stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng) + } else if (act === 3) { + stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng) + } else if (act === 5) { + stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng) + } else if (act === 1) { + stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng) + } +} + /* ------------------------------------------------------------------------- * * Outdoor Population Phase: Waypoints, Roadside Shrines/Chests, SuperUniques & Flavie * (`DRLGOUTDOORS_SpawnAct12Waypoint`, `DRLGOUTDOORS_SpawnAct12Shrines`) @@ -3439,7 +3811,17 @@ export function generateWilderness(request: WildernessRequest): WildernessResult let floorLayers = 1 let substitutionType = 0 let version = 0 - let act = 1 + let inferredAct = 1 + if (request.levelId >= 40 && request.levelId < 75) inferredAct = 2 + else if (request.levelId >= 75 && request.levelId < 103) inferredAct = 3 + else if (request.levelId >= 103 && request.levelId < 109) inferredAct = 4 + else if (request.levelId >= 109) inferredAct = 5 + else if (/Act 2/i.test(request.levelTypeName)) inferredAct = 2 + else if (/Act 3/i.test(request.levelTypeName)) inferredAct = 3 + else if (/Act 4/i.test(request.levelTypeName)) inferredAct = 4 + else if (/Act 5/i.test(request.levelTypeName)) inferredAct = 5 + + let act = inferredAct const allLevels: Ds1[] = [] for (const piece of request.pieces) for (const level of piece.levels) allLevels.push(level) for (const row of [...request.substitutions, ...(request.shrineSubstitutions ?? [])]) { @@ -3450,7 +3832,10 @@ export function generateWilderness(request: WildernessRequest): WildernessResult floorLayers = Math.max(floorLayers, level.floorLayers) substitutionType = Math.max(substitutionType, level.substitutionType) version = Math.max(version, level.version) - act = level.act + if (level.act > 0) act = level.act + } + if (act === 1 && inferredAct > 1) { + act = inferredAct } const substitutionLayers = substitutionType === 1 || substitutionType === 2 ? 1 : 0 @@ -3660,7 +4045,24 @@ export function generateWilderness(request: WildernessRequest): WildernessResult const inX = Math.floor(gridWidth / 2) const outY = Math.floor(gridHeight / 2) const reservedRoadBlocks = new Set() - if (request.levelId >= 2 && request.levelId <= 7) { + if ( + request.levelId !== 39 && + ((request.levelId >= 2 && request.levelId <= 7) || + (request.levelId >= 41 && request.levelId <= 46) || + (request.levelId >= 79 && request.levelId <= 82) || + request.levelId === 111 || + request.levelId === 112 || + request.levelId === 117 || + request.levelTypeName === 'Act 1 - Wilderness' || + request.levelTypeName === 'Act 2 - Desert' || + request.levelTypeName === 'Act 3 - Kurast' || + request.levelTypeName === 'Act 5 - Barricade' || + request.levelTypeName === 'Act 5 - Siege' || + act === 1 || + act === 2 || + act === 3 || + act === 5) + ) { for (let by = 1; by < gridHeight - 1; by += 1) reservedRoadBlocks.add(`${String(inX)},${String(by)}`) for (let bx = 1; bx < gridWidth - 1; bx += 1) reservedRoadBlocks.add(`${String(bx)},${String(outY)}`) } @@ -3703,12 +4105,33 @@ export function generateWilderness(request: WildernessRequest): WildernessResult dirtPathGrid, ) - // For Act 1 outdoor wilderness maps (LevelId 2..7), lay dirt road networks and - // interior stone wall divider lines across the meadow after random clutter substitutions. - if (request.levelId >= 2 && request.levelId <= 7) { - stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, allPlacedRects, rng) - stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, allPlacedRects, rng) - } + // Stamp outdoor road networks and boundary divider lines across the wilderness + // after random clutter substitutions. + stampOutdoorRoads( + canvas, + act, + request.levelId, + gridWidth, + gridHeight, + inX, + outY, + allPlacedRects, + rng, + request.levelTypeName, + act1Plan?.openGates, + ) + stampOutdoorDividers( + canvas, + act, + request.levelId, + gridWidth, + gridHeight, + inX, + outY, + allPlacedRects, + rng, + request.levelTypeName, + ) // Step 5: Outdoor Population Phase (Waypoints, Roadside Shrines/Chests, Landmark SuperUniques & Flavie) const populationRng = new Rng(request.seed ^ 0x6c07) diff --git a/tests/wilderness-roads-multi-act.test.ts b/tests/wilderness-roads-multi-act.test.ts new file mode 100644 index 0000000..9542c4d --- /dev/null +++ b/tests/wilderness-roads-multi-act.test.ts @@ -0,0 +1,416 @@ +import { describe, it, expect } from 'vitest' +import { + createCanvas, + stampAct1DirtRoads, + stampAct1InteriorStoneWalls, + stampAct2DesertRoads, + stampAct2SandstoneBerms, + stampAct3KurastAvenues, + stampAct3KurastBalustrades, + stampAct5SnowRoads, + stampAct5FrozenTrailDivides, + stampOutdoorRoads, + stampOutdoorDividers, + generateWilderness, + TILES_PER_BLOCK, +} from '../src/game/wilderness.ts' +import type { WildernessPiece, PlacedPresetRect, Canvas } from '../src/game/wilderness.ts' +import { Rng } from '../src/game/rng.ts' +import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts' + +function makeMockDs1(width: number, height: number): 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: [{ 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 countFloorTiles(canvas: Canvas, style: number, prop1: number): number { + let count = 0 + for (let y = 0; y < canvas.height; y += 1) { + const row = canvas.cells[y] + if (!row) continue + for (let x = 0; x < canvas.width; x += 1) { + const cell = row[x] + if (cell && cell.floors.some(f => f.style === style && f.prop1 === prop1)) { + count += 1 + } + } + } + return count +} + +function countWallTiles(canvas: Canvas, style: number, prop1: number): number { + let count = 0 + for (let y = 0; y < canvas.height; y += 1) { + const row = canvas.cells[y] + if (!row) continue + for (let x = 0; x < canvas.width; x += 1) { + const cell = row[x] + if (cell && cell.walls.some(w => w.style === style && w.prop1 === prop1)) { + count += 1 + } + } + } + return count +} + +describe('Multi-Act Outdoor Roads & Boundary Divider Networks', () => { + const gridW = 10 + const gridH = 10 + const width = gridW * TILES_PER_BLOCK + const height = gridH * TILES_PER_BLOCK + const inX = Math.floor(gridW / 2) + const outY = Math.floor(gridH / 2) + + describe('Act 2 Desert Roads & Sandstone Berms', () => { + it('stampAct2DesertRoads paints caravan routes with style: 1, prop1: 194 and connects spurs', () => { + const canvas = createCanvas(width, height, 1, 1, 0) + const rng = new Rng(42) + const placedRects: PlacedPresetRect[] = [ + { name: 'Act 2 - Desert Tomb 1', x: 24, y: 24, w: 16, h: 16 }, + ] + + stampAct2DesertRoads(canvas, gridW, gridH, inX, outY, placedRects, rng) + + const roadFloors = countFloorTiles(canvas, 1, 194) + expect(roadFloors).toBeGreaterThan(50) + + // Verify the main highway corridor has caravan road floors + const highwayTile = canvas.cells[outY * TILES_PER_BLOCK + 4]?.[inX * TILES_PER_BLOCK + 4] + expect(highwayTile).toBeDefined() + expect(highwayTile?.floors.some(f => f.style === 1 && f.prop1 === 194)).toBe(true) + + // Verify spur path connected towards the Tomb preset + let spurFound = false + for (let y = 30; y <= 34; y += 1) { + for (let x = 38; x <= 44; x += 1) { + const c = canvas.cells[y]?.[x] + if (c?.floors.some(f => f.style === 1 && f.prop1 === 194)) { + spurFound = true + break + } + } + } + expect(spurFound).toBe(true) + }) + + it('stampAct2SandstoneBerms paints sandstone berms with style: 1, prop1: 129, corner joins, and road archway breaks', () => { + const canvas = createCanvas(width, height, 1, 1, 0) + const rng = new Rng(42) + const placedRects: PlacedPresetRect[] = [] + + // First stamp roads so the divider can create 3-tile archway breaks across them + stampAct2DesertRoads(canvas, gridW, gridH, inX, outY, placedRects, rng) + stampAct2SandstoneBerms(canvas, gridW, gridH, inX, outY, placedRects, rng) + + const bermWalls = countWallTiles(canvas, 1, 129) + expect(bermWalls).toBeGreaterThan(20) + + // Berms should not block the road where the road crosses the divider + // Highway crosses dividing lines at (inX * 8 + 4, outY * 8 + 4) + const crossRoad = canvas.cells[outY * TILES_PER_BLOCK + 4]?.[inX * TILES_PER_BLOCK + 4] + expect(crossRoad?.walls.some(w => w.style === 1 && w.prop1 === 129)).toBe(false) + }) + }) + + describe('Act 3 Kurast Avenues & Balustrades', () => { + it('stampAct3KurastAvenues paints flagstone avenues with style: 1, prop1: 194', () => { + const canvas = createCanvas(width, height, 1, 1, 0) + const rng = new Rng(100) + const placedRects: PlacedPresetRect[] = [ + { name: 'Act 3 - Burbs Temple', x: 20, y: 20, w: 16, h: 16 }, + ] + + stampAct3KurastAvenues(canvas, gridW, gridH, inX, outY, placedRects, rng) + + const avenueFloors = countFloorTiles(canvas, 1, 194) + expect(avenueFloors).toBeGreaterThan(50) + }) + + it('stampAct3KurastBalustrades paints balustrades with style: 1, prop1: 129', () => { + const canvas = createCanvas(width, height, 1, 1, 0) + const rng = new Rng(100) + const placedRects: PlacedPresetRect[] = [] + + stampAct3KurastAvenues(canvas, gridW, gridH, inX, outY, placedRects, rng) + stampAct3KurastBalustrades(canvas, gridW, gridH, inX, outY, placedRects, rng) + + const balustradeWalls = countWallTiles(canvas, 1, 129) + expect(balustradeWalls).toBeGreaterThan(20) + }) + }) + + describe('Act 5 Snow Roads & Frozen Trail Divides', () => { + it('stampAct5SnowRoads paints snow march roads with style: 1, prop1: 194', () => { + const canvas = createCanvas(width, height, 1, 1, 0) + const rng = new Rng(555) + const placedRects: PlacedPresetRect[] = [ + { name: 'Act 5 - Barricade Building', x: 20, y: 20, w: 16, h: 16 }, + ] + + stampAct5SnowRoads(canvas, gridW, gridH, inX, outY, placedRects, rng) + + const snowRoadFloors = countFloorTiles(canvas, 1, 194) + expect(snowRoadFloors).toBeGreaterThan(50) + }) + + it('stampAct5FrozenTrailDivides paints frozen trail divides with style: 2, prop1: 129', () => { + const canvas = createCanvas(width, height, 1, 1, 0) + const rng = new Rng(555) + const placedRects: PlacedPresetRect[] = [] + + stampAct5SnowRoads(canvas, gridW, gridH, inX, outY, placedRects, rng) + stampAct5FrozenTrailDivides(canvas, gridW, gridH, inX, outY, placedRects, rng) + + const frozenDivides = countWallTiles(canvas, 2, 129) + expect(frozenDivides).toBeGreaterThan(20) + }) + }) + + describe('Unified Dispatchers: stampOutdoorRoads & stampOutdoorDividers', () => { + it('dispatches Act 2 Desert based on levelId 41', () => { + const canvas = createCanvas(width, height, 1, 1, 0) + const rng = new Rng(123) + stampOutdoorRoads(canvas, 1, 41, gridW, gridH, inX, outY, [], rng) + stampOutdoorDividers(canvas, 1, 41, gridW, gridH, inX, outY, [], rng) + + expect(countFloorTiles(canvas, 1, 194)).toBeGreaterThan(40) + expect(countWallTiles(canvas, 1, 129)).toBeGreaterThan(15) + }) + + it('dispatches Act 3 Kurast based on levelId 80', () => { + const canvas = createCanvas(width, height, 1, 1, 0) + const rng = new Rng(123) + stampOutdoorRoads(canvas, 1, 80, gridW, gridH, inX, outY, [], rng) + stampOutdoorDividers(canvas, 1, 80, gridW, gridH, inX, outY, [], rng) + + expect(countFloorTiles(canvas, 1, 194)).toBeGreaterThan(40) + expect(countWallTiles(canvas, 1, 129)).toBeGreaterThan(15) + }) + + it('dispatches Act 5 Snow based on levelId 111', () => { + const canvas = createCanvas(width, height, 1, 1, 0) + const rng = new Rng(123) + stampOutdoorRoads(canvas, 1, 111, gridW, gridH, inX, outY, [], rng) + stampOutdoorDividers(canvas, 1, 111, gridW, gridH, inX, outY, [], rng) + + expect(countFloorTiles(canvas, 1, 194)).toBeGreaterThan(40) + expect(countWallTiles(canvas, 2, 129)).toBeGreaterThan(15) + }) + + it('dispatches Act 1 Dirt Roads based on levelId 3', () => { + const canvas = createCanvas(width, height, 1, 1, 0) + const rng = new Rng(123) + stampOutdoorRoads(canvas, 1, 3, gridW, gridH, inX, outY, [], rng) + stampOutdoorDividers(canvas, 1, 3, gridW, gridH, inX, outY, [], rng) + + expect(countFloorTiles(canvas, 5, 194)).toBeGreaterThan(40) + expect(countWallTiles(canvas, 2, 129)).toBeGreaterThan(15) + }) + + it('dispatches based on levelTypeName when levelId is generic', () => { + const c2 = createCanvas(width, height, 1, 1, 0) + stampOutdoorRoads(c2, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 2 - Desert') + stampOutdoorDividers(c2, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 2 - Desert') + expect(countFloorTiles(c2, 1, 194)).toBeGreaterThan(40) + expect(countWallTiles(c2, 1, 129)).toBeGreaterThan(15) + + const c3 = createCanvas(width, height, 1, 1, 0) + stampOutdoorRoads(c3, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 3 - Kurast') + stampOutdoorDividers(c3, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 3 - Kurast') + expect(countFloorTiles(c3, 1, 194)).toBeGreaterThan(40) + expect(countWallTiles(c3, 1, 129)).toBeGreaterThan(15) + + const c5 = createCanvas(width, height, 1, 1, 0) + stampOutdoorRoads(c5, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 5 - Barricade') + stampOutdoorDividers(c5, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 5 - Barricade') + expect(countFloorTiles(c5, 1, 194)).toBeGreaterThan(40) + expect(countWallTiles(c5, 2, 129)).toBeGreaterThan(15) + }) + + it('dispatches based on act number fallback', () => { + const c2 = createCanvas(width, height, 1, 1, 0) + stampOutdoorRoads(c2, 2, 999, gridW, gridH, inX, outY, [], new Rng(1)) + stampOutdoorDividers(c2, 2, 999, gridW, gridH, inX, outY, [], new Rng(1)) + expect(countFloorTiles(c2, 1, 194)).toBeGreaterThan(40) + expect(countWallTiles(c2, 1, 129)).toBeGreaterThan(15) + }) + + it('strictly skips Cow Level (levelId 39)', () => { + const canvas = createCanvas(width, height, 1, 1, 0) + const rng = new Rng(42) + stampOutdoorRoads(canvas, 1, 39, gridW, gridH, inX, outY, [], rng, 'Act 1 - Wilderness') + stampOutdoorDividers(canvas, 1, 39, gridW, gridH, inX, outY, [], rng, 'Act 1 - Wilderness') + + expect(countFloorTiles(canvas, 1, 194)).toBe(0) + expect(countFloorTiles(canvas, 5, 194)).toBe(0) + expect(countWallTiles(canvas, 1, 129)).toBe(0) + expect(countWallTiles(canvas, 2, 129)).toBe(0) + }) + }) + + describe('generateWilderness Integration for Acts 2, 3, 5', () => { + const borderDs1 = makeMockDs1(8, 8) + const borderPiece: WildernessPiece = { + name: 'Act 2 - Desert Border 1', + border: true, + levels: [borderDs1], + } + + it('generates Act 2 Rocky Waste (Level 41) with caravan routes and sandstone berms', () => { + const result = generateWilderness({ + levelId: 41, + levelName: 'Rocky Waste', + levelTypeName: 'Act 2 - Desert', + sizeX: 80, + sizeY: 80, + subType: 0, + subTheme: 0, + seed: 77777, + pieces: [borderPiece], + substitutions: [], + }) + + expect(result.level.act).toBe(2) + let caravanRoads = 0 + let sandstoneBerms = 0 + for (let y = 8; y < 72; y += 1) { + for (let x = 8; x < 72; x += 1) { + const cell = result.level.cells[y]?.[x] + if (!cell) continue + if (cell.floors.some(f => f.style === 1 && f.prop1 === 194)) caravanRoads += 1 + if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) sandstoneBerms += 1 + } + } + + expect(caravanRoads).toBeGreaterThan(30) + expect(sandstoneBerms).toBeGreaterThan(15) + }) + + it('generates Act 3 Kurast Bazaar (Level 80) with Kurast avenues and balustrades', () => { + const act3Border: WildernessPiece = { + name: 'Act 3 - Kurast Border', + border: true, + levels: [borderDs1], + } + const result = generateWilderness({ + levelId: 80, + levelName: 'Kurast Bazaar', + levelTypeName: 'Act 3 - Kurast', + sizeX: 80, + sizeY: 80, + subType: 0, + subTheme: 0, + seed: 88888, + pieces: [act3Border], + substitutions: [], + }) + + expect(result.level.act).toBe(3) + let avenues = 0 + let balustrades = 0 + for (let y = 8; y < 72; y += 1) { + for (let x = 8; x < 72; x += 1) { + const cell = result.level.cells[y]?.[x] + if (!cell) continue + if (cell.floors.some(f => f.style === 1 && f.prop1 === 194)) avenues += 1 + if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) balustrades += 1 + } + } + + expect(avenues).toBeGreaterThan(30) + expect(balustrades).toBeGreaterThan(15) + }) + + it('generates Act 5 Frigid Highlands (Level 111) with snow roads and frozen trail divides', () => { + const act5Border: WildernessPiece = { + name: 'Act 5 - Barricade Border', + border: true, + levels: [borderDs1], + } + const result = generateWilderness({ + levelId: 111, + levelName: 'Frigid Highlands', + levelTypeName: 'Act 5 - Barricade', + sizeX: 80, + sizeY: 80, + subType: 0, + subTheme: 0, + seed: 99999, + pieces: [act5Border], + substitutions: [], + }) + + expect(result.level.act).toBe(5) + let snowRoads = 0 + let frozenDivides = 0 + for (let y = 8; y < 72; y += 1) { + for (let x = 8; x < 72; x += 1) { + const cell = result.level.cells[y]?.[x] + if (!cell) continue + if (cell.floors.some(f => f.style === 1 && f.prop1 === 194)) snowRoads += 1 + if (cell.walls.some(w => w.style === 2 && w.prop1 === 129)) frozenDivides += 1 + } + } + + expect(snowRoads).toBeGreaterThan(30) + expect(frozenDivides).toBeGreaterThan(15) + }) + + it('generates Cow Level (Level 39) without roads or stone divider networks', () => { + const cowBorder: WildernessPiece = { + name: 'Act 1 - Wild Border 1', + border: true, + levels: [borderDs1], + } + const result = generateWilderness({ + levelId: 39, + levelName: 'Moo Moo Farm', + levelTypeName: 'Act 1 - Wilderness', + sizeX: 80, + sizeY: 80, + subType: 0, + subTheme: 0, + seed: 12345, + pieces: [cowBorder], + substitutions: [], + }) + + let roadFloors = 0 + let dividerWalls = 0 + for (let y = 8; y < 72; y += 1) { + for (let x = 8; x < 72; x += 1) { + const cell = result.level.cells[y]?.[x] + if (!cell) continue + if (cell.floors.some(f => (f.style === 1 || f.style === 5) && f.prop1 === 194)) roadFloors += 1 + if (cell.walls.some(w => (w.style === 1 || w.style === 2) && w.prop1 === 129)) dividerWalls += 1 + } + } + + expect(roadFloors).toBe(0) + expect(dividerWalls).toBe(0) + }) + }) +})