fix(items): strictly align item names & stat descriptions with 1.13c .tbl across inventory, stash, and ground (fixes #480)
This commit is contained in:
parent
c09e237ff8
commit
b7ccc448b4
|
|
@ -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
|
|
@ -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' },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 '物品'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(' / ')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue