fix(items): strictly align item names & stat descriptions with 1.13c .tbl across inventory, stash, and ground (fixes #480)

This commit is contained in:
troytt 2026-09-26 05:20:08 +00:00
parent c09e237ff8
commit b7ccc448b4
13 changed files with 8500 additions and 98 deletions

View File

@ -0,0 +1,229 @@
/**
* scripts/pack-canonical-tbl.ts
*
* Extracts canonical Diablo II v1.13c `.tbl` string table entries (ENG & CHI)
* and `ItemStatCost.txt` tooltip formatting metadata (`descfunc`, `descval`,
* `descstrpos`, `descstrneg`, `descstr2`, `dgrp*`) from `samples/d2/` MPQ archives
* (`d2data.mpq`, `d2exp.mpq`, `Patch_D2.mpq`) into `src/data/canonical-tbl-strings.ts`.
*
* Ground Truth References:
* - D2MOO `source/D2Common/src/DataTbls/ItemsTbls.cpp` (`0x6FD55280` .. `0x6FD5E020`)
* - D2Client.dll `sub_6FB23F80` (`descfunc` 1..28 & `dgrp` 1..2)
*/
import * as fs from 'node:fs'
import * as path from 'node:path'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { loadStringTableChain } from '../src/game/item-names.ts'
import { parseTable } from '../src/game/tables.ts'
async function main() {
const archives = new MountedArchives()
for (const m of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
archives.add(m, await MpqArchive.open(await fileSource(`samples/d2/${m}`)))
}
const eng = await loadStringTableChain(archives, 'ENG')
const chi = await loadStringTableChain(archives, 'CHI')
const readTxt = async (name: string) =>
parseTable(new TextDecoder().decode(await archives.read(`data/global/excel/${name}`)))
const keys = new Set<string>()
const addKey = (k?: string) => {
const t = k?.trim()
if (t && t !== 'Expansion' && !t.startsWith('*')) {
keys.add(t)
}
}
// 1. Base items (weapons.txt, armor.txt, misc.txt)
for (const fn of ['weapons.txt', 'armor.txt', 'misc.txt']) {
const t = await readTxt(fn)
for (const r of t.rows) {
addKey(r['namestr'])
addKey(r['code'])
}
}
// 2. Uniques, SetItems, Sets, Runes, Gems, LowQualityItems
for (const fn of ['UniqueItems.txt', 'SetItems.txt']) {
const t = await readTxt(fn)
for (const r of t.rows) addKey(r['index'])
}
for (const r of (await readTxt('Sets.txt')).rows) {
addKey(r['name'])
addKey(r['index'])
}
for (const r of (await readTxt('Runes.txt')).rows) {
addKey(r['Name'])
}
for (const r of (await readTxt('Gems.txt')).rows) {
addKey(r['name'])
addKey(r['code'])
}
for (const r of (await readTxt('LowQualityItems.txt')).rows) {
addKey(r['Name'])
}
// 3. MagicPrefix, MagicSuffix, AutoMagic, RarePrefix, RareSuffix
for (const fn of ['MagicPrefix.txt', 'MagicSuffix.txt', 'AutoMagic.txt']) {
const t = await readTxt(fn)
for (const r of t.rows) addKey(r['Name'])
}
for (const fn of ['RarePrefix.txt', 'RareSuffix.txt']) {
const t = await readTxt(fn)
for (const r of t.rows) addKey(r['name'])
}
// 4. ItemStatCost.txt description & display group keys
const isc = await readTxt('ItemStatCost.txt')
interface IscDescMeta {
id: number
stat: string
descPriority: number
descFunc: number
descVal: number
descStrPos: string
descStrNeg: string
descStr2: string
dgrp: number
dgrpFunc: number
dgrpVal: number
dgrpStrPos: string
dgrpStrNeg: string
dgrpStr2: string
}
const iscMeta: Record<string, IscDescMeta> = {}
for (const r of isc.rows) {
const stat = r['Stat']?.trim()
if (!stat) continue
for (const col of ['descstrpos', 'descstrneg', 'descstr2', 'dgrpstrpos', 'dgrpstrneg', 'dgrpstr2']) {
addKey(r[col])
}
const descFunc = Number(r['descfunc']?.trim() || 0)
const dgrp = Number(r['dgrp']?.trim() || 0)
if (descFunc > 0 || dgrp > 0) {
iscMeta[stat] = {
id: Number(r['ID']?.trim() || 0),
stat,
descPriority: Number(r['descpriority']?.trim() || 0),
descFunc,
descVal: Number(r['descval']?.trim() || 0),
descStrPos: r['descstrpos']?.trim() || '',
descStrNeg: r['descstrneg']?.trim() || '',
descStr2: r['descstr2']?.trim() || '',
dgrp,
dgrpFunc: Number(r['dgrpfunc']?.trim() || 0),
dgrpVal: Number(r['dgrpval']?.trim() || 0),
dgrpStrPos: r['dgrpstrpos']?.trim() || '',
dgrpStrNeg: r['dgrpstrneg']?.trim() || '',
dgrpStr2: r['dgrpstr2']?.trim() || '',
}
}
}
// 5. CharStats.txt & SkillDesc.txt
for (const r of (await readTxt('CharStats.txt')).rows) {
for (const col of ['StrAllSkills', 'StrSkillTab1', 'StrSkillTab2', 'StrSkillTab3', 'StrClassOnly', 'class']) {
addKey(r[col])
}
}
for (const r of (await readTxt('SkillDesc.txt')).rows) {
addKey(r['str name'])
}
// 6. All ModStr*, strMod*, ItemStats*, Weapon*, StrGem*, GemXp*, ExInsert*, Socketable, quality/gold keys
for (const [k] of eng.dict.entries()) {
if (
k.startsWith('ModStr') ||
k.startsWith('strMod') ||
k.startsWith('ItemStats') ||
k.startsWith('Weapon') ||
k.startsWith('StrGem') ||
k.startsWith('GemXp') ||
k.startsWith('ExInsert') ||
k === 'Socketable' ||
k === 'gld' ||
k === 'Gold' ||
k === 'Hiquality' ||
k === 'Cracked' ||
k === 'Crude' ||
k === 'Damaged' ||
k === 'Low Quality' ||
k === 'Gemmed' ||
k === 'GemmedNormalName'
) {
keys.add(k)
}
}
const sortedKeys = Array.from(keys).sort()
const engOut: Record<string, string> = {}
const chiOut: Record<string, string> = {}
for (const k of sortedKeys) {
const ev = eng.get(k)
if (ev !== undefined) {
engOut[k] = ev
}
const cv = chi.get(k)
if (cv !== undefined) {
chiOut[k] = cv
}
}
// Also include expansionstring.tbl index 2745 ("Ethereal (Cannot be Repaired)") under canonical key 'strethereal'
const expTbl = eng.tables[1]
if (expTbl && expTbl[2745]) {
engOut['strethereal'] = expTbl[2745]!
}
const outPath = path.join(process.cwd(), 'src/data/canonical-tbl-strings.ts')
const fileContent = `/**
* AUTO-GENERATED by \`scripts/pack-canonical-tbl.ts\` from authoritative 1.13c MPQ archives:
* - \`d2data.mpq\` (\`string.tbl\`)
* - \`d2exp.mpq\` (\`expansionstring.tbl\`)
* - \`Patch_D2.mpq\` (\`patchstring.tbl\`)
* - \`data/global/excel/ItemStatCost.txt\`
*
* DO NOT EDIT MANUALLY.
*/
export interface CanonicalIscDescEntry {
readonly id: number
readonly stat: string
readonly descPriority: number
readonly descFunc: number
readonly descVal: number
readonly descStrPos: string
readonly descStrNeg: string
readonly descStr2: string
readonly dgrp: number
readonly dgrpFunc: number
readonly dgrpVal: number
readonly dgrpStrPos: string
readonly dgrpStrNeg: string
readonly dgrpStr2: string
}
export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = ${JSON.stringify(engOut, null, 2)}
export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = ${JSON.stringify(chiOut, null, 2)}
export const CANONICAL_ISC_DESC_TABLE: Readonly<Record<string, CanonicalIscDescEntry>> = ${JSON.stringify(iscMeta, null, 2)}
`
fs.writeFileSync(outPath, fileContent, 'utf8')
console.log(
`Wrote ${outPath}: ${Object.keys(engOut).length} ENG keys, ${Object.keys(chiOut).length} CHI keys, ${Object.keys(iscMeta).length} ISC entries.`,
)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})

File diff suppressed because it is too large Load Diff

View File

@ -27,6 +27,19 @@ import {
} from '../ui/hotkeys.ts'
import { SKILLS_BY_CLASS } from '../data/skills-catalog.ts'
import { calculateManaCost } from './skill-calc-engine.ts'
import { getSocketableInfo } from './gems-data.ts'
function buildSocketableStats(code: string): readonly { text: string; color: 'white' }[] {
const info = getSocketableInfo(code)
if (!info) return []
return [
{ text: '可镶嵌进有凹槽的装备', color: 'white' },
{ text: `武器: ${info.weaponsZh}`, color: 'white' },
{ text: `防具: ${info.armorZh}`, color: 'white' },
{ text: `头盔: ${info.helmsZh}`, color: 'white' },
{ text: `盾牌: ${info.shieldsZh}`, color: 'white' },
]
}
export interface ClassProfileState {
readonly classCode: CharacterClassCode
@ -92,7 +105,10 @@ const TRANG_BONUSES = [
{ text: '+100 法力 (完整套装)', color: 'green' as const },
{ text: '所有抗性 +50 (完整套装)', color: 'green' as const },
{ text: '化身为吸血鬼巫妖形态 (完整套装)', color: 'green' as const },
{ text: '+18 火球 / +13 火墙 / +10 陨石 / +3 支配火焰 (完整套装)', color: 'green' as const },
{ text: '+18 火球 (完整套装)', color: 'green' as const },
{ text: '+13 火墙 (完整套装)', color: 'green' as const },
{ text: '+10 陨石 (完整套装)', color: 'green' as const },
{ text: '+3 支配火焰 (完整套装)', color: 'green' as const },
]
const GRIS_PIECES: readonly string[] = [
@ -150,7 +166,8 @@ const NAT_BONUSES = [
{ text: '+200 防御 (完整套装)', color: 'green' as const },
{ text: '所有抗性 +50 (完整套装)', color: 'green' as const },
{ text: '物理伤害减少 30% (完整套装)', color: 'green' as const },
{ text: '14% 每次命中偷取生命 / 14% 偷取法力 (完整套装)', color: 'green' as const },
{ text: '14% 每次命中偷取生命 (完整套装)', color: 'green' as const },
{ text: '14% 每次命中偷取法力 (完整套装)', color: 'green' as const },
]
export const CLASS_EXCLUSIVE_SETS: Record<CharacterClassCode, Readonly<Partial<Record<EquipSlotId, UiInventoryItem>>>> = {
@ -253,7 +270,8 @@ export const CLASS_EXCLUSIVE_SETS: Record<CharacterClassCode, Readonly<Partial<R
reqStr: 88,
stats: [
{ text: '+50 防御', color: 'blue' },
{ text: '+10 力量 / +15 敏捷', color: 'blue' },
{ text: '+10 力量', color: 'blue' },
{ text: '+15 敏捷', color: 'blue' },
{ text: '增加 6-18 冰冷伤害 (持续 4 秒)', color: 'blue' },
{ text: '冻结目标时间减半', color: 'blue' },
{ text: '增加 131-252 冰冷伤害 (完整套装加成)', color: 'green' },
@ -310,7 +328,8 @@ export const CLASS_EXCLUSIVE_SETS: Record<CharacterClassCode, Readonly<Partial<R
{ text: '+45 防御', color: 'blue' },
{ text: '10% 每次命中偷取生命', color: 'blue' },
{ text: '10% 每次命中偷取法力', color: 'blue' },
{ text: '+60 生命 / +30 法力', color: 'blue' },
{ text: '+60 生命', color: 'blue' },
{ text: '+30 法力', color: 'blue' },
{ text: '所有抗性 +15', color: 'blue' },
],
setPieces: TAL_PIECES,
@ -334,7 +353,9 @@ export const CLASS_EXCLUSIVE_SETS: Record<CharacterClassCode, Readonly<Partial<R
reqStr: 84,
stats: [
{ text: '+400 防御', color: 'blue' },
{ text: '抗寒 +40% / 抗电 +40% / 抗火 +40%', color: 'blue' },
{ text: '抗寒 +40%', color: 'blue' },
{ text: '抗电 +40%', color: 'blue' },
{ text: '抗火 +40%', color: 'blue' },
{ text: '魔法伤害减少 15', color: 'blue' },
{ text: '88% 更佳机会取得魔法装备', color: 'blue' },
],
@ -359,8 +380,12 @@ export const CLASS_EXCLUSIVE_SETS: Record<CharacterClassCode, Readonly<Partial<R
reqLevel: 65,
stats: [
{ text: '+20% 快速施法速度 (FCR)', color: 'blue' },
{ text: '+1-2 支配火焰 / +1-2 支配闪电 / +1-2 支配冰冷', color: 'blue' },
{ text: '+57 生命 / +77 法力 / +10 精力', color: 'blue' },
{ text: '+2 支配火焰 (限法师)', color: 'blue' },
{ text: '+2 支配闪电 (限法师)', color: 'blue' },
{ text: '+2 支配冰冷 (限法师)', color: 'blue' },
{ text: '+57 生命', color: 'blue' },
{ text: '+77 法力', color: 'blue' },
{ text: '+10 精力', color: 'blue' },
],
setPieces: TAL_PIECES,
setBonuses: TAL_BONUSES,
@ -382,7 +407,8 @@ export const CLASS_EXCLUSIVE_SETS: Record<CharacterClassCode, Readonly<Partial<R
reqLevel: 53,
reqStr: 47,
stats: [
{ text: '+20 敏捷 / +30 法力', color: 'blue' },
{ text: '+20 敏捷', color: 'blue' },
{ text: '+30 法力', color: 'blue' },
{ text: '受损生命转化为法力 37%', color: 'blue' },
{ text: '15% 更佳机会取得魔法装备', color: 'blue' },
{ text: '需求 -20%', color: 'blue' },
@ -406,7 +432,8 @@ export const CLASS_EXCLUSIVE_SETS: Record<CharacterClassCode, Readonly<Partial<R
stats: [
{ text: '+2 法师技能等级', color: 'blue' },
{ text: '增加 3-32 闪电伤害', color: 'blue' },
{ text: '+50 生命 / +42 法力', color: 'blue' },
{ text: '+50 生命', color: 'blue' },
{ text: '+42 法力', color: 'blue' },
{ text: '抗电 +33%', color: 'blue' },
],
setPieces: TAL_PIECES,
@ -487,8 +514,10 @@ export const CLASS_EXCLUSIVE_SETS: Record<CharacterClassCode, Readonly<Partial<R
{ text: '+125 防御', color: 'blue' },
{ text: '+30% 增加格挡几率', color: 'blue' },
{ text: '+2 毒素与白骨技能 (限死灵法师)', color: 'blue' },
{ text: '+25 力量 / +15 敏捷', color: 'blue' },
{ text: '抗火 +45% / 抗毒 +40%', color: 'blue' },
{ text: '+25 力量', color: 'blue' },
{ text: '+15 敏捷', color: 'blue' },
{ text: '抗火 +45%', color: 'blue' },
{ text: '抗毒 +40%', color: 'blue' },
],
setPieces: TRANG_PIECES,
setBonuses: TRANG_BONUSES,
@ -539,7 +568,9 @@ export const CLASS_EXCLUSIVE_SETS: Record<CharacterClassCode, Readonly<Partial<R
{ text: '+100 防御', color: 'blue' },
{ text: '无法冰冻', color: 'blue' },
{ text: '需求 -40%', color: 'blue' },
{ text: '+66 生命 / +50 法力 / +30 耐力', color: 'blue' },
{ text: '+66 生命', color: 'blue' },
{ text: '+50 法力', color: 'blue' },
{ text: '+30 最大耐力', color: 'blue' },
{ text: '生命回复 +5', color: 'blue' },
],
setPieces: TRANG_PIECES,
@ -753,7 +784,8 @@ export const CLASS_EXCLUSIVE_SETS: Record<CharacterClassCode, Readonly<Partial<R
reqStr: 110,
stats: [
{ text: '+65 防御', color: 'blue' },
{ text: '+20 力量 / +20 敏捷', color: 'blue' },
{ text: '+20 力量', color: 'blue' },
{ text: '+20 敏捷', color: 'blue' },
],
setPieces: IK_PIECES,
setBonuses: IK_BONUSES,
@ -777,7 +809,8 @@ export const CLASS_EXCLUSIVE_SETS: Record<CharacterClassCode, Readonly<Partial<R
stats: [
{ text: '+36 防御', color: 'blue' },
{ text: '+25 力量', color: 'blue' },
{ text: '抗火 +28% / 抗电 +31%', color: 'blue' },
{ text: '抗火 +28%', color: 'blue' },
{ text: '抗电 +31%', color: 'blue' },
],
setPieces: IK_PIECES,
setBonuses: IK_BONUSES,
@ -854,8 +887,10 @@ export const CLASS_EXCLUSIVE_SETS: Record<CharacterClassCode, Readonly<Partial<R
reqStr: 115,
stats: [
{ text: '+300 防御', color: 'blue' },
{ text: '+1 元素技能 / +1 变形技能 (限德鲁伊)', color: 'blue' },
{ text: '+20 力量 / +15 敏捷', color: 'blue' },
{ text: '+1 元素技能 (限德鲁伊)', color: 'blue' },
{ text: '+1 变形技能 (限德鲁伊)', color: 'blue' },
{ text: '+20 力量', color: 'blue' },
{ text: '+15 敏捷', color: 'blue' },
{ text: '抗电 +50%', color: 'blue' },
{ text: '需求 -50%', color: 'blue' },
],
@ -883,7 +918,8 @@ export const CLASS_EXCLUSIVE_SETS: Record<CharacterClassCode, Readonly<Partial<R
stats: [
{ text: '+200% 增强伤害 (ED)', color: 'blue' },
{ text: '+30% 提升攻击速度 (IAS)', color: 'blue' },
{ text: '10% 每次命中偷取生命 / 5% 偷取法力', color: 'blue' },
{ text: '10% 每次命中偷取生命', color: 'blue' },
{ text: '5% 每次命中偷取法力', color: 'blue' },
{ text: '增加 40-62 闪电伤害', color: 'blue' },
{ text: '带插槽 (3)', color: 'blue' },
],
@ -908,7 +944,8 @@ export const CLASS_EXCLUSIVE_SETS: Record<CharacterClassCode, Readonly<Partial<R
reqStr: 95,
stats: [
{ text: '+40% 高速跑步/行走 (FRW)', color: 'blue' },
{ text: '+50 生命 / +180 耐力', color: 'blue' },
{ text: '+50 生命', color: 'blue' },
{ text: '+180 最大耐力', color: 'blue' },
{ text: '抗火 +50%', color: 'blue' },
{ text: '受损生命转化为法力 10%', color: 'blue' },
],
@ -936,7 +973,8 @@ export const CLASS_EXCLUSIVE_SETS: Record<CharacterClassCode, Readonly<Partial<R
stats: [
{ text: '+175 防御', color: 'blue' },
{ text: '所有抗性 +20', color: 'blue' },
{ text: '+20 力量 / +30 敏捷', color: 'blue' },
{ text: '+20 力量', color: 'blue' },
{ text: '+30 敏捷', color: 'blue' },
{ text: '魔法伤害减少 3', color: 'blue' },
],
setPieces: NAT_PIECES,
@ -1017,7 +1055,8 @@ export const CLASS_EXCLUSIVE_SETS: Record<CharacterClassCode, Readonly<Partial<R
stats: [
{ text: '+125 防御', color: 'blue' },
{ text: '+40% 高速跑步/行走 (FRW)', color: 'blue' },
{ text: '抗寒 +25% / 抗电 +25%', color: 'blue' },
{ text: '抗寒 +25%', color: 'blue' },
{ text: '抗电 +25%', color: 'blue' },
{ text: '生命回复 +10', color: 'blue' },
{ text: '受损生命转化为法力 10%', color: 'blue' },
],
@ -1037,42 +1076,42 @@ export const STARTER_STASH_ITEMS: readonly GridPlacement[] = [
col: 0, row: 0,
item: {
id: 'stash-r-jah', code: 'r31', invFile: 'invrjo', name: 'Jah Rune', nameZh: '乔 (Jah 符文)', baseNameZh: '符文 (#31)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 65,
stats: [{ text: '武器: 忽略目标防御', color: 'white' }, { text: '防具: 最大生命值提升 5%', color: 'white' }],
stats: buildSocketableStats('r31'),
},
},
{
col: 1, row: 0,
item: {
id: 'stash-r-ber', code: 'r30', invFile: 'invrber', name: 'Ber Rune', nameZh: '贝 (Ber 符文)', baseNameZh: '符文 (#30)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 63,
stats: [{ text: '武器: 20% 压碎性打击 (CB)', color: 'white' }, { text: '防具: 伤害减少 8%', color: 'white' }],
stats: buildSocketableStats('r30'),
},
},
{
col: 2, row: 0,
item: {
id: 'stash-r-lo', code: 'r28', invFile: 'invrlo', name: 'Lo Rune', nameZh: '罗 (Lo 符文)', baseNameZh: '符文 (#28)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 59,
stats: [{ text: '武器: 20% 致命攻击 (DS)', color: 'white' }, { text: '防具: +5% 闪电抗性上限', color: 'white' }],
stats: buildSocketableStats('r28'),
},
},
{
col: 3, row: 0,
item: {
id: 'stash-r-ohm', code: 'r27', invFile: 'invrohm', name: 'Ohm Rune', nameZh: '欧姆 (Ohm 符文)', baseNameZh: '符文 (#27)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 57,
stats: [{ text: '武器: +50% 增强伤害 (ED)', color: 'white' }, { text: '防具: +5% 冰冷抗性上限', color: 'white' }],
stats: buildSocketableStats('r27'),
},
},
{
col: 4, row: 0,
item: {
id: 'stash-r-vex', code: 'r26', invFile: 'invrvex', name: 'Vex Rune', nameZh: '伐克斯 (Vex 符文)', baseNameZh: '符文 (#26)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 55,
stats: [{ text: '武器: 7% 每次命中偷取法力', color: 'white' }, { text: '防具: +5% 火焰抗性上限', color: 'white' }],
stats: buildSocketableStats('r26'),
},
},
{
col: 5, row: 0,
item: {
id: 'stash-r-cham', code: 'r32', invFile: 'invrcham', name: 'Cham Rune', nameZh: '查姆 (Cham 符文)', baseNameZh: '符文 (#32)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 67,
stats: [{ text: '武器: 冰冻目标 +3', color: 'white' }, { text: '防具: 无法冰冻', color: 'white' }],
stats: buildSocketableStats('r32'),
},
},
@ -1081,42 +1120,42 @@ export const STARTER_STASH_ITEMS: readonly GridPlacement[] = [
col: 0, row: 1,
item: {
id: 'stash-r-zod', code: 'r33', invFile: 'invrzod', name: 'Zod Rune', nameZh: '佐德 (Zod 符文)', baseNameZh: '符文 (#33)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 69,
stats: [{ text: '永不磨损 (Indestructible)', color: 'white' }],
stats: buildSocketableStats('r33'),
},
},
{
col: 1, row: 1,
item: {
id: 'stash-r-sur', code: 'r29', invFile: 'invrsur', name: 'Sur Rune', nameZh: '苏尔 (Sur 符文)', baseNameZh: '符文 (#29)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 61,
stats: [{ text: '武器: 致盲目标', color: 'white' }, { text: '防具: 最大法力值提升 5%', color: 'white' }],
stats: buildSocketableStats('r29'),
},
},
{
col: 2, row: 1,
item: {
id: 'stash-r-gul', code: 'r25', invFile: 'invrgul', name: 'Gul Rune', nameZh: '古尔 (Gul 符文)', baseNameZh: '符文 (#25)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 53,
stats: [{ text: '武器: 20% 额外攻击准确率', color: 'white' }, { text: '防具: +5% 毒素抗性上限', color: 'white' }],
stats: buildSocketableStats('r25'),
},
},
{
col: 3, row: 1,
item: {
id: 'stash-r-ist', code: 'r24', invFile: 'invrist', name: 'Ist Rune', nameZh: '伊斯特 (Ist 符文)', baseNameZh: '符文 (#24)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 51,
stats: [{ text: '武器: 30% 更佳机会取得魔法装备', color: 'white' }, { text: '防具: 25% 更佳机会取得魔法装备', color: 'white' }],
stats: buildSocketableStats('r24'),
},
},
{
col: 4, row: 1,
item: {
id: 'stash-r-mal', code: 'r23', invFile: 'invrmal', name: 'Mal Rune', nameZh: '马尔 (Mal 符文)', baseNameZh: '符文 (#23)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 49,
stats: [{ text: '武器: 阻止怪物治疗', color: 'white' }, { text: '防具: 魔法伤害减少 7', color: 'white' }],
stats: buildSocketableStats('r23'),
},
},
{
col: 5, row: 1,
item: {
id: 'stash-r-um', code: 'r22', invFile: 'invrum', name: 'Um Rune', nameZh: '乌姆 (Um 符文)', baseNameZh: '符文 (#22)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 47,
stats: [{ text: '武器: 25% 造成撕裂伤口', color: 'white' }, { text: '防具: 所有抗性 +15 (盾牌 +22)', color: 'white' }],
stats: buildSocketableStats('r22'),
},
},
@ -1125,42 +1164,42 @@ export const STARTER_STASH_ITEMS: readonly GridPlacement[] = [
col: 0, row: 2,
item: {
id: 'stash-gem-p-ame', code: 'gpv', invFile: 'invgsve', name: 'Perfect Amethyst', nameZh: '完美紫宝石', baseNameZh: '完美宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 18,
stats: [{ text: '武器: +150 攻击准确率', color: 'white' }, { text: '防具: +10 力量', color: 'white' }],
stats: buildSocketableStats('gpv'),
},
},
{
col: 1, row: 2,
item: {
id: 'stash-gem-p-ruby', code: 'gpr', invFile: 'invgsre', name: 'Perfect Ruby', nameZh: '完美红宝石', baseNameZh: '完美宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 18,
stats: [{ text: '武器: 增加 15-20 火焰伤害', color: 'white' }, { text: '防具: +38 生命', color: 'white' }],
stats: buildSocketableStats('gpr'),
},
},
{
col: 2, row: 2,
item: {
id: 'stash-gem-p-sap', code: 'gpb', invFile: 'invgsbe', name: 'Perfect Sapphire', nameZh: '完美蓝宝石', baseNameZh: '完美宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 18,
stats: [{ text: '武器: 增加 10-14 冰冷伤害 (3秒)', color: 'white' }, { text: '防具: +38 法力', color: 'white' }],
stats: buildSocketableStats('gpb'),
},
},
{
col: 3, row: 2,
item: {
id: 'stash-gem-p-top', code: 'gpy', invFile: 'invgsye', name: 'Perfect Topaz', nameZh: '完美黄宝石', baseNameZh: '完美宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 18,
stats: [{ text: '武器: 增加 1-40 闪电伤害', color: 'white' }, { text: '防具: 24% 更佳机会取得魔法装备', color: 'white' }],
stats: buildSocketableStats('gpy'),
},
},
{
col: 4, row: 2,
item: {
id: 'stash-gem-p-eme', code: 'gpg', invFile: 'invgsge', name: 'Perfect Emerald', nameZh: '完美绿宝石', baseNameZh: '完美宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 18,
stats: [{ text: '武器: 增加 100 毒素伤害 (7秒)', color: 'white' }, { text: '防具: +10 敏捷', color: 'white' }],
stats: buildSocketableStats('gpg'),
},
},
{
col: 5, row: 2,
item: {
id: 'stash-gem-p-dia', code: 'gpw', invFile: 'invgswe', name: 'Perfect Diamond', nameZh: '完美白宝石', baseNameZh: '完美宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 18,
stats: [{ text: '武器: +68% 对不死生物的伤害', color: 'white' }, { text: '防具: 所有抗性 +19 (盾牌)', color: 'white' }],
stats: buildSocketableStats('gpw'),
},
},
@ -1169,42 +1208,42 @@ export const STARTER_STASH_ITEMS: readonly GridPlacement[] = [
col: 0, row: 3,
item: {
id: 'stash-gem-fl-ame', code: 'gzv', invFile: 'invgsvd', name: 'Flawless Amethyst', nameZh: '无瑕紫宝石', baseNameZh: '宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 15,
stats: [{ text: '防具: +8 力量', color: 'white' }],
stats: buildSocketableStats('gzv'),
},
},
{
col: 1, row: 3,
item: {
id: 'stash-gem-fl-ruby', code: 'glr', invFile: 'invgsrd', name: 'Flawless Ruby', nameZh: '无瑕红宝石', baseNameZh: '宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 15,
stats: [{ text: '防具: +31 生命', color: 'white' }],
stats: buildSocketableStats('glr'),
},
},
{
col: 2, row: 3,
item: {
id: 'stash-gem-fl-sap', code: 'glb', invFile: 'invgsbd', name: 'Flawless Sapphire', nameZh: '无瑕蓝宝石', baseNameZh: '宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 15,
stats: [{ text: '防具: +31 法力', color: 'white' }],
stats: buildSocketableStats('glb'),
},
},
{
col: 3, row: 3,
item: {
id: 'stash-gem-fl-top', code: 'gly', invFile: 'invgsyd', name: 'Flawless Topaz', nameZh: '无瑕黄宝石', baseNameZh: '宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 15,
stats: [{ text: '防具: 20% 更佳机会取得魔法装备', color: 'white' }],
stats: buildSocketableStats('gly'),
},
},
{
col: 4, row: 3,
item: {
id: 'stash-gem-fl-eme', code: 'glg', invFile: 'invgsgd', name: 'Flawless Emerald', nameZh: '无瑕绿宝石', baseNameZh: '宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 15,
stats: [{ text: '防具: +8 敏捷', color: 'white' }],
stats: buildSocketableStats('glg'),
},
},
{
col: 5, row: 3,
item: {
id: 'stash-gem-fl-skull', code: 'skz', invFile: 'invskz', name: 'Flawless Skull', nameZh: '无瑕骷髅', baseNameZh: '骷髅', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 15,
stats: [{ text: '武器: 3% 偷取生命 / 2% 偷取法力', color: 'white' }, { text: '防具: 生命回复 +4 / 法力回复 +12%', color: 'white' }],
stats: buildSocketableStats('skz'),
},
},
@ -1247,7 +1286,8 @@ export const STARTER_STASH_ITEMS: readonly GridPlacement[] = [
id: 'stash-jew-rare', code: 'jew', invFile: 'invjw4', name: 'Doom Gyre', nameZh: '末日环绕 (稀有珠宝)', baseNameZh: '稀有珠宝 (Rare Jewel)', quality: 'rare', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 42,
stats: [
{ text: '+30% 增强伤害 (ED)', color: 'blue' },
{ text: '+9 力量 / +9 敏捷', color: 'blue' },
{ text: '+9 力量', color: 'blue' },
{ text: '+9 敏捷', color: 'blue' },
{ text: '所有抗性 +10', color: 'blue' },
{ text: '+7% 快速再度攻击 (FHR)', color: 'blue' },
],

View File

@ -141,6 +141,14 @@ import {
type ItemStatCostTable,
} from './properties.ts'
import { setRuntimePropertyTables } from './item-tooltip.ts'
import {
translateItemName,
translateMagicName,
translateRareName,
resolveBaseItemNameEn,
resolveRareNameEn,
resolveMagicNameEn,
} from './tooltip-i18n.ts'
export {
rollAutoMagic,
@ -830,6 +838,41 @@ export function createDroppedItem(
durability = maxDurability
}
const baseCode = formatItemCode(base.id).trim()
const resolvedBaseEn = resolveBaseItemNameEn(base.name, baseCode)
let nameZh: string
if (qualityTier === 'unique' && options.uniqueItem) {
nameZh = translateItemName(options.uniqueItem.index)
} else if (qualityTier === 'set' && options.setItem) {
nameZh = translateItemName(options.setItem.index)
} else if (qualityTier === 'rare' && options.rareAffixes) {
name = resolveRareNameEn(
options.rareAffixes.name,
options.rareAffixes.rarePrefix?.name,
options.rareAffixes.rareSuffix?.name,
)
nameZh = translateRareName(options.rareAffixes.name)
} else if (qualityTier === 'magic' && options.magicAffixes) {
name = resolveMagicNameEn(
options.magicAffixes.name,
base.name,
options.magicAffixes.prefix?.name,
options.magicAffixes.suffix?.name,
baseCode,
)
nameZh = translateMagicName(options.magicAffixes.name, base.name)
} else if (qualityTier === 'superior') {
name = `Superior ${resolvedBaseEn}`
nameZh = translateItemName(name)
} else if (qualityTier === 'low') {
const lowPrefix = LOW_QUALITY_PREFIXES[qualitySubtype ?? 0] ?? 'Cracked'
name = `${lowPrefix} ${resolvedBaseEn}`
nameZh = translateItemName(name)
} else {
name = resolvedBaseEn
nameZh = translateItemName(name)
}
return {
base,
prefix,
@ -837,6 +880,7 @@ export function createDroppedItem(
level: options.ilvl,
ilvl: options.ilvl,
name,
nameZh,
stats,
invWidth: base.invWidth,
invHeight: base.invHeight,

View File

@ -128,7 +128,7 @@ export function resolveItemQualityString(quality: any, code?: string, item?: any
*/
export function formatBilingualGroundItemName(item: any, name: string, quality: string): string {
if (typeof item?.nameZh === 'string' && item.nameZh.trim().length > 0 && item.nameZh !== name) {
return item.nameZh
return item.nameZh.includes('(') ? item.nameZh : `${item.nameZh} (${name})`
}
const baseEn = typeof item?.base?.name === 'string' && item.base.name.length > 0 ? item.base.name : undefined
if (quality === 'unique' && item?.uniqueItemDef) {

View File

@ -4,18 +4,22 @@
* 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!
* Ground Truth (D2MOO `source/D2Common/src/DataTbls/ItemsTbls.cpp`):
* - Armor.txt / Weapons.txt / Misc.txt: `namestr` column (`0x6FD55280`)
* - UniqueItems.txt: `index` column (`0x6FD584E0`)
* - SetItems.txt: `index` column (`0x6FD59140`)
* - Sets.txt: `name` column (`0x6FD59140`)
* - Runes.txt: `Name` column (`0x6FD5E020`)
* - Gems.txt: `name` / `code` column (`0x6FD5B2F0`)
* - LowQualityItems.txt: `Name` column (`0x6FD5BDA0`)
* - MagicPrefix / MagicSuffix / AutoMagic: `Name` column via `D2LANG_GetTblIndex` (`0x6FD57C20`)
* - RarePrefix / RareSuffix: `name` column via `D2LANG_GetTblIndex` (`0x6FD58110`)
*/
import { decodeTbl } from '../formats/tbl.ts'
import type { DecodedTbl } from '../formats/tbl.ts'
import type { MountedArchives } from '../mpq/mount.ts'
import { CANONICAL_CHI_TBL_DICT, CANONICAL_ENG_TBL_DICT } from '../data/canonical-tbl-strings.ts'
import type { ArmorBase } from './armor.ts'
import type { WeaponBase } from './weapons.ts'
import type { MiscBase } from './misc-items.ts'
@ -283,11 +287,80 @@ export function resolveRuneName(
}
/**
* For Magic/Rare prefixes and suffixes, the name column in the txt file
* is already raw display text, so tbl lookup is not performed.
* Resolves display name for Magic/Rare/AutoMagic prefixes and suffixes via `.tbl`
* (`DATATBLS_LoadMagicSuffix_Prefix_AutomagicTxt` `0x6FD57C20` and
* `DATATBLS_LoadRareSuffix_PrefixTxt` `0x6FD58110`).
*/
export function resolveAffixName(
affix: string | { name?: string; Name?: string },
chiChain?: StringTableChain | ItemNameChains | null,
engChain?: StringTableChain | null,
): string {
return typeof affix === 'string' ? affix : (affix.name ?? affix.Name ?? '')
const key = typeof affix === 'string' ? affix : (affix.name ?? affix.Name ?? '')
return resolveItemName(key, chiChain, engChain)
}
/** Alias for `resolveAffixName` used for `RarePrefix.txt` / `RareSuffix.txt`. */
export const resolveRareName = resolveAffixName
/**
* Resolves display name for Gems (`Gems.txt` `code` or `name` column, `0x6FD5B2F0`).
*/
export function resolveGemName(
gem: string | { code?: string; name?: string },
chiChain?: StringTableChain | ItemNameChains | null,
engChain?: StringTableChain | null,
): string {
const key = typeof gem === 'string' ? gem : (gem.code ?? gem.name ?? '')
return resolveItemName(key, chiChain, engChain)
}
/**
* Resolves display name for Low Quality prefixes (`LowQualityItems.txt` `Name` column, `0x6FD5BDA0`).
*/
export function resolveLowQualityName(
entry: string | { Name?: string; name?: string },
chiChain?: StringTableChain | ItemNameChains | null,
engChain?: StringTableChain | null,
): string {
const key = typeof entry === 'string' ? entry : (entry.Name ?? entry.name ?? '')
return resolveItemName(key, chiChain, engChain)
}
let cachedEmbeddedChains: { chi: StringTableChain; eng: StringTableChain } | undefined
function createDecodedTblFromRecord(record: Readonly<Record<string, string>>): DecodedTbl {
const tbl = [] as unknown as DecodedTbl
tbl.dict = new Map<string, string>(Object.entries(record))
return tbl
}
/**
* Returns singleton `StringTableChain` instances backed by the pre-packed 1.13c
* `.tbl` dictionaries (`CANONICAL_CHI_TBL_DICT` and `CANONICAL_ENG_TBL_DICT`),
* suitable for zero-MPQ browser runtime resolution.
*/
export function getEmbeddedItemNameChains(): { chi: StringTableChain; eng: StringTableChain } {
if (!cachedEmbeddedChains) {
cachedEmbeddedChains = {
chi: new StringTableChain('CHI', [createDecodedTblFromRecord(CANONICAL_CHI_TBL_DICT)]),
eng: new StringTableChain('ENG', [createDecodedTblFromRecord(CANONICAL_ENG_TBL_DICT)]),
}
}
return cachedEmbeddedChains
}
/**
* Looks up a string key directly in the pre-packed 1.13c `.tbl` dictionary.
*/
export function lookupCanonicalTbl(
key: string,
lang: ItemLanguage | 'en' | 'zh' = 'ENG',
): string | undefined {
if (!key) return undefined
if (lang === 'CHI' || lang === 'zh') {
return CANONICAL_CHI_TBL_DICT[key] ?? CANONICAL_ENG_TBL_DICT[key]
}
return CANONICAL_ENG_TBL_DICT[key]
}

View File

@ -35,10 +35,19 @@ import {
translateItemName,
translateMagicName,
translateRareName,
resolveBaseItemNameEn,
resolveRareNameEn,
resolveMagicNameEn,
formatPropertyCodeI18n,
RUNE_INFO_ZH,
type TooltipLocale,
} from './tooltip-i18n.ts'
import {
CANONICAL_ENG_TBL_DICT,
CANONICAL_CHI_TBL_DICT,
CANONICAL_ISC_DESC_TABLE,
type CanonicalIscDescEntry,
} from '../data/canonical-tbl-strings.ts'
import {
GEM_INFO,
RUNE_INFO,
@ -53,8 +62,17 @@ export {
getTooltipLocale,
setTooltipLocale,
translateItemName,
resolveBaseItemNameEn,
resolveRareNameEn,
resolveMagicNameEn,
type TooltipLocale,
} from './tooltip-i18n.ts'
export {
CANONICAL_ENG_TBL_DICT,
CANONICAL_CHI_TBL_DICT,
CANONICAL_ISC_DESC_TABLE,
type CanonicalIscDescEntry,
} from '../data/canonical-tbl-strings.ts'
export {
GEM_INFO,
RUNE_INFO,
@ -1281,6 +1299,129 @@ export function getPropertyStatId(code: string, tables?: RuntimePropertyTables):
return PROP_STAT_ID[norm] ?? PROP_STAT_ID[lookup] ?? -1
}
const ISC_BY_ID: ReadonlyMap<number, CanonicalIscDescEntry> = (() => {
const map = new Map<number, CanonicalIscDescEntry>()
for (const entry of Object.values(CANONICAL_ISC_DESC_TABLE)) {
map.set(entry.id, entry)
}
return map
})()
/**
* Formats an `ItemStatCost.txt` stat or `Properties.txt` property code directly from
* 1.13c `.tbl` (`CANONICAL_ENG_TBL_DICT` / `CANONICAL_CHI_TBL_DICT`) and `CANONICAL_ISC_DESC_TABLE`
* (`descFunc` 1..28, `descVal` 0..2, `descStrPos`, `descStrNeg`, `descStr2`, and `dgrp` 1..2).
*/
export function formatStatByIscDescFunc(
statOrCode: string,
value: number,
min: number = value,
max: number = value,
param?: string | number,
locale: TooltipLocale = getTooltipLocale(),
): string {
const norm = (statOrCode || '').trim().toLowerCase().replace(/^\*/, '')
const dict = locale === 'en' ? CANONICAL_ENG_TBL_DICT : CANONICAL_CHI_TBL_DICT
const lookupTbl = (key: string): string => {
if (!key) return ''
return (dict[key] ?? CANONICAL_ENG_TBL_DICT[key] ?? key).trim()
}
const isDgrp1 = norm === 'all-stats'
const isDgrp2 = norm === 'res-all' || norm === 'all-res'
let entry: CanonicalIscDescEntry | undefined
if (isDgrp1) {
entry = CANONICAL_ISC_DESC_TABLE['strength']
} else if (isDgrp2) {
entry = CANONICAL_ISC_DESC_TABLE['fireresist']
} else {
entry =
CANONICAL_ISC_DESC_TABLE[norm] ??
(PROP_STAT_ID[norm] !== undefined ? ISC_BY_ID.get(PROP_STAT_ID[norm]) : undefined)
}
if (!entry) {
return formatPropertyCode(statOrCode, value, min, max, param, locale)
}
const useDgrp = isDgrp1 || isDgrp2
const descFunc = useDgrp ? entry.dgrpFunc : entry.descFunc
const descVal = useDgrp ? entry.dgrpVal : entry.descVal
const strKey = value >= 0
? (useDgrp ? entry.dgrpStrPos : entry.descStrPos)
: ((useDgrp ? entry.dgrpStrNeg : entry.descStrNeg) || (useDgrp ? entry.dgrpStrPos : entry.descStrPos))
const str2Key = useDgrp ? entry.dgrpStr2 : entry.descStr2
const str1 = lookupTbl(strKey)
const str2 = lookupTbl(str2Key)
const composeWithVal = (valStr: string, baseStr: string, extraStr = ''): string => {
let result: string
if (descVal === 0 || !valStr) {
result = baseStr
} else if (descVal === 1) {
result = baseStr ? `${valStr} ${baseStr}` : valStr
} else {
result = baseStr ? `${baseStr} ${valStr}` : valStr
}
if (extraStr) {
result = `${result} ${extraStr}`
}
return result.replace(/\s+/g, ' ').trim()
}
const signedInt = value >= 0 ? `+${value}` : `${value}`
const signedPerLvl = value >= 0 ? `+${formatPerLevelVal(value)}` : `${formatPerLevelVal(value)}`
switch (descFunc) {
case 1:
return composeWithVal(signedInt, str1)
case 2:
return composeWithVal(`${value}%`, str1)
case 3:
return composeWithVal(`${value}`, str1)
case 4:
return composeWithVal(`${signedInt}%`, str1)
case 5:
return composeWithVal(`${Math.floor((value * 100) / 128)}%`, str1)
case 6:
return composeWithVal(signedPerLvl, str1, str2)
case 7:
return composeWithVal(`${formatPerLevelVal(value)}%`, str1, str2)
case 8:
return composeWithVal(`${signedPerLvl}%`, str1, str2)
case 9:
return composeWithVal(formatPerLevelVal(value), str1, str2)
case 10:
return composeWithVal(`${Math.floor((value * 100) / 128)}%`, str1, str2)
case 11: {
const rate = Number(param) || value || 1
const seconds = Math.max(1, Math.floor(100 / rate))
if (str1.includes('%d')) {
return str1.replace('%d', String(seconds))
}
return formatPropertyCode(statOrCode, value, min, max, param, locale)
}
case 12:
return composeWithVal(value > 1 ? `+${value}` : '', str1)
case 19:
if (str1.includes('%+d')) {
return str1.replace('%+d', signedInt)
}
if (str1.includes('%d')) {
return str1.replace('%d', String(value))
}
return composeWithVal(signedInt, str1)
case 20:
return composeWithVal(`-${Math.abs(value)}%`, str1)
case 21:
return composeWithVal(`-${Math.abs(value)}`, str1)
default:
return formatPropertyCode(statOrCode, value, min, max, param, locale)
}
}
const ALIAS_CODE_MAP: Record<string, string> = {
swing1: 'ias',
swing2: 'ias',
@ -1807,21 +1948,24 @@ export function formatItemTooltip(item: Item, locale: TooltipLocale = getTooltip
affixReqLevel = Math.max(affixReqLevel, autoMagicDef.levelreq)
}
const baseCode = (item.code || (base as any)?.code || base.id || '').trim()
const baseNameEn = resolveBaseItemNameEn(base.name, baseCode)
if (quality === 'unique') {
qualityColor = '#c8a15a'
const uniqDef = item.uniqueItemDef as UniqueItem | undefined
if (uniqDef) {
if (locale === 'zh') {
title = translateItemName(uniqDef.index)
subTitle = translateItemName(base.name)
subTitle = translateItemName(baseNameEn)
} else if (locale === 'bilingual') {
const uZh = translateItemName(uniqDef.index)
const bZh = translateItemName(base.name)
const bZh = translateItemName(baseNameEn)
title = `${uniqDef.index} (${uZh})`
subTitle = `${base.name} (${bZh})`
subTitle = `${baseNameEn} (${bZh})`
} else {
title = uniqDef.index
subTitle = base.name
subTitle = baseNameEn
}
if (uniqDef.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, uniqDef.levelreq)
if (item.rolledProps && item.rolledProps.length > 0) {
@ -1840,7 +1984,7 @@ export function formatItemTooltip(item: Item, locale: TooltipLocale = getTooltip
}
}
} else {
subTitle = locale === 'zh' ? translateItemName(base.name) : locale === 'bilingual' ? `${base.name} (${translateItemName(base.name)})` : base.name
subTitle = locale === 'zh' ? translateItemName(baseNameEn) : locale === 'bilingual' ? `${baseNameEn} (${translateItemName(baseNameEn)})` : baseNameEn
}
} else if (quality === 'set') {
qualityColor = '#00e600'
@ -1848,16 +1992,16 @@ export function formatItemTooltip(item: Item, locale: TooltipLocale = getTooltip
if (setDef) {
if (locale === 'zh') {
title = translateItemName(setDef.index)
subTitle = `${translateItemName(base.name)} · [${translateItemName(setDef.set)}]`
subTitle = `${translateItemName(baseNameEn)} · [${translateItemName(setDef.set)}]`
} else if (locale === 'bilingual') {
const sZh = translateItemName(setDef.index)
const bZh = translateItemName(base.name)
const bZh = translateItemName(baseNameEn)
const setZh = translateItemName(setDef.set)
title = `${setDef.index} (${sZh})`
subTitle = `${base.name} (${bZh}) · [${setDef.set} (${setZh})]`
subTitle = `${baseNameEn} (${bZh}) · [${setDef.set} (${setZh})]`
} else {
title = setDef.index
subTitle = `${base.name} · [${setDef.set}]`
subTitle = `${baseNameEn} · [${setDef.set}]`
}
if (setDef.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, setDef.levelreq)
if (item.rolledProps && item.rolledProps.length > 0) {
@ -1876,23 +2020,28 @@ export function formatItemTooltip(item: Item, locale: TooltipLocale = getTooltip
}
}
} else {
subTitle = locale === 'zh' ? translateItemName(base.name) : locale === 'bilingual' ? `${base.name} (${translateItemName(base.name)})` : base.name
subTitle = locale === 'zh' ? translateItemName(baseNameEn) : locale === 'bilingual' ? `${baseNameEn} (${translateItemName(baseNameEn)})` : baseNameEn
}
} else if (quality === 'rare') {
qualityColor = '#ffff60'
const rareDef = item.rolledRareAffixes as GeneratedRareAffixes | undefined
const enName = rareDef?.name || item.name
const rawRareName = rareDef?.name || item.name
const enName = resolveRareNameEn(
rawRareName,
(rareDef as any)?.rarePrefix?.name ?? (rareDef as any)?.prefix1?.name,
(rareDef as any)?.rareSuffix?.name ?? (rareDef as any)?.suffix1?.name,
)
if (locale === 'zh') {
title = translateRareName(enName)
subTitle = translateItemName(base.name)
title = translateRareName(rawRareName)
subTitle = translateItemName(baseNameEn)
} else if (locale === 'bilingual') {
const rZh = translateRareName(enName)
const bZh = translateItemName(base.name)
const rZh = translateRareName(rawRareName)
const bZh = translateItemName(baseNameEn)
title = `${enName} (${rZh})`
subTitle = `${base.name} (${bZh})`
subTitle = `${baseNameEn} (${bZh})`
} else {
title = enName
subTitle = base.name
subTitle = baseNameEn
}
if (rareDef && rareDef.affixes) {
for (const aff of rareDef.affixes) {
@ -1928,18 +2077,25 @@ export function formatItemTooltip(item: Item, locale: TooltipLocale = getTooltip
} else if (quality === 'magic') {
qualityColor = '#4880ff'
const magicDef = item.rolledMagicAffixes as GeneratedAffixes | undefined
const enName = magicDef?.name || item.name
const rawMagicName = magicDef?.name || item.name
const enName = resolveMagicNameEn(
rawMagicName,
base.name,
magicDef?.prefix?.name ?? item.prefix?.name,
magicDef?.suffix?.name ?? item.suffix?.name,
baseCode,
)
if (locale === 'zh') {
title = translateMagicName(enName, base.name)
subTitle = translateItemName(base.name)
title = translateMagicName(rawMagicName, base.name)
subTitle = translateItemName(baseNameEn)
} else if (locale === 'bilingual') {
const mZh = translateMagicName(enName, base.name)
const bZh = translateItemName(base.name)
const mZh = translateMagicName(rawMagicName, base.name)
const bZh = translateItemName(baseNameEn)
title = `${enName} (${mZh})`
subTitle = `${base.name} (${bZh})`
subTitle = `${baseNameEn} (${bZh})`
} else {
title = enName
subTitle = base.name
subTitle = baseNameEn
}
if (magicDef) {
if (magicDef.prefix) {
@ -1996,7 +2152,7 @@ export function formatItemTooltip(item: Item, locale: TooltipLocale = getTooltip
}
} else if (quality === 'superior') {
qualityColor = '#ffffff'
const enName = item.name && item.name.startsWith('Superior ') ? item.name : `Superior ${base.name}`
const enName = item.name && item.name.startsWith('Superior ') ? item.name : `Superior ${baseNameEn}`
if (locale === 'zh') {
title = translateItemName(enName)
} else if (locale === 'bilingual') {
@ -2013,7 +2169,7 @@ export function formatItemTooltip(item: Item, locale: TooltipLocale = getTooltip
}
} else if (quality === 'low') {
qualityColor = '#a0a0a0'
const enName = item.name && item.name !== base.name ? item.name : `Cracked ${base.name}`
const enName = item.name && item.name !== base.name ? item.name : `Cracked ${baseNameEn}`
if (locale === 'zh') {
title = translateItemName(enName)
} else if (locale === 'bilingual') {
@ -2027,17 +2183,17 @@ export function formatItemTooltip(item: Item, locale: TooltipLocale = getTooltip
} else if (quality === 'runeword' || (quality === 'rune' && !socketable)) {
qualityColor = '#c8a15a'
const rwTitle = (item as any).nameZh || (item as any).runeword?.name || (item as any).runewordName || item.name || 'Runeword'
const bZh = (item as any).baseNameZh || translateItemName(base.name)
const bZh = (item as any).baseNameZh || translateItemName(baseNameEn)
if (locale === 'zh') {
title = (item as any).nameZh || translateItemName(rwTitle) || rwTitle
subTitle = bZh
} else if (locale === 'bilingual') {
const rwZh = translateItemName(rwTitle)
title = rwZh !== rwTitle ? `${rwTitle} (${rwZh})` : rwTitle
subTitle = `${base.name} (${bZh})`
subTitle = `${baseNameEn} (${bZh})`
} else {
title = rwTitle
subTitle = base.name
subTitle = baseNameEn
}
const rwLevelReq = (item as any).runeword?.levelreq || (item as any).reqLevel
if (rwLevelReq && rwLevelReq > 0) {
@ -2060,7 +2216,7 @@ export function formatItemTooltip(item: Item, locale: TooltipLocale = getTooltip
}
} else {
qualityColor = '#e0d5c1'
const enName = base.name
const enName = baseNameEn
if (locale === 'zh') {
title = translateItemName(enName)
} else if (locale === 'bilingual') {

View File

@ -302,6 +302,8 @@ export interface Item {
readonly level: number
/** Final name, affixes included. */
readonly name: string
/** Optional localized Chinese display name resolved from .tbl. */
readonly nameZh?: string | undefined
/** Final stats: base plus every rolled modifier. */
readonly stats: Readonly<Record<string, number>>
/** Inventory footprint. */

View File

@ -8,6 +8,7 @@
*/
import { resolveSkillInfo } from './skills-data.ts'
import { CANONICAL_ENG_TBL_DICT, CANONICAL_CHI_TBL_DICT } from '../data/canonical-tbl-strings.ts'
export type TooltipLocale = 'en' | 'zh' | 'bilingual'
@ -2839,6 +2840,131 @@ export const RUNE_INFO_ZH: Record<string, { num: number; nameZh: string; nameEn:
r33: { num: 33, nameZh: '萨德', nameEn: 'Zod Rune', lvl: 69, weaponZh: '无法破坏', armorZh: '无法破坏' },
}
/**
* Maps internal .txt item names (where misc.txt/weapons.txt/armor.txt `name` differs from `namestr` in .tbl)
* to their canonical `namestr` key in string.tbl / expansionstring.tbl / patchstring.tbl.
*/
const TXT_NAME_TO_TBL_KEY: Readonly<Record<string, string>> = {
'Lesser Healing Potion': 'hp1',
'Lesser Mana Potion': 'mp1',
'Strong Healing Potion': 'hp4',
'Strong Mana Potion': 'mp4',
'Rejuv Potion': 'rvs',
'Full Rejuv Potion': 'rvl',
'Town Portal Book': 'tbk',
'Identify Book': 'ibk',
'Town Portal Scroll': 'tsc',
'Identify Scroll': 'isc',
'Scroll of Horadric Quest Info': 'tr1',
'Bark Scroll': 'bks',
'deciphered Bark Scroll': 'bkd',
'viper amulet': 'vip',
'Staff of the Kings': 'msf',
'Mephisto SoulStone': 'mss',
'KhalimEye': 'qey',
'KhalimHeart': 'qhr',
'KhalimBrain': 'qbr',
'KhalimFlail': 'qf1',
'SuperKhalimFlail': 'qf2',
'decoy dagger': 'd33',
'Gidbinn': 'g33',
}
/**
* Resolves a base item's canonical 1.13c English display name via `.tbl` (`CANONICAL_ENG_TBL_DICT`).
*/
export function resolveBaseItemNameEn(baseName: string, baseCode?: string): string {
const trimmed = (baseName || '').trim()
const code = (baseCode || '').trim()
if (code && CANONICAL_ENG_TBL_DICT[code] && (!trimmed || trimmed === code || trimmed in TXT_NAME_TO_TBL_KEY)) {
return CANONICAL_ENG_TBL_DICT[code].trim()
}
if (!trimmed) return ''
const mappedKey = TXT_NAME_TO_TBL_KEY[trimmed]
if (mappedKey && CANONICAL_ENG_TBL_DICT[mappedKey]) {
return CANONICAL_ENG_TBL_DICT[mappedKey].trim()
}
if (CANONICAL_ENG_TBL_DICT[trimmed]) {
return CANONICAL_ENG_TBL_DICT[trimmed].trim()
}
return trimmed
}
/**
* Resolves a Rare item's canonical 1.13c English name via `.tbl` (`RarePrefix.txt` + `RareSuffix.txt` keys
* such as `GhoulRI` -> `Ghoul`, `barRI` -> `Bar`, `bite` -> `Bite`).
*/
export function resolveRareNameEn(rareName: string, prefixKey?: string, suffixKey?: string): string {
if (prefixKey && suffixKey) {
const preEn = (CANONICAL_ENG_TBL_DICT[prefixKey.trim()] ?? prefixKey.trim()).trim()
const sufEn = (CANONICAL_ENG_TBL_DICT[suffixKey.trim()] ?? suffixKey.trim()).trim()
return `${preEn} ${sufEn}`
}
if (!rareName) return ''
const parts = rareName.trim().split(/\s+/)
if (parts.length >= 2) {
const pre = parts[0]
const suf = parts.slice(1).join(' ')
const preEn = (CANONICAL_ENG_TBL_DICT[pre] ?? pre).trim()
const sufEn = (CANONICAL_ENG_TBL_DICT[suf] ?? suf).trim()
return `${preEn} ${sufEn}`
}
return (CANONICAL_ENG_TBL_DICT[rareName.trim()] ?? rareName.trim()).trim()
}
/**
* Resolves a Magic item's canonical 1.13c English name via `.tbl` (`MagicPrefix.txt` + `MagicSuffix.txt` keys
* such as `Mechanist's` -> `Mechanic's`, `of the Colosuss` -> `of the Colossus`, `of Warmth` -> `of Thawing`).
*/
export function resolveMagicNameEn(
magicName: string,
baseName?: string,
prefixKey?: string,
suffixKey?: string,
baseCode?: string,
): string {
if (!magicName && !baseName) return ''
let pre = prefixKey?.trim() || ''
let suf = suffixKey?.trim() || ''
let base = baseName?.trim() || ''
if (!pre && !suf && magicName) {
if (magicName.includes(' of ')) {
const ofParts = magicName.split(' of ')
suf = 'of ' + ofParts.slice(1).join(' of ').trim()
const left = ofParts[0].trim()
if (base && left.endsWith(base)) {
pre = left.slice(0, left.length - base.length).trim()
} else {
const words = left.split(/\s+/)
if (words.length > 1) {
pre = words[0]
base = words.slice(1).join(' ')
} else {
base = left
}
}
} else if (base && magicName.endsWith(base)) {
pre = magicName.slice(0, magicName.length - base.length).trim()
}
}
const resolvedBase = resolveBaseItemNameEn(base || magicName, baseCode)
const resolvedPre = pre ? (CANONICAL_ENG_TBL_DICT[pre] ?? pre).trim() : ''
const resolvedSuf = suf ? (CANONICAL_ENG_TBL_DICT[suf] ?? suf).trim() : ''
if (resolvedPre && resolvedSuf) {
return `${resolvedPre} ${resolvedBase} ${resolvedSuf}`
}
if (resolvedPre) {
return `${resolvedPre} ${resolvedBase}`
}
if (resolvedSuf) {
return `${resolvedBase} ${resolvedSuf}`
}
return magicName ? resolveBaseItemNameEn(magicName, baseCode) : resolvedBase
}
/**
* Translates an item name (base, unique, set item, set name, rune) into Chinese.
*/
@ -2847,6 +2973,14 @@ export function translateItemName(enName: string): string {
const trimmed = enName.trim()
if (ITEM_NAMES_ZH[trimmed]) return ITEM_NAMES_ZH[trimmed]
const tblEn = resolveBaseItemNameEn(trimmed)
if (tblEn !== trimmed && ITEM_NAMES_ZH[tblEn]) {
return ITEM_NAMES_ZH[tblEn]
}
if (CANONICAL_CHI_TBL_DICT[trimmed]) {
return CANONICAL_CHI_TBL_DICT[trimmed].trim()
}
// Superior prefix
if (trimmed.startsWith('Superior ')) {
const sub = trimmed.slice('Superior '.length).trim()
@ -2892,7 +3026,7 @@ export function translateSkillName(enSkill: string): string {
}
/**
* Translates Rare Item Name (e.g. "Shadow Brow" -> "阴影之额骨" or "野兽之刃").
* Translates Rare Item Name (e.g. "Shadow Brow" -> "阴影之额头", "GhoulRI barRI" -> "食尸鬼之棒").
*/
export function translateRareName(rareName: string): string {
if (!rareName) return ''
@ -2900,15 +3034,29 @@ export function translateRareName(rareName: string): string {
if (parts.length >= 2) {
const pre = parts[0]
const suf = parts.slice(1).join(' ')
const preZh = RARE_PREFIX_ZH[pre] ?? RARE_PREFIX_ZH[pre.toLowerCase()] ?? pre
const sufZh = RARE_SUFFIX_ZH[suf] ?? RARE_SUFFIX_ZH[suf.toLowerCase()] ?? suf
const preEn = (CANONICAL_ENG_TBL_DICT[pre] ?? pre).trim()
const sufEn = (CANONICAL_ENG_TBL_DICT[suf] ?? suf).trim()
const preZh =
RARE_PREFIX_ZH[pre] ??
RARE_PREFIX_ZH[preEn] ??
RARE_PREFIX_ZH[pre.toLowerCase()] ??
RARE_PREFIX_ZH[preEn.toLowerCase()] ??
CANONICAL_CHI_TBL_DICT[pre]?.trim() ??
preEn
const sufZh =
RARE_SUFFIX_ZH[suf] ??
RARE_SUFFIX_ZH[sufEn] ??
RARE_SUFFIX_ZH[suf.toLowerCase()] ??
RARE_SUFFIX_ZH[sufEn.toLowerCase()] ??
CANONICAL_CHI_TBL_DICT[suf]?.trim() ??
sufEn
return preZh + sufZh
}
return rareName
}
/**
* Translates Magic Item Name (e.g. "Sturdy Leather Armor of Life" -> "结实之皮甲生命的").
* Translates Magic Item Name (e.g. "Sturdy Leather Armor of Life" -> "结实之生命之皮甲").
*/
export function translateMagicName(magicName: string, baseName?: string): string {
if (!magicName) return ''
@ -2943,9 +3091,26 @@ export function translateMagicName(magicName: string, baseName?: string): string
}
}
const preZh = pre ? (MAGIC_PREFIX_ZH[pre] ?? (pre.endsWith('之') ? pre : pre + '之')) : ''
const preEn = pre ? (CANONICAL_ENG_TBL_DICT[pre] ?? pre).trim() : ''
const sufEn = suf ? (CANONICAL_ENG_TBL_DICT[suf] ?? suf).trim() : ''
const preZh = pre
? (MAGIC_PREFIX_ZH[pre] ??
MAGIC_PREFIX_ZH[preEn] ??
CANONICAL_CHI_TBL_DICT[pre]?.trim() ??
(preEn.endsWith('之') ? preEn : preEn + '之'))
: ''
const sufKey = suf.startsWith('of ') ? suf : 'of ' + suf
const sufZh = suf ? (MAGIC_SUFFIX_ZH[suf] ?? MAGIC_SUFFIX_ZH[sufKey] ?? MAGIC_SUFFIX_ZH[suf.replace(/^of\s+/, '')] ?? (suf.endsWith('之') ? suf : suf + '之')) : ''
const sufEnKey = sufEn.startsWith('of ') ? sufEn : 'of ' + sufEn
const sufZh = suf
? (MAGIC_SUFFIX_ZH[suf] ??
MAGIC_SUFFIX_ZH[sufKey] ??
MAGIC_SUFFIX_ZH[sufEn] ??
MAGIC_SUFFIX_ZH[sufEnKey] ??
MAGIC_SUFFIX_ZH[suf.replace(/^of\s+/, '')] ??
MAGIC_SUFFIX_ZH[sufEn.replace(/^of\s+/, '')] ??
CANONICAL_CHI_TBL_DICT[suf]?.trim() ??
(sufEn.endsWith('之') ? sufEn : sufEn + '之'))
: ''
const baseZh = translateItemName(base)
if (preZh && sufZh) {

View File

@ -13,6 +13,7 @@
*/
import type { GroundItemEntity } from '../game/ground-items.ts'
import { translateItemName, translateRareName, translateMagicName } from '../game/tooltip-i18n.ts'
/**
* Authentic 1.13c Diablo II quality color codes for ground item labels.
@ -111,7 +112,25 @@ export function formatGroundItemLabelText(item: GroundItemEntity): string {
if (item.isGold) {
return `${item.amount} 金币`
}
return item.nameZh || item.name || '物品'
if (item.nameZh) {
return item.nameZh
}
const srcNameZh = (item.item as any)?.nameZh ?? (item.item as any)?.item?.nameZh
if (typeof srcNameZh === 'string' && srcNameZh.trim() !== '') {
return srcNameZh
}
if (item.name) {
const q = (item.quality || 'normal').toLowerCase()
if (q === 'rare') {
return translateRareName(item.name)
}
if (q === 'magic') {
const baseName = (item.item as any)?.base?.name ?? (item.item as any)?.item?.base?.name
return translateMagicName(item.name, baseName)
}
return translateItemName(item.name)
}
return '物品'
}
/**

View File

@ -588,7 +588,8 @@ export const STARTER_EQUIPPED_GEAR: Readonly<Partial<Record<EquipSlotId, UiInven
{ text: '+3 法师技能等级', color: 'blue' },
{ text: '+30% 快速施法速度 (FCR)', color: 'blue' },
{ text: '+20% 增强防御', color: 'blue' },
{ text: '+20 体力 / +20 精力', color: 'blue' },
{ text: '+20 体力', color: 'blue' },
{ text: '+20 精力', color: 'blue' },
{ text: '所有抗性 +20', color: 'blue' },
{ text: '+5 每次击杀获得法力', color: 'blue' },
{ text: '50% 更佳机会取得魔法装备', color: 'blue' },
@ -612,7 +613,9 @@ export const STARTER_EQUIPPED_GEAR: Readonly<Partial<Record<EquipSlotId, UiInven
reqStr: 84,
stats: [
{ text: '+400 防御', color: 'blue' },
{ text: '抗寒 +40% / 抗电 +40% / 抗火 +40%', color: 'blue' },
{ text: '抗寒 +40%', color: 'blue' },
{ text: '抗电 +40%', color: 'blue' },
{ text: '抗火 +40%', color: 'blue' },
{ text: '魔法伤害减少 15', color: 'blue' },
{ text: '88% 更佳机会取得魔法装备', color: 'blue' },
],
@ -654,7 +657,9 @@ export const STARTER_EQUIPPED_GEAR: Readonly<Partial<Record<EquipSlotId, UiInven
{ text: '+250 对远距离攻击的防御', color: 'blue' },
{ text: '+22 体力', color: 'blue' },
{ text: '+112 法力', color: 'blue' },
{ text: '抗寒 +35% / 抗电 +35% / 抗毒 +35%', color: 'blue' },
{ text: '抗寒 +35%', color: 'blue' },
{ text: '抗电 +35%', color: 'blue' },
{ text: '抗毒 +35%', color: 'blue' },
{ text: '+8 魔法吸收', color: 'blue' },
],
},
@ -761,7 +766,8 @@ export const STARTER_EQUIPPED_GEAR: Readonly<Partial<Record<EquipSlotId, UiInven
reqStr: 95,
stats: [
{ text: '+25% 高速跑步/行走', color: 'blue' },
{ text: '+10 力量 / +10 体力', color: 'blue' },
{ text: '+10 力量', color: 'blue' },
{ text: '+10 体力', color: 'blue' },
{ text: '增加 15-25 伤害', color: 'blue' },
{ text: '+190% 增强防御', color: 'blue' },
{ text: '减慢耐力消耗 40%', color: 'blue' },

View File

@ -373,7 +373,9 @@ export function itemToUiInventoryItem(
// Use pre-localized overrides if present
if ((rawItem as any).nameZh) {
nameZh = (rawItem as any).nameZh
const rawZh = String((rawItem as any).nameZh)
const itemEn = rawItem.name || baseEn
nameZh = rawZh.includes('(') || rawZh === itemEn ? rawZh : `${rawZh} (${itemEn})`
}
if ((rawItem as any).baseNameZh) {
baseNameZh = (rawItem as any).baseNameZh

View File

@ -0,0 +1,272 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
CANONICAL_ENG_TBL_DICT,
CANONICAL_CHI_TBL_DICT,
CANONICAL_ISC_DESC_TABLE,
} from '../src/data/canonical-tbl-strings.ts'
import {
resolveAffixName,
resolveRareName,
resolveGemName,
resolveLowQualityName,
getEmbeddedItemNameChains,
lookupCanonicalTbl,
} from '../src/game/item-names.ts'
import {
resolveBaseItemNameEn,
resolveRareNameEn,
resolveMagicNameEn,
translateItemName,
translateRareName,
translateMagicName,
setTooltipLocale,
} from '../src/game/tooltip-i18n.ts'
import {
formatItemTooltip,
formatStatByIscDescFunc,
} from '../src/game/item-tooltip.ts'
import { createDroppedItem } from '../src/game/drop-pipeline.ts'
import { ItemQuality, type ItemBase } from '../src/game/items.ts'
import type { GroundItemEntity } from '../src/game/ground-items.ts'
import { formatGroundItemLabelText } from '../src/ui/ground-labels.ts'
import { STARTER_EQUIPPED_GEAR } from '../src/ui/inventory.ts'
import {
CLASS_EXCLUSIVE_SETS,
STARTER_STASH_ITEMS,
} from '../src/game/class-starter-profiles.ts'
describe('Issue #480: Canonical 1.13c .tbl & ItemStatCost.txt Parity across Inventory, Stash, and Ground', () => {
beforeEach(() => {
setTooltipLocale('en')
})
describe('1. Canonical .tbl String Dictionaries & ItemStatCost.txt Table', () => {
it('packs authentic 1.13c ENG and CHI .tbl dictionaries and ISC descfunc metadata', () => {
expect(Object.keys(CANONICAL_ENG_TBL_DICT).length).toBeGreaterThan(2000)
expect(Object.keys(CANONICAL_CHI_TBL_DICT).length).toBeGreaterThan(1000)
expect(Object.keys(CANONICAL_ISC_DESC_TABLE).length).toBeGreaterThan(150)
// Base item code hp1 in Misc.txt has internal name "Lesser Healing Potion",
// but string.tbl maps "hp1" -> "Minor Healing Potion" / "輕微治療葯劑"
expect(lookupCanonicalTbl('hp1', 'en')).toBe('Minor Healing Potion')
expect(lookupCanonicalTbl('hp1', 'zh')).toBe('輕微治療葯劑')
// RarePrefix.txt / RareSuffix.txt internal keys with RI or lowercase
expect(lookupCanonicalTbl('GhoulRI', 'en')).toBe('Ghoul')
expect(lookupCanonicalTbl('barRI', 'en')).toBe('Bar')
expect(lookupCanonicalTbl('bite', 'en')).toBe('Bite')
// MagicPrefix.txt / MagicSuffix.txt internal keys vs .tbl display strings
expect(lookupCanonicalTbl("Mechanist's", 'en')).toBe("Mechanic's")
expect(lookupCanonicalTbl('of the Colosuss', 'en')).toBe('of the Colossus')
expect(lookupCanonicalTbl('of Warmth', 'en')).toBe('of Thawing')
})
it('resolves affix, rare, gem, and low-quality names through .tbl in item-names.ts', () => {
const chains = getEmbeddedItemNameChains()
expect(resolveAffixName({ name: "Mechanist's" }, chains)).toBe("Mechanic's")
expect(resolveRareName('GhoulRI', chains)).toBe('食屍鬼之')
expect(resolveRareName('barRI', chains)).toBe('酒吧')
expect(resolveGemName({ name: 'Perfect Amethyst', code: 'gpv' }, chains)).toBe('完美的紫寶石')
expect(resolveLowQualityName('Low Quality', chains)).toBe('劣質的')
})
it('resolves English and Chinese base, rare, and magic names via .tbl in tooltip-i18n.ts', () => {
expect(resolveBaseItemNameEn('Lesser Healing Potion', 'hp1')).toBe('Minor Healing Potion')
expect(translateItemName('Lesser Healing Potion')).toBe('轻微治疗药剂')
expect(resolveRareNameEn('GhoulRI barRI', 'GhoulRI', 'barRI')).toBe('Ghoul Bar')
expect(resolveRareNameEn('Storm bite', 'Storm', 'bite')).toBe('Storm Bite')
expect(translateRareName('GhoulRI barRI')).toBe('食尸鬼之酒吧')
expect(resolveMagicNameEn("Mechanist's Cap of the Colosuss", 'Cap', "Mechanist's", 'of the Colosuss', 'cap')).toBe(
"Mechanic's Cap of the Colossus",
)
expect(translateMagicName("Mechanist's Cap of Warmth", 'Cap')).toBe('机械师之温暖之帽子')
})
})
describe('2. ItemStatCost.txt descfunc (1..28) & descval (0..2) Engine', () => {
it('formats discrete stats via CANONICAL_ISC_DESC_TABLE and .tbl keys', () => {
// strength: descFunc=1, descVal=1, descStrPos=ModStr1a ("to Strength" / "力量")
expect(formatStatByIscDescFunc('strength', 15, 15, 15, undefined, 'en')).toBe('+15 to Strength')
expect(formatStatByIscDescFunc('strength', 15, 15, 15, undefined, 'zh')).toBe('+15 力量')
// item_fastercastrate: descFunc=4, descVal=1, descStrPos=ModStr1c ("Faster Cast Rate" / "高速施展速度")
expect(formatStatByIscDescFunc('item_fastercastrate', 20, 20, 20, undefined, 'en')).toBe('+20% Faster Cast Rate')
expect(formatStatByIscDescFunc('item_fastercastrate', 20, 20, 20, undefined, 'zh')).toBe('+20% 高速施展速度')
// fireresist: descFunc=4, descVal=2, descStrPos=strModFireResist ("Fire Resist" / "抗火")
expect(formatStatByIscDescFunc('fireresist', 40, 40, 40, undefined, 'en')).toBe('Fire Resist +40%')
expect(formatStatByIscDescFunc('fireresist', 40, 40, 40, undefined, 'zh')).toBe('抗火 +40%')
// item_Cannotbefrozen: descFunc=3, descVal=0, descStrPos=ModStr2z ("Cannot Be Frozen" / "無法冰凍")
expect(formatStatByIscDescFunc('item_cannotbefrozen', 1, 1, 1, undefined, 'en')).toBe('Cannot Be Frozen')
expect(formatStatByIscDescFunc('item_cannotbefrozen', 1, 1, 1, undefined, 'zh')).toBe('無法冰凍')
// item_magicbonus: descFunc=7, descVal=1, descStrPos=ModStr1x ("Better Chance of Getting Magic Items")
expect(formatStatByIscDescFunc('item_magicbonus', 50, 50, 50, undefined, 'en')).toBe(
'50% Better Chance of Getting Magic Items',
)
})
})
describe('3. Ground Items (地面掉落物品): createDroppedItem & formatGroundItemLabelText', () => {
const hp1Base: ItemBase = {
id: 'hp1',
name: 'Lesser Healing Potion',
kind: 'misc',
invWidth: 1,
invHeight: 1,
maxStack: 1,
value: 25,
damage: 0,
defense: 0,
tags: ['hpot'],
level: 1,
}
const capBase: ItemBase = {
id: 'cap',
name: 'Cap',
kind: 'armor',
invWidth: 2,
invHeight: 2,
maxStack: 1,
value: 20,
damage: 0,
defense: 5,
tags: ['helm'],
level: 1,
}
it('resolves dropped item .name and .nameZh through .tbl and renders ground labels accurately', () => {
// hp1 has internal Misc.txt name "Lesser Healing Potion", must resolve to "Minor Healing Potion" / "轻微治疗药剂"
const hp1Drop = createDroppedItem(hp1Base, 'normal', { ilvl: 1, dwInitSeed: 480 })
expect(hp1Drop.name).toBe('Minor Healing Potion')
expect(hp1Drop.nameZh).toBe('轻微治疗药剂')
const makeGroundEntity = (partial: Partial<GroundItemEntity>): GroundItemEntity => ({
id: 'g-1',
item: hp1Drop,
name: hp1Drop.name,
nameZh: hp1Drop.nameZh ?? '',
quality: 'normal',
isGold: false,
amount: 1,
invWidth: 1,
invHeight: 1,
dropTime: 0,
x: 0,
y: 0,
cellX: 0,
cellY: 0,
sparklePhase: 0,
...partial,
})
expect(formatGroundItemLabelText(makeGroundEntity({}))).toBe('轻微治疗药剂')
// Ground item without pre-populated nameZh resolves via .tbl in formatGroundItemLabelText
expect(
formatGroundItemLabelText(
makeGroundEntity({
name: 'GhoulRI barRI',
nameZh: '',
quality: 'rare',
item: undefined,
}),
),
).toBe('食尸鬼之酒吧')
expect(
formatGroundItemLabelText(
makeGroundEntity({
name: "Mechanist's Cap of the Colosuss",
nameZh: '',
quality: 'magic',
item: undefined,
}),
),
).toBe('机械师之巨神之帽子')
})
it('formats tooltip titles for Rare and Magic items with internal .txt keys via .tbl', () => {
const capDrop = createDroppedItem(capBase, 'rare', { ilvl: 20, dwInitSeed: 480 })
const rareItem = {
...capDrop,
quality: ItemQuality.RARE,
rarity: 'rare' as const,
name: 'GhoulRI barRI',
rolledRareAffixes: {
name: 'GhoulRI barRI',
rarePrefix: { name: 'GhoulRI' },
rareSuffix: { name: 'barRI' },
affixes: [],
} as any,
}
const enTip = formatItemTooltip(rareItem, 'en')
expect(enTip.title).toBe('Ghoul Bar')
const zhTip = formatItemTooltip(rareItem, 'zh')
expect(zhTip.title).toBe('食尸鬼之酒吧')
})
})
describe('4. Inventory & Equipped Gear (背包与装备栏): 1-Stat-Per-Line .tbl Compliance', () => {
it('ensures STARTER_EQUIPPED_GEAR has no non-.tbl "/" merged multi-stat lines', () => {
for (const [slot, item] of Object.entries(STARTER_EQUIPPED_GEAR)) {
if (!item) continue
for (const stat of item.stats) {
// "高速跑步/行走" is the single stat FRW name in Chinese, not a multi-stat merge
const withoutFrw = stat.text.replace(/高速跑步\/行走/g, '')
expect(withoutFrw, `Slot ${slot} (${item.name}) has merged stat line: ${stat.text}`).not.toContain(' / ')
}
}
})
it('ensures all 7 classes in CLASS_EXCLUSIVE_SETS have 1-stat-per-line .tbl stats and setBonuses', () => {
for (const [cls, gear] of Object.entries(CLASS_EXCLUSIVE_SETS)) {
for (const [slot, item] of Object.entries(gear)) {
if (!item) continue
for (const stat of item.stats) {
const withoutFrw = stat.text.replace(/高速跑步\/行走/g, '')
expect(withoutFrw, `${cls}.${slot} (${item.name}) has merged stat line: ${stat.text}`).not.toContain(' / ')
}
for (const bonus of item.setBonuses ?? []) {
expect(bonus.text, `${cls}.${slot} (${item.name}) has merged setBonus: ${bonus.text}`).not.toContain(' / ')
}
}
}
})
})
describe('5. Stash Items (储物箱): Canonical 4-Slot Socketable .tbl Format & Rare Jewel Parity', () => {
it('ensures all 24 socketables (12 Runes + 12 Gems/Skulls) in STARTER_STASH_ITEMS have canonical 5-line .tbl descriptions', () => {
const socketables = STARTER_STASH_ITEMS.filter(
(gp) => gp.item.id.startsWith('stash-r-') || gp.item.id.startsWith('stash-gem-'),
)
expect(socketables.length).toBe(24)
for (const gp of socketables) {
const lines = gp.item.stats.map((s) => s.text)
expect(lines.length, `${gp.item.id} (${gp.item.name}) should have 5 canonical socketable lines`).toBe(5)
expect(lines[0]).toBe('可镶嵌进有凹槽的装备')
expect(lines[1]).toMatch(/^武器: /)
expect(lines[2]).toMatch(/^防具: /)
expect(lines[3]).toMatch(/^头盔: /)
expect(lines[4]).toMatch(/^盾牌: /)
}
})
it('ensures stash-jew-rare (Doom Gyre) splits Strength and Dexterity into separate .tbl lines', () => {
const rareJewel = STARTER_STASH_ITEMS.find((gp) => gp.item.id === 'stash-jew-rare')
expect(rareJewel).toBeDefined()
const texts = rareJewel!.item.stats.map((s) => s.text)
expect(texts).toContain('+9 力量')
expect(texts).toContain('+9 敏捷')
for (const t of texts) {
expect(t).not.toContain(' / ')
}
})
})
})