fix(maze): 让 UNIMPLEMENTED_PASSES 反映真实缺口

`UNIMPLEMENTED_PASSES` 此前被硬编码为 `[]`,注释声称 11 个核心地牢 pass 全部
实现,于是 `stats.unimplementedPasses` 在每一关都无条件报告「零缺口」——包括
那些 `specials` 表为空、根本不生成楼梯房间的 level type。

改为从 `MAZE_LEVEL_TYPE_PROFILES` 实测推导:

- 新增 `unimplementedPassesFor(levelTypeName)`,按 level type 返回缺口:
  没有 profile 行,或 `specials` 为空且不是 `singleRoom` 定制关。
- `UNIMPLEMENTED_PASSES` 改为所有 level type 缺口的并集,因此非空。
- `generateMaze` 的 `stats.unimplementedPasses` 改报本关自己的缺口,
  而不是全局常量;`unresolvedRoles` / `notes` 本来就已随 stats 返回。

同时修正两处已经与代码不符的注释:`maze.ts` 里「per-act 装饰 pass 均未复现」
(它们已在 `maze-special-passes.ts` 中实现),以及 `pack-act-assets.ts` 里
「第二幕下水道 / 墓穴 / 第三幕地牢 specials 为空」(这三者现在都有 specials,
真正为空的是 Harem、Basement、Spider、Act 5 Lava)。

测试:原 `UNIMPLEMENTED_PASSES is completely empty` 断言的是被修正的错误行为,
改为断言它恰好列出所有 `specials` 为空的 level type;另加 4 个用例覆盖
按 level type 查询与 `generateMaze` 的 stats 透出。

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
troytt 2026-09-17 11:29:06 +00:00
parent 4ece5aa3c1
commit 5b3a9db986
3 changed files with 110 additions and 13 deletions

View File

@ -730,9 +730,10 @@ function buildSceneLinks(
placedSeams.add(edge.to) placedSeams.add(edge.to)
} }
// 5. Last resort. Several level types — the Act 2 sewers, the tombs, the // 5. Last resort. Several level types — the Act 2 Harem and Basement, the
// Act 3 dungeons — have an empty `specials` table in `MAZE_LEVEL_TYPE_PROFILES` // Act 3 Spider Cavern, the Act 5 Infernal Pit — have an empty `specials`
// because their DRLG staircase pass has not been transcribed, so they // table in `MAZE_LEVEL_TYPE_PROFILES` because their DRLG staircase pass
// has not been transcribed (`unimplementedPassesFor` names them), so they
// stamp no stair room, and their pieces carry no marker either. Leaving // stamp no stair room, and their pieces carry no marker either. Leaving
// those edges unplaced makes a level you can enter and never leave, which // those edges unplaced makes a level you can enter and never leave, which
// is a worse lie than an approximate staircase. Each one is spread to a // is a worse lie than an approximate staircase. Each one is spread to a

View File

@ -594,8 +594,11 @@ function entries(plainSides: readonly string[] = NESW): Omit<SpecialStep, 'kind'
* `specials` covers the replacement tables that live inline in that function. * `specials` covers the replacement tables that live inline in that function.
* The per-act decoration passes that follow it in D2MOO — `DRLGMAZE_PlaceAct2TombStuff`, * The per-act decoration passes that follow it in D2MOO — `DRLGMAZE_PlaceAct2TombStuff`,
* `DRLGMAZE_PlaceAct3DungeonStuff`, `DRLGMAZE_PlaceAct5IceStuff` and the rest — * `DRLGMAZE_PlaceAct3DungeonStuff`, `DRLGMAZE_PlaceAct5IceStuff` and the rest —
* are **not** reproduced; they are listed in `stats.unimplementedPasses` so the * live in `maze-special-passes.ts` and are named by a row's `specialPass`; each
* omission is visible rather than silent. * one that runs is listed in `stats.specialPassesApplied`. A row with an empty
* `specials` table has no transcribed staircase pass at all, and
* {@link unimplementedPassesFor} reports that gap in `stats.unimplementedPasses`
* so the omission is visible rather than silent.
*/ */
export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
{ {
@ -850,10 +853,47 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
export const MAZE_SHARED_PRESET_NAMES: readonly string[] = ['Act 4 - Lava X'] export const MAZE_SHARED_PRESET_NAMES: readonly string[] = ['Act 4 - Lava X']
/** /**
* Per-act decoration passes D2MOO runs after the layout. * The gaps one level type still has, derived from its own profile row.
* All 11 core dungeon passes are now implemented in `maze-special-passes.ts`. *
* This used to be a module constant hardcoded to `[]` with a comment claiming
* every pass was implemented, which made `stats.unimplementedPasses` report
* "no gaps" for a level that stamps no staircase at all. The list is now
* *measured* from {@link MAZE_LEVEL_TYPE_PROFILES}, so it cannot drift away
* from the table it describes:
*
* - **no profile row** — `generateMaze` cannot build the type at all.
* - **an empty `specials` table** — the `Prev`/`Next`/quest replacement rows
* that `DRLGMAZE_GenerateLevel` runs inline have not been transcribed, so the
* level stamps no staircase room and the packer has to invent a fallback warp
* (see `scripts/pack-act-assets.ts`). A `singleRoom` type is exempt: it is one
* fixed `LvlPrest.txt` row whose stairs are part of the artwork.
*
* The 11 per-act decoration passes named in D2MOO (`DRLGMAZE_PlaceAct2TombStuff`
* and the rest) *are* reproduced, in `maze-special-passes.ts`; a type that runs
* one names it in `stats.specialPassesApplied` instead.
*
* @param levelTypeName - the `LvlTypes.txt` name.
* @returns the gaps, empty when the type is fully transcribed.
*/ */
export const UNIMPLEMENTED_PASSES: readonly string[] = [] export function unimplementedPassesFor(levelTypeName: string): readonly string[] {
const profile = MAZE_LEVEL_TYPE_PROFILES.find(candidate => candidate.levelTypeName === levelTypeName)
if (profile === undefined) return [`DRLGMAZE_GenerateLevel profile (${levelTypeName})`]
const gaps: string[] = []
if (profile.specials.length === 0 && profile.singleRoom === undefined) {
gaps.push(`DRLGMAZE_GenerateLevel specials table (${levelTypeName})`)
}
return gaps
}
/**
* Every gap {@link unimplementedPassesFor} can name, across all level types.
*
* Non-empty while any level type is still missing its replacement table. Prefer
* the per-level-type function: a caller that reports this constant for one level
* is reporting other levels' gaps as its own.
*/
export const UNIMPLEMENTED_PASSES: readonly string[] = MAZE_LEVEL_TYPE_PROFILES
.flatMap(profile => unimplementedPassesFor(profile.levelTypeName))
/* ------------------------------------------------------------------------- * /* ------------------------------------------------------------------------- *
* Geometry * Geometry
@ -2327,7 +2367,7 @@ export function generateMaze(request: MazeRequest): MazeResult {
} }
: null, : null,
unresolvedRoles: stats.unresolvedRoles, unresolvedRoles: stats.unresolvedRoles,
unimplementedPasses: UNIMPLEMENTED_PASSES, unimplementedPasses: unimplementedPassesFor(levelTypeName),
maxCellsX, maxCellsX,
maxCellsY, maxCellsY,
boundsRejected: stats.boundsRejected ?? 0, boundsRejected: stats.boundsRejected ?? 0,

View File

@ -13,7 +13,8 @@
* 9. DRLGMAZE_PlaceAct1Barracks (Monastery Barracks Smith Forge & Court Connect) * 9. DRLGMAZE_PlaceAct1Barracks (Monastery Barracks Smith Forge & Court Connect)
* 10. DRLGMAZE_PlaceAct4Lava (River of Flame Hellforge, Bridges 1 & 2, Warp, Lava X fillers) * 10. DRLGMAZE_PlaceAct4Lava (River of Flame Hellforge, Bridges 1 & 2, Warp, Lava X fillers)
* 11. DRLGMAZE_ScanReplaceSpecialAct2SewersPresets (Lut Gholein Sewers Radament's Lair, Dual Entrances, Waypoint) * 11. DRLGMAZE_ScanReplaceSpecialAct2SewersPresets (Lut Gholein Sewers Radament's Lair, Dual Entrances, Waypoint)
* 12. Full generateMaze integration tests verifying specialPassesApplied and UNIMPLEMENTED_PASSES == []. * 12. Full generateMaze integration tests verifying specialPassesApplied and the
* per-level-type `unimplementedPasses` gap list.
*/ */
import { describe, expect, test } from 'vitest' import { describe, expect, test } from 'vitest'
@ -21,7 +22,9 @@ import type { Ds1, Ds1Cell, Ds1Object } from '../src/formats/ds1.ts'
import { import {
generateMaze, generateMaze,
classifyMazePieceName, classifyMazePieceName,
MAZE_LEVEL_TYPE_PROFILES,
UNIMPLEMENTED_PASSES, UNIMPLEMENTED_PASSES,
unimplementedPassesFor,
type MazePiece, type MazePiece,
type MazePieceKind, type MazePieceKind,
type MazeRequest, type MazeRequest,
@ -106,11 +109,64 @@ function createStandardPieceSet(levelTypeName: string, extraPieces: MazePiece[]
} }
describe('Diablo II DRLG Maze Special Passes', () => { describe('Diablo II DRLG Maze Special Passes', () => {
test('UNIMPLEMENTED_PASSES is completely empty', () => { // Was `expect(UNIMPLEMENTED_PASSES).toEqual([])`: the constant was hardcoded
expect(UNIMPLEMENTED_PASSES).toEqual([]) // to `[]`, so it asserted a claim the profile table contradicts. The gap list
expect(UNIMPLEMENTED_PASSES.length).toBe(0) // is now derived, so the honest assertion is "it names exactly the level types
// whose `specials` table is still empty".
test('UNIMPLEMENTED_PASSES names every level type with no transcribed specials table', () => {
const emptySpecials = MAZE_LEVEL_TYPE_PROFILES
.filter(profile => profile.specials.length === 0 && profile.singleRoom === undefined)
.map(profile => profile.levelTypeName)
expect(emptySpecials.length).toBeGreaterThan(0)
expect(UNIMPLEMENTED_PASSES).toEqual(
emptySpecials.map(name => `DRLGMAZE_GenerateLevel specials table (${name})`),
)
}) })
test('unimplementedPassesFor is empty for a fully transcribed level type', () => {
expect(unimplementedPassesFor('Act 1 - Cave')).toEqual([])
expect(unimplementedPassesFor('Act 2 - Tomb')).toEqual([])
})
test('unimplementedPassesFor reports the gap for a level type with no specials', () => {
expect(unimplementedPassesFor('Act 3 - Spider'))
.toEqual(['DRLGMAZE_GenerateLevel specials table (Act 3 - Spider)'])
})
test('unimplementedPassesFor reports a level type that has no profile at all', () => {
expect(unimplementedPassesFor('Act 9 - Nowhere'))
.toEqual(['DRLGMAZE_GenerateLevel profile (Act 9 - Nowhere)'])
})
test('generateMaze reports the level type gap, not a global constant', () => {
const spider = generateMaze({
levelId: 84,
levelName: 'Spider Cavern',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 8484,
pieces: createStandardPieceSet('Act 3 - Spider'),
})
expect(spider.stats.unimplementedPasses)
.toEqual(['DRLGMAZE_GenerateLevel specials table (Act 3 - Spider)'])
// The other two diagnostic channels must travel with it.
expect(Array.isArray(spider.stats.unresolvedRoles)).toBe(true)
expect(Array.isArray(spider.stats.notes)).toBe(true)
const cave = generateMaze({
levelId: 8,
levelName: 'Den of Evil',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 8484,
pieces: createStandardPieceSet('Act 1 - Cave'),
})
expect(cave.stats.unimplementedPasses).toEqual([])
})
/* ----------------------------------------------------------------------- * /* ----------------------------------------------------------------------- *
* 1. Act 2 - Tomb * 1. Act 2 - Tomb
* ----------------------------------------------------------------------- */ * ----------------------------------------------------------------------- */