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
This commit is contained in:
troytt 2026-09-17 14:14:15 +00:00
parent 8ee3a4c971
commit 7efdad4693
2 changed files with 356 additions and 13 deletions

View File

@ -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
}

View File

@ -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)
}
})
})