From 399aed7d9595890d4cb8421cbec439a992930bdd Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 12:00:46 +0000 Subject: [PATCH] =?UTF-8?q?feat(items):=20=E5=AE=9E=E7=8E=B0=E7=89=A9?= =?UTF-8?q?=E5=93=81=E5=90=8D=E6=9C=AC=E5=9C=B0=E5=8C=96=E4=B8=89=E5=B1=82?= =?UTF-8?q?=E9=93=BE=E4=B8=8E=E4=B8=AD=E8=8B=B1=E6=96=87=20fallback=20(Iss?= =?UTF-8?q?ue=20#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/game/item-names.ts | 293 +++++++++++++++++++++++++++++++ tests/item-names.test.ts | 371 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 664 insertions(+) create mode 100644 src/game/item-names.ts create mode 100644 tests/item-names.test.ts diff --git a/src/game/item-names.ts b/src/game/item-names.ts new file mode 100644 index 0000000..7ba0ee8 --- /dev/null +++ b/src/game/item-names.ts @@ -0,0 +1,293 @@ +/** + * 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 + + 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 { + if (!this._dict) { + const merged = new Map() + // 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 { + 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 { + 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 | { 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 ?? '') +} diff --git a/tests/item-names.test.ts b/tests/item-names.test.ts new file mode 100644 index 0000000..ab94ddd --- /dev/null +++ b/tests/item-names.test.ts @@ -0,0 +1,371 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import * as fs from 'fs' +import { MountedArchives } from '../src/mpq/mount.ts' +import { MpqArchive } from '../src/mpq/archive.ts' +import { fileSource } from '../src/mpq/file-source.ts' +import { encodeTbl } from '../scripts/lib/tbl-writer.ts' +import { decodeTbl } from '../src/formats/tbl.ts' +import type { DecodedTbl } from '../src/formats/tbl.ts' +import { loadArmor } from '../src/game/armor.ts' +import { loadWeapons } from '../src/game/weapons.ts' +import { loadMisc } from '../src/game/misc-items.ts' +import { loadUniqueItems } from '../src/game/unique-items.ts' +import { loadSetData } from '../src/game/set-items.ts' +import { + StringTableChain, + ENG_STRING_TABLE_PATHS, + CHI_STRING_TABLE_PATHS, + loadStringTableChain, + loadItemNameChains, + resolveItemName, + resolveArmorName, + resolveWeaponName, + resolveMiscName, + resolveUniqueName, + resolveUniqueItemName, + resolveSetItemName, + resolveSetName, + resolveRunewordName, + resolveRuneName, + resolveAffixName, +} from '../src/game/item-names.ts' + +const hasD2 = fs.existsSync('samples/d2/d2data.mpq') + +function createSyntheticTable(entries: Record): DecodedTbl { + const tblEntries = Object.entries(entries).map(([key, value]) => ({ key, value })) + const encoded = encodeTbl(tblEntries) + return decodeTbl(encoded, 'utf-8') +} + +describe('Item Names Localization (Issue #108)', () => { + describe('Hermetic Unit Tests', () => { + it('verifies path constants for ENG and CHI chains', () => { + expect(ENG_STRING_TABLE_PATHS).toEqual([ + 'data\\local\\LNG\\ENG\\patchstring.tbl', + 'data\\local\\LNG\\ENG\\expansionstring.tbl', + 'data\\local\\lng\\eng\\string.tbl', + ]) + expect(CHI_STRING_TABLE_PATHS).toEqual([ + 'data\\local\\LNG\\CHI\\patchstring.tbl', + 'data\\local\\LNG\\CHI\\string.tbl', + ]) + }) + + it('implements three-tier lookup hierarchy: patch > expansion > base', () => { + const baseTbl = createSyntheticTable({ + key1: 'base1', + key2: 'base2', + key3: 'base3', + }) + const expTbl = createSyntheticTable({ + key2: 'exp2', + key4: 'exp4', + }) + const patchTbl = createSyntheticTable({ + key1: 'patch1', + }) + + const chain = new StringTableChain('ENG', [patchTbl, expTbl, baseTbl]) + + // patch overrides base + expect(chain.get('key1')).toBe('patch1') + // expansion overrides base + expect(chain.get('key2')).toBe('exp2') + // base is returned when neither patch nor expansion has it + expect(chain.get('key3')).toBe('base3') + // expansion-only entry + expect(chain.get('key4')).toBe('exp4') + // non-existent + expect(chain.get('unknown')).toBeUndefined() + + // has() check + expect(chain.has('key1')).toBe(true) + expect(chain.has('key4')).toBe(true) + expect(chain.has('missing')).toBe(false) + + // dict getter check + expect(chain.dict.get('key1')).toBe('patch1') + expect(chain.dict.get('key2')).toBe('exp2') + expect(chain.dict.get('key3')).toBe('base3') + expect(chain.dict.get('key4')).toBe('exp4') + + // iterable + expect([...chain].length).toBe(3) + }) + + it('implements language fallback: CHI -> ENG -> code fallback', () => { + const chiTbl = createSyntheticTable({ + cap: '帽子', + unique_shared: 'CHI版本', + }) + const engTbl = createSyntheticTable({ + cap: 'Cap', + unique_shared: 'ENG Version', + r33: 'Zod Rune', + }) + + const chiChain = new StringTableChain('CHI', [chiTbl]) + const engChain = new StringTableChain('ENG', [engTbl]) + + // Both CHI and ENG present -> CHI wins + expect(resolveItemName('cap', chiChain, engChain)).toBe('帽子') + expect(resolveItemName('unique_shared', chiChain, engChain)).toBe('CHI版本') + + // Absent in CHI, present in ENG -> ENG fallback + expect(resolveItemName('r33', chiChain, engChain)).toBe('Zod Rune') + + // Absent in both -> raw code fallback + expect(resolveItemName('hpo', chiChain, engChain)).toBe('hpo') + expect(resolveItemName('nonexistent_key', chiChain, engChain)).toBe('nonexistent_key') + + // Only ENG chain provided + expect(resolveItemName('cap', undefined, engChain)).toBe('Cap') + expect(resolveItemName('cap', null, engChain)).toBe('Cap') + + // Neither chain provided + expect(resolveItemName('cap', undefined, undefined)).toBe('cap') + + // Empty / falsy key returns empty string + expect(resolveItemName('', chiChain, engChain)).toBe('') + }) + + it('supports ItemNameChains composite object parameter', () => { + const chiChain = new StringTableChain('CHI', [createSyntheticTable({ tow: '塔盾' })]) + const engChain = new StringTableChain('ENG', [createSyntheticTable({ tow: 'Tower Shield' })]) + + expect(resolveItemName('tow', { chi: chiChain, eng: engChain })).toBe('塔盾') + expect(resolveItemName('tow', { eng: engChain })).toBe('Tower Shield') + }) + + it('tests specific resolver helpers with object and string inputs', () => { + const chiChain = new StringTableChain('CHI', [ + createSyntheticTable({ + cap: '帽子', + hax: '手斧', + hp1: '微型治療藥劑', + 'The Gnasher': '牙齒', + "Civerb's Ward": '希弗柏的防護', + "Civerb's Vestments": '希弗伯的法衣', + Runeword26: '末日', + r01: '艾爾', + }), + ]) + const engChain = new StringTableChain('ENG', [ + createSyntheticTable({ + cap: 'Cap', + hax: 'Hand Axe', + hp1: 'Minor Healing Potion', + 'The Gnasher': 'The Gnasher', + "Civerb's Ward": "Civerb's Ward", + "Civerb's Vestments": "Civerb's Vestments", + Runeword26: 'Doom', + r01: 'El Rune', + }), + ]) + + // Armor resolver helper (namestr) + expect(resolveArmorName('cap', chiChain, engChain)).toBe('帽子') + expect(resolveArmorName({ namestr: 'cap' }, chiChain, engChain)).toBe('帽子') + + // Weapon resolver helper (namestr) + expect(resolveWeaponName('hax', chiChain, engChain)).toBe('手斧') + expect(resolveWeaponName({ namestr: 'hax' }, chiChain, engChain)).toBe('手斧') + + // Misc resolver helper (namestr) + expect(resolveMiscName('hp1', chiChain, engChain)).toBe('微型治療藥劑') + expect(resolveMiscName({ namestr: 'hp1' }, chiChain, engChain)).toBe('微型治療藥劑') + + // Unique resolver helper (index) + expect(resolveUniqueName('The Gnasher', chiChain, engChain)).toBe('牙齒') + expect(resolveUniqueName({ index: 'The Gnasher' }, chiChain, engChain)).toBe('牙齒') + expect(resolveUniqueItemName({ index: 'The Gnasher' }, chiChain, engChain)).toBe('牙齒') + + // Set item resolver helper (index) + expect(resolveSetItemName("Civerb's Ward", chiChain, engChain)).toBe('希弗柏的防護') + expect(resolveSetItemName({ index: "Civerb's Ward" }, chiChain, engChain)).toBe('希弗柏的防護') + + // Set resolver helper (name) + expect(resolveSetName("Civerb's Vestments", chiChain, engChain)).toBe('希弗伯的法衣') + expect(resolveSetName({ name: "Civerb's Vestments" }, chiChain, engChain)).toBe('希弗伯的法衣') + + // Runeword resolver helper (Name) + expect(resolveRunewordName('Runeword26', chiChain, engChain)).toBe('末日') + expect(resolveRunewordName({ Name: 'Runeword26' }, chiChain, engChain)).toBe('末日') + expect(resolveRunewordName({ name: 'Runeword26' }, chiChain, engChain)).toBe('末日') + + // Rune resolver helper (namestr / code) + expect(resolveRuneName('r01', chiChain, engChain)).toBe('艾爾') + expect(resolveRuneName({ namestr: 'r01' }, chiChain, engChain)).toBe('艾爾') + expect(resolveRuneName({ code: 'r01' }, chiChain, engChain)).toBe('艾爾') + }) + + it('verifies Magic/Rare affix names return raw text without querying TBL', () => { + // Magic and rare affix text must be preserved verbatim without TBL query + expect(resolveAffixName('Cruel')).toBe('Cruel') + expect(resolveAffixName({ name: "of the Whale" })).toBe("of the Whale") + expect(resolveAffixName({ Name: "Grandmaster's" })).toBe("Grandmaster's") + expect(resolveAffixName({ name: 'Beast' })).toBe('Beast') + expect(resolveAffixName({ name: 'flange' })).toBe('flange') + }) + + it('handles simulated MountedArchives with missing tables', async () => { + const mockArchive = { + has(name: string) { + const lower = name.toLowerCase() + return lower.includes('patchstring') || lower.endsWith('\\string.tbl') || lower.endsWith('/string.tbl') + }, + async read(name: string) { + const lower = name.toLowerCase() + if (lower.includes('patchstring')) { + return encodeTbl([{ key: 'p_key', value: 'PatchVal' }]) + } + if (lower.endsWith('\\string.tbl') || lower.endsWith('/string.tbl')) { + return encodeTbl([{ key: 'b_key', value: 'BaseVal' }]) + } + throw new Error('file not found') + }, + } + + const chain = await loadStringTableChain(mockArchive as unknown as MountedArchives, 'ENG') + expect(chain.tables.length).toBe(2) + expect(chain.get('p_key')).toBe('PatchVal') + expect(chain.get('b_key')).toBe('BaseVal') + }) + }) + + describe.skipIf(!hasD2)('1.13c MPQ Integration Tests (samples/d2)', () => { + let archives: MountedArchives + let engChain: StringTableChain + let chiChain: StringTableChain + + beforeAll(async () => { + archives = new MountedArchives() + for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { + archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) + } + const loaded = await loadItemNameChains(archives) + engChain = loaded.eng + chiChain = loaded.chi + }) + + it('loads 3 tables for ENG chain and 2 tables for CHI chain', () => { + expect(engChain.tables.length).toBe(3) + expect(chiChain.tables.length).toBe(2) + expect(engChain.lang).toBe('ENG') + expect(chiChain.lang).toBe('CHI') + }) + + it('asserts ENG coverage on mounted MPQs: Armor 202/202, Weapons 306/306, Misc 143/151', async () => { + // Armor coverage: 202/202 + const armor = await loadArmor(archives) + expect(armor.all.length).toBe(202) + let armorHits = 0 + for (const a of armor.all) { + if (engChain.has(a.namestr)) armorHits++ + } + expect(armorHits).toBe(202) + + // Weapons coverage: 306/306 + const weapons = await loadWeapons(archives) + expect(weapons.all.length).toBe(306) + let weaponHits = 0 + for (const w of weapons.all) { + if (engChain.has(w.namestr)) weaponHits++ + } + expect(weaponHits).toBe(306) + + // Misc coverage: 143/151 (8 unused placeholder potions like hpo, mpo are unmapped) + const misc = await loadMisc(archives) + expect(misc.all.length).toBe(151) + let miscHits = 0 + const unmapped: string[] = [] + for (const m of misc.all) { + if (engChain.has(m.namestr)) { + miscHits++ + } else { + unmapped.push(m.code) + } + } + expect(miscHits).toBe(143) + expect(unmapped).toEqual(['hpo', 'mpo', 'hpf', 'mpf', 'rps', 'rpl', 'bps', 'bpl']) + }) + + it('asserts CHI UTF-8 decoding: cap -> 帽子, tow -> 塔盾, axe -> 斧', () => { + expect(resolveArmorName('cap', chiChain, engChain)).toBe('帽子') + expect(resolveArmorName('tow', chiChain, engChain)).toBe('塔盾') + expect(resolveWeaponName('axe', chiChain, engChain)).toBe('斧') + + // Also verify via direct resolveItemName + expect(resolveItemName('cap', chiChain, engChain)).toBe('帽子') + expect(resolveItemName('tow', chiChain, engChain)).toBe('塔盾') + expect(resolveItemName('axe', chiChain, engChain)).toBe('斧') + }) + + it('asserts fallback to ENG when absent in CHI: r33 (Zod Rune), Windforce, Annihilus', async () => { + // r33 (Zod Rune from Misc.txt) + expect(chiChain.get('r33')).toBeUndefined() + expect(engChain.get('r33')).toBe('Zod Rune') + expect(resolveItemName('r33', chiChain, engChain)).toBe('Zod Rune') + expect(resolveRuneName('r33', chiChain, engChain)).toBe('Zod Rune') + + // Windforce (Unique hydra bow from UniqueItems.txt) + expect(chiChain.get('Windforce')).toBeUndefined() + expect(engChain.get('Windforce')).toBe('Windforce') + expect(resolveItemName('Windforce', chiChain, engChain)).toBe('Windforce') + expect(resolveUniqueName('Windforce', chiChain, engChain)).toBe('Windforce') + + // Annihilus (Unique small charm from UniqueItems.txt) + expect(chiChain.get('Annihilus')).toBeUndefined() + expect(engChain.get('Annihilus')).toBe('Annihilus') + expect(resolveItemName('Annihilus', chiChain, engChain)).toBe('Annihilus') + expect(resolveUniqueName('Annihilus', chiChain, engChain)).toBe('Annihilus') + }) + + it('asserts Uniques: The Gnasher -> ENG "The Gnasher" / CHI "牙齒"', async () => { + const uniques = await loadUniqueItems(archives) + const gnasher = uniques.byIndex.get('The Gnasher') + expect(gnasher).toBeDefined() + + expect(resolveUniqueName('The Gnasher', undefined, engChain)).toBe('The Gnasher') + expect(resolveUniqueName('The Gnasher', chiChain, engChain)).toBe('牙齒') + expect(resolveUniqueName(gnasher!, chiChain, engChain)).toBe('牙齒') + }) + + it("asserts Sets: Civerb's Ward -> CHI '希弗柏的防護'", async () => { + const setData = await loadSetData(archives) + const ward = setData.items.getByIndex("Civerb's Ward") + expect(ward).toBeDefined() + + expect(resolveSetItemName("Civerb's Ward", undefined, engChain)).toBe("Civerb's Ward") + expect(resolveSetItemName("Civerb's Ward", chiChain, engChain)).toBe('希弗柏的防護') + expect(resolveSetItemName(ward!, chiChain, engChain)).toBe('希弗柏的防護') + + // Also check Sets.txt name resolution + const vestments = setData.sets.getByIndex("Civerb's Vestments") + expect(vestments).toBeDefined() + expect(resolveSetName(vestments!, chiChain, engChain)).toBe('希弗伯的法衣') + }) + + it('asserts Runeword: Runeword26 -> "Doom"', () => { + expect(engChain.get('Runeword26')).toBe('Doom') + expect(chiChain.get('Runeword26')).toBeUndefined() + expect(resolveRunewordName('Runeword26', chiChain, engChain)).toBe('Doom') + expect(resolveRunewordName({ Name: 'Runeword26' }, chiChain, engChain)).toBe('Doom') + }) + + it('verifies namestr vs name distinction (e.g. hp1)', async () => { + const misc = await loadMisc(archives) + const hp1 = misc.byCode.get('hp1') + expect(hp1).toBeDefined() + + // The raw table name is 'Lesser Healing Potion', but namestr is 'hp1' + expect(hp1?.name).toBe('Lesser Healing Potion') + expect(hp1?.namestr).toBe('hp1') + + // namestr resolves to 'Minor Healing Potion' in ENG and '輕微治療葯劑' in CHI + expect(resolveMiscName(hp1!, undefined, engChain)).toBe('Minor Healing Potion') + expect(resolveMiscName(hp1!, chiChain, engChain)).toBe('輕微治療葯劑') + }) + }) +})