- 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;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
59
src/boss.ts
59
src/boss.ts
|
|
@ -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()}">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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