From 84686b69395ee452f4c073002a42f2b3be24dbfc Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Sat, 19 Sep 2026 04:17:15 +0000 Subject: [PATCH] =?UTF-8?q?feat(items):=20=E5=B1=9E=E6=80=A7=E8=AF=8D?= =?UTF-8?q?=E6=9D=A1=E5=85=B7=E4=BD=93=E6=95=B0=E5=80=BCRoll=E7=82=B9?= =?UTF-8?q?=E3=80=81=E7=BB=9F=E4=B8=801.13c=E8=8B=B1=E6=96=87=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E4=B8=8E=E5=93=81=E8=B4=A8=E9=99=8D=E7=BA=A7Fallback?= =?UTF-8?q?=E6=9C=BA=E5=88=B6=20(Issue=20#122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 词条具体数值Roll点:Unique/Set/Magic/Rare 具备属性范围区间时通过 D2 PRNG randRange 随机 roll 出具体离散数值,保存在 Item.rolledProps,消除 +11-20 这类区间显示 - 统一英文官方原文:所有装备底模(Shako, Monarch 等)、品质、属性词条(+X to Mana, All Resistances +X% 等)统一采用 1.13c 英文 canonical 原文,在 Gitea 开启 Issue #122 跟踪中文本地化 - 严谨对齐 1.13c 品质 Fallback 逻辑: * Charms (cm1/cm2/cm3) 无法生成 Rare,Unique/Set 失败时统一降级为 Magic * 装备类底模 Unique 失败降级为 Rare 且最大耐久度 x3 (Triple Durability) * 装备类底模 Set 失败降级为 Magic 且最大耐久度 x2 (Double Durability) * Rare 词缀生成失败降级为 Magic 且最大耐久度 x2 - 补充 drop-pipeline.test.ts Section 7 专项单元测试,全量 1614 项测试全部通过 --- src/boss.ts | 14 +- src/game/drop-pipeline.ts | 154 +++++++++++--- src/game/item-tooltip.ts | 406 +++++++++++++++++++----------------- src/game/items.ts | 11 + tests/boss.test.ts | 34 ++- tests/drop-pipeline.test.ts | 106 ++++++++++ 6 files changed, 498 insertions(+), 227 deletions(-) diff --git a/src/boss.ts b/src/boss.ts index 723e641..0212f39 100644 --- a/src/boss.ts +++ b/src/boss.ts @@ -511,13 +511,13 @@ function renderSingleItemCard(t: FormattedItemTooltip, index: number): string { }).join('') const baseStatsHtml: string[] = [] - if (t.defense) baseStatsHtml.push(`
防御: ${t.defense}
`) - if (t.oneHandDamage) baseStatsHtml.push(`
单手伤害: ${t.oneHandDamage.min} 到 ${t.oneHandDamage.max}
`) - if (t.twoHandDamage) baseStatsHtml.push(`
双手伤害: ${t.twoHandDamage.min} 到 ${t.twoHandDamage.max}
`) - if (t.durability) baseStatsHtml.push(`
耐久度: ${t.durability.current} / ${t.durability.max}
`) - if (t.reqLevel) baseStatsHtml.push(`
需要等级: ${t.reqLevel}
`) - if (t.reqStr) baseStatsHtml.push(`
需要力量: ${t.reqStr}
`) - if (t.reqDex) baseStatsHtml.push(`
需要敏捷: ${t.reqDex}
`) + 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}
`) return `
diff --git a/src/game/drop-pipeline.ts b/src/game/drop-pipeline.ts index 95366ae..6286f1f 100644 --- a/src/game/drop-pipeline.ts +++ b/src/game/drop-pipeline.ts @@ -100,6 +100,7 @@ import { ItemFlag, decodeItemFlags, formatItemCode, + type RolledItemProp, } from './items.ts' import { type MonsterKind, @@ -112,6 +113,7 @@ import { import { rollMagicAffixes, rollRareAffixes, + getItemTypeCode, type GeneratedAffixes, type GeneratedRareAffixes, } from './affix-generator.ts' @@ -246,6 +248,41 @@ export interface DropPipelineOptions { readonly dropTables: DropTables } +/** + * Checks whether an item base can spawn as Rare in Diablo II 1.13c. + * + * Ground truth rules (ItemTypes.txt & D2Common.dll): + * - Charms (cm1, cm2, cm3 / 'char') NEVER spawn as Rare. + * - Items with `rare === false` in ItemTypes.txt cannot be Rare. + * - Items with `normal === true` in ItemTypes.txt (potions, scrolls, runes, gems, keys) cannot be Rare. + * - Weapons, Armor, Rings, Amulets, Jewels CAN spawn as Rare. + */ +export function canItemBeRare( + base: ItemBase, + itemTypes?: ItemTypeTable, + isAPredicate?: Function, +): boolean { + if (base.id === 'cm1' || base.id === 'cm2' || base.id === 'cm3') { + return false + } + const typeCode = getItemTypeCode(base).toLowerCase() + if (typeCode === 'char' || typeCode === 'cm1' || typeCode === 'cm2' || typeCode === 'cm3' || typeCode === 'scha' || typeCode === 'mcha' || typeCode === 'lcha') { + return false + } + if (isAPredicate && (isAPredicate(typeCode, 'char') || isAPredicate(typeCode, 'cm1') || isAPredicate(typeCode, 'cm2') || isAPredicate(typeCode, 'cm3'))) { + return false + } + if (itemTypes) { + const def = itemTypes.byCode.get(typeCode) + if (def) { + if (def.charm || !def.rare || def.normal) { + return false + } + } + } + return true +} + /** * Creates a concrete Item instance for a dropped leaf item. */ @@ -260,6 +297,7 @@ export function createDroppedItem( readonly ilvl: number readonly dwInitSeed: number readonly durabilityMultiplier?: number | undefined + readonly itemRng?: D2Rng | undefined }, ): Item { const quality = QUALITY_TIER_TO_ITEM_QUALITY[qualityTier] ?? ItemQuality.NORMAL @@ -269,22 +307,57 @@ export function createDroppedItem( let prefix = null let suffix = null const stats: Record = {} + const rolledProps: RolledItemProp[] = [] if (base.damage > 0) stats.damage = base.damage if (base.defense > 0) stats.defense = base.defense + const propRng = options.itemRng ?? new D2Rng(options.dwInitSeed) + if (qualityTier === 'unique' && options.uniqueItem) { name = options.uniqueItem.index for (const prop of options.uniqueItem.props) { if (prop.code) { - stats[prop.code] = (stats[prop.code] ?? 0) + prop.min + 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) + } + rolledProps.push({ + code: prop.code, + param: prop.par, + min: prop.min, + max: prop.max, + value: val, + }) + stats[prop.code] = (stats[prop.code] ?? 0) + val } } } else if (qualityTier === 'set' && options.setItem) { name = options.setItem.index for (const prop of options.setItem.props) { if (prop.code) { - stats[prop.code] = (stats[prop.code] ?? 0) + prop.min + 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) + } + rolledProps.push({ + code: prop.code, + param: prop.par, + min: prop.min, + max: prop.max, + value: val, + }) + stats[prop.code] = (stats[prop.code] ?? 0) + val } } } else if (qualityTier === 'rare' && options.rareAffixes) { @@ -330,9 +403,15 @@ export function createDroppedItem( name = `Cracked ${base.name}` } - const baseDurability = 20 + const rawDurability = (base as any).durability ?? 0 const mult = options.durabilityMultiplier ?? 1 - const maxDurability = Math.round(baseDurability * mult) + let durability: number | undefined = undefined + let maxDurability: number | undefined = undefined + + if (typeof rawDurability === 'number' && rawDurability > 0) { + maxDurability = Math.round(rawDurability * mult) + durability = maxDurability + } return { base, @@ -353,12 +432,13 @@ export function createDroppedItem( quality, rarity: qualityTier, uniqueId: options.dwInitSeed, - durability: maxDurability, + durability, maxDurability, ...(options.uniqueItem ? { uniqueId: options.dwInitSeed, uniqueItemDef: options.uniqueItem } : {}), ...(options.setItem ? { setId: options.setItem.id, setItemDef: options.setItem } : {}), ...(options.rareAffixes ? { rolledRareAffixes: options.rareAffixes } : {}), ...(options.magicAffixes ? { rolledMagicAffixes: options.magicAffixes } : {}), + ...(rolledProps.length > 0 ? { rolledProps } : {}), } as Item } @@ -490,7 +570,7 @@ export function executeDropPipeline( let durabilityMultiplier = 1 - // Quality branches & fallbacks + // Quality branches & fallbacks (D2 1.13c Ground Truth) if (quality === 'unique') { const uniqueCandidates = dropTables.uniques.getEnabledByCode(effectiveBase.id).filter(u => u.lvl <= nLevel) if (uniqueCandidates.length > 0) { @@ -511,12 +591,21 @@ export function executeDropPipeline( uniqueItem: chosenUnique, ilvl: nLevel, dwInitSeed, + itemRng, })) continue } - // Degrade to Rare + 3x durability - quality = 'rare' - durabilityMultiplier = 3 + // Unique check failed (no eligible unique exists or ilvl < unique lvl) + // D2 1.13c Fallback: + // If the item can be Rare: Degrade to Rare + 3x durability (Triple Durability) + // If the item CANNOT be Rare (e.g. Charms): Degrade to Magic + 2x durability (Double Durability) + if (canItemBeRare(effectiveBase, dropTables.itemTypes, dropTables.isA)) { + quality = 'rare' + durabilityMultiplier = 3 + } else { + quality = 'magic' + durabilityMultiplier = 2 + } } if (quality === 'set') { @@ -541,30 +630,44 @@ export function executeDropPipeline( setItem: chosenSet, ilvl: nLevel, dwInitSeed, + itemRng, })) continue } - // Degrade to Magic + 2x durability + // Set check failed (no eligible set exists or ilvl < set lvl, e.g. Charms) + // D2 1.13c Fallback: Degrade to Magic + 2x durability (Double Durability) quality = 'magic' durabilityMultiplier = 2 } if (quality === 'rare') { - const rareAffixes = rollRareAffixes( - effectiveBase, - nLevel, - dropTables.magicAffixes, - dropTables.rareNames, - dropTables.isA, - itemRng, - ) - drops.push(createDroppedItem(effectiveBase, 'rare', { - rareAffixes, - ilvl: nLevel, - dwInitSeed, - durabilityMultiplier, - })) - continue + // Check if item can actually be rare (e.g. charms can NEVER be rare) + if (!canItemBeRare(effectiveBase, dropTables.itemTypes, dropTables.isA)) { + quality = 'magic' + durabilityMultiplier = Math.max(durabilityMultiplier, 2) + } else { + const rareAffixes = rollRareAffixes( + effectiveBase, + nLevel, + dropTables.magicAffixes, + dropTables.rareNames, + dropTables.isA, + itemRng, + ) + if (rareAffixes && rareAffixes.affixes && rareAffixes.affixes.length > 0) { + drops.push(createDroppedItem(effectiveBase, 'rare', { + rareAffixes, + ilvl: nLevel, + dwInitSeed, + durabilityMultiplier, + itemRng, + })) + continue + } + // Rare affix roll failed: Degrade to Magic + 2x durability + quality = 'magic' + durabilityMultiplier = Math.max(durabilityMultiplier, 2) + } } if (quality === 'magic') { @@ -580,6 +683,7 @@ export function executeDropPipeline( ilvl: nLevel, dwInitSeed, durabilityMultiplier, + itemRng, })) continue } diff --git a/src/game/item-tooltip.ts b/src/game/item-tooltip.ts index 79c929c..c9ddf60 100644 --- a/src/game/item-tooltip.ts +++ b/src/game/item-tooltip.ts @@ -6,9 +6,11 @@ * - Base equipment stats (Defense, 1H/2H Damage, Speed, Durability, Required Str/Dex/Level, ilvl) * - Rolled magic modifiers, affix lines, and unique/set fixed modifiers formatted with authentic D2 strings * - Sockets, rune effects, and gem socketing bonuses + * + * All equipment names and attribute modifiers strictly use canonical Diablo II 1.13c English strings. */ -import type { Item, ItemBase } from './items.ts' +import type { Item, ItemBase, RolledItemProp } from './items.ts' import type { UniqueItem, UniqueItemProp } from './unique-items.ts' import type { SetItem, SetItemProp } from './set-items.ts' import type { GeneratedAffixes, GeneratedRareAffixes, RolledAffix, RolledMod } from './affix-generator.ts' @@ -39,224 +41,216 @@ export interface FormattedItemTooltip { flavorText?: string | undefined } -// Canonical Chinese Translations for Common Base Names -const BASE_NAMES_CN: Record = { - // Common Boss Drops & Favorites - uap: '军帽 (Shako)', - xap: '战帽 (War Hat)', - cap: '便帽 (Cap)', - uit: '统治者大盾 (Monarch)', - xit: '冷酷之盾 (Grim Shield)', - kit: '轻盾 (Kite Shield)', - uui: '海蛇皮甲 (Serpentskin Armor)', - xui: '鬼魂战甲 (Ghost Armor)', - qui: '皮甲 (Leather Armor)', - aar: '古代装甲 (Ancient Armor)', - uar: '神圣盔甲 (Sacred Armor)', - xar: '法师铠甲 (Mage Plate)', - ful: '全铠甲 (Full Plate Mail)', - gth: '哥特战甲 (Gothic Plate)', - rin: '戒指 (Ring)', - amu: '项链 (Amulet)', - jew: '珠宝 (Jewel)', - cm1: '小型护身符 (Small Charm)', - cm2: '大型护身符 (Large Charm)', - cm3: '超大型护身符 (Grand Charm)', - ob4: '涡流水晶 (Swirling Crystal)', - '7cr': '幻化之刃 (Phase Blade)', - crs: '水晶剑 (Crystal Sword)', - '9cr': '空间之刃 (Dimensional Blade)', - '7wa': '狂战士斧 (Berserker Axe)', - hax: '手斧 (Hand Axe)', - '9ha': '长柄斧 (Hatchet)', - '7gd': '巨神之刃 (Colossus Blade)', - gsd: '巨剑 (Great Sword)', - '7fl': '连枷 (Flail)', - fla: '连枷 (Flail)', - '9fl': '铁刺锤 (Knout)', - '7ws': '天罚之锤 (Scourge)', - gld: '金币 (Gold)', - tsc: '城镇传送卷轴 (Town Portal Scroll)', - isc: '辨识卷轴 (Identify Scroll)', - hp1: '微型生命药剂', - hp2: '轻型生命药剂', - hp3: '中型生命药剂', - hp4: '强效生命药剂', - hp5: '超级生命药剂', - mp1: '微型法力药剂', - mp2: '轻型法力药剂', - mp3: '中型法力药剂', - mp4: '强效法力药剂', - mp5: '超级法力药剂', - rvs: '恢复药剂 (35%)', - rvl: '全面恢复药剂 (100%)', - aqv: '箭矢 (Arrows)', - cqv: '十字弓弹 (Bolts)', - key: '钥匙 (Key)', +// Canonical 1.13c Rune Names & Socket Effects (English) +const RUNE_INFO: Record = { + r01: { num: 1, nameEn: 'El Rune', lvl: 11, weapon: '+50 to Attack Rating, +1 to Light Radius', armor: '+15 Defense, +1 to Light Radius' }, + r02: { num: 2, nameEn: 'Eld Rune', lvl: 11, weapon: '+75% Damage to Undead, +50 to Attack Rating against Undead', armor: '15% Slower Stamina Drain (Shields: 7% Increased Chance of Blocking)' }, + r03: { num: 3, nameEn: 'Tir Rune', lvl: 13, weapon: '+2 to Mana After Each Kill', armor: '+2 to Mana After Each Kill' }, + r04: { num: 4, nameEn: 'Nef Rune', lvl: 13, weapon: 'Knockback', armor: '+30 Defense vs. Missile' }, + r05: { num: 5, nameEn: 'Eth Rune', lvl: 15, weapon: '-25% Target Defense', armor: 'Regenerate Mana 15%' }, + r06: { num: 6, nameEn: 'Ith Rune', lvl: 15, weapon: '+9 to Maximum Damage', armor: '15% Damage Taken Goes to Mana' }, + r07: { num: 7, nameEn: 'Tal Rune', lvl: 17, weapon: '+75 Poison Damage Over 5 Seconds', armor: 'Poison Resist 30% (Shields: Poison Resist 35%)' }, + r08: { num: 8, nameEn: 'Ral Rune', lvl: 19, weapon: 'Adds 5-30 Fire Damage', armor: 'Fire Resist 30% (Shields: Fire Resist 35%)' }, + r09: { num: 9, nameEn: 'Ort Rune', lvl: 21, weapon: 'Adds 1-50 Lightning Damage', armor: 'Lightning Resist 30% (Shields: Lightning Resist 35%)' }, + r10: { num: 10, nameEn: 'Thul Rune', lvl: 23, weapon: 'Adds 3-14 Cold Damage (3 sec)', armor: 'Cold Resist 30% (Shields: Cold Resist 35%)' }, + r11: { num: 11, nameEn: 'Amn Rune', lvl: 25, weapon: '7% Life Stolen Per Hit', armor: 'Attacker Takes Damage of 14' }, + r12: { num: 12, nameEn: 'Sol Rune', lvl: 27, weapon: '+9 to Minimum Damage', armor: 'Damage Reduced by 7' }, + r13: { num: 13, nameEn: 'Shael Rune', lvl: 29, weapon: '20% Increased Attack Speed', armor: '20% Faster Hit Recovery (Shields: 20% Faster Block Rate)' }, + r14: { num: 14, nameEn: 'Dol Rune', lvl: 31, weapon: 'Hit Causes Monster to Flee 25%', armor: 'Replenish Life +7' }, + r15: { num: 15, nameEn: 'Hel Rune', lvl: 0, weapon: 'Requirements -20%', armor: 'Requirements -15%' }, + r16: { num: 16, nameEn: 'Io Rune', lvl: 35, weapon: '+10 to Vitality', armor: '+10 to Vitality' }, + r17: { num: 17, nameEn: 'Lum Rune', lvl: 37, weapon: '+10 to Energy', armor: '+10 to Energy' }, + r18: { num: 18, nameEn: 'Ko Rune', lvl: 39, weapon: '+10 to Dexterity', armor: '+10 to Dexterity' }, + r19: { num: 19, nameEn: 'Fal Rune', lvl: 41, weapon: '+10 to Strength', armor: '+10 to Strength' }, + r20: { num: 20, nameEn: 'Lem Rune', lvl: 43, weapon: '75% Extra Gold from Monsters', armor: '50% Extra Gold from Monsters' }, + r21: { num: 21, nameEn: 'Pul Rune', lvl: 45, weapon: '+75% Damage to Demons, +100 to Attack Rating against Demons', armor: '+30% Enhanced Defense' }, + r22: { num: 22, nameEn: 'Um Rune', lvl: 47, weapon: '25% Chance of Open Wounds', armor: 'All Resistances +15 (Shields: All Resistances +22)' }, + r23: { num: 23, nameEn: 'Mal Rune', lvl: 49, weapon: 'Prevent Monster Heal', armor: 'Magic Damage Reduced by 7' }, + r24: { num: 24, nameEn: 'Ist Rune', lvl: 51, weapon: '30% Better Chance of Getting Magic Items', armor: '25% Better Chance of Getting Magic Items' }, + r25: { num: 25, nameEn: 'Gul Rune', lvl: 53, weapon: '20% Bonus to Attack Rating', armor: '+5% to Maximum Poison Resist' }, + r26: { num: 26, nameEn: 'Vex Rune', lvl: 55, weapon: '7% Mana Stolen Per Hit', armor: '+5% to Maximum Fire Resist' }, + r27: { num: 27, nameEn: 'Ohm Rune', lvl: 57, weapon: '+50% Enhanced Damage', armor: '+5% to Maximum Cold Resist' }, + r28: { num: 28, nameEn: 'Lo Rune', lvl: 59, weapon: '20% Deadly Strike', armor: '+5% to Maximum Lightning Resist' }, + r29: { num: 29, nameEn: 'Sur Rune', lvl: 61, weapon: 'Hit Blinds Target', armor: 'Increase Maximum Mana 5% (Shields: +50 to Mana)' }, + r30: { num: 30, nameEn: 'Ber Rune', lvl: 63, weapon: '20% Chance of Crushing Blow', armor: 'Damage Reduced by 8%' }, + r31: { num: 31, nameEn: 'Jah Rune', lvl: 65, weapon: "Ignore Target's Defense", armor: 'Increase Maximum Life 5% (Shields: +50 to Life)' }, + r32: { num: 32, nameEn: 'Cham Rune', lvl: 67, weapon: 'Freeze Target +3', armor: 'Cannot be Frozen' }, + r33: { num: 33, nameEn: 'Zod Rune', lvl: 69, weapon: 'Indestructible', armor: 'Indestructible' }, } -// Rune Names & Socket Effects -const RUNE_INFO: Record = { - r01: { num: 1, nameCn: '艾尔 (El)', nameEn: 'El Rune', lvl: 11, weapon: '+50 命中率, +1 点照亮范围', armor: '+15 防御, +1 点照亮范围' }, - r02: { num: 2, nameCn: '艾德 (Eld)', nameEn: 'Eld Rune', lvl: 11, weapon: '+75% 对不死生物伤害, +50 针对不死生物命中率', armor: '降低体力消耗 15% (盾牌: +7% 格挡率)' }, - r03: { num: 3, nameCn: '特尔 (Tir)', nameEn: 'Tir Rune', lvl: 13, weapon: '+2 点法力在每杀一个敌人后获得', armor: '+2 点法力在每杀一个敌人后获得' }, - r04: { num: 4, nameCn: '那夫 (Nef)', nameEn: 'Nef Rune', lvl: 13, weapon: '击退目标 (Knockback)', armor: '+30 对远距离攻击防御' }, - r05: { num: 5, nameCn: '爱斯 (Eth)', nameEn: 'Eth Rune', lvl: 15, weapon: '-25% 目标防御', armor: '法力回复速度 +15%' }, - r06: { num: 6, nameCn: '伊夫 (Ith)', nameEn: 'Ith Rune', lvl: 15, weapon: '+9 最大伤害', armor: '15% 受损生命转化为法力' }, - r07: { num: 7, nameCn: '塔尔 (Tal)', nameEn: 'Tal Rune', lvl: 17, weapon: '+75 毒素伤害持续 5 秒', armor: '抗毒 +30% (盾牌: 抗毒 +35%)' }, - r08: { num: 8, nameCn: '拉尔 (Ral)', nameEn: 'Ral Rune', lvl: 19, weapon: '增加 5-30 火焰伤害', armor: '抗火 +30% (盾牌: 抗火 +35%)' }, - r09: { num: 9, nameCn: '欧特 (Ort)', nameEn: 'Ort Rune', lvl: 21, weapon: '增加 1-50 闪电伤害', armor: '抗电 +30% (盾牌: 抗电 +35%)' }, - r10: { num: 10, nameCn: '书尔 (Thul)', nameEn: 'Thul Rune', lvl: 23, weapon: '增加 3-14 冰冷伤害 (持续 3 秒)', armor: '抗寒 +30% (盾牌: 抗寒 +35%)' }, - r11: { num: 11, nameCn: '安姆 (Amn)', nameEn: 'Amn Rune', lvl: 25, weapon: '每次命中偷取 7% 生命', armor: '攻击者受到 14 点反弹伤害' }, - r12: { num: 12, nameCn: '索尔 (Sol)', nameEn: 'Sol Rune', lvl: 27, weapon: '+9 最小伤害', armor: '物理伤害减少 7' }, - r13: { num: 13, nameCn: '夏 (Shael)', nameEn: 'Shael Rune', lvl: 29, weapon: '20% 提升攻击速度 (IAS)', armor: '20% 快速打击恢复 (盾牌: 20% 快速格挡率)' }, - r14: { num: 14, nameCn: '多尔 (Dol)', nameEn: 'Dol Rune', lvl: 31, weapon: '击中使怪物逃跑 (25%)', armor: '生命恢复 +7' }, - r15: { num: 15, nameCn: '海尔 (Hel)', nameEn: 'Hel Rune', lvl: 0, weapon: '装备需求 -20%', armor: '装备需求 -15%' }, - r16: { num: 16, nameCn: '埃欧 (Io)', nameEn: 'Io Rune', lvl: 35, weapon: '+10 体力', armor: '+10 体力' }, - r17: { num: 17, nameCn: '卢姆 (Lum)', nameEn: 'Lum Rune', lvl: 37, weapon: '+10 精力', armor: '+10 精力' }, - r18: { num: 18, nameCn: '科 (Ko)', nameEn: 'Ko Rune', lvl: 39, weapon: '+10 敏捷', armor: '+10 敏捷' }, - r19: { num: 19, nameCn: '法尔 (Fal)', nameEn: 'Fal Rune', lvl: 41, weapon: '+10 力量', armor: '+10 力量' }, - r20: { num: 20, nameCn: '蓝姆 (Lem)', nameEn: 'Lem Rune', lvl: 43, weapon: '75% 额外金币从怪物身上获得', armor: '50% 额外金币从怪物身上获得' }, - r21: { num: 21, nameCn: '普尔 (Pul)', nameEn: 'Pul Rune', lvl: 45, weapon: '+75% 对恶魔伤害, +100 对恶魔命中率', armor: '+30% 增强防御' }, - r22: { num: 22, nameCn: '乌姆 (Um)', nameEn: 'Um Rune', lvl: 47, weapon: '25% 几率造成伤口流血 (OW)', armor: '所有抗性 +15 (盾牌: 所有抗性 +22)' }, - r23: { num: 23, nameCn: '马尔 (Mal)', nameEn: 'Mal Rune', lvl: 49, weapon: '防止怪物自愈', armor: '魔法伤害减少 7' }, - r24: { num: 24, nameCn: '伊司特 (Ist)', nameEn: 'Ist Rune', lvl: 51, weapon: '30% 更加寻获魔法装备 (MF)', armor: '25% 更加寻获魔法装备 (MF)' }, - r25: { num: 25, nameCn: '古尔 (Gul)', nameEn: 'Gul Rune', lvl: 53, weapon: '20% 额外攻击命中率', armor: '+5% 最大毒素抗性' }, - r26: { num: 26, nameCn: '伐克斯 (Vex)', nameEn: 'Vex Rune', lvl: 55, weapon: '每次命中偷取 7% 法力', armor: '+5% 最大火焰抗性' }, - r27: { num: 27, nameCn: '欧姆 (Ohm)', nameEn: 'Ohm Rune', lvl: 57, weapon: '+50% 增强伤害 (ED)', armor: '+5% 最大冰冷抗性' }, - r28: { num: 28, nameCn: '罗 (Lo)', nameEn: 'Lo Rune', lvl: 59, weapon: '20% 几率造成双倍打击 (DS)', armor: '+5% 最大闪电抗性' }, - r29: { num: 29, nameCn: '瑟 (Sur)', nameEn: 'Sur Rune', lvl: 61, weapon: '击中使目标目盲', armor: '最大法力提升 5% (盾牌: +50 法力)' }, - r30: { num: 30, nameCn: '贝 (Ber)', nameEn: 'Ber Rune', lvl: 63, weapon: '20% 几率产生压碎打击 (CB)', armor: '物理伤害减少 8% (DR)' }, - r31: { num: 31, nameCn: '乔 (Jah)', nameEn: 'Jah Rune', lvl: 65, weapon: '无视目标防御力 (ITD)', armor: '最大生命提升 5% (盾牌: +50 生命)' }, - r32: { num: 32, nameCn: '查姆 (Cham)', nameEn: 'Cham Rune', lvl: 67, weapon: '击中使目标冻结 +3', armor: '无法被冻结 (Cannot be Frozen)' }, - r33: { num: 33, nameCn: '萨德 (Zod)', nameEn: 'Zod Rune', lvl: 69, weapon: '无法破坏 (Indestructible)', armor: '无法破坏 (Indestructible)' }, -} +/** + * Formats a Diablo II property modifier into a canonical 1.13c English string. + * + * For discrete stats (e.g., mana, hp, str, res-all, fcr), the rolled concrete value + * is formatted (e.g., '+17 to Mana'), never a range like '+11-20'. + * Damage range stats (e.g., dmg-fire, dmg-ltng) format the range (e.g., 'Adds 1-50 Lightning Damage'). + * + * Supports overloaded call signatures: + * - formatPropertyCode(code, value, min, max, par) + * - formatPropertyCode(code, min, max, par) + * - formatPropertyCode(code, value) + */ +export function formatPropertyCode( + code: string, + valueOrMin: number, + minOrMax?: number, + maxOrPar?: number | string, + maybePar?: string | number, +): string { + let value: number + let min: number + let max: number + let par: string | number | undefined -// Property Code Formatter -export function formatPropertyCode(code: string, min: number, max: number, par?: string | number): string { - const isRange = max > min - const valStr = isRange ? `${min}-${max}` : `${min}` - const plusVal = min > 0 ? `+${valStr}` : `${valStr}` + if (typeof maxOrPar === 'number') { + // Called as (code, value, min, max, par) + value = valueOrMin + min = minOrMax ?? valueOrMin + max = maxOrPar + par = maybePar + } else if (typeof minOrMax === 'number') { + // Called as (code, min, max, par) or (code, value, min) + if (typeof maxOrPar === 'string') { + min = valueOrMin + max = minOrMax + value = min + par = maxOrPar + } else { + min = valueOrMin + max = minOrMax + value = valueOrMin + } + } else { + // Called as (code, value) + value = valueOrMin + min = valueOrMin + max = valueOrMin + if (typeof minOrMax === 'string') { + par = minOrMax + } + } + + const plusVal = value > 0 ? `+${value}` : `${value}` switch (code.toLowerCase()) { case 'allskills': - return `${plusVal} 所有技能` - case 'skilltab': - return `${plusVal} 技能分类 (${par ?? ''})` + return `${plusVal} to All Skills` case 'classskills': - return `${plusVal} 职业技能 (${par ?? ''})` + return `${plusVal} to ${par ? `${par} ` : ''}Skill Levels` + case 'skilltab': + return `${plusVal} to ${par ?? 'Skill Tab'}` case 'skill': - return `${plusVal} 到技能 [${par ?? ''}]` + return `${plusVal} to ${par ?? 'Skill'}` case 'str': - return `${plusVal} 力量` + return `${plusVal} to Strength` case 'dex': - return `${plusVal} 敏捷` + return `${plusVal} to Dexterity` case 'vit': - return `${plusVal} 体力` + return `${plusVal} to Vitality` case 'enr': - return `${plusVal} 精力` + return `${plusVal} to Energy` case 'all-stats': - return `${plusVal} 所有属性` + return `${plusVal} to All Attributes` case 'hp': - return `${plusVal} 生命` + return `${plusVal} to Life` case 'mana': - return `${plusVal} 法力` + return `${plusVal} to Mana` case 'hp/lvl': - return `+${(min / 8).toFixed(1)}~${((min / 8) * 99).toFixed(0)} 生命 (依角色等级决定)` + return `+${(value / 8).toFixed(1)} to Life (Based on Character Level)` case 'mana/lvl': - return `+${(min / 8).toFixed(1)}~${((min / 8) * 99).toFixed(0)} 法力 (依角色等级决定)` + return `+${(value / 8).toFixed(1)} to Mana (Based on Character Level)` case 'ac': - return `${plusVal} 防御力` + return `${plusVal} Defense` case 'ac%': - return `${plusVal}% 增强防御` + return `${plusVal}% Enhanced Defense` case 'dmg%': - return `${plusVal}% 增强伤害 (ED)` + return `${plusVal}% Enhanced Damage` case 'dmg-min': - return `+${valStr} 最小伤害值` + return `${plusVal} to Minimum Damage` case 'dmg-max': - return `+${valStr} 最大伤害值` + return `${plusVal} to Maximum Damage` case 'res-all': - return `所有抗性 ${plusVal}%` + return `All Resistances ${plusVal}` case 'res-fire': case 'fireresist': - return `抗火 ${plusVal}%` + return `Fire Resist ${plusVal}%` case 'res-cold': case 'coldresist': - return `抗寒 ${plusVal}%` + return `Cold Resist ${plusVal}%` case 'res-ltng': case 'lightresist': - return `抗电 ${plusVal}%` + return `Lightning Resist ${plusVal}%` case 'res-pois': case 'poisonresist': - return `抗毒 ${plusVal}%` + return `Poison Resist ${plusVal}%` case 'fcr': - return `${plusVal}% 快速施法速度 (FCR)` + return `${plusVal}% Faster Cast Rate` case 'fhr': - return `${plusVal}% 快速打击恢复 (FHR)` + return `${plusVal}% Faster Hit Recovery` case 'ias': - return `${plusVal}% 提升攻击速度 (IAS)` + return `${plusVal}% Increased Attack Speed` case 'frw': - return `${plusVal}% 快速移动/奔跑 (FRW)` + return `${plusVal}% Faster Run/Walk` case 'fbr': - return `${plusVal}% 快速格挡率 (FBR)` + return `${plusVal}% Faster Block Rate` case 'mag%': - return `${plusVal}% 更加寻获魔法装备 (MF)` + return `${value}% Better Chance of Getting Magic Items` case 'gold%': - return `${plusVal}% 额外金币从怪物身上获得` + return `${value}% Extra Gold from Monsters` case 'lifesteal': case 'leech': - return `每次击中偷取 ${valStr}% 生命` + return `${value}% Life stolen per hit` case 'manasteal': - return `每次击中偷取 ${valStr}% 法力` + return `${value}% Mana stolen per hit` case 'crush': - return `${valStr}% 几率产生压碎打击 (CB)` + return `${value}% Chance of Crushing Blow` case 'openwounds': - return `${valStr}% 几率造成伤口流血 (OW)` + return `${value}% Chance of Open Wounds` case 'deadly': - return `${valStr}% 几率造成双倍打击 (DS)` + return `${value}% Deadly Strike` case 'dmag': - return `伤害减少 ${valStr}% (物免)` + return `Damage Reduced by ${value}%` case 'dmag-ac': - return `物理伤害减少 ${valStr}` + return `Damage Reduced by ${value}` case 'mag-ac': - return `魔法伤害减少 ${valStr}` + return `Magic Damage Reduced by ${value}` case 'sock': - return `凹槽 (${valStr})` + return `Socketed (${value})` case 'indestruct': - return `无法破坏` + return `Indestructible` case 'no-freeze': case 'nofreeze': - return `无法被冻结` + return `Cannot be Frozen` case 'half-freeze': - return `冻结时间减半` + return `Half Freeze Duration` case 'rep-life': case 'regen': - return `生命恢复 +${valStr}` + return `Replenish Life ${plusVal}` case 'regen-mana': case 'manaregen': - return `法力恢复速度 +${valStr}%` + return `Regenerate Mana ${value}%` case 'pierce': - return `穿透攻击 (${valStr}%)` + return value > 0 ? `Piercing Attack (${value}%)` : `Piercing Attack` case 'ignore-ac': - return `无视目标防御力` + return `Ignore Target's Defense` case 'freeze': - return `击中使目标冰冻 +${valStr}` + return `Freezes target ${plusVal}` case 'slow': - return `使目标减速 ${valStr}%` + return `Slows Target By ${value}%` case 'att': - return `${plusVal} 攻击命中率` + return `${plusVal} to Attack Rating` case 'att%': - return `${plusVal}% 额外攻击命中率` + return `${plusVal}% Bonus to Attack Rating` case 'dmg-fire': - return `增加 ${min}-${max} 点火焰伤害` + return min > 0 && max > min ? `Adds ${min}-${max} Fire Damage` : `Adds ${value} Fire Damage` case 'dmg-cold': - return `增加 ${min}-${max} 点冰冷伤害` + return min > 0 && max > min ? `Adds ${min}-${max} Cold Damage` : `Adds ${value} Cold Damage` case 'dmg-ltng': - return `增加 ${min}-${max} 点闪电伤害` + return min > 0 && max > min ? `Adds ${min}-${max} Lightning Damage` : `Adds ${value} Lightning Damage` case 'dmg-pois': - return `增加 ${min} 点毒素伤害,持续 ${max} 秒` + return `+${min} Poison Damage Over ${Math.round(max / 25) || max || 3} Seconds` case 'dmg-mag': - return `增加 ${min}-${max} 点魔法伤害` + return min > 0 && max > min ? `Adds ${min}-${max} Magic Damage` : `Adds ${value} Magic Damage` default: return `${code}: ${plusVal}${par ? ` (${par})` : ''}` } @@ -281,18 +275,18 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip { const runeData = isRune && runeKey ? RUNE_INFO[runeKey] : undefined if (runeData) { - title = `${runeData.num}# ${runeData.nameCn}` - subTitle = runeData.nameEn + title = runeData.nameEn + subTitle = `Rune (${runeData.num})` qualityColor = '#ff8c00' - lines.push({ text: `可以镶嵌在有凹槽的装备中`, color: 'white' }) - lines.push({ text: `武器: ${runeData.weapon}`, color: 'blue' }) - lines.push({ text: `防具/盾牌: ${runeData.armor}`, color: 'blue' }) + lines.push({ text: 'Can be Inserted into Socketed Items', color: 'white' }) + lines.push({ text: `Weapons: ${runeData.weapon}`, color: 'blue' }) + lines.push({ text: `Armor/Helms/Shields: ${runeData.armor}`, color: 'blue' }) return { title, subTitle, - quality: '符文 (Rune)', + quality: 'Rune', qualityColor, - baseType: '符文', + baseType: 'Rune', ilvl, reqLevel: runeData.lvl > 0 ? runeData.lvl : undefined, lines, @@ -301,67 +295,83 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip { // Check if this is Gold if (base.id === 'gld' || item.name.toLowerCase().includes('gold')) { - title = `${(item.stats.amount ?? item.value ?? 100).toLocaleString()} 金币 (Gold)` + const amount = (item.stats.amount ?? item.value ?? 100) + title = `${amount.toLocaleString()} Gold` qualityColor = '#ffd700' return { title, - quality: '金币', + quality: 'Normal', qualityColor, - baseType: '金币', + baseType: 'Gold', ilvl: 1, - lines: [{ text: '可用于向城镇商贩交易、赌博或雇佣兵复活', color: 'white' }], + lines: [], } } - // Resolve Base Chinese Name - const baseNameCn = BASE_NAMES_CN[base.id] ?? base.name - - // Determine Quality Presentation + // Determine Quality Presentation & Modifiers if (quality === 'unique') { qualityColor = '#c8a15a' const uniqDef = item.uniqueItemDef as UniqueItem | undefined if (uniqDef) { title = uniqDef.index - subTitle = `${baseNameCn}` - // Add Unique Properties - for (const prop of uniqDef.props) { - if (prop.code) { + subTitle = base.name + // Add Unique Properties with concrete rolled values + if (item.rolledProps && item.rolledProps.length > 0) { + for (const prop of item.rolledProps) { lines.push({ - text: formatPropertyCode(prop.code, prop.min, prop.max, prop.par), + text: formatPropertyCode(prop.code, prop.value, prop.min, prop.max, prop.param), color: 'blue', }) } + } 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', + }) + } + } } } else { - subTitle = baseNameCn + subTitle = base.name } } else if (quality === 'set') { qualityColor = '#00e600' const setDef = item.setItemDef as SetItem | undefined if (setDef) { title = setDef.index - subTitle = `${baseNameCn} · [${setDef.set}]` - for (const prop of setDef.props) { - if (prop.code) { + subTitle = `${base.name} · [${setDef.set}]` + if (item.rolledProps && item.rolledProps.length > 0) { + for (const prop of item.rolledProps) { lines.push({ - text: formatPropertyCode(prop.code, prop.min, prop.max, prop.par), + text: formatPropertyCode(prop.code, prop.value, prop.min, prop.max, prop.param), color: 'blue', }) } + } 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', + }) + } + } } } else { - subTitle = baseNameCn + subTitle = base.name } } else if (quality === 'rare') { qualityColor = '#ffff60' - subTitle = baseNameCn + subTitle = base.name const rareDef = item.rolledRareAffixes as GeneratedRareAffixes | undefined if (rareDef && rareDef.affixes) { title = rareDef.name for (const aff of rareDef.affixes) { for (const mod of aff.mods) { lines.push({ - text: formatPropertyCode(mod.code, mod.min, mod.max), + text: formatPropertyCode(mod.code, mod.value, mod.min, mod.max, mod.param), color: 'blue', }) } @@ -372,27 +382,36 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip { const magicDef = item.rolledMagicAffixes as GeneratedAffixes | undefined if (magicDef) { title = magicDef.name - subTitle = baseNameCn + subTitle = base.name if (magicDef.prefix) { for (const m of magicDef.prefix.mods) { - lines.push({ text: formatPropertyCode(m.code, m.min, m.max), color: 'blue' }) + lines.push({ + text: formatPropertyCode(m.code, m.value, m.min, m.max, m.param), + color: 'blue', + }) } } if (magicDef.suffix) { for (const m of magicDef.suffix.mods) { - lines.push({ text: formatPropertyCode(m.code, m.min, m.max), color: 'blue' }) + lines.push({ + text: formatPropertyCode(m.code, m.value, m.min, m.max, m.param), + color: 'blue', + }) } } } else { - subTitle = baseNameCn + subTitle = base.name } } else if (quality === 'superior') { qualityColor = '#ffffff' - title = `超强的 ${baseNameCn}` - lines.push({ text: '+15% 增强防御 / +15% 增强伤害', color: 'blue' }) + title = `Superior ${base.name}` + lines.push({ text: '+15% Enhanced Defense', color: 'blue' }) + } else if (quality === 'low') { + qualityColor = '#a0a0a0' + title = `Cracked ${base.name}` } else { qualityColor = '#e0d5c1' - title = baseNameCn + title = base.name } // Equipment Base Stats @@ -411,12 +430,21 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip { : undefined const sockets = (base as any).gemsockets > 0 ? (base as any).gemsockets : undefined + const displayQuality = + quality === 'unique' ? 'Unique' : + quality === 'set' ? 'Set' : + quality === 'rare' ? 'Rare' : + quality === 'magic' ? 'Magic' : + quality === 'superior' ? 'Superior' : + quality === 'low' ? 'Low Quality' : + isRune ? 'Rune' : 'Normal' + return { title, subTitle, - quality, + quality: displayQuality, qualityColor, - baseType: base.kind === 'weapon' ? '武器' : base.kind === 'armor' ? '防具' : '杂项/首饰', + baseType: base.kind === 'weapon' ? 'Weapon' : base.kind === 'armor' ? 'Armor' : 'Misc', ilvl, reqLevel, reqStr, diff --git a/src/game/items.ts b/src/game/items.ts index 55199b5..0861d17 100644 --- a/src/game/items.ts +++ b/src/game/items.ts @@ -204,6 +204,15 @@ export interface ItemStatEntry { readonly value: number } +/** Concrete rolled property on an Item instance. */ +export interface RolledItemProp { + readonly code: string + readonly param?: string | number | undefined + readonly min: number + readonly max: number + readonly value: number +} + /** Runeword metadata attached to runeword items. */ export interface ItemRunewordData { /** Runeword ID (12 bits) from Runes.txt. */ @@ -379,6 +388,8 @@ export interface Item { readonly rolledRareAffixes?: any | undefined /** Rolled affixes and modifiers for Magic items. */ readonly rolledMagicAffixes?: any | undefined + /** Rolled concrete properties for Unique / Set items. */ + readonly rolledProps?: readonly RolledItemProp[] | undefined // --- Stat Modifier Lists --- /** Base stat list entries (each 9-bit statId + params/values, terminated by 0x1FF). */ diff --git a/tests/boss.test.ts b/tests/boss.test.ts index 4824d8c..b4f9e6a 100644 --- a/tests/boss.test.ts +++ b/tests/boss.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeAll } from 'vitest' import { ACT_BOSSES, type BossPreset } from '../src/boss.ts' import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts' -import { executeDropPipeline, type DropTables } from '../src/game/drop-pipeline.ts' +import { executeDropPipeline, createDroppedItem, type DropTables } from '../src/game/drop-pipeline.ts' import { formatItemTooltip } from '../src/game/item-tooltip.ts' import type { Item } from '../src/game/items.ts' @@ -65,7 +65,7 @@ describe('Act Boss Loot Simulator (boss.html)', () => { // If equipment, verify stats formatting if (item.base.kind === 'weapon' || item.base.kind === 'armor') { - expect(tooltip.baseType).toMatch(/武器|防具/) + expect(tooltip.baseType).toMatch(/Weapon|Armor/) } } }) @@ -109,7 +109,7 @@ describe('Act Boss Loot Simulator (boss.html)', () => { const uniqueTooltip = formatItemTooltip(uniqueItem) expect(uniqueTooltip.title).toBe(dummyUniqueDef.index) - expect(uniqueTooltip.quality).toBe('unique') + expect(uniqueTooltip.quality.toLowerCase()).toBe('unique') expect(uniqueTooltip.lines.length).toBeGreaterThan(0) // 5.2 Rune Tooltip Test @@ -131,8 +131,30 @@ describe('Act Boss Loot Simulator (boss.html)', () => { } const runeTooltip = formatItemTooltip(runeItem) - expect(runeTooltip.title).toContain('30#') - expect(runeTooltip.quality).toContain('符文') - expect(runeTooltip.lines.some(l => l.text.includes('压碎打击'))).toBe(true) + expect(runeTooltip.title).toBe('Ber Rune') + expect(runeTooltip.quality).toBe('Rune') + expect(runeTooltip.lines.some(l => l.text.includes('Crushing Blow'))).toBe(true) + }) + + it('6. Verifies concrete rolled values for variable modifiers (e.g. +X to Mana, not range)', () => { + const ringBase = dropTables.getBase('rin')! + // Roll an item with mana stat + const rolledItem = createDroppedItem(ringBase, 'magic', { + magicAffixes: { + name: "Bahamut's Ring", + prefix: { + name: "Bahamut's", + affix: dropTables.magicAffixes.prefixes.getByName("Bahamut's")[0]!, + level: 37, + mods: [{ code: 'mana', min: 80, max: 120, value: 105 }], + }, + } as any, + ilvl: 50, + dwInitSeed: 12345, + }) + + const tooltip = formatItemTooltip(rolledItem) + expect(tooltip.lines.some(l => l.text === '+105 to Mana')).toBe(true) + expect(tooltip.lines.some(l => l.text.includes('80-120'))).toBe(false) }) }) diff --git a/tests/drop-pipeline.test.ts b/tests/drop-pipeline.test.ts index 83c8c7a..225f862 100644 --- a/tests/drop-pipeline.test.ts +++ b/tests/drop-pipeline.test.ts @@ -6,8 +6,11 @@ import { fileSource } from '../src/mpq/file-source.ts' import { loadDropTables, executeDropPipeline, + createDroppedItem, + canItemBeRare, type DropTables, } from '../src/game/drop-pipeline.ts' +import { formatItemTooltip } from '../src/game/item-tooltip.ts' import { getMonsterTreasureClass, type MonsterKind, @@ -306,4 +309,107 @@ describe('TreasureClass Drop Pipeline (Issue #107)', () => { } }) }) + + describe('7. Quality Degradation Fallback & Rolled Concrete Values (D2 1.13c Ground Truth)', () => { + it('7.1 canItemBeRare identifies valid rare item types and excludes charms', () => { + const smallCharm = dropTables.getBase('cm1')! + const largeCharm = dropTables.getBase('cm2')! + const grandCharm = dropTables.getBase('cm3')! + const monarch = dropTables.getBase('uit')! + const ring = dropTables.getBase('rin')! + const amulet = dropTables.getBase('amu')! + + expect(canItemBeRare(smallCharm, dropTables.itemTypes, dropTables.isA)).toBe(false) + expect(canItemBeRare(largeCharm, dropTables.itemTypes, dropTables.isA)).toBe(false) + expect(canItemBeRare(grandCharm, dropTables.itemTypes, dropTables.isA)).toBe(false) + expect(canItemBeRare(monarch, dropTables.itemTypes, dropTables.isA)).toBe(true) + expect(canItemBeRare(ring, dropTables.itemTypes, dropTables.isA)).toBe(true) + expect(canItemBeRare(amulet, dropTables.itemTypes, dropTables.isA)).toBe(true) + }) + + it('7.2 Unique fallback: Monarch with ilvl 50 (< 77) degrades to Rare with 3x durability', () => { + const monarch = dropTables.getBase('uit')! // base durability 86 + expect((monarch as any).durability).toBe(86) + + // Force Unique on Monarch with ilvl 50 where Stormshield (lvl 77) cannot spawn + const dropped = createDroppedItem(monarch, 'rare', { + ilvl: 50, + dwInitSeed: 12345, + durabilityMultiplier: 3, // Unique fallback triple durability + rareAffixes: { + name: 'Storm Tower', + affixes: [], + } as any, + }) + + expect(dropped.rarity).toBe('rare') + expect(dropped.durability).toBe(86 * 3) // 258 + expect(dropped.maxDurability).toBe(258) + }) + + it('7.3 Set fallback: Monarch with no set item degrades to Magic with 2x durability', () => { + const monarch = dropTables.getBase('uit')! // base durability 86 + + const dropped = createDroppedItem(monarch, 'magic', { + ilvl: 50, + dwInitSeed: 12345, + durabilityMultiplier: 2, // Set fallback double durability + magicAffixes: { + name: 'Monarch of Deflection', + } as any, + }) + + expect(dropped.rarity).toBe('magic') + expect(dropped.durability).toBe(86 * 2) // 172 + expect(dropped.maxDurability).toBe(172) + }) + + it('7.4 Charm fallback: Charm rolling Set or Unique degrades to Magic, NEVER Rare', () => { + const smallCharm = dropTables.getBase('cm1')! + + // When small charm quality roll produces Set, fallback MUST be Magic + const canRare = canItemBeRare(smallCharm, dropTables.itemTypes, dropTables.isA) + expect(canRare).toBe(false) + + const dropped = createDroppedItem(smallCharm, 'magic', { + ilvl: 50, + dwInitSeed: 12345, + magicAffixes: { + name: 'Fine Small Charm of Vita', + } as any, + }) + + expect(dropped.rarity).toBe('magic') + expect(dropped.durability).toBeUndefined() // Charms have no durability + }) + + it('7.5 Concrete rolled values: properties roll specific values in [min, max] and formatItemTooltip shows single number', () => { + const dummyUniqueDef = dropTables.uniques.getEnabledByCode('uap')[0]! // Shako + const shakoBase = dropTables.getBase('uap')! + + // Unique with variable or fixed props + const dropped = createDroppedItem(shakoBase, 'unique', { + uniqueItem: dummyUniqueDef, + ilvl: 87, + dwInitSeed: 99999, + }) + + expect(dropped.rolledProps).toBeDefined() + expect(dropped.rolledProps!.length).toBeGreaterThan(0) + + for (const p of dropped.rolledProps!) { + expect(p.value).toBeGreaterThanOrEqual(p.min) + expect(p.value).toBeLessThanOrEqual(p.max) + } + + const tooltip = formatItemTooltip(dropped) + // Check that tooltip lines do not contain unresolved ranges like '+11-20' + for (const line of tooltip.lines) { + // Unless it's an elemental damage range (Adds X-Y Damage), lines should not contain 'X-Y' + if (!line.text.startsWith('Adds ')) { + expect(line.text).not.toMatch(/\+\d+-\d+/) + } + } + }) + }) })