294 lines
9.1 KiB
TypeScript
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 ?? '')
|
|
}
|