diff --git a/src/game/maze-special-passes.ts b/src/game/maze-special-passes.ts index 471125e..d2799a4 100644 --- a/src/game/maze-special-passes.ts +++ b/src/game/maze-special-passes.ts @@ -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> = { + [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, +): 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', diff --git a/src/game/maze.ts b/src/game/maze.ts index 3516412..6b2e094 100644 --- a/src/game/maze.ts +++ b/src/game/maze.ts @@ -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) diff --git a/tests/maze-113c.test.ts b/tests/maze-113c.test.ts new file mode 100644 index 0000000..6b1a8ae --- /dev/null +++ b/tests/maze-113c.test.ts @@ -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() + 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) + }) +})