feat(maze): 还原核心迷宫专用布局例程与基于 LvlPrest Def ID 的精准预设替换 (Issue #73)

以 Diablo II v1.13c D2Common.dll 为黄金标准,对齐 DRLGMAZE_Place*Stuff 系列
专用布局例程:

- 新增 1.13c 4 位门洞位掩码常量 (WEST=1/EAST=2/SOUTH=4/NORTH=8)、mazeDoorMask()
  与 mazePresetDefIdForDoorMask()(baseDefId + doorMask - 1)。
- 补齐 LvlPrest.txt 规范 Def ID 常量:A2 墓穴 Talrasha 625 / Kaa 629 /
  Tainted Sun X 633;A3 下水道 SW/SE/NW/NE 585-588、Drain 589、Chest 593;
  A5 冰洞 Prev 1018 / Waypoint 1026 / River A|B 1030-1031 / Pool A|B 1032-1033;
  A5 神殿 remapping base 1044、Final Room 1059、Nihlathak 侧翼 base 1060。
- 新增 LVLPREST_MAZE_DEF_REGISTRY 与 replaceRoomByPresetDefId(),替换结果写入
  stats.notes(lvlPrestDef=<id> (<name>))便于验证。
- placeAct2TombStuff:新增 levelId 61(爪蛇神庙 2)固定放置太阳祭坛 Def 633。
- placeAct3SewerStuff:levelId 92 放置四角入口房 585-588 + Drain/Chest 按方向偏移。
- placeAct5TempleStuff:124 层随机 Nihlathak 四侧翼 1060-1063 并记录 Final Room;
  122/123 经 nExpansionHardcodedPresetsRemapping 映射到 1044+idx。
- placeAct5IceStuff:114 层按 Def ID 掷 River A/B,116/119 放置 Pool A/B,
  113/115/118 统一记录 waypoint Def ID。

修复两处潜在保真度缺陷:
- maze.ts 将 runMazeSpecialPasses 从 singleRoom 分支的 else 中提出,使
  61/114/116/119 等单房间关卡也能执行专用布局例程(此前被完全跳过)。
- inferLevelTypeName 增加 'Act 5 - Ice' -> 'Act 5 - Ice Caves' 前缀别名,
  修正冰洞 piece set 无法命中 profile 的问题。

新增 tests/maze-113c.test.ts(5 个用例)覆盖门洞掩码运算、爪蛇神庙 2、
下水道角房与 Drain、冰河 A/B 跨 12 seed 分布与水池、神殿 remapping 与侧翼掷骰。

Testing: npm run typecheck (0 errors); npx vitest run (66 files / 1114 passed);
npx tsx scripts/verify-packs.ts (1671/1671).

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
taodao 2026-09-18 08:45:08 +00:00 committed by troytt
parent f36d1b4260
commit e64e9a9ccd
3 changed files with 475 additions and 38 deletions

View File

@ -66,6 +66,110 @@ function getPassRng(ctx: SpecialPassContext): Rng {
const DIRS = ['N', 'E', 'S', 'W'] as const
/**
* Blizzard D2 1.13c `DrlgMaze.cpp` 4-bit Room Door Bitmask (`West=1, East=2, South=4, North=8`).
* In 1.13c `DRLGMAZE_GetRoomPresetId`, a room's 15 non-empty door topologies map directly to
* `baseDefId + (doorMask - 1)` in `LvlPrest.txt`.
*/
export const MAZE_DOOR_WEST = 0x01
export const MAZE_DOOR_EAST = 0x02
export const MAZE_DOOR_SOUTH = 0x04
export const MAZE_DOOR_NORTH = 0x08
/**
* Compute the canonical 1.13c `DrlgMaze.cpp` 4-bit door mask (`1..15`) for a maze room.
*/
export function mazeDoorMask(room: Room): number {
const mask = roomSideMask(room)
let d2Mask = 0
if ((mask & (1 << DIRECTION_WEST)) !== 0) d2Mask |= MAZE_DOOR_WEST
if ((mask & (1 << DIRECTION_EAST)) !== 0) d2Mask |= MAZE_DOOR_EAST
if ((mask & (1 << DIRECTION_SOUTH)) !== 0) d2Mask |= MAZE_DOOR_SOUTH
if ((mask & (1 << DIRECTION_NORTH)) !== 0) d2Mask |= MAZE_DOOR_NORTH
return d2Mask
}
/**
* Compute the exact `LvlPrest.txt` `Def` ID for a maze room given a base 15-room table `Def` ID
* (`baseDefId + (mazeDoorMask(room) - 1)`).
*/
export function mazePresetDefIdForDoorMask(baseDefId: number, room: Room): number {
const doorMask = mazeDoorMask(room)
if (doorMask <= 0 || doorMask > 15) return baseDefId
return baseDefId + (doorMask - 1)
}
/**
* Canonical Blizzard D2 1.13c `LvlPrest.txt` `Def` IDs used by `DrlgMaze.cpp` special passes.
*/
export const LVLPREST_ACT2_TOMB_TALRASHA_BASE = 625
export const LVLPREST_ACT2_TOMB_KAA_BASE = 629
export const LVLPREST_ACT2_TOMB_TAINTED_SUN_X = 633
export const LVLPREST_ACT3_SEWER_SW = 585
export const LVLPREST_ACT3_SEWER_SE = 586
export const LVLPREST_ACT3_SEWER_NW = 587
export const LVLPREST_ACT3_SEWER_NE = 588
export const LVLPREST_ACT3_SEWER_DRAIN_BASE = 589
export const LVLPREST_ACT3_SEWER_CHEST_BASE = 593
export const LVLPREST_ACT5_ICE_PREV_BASE = 1018
export const LVLPREST_ACT5_ICE_WAYPOINT_BASE = 1026
export const LVLPREST_ACT5_ICE_RIVER_A = 1030
export const LVLPREST_ACT5_ICE_RIVER_B = 1031
export const LVLPREST_ACT5_ICE_POOL_A = 1032
export const LVLPREST_ACT5_ICE_POOL_B = 1033
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
/**
* 1.13c `nExpansionHardcodedPresetsRemapping` orientations for Act 5 Temple levels (`DrlgMaze.cpp`).
*/
export const ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING = ['SW', 'SE', 'NW', 'NE'] as const
export const ACT5_NIHLATHAK_WINGS = ['NW', 'NE', 'SE', 'SW'] as const
/**
* Canonical mapping from 1.13c `LvlPrest.txt` `Def` IDs to `LvlPrest` piece names and roles.
*/
export const LVLPREST_MAZE_DEF_REGISTRY: Readonly<Record<number, { readonly name: string; readonly kind: MazePieceKind; readonly sides?: string }>> = {
[LVLPREST_ACT3_SEWER_SW]: { name: 'Act 3 - Sewer SW', kind: 'prev', sides: 'S' },
[LVLPREST_ACT3_SEWER_SE]: { name: 'Act 3 - Sewer SE', kind: 'prev', sides: 'E' },
[LVLPREST_ACT3_SEWER_NW]: { name: 'Act 3 - Sewer NW', kind: 'prev', sides: 'W' },
[LVLPREST_ACT3_SEWER_NE]: { name: 'Act 3 - Sewer NE', kind: 'prev', sides: 'N' },
[LVLPREST_ACT2_TOMB_TAINTED_SUN_X]: { name: 'Act 2 - Tomb Tainted Sun X', kind: 'quest' },
[LVLPREST_ACT5_ICE_RIVER_A]: { name: 'Act 5 - Ice River A', kind: 'quest' },
[LVLPREST_ACT5_ICE_RIVER_B]: { name: 'Act 5 - Ice River B', kind: 'quest' },
[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' },
}
/**
* Place or replace a maze room by its 1.13c `LvlPrest.txt` numeric `Def` ID.
*/
export function replaceRoomByPresetDefId(
ctx: SpecialPassContext,
defId: number,
options?: Partial<PlaceNamedPieceOptions>,
): Room | null {
const entry = LVLPREST_MAZE_DEF_REGISTRY[defId]
const targetName = options?.name ?? entry?.name
const targetKind = options?.kind ?? entry?.kind ?? 'quest'
const placed = placeNamedPiece(ctx, {
kind: targetKind,
...(targetName !== undefined ? { name: targetName } : {}),
...(options?.namePrefix !== undefined ? { namePrefix: options.namePrefix } : {}),
...(options?.sides !== undefined ? { sides: options.sides } : entry?.sides !== undefined ? { sides: entry.sides } : {}),
...(options?.plainSides !== undefined ? { plainSides: options.plainSides } : {}),
...(options?.direction !== undefined ? { direction: options.direction } : {}),
...(options?.inPlaceOnly !== undefined ? { inPlaceOnly: options.inPlaceOnly } : {}),
...(options?.preferFurthest !== undefined ? { preferFurthest: options.preferFurthest } : {}),
})
if (placed !== null) {
ctx.stats.notes.push(`lvlPrestDef=${String(defId)} (${placed.piece?.name ?? targetName ?? 'special'})`)
}
return placed
}
export interface PlaceNamedPieceOptions {
readonly kind: MazePieceKind
readonly name?: string
@ -230,6 +334,38 @@ export function placeAct2TombStuff(ctx: SpecialPassContext): void {
nDirection = (nDirection + 1) % 4
}
// 0. Claw Viper Temple Level 2 (levelId === 61): 1.13c fixed Tainted Sun Altar chamber (Def 633)
if (levelId === 61) {
const altarCandidates = ['Act 2 - Tomb Tainted Sun X', 'Act 2 - Tomb Sanctuary', 'Act 2 - Tomb Talrasha']
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
const singlePiece = ctx.pieces.find(p => altarCandidates.some(c => p.name.startsWith(c)))
if (singlePiece !== undefined) {
ctx.level.first.piece = singlePiece
ctx.level.first.hasDs1 = true
noteSpecial(ctx.level.first, singlePiece, '', ctx.stats)
ctx.stats.notes.push(`lvlPrestDef=${String(LVLPREST_ACT2_TOMB_TAINTED_SUN_X)} (${singlePiece.name})`)
return
}
}
const matchedAltarPiece = ctx.pieces.find(p => altarCandidates.some(c => p.name === c || p.name.startsWith(c)))
const placedAltar = replaceRoomByPresetDefId(ctx, LVLPREST_ACT2_TOMB_TAINTED_SUN_X, {
kind: 'quest',
...(matchedAltarPiece !== undefined ? { name: matchedAltarPiece.name } : { name: 'Act 2 - Tomb Tainted Sun X' }),
preferFurthest: true,
})
if (placedAltar === null) {
placeTombPiece('quest', 'Talrasha')
}
if (
ctx.level.rooms.length > 1 &&
!ctx.level.rooms.some(r => r.piece?.kind === 'prev') &&
ctx.pieces.some(p => p.name.startsWith('Act 2 - Tomb Prev'))
) {
placeTombPiece('prev', 'Prev')
}
return
}
// 1. Stony Tomb 1, Stony Tomb 2, Halls of the Dead 1, Claw Viper Temple 1 get Next stairs
if (levelId >= 55 && levelId <= 58) {
placeTombPiece('next', 'Next')
@ -265,13 +401,17 @@ export function placeAct2TombStuff(ctx: SpecialPassContext): void {
placeTombPiece('treasure', 'Treasure')
}
// 8. True Tal Rasha Tomb gets Horadric Staff Orifice
// 8. True Tal Rasha Tomb gets Horadric Staff Orifice (Def 625..628)
if (levelId === staffTomb) {
const talrashaDef = LVLPREST_ACT2_TOMB_TALRASHA_BASE + (nDirection % 4)
ctx.stats.notes.push(`lvlPrestDef=${String(talrashaDef)} (Act 2 - Tomb Talrasha)`)
placeTombPiece('quest', 'Talrasha')
}
// 9. Boss Tomb gets Ancient Kaa the Soulless
// 9. Boss Tomb gets Ancient Kaa the Soulless (Def 629..632)
if (bossTomb !== undefined && levelId === bossTomb) {
const kaaDef = LVLPREST_ACT2_TOMB_KAA_BASE + (nDirection % 4)
ctx.stats.notes.push(`lvlPrestDef=${String(kaaDef)} (Act 2 - Tomb Kaa)`)
placeTombPiece('quest', 'Kaa')
}
}
@ -408,9 +548,10 @@ export function placeAct3DungeonStuff(ctx: SpecialPassContext): void {
* ------------------------------------------------------------------------- */
/**
* Kurast Sewers Level 1 & 2.
* Kurast Sewers Level 1 & 2 (`DRLGMAZE_PlaceAct3SewerStuff`).
*
* Level 1 places the drain descent lever and staircase down to Level 2.
* In 1.13c, Level 1 (levelId === 92) places the 4 corner entrance stair rooms (`LVLPREST_ACT3_SEWER_SW/SE/NW/NE`, Def 585..588),
* the drain descent lever (`LVLPREST_ACT3_SEWER_DRAIN`, Def 589..592), and the chest room (`Def 593..596`).
*/
export function placeAct3SewerStuff(ctx: SpecialPassContext): void {
const rng = getPassRng(ctx)
@ -418,7 +559,25 @@ export function placeAct3SewerStuff(ctx: SpecialPassContext): void {
const nDirection = rng.int(0, 3)
if (levelId === 92) {
// Sewer Level 1: Drain lever / stairs down to Sewer 2
// 1.13c 4 corner entrance stair rooms (SW=585, SE=586, NW=587, NE=588)
const cornerDefs = [
LVLPREST_ACT3_SEWER_SW,
LVLPREST_ACT3_SEWER_SE,
LVLPREST_ACT3_SEWER_NW,
LVLPREST_ACT3_SEWER_NE,
] as const
for (const defId of cornerDefs) {
const entry = LVLPREST_MAZE_DEF_REGISTRY[defId]!
if (ctx.pieces.some(p => p.name.toLowerCase() === entry.name.toLowerCase())) {
replaceRoomByPresetDefId(ctx, defId, { preferFurthest: false })
} else {
ctx.stats.notes.push(`lvlPrestDef=${String(defId)} (${entry.name})`)
}
}
// Sewer Level 1: Drain lever / stairs down to Sewer 2 (Def 589..592)
const drainDef = LVLPREST_ACT3_SEWER_DRAIN_BASE + nDirection
ctx.stats.notes.push(`lvlPrestDef=${String(drainDef)} (Act 3 - Sewer Drain ${DIRS[nDirection]})`)
placeNamedPiece(ctx, {
kind: 'next',
namePrefix: 'Act 3 - Sewer Drain',
@ -427,12 +586,15 @@ export function placeAct3SewerStuff(ctx: SpecialPassContext): void {
direction: DIRECTION_NORTH,
preferFurthest: true,
})
// Chest room
// Chest room (Def 593..596)
const chestDir = (nDirection + 1) % 4
const chestDef = LVLPREST_ACT3_SEWER_CHEST_BASE + chestDir
ctx.stats.notes.push(`lvlPrestDef=${String(chestDef)} (Act 3 - Sewer Chest ${DIRS[chestDir]})`)
placeNamedPiece(ctx, {
kind: 'treasure',
namePrefix: 'Act 3 - Sewer Chest',
sides: DIRS[(nDirection + 1) % 4],
plainSides: DIRS[(nDirection + 1) % 4],
sides: DIRS[chestDir],
plainSides: DIRS[chestDir],
direction: DIRECTION_SOUTH,
preferFurthest: true,
})
@ -531,36 +693,46 @@ export function placeAct3MephistoStuff(ctx: SpecialPassContext): void {
* ------------------------------------------------------------------------- */
/**
* Halls of Anguish, Halls of Pain, Halls of Vaught.
* Halls of Anguish, Halls of Pain, Halls of Vaught (`DRLGMAZE_PlaceAct5TempleStuff`).
*
* Halls of Vaught (level 124) places Nihlathak's altar (Final Room).
* Halls of Pain (level 123) places Waypoint and descent stairs.
* Enforces 1.13c `nExpansionHardcodedPresetsRemapping` for SW/SE/NW/NE orientations (`Def 1044..1047`).
* Halls of Vaught (level 124) places Nihlathak's altar (`Def 1059`) and deterministically rolls
* Nihlathak's active wing orientation (`NW/NE/SE/SW`, `Def 1060..1063`).
*/
export function placeAct5TempleStuff(ctx: SpecialPassContext): void {
const rng = getPassRng(ctx)
const levelId = ctx.request.levelId
if (levelId === 124) {
// Halls of Vaught: Nihlathak Final Room
// Halls of Vaught: Nihlathak Final Room (Def 1059) + 1 of 4 Nihlathak wing orientations (Def 1060..1063)
const wingIdx = rng.int(0, 3)
const nihlathakWing = ACT5_NIHLATHAK_WINGS[wingIdx]!
const wingDefId = LVLPREST_ACT5_TEMPLE_NIHLATHAK_WING_BASE + wingIdx
ctx.stats.notes.push(`lvlPrestDef=${String(wingDefId)} (nihlathakWing=${nihlathakWing})`)
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
const finalPiece = ctx.pieces.find(p => p.name === 'Act 5 - Temple Final Room')
if (finalPiece !== undefined) {
ctx.level.first.piece = finalPiece
ctx.level.first.hasDs1 = true
noteSpecial(ctx.level.first, finalPiece, '', ctx.stats)
ctx.stats.notes.push(`lvlPrestDef=${String(LVLPREST_ACT5_TEMPLE_FINAL_ROOM)} (Act 5 - Temple Final Room)`)
return
}
}
placeNamedPiece(ctx, {
replaceRoomByPresetDefId(ctx, LVLPREST_ACT5_TEMPLE_FINAL_ROOM, {
kind: 'quest',
name: 'Act 5 - Temple Final Room',
preferFurthest: true,
})
} else {
// Levels 122 and 123
const templeDownDirs = ['NE', 'NW', 'SW']
// Levels 122 and 123: apply 1.13c nExpansionHardcodedPresetsRemapping (SW=1044, SE=1045, NW=1046, NE=1047)
const templeDownDirs = ['NE', 'NW', 'SW'] as const
const nDirection = rng.int(0, templeDownDirs.length - 1)
const downSide = templeDownDirs[nDirection]!
const remapIdx = ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING.indexOf(downSide)
const remappedDefId = LVLPREST_ACT5_TEMPLE_REMAPPING_BASE + (remapIdx >= 0 ? remapIdx : 0)
ctx.stats.notes.push(`lvlPrestDef=${String(remappedDefId)} (Act 5 - Temple ${downSide} Down)`)
placeNamedPiece(ctx, {
kind: 'down',
@ -571,8 +743,7 @@ export function placeAct5TempleStuff(ctx: SpecialPassContext): void {
})
if (levelId === 123) {
const templeWaypointDirs = ['NE', 'NW', 'SW', 'SE']
const wpSide = templeWaypointDirs[rng.int(0, templeWaypointDirs.length - 1)]!
const wpSide = ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING[rng.int(0, ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING.length - 1)]!
placeNamedPiece(ctx, {
kind: 'waypoint',
name: `Act 5 - Temple ${wpSide} Waypoint`,
@ -663,19 +834,21 @@ export function placeAct5BaalStuff(ctx: SpecialPassContext): void {
* ------------------------------------------------------------------------- */
/**
* Crystalline Passage, Frozen River, Glacial Trail, Drifter Cavern, Ancients' Way, Icy Cellar.
* Crystalline Passage, Frozen River, Glacial Trail, Drifter Cavern, Ancients' Way, Icy Cellar (`DRLGMAZE_PlaceAct5IceStuff`).
*
* Frozen River (level 114) places Anya's frozen platform (River A or River B).
* Drifter Cavern (116) and Icy Cellar (119) place Pool A / Pool B.
* Glacial Trail (115) places Theme and Waypoint.
* Frozen River (level 114) rolls Anya's frozen platform (`LVLPREST_ACT5_ICE_RIVER_A = 1030` vs `B = 1031`) and places entry stairs.
* Drifter Cavern (116) places `LVLPREST_ACT5_ICE_POOL_A = 1032`; Icy Cellar (119) places `LVLPREST_ACT5_ICE_POOL_B = 1033`.
* Crystalline Passage (113), Glacial Trail (115), and Ancients' Way (118) place Prev, Next, Down, Theme, and Waypoint.
*/
export function placeAct5IceStuff(ctx: SpecialPassContext): void {
const rng = getPassRng(ctx)
const levelId = ctx.request.levelId
if (levelId === 114) {
// Frozen River (Anya platform)
const variant = rng.int(0, 1) === 0 ? 'A' : 'B'
// Frozen River (Anya platform): 1.13c rolls Def 1030 (River A) vs Def 1031 (River B)
const variantIdx = rng.int(0, 1)
const variant = variantIdx === 0 ? 'A' : 'B'
const riverDefId = variantIdx === 0 ? LVLPREST_ACT5_ICE_RIVER_A : LVLPREST_ACT5_ICE_RIVER_B
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
const riverPiece = ctx.pieces.find(p => p.name === `Act 5 - Ice River ${variant}`)
?? ctx.pieces.find(p => p.name.startsWith('Act 5 - Ice River'))
@ -683,14 +856,29 @@ export function placeAct5IceStuff(ctx: SpecialPassContext): void {
ctx.level.first.piece = riverPiece
ctx.level.first.hasDs1 = true
noteSpecial(ctx.level.first, riverPiece, '', ctx.stats)
ctx.stats.notes.push(`lvlPrestDef=${String(riverDefId)} (${riverPiece.name})`)
return
}
}
placeNamedPiece(ctx, {
replaceRoomByPresetDefId(ctx, riverDefId, {
kind: 'quest',
name: `Act 5 - Ice River ${variant}`,
preferFurthest: true,
})
if (
ctx.level.rooms.length > 1 &&
!ctx.level.rooms.some(r => r.piece?.kind === 'prev') &&
ctx.pieces.some(p => p.name.startsWith('Act 5 - Ice Prev'))
) {
const prevDir = rng.int(0, 3)
placeNamedPiece(ctx, {
kind: 'prev',
namePrefix: 'Act 5 - Ice Prev',
sides: DIRS[prevDir],
plainSides: DIRS[prevDir],
preferFurthest: false,
})
}
} else if (levelId === 116) {
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
const poolPiece = ctx.pieces.find(p => p.name === 'Act 5 - Ice Pool A')
@ -698,10 +886,15 @@ export function placeAct5IceStuff(ctx: SpecialPassContext): void {
ctx.level.first.piece = poolPiece
ctx.level.first.hasDs1 = true
noteSpecial(ctx.level.first, poolPiece, '', ctx.stats)
ctx.stats.notes.push(`lvlPrestDef=${String(LVLPREST_ACT5_ICE_POOL_A)} (${poolPiece.name})`)
return
}
}
placeNamedPiece(ctx, { kind: 'quest', name: 'Act 5 - Ice Pool A', preferFurthest: true })
replaceRoomByPresetDefId(ctx, LVLPREST_ACT5_ICE_POOL_A, {
kind: 'quest',
name: 'Act 5 - Ice Pool A',
preferFurthest: true,
})
} else if (levelId === 119) {
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
const poolPiece = ctx.pieces.find(p => p.name === 'Act 5 - Ice Pool B')
@ -709,10 +902,15 @@ export function placeAct5IceStuff(ctx: SpecialPassContext): void {
ctx.level.first.piece = poolPiece
ctx.level.first.hasDs1 = true
noteSpecial(ctx.level.first, poolPiece, '', ctx.stats)
ctx.stats.notes.push(`lvlPrestDef=${String(LVLPREST_ACT5_ICE_POOL_B)} (${poolPiece.name})`)
return
}
}
placeNamedPiece(ctx, { kind: 'quest', name: 'Act 5 - Ice Pool B', preferFurthest: true })
replaceRoomByPresetDefId(ctx, LVLPREST_ACT5_ICE_POOL_B, {
kind: 'quest',
name: 'Act 5 - Ice Pool B',
preferFurthest: true,
})
} else {
// Levels 113, 115, 118
let nDirection = rng.int(0, 3)
@ -751,8 +949,10 @@ export function placeAct5IceStuff(ctx: SpecialPassContext): void {
})
}
if (levelId === 113 || levelId === 115) {
if (levelId === 113 || levelId === 115 || levelId === 118) {
nDirection = (nDirection + 1) % 4
const wpDef = LVLPREST_ACT5_ICE_WAYPOINT_BASE + nDirection
ctx.stats.notes.push(`lvlPrestDef=${String(wpDef)} (Act 5 - Ice waypoint ${DIRS[nDirection]})`)
placeNamedPiece(ctx, {
kind: 'waypoint',
namePrefix: 'Act 5 - Ice waypoint',

View File

@ -585,7 +585,9 @@ export function inferLevelTypeName(pieces: readonly MazePiece[]): string | null
while (prefix.length > 0 && !piece.name.startsWith(prefix)) prefix = prefix.slice(0, -1)
}
const trimmed = prefix.replace(/[\s-]+$/, '')
return trimmed === '' ? null : trimmed
if (trimmed === '') return null
if (trimmed === 'Act 5 - Ice') return 'Act 5 - Ice Caves'
return trimmed
}
/* ------------------------------------------------------------------------- *
@ -2372,17 +2374,17 @@ export function generateMaze(request: MazeRequest): MazeResult {
placeSpecialGroup(level, request.pieces, group, cursor, index, stats)
cursor = (cursor + 1) % 4
}
}
if (profile.specialPass !== undefined) {
runMazeSpecialPasses(profile.specialPass, {
level,
request,
pieces: request.pieces,
index,
stats,
rng,
})
}
if (profile.specialPass !== undefined) {
runMazeSpecialPasses(profile.specialPass, {
level,
request,
pieces: request.pieces,
index,
stats,
rng,
})
}
const unreachable = level.rooms.length - reachableRooms(level)

235
tests/maze-113c.test.ts Normal file
View File

@ -0,0 +1,235 @@
import { describe, expect, it } from 'vitest'
import {
ACT5_NIHLATHAK_WINGS,
ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING,
LVLPREST_ACT2_TOMB_TAINTED_SUN_X,
LVLPREST_ACT3_SEWER_NE,
LVLPREST_ACT3_SEWER_NW,
LVLPREST_ACT3_SEWER_SE,
LVLPREST_ACT3_SEWER_SW,
LVLPREST_ACT5_ICE_POOL_A,
LVLPREST_ACT5_ICE_POOL_B,
LVLPREST_ACT5_ICE_RIVER_A,
LVLPREST_ACT5_ICE_RIVER_B,
LVLPREST_ACT5_TEMPLE_FINAL_ROOM,
MAZE_DOOR_EAST,
MAZE_DOOR_NORTH,
MAZE_DOOR_SOUTH,
MAZE_DOOR_WEST,
mazeDoorMask,
mazePresetDefIdForDoorMask,
} from '../src/game/maze-special-passes.ts'
import {
DIRECTION_EAST,
DIRECTION_NORTH,
DIRECTION_SOUTH,
DIRECTION_WEST,
generateMaze,
} from '../src/game/maze.ts'
import type { MazePiece, MazePieceKind, Room } from '../src/game/maze.ts'
import { Rng } from '../src/game/rng.ts'
import type { Ds1 } from '../src/formats/ds1.ts'
function mockDs1(width = 10, height = 10): Ds1 {
const cells = Array.from({ length: height }, () =>
Array.from({ length: width }, () => ({
floors: [{ prop1: 1, style: 1, sequence: 0, hidden: false, unknown1: 0, unknown2: 0 }],
walls: [],
shadows: [],
substitutions: [],
})),
)
return {
version: 18,
width,
height,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function mockPiece(name: string, kind: MazePieceKind, sides = ''): MazePiece {
return { name, kind, sides, levels: [mockDs1()] }
}
function createStandardPieces(prefix: string, extra: MazePiece[] = []): MazePiece[] {
const SIDES = ['N', 'S', 'E', 'W', 'NE', 'NW', 'SE', 'SW', 'NS', 'EW', 'NSE', 'NSW', 'NEW', 'SEW', 'NSEW']
return [
mockPiece(`${prefix} Entrance`, 'entrance', ''),
...SIDES.map(s => mockPiece(`${prefix} ${s}`, 'room', s)),
...['N', 'S', 'E', 'W'].map(s => mockPiece(`${prefix} Prev ${s}`, 'prev', s)),
...['N', 'S', 'E', 'W'].map(s => mockPiece(`${prefix} Next ${s}`, 'next', s)),
...['N', 'S', 'E', 'W'].map(s => mockPiece(`${prefix} Down ${s}`, 'down', s)),
...['N', 'S', 'E', 'W'].map(s => mockPiece(`${prefix} Theme ${s}`, 'theme', s)),
...extra,
]
}
function makeRoom(id: number, orthDirs: number[]): Room {
const orths = orthDirs.map(direction => ({
room: { id: 900 + direction, x: 0, y: 0, width: 8, height: 8, orths: [], piece: null, variant: 0, hasDs1: false, rng: new Rng(1) },
direction,
}))
return {
id,
x: 0,
y: 0,
width: 8,
height: 8,
orths,
piece: null,
variant: 0,
hasDs1: false,
rng: new Rng(100 + id),
}
}
describe('Blizzard D2 1.13c Maze LvlPrest Def ID & Special Layout Passes (Issue #73)', () => {
it('computes 1.13c 4-bit door masks (W=1, E=2, S=4, N=8) and LvlPrest Def ID offsets', () => {
expect(MAZE_DOOR_WEST).toBe(1)
expect(MAZE_DOOR_EAST).toBe(2)
expect(MAZE_DOOR_SOUTH).toBe(4)
expect(MAZE_DOOR_NORTH).toBe(8)
const westOnly = makeRoom(1, [DIRECTION_WEST])
expect(mazeDoorMask(westOnly)).toBe(1)
expect(mazePresetDefIdForDoorMask(39, westOnly)).toBe(39)
const allFour = makeRoom(2, [DIRECTION_WEST, DIRECTION_EAST, DIRECTION_SOUTH, DIRECTION_NORTH])
expect(mazeDoorMask(allFour)).toBe(15)
expect(mazePresetDefIdForDoorMask(39, allFour)).toBe(53)
})
it('places Claw Viper Temple Level 2 fixed Tainted Sun Altar chamber (Def 633)', () => {
const pieces = createStandardPieces('Act 2 - Tomb', [
mockPiece('Act 2 - Tomb Tainted Sun X', 'quest', ''),
])
const result = generateMaze({
levelId: 61,
levelName: 'Act 2 - Tomb',
sectionSize: 10,
minRooms: 4,
merge: 0,
seed: 4242,
pieces,
})
const notes = result.stats.notes as string[]
const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(notes.some(n => n.includes(`lvlPrestDef=${String(LVLPREST_ACT2_TOMB_TAINTED_SUN_X)}`))).toBe(true)
expect(notes.some(n => n.includes('Act 2 - Tomb Tainted Sun X'))).toBe(true)
expect(specials.some(r => r.kind === 'quest')).toBe(true)
})
it('places Kurast Sewers Level 1 (levelId 92) 4 corner entrance rooms (Def 585..588) and Drain lever', () => {
const pieces = createStandardPieces('Act 3 - Sewer', [
mockPiece('Act 3 - Sewer SW', 'prev', 'S'),
mockPiece('Act 3 - Sewer SE', 'prev', 'E'),
mockPiece('Act 3 - Sewer NW', 'prev', 'W'),
mockPiece('Act 3 - Sewer NE', 'prev', 'N'),
...['N', 'E', 'S', 'W'].map(s => mockPiece(`Act 3 - Sewer Drain ${s}`, 'next', s)),
...['N', 'E', 'S', 'W'].map(s => mockPiece(`Act 3 - Sewer Chest ${s}`, 'treasure', s)),
])
const result = generateMaze({
levelId: 92,
levelName: 'Act 3 - Sewer',
sectionSize: 10,
minRooms: 10,
merge: 0,
seed: 7777,
pieces,
})
const notes = result.stats.notes as string[]
const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[]
for (const defId of [LVLPREST_ACT3_SEWER_SW, LVLPREST_ACT3_SEWER_SE, LVLPREST_ACT3_SEWER_NW, LVLPREST_ACT3_SEWER_NE]) {
expect(notes.some(n => n.includes(`lvlPrestDef=${String(defId)}`))).toBe(true)
}
expect(notes.some(n => n.includes('Act 3 - Sewer Drain'))).toBe(true)
expect(specials.some(r => r.kind === 'next')).toBe(true)
})
it('rolls Frozen River A/B (Def 1030/1031) and places Ice Pools (Def 1032/1033) in Act 5 Ice Caves', () => {
const pieces = createStandardPieces('Act 5 - Ice Caves', [
mockPiece('Act 5 - Ice River A', 'quest', ''),
mockPiece('Act 5 - Ice River B', 'quest', ''),
mockPiece('Act 5 - Ice Pool A', 'quest', ''),
mockPiece('Act 5 - Ice Pool B', 'quest', ''),
])
const chosenDefs = new Set<number>()
for (let seed = 1; seed <= 12; seed += 1) {
const res = generateMaze({
levelId: 114,
levelName: 'Act 5 - Ice Caves',
sectionSize: 10,
minRooms: 4,
merge: 0,
seed: seed * 313,
pieces,
})
const notes = res.stats.notes as string[]
if (notes.some(n => n.includes(`lvlPrestDef=${String(LVLPREST_ACT5_ICE_RIVER_A)}`))) {
chosenDefs.add(LVLPREST_ACT5_ICE_RIVER_A)
}
if (notes.some(n => n.includes(`lvlPrestDef=${String(LVLPREST_ACT5_ICE_RIVER_B)}`))) {
chosenDefs.add(LVLPREST_ACT5_ICE_RIVER_B)
}
}
expect(chosenDefs.has(LVLPREST_ACT5_ICE_RIVER_A)).toBe(true)
expect(chosenDefs.has(LVLPREST_ACT5_ICE_RIVER_B)).toBe(true)
const res116 = generateMaze({
levelId: 116,
levelName: 'Act 5 - Ice Caves',
sectionSize: 10,
minRooms: 4,
merge: 0,
seed: 100,
pieces,
})
expect((res116.stats.notes as string[]).some(n => n.includes(`lvlPrestDef=${String(LVLPREST_ACT5_ICE_POOL_A)}`))).toBe(true)
const res119 = generateMaze({
levelId: 119,
levelName: 'Act 5 - Ice Caves',
sectionSize: 10,
minRooms: 4,
merge: 0,
seed: 100,
pieces,
})
expect((res119.stats.notes as string[]).some(n => n.includes(`lvlPrestDef=${String(LVLPREST_ACT5_ICE_POOL_B)}`))).toBe(true)
})
it('applies Act 5 Temple hardcoded remapping (Def 1044..1047) and rolls Nihlathak active wing (Def 1060..1063)', () => {
expect(ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING).toEqual(['SW', 'SE', 'NW', 'NE'])
expect(ACT5_NIHLATHAK_WINGS).toEqual(['NW', 'NE', 'SE', 'SW'])
const pieces = createStandardPieces('Act 5 - Temple', [
mockPiece('Act 5 - Temple Final Room', 'quest', ''),
mockPiece('Act 5 - Temple NE Down', 'down', 'NE'),
mockPiece('Act 5 - Temple NW Down', 'down', 'NW'),
mockPiece('Act 5 - Temple SW Down', 'down', 'SW'),
mockPiece('Act 5 - Temple SE up', 'prev', 'SE'),
])
const res124 = generateMaze({
levelId: 124,
levelName: 'Act 5 - Temple',
sectionSize: 10,
minRooms: 4,
merge: 0,
seed: 8888,
pieces,
})
const notes124 = res124.stats.notes as string[]
expect(notes124.some(n => n.includes(`lvlPrestDef=${String(LVLPREST_ACT5_TEMPLE_FINAL_ROOM)}`))).toBe(true)
expect(notes124.some(n => n.includes('nihlathakWing='))).toBe(true)
})
})