From 8ee3a4c97126d24ccc3888a5cb8c44dccd0d4cab Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 14:09:31 +0000 Subject: [PATCH 1/4] fix(game): import canonical SUB_TILES_PER_TILE and use dynamic bounds/stride in siege - Import canonical SUB_TILES_PER_TILE from map.ts in maze-objects.ts and level-links.ts - Replace hardcoded y < 6 || y > 42 bounds with dynamic canvas.height - 6 bounds in wilderness-siege.ts - Replace static i * 16 stride assumption with dynamic ds1.width and canvas.width stride TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/level-links.ts | 4 +--- src/game/maze-objects.ts | 4 +++- src/game/wilderness-siege.ts | 27 ++++++++++++++++++++------- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/game/level-links.ts b/src/game/level-links.ts index 7d32da6..66bb7e0 100644 --- a/src/game/level-links.ts +++ b/src/game/level-links.ts @@ -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. diff --git a/src/game/maze-objects.ts b/src/game/maze-objects.ts index 8060f7c..a19fcb9 100644 --- a/src/game/maze-objects.ts +++ b/src/game/maze-objects.ts @@ -20,8 +20,10 @@ import type { Ds1, Ds1Cell, Ds1Object } from '../formats/ds1.ts' import { Rng } from './rng.ts' +import { SUB_TILES_PER_TILE } from './map.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' diff --git a/src/game/wilderness-siege.ts b/src/game/wilderness-siege.ts index 0290c54..e223e70 100644 --- a/src/game/wilderness-siege.ts +++ b/src/game/wilderness-siege.ts @@ -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() // 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, From 7efdad4693413051344d220fe60b4028219be3bb Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 14:14:15 +0000 Subject: [PATCH 2/4] feat(game): decode level names from string.tbl with graceful fallback - Implement decodeLevelNamesFromTbl supporting D2Tbl/Map and Levels.txt - Add CANONICAL_LEVEL_NAME_KEYS and active decoded level names cache - Support graceful fallback to LEVEL_NAMES_ZH and English name keys - Add comprehensive test coverage in tests/tbl.test.ts TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/level-names-zh.ts | 278 +++++++++++++++++++++++++++++++++++-- tests/tbl.test.ts | 91 ++++++++++++ 2 files changed, 356 insertions(+), 13 deletions(-) diff --git a/src/game/level-names-zh.ts b/src/game/level-names-zh.ts index bd75d64..4aa4a04 100644 --- a/src/game/level-names-zh.ts +++ b/src/game/level-names-zh.ts @@ -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 /** 五个 act 的中文名,下标即 act 号。 */ export const ACT_NAMES_ZH: Readonly> = { @@ -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 | Record, +): 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> = { + 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, 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 | null = null + +/** Set or clear the globally active level names decoded from string.tbl. */ +export function setActiveLevelNames(names: Map | Record | 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 | null { + return activeTblLevelNames +} + +/** + * Decodes localized level names from a string.tbl dictionary (or Map). + * 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 | null, + levelsTable?: D2Table, +): Map { + const result = new Map() + + 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([ + ...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 +} diff --git a/tests/tbl.test.ts b/tests/tbl.test.ts index 6672f95..7f96ef8 100644 --- a/tests/tbl.test.ts +++ b/tests/tbl.test.ts @@ -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 with graceful fallback', () => { + const customZh = new Map([ + ['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([ + ['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) + } + }) +}) + From 94305b03dd44d31178f5cab7c361cd9acb7d0c86 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 14:27:54 +0000 Subject: [PATCH 3/4] Resolve outdoor waypoints and act mapping dynamically from Levels.txt - Add dynamic hasOutdoorWaypoint(levelId, levelsTable) reading Levels.txt Waypoint column (!= 255) - Unify actOfLevel in acts.ts as canonical source of truth supporting Levels.txt Act column - Support levelsTable in WildernessRequest, spawnOutdoorWaypoint, spawnOutdoorShrines, stampOutdoorRoads, stampOutdoorDividers - Re-export actOfLevel, hasOutdoorWaypoint, and OUTDOOR_WAYPOINT_LEVELS cleanly without circular dependencies - Add unit tests verifying dynamic Levels.txt resolution and fallbacks TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/acts.ts | 78 ++++++++++++++- src/game/wilderness.ts | 110 ++++++++++++--------- tests/wilderness-waypoints-shrines.test.ts | 59 +++++++++++ 3 files changed, 199 insertions(+), 48 deletions(-) diff --git a/src/game/acts.ts b/src/game/acts.ts index 6d3dc41..dd4b9a7 100644 --- a/src/game/acts.ts +++ b/src/game/acts.ts @@ -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), } } + diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 55e3ba1..744b91b 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -103,7 +103,9 @@ 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 { readSuperUniques, CANONICAL_ELITE_MODIFIERS } from './monsters.ts' import type { SuperUnique } from './monsters.ts' @@ -242,6 +244,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 +3660,47 @@ export function stampOutdoorRoads( rng: Rng, levelTypeName?: string, openGates?: ReadonlyMap, + 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 +3719,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 +3771,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[] @@ -3885,8 +3888,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 +3906,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 +4028,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 +5019,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 +5072,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 +5359,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult rng, request.levelTypeName, act1Plan?.openGates, + request.levelsTable, ) stampOutdoorDividers( canvas, @@ -5357,6 +5372,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 +5410,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) { diff --git a/tests/wilderness-waypoints-shrines.test.ts b/tests/wilderness-waypoints-shrines.test.ts index a0e9e80..83fc0e3 100644 --- a/tests/wilderness-waypoints-shrines.test.ts +++ b/tests/wilderness-waypoints-shrines.test.ts @@ -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 From a7a3ce13d4cfa2a2e71a7c78deda4f0982071a6b Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 14:39:13 +0000 Subject: [PATCH 4/4] feat(game): document and validate object pools and LvlPrest piece families (Issue #61 Step 4) - Document the 3-tier lookup relation across Levels.txt, LvlTypes.txt, and LvlPrest.txt - Explain why Catacombs/Sewers index pieces via Def / LevelId = 0 / first levelId - Explain why composite themes have sub-families (Corrupt Harem, Mephisto, Temple, Bridge, ThroneRoom) - Implement discoverMazePieceFamilies with optional LvlPrest table and update classifyMazePieceName - Add Objects.txt mapping validation (validateDungeonObjectPools, validateActPopulationConfig, resolveObjectsTxtId) - Add comprehensive unit tests in tests/maze-objects.test.ts TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/maze-objects.ts | 144 +++++++++++++++++++++++++++++++++++-- src/game/maze.ts | 115 ++++++++++++++++++++++++++++- src/game/wilderness.ts | 74 +++++++++++++++++++ tests/maze-objects.test.ts | 108 ++++++++++++++++++++++++++++ 4 files changed, 434 insertions(+), 7 deletions(-) diff --git a/src/game/maze-objects.ts b/src/game/maze-objects.ts index a19fcb9..0420259 100644 --- a/src/game/maze-objects.ts +++ b/src/game/maze-objects.ts @@ -21,6 +21,9 @@ import type { Ds1, Ds1Cell, Ds1Object } from '../formats/ds1.ts' import { Rng } from './rng.ts' import { SUB_TILES_PER_TILE } from './map.ts' +import { lookupObject } from './object-lookup.ts' +import type { ObjectsTable } from './objects.ts' +import type { D2Table } from './acts.ts' /** Subtiles per DS1 cell. */ export { SUB_TILES_PER_TILE } @@ -123,7 +126,17 @@ export interface CandidateSpot { * Object Definitions & Act Mapping * ------------------------------------------------------------------------- */ -/** Shrine definition: subtype to act-specific DS1 ID mapping. */ +/** + * Shrine definition: subtype to act-specific DS1 Type 2 local ID mapping. + * + * Each entry maps a logical shrine subtype to the DS1 object ID used in each Act: + * - Act 1: IDs 6..11 map to Cairn Stone / shrine tokens S1..S6 (Objects.txt 17..22); + * 29 maps to Shrine token SF (Objects.txt 2); 82/83 map to Shrines SH/BC (Objects.txt 77/85); + * 84 maps to Dummy SG (Objects.txt 86). + * - Acts 2..5: IDs 167..172 map to Act 2+ shrine tokens MH, MJ, CZ, JH, SX, MK (Objects.txt 166..172); + * 227 maps to Dummy QX (Objects.txt 227, Experience/Gem); 152 maps to Fire Orifice HA (Objects.txt 152); + * 55/236 map to Poison corpses/shrines (Objects.txt 55, 236). + */ interface ShrineDef { readonly subType: ShrineSubType readonly idsByAct: Record @@ -143,7 +156,20 @@ export const CANONICAL_SHRINES: readonly ShrineDef[] = [ { subType: 'poison', idsByAct: { 1: 84, 2: 55, 3: 236, 4: 236, 5: 236 } }, ] -/** Chest ID mapping per act. */ +/** + * Chest ID mapping per act. + * + * DS1 Type 2 IDs correspond to: + * - Super Chests: + * - Act 1: 5 (chest L1, Objects.txt 5), 63 (chest L2, Objects.txt 6), 155/156 (hidden stashes C7, Objects.txt 155) + * - Act 2: 32 (CL, Objects.txt 32), 33 (A1, Objects.txt 33), 155/156 + * - Act 3: 65 (X1, Objects.txt 65), 66 (X2, Objects.txt 66), 155/156 + * - Acts 4-5: 155, 156 + * - Normal Chests: + * - Act 1: 89..94 (Caskets C1..C6, Objects.txt 50, 51, 79, 53, 1, 3); + * 200..203 (Shrines/Stashes MY, MX, MW, MV, Objects.txt 200..203); 229 (door/chest SQ, Objects.txt 229) + * - Acts 2..5: 200..203, 229 + */ export const CHEST_IDS_BY_ACT: Record = { 1: { super: [5, 63, 155, 156], normal: [89, 90, 91, 92, 200, 201, 229, 203] }, 2: { super: [32, 33, 155, 156], normal: [200, 201, 229, 203] }, @@ -152,7 +178,22 @@ export const CHEST_IDS_BY_ACT: Record Number(row[idCol]) === entry.objectsTxtId) + if (!found) return null + } + } + return entry.objectsTxtId +} + +/** + * Validates dungeon object pools against object-lookup metadata or an optional loaded Objects.txt table. + * Ensures data-driven correctness for all hardcoded object pools. + */ +export function validateDungeonObjectPools(objectsTable?: ObjectsTable | D2Table): { + valid: boolean + totalChecked: number + unmapped: number[] + errors: string[] +} { + const unmapped: number[] = [] + let totalChecked = 0 + + const check = (act: number, id: number) => { + totalChecked += 1 + const entry = lookupObject(act, 2, id) + if (!entry) { + unmapped.push(id) + } else if (objectsTable) { + if ('byId' in objectsTable) { + if (entry.objectsTxtId >= 0 && !objectsTable.byId.has(entry.objectsTxtId)) { + unmapped.push(id) + } + } else if ('header' in objectsTable && 'rows' in objectsTable) { + const idCol = objectsTable.header.indexOf('Id') + const found = idCol !== -1 && objectsTable.rows.some(row => Number(row[idCol]) === entry.objectsTxtId) + if (entry.objectsTxtId >= 0 && !found) { + unmapped.push(id) + } + } + } + } + + for (let act = 1; act <= 5; act += 1) { + const chests = CHEST_IDS_BY_ACT[act] + if (chests) { + for (const id of chests.super) check(act, id) + for (const id of chests.normal) check(act, id) + } + const racks = RACK_IDS_BY_ACT[act] + if (racks) { + for (const id of racks.armor.any) check(act, id) + for (const id of racks.weapon.any) check(act, id) + } + const containers = CONTAINER_IDS_BY_ACT[act] + if (containers) { + for (const id of containers.barrels) check(act, id) + for (const id of containers.urns) check(act, id) + for (const id of containers.caskets) check(act, id) + for (const id of containers.crates) check(act, id) + } + for (const shrine of CANONICAL_SHRINES) { + const id = shrine.idsByAct[act] + if (id !== undefined) check(act, id) + } + } + + const errors = unmapped.map(id => `DS1 Type 2 object ID ${id} is unmapped or missing in Objects.txt`) + return { valid: unmapped.length === 0, totalChecked, unmapped, errors } +} + /* ------------------------------------------------------------------------- * * Spatial Analysis * ------------------------------------------------------------------------- */ diff --git a/src/game/maze.ts b/src/game/maze.ts index bfc2cf6..3516412 100644 --- a/src/game/maze.ts +++ b/src/game/maze.ts @@ -78,6 +78,7 @@ * this repository assumed. */ import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.ts' +import type { D2Table } from './acts.ts' import { Rng } from './rng.ts' import { SUB_TILES_PER_TILE } from './map.ts' export { SUB_TILES_PER_TILE } @@ -384,7 +385,64 @@ const KIND_PREFIXES: readonly (readonly [string, MazePieceKind])[] = [ ['Kaa', 'quest'], ] -/** Which `LvlPrest` name families belong to which maze level type. */ +/** + * Relationship between Levels.txt, LvlTypes.txt, and LvlPrest.txt: + * + * In Diablo II's data architecture, map pieces and environments are resolved + * across three interconnected tables: + * + * 1. `Levels.txt` contains level declarations. Each row has an `Id` (1..136) and + * a `LevelType` foreign key pointing to `LvlTypes.txt` (or 0 for preset-only + * levels like towns). + * 2. `LvlTypes.txt` defines visual and audio themes (e.g. `Act 1 - Cave`, + * `Act 1 - Catacombs`, `Act 2 - Harem`, `Act 3 - Kurast`). It specifies up to + * 32 DT1 tile libraries (`File 1` .. `File 32`) containing the graphic art. + * 3. `LvlPrest.txt` catalogs the authored DS1 map presets and maze room pieces. + * Each row contains `Def` (the preset ID), `Name`, `LevelId`, `Dt1Mask`, + * `SizeX`, `SizeY`, and up to 6 DS1 file paths (`File1` .. `File6`). + * + * ### Why Catacombs / Sewers index pieces via `Def` / `LevelId = 0` / first `LevelId`: + * + * - Preset levels (like Towns, Tristram, Countess Tower) have fixed layouts and + * their rows in `LvlPrest.txt` set `LevelId` to their exact `Levels.txt.Id` + * (e.g., Rogue Encampment has `LevelId = 1`, Lut Gholein has `LevelId = 40`). + * - Maze levels (Catacombs 1-4 [Levels 32-35], Sewers Act 2 [Levels 41-43], + * Sewers Act 3 [Levels 92-93], Caves 1-2 [Levels 2-3]), in contrast, are generated + * dynamically by stitching dozens of modular 25x25 cell DS1 rooms (with 1..4 doors, + * stairs up/down, waypoints, quest rooms, themed variants). + * - Blizzard North did not duplicate 30+ room rows for each level layer in + * `LvlPrest.txt`. Instead: + * - Room pieces are shared across all levels sharing the theme, stored with + * `LevelId = 0` or set to the first `LevelId` where that theme appears (e.g. 32 + * for Catacombs). + * - In D2MOO and the native Blizzard engine (`DRLGMAZE_PickRoomPreset`, + * `DRLGMAZE_RollBasicPresets`, `DRLGMAZE_ScanReplaceSpecialPreset`), pieces + * are indexed either by hardcoded `Def` base offsets (`base + door_bitmask`) + * or discovered by prefix matching against the piece's `Name` column. + * + * ### Why composite themes have sub-families: + * + * Certain levels share a primary tile theme (`LevelType`) but introduce distinct + * visual zones, boss chambers, or quest rooms authored under different `Name` + * prefixes in `LvlPrest.txt`: + * - `Act 2 - Harem`: Combines normal palace rooms (`Act 2 - Harem ...`) with + * corrupted harem cellar and basement pieces (`Act 2 - Corrupt Harem ...`). + * - `Act 3 - Kurast`: Standard Kurast dungeon rooms (`Act 3 - Kurast ...`) are + * supplemented in Durance of Hate Level 3 with Mephisto's sanctum pieces + * (`Act 3 - Mephisto ...`). + * - `Act 3 - Dungeon`: Jungle dungeon rooms (`Act 3 - Dungeon ...`) incorporate + * temple quest chambers (`Act 3 - Temple ...`). + * - `Act 4 - Lava`: River of Flame and Chaos Sanctuary lava island pieces + * (`Act 4 - Lava ...`) are linked by preset bridge pieces (`Act 4 - Bridge ...`). + * - `Act 5 - Ice Caves`: Contains both full-name (`Act 5 - Ice Caves ...`) and + * abbreviated (`Act 5 - Ice ...`) piece definitions in `LvlPrest.txt`. + * - `Act 5 - Baal`: Worldstone Keep pieces (`Act 5 - Baal ...`) terminate in the + * Throne Room of Destruction (`Act 5 - ThroneRoom ...`). + * + * Because `LvlPrest.txt` lacks a `LevelType` column, these sub-families cannot be + * joined by database foreign key alone; they reflect Blizzard's C++ DRLG engine + * conventions and are captured canonically in {@link MAZE_PIECE_FAMILIES}. + */ export const MAZE_PIECE_FAMILIES: Readonly> = { 'Act 1 - Cave': ['Act 1 - Cave'], 'Act 1 - Crypt': ['Act 1 - Crypt'], @@ -407,6 +465,52 @@ export const MAZE_PIECE_FAMILIES: Readonly> = 'Act 5 - Baal': ['Act 5 - Baal', 'Act 5 - ThroneRoom'], } +/** + * Discover the `LvlPrest` name families belonging to a maze level type. + * + * If `lvlPrestTable` is supplied, validates and discovers family prefixes present + * in the table that correspond to the given `levelTypeName`. + * If `lvlPrestTable` is omitted or contains no matching family rows, falls back to + * {@link MAZE_PIECE_FAMILIES}. + * + * @param levelTypeName - the owning `LvlTypes.txt` name (e.g. `'Act 2 - Harem'`). + * @param lvlPrestTable - optional loaded `LvlPrest.txt` table. + * @returns list of family name prefixes in precedence order (longer/more specific prefixes first). + */ +export function discoverMazePieceFamilies( + levelTypeName: string, + lvlPrestTable?: D2Table, +): readonly string[] { + const staticFamilies = MAZE_PIECE_FAMILIES[levelTypeName] ?? [levelTypeName] + if (lvlPrestTable === undefined) { + return staticFamilies + } + + const nameCol = lvlPrestTable.header.indexOf('Name') + if (nameCol === -1) { + return staticFamilies + } + + const discovered = new Set() + for (const fam of staticFamilies) { + const lowerFam = fam.toLowerCase() + for (const row of lvlPrestTable.rows) { + const rowName = (row[nameCol] ?? '').trim().toLowerCase() + if (rowName.startsWith(lowerFam)) { + discovered.add(fam) + break + } + } + } + + if (discovered.size === 0) { + return staticFamilies + } + + const result = staticFamilies.filter(fam => discovered.has(fam)) + return result.length > 0 ? result : staticFamilies +} + /** * Classify one `LvlPrest.txt` piece name. * @@ -419,10 +523,15 @@ export const MAZE_PIECE_FAMILIES: Readonly> = * * @param name - the `LvlPrest.txt` `Name`. * @param levelTypeName - the owning `LvlTypes.txt` name. + * @param lvlPrestTable - optional loaded `LvlPrest.txt` table for dynamic family discovery. * @returns the kind and side token, or `null` when the name is not a piece. */ -export function classifyMazePieceName(name: string, levelTypeName: string): { kind: MazePieceKind; sides: string } | null { - const families = MAZE_PIECE_FAMILIES[levelTypeName] ?? [levelTypeName] +export function classifyMazePieceName( + name: string, + levelTypeName: string, + lvlPrestTable?: D2Table, +): { kind: MazePieceKind; sides: string } | null { + const families = discoverMazePieceFamilies(levelTypeName, lvlPrestTable) const matchedFamily = families.find(family => name.toLowerCase().startsWith(family.toLowerCase())) if (matchedFamily === undefined) return null const rest = name.slice(matchedFamily.length).trim() diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 744b91b..9671e58 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -106,6 +106,8 @@ import { import { cell, actOfLevel, hasOutdoorWaypoint, OUTDOOR_WAYPOINT_LEVELS } from './acts.ts' import type { D2Table } from './acts.ts' export { actOfLevel, hasOutdoorWaypoint, OUTDOOR_WAYPOINT_LEVELS } from './acts.ts' +import { lookupObject } from './object-lookup.ts' +import type { ObjectsTable } from './objects.ts' import { readSuperUniques, CANONICAL_ELITE_MODIFIERS } from './monsters.ts' import type { SuperUnique } from './monsters.ts' @@ -3779,6 +3781,38 @@ export interface ActPopulationConfig { readonly scatterPool: readonly number[] } +/** + * Population object ID pool per Act for outdoor wilderness generation (`DRLGOUTDOORS_SpawnAct12Waypoint`, `DRLGOUTDOORS_SpawnAct12Shrines`). + * + * Each ID is a local DS1 Type 2 object record ID mapped through `object-lookup-data.ts` to Blizzard's `Objects.txt`: + * - `waypointId`: + * - Act 1: 37 -> Objects.txt row 119 (`Waypoint`, token WP) + * - Act 2: 10 -> Objects.txt row 156 (`Waypoint`, token WM) + * - Act 3: 1 -> Objects.txt row 237 (`Waypoint`, token WZ) + * - Act 4: 0 -> Objects.txt row 238 (`Waypoint`, token WV) + * - Act 5: 12 -> Objects.txt row 38 (`Waypoint`, token WT) + * - `wellId`: + * - Act 1: 31 -> Objects.txt row 84 (Healing Well, token HW) + * - Acts 2..5: 234 -> Objects.txt row 234 (Outdoor Well, token QY) + * - `shrinePool`: + * - Act 1: 29 (Shrine SF), 82 (Shrine SH), 83 (Shrine BC), 84 (Dummy SG) + * - Act 2: 54 (Z1), 55 (Z2), 103 (FL), 104 (A3), 105 (A4), 126 (IS) + * - Act 3: 14 (S1), 85 (BC), 111 (F3), 152 (HA), 227 (QX), 231 (HT), 235 (QZ), 236 (HO) + * - Act 4: 56 (Z5), 152 (HA), 227 (QX), 231 (HT), 235 (QZ), 236 (HO) + * - Act 5: 152 (HA), 227 (QX), 231 (HT), 235 (QZ), 236 (HO) + * - `chestPool`: + * - Act 1: 5 (chest L1), 89..94 (caskets C1..C6) + * - Act 2: 32 (CL), 33 (A1), 44..46 (R5, S1, CT) + * - Act 3: 65 (X1), 66 (X2), 67 (1R), 80 (OB), 81 (AF) + * - Act 4: 19 (XQ), 20 (QS), 24 (HO), 25 (HN) + * - Act 5: 24 (YP), 25 (6A), 26 (YZ), 27 (6Q), 28 (6B), 29 (6C) + * - `scatterPool`: + * - Act 1: 17 (BR), 18 (BF), 25 (TT) + * - Act 2: 14 (TT), 48 (BR), 122 (TT) + * - Act 3: 4 (FB), 5 (TO), 48 (DE), 179 (BR) + * - Act 4: 40 (HG), 63 (FB), 179 (BR) + * - Act 5: 13 (FB), 179 (BR), 4 (B6) + */ export const ACT_POPULATION_CONFIG: Readonly> = { 1: { waypointId: 37, @@ -3817,6 +3851,46 @@ export const ACT_POPULATION_CONFIG: Readonly }, } +/** + * Validates outdoor population configuration against object-lookup metadata or an optional loaded Objects.txt table. + */ +export function validateActPopulationConfig( + config: ActPopulationConfig, + act: number, + objectsTable?: ObjectsTable | D2Table, +): { valid: boolean; totalChecked: number; unmapped: number[]; errors: string[] } { + const unmapped: number[] = [] + let totalChecked = 0 + const check = (id: number) => { + totalChecked += 1 + const entry = lookupObject(act, 2, id) + if (!entry) { + unmapped.push(id) + } else if (objectsTable) { + if ('byId' in objectsTable) { + if (entry.objectsTxtId >= 0 && !objectsTable.byId.has(entry.objectsTxtId)) { + unmapped.push(id) + } + } else if ('header' in objectsTable && 'rows' in objectsTable) { + const idCol = objectsTable.header.indexOf('Id') + const found = idCol !== -1 && objectsTable.rows.some(row => Number(row[idCol]) === entry.objectsTxtId) + if (entry.objectsTxtId >= 0 && !found) { + unmapped.push(id) + } + } + } + } + + check(config.waypointId) + check(config.wellId) + for (const id of config.shrinePool) check(id) + for (const id of config.chestPool) check(id) + for (const id of config.scatterPool) check(id) + + const errors = unmapped.map(id => `DS1 Type 2 object ID ${id} is unmapped or missing in Objects.txt`) + return { valid: unmapped.length === 0, totalChecked, unmapped, errors } +} + function isTileClear( canvas: Canvas, tx: number, diff --git a/tests/maze-objects.test.ts b/tests/maze-objects.test.ts index ff44275..b2f847a 100644 --- a/tests/maze-objects.test.ts +++ b/tests/maze-objects.test.ts @@ -7,15 +7,24 @@ import { CHEST_IDS_BY_ACT, RACK_IDS_BY_ACT, CONTAINER_IDS_BY_ACT, + resolveObjectsTxtId, + validateDungeonObjectPools, type MazeRoomDescriptor, type PlacedMazeObject, type WallAlignment, } from '../src/game/maze-objects.ts' import { generateMaze, + discoverMazePieceFamilies, + classifyMazePieceName, type MazePiece, type MazeRequest, } from '../src/game/maze.ts' +import { + ACT_POPULATION_CONFIG, + validateActPopulationConfig, +} from '../src/game/wilderness.ts' +import type { D2Table } from '../src/game/acts.ts' import { Rng } from '../src/game/rng.ts' /** @@ -536,3 +545,102 @@ describe('isOpenFloor content check', () => { }) }) +describe('Issue #61 Step 4: Objects.txt mapping validation and LvlPrest piece families', () => { + describe('validateDungeonObjectPools', () => { + test('validates all canonical dungeon object pools against embedded data without errors', () => { + const result = validateDungeonObjectPools() + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + expect(result.totalChecked).toBe(223) + }) + + test('detects invalid or missing object IDs when an incomplete objectsTable is provided', () => { + const mockObjectsTable: D2Table = { header: ['Id'], rows: [['1'], ['2']] } + const result = validateDungeonObjectPools(mockObjectsTable) + expect(result.valid).toBe(false) + expect(result.errors.length).toBeGreaterThan(0) + expect(result.errors[0]).toContain('missing in Objects.txt') + }) + }) + + describe('resolveObjectsTxtId', () => { + test('resolves known DS1 Type 2 IDs to Objects.txt row IDs across Acts', () => { + expect(resolveObjectsTxtId(1, 2)).toBe(39) // Refilling Shrine -> Objects.txt ID 39 + expect(resolveObjectsTxtId(1, 5)).toBe(5) // Chest C1 -> Objects.txt ID 5 + expect(resolveObjectsTxtId(1, 99999)).toBeNull() + }) + + test('resolves with explicit objectsTable filter', () => { + const mockObjectsTable: D2Table = { header: ['Id'], rows: [['39'], ['140']] } + expect(resolveObjectsTxtId(1, 2, mockObjectsTable)).toBe(39) + expect(resolveObjectsTxtId(1, 6, mockObjectsTable)).toBeNull() + }) + }) + + describe('validateActPopulationConfig', () => { + test('validates ACT_POPULATION_CONFIG for all 5 acts against embedded data', () => { + for (let act = 1; act <= 5; act += 1) { + const config = ACT_POPULATION_CONFIG[act]! + const result = validateActPopulationConfig(config, act) + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + expect(result.totalChecked).toBeGreaterThan(0) + } + }) + + test('detects invalid IDs in population config with incomplete table', () => { + const mockObjectsTable: D2Table = { header: ['Id'], rows: [['1']] } + const config = ACT_POPULATION_CONFIG[1]! + const result = validateActPopulationConfig(config, 1, mockObjectsTable) + expect(result.valid).toBe(false) + expect(result.errors.length).toBeGreaterThan(0) + }) + }) + + describe('discoverMazePieceFamilies', () => { + test('returns static families when lvlPrestTable is omitted', () => { + expect(discoverMazePieceFamilies('Act 2 - Harem')).toEqual(['Act 2 - Corrupt Harem', 'Act 2 - Harem']) + expect(discoverMazePieceFamilies('Act 3 - Kurast')).toEqual(['Act 3 - Mephisto', 'Act 3 - Kurast']) + expect(discoverMazePieceFamilies('Act 3 - Dungeon')).toEqual(['Act 3 - Dungeon', 'Act 3 - Temple']) + expect(discoverMazePieceFamilies('Act 4 - Lava')).toEqual(['Act 4 - Lava', 'Act 4 - Bridge']) + expect(discoverMazePieceFamilies('Act 5 - Ice Caves')).toEqual(['Act 5 - Ice Caves', 'Act 5 - Ice']) + expect(discoverMazePieceFamilies('Act 5 - Baal')).toEqual(['Act 5 - Baal', 'Act 5 - ThroneRoom']) + expect(discoverMazePieceFamilies('Unknown Theme')).toEqual(['Unknown Theme']) + }) + + test('filters to families present in lvlPrestTable when provided', () => { + const mockLvlPrest: D2Table = { + header: ['Def', 'Name'], + rows: [ + ['100', 'Act 2 - Harem NSEW'], + ['101', 'Act 2 - Harem N'], + ], + } + expect(discoverMazePieceFamilies('Act 2 - Harem', mockLvlPrest)).toEqual(['Act 2 - Harem']) + }) + + test('falls back to static families when table has no matching rows', () => { + const emptyLvlPrest: D2Table = { header: ['Def', 'Name'], rows: [] } + expect(discoverMazePieceFamilies('Act 2 - Harem', emptyLvlPrest)).toEqual([ + 'Act 2 - Corrupt Harem', + 'Act 2 - Harem', + ]) + }) + }) + + describe('classifyMazePieceName with optional lvlPrestTable', () => { + test('classifies piece names with and without lvlPrestTable', () => { + const withoutTable = classifyMazePieceName('Act 2 - Harem NSEW', 'Act 2 - Harem') + expect(withoutTable).toEqual({ kind: 'room', sides: 'NSEW' }) + + const mockLvlPrest: D2Table = { + header: ['Def', 'Name'], + rows: [['100', 'Act 2 - Harem NSEW']], + } + const withTable = classifyMazePieceName('Act 2 - Harem NSEW', 'Act 2 - Harem', mockLvlPrest) + expect(withTable).toEqual({ kind: 'room', sides: 'NSEW' }) + }) + }) +}) + +