684 lines
26 KiB
TypeScript
684 lines
26 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 } from '../game/items.ts'
|
|
import { ItemQuality } from '../game/items.ts'
|
|
import type { DropTables } from '../game/drop-pipeline.ts'
|
|
import {
|
|
type UiInventoryItem,
|
|
type EquipSlotId,
|
|
type UiItemQuality,
|
|
} from './inventory.ts'
|
|
import { BAKED_UI_MANIFEST } from './baked-ui-meta.ts'
|
|
import { formatItemTooltip } from '../game/item-tooltip.ts'
|
|
import { translateItemName, translateRareName, translateMagicName } from '../game/tooltip-i18n.ts'
|
|
import { isRuneCode } from '../game/gems-data.ts'
|
|
import type { D2ColorCode } from './font.ts'
|
|
import { isA } from '../game/item-types.ts'
|
|
import { inferItemClassFromType } from '../game/affixes.ts'
|
|
import { CHARACTER_CLASS_CODES, type CharacterClassCode } from '../game/classes.ts'
|
|
|
|
const VALID_CLASS_CODES = new Set<string>(CHARACTER_CLASS_CODES)
|
|
|
|
/**
|
|
* 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 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 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
|
|
}
|
|
}
|
|
|
|
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 []
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
let invtransform: string | undefined = item.uniqueItemDef?.invtransform ?? item.setItemDef?.invtransform
|
|
const chrtransform: string | undefined = item.uniqueItemDef?.chrtransform ?? item.setItemDef?.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') {
|
|
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 {
|
|
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 = `${tt.oneHandDamage.min} to ${tt.oneHandDamage.max} / Two-Hand: ${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.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 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 = rawItem.uniqueId
|
|
? `item-${code}-${rawItem.uniqueId}`
|
|
: (rawItem as any).id ?? `item-${code}-${Math.random().toString(36).slice(2, 9)}`
|
|
|
|
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
|
|
|
|
return {
|
|
id,
|
|
code,
|
|
invFile,
|
|
baseInvFile,
|
|
name: rawItem.name || baseEn,
|
|
nameZh,
|
|
baseNameZh,
|
|
baseNameEn,
|
|
quality: isGambleMystery ? 'normal' : quality,
|
|
invWidth: rawItem.invWidth ?? base.invWidth ?? 1,
|
|
invHeight: rawItem.invHeight ?? base.invHeight ?? 1,
|
|
allowedSlots,
|
|
...(!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 } : {}),
|
|
...(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 } : {}),
|
|
...(!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 } : {}),
|
|
}
|
|
}
|