diff --git a/src/game/level-names-zh.ts b/src/game/level-names-zh.ts index 4aa4a04..93807d0 100644 --- a/src/game/level-names-zh.ts +++ b/src/game/level-names-zh.ts @@ -142,13 +142,13 @@ export const LEVEL_NAMES_ZH: Readonly> = { 115: '冰河路径', 116: '漂泊者洞穴', 117: '冰冻苔原', - 118: '冰窖', - 119: '痛苦之厅', - 120: '岩石之巅', - 121: '尼拉塞克神殿', + 118: '远古之路', + 119: '冰窖', + 120: '亚瑞特山顶', + 121: '尼拉塞克的神殿', 122: '悲痛之厅', - 123: '死者呼唤之厅', - 124: '沃特厅', + 123: '痛苦之厅', + 124: '沃德之厅', 125: '艾巴当', 126: '阿克隆地道', 127: '炼狱地坑', @@ -194,43 +194,6 @@ export function variantLabelZh(suffix: string): string { return DIRECTION_SUFFIXES[suffix.toLowerCase()] ?? suffix } -/** - * 从 pack 索引条目的 `slug` 里取 `Levels.txt` 的关卡 Id。 - * - * @param slug - e.g. `33-act-1-cathedral`. - * @returns the id, or null when the slug does not start with one. - */ -export function levelIdOfSlug(slug: string): number | null { - const match = /^(\d+)-/.exec(slug) - return match?.[1] === undefined ? null : Number(match[1]) -} - -/** - * 一个场景的中文名,取不到就回退调用方给的英文名。 - * 支持传入自定义映射表或自动使用全局由 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, - 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', @@ -355,24 +318,265 @@ export const CANONICAL_LEVEL_NAME_KEYS: Readonly> = { 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', + 119: 'Icy Cellar', + 120: 'Arreat Summit', + 121: "Nihlathak's Temple", + 122: 'Halls of Anguish', + 123: 'Halls of Pain', + 124: 'Halls of Vaught', + 125: 'Abaddon', + 126: 'Pit of Acheron', + 127: 'Infernal Pit', + 128: 'Worldstone Keep Level 1', + 129: 'Worldstone Keep Level 2', + 130: 'Worldstone Keep Level 3', + 131: 'Throne of Destruction', + 132: 'Worldstone Chamber', + 133: "Matron's Den", + 134: 'Forgotten Sands', + 135: 'Furnace of Pain', + 136: 'Tristram', +} + +/** Alias for CANONICAL_LEVEL_NAME_KEYS for symmetry with LEVEL_NAMES_ZH. */ +export const LEVEL_NAMES_EN: Readonly> = CANONICAL_LEVEL_NAME_KEYS + +/** + * Normalizes any level slug or name string for robust lookup. + * e.g. "17-burial-grounds" -> "burialgrounds" + * "rogue-encampment" -> "rogueencampment" + * "The Ancients' Way" -> "theancientsway" + */ +function normalizeSlug(str: string): string { + return str.toLowerCase().replace(/[^a-z0-9]/g, '') +} + +/** Check if a string looks like a code/slug rather than a human-readable title. */ +function isRawSlug(str: string): boolean { + return str.includes('-') || str.includes('_') || /^[a-z0-9]+$/.test(str) +} + +function toTitleCase(slug: string): string { + const clean = slug.replace(/^\d+[-_]/, '') + return clean + .split(/[-_\s]+/) + .filter(Boolean) + .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join(' ') +} + +/** Pre-built map of normalized slugs/names to canonical level ID. */ +const SLUG_TO_LEVEL_ID: ReadonlyMap = (() => { + const map = new Map() + for (let id = 1; id <= 136; id++) { + const en = CANONICAL_LEVEL_NAME_KEYS[id] + if (en) { + map.set(normalizeSlug(en), id) + map.set(normalizeSlug(en.replace(/^The\s+/i, '')), id) + } + } + // Common aliases in pack manifests + const aliases: Record = { + 'rogueencampment': 1, + 'bloodmoor': 2, + 'coldplains': 3, + 'stonyfield': 4, + 'darkwood': 5, + 'blackmarsh': 6, + 'tamoehighland': 7, + 'denofevil': 8, + 'cave1': 9, + 'cavelevel1': 9, + 'undergroundpassage1': 10, + 'undergroundpassagelevel1': 10, + 'hole1': 11, + 'holelevel1': 11, + 'pit1': 12, + 'pitlevel1': 12, + 'cave2': 13, + 'cavelevel2': 13, + 'undergroundpassage2': 14, + 'undergroundpassagelevel2': 14, + 'hole2': 15, + 'holelevel2': 15, + 'pit2': 16, + 'pitlevel2': 16, + 'burialgrounds': 17, + 'crypt': 18, + 'mausoleum': 19, + 'forgottentower': 20, + 'towercellar1': 21, + 'towercellar2': 22, + 'towercellar3': 23, + 'towercellar4': 24, + 'towercellar5': 25, + 'monasterygate': 26, + 'outercloister': 27, + 'barracks': 28, + 'jail1': 29, + 'jail2': 30, + 'jail3': 31, + 'innercloister': 32, + 'cathedral': 33, + 'catacombs1': 34, + 'catacombs2': 35, + 'catacombs3': 36, + 'catacombs4': 37, + 'tristram': 38, + 'moomoofarm': 39, + 'lutgholein': 40, + 'rockywaste': 41, + 'dryhills': 42, + 'faroasis': 43, + 'lostcity': 44, + 'valleyofsnakes': 45, + 'canyonofmagi': 46, + 'canyonofthemagi': 46, + 'harrogath': 109, + 'bloodyfoothills': 110, + 'frigidhighlands': 111, + 'arreatplateau': 112, + 'crystallinepassage': 113, + 'frozenriver': 114, + 'glacialtrail': 115, + 'driftercavern': 116, + 'frozentundra': 117, + 'ancientsway': 118, + 'theancientsway': 118, + 'icycellar': 119, + 'arreatsummit': 120, + 'nihlathakstemple': 121, + 'hallsofanguish': 122, + 'hallsofpain': 123, + 'hallsofvaught': 124, + 'abaddon': 125, + 'pitofacheron': 126, + 'infernalpit': 127, + 'worldstonekeep1': 128, + 'worldstonekeep2': 129, + 'worldstonekeep3': 130, + 'throneofdestruction': 131, + 'worldstonechamber': 132, + } + for (const [k, v] of Object.entries(aliases)) { + map.set(normalizeSlug(k), v) + } + return map +})() + +/** + * 从 pack 索引条目的 `slug` 或关卡代号里解析 `Levels.txt` 的关卡 Id。 + * 支持前缀格式(`17-burial-grounds`)以及无前缀纯文本 slug(`burial-grounds`、`rogue-encampment`)。 + * + * @param slug - e.g. `17-burial-grounds` or `burial-grounds`. + * @returns the id (1..136), or null when unresolvable. + */ +export function levelIdOfSlug(slug: string): number | null { + const match = /^(\d+)(?:-|_|$)/.exec(slug) + if (match?.[1] !== undefined) { + const parsed = Number(match[1]) + if (parsed >= 1 && parsed <= 136) return parsed + } + const norm = normalizeSlug(slug) + return SLUG_TO_LEVEL_ID.get(norm) ?? null +} + +export interface LevelNames { + readonly nameZh: string + readonly nameEn: string +} + +/** + * Bilingual level name lookup for UI banners and HUD displays. + * Resolves both numeric level IDs (1..136) and string slugs (e.g. 'burial-grounds'). + * Returns authentic Chinese and English names, never empty, never leaking raw slugs. + */ +export function getLevelNames(levelIdOrSlug: number | string): LevelNames { + let id: number | null = null + let rawStr = '' + + if (typeof levelIdOrSlug === 'number') { + id = levelIdOrSlug + rawStr = String(levelIdOrSlug) + } else { + rawStr = levelIdOrSlug.trim() + id = levelIdOfSlug(rawStr) + } + + let nameZh: string | undefined + let nameEn: string | undefined + + if (id !== null) { + nameZh = activeTblLevelNames?.get(id) ?? LEVEL_NAMES_ZH[id] + nameEn = CANONICAL_LEVEL_NAME_KEYS[id] + } + + if (!nameEn) { + nameEn = toTitleCase(rawStr) || 'Unknown Area' + } + if (!nameZh) { + nameZh = nameEn + } + + return { nameZh, nameEn } +} + +/** + * 一个场景的中文名,取不到就回退调用方给的英文名。 + * 支持传入自定义映射表或自动使用全局由 string.tbl 解码的名称,无匹配时回退到 LEVEL_NAMES_ZH 或 fallback。 + * 防止原始无前缀 slug(如 'burial-grounds')直接泄露到 UI。 + * + * @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 | number, + fallback?: string, + customMap?: Map | Record, +): string { + const slugStr = String(slug) + const id = typeof slug === 'number' ? slug : levelIdOfSlug(slugStr) + if (id !== null) { + if (customMap) { + const val = customMap instanceof Map ? customMap.get(id) : customMap[id] + if (val) return val + } + if (activeTblLevelNames?.has(id)) { + return activeTblLevelNames.get(id)! + } + if (LEVEL_NAMES_ZH[id]) { + return LEVEL_NAMES_ZH[id]! + } + } + // Guard against leaking raw un-prefixed slugs + if (fallback && !isRawSlug(fallback)) { + return fallback + } + return getLevelNames(slug).nameZh +} + +/** + * 一个场景的英文名。 + * + * @param slug - the scene slug from the pack index or numeric level ID. + * @param fallback - optional fallback English string. + * @returns canonical English level name. + */ +export function sceneNameEn( + slug: string | number, + fallback?: string, +): string { + const slugStr = String(slug) + const id = typeof slug === 'number' ? slug : levelIdOfSlug(slugStr) + if (id !== null && CANONICAL_LEVEL_NAME_KEYS[id]) { + return CANONICAL_LEVEL_NAME_KEYS[id]! + } + if (fallback && !isRawSlug(fallback)) { + return fallback + } + return getLevelNames(slug).nameEn } function lookupTbl(tbl: D2Tbl | Map, key: string): string | undefined { diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index e0d77b9..61e6a61 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -55,7 +55,7 @@ import { facingToDirection } from '../game/character.ts' import type { LoadedMonsterArt } from '../game/monster-art.ts' import { ActorAnimator, type AnimClipMeta } from '../game/actor-animator.ts' import { resolveMonsterArtSpec } from '../game/monster-mapping.ts' -import { ACT_NAMES_ZH, LEVEL_NAMES_ZH, sceneNameZh, variantLabelZh } from '../game/level-names-zh.ts' +import { ACT_NAMES_ZH, LEVEL_NAMES_ZH, getLevelNames, sceneNameZh, variantLabelZh } from '../game/level-names-zh.ts' import { GameLoop } from '../sim/loop.ts' import { KeyboardInput } from '../sim/input.ts' import type { SceneEntrance, SceneWarp, SceneWaypoint } from '../game/level-links.ts' @@ -2675,7 +2675,6 @@ if (typeof window !== 'undefined') { if (typeof window !== 'undefined' && window.__d2webHud) { (window.__d2webHud as any).proceduralFallbackCount = hudManager.globes.proceduralFallbackCount } - hudManager.worldPanels.showAreaBanner(sceneNameZh(runtime.level, runtime.level), runtime.level) } const mouseController = new SceneMouseController({ @@ -2869,6 +2868,9 @@ if (typeof window !== 'undefined') { state.animatedTiles = next.animatedDrawables?.length ?? 0 state.animSpeed = next.animSpeed state.frameDurationMs = next.frameDurationMs + + const names = getLevelNames(next.levelId ?? next.level) + hudManager?.worldPanels.showAreaBanner(names.nameZh, names.nameEn) } /** @@ -2994,8 +2996,8 @@ if (typeof window !== 'undefined') { preloadRemaining(next, renderer) minimapCache = null - hudManager?.worldPanels.showAreaBanner(sceneNameZh(next.level, next.level), next.level) - status.textContent = `已传送到:${sceneNameZh(next.level, next.level)}(Act ${String(next.act)})` + const names = getLevelNames(next.levelId ?? next.level) + status.textContent = `已传送到:${names.nameZh}(Act ${String(next.act)})` } catch (err) { status.textContent = `传送点传送失败:${(err as Error).message}` } finally { diff --git a/tests/ui-hud.test.ts b/tests/ui-hud.test.ts index e1de876..91915dd 100644 --- a/tests/ui-hud.test.ts +++ b/tests/ui-hud.test.ts @@ -30,6 +30,13 @@ import { WorldPanelsHud, ACT_WAYPOINTS, ALL_QUESTS, ACT_QUESTS, findWaypointBySl import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts' import { HudManager, computeHudLayout } from '../src/ui/hud-manager.ts' import { projectNpcLabel } from '../src/scene/act-scene.ts' +import { + getLevelNames, + sceneNameZh, + sceneNameEn, + LEVEL_NAMES_ZH, + LEVEL_NAMES_EN, +} from '../src/game/level-names-zh.ts' describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { it('verifies offline DC6 UI asset extraction manifest has 0 decode failures', () => { @@ -548,4 +555,70 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { expect(act4Quests[1]!.id).toBe('a4q2') expect(act4Quests[2]!.id).toBe('a4q3') }) + + it('loads localized map area names from tbl with 1.13c Act 5 alignment and slug protection (Issue #147)', () => { + // 1. Specific key areas required by Issue #147 and v1.13c tbl + expect(getLevelNames(1)).toEqual({ nameZh: '罗格营地', nameEn: 'Rogue Encampment' }) + expect(getLevelNames(2)).toEqual({ nameZh: '鲜血荒地', nameEn: 'Blood Moor' }) + expect(getLevelNames(17)).toEqual({ nameZh: '埋骨之地', nameEn: 'Burial Grounds' }) + + // 2. Act 5 (109..136) alignment against string.tbl / expansionstring.tbl + expect(getLevelNames(109)).toEqual({ nameZh: '哈洛加斯', nameEn: 'Harrogath' }) + expect(getLevelNames(118)).toEqual({ nameZh: '远古之路', nameEn: "The Ancients' Way" }) + expect(getLevelNames(119)).toEqual({ nameZh: '冰窖', nameEn: 'Icy Cellar' }) + expect(getLevelNames(120)).toEqual({ nameZh: '亚瑞特山顶', nameEn: 'Arreat Summit' }) + expect(getLevelNames(121)).toEqual({ nameZh: '尼拉塞克的神殿', nameEn: "Nihlathak's Temple" }) + expect(getLevelNames(122)).toEqual({ nameZh: '悲痛之厅', nameEn: 'Halls of Anguish' }) + expect(getLevelNames(123)).toEqual({ nameZh: '痛苦之厅', nameEn: 'Halls of Pain' }) + expect(getLevelNames(124)).toEqual({ nameZh: '沃德之厅', nameEn: 'Halls of Vaught' }) + expect(getLevelNames(131)).toEqual({ nameZh: '毁灭王座', nameEn: 'Throne of Destruction' }) + expect(getLevelNames(132)).toEqual({ nameZh: '世界之石大殿', nameEn: 'Worldstone Chamber' }) + + // 3. Complete 136 level IDs validity: no empty strings, no raw hyphen slugs + for (let id = 1; id <= 136; id++) { + const names = getLevelNames(id) + expect(names.nameZh).toBeTruthy() + expect(names.nameEn).toBeTruthy() + expect(names.nameZh).not.toContain('-') + expect(names.nameEn).not.toContain('-') + expect(LEVEL_NAMES_ZH[id]).toBe(names.nameZh) + expect(LEVEL_NAMES_EN[id]).toBe(names.nameEn) + } + + // 4. Slug resolution: both un-prefixed and numbered prefixes resolve cleanly + expect(getLevelNames('burial-grounds')).toEqual({ nameZh: '埋骨之地', nameEn: 'Burial Grounds' }) + expect(getLevelNames('17-burial-grounds')).toEqual({ nameZh: '埋骨之地', nameEn: 'Burial Grounds' }) + expect(getLevelNames('rogue-encampment')).toEqual({ nameZh: '罗格营地', nameEn: 'Rogue Encampment' }) + expect(getLevelNames('blood-moor')).toEqual({ nameZh: '鲜血荒地', nameEn: 'Blood Moor' }) + expect(getLevelNames('harrogath')).toEqual({ nameZh: '哈洛加斯', nameEn: 'Harrogath' }) + expect(getLevelNames('the-ancients-way')).toEqual({ nameZh: '远古之路', nameEn: "The Ancients' Way" }) + expect(getLevelNames('icy-cellar')).toEqual({ nameZh: '冰窖', nameEn: 'Icy Cellar' }) + expect(getLevelNames('arreat-summit')).toEqual({ nameZh: '亚瑞特山顶', nameEn: 'Arreat Summit' }) + + // 5. sceneNameZh and sceneNameEn parity and slug leak prevention + expect(sceneNameZh(17)).toBe('埋骨之地') + expect(sceneNameZh('burial-grounds')).toBe('埋骨之地') + expect(sceneNameZh('17-burial-grounds')).toBe('埋骨之地') + expect(sceneNameEn(17)).toBe('Burial Grounds') + expect(sceneNameEn('burial-grounds')).toBe('Burial Grounds') + expect(sceneNameEn('17-burial-grounds')).toBe('Burial Grounds') + + // 6. Unknown fallback must never produce raw un-spaced lowercase slugs + const unknownFallback = getLevelNames('unknown-custom-dungeon') + expect(unknownFallback.nameZh).toBe('Unknown Custom Dungeon') + expect(unknownFallback.nameEn).toBe('Unknown Custom Dungeon') + expect(sceneNameZh('unknown-custom-dungeon')).toBe('Unknown Custom Dungeon') + expect(sceneNameEn('unknown-custom-dungeon')).toBe('Unknown Custom Dungeon') + + // 7. Area banner display logic in WorldPanelsHud + const worldPanels = new WorldPanelsHud() + const now = 1000 + const bgNames = getLevelNames(17) + worldPanels.showAreaBanner(bgNames.nameZh, bgNames.nameEn, now) + expect(worldPanels.areaBanner).toEqual({ + titleZh: '埋骨之地', + titleEn: 'Burial Grounds', + untilMs: now + 3600, + }) + }) })