feat(map-gen/wilderness): dynamic siege trench and fortification generation for Bloody Foothills (fixes #54)
This commit is contained in:
parent
1fa3f8b7a2
commit
2fe5f4d892
|
|
@ -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<string> {
|
||||
const rng = rngOrStats instanceof Rng ? rngOrStats : new Rng(42)
|
||||
const stats = rngOrStats instanceof Rng ? maybeStats! : rngOrStats
|
||||
const placed = new Set<string>()
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
@ -78,6 +78,12 @@ 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 {
|
||||
stampAct5SiegeStrips,
|
||||
type SiegeTopographyStats,
|
||||
} from './wilderness-siege.ts'
|
||||
|
||||
export { stampAct5SiegeStrips, type SiegeTopographyStats }
|
||||
|
||||
/** One generator block, in cells. `DRLGOUTDOORS_GenerateLevel` divides by 8. */
|
||||
export const TILES_PER_BLOCK = 8
|
||||
|
|
@ -314,17 +320,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[][]
|
||||
|
|
@ -380,7 +386,7 @@ function createCanvas(width: number, height: number, wallLayers: number, floorLa
|
|||
* @param originY - top edge in cells.
|
||||
* @returns the number of cells actually written.
|
||||
*/
|
||||
function stampDs1(canvas: Canvas, source: Ds1, originX: number, originY: number): number {
|
||||
export function stampDs1(canvas: Canvas, source: Ds1, originX: number, originY: number): number {
|
||||
let written = 0
|
||||
for (let y = 0; y < source.height; y += 1) {
|
||||
const targetY = originY + y
|
||||
|
|
@ -586,7 +592,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 +614,7 @@ export interface LandmarkEntity {
|
|||
}
|
||||
|
||||
/** The report collector. */
|
||||
interface WildernessStats {
|
||||
export interface WildernessStats {
|
||||
readonly substitutions: SubstitutionReport[]
|
||||
readonly borderPieces: Record<string, number>
|
||||
readonly unresolved: string[]
|
||||
|
|
@ -628,6 +634,7 @@ interface WildernessStats {
|
|||
shrinesSpawned?: number
|
||||
interactivesSpawned?: number
|
||||
landmarkEntities?: LandmarkEntity[]
|
||||
siegeTopography?: SiegeTopographyStats
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -677,7 +684,6 @@ export const UNIMPLEMENTED_PASSES: readonly string[] = [
|
|||
'DRLGOUTDESR_InitAct2OutdoorLevel',
|
||||
'DRLGOUTPLACE_InitAct3OutdoorLevel',
|
||||
'DRLGOUTDOORS_InitAct4OutdoorLevel',
|
||||
'DRLGOUTSIEGE_InitAct5OutdoorLevel',
|
||||
'DRLG_GenerateJungles',
|
||||
'DRLGOUTDOORS_SpawnAct3Mephisto',
|
||||
]
|
||||
|
|
@ -2481,43 +2487,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<string> {
|
||||
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<string>()
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the 7 cross-wing presets for Act 4 Chaos Sanctuary (LevelId 108, 120x120).
|
||||
|
|
@ -3481,7 +3450,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) {
|
||||
|
|
@ -3536,6 +3505,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
|||
unresolved: stats.unresolved,
|
||||
unimplementedPasses: UNIMPLEMENTED_PASSES,
|
||||
notes: stats.notes,
|
||||
...(stats.siegeTopography ? { siegeTopography: stats.siegeTopography } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -3793,6 +3763,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
|||
unresolved: stats.unresolved,
|
||||
unimplementedPasses: UNIMPLEMENTED_PASSES,
|
||||
notes: stats.notes,
|
||||
...(stats.siegeTopography ? { siegeTopography: stats.siegeTopography } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue