feat(items): 实现物品名本地化三层链与中英文 fallback (Issue #108)

This commit is contained in:
troytt 2026-09-18 12:01:36 +00:00
commit acd027bd38
2 changed files with 664 additions and 0 deletions

293
src/game/item-names.ts Normal file
View File

@ -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<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 ?? '')
}

371
tests/item-names.test.ts Normal file
View File

@ -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<string, string>): 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('輕微治療葯劑')
})
})
})