/** * 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 = { 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 = { 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() const SKILL_LOOKUP_BY_NAME = new Map() 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 = { 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 = { 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 = { '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 = { 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 = { 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 = { 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() 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, } }