fix(wilderness): 修复 pack-act-assets 漏传 dt1Libraries 及 wilderness 自动贴图跨章节污染 (Issue #113)

- scripts/pack-act-assets.ts: 在 generateWilderness 中透传 dt1Libraries: resolvedLibraries,保证地面自动过渡与瓦片解析能获得关卡所加载的全部 DT1 库。
- src/game/wilderness.ts:
  - applyGroundTransitions: 当 options.libraries 为空或未指定时直接返回 0 处过渡,修复 isTileAvailable 无库时无条件返回 true 的漏洞。
  - 增加关卡类型与章节编号守卫(非 Act 1 - Wilderness 关卡与非 Act 1 关卡跳过),避免对 Mesa (104)、Desert (43)、Barricade (112, 117) 注入无效瓦片。
  - 在 phase 1 与 phase 2 中严格校验 (update.style, update.sequence) 瓦片是否存在于当前 DT1 库中,确保不会写入不存在的过渡瓦片。
- tests/wilderness-ground-blend.test.ts: 更新并补充 auto-tiling 测试,覆盖无库安全回退以及非 Act 1 关卡拦截。

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
troytt 2026-09-18 14:26:16 +00:00
parent 370dcc05b1
commit 985d85ebb0
3 changed files with 137 additions and 31 deletions

View File

@ -1663,8 +1663,8 @@ for (const entry of LEVELS) {
const rows = await getSubstitutions(subType)
const shrineRows = await getSubstitutions(subShrine)
const libInfo = resolveLevelLibraries(tables, entry.levelId)
const libraries: Dt1[] = []
for (const name of libInfo.dt1Names) libraries.push(await libraryOf(name))
const resolvedLibraries: Dt1[] = []
for (const name of libInfo.dt1Names) resolvedLibraries.push(await libraryOf(name))
// Solved once for the whole world, so all three variants cut the same
// seams on the same edges and any variant docks with any neighbour.
@ -1687,9 +1687,10 @@ for (const entry of LEVELS) {
substitutions: rows,
shrineSubstitutions: shrineRows,
gates,
dt1Libraries: resolvedLibraries,
})
await bakeDs1Variant(
entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries,
entry, entry.name, palette, paletteName, libInfo.dt1Names, resolvedLibraries,
result.level, `generated:${label}`, label, seed,
{
entrances: (result.stats.entrances ?? []) as WildernessEntrance[],

View File

@ -1509,29 +1509,34 @@ export function applyGroundTransitions(
options?: GroundTransitionOptions,
): GroundTransitionResult {
// Ground transition auto-tiling LUTs (DIRT_PATH_TILE_LUT, WATER_EDGE_TILE_LUT) are specific
// to Act 1 Wilderness dirt roads and river water. Skip for other level types.
// to Act 1 Wilderness dirt roads and river water. Skip for other level types and non-Act-1 levels.
if (options?.levelTypeName !== undefined && options.levelTypeName !== 'Act 1 - Wilderness') {
return { transitionCells: 0, roadTransitions: 0, waterTransitions: 0 }
}
if (options?.levelId !== undefined && (options.levelId < 2 || options.levelId > 7)) {
return { transitionCells: 0, roadTransitions: 0, waterTransitions: 0 }
}
// Build set of available floor tiles if libraries are provided
// Require DT1 libraries to confirm tile availability. If not provided or empty, do not apply transitions.
if (!options?.libraries || options.libraries.length === 0) {
return { transitionCells: 0, roadTransitions: 0, waterTransitions: 0 }
}
// Build set of available floor tiles from active DT1 libraries
const availableTiles = new Set<string>()
if (options?.libraries && options.libraries.length > 0) {
const mask = options.dt1Mask ?? 0xFFFFFFFF
for (let libIndex = 0; libIndex < options.libraries.length; libIndex += 1) {
if ((mask & (1 << libIndex)) === 0) continue
const lib = options.libraries[libIndex]
if (!lib) continue
for (const tile of lib.tiles) {
if (tile.type === 0) {
availableTiles.add(`${String(tile.style)}:${String(tile.sequence)}`)
}
const mask = options.dt1Mask ?? 0xFFFFFFFF
for (let libIndex = 0; libIndex < options.libraries.length; libIndex += 1) {
if ((mask & (1 << libIndex)) === 0) continue
const lib = options.libraries[libIndex]
if (!lib) continue
for (const tile of lib.tiles) {
if (tile.type === 0) {
availableTiles.add(`${String(tile.style)}:${String(tile.sequence)}`)
}
}
}
const isTileAvailable = (style: number, sequence: number): boolean => {
if (availableTiles.size === 0) return true
return availableTiles.has(`${String(style)}:${String(sequence)}`)
}
@ -1572,7 +1577,8 @@ export function applyGroundTransitions(
if (!floor || floor.hidden || floor.prop1 === 0) continue
if (isRoadCell(floor)) {
if (floor.style === 0) {
// Limit road transition (DIRT_PATH_TILE_LUT) to environments that actually use style 0 dirt roads
if (floor.style === 0 && (options.levelTypeName === undefined || options.levelTypeName === 'Act 1 - Wilderness')) {
const roadMask = computeNeighborMask(canvas, x, y, (nCell) => {
return isRoadCell(nCell?.floors[0])
})
@ -1595,20 +1601,21 @@ export function applyGroundTransitions(
}
}
} else if (isWaterCell(floor)) {
const waterMask = computeNeighborMask(canvas, x, y, (nCell) => {
return isWaterCell(nCell?.floors[0])
})
// Limit water transition (WATER_EDGE_TILE_LUT) to environments that actually use style 2 water
if (floor.style === 2 && (options.levelTypeName === undefined || options.levelTypeName === 'Act 1 - Wilderness')) {
const waterMask = computeNeighborMask(canvas, x, y, (nCell) => {
return isWaterCell(nCell?.floors[0])
})
if (waterMask === 0xFF) {
// Interior deep water
if (floor.style === 2 && floor.sequence !== 24) {
if (isTileAvailable(2, 24)) {
pendingUpdates.push({ x, y, style: 2, sequence: 24, prop1: 2, isWater: true })
if (waterMask === 0xFF) {
// Interior deep water
if (floor.sequence !== 24) {
if (isTileAvailable(2, 24)) {
pendingUpdates.push({ x, y, style: 2, sequence: 24, prop1: 2, isWater: true })
}
}
}
} else {
// Boundary water cell
if (floor.style === 2) {
} else {
// Boundary water cell
const targetSeq = WATER_EDGE_TILE_LUT[waterMask] ?? 0
if (targetSeq !== floor.sequence) {
if (isTileAvailable(2, targetSeq)) {
@ -1626,6 +1633,9 @@ export function applyGroundTransitions(
let waterTransitions = 0
for (const update of pendingUpdates) {
// Check if the target tile actually exists in the active DT1 libraries before applying
if (!isTileAvailable(update.style, update.sequence)) continue
const cell = canvas.cells[update.y]?.[update.x]
if (!cell) continue
const floor = cell.floors[0]

View File

@ -818,7 +818,15 @@ describe("Wilderness Ground Tile Blending (Issue #52)", () => {
}
}
const res = applyGroundTransitions(canvas)
const roadDt1 = makeMockDt1(
Array.from({ length: 47 }, (_, i) => ({ type: 0, style: 0, sequence: i })),
)
const res = applyGroundTransitions(canvas, {
libraries: [roadDt1],
dt1Mask: 1,
levelTypeName: 'Act 1 - Wilderness',
levelId: 2,
})
expect(res.roadTransitions).toBeGreaterThan(0)
expect(res.waterTransitions).toBe(0)
expect(res.transitionCells).toBe(res.roadTransitions)
@ -861,7 +869,15 @@ describe("Wilderness Ground Tile Blending (Issue #52)", () => {
}
}
const res = applyGroundTransitions(canvas)
const waterDt1 = makeMockDt1(
Array.from({ length: 25 }, (_, i) => ({ type: 0, style: 2, sequence: i })),
)
const res = applyGroundTransitions(canvas, {
libraries: [waterDt1],
dt1Mask: 1,
levelTypeName: 'Act 1 - Wilderness',
levelId: 2,
})
expect(res.waterTransitions).toBeGreaterThan(0)
expect(res.transitionCells).toBe(res.waterTransitions)
@ -881,6 +897,85 @@ describe("Wilderness Ground Tile Blending (Issue #52)", () => {
}
})
test("returns 0 transitions when dt1Libraries are undefined or empty (Issue #113)", () => {
const canvas = createCanvas(9, 9, 1, 1, 0)
for (let y = 0; y < 9; y += 1) {
for (let x = 0; x < 9; x += 1) {
setFloor(canvas, x, y, { style: 0, sequence: 0 })
}
}
for (let ry = 3; ry <= 5; ry += 1) {
for (let rx = 3; rx <= 5; rx += 1) {
setFloor(canvas, rx, ry, { style: 0, sequence: 1 })
}
}
// No options -> 0 transitions
const resNoOptions = applyGroundTransitions(canvas)
expect(resNoOptions.transitionCells).toBe(0)
expect(resNoOptions.roadTransitions).toBe(0)
expect(resNoOptions.waterTransitions).toBe(0)
// Empty libraries array -> 0 transitions
const resEmptyLibs = applyGroundTransitions(canvas, { libraries: [] })
expect(resEmptyLibs.transitionCells).toBe(0)
expect(resEmptyLibs.roadTransitions).toBe(0)
})
test("does not inject transition tiles into non-Act-1 environments (Issue #113)", () => {
const canvas = createCanvas(9, 9, 1, 1, 0)
for (let y = 0; y < 9; y += 1) {
for (let x = 0; x < 9; x += 1) {
setFloor(canvas, x, y, { style: 0, sequence: 0 })
}
}
for (let ry = 3; ry <= 5; ry += 1) {
for (let rx = 3; rx <= 5; rx += 1) {
setFloor(canvas, rx, ry, { style: 0, sequence: 1 })
}
}
const roadDt1 = makeMockDt1(
Array.from({ length: 47 }, (_, i) => ({ type: 0, style: 0, sequence: i })),
)
// Mesa 1 (levelId 104, Act 4 - Mesa)
const resMesa = applyGroundTransitions(canvas, {
libraries: [roadDt1],
dt1Mask: 1,
levelTypeName: 'Act 4 - Mesa',
levelId: 104,
})
expect(resMesa.transitionCells).toBe(0)
// Desert 3 (levelId 43, Act 2 - Desert)
const resDesert = applyGroundTransitions(canvas, {
libraries: [roadDt1],
dt1Mask: 1,
levelTypeName: 'Act 2 - Desert',
levelId: 43,
})
expect(resDesert.transitionCells).toBe(0)
// Barricade 2 (levelId 112, Act 5 - Barricade)
const resBarricade2 = applyGroundTransitions(canvas, {
libraries: [roadDt1],
dt1Mask: 1,
levelTypeName: 'Act 5 - Barricade',
levelId: 112,
})
expect(resBarricade2.transitionCells).toBe(0)
// Barricade Snow (levelId 117, Act 5 - Barricade)
const resBarricadeSnow = applyGroundTransitions(canvas, {
libraries: [roadDt1],
dt1Mask: 1,
levelTypeName: 'Act 5 - Barricade',
levelId: 117,
})
expect(resBarricadeSnow.transitionCells).toBe(0)
})
test("WATER_EDGE_TILE_LUT has exactly 256 entries with 24 at 0xFF", () => {
expect(WATER_EDGE_TILE_LUT).toHaveLength(256)
expect(WATER_EDGE_TILE_LUT[0xFF]).toBe(24)