feat(items): 属性词条具体数值Roll点、统一1.13c英文规范与品质降级Fallback机制 (Issue #122)

- 词条具体数值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 项测试全部通过
This commit is contained in:
troytt 2026-09-19 04:17:15 +00:00
parent c9a9a5558d
commit 84686b6939
6 changed files with 498 additions and 227 deletions

View File

@ -511,13 +511,13 @@ function renderSingleItemCard(t: FormattedItemTooltip, index: number): string {
}).join('')
const baseStatsHtml: string[] = []
if (t.defense) baseStatsHtml.push(`<div class="base-stat-item">防御: <span class="val-white">${t.defense}</span></div>`)
if (t.oneHandDamage) baseStatsHtml.push(`<div class="base-stat-item">单手伤害: <span class="val-white">${t.oneHandDamage.min} 到 ${t.oneHandDamage.max}</span></div>`)
if (t.twoHandDamage) baseStatsHtml.push(`<div class="base-stat-item">双手伤害: <span class="val-white">${t.twoHandDamage.min} 到 ${t.twoHandDamage.max}</span></div>`)
if (t.durability) baseStatsHtml.push(`<div class="base-stat-item">耐久度: <span class="val-white">${t.durability.current} / ${t.durability.max}</span></div>`)
if (t.reqLevel) baseStatsHtml.push(`<div class="base-stat-item req">需要等级: <span class="val-white">${t.reqLevel}</span></div>`)
if (t.reqStr) baseStatsHtml.push(`<div class="base-stat-item req">需要力量: <span class="val-white">${t.reqStr}</span></div>`)
if (t.reqDex) baseStatsHtml.push(`<div class="base-stat-item req">需要敏捷: <span class="val-white">${t.reqDex}</span></div>`)
if (t.defense) baseStatsHtml.push(`<div class="base-stat-item">Defense: <span class="val-white">${t.defense}</span></div>`)
if (t.oneHandDamage) baseStatsHtml.push(`<div class="base-stat-item">One-Hand Damage: <span class="val-white">${t.oneHandDamage.min} to ${t.oneHandDamage.max}</span></div>`)
if (t.twoHandDamage) baseStatsHtml.push(`<div class="base-stat-item">Two-Hand Damage: <span class="val-white">${t.twoHandDamage.min} to ${t.twoHandDamage.max}</span></div>`)
if (t.durability) baseStatsHtml.push(`<div class="base-stat-item">Durability: <span class="val-white">${t.durability.current} of ${t.durability.max}</span></div>`)
if (t.reqLevel) baseStatsHtml.push(`<div class="base-stat-item req">Required Level: <span class="val-white">${t.reqLevel}</span></div>`)
if (t.reqStr) baseStatsHtml.push(`<div class="base-stat-item req">Required Strength: <span class="val-white">${t.reqStr}</span></div>`)
if (t.reqDex) baseStatsHtml.push(`<div class="base-stat-item req">Required Dexterity: <span class="val-white">${t.reqDex}</span></div>`)
return `
<div class="d2-item-card quality-${t.quality.toLowerCase()}">

View File

@ -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<string, number> = {}
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
}

View File

@ -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<string, string> = {
// 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<string, { num: number; nameEn: string; lvl: number; weapon: string; armor: string }> = {
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<string, { num: number; nameCn: string; nameEn: string; lvl: number; weapon: string; armor: string }> = {
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,

View File

@ -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). */

View File

@ -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)
})
})

View File

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