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:
parent
94305b03dd
commit
a7a3ce13d4
|
|
@ -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
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
|
|
|||
115
src/game/maze.ts
115
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<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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue