372 lines
15 KiB
TypeScript
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('輕微治療葯劑')
|
|
})
|
|
})
|
|
})
|