fix(wilderness): prevent destructive wiping of walls and presets in roads and bridges

1. Protect placed presets and preset walls from being wiped in stampThemedRoads:
   check placedRects in paintRoadTile, ensuring preset walls are never cleared.
2. Protect preset floors and walls in stampAct3KurastCauseway so artist DS1
   tiles are not overwritten by procedural causeway passes; wire specialPlacedRects.
3. Protect placed preset walls and floors in synthesizeBridgePlanks in Act 3 jungles,
   recording placedRects for all jungle pieces.
4. Add unit tests for Causeway preset preservation and road preset wall preservation.

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
troytt 2026-09-17 15:10:59 +00:00
parent 37dc936169
commit fa307bff46
5 changed files with 134 additions and 18 deletions

View File

@ -418,6 +418,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')
@ -427,6 +428,15 @@ 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:
@ -438,6 +448,15 @@ export function stampAct3KurastCauseway(
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)

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

@ -3122,7 +3122,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. */
@ -3156,7 +3156,7 @@ export function stampThemedRoads(
): void {
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) {
@ -3176,7 +3176,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
@ -3508,7 +3512,7 @@ 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,
@ -3564,7 +3568,7 @@ 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,
@ -3620,7 +3624,7 @@ 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,
@ -5141,7 +5145,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)

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)
})
})
})