- Issue #123: 计算武器1H/2H增强伤害 (dmg%, dmg-min, dmg-max)、防具增强防御 (ac%, ac)、力量/敏捷/等级需求 (ease, levelreq) 及耐久度上限 (dur, dur%, 3x/2x降级),并附带原始底模数值对照(如 Two-Hand Damage: 36 to 48 (18 to 24), Defense: 260 (130), Required Strength: 124 (156)) - Issue #125: 实现 aggregateAndSortProperties: * 自动合并 fire-min + fire-max -> Adds X-Y Fire Damage(冰、电、毒、物理同理) * 自动合并 dgrp=2 四抗为 All Resistances +X,dgrp=1 四维为 +X to All Attributes * 累加前后缀同类标量词缀,并按 ItemStatCost.txt descpriority (180..0) 严格降序排列 - 全程保持 1.13c 官方英文规范,新增 tests/item-tooltip-123-125.test.ts 专项单测通过 TAG=agy CONV=aade657e-0c8e-42ff-a5f2-87b2afe80e02
This commit is contained in:
parent
2cf5acb459
commit
f68b1de4ec
|
|
@ -605,6 +605,14 @@
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
.base-stat-item .val-blue {
|
||||||
|
color: #6888ff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.base-stat-item .val-base {
|
||||||
|
color: #9e9382;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
.base-stat-item.req {
|
.base-stat-item.req {
|
||||||
color: #b08d74;
|
color: #b08d74;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
59
src/boss.ts
59
src/boss.ts
|
|
@ -511,13 +511,58 @@ function renderSingleItemCard(t: FormattedItemTooltip, index: number): string {
|
||||||
}).join('')
|
}).join('')
|
||||||
|
|
||||||
const baseStatsHtml: string[] = []
|
const baseStatsHtml: string[] = []
|
||||||
if (t.defense) baseStatsHtml.push(`<div class="base-stat-item">Defense: <span class="val-white">${t.defense}</span></div>`)
|
if (t.defense !== undefined) {
|
||||||
if (t.oneHandDamage) baseStatsHtml.push(`<div class="base-stat-item">One-Hand Damage: <span class="val-white">${t.oneHandDamage.min} to ${t.oneHandDamage.max}</span></div>`)
|
const defText =
|
||||||
if (t.twoHandDamage) baseStatsHtml.push(`<div class="base-stat-item">Two-Hand Damage: <span class="val-white">${t.twoHandDamage.min} to ${t.twoHandDamage.max}</span></div>`)
|
t.baseDefense !== undefined && t.baseDefense !== t.defense
|
||||||
if (t.durability) baseStatsHtml.push(`<div class="base-stat-item">Durability: <span class="val-white">${t.durability.current} of ${t.durability.max}</span></div>`)
|
? `<span class="val-blue">${t.defense}</span> <span class="val-base">(${t.baseDefense})</span>`
|
||||||
if (t.reqLevel) baseStatsHtml.push(`<div class="base-stat-item req">Required Level: <span class="val-white">${t.reqLevel}</span></div>`)
|
: `<span class="val-white">${t.defense}</span>`
|
||||||
if (t.reqStr) baseStatsHtml.push(`<div class="base-stat-item req">Required Strength: <span class="val-white">${t.reqStr}</span></div>`)
|
baseStatsHtml.push(`<div class="base-stat-item">Defense: ${defText}</div>`)
|
||||||
if (t.reqDex) baseStatsHtml.push(`<div class="base-stat-item req">Required Dexterity: <span class="val-white">${t.reqDex}</span></div>`)
|
}
|
||||||
|
if (t.oneHandDamage) {
|
||||||
|
const d = t.oneHandDamage
|
||||||
|
const dmgText =
|
||||||
|
d.isEnhanced && d.baseMin !== undefined && d.baseMax !== undefined
|
||||||
|
? `<span class="val-blue">${d.min} to ${d.max}</span> <span class="val-base">(${d.baseMin} to ${d.baseMax})</span>`
|
||||||
|
: `<span class="val-white">${d.min} to ${d.max}</span>`
|
||||||
|
baseStatsHtml.push(`<div class="base-stat-item">One-Hand Damage: ${dmgText}</div>`)
|
||||||
|
}
|
||||||
|
if (t.twoHandDamage) {
|
||||||
|
const d = t.twoHandDamage
|
||||||
|
const dmgText =
|
||||||
|
d.isEnhanced && d.baseMin !== undefined && d.baseMax !== undefined
|
||||||
|
? `<span class="val-blue">${d.min} to ${d.max}</span> <span class="val-base">(${d.baseMin} to ${d.baseMax})</span>`
|
||||||
|
: `<span class="val-white">${d.min} to ${d.max}</span>`
|
||||||
|
baseStatsHtml.push(`<div class="base-stat-item">Two-Hand Damage: ${dmgText}</div>`)
|
||||||
|
}
|
||||||
|
if (t.durability) {
|
||||||
|
const dur = t.durability
|
||||||
|
const durText =
|
||||||
|
dur.isEnhanced && dur.baseMax !== undefined
|
||||||
|
? `<span class="val-blue">${dur.current} of ${dur.max}</span> <span class="val-base">(${dur.baseMax})</span>`
|
||||||
|
: `<span class="val-white">${dur.current} of ${dur.max}</span>`
|
||||||
|
baseStatsHtml.push(`<div class="base-stat-item">Durability: ${durText}</div>`)
|
||||||
|
}
|
||||||
|
if (t.reqLevel !== undefined) {
|
||||||
|
const lvlText =
|
||||||
|
t.baseReqLevel !== undefined && t.baseReqLevel !== t.reqLevel
|
||||||
|
? `<span class="val-blue">${t.reqLevel}</span> <span class="val-base">(${t.baseReqLevel})</span>`
|
||||||
|
: `<span class="val-white">${t.reqLevel}</span>`
|
||||||
|
baseStatsHtml.push(`<div class="base-stat-item req">Required Level: ${lvlText}</div>`)
|
||||||
|
}
|
||||||
|
if (t.reqStr !== undefined) {
|
||||||
|
const strText =
|
||||||
|
t.baseReqStr !== undefined && t.baseReqStr !== t.reqStr
|
||||||
|
? `<span class="val-blue">${t.reqStr}</span> <span class="val-base">(${t.baseReqStr})</span>`
|
||||||
|
: `<span class="val-white">${t.reqStr}</span>`
|
||||||
|
baseStatsHtml.push(`<div class="base-stat-item req">Required Strength: ${strText}</div>`)
|
||||||
|
}
|
||||||
|
if (t.reqDex !== undefined) {
|
||||||
|
const dexText =
|
||||||
|
t.baseReqDex !== undefined && t.baseReqDex !== t.reqDex
|
||||||
|
? `<span class="val-blue">${t.reqDex}</span> <span class="val-base">(${t.baseReqDex})</span>`
|
||||||
|
: `<span class="val-white">${t.reqDex}</span>`
|
||||||
|
baseStatsHtml.push(`<div class="base-stat-item req">Required Dexterity: ${dexText}</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="d2-item-card quality-${t.quality.toLowerCase()}">
|
<div class="d2-item-card quality-${t.quality.toLowerCase()}">
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,33 @@ export interface FormattedItemTooltip {
|
||||||
baseType: string
|
baseType: string
|
||||||
ilvl: number
|
ilvl: number
|
||||||
reqLevel?: number | undefined
|
reqLevel?: number | undefined
|
||||||
|
baseReqLevel?: number | undefined
|
||||||
reqStr?: number | undefined
|
reqStr?: number | undefined
|
||||||
|
baseReqStr?: number | undefined
|
||||||
reqDex?: number | undefined
|
reqDex?: number | undefined
|
||||||
|
baseReqDex?: number | undefined
|
||||||
defense?: number | undefined
|
defense?: number | undefined
|
||||||
oneHandDamage?: { min: number; max: number } | undefined
|
baseDefense?: number | undefined
|
||||||
twoHandDamage?: { min: number; max: number } | undefined
|
oneHandDamage?: {
|
||||||
durability?: { current: number; max: number } | undefined
|
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
|
sockets?: number | undefined
|
||||||
lines: FormattedStatLine[]
|
lines: FormattedStatLine[]
|
||||||
setPieces?: string[] | undefined
|
setPieces?: string[] | undefined
|
||||||
|
|
@ -916,6 +937,579 @@ export function formatPropertyCode(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPropertyPriority(code: string): number {
|
||||||
|
return PROP_DESC_PRIORITY[code.trim().toLowerCase()] ?? 10
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
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):
|
||||||
|
* 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` when all 4 are positive
|
||||||
|
* 4. Merges 4 core attributes (`dgrp=1`) into `+X to All Attributes` when all 4 are positive
|
||||||
|
* 5. Accumulates identical scalar stats across prefixes, suffixes, and base modifiers
|
||||||
|
* 6. Sorts all resulting properties descending by `ItemStatCost.txt` `descpriority`
|
||||||
|
*/
|
||||||
|
export function aggregateAndSortProperties(rawProps: readonly RolledItemProp[]): 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') {
|
||||||
|
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)
|
||||||
|
const commonAttr = Math.min(strVal, dexVal, vitVal, enrVal)
|
||||||
|
if (commonAttr > 0) {
|
||||||
|
out.push({ code: 'all-stats', min: commonAttr, max: commonAttr, value: commonAttr })
|
||||||
|
strVal -= commonAttr
|
||||||
|
dexVal -= commonAttr
|
||||||
|
vitVal -= commonAttr
|
||||||
|
enrVal -= commonAttr
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
const commonRes = Math.min(fireRes, coldRes, ltngRes, poisRes)
|
||||||
|
if (commonRes > 0) {
|
||||||
|
out.push({ code: 'res-all', min: commonRes, max: commonRes, value: commonRes })
|
||||||
|
fireRes -= commonRes
|
||||||
|
coldRes -= commonRes
|
||||||
|
ltngRes -= commonRes
|
||||||
|
poisRes -= commonRes
|
||||||
|
}
|
||||||
|
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
|
||||||
|
return out
|
||||||
|
.map((item, index) => ({ item, index, prio: getPropertyPriority(item.code) }))
|
||||||
|
.sort((a, b) => (b.prio !== a.prio ? b.prio - a.prio : a.index - b.index))
|
||||||
|
.map(x => x.item)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates formatted tooltip structure for any dropped Diablo II Item.
|
* Generates formatted tooltip structure for any dropped Diablo II Item.
|
||||||
*/
|
*/
|
||||||
|
|
@ -968,27 +1562,28 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine Quality Presentation & Modifiers
|
// Collect raw properties from the item across all quality tiers
|
||||||
|
const rawProps: RolledItemProp[] = []
|
||||||
|
let affixReqLevel = 0
|
||||||
|
|
||||||
if (quality === 'unique') {
|
if (quality === 'unique') {
|
||||||
qualityColor = '#c8a15a'
|
qualityColor = '#c8a15a'
|
||||||
const uniqDef = item.uniqueItemDef as UniqueItem | undefined
|
const uniqDef = item.uniqueItemDef as UniqueItem | undefined
|
||||||
if (uniqDef) {
|
if (uniqDef) {
|
||||||
title = uniqDef.index
|
title = uniqDef.index
|
||||||
subTitle = base.name
|
subTitle = base.name
|
||||||
// Add Unique Properties with concrete rolled values
|
if (uniqDef.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, uniqDef.levelreq)
|
||||||
if (item.rolledProps && item.rolledProps.length > 0) {
|
if (item.rolledProps && item.rolledProps.length > 0) {
|
||||||
for (const prop of item.rolledProps) {
|
rawProps.push(...item.rolledProps)
|
||||||
lines.push({
|
|
||||||
text: formatPropertyCode(prop.code, prop.value, prop.min, prop.max, prop.param),
|
|
||||||
color: 'blue',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
for (const prop of uniqDef.props) {
|
for (const prop of uniqDef.props) {
|
||||||
if (prop.code) {
|
if (prop.code) {
|
||||||
lines.push({
|
rawProps.push({
|
||||||
text: formatPropertyCode(prop.code, prop.min, prop.min, prop.max, prop.par),
|
code: prop.code,
|
||||||
color: 'blue',
|
param: prop.par,
|
||||||
|
min: prop.min,
|
||||||
|
max: prop.max,
|
||||||
|
value: prop.min,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1002,19 +1597,18 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
||||||
if (setDef) {
|
if (setDef) {
|
||||||
title = setDef.index
|
title = setDef.index
|
||||||
subTitle = `${base.name} · [${setDef.set}]`
|
subTitle = `${base.name} · [${setDef.set}]`
|
||||||
|
if (setDef.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, setDef.levelreq)
|
||||||
if (item.rolledProps && item.rolledProps.length > 0) {
|
if (item.rolledProps && item.rolledProps.length > 0) {
|
||||||
for (const prop of item.rolledProps) {
|
rawProps.push(...item.rolledProps)
|
||||||
lines.push({
|
|
||||||
text: formatPropertyCode(prop.code, prop.value, prop.min, prop.max, prop.param),
|
|
||||||
color: 'blue',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
for (const prop of setDef.props) {
|
for (const prop of setDef.props) {
|
||||||
if (prop.code) {
|
if (prop.code) {
|
||||||
lines.push({
|
rawProps.push({
|
||||||
text: formatPropertyCode(prop.code, prop.min, prop.min, prop.max, prop.par),
|
code: prop.code,
|
||||||
color: 'blue',
|
param: prop.par,
|
||||||
|
min: prop.min,
|
||||||
|
max: prop.max,
|
||||||
|
value: prop.min,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1029,13 +1623,19 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
||||||
if (rareDef && rareDef.affixes) {
|
if (rareDef && rareDef.affixes) {
|
||||||
title = rareDef.name
|
title = rareDef.name
|
||||||
for (const aff of rareDef.affixes) {
|
for (const aff of rareDef.affixes) {
|
||||||
|
if (aff.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, aff.levelreq)
|
||||||
for (const mod of aff.mods) {
|
for (const mod of aff.mods) {
|
||||||
lines.push({
|
rawProps.push({
|
||||||
text: formatPropertyCode(mod.code, mod.value, mod.min, mod.max, mod.param),
|
code: mod.code,
|
||||||
color: 'blue',
|
param: mod.param,
|
||||||
|
min: mod.min,
|
||||||
|
max: mod.max,
|
||||||
|
value: mod.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (item.rolledProps && item.rolledProps.length > 0) {
|
||||||
|
rawProps.push(...item.rolledProps)
|
||||||
}
|
}
|
||||||
} else if (quality === 'magic') {
|
} else if (quality === 'magic') {
|
||||||
qualityColor = '#4880ff'
|
qualityColor = '#4880ff'
|
||||||
|
|
@ -1044,51 +1644,170 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
||||||
title = magicDef.name
|
title = magicDef.name
|
||||||
subTitle = base.name
|
subTitle = base.name
|
||||||
if (magicDef.prefix) {
|
if (magicDef.prefix) {
|
||||||
|
if (magicDef.prefix.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, magicDef.prefix.levelreq)
|
||||||
for (const m of magicDef.prefix.mods) {
|
for (const m of magicDef.prefix.mods) {
|
||||||
lines.push({
|
rawProps.push({
|
||||||
text: formatPropertyCode(m.code, m.value, m.min, m.max, m.param),
|
code: m.code,
|
||||||
color: 'blue',
|
param: m.param,
|
||||||
|
min: m.min,
|
||||||
|
max: m.max,
|
||||||
|
value: m.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (magicDef.suffix) {
|
if (magicDef.suffix) {
|
||||||
|
if (magicDef.suffix.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, magicDef.suffix.levelreq)
|
||||||
for (const m of magicDef.suffix.mods) {
|
for (const m of magicDef.suffix.mods) {
|
||||||
lines.push({
|
rawProps.push({
|
||||||
text: formatPropertyCode(m.code, m.value, m.min, m.max, m.param),
|
code: m.code,
|
||||||
color: 'blue',
|
param: m.param,
|
||||||
|
min: m.min,
|
||||||
|
max: m.max,
|
||||||
|
value: m.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (item.rolledProps && item.rolledProps.length > 0) {
|
||||||
|
subTitle = base.name
|
||||||
|
rawProps.push(...item.rolledProps)
|
||||||
} else {
|
} else {
|
||||||
subTitle = base.name
|
subTitle = base.name
|
||||||
}
|
}
|
||||||
} else if (quality === 'superior') {
|
} else if (quality === 'superior') {
|
||||||
qualityColor = '#ffffff'
|
qualityColor = '#ffffff'
|
||||||
title = `Superior ${base.name}`
|
title = `Superior ${base.name}`
|
||||||
lines.push({ text: '+15% Enhanced Defense', color: 'blue' })
|
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') {
|
} else if (quality === 'low') {
|
||||||
qualityColor = '#a0a0a0'
|
qualityColor = '#a0a0a0'
|
||||||
title = `Cracked ${base.name}`
|
title = `Cracked ${base.name}`
|
||||||
} else {
|
} else {
|
||||||
qualityColor = '#e0d5c1'
|
qualityColor = '#e0d5c1'
|
||||||
title = base.name
|
title = base.name
|
||||||
|
if (item.rolledProps && item.rolledProps.length > 0) {
|
||||||
|
rawProps.push(...item.rolledProps)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Equipment Base Stats
|
// Merge and sort properties per Issue #125 (min/max damage pairs, dgrp resists/attributes, descpriority)
|
||||||
const reqLevel = (base as any).levelreq > 0 ? (base as any).levelreq : undefined
|
const mergedProps = aggregateAndSortProperties(rawProps)
|
||||||
const reqStr = (base as any).reqstr > 0 ? (base as any).reqstr : undefined
|
for (const prop of mergedProps) {
|
||||||
const reqDex = (base as any).reqdex > 0 ? (base as any).reqdex : undefined
|
lines.push({
|
||||||
const defense = base.defense > 0 ? (item.stats.defense ?? base.defense) : undefined
|
text: formatPropertyCode(prop.code, prop.value, prop.min, prop.max, prop.param),
|
||||||
const oneHandDamage = (base as any).mindam && (base as any).maxdam
|
color: 'blue',
|
||||||
? { min: (base as any).mindam, max: (base as any).maxdam }
|
})
|
||||||
: undefined
|
}
|
||||||
const twoHandDamage = (base as any).twoHandedMindam && (base as any).twoHandedMaxdam
|
|
||||||
? { min: (base as any).twoHandedMindam, max: (base as any).twoHandedMaxdam }
|
// Compute Enhanced Base Stats vs Original Base Stats per Issue #123
|
||||||
: undefined
|
let totalEdPct = 0
|
||||||
const durability = item.durability && item.maxDurability
|
let flatMinDmg = 0
|
||||||
? { current: item.durability, max: item.maxDurability }
|
let flatMaxDmg = 0
|
||||||
: undefined
|
let totalAcPct = 0
|
||||||
const sockets = (base as any).gemsockets > 0 ? (base as any).gemsockets : undefined
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Defense (Enhanced vs Base)
|
||||||
|
const rawBaseDef = base.defense > 0 ? base.defense : 0
|
||||||
|
let defense: number | undefined = undefined
|
||||||
|
let baseDefense: number | undefined = undefined
|
||||||
|
if (rawBaseDef > 0) {
|
||||||
|
const finalDef = Math.floor(rawBaseDef * (100 + totalAcPct) / 100) + flatAc
|
||||||
|
defense = finalDef
|
||||||
|
if (finalDef !== rawBaseDef) {
|
||||||
|
baseDefense = rawBaseDef
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. One-Hand & Two-Hand Damage (Enhanced vs Base)
|
||||||
|
const computeWeaponDamage = (baseMin: number, baseMax: number) => {
|
||||||
|
let finalMin = Math.floor(baseMin * (100 + totalEdPct) / 100) + flatMinDmg
|
||||||
|
let finalMax = Math.floor(baseMax * (100 + totalEdPct) / 100) + flatMaxDmg
|
||||||
|
if ((totalEdPct !== 0 || flatMinDmg !== 0 || flatMaxDmg !== 0) && finalMin >= finalMax) {
|
||||||
|
finalMax = finalMin + 1
|
||||||
|
}
|
||||||
|
const isEnhanced = finalMin !== baseMin || finalMax !== baseMax
|
||||||
|
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))
|
||||||
|
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))
|
||||||
|
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
|
||||||
|
durability = isEnhanced
|
||||||
|
? { current: finalCurDur, max: finalMaxDur, baseMax: rawBaseDur, isEnhanced: true }
|
||||||
|
: { current: finalCurDur, max: finalMaxDur }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sockets = rolledSockets > 0 ? rolledSockets : (base as any).gemsockets > 0 ? (base as any).gemsockets : undefined
|
||||||
|
|
||||||
const displayQuality =
|
const displayQuality =
|
||||||
quality === 'unique' ? 'Unique' :
|
quality === 'unique' ? 'Unique' :
|
||||||
|
|
@ -1107,9 +1826,13 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
||||||
baseType: base.kind === 'weapon' ? 'Weapon' : base.kind === 'armor' ? 'Armor' : 'Misc',
|
baseType: base.kind === 'weapon' ? 'Weapon' : base.kind === 'armor' ? 'Armor' : 'Misc',
|
||||||
ilvl,
|
ilvl,
|
||||||
reqLevel,
|
reqLevel,
|
||||||
|
...(baseReqLevel !== undefined ? { baseReqLevel } : {}),
|
||||||
reqStr,
|
reqStr,
|
||||||
|
...(baseReqStr !== undefined ? { baseReqStr } : {}),
|
||||||
reqDex,
|
reqDex,
|
||||||
|
...(baseReqDex !== undefined ? { baseReqDex } : {}),
|
||||||
defense,
|
defense,
|
||||||
|
...(baseDefense !== undefined ? { baseDefense } : {}),
|
||||||
oneHandDamage,
|
oneHandDamage,
|
||||||
twoHandDamage,
|
twoHandDamage,
|
||||||
durability,
|
durability,
|
||||||
|
|
@ -1117,3 +1840,4 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
||||||
lines,
|
lines,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,271 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { ItemBase } from '../src/game/items.ts'
|
||||||
|
import { createDroppedItem } from '../src/game/drop-pipeline.ts'
|
||||||
|
import { formatItemTooltip, aggregateAndSortProperties } from '../src/game/item-tooltip.ts'
|
||||||
|
|
||||||
|
describe('Issue #123 & Issue #125: Enhanced vs Base Stats, Property Merging & Priority Sorting', () => {
|
||||||
|
it('Issue #123: calculates enhanced 1H/2H weapon damage, defense, requirements, and durability alongside original base stats', () => {
|
||||||
|
const weaponBase: ItemBase & Record<string, any> = {
|
||||||
|
id: 'btx',
|
||||||
|
name: 'Battle Axe',
|
||||||
|
kind: 'weapon',
|
||||||
|
tags: ['axe', 'weap'],
|
||||||
|
damage: 24,
|
||||||
|
defense: 0,
|
||||||
|
value: 500,
|
||||||
|
invWidth: 2,
|
||||||
|
invHeight: 3,
|
||||||
|
maxStack: 1,
|
||||||
|
level: 25,
|
||||||
|
mindam: 6,
|
||||||
|
maxdam: 12,
|
||||||
|
twoHandedMindam: 18,
|
||||||
|
twoHandedMaxdam: 24,
|
||||||
|
reqstr: 100,
|
||||||
|
reqdex: 50,
|
||||||
|
levelreq: 20,
|
||||||
|
durability: 40,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 100% ED + Requirements -20%
|
||||||
|
const droppedWeapon = createDroppedItem(weaponBase, 'unique', {
|
||||||
|
ilvl: 85,
|
||||||
|
dwInitSeed: 42,
|
||||||
|
uniqueItem: {
|
||||||
|
id: 1,
|
||||||
|
index: 'The Chieftain',
|
||||||
|
version: 100,
|
||||||
|
enabled: true,
|
||||||
|
ladder: false,
|
||||||
|
rarity: 1,
|
||||||
|
nolimit: false,
|
||||||
|
lvl: 29,
|
||||||
|
levelreq: 35,
|
||||||
|
code: 'btx',
|
||||||
|
carry1: false,
|
||||||
|
costMult: 5,
|
||||||
|
costAdd: 5000,
|
||||||
|
props: [
|
||||||
|
{ code: 'dmg%', min: 100, max: 100 },
|
||||||
|
{ code: 'ease', min: -20, max: -20 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const wTooltip = formatItemTooltip(droppedWeapon)
|
||||||
|
|
||||||
|
// Two-Hand Damage: 36 to 48 (18 to 24)
|
||||||
|
expect(wTooltip.twoHandDamage).toEqual({
|
||||||
|
min: 36,
|
||||||
|
max: 48,
|
||||||
|
baseMin: 18,
|
||||||
|
baseMax: 24,
|
||||||
|
isEnhanced: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// One-Hand Damage: 12 to 24 (6 to 12)
|
||||||
|
expect(wTooltip.oneHandDamage).toEqual({
|
||||||
|
min: 12,
|
||||||
|
max: 24,
|
||||||
|
baseMin: 6,
|
||||||
|
baseMax: 12,
|
||||||
|
isEnhanced: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Requirements -20%: Str 100 -> 80 (100), Dex 50 -> 40 (50), Level 35 (20)
|
||||||
|
expect(wTooltip.reqStr).toBe(80)
|
||||||
|
expect(wTooltip.baseReqStr).toBe(100)
|
||||||
|
expect(wTooltip.reqDex).toBe(40)
|
||||||
|
expect(wTooltip.baseReqDex).toBe(50)
|
||||||
|
expect(wTooltip.reqLevel).toBe(35)
|
||||||
|
expect(wTooltip.baseReqLevel).toBe(20)
|
||||||
|
|
||||||
|
// Armor with +100% Enhanced Defense (Defense: 260 (130)) and 3x Rare Fallback Durability (258 of 258 (86))
|
||||||
|
const armorBase: ItemBase & Record<string, any> = {
|
||||||
|
id: 'uit',
|
||||||
|
name: 'Monarch',
|
||||||
|
kind: 'armor',
|
||||||
|
tags: ['shie', 'armo'],
|
||||||
|
damage: 0,
|
||||||
|
defense: 130,
|
||||||
|
value: 1000,
|
||||||
|
invWidth: 2,
|
||||||
|
invHeight: 3,
|
||||||
|
maxStack: 1,
|
||||||
|
level: 72,
|
||||||
|
reqstr: 156,
|
||||||
|
levelreq: 54,
|
||||||
|
durability: 86,
|
||||||
|
}
|
||||||
|
|
||||||
|
const droppedArmor = createDroppedItem(armorBase, 'unique', {
|
||||||
|
ilvl: 85,
|
||||||
|
dwInitSeed: 99,
|
||||||
|
durabilityMultiplier: 3,
|
||||||
|
uniqueItem: {
|
||||||
|
id: 2,
|
||||||
|
index: 'Stormshield',
|
||||||
|
version: 100,
|
||||||
|
enabled: true,
|
||||||
|
ladder: false,
|
||||||
|
rarity: 1,
|
||||||
|
nolimit: false,
|
||||||
|
lvl: 77,
|
||||||
|
levelreq: 73,
|
||||||
|
code: 'uit',
|
||||||
|
carry1: false,
|
||||||
|
costMult: 5,
|
||||||
|
costAdd: 5000,
|
||||||
|
props: [
|
||||||
|
{ code: 'ac%', min: 100, max: 100 },
|
||||||
|
{ code: 'ease', min: -20, max: -20 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const aTooltip = formatItemTooltip(droppedArmor)
|
||||||
|
expect(aTooltip.defense).toBe(260)
|
||||||
|
expect(aTooltip.baseDefense).toBe(130)
|
||||||
|
expect(aTooltip.reqStr).toBe(124)
|
||||||
|
expect(aTooltip.baseReqStr).toBe(156)
|
||||||
|
expect(aTooltip.durability).toEqual({
|
||||||
|
current: 258,
|
||||||
|
max: 258,
|
||||||
|
baseMax: 86,
|
||||||
|
isEnhanced: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Issue #125: merges elemental and physical min/max damage pairs (e.g., fire-min +2 and fire-max +8 -> Adds 2-8 Fire Damage)', () => {
|
||||||
|
const merged = aggregateAndSortProperties([
|
||||||
|
{ code: 'fire-min', min: 2, max: 2, value: 2 },
|
||||||
|
{ code: 'fire-max', min: 8, max: 8, value: 8 },
|
||||||
|
{ code: 'ltng-min', min: 1, max: 1, value: 1 },
|
||||||
|
{ code: 'ltng-max', min: 20, max: 20, value: 20 },
|
||||||
|
{ code: 'cold-min', min: 6, max: 6, value: 6 },
|
||||||
|
{ code: 'cold-max', min: 14, max: 14, value: 14 },
|
||||||
|
{ code: 'dmg-min', min: 5, max: 5, value: 5 },
|
||||||
|
{ code: 'dmg-max', min: 10, max: 10, value: 10 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const base: ItemBase = {
|
||||||
|
id: 'rin',
|
||||||
|
name: 'Ring',
|
||||||
|
kind: 'armor',
|
||||||
|
tags: ['ring'],
|
||||||
|
damage: 0,
|
||||||
|
defense: 0,
|
||||||
|
value: 100,
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
maxStack: 1,
|
||||||
|
level: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = createDroppedItem(base, 'normal', {
|
||||||
|
ilvl: 50,
|
||||||
|
dwInitSeed: 1,
|
||||||
|
})
|
||||||
|
;(item as any).rolledProps = merged
|
||||||
|
|
||||||
|
const tooltip = formatItemTooltip(item)
|
||||||
|
const texts = tooltip.lines.map(l => l.text)
|
||||||
|
|
||||||
|
expect(texts).toContain('Adds 5-10 Damage')
|
||||||
|
expect(texts).toContain('Adds 2-8 Fire Damage')
|
||||||
|
expect(texts).toContain('Adds 1-20 Lightning Damage')
|
||||||
|
expect(texts).toContain('Adds 6-14 Cold Damage')
|
||||||
|
expect(texts).not.toContain('+2 to Minimum Fire Damage')
|
||||||
|
expect(texts).not.toContain('+8 to Maximum Fire Damage')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Issue #125: merges 4-element resistances (dgrp=2) into All Resistances and 4 attributes (dgrp=1) into All Attributes', () => {
|
||||||
|
const base: ItemBase = {
|
||||||
|
id: 'amu',
|
||||||
|
name: 'Amulet',
|
||||||
|
kind: 'armor',
|
||||||
|
tags: ['amul'],
|
||||||
|
damage: 0,
|
||||||
|
defense: 0,
|
||||||
|
value: 100,
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
maxStack: 1,
|
||||||
|
level: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = createDroppedItem(base, 'normal', { ilvl: 80, dwInitSeed: 2 })
|
||||||
|
;(item as any).rolledProps = [
|
||||||
|
{ code: 'res-fire', min: 35, max: 35, value: 35 },
|
||||||
|
{ code: 'res-cold', min: 15, max: 15, value: 15 },
|
||||||
|
{ code: 'res-ltng', min: 15, max: 15, value: 15 },
|
||||||
|
{ code: 'res-pois', min: 15, max: 15, value: 15 },
|
||||||
|
{ code: 'str', min: 10, max: 10, value: 10 },
|
||||||
|
{ code: 'dex', min: 10, max: 10, value: 10 },
|
||||||
|
{ code: 'vit', min: 10, max: 10, value: 10 },
|
||||||
|
{ code: 'enr', min: 10, max: 10, value: 10 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const tooltip = formatItemTooltip(item)
|
||||||
|
const texts = tooltip.lines.map(l => l.text)
|
||||||
|
|
||||||
|
// 4 attributes of +10 merge into "+10 to All Attributes"
|
||||||
|
// 4 resists of 15 + extra 20 fire merge into "All Resistances +15" and "Fire Resist +20%"
|
||||||
|
expect(texts).toEqual(['+10 to All Attributes', 'All Resistances +15', 'Fire Resist +20%'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Issue #125: sorts properties in descending order of ItemStatCost.txt descpriority', () => {
|
||||||
|
const base: ItemBase = {
|
||||||
|
id: 'uap',
|
||||||
|
name: 'Shako',
|
||||||
|
kind: 'armor',
|
||||||
|
tags: ['helm', 'armo'],
|
||||||
|
damage: 0,
|
||||||
|
defense: 141,
|
||||||
|
value: 1000,
|
||||||
|
invWidth: 2,
|
||||||
|
invHeight: 2,
|
||||||
|
maxStack: 1,
|
||||||
|
level: 58,
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = createDroppedItem(base, 'normal', { ilvl: 85, dwInitSeed: 3 })
|
||||||
|
// Deliberately provide properties in reverse/scrambled priority order
|
||||||
|
;(item as any).rolledProps = [
|
||||||
|
{ code: 'sock', min: 2, max: 2, value: 2 }, // prio 0
|
||||||
|
{ code: 'rep-dur', param: 5, min: 0, max: 0, value: 0 }, // prio 1
|
||||||
|
{ code: 'mag%', min: 50, max: 50, value: 50 }, // prio 8
|
||||||
|
{ code: 'res-fire', min: 30, max: 30, value: 30 }, // prio 36
|
||||||
|
{ code: 'mana', min: 40, max: 40, value: 40 }, // prio 55
|
||||||
|
{ code: 'hp', min: 60, max: 60, value: 60 }, // prio 59
|
||||||
|
{ code: 'str', min: 20, max: 20, value: 20 }, // prio 67
|
||||||
|
{ code: 'ac%', min: 150, max: 150, value: 150 }, // prio 74
|
||||||
|
{ code: 'lifesteal', min: 8, max: 8, value: 8 }, // prio 88
|
||||||
|
{ code: 'dmg-fire', min: 10, max: 20, value: 10 }, // prio 102
|
||||||
|
{ code: 'dmg%', min: 200, max: 200, value: 200 }, // prio 129
|
||||||
|
{ code: 'swing2', min: 20, max: 20, value: 20 }, // prio 145
|
||||||
|
{ code: 'allskills', min: 2, max: 2, value: 2 }, // prio 158
|
||||||
|
{ code: 'hit-skill', param: 197, min: 5, max: 10, value: 5 }, // prio 160
|
||||||
|
]
|
||||||
|
|
||||||
|
const tooltip = formatItemTooltip(item)
|
||||||
|
const texts = tooltip.lines.map(l => l.text)
|
||||||
|
|
||||||
|
expect(texts).toEqual([
|
||||||
|
'5% Chance to cast level 10 Firestorm on striking',
|
||||||
|
'+2 to All Skills',
|
||||||
|
'+20% Increased Attack Speed',
|
||||||
|
'+200% Enhanced Damage',
|
||||||
|
'Adds 10-20 Fire Damage',
|
||||||
|
'8% Life stolen per hit',
|
||||||
|
'+150% Enhanced Defense',
|
||||||
|
'+20 to Strength',
|
||||||
|
'+60 to Life',
|
||||||
|
'+40 to Mana',
|
||||||
|
'Fire Resist +30%',
|
||||||
|
'50% Better Chance of Getting Magic Items',
|
||||||
|
'Repairs 1 durability in 20 seconds',
|
||||||
|
'Socketed (2)',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue