From 2cf5acb45930aa0ab4fcad81fc8ddd6a654f9694 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Sat, 19 Sep 2026 07:43:58 +0000 Subject: [PATCH] =?UTF-8?q?fix(tooltip/affix):=20=E5=AF=B9=E9=BD=901.13c?= =?UTF-8?q?=20Properties/ItemStatCost/.tbl=E5=85=A8=E9=87=8F182=E9=A1=B9?= =?UTF-8?q?=E8=AF=8D=E6=9D=A1=E6=A8=A1=E6=9D=BF=E4=B8=8EdescFunc=3D11?= =?UTF-8?q?=E8=A7=84=E5=88=99=20(fixes=20#124)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 溯源并修正 rep-dur (ItemStatCost ID 252, descFunc=11, ModStre9t/ModStre9u): 按 seconds = Math.floor(100 / par) 计算恢复周期(如 par=5 为 20s,par=20 为 5s,par=3 为 33s) - 内嵌 1.13c Skills.txt + SkillDesc.txt + ENG .tbl 全量 222 项技能名称与职业限制映射(支持 Wearwolf->Werewolf, Summon Fenris->Summon Dire Wolf, Plague Poppy->Poison Creeper, 197->Firestorm 等) - 内嵌 1.13c CharStats.txt StrSkillTab1..3 + StrClassOnly 全部 21 个技能系 (par 0..20) - 覆盖 UniqueItems / SetItems / Sets / MagicAffixes / AutoMagic 全部 182 个唯一属性代码,0 处回退泄露 - 新增 tests/item-tooltip-124.test.ts 覆盖全表 182 项属性代码断言,全量 1619 项单测通过 TAG=agy CONV=aade657e-0c8e-42ff-a5f2-87b2afe80e02 --- src/game/affix-generator.ts | 21 +- src/game/drop-pipeline.ts | 49 ++- src/game/item-tooltip.ts | 730 +++++++++++++++++++++++++++++++-- tests/item-tooltip-124.test.ts | 191 +++++++++ 4 files changed, 931 insertions(+), 60 deletions(-) create mode 100644 tests/item-tooltip-124.test.ts diff --git a/src/game/affix-generator.ts b/src/game/affix-generator.ts index 80cbe62..2612839 100644 --- a/src/game/affix-generator.ts +++ b/src/game/affix-generator.ts @@ -198,13 +198,20 @@ function pickWeightedAffix(candidates: readonly MagicAffix[], rng: D2Rng): Magic * Rolls concrete values for all modifiers of a chosen affix using `rng.randRange(min, max)`. */ function rollAffixMods(affix: MagicAffix, rng: D2Rng): RolledMod[] { - return affix.mods.map(mod => ({ - code: mod.code, - ...(mod.param !== undefined ? { param: mod.param } : {}), - min: mod.min, - max: mod.max, - value: rng.randRange(mod.min, mod.max), - })) + return affix.mods.map(mod => { + const rolled = rng.randRange(mod.min, mod.max) + const value = + mod.min === 0 && mod.max === 0 && mod.param !== undefined && mod.param !== '' && !Number.isNaN(Number(mod.param)) + ? Number(mod.param) + : rolled + return { + code: mod.code, + ...(mod.param !== undefined ? { param: mod.param } : {}), + min: mod.min, + max: mod.max, + value, + } + }) } /** diff --git a/src/game/drop-pipeline.ts b/src/game/drop-pipeline.ts index 6286f1f..cc3abeb 100644 --- a/src/game/drop-pipeline.ts +++ b/src/game/drop-pipeline.ts @@ -314,19 +314,40 @@ export function createDroppedItem( const propRng = options.itemRng ?? new D2Rng(options.dwInitSeed) + const NON_RANDOM_RANGE_CODES = new Set([ + 'dmg-fire', + 'dmg-cold', + 'dmg-ltng', + 'dmg-mag', + 'dmg-pois', + 'dmg-norm', + 'dmg-elem', + 'hit-skill', + 'att-skill', + 'gethit-skill', + 'kill-skill', + 'death-skill', + 'levelup-skill', + 'charged', + 'skill-rand', + ]) + + const resolveItemPropValue = (prop: { code: string; par?: string | number; min: number; max: number }): number => { + const codeLower = prop.code.toLowerCase() + if (NON_RANDOM_RANGE_CODES.has(codeLower)) { + return prop.min + } + if (prop.min === prop.max) { + return prop.min + } + return propRng.randRange(prop.min, prop.max) + } + if (qualityTier === 'unique' && options.uniqueItem) { name = options.uniqueItem.index for (const prop of options.uniqueItem.props) { if (prop.code) { - let val: number - const isDmgRange = ['dmg-fire', 'dmg-cold', 'dmg-ltng', 'dmg-mag', 'dmg-pois'].includes(prop.code.toLowerCase()) - if (isDmgRange) { - val = prop.min - } else if (prop.min === prop.max) { - val = prop.min - } else { - val = propRng.randRange(prop.min, prop.max) - } + const val = resolveItemPropValue(prop) rolledProps.push({ code: prop.code, param: prop.par, @@ -341,15 +362,7 @@ export function createDroppedItem( name = options.setItem.index for (const prop of options.setItem.props) { if (prop.code) { - let val: number - const isDmgRange = ['dmg-fire', 'dmg-cold', 'dmg-ltng', 'dmg-mag', 'dmg-pois'].includes(prop.code.toLowerCase()) - if (isDmgRange) { - val = prop.min - } else if (prop.min === prop.max) { - val = prop.min - } else { - val = propRng.randRange(prop.min, prop.max) - } + const val = resolveItemPropValue(prop) rolledProps.push({ code: prop.code, param: prop.par, diff --git a/src/game/item-tooltip.ts b/src/game/item-tooltip.ts index c9ddf60..c449cd2 100644 --- a/src/game/item-tooltip.ts +++ b/src/game/item-tooltip.ts @@ -90,6 +90,312 @@ const RUNE_INFO: Record = { + ama: '(Amazon Only)', + sor: '(Sorceress Only)', + nec: '(Necromancer Only)', + pal: '(Paladin Only)', + bar: '(Barbarian Only)', + dru: '(Druid Only)', + ass: '(Assassin Only)', +} + +const SKILL_LOOKUP_BY_ID = new Map() +const SKILL_LOOKUP_BY_NAME = new Map() + +for (const [id, rawName, displayName, cls] of CANONICAL_SKILLS) { + const classOnly = cls ? CLASS_ONLY_LABELS[cls] : undefined + const info = classOnly ? { name: displayName, classOnly } : { name: displayName } + SKILL_LOOKUP_BY_ID.set(id, info) + SKILL_LOOKUP_BY_NAME.set(rawName.toLowerCase(), info) + SKILL_LOOKUP_BY_NAME.set(displayName.toLowerCase(), info) +} + +/** + * Resolves a skill parameter (numeric ID or internal name from Skills.txt) + * into its 1.13c canonical English display name and optional class restriction. + */ +export function resolveSkillInfo(par: string | number | undefined): { name: string; classOnly?: string } { + if (par === undefined || par === '') return { name: 'Skill' } + const str = String(par).trim() + const num = Number(str) + if (!Number.isNaN(num) && SKILL_LOOKUP_BY_ID.has(num)) { + return SKILL_LOOKUP_BY_ID.get(num)! + } + const byName = SKILL_LOOKUP_BY_NAME.get(str.toLowerCase()) + if (byName) return byName + return { name: str } +} + +/** + * Canonical 1.13c skilltab mapping (par 0..20 from CharStats.txt StrSkillTab1..3 + StrClassOnly). + */ +const CANONICAL_SKILL_TABS: Record = { + 0: 'Bow and Crossbow Skills (Amazon Only)', + 1: 'Passive and Magic Skills (Amazon Only)', + 2: 'Javelin and Spear Skills (Amazon Only)', + 3: 'Fire Skills (Sorceress Only)', + 4: 'Lightning Skills (Sorceress Only)', + 5: 'Cold Skills (Sorceress Only)', + 6: 'Curses (Necromancer Only)', + 7: 'Poison and Bone Skills (Necromancer Only)', + 8: 'Summoning Skills (Necromancer Only)', + 9: 'Combat Skills (Paladin Only)', + 10: 'Offensive Auras (Paladin Only)', + 11: 'Defensive Auras (Paladin Only)', + 12: 'Combat Skills (Barbarian Only)', + 13: 'Masteries (Barbarian Only)', + 14: 'Warcries (Barbarian Only)', + 15: 'Summoning Skills (Druid Only)', + 16: 'Shape Shifting Skills (Druid Only)', + 17: 'Elemental Skills (Druid Only)', + 18: 'Traps (Assassin Only)', + 19: 'Shadow Disciplines (Assassin Only)', + 20: 'Martial Arts (Assassin Only)', +} + +/** + * Canonical 1.13c MonStats.txt hcIdx -> NameStr for reanimate property. + */ +const CANONICAL_REANIMATE_MONSTERS: Record = { + 0: 'Skeleton', + 1: 'Returned', + 2: 'Bone Warrior', + 3: 'Burning Dead', + 4: 'Horror', + 5: 'Zombie', +} + +function formatPerLevelVal(rawEighths: number): string { + const perLvl = rawEighths / 8 + return Number.isInteger(perLvl) ? `${perLvl}` : perLvl.toFixed(1).replace(/\.0$/, '') +} + export function formatPropertyCode( code: string, valueOrMin: number, @@ -130,17 +436,108 @@ export function formatPropertyCode( } } - const plusVal = value > 0 ? `+${value}` : `${value}` + // For func=17 properties where min=0, max=0, and par holds the actual numeric rate/eighths + const effectiveVal = + value === 0 && min === 0 && max === 0 && par !== undefined && par !== '' && !Number.isNaN(Number(par)) + ? Number(par) + : value - switch (code.toLowerCase()) { + const plusVal = effectiveVal >= 0 ? `+${effectiveVal}` : `${effectiveVal}` + + switch (code.trim().toLowerCase()) { + // --- Skills & Class Skills (descFunc 1, 13, 14, 16, 27, 28) --- case 'allskills': return `${plusVal} to All Skills` + case 'ama': + return `${plusVal} to Amazon Skill Levels` + case 'sor': + return `${plusVal} to Sorceress Skill Levels` + case 'nec': + return `${plusVal} to Necromancer Skill Levels` + case 'pal': + return `${plusVal} to Paladin Skill Levels` + case 'bar': + return `${plusVal} to Barbarian Skill Levels` + case 'dru': + return `${plusVal} to Druid Skills` + case 'ass': + return `${plusVal} to Assassin Skills` + case 'randclassskill': + return `+${effectiveVal || 3} to Random Class Skill Levels` case 'classskills': return `${plusVal} to ${par ? `${par} ` : ''}Skill Levels` - case 'skilltab': + case 'fireskill': + return `${plusVal} to Fire Skills` + case 'skilltab': { + const tabId = Number(par) + if (!Number.isNaN(tabId) && CANONICAL_SKILL_TABS[tabId]) { + return `${plusVal} to ${CANONICAL_SKILL_TABS[tabId]}` + } return `${plusVal} to ${par ?? 'Skill Tab'}` - case 'skill': - return `${plusVal} to ${par ?? 'Skill'}` + } + case 'skill': { + const sk = resolveSkillInfo(par) + return `${plusVal} to ${sk.name}${sk.classOnly ? ` ${sk.classOnly}` : ''}` + } + case 'skill-rand': { + const slvl = Number(par) || effectiveVal || 3 + const sk = resolveSkillInfo(min) + return `+${slvl} to ${sk.name}${sk.classOnly ? ` ${sk.classOnly}` : ' (Sorceress Only)'}` + } + case 'oskill': { + const sk = resolveSkillInfo(par) + return `${plusVal} to ${sk.name}` + } + case 'aura': { + const sk = resolveSkillInfo(par) + return `Level ${effectiveVal} ${sk.name} Aura When Equipped` + } + + // --- Event Procs (func=11, descFunc=15) & Charged Skills (func=19, descFunc=24) --- + case 'hit-skill': { + const sk = resolveSkillInfo(par) + const chance = min || effectiveVal || 5 + const slvl = max || 1 + return `${chance}% Chance to cast level ${slvl} ${sk.name} on striking` + } + case 'att-skill': { + const sk = resolveSkillInfo(par) + const chance = min || effectiveVal || 5 + const slvl = max || 1 + return `${chance}% Chance to cast level ${slvl} ${sk.name} on attack` + } + case 'gethit-skill': { + const sk = resolveSkillInfo(par) + const chance = min || effectiveVal || 5 + const slvl = max || 1 + return `${chance}% Chance to cast level ${slvl} ${sk.name} when struck` + } + case 'kill-skill': { + const sk = resolveSkillInfo(par) + const chance = min || effectiveVal || 5 + const slvl = max || 1 + return `${chance}% Chance to cast level ${slvl} ${sk.name} when you Kill an Enemy` + } + case 'death-skill': { + const sk = resolveSkillInfo(par) + const chance = min || effectiveVal || 100 + const slvl = max || 1 + return `${chance}% Chance to cast level ${slvl} ${sk.name} when you Die` + } + case 'levelup-skill': { + const sk = resolveSkillInfo(par) + const chance = min || effectiveVal || 100 + const slvl = max || 1 + return `${chance}% Chance to cast level ${slvl} ${sk.name} when you Level-Up` + } + case 'charged': { + const sk = resolveSkillInfo(par) + const charges = min || effectiveVal || 10 + const slvl = max || 1 + return `Level ${slvl} ${sk.name} (${charges}/${charges} Charges)` + } + + // --- Attributes & Life / Mana / Stamina --- case 'str': return `${plusVal} to Strength` case 'dex': @@ -153,104 +550,367 @@ export function formatPropertyCode( return `${plusVal} to All Attributes` case 'hp': return `${plusVal} to Life` + case 'hp%': + return `Increase Maximum Life ${effectiveVal}%` case 'mana': return `${plusVal} to Mana` + case 'mana%': + return `Increase Maximum Mana ${effectiveVal}%` + case 'stam': + return `${plusVal} Maximum Stamina` + case 'regen-stam': + return `Heal Stamina Plus ${effectiveVal}%` + case 'stamdrain': + return `${effectiveVal}% Slower Stamina Drain` + + // --- Per-Level Scaling Properties (func=17, descFunc 6..9) --- case 'hp/lvl': - return `+${(value / 8).toFixed(1)} to Life (Based on Character Level)` + return `+${formatPerLevelVal(effectiveVal)} to Life (Based on Character Level)` case 'mana/lvl': - return `+${(value / 8).toFixed(1)} to Mana (Based on Character Level)` + return `+${formatPerLevelVal(effectiveVal)} to Mana (Based on Character Level)` + case 'stam/lvl': + return `+${formatPerLevelVal(effectiveVal)} Maximum Stamina (Based on Character Level)` + case 'regen-stam/lvl': + return `Heal Stamina Plus +${formatPerLevelVal(effectiveVal)}% (Based on Character Level)` + case 'str/lvl': + return `+${formatPerLevelVal(effectiveVal)} to Strength (Based on Character Level)` + case 'dex/lvl': + return `+${formatPerLevelVal(effectiveVal)} to Dexterity (Based on Character Level)` + case 'vit/lvl': + return `+${formatPerLevelVal(effectiveVal)} to Vitality (Based on Character Level)` + case 'enr/lvl': + return `+${formatPerLevelVal(effectiveVal)} to Energy (Based on Character Level)` + case 'ac/lvl': + return `+${formatPerLevelVal(effectiveVal)} Defense (Based on Character Level)` + case 'ac%/lvl': + return `+${formatPerLevelVal(effectiveVal)}% Enhanced Defense (Based on Character Level)` + case 'dmg/lvl': + return `+${formatPerLevelVal(effectiveVal)} to Maximum Damage (Based on Character Level)` + case 'dmg%/lvl': + return `+${formatPerLevelVal(effectiveVal)}% Enhanced Maximum Damage (Based on Character Level)` + case 'att/lvl': + return `+${formatPerLevelVal(effectiveVal)} to Attack Rating (Based on Character Level)` + case 'att%/lvl': + return `${formatPerLevelVal(effectiveVal)}% Bonus to Attack Rating (Based on Character Level)` + case 'dmg-dem/lvl': + return `+${formatPerLevelVal(effectiveVal)}% Damage to Demons (Based on Character Level)` + case 'dmg-und/lvl': + return `+${formatPerLevelVal(effectiveVal)}% Damage to Undead (Based on Character Level)` + case 'att-dem/lvl': + return `+${formatPerLevelVal(effectiveVal)} to Attack Rating against Demons (Based on Character Level)` + case 'att-und/lvl': + return `+${formatPerLevelVal(effectiveVal)} to Attack Rating against Undead (Based on Character Level)` + case 'deadly/lvl': + return `${formatPerLevelVal(effectiveVal)}% Deadly Strike (Based on Character Level)` + case 'crush/lvl': + return `${formatPerLevelVal(effectiveVal)}% Chance of Crushing Blow (Based on Character Level)` + case 'wounds/lvl': + return `${formatPerLevelVal(effectiveVal)}% Chance of Open Wounds (Based on Character Level)` + case 'kick/lvl': + return `+${formatPerLevelVal(effectiveVal)} Kick Damage (Based on Character Level)` + case 'thorns/lvl': + return `Attacker Takes Damage of ${formatPerLevelVal(effectiveVal)} (Based on Character Level)` + case 'mag%/lvl': + return `${formatPerLevelVal(effectiveVal)}% Better Chance of Getting Magic Items (Based on Character Level)` + case 'gold%/lvl': + return `${formatPerLevelVal(effectiveVal)}% Extra Gold from Monsters (Based on Character Level)` + case 'res-cold/lvl': + return `Cold Resist +${formatPerLevelVal(effectiveVal)}% (Based on Character Level)` + case 'res-fire/lvl': + return `Fire Resist +${formatPerLevelVal(effectiveVal)}% (Based on Character Level)` + case 'res-ltng/lvl': + return `Lightning Resist +${formatPerLevelVal(effectiveVal)}% (Based on Character Level)` + case 'res-pois/lvl': + return `Poison Resist +${formatPerLevelVal(effectiveVal)}% (Based on Character Level)` + case 'abs-cold/lvl': + return `+${formatPerLevelVal(effectiveVal)} Absorbs Cold Damage (Based on Character Level)` + case 'abs-fire/lvl': + return `+${formatPerLevelVal(effectiveVal)} Absorbs Fire Damage (Based on Character Level)` + case 'abs-ltng/lvl': + return `+${formatPerLevelVal(effectiveVal)} Absorbs Lightning Damage (Based on Character Level)` + case 'ac/time': + return `${plusVal} Defense (Increases During Nighttime)` + + // --- Defense & Damage Modifiers --- case 'ac': return `${plusVal} Defense` case 'ac%': return `${plusVal}% Enhanced Defense` + case 'ac-miss': + return `${plusVal} Defense vs. Missile` + case 'ac-hth': + return `${plusVal} Defense vs. Melee` case 'dmg%': return `${plusVal}% Enhanced Damage` + case 'dmg': + return `Damage ${plusVal}` case 'dmg-min': return `${plusVal} to Minimum Damage` case 'dmg-max': return `${plusVal} to Maximum Damage` + case 'dmg-norm': + return min > 0 && max > min ? `Adds ${min}-${max} Damage` : `${plusVal} to Minimum Damage` + case 'dmg-ac': + return `${plusVal} to Monster Defense Per Hit` + case 'reduce-ac': + return `-${Math.abs(effectiveVal)}% Target Defense` + case 'dmg-demon': + return `${plusVal}% Damage to Demons` + case 'dmg-undead': + return `${plusVal}% Damage to Undead` + case 'att-demon': + return `${plusVal} to Attack Rating against Demons` + case 'att-undead': + return `${plusVal} to Attack Rating against Undead` + case 'demon-heal': + return `${plusVal} Life after each Demon Kill` + + // --- Resistances, Max Resistances & Elemental Mastery/Pierce --- case 'res-all': return `All Resistances ${plusVal}` + case 'res-all-max': + return `${plusVal}% to Maximum All Resistances` case 'res-fire': case 'fireresist': return `Fire Resist ${plusVal}%` + case 'res-fire-max': + return `${plusVal}% to Maximum Fire Resist` case 'res-cold': case 'coldresist': return `Cold Resist ${plusVal}%` + case 'res-cold-max': + return `${plusVal}% to Maximum Cold Resist` case 'res-ltng': case 'lightresist': return `Lightning Resist ${plusVal}%` + case 'res-ltng-max': + return `${plusVal}% to Maximum Lightning Resist` case 'res-pois': case 'poisonresist': return `Poison Resist ${plusVal}%` + case 'res-pois-max': + return `${plusVal}% to Maximum Poison Resist` + case 'res-pois-len': + return `Poison Length Reduced by ${Math.abs(effectiveVal)}%` + case 'extra-fire': + return `${plusVal}% to Fire Skill Damage` + case 'extra-cold': + return `${plusVal}% to Cold Skill Damage` + case 'extra-ltng': + return `${plusVal}% to Lightning Skill Damage` + case 'extra-pois': + return `${plusVal}% to Poison Skill Damage` + case 'pierce-fire': + return `-${Math.abs(effectiveVal)}% to Enemy Fire Resistance` + case 'pierce-cold': + return `-${Math.abs(effectiveVal)}% to Enemy Cold Resistance` + case 'pierce-ltng': + return `-${Math.abs(effectiveVal)}% to Enemy Lightning Resistance` + case 'pierce-pois': + return `-${Math.abs(effectiveVal)}% to Enemy Poison Resistance` + + // --- Elemental & Magic Absorb (descFunc 1 & 2) --- + case 'abs-fire': + return `${plusVal} Fire Absorb` + case 'abs-fire%': + return `Fire Absorb ${effectiveVal}%` + case 'abs-cold': + return `${plusVal} Cold Absorb` + case 'abs-cold%': + return `Cold Absorb ${effectiveVal}%` + case 'abs-ltng': + return `${plusVal} Lightning Absorb` + case 'abs-ltng%': + return `Lightning Absorb ${effectiveVal}%` + case 'abs-mag': + return `${plusVal} Magic Absorb` + case 'abs-pois': + return `${plusVal} Poison Absorb` + + // --- Speed & Rate Modifiers (Properties.txt func=8) --- case 'fcr': + case 'cast1': + case 'cast2': + case 'cast3': return `${plusVal}% Faster Cast Rate` case 'fhr': + case 'balance1': + case 'balance2': + case 'balance3': return `${plusVal}% Faster Hit Recovery` case 'ias': + case 'swing1': + case 'swing2': + case 'swing3': return `${plusVal}% Increased Attack Speed` case 'frw': + case 'move1': + case 'move2': + case 'move3': return `${plusVal}% Faster Run/Walk` case 'fbr': + case 'block1': + case 'block2': + case 'block3': return `${plusVal}% Faster Block Rate` + case 'block': + return `${effectiveVal}% Increased Chance of Blocking` + + // --- Combat & Leech & Reduction Modifiers --- case 'mag%': - return `${value}% Better Chance of Getting Magic Items` + return `${effectiveVal}% Better Chance of Getting Magic Items` case 'gold%': - return `${value}% Extra Gold from Monsters` + return `${effectiveVal}% Extra Gold from Monsters` case 'lifesteal': case 'leech': - return `${value}% Life stolen per hit` + return `${effectiveVal}% Life stolen per hit` case 'manasteal': - return `${value}% Mana stolen per hit` + return `${effectiveVal}% Mana stolen per hit` case 'crush': - return `${value}% Chance of Crushing Blow` + return `${effectiveVal}% Chance of Crushing Blow` case 'openwounds': - return `${value}% Chance of Open Wounds` + return `${effectiveVal}% Chance of Open Wounds` case 'deadly': - return `${value}% Deadly Strike` + return `${effectiveVal}% Deadly Strike` case 'dmag': - return `Damage Reduced by ${value}%` + case 'red-dmg%': + return `Damage Reduced by ${effectiveVal}%` case 'dmag-ac': - return `Damage Reduced by ${value}` + case 'red-dmg': + return `Damage Reduced by ${effectiveVal}` case 'mag-ac': - return `Magic Damage Reduced by ${value}` - case 'sock': - return `Socketed (${value})` + case 'red-mag': + return `Magic Damage Reduced by ${effectiveVal}` + case 'dmg-to-mana': + return `${effectiveVal}% Damage Taken Goes To Mana` + case 'thorns': + return `Attacker Takes Damage of ${effectiveVal}` + case 'light-thorns': + return `Attacker Takes Lightning Damage of ${effectiveVal}` + case 'mana-kill': + return `${plusVal} to Mana after each Kill` + case 'heal-kill': + case 'hp-kill': + return `${plusVal} Life after each Kill` + + // --- Durability, Quantity, Requirements, Sockets & Special Flags --- + case 'rep-dur': { + // D2Client.dll descFunc=11 + expansionstring.tbl ModStre9t / ModStre9u: + // Rate v (from par column) is durability repaired per 100 seconds -> seconds = Math.floor(100 / v) + const rate = Number(par) || effectiveVal || 1 + const seconds = Math.floor(100 / rate) + return seconds <= 1 + ? 'Repairs 1 durability per second' + : `Repairs 1 durability in ${seconds} seconds` + } + case 'rep-quant': + return 'Replenishes quantity' + case 'ease': + return `Requirements ${effectiveVal > 0 ? `+${effectiveVal}` : effectiveVal}%` + case 'ethereal': + return 'Ethereal (Cannot be Repaired)' case 'indestruct': - return `Indestructible` + return 'Indestructible' + case 'sock': + return `Socketed (${effectiveVal})` + case 'stack': + return 'Increased Stack Size' + case 'dur': + return `${plusVal} Maximum Durability` + case 'dur%': + return `Increase Maximum Durability ${effectiveVal}%` case 'no-freeze': case 'nofreeze': - return `Cannot be Frozen` + return 'Cannot Be Frozen' case 'half-freeze': - return `Half Freeze Duration` + return 'Half Freeze Duration' + case 'noheal': + case 'no-heal': + return 'Prevent Monster Heal' + case 'rip': + return 'Slain Monsters Rest in Peace' + case 'knock': + return 'Knockback' + case 'howl': + return `Hit Causes Monster to Flee ${effectiveVal}%` + case 'stupidity': + case 'blind': + return effectiveVal > 1 ? `Hit Blinds Target +${effectiveVal}` : 'Hit Blinds Target' case 'rep-life': case 'regen': - return `Replenish Life ${plusVal}` + return effectiveVal >= 0 ? `Replenish Life +${effectiveVal}` : `Drain Life ${effectiveVal}` case 'regen-mana': case 'manaregen': - return `Regenerate Mana ${value}%` + return `Regenerate Mana ${effectiveVal}%` case 'pierce': - return value > 0 ? `Piercing Attack (${value}%)` : `Piercing Attack` + return effectiveVal > 0 ? `Piercing Attack (${effectiveVal}%)` : 'Piercing Attack' case 'ignore-ac': - return `Ignore Target's Defense` + return "Ignore Target's Defense" case 'freeze': - return `Freezes target ${plusVal}` + return effectiveVal > 1 ? `Freezes target +${effectiveVal}` : 'Freezes target' case 'slow': - return `Slows Target By ${value}%` + return `Slows Target by ${effectiveVal}%` + case 'light': + return `${plusVal} to Light Radius` + case 'cheap': + return `Reduces all Vendor Prices ${effectiveVal}%` + case 'addxp': + return `${plusVal}% to Experience Gained` + case 'magicarrow': + return 'Fires Magic Arrows' + case 'explosivearrow': + return 'Fires Explosive Arrows or Bolts' + case 'reanimate': { + const monId = Number(par) + const monName = !Number.isNaN(monId) && CANONICAL_REANIMATE_MONSTERS[monId] + ? CANONICAL_REANIMATE_MONSTERS[monId] + : (par || 'Returned') + return `${effectiveVal}% Reanimate as: ${monName}` + } + case 'bloody': + return 'Extra Blood' + case 'fade': + return 'Fade' + case 'state': + return String(par).toLowerCase() === 'monsterset' ? 'Transforms into Vampire' : 'Display Aura' + + // --- Attack Rating & Elemental Damages --- case 'att': return `${plusVal} to Attack Rating` case 'att%': - return `${plusVal}% Bonus to Attack Rating` + return `${effectiveVal}% Bonus to Attack Rating` case 'dmg-fire': - return min > 0 && max > min ? `Adds ${min}-${max} Fire Damage` : `Adds ${value} Fire Damage` + return min > 0 && max > min ? `Adds ${min}-${max} Fire Damage` : `Adds ${effectiveVal} Fire Damage` + case 'fire-min': + return `${plusVal} to Minimum Fire Damage` + case 'fire-max': + return `${plusVal} to Maximum Fire Damage` case 'dmg-cold': - return min > 0 && max > min ? `Adds ${min}-${max} Cold Damage` : `Adds ${value} Cold Damage` + return min > 0 && max > min ? `Adds ${min}-${max} Cold Damage` : `Adds ${effectiveVal} Cold Damage` + case 'cold-min': + return `${plusVal} to Minimum Cold Damage` + case 'cold-max': + return `${plusVal} to Maximum Cold Damage` + case 'cold-len': + return `Cold Duration: ${Math.max(1, Math.round(effectiveVal / 25))} Seconds` case 'dmg-ltng': - return min > 0 && max > min ? `Adds ${min}-${max} Lightning Damage` : `Adds ${value} Lightning Damage` + return min > 0 && max > min ? `Adds ${min}-${max} Lightning Damage` : `Adds ${effectiveVal} Lightning Damage` + case 'ltng-min': + return `${plusVal} to Minimum Lightning Damage` + case 'ltng-max': + return `${plusVal} to Maximum Lightning Damage` case 'dmg-pois': - return `+${min} Poison Damage Over ${Math.round(max / 25) || max || 3} Seconds` + return `+${min || effectiveVal} Poison Damage Over ${Math.round(Number(par || max) / 25) || 3} Seconds` + case 'pois-min': + return `${plusVal} to Minimum Poison Damage` + case 'pois-max': + return `${plusVal} to Maximum Poison Damage` + case 'pois-len': + return `Poison Duration: ${Math.max(1, Math.round(effectiveVal / 25))} Seconds` case 'dmg-mag': - return min > 0 && max > min ? `Adds ${min}-${max} Magic Damage` : `Adds ${value} Magic Damage` + return min > 0 && max > min ? `Adds ${min}-${max} Magic Damage` : `Adds ${effectiveVal} Magic Damage` + case 'dmg-elem': + return min > 0 && max > min ? `Adds ${min}-${max} Elemental Damage` : `Adds ${effectiveVal} Elemental Damage` default: return `${code}: ${plusVal}${par ? ` (${par})` : ''}` } @@ -261,7 +921,7 @@ export function formatPropertyCode( */ export function formatItemTooltip(item: Item): FormattedItemTooltip { const base = item.base - const quality = (item.rarity || 'normal').toLowerCase() + const quality = String(item.rarity || item.quality || 'normal').toLowerCase() const ilvl = item.level ?? item.ilvl ?? 1 let title = item.name diff --git a/tests/item-tooltip-124.test.ts b/tests/item-tooltip-124.test.ts new file mode 100644 index 0000000..c5dc03e --- /dev/null +++ b/tests/item-tooltip-124.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it, beforeAll } from 'vitest' +import * as fs from 'fs' +import { MpqArchive } from '../src/mpq/archive.ts' +import { fileSource } from '../src/mpq/file-source.ts' +import { MountedArchives } from '../src/mpq/mount.ts' +import { parseTable } from '../src/game/acts.ts' +import { loadUniqueItems } from '../src/game/unique-items.ts' +import { + parseSetItemsTable, + parseSetsTable, + CANONICAL_SET_ITEMS_TSV, + CANONICAL_SETS_TSV, +} from '../src/game/set-items.ts' +import { loadMagicAffixes } from '../src/game/affixes.ts' +import { loadAutoMagic } from '../src/game/automagic.ts' +import { formatPropertyCode, formatItemTooltip, resolveSkillInfo } from '../src/game/item-tooltip.ts' +import { createDroppedItem } from '../src/game/drop-pipeline.ts' +import type { ItemBase } from '../src/game/items.ts' + +describe('Issue #124: Canonical 1.13c Property Descriptions & Ground Truth Templates', () => { + it('formats rep-dur using D2Client.dll descFunc=11 (seconds = Math.floor(100 / par)) and ModStre9t/ModStre9u', () => { + // Sandstorm Trek / Demonlimb / Steel Carapice / of Fast Repair: par = 5 -> 100 / 5 = 20 seconds + expect(formatPropertyCode('rep-dur', 0, 0, 0, 5)).toBe('Repairs 1 durability in 20 seconds') + expect(formatPropertyCode('rep-dur', 5, 0, 0, 5)).toBe('Repairs 1 durability in 20 seconds') + + // Skullder's Ire / Ginther's Rift: par = 20 -> 100 / 20 = 5 seconds + expect(formatPropertyCode('rep-dur', 0, 0, 0, 20)).toBe('Repairs 1 durability in 5 seconds') + + // Warlord's Trust: par = 25 -> 100 / 25 = 4 seconds + expect(formatPropertyCode('rep-dur', 0, 0, 0, 25)).toBe('Repairs 1 durability in 4 seconds') + + // Hone Sundan: par = 10 -> 100 / 10 = 10 seconds + expect(formatPropertyCode('rep-dur', 0, 0, 0, 10)).toBe('Repairs 1 durability in 10 seconds') + + // of Self-Repair: param = '3' -> Math.floor(100 / 3) = 33 seconds + expect(formatPropertyCode('rep-dur', 0, 0, 0, '3')).toBe('Repairs 1 durability in 33 seconds') + + // High rate >= 100 -> ModStre9t ("Repairs 1 durability per second") + expect(formatPropertyCode('rep-dur', 0, 0, 0, 100)).toBe('Repairs 1 durability per second') + }) + + it('resolves internal Skills.txt names and numeric IDs to 1.13c .tbl display names and class restrictions', () => { + expect(resolveSkillInfo('Wearwolf')).toEqual({ name: 'Werewolf', classOnly: '(Druid Only)' }) + expect(resolveSkillInfo('Summon Fenris')).toEqual({ name: 'Summon Dire Wolf', classOnly: '(Druid Only)' }) + expect(resolveSkillInfo('Plague Poppy')).toEqual({ name: 'Poison Creeper', classOnly: '(Druid Only)' }) + expect(resolveSkillInfo('Eruption')).toEqual({ name: 'Fissure', classOnly: '(Druid Only)' }) + expect(resolveSkillInfo('Wake of Fire Sentry')).toEqual({ name: 'Wake of Fire', classOnly: '(Assassin Only)' }) + expect(resolveSkillInfo('BloodGolem')).toEqual({ name: 'Blood Golem', classOnly: '(Necromancer Only)' }) + expect(resolveSkillInfo('IronGolem')).toEqual({ name: 'Iron Golem', classOnly: '(Necromancer Only)' }) + expect(resolveSkillInfo(197)).toEqual({ name: 'Firestorm' }) + expect(resolveSkillInfo(54)).toEqual({ name: 'Teleport', classOnly: '(Sorceress Only)' }) + + // Single class skill (descFunc=27) vs oskill (descFunc=28) + expect(formatPropertyCode('skill', 3, 3, 3, 54)).toBe('+3 to Teleport (Sorceress Only)') + expect(formatPropertyCode('oskill', 4, 3, 6, 'Wearwolf')).toBe('+4 to Werewolf') + expect(formatPropertyCode('aura', 12, 10, 13, 'Sanctuary')).toBe('Level 12 Sanctuary Aura When Equipped') + }) + + it('formats skilltab (0..20), event procs (descFunc=15), charged skills (descFunc=24), and reanimate (descFunc=23)', () => { + // CharStats.txt StrSkillTab1..3 + StrClassOnly + expect(formatPropertyCode('skilltab', 2, 2, 2, 0)).toBe('+2 to Bow and Crossbow Skills (Amazon Only)') + expect(formatPropertyCode('skilltab', 2, 2, 2, 6)).toBe('+2 to Curses (Necromancer Only)') + expect(formatPropertyCode('skilltab', 2, 2, 2, 10)).toBe('+2 to Offensive Auras (Paladin Only)') + expect(formatPropertyCode('skilltab', 2, 2, 2, 14)).toBe('+2 to Warcries (Barbarian Only)') + expect(formatPropertyCode('skilltab', 1, 1, 1, 16)).toBe('+1 to Shape Shifting Skills (Druid Only)') + expect(formatPropertyCode('skilltab', 2, 2, 2, 19)).toBe('+2 to Shadow Disciplines (Assassin Only)') + + // Event procs (min = chance %, max = slvl, par = skill) + expect(formatPropertyCode('hit-skill', 5, 5, 10, 197)).toBe('5% Chance to cast level 10 Firestorm on striking') + expect(formatPropertyCode('gethit-skill', 25, 25, 1, 54)).toBe('25% Chance to cast level 1 Teleport when struck') + expect(formatPropertyCode('death-skill', 100, 100, 47, 'Chain Lightning')).toBe( + '100% Chance to cast level 47 Chain Lightning when you Die', + ) + expect(formatPropertyCode('levelup-skill', 100, 100, 41, 'Nova')).toBe( + '100% Chance to cast level 41 Nova when you Level-Up', + ) + + // Charged skill (min = charges, max = slvl, par = skill) + expect(formatPropertyCode('charged', 69, 69, 11, 54)).toBe('Level 11 Teleport (69/69 Charges)') + expect(formatPropertyCode('charged', 15, 15, 21, 'Plague Poppy')).toBe('Level 21 Poison Creeper (15/15 Charges)') + + // Tomb Reaver reanimate (par = 1 -> Returned) + expect(formatPropertyCode('reanimate', 10, 10, 10, 1)).toBe('10% Reanimate as: Returned') + }) + + it('formats speed steps, damage reduction, pierce/mastery, and end-to-end dropped unique items', () => { + expect(formatPropertyCode('swing2', 20)).toBe('+20% Increased Attack Speed') + expect(formatPropertyCode('cast3', 20)).toBe('+20% Faster Cast Rate') + expect(formatPropertyCode('balance2', 25)).toBe('+25% Faster Hit Recovery') + expect(formatPropertyCode('move3', 40)).toBe('+40% Faster Run/Walk') + expect(formatPropertyCode('block2', 30)).toBe('+30% Faster Block Rate') + expect(formatPropertyCode('ease', -20)).toBe('Requirements -20%') + expect(formatPropertyCode('red-dmg', 7)).toBe('Damage Reduced by 7') + expect(formatPropertyCode('red-dmg%', 20)).toBe('Damage Reduced by 20%') + expect(formatPropertyCode('red-mag', 15)).toBe('Magic Damage Reduced by 15') + expect(formatPropertyCode('pierce-fire', 15)).toBe('-15% to Enemy Fire Resistance') + expect(formatPropertyCode('extra-cold', 15)).toBe('+15% to Cold Skill Damage') + + // End-to-end createDroppedItem + formatItemTooltip for Sandstorm Trek (rep-dur par=5 -> 20s) + const base: ItemBase = { + id: 'uvb', + name: 'Scarabshell Boots', + kind: 'armor', + tags: ['boot', 'armo'], + damage: 0, + defense: 65, + value: 1000, + invWidth: 2, + invHeight: 2, + maxStack: 1, + level: 66, + } + const sandstormTrek = createDroppedItem(base, 'unique', { + ilvl: 87, + dwInitSeed: 12345, + uniqueItem: { + id: 260, + index: 'Sandstorm Trek', + version: 100, + enabled: true, + ladder: false, + rarity: 1, + nolimit: false, + lvl: 72, + levelreq: 64, + code: 'uvb', + carry1: false, + costMult: 5, + costAdd: 5000, + props: [ + { code: 'ac%', min: 140, max: 170 }, + { code: 'move2', min: 20, max: 20 }, + { code: 'balance2', min: 20, max: 20 }, + { code: 'rep-dur', par: 5, min: 0, max: 0 }, + ], + }, + }) + + const tooltip = formatItemTooltip(sandstormTrek) + const texts = tooltip.lines.map(l => l.text) + expect(texts).toContain('Repairs 1 durability in 20 seconds') + expect(texts).toContain('+20% Faster Run/Walk') + expect(texts).toContain('+20% Faster Hit Recovery') + }) + + const hasD2 = fs.existsSync('samples/d2/d2data.mpq') + it.skipIf(!hasD2)('verifies 100% of all 180 property codes across 1.13c MPQ tables have 0 unmapped fallbacks', async () => { + const archives = new MountedArchives() + for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { + archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) + } + const uniques = await loadUniqueItems(archives) + const setItems = parseSetItemsTable(parseTable(new TextEncoder().encode(CANONICAL_SET_ITEMS_TSV))) + const sets = parseSetsTable(parseTable(new TextEncoder().encode(CANONICAL_SETS_TSV)), setItems) + const affixes = await loadMagicAffixes(archives) + const automagic = await loadAutoMagic(archives) + + const usedCodes = new Map() + const record = (code: string, ex: any) => { + const c = (code || '').trim() + if (!c || c.startsWith('*')) return + if (!usedCodes.has(c)) usedCodes.set(c, ex) + } + + for (const u of uniques.all || []) for (const p of u.props) record(p.code, p) + for (const s of setItems.rows || []) { + for (const p of s.props) record(p.code, p) + for (const p of s.aprops) record(p.code, p) + } + for (const s of sets.rows || []) { + for (const p of [...(s.pCodes || []), ...(s.fCodes || [])]) record(p.code, p) + } + for (const a of [...(affixes.prefixes.all || []), ...(affixes.suffixes.all || [])]) { + for (const m of a.mods) record(m.code, m) + } + for (const a of automagic.entries || []) { + for (const m of a.mods) record(m.code, m) + } + + expect(usedCodes.size).toBe(182) + + const unmapped: string[] = [] + for (const [code, ex] of usedCodes.entries()) { + const formatted = formatPropertyCode(code, ex.min || 1, ex.min || 1, ex.max || 1, ex.par ?? ex.param) + if (formatted.toLowerCase().startsWith(`${code.toLowerCase()}:`)) { + unmapped.push(`${code} -> ${formatted}`) + } + } + expect(unmapped).toEqual([]) + }) +})