feat(game): document and validate object pools and LvlPrest piece families (Issue #61 Step 4)

- Document the 3-tier lookup relation across Levels.txt, LvlTypes.txt, and LvlPrest.txt
- Explain why Catacombs/Sewers index pieces via Def / LevelId = 0 / first levelId
- Explain why composite themes have sub-families (Corrupt Harem, Mephisto, Temple, Bridge, ThroneRoom)
- Implement discoverMazePieceFamilies with optional LvlPrest table and update classifyMazePieceName
- Add Objects.txt mapping validation (validateDungeonObjectPools, validateActPopulationConfig, resolveObjectsTxtId)
- Add comprehensive unit tests in tests/maze-objects.test.ts

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
troytt 2026-09-17 14:39:13 +00:00
parent 94305b03dd
commit a7a3ce13d4
4 changed files with 434 additions and 7 deletions

View File

@ -21,6 +21,9 @@ import type { Ds1, Ds1Cell, Ds1Object } from '../formats/ds1.ts'
import { Rng } from './rng.ts'
import { SUB_TILES_PER_TILE } from './map.ts'
import { lookupObject } from './object-lookup.ts'
import type { ObjectsTable } from './objects.ts'
import type { D2Table } from './acts.ts'
/** Subtiles per DS1 cell. */
export { SUB_TILES_PER_TILE }
@ -123,7 +126,17 @@ export interface CandidateSpot {
* Object Definitions & Act Mapping
* ------------------------------------------------------------------------- */
/** Shrine definition: subtype to act-specific DS1 ID mapping. */
/**
* Shrine definition: subtype to act-specific DS1 Type 2 local ID mapping.
*
* Each entry maps a logical shrine subtype to the DS1 object ID used in each Act:
* - Act 1: IDs 6..11 map to Cairn Stone / shrine tokens S1..S6 (Objects.txt 17..22);
* 29 maps to Shrine token SF (Objects.txt 2); 82/83 map to Shrines SH/BC (Objects.txt 77/85);
* 84 maps to Dummy SG (Objects.txt 86).
* - Acts 2..5: IDs 167..172 map to Act 2+ shrine tokens MH, MJ, CZ, JH, SX, MK (Objects.txt 166..172);
* 227 maps to Dummy QX (Objects.txt 227, Experience/Gem); 152 maps to Fire Orifice HA (Objects.txt 152);
* 55/236 map to Poison corpses/shrines (Objects.txt 55, 236).
*/
interface ShrineDef {
readonly subType: ShrineSubType
readonly idsByAct: Record<number, number>
@ -143,7 +156,20 @@ export const CANONICAL_SHRINES: readonly ShrineDef[] = [
{ subType: 'poison', idsByAct: { 1: 84, 2: 55, 3: 236, 4: 236, 5: 236 } },
]
/** Chest ID mapping per act. */
/**
* Chest ID mapping per act.
*
* DS1 Type 2 IDs correspond to:
* - Super Chests:
* - Act 1: 5 (chest L1, Objects.txt 5), 63 (chest L2, Objects.txt 6), 155/156 (hidden stashes C7, Objects.txt 155)
* - Act 2: 32 (CL, Objects.txt 32), 33 (A1, Objects.txt 33), 155/156
* - Act 3: 65 (X1, Objects.txt 65), 66 (X2, Objects.txt 66), 155/156
* - Acts 4-5: 155, 156
* - Normal Chests:
* - Act 1: 89..94 (Caskets C1..C6, Objects.txt 50, 51, 79, 53, 1, 3);
* 200..203 (Shrines/Stashes MY, MX, MW, MV, Objects.txt 200..203); 229 (door/chest SQ, Objects.txt 229)
* - Acts 2..5: 200..203, 229
*/
export const CHEST_IDS_BY_ACT: Record<number, { super: readonly number[]; normal: readonly number[] }> = {
1: { super: [5, 63, 155, 156], normal: [89, 90, 91, 92, 200, 201, 229, 203] },
2: { super: [32, 33, 155, 156], normal: [200, 201, 229, 203] },
@ -152,7 +178,22 @@ export const CHEST_IDS_BY_ACT: Record<number, { super: readonly number[]; normal
5: { super: [155, 156], normal: [200, 201, 229, 203] },
}
/** Weapon and Armor rack ID mapping per act and orientation. */
/**
* Weapon and Armor rack ID mapping per act and orientation.
*
* DS1 Type 2 IDs correspond to:
* - Armor Racks:
* - Act 1: 19/20 (A1/A2, Objects.txt 33/34), 46/47 (Crate/Door CT/AD, Objects.txt 46/47),
* 183/184 (Chest/Shrine JZ/JY, Objects.txt 183/184), 254/255 (Dummy 12/SS, Objects.txt 254/255)
* - Act 2: 74/75 (TrappDoor/Door TD/DD, Objects.txt 74/75), 183/184, 254/255
* - Act 3: 9/10 (A3/A4 ArmorStand, Objects.txt 104/105), 183/184, 254/255
* - Acts 4-5: 183/184, 254/255, 10/11
* - Weapon Racks:
* - Act 1: 48/49 (Dummy T1/T2, Objects.txt 48/49), 256/257 (Dummy S9/WG, Objects.txt 256/257)
* - Act 2: 76/77 (Dummy/Shrine SZ/SH, Objects.txt 76/77), 256/257
* - Act 3: 11/12 (3R/4R WeaponStand, Objects.txt 106/107), 256/257
* - Acts 4-5: 256, 257
*/
export const RACK_IDS_BY_ACT: Record<
number,
{
@ -182,7 +223,18 @@ export const RACK_IDS_BY_ACT: Record<
},
}
/** Container pool mapping per act. */
/**
* Container pool mapping per act.
*
* DS1 Type 2 IDs correspond to:
* - Barrels: 95 (LargeUrn U5, Objects.txt 95), 28 (B2, Objects.txt 82), 50/51 (Caskets C1/C2, Objects.txt 50/51),
* 102 (Dummy FB, Objects.txt 102), 157 (Waypoint WN, Objects.txt 157), 232 (Shrine HQ, Objects.txt 232),
* 251 (altar GA, Objects.txt 251), 329/330 (Chests XB/XC, Objects.txt 329/330), 417 (Flag YM, Objects.txt 417)
* - Urns: 154 (Corpse GC, Objects.txt 154), 159 (Hidden stash CQ, Objects.txt 159), 202 (Shrine MW, Objects.txt 202),
* 244 (Ratnest RN, Objects.txt 244), 245 (Body BY, Objects.txt 245), 25..29 (Act 2 Urns U1..U5, Objects.txt 4, 9, 52, 94, 95)
* - Caskets: 93/94 (Caskets C5/C6, Objects.txt 1/3), 151 (Shrine ZE, Objects.txt 151), 153 (Door DX, Objects.txt 153)
* - Crates: 161 (Fire FY, Objects.txt 161)
*/
export const CONTAINER_IDS_BY_ACT: Record<
number,
{
@ -224,6 +276,90 @@ export const CONTAINER_IDS_BY_ACT: Record<
},
}
/**
* Resolves the underlying Diablo II Objects.txt row ID for a given Act and DS1 Type 2 object ID.
* Returns null if unmapped in object-lookup tables or absent in the optional objects table.
*/
export function resolveObjectsTxtId(
act: number,
ds1ObjectId: number,
objectsTable?: ObjectsTable | D2Table,
): number | null {
const entry = lookupObject(act, 2, ds1ObjectId)
if (entry === null || entry.objectsTxtId < 0) return null
if (objectsTable) {
if ('byId' in objectsTable) {
if (!objectsTable.byId.has(entry.objectsTxtId)) return null
} else if ('header' in objectsTable && 'rows' in objectsTable) {
const idCol = objectsTable.header.indexOf('Id')
const found = idCol !== -1 && objectsTable.rows.some(row => Number(row[idCol]) === entry.objectsTxtId)
if (!found) return null
}
}
return entry.objectsTxtId
}
/**
* Validates dungeon object pools against object-lookup metadata or an optional loaded Objects.txt table.
* Ensures data-driven correctness for all hardcoded object pools.
*/
export function validateDungeonObjectPools(objectsTable?: ObjectsTable | D2Table): {
valid: boolean
totalChecked: number
unmapped: number[]
errors: string[]
} {
const unmapped: number[] = []
let totalChecked = 0
const check = (act: number, id: number) => {
totalChecked += 1
const entry = lookupObject(act, 2, id)
if (!entry) {
unmapped.push(id)
} else if (objectsTable) {
if ('byId' in objectsTable) {
if (entry.objectsTxtId >= 0 && !objectsTable.byId.has(entry.objectsTxtId)) {
unmapped.push(id)
}
} else if ('header' in objectsTable && 'rows' in objectsTable) {
const idCol = objectsTable.header.indexOf('Id')
const found = idCol !== -1 && objectsTable.rows.some(row => Number(row[idCol]) === entry.objectsTxtId)
if (entry.objectsTxtId >= 0 && !found) {
unmapped.push(id)
}
}
}
}
for (let act = 1; act <= 5; act += 1) {
const chests = CHEST_IDS_BY_ACT[act]
if (chests) {
for (const id of chests.super) check(act, id)
for (const id of chests.normal) check(act, id)
}
const racks = RACK_IDS_BY_ACT[act]
if (racks) {
for (const id of racks.armor.any) check(act, id)
for (const id of racks.weapon.any) check(act, id)
}
const containers = CONTAINER_IDS_BY_ACT[act]
if (containers) {
for (const id of containers.barrels) check(act, id)
for (const id of containers.urns) check(act, id)
for (const id of containers.caskets) check(act, id)
for (const id of containers.crates) check(act, id)
}
for (const shrine of CANONICAL_SHRINES) {
const id = shrine.idsByAct[act]
if (id !== undefined) check(act, id)
}
}
const errors = unmapped.map(id => `DS1 Type 2 object ID ${id} is unmapped or missing in Objects.txt`)
return { valid: unmapped.length === 0, totalChecked, unmapped, errors }
}
/* ------------------------------------------------------------------------- *
* Spatial Analysis
* ------------------------------------------------------------------------- */

View File

@ -78,6 +78,7 @@
* this repository assumed.
*/
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.ts'
import type { D2Table } from './acts.ts'
import { Rng } from './rng.ts'
import { SUB_TILES_PER_TILE } from './map.ts'
export { SUB_TILES_PER_TILE }
@ -384,7 +385,64 @@ const KIND_PREFIXES: readonly (readonly [string, MazePieceKind])[] = [
['Kaa', 'quest'],
]
/** Which `LvlPrest` name families belong to which maze level type. */
/**
* Relationship between Levels.txt, LvlTypes.txt, and LvlPrest.txt:
*
* In Diablo II's data architecture, map pieces and environments are resolved
* across three interconnected tables:
*
* 1. `Levels.txt` contains level declarations. Each row has an `Id` (1..136) and
* a `LevelType` foreign key pointing to `LvlTypes.txt` (or 0 for preset-only
* levels like towns).
* 2. `LvlTypes.txt` defines visual and audio themes (e.g. `Act 1 - Cave`,
* `Act 1 - Catacombs`, `Act 2 - Harem`, `Act 3 - Kurast`). It specifies up to
* 32 DT1 tile libraries (`File 1` .. `File 32`) containing the graphic art.
* 3. `LvlPrest.txt` catalogs the authored DS1 map presets and maze room pieces.
* Each row contains `Def` (the preset ID), `Name`, `LevelId`, `Dt1Mask`,
* `SizeX`, `SizeY`, and up to 6 DS1 file paths (`File1` .. `File6`).
*
* ### Why Catacombs / Sewers index pieces via `Def` / `LevelId = 0` / first `LevelId`:
*
* - Preset levels (like Towns, Tristram, Countess Tower) have fixed layouts and
* their rows in `LvlPrest.txt` set `LevelId` to their exact `Levels.txt.Id`
* (e.g., Rogue Encampment has `LevelId = 1`, Lut Gholein has `LevelId = 40`).
* - Maze levels (Catacombs 1-4 [Levels 32-35], Sewers Act 2 [Levels 41-43],
* Sewers Act 3 [Levels 92-93], Caves 1-2 [Levels 2-3]), in contrast, are generated
* dynamically by stitching dozens of modular 25x25 cell DS1 rooms (with 1..4 doors,
* stairs up/down, waypoints, quest rooms, themed variants).
* - Blizzard North did not duplicate 30+ room rows for each level layer in
* `LvlPrest.txt`. Instead:
* - Room pieces are shared across all levels sharing the theme, stored with
* `LevelId = 0` or set to the first `LevelId` where that theme appears (e.g. 32
* for Catacombs).
* - In D2MOO and the native Blizzard engine (`DRLGMAZE_PickRoomPreset`,
* `DRLGMAZE_RollBasicPresets`, `DRLGMAZE_ScanReplaceSpecialPreset`), pieces
* are indexed either by hardcoded `Def` base offsets (`base + door_bitmask`)
* or discovered by prefix matching against the piece's `Name` column.
*
* ### Why composite themes have sub-families:
*
* Certain levels share a primary tile theme (`LevelType`) but introduce distinct
* visual zones, boss chambers, or quest rooms authored under different `Name`
* prefixes in `LvlPrest.txt`:
* - `Act 2 - Harem`: Combines normal palace rooms (`Act 2 - Harem ...`) with
* corrupted harem cellar and basement pieces (`Act 2 - Corrupt Harem ...`).
* - `Act 3 - Kurast`: Standard Kurast dungeon rooms (`Act 3 - Kurast ...`) are
* supplemented in Durance of Hate Level 3 with Mephisto's sanctum pieces
* (`Act 3 - Mephisto ...`).
* - `Act 3 - Dungeon`: Jungle dungeon rooms (`Act 3 - Dungeon ...`) incorporate
* temple quest chambers (`Act 3 - Temple ...`).
* - `Act 4 - Lava`: River of Flame and Chaos Sanctuary lava island pieces
* (`Act 4 - Lava ...`) are linked by preset bridge pieces (`Act 4 - Bridge ...`).
* - `Act 5 - Ice Caves`: Contains both full-name (`Act 5 - Ice Caves ...`) and
* abbreviated (`Act 5 - Ice ...`) piece definitions in `LvlPrest.txt`.
* - `Act 5 - Baal`: Worldstone Keep pieces (`Act 5 - Baal ...`) terminate in the
* Throne Room of Destruction (`Act 5 - ThroneRoom ...`).
*
* Because `LvlPrest.txt` lacks a `LevelType` column, these sub-families cannot be
* joined by database foreign key alone; they reflect Blizzard's C++ DRLG engine
* conventions and are captured canonically in {@link MAZE_PIECE_FAMILIES}.
*/
export const MAZE_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 1 - Cave': ['Act 1 - Cave'],
'Act 1 - Crypt': ['Act 1 - Crypt'],
@ -407,6 +465,52 @@ export const MAZE_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> =
'Act 5 - Baal': ['Act 5 - Baal', 'Act 5 - ThroneRoom'],
}
/**
* Discover the `LvlPrest` name families belonging to a maze level type.
*
* If `lvlPrestTable` is supplied, validates and discovers family prefixes present
* in the table that correspond to the given `levelTypeName`.
* If `lvlPrestTable` is omitted or contains no matching family rows, falls back to
* {@link MAZE_PIECE_FAMILIES}.
*
* @param levelTypeName - the owning `LvlTypes.txt` name (e.g. `'Act 2 - Harem'`).
* @param lvlPrestTable - optional loaded `LvlPrest.txt` table.
* @returns list of family name prefixes in precedence order (longer/more specific prefixes first).
*/
export function discoverMazePieceFamilies(
levelTypeName: string,
lvlPrestTable?: D2Table,
): readonly string[] {
const staticFamilies = MAZE_PIECE_FAMILIES[levelTypeName] ?? [levelTypeName]
if (lvlPrestTable === undefined) {
return staticFamilies
}
const nameCol = lvlPrestTable.header.indexOf('Name')
if (nameCol === -1) {
return staticFamilies
}
const discovered = new Set<string>()
for (const fam of staticFamilies) {
const lowerFam = fam.toLowerCase()
for (const row of lvlPrestTable.rows) {
const rowName = (row[nameCol] ?? '').trim().toLowerCase()
if (rowName.startsWith(lowerFam)) {
discovered.add(fam)
break
}
}
}
if (discovered.size === 0) {
return staticFamilies
}
const result = staticFamilies.filter(fam => discovered.has(fam))
return result.length > 0 ? result : staticFamilies
}
/**
* Classify one `LvlPrest.txt` piece name.
*
@ -419,10 +523,15 @@ export const MAZE_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> =
*
* @param name - the `LvlPrest.txt` `Name`.
* @param levelTypeName - the owning `LvlTypes.txt` name.
* @param lvlPrestTable - optional loaded `LvlPrest.txt` table for dynamic family discovery.
* @returns the kind and side token, or `null` when the name is not a piece.
*/
export function classifyMazePieceName(name: string, levelTypeName: string): { kind: MazePieceKind; sides: string } | null {
const families = MAZE_PIECE_FAMILIES[levelTypeName] ?? [levelTypeName]
export function classifyMazePieceName(
name: string,
levelTypeName: string,
lvlPrestTable?: D2Table,
): { kind: MazePieceKind; sides: string } | null {
const families = discoverMazePieceFamilies(levelTypeName, lvlPrestTable)
const matchedFamily = families.find(family => name.toLowerCase().startsWith(family.toLowerCase()))
if (matchedFamily === undefined) return null
const rest = name.slice(matchedFamily.length).trim()

View File

@ -106,6 +106,8 @@ import {
import { cell, actOfLevel, hasOutdoorWaypoint, OUTDOOR_WAYPOINT_LEVELS } from './acts.ts'
import type { D2Table } from './acts.ts'
export { actOfLevel, hasOutdoorWaypoint, OUTDOOR_WAYPOINT_LEVELS } from './acts.ts'
import { lookupObject } from './object-lookup.ts'
import type { ObjectsTable } from './objects.ts'
import { readSuperUniques, CANONICAL_ELITE_MODIFIERS } from './monsters.ts'
import type { SuperUnique } from './monsters.ts'
@ -3779,6 +3781,38 @@ export interface ActPopulationConfig {
readonly scatterPool: readonly number[]
}
/**
* Population object ID pool per Act for outdoor wilderness generation (`DRLGOUTDOORS_SpawnAct12Waypoint`, `DRLGOUTDOORS_SpawnAct12Shrines`).
*
* Each ID is a local DS1 Type 2 object record ID mapped through `object-lookup-data.ts` to Blizzard's `Objects.txt`:
* - `waypointId`:
* - Act 1: 37 -> Objects.txt row 119 (`Waypoint`, token WP)
* - Act 2: 10 -> Objects.txt row 156 (`Waypoint`, token WM)
* - Act 3: 1 -> Objects.txt row 237 (`Waypoint`, token WZ)
* - Act 4: 0 -> Objects.txt row 238 (`Waypoint`, token WV)
* - Act 5: 12 -> Objects.txt row 38 (`Waypoint`, token WT)
* - `wellId`:
* - Act 1: 31 -> Objects.txt row 84 (Healing Well, token HW)
* - Acts 2..5: 234 -> Objects.txt row 234 (Outdoor Well, token QY)
* - `shrinePool`:
* - Act 1: 29 (Shrine SF), 82 (Shrine SH), 83 (Shrine BC), 84 (Dummy SG)
* - Act 2: 54 (Z1), 55 (Z2), 103 (FL), 104 (A3), 105 (A4), 126 (IS)
* - Act 3: 14 (S1), 85 (BC), 111 (F3), 152 (HA), 227 (QX), 231 (HT), 235 (QZ), 236 (HO)
* - Act 4: 56 (Z5), 152 (HA), 227 (QX), 231 (HT), 235 (QZ), 236 (HO)
* - Act 5: 152 (HA), 227 (QX), 231 (HT), 235 (QZ), 236 (HO)
* - `chestPool`:
* - Act 1: 5 (chest L1), 89..94 (caskets C1..C6)
* - Act 2: 32 (CL), 33 (A1), 44..46 (R5, S1, CT)
* - Act 3: 65 (X1), 66 (X2), 67 (1R), 80 (OB), 81 (AF)
* - Act 4: 19 (XQ), 20 (QS), 24 (HO), 25 (HN)
* - Act 5: 24 (YP), 25 (6A), 26 (YZ), 27 (6Q), 28 (6B), 29 (6C)
* - `scatterPool`:
* - Act 1: 17 (BR), 18 (BF), 25 (TT)
* - Act 2: 14 (TT), 48 (BR), 122 (TT)
* - Act 3: 4 (FB), 5 (TO), 48 (DE), 179 (BR)
* - Act 4: 40 (HG), 63 (FB), 179 (BR)
* - Act 5: 13 (FB), 179 (BR), 4 (B6)
*/
export const ACT_POPULATION_CONFIG: Readonly<Record<number, ActPopulationConfig>> = {
1: {
waypointId: 37,
@ -3817,6 +3851,46 @@ export const ACT_POPULATION_CONFIG: Readonly<Record<number, ActPopulationConfig>
},
}
/**
* Validates outdoor population configuration against object-lookup metadata or an optional loaded Objects.txt table.
*/
export function validateActPopulationConfig(
config: ActPopulationConfig,
act: number,
objectsTable?: ObjectsTable | D2Table,
): { valid: boolean; totalChecked: number; unmapped: number[]; errors: string[] } {
const unmapped: number[] = []
let totalChecked = 0
const check = (id: number) => {
totalChecked += 1
const entry = lookupObject(act, 2, id)
if (!entry) {
unmapped.push(id)
} else if (objectsTable) {
if ('byId' in objectsTable) {
if (entry.objectsTxtId >= 0 && !objectsTable.byId.has(entry.objectsTxtId)) {
unmapped.push(id)
}
} else if ('header' in objectsTable && 'rows' in objectsTable) {
const idCol = objectsTable.header.indexOf('Id')
const found = idCol !== -1 && objectsTable.rows.some(row => Number(row[idCol]) === entry.objectsTxtId)
if (entry.objectsTxtId >= 0 && !found) {
unmapped.push(id)
}
}
}
}
check(config.waypointId)
check(config.wellId)
for (const id of config.shrinePool) check(id)
for (const id of config.chestPool) check(id)
for (const id of config.scatterPool) check(id)
const errors = unmapped.map(id => `DS1 Type 2 object ID ${id} is unmapped or missing in Objects.txt`)
return { valid: unmapped.length === 0, totalChecked, unmapped, errors }
}
function isTileClear(
canvas: Canvas,
tx: number,

View File

@ -7,15 +7,24 @@ import {
CHEST_IDS_BY_ACT,
RACK_IDS_BY_ACT,
CONTAINER_IDS_BY_ACT,
resolveObjectsTxtId,
validateDungeonObjectPools,
type MazeRoomDescriptor,
type PlacedMazeObject,
type WallAlignment,
} from '../src/game/maze-objects.ts'
import {
generateMaze,
discoverMazePieceFamilies,
classifyMazePieceName,
type MazePiece,
type MazeRequest,
} from '../src/game/maze.ts'
import {
ACT_POPULATION_CONFIG,
validateActPopulationConfig,
} from '../src/game/wilderness.ts'
import type { D2Table } from '../src/game/acts.ts'
import { Rng } from '../src/game/rng.ts'
/**
@ -536,3 +545,102 @@ describe('isOpenFloor content check', () => {
})
})
describe('Issue #61 Step 4: Objects.txt mapping validation and LvlPrest piece families', () => {
describe('validateDungeonObjectPools', () => {
test('validates all canonical dungeon object pools against embedded data without errors', () => {
const result = validateDungeonObjectPools()
expect(result.valid).toBe(true)
expect(result.errors).toEqual([])
expect(result.totalChecked).toBe(223)
})
test('detects invalid or missing object IDs when an incomplete objectsTable is provided', () => {
const mockObjectsTable: D2Table = { header: ['Id'], rows: [['1'], ['2']] }
const result = validateDungeonObjectPools(mockObjectsTable)
expect(result.valid).toBe(false)
expect(result.errors.length).toBeGreaterThan(0)
expect(result.errors[0]).toContain('missing in Objects.txt')
})
})
describe('resolveObjectsTxtId', () => {
test('resolves known DS1 Type 2 IDs to Objects.txt row IDs across Acts', () => {
expect(resolveObjectsTxtId(1, 2)).toBe(39) // Refilling Shrine -> Objects.txt ID 39
expect(resolveObjectsTxtId(1, 5)).toBe(5) // Chest C1 -> Objects.txt ID 5
expect(resolveObjectsTxtId(1, 99999)).toBeNull()
})
test('resolves with explicit objectsTable filter', () => {
const mockObjectsTable: D2Table = { header: ['Id'], rows: [['39'], ['140']] }
expect(resolveObjectsTxtId(1, 2, mockObjectsTable)).toBe(39)
expect(resolveObjectsTxtId(1, 6, mockObjectsTable)).toBeNull()
})
})
describe('validateActPopulationConfig', () => {
test('validates ACT_POPULATION_CONFIG for all 5 acts against embedded data', () => {
for (let act = 1; act <= 5; act += 1) {
const config = ACT_POPULATION_CONFIG[act]!
const result = validateActPopulationConfig(config, act)
expect(result.valid).toBe(true)
expect(result.errors).toEqual([])
expect(result.totalChecked).toBeGreaterThan(0)
}
})
test('detects invalid IDs in population config with incomplete table', () => {
const mockObjectsTable: D2Table = { header: ['Id'], rows: [['1']] }
const config = ACT_POPULATION_CONFIG[1]!
const result = validateActPopulationConfig(config, 1, mockObjectsTable)
expect(result.valid).toBe(false)
expect(result.errors.length).toBeGreaterThan(0)
})
})
describe('discoverMazePieceFamilies', () => {
test('returns static families when lvlPrestTable is omitted', () => {
expect(discoverMazePieceFamilies('Act 2 - Harem')).toEqual(['Act 2 - Corrupt Harem', 'Act 2 - Harem'])
expect(discoverMazePieceFamilies('Act 3 - Kurast')).toEqual(['Act 3 - Mephisto', 'Act 3 - Kurast'])
expect(discoverMazePieceFamilies('Act 3 - Dungeon')).toEqual(['Act 3 - Dungeon', 'Act 3 - Temple'])
expect(discoverMazePieceFamilies('Act 4 - Lava')).toEqual(['Act 4 - Lava', 'Act 4 - Bridge'])
expect(discoverMazePieceFamilies('Act 5 - Ice Caves')).toEqual(['Act 5 - Ice Caves', 'Act 5 - Ice'])
expect(discoverMazePieceFamilies('Act 5 - Baal')).toEqual(['Act 5 - Baal', 'Act 5 - ThroneRoom'])
expect(discoverMazePieceFamilies('Unknown Theme')).toEqual(['Unknown Theme'])
})
test('filters to families present in lvlPrestTable when provided', () => {
const mockLvlPrest: D2Table = {
header: ['Def', 'Name'],
rows: [
['100', 'Act 2 - Harem NSEW'],
['101', 'Act 2 - Harem N'],
],
}
expect(discoverMazePieceFamilies('Act 2 - Harem', mockLvlPrest)).toEqual(['Act 2 - Harem'])
})
test('falls back to static families when table has no matching rows', () => {
const emptyLvlPrest: D2Table = { header: ['Def', 'Name'], rows: [] }
expect(discoverMazePieceFamilies('Act 2 - Harem', emptyLvlPrest)).toEqual([
'Act 2 - Corrupt Harem',
'Act 2 - Harem',
])
})
})
describe('classifyMazePieceName with optional lvlPrestTable', () => {
test('classifies piece names with and without lvlPrestTable', () => {
const withoutTable = classifyMazePieceName('Act 2 - Harem NSEW', 'Act 2 - Harem')
expect(withoutTable).toEqual({ kind: 'room', sides: 'NSEW' })
const mockLvlPrest: D2Table = {
header: ['Def', 'Name'],
rows: [['100', 'Act 2 - Harem NSEW']],
}
const withTable = classifyMazePieceName('Act 2 - Harem NSEW', 'Act 2 - Harem', mockLvlPrest)
expect(withTable).toEqual({ kind: 'room', sides: 'NSEW' })
})
})
})