Merge branch 'fix/issue-62'

refactor(map): 处理凭空发明的程序化地形 pass、消除破坏性预设涂抹与增加种子多样性

1. 删除 wilderness-acts.ts 中从未被主动调用的死代码 generateAct3Jungle 及相关孤儿引入
2. 消除 stampThemedRoads 铺路时破坏性清空墙体(walls.length = 0)与 stampKurastCauseway 覆盖已铺设 Act 3 - Bridge 预设的缺陷,引入预设区域与既有瓦片保护
3. 在 WildernessTelemetry / WildernessResult.stats 中完整追踪程序化道路、分隔墙、水体与障碍物实际绘制瓦片数,杜绝静态假数据
4. 改造血腥丘陵障碍物静态坐标矩阵,接入关卡 PRNG 流实现位置微扰与组件随机化,打破地图固化并保持中央通道畅通

验证:typecheck 零错误;vitest 61 files / 1003 tests passed(基线 61 / 996,新增 7 个测试全绿,测试总数破千);verify-packs 1671 项断言全过。

Closes #62

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
troytt 2026-09-17 15:32:55 +00:00
commit c377e8ea01
8 changed files with 425 additions and 183 deletions

View File

@ -398,6 +398,9 @@ export function initAct2OutdoorLevel(
}
stats.act2Topography = { canyonWalls, sandTrailCells, oasisPoolCells, tombEntrances }
stats.proceduralWallCells = (stats.proceduralWallCells ?? 0) + canyonWalls
stats.proceduralRoadCells = (stats.proceduralRoadCells ?? 0) + sandTrailCells
stats.proceduralWaterCells = (stats.proceduralWaterCells ?? 0) + oasisPoolCells
stats.notes.push(
`Act 2 Desert Topography: ${String(canyonWalls)} canyon walls, ${String(sandTrailCells)} sand trail cells, ${String(oasisPoolCells)} oasis cells, ${String(tombEntrances)} tomb entrances`,
)
@ -409,123 +412,6 @@ export function initAct2OutdoorLevel(
* Act 3: DRLGOUTPLACE_InitAct3OutdoorLevel & DRLG_GenerateJungles
* ------------------------------------------------------------------------- */
/**
* Generate Act 3 Jungle River & Clearings layout (`DRLG_GenerateJungles`):
* - Spans wooden river bridges across the East river channel at row crossings.
* - Connects West clearings to wooden river bridges via winding jungle causeways/paths.
*/
export function generateAct3Jungle(
canvas: Canvas,
levelId: number,
pieces: readonly WildernessPiece[],
rng: Rng,
stats: WildernessStats,
): Set<string> {
const placed = new Set<string>()
const tailPiece = pieces.find(p => p.name === 'Act 3 - Jungle Tail')
const headPiece = pieces.find(p => p.name === 'Act 3 - Jungle Head')
if (tailPiece?.levels[0]) {
stampDs1(canvas, tailPiece.levels[0], 0, 0)
placed.add(tailPiece.name)
stats.substitutions.push({ name: tailPiece.name, role: 'object', enabled: true, clusters: 1 })
}
if (headPiece?.levels[0]) {
stampDs1(canvas, headPiece.levels[0], 0, 160)
placed.add(headPiece.name)
stats.substitutions.push({ name: headPiece.name, role: 'object', enabled: true, clusters: 1 })
}
const clearingPrefix =
levelId === 76
? 'Act 3 - Clearing Webby'
: levelId === 77
? 'Act 3 - Clearing Boggy'
: 'Act 3 - Clearing Pygmy'
const allClearingPieces = pieces.filter(p => p.name.startsWith(clearingPrefix) && p.levels.length > 0)
const eastOpeningClearings = allClearingPieces.filter(p => /\b(E|EW|SE|NE)\b/i.test(p.name))
const clearingPool = eastOpeningClearings.length > 0 ? eastOpeningClearings : allClearingPieces
const allRiverPieces = pieces.filter(
p =>
p.name.startsWith('Act 3 - Jungle ') &&
p.name !== 'Act 3 - Jungle Head' &&
p.name !== 'Act 3 - Jungle Tail' &&
p.levels.length > 0,
)
const nsWestRivers = allRiverPieces.filter(p => /NS W|NS$/i.test(p.name))
const riverPool = nsWestRivers.length > 0 ? nsWestRivers : allRiverPieces
for (let row = 0; row < 4; row += 1) {
const y = 32 + row * 32
// West column (x = 0): Jungle Clearing opening East
if (clearingPool.length > 0) {
const chosenClearing = clearingPool[rng.int(0, clearingPool.length - 1)]!
const ds1 = chosenClearing.levels[rng.int(0, chosenClearing.levels.length - 1)]!
stampDs1(canvas, ds1, 0, y)
placed.add(chosenClearing.name)
stats.substitutions.push({ name: chosenClearing.name, role: 'object', enabled: true, clusters: 1 })
}
// East column (x = 32): Continuous North-South River channel
if (riverPool.length > 0) {
const chosenRiver = riverPool[rng.int(0, riverPool.length - 1)]!
const ds1 = chosenRiver.levels[rng.int(0, chosenRiver.levels.length - 1)]!
stampDs1(canvas, ds1, 32, y)
placed.add(chosenRiver.name)
stats.substitutions.push({ name: chosenRiver.name, role: 'object', enabled: true, clusters: 1 })
}
}
// Stamp Wooden River Bridges & Jungle Paths spanning the river channel
let riverBridges = 0
let junglePathCells = 0
// Two canonical wooden bridge crossings at row 1 (y = 78..81) and row 3 (y = 142..145)
const bridgeYPositions = [78, 142]
for (const by of bridgeYPositions) {
for (let y = by; y < by + 4; y += 1) {
for (let x = 32; x < 60; x += 1) {
// Style 4: wooden bridge planks
paintFloor(canvas, x, y, makeFloor(4, 0, 2), true)
clearWalls(canvas, x, y)
}
}
riverBridges += 1
}
// Jungle Trails connecting clearings across to the river bridges
for (const by of bridgeYPositions) {
// West clearing connection (x = 12..32)
for (let x = 12; x < 32; x += 1) {
for (let dy = 0; dy < 3; dy += 1) {
const y = by + dy
paintFloor(canvas, x, y, makeFloor(5, 0, 194))
clearWalls(canvas, x, y)
junglePathCells += 1
}
}
// East trail along riverbank (x = 58..61, North-South trail)
for (let y = by - 14; y <= by + 14; y += 1) {
if (y >= 4 && y < canvas.height - 4) {
paintFloor(canvas, 59, y, makeFloor(5, 0, 194))
clearWalls(canvas, 59, y)
junglePathCells += 1
}
}
}
stats.act3Topography = {
causewayCells: 0,
riverBridges,
canalCells: 0,
junglePathCells,
}
stats.notes.push(
`Act 3 Jungle Topography: ${String(riverBridges)} river bridges, ${String(junglePathCells)} jungle path cells`,
)
return placed
}
/**
* Stamp Kurast Causeway (LevelId 82):
@ -535,6 +421,7 @@ export function stampAct3KurastCauseway(
canvas: Canvas,
pieces: readonly WildernessPiece[],
stats: WildernessStats,
specialPlacedRects?: PlacedPresetRect[],
): Set<string> {
const placed = new Set<string>()
const piece = pieces.find(p => p.name === 'Act 3 - Bridge')
@ -544,17 +431,36 @@ export function stampAct3KurastCauseway(
stampDs1(canvas, ds1, 0, 0)
placed.add('Act 3 - Bridge')
stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 })
if (specialPlacedRects) {
specialPlacedRects.push({
x: 0,
y: 0,
w: ds1.width,
h: ds1.height,
name: 'Act 3 - Bridge',
})
}
}
// Construct / ensure canonical causeway layout:
// Central paved walkway flanked by canal water and balustrades
let causewayCells = 0
let canalCells = 0
let balustradeCells = 0
const walkwayLeft = Math.max(2, Math.floor((canvas.width - 8) / 2))
const walkwayRight = walkwayLeft + 7
for (let y = 0; y < canvas.height; y += 1) {
for (let x = 0; x < canvas.width; x += 1) {
if (ds1 && x >= 0 && x < ds1.width && y >= 0 && y < ds1.height) {
const ds1Cell = ds1.cells[y]?.[x]
const hasFloor = ds1Cell?.floors && ds1Cell.floors.length > 0 && !ds1Cell.floors[0]?.hidden
const hasWall = ds1Cell?.walls && ds1Cell.walls.length > 0 && !ds1Cell.walls[0]?.hidden
if (hasFloor || hasWall) {
// Keep artist-placed preset tiles from ds1; do not destructively wipe
continue
}
}
if (x >= walkwayLeft && x <= walkwayRight) {
// Paved stone causeway: style 5, prop1 194
paintFloor(canvas, x, y, makeFloor(5, 0, 194), true)
@ -563,6 +469,7 @@ export function stampAct3KurastCauseway(
} else if (x === walkwayLeft - 1 || x === walkwayRight + 1) {
// Stone balustrade wall: style 1, prop1 129
paintWall(canvas, x, y, makeWall(1, 0, 1, 129))
balustradeCells += 1
} else {
// Deep canal water: style 2, sequence 0, prop1 2
paintFloor(canvas, x, y, makeFloor(2, 0, 2), true)
@ -578,6 +485,9 @@ export function stampAct3KurastCauseway(
canalCells,
junglePathCells: 0,
}
stats.proceduralRoadCells = (stats.proceduralRoadCells ?? 0) + causewayCells
stats.proceduralWaterCells = (stats.proceduralWaterCells ?? 0) + canalCells
stats.proceduralWallCells = (stats.proceduralWallCells ?? 0) + balustradeCells
stats.notes.push(
`Act 3 Causeway Topography: ${String(causewayCells)} causeway cells, ${String(canalCells)} canal water cells`,
)
@ -605,6 +515,7 @@ export function initAct3OutdoorLevel(
let causewayCells = 0
let riverBridges = 0
let canalCells = 0
let embankmentWalls = 0
const junglePathCells = 0
const hubX = inX * TILES_PER_BLOCK + 4
@ -656,10 +567,14 @@ export function initAct3OutdoorLevel(
// Stone canal embankments
paintWall(canvas, canalX - 1, y, makeWall(1, 0, 1, 129))
paintWall(canvas, canalX + 4, y, makeWall(1, 0, 1, 129))
embankmentWalls += 2
}
}
stats.act3Topography = { causewayCells, riverBridges, canalCells, junglePathCells }
stats.proceduralRoadCells = (stats.proceduralRoadCells ?? 0) + causewayCells
stats.proceduralWaterCells = (stats.proceduralWaterCells ?? 0) + canalCells
stats.proceduralWallCells = (stats.proceduralWallCells ?? 0) + embankmentWalls
stats.notes.push(
`Act 3 Kurast Topography: ${String(causewayCells)} causeway cells, ${String(canalCells)} canal cells, ${String(riverBridges)} bridges`,
)
@ -788,6 +703,8 @@ export function initAct4OutdoorLevel(
}
stats.act4Topography = { lavaChasmCells, rockyBridges, plateauCells }
stats.proceduralWaterCells = (stats.proceduralWaterCells ?? 0) + lavaChasmCells
stats.proceduralRoadCells = (stats.proceduralRoadCells ?? 0) + rockyBridges
stats.notes.push(
`Act 4 Mesa/Lava Topography: ${String(lavaChasmCells)} lava cells, ${String(rockyBridges)} rocky bridge cells, ${String(plateauCells)} plateau cells`,
)
@ -909,6 +826,9 @@ export function initAct5OutdoorLevel(
}
stats.act5Topography = { barricadeWalls, trenchCells, frozenLakeCells }
stats.proceduralWallCells = (stats.proceduralWallCells ?? 0) + barricadeWalls
stats.proceduralRoadCells = (stats.proceduralRoadCells ?? 0) + trenchCells
stats.proceduralWaterCells = (stats.proceduralWaterCells ?? 0) + frozenLakeCells
stats.notes.push(
`Act 5 Siege/Barricade Topography: ${String(barricadeWalls)} barricade walls, ${String(trenchCells)} trench cells, ${String(frozenLakeCells)} frozen lake cells`,
)

View File

@ -23,7 +23,7 @@
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Wall } from '../formats/ds1.ts'
import { Rng } from './rng.ts'
import type { Canvas, SubstitutionReport, WildernessPiece, WildernessStats } from './wilderness.ts'
import type { Canvas, PlacedPresetRect, SubstitutionReport, WildernessPiece, WildernessStats } from './wilderness.ts'
/** Connection between two Act 3 zones. */
export interface JungleZoneConnection {
@ -242,6 +242,7 @@ function synthesizeBridgePlanks(
bridgeX: number,
bridgeY: number,
orientation: 'horizontal' | 'vertical',
bridgeDs1?: Ds1,
): void {
const makeBridgeFloor = (): Ds1Floor => ({
prop1: 2,
@ -261,6 +262,14 @@ function synthesizeBridgePlanks(
if (cy >= 0 && cy < canvas.height && cx >= 0 && cx < canvas.width) {
const row = canvas.cells[cy]
if (row?.[cx]) {
if (bridgeDs1 && dx < bridgeDs1.width && dy < bridgeDs1.height) {
const ds1Cell = bridgeDs1.cells[dy]?.[dx]
const hasPresetWall = ds1Cell?.walls && ds1Cell.walls.length > 0 && !ds1Cell.walls[0]?.hidden
const hasPresetFloor = ds1Cell?.floors && ds1Cell.floors.length > 0 && !ds1Cell.floors[0]?.hidden && ds1Cell.floors[0]?.style !== 1
if (hasPresetWall || hasPresetFloor) {
continue
}
}
;(row[cx]!.floors as Ds1Floor[])[0] = makeBridgeFloor()
}
}
@ -274,6 +283,14 @@ function synthesizeBridgePlanks(
if (cy >= 0 && cy < canvas.height && cx >= 0 && cx < canvas.width) {
const row = canvas.cells[cy]
if (row?.[cx]) {
if (bridgeDs1 && dx < bridgeDs1.width && dy < bridgeDs1.height) {
const ds1Cell = bridgeDs1.cells[dy]?.[dx]
const hasPresetWall = ds1Cell?.walls && ds1Cell.walls.length > 0 && !ds1Cell.walls[0]?.hidden
const hasPresetFloor = ds1Cell?.floors && ds1Cell.floors.length > 0 && !ds1Cell.floors[0]?.hidden && ds1Cell.floors[0]?.style !== 1
if (hasPresetWall || hasPresetFloor) {
continue
}
}
;(row[cx]!.floors as Ds1Floor[])[0] = makeBridgeFloor()
}
}
@ -343,6 +360,7 @@ export function DRLG_GenerateJungles(
rng: Rng,
stats: WildernessStats,
stampDs1Fn: (canvas: Canvas, source: Ds1, originX: number, originY: number) => number,
placedRects?: PlacedPresetRect[],
): Set<string> {
const placed = new Set<string>()
@ -523,8 +541,9 @@ export function DRLG_GenerateJungles(
const offsetX = Math.max(0, Math.floor((32 - bDs1.width) / 2))
const offsetY = Math.max(0, Math.floor((32 - bDs1.height) / 2))
stampDs1Fn(canvas, bDs1, bx + offsetX, by + offsetY)
placedRects?.push({ x: bx + offsetX, y: by + offsetY, w: bDs1.width, h: bDs1.height, name: bridgePiece.name })
}
synthesizeBridgePlanks(canvas, bx, by, orientation)
synthesizeBridgePlanks(canvas, bx, by, orientation, bridgePiece?.levels[0])
placed.add('Act 3 - Bridge')
stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 })
}
@ -538,9 +557,11 @@ export function DRLG_GenerateJungles(
bridges.push({ col: bridgeCol, row: bridgeRow, x: bx, y: by, pieceName: 'Act 3 - Bridge' })
if (bridgePiece?.levels[0]) {
stampDs1Fn(canvas, bridgePiece.levels[0], bx, by)
const bDs1 = bridgePiece.levels[0]
stampDs1Fn(canvas, bDs1, bx, by)
placedRects?.push({ x: bx, y: by, w: bDs1.width, h: bDs1.height, name: bridgePiece.name })
}
synthesizeBridgePlanks(canvas, bx, by, 'horizontal')
synthesizeBridgePlanks(canvas, bx, by, 'horizontal', bridgePiece?.levels[0])
placed.add('Act 3 - Bridge')
stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 })
}
@ -553,11 +574,13 @@ export function DRLG_GenerateJungles(
stampDs1Fn(canvas, tailPiece.levels[0], 0, 0)
placed.add(tailPiece.name)
stats.substitutions.push({ name: tailPiece.name, role: 'object', enabled: true, clusters: 1 })
placedRects?.push({ x: 0, y: 0, w: tailPiece.levels[0].width, h: tailPiece.levels[0].height, name: tailPiece.name })
}
if (headPiece?.levels[0]) {
stampDs1Fn(canvas, headPiece.levels[0], 0, (numRows - 1) * 32)
placed.add(headPiece.name)
stats.substitutions.push({ name: headPiece.name, role: 'object', enabled: true, clusters: 1 })
placedRects?.push({ x: 0, y: (numRows - 1) * 32, w: headPiece.levels[0].width, h: headPiece.levels[0].height, name: headPiece.name })
}
// 8. Stamp Interior River and Clearing Presets
@ -575,6 +598,7 @@ export function DRLG_GenerateJungles(
cell.pieceName = riverPiece.name
placed.add(riverPiece.name)
stats.substitutions.push({ name: riverPiece.name, role: 'object', enabled: true, clusters: 1 })
placedRects?.push({ x, y, w: ds1.width, h: ds1.height, name: riverPiece.name })
}
} else if (cell.type === 'clearing') {
const preferredOpening =
@ -591,6 +615,7 @@ export function DRLG_GenerateJungles(
cell.pieceName = clearingPiece.name
placed.add(clearingPiece.name)
stats.substitutions.push({ name: clearingPiece.name, role: 'object', enabled: true, clusters: 1 })
placedRects?.push({ x, y, w: ds1.width, h: ds1.height, name: clearingPiece.name })
clearings.push({
col: c,
row: r,
@ -605,9 +630,11 @@ export function DRLG_GenerateJungles(
// 9. Re-stamp Bridges on top of river water tiles to ensure bridge plank floor integrity
for (const b of bridges) {
if (bridgePiece?.levels[0]) {
stampDs1Fn(canvas, bridgePiece.levels[0], b.x, b.y)
const ds1 = bridgePiece.levels[0]
stampDs1Fn(canvas, ds1, b.x, b.y)
placedRects?.push({ x: b.x, y: b.y, w: ds1.width, h: ds1.height, name: bridgePiece.name })
}
synthesizeBridgePlanks(canvas, b.x, b.y, 'horizontal')
synthesizeBridgePlanks(canvas, b.x, b.y, 'horizontal', bridgePiece?.levels[0])
}
// 10. Record Layout Statistics

View File

@ -416,34 +416,34 @@ export function stampAct5SiegeStrips(
// 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)
totalTrenchCells += stampTrench(canvas, 18 + rng.int(-2, 2), 8 + rng.int(-1, 1), 10 + rng.int(-1, 1), 'vertical', rng)
totalTrenchCells += stampTrench(canvas, 50 + rng.int(-2, 2), 28 + rng.int(-1, 1), 10 + rng.int(-1, 1), 'vertical', rng)
totalTrenchCells += stampTrench(canvas, 34 + rng.int(-2, 2), 12 + rng.int(-1, 1), 7 + rng.int(-1, 1), 'horizontal', rng)
westObstacles += 3
// Breached Barricades (3 barricades)
const b1 = stampBreachedBarricade(canvas, 26, 10, 24, 21, 5, rng)
const b1 = stampBreachedBarricade(canvas, 26 + rng.int(-2, 2), 10 + rng.int(-1, 1), 24, 21 + rng.int(0, 1), 5, rng)
totalBarricadeWalls += b1.wallCount
totalTrenchCells += b1.breachFloorCount
const b2 = stampBreachedBarricade(canvas, 42, 8, 12, 13, 3, rng)
const b2 = stampBreachedBarricade(canvas, 42 + rng.int(-2, 2), 8 + rng.int(-1, 1), 12, 13, 3, rng)
totalBarricadeWalls += b2.wallCount
totalTrenchCells += b2.breachFloorCount
const b3 = stampBreachedBarricade(canvas, 68, 26, 12, 31, 3, rng)
const b3 = stampBreachedBarricade(canvas, 68 + rng.int(-2, 2), 26 + rng.int(-1, 1), 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)
stampCatapultPlatform(canvas, 12 + rng.int(-2, 2), 10 + rng.int(-1, 1), 4, 4, rng)
stampCatapultPlatform(canvas, 58 + rng.int(-2, 2), 30 + rng.int(-1, 1), 4, 4, rng)
catapultPlatformCount += 2
westObstacles += 2
// Fort Ruins (2 ruins)
stampFortRuin(canvas, 30, 30, 4, rng)
stampFortRuin(canvas, 64, 10, 4, rng)
stampFortRuin(canvas, 30 + rng.int(-2, 2), 30 + rng.int(-1, 1), 4, rng)
stampFortRuin(canvas, 64 + rng.int(-2, 2), 10 + rng.int(-1, 1), 4, rng)
fortRuinCount += 2
westObstacles += 2
@ -451,27 +451,27 @@ export function stampAct5SiegeStrips(
// 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)
totalTrenchCells += stampTrench(canvas, 98 + rng.int(-2, 2), 10 + rng.int(-1, 1), 10 + rng.int(-1, 1), 'vertical', rng)
totalTrenchCells += stampTrench(canvas, 138 + rng.int(-2, 2), 28 + rng.int(-1, 1), 10 + rng.int(-1, 1), 'vertical', rng)
midObstacles += 2
// Breached Barricades (2 barricades)
const b4 = stampBreachedBarricade(canvas, 114, 10, 24, 21, 5, rng)
const b4 = stampBreachedBarricade(canvas, 114 + rng.int(-2, 2), 10 + rng.int(-1, 1), 24, 21 + rng.int(0, 1), 5, rng)
totalBarricadeWalls += b4.wallCount
totalTrenchCells += b4.breachFloorCount
const b5 = stampBreachedBarricade(canvas, 146, 8, 12, 13, 3, rng)
const b5 = stampBreachedBarricade(canvas, 146 + rng.int(-2, 2), 8 + rng.int(-1, 1), 12, 13, 3, rng)
totalBarricadeWalls += b5.wallCount
totalTrenchCells += b5.breachFloorCount
midObstacles += 2
// Catapult Platforms (1 platform)
stampCatapultPlatform(canvas, 86, 30, 4, 4, rng)
stampCatapultPlatform(canvas, 86 + rng.int(-2, 2), 30 + rng.int(-1, 1), 4, 4, rng)
catapultPlatformCount += 1
midObstacles += 1
// Fort Ruins (1 ruin)
stampFortRuin(canvas, 124, 10, 4, rng)
stampFortRuin(canvas, 124 + rng.int(-2, 2), 10 + rng.int(-1, 1), 4, rng)
fortRuinCount += 1
midObstacles += 1
@ -479,11 +479,11 @@ export function stampAct5SiegeStrips(
// Zone 3: East Rear (x in [160, 240)) - Low density (Harrogath outskirts)
// -------------------------------------------------------------------------
// Trenches (1 trench)
totalTrenchCells += stampTrench(canvas, 186, 12, 10, 'vertical', rng)
totalTrenchCells += stampTrench(canvas, 186 + rng.int(-2, 2), 12 + rng.int(-1, 1), 10 + rng.int(-1, 1), 'vertical', rng)
eastObstacles += 1
// Breached Barricades (1 barricade)
const b6 = stampBreachedBarricade(canvas, 202, 10, 24, 21, 5, rng)
const b6 = stampBreachedBarricade(canvas, 202 + rng.int(-2, 2), 10 + rng.int(-1, 1), 24, 21 + rng.int(0, 1), 5, rng)
totalBarricadeWalls += b6.wallCount
totalTrenchCells += b6.breachFloorCount
eastObstacles += 1
@ -491,7 +491,7 @@ export function stampAct5SiegeStrips(
// Catapult Platforms (0 platforms in rear reserve)
// Fort Ruins (1 ruin)
stampFortRuin(canvas, 170, 30, 4, rng)
stampFortRuin(canvas, 170 + rng.int(-2, 2), 30 + rng.int(-1, 1), 4, rng)
fortRuinCount += 1
eastObstacles += 1
@ -510,6 +510,8 @@ export function stampAct5SiegeStrips(
}
stats.siegeTopography = siegeStats
stats.proceduralWallCells = (stats.proceduralWallCells ?? 0) + totalBarricadeWalls
stats.proceduralRoadCells = (stats.proceduralRoadCells ?? 0) + totalTrenchCells
stats.notes.push('generated dynamic siege trenches and fortifications')
return placed

View File

@ -82,7 +82,6 @@ import type { Side } from './world-graph.ts'
import {
initAct2OutdoorLevel,
initAct3OutdoorLevel,
generateAct3Jungle,
stampAct3KurastCauseway,
initAct4OutdoorLevel,
initAct5OutdoorLevel,
@ -1071,6 +1070,14 @@ export interface LandmarkEntity {
readonly objectId?: number
}
/** Procedural terrain pass telemetry counters (Issue #62). */
export interface WildernessTelemetry {
readonly proceduralRoadCells: number
readonly proceduralDividerCells: number
readonly proceduralWaterCells: number
readonly proceduralWallCells: number
}
/** The report collector. */
export interface WildernessStats {
readonly substitutions: SubstitutionReport[]
@ -1104,6 +1111,11 @@ export interface WildernessStats {
riverForks?: number
meanderCount?: number
siegeTopography?: SiegeTopographyStats
subTheme?: number
proceduralRoadCells?: number
proceduralDividerCells?: number
proceduralWaterCells?: number
proceduralWallCells?: number
}
/**
@ -3123,7 +3135,7 @@ function stampAct3Jungle(
stats: WildernessStats,
placedRects?: PlacedPresetRect[],
): Set<string> {
return DRLG_GenerateJungles(canvas, levelId, pieces, rng, stats, stampDs1)
return DRLG_GenerateJungles(canvas, levelId, pieces, rng, stats, stampDs1, placedRects)
}
/** Theme configuration for outdoor road rasterization. */
@ -3154,10 +3166,13 @@ export function stampThemedRoads(
rng: Rng,
theme: OutdoorRoadTheme,
openGates?: ReadonlyMap<string, number>,
stats?: WildernessStats,
): void {
const paintedRoadCells = new Set<string>()
const isInsideProtectedPreset = (x: number, y: number): boolean => {
for (const r of placedRects) {
if (theme.isProtectedPreset ? !theme.isProtectedPreset(r.name) : /River|Bridge/i.test(r.name)) {
if (theme.isProtectedPreset ? !theme.isProtectedPreset(r.name) : false) {
continue
}
if (x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) {
@ -3177,7 +3192,11 @@ export function stampThemedRoads(
if (existingFloor && (existingFloor.style === 2 || existingFloor.style === 3 || existingFloor.style === 4)) {
return
}
const belongsToPreset = placedRects.some(r => x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h)
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) {
if (belongsToPreset) {
return
}
if (clearStrayClutter) {
// Clear random LvlSub bush/tree clutter on the main highway spine
;(cell.walls as Ds1Wall[]).length = 0
@ -3186,6 +3205,7 @@ export function stampThemedRoads(
}
}
;(cell.floors as Ds1Floor[])[0] = theme.roadFloor(rng)
paintedRoadCells.add(`${x},${y}`)
}
const hubX = inX * TILES_PER_BLOCK + 4
@ -3284,6 +3304,10 @@ export function stampThemedRoads(
paintSpurSegment(doorX, doorY, doorX, hubY)
}
}
if (stats) {
stats.proceduralRoadCells = (stats.proceduralRoadCells ?? 0) + paintedRoadCells.size
}
}
/**
@ -3299,11 +3323,13 @@ export function stampThemedDividers(
placedRects: readonly PlacedPresetRect[],
rng: Rng,
theme: OutdoorDividerTheme,
stats?: WildernessStats,
): void {
if (gridWidth < 6 || gridHeight < 6) return
const wallStyle = theme.wallStyle
const wallProp1 = theme.wallProp1 ?? 129
const placedCells = new Set<string>()
const makeWall = (type: number, sequence: number): Ds1Wall => ({
prop1: wallProp1,
@ -3324,6 +3350,7 @@ export function stampThemedDividers(
} else {
targetWalls.push(wall)
}
placedCells.add(`${x},${y}`)
}
// Offset divider lines by 2 blocks (16 tiles) from the main road spine
@ -3419,6 +3446,11 @@ export function stampThemedDividers(
}
}
}
if (stats) {
stats.proceduralDividerCells = (stats.proceduralDividerCells ?? 0) + placedCells.size
stats.proceduralWallCells = (stats.proceduralWallCells ?? 0) + placedCells.size
}
}
/**
@ -3434,6 +3466,7 @@ export function stampAct1DirtRoads(
placedRects: readonly PlacedPresetRect[],
rng: Rng,
openGates?: ReadonlyMap<string, number>,
stats?: WildernessStats,
): void {
stampThemedRoads(
canvas,
@ -3456,6 +3489,7 @@ export function stampAct1DirtRoads(
isIgnoredSpurPreset: (name) => name === 'Act 1 - River' || /Stone Fill|Swamp Fill|Tree Fill|Fence Fill|Corral Fill/i.test(name),
},
openGates,
stats,
)
}
@ -3471,12 +3505,13 @@ export function stampAct1InteriorStoneWalls(
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
stats?: WildernessStats,
): void {
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
wallStyle: 2,
wallProp1: 129,
roadStyles: [5],
})
}, stats)
}
/**
@ -3491,6 +3526,7 @@ export function stampAct2DesertRoads(
placedRects: readonly PlacedPresetRect[],
rng: Rng,
openGates?: ReadonlyMap<string, number>,
stats?: WildernessStats,
): void {
stampThemedRoads(
canvas,
@ -3509,10 +3545,11 @@ export function stampAct2DesertRoads(
unknown2: 0,
hidden: false,
}),
isProtectedPreset: (name) => !/River|Bridge/i.test(name),
isProtectedPreset: (name) => name !== 'Act 1 - River',
isIgnoredSpurPreset: (name) => /Fill|Berms/i.test(name),
},
openGates,
stats,
)
}
@ -3527,12 +3564,13 @@ export function stampAct2SandstoneBerms(
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
stats?: WildernessStats,
): void {
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
wallStyle: 1,
wallProp1: 129,
roadStyles: [1],
})
}, stats)
}
/**
@ -3547,6 +3585,7 @@ export function stampAct3KurastAvenues(
placedRects: readonly PlacedPresetRect[],
rng: Rng,
openGates?: ReadonlyMap<string, number>,
stats?: WildernessStats,
): void {
stampThemedRoads(
canvas,
@ -3565,10 +3604,11 @@ export function stampAct3KurastAvenues(
unknown2: 0,
hidden: false,
}),
isProtectedPreset: (name) => !/River|Bridge/i.test(name),
isProtectedPreset: (name) => name !== 'Act 1 - River',
isIgnoredSpurPreset: (name) => /Fill/i.test(name),
},
openGates,
stats,
)
}
@ -3583,12 +3623,13 @@ export function stampAct3KurastBalustrades(
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
stats?: WildernessStats,
): void {
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
wallStyle: 1,
wallProp1: 129,
roadStyles: [1],
})
}, stats)
}
/**
@ -3603,6 +3644,7 @@ export function stampAct5SnowRoads(
placedRects: readonly PlacedPresetRect[],
rng: Rng,
openGates?: ReadonlyMap<string, number>,
stats?: WildernessStats,
): void {
stampThemedRoads(
canvas,
@ -3621,10 +3663,11 @@ export function stampAct5SnowRoads(
unknown2: 0,
hidden: false,
}),
isProtectedPreset: (name) => !/River|Bridge/i.test(name),
isProtectedPreset: (name) => name !== 'Act 1 - River',
isIgnoredSpurPreset: (name) => /Fill|Filler/i.test(name),
},
openGates,
stats,
)
}
@ -3639,12 +3682,13 @@ export function stampAct5FrozenTrailDivides(
outY: number,
placedRects: readonly PlacedPresetRect[],
rng: Rng,
stats?: WildernessStats,
): void {
stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, {
wallStyle: 2,
wallProp1: 129,
roadStyles: [1],
})
}, stats)
}
/**
@ -3663,47 +3707,48 @@ export function stampOutdoorRoads(
levelTypeName?: string,
openGates?: ReadonlyMap<string, number>,
levelsTable?: D2Table,
stats?: WildernessStats,
): 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)
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
return
} else if (levelId >= 79 && levelId <= 82) {
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
return
} else if (levelId === 111 || levelId === 112 || levelId === 117) {
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
return
} else if (levelId >= 2 && levelId <= 7) {
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
return
// Level type name takes second precedence
} else if (levelTypeName === 'Act 2 - Desert') {
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
return
} else if (levelTypeName === 'Act 3 - Kurast') {
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
return
} else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') {
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
return
} else if (levelTypeName === 'Act 1 - Wilderness') {
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
return
}
// Unified actOfLevel fallback
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
if (effectiveAct === 2) {
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
} else if (effectiveAct === 3) {
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
} else if (effectiveAct === 5) {
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
} else if (effectiveAct === 1) {
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats)
}
}
@ -3722,49 +3767,50 @@ export function stampOutdoorDividers(
rng: Rng,
levelTypeName?: string,
levelsTable?: D2Table,
stats?: WildernessStats,
): 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)
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
return
} else if (levelId >= 79 && levelId <= 82) {
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
return
} else if (levelId === 111 || levelId === 112 || levelId === 117) {
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
return
} else if (levelId >= 2 && levelId <= 7) {
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
return
}
// Level type name takes second precedence
if (levelTypeName === 'Act 2 - Desert') {
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
return
} else if (levelTypeName === 'Act 3 - Kurast') {
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
return
} else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') {
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
return
} else if (levelTypeName === 'Act 1 - Wilderness') {
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
return
}
// Unified actOfLevel fallback
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
if (effectiveAct === 2) {
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
} else if (effectiveAct === 3) {
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
} else if (effectiveAct === 5) {
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
} else if (effectiveAct === 1) {
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats)
}
}
@ -5070,6 +5116,10 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
anchors: 0,
specialPresets: [],
entrances: [],
proceduralRoadCells: 0,
proceduralDividerCells: 0,
proceduralWaterCells: 0,
proceduralWallCells: 0,
substitutions: [],
substitutedClusters: 0,
objects: canvas.objects.length,
@ -5087,6 +5137,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
substitutions: [], borderPieces: {}, unresolved: [], notes: [],
borderStamped: 0, groundCells: 0, groundTile: null, groundTiles: undefined, sizeSource,
roadCells: 0, roadSegments: 0, anchors: 0, specialPresets: [], entrances: [],
proceduralRoadCells: 0, proceduralDividerCells: 0, proceduralWaterCells: 0, proceduralWallCells: 0,
}
let wallLayers = 1
@ -5142,7 +5193,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
if (request.levelId === 83) {
placedSpecial = stampAct3Travincal(canvas, request.pieces, stats, specialPlacedRects)
} else if (request.levelId === 82) {
placedSpecial = stampAct3KurastCauseway(canvas, request.pieces, stats)
placedSpecial = stampAct3KurastCauseway(canvas, request.pieces, stats, specialPlacedRects)
} else if (request.levelId >= 76 && request.levelId <= 78) {
placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats, specialPlacedRects)
const populationRng = new Rng(request.seed ^ 0x6c07)
@ -5211,6 +5262,10 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
anchors: stats.anchors,
entrances: stats.entrances,
specialPresets: stats.specialPresets,
proceduralRoadCells: stats.proceduralRoadCells ?? 0,
proceduralDividerCells: stats.proceduralDividerCells ?? 0,
proceduralWaterCells: stats.proceduralWaterCells ?? 0,
proceduralWallCells: stats.proceduralWallCells ?? 0,
substitutions: stats.substitutions,
substitutedClusters: placedSpecial.size,
objects: canvas.objects.length,
@ -5278,6 +5333,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
dirtPathGrid = roadRes.dirtPathGrid
stats.roadCells = roadRes.roadCells
stats.roadSegments = roadRes.roadSegments
stats.proceduralRoadCells = (stats.proceduralRoadCells ?? 0) + roadRes.roadCells
}
}
@ -5434,6 +5490,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
request.levelTypeName,
act1Plan?.openGates,
request.levelsTable,
stats,
)
stampOutdoorDividers(
canvas,
@ -5447,6 +5504,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
rng,
request.levelTypeName,
request.levelsTable,
stats,
)
// Act 2 Desert Topography: Canyons, oasis pools, sand caravan trails, tomb entrances (DRLGOUTDESR_InitAct2OutdoorLevel)
@ -5569,6 +5627,10 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
act3Topography: stats.act3Topography,
act4Topography: stats.act4Topography,
act5Topography: stats.act5Topography,
proceduralRoadCells: stats.proceduralRoadCells ?? 0,
proceduralDividerCells: stats.proceduralDividerCells ?? 0,
proceduralWaterCells: stats.proceduralWaterCells ?? 0,
proceduralWallCells: stats.proceduralWallCells ?? 0,
cells: canvas.width * canvas.height,
unresolved: stats.unresolved,
unimplementedPasses: UNIMPLEMENTED_PASSES,

View File

@ -13,15 +13,21 @@ import type {
} from '../src/game/wilderness-acts.ts'
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
function makeMockDs1(width: number, height: number, hasGapInWalls: boolean = false): Ds1 {
function makeMockDs1(
width: number,
height: number,
hasGapInWalls: boolean = false,
floorStyle: number = 1,
wallStyle: number = 1,
): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
const isGap = hasGapInWalls && x === Math.floor(width / 2)
row.push({
walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 2, sequence: 0, style: 1, unknown1: 0, unknown2: 0, hidden: false }],
walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: wallStyle, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 2, sequence: 0, style: floorStyle, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
@ -231,9 +237,9 @@ describe('Wilderness Outdoor Initializers for Acts 2-5 (Issue #51)', () => {
})
test('Kurast Causeway (82) stamps causeway with central walkway, balustrades, and canal water', () => {
const bridgeDs1 = makeMockDs1(48, 16)
const groundDs1 = makeMockDs1(48, 16)
const pieces: WildernessPiece[] = [
{ name: 'Act 3 - Bridge', border: false, levels: [bridgeDs1] },
{ name: 'Act 3 - Ground', border: false, levels: [groundDs1] },
]
const result = generateWilderness({
@ -271,6 +277,38 @@ describe('Wilderness Outdoor Initializers for Acts 2-5 (Issue #51)', () => {
expect(foundCanalWater).toBe(true)
})
test('Kurast Causeway (82) preserves preset when Act 3 - Bridge is provided', () => {
const bridgeDs1 = makeMockDs1(48, 16, false, 29, 31)
const pieces: WildernessPiece[] = [
{ name: 'Act 3 - Bridge', border: false, levels: [bridgeDs1] },
]
const result = generateWilderness({
levelId: 82,
levelName: 'Kurast Causeway',
levelTypeName: 'Act 3 - Kurast',
sizeX: 48,
sizeY: 16,
subType: 0,
subTheme: 0,
seed: 82001,
pieces,
substitutions: [],
})
let foundPresetFloor = false
let foundPresetWall = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 29)) foundPresetFloor = true
if (cell.walls.some(w => w.style === 31)) foundPresetWall = true
}
}
expect(foundPresetFloor).toBe(true)
expect(foundPresetWall).toBe(true)
})
test('Kurast Bazaar (79) generates paved stone avenues, canals, and stone bridges', () => {
const pieces = makeMockBorderPieces('Act 3')
const result = generateWilderness({

View File

@ -16,7 +16,7 @@ import {
} 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'
import type { Ds1, Ds1Cell, Ds1Wall } from '../src/formats/ds1.ts'
function makeMockDs1(width: number, height: number): Ds1 {
const cells: Ds1Cell[][] = []
@ -412,5 +412,33 @@ describe('Multi-Act Outdoor Roads & Boundary Divider Networks', () => {
expect(roadFloors).toBe(0)
expect(dividerWalls).toBe(0)
})
it('stampThemedRoads preserves preset walls on the highway spine', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(42)
const hubX = inX * TILES_PER_BLOCK + 4
const hubY = outY * TILES_PER_BLOCK + 4
// Preset directly intersects the road spine
const placedRects: PlacedPresetRect[] = [
{ name: 'Act 1 - Ruin', x: hubX - 2, y: hubY - 2, w: 5, h: 5 },
]
// Paint preset wall inside the preset on the highway spine
const targetCell = canvas.cells[hubY]?.[hubX]
expect(targetCell).toBeDefined()
;(targetCell!.walls as Ds1Wall[]).push({
prop1: 100,
sequence: 0,
style: 99,
type: 1,
unknown1: 0,
unknown2: 0,
hidden: false,
})
stampAct1DirtRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
// The wall inside the preset must NOT have been wiped
expect(targetCell!.walls.some(w => w.style === 99 && w.prop1 === 100)).toBe(true)
})
})
})

View File

@ -415,4 +415,59 @@ describe("Act 5 Bloody Foothills Dynamic Siege Generation (DRLGOUTSIEGE)", () =>
expect(notes).toContain("generated dynamic siege trenches and fortifications")
expect(result.stats.siegeTopography).toBeDefined()
})
test("generates varied obstacle layouts with different seeds (seed diversity)", () => {
const pieces = createMockSiegePieces()
const result1 = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 11001,
pieces,
substitutions: [],
})
const result2 = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 11002,
pieces,
substitutions: [],
})
// Collect obstacle wall coordinates for both seeds
const walls1 = new Set<string>()
const walls2 = new Set<string>()
for (let y = 0; y < result1.level.height; y += 1) {
for (let x = 0; x < result1.level.width; x += 1) {
if (result1.level.cells[y]![x]!.walls.some(w => !w.hidden && w.prop1 !== 0)) {
walls1.add(`${x},${y}`)
}
if (result2.level.cells[y]![x]!.walls.some(w => !w.hidden && w.prop1 !== 0)) {
walls2.add(`${x},${y}`)
}
}
}
// Both should have placed walls
expect(walls1.size).toBeGreaterThan(0)
expect(walls2.size).toBeGreaterThan(0)
// The obstacle coordinates must differ across different seeds
let diffCount = 0
for (const coord of walls1) {
if (!walls2.has(coord)) diffCount += 1
}
expect(diffCount).toBeGreaterThan(0)
})
})

View File

@ -43,6 +43,10 @@ function makeStats(): WildernessStats {
anchors: 0,
specialPresets: [],
entrances: [],
proceduralRoadCells: 0,
proceduralDividerCells: 0,
proceduralWaterCells: 0,
proceduralWallCells: 0,
}
}
@ -276,3 +280,109 @@ describe('BordType 0 really is one cluster for the whole level', () => {
})
})
describe('Procedural terrain pass telemetry tracks real cell counts (Issue #62)', () => {
test('Act 1 wilderness tracks procedural road and divider wall cells matching canvas', () => {
const result = generateWilderness({
levelId: 2,
levelName: 'Cold Plains',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 64,
sizeY: 64,
subType: 1,
subTheme: 0,
seed: 2001,
pieces: makeMockBorderPieces('Act 1'),
substitutions: [],
})
expect(result.stats.proceduralRoadCells).toBeGreaterThan(0)
expect(result.stats.proceduralDividerCells).toBeGreaterThan(0)
expect(result.stats.proceduralWallCells).toBe(result.stats.proceduralDividerCells)
// Count actual divider wall cells on canvas (walls with style 2, prop1 129)
let wallCount = 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.walls.some(w => !w.hidden && w.style === 2 && w.prop1 === 129)) {
wallCount += 1
}
}
}
expect(result.stats.proceduralWallCells).toBe(wallCount)
})
test('Act 2 desert tracks procedural roads, water (oasis), and walls (canyon + berms)', () => {
const rocky = generateWilderness({
levelId: 41,
levelName: 'Rocky Waste',
levelTypeName: 'Act 2 - Desert',
sizeX: 64,
sizeY: 64,
subType: 0,
subTheme: 0,
seed: 41001,
pieces: makeMockBorderPieces('Act 2'),
substitutions: [],
})
expect(rocky.stats.proceduralRoadCells).toBeGreaterThan(0)
expect(rocky.stats.proceduralWallCells).toBeGreaterThan(0)
const oasis = generateWilderness({
levelId: 43,
levelName: 'Far Oasis',
levelTypeName: 'Act 2 - Desert',
sizeX: 64,
sizeY: 64,
subType: 0,
subTheme: 0,
seed: 43001,
pieces: makeMockBorderPieces('Act 2'),
substitutions: [],
})
expect(oasis.stats.proceduralRoadCells).toBeGreaterThan(0)
expect(oasis.stats.proceduralWaterCells).toBeGreaterThan(0)
})
test('Act 3 Kurast Causeway tracks causeway, canal, and balustrade cells', () => {
const result = generateWilderness({
levelId: 82,
levelName: 'Kurast Causeway',
levelTypeName: 'Act 3 - Kurast',
sizeX: 32,
sizeY: 64,
subType: 0,
subTheme: 0,
seed: 82001,
pieces: [{ name: 'Act 3 - Bridge', border: false, levels: [makeMockDs1(16, 16)] }],
substitutions: [],
})
expect(result.stats.proceduralRoadCells).toBeGreaterThan(0)
expect(result.stats.proceduralWaterCells).toBeGreaterThan(0)
expect(result.stats.proceduralWallCells).toBeGreaterThan(0)
})
test('Act 5 Siege levels track barricade walls, trenches, and frozen lakes', () => {
const result = generateWilderness({
levelId: 112,
levelName: 'Arreat Plateau',
levelTypeName: 'Act 5 - Barricade',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 112001,
pieces: makeMockBorderPieces('Act 5'),
substitutions: [],
})
expect(result.stats.proceduralWallCells).toBeGreaterThan(0)
expect(result.stats.proceduralRoadCells).toBeGreaterThan(0)
expect(result.stats.proceduralWaterCells).toBeGreaterThan(0)
})
})