diablo2-web/src/ui/item-bridge.ts

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 } : {}),
}
}