diablo2-web/src/game/item-names.ts

294 lines
9.1 KiB
TypeScript

/**
* Item name localization and translation resolver.
*
* Implements a three-tier string table lookup chain (patch -> expansion -> base)
* with language fallback order: CHI -> ENG -> code/key fallback.
*
* Table key column mappings:
* - Armor.txt / Weapons.txt / Misc.txt: `namestr` column (not `name`!)
* - UniqueItems.txt: `index` column
* - SetItems.txt: `index` column
* - Sets.txt: `name` column
* - Runes.txt: `Name` column (not `Rune Name`!)
* - MagicPrefix / MagicSuffix / RarePrefix / RareSuffix: raw display text (`name`), do NOT query tbl!
*/
import { decodeTbl } from '../formats/tbl.ts'
import type { DecodedTbl } from '../formats/tbl.ts'
import type { MountedArchives } from '../mpq/mount.ts'
import type { ArmorBase } from './armor.ts'
import type { WeaponBase } from './weapons.ts'
import type { MiscBase } from './misc-items.ts'
import type { UniqueItem } from './unique-items.ts'
import type { SetItem, SetDefinition } from './set-items.ts'
export type ItemLanguage = 'ENG' | 'CHI'
/** ENG string table paths in search order (patch -> expansion -> base). */
export const ENG_STRING_TABLE_PATHS: readonly string[] = [
'data\\local\\LNG\\ENG\\patchstring.tbl',
'data\\local\\LNG\\ENG\\expansionstring.tbl',
'data\\local\\lng\\eng\\string.tbl',
]
/**
* CHI string table paths in search order (patch -> base).
* Note: `expansionstring.tbl` is absent in official 1.13c CHI MPQ;
* keys missing in CHI must fallback to the ENG chain.
*/
export const CHI_STRING_TABLE_PATHS: readonly string[] = [
'data\\local\\LNG\\CHI\\patchstring.tbl',
'data\\local\\LNG\\CHI\\string.tbl',
]
/**
* Represents an ordered chain of `.tbl` string tables for a language.
* Searches from first table (highest priority, e.g. patch) to last (base).
*/
export class StringTableChain {
readonly lang: ItemLanguage
readonly tables: readonly DecodedTbl[]
private _dict?: Map<string, string>
constructor(lang: ItemLanguage, tables: readonly DecodedTbl[]) {
this.lang = lang
this.tables = tables
}
/**
* Look up a key in the string table chain.
* Returns the first non-undefined matching value, or undefined if not found.
*/
get(key: string): string | undefined {
for (const table of this.tables) {
const val = table.dict.get(key)
if (val !== undefined) return val
}
return undefined
}
/** Check if a key exists in any table in the chain. */
has(key: string): boolean {
return this.get(key) !== undefined
}
/**
* Flattened dictionary view of the chain with earlier tables taking precedence.
*/
get dict(): ReadonlyMap<string, string> {
if (!this._dict) {
const merged = new Map<string, string>()
// Reverse iteration so earlier tables overwrite later tables
for (let i = this.tables.length - 1; i >= 0; i--) {
for (const [k, v] of this.tables[i]!.dict.entries()) {
merged.set(k, v)
}
}
this._dict = merged
}
return this._dict
}
[Symbol.iterator](): Iterator<DecodedTbl> {
return this.tables[Symbol.iterator]()
}
}
/** Combined chains for both CHI and ENG. */
export interface ItemNameChains {
readonly chi?: StringTableChain | undefined
readonly eng?: StringTableChain | undefined
}
/**
* Loads the string table chain for the specified language from mounted MPQ archives.
*
* - ENG: windows-1252 encoding across patchstring -> expansionstring -> string.tbl
* - CHI: utf-8 encoding across patchstring -> string.tbl
*/
export async function loadStringTableChain(
archives: MountedArchives,
lang: ItemLanguage,
): Promise<StringTableChain> {
const encoding = lang === 'ENG' ? 'windows-1252' : 'utf-8'
const paths = lang === 'ENG' ? ENG_STRING_TABLE_PATHS : CHI_STRING_TABLE_PATHS
const tables: DecodedTbl[] = []
for (const path of paths) {
if (typeof archives.has === 'function' && !archives.has(path)) {
continue
}
try {
const bytes = await archives.read(path)
tables.push(decodeTbl(bytes, encoding))
} catch {
// Continue if archive member is missing
}
}
return new StringTableChain(lang, tables)
}
/** Helper to load both CHI and ENG string table chains in parallel. */
export async function loadItemNameChains(
archives: MountedArchives,
): Promise<{ chi: StringTableChain; eng: StringTableChain }> {
const [chi, eng] = await Promise.all([
loadStringTableChain(archives, 'CHI'),
loadStringTableChain(archives, 'ENG'),
])
return { chi, eng }
}
function extractChainLookup(
chain: StringTableChain | Map<string, string> | { get(k: string): string | undefined } | null | undefined,
key: string,
): string | undefined {
if (!chain || !key) return undefined
if (typeof chain.get === 'function') {
return chain.get(key)
}
return undefined
}
/**
* Resolves an item translation key with fallback:
* 1. CHI chain (if available and found)
* 2. ENG chain (if available and found)
* 3. Raw key fallback
*/
export function resolveItemName(
key: string,
chiChain?: StringTableChain | ItemNameChains | null,
engChain?: StringTableChain | null,
): string {
if (!key) return ''
let chi: StringTableChain | undefined
let eng: StringTableChain | undefined
if (chiChain && ('chi' in chiChain || 'eng' in chiChain)) {
chi = (chiChain as ItemNameChains).chi
eng = (chiChain as ItemNameChains).eng
} else {
chi = chiChain as StringTableChain | undefined
eng = engChain ?? undefined
}
const chiVal = extractChainLookup(chi, key)
if (chiVal !== undefined) return chiVal
const engVal = extractChainLookup(eng, key)
if (engVal !== undefined) return engVal
return key
}
/**
* Resolves display name for Armor bases (`Armor.txt` `namestr` column).
*/
export function resolveArmorName(
item: string | ArmorBase | { namestr: string },
chiChain?: StringTableChain | ItemNameChains | null,
engChain?: StringTableChain | null,
): string {
const key = typeof item === 'string' ? item : item.namestr
return resolveItemName(key, chiChain, engChain)
}
/**
* Resolves display name for Weapon bases (`Weapons.txt` `namestr` column).
*/
export function resolveWeaponName(
item: string | WeaponBase | { namestr: string },
chiChain?: StringTableChain | ItemNameChains | null,
engChain?: StringTableChain | null,
): string {
const key = typeof item === 'string' ? item : item.namestr
return resolveItemName(key, chiChain, engChain)
}
/**
* Resolves display name for Misc bases (`Misc.txt` `namestr` column).
*/
export function resolveMiscName(
item: string | MiscBase | { namestr: string },
chiChain?: StringTableChain | ItemNameChains | null,
engChain?: StringTableChain | null,
): string {
const key = typeof item === 'string' ? item : item.namestr
return resolveItemName(key, chiChain, engChain)
}
/**
* Resolves display name for Unique items (`UniqueItems.txt` `index` column).
*/
export function resolveUniqueName(
item: string | UniqueItem | { index: string },
chiChain?: StringTableChain | ItemNameChains | null,
engChain?: StringTableChain | null,
): string {
const key = typeof item === 'string' ? item : item.index
return resolveItemName(key, chiChain, engChain)
}
/** Alias for `resolveUniqueName`. */
export const resolveUniqueItemName = resolveUniqueName
/**
* Resolves display name for Set items (`SetItems.txt` `index` column).
*/
export function resolveSetItemName(
item: string | SetItem | { index: string },
chiChain?: StringTableChain | ItemNameChains | null,
engChain?: StringTableChain | null,
): string {
const key = typeof item === 'string' ? item : item.index
return resolveItemName(key, chiChain, engChain)
}
/**
* Resolves display name for Sets (`Sets.txt` `name` column).
*/
export function resolveSetName(
set: string | SetDefinition | { name?: string; index?: string },
chiChain?: StringTableChain | ItemNameChains | null,
engChain?: StringTableChain | null,
): string {
const key = typeof set === 'string' ? set : (set.name ?? set.index ?? '')
return resolveItemName(key, chiChain, engChain)
}
/**
* Resolves display name for Runewords (`Runes.txt` `Name` column, e.g. 'Runeword26' -> 'Doom').
*/
export function resolveRunewordName(
runeword: string | { Name?: string; name?: string },
chiChain?: StringTableChain | ItemNameChains | null,
engChain?: StringTableChain | null,
): string {
const key = typeof runeword === 'string' ? runeword : (runeword.Name ?? runeword.name ?? '')
return resolveItemName(key, chiChain, engChain)
}
/**
* Resolves display name for Runes (`Misc.txt` `namestr` or `code`, e.g. 'r01'..'r33').
*/
export function resolveRuneName(
rune: string | { namestr?: string; code?: string },
chiChain?: StringTableChain | ItemNameChains | null,
engChain?: StringTableChain | null,
): string {
const key = typeof rune === 'string' ? rune : (rune.namestr ?? rune.code ?? '')
return resolveItemName(key, chiChain, engChain)
}
/**
* For Magic/Rare prefixes and suffixes, the name column in the txt file
* is already raw display text, so tbl lookup is not performed.
*/
export function resolveAffixName(
affix: string | { name?: string; Name?: string },
): string {
return typeof affix === 'string' ? affix : (affix.name ?? affix.Name ?? '')
}