feat(tooltip/stats): 实现武器防具基础属性增强与底模对照、词条合并与ItemStatCost优先级排序 (fixes #123, #125)

- 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:
troytt 2026-09-19 08:05:07 +00:00
parent 2cf5acb459
commit f68b1de4ec
4 changed files with 1103 additions and 55 deletions

View File

@ -605,6 +605,14 @@
color: #fff;
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 {
color: #b08d74;
}

View File

@ -511,13 +511,58 @@ function renderSingleItemCard(t: FormattedItemTooltip, index: number): string {
}).join('')
const baseStatsHtml: string[] = []
if (t.defense) baseStatsHtml.push(`<div class="base-stat-item">Defense: <span class="val-white">${t.defense}</span></div>`)
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>`)
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>`)
if (t.durability) baseStatsHtml.push(`<div class="base-stat-item">Durability: <span class="val-white">${t.durability.current} of ${t.durability.max}</span></div>`)
if (t.reqLevel) baseStatsHtml.push(`<div class="base-stat-item req">Required Level: <span class="val-white">${t.reqLevel}</span></div>`)
if (t.reqStr) baseStatsHtml.push(`<div class="base-stat-item req">Required Strength: <span class="val-white">${t.reqStr}</span></div>`)
if (t.reqDex) baseStatsHtml.push(`<div class="base-stat-item req">Required Dexterity: <span class="val-white">${t.reqDex}</span></div>`)
if (t.defense !== undefined) {
const defText =
t.baseDefense !== undefined && t.baseDefense !== t.defense
? `<span class="val-blue">${t.defense}</span> <span class="val-base">(${t.baseDefense})</span>`
: `<span class="val-white">${t.defense}</span>`
baseStatsHtml.push(`<div class="base-stat-item">Defense: ${defText}</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 `
<div class="d2-item-card quality-${t.quality.toLowerCase()}">

View File

@ -28,12 +28,33 @@ export interface FormattedItemTooltip {
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
oneHandDamage?: { min: number; max: number } | undefined
twoHandDamage?: { min: number; max: number } | undefined
durability?: { current: number; max: 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
@ -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.
*/
@ -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') {
qualityColor = '#c8a15a'
const uniqDef = item.uniqueItemDef as UniqueItem | undefined
if (uniqDef) {
title = uniqDef.index
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) {
for (const prop of item.rolledProps) {
lines.push({
text: formatPropertyCode(prop.code, prop.value, prop.min, prop.max, prop.param),
color: 'blue',
})
}
rawProps.push(...item.rolledProps)
} else {
for (const prop of uniqDef.props) {
if (prop.code) {
lines.push({
text: formatPropertyCode(prop.code, prop.min, prop.min, prop.max, prop.par),
color: 'blue',
rawProps.push({
code: prop.code,
param: prop.par,
min: prop.min,
max: prop.max,
value: prop.min,
})
}
}
@ -1002,19 +1597,18 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
if (setDef) {
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) {
for (const prop of item.rolledProps) {
lines.push({
text: formatPropertyCode(prop.code, prop.value, prop.min, prop.max, prop.param),
color: 'blue',
})
}
rawProps.push(...item.rolledProps)
} else {
for (const prop of setDef.props) {
if (prop.code) {
lines.push({
text: formatPropertyCode(prop.code, prop.min, prop.min, prop.max, prop.par),
color: 'blue',
rawProps.push({
code: prop.code,
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) {
title = rareDef.name
for (const aff of rareDef.affixes) {
if (aff.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, aff.levelreq)
for (const mod of aff.mods) {
lines.push({
text: formatPropertyCode(mod.code, mod.value, mod.min, mod.max, mod.param),
color: 'blue',
rawProps.push({
code: mod.code,
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') {
qualityColor = '#4880ff'
@ -1044,51 +1644,170 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
title = magicDef.name
subTitle = base.name
if (magicDef.prefix) {
if (magicDef.prefix.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, magicDef.prefix.levelreq)
for (const m of magicDef.prefix.mods) {
lines.push({
text: formatPropertyCode(m.code, m.value, m.min, m.max, m.param),
color: 'blue',
rawProps.push({
code: m.code,
param: m.param,
min: m.min,
max: m.max,
value: m.value,
})
}
}
if (magicDef.suffix) {
if (magicDef.suffix.levelreq > 0) affixReqLevel = Math.max(affixReqLevel, magicDef.suffix.levelreq)
for (const m of magicDef.suffix.mods) {
lines.push({
text: formatPropertyCode(m.code, m.value, m.min, m.max, m.param),
color: 'blue',
rawProps.push({
code: m.code,
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 {
subTitle = base.name
}
} else if (quality === 'superior') {
qualityColor = '#ffffff'
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') {
qualityColor = '#a0a0a0'
title = `Cracked ${base.name}`
} else {
qualityColor = '#e0d5c1'
title = base.name
if (item.rolledProps && item.rolledProps.length > 0) {
rawProps.push(...item.rolledProps)
}
}
// Equipment Base Stats
const reqLevel = (base as any).levelreq > 0 ? (base as any).levelreq : undefined
const reqStr = (base as any).reqstr > 0 ? (base as any).reqstr : undefined
const reqDex = (base as any).reqdex > 0 ? (base as any).reqdex : undefined
const defense = base.defense > 0 ? (item.stats.defense ?? base.defense) : undefined
const oneHandDamage = (base as any).mindam && (base as any).maxdam
? { min: (base as any).mindam, max: (base as any).maxdam }
// 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),
color: 'blue',
})
}
// Compute Enhanced Base Stats vs Original Base Stats per Issue #123
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
}
// 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
? { min: (base as any).twoHandedMindam, max: (base as any).twoHandedMaxdam }
const twoHandDamage =
(base as any).twoHandedMindam && (base as any).twoHandedMaxdam
? computeWeaponDamage((base as any).twoHandedMindam, (base as any).twoHandedMaxdam)
: undefined
const durability = item.durability && item.maxDurability
? { current: item.durability, max: item.maxDurability }
: undefined
const sockets = (base as any).gemsockets > 0 ? (base as any).gemsockets : 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 =
quality === 'unique' ? 'Unique' :
@ -1107,9 +1826,13 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
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,
@ -1117,3 +1840,4 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
lines,
}
}

View File

@ -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)',
])
})
})