2291 lines
68 KiB
TypeScript
2291 lines
68 KiB
TypeScript
/**
|
|
* Diablo II Item Tooltip and Formatted Attribute Generator.
|
|
*
|
|
* Formats dropped Item instances into rich, canonical Diablo II tooltip data:
|
|
* - Quality colors and tiered title (Unique gold, Set green, Rare yellow, Magic blue, Rune orange, Gem purple)
|
|
* - Base equipment stats (Defense, 1H/2H Damage, Speed, Durability, Required Str/Dex/Level, ilvl)
|
|
* - Rolled magic modifiers, affix lines, and unique/set fixed modifiers formatted with authentic D2 strings
|
|
* - Sockets, rune effects, and gem socketing bonuses
|
|
*
|
|
* All equipment names and attribute modifiers strictly use canonical Diablo II 1.13c English strings.
|
|
*/
|
|
|
|
import type { Item, ItemBase, RolledItemProp } from './items.ts'
|
|
import type { UniqueItem, UniqueItemProp } from './unique-items.ts'
|
|
import type { SetItem, SetItemProp } from './set-items.ts'
|
|
import { inferItemClassFromType } from './affixes.ts'
|
|
import {
|
|
NON_RANDOM_RANGE_CODES,
|
|
getItemTypeCode,
|
|
type GeneratedAffixes,
|
|
type GeneratedRareAffixes,
|
|
type RolledAffix,
|
|
type RolledMod,
|
|
} from './affix-generator.ts'
|
|
import {
|
|
resolveProperty,
|
|
getDefaultItemPropertiesAndStats,
|
|
type PropertiesTable,
|
|
type ItemStatCostTable,
|
|
type ItemStatCostRecord,
|
|
} from './properties.ts'
|
|
import {
|
|
getTooltipLocale,
|
|
setTooltipLocale,
|
|
translateItemName,
|
|
translateMagicName,
|
|
translateRareName,
|
|
formatPropertyCodeI18n,
|
|
RUNE_INFO_ZH,
|
|
type TooltipLocale,
|
|
} from './tooltip-i18n.ts'
|
|
import {
|
|
GEM_INFO,
|
|
RUNE_INFO,
|
|
getSocketableInfo,
|
|
isGemCode,
|
|
isRuneCode,
|
|
type GemInfo,
|
|
type RuneInfo,
|
|
} from './gems-data.ts'
|
|
|
|
export {
|
|
getTooltipLocale,
|
|
setTooltipLocale,
|
|
translateItemName,
|
|
type TooltipLocale,
|
|
} from './tooltip-i18n.ts'
|
|
export {
|
|
GEM_INFO,
|
|
RUNE_INFO,
|
|
getSocketableInfo,
|
|
isGemCode,
|
|
isRuneCode,
|
|
type GemInfo,
|
|
type RuneInfo,
|
|
} from './gems-data.ts'
|
|
|
|
export interface FormattedStatLine {
|
|
text: string
|
|
color?: 'blue' | 'white' | 'gold' | 'green' | 'orange' | 'yellow' | 'red' | 'gray' | 'instruction'
|
|
isMagic?: boolean | undefined
|
|
}
|
|
|
|
export interface FormattedItemTooltip {
|
|
title: string
|
|
subTitle?: string | undefined
|
|
quality: string
|
|
qualityColor: string
|
|
baseType: string
|
|
ilvl: number
|
|
reqLevel?: number | undefined
|
|
baseReqLevel?: number | undefined
|
|
reqStr?: number | undefined
|
|
baseReqStr?: number | undefined
|
|
reqDex?: number | undefined
|
|
baseReqDex?: number | undefined
|
|
defense?: number | undefined
|
|
baseDefense?: number | undefined
|
|
oneHandDamage?: {
|
|
min: number
|
|
max: number
|
|
baseMin?: number | undefined
|
|
baseMax?: number | undefined
|
|
isEnhanced?: boolean | undefined
|
|
} | undefined
|
|
twoHandDamage?: {
|
|
min: number
|
|
max: number
|
|
baseMin?: number | undefined
|
|
baseMax?: number | undefined
|
|
isEnhanced?: boolean | undefined
|
|
} | undefined
|
|
durability?: {
|
|
current: number
|
|
max: number
|
|
baseMax?: number | undefined
|
|
isEnhanced?: boolean | undefined
|
|
} | undefined
|
|
sockets?: number | undefined
|
|
lines: FormattedStatLine[]
|
|
setPieces?: string[] | undefined
|
|
setBonuses?: FormattedStatLine[] | undefined
|
|
flavorText?: string | undefined
|
|
isSocketable?: boolean | undefined
|
|
runewordRunes?: string | undefined
|
|
speedText?: string | undefined
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Formats a Diablo II property modifier into a canonical 1.13c English string.
|
|
*
|
|
* For discrete stats (e.g., mana, hp, str, res-all, fcr), the rolled concrete value
|
|
* is formatted (e.g., '+17 to Mana'), never a range like '+11-20'.
|
|
* Damage range stats (e.g., dmg-fire, dmg-ltng) format the range (e.g., 'Adds 1-50 Lightning Damage').
|
|
*
|
|
* Supports overloaded call signatures:
|
|
* - formatPropertyCode(code, value, min, max, par)
|
|
* - formatPropertyCode(code, min, max, par)
|
|
* - formatPropertyCode(code, value)
|
|
*/
|
|
import {
|
|
CANONICAL_SKILLS,
|
|
resolveSkillInfo,
|
|
CANONICAL_SKILL_TABS,
|
|
CANONICAL_REANIMATE_MONSTERS,
|
|
} from './skills-data.ts'
|
|
|
|
export {
|
|
resolveSkillInfo,
|
|
CANONICAL_SKILLS,
|
|
CANONICAL_SKILL_TABS,
|
|
CANONICAL_REANIMATE_MONSTERS,
|
|
}
|
|
|
|
function formatPerLevelVal(rawEighths: number): string {
|
|
const perLvl = rawEighths / 8
|
|
return Number.isInteger(perLvl) ? `${perLvl}` : perLvl.toFixed(1).replace(/\.0$/, '')
|
|
}
|
|
|
|
export function formatPropertyCode(
|
|
code: string,
|
|
valueOrMin: number,
|
|
minOrMax?: number,
|
|
maxOrPar?: number | string,
|
|
maybePar?: string | number,
|
|
locale?: TooltipLocale,
|
|
): string {
|
|
if (locale && locale !== 'en') {
|
|
return formatPropertyCodeI18n(code, valueOrMin, minOrMax, maxOrPar, maybePar, locale)
|
|
}
|
|
let value: number
|
|
let min: number
|
|
let max: number
|
|
let par: string | number | undefined
|
|
|
|
if (typeof maxOrPar === 'number') {
|
|
// Called as (code, value, min, max, par)
|
|
value = valueOrMin
|
|
min = minOrMax ?? valueOrMin
|
|
max = maxOrPar
|
|
par = maybePar
|
|
} else if (typeof minOrMax === 'number') {
|
|
// Called as (code, min, max, par) or (code, value, min)
|
|
if (typeof maxOrPar === 'string') {
|
|
min = valueOrMin
|
|
max = minOrMax
|
|
value = min
|
|
par = maxOrPar
|
|
} else {
|
|
min = valueOrMin
|
|
max = minOrMax
|
|
value = valueOrMin
|
|
}
|
|
} else {
|
|
// Called as (code, value)
|
|
value = valueOrMin
|
|
min = valueOrMin
|
|
max = valueOrMin
|
|
if (typeof minOrMax === 'string') {
|
|
par = minOrMax
|
|
}
|
|
}
|
|
|
|
// For func=17 properties where min=0, max=0, and par holds the actual numeric rate/eighths
|
|
const effectiveVal =
|
|
value === 0 && min === 0 && max === 0 && par !== undefined && par !== '' && !Number.isNaN(Number(par))
|
|
? Number(par)
|
|
: value
|
|
|
|
const plusVal = effectiveVal >= 0 ? `+${effectiveVal}` : `${effectiveVal}`
|
|
|
|
switch (code.trim().toLowerCase().replace(/^\*/, '')) {
|
|
// --- Skills & Class Skills (descFunc 1, 13, 14, 16, 27, 28) ---
|
|
case 'allskills':
|
|
return `${plusVal} to All Skills`
|
|
case 'ama':
|
|
return `${plusVal} to Amazon Skill Levels`
|
|
case 'sor':
|
|
return `${plusVal} to Sorceress Skill Levels`
|
|
case 'nec':
|
|
return `${plusVal} to Necromancer Skill Levels`
|
|
case 'pal':
|
|
return `${plusVal} to Paladin Skill Levels`
|
|
case 'bar':
|
|
return `${plusVal} to Barbarian Skill Levels`
|
|
case 'dru':
|
|
return `${plusVal} to Druid Skills`
|
|
case 'ass':
|
|
return `${plusVal} to Assassin Skills`
|
|
case 'randclassskill':
|
|
return `+${effectiveVal || 3} to Random Class Skill Levels`
|
|
case 'classskills':
|
|
return `${plusVal} to ${par ? `${par} ` : ''}Skill Levels`
|
|
case 'fireskill':
|
|
return `${plusVal} to Fire Skills`
|
|
case 'skilltab': {
|
|
const tabId = Number(par)
|
|
if (!Number.isNaN(tabId) && CANONICAL_SKILL_TABS[tabId]) {
|
|
return `${plusVal} to ${CANONICAL_SKILL_TABS[tabId]}`
|
|
}
|
|
return `${plusVal} to ${par ?? 'Skill Tab'}`
|
|
}
|
|
case 'skill': {
|
|
const sk = resolveSkillInfo(par)
|
|
return `${plusVal} to ${sk.name}${sk.classOnly ? ` ${sk.classOnly}` : ''}`
|
|
}
|
|
case 'skill-rand': {
|
|
const slvl = Number(par) || effectiveVal || 3
|
|
const sk = resolveSkillInfo(min)
|
|
return `+${slvl} to ${sk.name}${sk.classOnly ? ` ${sk.classOnly}` : ' (Sorceress Only)'}`
|
|
}
|
|
case 'oskill': {
|
|
const sk = resolveSkillInfo(par)
|
|
return `${plusVal} to ${sk.name}`
|
|
}
|
|
case 'aura': {
|
|
const sk = resolveSkillInfo(par)
|
|
return `Level ${effectiveVal} ${sk.name} Aura When Equipped`
|
|
}
|
|
|
|
// --- Event Procs (func=11, descFunc=15) & Charged Skills (func=19, descFunc=24) ---
|
|
case 'hit-skill': {
|
|
const sk = resolveSkillInfo(par)
|
|
const chance = min || effectiveVal || 5
|
|
const slvl = max || 1
|
|
return `${chance}% Chance to cast level ${slvl} ${sk.name} on striking`
|
|
}
|
|
case 'att-skill': {
|
|
const sk = resolveSkillInfo(par)
|
|
const chance = min || effectiveVal || 5
|
|
const slvl = max || 1
|
|
return `${chance}% Chance to cast level ${slvl} ${sk.name} on attack`
|
|
}
|
|
case 'gethit-skill': {
|
|
const sk = resolveSkillInfo(par)
|
|
const chance = min || effectiveVal || 5
|
|
const slvl = max || 1
|
|
return `${chance}% Chance to cast level ${slvl} ${sk.name} when struck`
|
|
}
|
|
case 'kill-skill': {
|
|
const sk = resolveSkillInfo(par)
|
|
const chance = min || effectiveVal || 5
|
|
const slvl = max || 1
|
|
return `${chance}% Chance to cast level ${slvl} ${sk.name} when you Kill an Enemy`
|
|
}
|
|
case 'death-skill': {
|
|
const sk = resolveSkillInfo(par)
|
|
const chance = min || effectiveVal || 100
|
|
const slvl = max || 1
|
|
return `${chance}% Chance to cast level ${slvl} ${sk.name} when you Die`
|
|
}
|
|
case 'levelup-skill': {
|
|
const sk = resolveSkillInfo(par)
|
|
const chance = min || effectiveVal || 100
|
|
const slvl = max || 1
|
|
return `${chance}% Chance to cast level ${slvl} ${sk.name} when you Level-Up`
|
|
}
|
|
case 'charged': {
|
|
const sk = resolveSkillInfo(par)
|
|
const charges = Math.abs(min || effectiveVal || 10)
|
|
const slvl = Math.abs(max || 1)
|
|
return `Level ${slvl} ${sk.name} (${charges}/${charges} Charges)`
|
|
}
|
|
|
|
// --- Attributes & Life / Mana / Stamina ---
|
|
case 'str':
|
|
return `${plusVal} to Strength`
|
|
case 'dex':
|
|
return `${plusVal} to Dexterity`
|
|
case 'vit':
|
|
return `${plusVal} to Vitality`
|
|
case 'enr':
|
|
return `${plusVal} to Energy`
|
|
case 'all-stats':
|
|
return `${plusVal} to All Attributes`
|
|
case 'hp':
|
|
return `${plusVal} to Life`
|
|
case 'hp%':
|
|
return `Increase Maximum Life ${effectiveVal}%`
|
|
case 'mana':
|
|
return `${plusVal} to Mana`
|
|
case 'mana%':
|
|
return `Increase Maximum Mana ${effectiveVal}%`
|
|
case 'stam':
|
|
return `${plusVal} Maximum Stamina`
|
|
case 'regen-stam':
|
|
return `Heal Stamina Plus ${effectiveVal}%`
|
|
case 'stamdrain':
|
|
return `${effectiveVal}% Slower Stamina Drain`
|
|
|
|
// --- Per-Level Scaling Properties (func=17, descFunc 6..9) ---
|
|
case 'hp/lvl':
|
|
return `+${formatPerLevelVal(effectiveVal)} to Life (Based on Character Level)`
|
|
case 'mana/lvl':
|
|
return `+${formatPerLevelVal(effectiveVal)} to Mana (Based on Character Level)`
|
|
case 'stam/lvl':
|
|
return `+${formatPerLevelVal(effectiveVal)} Maximum Stamina (Based on Character Level)`
|
|
case 'regen-stam/lvl':
|
|
return `Heal Stamina Plus +${formatPerLevelVal(effectiveVal)}% (Based on Character Level)`
|
|
case 'str/lvl':
|
|
return `+${formatPerLevelVal(effectiveVal)} to Strength (Based on Character Level)`
|
|
case 'dex/lvl':
|
|
return `+${formatPerLevelVal(effectiveVal)} to Dexterity (Based on Character Level)`
|
|
case 'vit/lvl':
|
|
return `+${formatPerLevelVal(effectiveVal)} to Vitality (Based on Character Level)`
|
|
case 'enr/lvl':
|
|
return `+${formatPerLevelVal(effectiveVal)} to Energy (Based on Character Level)`
|
|
case 'ac/lvl':
|
|
return `+${formatPerLevelVal(effectiveVal)} Defense (Based on Character Level)`
|
|
case 'ac%/lvl':
|
|
return `+${formatPerLevelVal(effectiveVal)}% Enhanced Defense (Based on Character Level)`
|
|
case 'dmg/lvl':
|
|
return `+${formatPerLevelVal(effectiveVal)} to Maximum Damage (Based on Character Level)`
|
|
case 'dmg%/lvl':
|
|
return `+${formatPerLevelVal(effectiveVal)}% Enhanced Maximum Damage (Based on Character Level)`
|
|
case 'att/lvl':
|
|
return `+${formatPerLevelVal(effectiveVal)} to Attack Rating (Based on Character Level)`
|
|
case 'att%/lvl':
|
|
return `${formatPerLevelVal(effectiveVal)}% Bonus to Attack Rating (Based on Character Level)`
|
|
case 'dmg-dem/lvl':
|
|
return `+${formatPerLevelVal(effectiveVal)}% Damage to Demons (Based on Character Level)`
|
|
case 'dmg-und/lvl':
|
|
return `+${formatPerLevelVal(effectiveVal)}% Damage to Undead (Based on Character Level)`
|
|
case 'att-dem/lvl':
|
|
return `+${formatPerLevelVal(effectiveVal)} to Attack Rating against Demons (Based on Character Level)`
|
|
case 'att-und/lvl':
|
|
return `+${formatPerLevelVal(effectiveVal)} to Attack Rating against Undead (Based on Character Level)`
|
|
case 'deadly/lvl':
|
|
return `${formatPerLevelVal(effectiveVal)}% Deadly Strike (Based on Character Level)`
|
|
case 'crush/lvl':
|
|
return `${formatPerLevelVal(effectiveVal)}% Chance of Crushing Blow (Based on Character Level)`
|
|
case 'wounds/lvl':
|
|
return `${formatPerLevelVal(effectiveVal)}% Chance of Open Wounds (Based on Character Level)`
|
|
case 'kick/lvl':
|
|
return `+${formatPerLevelVal(effectiveVal)} Kick Damage (Based on Character Level)`
|
|
case 'thorns/lvl':
|
|
return `Attacker Takes Damage of ${formatPerLevelVal(effectiveVal)} (Based on Character Level)`
|
|
case 'mag%/lvl':
|
|
return `${formatPerLevelVal(effectiveVal)}% Better Chance of Getting Magic Items (Based on Character Level)`
|
|
case 'gold%/lvl':
|
|
return `${formatPerLevelVal(effectiveVal)}% Extra Gold from Monsters (Based on Character Level)`
|
|
case 'res-cold/lvl':
|
|
return `Cold Resist +${formatPerLevelVal(effectiveVal)}% (Based on Character Level)`
|
|
case 'res-fire/lvl':
|
|
return `Fire Resist +${formatPerLevelVal(effectiveVal)}% (Based on Character Level)`
|
|
case 'res-ltng/lvl':
|
|
return `Lightning Resist +${formatPerLevelVal(effectiveVal)}% (Based on Character Level)`
|
|
case 'res-pois/lvl':
|
|
return `Poison Resist +${formatPerLevelVal(effectiveVal)}% (Based on Character Level)`
|
|
case 'abs-cold/lvl':
|
|
return `+${formatPerLevelVal(effectiveVal)} Absorbs Cold Damage (Based on Character Level)`
|
|
case 'abs-fire/lvl':
|
|
return `+${formatPerLevelVal(effectiveVal)} Absorbs Fire Damage (Based on Character Level)`
|
|
case 'abs-ltng/lvl':
|
|
return `+${formatPerLevelVal(effectiveVal)} Absorbs Lightning Damage (Based on Character Level)`
|
|
case 'ac/time':
|
|
return `${plusVal} Defense (Increases During Nighttime)`
|
|
|
|
// --- Defense & Damage Modifiers ---
|
|
case 'ac':
|
|
return `${plusVal} Defense`
|
|
case 'ac%':
|
|
return `${plusVal}% Enhanced Defense`
|
|
case 'ac-miss':
|
|
return `${plusVal} Defense vs. Missile`
|
|
case 'ac-hth':
|
|
return `${plusVal} Defense vs. Melee`
|
|
case 'dmg%':
|
|
return `${plusVal}% Enhanced Damage`
|
|
case 'dmg':
|
|
return `Damage ${plusVal}`
|
|
case 'dmg-min':
|
|
return `${plusVal} to Minimum Damage`
|
|
case 'dmg-max':
|
|
return `${plusVal} to Maximum Damage`
|
|
case 'dmg-norm':
|
|
return min > 0 && max > min ? `Adds ${min}-${max} Damage` : `${plusVal} to Minimum Damage`
|
|
case 'dmg-ac':
|
|
return `${plusVal} to Monster Defense Per Hit`
|
|
case 'reduce-ac':
|
|
return `-${Math.abs(effectiveVal)}% Target Defense`
|
|
case 'dmg-demon':
|
|
return `${plusVal}% Damage to Demons`
|
|
case 'dmg-undead':
|
|
return `${plusVal}% Damage to Undead`
|
|
case 'att-demon':
|
|
return `${plusVal} to Attack Rating against Demons`
|
|
case 'att-undead':
|
|
return `${plusVal} to Attack Rating against Undead`
|
|
case 'demon-heal':
|
|
return `${plusVal} Life after each Demon Kill`
|
|
|
|
// --- Resistances, Max Resistances & Elemental Mastery/Pierce ---
|
|
case 'res-all':
|
|
return `All Resistances ${plusVal}`
|
|
case 'res-all-max':
|
|
return `${plusVal}% to Maximum All Resistances`
|
|
case 'res-fire':
|
|
case 'fireresist':
|
|
return `Fire Resist ${plusVal}%`
|
|
case 'res-fire-max':
|
|
return `${plusVal}% to Maximum Fire Resist`
|
|
case 'res-cold':
|
|
case 'coldresist':
|
|
return `Cold Resist ${plusVal}%`
|
|
case 'res-cold-max':
|
|
return `${plusVal}% to Maximum Cold Resist`
|
|
case 'res-ltng':
|
|
case 'lightresist':
|
|
return `Lightning Resist ${plusVal}%`
|
|
case 'res-ltng-max':
|
|
return `${plusVal}% to Maximum Lightning Resist`
|
|
case 'res-pois':
|
|
case 'poisonresist':
|
|
return `Poison Resist ${plusVal}%`
|
|
case 'res-pois-max':
|
|
return `${plusVal}% to Maximum Poison Resist`
|
|
case 'res-pois-len':
|
|
return `Poison Length Reduced by ${Math.abs(effectiveVal)}%`
|
|
case 'extra-fire':
|
|
return `${plusVal}% to Fire Skill Damage`
|
|
case 'extra-cold':
|
|
return `${plusVal}% to Cold Skill Damage`
|
|
case 'extra-ltng':
|
|
return `${plusVal}% to Lightning Skill Damage`
|
|
case 'extra-pois':
|
|
return `${plusVal}% to Poison Skill Damage`
|
|
case 'pierce-fire':
|
|
return `-${Math.abs(effectiveVal)}% to Enemy Fire Resistance`
|
|
case 'pierce-cold':
|
|
return `-${Math.abs(effectiveVal)}% to Enemy Cold Resistance`
|
|
case 'pierce-ltng':
|
|
return `-${Math.abs(effectiveVal)}% to Enemy Lightning Resistance`
|
|
case 'pierce-pois':
|
|
return `-${Math.abs(effectiveVal)}% to Enemy Poison Resistance`
|
|
|
|
// --- Elemental & Magic Absorb (descFunc 1 & 2) ---
|
|
case 'abs-fire':
|
|
return `${plusVal} Fire Absorb`
|
|
case 'abs-fire%':
|
|
return `Fire Absorb ${effectiveVal}%`
|
|
case 'abs-cold':
|
|
return `${plusVal} Cold Absorb`
|
|
case 'abs-cold%':
|
|
return `Cold Absorb ${effectiveVal}%`
|
|
case 'abs-ltng':
|
|
return `${plusVal} Lightning Absorb`
|
|
case 'abs-ltng%':
|
|
return `Lightning Absorb ${effectiveVal}%`
|
|
case 'abs-mag':
|
|
return `${plusVal} Magic Absorb`
|
|
case 'abs-pois':
|
|
return `${plusVal} Poison Absorb`
|
|
|
|
// --- Speed & Rate Modifiers (Properties.txt func=8) ---
|
|
case 'fcr':
|
|
case 'cast1':
|
|
case 'cast2':
|
|
case 'cast3':
|
|
return `${plusVal}% Faster Cast Rate`
|
|
case 'fhr':
|
|
case 'balance1':
|
|
case 'balance2':
|
|
case 'balance3':
|
|
return `${plusVal}% Faster Hit Recovery`
|
|
case 'ias':
|
|
case 'swing1':
|
|
case 'swing2':
|
|
case 'swing3':
|
|
return `${plusVal}% Increased Attack Speed`
|
|
case 'frw':
|
|
case 'move1':
|
|
case 'move2':
|
|
case 'move3':
|
|
return `${plusVal}% Faster Run/Walk`
|
|
case 'fbr':
|
|
case 'block1':
|
|
case 'block2':
|
|
case 'block3':
|
|
return `${plusVal}% Faster Block Rate`
|
|
case 'block':
|
|
return `${effectiveVal}% Increased Chance of Blocking`
|
|
|
|
// --- Combat & Leech & Reduction Modifiers ---
|
|
case 'mag%':
|
|
return `${effectiveVal}% Better Chance of Getting Magic Items`
|
|
case 'gold%':
|
|
return `${effectiveVal}% Extra Gold from Monsters`
|
|
case 'lifesteal':
|
|
case 'leech':
|
|
return `${effectiveVal}% Life stolen per hit`
|
|
case 'manasteal':
|
|
return `${effectiveVal}% Mana stolen per hit`
|
|
case 'crush':
|
|
return `${effectiveVal}% Chance of Crushing Blow`
|
|
case 'openwounds':
|
|
return `${effectiveVal}% Chance of Open Wounds`
|
|
case 'deadly':
|
|
return `${effectiveVal}% Deadly Strike`
|
|
case 'dmag':
|
|
case 'red-dmg%':
|
|
return `Damage Reduced by ${effectiveVal}%`
|
|
case 'dmag-ac':
|
|
case 'red-dmg':
|
|
return `Damage Reduced by ${effectiveVal}`
|
|
case 'mag-ac':
|
|
case 'red-mag':
|
|
return `Magic Damage Reduced by ${effectiveVal}`
|
|
case 'dmg-to-mana':
|
|
return `${effectiveVal}% Damage Taken Goes To Mana`
|
|
case 'thorns':
|
|
return `Attacker Takes Damage of ${effectiveVal}`
|
|
case 'light-thorns':
|
|
return `Attacker Takes Lightning Damage of ${effectiveVal}`
|
|
case 'mana-kill':
|
|
return `${plusVal} to Mana after each Kill`
|
|
case 'heal-kill':
|
|
case 'hp-kill':
|
|
return `${plusVal} Life after each Kill`
|
|
|
|
// --- Durability, Quantity, Requirements, Sockets & Special Flags ---
|
|
case 'rep-dur': {
|
|
// D2Client.dll descFunc=11 + expansionstring.tbl ModStre9t / ModStre9u:
|
|
// Rate v (from par column) is durability repaired per 100 seconds -> seconds = Math.floor(100 / v)
|
|
const rate = Number(par) || effectiveVal || 1
|
|
const seconds = Math.floor(100 / rate)
|
|
return seconds <= 1
|
|
? 'Repairs 1 durability per second'
|
|
: `Repairs 1 durability in ${seconds} seconds`
|
|
}
|
|
case 'rep-quant':
|
|
return 'Replenishes quantity'
|
|
case 'ease':
|
|
return `Requirements ${effectiveVal > 0 ? `+${effectiveVal}` : effectiveVal}%`
|
|
case 'ethereal':
|
|
return 'Ethereal (Cannot be Repaired)'
|
|
case 'indestruct':
|
|
return 'Indestructible'
|
|
case 'sock':
|
|
return `Socketed (${effectiveVal})`
|
|
case 'stack':
|
|
return 'Increased Stack Size'
|
|
case 'dur':
|
|
return `${plusVal} Maximum Durability`
|
|
case 'dur%':
|
|
return `Increase Maximum Durability ${effectiveVal}%`
|
|
case 'no-freeze':
|
|
case 'nofreeze':
|
|
return 'Cannot Be Frozen'
|
|
case 'half-freeze':
|
|
return 'Half Freeze Duration'
|
|
case 'noheal':
|
|
case 'no-heal':
|
|
return 'Prevent Monster Heal'
|
|
case 'rip':
|
|
return 'Slain Monsters Rest in Peace'
|
|
case 'knock':
|
|
return 'Knockback'
|
|
case 'howl':
|
|
return `Hit Causes Monster to Flee ${effectiveVal}%`
|
|
case 'stupidity':
|
|
case 'blind':
|
|
return effectiveVal > 1 ? `Hit Blinds Target +${effectiveVal}` : 'Hit Blinds Target'
|
|
case 'rep-life':
|
|
case 'regen':
|
|
return effectiveVal >= 0 ? `Replenish Life +${effectiveVal}` : `Drain Life ${effectiveVal}`
|
|
case 'regen-mana':
|
|
case 'manaregen':
|
|
return `Regenerate Mana ${effectiveVal}%`
|
|
case 'pierce':
|
|
return effectiveVal > 0 ? `Piercing Attack (${effectiveVal}%)` : 'Piercing Attack'
|
|
case 'ignore-ac':
|
|
return "Ignore Target's Defense"
|
|
case 'freeze':
|
|
return effectiveVal > 1 ? `Freezes target +${effectiveVal}` : 'Freezes target'
|
|
case 'slow':
|
|
return `Slows Target by ${effectiveVal}%`
|
|
case 'light':
|
|
return `${plusVal} to Light Radius`
|
|
case 'cheap':
|
|
return `Reduces all Vendor Prices ${effectiveVal}%`
|
|
case 'addxp':
|
|
return `${plusVal}% to Experience Gained`
|
|
case 'magicarrow':
|
|
return 'Fires Magic Arrows'
|
|
case 'explosivearrow':
|
|
return 'Fires Explosive Arrows or Bolts'
|
|
case 'reanimate': {
|
|
const monId = Number(par)
|
|
const monName = !Number.isNaN(monId) && CANONICAL_REANIMATE_MONSTERS[monId]
|
|
? CANONICAL_REANIMATE_MONSTERS[monId]
|
|
: (par || 'Returned')
|
|
return `${effectiveVal}% Reanimate as: ${monName}`
|
|
}
|
|
case 'bloody':
|
|
return 'Extra Blood'
|
|
case 'fade':
|
|
return 'Fade'
|
|
case 'state':
|
|
return String(par).toLowerCase() === 'monsterset' ? 'Transforms into Vampire' : 'Display Aura'
|
|
|
|
// --- Attack Rating & Elemental Damages ---
|
|
case 'att':
|
|
return `${plusVal} to Attack Rating`
|
|
case 'att%':
|
|
return `${effectiveVal}% Bonus to Attack Rating`
|
|
case 'dmg-fire':
|
|
return min > 0 && max > min ? `Adds ${min}-${max} Fire Damage` : `Adds ${effectiveVal} Fire Damage`
|
|
case 'fire-min':
|
|
return `${plusVal} to Minimum Fire Damage`
|
|
case 'fire-max':
|
|
return `${plusVal} to Maximum Fire Damage`
|
|
case 'dmg-cold':
|
|
return min > 0 && max > min ? `Adds ${min}-${max} Cold Damage` : `Adds ${effectiveVal} Cold Damage`
|
|
case 'cold-min':
|
|
return `${plusVal} to Minimum Cold Damage`
|
|
case 'cold-max':
|
|
return `${plusVal} to Maximum Cold Damage`
|
|
case 'cold-len':
|
|
return `Cold Duration: ${Math.max(1, Math.round(effectiveVal / 25))} Seconds`
|
|
case 'dmg-ltng':
|
|
return min > 0 && max > min ? `Adds ${min}-${max} Lightning Damage` : `Adds ${effectiveVal} Lightning Damage`
|
|
case 'ltng-min':
|
|
return `${plusVal} to Minimum Lightning Damage`
|
|
case 'ltng-max':
|
|
return `${plusVal} to Maximum Lightning Damage`
|
|
case 'dmg-pois':
|
|
return `+${min || effectiveVal} Poison Damage Over ${Math.round(Number(par || max) / 25) || 3} Seconds`
|
|
case 'pois-min':
|
|
return `${plusVal} to Minimum Poison Damage`
|
|
case 'pois-max':
|
|
return `${plusVal} to Maximum Poison Damage`
|
|
case 'pois-len':
|
|
return `Poison Duration: ${Math.max(1, Math.round(effectiveVal / 25))} Seconds`
|
|
case 'dmg-mag':
|
|
return min > 0 && max > min ? `Adds ${min}-${max} Magic Damage` : `Adds ${effectiveVal} Magic Damage`
|
|
case 'dmg-elem':
|
|
return min > 0 && max > min ? `Adds ${min}-${max} Elemental Damage` : `Adds ${effectiveVal} Elemental Damage`
|
|
default:
|
|
return `${code}: ${plusVal}${par ? ` (${par})` : ''}`
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Canonical 1.13c ItemStatCost.txt descpriority lookup table (0..180).
|
|
* Higher priority stats appear higher on the item tooltip.
|
|
*/
|
|
const PROP_DESC_PRIORITY: Record<string, number> = {
|
|
'ac/time': 180,
|
|
'att-skill': 160,
|
|
'hit-skill': 160,
|
|
'gethit-skill': 160,
|
|
'kill-skill': 160,
|
|
'death-skill': 160,
|
|
'levelup-skill': 160,
|
|
indestruct: 160,
|
|
aura: 159,
|
|
allskills: 158,
|
|
fireskill: 157,
|
|
skilltab: 151,
|
|
ama: 150,
|
|
pal: 150,
|
|
nec: 150,
|
|
sor: 150,
|
|
bar: 150,
|
|
dru: 150,
|
|
ass: 150,
|
|
randclassskill: 150,
|
|
classskills: 150,
|
|
frw: 148,
|
|
move1: 148,
|
|
move2: 148,
|
|
move3: 148,
|
|
ias: 145,
|
|
swing1: 145,
|
|
swing2: 145,
|
|
swing3: 145,
|
|
fcr: 142,
|
|
cast1: 142,
|
|
cast2: 142,
|
|
cast3: 142,
|
|
fhr: 139,
|
|
balance1: 139,
|
|
balance2: 139,
|
|
balance3: 139,
|
|
fbr: 136,
|
|
block1: 136,
|
|
block2: 136,
|
|
block3: 136,
|
|
block: 134,
|
|
explosivearrow: 133,
|
|
pierce: 132,
|
|
magicarrow: 131,
|
|
'dmg%': 129,
|
|
'dmg%/lvl': 128,
|
|
'dmg-norm': 127,
|
|
'dmg-min': 126,
|
|
'dmg-max': 125,
|
|
'dmg/lvl': 125,
|
|
dmg: 122,
|
|
'kick/lvl': 120,
|
|
'ignore-ac': 119,
|
|
'reduce-ac': 118,
|
|
'att%': 117,
|
|
'att%/lvl': 116,
|
|
att: 115,
|
|
'att/lvl': 114,
|
|
'dmg-demon': 112,
|
|
'dmg-dem/lvl': 111,
|
|
'att-demon': 110,
|
|
'att-dem/lvl': 109,
|
|
'dmg-undead': 108,
|
|
'dmg-und/lvl': 107,
|
|
'att-undead': 106,
|
|
'att-und/lvl': 105,
|
|
'dmg-mag': 104,
|
|
'dmg-fire': 102,
|
|
'fire-min': 102,
|
|
'dmg-elem': 102,
|
|
'fire-max': 101,
|
|
'dmg-ltng': 99,
|
|
'ltng-min': 99,
|
|
'ltng-max': 98,
|
|
'dmg-cold': 96,
|
|
'cold-min': 96,
|
|
'cold-max': 95,
|
|
'dmg-pois': 92,
|
|
'pois-min': 92,
|
|
'pois-max': 91,
|
|
manasteal: 89,
|
|
lifesteal: 88,
|
|
leech: 88,
|
|
'pierce-fire': 88,
|
|
'pierce-ltng': 88,
|
|
'pierce-cold': 88,
|
|
'pierce-pois': 88,
|
|
'extra-fire': 88,
|
|
'extra-ltng': 88,
|
|
'extra-cold': 88,
|
|
'extra-pois': 88,
|
|
crush: 87,
|
|
'crush/lvl': 86,
|
|
deadly: 85,
|
|
'deadly/lvl': 84,
|
|
openwounds: 83,
|
|
'wounds/lvl': 82,
|
|
noheal: 81,
|
|
'no-heal': 81,
|
|
skill: 81,
|
|
rip: 81,
|
|
'skill-rand': 81,
|
|
oskill: 81,
|
|
stupidity: 80,
|
|
blind: 80,
|
|
howl: 79,
|
|
freeze: 78,
|
|
slow: 77,
|
|
knock: 76,
|
|
'dmg-ac': 75,
|
|
'ac%': 74,
|
|
'ac%/lvl': 73,
|
|
'ac/lvl': 72,
|
|
ac: 71,
|
|
'ac-hth': 70,
|
|
'ac-miss': 69,
|
|
'all-stats': 68,
|
|
str: 67,
|
|
'str/lvl': 66,
|
|
dex: 65,
|
|
'dex/lvl': 64,
|
|
vit: 63,
|
|
'vit/lvl': 62,
|
|
enr: 61,
|
|
'enr/lvl': 60,
|
|
hp: 59,
|
|
'hp%': 58,
|
|
'hp/lvl': 57,
|
|
regen: 56,
|
|
'rep-life': 56,
|
|
mana: 55,
|
|
'mana%': 54,
|
|
'mana/lvl': 53,
|
|
'regen-mana': 52,
|
|
manaregen: 52,
|
|
stam: 51,
|
|
'stam/lvl': 50,
|
|
stamdrain: 49,
|
|
'regen-stam': 48,
|
|
'regen-stam/lvl': 47,
|
|
'res-pois-max': 45,
|
|
'res-cold-max': 44,
|
|
'res-ltng-max': 43,
|
|
'res-fire-max': 42,
|
|
'res-all-max': 42,
|
|
'res-all': 41,
|
|
'res-cold': 40,
|
|
coldresist: 40,
|
|
'res-cold/lvl': 39,
|
|
'res-ltng': 38,
|
|
lightresist: 38,
|
|
'res-ltng/lvl': 37,
|
|
'res-fire': 36,
|
|
fireresist: 36,
|
|
'res-fire/lvl': 35,
|
|
'res-pois': 34,
|
|
poisonresist: 34,
|
|
'abs-mag': 33,
|
|
'res-pois/lvl': 33,
|
|
'abs-cold/lvl': 32,
|
|
'abs-cold': 31,
|
|
'abs-ltng/lvl': 30,
|
|
'abs-ltng': 29,
|
|
'abs-fire/lvl': 28,
|
|
'abs-fire': 27,
|
|
'abs-cold%': 25,
|
|
'abs-ltng%': 24,
|
|
'abs-fire%': 23,
|
|
'red-dmg': 22,
|
|
'dmag-ac': 22,
|
|
'red-dmg%': 22,
|
|
dmag: 22,
|
|
'red-mag': 21,
|
|
'mag-ac': 21,
|
|
nofreeze: 20,
|
|
'no-freeze': 20,
|
|
'half-freeze': 19,
|
|
'res-pois-len': 18,
|
|
reanimate: 17,
|
|
'mana-kill': 16,
|
|
'heal-kill': 16,
|
|
'hp-kill': 16,
|
|
'demon-heal': 15,
|
|
'light-thorns': 14,
|
|
thorns: 13,
|
|
'thorns/lvl': 12,
|
|
'dmg-to-mana': 11,
|
|
addxp: 11,
|
|
'gold%': 10,
|
|
'gold%/lvl': 9,
|
|
'mag%': 8,
|
|
cheap: 8,
|
|
'mag%/lvl': 7,
|
|
light: 6,
|
|
stack: 4,
|
|
'dur%': 3,
|
|
dur: 3,
|
|
'rep-quant': 2,
|
|
'rep-dur': 1,
|
|
charged: 1,
|
|
ease: 0,
|
|
sock: 0,
|
|
ethereal: 0,
|
|
bloody: 0,
|
|
fade: 0,
|
|
state: 0,
|
|
'res-mag': 41,
|
|
'res-mag-max': 46,
|
|
'abs-mag%': 26,
|
|
kick: 121,
|
|
throw: 5,
|
|
'dmg-fire/lvl': 100,
|
|
'dmg-ltng/lvl': 97,
|
|
'dmg-cold/lvl': 94,
|
|
'dmg-pois/lvl': 90,
|
|
'att-mon%': 108,
|
|
'dmg-mon%': 106,
|
|
}
|
|
|
|
/**
|
|
* Canonical 1.13c ItemStatCost.txt numeric Stat ID lookup table (0..358, plus 359 for ethereal).
|
|
* Used as the secondary tie-breaker when two distinct properties share the same descpriority.
|
|
*/
|
|
export const PROP_STAT_ID: Record<string, number> = {
|
|
ac: 31,
|
|
'ac-miss': 32,
|
|
'ac-hth': 33,
|
|
'red-dmg': 34,
|
|
'dmag-ac': 34,
|
|
'red-mag': 35,
|
|
'mag-ac': 35,
|
|
'red-dmg%': 36,
|
|
dmag: 36,
|
|
'ac%': 16,
|
|
str: 0,
|
|
enr: 1,
|
|
dex: 2,
|
|
vit: 3,
|
|
hp: 7,
|
|
mana: 9,
|
|
stam: 11,
|
|
'dmg%': 17,
|
|
att: 19,
|
|
block: 20,
|
|
'dmg-min': 21,
|
|
'dmg-norm': 21,
|
|
'dmg-max': 22,
|
|
'regen-mana': 27,
|
|
manaregen: 27,
|
|
'regen-stam': 28,
|
|
'res-mag': 37,
|
|
'res-mag-max': 38,
|
|
'res-fire': 39,
|
|
fireresist: 39,
|
|
'res-all': 39,
|
|
'all-res': 39,
|
|
'res-fire-max': 40,
|
|
'res-all-max': 40,
|
|
'res-ltng': 41,
|
|
lightresist: 41,
|
|
'res-ltng-max': 42,
|
|
'res-cold': 43,
|
|
coldresist: 43,
|
|
'res-cold-max': 44,
|
|
'res-pois': 45,
|
|
poisonresist: 45,
|
|
'res-pois-max': 46,
|
|
'fire-min': 48,
|
|
'dmg-fire': 48,
|
|
'dmg-elem': 48,
|
|
'dmg-elem-min': 48,
|
|
'fire-max': 49,
|
|
'dmg-elem-max': 49,
|
|
'ltng-min': 50,
|
|
'dmg-ltng': 50,
|
|
'ltng-max': 51,
|
|
'dmg-mag': 52,
|
|
'cold-min': 54,
|
|
'dmg-cold': 54,
|
|
'cold-max': 55,
|
|
'cold-len': 56,
|
|
'pois-min': 57,
|
|
'dmg-pois': 57,
|
|
'pois-max': 58,
|
|
'pois-len': 59,
|
|
lifesteal: 60,
|
|
leech: 60,
|
|
manasteal: 62,
|
|
dur: 73,
|
|
regen: 74,
|
|
'rep-life': 74,
|
|
'dur%': 75,
|
|
'hp%': 76,
|
|
'mana%': 77,
|
|
thorns: 78,
|
|
'gold%': 79,
|
|
'mag%': 80,
|
|
knock: 81,
|
|
time: 82,
|
|
ama: 83,
|
|
pal: 83,
|
|
nec: 83,
|
|
sor: 83,
|
|
bar: 83,
|
|
dru: 83,
|
|
ass: 83,
|
|
randclassskill: 83,
|
|
classskills: 83,
|
|
addxp: 85,
|
|
'heal-kill': 86,
|
|
'hp-kill': 86,
|
|
cheap: 87,
|
|
herb: 88,
|
|
light: 89,
|
|
color: 90,
|
|
ease: 91,
|
|
levelreq: 92,
|
|
ias: 93,
|
|
swing1: 93,
|
|
swing2: 93,
|
|
swing3: 93,
|
|
frw: 96,
|
|
move1: 96,
|
|
move2: 96,
|
|
move3: 96,
|
|
oskill: 97,
|
|
state: 98,
|
|
fhr: 99,
|
|
balance1: 99,
|
|
balance2: 99,
|
|
balance3: 99,
|
|
fbr: 102,
|
|
block1: 102,
|
|
block2: 102,
|
|
block3: 102,
|
|
fcr: 105,
|
|
cast1: 105,
|
|
cast2: 105,
|
|
cast3: 105,
|
|
skill: 107,
|
|
'skill-rand': 107,
|
|
rip: 108,
|
|
'res-pois-len': 110,
|
|
dmg: 111,
|
|
howl: 112,
|
|
stupidity: 113,
|
|
blind: 113,
|
|
'dmg-to-mana': 114,
|
|
'ignore-ac': 115,
|
|
'reduce-ac': 116,
|
|
noheal: 117,
|
|
'no-heal': 117,
|
|
'half-freeze': 118,
|
|
'att%': 119,
|
|
'dmg-ac': 120,
|
|
'dmg-demon': 121,
|
|
'dmg-undead': 122,
|
|
'att-demon': 123,
|
|
'att-undead': 124,
|
|
throw: 125,
|
|
fireskill: 126,
|
|
allskills: 127,
|
|
'light-thorns': 128,
|
|
freeze: 134,
|
|
openwounds: 135,
|
|
crush: 136,
|
|
kick: 137,
|
|
'mana-kill': 138,
|
|
'demon-heal': 139,
|
|
bloody: 140,
|
|
deadly: 141,
|
|
'abs-fire%': 142,
|
|
'abs-fire': 143,
|
|
'abs-ltng%': 144,
|
|
'abs-ltng': 145,
|
|
'abs-mag%': 146,
|
|
'abs-mag': 147,
|
|
'abs-cold%': 148,
|
|
'abs-cold': 149,
|
|
slow: 150,
|
|
aura: 151,
|
|
indestruct: 152,
|
|
nofreeze: 153,
|
|
'no-freeze': 153,
|
|
stamdrain: 154,
|
|
reanimate: 155,
|
|
pierce: 156,
|
|
magicarrow: 157,
|
|
explosivearrow: 158,
|
|
'dmg-throw': 159,
|
|
'att-mon%': 179,
|
|
'dmg-mon%': 180,
|
|
fade: 181,
|
|
skilltab: 188,
|
|
sock: 194,
|
|
'att-skill': 195,
|
|
'kill-skill': 196,
|
|
'death-skill': 197,
|
|
'hit-skill': 198,
|
|
'levelup-skill': 199,
|
|
'gethit-skill': 201,
|
|
charged: 204,
|
|
'ac/lvl': 214,
|
|
'ac%/lvl': 215,
|
|
'hp/lvl': 216,
|
|
'mana/lvl': 217,
|
|
'dmg/lvl': 218,
|
|
'dmg%/lvl': 219,
|
|
'str/lvl': 220,
|
|
'dex/lvl': 221,
|
|
'enr/lvl': 222,
|
|
'vit/lvl': 223,
|
|
'att/lvl': 224,
|
|
'att%/lvl': 225,
|
|
'dmg-cold/lvl': 226,
|
|
'dmg-fire/lvl': 227,
|
|
'dmg-ltng/lvl': 228,
|
|
'dmg-pois/lvl': 229,
|
|
'res-cold/lvl': 230,
|
|
'res-fire/lvl': 231,
|
|
'res-ltng/lvl': 232,
|
|
'res-pois/lvl': 233,
|
|
'abs-cold/lvl': 234,
|
|
'abs-fire/lvl': 235,
|
|
'abs-ltng/lvl': 236,
|
|
'abs-pois/lvl': 237,
|
|
'thorns/lvl': 238,
|
|
'gold%/lvl': 239,
|
|
'mag%/lvl': 240,
|
|
'regen-stam/lvl': 241,
|
|
'stam/lvl': 242,
|
|
'dmg-dem/lvl': 243,
|
|
'dmg-und/lvl': 244,
|
|
'att-dem/lvl': 245,
|
|
'att-und/lvl': 246,
|
|
'crush/lvl': 247,
|
|
'wounds/lvl': 248,
|
|
'kick/lvl': 249,
|
|
'deadly/lvl': 250,
|
|
'rep-dur': 252,
|
|
'rep-quant': 253,
|
|
stack: 254,
|
|
'ac/time': 268,
|
|
'extra-fire': 329,
|
|
'extra-ltng': 330,
|
|
'extra-cold': 331,
|
|
'extra-pois': 332,
|
|
'pierce-fire': 333,
|
|
'pierce-ltng': 334,
|
|
'pierce-cold': 335,
|
|
'pierce-pois': 336,
|
|
'all-stats': 0,
|
|
ethereal: 359,
|
|
}
|
|
|
|
const PROP_CODE_TO_MPQ_PROP: Record<string, string> = {
|
|
ias: 'swing1',
|
|
fcr: 'cast1',
|
|
fhr: 'balance1',
|
|
frw: 'move1',
|
|
fbr: 'block1',
|
|
classskills: 'ama',
|
|
fireresist: 'res-fire',
|
|
coldresist: 'res-cold',
|
|
lightresist: 'res-ltng',
|
|
poisonresist: 'res-pois',
|
|
'all-res': 'res-all',
|
|
dmag: 'red-dmg%',
|
|
'dmag-ac': 'red-dmg',
|
|
'mag-ac': 'red-mag',
|
|
leech: 'lifesteal',
|
|
'rep-life': 'regen',
|
|
manaregen: 'regen-mana',
|
|
'hp-kill': 'heal-kill',
|
|
'no-freeze': 'nofreeze',
|
|
'no-heal': 'noheal',
|
|
blind: 'stupidity',
|
|
}
|
|
|
|
const COMPOSITE_OR_SPECIAL_PRIORITY_CODES = new Set([
|
|
'dmg-norm',
|
|
'dmg-min',
|
|
'dmg-max',
|
|
'res-all',
|
|
'all-res',
|
|
'all-stats',
|
|
'dur',
|
|
'cold-len',
|
|
'pois-len',
|
|
'levelreq',
|
|
])
|
|
|
|
let runtimePropertiesTable: PropertiesTable | undefined
|
|
let runtimeItemStatCostTable: ItemStatCostTable | undefined
|
|
|
|
export interface RuntimePropertyTables {
|
|
readonly properties?: PropertiesTable | undefined
|
|
readonly itemStatCost?: ItemStatCostTable | undefined
|
|
}
|
|
|
|
/**
|
|
* Injects runtime MPQ-parsed Properties.txt and ItemStatCost.txt tables into the tooltip engine.
|
|
*/
|
|
export function setRuntimePropertyTables(
|
|
properties?: PropertiesTable,
|
|
itemStatCost?: ItemStatCostTable,
|
|
): void {
|
|
runtimePropertiesTable = properties
|
|
runtimeItemStatCostTable = itemStatCost
|
|
}
|
|
|
|
/**
|
|
* Resolves live metadata (descPriority, statId, descFunc, descVal, dgrp) for a property code
|
|
* from the active MPQ Properties.txt -> ItemStatCost.txt tables.
|
|
*/
|
|
export function getRuntimePropertyMetadata(
|
|
code: string,
|
|
tables?: RuntimePropertyTables,
|
|
): { descPriority: number; statId: number; descFunc?: number; descVal?: number; dgrp?: number } | undefined {
|
|
const defaults = getDefaultItemPropertiesAndStats()
|
|
const propsTable = tables?.properties ?? runtimePropertiesTable ?? defaults.properties
|
|
const iscTable = tables?.itemStatCost ?? runtimeItemStatCostTable ?? defaults.stats
|
|
|
|
if (!propsTable || !iscTable) {
|
|
return undefined
|
|
}
|
|
|
|
const norm = (code || '').trim().toLowerCase()
|
|
if (!norm) return undefined
|
|
|
|
const lookupCode = PROP_CODE_TO_MPQ_PROP[norm] ?? norm
|
|
const propRec = propsTable.byCode.get(norm) ?? propsTable.byCode.get(lookupCode)
|
|
|
|
let statRec: ItemStatCostRecord | undefined
|
|
if (propRec?.stat1) {
|
|
statRec = iscTable.byStat.get(propRec.stat1)
|
|
}
|
|
if (!statRec && propRec) {
|
|
try {
|
|
const resolved = resolveProperty(propRec.code, undefined, 1, 1, {
|
|
properties: propsTable,
|
|
stats: iscTable,
|
|
})
|
|
if (resolved[0]?.stat) {
|
|
statRec = iscTable.byStat.get(resolved[0].stat)
|
|
}
|
|
} catch {
|
|
// Fallback below if resolveProperty fails
|
|
}
|
|
}
|
|
if (!statRec) {
|
|
statRec = iscTable.byStat.get(norm)
|
|
}
|
|
|
|
if (!propRec && !statRec && !(norm in PROP_DESC_PRIORITY) && !(norm in PROP_STAT_ID)) {
|
|
return undefined
|
|
}
|
|
|
|
const statId = statRec?.id ?? PROP_STAT_ID[norm] ?? PROP_STAT_ID[lookupCode] ?? -1
|
|
let descPriority: number
|
|
if (
|
|
COMPOSITE_OR_SPECIAL_PRIORITY_CODES.has(norm) ||
|
|
COMPOSITE_OR_SPECIAL_PRIORITY_CODES.has(lookupCode) ||
|
|
!statRec
|
|
) {
|
|
descPriority = PROP_DESC_PRIORITY[norm] ?? PROP_DESC_PRIORITY[lookupCode] ?? statRec?.descPriority ?? 10
|
|
} else {
|
|
descPriority = statRec.descPriority
|
|
}
|
|
|
|
return {
|
|
descPriority,
|
|
statId,
|
|
...(statRec
|
|
? {
|
|
descFunc: statRec.descFunc,
|
|
descVal: statRec.descVal,
|
|
dgrp: statRec.dgrp,
|
|
}
|
|
: {}),
|
|
}
|
|
}
|
|
|
|
export function getPropertyPriority(code: string, tables?: RuntimePropertyTables): number {
|
|
const norm = code.trim().toLowerCase()
|
|
const meta = getRuntimePropertyMetadata(norm, tables)
|
|
if (meta !== undefined) {
|
|
return meta.descPriority
|
|
}
|
|
return PROP_DESC_PRIORITY[norm] ?? 10
|
|
}
|
|
|
|
export function getPropertyStatId(code: string, tables?: RuntimePropertyTables): number {
|
|
const norm = code.trim().toLowerCase()
|
|
const meta = getRuntimePropertyMetadata(norm, tables)
|
|
if (meta !== undefined && meta.statId >= 0) {
|
|
return meta.statId
|
|
}
|
|
const lookup = PROP_CODE_TO_MPQ_PROP[norm] ?? norm
|
|
return PROP_STAT_ID[norm] ?? PROP_STAT_ID[lookup] ?? -1
|
|
}
|
|
|
|
const ALIAS_CODE_MAP: Record<string, string> = {
|
|
swing1: 'ias',
|
|
swing2: 'ias',
|
|
swing3: 'ias',
|
|
cast1: 'fcr',
|
|
cast2: 'fcr',
|
|
cast3: 'fcr',
|
|
balance1: 'fhr',
|
|
balance2: 'fhr',
|
|
balance3: 'fhr',
|
|
move1: 'frw',
|
|
move2: 'frw',
|
|
move3: 'frw',
|
|
block1: 'fbr',
|
|
block2: 'fbr',
|
|
block3: 'fbr',
|
|
fireresist: 'res-fire',
|
|
coldresist: 'res-cold',
|
|
lightresist: 'res-ltng',
|
|
poisonresist: 'res-pois',
|
|
'all-res': 'res-all',
|
|
dmag: 'red-dmg%',
|
|
'dmag-ac': 'red-dmg',
|
|
'mag-ac': 'red-mag',
|
|
leech: 'lifesteal',
|
|
'rep-life': 'regen',
|
|
manaregen: 'regen-mana',
|
|
'hp-kill': 'heal-kill',
|
|
'no-freeze': 'nofreeze',
|
|
'no-heal': 'noheal',
|
|
blind: 'stupidity',
|
|
}
|
|
|
|
/**
|
|
* Aggregates and sorts item properties per Diablo II 1.13c tooltip rules (Issue #125, #130, #133):
|
|
* 1. Combines elemental min/max pairs (`fire-min` + `fire-max` -> `Adds X-Y Fire Damage`, etc.)
|
|
* 2. Combines physical min/max pairs (`dmg-min` + `dmg-max` -> `Adds X-Y Damage`)
|
|
* 3. Merges 4-element resistances (`dgrp=2`) into `All Resistances +X` ONLY when all 4 are positive and strictly equal
|
|
* 4. Merges 4 core attributes (`dgrp=1`) into `+X to All Attributes` ONLY when all 4 are positive and strictly equal
|
|
* 5. Accumulates identical scalar stats across prefixes, suffixes, and base modifiers
|
|
* 6. Sorts all resulting properties descending by `ItemStatCost.txt` `descpriority`, with secondary ascending `statId` tie-breaker
|
|
*/
|
|
export function aggregateAndSortProperties(
|
|
rawProps: readonly RolledItemProp[],
|
|
tables?: RuntimePropertyTables,
|
|
): RolledItemProp[] {
|
|
let fireMin = 0
|
|
let fireMax = 0
|
|
let ltngMin = 0
|
|
let ltngMax = 0
|
|
let coldMin = 0
|
|
let coldMax = 0
|
|
let coldLen = 0
|
|
let poisMin = 0
|
|
let poisMax = 0
|
|
let poisLen = 0
|
|
let physMin = 0
|
|
let physMax = 0
|
|
|
|
let fireRes = 0
|
|
let coldRes = 0
|
|
let ltngRes = 0
|
|
let poisRes = 0
|
|
|
|
let strVal = 0
|
|
let dexVal = 0
|
|
let vitVal = 0
|
|
let enrVal = 0
|
|
|
|
const otherMerged = new Map<string, RolledItemProp>()
|
|
const unmergedProcs: RolledItemProp[] = []
|
|
|
|
const NON_ACCUMULATABLE = new Set([
|
|
'hit-skill',
|
|
'att-skill',
|
|
'gethit-skill',
|
|
'kill-skill',
|
|
'death-skill',
|
|
'levelup-skill',
|
|
'charged',
|
|
'skill-rand',
|
|
'dmg-mag',
|
|
])
|
|
|
|
for (const p of rawProps) {
|
|
const rawCode = (p.code || '').trim().toLowerCase()
|
|
if (!rawCode) continue
|
|
const code = ALIAS_CODE_MAP[rawCode] ?? rawCode
|
|
const val = p.value
|
|
|
|
// 1. Fire damage bucket
|
|
if (code === 'dmg-fire') {
|
|
fireMin += p.min > 0 ? p.min : val
|
|
fireMax += p.max > 0 ? p.max : val
|
|
continue
|
|
}
|
|
if (code === 'fire-min') {
|
|
fireMin += val
|
|
continue
|
|
}
|
|
if (code === 'fire-max') {
|
|
fireMax += val
|
|
continue
|
|
}
|
|
|
|
// 2. Lightning damage bucket
|
|
if (code === 'dmg-ltng') {
|
|
ltngMin += p.min > 0 ? p.min : val
|
|
ltngMax += p.max > 0 ? p.max : val
|
|
continue
|
|
}
|
|
if (code === 'ltng-min') {
|
|
ltngMin += val
|
|
continue
|
|
}
|
|
if (code === 'ltng-max') {
|
|
ltngMax += val
|
|
continue
|
|
}
|
|
|
|
// 3. Cold damage bucket
|
|
if (code === 'dmg-cold') {
|
|
coldMin += p.min > 0 ? p.min : val
|
|
coldMax += p.max > 0 ? p.max : val
|
|
coldLen += Number(p.param) || 0
|
|
continue
|
|
}
|
|
if (code === 'cold-min') {
|
|
coldMin += val
|
|
continue
|
|
}
|
|
if (code === 'cold-max') {
|
|
coldMax += val
|
|
continue
|
|
}
|
|
if (code === 'cold-len') {
|
|
coldLen += val
|
|
continue
|
|
}
|
|
|
|
// 4. Tri-element damage
|
|
if (code === 'dmg-elem') {
|
|
const eMin = p.min > 0 ? p.min : val
|
|
const eMax = p.max > 0 ? p.max : val
|
|
fireMin += eMin
|
|
fireMax += eMax
|
|
ltngMin += eMin
|
|
ltngMax += eMax
|
|
coldMin += eMin
|
|
coldMax += eMax
|
|
coldLen += Number(p.param) || 0
|
|
continue
|
|
}
|
|
|
|
// 5. Poison damage bucket
|
|
if (code === 'dmg-pois') {
|
|
poisMin += p.min > 0 ? p.min : val
|
|
poisMax += p.max > 0 ? p.max : val
|
|
poisLen += Number(p.param) || 75
|
|
continue
|
|
}
|
|
if (code === 'pois-min') {
|
|
poisMin += val
|
|
continue
|
|
}
|
|
if (code === 'pois-max') {
|
|
poisMax += val
|
|
continue
|
|
}
|
|
if (code === 'pois-len') {
|
|
poisLen += val
|
|
continue
|
|
}
|
|
|
|
// 6. Physical min/max damage bucket
|
|
if (code === 'dmg-norm') {
|
|
physMin += p.min > 0 ? p.min : val
|
|
physMax += p.max > 0 ? p.max : val
|
|
continue
|
|
}
|
|
if (code === 'dmg-min') {
|
|
physMin += val
|
|
continue
|
|
}
|
|
if (code === 'dmg-max') {
|
|
physMax += val
|
|
continue
|
|
}
|
|
|
|
// 7. Resistances bucket (dgrp=2)
|
|
if (code === 'res-all' || code === 'all-res') {
|
|
fireRes += val
|
|
coldRes += val
|
|
ltngRes += val
|
|
poisRes += val
|
|
continue
|
|
}
|
|
if (code === 'res-fire') {
|
|
fireRes += val
|
|
continue
|
|
}
|
|
if (code === 'res-cold') {
|
|
coldRes += val
|
|
continue
|
|
}
|
|
if (code === 'res-ltng') {
|
|
ltngRes += val
|
|
continue
|
|
}
|
|
if (code === 'res-pois') {
|
|
poisRes += val
|
|
continue
|
|
}
|
|
|
|
// 8. Attributes bucket (dgrp=1)
|
|
if (code === 'all-stats') {
|
|
strVal += val
|
|
dexVal += val
|
|
vitVal += val
|
|
enrVal += val
|
|
continue
|
|
}
|
|
if (code === 'str') {
|
|
strVal += val
|
|
continue
|
|
}
|
|
if (code === 'dex') {
|
|
dexVal += val
|
|
continue
|
|
}
|
|
if (code === 'vit') {
|
|
vitVal += val
|
|
continue
|
|
}
|
|
if (code === 'enr') {
|
|
enrVal += val
|
|
continue
|
|
}
|
|
|
|
// 9. Procs / Charges / Ranges
|
|
if (NON_ACCUMULATABLE.has(code)) {
|
|
unmergedProcs.push({ ...p, code })
|
|
continue
|
|
}
|
|
|
|
// 10. General accumulatable scalar stats
|
|
const mergeKey = `${code}::${String(p.param ?? '')}`
|
|
const existing = otherMerged.get(mergeKey)
|
|
if (existing) {
|
|
otherMerged.set(mergeKey, {
|
|
code,
|
|
param: existing.param,
|
|
min: existing.min + p.min,
|
|
max: existing.max + p.max,
|
|
value: existing.value + val,
|
|
})
|
|
} else {
|
|
otherMerged.set(mergeKey, {
|
|
code,
|
|
param: p.param,
|
|
min: p.min,
|
|
max: p.max,
|
|
value: val,
|
|
})
|
|
}
|
|
}
|
|
|
|
const out: RolledItemProp[] = [...unmergedProcs, ...otherMerged.values()]
|
|
|
|
// Emit merged Physical Damage
|
|
if (physMin > 0 && physMax > 0) {
|
|
out.push({ code: 'dmg-norm', min: physMin, max: Math.max(physMin, physMax), value: physMin })
|
|
} else if (physMin > 0) {
|
|
out.push({ code: 'dmg-min', min: physMin, max: physMin, value: physMin })
|
|
} else if (physMax > 0) {
|
|
out.push({ code: 'dmg-max', min: physMax, max: physMax, value: physMax })
|
|
}
|
|
|
|
// Emit merged Fire Damage
|
|
if (fireMin > 0 && fireMax > 0) {
|
|
out.push({ code: 'dmg-fire', min: fireMin, max: Math.max(fireMin, fireMax), value: fireMin })
|
|
} else if (fireMin > 0) {
|
|
out.push({ code: 'fire-min', min: fireMin, max: fireMin, value: fireMin })
|
|
} else if (fireMax > 0) {
|
|
out.push({ code: 'fire-max', min: fireMax, max: fireMax, value: fireMax })
|
|
}
|
|
|
|
// Emit merged Lightning Damage
|
|
if (ltngMin > 0 && ltngMax > 0) {
|
|
out.push({ code: 'dmg-ltng', min: ltngMin, max: Math.max(ltngMin, ltngMax), value: ltngMin })
|
|
} else if (ltngMin > 0) {
|
|
out.push({ code: 'ltng-min', min: ltngMin, max: ltngMin, value: ltngMin })
|
|
} else if (ltngMax > 0) {
|
|
out.push({ code: 'ltng-max', min: ltngMax, max: ltngMax, value: ltngMax })
|
|
}
|
|
|
|
// Emit merged Cold Damage
|
|
if (coldMin > 0 && coldMax > 0) {
|
|
out.push({
|
|
code: 'dmg-cold',
|
|
param: coldLen || 50,
|
|
min: coldMin,
|
|
max: Math.max(coldMin, coldMax),
|
|
value: coldMin,
|
|
})
|
|
} else if (coldMin > 0) {
|
|
out.push({ code: 'cold-min', min: coldMin, max: coldMin, value: coldMin })
|
|
} else if (coldMax > 0) {
|
|
out.push({ code: 'cold-max', min: coldMax, max: coldMax, value: coldMax })
|
|
}
|
|
|
|
// Emit merged Poison Damage
|
|
if (poisMin > 0 || poisMax > 0) {
|
|
const pVal = poisMin > 0 ? poisMin : poisMax
|
|
const pLen = poisLen || 75
|
|
out.push({
|
|
code: 'dmg-pois',
|
|
param: pLen,
|
|
min: pVal,
|
|
max: pLen,
|
|
value: pVal,
|
|
})
|
|
}
|
|
|
|
// Emit merged Attributes (dgrp=1): fold ONLY when all 4 attributes are positive and strictly equal
|
|
if (strVal > 0 && strVal === dexVal && dexVal === vitVal && vitVal === enrVal) {
|
|
out.push({ code: 'all-stats', min: strVal, max: strVal, value: strVal })
|
|
} else {
|
|
if (strVal !== 0) out.push({ code: 'str', min: strVal, max: strVal, value: strVal })
|
|
if (dexVal !== 0) out.push({ code: 'dex', min: dexVal, max: dexVal, value: dexVal })
|
|
if (vitVal !== 0) out.push({ code: 'vit', min: vitVal, max: vitVal, value: vitVal })
|
|
if (enrVal !== 0) out.push({ code: 'enr', min: enrVal, max: enrVal, value: enrVal })
|
|
}
|
|
|
|
// Emit merged Resistances (dgrp=2): fold ONLY when all 4 resistances are positive and strictly equal
|
|
if (fireRes > 0 && fireRes === coldRes && coldRes === ltngRes && ltngRes === poisRes) {
|
|
out.push({ code: 'res-all', min: fireRes, max: fireRes, value: fireRes })
|
|
} else {
|
|
if (coldRes !== 0) out.push({ code: 'res-cold', min: coldRes, max: coldRes, value: coldRes })
|
|
if (ltngRes !== 0) out.push({ code: 'res-ltng', min: ltngRes, max: ltngRes, value: ltngRes })
|
|
if (fireRes !== 0) out.push({ code: 'res-fire', min: fireRes, max: fireRes, value: fireRes })
|
|
if (poisRes !== 0) out.push({ code: 'res-pois', min: poisRes, max: poisRes, value: poisRes })
|
|
}
|
|
|
|
// Sort descending by ItemStatCost.txt descpriority, then ascending by 1.13c statId for distinct properties
|
|
return out
|
|
.map((item, index) => ({
|
|
item,
|
|
index,
|
|
prio: getPropertyPriority(item.code, tables),
|
|
statId: getPropertyStatId(item.code, tables),
|
|
}))
|
|
.sort((a, b) => {
|
|
if (b.prio !== a.prio) {
|
|
return b.prio - a.prio
|
|
}
|
|
if (
|
|
a.statId >= 0 &&
|
|
b.statId >= 0 &&
|
|
a.statId !== b.statId &&
|
|
a.item.code.trim().toLowerCase() !== b.item.code.trim().toLowerCase()
|
|
) {
|
|
return a.statId - b.statId
|
|
}
|
|
return a.index - b.index
|
|
})
|
|
.map(x => x.item)
|
|
}
|
|
|
|
function resolveItemClass(item: Item): string {
|
|
const base = item.base
|
|
const explicit = (item as any).itemClass ?? (base as any).itemClass ?? (base as any).class
|
|
if (typeof explicit === 'string' && explicit.trim() !== '') {
|
|
return explicit.trim().toLowerCase()
|
|
}
|
|
const fromType = inferItemClassFromType(getItemTypeCode(base))
|
|
if (fromType) return fromType
|
|
if (base.tags && base.tags.length > 0) {
|
|
for (const tag of base.tags) {
|
|
const fromTag = inferItemClassFromType(tag)
|
|
if (fromTag) return fromTag
|
|
}
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function getAffixEffectiveLevelReq(aff: RolledAffix, itemClass: string): number {
|
|
const affClass = (aff.class ?? aff.affix?.class ?? '').trim().toLowerCase()
|
|
const affClassLevelReq = aff.classlevelreq ?? aff.affix?.classlevelreq
|
|
if (
|
|
itemClass &&
|
|
affClass &&
|
|
affClass === itemClass &&
|
|
affClassLevelReq !== undefined &&
|
|
affClassLevelReq > 0
|
|
) {
|
|
return affClassLevelReq
|
|
}
|
|
return aff.levelreq
|
|
}
|
|
|
|
/**
|
|
* Generates formatted tooltip structure for any dropped Diablo II Item.
|
|
*/
|
|
export function formatItemTooltip(item: Item, locale: TooltipLocale = getTooltipLocale()): FormattedItemTooltip {
|
|
const base = item.base
|
|
const quality = String(item.rarity || item.quality || 'normal').toLowerCase()
|
|
const ilvl = item.level ?? item.ilvl ?? 1
|
|
const itemClass = resolveItemClass(item)
|
|
|
|
let title = item.name
|
|
let subTitle: string | undefined = undefined
|
|
let qualityColor: string = '#ffffff'
|
|
const lines: FormattedStatLine[] = []
|
|
|
|
// Check if this item is a Gem or a Rune (Socketable)
|
|
const itemCode = (item.code || (item.base as any)?.code || item.base?.id || '').trim().toLowerCase()
|
|
const socketable = getSocketableInfo(itemCode)
|
|
const isRune = isRuneCode(itemCode)
|
|
|
|
if (socketable) {
|
|
const isZh = locale === 'zh' || locale === 'bilingual'
|
|
let title: string
|
|
let subTitle: string | undefined = undefined
|
|
let quality: string
|
|
let qualityColor: string
|
|
let baseType: string
|
|
|
|
if (isRune) {
|
|
const runeInfo = socketable as RuneInfo
|
|
if (locale === 'zh') {
|
|
title = `${runeInfo.nameZh}符文`
|
|
subTitle = `符文 (${runeInfo.num})`
|
|
} else if (locale === 'bilingual') {
|
|
title = `${runeInfo.nameEn} (${runeInfo.nameZh})`
|
|
subTitle = `Rune (${runeInfo.num})`
|
|
} else {
|
|
title = runeInfo.nameEn
|
|
subTitle = `Rune (${runeInfo.num})`
|
|
}
|
|
quality = locale === 'zh' ? '符文' : 'Rune'
|
|
qualityColor = '#ff8c00'
|
|
baseType = locale === 'zh' ? '符文' : 'Rune'
|
|
} else {
|
|
const gemInfo = socketable as GemInfo
|
|
if (locale === 'zh') {
|
|
title = gemInfo.nameZh
|
|
} else if (locale === 'bilingual') {
|
|
title = `${gemInfo.nameEn} (${gemInfo.nameZh})`
|
|
} else {
|
|
title = gemInfo.nameEn
|
|
}
|
|
quality = locale === 'zh' ? '宝石' : 'Gem'
|
|
qualityColor = '#ffffff'
|
|
baseType = locale === 'zh' ? '宝石' : 'Gem'
|
|
}
|
|
|
|
// Line 0: Instruction (White / Instruction)
|
|
lines.push({
|
|
text: isZh ? '可镶嵌进有凹槽的装备' : 'Can be Inserted into Socketed Items',
|
|
color: 'instruction',
|
|
})
|
|
|
|
// Lines 1..4: The 4 distinct equipment slots (White, strictly matching D2 1.13c)
|
|
lines.push({
|
|
text: isZh ? `武器: ${socketable.weaponsZh}` : `Weapons: ${socketable.weaponsEn}`,
|
|
color: 'white',
|
|
})
|
|
lines.push({
|
|
text: isZh ? `防具: ${socketable.armorZh}` : `Armor: ${socketable.armorEn}`,
|
|
color: 'white',
|
|
})
|
|
lines.push({
|
|
text: isZh ? `头盔: ${socketable.helmsZh}` : `Helms: ${socketable.helmsEn}`,
|
|
color: 'white',
|
|
})
|
|
lines.push({
|
|
text: isZh ? `盾牌: ${socketable.shieldsZh}` : `Shields: ${socketable.shieldsEn}`,
|
|
color: 'white',
|
|
})
|
|
|
|
// Bottom Line: Required Level (Red, matching D2 1.13c, shown when reqLevel > 0)
|
|
if (socketable.reqLevel > 0) {
|
|
lines.push({
|
|
text: isZh ? `需要等级: ${socketable.reqLevel}` : `Required Level: ${socketable.reqLevel}`,
|
|
color: 'red',
|
|
})
|
|
}
|
|
|
|
return {
|
|
title,
|
|
subTitle,
|
|
quality,
|
|
qualityColor,
|
|
baseType,
|
|
ilvl,
|
|
reqLevel: socketable.reqLevel > 0 ? socketable.reqLevel : undefined,
|
|
lines,
|
|
isSocketable: true,
|
|
}
|
|
}
|
|
|
|
// Check if this is Gold
|
|
if (base.id === 'gld' || (base.kind !== 'weapon' && base.kind !== 'armor' && base.name.trim().toLowerCase() === 'gold')) {
|
|
const amount = (item.stats.amount ?? item.value ?? 100)
|
|
title = locale === 'zh' ? `${amount.toLocaleString()} 金币` : `${amount.toLocaleString()} Gold`
|
|
qualityColor = '#ffd700'
|
|
return {
|
|
title,
|
|
quality: locale === 'zh' ? '普通' : 'Normal',
|
|
qualityColor,
|
|
baseType: locale === 'zh' ? '金币' : 'Gold',
|
|
ilvl: 1,
|
|
lines: [],
|
|
}
|
|
}
|
|
|
|
// Collect raw properties from the item across all quality tiers
|
|
const rawProps: RolledItemProp[] = []
|
|
let affixReqLevel = 0
|
|
|
|
const autoMagicDef = (item as any).rolledAutoMagic as { levelreq?: number; mods?: readonly RolledMod[] } | undefined
|
|
if (autoMagicDef?.levelreq && autoMagicDef.levelreq > 0) {
|
|
affixReqLevel = Math.max(affixReqLevel, autoMagicDef.levelreq)
|
|
}
|
|
|
|
if (quality === 'unique') {
|
|
qualityColor = '#c8a15a'
|
|
const uniqDef = item.uniqueItemDef as UniqueItem | undefined
|
|
if (uniqDef) {
|
|
if (locale === 'zh') {
|
|
title = translateItemName(uniqDef.index)
|
|
subTitle = translateItemName(base.name)
|
|
} else if (locale === 'bilingual') {
|
|
const uZh = translateItemName(uniqDef.index)
|
|
const bZh = translateItemName(base.name)
|
|
title = `${uniqDef.index} (${uZh})`
|
|
subTitle = `${base.name} (${bZh})`
|
|
} else {
|
|
title = uniqDef.index
|
|
subTitle = base.name
|
|
}
|
|
if (uniqDef.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, uniqDef.levelreq)
|
|
if (item.rolledProps && item.rolledProps.length > 0) {
|
|
rawProps.push(...item.rolledProps)
|
|
} else {
|
|
for (const prop of uniqDef.props) {
|
|
if (prop.code) {
|
|
rawProps.push({
|
|
code: prop.code,
|
|
param: prop.par,
|
|
min: prop.min,
|
|
max: prop.max,
|
|
value: prop.min,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
subTitle = locale === 'zh' ? translateItemName(base.name) : locale === 'bilingual' ? `${base.name} (${translateItemName(base.name)})` : base.name
|
|
}
|
|
} else if (quality === 'set') {
|
|
qualityColor = '#00e600'
|
|
const setDef = item.setItemDef as SetItem | undefined
|
|
if (setDef) {
|
|
if (locale === 'zh') {
|
|
title = translateItemName(setDef.index)
|
|
subTitle = `${translateItemName(base.name)} · [${translateItemName(setDef.set)}]`
|
|
} else if (locale === 'bilingual') {
|
|
const sZh = translateItemName(setDef.index)
|
|
const bZh = translateItemName(base.name)
|
|
const setZh = translateItemName(setDef.set)
|
|
title = `${setDef.index} (${sZh})`
|
|
subTitle = `${base.name} (${bZh}) · [${setDef.set} (${setZh})]`
|
|
} else {
|
|
title = setDef.index
|
|
subTitle = `${base.name} · [${setDef.set}]`
|
|
}
|
|
if (setDef.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, setDef.levelreq)
|
|
if (item.rolledProps && item.rolledProps.length > 0) {
|
|
rawProps.push(...item.rolledProps)
|
|
} else {
|
|
for (const prop of setDef.props) {
|
|
if (prop.code) {
|
|
rawProps.push({
|
|
code: prop.code,
|
|
param: prop.par,
|
|
min: prop.min,
|
|
max: prop.max,
|
|
value: prop.min,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
subTitle = locale === 'zh' ? translateItemName(base.name) : locale === 'bilingual' ? `${base.name} (${translateItemName(base.name)})` : base.name
|
|
}
|
|
} else if (quality === 'rare') {
|
|
qualityColor = '#ffff60'
|
|
const rareDef = item.rolledRareAffixes as GeneratedRareAffixes | undefined
|
|
const enName = rareDef?.name || item.name
|
|
if (locale === 'zh') {
|
|
title = translateRareName(enName)
|
|
subTitle = translateItemName(base.name)
|
|
} else if (locale === 'bilingual') {
|
|
const rZh = translateRareName(enName)
|
|
const bZh = translateItemName(base.name)
|
|
title = `${enName} (${rZh})`
|
|
subTitle = `${base.name} (${bZh})`
|
|
} else {
|
|
title = enName
|
|
subTitle = base.name
|
|
}
|
|
if (rareDef && rareDef.affixes) {
|
|
for (const aff of rareDef.affixes) {
|
|
const effReq = getAffixEffectiveLevelReq(aff, itemClass)
|
|
if (effReq > 0) affixReqLevel = Math.max(affixReqLevel, effReq)
|
|
}
|
|
}
|
|
if (item.rolledProps && item.rolledProps.length > 0) {
|
|
for (const p of item.rolledProps) {
|
|
const keepRange = NON_RANDOM_RANGE_CODES.has(p.code.toLowerCase()) || (p.min === 0 && p.max === 0)
|
|
rawProps.push({
|
|
code: p.code,
|
|
param: p.param,
|
|
min: keepRange ? p.min : p.value,
|
|
max: keepRange ? p.max : p.value,
|
|
value: p.value,
|
|
})
|
|
}
|
|
} else if (rareDef && rareDef.affixes) {
|
|
for (const aff of rareDef.affixes) {
|
|
for (const mod of aff.mods) {
|
|
const keepRange = NON_RANDOM_RANGE_CODES.has(mod.code.toLowerCase()) || (mod.min === 0 && mod.max === 0)
|
|
rawProps.push({
|
|
code: mod.code,
|
|
param: mod.param,
|
|
min: keepRange ? mod.min : mod.value,
|
|
max: keepRange ? mod.max : mod.value,
|
|
value: mod.value,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
} else if (quality === 'magic') {
|
|
qualityColor = '#4880ff'
|
|
const magicDef = item.rolledMagicAffixes as GeneratedAffixes | undefined
|
|
const enName = magicDef?.name || item.name
|
|
if (locale === 'zh') {
|
|
title = translateMagicName(enName, base.name)
|
|
subTitle = translateItemName(base.name)
|
|
} else if (locale === 'bilingual') {
|
|
const mZh = translateMagicName(enName, base.name)
|
|
const bZh = translateItemName(base.name)
|
|
title = `${enName} (${mZh})`
|
|
subTitle = `${base.name} (${bZh})`
|
|
} else {
|
|
title = enName
|
|
subTitle = base.name
|
|
}
|
|
if (magicDef) {
|
|
if (magicDef.prefix) {
|
|
const effReq = getAffixEffectiveLevelReq(magicDef.prefix, itemClass)
|
|
if (effReq > 0) affixReqLevel = Math.max(affixReqLevel, effReq)
|
|
}
|
|
if (magicDef.suffix) {
|
|
const effReq = getAffixEffectiveLevelReq(magicDef.suffix, itemClass)
|
|
if (effReq > 0) affixReqLevel = Math.max(affixReqLevel, effReq)
|
|
}
|
|
if (magicDef.affixes) {
|
|
for (const aff of magicDef.affixes) {
|
|
const effReq = getAffixEffectiveLevelReq(aff, itemClass)
|
|
if (effReq > 0) affixReqLevel = Math.max(affixReqLevel, effReq)
|
|
}
|
|
}
|
|
}
|
|
if (item.rolledProps && item.rolledProps.length > 0) {
|
|
for (const p of item.rolledProps) {
|
|
const keepRange = NON_RANDOM_RANGE_CODES.has(p.code.toLowerCase()) || (p.min === 0 && p.max === 0)
|
|
rawProps.push({
|
|
code: p.code,
|
|
param: p.param,
|
|
min: keepRange ? p.min : p.value,
|
|
max: keepRange ? p.max : p.value,
|
|
value: p.value,
|
|
})
|
|
}
|
|
} else if (magicDef) {
|
|
if (magicDef.prefix) {
|
|
for (const m of magicDef.prefix.mods) {
|
|
const keepRange = NON_RANDOM_RANGE_CODES.has(m.code.toLowerCase()) || (m.min === 0 && m.max === 0)
|
|
rawProps.push({
|
|
code: m.code,
|
|
param: m.param,
|
|
min: keepRange ? m.min : m.value,
|
|
max: keepRange ? m.max : m.value,
|
|
value: m.value,
|
|
})
|
|
}
|
|
}
|
|
if (magicDef.suffix) {
|
|
for (const m of magicDef.suffix.mods) {
|
|
const keepRange = NON_RANDOM_RANGE_CODES.has(m.code.toLowerCase()) || (m.min === 0 && m.max === 0)
|
|
rawProps.push({
|
|
code: m.code,
|
|
param: m.param,
|
|
min: keepRange ? m.min : m.value,
|
|
max: keepRange ? m.max : m.value,
|
|
value: m.value,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
} else if (quality === 'superior') {
|
|
qualityColor = '#ffffff'
|
|
const enName = item.name && item.name.startsWith('Superior ') ? item.name : `Superior ${base.name}`
|
|
if (locale === 'zh') {
|
|
title = translateItemName(enName)
|
|
} else if (locale === 'bilingual') {
|
|
title = `${enName} (${translateItemName(enName)})`
|
|
} else {
|
|
title = enName
|
|
}
|
|
if (item.rolledProps && item.rolledProps.length > 0) {
|
|
rawProps.push(...item.rolledProps)
|
|
} else if (base.kind === 'weapon') {
|
|
rawProps.push({ code: 'dmg%', min: 15, max: 15, value: 15 })
|
|
} else {
|
|
rawProps.push({ code: 'ac%', min: 15, max: 15, value: 15 })
|
|
}
|
|
} else if (quality === 'low') {
|
|
qualityColor = '#a0a0a0'
|
|
const enName = item.name && item.name !== base.name ? item.name : `Cracked ${base.name}`
|
|
if (locale === 'zh') {
|
|
title = translateItemName(enName)
|
|
} else if (locale === 'bilingual') {
|
|
title = `${enName} (${translateItemName(enName)})`
|
|
} else {
|
|
title = enName
|
|
}
|
|
if (item.rolledProps && item.rolledProps.length > 0) {
|
|
rawProps.push(...item.rolledProps)
|
|
}
|
|
} else if (quality === 'runeword' || (quality === 'rune' && !socketable)) {
|
|
qualityColor = '#c8a15a'
|
|
const rwTitle = (item as any).nameZh || (item as any).runeword?.name || (item as any).runewordName || item.name || 'Runeword'
|
|
const bZh = (item as any).baseNameZh || translateItemName(base.name)
|
|
if (locale === 'zh') {
|
|
title = (item as any).nameZh || translateItemName(rwTitle) || rwTitle
|
|
subTitle = bZh
|
|
} else if (locale === 'bilingual') {
|
|
const rwZh = translateItemName(rwTitle)
|
|
title = rwZh !== rwTitle ? `${rwTitle} (${rwZh})` : rwTitle
|
|
subTitle = `${base.name} (${bZh})`
|
|
} else {
|
|
title = rwTitle
|
|
subTitle = base.name
|
|
}
|
|
const rwLevelReq = (item as any).runeword?.levelreq || (item as any).reqLevel
|
|
if (rwLevelReq && rwLevelReq > 0) {
|
|
affixReqLevel = Math.max(affixReqLevel, rwLevelReq)
|
|
}
|
|
if (item.rolledProps && item.rolledProps.length > 0) {
|
|
rawProps.push(...item.rolledProps)
|
|
} else if ((item as any).runeword?.props) {
|
|
for (const prop of (item as any).runeword.props) {
|
|
if (prop.code) {
|
|
rawProps.push({
|
|
code: prop.code,
|
|
param: prop.par,
|
|
min: prop.min,
|
|
max: prop.max,
|
|
value: prop.min,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
qualityColor = '#e0d5c1'
|
|
const enName = base.name
|
|
if (locale === 'zh') {
|
|
title = translateItemName(enName)
|
|
} else if (locale === 'bilingual') {
|
|
title = `${enName} (${translateItemName(enName)})`
|
|
} else {
|
|
title = enName
|
|
}
|
|
if (item.rolledProps && item.rolledProps.length > 0) {
|
|
rawProps.push(...item.rolledProps)
|
|
}
|
|
}
|
|
|
|
// Merge and sort properties per Issue #125 (min/max damage pairs, dgrp resists/attributes, descpriority)
|
|
const mergedProps = aggregateAndSortProperties(rawProps)
|
|
for (const prop of mergedProps) {
|
|
lines.push({
|
|
text: formatPropertyCode(prop.code, prop.value, prop.min, prop.max, prop.param, locale),
|
|
color: 'blue',
|
|
isMagic: true,
|
|
})
|
|
}
|
|
|
|
// Compute Enhanced Base Stats vs Original Base Stats per Issue #123 & #131
|
|
let totalEdPct = 0
|
|
let flatMinDmg = 0
|
|
let flatMaxDmg = 0
|
|
let totalAcPct = 0
|
|
let flatAc = 0
|
|
let totalEase = 0
|
|
let durBonus = 0
|
|
let durPct = 0
|
|
let rolledSockets = 0
|
|
|
|
for (const p of rawProps) {
|
|
const c = (p.code || '').trim().toLowerCase()
|
|
if (c === 'dmg%') totalEdPct += p.value
|
|
else if (c === 'dmg-min') flatMinDmg += p.value
|
|
else if (c === 'dmg-max') flatMaxDmg += p.value
|
|
else if (c === 'dmg-norm') {
|
|
flatMinDmg += p.min > 0 ? p.min : p.value
|
|
flatMaxDmg += p.max > 0 ? p.max : p.value
|
|
} else if (c === 'dmg') {
|
|
flatMinDmg += p.value
|
|
flatMaxDmg += p.value
|
|
} else if (c === 'ac%') totalAcPct += p.value
|
|
else if (c === 'ac') flatAc += p.value
|
|
else if (c === 'ease') totalEase += p.value
|
|
else if (c === 'dur') durBonus += p.value
|
|
else if (c === 'dur%') durPct += p.value
|
|
else if (c === 'sock') rolledSockets += p.value
|
|
}
|
|
|
|
const isEthereal = Boolean(
|
|
item.flags?.ethereal ||
|
|
(item as any).ethereal ||
|
|
item.stats?.ethereal ||
|
|
rawProps.some(p => (p.code || '').trim().toLowerCase() === 'ethereal'),
|
|
)
|
|
|
|
const explicitSockets = Number((item as any).sockets || item.totalSockets || rolledSockets || 0)
|
|
|
|
if (isEthereal && !lines.some(l => l.text.includes('Ethereal') || l.text.includes('无形'))) {
|
|
const ethText = locale === 'zh' || locale === 'bilingual' ? '无形 (无法修复)' : 'Ethereal (Cannot be Repaired)'
|
|
lines.push({ text: ethText, color: 'blue', isMagic: true })
|
|
}
|
|
if (explicitSockets > 0 && !lines.some(l => l.text.includes('Socketed') || l.text.includes('凹槽'))) {
|
|
const sockText = locale === 'zh' || locale === 'bilingual' ? `凹槽 (${explicitSockets})` : `Socketed (${explicitSockets})`
|
|
lines.push({ text: sockText, color: 'blue', isMagic: true })
|
|
}
|
|
|
|
// 1. Defense (Enhanced / Low Quality / Ethereal vs Base)
|
|
const rawBaseDef = base.defense > 0 ? base.defense : 0
|
|
let defense: number | undefined = undefined
|
|
let baseDefense: number | undefined = undefined
|
|
if (rawBaseDef > 0) {
|
|
let effectiveBaseDef = rawBaseDef
|
|
if (quality === 'low') {
|
|
effectiveBaseDef = Math.max(1, Math.floor(effectiveBaseDef * 0.75))
|
|
}
|
|
if (isEthereal) {
|
|
effectiveBaseDef = Math.floor(effectiveBaseDef * 1.5)
|
|
}
|
|
let finalDef = Math.floor(effectiveBaseDef * (100 + totalAcPct) / 100) + flatAc
|
|
if (totalAcPct > 0 && finalDef === effectiveBaseDef) {
|
|
finalDef = effectiveBaseDef + 1
|
|
}
|
|
defense = finalDef
|
|
if (finalDef !== rawBaseDef || totalAcPct !== 0 || flatAc !== 0 || quality === 'low' || isEthereal) {
|
|
baseDefense = rawBaseDef
|
|
}
|
|
}
|
|
|
|
// 2. One-Hand & Two-Hand Damage (Enhanced / Low Quality / Ethereal vs Base)
|
|
const computeWeaponDamage = (baseMin: number, baseMax: number) => {
|
|
let effMin = baseMin
|
|
let effMax = baseMax
|
|
if (quality === 'low') {
|
|
effMin = Math.max(1, Math.floor(effMin * 0.75))
|
|
effMax = Math.max(1, Math.floor(effMax * 0.75))
|
|
}
|
|
if (isEthereal) {
|
|
effMin = Math.floor(effMin * 1.5)
|
|
effMax = Math.floor(effMax * 1.5)
|
|
}
|
|
let finalMin = Math.floor(effMin * (100 + totalEdPct) / 100) + flatMinDmg
|
|
let finalMax = Math.floor(effMax * (100 + totalEdPct) / 100) + flatMaxDmg
|
|
if (totalEdPct > 0 && finalMax === effMax) {
|
|
finalMax = effMax + 1
|
|
}
|
|
if ((totalEdPct !== 0 || flatMinDmg !== 0 || flatMaxDmg !== 0) && finalMin >= finalMax) {
|
|
finalMax = finalMin + 1
|
|
}
|
|
const isEnhanced =
|
|
finalMin !== baseMin ||
|
|
finalMax !== baseMax ||
|
|
totalEdPct !== 0 ||
|
|
flatMinDmg !== 0 ||
|
|
flatMaxDmg !== 0 ||
|
|
quality === 'low' ||
|
|
isEthereal
|
|
return isEnhanced
|
|
? { min: finalMin, max: finalMax, baseMin, baseMax, isEnhanced: true }
|
|
: { min: baseMin, max: baseMax }
|
|
}
|
|
|
|
const oneHandDamage =
|
|
(base as any).mindam && (base as any).maxdam
|
|
? computeWeaponDamage((base as any).mindam, (base as any).maxdam)
|
|
: (item as any).damage && typeof (item as any).damage === 'string' && (item as any).damage.includes('-')
|
|
? (() => {
|
|
const parts = (item as any).damage.split('-').map((s: string) => parseInt(s.trim(), 10))
|
|
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
|
return { min: parts[0], max: parts[1], isEnhanced: totalEdPct > 0 }
|
|
}
|
|
return undefined
|
|
})()
|
|
: undefined
|
|
|
|
const twoHandDamage =
|
|
(base as any).twoHandedMindam && (base as any).twoHandedMaxdam
|
|
? computeWeaponDamage((base as any).twoHandedMindam, (base as any).twoHandedMaxdam)
|
|
: undefined
|
|
|
|
// 3. Requirements (Required Strength, Required Dexterity, Required Level)
|
|
const easeSigned = totalEase > 0 ? -totalEase : totalEase
|
|
const rawBaseReqStr = (base as any).reqstr > 0 ? (base as any).reqstr : 0
|
|
let reqStr: number | undefined = undefined
|
|
let baseReqStr: number | undefined = undefined
|
|
if (rawBaseReqStr > 0) {
|
|
const finalReqStr = Math.max(
|
|
0,
|
|
Math.floor(rawBaseReqStr * (100 + easeSigned) / 100) - (isEthereal ? 10 : 0),
|
|
)
|
|
reqStr = finalReqStr
|
|
if (finalReqStr !== rawBaseReqStr) {
|
|
baseReqStr = rawBaseReqStr
|
|
}
|
|
}
|
|
|
|
const rawBaseReqDex = (base as any).reqdex > 0 ? (base as any).reqdex : 0
|
|
let reqDex: number | undefined = undefined
|
|
let baseReqDex: number | undefined = undefined
|
|
if (rawBaseReqDex > 0) {
|
|
const finalReqDex = Math.max(
|
|
0,
|
|
Math.floor(rawBaseReqDex * (100 + easeSigned) / 100) - (isEthereal ? 10 : 0),
|
|
)
|
|
reqDex = finalReqDex
|
|
if (finalReqDex !== rawBaseReqDex) {
|
|
baseReqDex = rawBaseReqDex
|
|
}
|
|
}
|
|
|
|
const rawBaseReqLevel = (base as any).levelreq > 0 ? (base as any).levelreq : 0
|
|
const finalReqLevel = Math.max(rawBaseReqLevel, affixReqLevel)
|
|
const reqLevel = finalReqLevel > 0 ? finalReqLevel : undefined
|
|
const baseReqLevel = rawBaseReqLevel > 0 && finalReqLevel !== rawBaseReqLevel ? rawBaseReqLevel : undefined
|
|
|
|
// 4. Durability
|
|
const rawBaseDur = (base as any).durability > 0 ? (base as any).durability : undefined
|
|
let durability: FormattedItemTooltip['durability'] = undefined
|
|
if (item.durability && item.maxDurability) {
|
|
const finalMaxDur = Math.floor(item.maxDurability * (100 + durPct) / 100) + durBonus
|
|
const finalCurDur = Math.floor(item.durability * (100 + durPct) / 100) + durBonus
|
|
const isEnhanced = rawBaseDur !== undefined && (finalMaxDur !== rawBaseDur || quality === 'low')
|
|
durability = isEnhanced
|
|
? { current: finalCurDur, max: finalMaxDur, baseMax: rawBaseDur, isEnhanced: true }
|
|
: { current: finalCurDur, max: finalMaxDur }
|
|
}
|
|
|
|
const sockets = explicitSockets > 0 ? explicitSockets : (base as any).gemsockets > 0 ? (base as any).gemsockets : undefined
|
|
|
|
const displayQuality =
|
|
quality === 'runeword' || (quality === 'rune' && !socketable) ? (locale === 'zh' ? '符文之语' : 'Runeword') :
|
|
quality === 'unique' ? (locale === 'zh' ? '暗金' : 'Unique') :
|
|
quality === 'set' ? (locale === 'zh' ? '套装' : 'Set') :
|
|
quality === 'rare' ? (locale === 'zh' ? '亮金' : 'Rare') :
|
|
quality === 'magic' ? (locale === 'zh' ? '魔法' : 'Magic') :
|
|
quality === 'superior' ? (locale === 'zh' ? '超强' : 'Superior') :
|
|
quality === 'low' ? (locale === 'zh' ? '劣质' : 'Low Quality') :
|
|
isRune ? (locale === 'zh' ? '符文' : 'Rune') : (locale === 'zh' ? '普通' : 'Normal')
|
|
|
|
return {
|
|
title,
|
|
subTitle,
|
|
quality: displayQuality,
|
|
qualityColor,
|
|
baseType: base.kind === 'weapon' ? 'Weapon' : base.kind === 'armor' ? 'Armor' : 'Misc',
|
|
ilvl,
|
|
reqLevel,
|
|
...(baseReqLevel !== undefined ? { baseReqLevel } : {}),
|
|
reqStr,
|
|
...(baseReqStr !== undefined ? { baseReqStr } : {}),
|
|
reqDex,
|
|
...(baseReqDex !== undefined ? { baseReqDex } : {}),
|
|
defense,
|
|
...(baseDefense !== undefined ? { baseDefense } : {}),
|
|
oneHandDamage,
|
|
twoHandDamage,
|
|
durability,
|
|
sockets,
|
|
lines,
|
|
...((item as any).runewordRunes ? { runewordRunes: (item as any).runewordRunes } : {}),
|
|
...((item as any).speedText ? { speedText: (item as any).speedText } : {}),
|
|
}
|
|
}
|
|
|