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:
parent
8ee3a4c971
commit
7efdad4693
|
|
@ -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 — 关卡/场景的中文显示名(用于页面上的三级选择器)
|
/** Decoded TBL structure or string-to-string Map. */
|
||||||
//
|
export type D2Tbl = DecodedTbl | Map<string, string>
|
||||||
// 出处说明(重要,避免误认为官方本地化):
|
|
||||||
// 这批译名是**社区通用译名**,由本项目手工整理;英文原名取自 `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`。
|
|
||||||
|
|
||||||
/** 五个 act 的中文名,下标即 act 号。 */
|
/** 五个 act 的中文名,下标即 act 号。 */
|
||||||
export const ACT_NAMES_ZH: Readonly<Record<number, string>> = {
|
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 slug - the scene slug from the pack index.
|
||||||
* @param fallback - the English name 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.
|
* @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)
|
const id = levelIdOfSlug(slug)
|
||||||
if (id === null) return fallback
|
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
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,13 @@ import { join } from 'node:path'
|
||||||
import { decodeTbl, TblError, tblHash, lookupTblFast } from '../src/formats/tbl.ts'
|
import { decodeTbl, TblError, tblHash, lookupTblFast } from '../src/formats/tbl.ts'
|
||||||
import { encodeTbl } from '../scripts/lib/tbl-writer.ts'
|
import { encodeTbl } from '../scripts/lib/tbl-writer.ts'
|
||||||
import type { TblEntry } 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 fixturePath = join(__dirname, '../samples/fixtures/tbl/patchstring_113c_eng.tbl')
|
||||||
const fixtureData = readFileSync(fixturePath)
|
const fixtureData = readFileSync(fixturePath)
|
||||||
|
|
@ -146,3 +153,87 @@ describe('TBL Decoder', () => {
|
||||||
expect(() => decodeTbl(buf)).toThrowError(/keyOffset/)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue