feat(items): 实现物品名本地化三层链与中英文 fallback (Issue #108)
This commit is contained in:
parent
b576f634be
commit
399aed7d95
|
|
@ -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 ?? '')
|
||||
}
|
||||
|
|
@ -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('輕微治療葯劑')
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue