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