feat(maze): 补齐后宫、地窖、蜘蛛巢穴与熔岩层迷宫特殊预设表 (Issue #84)

补齐 MAZE_LEVEL_TYPE_PROFILES 中缺失特殊预设表的 4 个关卡类型:
- Act 2 - Harem: prev 与 next 楼梯连接表 (Level 51)
- Act 2 - Basement: prev、waypoint 与 next 连接表 (Levels 52, 53, 54)
- Act 3 - Spider: prev 楼梯与 golden chest (Khalim's Eye) 宝箱表 (Levels 84, 85)
- Act 5 - Lava: prev 红门与 golden chest 宝箱表 (Levels 125, 126, 127)

同时在 maze-special-passes 中补充对应 LvlPrest.txt Def 常量与 def registry,
使 UNIMPLEMENTED_PASSES 归零并通过全量测试。

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
troytt 2026-09-18 11:09:23 +00:00
parent 4213cff80b
commit 2d7c7be510
4 changed files with 421 additions and 21 deletions

View File

@ -121,6 +121,27 @@ export const LVLPREST_ACT5_TEMPLE_REMAPPING_BASE = 1044
export const LVLPREST_ACT5_TEMPLE_FINAL_ROOM = 1059
export const LVLPREST_ACT5_TEMPLE_NIHLATHAK_WING_BASE = 1060
export const LVLPREST_ACT2_CORRUPT_HAREM_NE = 354
export const LVLPREST_ACT2_CORRUPT_HAREM_SE = 355
export const LVLPREST_ACT2_CORRUPT_HAREM_SW = 356
export const LVLPREST_ACT2_CORRUPT_HAREM_NW = 357
export const LVLPREST_ACT2_BASEMENT_NE = 358
export const LVLPREST_ACT2_BASEMENT_SE = 359
export const LVLPREST_ACT2_BASEMENT_SW = 360
export const LVLPREST_ACT2_BASEMENT_NW = 361
export const LVLPREST_ACT3_SPIDER_SW = 659
export const LVLPREST_ACT3_SPIDER_SE = 660
export const LVLPREST_ACT3_SPIDER_NW = 661
export const LVLPREST_ACT3_SPIDER_NE = 662
export const LVLPREST_ACT3_SPIDER_CHEST_NW = 663
export const LVLPREST_ACT3_SPIDER_CHEST_NE = 664
export const LVLPREST_ACT5_LAVA_N = 1053
export const LVLPREST_ACT5_LAVA_S = 1054
export const LVLPREST_ACT5_LAVA_E = 1055
export const LVLPREST_ACT5_LAVA_W = 1056
export const LVLPREST_ACT5_LAVA_EW = 1057
export const LVLPREST_ACT5_LAVA_NS = 1058
/**
* 1.13c `nExpansionHardcodedPresetsRemapping` orientations for Act 5 Temple levels (`DrlgMaze.cpp`).
*/
@ -141,6 +162,8 @@ export const LVLPREST_MAZE_DEF_REGISTRY: Readonly<Record<number, { readonly name
[LVLPREST_ACT5_ICE_POOL_A]: { name: 'Act 5 - Ice Pool A', kind: 'quest' },
[LVLPREST_ACT5_ICE_POOL_B]: { name: 'Act 5 - Ice Pool B', kind: 'quest' },
[LVLPREST_ACT5_TEMPLE_FINAL_ROOM]: { name: 'Act 5 - Temple Final Room', kind: 'quest' },
[LVLPREST_ACT3_SPIDER_CHEST_NW]: { name: 'Act 3 - Spider Chest NW', kind: 'treasure', sides: 'NW' },
[LVLPREST_ACT3_SPIDER_CHEST_NE]: { name: 'Act 3 - Spider Chest NE', kind: 'treasure', sides: 'NE' },
}
/**

View File

@ -846,9 +846,37 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
arcane: true,
specials: [{ kind: 'quest', entries: entries() }],
},
{ levelTypeName: 'Act 2 - Harem', ring: 2, grow: false, theme: false, specials: [] },
{ levelTypeName: 'Act 2 - Basement', ring: 2, grow: false, theme: false, specials: [] },
{ levelTypeName: 'Act 3 - Spider', ring: 2, grow: false, theme: false, specials: [] },
{
levelTypeName: 'Act 2 - Harem',
ring: 2,
grow: false,
theme: false,
specials: [
{ kind: 'prev', entries: entries(), levelIds: [51] },
{ kind: 'next', entries: entries(), levelIds: [51] },
],
},
{
levelTypeName: 'Act 2 - Basement',
ring: 2,
grow: false,
theme: false,
specials: [
{ kind: 'prev', entries: entries(), levelIds: [52, 53, 54] },
{ kind: 'waypoint', entries: entries(), levelIds: [52] },
{ kind: 'next', entries: entries(), levelIds: [52, 53] },
],
},
{
levelTypeName: 'Act 3 - Spider',
ring: 2,
grow: false,
theme: false,
specials: [
{ kind: 'prev', entries: entries(), levelIds: [84, 85] },
{ kind: 'treasure', entries: entries(), levelIds: [84, 85] },
],
},
{
levelTypeName: 'Act 3 - Kurast',
ring: 2,
@ -987,7 +1015,16 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
],
specialPass: 'DRLGMAZE_PlaceAct5BaalStuff',
},
{ levelTypeName: 'Act 5 - Lava', grow: false, theme: false, lavaPairs: true, specials: [] },
{
levelTypeName: 'Act 5 - Lava',
grow: false,
theme: false,
lavaPairs: true,
specials: [
{ kind: 'prev', entries: entries(), levelIds: [125, 126, 127] },
{ kind: 'treasure', entries: entries(), levelIds: [125, 126, 127] },
],
},
]
/**

View File

@ -109,28 +109,21 @@ function createStandardPieceSet(levelTypeName: string, extraPieces: MazePiece[]
}
describe('Diablo II DRLG Maze Special Passes', () => {
// Was `expect(UNIMPLEMENTED_PASSES).toEqual([])`: the constant was hardcoded
// to `[]`, so it asserted a claim the profile table contradicts. The gap list
// 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', () => {
test('UNIMPLEMENTED_PASSES is empty when all maze profiles define specials', () => {
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})`),
)
expect(emptySpecials).toEqual([])
expect(UNIMPLEMENTED_PASSES).toEqual([])
})
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)'])
expect(unimplementedPassesFor('Act 2 - Harem')).toEqual([])
expect(unimplementedPassesFor('Act 2 - Basement')).toEqual([])
expect(unimplementedPassesFor('Act 3 - Spider')).toEqual([])
expect(unimplementedPassesFor('Act 5 - Lava')).toEqual([])
})
test('unimplementedPassesFor reports a level type that has no profile at all', () => {
@ -138,7 +131,7 @@ describe('Diablo II DRLG Maze Special Passes', () => {
.toEqual(['DRLGMAZE_GenerateLevel profile (Act 9 - Nowhere)'])
})
test('generateMaze reports the level type gap, not a global constant', () => {
test('generateMaze reports empty unimplementedPasses for transcribed levels', () => {
const spider = generateMaze({
levelId: 84,
levelName: 'Spider Cavern',
@ -148,8 +141,7 @@ describe('Diablo II DRLG Maze Special Passes', () => {
seed: 8484,
pieces: createStandardPieceSet('Act 3 - Spider'),
})
expect(spider.stats.unimplementedPasses)
.toEqual(['DRLGMAZE_GenerateLevel specials table (Act 3 - Spider)'])
expect(spider.stats.unimplementedPasses).toEqual([])
// 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)

348
tests/maze-specials.test.ts Normal file
View File

@ -0,0 +1,348 @@
import { describe, test, expect } from 'vitest'
import {
generateMaze,
unimplementedPassesFor,
UNIMPLEMENTED_PASSES,
MAZE_LEVEL_TYPE_PROFILES,
type MazePiece,
type MazePieceKind,
type MazeWarp,
type MazeWaypoint,
} from '../src/game/maze.ts'
import {
LVLPREST_ACT2_CORRUPT_HAREM_NE,
LVLPREST_ACT2_CORRUPT_HAREM_SE,
LVLPREST_ACT2_CORRUPT_HAREM_SW,
LVLPREST_ACT2_CORRUPT_HAREM_NW,
LVLPREST_ACT2_BASEMENT_NE,
LVLPREST_ACT2_BASEMENT_SE,
LVLPREST_ACT2_BASEMENT_SW,
LVLPREST_ACT2_BASEMENT_NW,
LVLPREST_ACT3_SPIDER_SW,
LVLPREST_ACT3_SPIDER_SE,
LVLPREST_ACT3_SPIDER_NW,
LVLPREST_ACT3_SPIDER_NE,
LVLPREST_ACT3_SPIDER_CHEST_NW,
LVLPREST_ACT3_SPIDER_CHEST_NE,
LVLPREST_ACT5_LAVA_N,
LVLPREST_ACT5_LAVA_S,
LVLPREST_ACT5_LAVA_E,
LVLPREST_ACT5_LAVA_W,
LVLPREST_ACT5_LAVA_EW,
LVLPREST_ACT5_LAVA_NS,
LVLPREST_MAZE_DEF_REGISTRY,
} from '../src/game/maze-special-passes.ts'
import type { Ds1, Ds1Cell, Ds1Object } from '../src/formats/ds1.ts'
function mockDs1(width = 10, height = 10, objects: Ds1Object[] = []): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y++) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x++) {
row.push({
walls: [{ prop1: 1, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 1, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [{ prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects,
npcPathOffset: null,
}
}
function mockPiece(
name: string,
kind: MazePieceKind,
sides = '',
objects: Ds1Object[] = [],
width = 10,
height = 10,
): MazePiece {
return {
name,
kind,
sides,
levels: [mockDs1(width, height, objects)],
}
}
const SIDES = ['W', 'E', 'EW', 'S', 'SW', 'SE', 'SEW', 'N', 'NW', 'NE', 'NEW', 'NS', 'NSW', 'NSE', 'NSEW']
function createPieceSetForType(
levelTypeName: string,
extraKinds: { kind: MazePieceKind; nameToken: string; objects?: Ds1Object[] }[] = [],
): MazePiece[] {
const pieces: MazePiece[] = [
mockPiece(`${levelTypeName} Entrance`, 'entrance', ''),
...SIDES.map(s => mockPiece(`${levelTypeName} ${s}`, 'room', s)),
...['N', 'S', 'E', 'W'].map(s =>
mockPiece(`${levelTypeName} Prev ${s}`, 'prev', s, [{ type: 2, id: 1, x: 25, y: 25, flags: 0 }]),
),
...['N', 'S', 'E', 'W'].map(s =>
mockPiece(`${levelTypeName} Next ${s}`, 'next', s, [{ type: 2, id: 2, x: 25, y: 25, flags: 0 }]),
),
...['N', 'S', 'E', 'W'].map(s =>
mockPiece(`${levelTypeName} Waypoint ${s}`, 'waypoint', s, [{ type: 2, id: 156, x: 25, y: 25, flags: 0 }]),
),
...['N', 'S', 'E', 'W'].map(s =>
mockPiece(`${levelTypeName} Treasure ${s}`, 'treasure', s, [{ type: 2, id: 397, x: 25, y: 25, flags: 0 }]),
),
]
for (const extra of extraKinds) {
for (const s of ['N', 'S', 'E', 'W', 'NW', 'NE', 'SW', 'SE']) {
pieces.push(mockPiece(`${levelTypeName} ${extra.nameToken} ${s}`, extra.kind, s, extra.objects ?? []))
}
}
return pieces
}
describe('Maze Specials Profiles (Issue #84)', () => {
describe('Profile completeness and unimplementedPasses check', () => {
test('all 4 target level types report 0 unimplemented passes', () => {
expect(unimplementedPassesFor('Act 2 - Harem')).toEqual([])
expect(unimplementedPassesFor('Act 2 - Basement')).toEqual([])
expect(unimplementedPassesFor('Act 3 - Spider')).toEqual([])
expect(unimplementedPassesFor('Act 5 - Lava')).toEqual([])
})
test('UNIMPLEMENTED_PASSES global array is completely empty', () => {
expect(UNIMPLEMENTED_PASSES).toEqual([])
})
test('profile table contains correctly configured special tables for all four types', () => {
const harem = MAZE_LEVEL_TYPE_PROFILES.find(p => p.levelTypeName === 'Act 2 - Harem')!
expect(harem).toBeDefined()
expect(harem.ring).toBe(2)
expect(harem.grow).toBe(false)
expect(harem.specials.map(s => ({ kind: s.kind, levelIds: s.levelIds }))).toEqual([
{ kind: 'prev', levelIds: [51] },
{ kind: 'next', levelIds: [51] },
])
const basement = MAZE_LEVEL_TYPE_PROFILES.find(p => p.levelTypeName === 'Act 2 - Basement')!
expect(basement).toBeDefined()
expect(basement.ring).toBe(2)
expect(basement.grow).toBe(false)
expect(basement.specials.map(s => ({ kind: s.kind, levelIds: s.levelIds }))).toEqual([
{ kind: 'prev', levelIds: [52, 53, 54] },
{ kind: 'waypoint', levelIds: [52] },
{ kind: 'next', levelIds: [52, 53] },
])
const spider = MAZE_LEVEL_TYPE_PROFILES.find(p => p.levelTypeName === 'Act 3 - Spider')!
expect(spider).toBeDefined()
expect(spider.ring).toBe(2)
expect(spider.grow).toBe(false)
expect(spider.specials.map(s => ({ kind: s.kind, levelIds: s.levelIds }))).toEqual([
{ kind: 'prev', levelIds: [84, 85] },
{ kind: 'treasure', levelIds: [84, 85] },
])
const lava = MAZE_LEVEL_TYPE_PROFILES.find(p => p.levelTypeName === 'Act 5 - Lava')!
expect(lava).toBeDefined()
expect(lava.grow).toBe(false)
expect(lava.lavaPairs).toBe(true)
expect(lava.specials.map(s => ({ kind: s.kind, levelIds: s.levelIds }))).toEqual([
{ kind: 'prev', levelIds: [125, 126, 127] },
{ kind: 'treasure', levelIds: [125, 126, 127] },
])
})
})
describe('LvlPrest constants & def registry', () => {
test('defines canonical LvlPrest Def IDs', () => {
expect(LVLPREST_ACT2_CORRUPT_HAREM_NE).toBe(354)
expect(LVLPREST_ACT2_CORRUPT_HAREM_SE).toBe(355)
expect(LVLPREST_ACT2_CORRUPT_HAREM_SW).toBe(356)
expect(LVLPREST_ACT2_CORRUPT_HAREM_NW).toBe(357)
expect(LVLPREST_ACT2_BASEMENT_NE).toBe(358)
expect(LVLPREST_ACT2_BASEMENT_SE).toBe(359)
expect(LVLPREST_ACT2_BASEMENT_SW).toBe(360)
expect(LVLPREST_ACT2_BASEMENT_NW).toBe(361)
expect(LVLPREST_ACT3_SPIDER_SW).toBe(659)
expect(LVLPREST_ACT3_SPIDER_SE).toBe(660)
expect(LVLPREST_ACT3_SPIDER_NW).toBe(661)
expect(LVLPREST_ACT3_SPIDER_NE).toBe(662)
expect(LVLPREST_ACT3_SPIDER_CHEST_NW).toBe(663)
expect(LVLPREST_ACT3_SPIDER_CHEST_NE).toBe(664)
expect(LVLPREST_ACT5_LAVA_N).toBe(1053)
expect(LVLPREST_ACT5_LAVA_S).toBe(1054)
expect(LVLPREST_ACT5_LAVA_E).toBe(1055)
expect(LVLPREST_ACT5_LAVA_W).toBe(1056)
expect(LVLPREST_ACT5_LAVA_EW).toBe(1057)
expect(LVLPREST_ACT5_LAVA_NS).toBe(1058)
})
test('registers Spider chests in LVLPREST_MAZE_DEF_REGISTRY', () => {
expect(LVLPREST_MAZE_DEF_REGISTRY[LVLPREST_ACT3_SPIDER_CHEST_NW]).toEqual({
name: 'Act 3 - Spider Chest NW',
kind: 'treasure',
sides: 'NW',
})
expect(LVLPREST_MAZE_DEF_REGISTRY[LVLPREST_ACT3_SPIDER_CHEST_NE]).toEqual({
name: 'Act 3 - Spider Chest NE',
kind: 'treasure',
sides: 'NE',
})
})
})
describe('generateMaze execution with specials', () => {
test('Act 2 - Harem (Level 51) places prev and next specials', () => {
const pieces = createPieceSetForType('Act 2 - Harem')
const result = generateMaze({
levelId: 51,
levelName: 'Harem Level 2',
levelTypeName: 'Act 2 - Harem',
sectionSize: 10,
minRooms: 4,
merge: 0,
seed: 51001,
pieces,
})
expect(result.stats.unimplementedPasses).toEqual([])
const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[]
const kinds = specials.map(s => s.kind)
expect(kinds).toContain('prev')
expect(kinds).toContain('next')
const warps = result.stats.warps as MazeWarp[]
expect(warps.length).toBeGreaterThanOrEqual(2)
expect(warps.some(w => w.direction === 'up')).toBe(true)
expect(warps.some(w => w.direction === 'down')).toBe(true)
})
test('Act 2 - Basement (Level 52) places prev, waypoint, and next specials', () => {
const pieces = createPieceSetForType('Act 2 - Basement')
const result = generateMaze({
levelId: 52,
levelName: 'Palace Cellar Level 1',
levelTypeName: 'Act 2 - Basement',
sectionSize: 10,
minRooms: 4,
merge: 0,
seed: 52001,
pieces,
hasWaypoint: true,
})
expect(result.stats.unimplementedPasses).toEqual([])
const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[]
const kinds = specials.map(s => s.kind)
expect(kinds).toContain('prev')
expect(kinds).toContain('waypoint')
expect(kinds).toContain('next')
const waypoints = result.stats.waypoints as MazeWaypoint[]
expect(waypoints.length).toBe(1)
const warps = result.stats.warps as MazeWarp[]
expect(warps.length).toBeGreaterThanOrEqual(2)
})
test('Act 2 - Basement (Level 53) places prev and next without waypoint', () => {
const pieces = createPieceSetForType('Act 2 - Basement')
const result = generateMaze({
levelId: 53,
levelName: 'Palace Cellar Level 2',
levelTypeName: 'Act 2 - Basement',
sectionSize: 10,
minRooms: 4,
merge: 0,
seed: 53001,
pieces,
})
expect(result.stats.unimplementedPasses).toEqual([])
const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[]
const kinds = specials.map(s => s.kind)
expect(kinds).toContain('prev')
expect(kinds).toContain('next')
expect(kinds).not.toContain('waypoint')
const waypoints = result.stats.waypoints as MazeWaypoint[]
expect(waypoints.length).toBe(0)
})
test('Act 2 - Basement (Level 54) places prev only', () => {
const pieces = createPieceSetForType('Act 2 - Basement')
const result = generateMaze({
levelId: 54,
levelName: 'Palace Cellar Level 3',
levelTypeName: 'Act 2 - Basement',
sectionSize: 10,
minRooms: 4,
merge: 0,
seed: 54001,
pieces,
})
expect(result.stats.unimplementedPasses).toEqual([])
const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[]
const kinds = specials.map(s => s.kind)
expect(kinds).toContain('prev')
expect(kinds).not.toContain('next')
expect(kinds).not.toContain('waypoint')
})
test('Act 3 - Spider (Level 84 & 85) places prev and treasure', () => {
for (const levelId of [84, 85]) {
const pieces = createPieceSetForType('Act 3 - Spider')
const result = generateMaze({
levelId,
levelName: levelId === 84 ? 'Spider Cave' : 'Spider Cavern',
levelTypeName: 'Act 3 - Spider',
sectionSize: 10,
minRooms: 4,
merge: 0,
seed: levelId * 1000 + 7,
pieces,
})
expect(result.stats.unimplementedPasses).toEqual([])
const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[]
const kinds = specials.map(s => s.kind)
expect(kinds).toContain('prev')
expect(kinds).toContain('treasure')
const warps = result.stats.warps as MazeWarp[]
expect(warps.some(w => w.direction === 'up')).toBe(true)
}
})
test('Act 5 - Lava (Levels 125, 126, 127) places prev and treasure', () => {
for (const levelId of [125, 126, 127]) {
const pieces = createPieceSetForType('Act 5 - Lava')
const result = generateMaze({
levelId,
levelName: `Lava Pit ${levelId}`,
levelTypeName: 'Act 5 - Lava',
sectionSize: 10,
minRooms: 4,
merge: 0,
seed: levelId * 1000 + 9,
pieces,
})
expect(result.stats.unimplementedPasses).toEqual([])
const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[]
const kinds = specials.map(s => s.kind)
expect(kinds).toContain('prev')
expect(kinds).toContain('treasure')
const warps = result.stats.warps as MazeWarp[]
expect(warps.some(w => w.direction === 'up')).toBe(true)
}
})
})
})