From f68b1de4ecc29b687049d2e5744bbc8bfd72bf7d Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Sat, 19 Sep 2026 08:05:07 +0000 Subject: [PATCH] =?UTF-8?q?feat(tooltip/stats):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=AD=A6=E5=99=A8=E9=98=B2=E5=85=B7=E5=9F=BA=E7=A1=80=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E5=A2=9E=E5=BC=BA=E4=B8=8E=E5=BA=95=E6=A8=A1=E5=AF=B9?= =?UTF-8?q?=E7=85=A7=E3=80=81=E8=AF=8D=E6=9D=A1=E5=90=88=E5=B9=B6=E4=B8=8E?= =?UTF-8?q?ItemStatCost=E4=BC=98=E5=85=88=E7=BA=A7=E6=8E=92=E5=BA=8F=20(fi?= =?UTF-8?q?xes=20#123,=20#125)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- boss.html | 8 + src/boss.ts | 59 ++- src/game/item-tooltip.ts | 820 +++++++++++++++++++++++++++-- tests/item-tooltip-123-125.test.ts | 271 ++++++++++ 4 files changed, 1103 insertions(+), 55 deletions(-) create mode 100644 tests/item-tooltip-123-125.test.ts diff --git a/boss.html b/boss.html index 91044ad..5b1b2e9 100644 --- a/boss.html +++ b/boss.html @@ -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; } diff --git a/src/boss.ts b/src/boss.ts index 0212f39..4bed057 100644 --- a/src/boss.ts +++ b/src/boss.ts @@ -511,13 +511,58 @@ function renderSingleItemCard(t: FormattedItemTooltip, index: number): string { }).join('') const baseStatsHtml: string[] = [] - if (t.defense) baseStatsHtml.push(`
Defense: ${t.defense}
`) - if (t.oneHandDamage) baseStatsHtml.push(`
One-Hand Damage: ${t.oneHandDamage.min} to ${t.oneHandDamage.max}
`) - if (t.twoHandDamage) baseStatsHtml.push(`
Two-Hand Damage: ${t.twoHandDamage.min} to ${t.twoHandDamage.max}
`) - if (t.durability) baseStatsHtml.push(`
Durability: ${t.durability.current} of ${t.durability.max}
`) - if (t.reqLevel) baseStatsHtml.push(`
Required Level: ${t.reqLevel}
`) - if (t.reqStr) baseStatsHtml.push(`
Required Strength: ${t.reqStr}
`) - if (t.reqDex) baseStatsHtml.push(`
Required Dexterity: ${t.reqDex}
`) + if (t.defense !== undefined) { + const defText = + t.baseDefense !== undefined && t.baseDefense !== t.defense + ? `${t.defense} (${t.baseDefense})` + : `${t.defense}` + baseStatsHtml.push(`
Defense: ${defText}
`) + } + if (t.oneHandDamage) { + const d = t.oneHandDamage + const dmgText = + d.isEnhanced && d.baseMin !== undefined && d.baseMax !== undefined + ? `${d.min} to ${d.max} (${d.baseMin} to ${d.baseMax})` + : `${d.min} to ${d.max}` + baseStatsHtml.push(`
One-Hand Damage: ${dmgText}
`) + } + if (t.twoHandDamage) { + const d = t.twoHandDamage + const dmgText = + d.isEnhanced && d.baseMin !== undefined && d.baseMax !== undefined + ? `${d.min} to ${d.max} (${d.baseMin} to ${d.baseMax})` + : `${d.min} to ${d.max}` + baseStatsHtml.push(`
Two-Hand Damage: ${dmgText}
`) + } + if (t.durability) { + const dur = t.durability + const durText = + dur.isEnhanced && dur.baseMax !== undefined + ? `${dur.current} of ${dur.max} (${dur.baseMax})` + : `${dur.current} of ${dur.max}` + baseStatsHtml.push(`
Durability: ${durText}
`) + } + if (t.reqLevel !== undefined) { + const lvlText = + t.baseReqLevel !== undefined && t.baseReqLevel !== t.reqLevel + ? `${t.reqLevel} (${t.baseReqLevel})` + : `${t.reqLevel}` + baseStatsHtml.push(`
Required Level: ${lvlText}
`) + } + if (t.reqStr !== undefined) { + const strText = + t.baseReqStr !== undefined && t.baseReqStr !== t.reqStr + ? `${t.reqStr} (${t.baseReqStr})` + : `${t.reqStr}` + baseStatsHtml.push(`
Required Strength: ${strText}
`) + } + if (t.reqDex !== undefined) { + const dexText = + t.baseReqDex !== undefined && t.baseReqDex !== t.reqDex + ? `${t.reqDex} (${t.baseReqDex})` + : `${t.reqDex}` + baseStatsHtml.push(`
Required Dexterity: ${dexText}
`) + } return `
diff --git a/src/game/item-tooltip.ts b/src/game/item-tooltip.ts index c449cd2..3cbc4b9 100644 --- a/src/game/item-tooltip.ts +++ b/src/game/item-tooltip.ts @@ -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 = { + '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 = { + 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() + 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 } - : undefined - const twoHandDamage = (base as any).twoHandedMindam && (base as any).twoHandedMaxdam - ? { min: (base as any).twoHandedMindam, max: (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 + // 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 + ? 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 = 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, } } + diff --git a/tests/item-tooltip-123-125.test.ts b/tests/item-tooltip-123-125.test.ts new file mode 100644 index 0000000..fd046a0 --- /dev/null +++ b/tests/item-tooltip-123-125.test.ts @@ -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 = { + 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 = { + 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)', + ]) + }) +})