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+/)
+ }
+ }
+ })
+ })
})