1888 lines
71 KiB
TypeScript
1888 lines
71 KiB
TypeScript
/**
|
|
* Diablo II v1.13c Drop Item to UiInventoryItem Bridge.
|
|
*
|
|
* Implements authoritative bridging from concrete `Item` (produced by drop pipeline)
|
|
* to `UiInventoryItem` (consumed by UI Inventory Paperdoll and Canvas rendering):
|
|
* - 1.13c invFile resolution hierarchy matching BAKED_UI_MANIFEST.itemRects 100%
|
|
* - BodyLoc1/BodyLoc2 equipment slot mapping with category inheritance
|
|
* - Bilingual item names (nameZh, baseNameZh) across all quality tiers
|
|
* - Quality tier normalization ('normal' | 'magic' | 'rare' | 'set' | 'unique' | 'craft' | 'rune')
|
|
* - Rich tooltip attributes & rolled property formatting with D2ColorCode lines
|
|
*/
|
|
|
|
import type { Item, ItemBase, RolledItemProp } from './items.ts'
|
|
import { ItemQuality } from './items.ts'
|
|
import type { DropTables } from './drop-tables.ts'
|
|
import { getEmbeddedDropTables } from './embedded-drop-tables.ts'
|
|
import { CANONICAL_RUNES_TABLE, type RunewordDefinition } from './runeword.ts'
|
|
import {
|
|
type UiInventoryItem,
|
|
type EquipSlotId,
|
|
type UiItemQuality,
|
|
} from './inventory-model.ts'
|
|
import { BAKED_UI_MANIFEST } from './baked-ui-meta.ts'
|
|
import {
|
|
formatItemTooltip,
|
|
CANONICAL_ISC_DESC_TABLE,
|
|
CANONICAL_ENG_TBL_DICT,
|
|
resolveBaseItemNameEn,
|
|
resolveRareNameEn,
|
|
} from './item-tooltip.ts'
|
|
import { translateItemName, translateRareName, translateMagicName } from '../i18n/tooltip-i18n.ts'
|
|
import { isRuneCode, getGemColorTransform, COLOR_CODES } from './gems-data.ts'
|
|
import { CANONICAL_AUTOMAGIC_TABLE } from './automagic.ts'
|
|
import type { D2ColorCode } from '../skills/skills-meta.ts'
|
|
import { isA } from './item-types.ts'
|
|
import { inferItemClassFromType } from './affixes.ts'
|
|
import { CHARACTER_CLASS_CODES, type CharacterClassCode } from '../units/classes.ts'
|
|
|
|
interface ItemStat {
|
|
readonly statId?: number
|
|
readonly id?: number
|
|
readonly param?: number
|
|
readonly value: number
|
|
}
|
|
|
|
type ItemData = Record<string, any> & {
|
|
readonly stats?: readonly ItemStat[]
|
|
readonly runewordStats?: readonly ItemStat[]
|
|
}
|
|
|
|
export { COLOR_CODES, getGemColorTransform }
|
|
|
|
const VALID_CLASS_CODES = new Set<string>(CHARACTER_CLASS_CODES)
|
|
let itemBridgeSeqCounter = 0
|
|
|
|
/**
|
|
* Ground Truth (`Weapons.txt` `1or2handed === 1` & `2handed === 1` in Diablo II v1.13c):
|
|
* The 18 two-handed swords (`type = swor`) that a Barbarian (`bar`) can wield in one hand
|
|
* (allowing dual-wielding or pairing with a shield) while all other classes must wield them two-handed.
|
|
*/
|
|
export const BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES: ReadonlySet<string> = new Set([
|
|
'2hs', 'clm', 'gis', 'bsw', 'flb', 'gsd',
|
|
'92h', '9cm', '9gs', '9b9', '9fb', '9gd',
|
|
'72h', '7cm', '7gs', '7b7', '7fb', '7gd',
|
|
])
|
|
|
|
/**
|
|
* Ground Truth (`Weapons.txt` `2handed === 1` & `1or2handed !== 1` in Diablo II v1.13c):
|
|
* All 119 strict two-handed weapons (2H axes, mauls, spears, polearms, staves, bows, crossbows,
|
|
* Amazon bows, Amazon spears) that require both hands for ALL classes including Barbarian.
|
|
*/
|
|
export const STRICT_TWO_HANDED_WEAPON_CODES: ReadonlySet<string> = new Set([
|
|
'lax', 'bax', 'btx', 'gax', 'gix', 'mau', 'gma', 'spr', 'tri', 'brn', 'spt', 'pik',
|
|
'bar', 'vou', 'scy', 'pax', 'hal', 'wsc', 'sst', 'lst', 'cst', 'bst', 'wst', 'sbw',
|
|
'hbw', 'lbw', 'cbw', 'sbb', 'lbb', 'swb', 'lwb', 'lxb', 'mxb', 'hxb', 'rxb', 'hst',
|
|
'msf', '9la', '9ba', '9bt', '9ga', '9gi', '9m9', '9gm', '9sr', '9tr', '9br', '9st',
|
|
'9p9', '9b7', '9vo', '9s8', '9pa', '9h9', '9wc', '8ss', '8ls', '8cs', '8bs', '8ws',
|
|
'8sb', '8hb', '8lb', '8cb', '8s8', '8l8', '8sw', '8lw', '8lx', '8mx', '8hx', '8rx',
|
|
'7la', '7ba', '7bt', '7ga', '7gi', '7m7', '7gm', '7sr', '7tr', '7br', '7st', '7p7',
|
|
'7o7', '7vo', '7s8', '7pa', '7h7', '7wc', '6ss', '6ls', '6cs', '6bs', '6ws', '6sb',
|
|
'6hb', '6lb', '6cb', '6s7', '6l7', '6sw', '6lw', '6lx', '6mx', '6hx', '6rx', 'am1',
|
|
'am2', 'am3', 'am4', 'am6', 'am7', 'am8', 'am9', 'amb', 'amc', 'amd', 'ame',
|
|
])
|
|
|
|
/**
|
|
* Ground Truth (`Weapons.txt` `2handed !== 1` in Diablo II v1.13c):
|
|
* All 169 one-handed weapons across normal, exceptional, elite, and class/quest tiers.
|
|
*/
|
|
export const CANONICAL_ONE_HANDED_WEAPON_CODES: ReadonlySet<string> = new Set([
|
|
'hax', 'axe', '2ax', 'mpi', 'wax', 'wnd', 'ywn', 'bwn', 'gwn', 'clb', 'scp', 'gsc',
|
|
'wsp', 'spc', 'mac', 'mst', 'fla', 'whm', 'ssd', 'scm', 'sbr', 'flc', 'crs', 'bsd',
|
|
'lsd', 'wsd', 'dgr', 'dir', 'kri', 'bld', 'tkf', 'tax', 'bkf', 'bal', 'jav', 'pil',
|
|
'ssp', 'glv', 'tsp', 'gps', 'ops', 'gpm', 'opm', 'gpl', 'opl', 'd33', 'g33', 'leg',
|
|
'hdm', 'hfh', '9ha', '9ax', '92a', '9mp', '9wa', '9wn', '9yw', '9bw', '9gw', '9cl',
|
|
'9sc', '9qs', '9ws', '9sp', '9ma', '9mt', '9fl', '9wh', '9ss', '9sm', '9sb', '9fc',
|
|
'9cr', '9bs', '9ls', '9wd', '9dg', '9di', '9kr', '9bl', '9tk', '9ta', '9bk', '9b8',
|
|
'9ja', '9pi', '9s9', '9gl', '9ts', 'qf1', 'qf2', 'ktr', 'wrb', 'axf', 'ces', 'clw',
|
|
'btl', 'skr', '9ar', '9wb', '9xf', '9cs', '9lw', '9tw', '9qr', '7ar', '7wb', '7xf',
|
|
'7cs', '7lw', '7tw', '7qr', '7ha', '7ax', '72a', '7mp', '7wa', '7wn', '7yw', '7bw',
|
|
'7gw', '7cl', '7sc', '7qs', '7ws', '7sp', '7ma', '7mt', '7fl', '7wh', '7ss', '7sm',
|
|
'7sb', '7fc', '7cr', '7bs', '7ls', '7wd', '7dg', '7di', '7kr', '7bl', '7tk', '7ta',
|
|
'7bk', '7b8', '7ja', '7pi', '7s7', '7gl', '7ts', 'ob1', 'ob2', 'ob3', 'ob4', 'ob5',
|
|
'am5', 'ob6', 'ob7', 'ob8', 'ob9', 'oba', 'ama', 'obb', 'obc', 'obd', 'obe', 'obf',
|
|
'amf',
|
|
])
|
|
|
|
/**
|
|
* Ground Truth (`Armor.txt` `shie` / `ashd` / `head` + `Misc.txt` `bowq` / `xboq` in Diablo II v1.13c):
|
|
* All 54 shield, paladin shield, necromancer shrunken head, and quiver codes.
|
|
*/
|
|
export const CANONICAL_SHIELD_AND_OFFHAND_CODES: ReadonlySet<string> = new Set([
|
|
'buc', 'sml', 'lrg', 'kit', 'tow', 'gts', 'bsh', 'spk',
|
|
'xuc', 'xml', 'xrg', 'xit', 'xow', 'xts', 'xsh', 'xpk',
|
|
'uuc', 'uml', 'urg', 'uit', 'uow', 'uts', 'ush', 'upk',
|
|
'pa1', 'pa2', 'pa3', 'pa4', 'pa5', 'pa6', 'pa7', 'pa8', 'pa9', 'paa', 'pab', 'pac', 'pad', 'pae', 'paf',
|
|
'ne1', 'ne2', 'ne3', 'ne4', 'ne5', 'ne6', 'ne7', 'ne8', 'ne9', 'nea', 'neb', 'neg', 'ned', 'nee', 'nef',
|
|
'aqv', 'cqv',
|
|
])
|
|
|
|
/**
|
|
* Ground Truth (`ItemTypes.txt` `Class` column + `Weapons.txt` / `Armor.txt` in Diablo II v1.13c):
|
|
* All 111 class-specific item codes mapped to their required playable class.
|
|
*/
|
|
export const CANONICAL_CLASS_SPECIFIC_ITEM_CODES: Readonly<Record<string, CharacterClassCode>> = {
|
|
ktr: 'ass', wrb: 'ass', axf: 'ass', ces: 'ass', clw: 'ass', btl: 'ass', skr: 'ass',
|
|
'9ar': 'ass', '9wb': 'ass', '9xf': 'ass', '9cs': 'ass', '9lw': 'ass', '9tw': 'ass', '9qr': 'ass',
|
|
'7ar': 'ass', '7wb': 'ass', '7xf': 'ass', '7cs': 'ass', '7lw': 'ass', '7tw': 'ass', '7qr': 'ass',
|
|
ob1: 'sor', ob2: 'sor', ob3: 'sor', ob4: 'sor', ob5: 'sor',
|
|
ob6: 'sor', ob7: 'sor', ob8: 'sor', ob9: 'sor', oba: 'sor',
|
|
obb: 'sor', obc: 'sor', obd: 'sor', obe: 'sor', obf: 'sor',
|
|
am1: 'ama', am2: 'ama', am3: 'ama', am4: 'ama', am5: 'ama',
|
|
am6: 'ama', am7: 'ama', am8: 'ama', am9: 'ama', ama: 'ama',
|
|
amb: 'ama', amc: 'ama', amd: 'ama', ame: 'ama', amf: 'ama',
|
|
dr1: 'dru', dr2: 'dru', dr3: 'dru', dr4: 'dru', dr5: 'dru',
|
|
dr6: 'dru', dr7: 'dru', dr8: 'dru', dr9: 'dru', dra: 'dru',
|
|
drb: 'dru', drc: 'dru', drd: 'dru', dre: 'dru', drf: 'dru',
|
|
ba1: 'bar', ba2: 'bar', ba3: 'bar', ba4: 'bar', ba5: 'bar',
|
|
ba6: 'bar', ba7: 'bar', ba8: 'bar', ba9: 'bar', baa: 'bar',
|
|
bab: 'bar', bac: 'bar', bad: 'bar', bae: 'bar', baf: 'bar',
|
|
pa1: 'pal', pa2: 'pal', pa3: 'pal', pa4: 'pal', pa5: 'pal',
|
|
pa6: 'pal', pa7: 'pal', pa8: 'pal', pa9: 'pal', paa: 'pal',
|
|
pab: 'pal', pac: 'pal', pad: 'pal', pae: 'pal', paf: 'pal',
|
|
ne1: 'nec', ne2: 'nec', ne3: 'nec', ne4: 'nec', ne5: 'nec',
|
|
ne6: 'nec', ne7: 'nec', ne8: 'nec', ne9: 'nec', nea: 'nec',
|
|
neb: 'nec', neg: 'nec', ned: 'nec', nee: 'nec', nef: 'nec',
|
|
}
|
|
|
|
/**
|
|
* Resolves the effective class restriction (`reqClass`) for a UI item, checking explicit
|
|
* `item.reqClass` first and falling back to `CANONICAL_CLASS_SPECIFIC_ITEM_CODES[code]`.
|
|
*/
|
|
export function getEffectiveItemReqClass(item: {
|
|
readonly reqClass?: CharacterClassCode | undefined
|
|
readonly code?: string | undefined
|
|
}): CharacterClassCode | undefined {
|
|
if (item.reqClass !== undefined && VALID_CLASS_CODES.has(item.reqClass)) {
|
|
return item.reqClass
|
|
}
|
|
const code = (item.code ?? '').trim().toLowerCase()
|
|
return code ? CANONICAL_CLASS_SPECIFIC_ITEM_CODES[code] : undefined
|
|
}
|
|
|
|
/**
|
|
* Ground Truth (D2Common.dll 1.13c @ 0x6fd74280 `ITEMS_GetClassOfClassSpecificItem` #10202):
|
|
* Resolves the required playable class (`'ama' | 'sor' | 'nec' | 'pal' | 'bar' | 'dru' | 'ass'`)
|
|
* for class-specific items from `ItemTypes.txt` `Class` column and type hierarchy (`Equiv1`/`Equiv2`),
|
|
* or `undefined` if any class can equip/use the item.
|
|
*/
|
|
export function resolveItemRequiredClass(
|
|
base: {
|
|
readonly id?: string | undefined
|
|
readonly code?: string | undefined
|
|
readonly type?: string | undefined
|
|
readonly type2?: string | undefined
|
|
readonly tags?: readonly string[] | undefined
|
|
readonly class?: string | undefined
|
|
readonly reqClass?: string | undefined
|
|
readonly itemClass?: string | undefined
|
|
},
|
|
rawItem?: {
|
|
readonly code?: string | undefined
|
|
readonly reqClass?: string | undefined
|
|
readonly itemClass?: string | undefined
|
|
},
|
|
dropTables?: DropTables,
|
|
): CharacterClassCode | undefined {
|
|
const explicit =
|
|
rawItem?.reqClass ?? rawItem?.itemClass ?? base.reqClass ?? base.itemClass ?? base.class
|
|
if (typeof explicit === 'string') {
|
|
const norm = explicit.trim().toLowerCase()
|
|
if (VALID_CLASS_CODES.has(norm)) return norm as CharacterClassCode
|
|
}
|
|
|
|
const isAPred = (type: string, target: string): boolean => {
|
|
const t = type.trim().toLowerCase()
|
|
if (!t) return false
|
|
if (t === target) return true
|
|
if (dropTables?.isA) {
|
|
try {
|
|
if (dropTables.isA(t, target)) return true
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
if (dropTables?.itemTypes) {
|
|
try {
|
|
if (isA(t, target, dropTables.itemTypes)) return true
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
const candidateTypes: string[] = []
|
|
if (base.type) candidateTypes.push(base.type)
|
|
if (base.type2) candidateTypes.push(base.type2)
|
|
if (base.tags) candidateTypes.push(...base.tags)
|
|
|
|
for (const rawType of candidateTypes) {
|
|
const typeCode = rawType.trim().toLowerCase()
|
|
if (!typeCode) continue
|
|
if (dropTables?.itemTypes) {
|
|
const def = dropTables.itemTypes.byCode.get(typeCode)
|
|
const defClass = def?.class?.trim().toLowerCase()
|
|
if (defClass && VALID_CLASS_CODES.has(defClass)) {
|
|
return defClass as CharacterClassCode
|
|
}
|
|
}
|
|
const inferred = inferItemClassFromType(typeCode, isAPred)
|
|
if (inferred && VALID_CLASS_CODES.has(inferred)) {
|
|
return inferred as CharacterClassCode
|
|
}
|
|
}
|
|
|
|
const rawCode = (rawItem?.code ?? base.code ?? base.id ?? '').trim().toLowerCase()
|
|
if (rawCode && CANONICAL_CLASS_SPECIFIC_ITEM_CODES[rawCode]) {
|
|
return CANONICAL_CLASS_SPECIFIC_ITEM_CODES[rawCode]
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Resolves the allowable paperdoll equipment slot(s) for an item base based on
|
|
* Diablo II v1.13c `ItemTypes.txt` body locations and category inheritance.
|
|
*/
|
|
export function resolveAllowedSlots(
|
|
base: {
|
|
readonly id?: string | undefined
|
|
readonly type?: string | undefined
|
|
readonly kind?: string | undefined
|
|
readonly tags?: readonly string[] | undefined
|
|
readonly twoHanded?: boolean | undefined
|
|
readonly oneOrTwoHanded?: boolean | undefined
|
|
},
|
|
dropTables?: DropTables,
|
|
): readonly EquipSlotId[] {
|
|
const code = (base.id ?? '').trim().toLowerCase()
|
|
const typeCode = (base.type ?? '').trim().toLowerCase()
|
|
const tags = base.tags ?? []
|
|
|
|
// Check ItemTypes definition if dropTables is provided
|
|
if (dropTables?.itemTypes && typeCode) {
|
|
const def = dropTables.itemTypes.byCode.get(typeCode)
|
|
if (def && !def.body) {
|
|
return []
|
|
}
|
|
if (def && def.bodyLoc1 && def.bodyLoc1 === def.bodyLoc2) {
|
|
switch (def.bodyLoc1) {
|
|
case 'head': return ['helm']
|
|
case 'neck': return ['amulet']
|
|
case 'tors': return ['armor']
|
|
case 'glov': return ['gloves']
|
|
case 'feet': return ['boots']
|
|
case 'belt': return ['belt']
|
|
}
|
|
}
|
|
if (def && def.bodyLoc1 === 'rrin' && def.bodyLoc2 === 'lrin') {
|
|
return ['ring1', 'ring2']
|
|
}
|
|
}
|
|
|
|
const isAPred = (target: string): boolean => {
|
|
if (!typeCode) return false
|
|
if (typeCode === target) return true
|
|
if (dropTables?.isA) {
|
|
try {
|
|
if (dropTables.isA(typeCode, target)) return true
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
if (dropTables?.itemTypes) {
|
|
try {
|
|
if (isA(typeCode, target, dropTables.itemTypes)) return true
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Helm
|
|
if (
|
|
tags.some(t => ['helm', 'phlm', 'circ', 'pelt'].includes(t)) ||
|
|
['helm', 'circ', 'phlm', 'pelt', 'cap', 'hlm', 'skp', 'ghm', 'crn', 'msk'].includes(typeCode) ||
|
|
isAPred('helm')
|
|
) {
|
|
return ['helm']
|
|
}
|
|
|
|
// Amulet
|
|
if (tags.includes('amul') || typeCode === 'amul' || code === 'amu' || isAPred('amul')) {
|
|
return ['amulet']
|
|
}
|
|
|
|
// Ring
|
|
if (tags.includes('ring') || typeCode === 'ring' || code === 'rin' || isAPred('ring')) {
|
|
return ['ring1', 'ring2']
|
|
}
|
|
|
|
// Gloves
|
|
if (tags.includes('glov') || typeCode === 'glov' || isAPred('glov') || ['lgl', 'vgl', 'mgl', 'tgl', 'hgl'].includes(code)) {
|
|
return ['gloves']
|
|
}
|
|
|
|
// Boots
|
|
if (tags.includes('boot') || typeCode === 'boot' || isAPred('boot') || ['lbt', 'vbt', 'mbt', 'tbt', 'hbt'].includes(code)) {
|
|
return ['boots']
|
|
}
|
|
|
|
// Belt
|
|
if (tags.includes('belt') || typeCode === 'belt' || isAPred('belt') || ['lbl', 'vbl', 'mbl', 'tbl', 'hbl'].includes(code)) {
|
|
return ['belt']
|
|
}
|
|
|
|
// Shields & Off-hand (strictly weapon2)
|
|
if (
|
|
tags.some(t => ['shld', 'ashd', 'head', 'seco'].includes(t)) ||
|
|
['shie', 'ashd', 'head', 'shld'].includes(typeCode) ||
|
|
isAPred('shld') ||
|
|
isAPred('seco')
|
|
) {
|
|
return ['weapon2']
|
|
}
|
|
|
|
// Quivers (strictly weapon2)
|
|
if (
|
|
tags.some(t => ['bowq', 'xboq', 'mboq', 'mxbq'].includes(t)) ||
|
|
['bowq', 'xboq', 'mboq', 'mxbq'].includes(typeCode) ||
|
|
isAPred('bowq') ||
|
|
isAPred('xboq')
|
|
) {
|
|
return ['weapon2']
|
|
}
|
|
|
|
// Armor / Torso
|
|
if (
|
|
base.kind === 'armor' ||
|
|
tags.some(t => ['tors', 'cloa'].includes(t)) ||
|
|
['tors', 'cloa'].includes(typeCode) ||
|
|
isAPred('tors')
|
|
) {
|
|
return ['armor']
|
|
}
|
|
|
|
// Weapons
|
|
if (base.kind === 'weapon' || tags.includes('weap') || isAPred('weap')) {
|
|
const isTwoHanded = Boolean(
|
|
base.twoHanded ||
|
|
tags.some(t => ['bow', 'xbow', 'staf', 'pole', 'spea', '2h'].includes(t)) ||
|
|
['bow', 'xbow', 'staf', 'pole', 'spea', '2h'].includes(typeCode) ||
|
|
(dropTables?.weapons?.get?.(code) as any)?.twohanded === '1'
|
|
)
|
|
if (isTwoHanded) {
|
|
return ['weapon1']
|
|
}
|
|
return ['weapon1', 'weapon2']
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
/**
|
|
* Ground Truth (`D2Common.dll` 1.13c `ITEMS_GetColor` @ `0x6fd99750`, `D2Common/src/Items/Items.cpp:3651-3836`):
|
|
* Resolves the 2D inventory palette shift (`invtransform`) and 3D character paperdoll palette shift (`chrtransform`)
|
|
* for an item across all quality tiers:
|
|
* - Unidentified / gamble mystery items have no color transform (`undefined`).
|
|
* - Unique (`ITEMQUAL_UNIQUE`): `uniqueItemDef.invtransform` / `uniqueItemDef.chrtransform` (or lookup in `dropTables.uniques`).
|
|
* - Set (`ITEMQUAL_SET`): `setItemDef.invtransform` / `setItemDef.chrtransform` (or lookup in `dropTables.sets.items`).
|
|
* - Magic / Rare / Crafted (`ITEMQUAL_MAGIC` | `ITEMQUAL_RARE` | `ITEMQUAL_CRAFT` | `ITEMQUAL_TEMPERED`):
|
|
* checks rolled **suffixes** first (`MagicSuffix.txt`), first suffix with `transform === true && transformColor` wins;
|
|
* if none, checks rolled **prefixes** (`MagicPrefix.txt`), first prefix with `transform === true && transformColor` wins.
|
|
* - Low / Normal / Superior (and non-runeword socketed items):
|
|
* checks `socketedItems` in socket order; the first socketed gem (`Gems.txt` `isGem`) maps `gem.transform` (`0..20`)
|
|
* to `COLOR_CODES[gem.transform]`.
|
|
* - AutoMagic fallback (`item.autoAffixId`): checks `CANONICAL_AUTOMAGIC_TABLE` for `transform && transformColor`.
|
|
*/
|
|
export function resolveItemColorTransform(
|
|
item: Item,
|
|
quality: UiItemQuality,
|
|
code: string,
|
|
dropTables?: DropTables,
|
|
): { invtransform?: string | undefined; chrtransform?: string | undefined } {
|
|
const isUnidentified = Boolean(
|
|
item.isGambleMystery ||
|
|
item.identified === false ||
|
|
(item.flags !== undefined && item.flags.identified === false),
|
|
)
|
|
if (isUnidentified || (item as any).runewordRunes || item.flags?.runeword) {
|
|
return { invtransform: undefined, chrtransform: undefined }
|
|
}
|
|
|
|
let invtransform: string | undefined =
|
|
(item as any).invtransform ?? item.uniqueItemDef?.invtransform ?? item.setItemDef?.invtransform
|
|
let chrtransform: string | undefined =
|
|
(item as any).chrtransform ?? item.uniqueItemDef?.chrtransform ?? item.setItemDef?.chrtransform
|
|
|
|
if (quality === 'unique' && (!invtransform || !chrtransform) && dropTables?.uniques) {
|
|
const targetName = (item.name ?? '').trim()
|
|
let match =
|
|
(targetName ? dropTables.uniques.get?.(targetName) : undefined) ??
|
|
(targetName
|
|
? dropTables.uniques.all?.find(
|
|
u => u.enabled && u.index.trim().toLowerCase() === targetName.toLowerCase(),
|
|
)
|
|
: undefined)
|
|
if (!match && code) {
|
|
const byCode = dropTables.uniques.getEnabledByCode?.(code.trim())
|
|
if (byCode && byCode.length === 1) {
|
|
match = byCode[0]
|
|
}
|
|
}
|
|
if (match) {
|
|
if (!invtransform && match.invtransform) invtransform = match.invtransform.trim().toLowerCase()
|
|
if (!chrtransform && match.chrtransform) chrtransform = match.chrtransform.trim().toLowerCase()
|
|
}
|
|
} else if (quality === 'set' && (!invtransform || !chrtransform) && dropTables?.sets?.items) {
|
|
const targetName = (item.name ?? '').trim()
|
|
let match =
|
|
(targetName ? dropTables.sets.items.getByIndex?.(targetName) : undefined) ??
|
|
(targetName
|
|
? dropTables.sets.items.entries?.find(
|
|
s => s.index.trim().toLowerCase() === targetName.toLowerCase(),
|
|
)
|
|
: undefined)
|
|
if (!match && code) {
|
|
const lowerCode = code.trim().toLowerCase()
|
|
const byCode = dropTables.sets.items.entries?.filter(
|
|
s => s.item.trim().toLowerCase() === lowerCode,
|
|
)
|
|
if (byCode && byCode.length === 1) {
|
|
match = byCode[0]
|
|
}
|
|
}
|
|
if (match) {
|
|
if (!invtransform && match.invtransform) invtransform = match.invtransform.trim().toLowerCase()
|
|
if (!chrtransform && match.chrtransform) chrtransform = match.chrtransform.trim().toLowerCase()
|
|
}
|
|
} else if (quality === 'magic' || quality === 'rare' || quality === 'craft') {
|
|
if (!invtransform && !chrtransform) {
|
|
let affixColor: string | undefined
|
|
const suffixes =
|
|
item.rolledMagicAffixes?.suffixes ??
|
|
item.rolledRareAffixes?.suffixes ??
|
|
(item.rolledMagicAffixes?.suffix ? [item.rolledMagicAffixes.suffix] : [])
|
|
for (const suf of suffixes) {
|
|
if (suf && suf.transform && suf.transformColor) {
|
|
affixColor = suf.transformColor.trim().toLowerCase()
|
|
break
|
|
}
|
|
}
|
|
if (!affixColor && dropTables?.magicAffixes?.suffixes) {
|
|
const sufList = dropTables.magicAffixes.suffixes.all ?? (dropTables.magicAffixes.suffixes as any)
|
|
const sufIds = (item as any).magicSuffixIds ?? (item.magicSuffixId !== undefined ? [item.magicSuffixId] : [])
|
|
for (const id of sufIds) {
|
|
if (typeof id === 'number' && id > 0 && Array.isArray(sufList)) {
|
|
const suf = sufList[id - 1] ?? sufList.find((s: any) => s.id === id)
|
|
if (suf && suf.transform && suf.transformColor) {
|
|
affixColor = suf.transformColor.trim().toLowerCase()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!affixColor) {
|
|
const prefixes =
|
|
item.rolledMagicAffixes?.prefixes ??
|
|
item.rolledRareAffixes?.prefixes ??
|
|
(item.rolledMagicAffixes?.prefix ? [item.rolledMagicAffixes.prefix] : [])
|
|
for (const pre of prefixes) {
|
|
if (pre && pre.transform && pre.transformColor) {
|
|
affixColor = pre.transformColor.trim().toLowerCase()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if (!affixColor && dropTables?.magicAffixes?.prefixes) {
|
|
const preList = dropTables.magicAffixes.prefixes.all ?? (dropTables.magicAffixes.prefixes as any)
|
|
const preIds = (item as any).magicPrefixIds ?? (item.magicPrefixId !== undefined ? [item.magicPrefixId] : [])
|
|
for (const id of preIds) {
|
|
if (typeof id === 'number' && id > 0 && Array.isArray(preList)) {
|
|
const pre = preList[id - 1] ?? preList.find((p: any) => p.id === id)
|
|
if (pre && pre.transform && pre.transformColor) {
|
|
affixColor = pre.transformColor.trim().toLowerCase()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (affixColor) {
|
|
invtransform = affixColor
|
|
chrtransform = affixColor
|
|
}
|
|
}
|
|
} else if (quality === 'normal' && !(item as any).runewordRunes && !item.flags?.runeword) {
|
|
if (!invtransform && !chrtransform && Array.isArray(item.socketedItems)) {
|
|
for (const sock of item.socketedItems) {
|
|
const sockCode = (sock?.code ?? sock?.base?.id ?? '').trim().toLowerCase()
|
|
const gemColor = getGemColorTransform(sockCode)
|
|
if (gemColor) {
|
|
invtransform = gemColor
|
|
chrtransform = gemColor
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: AutoMagic (`Items.cpp:3816-3827`)
|
|
if (!invtransform && !chrtransform) {
|
|
const directAuto = (item as any).autoAffix
|
|
if (directAuto && directAuto.transform && directAuto.transformColor) {
|
|
const autoColor = String(directAuto.transformColor).trim().toLowerCase()
|
|
invtransform = autoColor
|
|
chrtransform = autoColor
|
|
} else if (typeof item.autoAffixId === 'number' && item.autoAffixId > 0) {
|
|
const autoTable = dropTables?.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE
|
|
const autoEntry =
|
|
autoTable.entries[item.autoAffixId - 1] ??
|
|
autoTable.entries.find(e => e.id === item.autoAffixId)
|
|
if (autoEntry && autoEntry.transform && autoEntry.transformColor) {
|
|
const autoColor = autoEntry.transformColor.trim().toLowerCase()
|
|
invtransform = autoColor
|
|
chrtransform = autoColor
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
invtransform: invtransform ? invtransform.trim().toLowerCase() : undefined,
|
|
chrtransform: chrtransform ? chrtransform.trim().toLowerCase() : undefined,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolves the DC6 inventory graphic sprite key (`invFile`) following
|
|
* the authentic Diablo II 1.13c resolution hierarchy and guaranteeing
|
|
* 100% resolution against `BAKED_UI_MANIFEST.itemRects`.
|
|
*/
|
|
export function resolveItemInvFile(
|
|
item: Item,
|
|
quality: UiItemQuality,
|
|
code: string,
|
|
dropTables?: DropTables,
|
|
): { invFile: string; invtransform?: string | undefined; chrtransform?: string | undefined } {
|
|
let invFile: string | undefined = undefined
|
|
const resolvedColors = resolveItemColorTransform(item, quality, code, dropTables)
|
|
let invtransform: string | undefined = resolvedColors.invtransform
|
|
const chrtransform: string | undefined = resolvedColors.chrtransform
|
|
|
|
|
|
// 1. Explicit Unique / Set Table Graphics
|
|
if (quality === 'unique') {
|
|
if (item.uniqueItemDef?.invfile) {
|
|
invFile = item.uniqueItemDef.invfile
|
|
} else if (dropTables) {
|
|
const b = dropTables.weapons.get(code) ?? dropTables.armor.get(code) ?? dropTables.misc.get(code)
|
|
if ((b as any)?.uniqueinvfile) {
|
|
invFile = (b as any).uniqueinvfile
|
|
}
|
|
}
|
|
} else if (quality === 'set') {
|
|
if (item.setItemDef?.invfile) {
|
|
invFile = item.setItemDef.invfile
|
|
} else if (dropTables) {
|
|
const b = dropTables.weapons.get(code) ?? dropTables.armor.get(code) ?? dropTables.misc.get(code)
|
|
if ((b as any)?.setinvfile) {
|
|
invFile = (b as any).setinvfile
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Pre-baked Manifest Lookup by Item Display Name (handles dyed unique/set sprites e.g. "invcap_cgrn")
|
|
if (!invFile && item.name && BAKED_UI_MANIFEST.codeToInvFile[item.name]) {
|
|
const manifestInv = BAKED_UI_MANIFEST.codeToInvFile[item.name]
|
|
if (manifestInv.includes('_')) {
|
|
const [baseKey, dyeKey] = manifestInv.split('_')
|
|
invFile = baseKey
|
|
if (!invtransform) invtransform = dyeKey
|
|
} else {
|
|
invFile = manifestInv
|
|
}
|
|
}
|
|
|
|
// 3. Base Item Definition Graphic
|
|
if (!invFile) {
|
|
const baseInv = (item.base as any)?.invfile ?? (item.base as any)?.invFile
|
|
if (baseInv) {
|
|
invFile = baseInv
|
|
} else if (dropTables) {
|
|
const b = dropTables.weapons.get(code) ?? dropTables.armor.get(code) ?? dropTables.misc.get(code)
|
|
if ((b as any)?.invfile) {
|
|
invFile = (b as any).invfile
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Base Code Lookup in Manifest
|
|
if (!invFile && BAKED_UI_MANIFEST.codeToInvFile[code]) {
|
|
invFile = BAKED_UI_MANIFEST.codeToInvFile[code]
|
|
}
|
|
|
|
// 5. Direct Prefix Fallback
|
|
if (!invFile) {
|
|
invFile = 'inv' + code
|
|
}
|
|
|
|
// 6. Authentic Charms, Rings, Amulets, Jewels & Quest Item Overrides
|
|
if (code === 'cm1' && (!invFile || invFile === 'invchm')) invFile = 'invch1'
|
|
if (code === 'cm2' && (!invFile || invFile === 'invwnd')) invFile = 'invch2'
|
|
if (code === 'cm3' && (!invFile || invFile === 'invsst')) invFile = 'invch3'
|
|
if ((code === 'rin' || code === 'ring') && (!invFile || invFile === 'invrin')) invFile = 'invrin1'
|
|
if ((code === 'amu' || code === 'amul') && (!invFile || invFile === 'invamu')) invFile = 'invamu1'
|
|
if ((code === 'jew' || code === 'jewl') && (!invFile || invFile === 'invjew' || invFile === 'invjw')) invFile = 'invjw1'
|
|
if (code === 'tbk') invFile = 'invbbk'
|
|
if (code === 'ibk') invFile = 'invrbk'
|
|
if (code === 'box' || code === 'cube') invFile = 'invbox'
|
|
|
|
// 7. Verify 100% Atlas Matching Against BAKED_UI_MANIFEST.itemRects
|
|
if (invFile && BAKED_UI_MANIFEST.itemRects[invFile]) {
|
|
// verified exact match
|
|
} else if (invFile && BAKED_UI_MANIFEST.itemRects[invFile.toLowerCase()]) {
|
|
invFile = invFile.toLowerCase()
|
|
} else {
|
|
// Find closest valid atlas rect
|
|
if (item.name && BAKED_UI_MANIFEST.codeToInvFile[item.name] && BAKED_UI_MANIFEST.itemRects[BAKED_UI_MANIFEST.codeToInvFile[item.name]]) {
|
|
invFile = BAKED_UI_MANIFEST.codeToInvFile[item.name]
|
|
} else if (BAKED_UI_MANIFEST.codeToInvFile[code] && BAKED_UI_MANIFEST.itemRects[BAKED_UI_MANIFEST.codeToInvFile[code]]) {
|
|
invFile = BAKED_UI_MANIFEST.codeToInvFile[code]
|
|
} else {
|
|
const w = item.invWidth ?? item.base?.invWidth ?? 1
|
|
const h = item.invHeight ?? item.base?.invHeight ?? 1
|
|
if (item.base?.kind === 'weapon') {
|
|
invFile = 'invhax'
|
|
} else if (item.base?.kind === 'armor') {
|
|
invFile = 'invcap'
|
|
} else if (w === 1 && h === 1) {
|
|
invFile = 'invch1'
|
|
} else if (w === 1 && h === 2) {
|
|
invFile = 'invch2'
|
|
} else if (w === 1 && h === 3) {
|
|
invFile = 'invch3'
|
|
} else {
|
|
invFile = 'invcap'
|
|
}
|
|
}
|
|
}
|
|
|
|
return { invFile, invtransform, chrtransform }
|
|
}
|
|
|
|
/**
|
|
* Converts a game engine `Item` instance into a `UiInventoryItem` instance
|
|
* suitable for the UI Inventory grid, paperdoll slots, and hover tooltips.
|
|
*
|
|
* Guarantees Diablo II v1.13c parity:
|
|
* - 100% valid `invFile` resolving to `BAKED_UI_MANIFEST.itemRects`
|
|
* - Accurate `allowedSlots` for equipment placement
|
|
* - Bilingual names `nameZh` and `baseNameZh`
|
|
* - Standardized `UiItemQuality`
|
|
* - Formatted stats and requirement lines with authentic D2ColorCode colors
|
|
*/
|
|
export function itemToUiInventoryItem(
|
|
item: Item,
|
|
dropTables?: DropTables,
|
|
): UiInventoryItem {
|
|
// Defensive passthrough: already a complete UiInventoryItem
|
|
if (
|
|
item &&
|
|
Array.isArray((item as any).allowedSlots) &&
|
|
typeof (item as any).invFile === 'string' &&
|
|
typeof (item as any).nameZh === 'string'
|
|
) {
|
|
return item as unknown as UiInventoryItem
|
|
}
|
|
|
|
// Handle case where ground entity was passed instead of ground entity.item
|
|
const rawItem: Item = (item as any)?.item && typeof (item as any)?.item === 'object' ? (item as any).item : item
|
|
const base = rawItem.base ?? { id: 'unknown', name: rawItem.name ?? 'Item', kind: 'misc', invWidth: 1, invHeight: 1, maxStack: 1, value: 0, damage: 0, defense: 0, tags: [], level: 1 }
|
|
const code = (rawItem.code ?? base.id ?? '').trim().toLowerCase()
|
|
|
|
// 1. Quality Mapping
|
|
let quality: UiItemQuality = 'normal'
|
|
const rawQ = rawItem.quality
|
|
const rawRarity = String(rawItem.rarity ?? '').toLowerCase()
|
|
|
|
if (
|
|
isRuneCode(code) ||
|
|
(base as any)?.isRune ||
|
|
(rawItem as any)?.isRune ||
|
|
rawRarity === 'rune' ||
|
|
rawRarity === 'runeword' ||
|
|
Boolean(rawItem.runeword || rawItem.flags?.runeword || (rawItem as any).isRuneword)
|
|
) {
|
|
quality = 'rune'
|
|
} else if (rawQ === ItemQuality.UNIQUE || rawQ === 7 || rawRarity === 'unique' || Boolean(rawItem.uniqueItemDef)) {
|
|
quality = 'unique'
|
|
} else if (rawQ === ItemQuality.SET || rawQ === 5 || rawRarity === 'set' || Boolean(rawItem.setItemDef)) {
|
|
quality = 'set'
|
|
} else if (rawQ === ItemQuality.RARE || rawQ === 6 || rawRarity === 'rare' || Boolean(rawItem.rolledRareAffixes)) {
|
|
quality = 'rare'
|
|
} else if (rawQ === ItemQuality.MAGIC || rawQ === 4 || rawRarity === 'magic' || Boolean(rawItem.rolledMagicAffixes)) {
|
|
quality = 'magic'
|
|
} else if (rawQ === ItemQuality.CRAFTED || rawQ === 8 || rawRarity === 'crafted' || rawRarity === 'craft') {
|
|
quality = 'craft'
|
|
} else if (typeof rawQ === 'string' && ['normal', 'magic', 'rare', 'set', 'unique', 'craft', 'rune'].includes(rawQ)) {
|
|
quality = rawQ as UiItemQuality
|
|
}
|
|
|
|
// 2. Pure Chinese & Pure English Name & Subtitle Construction
|
|
let nameZh: string
|
|
let baseNameZh: string
|
|
let baseNameEn: string
|
|
|
|
const baseEn = base.name || rawItem.name || 'Item'
|
|
const rawBaseZh = translateItemName(baseEn)
|
|
const formattedBaseZh = rawBaseZh && rawBaseZh !== baseEn ? rawBaseZh : baseEn
|
|
|
|
if (quality === 'unique' && rawItem.uniqueItemDef) {
|
|
const uEn = rawItem.uniqueItemDef.index || rawItem.name
|
|
const uZh = translateItemName(uEn)
|
|
nameZh = uZh && uZh !== uEn ? uZh : uEn
|
|
baseNameZh = formattedBaseZh
|
|
baseNameEn = baseEn
|
|
} else if (quality === 'set' && rawItem.setItemDef) {
|
|
const sEn = rawItem.setItemDef.index || rawItem.name
|
|
const sZh = translateItemName(sEn)
|
|
const setEn = rawItem.setItemDef.set
|
|
const setZh = setEn ? translateItemName(setEn) : ''
|
|
nameZh = sZh && sZh !== sEn ? sZh : sEn
|
|
baseNameZh = setEn && setZh && setZh !== setEn
|
|
? `${formattedBaseZh} · [${setZh}]`
|
|
: formattedBaseZh
|
|
baseNameEn = setEn ? `${baseEn} · [${setEn}]` : baseEn
|
|
} else if (quality === 'rare' && rawItem.rolledRareAffixes) {
|
|
const rEn = rawItem.rolledRareAffixes.name || rawItem.name
|
|
const rZh = translateRareName(rEn)
|
|
nameZh = rZh && rZh !== rEn ? rZh : rEn
|
|
baseNameZh = formattedBaseZh
|
|
baseNameEn = baseEn
|
|
} else if (quality === 'magic' && rawItem.rolledMagicAffixes) {
|
|
const mEn = rawItem.rolledMagicAffixes.name || rawItem.name
|
|
const mZh = translateMagicName(mEn, baseEn)
|
|
nameZh = mZh && mZh !== mEn ? mZh : mEn
|
|
baseNameZh = formattedBaseZh
|
|
baseNameEn = baseEn
|
|
} else if (
|
|
(rawItem.runeword || rawItem.flags?.runeword || (rawItem as any).isRuneword) &&
|
|
(rawItem.runeword?.name || (rawItem as any).runewordName || rawItem.name)
|
|
) {
|
|
const rwKey = String((rawItem.runeword as any)?.key || rawItem.runeword?.name || (rawItem as any).runewordName || rawItem.name)
|
|
const rwEn = String(rawItem.runeword?.name || (rawItem as any).runewordName || rawItem.name)
|
|
const rwZh =
|
|
translateItemName(rwKey) !== rwKey
|
|
? translateItemName(rwKey)
|
|
: translateItemName(rwEn)
|
|
nameZh = rwZh && rwZh !== rwEn ? rwZh : rwEn
|
|
baseNameZh = formattedBaseZh
|
|
baseNameEn = baseEn
|
|
} else {
|
|
const itemEn = rawItem.name || baseEn
|
|
const itemZh = translateItemName(itemEn)
|
|
nameZh = itemZh && itemZh !== itemEn ? itemZh : itemEn
|
|
baseNameZh = formattedBaseZh
|
|
baseNameEn = baseEn
|
|
}
|
|
|
|
// Use pre-localized overrides if present (strip any mixed English parentheticals)
|
|
if ((rawItem as any).nameZh) {
|
|
const rawZh = String((rawItem as any).nameZh).replace(/\s*\([A-Za-z0-9' -]+\)$/, '')
|
|
nameZh = rawZh
|
|
}
|
|
if ((rawItem as any).baseNameZh) {
|
|
baseNameZh = String((rawItem as any).baseNameZh).replace(/\s*\([A-Za-z0-9' -]+\)$/, '')
|
|
}
|
|
if ((rawItem as any).baseNameEn) {
|
|
baseNameEn = String((rawItem as any).baseNameEn)
|
|
}
|
|
|
|
// 3. invFile & Dye Resolution
|
|
const { invFile, invtransform, chrtransform } = resolveItemInvFile(rawItem, quality, code, dropTables)
|
|
const { invFile: baseInvFile } = resolveItemInvFile(
|
|
{ ...rawItem, name: baseEn, uniqueItemDef: undefined, setItemDef: undefined } as Item,
|
|
'normal',
|
|
code,
|
|
dropTables,
|
|
)
|
|
|
|
// 4. Allowed Equipment Slots
|
|
const allowedSlots = resolveAllowedSlots(
|
|
{
|
|
id: code,
|
|
type: (base as any).type,
|
|
kind: base.kind,
|
|
tags: base.tags,
|
|
twoHanded: (base as any).twoHanded,
|
|
oneOrTwoHanded: (base as any).oneOrTwoHanded,
|
|
},
|
|
dropTables,
|
|
)
|
|
|
|
// 5. Tooltip & Formatted Stats (both CHI and ENG)
|
|
const isGambleMystery = Boolean(rawItem.isGambleMystery || rawItem.flags?.gamble)
|
|
const tt = formatItemTooltip({ ...rawItem, base } as Item, 'zh')
|
|
const ttEn = formatItemTooltip({ ...rawItem, base } as Item, 'en')
|
|
let stats: { text: string; color?: D2ColorCode }[] = isGambleMystery
|
|
? [{ text: '未辨识', color: 'red' }]
|
|
: (tt.lines || []).map(l => ({
|
|
text: l.text,
|
|
color: (l.color === 'instruction' ? 'white' : l.color) as D2ColorCode,
|
|
}))
|
|
let statsEn: { text: string; color?: D2ColorCode }[] = isGambleMystery
|
|
? [{ text: 'Unidentified', color: 'red' }]
|
|
: (ttEn.lines || []).map(l => ({
|
|
text: l.text,
|
|
color: (l.color === 'instruction' ? 'white' : l.color) as D2ColorCode,
|
|
}))
|
|
|
|
// Fallback for items with flat stats dictionary but no rolled props
|
|
if (!isGambleMystery && stats.length === 0 && rawItem.stats && typeof rawItem.stats === 'object') {
|
|
const fallbackStats = Object.entries(rawItem.stats)
|
|
.filter(([k, v]) => typeof v === 'number' && v !== 0 && k !== 'gold' && k !== 'value')
|
|
.map(([k, v]) => ({
|
|
text: `+${v} ${k}`,
|
|
color: 'blue' as D2ColorCode,
|
|
}))
|
|
if (fallbackStats.length > 0) {
|
|
stats = fallbackStats
|
|
if (statsEn.length === 0) statsEn = fallbackStats
|
|
}
|
|
}
|
|
|
|
// Damage evaluation
|
|
let damage: string | undefined = undefined
|
|
let damageEn: string | undefined = undefined
|
|
if (!isGambleMystery) {
|
|
if (tt.oneHandDamage && tt.twoHandDamage) {
|
|
damage = `${tt.oneHandDamage.min} - ${tt.oneHandDamage.max} / 双手: ${tt.twoHandDamage.min} - ${tt.twoHandDamage.max}`
|
|
damageEn = `One-Hand Damage: ${tt.oneHandDamage.min} to ${tt.oneHandDamage.max} / Two-Hand Damage: ${tt.twoHandDamage.min} to ${tt.twoHandDamage.max}`
|
|
} else if (tt.twoHandDamage) {
|
|
damage = `双手伤害: ${tt.twoHandDamage.min} - ${tt.twoHandDamage.max}`
|
|
damageEn = `Two-Hand Damage: ${tt.twoHandDamage.min} to ${tt.twoHandDamage.max}`
|
|
} else if (tt.oneHandDamage) {
|
|
damage = `${tt.oneHandDamage.min} - ${tt.oneHandDamage.max}`
|
|
damageEn = `${tt.oneHandDamage.min} to ${tt.oneHandDamage.max}`
|
|
} else if ((base as any).minMisDam && (base as any).maxMisDam) {
|
|
damage = `投掷伤害: ${(base as any).minMisDam} - ${(base as any).maxMisDam}`
|
|
damageEn = `Throw Damage: ${(base as any).minMisDam} to ${(base as any).maxMisDam}`
|
|
} else if (base.kind === 'weapon' && (base as any).damage !== undefined && (base as any).damage > 0) {
|
|
damage = String((base as any).damage)
|
|
damageEn = String((base as any).damage)
|
|
}
|
|
}
|
|
|
|
// Durability evaluation
|
|
const durability = isGambleMystery
|
|
? undefined
|
|
: tt.durability
|
|
? { current: tt.durability.current, max: tt.durability.max }
|
|
: rawItem.durability !== undefined && rawItem.maxDurability !== undefined
|
|
? { current: rawItem.durability, max: rawItem.maxDurability }
|
|
: undefined
|
|
|
|
const rolledSockProp = Array.isArray(rawItem.rolledProps)
|
|
? rawItem.rolledProps.reduce((sum, p) => ((p.code || '').trim().toLowerCase() === 'sock' ? sum + p.value : sum), 0)
|
|
: 0
|
|
const sockets = isGambleMystery
|
|
? undefined
|
|
: (rawItem as any).sockets ??
|
|
rawItem.totalSockets ??
|
|
rawItem.socketedCount ??
|
|
(rolledSockProp > 0 ? rolledSockProp : undefined)
|
|
const socketedItems =
|
|
!isGambleMystery && Array.isArray(rawItem.socketedItems) && rawItem.socketedItems.length > 0
|
|
? rawItem.socketedItems.map(s => itemToUiInventoryItem(s, dropTables))
|
|
: undefined
|
|
const ethereal = !isGambleMystery && Boolean((tt as any).ethereal || (rawItem as any).ethereal || rawItem.flags?.ethereal)
|
|
|
|
const isUnidentified = Boolean(
|
|
isGambleMystery ||
|
|
rawItem.identified === false ||
|
|
(rawItem.flags !== undefined && rawItem.flags.identified === false),
|
|
)
|
|
if (
|
|
isUnidentified &&
|
|
!isGambleMystery &&
|
|
!stats.some(s => s.text.includes('未鉴定') || s.text.includes('未辨识') || s.text.includes('Unidentified'))
|
|
) {
|
|
stats = [{ text: '未辨识', color: 'red' }, ...stats]
|
|
}
|
|
if (
|
|
isUnidentified &&
|
|
!isGambleMystery &&
|
|
!statsEn.some(s => s.text.includes('Unidentified'))
|
|
) {
|
|
statsEn = [{ text: 'Unidentified', color: 'red' }, ...statsEn]
|
|
}
|
|
|
|
const isTome = code === 'tbk' || code === 'ibk'
|
|
const rawQty =
|
|
typeof rawItem.quantity === 'number'
|
|
? rawItem.quantity
|
|
: isTome && typeof rawItem.stack === 'number'
|
|
? rawItem.stack
|
|
: isTome
|
|
? 20
|
|
: undefined
|
|
const maxQuantity = isTome ? 20 : base.maxStack > 1 ? base.maxStack : undefined
|
|
if (isTome && rawQty !== undefined && !baseNameZh.includes('数量:') && !stats.some(s => s.text.includes('数量:'))) {
|
|
stats = [...stats, { text: `数量: ${rawQty}`, color: 'white' }]
|
|
}
|
|
if (isTome && rawQty !== undefined && !baseNameEn.includes('Quantity:') && !statsEn.some(s => s.text.includes('Quantity:'))) {
|
|
statsEn = [...statsEn, { text: `Quantity: ${rawQty}`, color: 'white' }]
|
|
}
|
|
|
|
const id = String(
|
|
rawItem.uniqueId
|
|
? `item-${code}-${rawItem.uniqueId}`
|
|
: (rawItem as any).id ?? `item-${code}-${++itemBridgeSeqCounter}`,
|
|
)
|
|
|
|
const flippyFile = (rawItem as any).flippyFile ?? (base as any).flippyfile
|
|
const dropSound = (rawItem as any).dropSound ?? (rawItem as any).dropsound ?? (base as any).dropsound
|
|
const dropSfxFrame = (rawItem as any).dropSfxFrame ?? (rawItem as any).dropsfxframe ?? (base as any).dropsfxframe
|
|
const permStore = Boolean(rawItem.permStore ?? base.permStoreItem)
|
|
|
|
const reqClass = !isGambleMystery
|
|
? resolveItemRequiredClass(base as any, rawItem as any, dropTables)
|
|
: undefined
|
|
const effectiveReqLevel = !isGambleMystery
|
|
? Math.max(
|
|
tt.reqLevel ?? 0,
|
|
typeof (rawItem as any).reqLevel === 'number' ? (rawItem as any).reqLevel : 0,
|
|
typeof (base as any).levelreq === 'number' ? (base as any).levelreq : 0,
|
|
)
|
|
: 0
|
|
const effectiveReqStr = !isGambleMystery
|
|
? Math.max(
|
|
tt.reqStr ?? 0,
|
|
typeof (rawItem as any).reqStr === 'number' ? (rawItem as any).reqStr : 0,
|
|
typeof (base as any).reqstr === 'number' && tt.reqStr === undefined ? (base as any).reqstr : 0,
|
|
)
|
|
: 0
|
|
const effectiveReqDex = !isGambleMystery
|
|
? Math.max(
|
|
tt.reqDex ?? 0,
|
|
typeof (rawItem as any).reqDex === 'number' ? (rawItem as any).reqDex : 0,
|
|
typeof (base as any).reqdex === 'number' && tt.reqDex === undefined ? (base as any).reqdex : 0,
|
|
)
|
|
: 0
|
|
const isTwoHandedWeapon =
|
|
!isGambleMystery &&
|
|
Boolean(
|
|
(rawItem as any).twoHanded ||
|
|
base.twoHanded ||
|
|
BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES.has(code) ||
|
|
STRICT_TWO_HANDED_WEAPON_CODES.has(code) ||
|
|
(dropTables?.weapons?.get?.(code) as any)?.twoHanded ||
|
|
(dropTables?.weapons?.get?.(code) as any)?.twohanded === '1',
|
|
)
|
|
const isOneOrTwoHandedWeapon =
|
|
!isGambleMystery &&
|
|
Boolean(
|
|
(rawItem as any).oneOrTwoHanded ||
|
|
base.oneOrTwoHanded ||
|
|
BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES.has(code) ||
|
|
(dropTables?.weapons?.get?.(code) as any)?.oneOrTwoHanded ||
|
|
(dropTables?.weapons?.get?.(code) as any)?.['1or2handed'] === '1',
|
|
)
|
|
|
|
const invWidth = rawItem.invWidth ?? (rawItem as any).invW ?? base.invWidth ?? (base as any).invW ?? 1
|
|
const invHeight = rawItem.invHeight ?? (rawItem as any).invH ?? base.invHeight ?? (base as any).invH ?? 1
|
|
const slotType =
|
|
(rawItem as any).slot ??
|
|
(allowedSlots[0] === 'weapon1' ? 'weapon' : allowedSlots[0] ?? 'none')
|
|
|
|
return {
|
|
id,
|
|
code,
|
|
invFile,
|
|
spriteCode: invFile,
|
|
baseInvFile,
|
|
name: rawItem.runeword?.name || (rawItem as any).runewordName || rawItem.name || baseEn,
|
|
nameZh,
|
|
baseNameZh,
|
|
baseNameEn,
|
|
quality: isGambleMystery ? 'normal' : quality,
|
|
invWidth,
|
|
invHeight,
|
|
gridW: invWidth,
|
|
gridH: invHeight,
|
|
slotType,
|
|
allowedSlots,
|
|
...(isTwoHandedWeapon ? { twoHanded: true } : {}),
|
|
...(isOneOrTwoHandedWeapon ? { oneOrTwoHanded: true } : {}),
|
|
...(!isGambleMystery && tt.defense !== undefined
|
|
? { defense: tt.defense }
|
|
: !isGambleMystery && (rawItem.defense !== undefined || base.defense > 0)
|
|
? { defense: rawItem.defense ?? base.defense }
|
|
: {}),
|
|
...(damage !== undefined ? { damage } : {}),
|
|
...(damageEn !== undefined ? { damageEn } : {}),
|
|
...(effectiveReqLevel > 0 ? { reqLevel: effectiveReqLevel } : {}),
|
|
...(effectiveReqStr > 0 ? { reqStr: effectiveReqStr } : {}),
|
|
...(effectiveReqDex > 0 ? { reqDex: effectiveReqDex } : {}),
|
|
...(reqClass !== undefined ? { reqClass } : {}),
|
|
stats,
|
|
statsEn,
|
|
...(!isGambleMystery && invtransform ? { invtransform } : {}),
|
|
...(!isGambleMystery && chrtransform ? { chrtransform } : {}),
|
|
...(durability ? { durability } : {}),
|
|
...(sockets !== undefined && sockets > 0 ? { sockets } : {}),
|
|
...(socketedItems !== undefined ? { socketedItems } : {}),
|
|
...(ethereal ? { ethereal: true } : {}),
|
|
...(!isGambleMystery && ((rawItem as any).speedText || tt.speedText) ? { speedText: (rawItem as any).speedText || tt.speedText } : {}),
|
|
...(!isGambleMystery && ((rawItem as any).speedTextEn || ttEn.speedText) ? { speedTextEn: (rawItem as any).speedTextEn || ttEn.speedText } : {}),
|
|
...(!isGambleMystery && ((rawItem as any).runewordRunes || tt.runewordRunes) ? { runewordRunes: (rawItem as any).runewordRunes || tt.runewordRunes } : {}),
|
|
...(!isGambleMystery && tt.setPieces ? { setPieces: tt.setPieces } : {}),
|
|
...(!isGambleMystery && ttEn.setPieces ? { setPiecesEn: ttEn.setPieces } : {}),
|
|
...(!isGambleMystery && tt.setBonuses ? { setBonuses: tt.setBonuses.map(b => ({ text: b.text, color: (b.color ?? 'green') as D2ColorCode })) } : {}),
|
|
...(!isGambleMystery && ttEn.setBonuses ? { setBonusesEn: ttEn.setBonuses.map(b => ({ text: b.text, color: (b.color ?? 'green') as D2ColorCode })) } : {}),
|
|
...(typeof flippyFile === 'string' && flippyFile.length > 0 ? { flippyFile } : {}),
|
|
...(typeof dropSound === 'string' && dropSound.length > 0 ? { dropSound } : {}),
|
|
...(typeof dropSfxFrame === 'number' && Number.isFinite(dropSfxFrame) ? { dropSfxFrame } : {}),
|
|
rawItem: { ...rawItem, base } as Item,
|
|
...(permStore ? { permStore: true } : {}),
|
|
...(isGambleMystery ? { isGambleMystery: true } : {}),
|
|
...(isUnidentified ? { identified: false } : { identified: true }),
|
|
...(rawQty !== undefined ? { quantity: rawQty } : {}),
|
|
...(maxQuantity !== undefined ? { maxQuantity } : {}),
|
|
...((rawItem as any).buyCost !== undefined ? { buyCost: (rawItem as any).buyCost } : {}),
|
|
...((rawItem as any).sellCost !== undefined ? { sellCost: (rawItem as any).sellCost } : {}),
|
|
...((rawItem as any).repairCost !== undefined ? { repairCost: (rawItem as any).repairCost } : {}),
|
|
}
|
|
}
|
|
|
|
const STAT_ID_TO_PROP_CODE: Readonly<Record<number, string>> = {
|
|
0: 'str',
|
|
1: 'enr',
|
|
2: 'dex',
|
|
3: 'vit',
|
|
6: 'hp',
|
|
7: 'hp',
|
|
8: 'mana',
|
|
9: 'mana',
|
|
10: 'stam',
|
|
11: 'stam',
|
|
16: 'ac%',
|
|
17: 'dmg%',
|
|
18: 'dmg%',
|
|
19: 'att',
|
|
20: 'block',
|
|
21: 'dmg-min',
|
|
22: 'dmg-max',
|
|
23: 'dmg-min',
|
|
24: 'dmg-max',
|
|
27: 'regen-mana',
|
|
28: 'regen-stam',
|
|
31: 'ac',
|
|
32: 'ac-miss',
|
|
33: 'ac-hth',
|
|
34: 'red-dmg',
|
|
35: 'red-mag',
|
|
36: 'red-dmg%',
|
|
37: 'res-mag',
|
|
38: 'res-mag-max',
|
|
39: 'res-fire',
|
|
40: 'res-fire-max',
|
|
41: 'res-ltng',
|
|
42: 'res-ltng-max',
|
|
43: 'res-cold',
|
|
44: 'res-cold-max',
|
|
45: 'res-pois',
|
|
46: 'res-pois-max',
|
|
48: 'fire-min',
|
|
49: 'fire-max',
|
|
50: 'ltng-min',
|
|
51: 'ltng-max',
|
|
52: 'dmg-mag',
|
|
53: 'dmg-mag',
|
|
54: 'cold-min',
|
|
55: 'cold-max',
|
|
56: 'cold-len',
|
|
57: 'pois-min',
|
|
58: 'pois-max',
|
|
59: 'pois-len',
|
|
60: 'lifesteal',
|
|
62: 'manasteal',
|
|
74: 'regen',
|
|
75: 'dur%',
|
|
76: 'hp%',
|
|
77: 'mana%',
|
|
78: 'thorns',
|
|
79: 'gold%',
|
|
80: 'mag%',
|
|
81: 'knock',
|
|
85: 'addxp',
|
|
86: 'heal-kill',
|
|
87: 'cheap',
|
|
89: 'light',
|
|
91: 'ease',
|
|
93: 'ias',
|
|
96: 'frw',
|
|
97: 'oskill',
|
|
98: 'state',
|
|
99: 'fhr',
|
|
102: 'fbr',
|
|
105: 'fcr',
|
|
107: 'skill',
|
|
108: 'rip',
|
|
110: 'res-pois-len',
|
|
111: 'dmg',
|
|
112: 'howl',
|
|
113: 'stupidity',
|
|
114: 'dmg-to-mana',
|
|
115: 'ignore-ac',
|
|
116: 'reduce-ac',
|
|
117: 'noheal',
|
|
118: 'half-freeze',
|
|
119: 'att%',
|
|
120: 'dmg-ac',
|
|
121: 'dmg-demon',
|
|
122: 'dmg-undead',
|
|
123: 'att-demon',
|
|
124: 'att-undead',
|
|
125: 'throw',
|
|
126: 'fireskill',
|
|
127: 'allskills',
|
|
128: 'light-thorns',
|
|
134: 'freeze',
|
|
135: 'openwounds',
|
|
136: 'crush',
|
|
137: 'kick',
|
|
138: 'mana-kill',
|
|
139: 'demon-heal',
|
|
140: 'bloody',
|
|
141: 'deadly',
|
|
142: 'abs-fire%',
|
|
143: 'abs-fire',
|
|
144: 'abs-ltng%',
|
|
145: 'abs-ltng',
|
|
146: 'abs-mag%',
|
|
147: 'abs-mag',
|
|
148: 'abs-cold%',
|
|
149: 'abs-cold',
|
|
150: 'slow',
|
|
151: 'aura',
|
|
152: 'indestruct',
|
|
153: 'nofreeze',
|
|
154: 'stamdrain',
|
|
155: 'reanimate',
|
|
156: 'pierce',
|
|
157: 'magicarrow',
|
|
158: 'explosivearrow',
|
|
159: 'dmg-min',
|
|
160: 'dmg-max',
|
|
179: 'att-mon%',
|
|
180: 'dmg-mon%',
|
|
181: 'fade',
|
|
188: 'skilltab',
|
|
195: 'att-skill',
|
|
196: 'kill-skill',
|
|
197: 'death-skill',
|
|
198: 'hit-skill',
|
|
199: 'levelup-skill',
|
|
201: 'gethit-skill',
|
|
204: 'charged',
|
|
214: 'ac/lvl',
|
|
215: 'ac%/lvl',
|
|
216: 'hp/lvl',
|
|
217: 'mana/lvl',
|
|
218: 'dmg/lvl',
|
|
219: 'dmg%/lvl',
|
|
220: 'str/lvl',
|
|
221: 'dex/lvl',
|
|
222: 'enr/lvl',
|
|
223: 'vit/lvl',
|
|
224: 'att/lvl',
|
|
225: 'att%/lvl',
|
|
226: 'dmg-cold/lvl',
|
|
227: 'dmg-fire/lvl',
|
|
228: 'dmg-ltng/lvl',
|
|
229: 'dmg-pois/lvl',
|
|
230: 'res-cold/lvl',
|
|
231: 'res-fire/lvl',
|
|
232: 'res-ltng/lvl',
|
|
233: 'res-pois/lvl',
|
|
234: 'abs-cold/lvl',
|
|
235: 'abs-fire/lvl',
|
|
236: 'abs-ltng/lvl',
|
|
237: 'abs-pois/lvl',
|
|
238: 'thorns/lvl',
|
|
239: 'gold%/lvl',
|
|
240: 'mag%/lvl',
|
|
241: 'regen-stam/lvl',
|
|
242: 'stam/lvl',
|
|
243: 'dmg-dem/lvl',
|
|
244: 'dmg-und/lvl',
|
|
245: 'att-dem/lvl',
|
|
246: 'att-und/lvl',
|
|
247: 'crush/lvl',
|
|
248: 'wounds/lvl',
|
|
249: 'kick/lvl',
|
|
250: 'deadly/lvl',
|
|
252: 'rep-dur',
|
|
253: 'rep-quant',
|
|
254: 'stack',
|
|
268: 'ac/time',
|
|
329: 'extra-fire',
|
|
330: 'extra-ltng',
|
|
331: 'extra-cold',
|
|
332: 'extra-pois',
|
|
333: 'pierce-fire',
|
|
334: 'pierce-ltng',
|
|
335: 'pierce-cold',
|
|
336: 'pierce-pois',
|
|
359: 'ethereal',
|
|
}
|
|
|
|
const ISC_STAT_NAME_BY_ID: ReadonlyMap<number, string> = (() => {
|
|
const m = new Map<number, string>()
|
|
for (const entry of Object.values(CANONICAL_ISC_DESC_TABLE)) {
|
|
m.set(entry.id, entry.stat)
|
|
}
|
|
return m
|
|
})()
|
|
|
|
function convertOnlineItemStatsToRolledProps(stats: readonly ItemStat[]): {
|
|
readonly props: RolledItemProp[]
|
|
readonly reqLevelBonus: number
|
|
readonly socketsStat: number
|
|
readonly curDurStat?: number | undefined
|
|
readonly maxDurStat?: number | undefined
|
|
readonly quantityStat?: number | undefined
|
|
} {
|
|
const props: RolledItemProp[] = []
|
|
let reqLevelBonus = 0
|
|
let socketsStat = 0
|
|
let curDurStat: number | undefined
|
|
let maxDurStat: number | undefined
|
|
let quantityStat: number | undefined
|
|
|
|
const getStatId = (s: ItemStat | Record<string, any>): number =>
|
|
Number(s.statId ?? (s as any).id ?? -1)
|
|
const has17 = stats.some(s => getStatId(s) === 17)
|
|
const has21 = stats.some(s => getStatId(s) === 21)
|
|
const has22 = stats.some(s => getStatId(s) === 22)
|
|
|
|
for (let i = 0; i < stats.length; i++) {
|
|
const st = stats[i]!
|
|
const statId = getStatId(st)
|
|
const param = Number(st.param ?? 0)
|
|
const value = Number(st.value ?? 0)
|
|
|
|
if (statId === 72) {
|
|
curDurStat = value
|
|
continue
|
|
}
|
|
if (statId === 73) {
|
|
maxDurStat = value
|
|
continue
|
|
}
|
|
if (statId === 194) {
|
|
socketsStat = Math.max(socketsStat, value)
|
|
continue
|
|
}
|
|
if (statId === 70) {
|
|
quantityStat = value
|
|
continue
|
|
}
|
|
if (statId === 92) {
|
|
reqLevelBonus = Math.max(reqLevelBonus, value)
|
|
continue
|
|
}
|
|
if (statId === 14 || statId === 356) {
|
|
continue
|
|
}
|
|
if (statId === 18 && has17) {
|
|
continue
|
|
}
|
|
if (statId === 23 && has21) {
|
|
continue
|
|
}
|
|
if (statId === 24 && has22) {
|
|
continue
|
|
}
|
|
|
|
if (statId === 52) {
|
|
const next = stats[i + 1]
|
|
if (next && getStatId(next) === 53) {
|
|
props.push({
|
|
code: 'dmg-mag',
|
|
min: value,
|
|
max: Math.max(value, next.value),
|
|
value,
|
|
})
|
|
i++
|
|
continue
|
|
}
|
|
props.push({ code: 'dmg-mag', min: value, max: value, value })
|
|
continue
|
|
}
|
|
if (statId === 53) {
|
|
props.push({ code: 'dmg-mag', min: value, max: value, value })
|
|
continue
|
|
}
|
|
|
|
if (statId === 6 || statId === 7 || statId === 8 || statId === 9 || statId === 10 || statId === 11) {
|
|
const poolCode = statId <= 7 ? 'hp' : statId <= 9 ? 'mana' : 'stam'
|
|
const normVal = Math.abs(value) >= 256 && (value & 0xff) === 0 ? value >> 8 : value
|
|
props.push({ code: poolCode, min: normVal, max: normVal, value: normVal })
|
|
continue
|
|
}
|
|
|
|
if (statId === 83) {
|
|
const clsCode = (['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'] as const)[param] ?? 'ama'
|
|
props.push({ code: clsCode, min: value, max: value, value })
|
|
continue
|
|
}
|
|
|
|
if (statId === 188) {
|
|
const tabIdx = param > 20 ? ((param >> 3) & 7) * 3 + (param & 7) : param
|
|
props.push({ code: 'skilltab', param: tabIdx, min: value, max: value, value })
|
|
continue
|
|
}
|
|
|
|
if (statId === 97 || statId === 107 || statId === 151 || statId === 155) {
|
|
const code = STAT_ID_TO_PROP_CODE[statId]!
|
|
props.push({ code, param, min: value, max: value, value })
|
|
continue
|
|
}
|
|
|
|
if (statId >= 195 && statId <= 201 && STAT_ID_TO_PROP_CODE[statId]) {
|
|
const procCode = STAT_ID_TO_PROP_CODE[statId]!
|
|
const skillId = param >= 64 ? param >> 6 : param
|
|
const slvl = param >= 64 ? Math.max(1, param & 0x3f) : 1
|
|
props.push({ code: procCode, param: skillId, min: value, max: slvl, value })
|
|
continue
|
|
}
|
|
|
|
if (statId === 204) {
|
|
const skillId = param >= 64 ? param >> 6 : param
|
|
const slvl = param >= 64 ? Math.max(1, param & 0x3f) : 1
|
|
const charges = value > 255 ? (value & 0xff) || ((value >> 8) & 0xff) : Math.max(1, value)
|
|
props.push({ code: 'charged', param: skillId, min: charges, max: slvl, value: charges })
|
|
continue
|
|
}
|
|
|
|
if (statId === 252) {
|
|
const rate = value || param || 1
|
|
props.push({ code: 'rep-dur', param: rate, min: rate, max: rate, value: rate })
|
|
continue
|
|
}
|
|
|
|
const mappedCode = STAT_ID_TO_PROP_CODE[statId] ?? ISC_STAT_NAME_BY_ID.get(statId)
|
|
if (mappedCode) {
|
|
props.push({
|
|
code: mappedCode,
|
|
...(param ? { param } : {}),
|
|
min: value,
|
|
max: value,
|
|
value,
|
|
})
|
|
}
|
|
}
|
|
|
|
return { props, reqLevelBonus, socketsStat, curDurStat, maxDurStat, quantityStat }
|
|
}
|
|
|
|
function resolveOnlineRunewordDef(
|
|
item: ItemData | Record<string, any>,
|
|
socketChildren?: readonly (ItemData | Record<string, any>)[],
|
|
): RunewordDefinition | undefined {
|
|
if (socketChildren && socketChildren.length > 0) {
|
|
const childRuneCodes = socketChildren
|
|
.map(c => String(c.code ?? '').trim().toLowerCase())
|
|
.filter(c => isRuneCode(c))
|
|
if (childRuneCodes.length > 0) {
|
|
for (const rw of CANONICAL_RUNES_TABLE.complete) {
|
|
if (
|
|
rw.runes.length === childRuneCodes.length &&
|
|
rw.runes.every((r, idx) => r.trim().toLowerCase() === childRuneCodes[idx])
|
|
) {
|
|
return rw
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const rwData = item.runewordData
|
|
if (typeof rwData === 'number' && rwData > 0) {
|
|
const low12 = rwData & 0xfff
|
|
return (
|
|
CANONICAL_RUNES_TABLE.getById(rwData) ??
|
|
(low12 > 27 ? CANONICAL_RUNES_TABLE.getById(low12 - 27) : undefined) ??
|
|
(low12 > 26 ? CANONICAL_RUNES_TABLE.getById(low12 - 26) : undefined) ??
|
|
CANONICAL_RUNES_TABLE.getById(low12) ??
|
|
CANONICAL_RUNES_TABLE.all[rwData]
|
|
)
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
const RUNEWORD_DISPLAY_OVERRIDES_EN: Readonly<Record<string, string>> = {
|
|
Runeword4: 'Beast',
|
|
Runeword14: 'Chains of Honor',
|
|
Runeword26: 'Doom',
|
|
Runeword37: 'Exile',
|
|
Runeword47: 'Grief',
|
|
Runeword99: 'Voice of Reason',
|
|
}
|
|
|
|
/**
|
|
* Canonical 1.13c Online `ItemData` (`0x9C` / `0x9D`) to `UiInventoryItem` Bridge.
|
|
*
|
|
* Converts a decoded D2GS `ItemData` (plus optional socketed children and `DropTables`)
|
|
* into a localized, tooltip-ready `UiInventoryItem` with base item names, quality titles,
|
|
* combat stats (1H/2H damage, defense, durability, requirements), sockets, and formatted
|
|
* 1.13c property lines in both Chinese (`stats`) and English (`statsEn`).
|
|
*/
|
|
export function onlineItemDataToUiInventoryItem(
|
|
item: ItemData | Record<string, any>,
|
|
socketChildren?: readonly (ItemData | Record<string, any>)[],
|
|
dropTables?: DropTables,
|
|
): UiInventoryItem & {
|
|
readonly nameEn: string
|
|
readonly baseName: string
|
|
readonly usedSockets: number
|
|
readonly damageRange?: { readonly min: number; readonly max: number } | undefined
|
|
readonly twoHandDamage?: { readonly min: number; readonly max: number } | undefined
|
|
} {
|
|
const tables = dropTables ?? getEmbeddedDropTables()
|
|
const code = String(item.code ?? '').trim().toLowerCase() || 'rin'
|
|
|
|
const weaponRow = tables.weapons?.get(code)
|
|
const armorRow = !weaponRow ? tables.armor?.get(code) : undefined
|
|
const miscRow = !weaponRow && !armorRow ? tables.misc?.get(code) : undefined
|
|
const baseRow: any = weaponRow ?? armorRow ?? miscRow ?? tables.getBase?.(code)
|
|
|
|
const kind: 'weapon' | 'armor' | 'misc' = weaponRow
|
|
? 'weapon'
|
|
: armorRow
|
|
? 'armor'
|
|
: ((baseRow?.kind as 'weapon' | 'armor' | 'misc' | undefined) ?? 'misc')
|
|
|
|
const baseNameEn = resolveBaseItemNameEn(baseRow?.name || code.toUpperCase(), code)
|
|
const rawBaseZh = translateItemName(baseNameEn)
|
|
const baseNameZh = rawBaseZh && rawBaseZh !== baseNameEn ? rawBaseZh : baseNameEn
|
|
|
|
const isUnidentified = item.identified === false
|
|
const isRuneword = Boolean(item.runeword || (item as any).isRuneword)
|
|
const isEthereal = Boolean(item.ethereal)
|
|
|
|
const rawQ = item.quality
|
|
let qualityStr: string = 'normal'
|
|
if (rawQ === 1 || rawQ === 'low') qualityStr = 'low'
|
|
else if (rawQ === 3 || rawQ === 'superior') qualityStr = 'superior'
|
|
else if (rawQ === 4 || rawQ === 'magic') qualityStr = 'magic'
|
|
else if (rawQ === 5 || rawQ === 'set') qualityStr = 'set'
|
|
else if (rawQ === 6 || rawQ === 'rare') qualityStr = 'rare'
|
|
else if (rawQ === 7 || rawQ === 'unique') qualityStr = 'unique'
|
|
else if (rawQ === 8 || rawQ === 9 || rawQ === 'crafted' || rawQ === 'craft') qualityStr = 'craft'
|
|
|
|
const rawCombinedStats: (ItemStat | Record<string, any>)[] = [
|
|
...(Array.isArray(item.stats) ? item.stats : []),
|
|
...(Array.isArray(item.runewordStats) ? item.runewordStats : []),
|
|
]
|
|
let armorStatDefense: number | undefined
|
|
let consumedArmorStat = false
|
|
const combinedStats = rawCombinedStats.filter(s => {
|
|
const sid = Number(s.statId ?? (s as any).id ?? -1)
|
|
if (
|
|
!consumedArmorStat &&
|
|
kind === 'armor' &&
|
|
sid === 31 &&
|
|
(typeof item.defense !== 'number' || item.defense <= 0 || Number(s.value) === item.defense)
|
|
) {
|
|
armorStatDefense = Number(s.value)
|
|
consumedArmorStat = true
|
|
return false
|
|
}
|
|
return true
|
|
}) as ItemStat[]
|
|
const converted = convertOnlineItemStatsToRolledProps(combinedStats)
|
|
const rolledProps: RolledItemProp[] = [...converted.props]
|
|
|
|
// Resolve quality-specific definitions when identified
|
|
let uniqueItemDef: any
|
|
let setItemDef: any
|
|
let rolledMagicAffixes: any
|
|
let rolledRareAffixes: any
|
|
let runewordObj: { readonly key: string; readonly name: string; readonly levelreq?: number; readonly props?: readonly any[] } | undefined
|
|
let itemDisplayNameEn = baseNameEn
|
|
let extraReqLevel = converted.reqLevelBonus
|
|
|
|
if (!isUnidentified) {
|
|
if (isRuneword) {
|
|
const rwDef = resolveOnlineRunewordDef(item, socketChildren)
|
|
if (rwDef) {
|
|
const rwKey = rwDef.name
|
|
const rwEn =
|
|
RUNEWORD_DISPLAY_OVERRIDES_EN[rwKey] ??
|
|
CANONICAL_ENG_TBL_DICT[rwKey] ??
|
|
(rwDef.runeName || rwKey)
|
|
itemDisplayNameEn = rwEn
|
|
runewordObj = {
|
|
key: rwKey,
|
|
name: rwEn,
|
|
props: rwDef.properties,
|
|
}
|
|
if (rolledProps.length === 0 && Array.isArray(rwDef.properties)) {
|
|
for (const p of rwDef.properties) {
|
|
if (p.code) {
|
|
rolledProps.push({
|
|
code: p.code,
|
|
param: p.par,
|
|
min: p.min,
|
|
max: p.max,
|
|
value: p.min,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
} else if ((item as any).runewordName) {
|
|
const rwEn = String((item as any).runewordName)
|
|
itemDisplayNameEn = rwEn
|
|
runewordObj = { key: rwEn, name: rwEn }
|
|
}
|
|
} else if (qualityStr === 'unique') {
|
|
const uid = item.uniqueItemId ?? (item as any).uniqueId
|
|
if (typeof uid === 'number' && tables.uniques) {
|
|
uniqueItemDef =
|
|
(tables.uniques as any).byId?.get?.(uid) ??
|
|
tables.uniques.all?.[uid]
|
|
}
|
|
if (!uniqueItemDef && tables.uniques) {
|
|
const byCode = tables.uniques.getEnabledByCode?.(code)
|
|
if (byCode && byCode.length > 0) {
|
|
uniqueItemDef = byCode[0]
|
|
}
|
|
}
|
|
if (uniqueItemDef) {
|
|
itemDisplayNameEn = uniqueItemDef.index || baseNameEn
|
|
if (uniqueItemDef.levelreq > 0) {
|
|
extraReqLevel = Math.max(extraReqLevel, uniqueItemDef.levelreq)
|
|
}
|
|
}
|
|
} else if (qualityStr === 'set') {
|
|
const sid = item.setItemId ?? (item as any).setId
|
|
if (typeof sid === 'number' && tables.sets?.items) {
|
|
setItemDef =
|
|
(tables.sets.items as any).byId?.get?.(sid) ??
|
|
tables.sets.items.entries?.[sid]
|
|
}
|
|
if (!setItemDef && tables.sets?.items?.entries) {
|
|
setItemDef = tables.sets.items.entries.find(
|
|
(s: any) => s.item?.trim().toLowerCase() === code,
|
|
)
|
|
}
|
|
if (setItemDef) {
|
|
itemDisplayNameEn = setItemDef.index || baseNameEn
|
|
if (setItemDef.levelreq > 0) {
|
|
extraReqLevel = Math.max(extraReqLevel, setItemDef.levelreq)
|
|
}
|
|
}
|
|
} else if (
|
|
qualityStr === 'magic' ||
|
|
item.magicPrefixId ||
|
|
item.magicSuffixId ||
|
|
(item as any).magicPrefix ||
|
|
(item as any).magicSuffix ||
|
|
(item as any).prefixIds?.length ||
|
|
(item as any).suffixIds?.length
|
|
) {
|
|
const preId =
|
|
item.magicPrefixId ??
|
|
(item as any).magicPrefix ??
|
|
(Array.isArray((item as any).prefixIds) ? (item as any).prefixIds[0] : undefined)
|
|
const sufId =
|
|
item.magicSuffixId ??
|
|
(item as any).magicSuffix ??
|
|
(Array.isArray((item as any).suffixIds) ? (item as any).suffixIds[0] : undefined)
|
|
const preList = tables.magicAffixes?.prefixes?.all ?? []
|
|
const sufList = tables.magicAffixes?.suffixes?.all ?? []
|
|
const preEntry =
|
|
typeof preId === 'number' && preId > 0
|
|
? (preList[preId - 1] ?? preList.find((p: any) => p.id === preId))
|
|
: undefined
|
|
const sufEntry =
|
|
typeof sufId === 'number' && sufId > 0
|
|
? (sufList[sufId - 1] ?? sufList.find((s: any) => s.id === sufId))
|
|
: undefined
|
|
if (preEntry?.levelreq) extraReqLevel = Math.max(extraReqLevel, preEntry.levelreq)
|
|
if (sufEntry?.levelreq) extraReqLevel = Math.max(extraReqLevel, sufEntry.levelreq)
|
|
const parts = [preEntry?.name, baseNameEn, sufEntry?.name].filter(Boolean)
|
|
const magicName = parts.join(' ') || baseNameEn
|
|
itemDisplayNameEn = magicName
|
|
rolledMagicAffixes = {
|
|
name: magicName,
|
|
prefix: preEntry ? { ...preEntry, affix: preEntry, mods: [] } : undefined,
|
|
suffix: sufEntry ? { ...sufEntry, affix: sufEntry, mods: [] } : undefined,
|
|
prefixes: preEntry ? [preEntry] : [],
|
|
suffixes: sufEntry ? [sufEntry] : [],
|
|
affixes: [preEntry, sufEntry].filter(Boolean),
|
|
}
|
|
} else if (qualityStr === 'rare' || qualityStr === 'craft') {
|
|
const n1 = item.rareName1
|
|
const n2 = item.rareName2
|
|
const preTable = tables.rareNames?.prefixes
|
|
const sufTable = tables.rareNames?.suffixes
|
|
const sufCount = sufTable?.length ?? 155
|
|
let preWord: string | undefined
|
|
let sufWord: string | undefined
|
|
if (typeof n1 === 'number' && n1 > 0 && preTable) {
|
|
const entry =
|
|
n1 > sufCount
|
|
? (preTable.byIndex(n1 - sufCount - 1) ?? preTable.byIndex(n1 - 1) ?? preTable.byIndex(n1))
|
|
: (preTable.byIndex(n1 - 1) ?? preTable.byIndex(n1))
|
|
preWord = entry?.name
|
|
}
|
|
if (typeof n2 === 'number' && n2 > 0 && sufTable) {
|
|
const entry = sufTable.byIndex(n2 - 1) ?? sufTable.byIndex(n2)
|
|
sufWord = entry?.name
|
|
}
|
|
const rareAffixEntries: any[] = []
|
|
const rareSuffixes: any[] = []
|
|
const rarePrefixes: any[] = []
|
|
if (Array.isArray(item.rareAffixes)) {
|
|
const preList = tables.magicAffixes?.prefixes?.all ?? []
|
|
const sufList = tables.magicAffixes?.suffixes?.all ?? []
|
|
for (let i = 0; i < item.rareAffixes.length; i++) {
|
|
const aid = item.rareAffixes[i]
|
|
if (typeof aid !== 'number' || aid <= 0) continue
|
|
const isPrefixSlot = i % 2 === 0
|
|
const list = isPrefixSlot ? preList : sufList
|
|
const aff = list[aid - 1] ?? list.find((a: any) => a.id === aid)
|
|
if (aff) {
|
|
rareAffixEntries.push({ ...aff, affix: aff, mods: [] })
|
|
if (isPrefixSlot) rarePrefixes.push(aff)
|
|
else rareSuffixes.push(aff)
|
|
if (aff.levelreq > 0) extraReqLevel = Math.max(extraReqLevel, aff.levelreq)
|
|
}
|
|
}
|
|
}
|
|
const rawRareName = [preWord, sufWord].filter(Boolean).join(' ') || baseNameEn
|
|
const resolvedEn = preWord || sufWord ? resolveRareNameEn(rawRareName, preWord, sufWord) : baseNameEn
|
|
itemDisplayNameEn = resolvedEn
|
|
rolledRareAffixes = {
|
|
name: rawRareName,
|
|
rarePrefix: preWord ? { name: preWord } : undefined,
|
|
rareSuffix: sufWord ? { name: sufWord } : undefined,
|
|
prefixes: rarePrefixes,
|
|
suffixes: rareSuffixes,
|
|
affixes: rareAffixEntries,
|
|
}
|
|
}
|
|
}
|
|
|
|
const isIndestructible = combinedStats.some(s => s.statId === 152 && s.value > 0)
|
|
const maxDur =
|
|
item.maxDurability ??
|
|
converted.maxDurStat ??
|
|
(baseRow?.nodurability || baseRow?.noDurability ? 0 : (baseRow?.durability ?? 0))
|
|
const curDur =
|
|
item.curDurability ??
|
|
item.durability ??
|
|
converted.curDurStat ??
|
|
maxDur
|
|
const hasDur =
|
|
!isIndestructible &&
|
|
kind !== 'misc' &&
|
|
!baseRow?.nodurability &&
|
|
!baseRow?.noDurability &&
|
|
maxDur > 0
|
|
|
|
const totalSockets =
|
|
item.totalSockets ||
|
|
(item as any).sockets ||
|
|
converted.socketsStat ||
|
|
(socketChildren ? socketChildren.length : 0) ||
|
|
(item.socketed ? Math.max(1, item.usedSockets || item.socketedChildCount || 1) : 0)
|
|
const usedSockets = socketChildren
|
|
? socketChildren.length
|
|
: (item.usedSockets ?? item.socketedChildCount ?? 0)
|
|
|
|
const quantity =
|
|
item.quantity ??
|
|
converted.quantityStat ??
|
|
item.tomeData ??
|
|
(item.goldAmount !== undefined ? item.goldAmount : undefined)
|
|
|
|
// Convert socketed children into domain Items for formatItemTooltip & itemToUiInventoryItem
|
|
const childUiItems =
|
|
socketChildren && socketChildren.length > 0
|
|
? socketChildren.map(ch => onlineItemDataToUiInventoryItem(ch, undefined, tables))
|
|
: undefined
|
|
const childDomainItems =
|
|
childUiItems && childUiItems.length > 0
|
|
? childUiItems.map(u => u.rawItem!).filter(Boolean)
|
|
: undefined
|
|
|
|
// Base defense: if item.defense or armorStatDefense is explicitly provided on ItemData, use it as base defense
|
|
// (in 1.13c bitstream, item.defense already includes ethereal 1.5x if item is ethereal, whereas
|
|
// if item.defense is omitted we use baseRow.maxac || baseRow.minac).
|
|
const explicitDefense =
|
|
typeof item.defense === 'number' && item.defense > 0 ? item.defense : armorStatDefense
|
|
const hasExplicitDefense = typeof explicitDefense === 'number' && explicitDefense > 0
|
|
const rawBaseAc = hasExplicitDefense
|
|
? (isEthereal ? Math.max(1, Math.ceil(explicitDefense / 1.5)) : explicitDefense)
|
|
: (baseRow?.maxac ?? baseRow?.minac ?? baseRow?.defense ?? 0)
|
|
|
|
const baseObj: ItemBase & Record<string, any> = {
|
|
id: code,
|
|
code,
|
|
name: baseNameEn,
|
|
kind,
|
|
type: baseRow?.type,
|
|
type2: baseRow?.type2,
|
|
tags: baseRow?.tags ?? (baseRow?.type ? [baseRow.type] : []),
|
|
invWidth: Math.max(1, item.invWidth || baseRow?.invWidth || baseRow?.invwidth || 1),
|
|
invHeight: Math.max(1, item.invHeight || baseRow?.invHeight || baseRow?.invheight || 1),
|
|
maxStack: baseRow?.maxStack || baseRow?.maxstack || 1,
|
|
value: baseRow?.value || baseRow?.cost || 0,
|
|
damage: baseRow?.maxdam || baseRow?.damage || 0,
|
|
mindam: baseRow?.mindam || 0,
|
|
maxdam: baseRow?.maxdam || 0,
|
|
twoHandedMindam: baseRow?.twoHandedMindam ?? baseRow?.['2handmindam'] ?? 0,
|
|
twoHandedMaxdam: baseRow?.twoHandedMaxdam ?? baseRow?.['2handmaxdam'] ?? 0,
|
|
minMisDam: baseRow?.minMisDam ?? baseRow?.minmisdam ?? 0,
|
|
maxMisDam: baseRow?.maxMisDam ?? baseRow?.maxmisdam ?? 0,
|
|
defense: rawBaseAc,
|
|
durability: hasDur ? maxDur : 0,
|
|
nodurability: !hasDur,
|
|
reqstr: baseRow?.reqstr ?? baseRow?.reqStr ?? 0,
|
|
reqdex: baseRow?.reqdex ?? baseRow?.reqDex ?? 0,
|
|
levelreq: baseRow?.levelreq ?? baseRow?.reqLevel ?? 0,
|
|
level: baseRow?.level ?? 1,
|
|
invfile: baseRow?.invfile ?? baseRow?.invFile,
|
|
uniqueinvfile: baseRow?.uniqueinvfile,
|
|
setinvfile: baseRow?.setinvfile,
|
|
flippyfile: baseRow?.flippyfile,
|
|
dropsound: baseRow?.dropsound,
|
|
dropsfxframe: baseRow?.dropsfxframe,
|
|
twoHanded: Boolean(baseRow?.twoHanded || baseRow?.twohanded === '1' || baseRow?.['2handed'] === 1),
|
|
oneOrTwoHanded: Boolean(baseRow?.oneOrTwoHanded || baseRow?.['1or2handed'] === '1' || baseRow?.['1or2handed'] === 1),
|
|
}
|
|
|
|
const syntheticItem = {
|
|
id: String(item.id ?? item.itemId ?? `item-${code}-${++itemBridgeSeqCounter}`),
|
|
code,
|
|
name: isUnidentified ? baseNameEn : itemDisplayNameEn,
|
|
base: baseObj,
|
|
prefix: [],
|
|
suffix: [],
|
|
invWidth: baseObj.invWidth,
|
|
invHeight: baseObj.invHeight,
|
|
value: baseObj.value || 1,
|
|
quality:
|
|
qualityStr === 'low'
|
|
? ItemQuality.LOW
|
|
: qualityStr === 'superior'
|
|
? ItemQuality.SUPERIOR
|
|
: qualityStr === 'magic'
|
|
? ItemQuality.MAGIC
|
|
: qualityStr === 'set'
|
|
? ItemQuality.SET
|
|
: qualityStr === 'rare'
|
|
? ItemQuality.RARE
|
|
: qualityStr === 'unique'
|
|
? ItemQuality.UNIQUE
|
|
: qualityStr === 'craft'
|
|
? ItemQuality.CRAFTED
|
|
: ItemQuality.NORMAL,
|
|
rarity: isRuneword && !isUnidentified ? 'runeword' : qualityStr,
|
|
level: item.ilvl ?? baseObj.level ?? 1,
|
|
ilvl: item.ilvl ?? baseObj.level ?? 1,
|
|
identified: !isUnidentified,
|
|
ethereal: isEthereal,
|
|
flags: {
|
|
identified: !isUnidentified,
|
|
ethereal: isEthereal,
|
|
socketed: Boolean(item.socketed || totalSockets > 0),
|
|
runeword: isRuneword,
|
|
},
|
|
...(hasDur ? { durability: curDur, maxDurability: maxDur } : {}),
|
|
...(totalSockets > 0 ? { sockets: totalSockets, totalSockets, socketedCount: usedSockets } : {}),
|
|
...(childDomainItems ? { socketedItems: childDomainItems } : {}),
|
|
...(uniqueItemDef ? { uniqueItemDef } : {}),
|
|
...(setItemDef ? { setItemDef } : {}),
|
|
...(rolledMagicAffixes ? { rolledMagicAffixes } : {}),
|
|
...(rolledRareAffixes ? { rolledRareAffixes } : {}),
|
|
...(runewordObj ? { runeword: runewordObj, runewordName: runewordObj.name } : {}),
|
|
...(extraReqLevel > 0 ? { reqLevel: extraReqLevel } : {}),
|
|
...(typeof item.autoAffixId === 'number' ? { autoAffixId: item.autoAffixId } : {}),
|
|
...(typeof item.magicPrefixId === 'number' ? { magicPrefixId: item.magicPrefixId } : {}),
|
|
...(typeof item.magicSuffixId === 'number' ? { magicSuffixId: item.magicSuffixId } : {}),
|
|
...(quantity !== undefined ? { quantity, stack: quantity } : {}),
|
|
rolledProps: isUnidentified ? [] : rolledProps,
|
|
stats: item.goldAmount !== undefined ? { amount: item.goldAmount } : {},
|
|
} as unknown as Item & Record<string, any>
|
|
|
|
const uiItem = itemToUiInventoryItem(syntheticItem, tables)
|
|
|
|
let finalNameEn = isUnidentified ? baseNameEn : uiItem.name
|
|
let finalNameZh = isUnidentified ? baseNameZh : uiItem.nameZh
|
|
|
|
if (code === 'gld') {
|
|
const goldAmt =
|
|
item.goldAmount ??
|
|
quantity ??
|
|
combinedStats.find(s => s.statId === 14)?.value
|
|
if (typeof goldAmt === 'number' && goldAmt > 0) {
|
|
finalNameEn = `${goldAmt} Gold`
|
|
finalNameZh = `${goldAmt} 金币`
|
|
}
|
|
}
|
|
|
|
if (item.personalized && item.personalizedName) {
|
|
finalNameEn = `${item.personalizedName}'s ${finalNameEn}`
|
|
finalNameZh = `${item.personalizedName}的${finalNameZh}`
|
|
}
|
|
|
|
const dmgText = uiItem.damageEn ?? uiItem.damage
|
|
let damageRange: { min: number; max: number } | undefined
|
|
let twoHandDamage: { min: number; max: number } | undefined
|
|
if (dmgText) {
|
|
const matches = [...dmgText.matchAll(/(\d+)\s*(?:-|to)\s*(\d+)/gi)]
|
|
if (matches.length >= 2) {
|
|
damageRange = { min: Number(matches[0]![1]), max: Number(matches[0]![2]) }
|
|
twoHandDamage = { min: Number(matches[1]![1]), max: Number(matches[1]![2]) }
|
|
} else if (matches.length === 1) {
|
|
const parsed = { min: Number(matches[0]![1]), max: Number(matches[0]![2]) }
|
|
if (/two-hand|双手/i.test(dmgText)) {
|
|
twoHandDamage = parsed
|
|
}
|
|
damageRange = parsed
|
|
}
|
|
}
|
|
|
|
const hasAcBonus = rolledProps.some(p => p.code === 'ac' || p.code === 'ac%')
|
|
const finalDefense =
|
|
kind === 'armor' && hasExplicitDefense && !hasAcBonus ? explicitDefense : uiItem.defense
|
|
|
|
return {
|
|
...uiItem,
|
|
id: String(item.id ?? item.itemId ?? uiItem.id),
|
|
name: finalNameEn,
|
|
nameEn: finalNameEn,
|
|
nameZh: finalNameZh,
|
|
baseName: baseNameEn,
|
|
baseNameZh,
|
|
baseNameEn,
|
|
usedSockets,
|
|
...(finalDefense !== undefined ? { defense: finalDefense } : {}),
|
|
...(totalSockets > 0 ? { sockets: totalSockets } : {}),
|
|
...(childUiItems
|
|
? { socketedItems: childUiItems, socketedGems: childUiItems.map(c => c.code) }
|
|
: {}),
|
|
affixes: (uiItem.statsEn ?? uiItem.stats).map(s => s.text),
|
|
...(damageRange !== undefined ? { damageRange } : {}),
|
|
...(twoHandDamage !== undefined ? { twoHandDamage } : {}),
|
|
}
|
|
}
|
|
|
|
export const itemDataToUiInventoryItem = onlineItemDataToUiInventoryItem
|
|
|