From a7a3ce13d4cfa2a2e71a7c78deda4f0982071a6b Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 14:39:13 +0000 Subject: [PATCH] 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 --- src/game/maze-objects.ts | 144 +++++++++++++++++++++++++++++++++++-- src/game/maze.ts | 115 ++++++++++++++++++++++++++++- src/game/wilderness.ts | 74 +++++++++++++++++++ tests/maze-objects.test.ts | 108 ++++++++++++++++++++++++++++ 4 files changed, 434 insertions(+), 7 deletions(-) diff --git a/src/game/maze-objects.ts b/src/game/maze-objects.ts index a19fcb9..0420259 100644 --- a/src/game/maze-objects.ts +++ b/src/game/maze-objects.ts @@ -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 @@ -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 = { 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(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 * ------------------------------------------------------------------------- */ diff --git a/src/game/maze.ts b/src/game/maze.ts index bfc2cf6..3516412 100644 --- a/src/game/maze.ts +++ b/src/game/maze.ts @@ -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> = { 'Act 1 - Cave': ['Act 1 - Cave'], 'Act 1 - Crypt': ['Act 1 - Crypt'], @@ -407,6 +465,52 @@ export const MAZE_PIECE_FAMILIES: Readonly> = '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() + 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> = * * @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() diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 744b91b..9671e58 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -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> = { 1: { waypointId: 37, @@ -3817,6 +3851,46 @@ export const ACT_POPULATION_CONFIG: Readonly }, } +/** + * 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, diff --git a/tests/maze-objects.test.ts b/tests/maze-objects.test.ts index ff44275..b2f847a 100644 --- a/tests/maze-objects.test.ts +++ b/tests/maze-objects.test.ts @@ -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' }) + }) + }) +}) + +