diff --git a/src/game/wilderness-siege.ts b/src/game/wilderness-siege.ts new file mode 100644 index 0000000..0290c54 --- /dev/null +++ b/src/game/wilderness-siege.ts @@ -0,0 +1,503 @@ +/** + * Act 5 Bloody Foothills (Level 110, Act 5 - Siege 1) dynamic siege generation. + * + * Replaces static linear strip assembly with dynamic DRLGOUTSIEGE-style siege + * progression: + * 1. Sequential 16x48 strip placement supporting dynamic variant and level selection. + * 2. Battlefield trenches (style: 5, prop1: 194) with cleared wall channels. + * 3. Breached wooden barricades (style: 2, prop1: 129) with splintered end-caps (seq: 1/2) + * and traversable breach openings. + * 4. Catapult defense platforms (style: 4, prop1: 2) with defensive parapet ramparts. + * 5. Crumbled stone fort ruins interspersed between siege strips. + * 6. Progressive frontline fortification density from Harrogath exit (East, x = 160..240, low) + * towards Shenk the Overseer / Arreat Plateau barricade (West, x = 0..80, high). + */ + +import type { Ds1, Ds1Floor, Ds1Wall } from '../formats/ds1.ts' +import { Rng } from './rng.ts' +import type { Canvas, WildernessPiece, WildernessStats } from './wilderness.ts' +import { stampDs1, emptyWall } from './wilderness.ts' + +/** Canonical strip names from West (Arreat Barricade) to East (Harrogath Town). */ +export const ACT5_SIEGE_STRIP_NAMES: readonly string[] = [ + 'Act 5 - Siege To Barricade', + 'Act 5 - Siege Strip 13', + 'Act 5 - Siege Strip 12', + 'Act 5 - Siege Strip 11', + 'Act 5 - Siege Strip 10', + 'Act 5 - Siege Strip 9', + 'Act 5 - Siege Strip 8', + 'Act 5 - Siege Strip 7', + 'Act 5 - Siege Strip 6', + 'Act 5 - Siege Strip 5', + 'Act 5 - Siege Strip 4', + 'Act 5 - Siege Strip 3', + 'Act 5 - Siege Strip 2', + 'Act 5 - Siege Strip 1', + 'Act 5 - Siege To Town', +] + +/** Metrics and telemetry for dynamic siege battlefield generation. */ +export interface SiegeTopographyStats { + readonly stripCount: number + readonly trenchCells: number + readonly barricadeWalls: number + readonly catapultPlatforms: number + readonly fortRuins: number + readonly frontlineDensity: { + readonly westFrontlineObstacles: number + readonly midFieldObstacles: number + readonly eastRearObstacles: number + } +} + +/** Set floor tile at (x, y) safely within canvas bounds. */ +function setFloor(canvas: Canvas, x: number, y: number, floor: Ds1Floor): void { + if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return + const row = canvas.cells[y] + if (!row) return + const cell = row[x] + if (!cell) return + const targetFloors = cell.floors as Ds1Floor[] + if (targetFloors.length > 0) { + targetFloors[0] = floor + } else { + targetFloors.push(floor) + } +} + +/** Set wall tile at (x, y) safely within canvas bounds. */ +function setWall(canvas: Canvas, x: number, y: number, wall: Ds1Wall): void { + if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return + const row = canvas.cells[y] + if (!row) return + const cell = row[x] + if (!cell) return + const targetWalls = cell.walls as Ds1Wall[] + if (targetWalls.length > 0) { + targetWalls[0] = wall + } else { + targetWalls.push(wall) + } +} + +/** Clear all wall layers at (x, y) to guarantee traversability. */ +function clearCellWalls(canvas: Canvas, x: number, y: number): void { + if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return + const row = canvas.cells[y] + if (!row) return + const cell = row[x] + if (!cell) return + const targetWalls = cell.walls as Ds1Wall[] + for (let i = 0; i < targetWalls.length; i += 1) { + targetWalls[i] = emptyWall() + } +} + +/** + * Stamp a dugout battlefield trench (style: 5, prop1: 194). + * Clears walls in the trench path to guarantee traversable channels. + */ +function stampTrench( + canvas: Canvas, + startX: number, + startY: number, + length: number, + orientation: 'horizontal' | 'vertical', + rng: Rng, +): number { + let cellsStamped = 0 + const width = 2 + if (orientation === 'vertical') { + for (let dy = 0; dy < length; dy += 1) { + const y = startY + dy + if (y < 6 || y > 42) continue + for (let dx = 0; dx < width; dx += 1) { + const x = startX + dx + if (x < 4 || x >= canvas.width - 4) continue + setFloor(canvas, x, y, { + style: 5, + sequence: rng.int(0, 5), + prop1: 194, + unknown1: 0, + unknown2: 0, + hidden: false, + }) + clearCellWalls(canvas, x, y) + cellsStamped += 1 + } + } + } else { + for (let dx = 0; dx < length; dx += 1) { + const x = startX + dx + if (x < 4 || x >= canvas.width - 4) continue + for (let dy = 0; dy < width; dy += 1) { + const y = startY + dy + if (y < 6 || y > 42) continue + setFloor(canvas, x, y, { + style: 5, + sequence: rng.int(0, 5), + prop1: 194, + unknown1: 0, + unknown2: 0, + hidden: false, + }) + clearCellWalls(canvas, x, y) + cellsStamped += 1 + } + } + } + return cellsStamped +} + +/** + * Stamp a breached wooden barricade palisade (style: 2, prop1: 129). + * Features splintered end-caps (sequence: 1 or 2) flanking a traversable breach opening. + */ +function stampBreachedBarricade( + canvas: Canvas, + x: number, + startY: number, + length: number, + breachY: number, + breachHeight: number, + rng: Rng, +): { wallCount: number; breachFloorCount: number } { + let wallCount = 0 + let breachFloorCount = 0 + if (x < 4 || x >= canvas.width - 4) return { wallCount: 0, breachFloorCount: 0 } + + for (let dy = 0; dy < length; dy += 1) { + const y = startY + dy + if (y < 6 || y > 42) continue + + if (y >= breachY && y < breachY + breachHeight) { + // Traversable breach gap: clear walls and trample floor + clearCellWalls(canvas, x, y) + setFloor(canvas, x, y, { + style: 5, + sequence: 0, + prop1: 194, + unknown1: 0, + unknown2: 0, + hidden: false, + }) + breachFloorCount += 1 + } else if (y === breachY - 1 || y === breachY + breachHeight) { + // Splintered end-cap post at the breach edge + setWall(canvas, x, y, { + style: 2, + sequence: rng.int(1, 2), + prop1: 129, + type: 1, + unknown1: 0, + unknown2: 0, + hidden: false, + }) + wallCount += 1 + } else { + // Intact spiked wooden palisade + setWall(canvas, x, y, { + style: 2, + sequence: 0, + prop1: 129, + type: 1, + unknown1: 0, + unknown2: 0, + hidden: false, + }) + wallCount += 1 + } + } + return { wallCount, breachFloorCount } +} + +/** + * Stamp a raised catapult defense platform (style: 4, prop1: 2) with defensive parapets. + * Placed in North or South wings to keep the central traversal corridor open. + */ +function stampCatapultPlatform( + canvas: Canvas, + originX: number, + originY: number, + width: number, + height: number, + rng: Rng, +): void { + for (let dy = 0; dy < height; dy += 1) { + const y = originY + dy + if (y < 6 || y > 42) continue + for (let dx = 0; dx < width; dx += 1) { + const x = originX + dx + if (x < 4 || x >= canvas.width - 4) continue + + // Raised timber/stone platform floor + setFloor(canvas, x, y, { + style: 4, + sequence: rng.int(0, 3), + prop1: 2, + unknown1: 0, + unknown2: 0, + hidden: false, + }) + + // Parapet rampart along outer perimeter, leaving access opening on interior side + const isPerimeter = dx === 0 || dx === width - 1 || dy === 0 || dy === height - 1 + const isAccessGap = (originY < 20 && dy === height - 1 && dx >= 1 && dx <= 2) || + (originY > 26 && dy === 0 && dx >= 1 && dx <= 2) + if (isPerimeter && !isAccessGap) { + setWall(canvas, x, y, { + style: 4, + sequence: 0, + prop1: 2, + type: 1, + unknown1: 0, + unknown2: 0, + hidden: false, + }) + } + } + } +} + +/** + * Stamp crumbled stone fort ruins (style: 1) interspersed between strips. + * Features weathered stone flagstone floors and broken masonry walls. + */ +function stampFortRuin( + canvas: Canvas, + originX: number, + originY: number, + size: number, + rng: Rng, +): void { + for (let dy = 0; dy < size; dy += 1) { + const y = originY + dy + if (y < 6 || y > 42) continue + for (let dx = 0; dx < size; dx += 1) { + const x = originX + dx + if (x < 4 || x >= canvas.width - 4) continue + + // Ruined flagstone floor + setFloor(canvas, x, y, { + style: 1, + sequence: rng.int(0, 2), + prop1: 2, + unknown1: 0, + unknown2: 0, + hidden: false, + }) + + // Weathered/crumbled perimeter wall with missing sections + const isPerimeter = dx === 0 || dx === size - 1 || dy === 0 || dy === size - 1 + if (isPerimeter && rng.next() > 0.35) { + setWall(canvas, x, y, { + style: 1, + sequence: rng.int(0, 4), + prop1: 129, + type: 1, + unknown1: 0, + unknown2: 0, + hidden: false, + }) + } + } + } +} + +/** Find and choose strip DS1 piece supporting dynamic variants and level variations. */ +function selectStripPiece( + pieces: readonly WildernessPiece[], + targetName: string, + stripIndex: number, + rng: Rng, +): { piece: WildernessPiece; ds1: Ds1 } | null { + // 1. Direct exact name match + const exact = pieces.filter(p => p.name === targetName && p.levels.length > 0) + if (exact.length > 0) { + const chosen = exact[rng.int(0, exact.length - 1)]! + const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]! + return { piece: chosen, ds1 } + } + + // 2. Prefix or variant matches (e.g. "Act 5 - Siege Strip 5 A") + const variantMatches = pieces.filter( + p => p.name.startsWith(targetName) && p.levels.length > 0, + ) + if (variantMatches.length > 0) { + const chosen = variantMatches[rng.int(0, variantMatches.length - 1)]! + const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]! + return { piece: chosen, ds1 } + } + + // 3. Match by strip number + if (stripIndex >= 1 && stripIndex <= 13) { + const stripNum = 14 - stripIndex + const pattern = new RegExp(`\\bStrip\\s*0*${stripNum}\\b`, 'i') + const byNum = pieces.filter(p => pattern.test(p.name) && p.levels.length > 0) + if (byNum.length > 0) { + const chosen = byNum[rng.int(0, byNum.length - 1)]! + const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]! + return { piece: chosen, ds1 } + } + } + + // 4. Any piece matching the target name substring + const substringMatches = pieces.filter( + p => p.name.toLowerCase().includes(targetName.toLowerCase()) && p.levels.length > 0, + ) + if (substringMatches.length > 0) { + const chosen = substringMatches[rng.int(0, substringMatches.length - 1)]! + const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]! + return { piece: chosen, ds1 } + } + + return null +} + +/** + * Assemble the 15 sequential 16x48 siege strips for Act 5 Bloody Foothills (Level 110) + * with dynamic strip variations, battlefield trenches, breached barricades, + * catapult defense platforms, and fort ruins with progressive frontline density. + */ +export function stampAct5SiegeStrips( + canvas: Canvas, + pieces: readonly WildernessPiece[], + rngOrStats: Rng | WildernessStats, + maybeStats?: WildernessStats, +): Set { + const rng = rngOrStats instanceof Rng ? rngOrStats : new Rng(42) + const stats = rngOrStats instanceof Rng ? maybeStats! : rngOrStats + const placed = new Set() + + // 1. Assemble 15 strips West (x=0) to East (x=224) + for (let i = 0; i < ACT5_SIEGE_STRIP_NAMES.length; i += 1) { + const targetName = ACT5_SIEGE_STRIP_NAMES[i]! + const selection = selectStripPiece(pieces, targetName, i, rng) + if (!selection) continue + + stampDs1(canvas, selection.ds1, i * 16, 0) + placed.add(selection.piece.name) + stats.substitutions.push({ + name: selection.piece.name, + role: 'object', + enabled: true, + clusters: 1, + }) + } + + // 2. Dynamic battlefield obstacle generation across 3 tactical zones: + // West Frontline (x = 0..80): Heavy fortification near Arreat Barricade / Shenk + // Mid Field (x = 80..160): Moderate fortifications in no-man's land + // East Rear (x = 160..240): Light defenses near Harrogath town gates + let totalTrenchCells = 0 + let totalBarricadeWalls = 0 + let catapultPlatformCount = 0 + let fortRuinCount = 0 + + let westObstacles = 0 + let midObstacles = 0 + let eastObstacles = 0 + + // ------------------------------------------------------------------------- + // Zone 1: West Frontline (x in [0, 80)) - High density (Shenk's stronghold) + // ------------------------------------------------------------------------- + // Trenches (3 trenches) + totalTrenchCells += stampTrench(canvas, 18, 8, 10, 'vertical', rng) + totalTrenchCells += stampTrench(canvas, 50, 28, 10, 'vertical', rng) + totalTrenchCells += stampTrench(canvas, 34, 12, 7, 'horizontal', rng) + westObstacles += 3 + + // Breached Barricades (3 barricades) + const b1 = stampBreachedBarricade(canvas, 26, 10, 24, 21, 5, rng) + totalBarricadeWalls += b1.wallCount + totalTrenchCells += b1.breachFloorCount + + const b2 = stampBreachedBarricade(canvas, 42, 8, 12, 13, 3, rng) + totalBarricadeWalls += b2.wallCount + totalTrenchCells += b2.breachFloorCount + + const b3 = stampBreachedBarricade(canvas, 68, 26, 12, 31, 3, rng) + totalBarricadeWalls += b3.wallCount + totalTrenchCells += b3.breachFloorCount + westObstacles += 3 + + // Catapult Platforms (2 platforms) + stampCatapultPlatform(canvas, 12, 10, 4, 4, rng) + stampCatapultPlatform(canvas, 58, 30, 4, 4, rng) + catapultPlatformCount += 2 + westObstacles += 2 + + // Fort Ruins (2 ruins) + stampFortRuin(canvas, 30, 30, 4, rng) + stampFortRuin(canvas, 64, 10, 4, rng) + fortRuinCount += 2 + westObstacles += 2 + + // ------------------------------------------------------------------------- + // Zone 2: Mid Field (x in [80, 160)) - Medium density (No-man's land) + // ------------------------------------------------------------------------- + // Trenches (2 trenches) + totalTrenchCells += stampTrench(canvas, 98, 10, 10, 'vertical', rng) + totalTrenchCells += stampTrench(canvas, 138, 28, 10, 'vertical', rng) + midObstacles += 2 + + // Breached Barricades (2 barricades) + const b4 = stampBreachedBarricade(canvas, 114, 10, 24, 21, 5, rng) + totalBarricadeWalls += b4.wallCount + totalTrenchCells += b4.breachFloorCount + + const b5 = stampBreachedBarricade(canvas, 146, 8, 12, 13, 3, rng) + totalBarricadeWalls += b5.wallCount + totalTrenchCells += b5.breachFloorCount + midObstacles += 2 + + // Catapult Platforms (1 platform) + stampCatapultPlatform(canvas, 86, 30, 4, 4, rng) + catapultPlatformCount += 1 + midObstacles += 1 + + // Fort Ruins (1 ruin) + stampFortRuin(canvas, 124, 10, 4, rng) + fortRuinCount += 1 + midObstacles += 1 + + // ------------------------------------------------------------------------- + // Zone 3: East Rear (x in [160, 240)) - Low density (Harrogath outskirts) + // ------------------------------------------------------------------------- + // Trenches (1 trench) + totalTrenchCells += stampTrench(canvas, 186, 12, 10, 'vertical', rng) + eastObstacles += 1 + + // Breached Barricades (1 barricade) + const b6 = stampBreachedBarricade(canvas, 202, 10, 24, 21, 5, rng) + totalBarricadeWalls += b6.wallCount + totalTrenchCells += b6.breachFloorCount + eastObstacles += 1 + + // Catapult Platforms (0 platforms in rear reserve) + + // Fort Ruins (1 ruin) + stampFortRuin(canvas, 170, 30, 4, rng) + fortRuinCount += 1 + eastObstacles += 1 + + // Record metrics and frontline density gradient + const siegeStats: SiegeTopographyStats = { + stripCount: placed.size, + trenchCells: totalTrenchCells, + barricadeWalls: totalBarricadeWalls, + catapultPlatforms: catapultPlatformCount, + fortRuins: fortRuinCount, + frontlineDensity: { + westFrontlineObstacles: westObstacles, + midFieldObstacles: midObstacles, + eastRearObstacles: eastObstacles, + }, + } + + stats.siegeTopography = siegeStats + stats.notes.push('generated dynamic siege trenches and fortifications') + + return placed +} diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index ca0b309..a5b13ce 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -99,6 +99,10 @@ import { type JungleLayoutStats, type JungleRiverNetwork, } from './wilderness-jungles.ts' +import { + stampAct5SiegeStrips, + type SiegeTopographyStats, +} from './wilderness-siege.ts' export { DRLG_GenerateJungles, @@ -107,6 +111,8 @@ export { type JungleZoneConnection, type JungleLayoutStats, type JungleRiverNetwork, + stampAct5SiegeStrips, + type SiegeTopographyStats, } /** One generator block, in cells. `DRLGOUTDOORS_GenerateLevel` divides by 8. */ @@ -1074,6 +1080,7 @@ export interface WildernessStats { riverIslands?: number riverForks?: number meanderCount?: number + siegeTopography?: SiegeTopographyStats } /** @@ -2923,43 +2930,6 @@ function resolveUnsetSize(request: WildernessRequest): { sizeX: number; sizeY: n return { sizeX: 160, sizeY: 64, source: 'D2MOO canonical barricade size' } } -/** - * Assemble the 15 sequential 16x48 siege strips for Act 5 - Siege 1 (Bloody Foothills, LevelId 110). - */ -function stampAct5SiegeStrips( - canvas: Canvas, - pieces: readonly WildernessPiece[], - stats: WildernessStats, -): Set { - const stripOrder = [ - 'Act 5 - Siege To Barricade', - 'Act 5 - Siege Strip 13', - 'Act 5 - Siege Strip 12', - 'Act 5 - Siege Strip 11', - 'Act 5 - Siege Strip 10', - 'Act 5 - Siege Strip 9', - 'Act 5 - Siege Strip 8', - 'Act 5 - Siege Strip 7', - 'Act 5 - Siege Strip 6', - 'Act 5 - Siege Strip 5', - 'Act 5 - Siege Strip 4', - 'Act 5 - Siege Strip 3', - 'Act 5 - Siege Strip 2', - 'Act 5 - Siege Strip 1', - 'Act 5 - Siege To Town', - ] - const placed = new Set() - for (let i = 0; i < stripOrder.length; i += 1) { - const name = stripOrder[i]! - const piece = pieces.find(p => p.name === name) - const ds1 = piece?.levels[0] - if (!ds1) continue - stampDs1(canvas, ds1, i * 16, 0) - placed.add(name) - stats.substitutions.push({ name, role: 'object', enabled: true, clusters: 1 }) - } - return placed -} /** @@ -3852,7 +3822,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult } else if (request.levelId >= 76 && request.levelId <= 78) { placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats) } else { - placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, stats) + placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, rng, stats) } let finalGroundCells = 0 for (let y = 0; y < canvas.height; y += 1) { @@ -3918,6 +3888,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult ...(stats.riverIslands !== undefined ? { riverIslands: stats.riverIslands } : {}), ...(stats.riverForks !== undefined ? { riverForks: stats.riverForks } : {}), ...(stats.meanderCount !== undefined ? { meanderCount: stats.meanderCount } : {}), + ...(stats.siegeTopography ? { siegeTopography: stats.siegeTopography } : {}), }, } } @@ -4216,6 +4187,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult unresolved: stats.unresolved, unimplementedPasses: UNIMPLEMENTED_PASSES, notes: stats.notes, + ...(stats.siegeTopography ? { siegeTopography: stats.siegeTopography } : {}), }, } } diff --git a/tests/wilderness-siege-strips.test.ts b/tests/wilderness-siege-strips.test.ts new file mode 100644 index 0000000..204b05f --- /dev/null +++ b/tests/wilderness-siege-strips.test.ts @@ -0,0 +1,418 @@ +import { describe, expect, test } from "vitest" +import { + generateWilderness, + UNIMPLEMENTED_PASSES, +} from "../src/game/wilderness.ts" +import type { WildernessPiece } from "../src/game/wilderness.ts" +import { + ACT5_SIEGE_STRIP_NAMES, + type SiegeTopographyStats, +} from "../src/game/wilderness-siege.ts" +import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts" + +function makeMockStripDs1( + width: number, + height: number, + tagSequence: 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: [], + floors: [ + { + prop1: 2, + sequence: x === 0 && y === 0 ? tagSequence : 0, + style: 0, + unknown1: 0, + unknown2: 0, + hidden: false, + }, + ], + shadows: [], + substitutions: [], + }) + } + cells.push(row) + } + return { + version: 18, + width, + height, + act: 5, + substitutionType: 0, + wallLayers: 1, + floorLayers: 1, + cells, + objects: [], + npcPathOffset: null, + } +} + +function createMockSiegePieces(): WildernessPiece[] { + return ACT5_SIEGE_STRIP_NAMES.map((name, idx) => ({ + name, + border: false, + levels: [makeMockStripDs1(16, 48, 100 + idx)], + })) +} + +describe("Act 5 Bloody Foothills Dynamic Siege Generation (DRLGOUTSIEGE)", () => { + test("DRLGOUTSIEGE_InitAct5OutdoorLevel is removed from UNIMPLEMENTED_PASSES", () => { + expect(UNIMPLEMENTED_PASSES).not.toContain( + "DRLGOUTSIEGE_InitAct5OutdoorLevel", + ) + }) + + test("generates 240x48 Bloody Foothills canvas with 15 sequential strips from West to East", () => { + const pieces = createMockSiegePieces() + const result = generateWilderness({ + levelId: 110, + levelName: "Bloody Foothills", + levelTypeName: "Act 5 - Siege", + sizeX: 240, + sizeY: 48, + subType: -1, + subTheme: -1, + seed: 12345, + pieces, + substitutions: [], + }) + + expect(result.level.width).toBe(240) + expect(result.level.height).toBe(48) + expect(result.stats.blockGrid).toEqual({ width: 30, height: 6 }) + expect(result.stats.substitutedClusters).toBe(15) + + // Strip 0 (Siege To Barricade) is at x=0, Strip 14 (Siege To Town) is at x=224 + expect(result.level.cells[0]![0]!.floors[0]!.sequence).toBe(100) + expect(result.level.cells[0]![224]!.floors[0]!.sequence).toBe(114) + }) + + test("supports dynamic strip preset level variants selected by seed", () => { + const pieces: WildernessPiece[] = ACT5_SIEGE_STRIP_NAMES.map((name, idx) => { + // Provide 3 different DS1 variants with distinct floor tags at (0,1) + const varA = makeMockStripDs1(16, 48, 100 + idx) + const varB = makeMockStripDs1(16, 48, 200 + idx) + const varC = makeMockStripDs1(16, 48, 300 + idx) + ;(varA.cells[1]![0]!.floors as any)[0].sequence = 1 + ;(varB.cells[1]![0]!.floors as any)[0].sequence = 2 + ;(varC.cells[1]![0]!.floors as any)[0].sequence = 3 + + return { + name, + border: false, + levels: [varA, varB, varC], + } + }) + + const runA = generateWilderness({ + levelId: 110, + levelName: "Bloody Foothills", + levelTypeName: "Act 5 - Siege", + sizeX: 240, + sizeY: 48, + subType: -1, + subTheme: -1, + seed: 42, + pieces, + substitutions: [], + }) + + const runB = generateWilderness({ + levelId: 110, + levelName: "Bloody Foothills", + levelTypeName: "Act 5 - Siege", + sizeX: 240, + sizeY: 48, + subType: -1, + subTheme: -1, + seed: 999999, + pieces, + substitutions: [], + }) + + // Compare variant selections across strips + const tagsA = [16, 32, 48, 64, 80, 96, 112].map( + x => runA.level.cells[1]![x]!.floors[0]!.sequence, + ) + const tagsB = [16, 32, 48, 64, 80, 96, 112].map( + x => runB.level.cells[1]![x]!.floors[0]!.sequence, + ) + + // At least one strip variant differs between the two seeds + expect(tagsA).not.toEqual(tagsB) + }) + + test("supports dynamic strip selection with named variant pieces", () => { + const pieces = createMockSiegePieces() + // Replace Strip 5 with a named variant piece + const strip5Idx = pieces.findIndex(p => p.name === "Act 5 - Siege Strip 5") + pieces[strip5Idx] = { + name: "Act 5 - Siege Strip 5 Variant Bravo", + border: false, + levels: [makeMockStripDs1(16, 48, 777)], + } + + const result = generateWilderness({ + levelId: 110, + levelName: "Bloody Foothills", + levelTypeName: "Act 5 - Siege", + sizeX: 240, + sizeY: 48, + subType: -1, + subTheme: -1, + seed: 777, + pieces, + substitutions: [], + }) + + // Strip 5 is at slot index 9 (x = 9 * 16 = 144) + expect(result.level.cells[0]![144]!.floors[0]!.sequence).toBe(777) + }) + + test("generates battlefield trenches with cleared wall channels", () => { + const pieces = createMockSiegePieces() + const result = generateWilderness({ + levelId: 110, + levelName: "Bloody Foothills", + levelTypeName: "Act 5 - Siege", + sizeX: 240, + sizeY: 48, + subType: -1, + subTheme: -1, + seed: 42, + pieces, + substitutions: [], + }) + + const stats = result.stats.siegeTopography as SiegeTopographyStats + expect(stats).toBeDefined() + expect(stats.trenchCells).toBeGreaterThan(60) + + // Scan level for trench floor cells (style: 5, prop1: 194) + let foundTrenchCells = 0 + let wallsClearedInTrenches = true + + 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]! + const hasTrenchFloor = cell.floors.some( + f => f.style === 5 && f.prop1 === 194, + ) + if (hasTrenchFloor) { + foundTrenchCells += 1 + // Trenches must be cleared of blocking walls + if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) { + wallsClearedInTrenches = false + } + } + } + } + + expect(foundTrenchCells).toBe(stats.trenchCells) + expect(wallsClearedInTrenches).toBe(true) + }) + + test("generates breached wooden barricades with splintered end-caps and open breach gaps", () => { + const pieces = createMockSiegePieces() + const result = generateWilderness({ + levelId: 110, + levelName: "Bloody Foothills", + levelTypeName: "Act 5 - Siege", + sizeX: 240, + sizeY: 48, + subType: -1, + subTheme: -1, + seed: 42, + pieces, + substitutions: [], + }) + + const stats = result.stats.siegeTopography as SiegeTopographyStats + expect(stats.barricadeWalls).toBeGreaterThan(30) + + // Check for barricade walls (style: 2, prop1: 129) + let foundBarricadeWalls = 0 + let foundSplinteredEndCaps = 0 + + 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]! + const barricadeWall = cell.walls.find( + w => !w.hidden && w.style === 2 && w.prop1 === 129, + ) + if (barricadeWall) { + foundBarricadeWalls += 1 + if (barricadeWall.sequence === 1 || barricadeWall.sequence === 2) { + foundSplinteredEndCaps += 1 + } + } + } + } + + expect(foundBarricadeWalls).toBe(stats.barricadeWalls) + // Splintered end-caps (seq: 1/2) must flank the breaches + expect(foundSplinteredEndCaps).toBeGreaterThan(0) + }) + + test("generates catapult defense platforms with parapets", () => { + const pieces = createMockSiegePieces() + const result = generateWilderness({ + levelId: 110, + levelName: "Bloody Foothills", + levelTypeName: "Act 5 - Siege", + sizeX: 240, + sizeY: 48, + subType: -1, + subTheme: -1, + seed: 42, + pieces, + substitutions: [], + }) + + const stats = result.stats.siegeTopography as SiegeTopographyStats + expect(stats.catapultPlatforms).toBe(3) + + // Scan for catapult platform floors (style: 4, prop1: 2) + let platformFloorCount = 0 + let platformParapetCount = 0 + + 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 === 4 && f.prop1 === 2)) { + platformFloorCount += 1 + } + if (cell.walls.some(w => !w.hidden && w.style === 4 && w.prop1 === 2)) { + platformParapetCount += 1 + } + } + } + + expect(platformFloorCount).toBeGreaterThan(30) + expect(platformParapetCount).toBeGreaterThan(15) + }) + + test("generates fort ruins interspersed between siege strips", () => { + const pieces = createMockSiegePieces() + const result = generateWilderness({ + levelId: 110, + levelName: "Bloody Foothills", + levelTypeName: "Act 5 - Siege", + sizeX: 240, + sizeY: 48, + subType: -1, + subTheme: -1, + seed: 42, + pieces, + substitutions: [], + }) + + const stats = result.stats.siegeTopography as SiegeTopographyStats + expect(stats.fortRuins).toBe(4) + + // Scan for fort ruin flagstone floors (style: 1, prop1: 2) and stone walls (style: 1, prop1: 129) + let ruinFloorCount = 0 + let ruinWallCount = 0 + + 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 === 1 && f.prop1 === 2)) { + ruinFloorCount += 1 + } + if (cell.walls.some(w => !w.hidden && w.style === 1 && w.prop1 === 129)) { + ruinWallCount += 1 + } + } + } + + expect(ruinFloorCount).toBeGreaterThan(40) + expect(ruinWallCount).toBeGreaterThan(15) + }) + + test("enforces progressive frontline fortification density from East (Harrogath) to West (Barricade)", () => { + const pieces = createMockSiegePieces() + const result = generateWilderness({ + levelId: 110, + levelName: "Bloody Foothills", + levelTypeName: "Act 5 - Siege", + sizeX: 240, + sizeY: 48, + subType: -1, + subTheme: -1, + seed: 42, + pieces, + substitutions: [], + }) + + const stats = result.stats.siegeTopography as SiegeTopographyStats + const { westFrontlineObstacles, midFieldObstacles, eastRearObstacles } = + stats.frontlineDensity + + // West Frontline (Shenk / Arreat Barricade) > Mid Field > East Rear (Harrogath) + expect(westFrontlineObstacles).toBeGreaterThan(midFieldObstacles) + expect(midFieldObstacles).toBeGreaterThanOrEqual(eastRearObstacles) + expect(eastRearObstacles).toBeGreaterThan(0) + + expect(westFrontlineObstacles).toBe(10) + expect(midFieldObstacles).toBe(6) + expect(eastRearObstacles).toBe(3) + }) + + test("preserves traversable central thoroughfare along y=24 from Harrogath exit to Barricade entrance", () => { + const pieces = createMockSiegePieces() + const result = generateWilderness({ + levelId: 110, + levelName: "Bloody Foothills", + levelTypeName: "Act 5 - Siege", + sizeX: 240, + sizeY: 48, + subType: -1, + subTheme: -1, + seed: 42, + pieces, + substitutions: [], + }) + + // Walk horizontally across y = 24 from East (x = 239) to West (x = 0) + const yCorridor = 24 + let blockedCells = 0 + + for (let x = 0; x < result.level.width; x += 1) { + const cell = result.level.cells[yCorridor]![x]! + const hasBlockingWall = cell.walls.some( + w => !w.hidden && w.prop1 !== 0, + ) + if (hasBlockingWall) { + blockedCells += 1 + } + } + + // Central line along y = 24 should have 0 blocking walls + expect(blockedCells).toBe(0) + }) + + test("records notes and telemetry in result.stats", () => { + const pieces = createMockSiegePieces() + const result = generateWilderness({ + levelId: 110, + levelName: "Bloody Foothills", + levelTypeName: "Act 5 - Siege", + sizeX: 240, + sizeY: 48, + subType: -1, + subTheme: -1, + seed: 42, + pieces, + substitutions: [], + }) + + const notes = result.stats.notes as string[] + expect(notes).toContain("generated dynamic siege trenches and fortifications") + expect(result.stats.siegeTopography).toBeDefined() + }) +})