2501 lines
81 KiB
TypeScript
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,
|
|
}
|
|
}
|
|
|