diablo2-web/tests/item-names.test.ts

372 lines
15 KiB
TypeScript

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