diablo2-web/src/game/item-tooltip.ts

2501 lines
81 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'
export {
getTooltipLocale,
setTooltipLocale,
translateItemName,
type TooltipLocale,
} from './tooltip-i18n.ts'
export interface FormattedStatLine {
text: string
color?: 'blue' | 'white' | 'gold' | 'green' | 'orange' | 'yellow' | 'red' | 'gray'
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
}
// Canonical 1.13c Rune Names & Socket Effects (English)
const RUNE_INFO: Record<string, { num: number; nameEn: string; lvl: number; weapon: string; armor: string }> = {
r01: { num: 1, nameEn: 'El Rune', lvl: 11, weapon: '+50 to Attack Rating, +1 to Light Radius', armor: '+15 Defense, +1 to Light Radius' },
r02: { num: 2, nameEn: 'Eld Rune', lvl: 11, weapon: '+75% Damage to Undead, +50 to Attack Rating against Undead', armor: '15% Slower Stamina Drain (Shields: 7% Increased Chance of Blocking)' },
r03: { num: 3, nameEn: 'Tir Rune', lvl: 13, weapon: '+2 to Mana After Each Kill', armor: '+2 to Mana After Each Kill' },
r04: { num: 4, nameEn: 'Nef Rune', lvl: 13, weapon: 'Knockback', armor: '+30 Defense vs. Missile' },
r05: { num: 5, nameEn: 'Eth Rune', lvl: 15, weapon: '-25% Target Defense', armor: 'Regenerate Mana 15%' },
r06: { num: 6, nameEn: 'Ith Rune', lvl: 15, weapon: '+9 to Maximum Damage', armor: '15% Damage Taken Goes to Mana' },
r07: { num: 7, nameEn: 'Tal Rune', lvl: 17, weapon: '+75 Poison Damage Over 5 Seconds', armor: 'Poison Resist 30% (Shields: Poison Resist 35%)' },
r08: { num: 8, nameEn: 'Ral Rune', lvl: 19, weapon: 'Adds 5-30 Fire Damage', armor: 'Fire Resist 30% (Shields: Fire Resist 35%)' },
r09: { num: 9, nameEn: 'Ort Rune', lvl: 21, weapon: 'Adds 1-50 Lightning Damage', armor: 'Lightning Resist 30% (Shields: Lightning Resist 35%)' },
r10: { num: 10, nameEn: 'Thul Rune', lvl: 23, weapon: 'Adds 3-14 Cold Damage (3 sec)', armor: 'Cold Resist 30% (Shields: Cold Resist 35%)' },
r11: { num: 11, nameEn: 'Amn Rune', lvl: 25, weapon: '7% Life Stolen Per Hit', armor: 'Attacker Takes Damage of 14' },
r12: { num: 12, nameEn: 'Sol Rune', lvl: 27, weapon: '+9 to Minimum Damage', armor: 'Damage Reduced by 7' },
r13: { num: 13, nameEn: 'Shael Rune', lvl: 29, weapon: '20% Increased Attack Speed', armor: '20% Faster Hit Recovery (Shields: 20% Faster Block Rate)' },
r14: { num: 14, nameEn: 'Dol Rune', lvl: 31, weapon: 'Hit Causes Monster to Flee 25%', armor: 'Replenish Life +7' },
r15: { num: 15, nameEn: 'Hel Rune', lvl: 0, weapon: 'Requirements -20%', armor: 'Requirements -15%' },
r16: { num: 16, nameEn: 'Io Rune', lvl: 35, weapon: '+10 to Vitality', armor: '+10 to Vitality' },
r17: { num: 17, nameEn: 'Lum Rune', lvl: 37, weapon: '+10 to Energy', armor: '+10 to Energy' },
r18: { num: 18, nameEn: 'Ko Rune', lvl: 39, weapon: '+10 to Dexterity', armor: '+10 to Dexterity' },
r19: { num: 19, nameEn: 'Fal Rune', lvl: 41, weapon: '+10 to Strength', armor: '+10 to Strength' },
r20: { num: 20, nameEn: 'Lem Rune', lvl: 43, weapon: '75% Extra Gold from Monsters', armor: '50% Extra Gold from Monsters' },
r21: { num: 21, nameEn: 'Pul Rune', lvl: 45, weapon: '+75% Damage to Demons, +100 to Attack Rating against Demons', armor: '+30% Enhanced Defense' },
r22: { num: 22, nameEn: 'Um Rune', lvl: 47, weapon: '25% Chance of Open Wounds', armor: 'All Resistances +15 (Shields: All Resistances +22)' },
r23: { num: 23, nameEn: 'Mal Rune', lvl: 49, weapon: 'Prevent Monster Heal', armor: 'Magic Damage Reduced by 7' },
r24: { num: 24, nameEn: 'Ist Rune', lvl: 51, weapon: '30% Better Chance of Getting Magic Items', armor: '25% Better Chance of Getting Magic Items' },
r25: { num: 25, nameEn: 'Gul Rune', lvl: 53, weapon: '20% Bonus to Attack Rating', armor: '+5% to Maximum Poison Resist' },
r26: { num: 26, nameEn: 'Vex Rune', lvl: 55, weapon: '7% Mana Stolen Per Hit', armor: '+5% to Maximum Fire Resist' },
r27: { num: 27, nameEn: 'Ohm Rune', lvl: 57, weapon: '+50% Enhanced Damage', armor: '+5% to Maximum Cold Resist' },
r28: { num: 28, nameEn: 'Lo Rune', lvl: 59, weapon: '20% Deadly Strike', armor: '+5% to Maximum Lightning Resist' },
r29: { num: 29, nameEn: 'Sur Rune', lvl: 61, weapon: 'Hit Blinds Target', armor: 'Increase Maximum Mana 5% (Shields: +50 to Mana)' },
r30: { num: 30, nameEn: 'Ber Rune', lvl: 63, weapon: '20% Chance of Crushing Blow', armor: 'Damage Reduced by 8%' },
r31: { num: 31, nameEn: 'Jah Rune', lvl: 65, weapon: "Ignore Target's Defense", armor: 'Increase Maximum Life 5% (Shields: +50 to Life)' },
r32: { num: 32, nameEn: 'Cham Rune', lvl: 67, weapon: 'Freeze Target +3', armor: 'Cannot be Frozen' },
r33: { num: 33, nameEn: 'Zod Rune', lvl: 69, weapon: 'Indestructible', armor: 'Indestructible' },
}
/**
* 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)
*/
const CANONICAL_SKILLS: readonly [number, string, string, string][] = [
[0, "Attack", "Attack", ""],
[1, "Kick", "Kick", ""],
[2, "Throw", "Throw", ""],
[3, "Unsummon", "Unsummon", ""],
[4, "Left Hand Throw", "Left Hand Throw", ""],
[5, "Left Hand Swing", "Left Hand Swing", ""],
[6, "Magic Arrow", "Magic Arrow", "ama"],
[7, "Fire Arrow", "Fire Arrow", "ama"],
[8, "Inner Sight", "Inner Sight", "ama"],
[9, "Critical Strike", "Critical Strike", "ama"],
[10, "Jab", "Jab", "ama"],
[11, "Cold Arrow", "Cold Arrow", "ama"],
[12, "Multiple Shot", "Multiple Shot", "ama"],
[13, "Dodge", "Dodge", "ama"],
[14, "Power Strike", "Power Strike", "ama"],
[15, "Poison Javelin", "Poison Javelin", "ama"],
[16, "Exploding Arrow", "Exploding Arrow", "ama"],
[17, "Slow Missiles", "Slow Missiles", "ama"],
[18, "Avoid", "Avoid", "ama"],
[19, "Impale", "Impale", "ama"],
[20, "Lightning Bolt", "Lightning Bolt", "ama"],
[21, "Ice Arrow", "Ice Arrow", "ama"],
[22, "Guided Arrow", "Guided Arrow", "ama"],
[23, "Penetrate", "Penetrate", "ama"],
[24, "Charged Strike", "Charged Strike", "ama"],
[25, "Plague Javelin", "Plague Javelin", "ama"],
[26, "Strafe", "Strafe", "ama"],
[27, "Immolation Arrow", "Immolation Arrow", "ama"],
[28, "Dopplezon", "Decoy", "ama"],
[29, "Evade", "Evade", "ama"],
[30, "Fend", "Fend", "ama"],
[31, "Freezing Arrow", "Freezing Arrow", "ama"],
[32, "Valkyrie", "Valkyrie", "ama"],
[33, "Pierce", "Pierce", "ama"],
[34, "Lightning Strike", "Lightning Strike", "ama"],
[35, "Lightning Fury", "Lightning Fury", "ama"],
[36, "Fire Bolt", "Fire Bolt", "sor"],
[37, "Warmth", "Warmth", "sor"],
[38, "Charged Bolt", "Charged Bolt", "sor"],
[39, "Ice Bolt", "Ice Bolt", "sor"],
[40, "Frozen Armor", "Frozen Armor", "sor"],
[41, "Inferno", "Inferno", "sor"],
[42, "Static Field", "Static Field", "sor"],
[43, "Telekinesis", "Telekinesis", "sor"],
[44, "Frost Nova", "Frost Nova", "sor"],
[45, "Ice Blast", "Ice Blast", "sor"],
[46, "Blaze", "Blaze", "sor"],
[47, "Fire Ball", "Fire Ball", "sor"],
[48, "Nova", "Nova", "sor"],
[49, "Lightning", "Lightning", "sor"],
[50, "Shiver Armor", "Shiver Armor", "sor"],
[51, "Fire Wall", "Fire Wall", "sor"],
[52, "Enchant", "Enchant", "sor"],
[53, "Chain Lightning", "Chain Lightning", "sor"],
[54, "Teleport", "Teleport", "sor"],
[55, "Glacial Spike", "Glacial Spike", "sor"],
[56, "Meteor", "Meteor", "sor"],
[57, "Thunder Storm", "Thunder Storm", "sor"],
[58, "Energy Shield", "Energy Shield", "sor"],
[59, "Blizzard", "Blizzard", "sor"],
[60, "Chilling Armor", "Chilling Armor", "sor"],
[61, "Fire Mastery", "Fire Mastery", "sor"],
[62, "Hydra", "Hydra", "sor"],
[63, "Lightning Mastery", "Lightning Mastery", "sor"],
[64, "Frozen Orb", "Frozen Orb", "sor"],
[65, "Cold Mastery", "Cold Mastery", "sor"],
[66, "Amplify Damage", "Amplify Damage", "nec"],
[67, "Teeth", "Teeth", "nec"],
[68, "Bone Armor", "Bone Armor", "nec"],
[69, "Skeleton Mastery", "Skeleton Mastery", "nec"],
[70, "Raise Skeleton", "Raise Skeleton", "nec"],
[71, "Dim Vision", "Dim Vision", "nec"],
[72, "Weaken", "Weaken", "nec"],
[73, "Poison Dagger", "Poison Dagger", "nec"],
[74, "Corpse Explosion", "Corpse Explosion", "nec"],
[75, "Clay Golem", "Clay Golem", "nec"],
[76, "Iron Maiden", "Iron Maiden", "nec"],
[77, "Terror", "Terror", "nec"],
[78, "Bone Wall", "Bone Wall", "nec"],
[79, "Golem Mastery", "Golem Mastery", "nec"],
[80, "Raise Skeletal Mage", "Raise Skeletal Mage", "nec"],
[81, "Confuse", "Confuse", "nec"],
[82, "Life Tap", "Life Tap", "nec"],
[83, "Poison Explosion", "Poison Explosion", "nec"],
[84, "Bone Spear", "Bone Spear", "nec"],
[85, "BloodGolem", "Blood Golem", "nec"],
[86, "Attract", "Attract", "nec"],
[87, "Decrepify", "Decrepify", "nec"],
[88, "Bone Prison", "Bone Prison", "nec"],
[89, "Summon Resist", "Summon Resist", "nec"],
[90, "IronGolem", "Iron Golem", "nec"],
[91, "Lower Resist", "Lower Resist", "nec"],
[92, "Poison Nova", "Poison Nova", "nec"],
[93, "Bone Spirit", "Bone Spirit", "nec"],
[94, "FireGolem", "Fire Golem", "nec"],
[95, "Revive", "Revive", "nec"],
[96, "Sacrifice", "Sacrifice", "pal"],
[97, "Smite", "Smite", "pal"],
[98, "Might", "Might", "pal"],
[99, "Prayer", "Prayer", "pal"],
[100, "Resist Fire", "Resist Fire", "pal"],
[101, "Holy Bolt", "Holy Bolt", "pal"],
[102, "Holy Fire", "Holy Fire", "pal"],
[103, "Thorns", "Thorns", "pal"],
[104, "Defiance", "Defiance", "pal"],
[105, "Resist Cold", "Resist Cold", "pal"],
[106, "Zeal", "Zeal", "pal"],
[107, "Charge", "Charge", "pal"],
[108, "Blessed Aim", "Blessed Aim", "pal"],
[109, "Cleansing", "Cleansing", "pal"],
[110, "Resist Lightning", "Resist Lightning", "pal"],
[111, "Vengeance", "Vengeance", "pal"],
[112, "Blessed Hammer", "Blessed Hammer", "pal"],
[113, "Concentration", "Concentration", "pal"],
[114, "Holy Freeze", "Holy Freeze", "pal"],
[115, "Vigor", "Vigor", "pal"],
[116, "Conversion", "Conversion", "pal"],
[117, "Holy Shield", "Holy Shield", "pal"],
[118, "Holy Shock", "Holy Shock", "pal"],
[119, "Sanctuary", "Sanctuary", "pal"],
[120, "Meditation", "Meditation", "pal"],
[121, "Fist of the Heavens", "Fist of the Heavens", "pal"],
[122, "Fanaticism", "Fanaticism", "pal"],
[123, "Conviction", "Conviction", "pal"],
[124, "Redemption", "Redemption", "pal"],
[125, "Salvation", "Salvation", "pal"],
[126, "Bash", "Bash", "bar"],
[127, "Sword Mastery", "Sword Mastery", "bar"],
[128, "Axe Mastery", "Axe Mastery", "bar"],
[129, "Mace Mastery", "Mace Mastery", "bar"],
[130, "Howl", "Howl", "bar"],
[131, "Find Potion", "Find Potion", "bar"],
[132, "Leap", "Leap", "bar"],
[133, "Double Swing", "Double Swing", "bar"],
[134, "Pole Arm Mastery", "Pole Arm Mastery", "bar"],
[135, "Throwing Mastery", "Throwing Mastery", "bar"],
[136, "Spear Mastery", "Spear Mastery", "bar"],
[137, "Taunt", "Taunt", "bar"],
[138, "Shout", "Shout", "bar"],
[139, "Stun", "Stun", "bar"],
[140, "Double Throw", "Double Throw", "bar"],
[141, "Increased Stamina", "Increased Stamina", "bar"],
[142, "Find Item", "Find Item", "bar"],
[143, "Leap Attack", "Leap Attack", "bar"],
[144, "Concentrate", "Concentrate", "bar"],
[145, "Iron Skin", "Iron Skin", "bar"],
[146, "Battle Cry", "Battle Cry", "bar"],
[147, "Frenzy", "Frenzy", "bar"],
[148, "Increased Speed", "Increased Speed", "bar"],
[149, "Battle Orders", "Battle Orders", "bar"],
[150, "Grim Ward", "Grim Ward", "bar"],
[151, "Whirlwind", "Whirlwind", "bar"],
[152, "Berserk", "Berserk", "bar"],
[153, "Natural Resistance", "Natural Resistance", "bar"],
[154, "War Cry", "War Cry", "bar"],
[155, "Battle Command", "Battle Command", "bar"],
[197, "DiabWall", "Firestorm", ""],
[217, "Scroll of Identify", "Scroll of Identify", ""],
[218, "Book of Identify", "Tome of Identify", ""],
[219, "Scroll of Townportal", "Scroll of Townportal", ""],
[220, "Book of Townportal", "Tome of Townportal", ""],
[221, "Raven", "Raven", "dru"],
[222, "Plague Poppy", "Poison Creeper", "dru"],
[223, "Wearwolf", "Werewolf", "dru"],
[224, "Shape Shifting", "Lycanthropy", "dru"],
[225, "Firestorm", "Firestorm", "dru"],
[226, "Oak Sage", "Oak Sage", "dru"],
[227, "Summon Spirit Wolf", "Summon Spirit Wolf", "dru"],
[228, "Wearbear", "Werebear", "dru"],
[229, "Molten Boulder", "Molten Boulder", "dru"],
[230, "Arctic Blast", "Arctic Blast", "dru"],
[231, "Cycle of Life", "Carrion Vine", "dru"],
[232, "Feral Rage", "Feral Rage", "dru"],
[233, "Maul", "Maul", "dru"],
[234, "Eruption", "Fissure", "dru"],
[235, "Cyclone Armor", "Cyclone Armor", "dru"],
[236, "Heart of Wolverine", "Heart of Wolverine", "dru"],
[237, "Summon Fenris", "Summon Dire Wolf", "dru"],
[238, "Rabies", "Rabies", "dru"],
[239, "Fire Claws", "Fire Claws", "dru"],
[240, "Twister", "Twister", "dru"],
[241, "Vines", "Solar Creeper", "dru"],
[242, "Hunger", "Hunger", "dru"],
[243, "Shock Wave", "Shock Wave", "dru"],
[244, "Volcano", "Volcano", "dru"],
[245, "Tornado", "Tornado", "dru"],
[246, "Spirit of Barbs", "Spirit of Barbs", "dru"],
[247, "Summon Grizzly", "Summon Grizzly", "dru"],
[248, "Fury", "Fury", "dru"],
[249, "Armageddon", "Armageddon", "dru"],
[250, "Hurricane", "Hurricane", "dru"],
[251, "Fire Trauma", "Fire Blast", "ass"],
[252, "Claw Mastery", "Claw Mastery", "ass"],
[253, "Psychic Hammer", "Psychic Hammer", "ass"],
[254, "Tiger Strike", "Tiger Strike", "ass"],
[255, "Dragon Talon", "Dragon Talon", "ass"],
[256, "Shock Field", "Shock Web", "ass"],
[257, "Blade Sentinel", "Blade Sentinel", "ass"],
[258, "Quickness", "Burst of Speed", "ass"],
[259, "Fists of Fire", "Fists of Fire", "ass"],
[260, "Dragon Claw", "Dragon Claw", "ass"],
[261, "Charged Bolt Sentry", "Charged Bolt Sentry", "ass"],
[262, "Wake of Fire Sentry", "Wake of Fire", "ass"],
[263, "Weapon Block", "Weapon Block", "ass"],
[264, "Cloak of Shadows", "Cloak of Shadows", "ass"],
[265, "Cobra Strike", "Cobra Strike", "ass"],
[266, "Blade Fury", "Blade Fury", "ass"],
[267, "Fade", "Fade", "ass"],
[268, "Shadow Warrior", "Shadow Warrior", "ass"],
[269, "Claws of Thunder", "Claws of Thunder", "ass"],
[270, "Dragon Tail", "Dragon Tail", "ass"],
[271, "Lightning Sentry", "Lightning Sentry", "ass"],
[272, "Inferno Sentry", "Wake of Inferno", "ass"],
[273, "Mind Blast", "Mind Blast", "ass"],
[274, "Blades of Ice", "Blades of Ice", "ass"],
[275, "Dragon Flight", "Dragon Flight", "ass"],
[276, "Death Sentry", "Death Sentry", "ass"],
[277, "Blade Shield", "Blade Shield", "ass"],
[278, "Venom", "Venom", "ass"],
[279, "Shadow Master", "Shadow Master", "ass"],
[280, "Royal Strike", "Phoenix Strike", "ass"],
[350, "Delerium Change", "Delirium", ""],
]
const CLASS_ONLY_LABELS: Record<string, string> = {
ama: '(Amazon Only)',
sor: '(Sorceress Only)',
nec: '(Necromancer Only)',
pal: '(Paladin Only)',
bar: '(Barbarian Only)',
dru: '(Druid Only)',
ass: '(Assassin Only)',
}
const SKILL_LOOKUP_BY_ID = new Map<number, { name: string; classOnly?: string }>()
const SKILL_LOOKUP_BY_NAME = new Map<string, { name: string; classOnly?: string }>()
for (const [id, rawName, displayName, cls] of CANONICAL_SKILLS) {
const classOnly = cls ? CLASS_ONLY_LABELS[cls] : undefined
const info = classOnly ? { name: displayName, classOnly } : { name: displayName }
SKILL_LOOKUP_BY_ID.set(id, info)
SKILL_LOOKUP_BY_NAME.set(rawName.toLowerCase(), info)
SKILL_LOOKUP_BY_NAME.set(displayName.toLowerCase(), info)
}
/**
* Resolves a skill parameter (numeric ID or internal name from Skills.txt)
* into its 1.13c canonical English display name and optional class restriction.
*/
export function resolveSkillInfo(par: string | number | undefined): { name: string; classOnly?: string } {
if (par === undefined || par === '') return { name: 'Skill' }
const str = String(par).trim()
const num = Number(str)
if (!Number.isNaN(num) && SKILL_LOOKUP_BY_ID.has(num)) {
return SKILL_LOOKUP_BY_ID.get(num)!
}
const byName = SKILL_LOOKUP_BY_NAME.get(str.toLowerCase())
if (byName) return byName
return { name: str }
}
/**
* Canonical 1.13c skilltab mapping (par 0..20 from CharStats.txt StrSkillTab1..3 + StrClassOnly).
*/
const CANONICAL_SKILL_TABS: Record<number, string> = {
0: 'Bow and Crossbow Skills (Amazon Only)',
1: 'Passive and Magic Skills (Amazon Only)',
2: 'Javelin and Spear Skills (Amazon Only)',
3: 'Fire Skills (Sorceress Only)',
4: 'Lightning Skills (Sorceress Only)',
5: 'Cold Skills (Sorceress Only)',
6: 'Curses (Necromancer Only)',
7: 'Poison and Bone Skills (Necromancer Only)',
8: 'Summoning Skills (Necromancer Only)',
9: 'Combat Skills (Paladin Only)',
10: 'Offensive Auras (Paladin Only)',
11: 'Defensive Auras (Paladin Only)',
12: 'Combat Skills (Barbarian Only)',
13: 'Masteries (Barbarian Only)',
14: 'Warcries (Barbarian Only)',
15: 'Summoning Skills (Druid Only)',
16: 'Shape Shifting Skills (Druid Only)',
17: 'Elemental Skills (Druid Only)',
18: 'Traps (Assassin Only)',
19: 'Shadow Disciplines (Assassin Only)',
20: 'Martial Arts (Assassin Only)',
}
/**
* Canonical 1.13c MonStats.txt hcIdx -> NameStr for reanimate property.
*/
const CANONICAL_REANIMATE_MONSTERS: Record<number, string> = {
0: 'Skeleton',
1: 'Returned',
2: 'Bone Warrior',
3: 'Burning Dead',
4: 'Horror',
5: 'Zombie',
}
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()) {
// --- 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 = min || effectiveVal || 10
const slvl = 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 is a Rune
const isRune = item.code?.trim().startsWith('r') && Number(item.code.trim().slice(1)) >= 1 && Number(item.code.trim().slice(1)) <= 33
const runeKey = item.code?.trim().toLowerCase()
const runeData = isRune && runeKey ? RUNE_INFO[runeKey] : undefined
if (runeData) {
const rZh = runeKey ? RUNE_INFO_ZH[runeKey] : undefined
if (locale === 'zh') {
title = (rZh ? rZh.nameZh : runeData.nameEn) + '符文'
subTitle = `符文 (${runeData.num})`
qualityColor = '#ff8c00'
lines.push({ text: '可镶嵌进有凹槽的装备', color: 'white' })
lines.push({ text: `武器: ${rZh ? rZh.weaponZh : runeData.weapon}`, color: 'blue' })
lines.push({ text: `防具/头盔/盾牌: ${rZh ? rZh.armorZh : runeData.armor}`, color: 'blue' })
} else if (locale === 'bilingual') {
title = `${runeData.nameEn} (${rZh ? rZh.nameZh : ''})`
subTitle = `Rune (${runeData.num})`
qualityColor = '#ff8c00'
lines.push({ text: '可镶嵌进有凹槽的装备', color: 'white' })
lines.push({ text: `武器: ${rZh ? rZh.weaponZh : runeData.weapon}`, color: 'blue' })
lines.push({ text: `防具/头盔/盾牌: ${rZh ? rZh.armorZh : runeData.armor}`, color: 'blue' })
} else {
title = runeData.nameEn
subTitle = `Rune (${runeData.num})`
qualityColor = '#ff8c00'
lines.push({ text: 'Can be Inserted into Socketed Items', color: 'white' })
lines.push({ text: `Weapons: ${runeData.weapon}`, color: 'blue' })
lines.push({ text: `Armor/Helms/Shields: ${runeData.armor}`, color: 'blue' })
}
return {
title,
subTitle,
quality: locale === 'zh' ? '符文' : 'Rune',
qualityColor,
baseType: locale === 'zh' ? '符文' : 'Rune',
ilvl,
reqLevel: runeData.lvl > 0 ? runeData.lvl : undefined,
lines,
}
}
// 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 {
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)
: 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 === '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,
}
}