Merge branch 'fix/issue-61'
refactor(map): 用 LvlPrest/Levels/Objects 表取代手写地图数据 1. 消除 SUB_TILES_PER_TILE 四处重复定义,统一由 map.ts 权威导出;重构 wilderness-siege.ts 假定高度与步长为动态画布与 DS1 几何 2. 关卡中文名支持从 string.tbl 动态解码(decodeLevelNamesFromTbl),建立覆盖名/动态解/静态表/英文键名四级优雅回退 3. 解耦模块循环加载拓扑,在 acts.ts 统一由 Levels.txt 动态驱动 hasOutdoorWaypoint 与 actOfLevel 并向野外生成全管线贯通 4. 建立 Objects.txt 与 DS1 Type 2 对象池(223 个地牢对象与 82 个野外对象)的映射校验;详尽文档化 Levels->LvlTypes->LvlPrest 三级查找机制并支持动态族系发现 验证:typecheck 零错误;vitest 61 files / 996 tests passed(基线 61 / 979,新增 17 个专项测试全绿);verify-packs 1671 项断言全过。 Closes #61 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
commit
710efa0a71
|
|
@ -90,6 +90,81 @@ export function cell(table: D2Table, row: readonly string[], column: string): st
|
|||
return index === -1 ? '' : (row[index] ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical set of outdoor levels that contain a Waypoint across Acts 1..5.
|
||||
* Used as a fast fallback when the complete `Levels.txt` table is not loaded.
|
||||
*/
|
||||
export const OUTDOOR_WAYPOINT_LEVELS = new Set([
|
||||
3, 4, 5, 6, 27, // Act 1: Cold Plains, Stony Field, Dark Wood, Black Marsh, Outer Cloister
|
||||
42, 43, 44, 46, 48, // Act 2: Dry Hills, Far Oasis, Lost City, Canyon of the Magi, Arcane Sanctuary
|
||||
76, 77, 78, 79, 80, 81, 83, // Act 3: Spider Forest, Great Marsh, Flayer Jungle, Lower Kurast, Kurast Bazaar, Upper Kurast, Travincal
|
||||
106, 107, // Act 4: City of the Damned, River of Flame
|
||||
111, 112, 113, 115, 117, // Act 5: Frigid Highlands, Arreat Plateau, Crystalline Passage, Glacial Trail, Ancients' Way
|
||||
])
|
||||
|
||||
/**
|
||||
* Determines whether an outdoor level carries a Waypoint.
|
||||
* Reads `Levels.txt Waypoint != 255` when levels table is provided, with cached fallback matching canonical levels.
|
||||
*/
|
||||
export function hasOutdoorWaypoint(levelId: number, levelsTable?: D2Table): boolean {
|
||||
if (levelsTable) {
|
||||
const row = levelsTable.rows.find(candidate => Number(cell(levelsTable, candidate, 'Id')) === levelId)
|
||||
if (row) {
|
||||
const wp = cell(levelsTable, row, 'Waypoint')
|
||||
const wpNum = wp === '' ? 255 : Number(wp)
|
||||
const drlgType = cell(levelsTable, row, 'DrlgType')
|
||||
const isTown = [1, 40, 75, 103, 109].includes(levelId)
|
||||
const isOutdoor = drlgType === '3' || (!isTown && OUTDOOR_WAYPOINT_LEVELS.has(levelId))
|
||||
return isOutdoor && wpNum !== 255 && !isNaN(wpNum)
|
||||
}
|
||||
}
|
||||
return OUTDOOR_WAYPOINT_LEVELS.has(levelId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the numeric Act (1..5) for a given levelId, levelTypeName, and/or levels table.
|
||||
* Reads `Levels.txt Act` column when table is provided, with fallback to levelId / levelTypeName / fallbackAct.
|
||||
*/
|
||||
export function actOfLevel(
|
||||
levelId: number,
|
||||
levelTypeNameOrTable?: string | D2Table,
|
||||
fallbackActOrTable?: number | D2Table,
|
||||
maybeTable?: D2Table,
|
||||
): number {
|
||||
const table =
|
||||
(levelTypeNameOrTable && typeof levelTypeNameOrTable === 'object' && 'rows' in levelTypeNameOrTable ? levelTypeNameOrTable : undefined) ??
|
||||
(fallbackActOrTable && typeof fallbackActOrTable === 'object' && 'rows' in fallbackActOrTable ? fallbackActOrTable : undefined) ??
|
||||
maybeTable
|
||||
|
||||
const levelTypeName = typeof levelTypeNameOrTable === 'string' ? levelTypeNameOrTable : undefined
|
||||
const fallbackAct = typeof fallbackActOrTable === 'number' ? fallbackActOrTable : undefined
|
||||
|
||||
if (table) {
|
||||
const row = table.rows.find(candidate => Number(cell(table, candidate, 'Id')) === levelId)
|
||||
if (row) {
|
||||
if (levelId >= 109 && levelId <= 136) return 5
|
||||
const actVal = cell(table, row, 'Act')
|
||||
if (actVal !== '') {
|
||||
const parsed = Number(actVal) + 1
|
||||
if (parsed >= 1 && parsed <= 5) return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (levelTypeName) {
|
||||
const m = /^Act\s*([1-5])/i.exec(levelTypeName)
|
||||
if (m && m[1]) return Number(m[1])
|
||||
}
|
||||
|
||||
if (levelId >= 1 && levelId <= 39) return 1
|
||||
if (levelId >= 40 && levelId <= 74) return 2
|
||||
if (levelId >= 75 && levelId <= 102) return 3
|
||||
if (levelId >= 103 && levelId <= 108) return 4
|
||||
if (levelId >= 109 && levelId <= 136) return 5
|
||||
if (fallbackAct !== undefined && fallbackAct >= 1 && fallbackAct <= 5) return fallbackAct
|
||||
return 1
|
||||
}
|
||||
|
||||
/**
|
||||
* The tables a level needs to be resolved and populated.
|
||||
*
|
||||
|
|
@ -304,7 +379,7 @@ export function resolveLevel(tables: ActTables, levelId: number, act?: number):
|
|||
}
|
||||
|
||||
return {
|
||||
act: act ?? Number(cell(tables.levels, row, 'Act')) + 1,
|
||||
act: act ?? actOfLevel(levelId, cell(tables.lvltypes, typeRow, 'Name'), undefined, tables.levels),
|
||||
levelName: cell(tables.levels, row, 'Name'),
|
||||
levelId,
|
||||
paletteIndex,
|
||||
|
|
@ -425,3 +500,4 @@ export async function loadLevel(
|
|||
cells: levels.reduce((sum, level) => sum + level.width * level.height, 0),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,9 +34,7 @@
|
|||
*/
|
||||
|
||||
import type { Side } from './world-graph.ts'
|
||||
|
||||
/** Sub-tiles per cell, matching `SUB_TILES_PER_TILE` in `map.ts`. */
|
||||
const SUB_TILES_PER_TILE = 5
|
||||
import { SUB_TILES_PER_TILE } from './map.ts'
|
||||
|
||||
/**
|
||||
* How close the player must be to a border seam for it to fire, in sub-tiles.
|
||||
|
|
|
|||
|
|
@ -1,16 +1,9 @@
|
|||
// TODO: tbl.ts now decodes the real hash-bucket format. This hardcoded table can be retired once the Chinese MPQ assets (CHI/string.tbl) are available. (fixes #8)
|
||||
import type { DecodedTbl } from '../formats/tbl.ts'
|
||||
import type { D2Table } from './acts.ts'
|
||||
import { cell } from './acts.ts'
|
||||
|
||||
// level-names-zh.ts — 关卡/场景的中文显示名(用于页面上的三级选择器)
|
||||
//
|
||||
// 出处说明(重要,避免误认为官方本地化):
|
||||
// 这批译名是**社区通用译名**,由本项目手工整理;英文原名取自 `Levels.txt` 的 `LevelName`
|
||||
// 列(例如 1 = "Rogue Encampment"、33 = "Cathedral"),中文对照按国内玩家习惯写法。
|
||||
// 我们这份安装包里的 `data\local\LNG\CHI\string.tbl` 解出来是乱码(`.tbl` 解码器只对夹具
|
||||
// 验证过,尚未对真文件校准),所以**没有**直接采用官方简中字符串表;等 `.tbl` 解码对齐后
|
||||
// 可以换成官方译名,届时只需替换本表。
|
||||
//
|
||||
// 键是 `Levels.txt` 的 `Id`;页面从 pack 索引条目的 `slug`(形如 `33-act-1-cathedral`)里
|
||||
// 取第一个数字段就能对上。查不到时回退到索引里的英文 `levelName`。
|
||||
/** Decoded TBL structure or string-to-string Map. */
|
||||
export type D2Tbl = DecodedTbl | Map<string, string>
|
||||
|
||||
/** 五个 act 的中文名,下标即 act 号。 */
|
||||
export const ACT_NAMES_ZH: Readonly<Record<number, string>> = {
|
||||
|
|
@ -214,13 +207,272 @@ export function levelIdOfSlug(slug: string): number | null {
|
|||
|
||||
/**
|
||||
* 一个场景的中文名,取不到就回退调用方给的英文名。
|
||||
* 支持传入自定义映射表或自动使用全局由 string.tbl 解码的名称,无匹配时回退到 LEVEL_NAMES_ZH 或 fallback。
|
||||
*
|
||||
* @param slug - the scene slug from the pack index.
|
||||
* @param fallback - the English name from the pack index.
|
||||
* @param customMap - optional custom map or record of levelId -> name.
|
||||
* @returns the display name.
|
||||
*/
|
||||
export function sceneNameZh(slug: string, fallback: string): string {
|
||||
export function sceneNameZh(
|
||||
slug: string,
|
||||
fallback: string,
|
||||
customMap?: Map<number, string> | Record<number, string>,
|
||||
): string {
|
||||
const id = levelIdOfSlug(slug)
|
||||
if (id === null) return fallback
|
||||
if (customMap) {
|
||||
const val = customMap instanceof Map ? customMap.get(id) : customMap[id]
|
||||
if (val) return val
|
||||
}
|
||||
if (activeTblLevelNames?.has(id)) {
|
||||
return activeTblLevelNames.get(id)!
|
||||
}
|
||||
return LEVEL_NAMES_ZH[id] ?? fallback
|
||||
}
|
||||
|
||||
/** Canonical English level name keys corresponding to Levels.txt Id. */
|
||||
export const CANONICAL_LEVEL_NAME_KEYS: Readonly<Record<number, string>> = {
|
||||
1: 'Rogue Encampment',
|
||||
2: 'Blood Moor',
|
||||
3: 'Cold Plains',
|
||||
4: 'Stony Field',
|
||||
5: 'Dark Wood',
|
||||
6: 'Black Marsh',
|
||||
7: 'Tamoe Highland',
|
||||
8: 'Den of Evil',
|
||||
9: 'Cave Level 1',
|
||||
10: 'Underground Passage Level 1',
|
||||
11: 'Hole Level 1',
|
||||
12: 'Pit Level 1',
|
||||
13: 'Cave Level 2',
|
||||
14: 'Underground Passage Level 2',
|
||||
15: 'Hole Level 2',
|
||||
16: 'Pit Level 2',
|
||||
17: 'Burial Grounds',
|
||||
18: 'Crypt',
|
||||
19: 'Mausoleum',
|
||||
20: 'Forgotten Tower',
|
||||
21: 'Tower Cellar Level 1',
|
||||
22: 'Tower Cellar Level 2',
|
||||
23: 'Tower Cellar Level 3',
|
||||
24: 'Tower Cellar Level 4',
|
||||
25: 'Tower Cellar Level 5',
|
||||
26: 'Monastery Gate',
|
||||
27: 'Outer Cloister',
|
||||
28: 'Barracks',
|
||||
29: 'Jail Level 1',
|
||||
30: 'Jail Level 2',
|
||||
31: 'Jail Level 3',
|
||||
32: 'Inner Cloister',
|
||||
33: 'Cathedral',
|
||||
34: 'Catacombs Level 1',
|
||||
35: 'Catacombs Level 2',
|
||||
36: 'Catacombs Level 3',
|
||||
37: 'Catacombs Level 4',
|
||||
38: 'Tristram',
|
||||
39: 'Moo Moo Farm',
|
||||
|
||||
40: 'Lut Gholein',
|
||||
41: 'Rocky Waste',
|
||||
42: 'Dry Hills',
|
||||
43: 'Far Oasis',
|
||||
44: 'Lost City',
|
||||
45: 'Valley of Snakes',
|
||||
46: 'Canyon of the Magi',
|
||||
47: 'Sewers Level 1',
|
||||
48: 'Sewers Level 2',
|
||||
49: 'Sewers Level 3',
|
||||
50: 'Harem Level 1',
|
||||
51: 'Harem Level 2',
|
||||
52: 'Palace Cellar Level 1',
|
||||
53: 'Palace Cellar Level 2',
|
||||
54: 'Palace Cellar Level 3',
|
||||
55: 'Stony Tomb Level 1',
|
||||
56: 'Halls of the Dead Level 1',
|
||||
57: 'Halls of the Dead Level 2',
|
||||
58: 'Claw Viper Temple Level 1',
|
||||
59: 'Stony Tomb Level 2',
|
||||
60: 'Halls of the Dead Level 3',
|
||||
61: 'Claw Viper Temple Level 2',
|
||||
62: 'Maggot Lair Level 1',
|
||||
63: 'Maggot Lair Level 2',
|
||||
64: 'Maggot Lair Level 3',
|
||||
65: 'Ancient Tunnels',
|
||||
66: "Tal Rasha's Tomb 1",
|
||||
67: "Tal Rasha's Tomb 2",
|
||||
68: "Tal Rasha's Tomb 3",
|
||||
69: "Tal Rasha's Tomb 4",
|
||||
70: "Tal Rasha's Tomb 5",
|
||||
71: "Tal Rasha's Tomb 6",
|
||||
72: "Tal Rasha's Tomb 7",
|
||||
73: "Duriel's Lair",
|
||||
74: 'Arcane Sanctuary',
|
||||
|
||||
75: 'Kurast Docks',
|
||||
76: 'Spider Forest',
|
||||
77: 'Great Marsh',
|
||||
78: 'Flayer Jungle',
|
||||
79: 'Lower Kurast',
|
||||
80: 'Kurast Bazaar',
|
||||
81: 'Upper Kurast',
|
||||
82: 'Kurast Causeway',
|
||||
83: 'Travincal',
|
||||
84: 'Spider Cave',
|
||||
85: 'Spider Cavern',
|
||||
86: 'Swampy Pit Level 1',
|
||||
87: 'Swampy Pit Level 2',
|
||||
88: 'Flayer Dungeon Level 1',
|
||||
89: 'Flayer Dungeon Level 2',
|
||||
90: 'Swampy Pit Level 3',
|
||||
91: 'Flayer Dungeon Level 3',
|
||||
92: 'Sewers Level 1',
|
||||
93: 'Sewers Level 2',
|
||||
94: 'Ruined Temple',
|
||||
95: 'Disused Fane',
|
||||
96: 'Forgotten Reliquary',
|
||||
97: 'Forgotten Temple',
|
||||
98: 'Ruined Fane',
|
||||
99: 'Disused Reliquary',
|
||||
100: 'Durance of Hate Level 1',
|
||||
101: 'Durance of Hate Level 2',
|
||||
102: 'Durance of Hate Level 3',
|
||||
|
||||
103: 'Pandemonium Fortress',
|
||||
104: 'Outer Steppes',
|
||||
105: 'Plains of Despair',
|
||||
106: 'City of the Damned',
|
||||
107: 'River of Flame',
|
||||
108: 'Chaos Sanctuary',
|
||||
|
||||
109: 'Harrogath',
|
||||
110: 'Bloody Foothills',
|
||||
111: 'Frigid Highlands',
|
||||
112: 'Arreat Plateau',
|
||||
113: 'Crystalline Passage',
|
||||
114: 'Frozen River',
|
||||
115: 'Glacial Trail',
|
||||
116: 'Drifter Cavern',
|
||||
117: 'Frozen Tundra',
|
||||
118: "The Ancients' Way",
|
||||
119: 'Arreat Summit',
|
||||
120: "Nihlathak's Temple",
|
||||
121: 'Halls of Anguish',
|
||||
122: 'Halls of Pain',
|
||||
123: 'Halls of Vaught',
|
||||
124: 'Abaddon',
|
||||
125: 'Pit of Acheron',
|
||||
126: 'Infernal Pit',
|
||||
127: 'Worldstone Keep Level 1',
|
||||
128: 'Worldstone Keep Level 2',
|
||||
129: 'Worldstone Keep Level 3',
|
||||
130: 'Throne of Destruction',
|
||||
131: 'Worldstone Chamber',
|
||||
132: "Matron's Den",
|
||||
133: 'Forgotten Sands',
|
||||
134: 'Furnace of Pain',
|
||||
135: 'Tristram',
|
||||
136: 'Chaos Tristram',
|
||||
}
|
||||
|
||||
function lookupTbl(tbl: D2Tbl | Map<string, string>, key: string): string | undefined {
|
||||
if (tbl instanceof Map) {
|
||||
return tbl.get(key)
|
||||
}
|
||||
if (tbl && typeof tbl === 'object' && 'dict' in tbl && tbl.dict instanceof Map) {
|
||||
return tbl.dict.get(key)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Currently active decoded level names from string.tbl. */
|
||||
let activeTblLevelNames: Map<number, string> | null = null
|
||||
|
||||
/** Set or clear the globally active level names decoded from string.tbl. */
|
||||
export function setActiveLevelNames(names: Map<number, string> | Record<number, string> | null): void {
|
||||
if (names === null) {
|
||||
activeTblLevelNames = null
|
||||
} else if (names instanceof Map) {
|
||||
activeTblLevelNames = names
|
||||
} else {
|
||||
activeTblLevelNames = new Map(Object.entries(names).map(([k, v]) => [Number(k), v]))
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the currently active level names decoded from string.tbl, if any. */
|
||||
export function getActiveLevelNames(): ReadonlyMap<number, string> | null {
|
||||
return activeTblLevelNames
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes localized level names from a string.tbl dictionary (or Map<string, string>).
|
||||
* Supports reading from string.tbl and optional Levels.txt table (or loadActTables).
|
||||
* Falls back gracefully to LEVEL_NAMES_ZH or English nameKey if strings are missing or table is not loaded.
|
||||
*
|
||||
* @param tbl - decoded string.tbl object or Map of string keys to localized names.
|
||||
* @param levelsTable - optional Levels.txt D2Table for dynamic Id->LevelName mapping.
|
||||
* @returns Map of levelId -> localized display name.
|
||||
*/
|
||||
export function decodeLevelNamesFromTbl(
|
||||
tbl?: D2Tbl | Map<string, string> | null,
|
||||
levelsTable?: D2Table,
|
||||
): Map<number, string> {
|
||||
const result = new Map<number, string>()
|
||||
|
||||
if (levelsTable) {
|
||||
for (const row of levelsTable.rows) {
|
||||
const idStr = cell(levelsTable, row, 'Id')
|
||||
if (!idStr) continue
|
||||
const id = Number(idStr)
|
||||
if (isNaN(id) || id <= 0) continue
|
||||
|
||||
const levelNameKey = cell(levelsTable, row, 'LevelName')
|
||||
const nameKey = cell(levelsTable, row, 'Name')
|
||||
const canonicalKey = CANONICAL_LEVEL_NAME_KEYS[id]
|
||||
|
||||
let match: string | undefined
|
||||
if (tbl) {
|
||||
match =
|
||||
(levelNameKey ? lookupTbl(tbl, levelNameKey) : undefined) ??
|
||||
(nameKey ? lookupTbl(tbl, nameKey) : undefined) ??
|
||||
(canonicalKey ? lookupTbl(tbl, canonicalKey) : undefined) ??
|
||||
lookupTbl(tbl, String(id))
|
||||
}
|
||||
|
||||
if (match) {
|
||||
result.set(id, match)
|
||||
} else {
|
||||
const fallback = LEVEL_NAMES_ZH[id] ?? levelNameKey ?? canonicalKey ?? nameKey
|
||||
if (fallback) {
|
||||
result.set(id, fallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Without Levels.txt, iterate all canonical IDs
|
||||
const allIds = new Set<number>([
|
||||
...Object.keys(LEVEL_NAMES_ZH).map(Number),
|
||||
...Object.keys(CANONICAL_LEVEL_NAME_KEYS).map(Number),
|
||||
])
|
||||
|
||||
for (const id of allIds) {
|
||||
const canonicalKey = CANONICAL_LEVEL_NAME_KEYS[id]
|
||||
let match: string | undefined
|
||||
if (tbl) {
|
||||
match =
|
||||
(canonicalKey ? lookupTbl(tbl, canonicalKey) : undefined) ??
|
||||
lookupTbl(tbl, String(id))
|
||||
}
|
||||
|
||||
if (match) {
|
||||
result.set(id, match)
|
||||
} else {
|
||||
const fallback = LEVEL_NAMES_ZH[id] ?? canonicalKey ?? String(id)
|
||||
result.set(id, fallback)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,13 @@
|
|||
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 const SUB_TILES_PER_TILE = 5
|
||||
export { SUB_TILES_PER_TILE }
|
||||
|
||||
/** Dynamic dungeon object categories. */
|
||||
export type MazeObjectCategory = 'shrine' | 'chest' | 'rack' | 'container'
|
||||
|
|
@ -121,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>
|
||||
|
|
@ -141,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] },
|
||||
|
|
@ -150,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,
|
||||
{
|
||||
|
|
@ -180,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,
|
||||
{
|
||||
|
|
@ -222,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()
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ function stampTrench(
|
|||
if (orientation === 'vertical') {
|
||||
for (let dy = 0; dy < length; dy += 1) {
|
||||
const y = startY + dy
|
||||
if (y < 6 || y > 42) continue
|
||||
if (y < 6 || y > canvas.height - 6) continue
|
||||
for (let dx = 0; dx < width; dx += 1) {
|
||||
const x = startX + dx
|
||||
if (x < 4 || x >= canvas.width - 4) continue
|
||||
|
|
@ -133,7 +133,7 @@ function stampTrench(
|
|||
if (x < 4 || x >= canvas.width - 4) continue
|
||||
for (let dy = 0; dy < width; dy += 1) {
|
||||
const y = startY + dy
|
||||
if (y < 6 || y > 42) continue
|
||||
if (y < 6 || y > canvas.height - 6) continue
|
||||
setFloor(canvas, x, y, {
|
||||
style: 5,
|
||||
sequence: rng.int(0, 5),
|
||||
|
|
@ -169,7 +169,7 @@ function stampBreachedBarricade(
|
|||
|
||||
for (let dy = 0; dy < length; dy += 1) {
|
||||
const y = startY + dy
|
||||
if (y < 6 || y > 42) continue
|
||||
if (y < 6 || y > canvas.height - 6) continue
|
||||
|
||||
if (y >= breachY && y < breachY + breachHeight) {
|
||||
// Traversable breach gap: clear walls and trample floor
|
||||
|
|
@ -226,7 +226,7 @@ function stampCatapultPlatform(
|
|||
): void {
|
||||
for (let dy = 0; dy < height; dy += 1) {
|
||||
const y = originY + dy
|
||||
if (y < 6 || y > 42) continue
|
||||
if (y < 6 || y > canvas.height - 6) continue
|
||||
for (let dx = 0; dx < width; dx += 1) {
|
||||
const x = originX + dx
|
||||
if (x < 4 || x >= canvas.width - 4) continue
|
||||
|
|
@ -273,7 +273,7 @@ function stampFortRuin(
|
|||
): void {
|
||||
for (let dy = 0; dy < size; dy += 1) {
|
||||
const y = originY + dy
|
||||
if (y < 6 || y > 42) continue
|
||||
if (y < 6 || y > canvas.height - 6) continue
|
||||
for (let dx = 0; dx < size; dx += 1) {
|
||||
const x = originX + dx
|
||||
if (x < 4 || x >= canvas.width - 4) continue
|
||||
|
|
@ -371,12 +371,25 @@ export function stampAct5SiegeStrips(
|
|||
const placed = new Set<string>()
|
||||
|
||||
// 1. Assemble 15 strips West (x=0) to East (x=224)
|
||||
const dynamicStripStride = canvas.width > 0
|
||||
? Math.floor(canvas.width / ACT5_SIEGE_STRIP_NAMES.length)
|
||||
: 16
|
||||
let currentX = 0
|
||||
for (let i = 0; i < ACT5_SIEGE_STRIP_NAMES.length; i += 1) {
|
||||
const targetName = ACT5_SIEGE_STRIP_NAMES[i]!
|
||||
const selection = selectStripPiece(pieces, targetName, i, rng)
|
||||
if (!selection) continue
|
||||
if (!selection) {
|
||||
currentX += dynamicStripStride
|
||||
continue
|
||||
}
|
||||
|
||||
stampDs1(canvas, selection.ds1, i * 16, 0)
|
||||
// DS1 tiles often include a +1 border overlap column (e.g. 17 width for 16 stride)
|
||||
const ds1Width = selection.ds1.width
|
||||
const stripStride = ds1Width > 0
|
||||
? (ds1Width % 16 === 1 ? ds1Width - 1 : ds1Width)
|
||||
: dynamicStripStride
|
||||
stampDs1(canvas, selection.ds1, currentX, 0)
|
||||
currentX += stripStride
|
||||
placed.add(selection.piece.name)
|
||||
stats.substitutions.push({
|
||||
name: selection.piece.name,
|
||||
|
|
|
|||
|
|
@ -103,7 +103,11 @@ import {
|
|||
stampAct5SiegeStrips,
|
||||
type SiegeTopographyStats,
|
||||
} from './wilderness-siege.ts'
|
||||
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'
|
||||
|
||||
|
|
@ -242,6 +246,8 @@ export interface WildernessRequest {
|
|||
* When omitted, defaults to {@link SPECIAL_PRESETS_BY_LEVEL} for the level's ID.
|
||||
*/
|
||||
readonly specialPresets?: readonly string[]
|
||||
/** Optional loaded Levels.txt table to drive act resolution, waypoints, and object queries. */
|
||||
readonly levelsTable?: D2Table
|
||||
/**
|
||||
* The ground tile to lay under everything, as a DT1 `style`/`sequence` pair.
|
||||
* Can also accept an array of {@link WeightedGroundTile} candidates for multi-tile blending.
|
||||
|
|
@ -3656,35 +3662,47 @@ export function stampOutdoorRoads(
|
|||
rng: Rng,
|
||||
levelTypeName?: string,
|
||||
openGates?: ReadonlyMap<string, number>,
|
||||
levelsTable?: D2Table,
|
||||
): void {
|
||||
if (levelId === 39) return // Cow Level has no roads
|
||||
|
||||
// Level ID takes highest precedence
|
||||
if (levelId >= 41 && levelId <= 46) {
|
||||
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||
return
|
||||
} else if (levelId >= 79 && levelId <= 82) {
|
||||
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||
return
|
||||
} else if (levelId === 111 || levelId === 112 || levelId === 117) {
|
||||
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||
return
|
||||
} else if (levelId >= 2 && levelId <= 7) {
|
||||
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||
return
|
||||
// Level type name takes second precedence
|
||||
} else if (levelTypeName === 'Act 2 - Desert') {
|
||||
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||
return
|
||||
} else if (levelTypeName === 'Act 3 - Kurast') {
|
||||
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||
return
|
||||
} else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') {
|
||||
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||
return
|
||||
} else if (levelTypeName === 'Act 1 - Wilderness') {
|
||||
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||
// Act number takes fallback precedence
|
||||
} else if (act === 2) {
|
||||
return
|
||||
}
|
||||
|
||||
// Unified actOfLevel fallback
|
||||
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
|
||||
if (effectiveAct === 2) {
|
||||
stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||
} else if (act === 3) {
|
||||
} else if (effectiveAct === 3) {
|
||||
stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||
} else if (act === 5) {
|
||||
} else if (effectiveAct === 5) {
|
||||
stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||
} else if (act === 1) {
|
||||
} else if (effectiveAct === 1) {
|
||||
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates)
|
||||
}
|
||||
}
|
||||
|
|
@ -3703,35 +3721,49 @@ export function stampOutdoorDividers(
|
|||
placedRects: readonly PlacedPresetRect[],
|
||||
rng: Rng,
|
||||
levelTypeName?: string,
|
||||
levelsTable?: D2Table,
|
||||
): void {
|
||||
if (levelId === 39) return // Cow Level has no dividers
|
||||
|
||||
// Level ID takes highest precedence
|
||||
if (levelId >= 41 && levelId <= 46) {
|
||||
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
return
|
||||
} else if (levelId >= 79 && levelId <= 82) {
|
||||
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
return
|
||||
} else if (levelId === 111 || levelId === 112 || levelId === 117) {
|
||||
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
return
|
||||
} else if (levelId >= 2 && levelId <= 7) {
|
||||
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
return
|
||||
}
|
||||
|
||||
// Level type name takes second precedence
|
||||
} else if (levelTypeName === 'Act 2 - Desert') {
|
||||
if (levelTypeName === 'Act 2 - Desert') {
|
||||
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
return
|
||||
} else if (levelTypeName === 'Act 3 - Kurast') {
|
||||
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
return
|
||||
} else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') {
|
||||
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
return
|
||||
} else if (levelTypeName === 'Act 1 - Wilderness') {
|
||||
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
// Act number takes fallback precedence
|
||||
} else if (act === 2) {
|
||||
return
|
||||
}
|
||||
|
||||
// Unified actOfLevel fallback
|
||||
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
|
||||
if (effectiveAct === 2) {
|
||||
stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
} else if (act === 3) {
|
||||
} else if (effectiveAct === 3) {
|
||||
stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
} else if (act === 5) {
|
||||
} else if (effectiveAct === 5) {
|
||||
stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
} else if (act === 1) {
|
||||
} else if (effectiveAct === 1) {
|
||||
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng)
|
||||
}
|
||||
}
|
||||
|
|
@ -3741,33 +3773,6 @@ export function stampOutdoorDividers(
|
|||
* (`DRLGOUTDOORS_SpawnAct12Waypoint`, `DRLGOUTDOORS_SpawnAct12Shrines`)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** Outdoor levels in Acts 1..5 that carry a Waypoint (`Levels.txt` `Waypoint != 255`). */
|
||||
export const OUTDOOR_WAYPOINT_LEVELS = new Set([
|
||||
3, 4, 5, 6, // Act 1: Cold Plains, Stony Field, Dark Wood, Black Marsh
|
||||
42, 43, 44, 46, // Act 2: Dry Hills, Far Oasis, Lost City, Canyon of the Magi
|
||||
76, 77, 78, 79, 80, 81, 83, // Act 3: Spider Forest, Great Marsh, Flayer Jungle, Lower Kurast, Kurast Bazaar, Upper Kurast, Travincal
|
||||
106, 107, // Act 4: City of the Damned, River of Flame
|
||||
111, 112, 117, // Act 5: Frigid Highlands, Arreat Plateau, Frozen Tundra
|
||||
])
|
||||
|
||||
/**
|
||||
* Resolves the numeric Act (1..5) for a given levelId and/or levelTypeName.
|
||||
* Handles cases where DS1 preset levels report act = 1 by default.
|
||||
*/
|
||||
export function actOfLevel(levelId: number, levelTypeName?: string, fallbackAct?: number): number {
|
||||
if (levelTypeName) {
|
||||
const m = /^Act\s*([1-5])/i.exec(levelTypeName)
|
||||
if (m && m[1]) return Number(m[1])
|
||||
}
|
||||
if (levelId >= 1 && levelId <= 39) return 1
|
||||
if (levelId >= 40 && levelId <= 74) return 2
|
||||
if (levelId >= 75 && levelId <= 102) return 3
|
||||
if (levelId >= 103 && levelId <= 108) return 4
|
||||
if (levelId >= 109 && levelId <= 136) return 5
|
||||
if (fallbackAct !== undefined && fallbackAct >= 1 && fallbackAct <= 5) return fallbackAct
|
||||
return 1
|
||||
}
|
||||
|
||||
export interface ActPopulationConfig {
|
||||
readonly waypointId: number
|
||||
readonly shrinePool: readonly number[]
|
||||
|
|
@ -3776,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,
|
||||
|
|
@ -3814,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,
|
||||
|
|
@ -3885,8 +3962,9 @@ export function spawnOutdoorWaypoint(
|
|||
rng: Rng,
|
||||
stats: WildernessStats,
|
||||
levelTypeName?: string,
|
||||
levelsTable?: D2Table,
|
||||
): void {
|
||||
if (!OUTDOOR_WAYPOINT_LEVELS.has(levelId)) return
|
||||
if (!hasOutdoorWaypoint(levelId, levelsTable)) return
|
||||
|
||||
// Check if a preset already placed a Waypoint object
|
||||
const ALL_WAYPOINT_IDS = new Set([37, 52, 269, 10, 306, 1, 387, 0, 388, 12, 188])
|
||||
|
|
@ -3902,7 +3980,7 @@ export function spawnOutdoorWaypoint(
|
|||
return
|
||||
}
|
||||
|
||||
const effectiveAct = actOfLevel(levelId, levelTypeName, act)
|
||||
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
|
||||
const config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]!
|
||||
const wpId = config.waypointId
|
||||
|
||||
|
|
@ -4024,19 +4102,29 @@ export function spawnOutdoorShrines(
|
|||
shrineSubstitutions?: readonly WildernessSubstitution[],
|
||||
subTheme = 0,
|
||||
levelTypeName?: string,
|
||||
levelsTable?: D2Table,
|
||||
): void {
|
||||
if (levelId <= 0 || levelId === 108 || levelId === 110) return
|
||||
|
||||
const isOutdoorLevel =
|
||||
let isOutdoorLevel =
|
||||
(levelId >= 2 && levelId <= 7) || levelId === 39 ||
|
||||
(levelId >= 41 && levelId <= 46) ||
|
||||
(levelId >= 76 && levelId <= 83) ||
|
||||
(levelId >= 104 && levelId <= 107) ||
|
||||
(levelId >= 111 && levelId <= 112) || levelId === 117 ||
|
||||
(levelTypeName !== undefined && /wilderness|desert|jungle|kurast|mesa|lava|siege|barricade/i.test(levelTypeName))
|
||||
|
||||
if (levelsTable) {
|
||||
const row = levelsTable.rows.find(candidate => Number(cell(levelsTable, candidate, 'Id')) === levelId)
|
||||
if (row && cell(levelsTable, row, 'DrlgType') === '3') {
|
||||
const isTown = [1, 40, 75, 103, 109].includes(levelId)
|
||||
if (!isTown) isOutdoorLevel = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOutdoorLevel) return
|
||||
|
||||
const effectiveAct = actOfLevel(levelId, levelTypeName, act)
|
||||
const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable)
|
||||
const config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]!
|
||||
|
||||
const isJungle = levelId >= 76 && levelId <= 78
|
||||
|
|
@ -5005,7 +5093,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
|||
let floorLayers = 1
|
||||
let substitutionType = 0
|
||||
let version = 0
|
||||
const inferredAct = actOfLevel(request.levelId, request.levelTypeName)
|
||||
const inferredAct = actOfLevel(request.levelId, request.levelTypeName, undefined, request.levelsTable)
|
||||
let act = inferredAct
|
||||
const allLevels: Ds1[] = []
|
||||
for (const piece of request.pieces) for (const level of piece.levels) allLevels.push(level)
|
||||
|
|
@ -5058,8 +5146,8 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
|||
} else if (request.levelId >= 76 && request.levelId <= 78) {
|
||||
placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats, specialPlacedRects)
|
||||
const populationRng = new Rng(request.seed ^ 0x6c07)
|
||||
spawnOutdoorWaypoint(canvas, request.levelId, act, undefined, [], populationRng, stats, request.levelTypeName)
|
||||
spawnOutdoorShrines(canvas, request.levelId, act, undefined, [], populationRng, stats, request.shrineSubstitutions, 0, request.levelTypeName)
|
||||
spawnOutdoorWaypoint(canvas, request.levelId, act, undefined, [], populationRng, stats, request.levelTypeName, request.levelsTable)
|
||||
spawnOutdoorShrines(canvas, request.levelId, act, undefined, [], populationRng, stats, request.shrineSubstitutions, 0, request.levelTypeName, request.levelsTable)
|
||||
} else {
|
||||
placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, rng, stats)
|
||||
}
|
||||
|
|
@ -5345,6 +5433,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
|||
rng,
|
||||
request.levelTypeName,
|
||||
act1Plan?.openGates,
|
||||
request.levelsTable,
|
||||
)
|
||||
stampOutdoorDividers(
|
||||
canvas,
|
||||
|
|
@ -5357,6 +5446,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
|||
allPlacedRects,
|
||||
rng,
|
||||
request.levelTypeName,
|
||||
request.levelsTable,
|
||||
)
|
||||
|
||||
// Act 2 Desert Topography: Canyons, oasis pools, sand caravan trails, tomb entrances (DRLGOUTDESR_InitAct2OutdoorLevel)
|
||||
|
|
@ -5394,8 +5484,8 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
|||
|
||||
// Step 5: Outdoor Population Phase (Waypoints, Roadside Shrines/Chests, Landmark SuperUniques & Flavie)
|
||||
const populationRng = new Rng(request.seed ^ 0x6c07)
|
||||
spawnOutdoorWaypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.levelTypeName)
|
||||
spawnOutdoorShrines(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.shrineSubstitutions, themeIndex, request.levelTypeName)
|
||||
spawnOutdoorWaypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.levelTypeName, request.levelsTable)
|
||||
spawnOutdoorShrines(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.shrineSubstitutions, themeIndex, request.levelTypeName, request.levelsTable)
|
||||
spawnLandmarkEntities(canvas, request.levelId, act1Plan, allPlacedRects, gridWidth, gridHeight, stats, landmarkDefs)
|
||||
|
||||
for (const piece of fillPieces) {
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@ import { join } from 'node:path'
|
|||
import { decodeTbl, TblError, tblHash, lookupTblFast } from '../src/formats/tbl.ts'
|
||||
import { encodeTbl } from '../scripts/lib/tbl-writer.ts'
|
||||
import type { TblEntry } from '../scripts/lib/tbl-writer.ts'
|
||||
import {
|
||||
decodeLevelNamesFromTbl,
|
||||
setActiveLevelNames,
|
||||
sceneNameZh,
|
||||
LEVEL_NAMES_ZH,
|
||||
} from '../src/game/level-names-zh.ts'
|
||||
import type { D2Table } from '../src/game/acts.ts'
|
||||
|
||||
const fixturePath = join(__dirname, '../samples/fixtures/tbl/patchstring_113c_eng.tbl')
|
||||
const fixtureData = readFileSync(fixturePath)
|
||||
|
|
@ -146,3 +153,87 @@ describe('TBL Decoder', () => {
|
|||
expect(() => decodeTbl(buf)).toThrowError(/keyOffset/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('decodeLevelNamesFromTbl', () => {
|
||||
test('decodes level names from Map<string, string> with graceful fallback', () => {
|
||||
const customZh = new Map<string, string>([
|
||||
['Rogue Encampment', '萝格宿营地'],
|
||||
['Blood Moor', '鲜血苔原'],
|
||||
['Cathedral', '圣堂'],
|
||||
])
|
||||
|
||||
const names = decodeLevelNamesFromTbl(customZh)
|
||||
expect(names.get(1)).toBe('萝格宿营地')
|
||||
expect(names.get(2)).toBe('鲜血苔原')
|
||||
expect(names.get(33)).toBe('圣堂')
|
||||
// Fallback for untouched levels
|
||||
expect(names.get(3)).toBe(LEVEL_NAMES_ZH[3])
|
||||
expect(names.get(110)).toBe(LEVEL_NAMES_ZH[110])
|
||||
})
|
||||
|
||||
test('decodes level names from DecodedTbl using encodeTbl round-trip', () => {
|
||||
const entries: TblEntry[] = [
|
||||
{ key: 'Rogue Encampment', value: '罗格新营地' },
|
||||
{ key: 'Cathedral', value: '宏伟教堂' },
|
||||
{ key: 'Chaos Sanctuary', value: '混沌神殿' },
|
||||
]
|
||||
const encoded = encodeTbl(entries)
|
||||
const decoded = decodeTbl(encoded, 'utf-8')
|
||||
|
||||
const names = decodeLevelNamesFromTbl(decoded)
|
||||
expect(names.get(1)).toBe('罗格新营地')
|
||||
expect(names.get(33)).toBe('宏伟教堂')
|
||||
expect(names.get(108)).toBe('混沌神殿')
|
||||
// Level without custom string falls back to LEVEL_NAMES_ZH
|
||||
expect(names.get(2)).toBe(LEVEL_NAMES_ZH[2])
|
||||
})
|
||||
|
||||
test('resolves level names using Levels.txt table structure', () => {
|
||||
const mockLevelsTable: D2Table = {
|
||||
header: ['Name', 'Id', 'LevelName'],
|
||||
rows: [
|
||||
['Act 1 - Town', '1', 'Rogue Encampment'],
|
||||
['Act 1 - Wilderness 1', '2', 'Blood Moor'],
|
||||
['Act 1 - Cathedral 1', '33', 'Cathedral'],
|
||||
['Custom Mod Level', '200', 'Modded Domain'],
|
||||
],
|
||||
}
|
||||
|
||||
const tblMap = new Map<string, string>([
|
||||
['Rogue Encampment', '罗格营地(官方)'],
|
||||
['Modded Domain', '模组领域'],
|
||||
])
|
||||
|
||||
const names = decodeLevelNamesFromTbl(tblMap, mockLevelsTable)
|
||||
expect(names.get(1)).toBe('罗格营地(官方)')
|
||||
expect(names.get(2)).toBe(LEVEL_NAMES_ZH[2]) // fallback to LEVEL_NAMES_ZH
|
||||
expect(names.get(33)).toBe(LEVEL_NAMES_ZH[33]) // fallback to LEVEL_NAMES_ZH
|
||||
expect(names.get(200)).toBe('模组领域') // custom mod row
|
||||
})
|
||||
|
||||
test('falls back gracefully to LEVEL_NAMES_ZH or English key when tbl is empty or null', () => {
|
||||
const fromNull = decodeLevelNamesFromTbl(null)
|
||||
expect(fromNull.get(1)).toBe(LEVEL_NAMES_ZH[1])
|
||||
expect(fromNull.get(40)).toBe(LEVEL_NAMES_ZH[40])
|
||||
|
||||
const fromEmpty = decodeLevelNamesFromTbl(new Map())
|
||||
expect(fromEmpty.get(1)).toBe(LEVEL_NAMES_ZH[1])
|
||||
expect(fromEmpty.get(75)).toBe(LEVEL_NAMES_ZH[75])
|
||||
})
|
||||
|
||||
test('sceneNameZh works with active decoded level names and custom override', () => {
|
||||
try {
|
||||
setActiveLevelNames(new Map([[1, '罗格基地']]))
|
||||
expect(sceneNameZh('1-act-1-rogue-encampment', 'Rogue Encampment')).toBe('罗格基地')
|
||||
expect(sceneNameZh('2-act-1-blood-moor', 'Blood Moor')).toBe(LEVEL_NAMES_ZH[2])
|
||||
|
||||
// Custom override in sceneNameZh
|
||||
expect(
|
||||
sceneNameZh('1-act-1-rogue-encampment', 'Rogue Encampment', new Map([[1, '营地覆盖']])),
|
||||
).toBe('营地覆盖')
|
||||
} finally {
|
||||
setActiveLevelNames(null)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,16 @@ import {
|
|||
generateWilderness,
|
||||
OUTDOOR_WAYPOINT_LEVELS,
|
||||
actOfLevel,
|
||||
hasOutdoorWaypoint,
|
||||
ACT_POPULATION_CONFIG,
|
||||
type WildernessPiece,
|
||||
type WildernessSubstitution,
|
||||
} from "../src/game/wilderness.ts"
|
||||
import {
|
||||
actOfLevel as actsActOfLevel,
|
||||
hasOutdoorWaypoint as actsHasOutdoorWaypoint,
|
||||
type D2Table,
|
||||
} from "../src/game/acts.ts"
|
||||
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
|
||||
|
||||
function makeMockDs1(width: number, height: number, hasGapInWalls = false, isOpen = false): Ds1 {
|
||||
|
|
@ -89,6 +95,59 @@ describe("Wilderness Thematic Waypoint and Shrine Distribution (Issue #56)", ()
|
|||
expect(actOfLevel(117, undefined)).toBe(5)
|
||||
})
|
||||
|
||||
test("actOfLevel resolves Act column from Levels.txt table when provided", () => {
|
||||
const mockLevelsTable: D2Table = {
|
||||
header: ["Id", "Name", "Act"],
|
||||
rows: [
|
||||
["2", "Blood Moor", "0"],
|
||||
["42", "Dry Hills", "1"],
|
||||
["76", "Spider Forest", "2"],
|
||||
["106", "City of the Damned", "3"],
|
||||
["112", "Arreat Plateau", "0"], // Expansion Act 5 row stores 0 in blizzard data
|
||||
],
|
||||
}
|
||||
|
||||
expect(actOfLevel(2, undefined, undefined, mockLevelsTable)).toBe(1)
|
||||
expect(actOfLevel(42, undefined, undefined, mockLevelsTable)).toBe(2)
|
||||
expect(actOfLevel(76, undefined, undefined, mockLevelsTable)).toBe(3)
|
||||
expect(actOfLevel(106, undefined, undefined, mockLevelsTable)).toBe(4)
|
||||
expect(actOfLevel(112, undefined, undefined, mockLevelsTable)).toBe(5)
|
||||
|
||||
// Re-export from acts.ts is identical
|
||||
expect(actsActOfLevel).toBe(actOfLevel)
|
||||
expect(actsHasOutdoorWaypoint).toBe(hasOutdoorWaypoint)
|
||||
})
|
||||
|
||||
test("hasOutdoorWaypoint reads Levels.txt Waypoint column dynamically", () => {
|
||||
const mockLevelsTable: D2Table = {
|
||||
header: ["Id", "Name", "Act", "DrlgType", "Waypoint"],
|
||||
rows: [
|
||||
["1", "Rogue Encampment", "0", "1", "0"], // Town level -> false for outdoor waypoint
|
||||
["2", "Blood Moor", "0", "3", "255"], // Outdoor, no waypoint (255) -> false
|
||||
["3", "Cold Plains", "0", "3", "1"], // Outdoor, waypoint (1) -> true
|
||||
["40", "Lut Gholein", "1", "1", "2"], // Town level -> false
|
||||
["43", "Far Oasis", "1", "3", "3"], // Outdoor, waypoint -> true
|
||||
["200", "Custom Field", "0", "3", "4"], // Custom outdoor level with waypoint -> true
|
||||
["201", "Custom Field No WP", "0", "3", "255"], // Custom outdoor level without waypoint -> false
|
||||
],
|
||||
}
|
||||
|
||||
// Dynamic resolution via table
|
||||
expect(hasOutdoorWaypoint(3, mockLevelsTable)).toBe(true)
|
||||
expect(hasOutdoorWaypoint(2, mockLevelsTable)).toBe(false)
|
||||
expect(hasOutdoorWaypoint(1, mockLevelsTable)).toBe(false) // Town
|
||||
expect(hasOutdoorWaypoint(40, mockLevelsTable)).toBe(false) // Town
|
||||
expect(hasOutdoorWaypoint(43, mockLevelsTable)).toBe(true)
|
||||
expect(hasOutdoorWaypoint(200, mockLevelsTable)).toBe(true)
|
||||
expect(hasOutdoorWaypoint(201, mockLevelsTable)).toBe(false)
|
||||
|
||||
// Fallback when no table provided
|
||||
expect(hasOutdoorWaypoint(3)).toBe(true)
|
||||
expect(hasOutdoorWaypoint(2)).toBe(false)
|
||||
expect(hasOutdoorWaypoint(43)).toBe(true)
|
||||
expect(hasOutdoorWaypoint(41)).toBe(false)
|
||||
})
|
||||
|
||||
test("OUTDOOR_WAYPOINT_LEVELS canonical set covers Acts 1..5 outdoor waypoints", () => {
|
||||
// Act 1
|
||||
expect(OUTDOOR_WAYPOINT_LEVELS.has(3)).toBe(true) // Cold Plains
|
||||
|
|
|
|||
Loading…
Reference in New Issue