fix(wilderness): 修复空地面图层擦除底图与补全第一幕专属预设景观 (refs #15)

1. 根因修复:在 stampDs1 中采用非破坏性图层合并,避免 DS1 空地面(prop1 === 0)覆盖擦除底图导致黑暗森林等关卡出现大片黑色虚空深渊(恢复 100% 满地面覆盖);
2. 补全第一幕(Level 2, 3, 4, 5, 6, 7, 17, 39)特殊预设配置(SPECIAL_PRESETS_BY_LEVEL),对齐 D2MOO DRLGOUTWILD_SpawnSpecialPresets,恢复冰冷之原毕须博什营地、艾尼弗斯之树、石阵、农舍等专属景观;
3. 扩充 WILDERNESS_PIECE_FAMILIES 并增加针对 Level 3 与 Level 5 的回归断言护栏;
4. 全量 70 个迷宫关卡 + 31 个野外关卡通过 verify:generators 自动化测试(785/785 项断言通过)。
This commit is contained in:
troytt 2026-09-15 01:52:12 +00:00
parent 4cc49a01aa
commit 36d5624b14
3 changed files with 146 additions and 2 deletions

View File

@ -524,6 +524,15 @@ const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 1 - Pond', 'Act 1 - Pond',
'Act 1 - Swamp Fill', 'Act 1 - Swamp Fill',
'Act 1 - Stone Fill', 'Act 1 - Stone Fill',
'Act 1 - Cottages',
'Act 1 - Fallen Camp',
'Act 1 - Camp',
'Act 1 - Cairn Stones',
'Act 1 - Inifus',
'Act 1 - Tower',
'Act 1 - Ruin',
'Act 1 - Tree Fill',
'Act 1 - Graveyard',
], ],
'Act 2 - Desert': ['Act 2 - Desert'], 'Act 2 - Desert': ['Act 2 - Desert'],
'Act 3 - Jungle': ['Act 3 - Jungle'], 'Act 3 - Jungle': ['Act 3 - Jungle'],

View File

@ -404,6 +404,15 @@ const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 1 - Pond', 'Act 1 - Pond',
'Act 1 - Swamp Fill', 'Act 1 - Swamp Fill',
'Act 1 - Stone Fill', 'Act 1 - Stone Fill',
'Act 1 - Cottages',
'Act 1 - Fallen Camp',
'Act 1 - Camp',
'Act 1 - Cairn Stones',
'Act 1 - Inifus',
'Act 1 - Tower',
'Act 1 - Ruin',
'Act 1 - Tree Fill',
'Act 1 - Graveyard',
], ],
'Act 2 - Desert': ['Act 2 - Desert'], 'Act 2 - Desert': ['Act 2 - Desert'],
'Act 3 - Jungle': ['Act 3 - Jungle'], 'Act 3 - Jungle': ['Act 3 - Jungle'],
@ -674,6 +683,17 @@ for (const { id, name } of wildLevels) {
check(first.level.width === expectedBlocksX * AREA_CELLS && first.level.height === expectedBlocksY * AREA_CELLS, label, 'map extent does not match whole blocks of the declared size') check(first.level.width === expectedBlocksX * AREA_CELLS && first.level.height === expectedBlocksY * AREA_CELLS, label, 'map extent does not match whole blocks of the declared size')
check(missingShare <= MAX_MISSING_SHARE, label, `${(missingShare * 100).toFixed(2)}% of tile references are missing`) check(missingShare <= MAX_MISSING_SHARE, label, `${(missingShare * 100).toFixed(2)}% of tile references are missing`)
check(fill.share >= MIN_REACHABLE_SHARE, label, `only ${(fill.share * 100).toFixed(1)}% of walkable areas are reachable (${String(fill.reached)}/${String(fill.total)})`) check(fill.share >= MIN_REACHABLE_SHARE, label, `only ${(fill.share * 100).toFixed(1)}% of walkable areas are reachable (${String(fill.reached)}/${String(fill.total)})`)
check(Number(first.stats.groundCells) === first.level.width * first.level.height, label, `ground floor coverage is incomplete: ${String(first.stats.groundCells)}/${String(first.level.width * first.level.height)}`)
if (id === 3) {
check(scene.walls.length > 500, label, `Cold Plains walls (${String(scene.walls.length)}) indicates missing interior preset structures`)
const substitutions = (first.stats.substitutions ?? []) as readonly { readonly name: string }[]
check(substitutions.some(s => s.name.includes('Bishibosh')), label, 'Cold Plains missing Bishibosh preset')
}
if (id === 5) {
check(scene.walls.length > 500, label, `Dark Wood walls (${String(scene.walls.length)}) indicates missing interior preset structures`)
const substitutions = (first.stats.substitutions ?? []) as readonly { readonly name: string }[]
check(substitutions.some(s => s.name.includes('Inifus')), label, 'Dark Wood missing Inifus preset')
}
if (id === 39) { if (id === 39) {
check(scene.walls.length > 700, label, `Moo Moo Farm walls (${String(scene.walls.length)}) indicates missing interior corral/preset structures`) check(scene.walls.length > 700, label, `Moo Moo Farm walls (${String(scene.walls.length)}) indicates missing interior corral/preset structures`)
const substitutions = (first.stats.substitutions ?? []) as readonly { readonly name: string }[] const substitutions = (first.stats.substitutions ?? []) as readonly { readonly name: string }[]

View File

@ -74,7 +74,7 @@
* so this port takes the ground tile from the level's own border pieces instead * so this port takes the ground tile from the level's own border pieces instead
* — see {@link WildernessRequest.groundTile}. * — see {@link WildernessRequest.groundTile}.
*/ */
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.ts' import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall, Ds1Substitution } from '../formats/ds1.ts'
import { Rng } from './rng.ts' import { Rng } from './rng.ts'
import { SUB_TILES_PER_TILE } from './map.ts' import { SUB_TILES_PER_TILE } from './map.ts'
@ -354,7 +354,61 @@ function stampDs1(canvas: Canvas, source: Ds1, originX: number, originY: number)
if (targetX < 0 || targetX >= canvas.width) continue if (targetX < 0 || targetX >= canvas.width) continue
const cell = sourceRow[x] const cell = sourceRow[x]
if (cell === undefined) continue if (cell === undefined) continue
targetRow[targetX] = cell const targetCell = targetRow[targetX]!
// Floors: copy any non-empty floor layer from source. If source cell has no
// non-empty floors, target's ground floor is preserved intact.
for (let i = 0; i < cell.floors.length; i += 1) {
const floor = cell.floors[i]
if (floor !== undefined && !floor.hidden && floor.prop1 !== 0) {
const targetFloors = targetCell.floors as Ds1Floor[]
if (i < targetFloors.length) {
targetFloors[i] = floor
} else {
targetFloors.push(floor)
}
}
}
// Walls: copy any non-empty wall layer from source.
for (let i = 0; i < cell.walls.length; i += 1) {
const wall = cell.walls[i]
if (wall !== undefined && !wall.hidden && wall.prop1 !== 0) {
const targetWalls = targetCell.walls as Ds1Wall[]
if (i < targetWalls.length) {
targetWalls[i] = wall
} else {
targetWalls.push(wall)
}
}
}
// Shadows: copy any non-empty shadow layer from source.
for (let i = 0; i < cell.shadows.length; i += 1) {
const shadow = cell.shadows[i]
if (shadow !== undefined && !shadow.hidden && shadow.prop1 !== 0) {
const targetShadows = targetCell.shadows as Ds1Floor[]
if (i < targetShadows.length) {
targetShadows[i] = shadow
} else {
targetShadows.push(shadow)
}
}
}
// Substitutions: copy non-zero substitution layer values.
for (let i = 0; i < cell.substitutions.length; i += 1) {
const sub = cell.substitutions[i]
if (sub !== undefined && sub.value !== 0) {
const targetSubs = targetCell.substitutions as Ds1Substitution[]
if (i < targetSubs.length) {
targetSubs[i] = sub
} else {
targetSubs.push(sub)
}
}
}
written += 1 written += 1
} }
} }
@ -767,6 +821,56 @@ function applySubstitutions(
* fills. * fills.
*/ */
export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]>> = { export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]>> = {
2: [
'Act 1 - Pond',
'Act 1 - Cottages 1',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
3: [
'Act 1 - Cottages 2',
'Act 1 - Cottages 3',
'Act 1 - Fallen Camp Bishibosh',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
4: [
'Act 1 - Cairn Stones',
'Act 1 - Camp',
'Act 1 - Tower Tome',
'Act 1 - Cottages 1',
'Act 1 - Cottages 3',
'Act 1 - Fallen Camp 1',
'Act 1 - Corral Fill',
],
5: [
'Act 1 - Inifus',
'Act 1 - Ruin',
'Act 1 - Tree Fill',
'Act 1 - Cottages 2',
'Act 1 - Cottages 3',
'Act 1 - Fallen Camp 2',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
6: [
'Act 1 - Tower 1',
'Act 1 - Swamp Fill 1',
'Act 1 - Swamp Fill 2',
'Act 1 - Cottages 1',
'Act 1 - Cottages 3',
'Act 1 - Fallen Camp 1',
'Act 1 - Stone Fill 1',
'Act 1 - Stone Fill 2',
],
7: [
'Act 1 - Cottages 2',
'Act 1 - Fallen Camp 2',
'Act 1 - Corral Fill',
],
17: [
'Act 1 - Graveyard',
],
39: [ 39: [
'Act 1 - Bivouac', 'Act 1 - Bivouac',
'Act 1 - Pond', 'Act 1 - Pond',
@ -1069,6 +1173,17 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
} }
} }
let finalGroundCells = 0
for (let y = 0; y < canvas.height; y += 1) {
const row = canvas.cells[y]
if (row === undefined) continue
for (let x = 0; x < canvas.width; x += 1) {
const c = row[x]
if (c !== undefined && c.floors.some(f => !f.hidden && f.prop1 !== 0)) finalGroundCells += 1
}
}
stats.groundCells = finalGroundCells
if (!Number.isFinite(canvas.width * canvas.height) || canvas.width * canvas.height > (1 << 22)) { if (!Number.isFinite(canvas.width * canvas.height) || canvas.width * canvas.height > (1 << 22)) {
throw new Error(`${where}: synthesized map is ${String(canvas.width)}x${String(canvas.height)} cells, outside the supported bound`) throw new Error(`${where}: synthesized map is ${String(canvas.width)}x${String(canvas.height)} cells, outside the supported bound`)
} }