fix(tooltip): 对齐1.13c dgrp全等合并规则,四抗与四维属性仅在全部相等时折叠显示 (fixes #130)

This commit is contained in:
troytt 2026-09-19 10:32:47 +00:00
parent 6a935cc91a
commit 041cd65ce0
3 changed files with 143 additions and 38 deletions

View File

@ -1181,6 +1181,7 @@ const ALIAS_CODE_MAP: Record<string, string> = {
coldresist: 'res-cold',
lightresist: 'res-ltng',
poisonresist: 'res-pois',
'all-res': 'res-all',
dmag: 'red-dmg%',
'dmag-ac': 'red-dmg',
'mag-ac': 'red-mag',
@ -1194,11 +1195,11 @@ const ALIAS_CODE_MAP: Record<string, string> = {
}
/**
* Aggregates and sorts item properties per Diablo II 1.13c tooltip rules (Issue #125):
* Aggregates and sorts item properties per Diablo II 1.13c tooltip rules (Issue #125 & Issue #130):
* 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
* 3. Merges 4-element resistances (`dgrp=2`) into `All Resistances +X` ONLY when all 4 are positive and strictly equal
* 4. Merges 4 core attributes (`dgrp=1`) into `+X to All Attributes` ONLY when all 4 are positive and strictly equal
* 5. Accumulates identical scalar stats across prefixes, suffixes, and base modifiers
* 6. Sorts all resulting properties descending by `ItemStatCost.txt` `descpriority`
*/
@ -1347,7 +1348,7 @@ export function aggregateAndSortProperties(rawProps: readonly RolledItemProp[]):
}
// 7. Resistances bucket (dgrp=2)
if (code === 'res-all') {
if (code === 'res-all' || code === 'all-res') {
fireRes += val
coldRes += val
ltngRes += val
@ -1481,33 +1482,25 @@ export function aggregateAndSortProperties(rawProps: readonly RolledItemProp[]):
})
}
// 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
}
// Emit merged Attributes (dgrp=1): fold ONLY when all 4 attributes are positive and strictly equal
if (strVal > 0 && strVal === dexVal && dexVal === vitVal && vitVal === enrVal) {
out.push({ code: 'all-stats', min: strVal, max: strVal, value: strVal })
} else {
if (strVal !== 0) out.push({ code: 'str', min: strVal, max: strVal, value: strVal })
if (dexVal !== 0) out.push({ code: 'dex', min: dexVal, max: dexVal, value: dexVal })
if (vitVal !== 0) out.push({ code: 'vit', min: vitVal, max: vitVal, value: vitVal })
if (enrVal !== 0) out.push({ code: 'enr', min: enrVal, max: enrVal, value: enrVal })
// Emit merged Resistances (dgrp=2)
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
}
// Emit merged Resistances (dgrp=2): fold ONLY when all 4 resistances are positive and strictly equal
if (fireRes > 0 && fireRes === coldRes && coldRes === ltngRes && ltngRes === poisRes) {
out.push({ code: 'res-all', min: fireRes, max: fireRes, value: fireRes })
} else {
if (coldRes !== 0) out.push({ code: 'res-cold', min: coldRes, max: coldRes, value: coldRes })
if (ltngRes !== 0) out.push({ code: 'res-ltng', min: ltngRes, max: ltngRes, value: ltngRes })
if (fireRes !== 0) out.push({ code: 'res-fire', min: fireRes, max: fireRes, value: fireRes })
if (poisRes !== 0) out.push({ code: 'res-pois', min: poisRes, max: poisRes, value: poisRes })
}
// Sort descending by ItemStatCost.txt descpriority
return out

View File

@ -179,7 +179,7 @@ describe('Issue #123 & Issue #125: Enhanced vs Base Stats, Property Merging & Pr
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', () => {
it('Issue #125 & #130: merges 4-element resistances (dgrp=2) into All Resistances and 4 attributes (dgrp=1) into All Attributes ONLY when all 4 are strictly equal', () => {
const base: ItemBase = {
id: 'amu',
name: 'Amulet',
@ -194,9 +194,10 @@ describe('Issue #123 & Issue #125: Enhanced vs Base Stats, Property Merging & Pr
level: 1,
}
const item = createDroppedItem(base, 'normal', { ilvl: 80, dwInitSeed: 2 })
;(item as any).rolledProps = [
{ code: 'res-fire', min: 35, max: 35, value: 35 },
// Case 1: All 4 resistances equal (+15) and all 4 attributes equal (+10) -> fold into All Attributes / All Resistances
const equalItem = createDroppedItem(base, 'normal', { ilvl: 80, dwInitSeed: 2 })
;(equalItem as any).rolledProps = [
{ code: 'res-fire', min: 15, max: 15, value: 15 },
{ 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 },
@ -206,12 +207,33 @@ describe('Issue #123 & Issue #125: Enhanced vs Base Stats, Property Merging & Pr
{ code: 'enr', min: 10, max: 10, value: 10 },
]
const tooltip = formatItemTooltip(item)
const texts = tooltip.lines.map(l => l.text)
const equalTooltip = formatItemTooltip(equalItem)
expect(equalTooltip.lines.map(l => l.text)).toEqual([
'+10 to All Attributes',
'All Resistances +15',
])
// 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%'])
// Case 2: Unequal resistances (res-all: 15 + res-fire: 15 => fire=30, cold=15, ltng=15, pois=15) -> 4 independent lines, no partial All Resistances
const unequalItem = createDroppedItem(base, 'normal', { ilvl: 80, dwInitSeed: 22 })
;(unequalItem as any).rolledProps = [
{ code: 'res-all', min: 15, max: 15, value: 15 },
{ code: 'res-fire', 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 unequalTooltip = formatItemTooltip(unequalItem)
const unequalTexts = unequalTooltip.lines.map(l => l.text)
expect(unequalTexts).toEqual([
'+10 to All Attributes',
'Cold Resist +15%',
'Lightning Resist +15%',
'Fire Resist +30%',
'Poison Resist +15%',
])
expect(unequalTexts).not.toContain('All Resistances +15')
})
it('Issue #125: sorts properties in descending order of ItemStatCost.txt descpriority', () => {

View File

@ -16,7 +16,7 @@ import {
rollMagicAffixes,
rollRareAffixes,
} from '../src/game/affix-generator.ts'
import { formatItemTooltip } from '../src/game/item-tooltip.ts'
import { formatItemTooltip, aggregateAndSortProperties } from '../src/game/item-tooltip.ts'
import { D2Rng } from '../src/game/d2-rng.ts'
import type { MagicAffix, AffixTable } from '../src/game/affixes.ts'
import type { RareNameTable } from '../src/game/rare-names.ts'
@ -736,3 +736,93 @@ describe('AutoMagic.txt & 1.13c StaffMods Drop Pipeline & Tooltip Integration (I
})
})
describe('dgrp Exact-Equality Aggregation Rule (Issue #130 / C2)', () => {
const amuletBase: ItemBase = {
id: 'amu',
name: 'Amulet',
kind: 'armor',
tags: ['amul'],
damage: 0,
defense: 0,
value: 100,
invWidth: 1,
invHeight: 1,
maxStack: 1,
level: 1,
}
it('1. folds dgrp=2 into All Resistances +X ONLY when all 4 resistances are positive and strictly equal', () => {
// 4 equal individual resistances (+15 each)
const itemFromFour = createDroppedItem(amuletBase, 'normal', { ilvl: 80, dwInitSeed: 100 })
;(itemFromFour as any).rolledProps = [
{ code: 'res-fire', min: 15, max: 15, value: 15 },
{ 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 },
]
expect(formatItemTooltip(itemFromFour).lines.map(l => l.text)).toEqual(['All Resistances +15'])
// Pure res-all (+15)
const itemFromResAll = createDroppedItem(amuletBase, 'normal', { ilvl: 80, dwInitSeed: 101 })
;(itemFromResAll as any).rolledProps = [{ code: 'res-all', min: 15, max: 15, value: 15 }]
expect(formatItemTooltip(itemFromResAll).lines.map(l => l.text)).toEqual(['All Resistances +15'])
})
it('2. unfolds dgrp=2 into 4 separate resistance lines when res-all stacks with a single elemental resistance (+15 res-all + +20 res-fire)', () => {
const item = createDroppedItem(amuletBase, 'normal', { ilvl: 80, dwInitSeed: 102 })
;(item as any).rolledProps = [
{ code: 'res-all', min: 15, max: 15, value: 15 },
{ code: 'res-fire', min: 20, max: 20, value: 20 },
]
const texts = formatItemTooltip(item).lines.map(l => l.text)
expect(texts).toEqual([
'Cold Resist +15%',
'Lightning Resist +15%',
'Fire Resist +35%',
'Poison Resist +15%',
])
expect(texts.some(t => t.startsWith('All Resistances'))).toBe(false)
})
it('3. unfolds dgrp=1 into 4 separate attribute lines when all-stats stacks with a single attribute (+10 all-stats + +15 str), and folds ONLY when all 4 are strictly equal', () => {
// Strictly equal (+10 to all 4)
const equalAttrItem = createDroppedItem(amuletBase, 'normal', { ilvl: 80, dwInitSeed: 103 })
;(equalAttrItem as any).rolledProps = [{ code: 'all-stats', min: 10, max: 10, value: 10 }]
expect(formatItemTooltip(equalAttrItem).lines.map(l => l.text)).toEqual(['+10 to All Attributes'])
// Stacked (+10 all-stats + +15 str -> str=25, dex=10, vit=10, enr=10)
const unequalAttrItem = createDroppedItem(amuletBase, 'normal', { ilvl: 80, dwInitSeed: 104 })
;(unequalAttrItem as any).rolledProps = [
{ code: 'all-stats', min: 10, max: 10, value: 10 },
{ code: 'str', min: 15, max: 15, value: 15 },
]
const texts = formatItemTooltip(unequalAttrItem).lines.map(l => l.text)
expect(texts).toEqual([
'+25 to Strength',
'+10 to Dexterity',
'+10 to Vitality',
'+10 to Energy',
])
expect(texts.some(t => t.includes('All Attributes'))).toBe(false)
// Verify aggregateAndSortProperties never puts 'all-res' or 'all-stats' into otherMerged when unequal
const rawMerged = aggregateAndSortProperties([
{ code: 'all-res', min: 12, max: 12, value: 12 },
{ code: 'res-ltng', min: 18, max: 18, value: 18 },
{ code: 'all-stats', min: 5, max: 5, value: 5 },
{ code: 'dex', min: 7, max: 7, value: 7 },
])
expect(rawMerged.map(p => `${p.code}:${p.value}`)).toEqual([
'str:5',
'dex:12',
'vit:5',
'enr:5',
'res-cold:12',
'res-ltng:30',
'res-fire:12',
'res-pois:12',
])
})
})