feat(drop/tooltip): 实现Superior/LowQuality属性、Ethereal无形与Socket打孔生成及Rare逐槽分配 (fixes #131)

This commit is contained in:
troytt 2026-09-19 10:43:20 +00:00
parent 3da41f5fc7
commit 75eb4f4f4b
5 changed files with 513 additions and 117 deletions

View File

@ -440,21 +440,18 @@ export function rollRareAffixes(
) )
const name = rareNameParts.length > 0 ? rareNameParts.join(' ') : base.name const name = rareNameParts.length > 0 ? rareNameParts.join(' ') : base.name
// 2. Determine slot distribution: // 2. Determine slot distribution (1.13c D2Game!6FC31350):
// Total affixes: 3 to 6 (at least 1 prefix and 1 suffix, max 3 each) // Total affixes: 3 to 6, rolled slot-by-slot with rng.rand(2) and max 3 prefixes / 3 suffixes
const totalAffixes = rng.randRange(3, 6) const totalAffixes = rng.randRange(3, 6)
const minPrefix = Math.max(1, totalAffixes - 3)
const maxPrefix = Math.min(3, totalAffixes - 1)
const targetPrefixCount = rng.randRange(minPrefix, maxPrefix)
const targetSuffixCount = totalAffixes - targetPrefixCount
const usedGroups = new Set<number>() const usedGroups = new Set<number>()
const usedAffixes = new Set<MagicAffix>() const usedAffixes = new Set<MagicAffix>()
const rolledPrefixes: RolledAffix[] = [] const rolledPrefixes: RolledAffix[] = []
const rolledSuffixes: RolledAffix[] = [] const rolledSuffixes: RolledAffix[] = []
// Roll target prefix slots const tryRollSide = (wantPrefix: boolean): boolean => {
for (let i = 0; i < targetPrefixCount; i++) { if (wantPrefix) {
if (rolledPrefixes.length >= 3) return false
const candidates = getEligibleCandidates( const candidates = getEligibleCandidates(
affixes.prefixes, affixes.prefixes,
itemTypeCode, itemTypeCode,
@ -464,16 +461,15 @@ export function rollRareAffixes(
usedAffixes, usedAffixes,
) )
const picked = pickWeightedAffix(candidates, rng) const picked = pickWeightedAffix(candidates, rng)
if (!picked) break if (!picked) return false
usedAffixes.add(picked) usedAffixes.add(picked)
if (picked.group > 0) { if (picked.group > 0) {
usedGroups.add(picked.group) usedGroups.add(picked.group)
} }
rolledPrefixes.push(createRolledAffix(picked, rng)) rolledPrefixes.push(createRolledAffix(picked, rng))
} return true
} else {
// Roll target suffix slots if (rolledSuffixes.length >= 3) return false
for (let i = 0; i < targetSuffixCount; i++) {
const candidates = getEligibleCandidates( const candidates = getEligibleCandidates(
affixes.suffixes, affixes.suffixes,
itemTypeCode, itemTypeCode,
@ -483,60 +479,29 @@ export function rollRareAffixes(
usedAffixes, usedAffixes,
) )
const picked = pickWeightedAffix(candidates, rng) const picked = pickWeightedAffix(candidates, rng)
if (!picked) break if (!picked) return false
usedAffixes.add(picked) usedAffixes.add(picked)
if (picked.group > 0) { if (picked.group > 0) {
usedGroups.add(picked.group) usedGroups.add(picked.group)
} }
rolledSuffixes.push(createRolledAffix(picked, rng)) rolledSuffixes.push(createRolledAffix(picked, rng))
} return true
// If pool exhaustion prevented filling target count, try filling remaining slots from either category (up to 3 each)
while (rolledPrefixes.length + rolledSuffixes.length < totalAffixes) {
let rolledAny = false
if (rolledPrefixes.length < 3) {
const candidates = getEligibleCandidates(
affixes.prefixes,
itemTypeCode,
predicate,
{ alvl, isRare: true },
usedGroups,
usedAffixes,
)
const picked = pickWeightedAffix(candidates, rng)
if (picked) {
usedAffixes.add(picked)
if (picked.group > 0) {
usedGroups.add(picked.group)
}
rolledPrefixes.push(createRolledAffix(picked, rng))
rolledAny = true
if (rolledPrefixes.length + rolledSuffixes.length >= totalAffixes) break
} }
} }
if (rolledSuffixes.length < 3) { for (let slot = 0; slot < totalAffixes; slot++) {
const candidates = getEligibleCandidates( let pickPrefix: boolean
affixes.suffixes, if (rolledPrefixes.length >= 3) {
itemTypeCode, pickPrefix = false
predicate, } else if (rolledSuffixes.length >= 3) {
{ alvl, isRare: true }, pickPrefix = true
usedGroups, } else {
usedAffixes, pickPrefix = rng.rand(2) === 0
)
const picked = pickWeightedAffix(candidates, rng)
if (picked) {
usedAffixes.add(picked)
if (picked.group > 0) {
usedGroups.add(picked.group)
}
rolledSuffixes.push(createRolledAffix(picked, rng))
rolledAny = true
if (rolledPrefixes.length + rolledSuffixes.length >= totalAffixes) break
}
} }
if (!rolledAny) break if (!tryRollSide(pickPrefix)) {
tryRollSide(!pickPrefix)
}
} }
const allRolled = [...rolledPrefixes, ...rolledSuffixes] const allRolled = [...rolledPrefixes, ...rolledSuffixes]

View File

@ -426,6 +426,80 @@ export function resolveItemTypeFlags(
} }
} }
/**
* Canonical 1.13c Low Quality prefixes (4 subtypes: 0..3).
*/
export const LOW_QUALITY_PREFIXES = ['Low Quality', 'Cracked', 'Crude', 'Damaged'] as const
export interface EtherealAndSocketsRoll {
readonly ethereal: boolean
readonly sockets: number
}
/**
* Rolls Ethereal status (1/20 chance) and Socket count (1/3 chance for normal/superior/low)
* according to Diablo II 1.13c rules (D2Game!6FC30090).
*/
export function rollItemEtherealAndSockets(
base: ItemBase,
qualityTier: ItemQualityTier,
ilvl: number,
itemRng: D2Rng,
options?: {
readonly uniqueItem?: UniqueItem | undefined
readonly itemTypes?: ItemTypeTable | undefined
readonly rolledProps?: readonly RolledItemProp[] | undefined
},
): EtherealAndSocketsRoll {
let ethereal = false
let sockets = 0
const isWeaponOrArmor = base.kind === 'weapon' || base.kind === 'armor'
const rawDur = Number((base as any).durability) || 0
const noDur = Boolean((base as any).nodurability) || Boolean((base as any).indestructible)
const hasFixedIndestruct = Boolean(
options?.uniqueItem?.props.some(p => p.code.trim().toLowerCase() === 'indestruct') ||
options?.rolledProps?.some(p => p.code.trim().toLowerCase() === 'indestruct'),
)
const hasFixedEthereal = Boolean(
options?.uniqueItem?.props.some(p => p.code.trim().toLowerCase() === 'ethereal') ||
options?.rolledProps?.some(p => p.code.trim().toLowerCase() === 'ethereal'),
)
if (hasFixedEthereal) {
ethereal = true
} else if (isWeaponOrArmor && rawDur > 0 && !noDur && qualityTier !== 'set' && !hasFixedIndestruct) {
if (itemRng.rand(20) === 0) {
ethereal = true
}
}
if (
isWeaponOrArmor &&
(qualityTier === 'normal' || qualityTier === 'superior' || qualityTier === 'low')
) {
const baseMaxSockets = Number((base as any).gemsockets) || 0
if (baseMaxSockets > 0) {
const typeCode = getItemTypeCode(base).trim().toLowerCase()
const def = options?.itemTypes?.byCode.get(typeCode)
const typeMaxSockets = def
? ilvl >= 40
? def.maxSock40
: ilvl >= 25
? def.maxSock25
: def.maxSock1
: baseMaxSockets
const maxAllowedSockets = Math.min(baseMaxSockets, typeMaxSockets)
if (maxAllowedSockets > 0 && itemRng.rand(3) === 0) {
sockets = itemRng.randRange(1, maxAllowedSockets)
}
}
}
return { ethereal, sockets }
}
/** /**
* Creates a concrete Item instance for a dropped leaf item. * Creates a concrete Item instance for a dropped leaf item.
*/ */
@ -439,6 +513,10 @@ export function createDroppedItem(
readonly rareAffixes?: GeneratedRareAffixes | undefined readonly rareAffixes?: GeneratedRareAffixes | undefined
readonly autoMagicAffix?: RolledAutoMagic | null | undefined readonly autoMagicAffix?: RolledAutoMagic | null | undefined
readonly staffMods?: readonly RolledItemProp[] | undefined readonly staffMods?: readonly RolledItemProp[] | undefined
readonly rolledProps?: readonly RolledItemProp[] | undefined
readonly ethereal?: boolean | undefined
readonly sockets?: number | undefined
readonly itemTypes?: ItemTypeTable | undefined
readonly ilvl: number readonly ilvl: number
readonly dwInitSeed: number readonly dwInitSeed: number
readonly durabilityMultiplier?: number | undefined readonly durabilityMultiplier?: number | undefined
@ -446,11 +524,12 @@ export function createDroppedItem(
}, },
): Item { ): Item {
const quality = QUALITY_TIER_TO_ITEM_QUALITY[qualityTier] ?? ItemQuality.NORMAL const quality = QUALITY_TIER_TO_ITEM_QUALITY[qualityTier] ?? ItemQuality.NORMAL
const rawFlags = ItemFlag.IDENTIFIED let rawFlags = ItemFlag.IDENTIFIED
let name = base.name let name = base.name
let prefix = null let prefix = null
let suffix = null let suffix = null
let qualitySubtype: number | undefined = undefined
const stats: Record<string, number> = {} const stats: Record<string, number> = {}
const rolledProps: RolledItemProp[] = [] const rolledProps: RolledItemProp[] = []
@ -470,8 +549,16 @@ export function createDroppedItem(
return propRng.randRange(prop.min, prop.max) return propRng.randRange(prop.min, prop.max)
} }
if (options.rolledProps && options.rolledProps.length > 0) {
for (const p of options.rolledProps) {
rolledProps.push(p)
stats[p.code] = (stats[p.code] ?? 0) + p.value
}
}
if (qualityTier === 'unique' && options.uniqueItem) { if (qualityTier === 'unique' && options.uniqueItem) {
name = options.uniqueItem.index name = options.uniqueItem.index
if (!options.rolledProps || options.rolledProps.length === 0) {
for (const prop of options.uniqueItem.props) { for (const prop of options.uniqueItem.props) {
if (prop.code) { if (prop.code) {
const val = resolveItemPropValue(prop) const val = resolveItemPropValue(prop)
@ -485,8 +572,10 @@ export function createDroppedItem(
stats[prop.code] = (stats[prop.code] ?? 0) + val stats[prop.code] = (stats[prop.code] ?? 0) + val
} }
} }
}
} else if (qualityTier === 'set' && options.setItem) { } else if (qualityTier === 'set' && options.setItem) {
name = options.setItem.index name = options.setItem.index
if (!options.rolledProps || options.rolledProps.length === 0) {
for (const prop of options.setItem.props) { for (const prop of options.setItem.props) {
if (prop.code) { if (prop.code) {
const val = resolveItemPropValue(prop) const val = resolveItemPropValue(prop)
@ -500,6 +589,7 @@ export function createDroppedItem(
stats[prop.code] = (stats[prop.code] ?? 0) + val stats[prop.code] = (stats[prop.code] ?? 0) + val
} }
} }
}
} else if (qualityTier === 'rare' && options.rareAffixes) { } else if (qualityTier === 'rare' && options.rareAffixes) {
name = options.rareAffixes.name name = options.rareAffixes.name
for (const affix of options.rareAffixes.affixes) { for (const affix of options.rareAffixes.affixes) {
@ -560,8 +650,51 @@ export function createDroppedItem(
} }
} else if (qualityTier === 'superior') { } else if (qualityTier === 'superior') {
name = `Superior ${base.name}` name = `Superior ${base.name}`
if (!options.rolledProps || options.rolledProps.length === 0) {
if (base.kind === 'armor') {
const acPct = propRng.randRange(5, 15)
rolledProps.push({
code: 'ac%',
min: 5,
max: 15,
value: acPct,
})
stats['ac%'] = (stats['ac%'] ?? 0) + acPct
if (propRng.rand(2) === 0) {
const durPct = propRng.randRange(10, 15)
rolledProps.push({
code: 'dur%',
min: 10,
max: 15,
value: durPct,
})
stats['dur%'] = (stats['dur%'] ?? 0) + durPct
}
} else if (base.kind === 'weapon') {
const dmgPct = propRng.randRange(5, 15)
rolledProps.push({
code: 'dmg%',
min: 5,
max: 15,
value: dmgPct,
})
stats['dmg%'] = (stats['dmg%'] ?? 0) + dmgPct
if (propRng.rand(2) === 0) {
const attVal = propRng.randRange(1, 3)
rolledProps.push({
code: 'att',
min: 1,
max: 3,
value: attVal,
})
stats['att'] = (stats['att'] ?? 0) + attVal
}
}
}
} else if (qualityTier === 'low') { } else if (qualityTier === 'low') {
name = `Cracked ${base.name}` qualitySubtype = propRng.rand(4)
const lowPrefix = LOW_QUALITY_PREFIXES[qualitySubtype] ?? 'Cracked'
name = `${lowPrefix} ${base.name}`
} }
let effectiveAutoMagic = options.autoMagicAffix let effectiveAutoMagic = options.autoMagicAffix
@ -606,13 +739,30 @@ export function createDroppedItem(
} }
} }
const isEthereal = Boolean(
options.ethereal || rolledProps.some(p => p.code.trim().toLowerCase() === 'ethereal'),
)
const socketCount = options.sockets !== undefined && options.sockets > 0 ? options.sockets : 0
if (isEthereal) {
rawFlags |= ItemFlag.ETHEREAL
stats.ethereal = 1
}
if (socketCount > 0) {
rawFlags |= ItemFlag.SOCKETED
}
const rawDurability = (base as any).durability ?? 0 const rawDurability = (base as any).durability ?? 0
const mult = options.durabilityMultiplier ?? 1 const mult = options.durabilityMultiplier ?? 1
let durability: number | undefined = undefined let durability: number | undefined = undefined
let maxDurability: number | undefined = undefined let maxDurability: number | undefined = undefined
if (typeof rawDurability === 'number' && rawDurability > 0) { if (typeof rawDurability === 'number' && rawDurability > 0) {
if (qualityTier === 'low') {
maxDurability = Math.max(1, Math.floor(rawDurability / 3))
} else {
maxDurability = Math.round(rawDurability * mult) maxDurability = Math.round(rawDurability * mult)
}
durability = maxDurability durability = maxDurability
} }
@ -637,6 +787,9 @@ export function createDroppedItem(
uniqueId: options.dwInitSeed, uniqueId: options.dwInitSeed,
durability, durability,
maxDurability, maxDurability,
...(qualitySubtype !== undefined ? { qualitySubtype } : {}),
...(isEthereal ? { ethereal: true } : {}),
...(socketCount > 0 ? { sockets: socketCount, totalSockets: socketCount } : {}),
...(options.uniqueItem ? { uniqueId: options.dwInitSeed, uniqueItemDef: options.uniqueItem } : {}), ...(options.uniqueItem ? { uniqueId: options.dwInitSeed, uniqueItemDef: options.uniqueItem } : {}),
...(options.setItem ? { setId: options.setItem.id, setItemDef: options.setItem } : {}), ...(options.setItem ? { setId: options.setItem.id, setItemDef: options.setItem } : {}),
...(options.rareAffixes ? { rolledRareAffixes: options.rareAffixes } : {}), ...(options.rareAffixes ? { rolledRareAffixes: options.rareAffixes } : {}),
@ -815,8 +968,15 @@ export function executeDropPipeline(
roll -= weight roll -= weight
} }
} }
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'unique', nLevel, itemRng, {
uniqueItem: chosenUnique,
itemTypes: dropTables.itemTypes,
})
drops.push(createDroppedItem(effectiveBase, 'unique', { drops.push(createDroppedItem(effectiveBase, 'unique', {
uniqueItem: chosenUnique, uniqueItem: chosenUnique,
ethereal: ethAndSock.ethereal,
sockets: ethAndSock.sockets,
itemTypes: dropTables.itemTypes,
ilvl: nLevel, ilvl: nLevel,
dwInitSeed, dwInitSeed,
itemRng, itemRng,
@ -854,8 +1014,11 @@ export function executeDropPipeline(
roll -= weight roll -= weight
} }
} }
// 1.13c Ground Truth: Set items NEVER spawn Ethereal
drops.push(createDroppedItem(effectiveBase, 'set', { drops.push(createDroppedItem(effectiveBase, 'set', {
setItem: chosenSet, setItem: chosenSet,
ethereal: false,
itemTypes: dropTables.itemTypes,
ilvl: nLevel, ilvl: nLevel,
dwInitSeed, dwInitSeed,
itemRng, itemRng,
@ -899,10 +1062,16 @@ export function executeDropPipeline(
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
? rollStaffMods(effectiveBase, nLevel, dropTables.itemTypes, itemRng) ? rollStaffMods(effectiveBase, nLevel, dropTables.itemTypes, itemRng)
: undefined : undefined
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'rare', nLevel, itemRng, {
itemTypes: dropTables.itemTypes,
})
drops.push(createDroppedItem(effectiveBase, 'rare', { drops.push(createDroppedItem(effectiveBase, 'rare', {
rareAffixes, rareAffixes,
autoMagicAffix, autoMagicAffix,
staffMods, staffMods,
ethereal: ethAndSock.ethereal,
sockets: ethAndSock.sockets,
itemTypes: dropTables.itemTypes,
ilvl: nLevel, ilvl: nLevel,
dwInitSeed, dwInitSeed,
durabilityMultiplier, durabilityMultiplier,
@ -940,10 +1109,16 @@ export function executeDropPipeline(
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
? rollStaffMods(effectiveBase, nLevel, dropTables.itemTypes, itemRng) ? rollStaffMods(effectiveBase, nLevel, dropTables.itemTypes, itemRng)
: undefined : undefined
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'magic', nLevel, itemRng, {
itemTypes: dropTables.itemTypes,
})
drops.push(createDroppedItem(effectiveBase, 'magic', { drops.push(createDroppedItem(effectiveBase, 'magic', {
magicAffixes, magicAffixes,
autoMagicAffix, autoMagicAffix,
staffMods, staffMods,
ethereal: ethAndSock.ethereal,
sockets: ethAndSock.sockets,
itemTypes: dropTables.itemTypes,
ilvl: nLevel, ilvl: nLevel,
dwInitSeed, dwInitSeed,
durabilityMultiplier, durabilityMultiplier,
@ -970,9 +1145,15 @@ export function executeDropPipeline(
: undefined : undefined
if (quality === 'superior') { if (quality === 'superior') {
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'superior', nLevel, itemRng, {
itemTypes: dropTables.itemTypes,
})
drops.push(createDroppedItem(effectiveBase, 'superior', { drops.push(createDroppedItem(effectiveBase, 'superior', {
autoMagicAffix, autoMagicAffix,
staffMods, staffMods,
ethereal: ethAndSock.ethereal,
sockets: ethAndSock.sockets,
itemTypes: dropTables.itemTypes,
ilvl: nLevel, ilvl: nLevel,
dwInitSeed, dwInitSeed,
itemRng, itemRng,
@ -981,9 +1162,15 @@ export function executeDropPipeline(
} }
if (quality === 'low') { if (quality === 'low') {
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'low', nLevel, itemRng, {
itemTypes: dropTables.itemTypes,
})
drops.push(createDroppedItem(effectiveBase, 'low', { drops.push(createDroppedItem(effectiveBase, 'low', {
autoMagicAffix, autoMagicAffix,
staffMods, staffMods,
ethereal: ethAndSock.ethereal,
sockets: ethAndSock.sockets,
itemTypes: dropTables.itemTypes,
ilvl: nLevel, ilvl: nLevel,
dwInitSeed, dwInitSeed,
itemRng, itemRng,
@ -992,9 +1179,15 @@ export function executeDropPipeline(
} }
// Default: normal quality // Default: normal quality
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'normal', nLevel, itemRng, {
itemTypes: dropTables.itemTypes,
})
drops.push(createDroppedItem(effectiveBase, 'normal', { drops.push(createDroppedItem(effectiveBase, 'normal', {
autoMagicAffix, autoMagicAffix,
staffMods, staffMods,
ethereal: ethAndSock.ethereal,
sockets: ethAndSock.sockets,
itemTypes: dropTables.itemTypes,
ilvl: nLevel, ilvl: nLevel,
dwInitSeed, dwInitSeed,
itemRng, itemRng,

View File

@ -24,6 +24,7 @@ import {
export interface FormattedStatLine { export interface FormattedStatLine {
text: string text: string
color?: 'blue' | 'white' | 'gold' | 'green' | 'orange' | 'yellow' | 'red' | 'gray' color?: 'blue' | 'white' | 'gold' | 'green' | 'orange' | 'yellow' | 'red' | 'gray'
isMagic?: boolean | undefined
} }
export interface FormattedItemTooltip { export interface FormattedItemTooltip {
@ -1709,18 +1710,17 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
} }
} else if (quality === 'superior') { } else if (quality === 'superior') {
qualityColor = '#ffffff' qualityColor = '#ffffff'
title = `Superior ${base.name}` title = item.name && item.name.startsWith('Superior ') ? item.name : `Superior ${base.name}`
if (base.kind === 'weapon') { if (item.rolledProps && item.rolledProps.length > 0) {
rawProps.push(...item.rolledProps)
} else if (base.kind === 'weapon') {
rawProps.push({ code: 'dmg%', min: 15, max: 15, value: 15 }) rawProps.push({ code: 'dmg%', min: 15, max: 15, value: 15 })
} else { } else {
rawProps.push({ code: 'ac%', min: 15, max: 15, value: 15 }) rawProps.push({ code: 'ac%', min: 15, max: 15, value: 15 })
} }
if (item.rolledProps && item.rolledProps.length > 0) {
rawProps.push(...item.rolledProps)
}
} else if (quality === 'low') { } else if (quality === 'low') {
qualityColor = '#a0a0a0' qualityColor = '#a0a0a0'
title = `Cracked ${base.name}` title = item.name && item.name !== base.name ? item.name : `Cracked ${base.name}`
if (item.rolledProps && item.rolledProps.length > 0) { if (item.rolledProps && item.rolledProps.length > 0) {
rawProps.push(...item.rolledProps) rawProps.push(...item.rolledProps)
} }
@ -1738,10 +1738,11 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
lines.push({ lines.push({
text: formatPropertyCode(prop.code, prop.value, prop.min, prop.max, prop.param), text: formatPropertyCode(prop.code, prop.value, prop.min, prop.max, prop.param),
color: 'blue', color: 'blue',
isMagic: true,
}) })
} }
// Compute Enhanced Base Stats vs Original Base Stats per Issue #123 // Compute Enhanced Base Stats vs Original Base Stats per Issue #123 & #131
let totalEdPct = 0 let totalEdPct = 0
let flatMinDmg = 0 let flatMinDmg = 0
let flatMaxDmg = 0 let flatMaxDmg = 0
@ -1771,26 +1772,72 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
else if (c === 'sock') rolledSockets += p.value else if (c === 'sock') rolledSockets += p.value
} }
// 1. Defense (Enhanced vs Base) const isEthereal = Boolean(
item.flags?.ethereal ||
(item as any).ethereal ||
item.stats?.ethereal ||
rawProps.some(p => (p.code || '').trim().toLowerCase() === 'ethereal'),
)
const explicitSockets = Number((item as any).sockets || item.totalSockets || rolledSockets || 0)
if (isEthereal && !lines.some(l => l.text.includes('Ethereal (Cannot be Repaired)'))) {
lines.push({ text: 'Ethereal (Cannot be Repaired)', color: 'blue', isMagic: true })
}
if (explicitSockets > 0 && !lines.some(l => l.text.includes('Socketed ('))) {
lines.push({ text: `Socketed (${explicitSockets})`, color: 'blue', isMagic: true })
}
// 1. Defense (Enhanced / Low Quality / Ethereal vs Base)
const rawBaseDef = base.defense > 0 ? base.defense : 0 const rawBaseDef = base.defense > 0 ? base.defense : 0
let defense: number | undefined = undefined let defense: number | undefined = undefined
let baseDefense: number | undefined = undefined let baseDefense: number | undefined = undefined
if (rawBaseDef > 0) { if (rawBaseDef > 0) {
const finalDef = Math.floor(rawBaseDef * (100 + totalAcPct) / 100) + flatAc let effectiveBaseDef = rawBaseDef
if (quality === 'low') {
effectiveBaseDef = Math.max(1, Math.floor(effectiveBaseDef * 0.75))
}
if (isEthereal) {
effectiveBaseDef = Math.floor(effectiveBaseDef * 1.5)
}
let finalDef = Math.floor(effectiveBaseDef * (100 + totalAcPct) / 100) + flatAc
if (totalAcPct > 0 && finalDef === effectiveBaseDef) {
finalDef = effectiveBaseDef + 1
}
defense = finalDef defense = finalDef
if (finalDef !== rawBaseDef) { if (finalDef !== rawBaseDef || totalAcPct !== 0 || flatAc !== 0 || quality === 'low' || isEthereal) {
baseDefense = rawBaseDef baseDefense = rawBaseDef
} }
} }
// 2. One-Hand & Two-Hand Damage (Enhanced vs Base) // 2. One-Hand & Two-Hand Damage (Enhanced / Low Quality / Ethereal vs Base)
const computeWeaponDamage = (baseMin: number, baseMax: number) => { const computeWeaponDamage = (baseMin: number, baseMax: number) => {
let finalMin = Math.floor(baseMin * (100 + totalEdPct) / 100) + flatMinDmg let effMin = baseMin
let finalMax = Math.floor(baseMax * (100 + totalEdPct) / 100) + flatMaxDmg let effMax = baseMax
if (quality === 'low') {
effMin = Math.max(1, Math.floor(effMin * 0.75))
effMax = Math.max(1, Math.floor(effMax * 0.75))
}
if (isEthereal) {
effMin = Math.floor(effMin * 1.5)
effMax = Math.floor(effMax * 1.5)
}
let finalMin = Math.floor(effMin * (100 + totalEdPct) / 100) + flatMinDmg
let finalMax = Math.floor(effMax * (100 + totalEdPct) / 100) + flatMaxDmg
if (totalEdPct > 0 && finalMax === effMax) {
finalMax = effMax + 1
}
if ((totalEdPct !== 0 || flatMinDmg !== 0 || flatMaxDmg !== 0) && finalMin >= finalMax) { if ((totalEdPct !== 0 || flatMinDmg !== 0 || flatMaxDmg !== 0) && finalMin >= finalMax) {
finalMax = finalMin + 1 finalMax = finalMin + 1
} }
const isEnhanced = finalMin !== baseMin || finalMax !== baseMax const isEnhanced =
finalMin !== baseMin ||
finalMax !== baseMax ||
totalEdPct !== 0 ||
flatMinDmg !== 0 ||
flatMaxDmg !== 0 ||
quality === 'low' ||
isEthereal
return isEnhanced return isEnhanced
? { min: finalMin, max: finalMax, baseMin, baseMax, isEnhanced: true } ? { min: finalMin, max: finalMax, baseMin, baseMax, isEnhanced: true }
: { min: baseMin, max: baseMax } : { min: baseMin, max: baseMax }
@ -1812,7 +1859,10 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
let reqStr: number | undefined = undefined let reqStr: number | undefined = undefined
let baseReqStr: number | undefined = undefined let baseReqStr: number | undefined = undefined
if (rawBaseReqStr > 0) { if (rawBaseReqStr > 0) {
const finalReqStr = Math.max(0, Math.floor(rawBaseReqStr * (100 + easeSigned) / 100)) const finalReqStr = Math.max(
0,
Math.floor(rawBaseReqStr * (100 + easeSigned) / 100) - (isEthereal ? 10 : 0),
)
reqStr = finalReqStr reqStr = finalReqStr
if (finalReqStr !== rawBaseReqStr) { if (finalReqStr !== rawBaseReqStr) {
baseReqStr = rawBaseReqStr baseReqStr = rawBaseReqStr
@ -1823,7 +1873,10 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
let reqDex: number | undefined = undefined let reqDex: number | undefined = undefined
let baseReqDex: number | undefined = undefined let baseReqDex: number | undefined = undefined
if (rawBaseReqDex > 0) { if (rawBaseReqDex > 0) {
const finalReqDex = Math.max(0, Math.floor(rawBaseReqDex * (100 + easeSigned) / 100)) const finalReqDex = Math.max(
0,
Math.floor(rawBaseReqDex * (100 + easeSigned) / 100) - (isEthereal ? 10 : 0),
)
reqDex = finalReqDex reqDex = finalReqDex
if (finalReqDex !== rawBaseReqDex) { if (finalReqDex !== rawBaseReqDex) {
baseReqDex = rawBaseReqDex baseReqDex = rawBaseReqDex
@ -1841,13 +1894,13 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
if (item.durability && item.maxDurability) { if (item.durability && item.maxDurability) {
const finalMaxDur = Math.floor(item.maxDurability * (100 + durPct) / 100) + durBonus const finalMaxDur = Math.floor(item.maxDurability * (100 + durPct) / 100) + durBonus
const finalCurDur = Math.floor(item.durability * (100 + durPct) / 100) + durBonus const finalCurDur = Math.floor(item.durability * (100 + durPct) / 100) + durBonus
const isEnhanced = rawBaseDur !== undefined && finalMaxDur !== rawBaseDur const isEnhanced = rawBaseDur !== undefined && (finalMaxDur !== rawBaseDur || quality === 'low')
durability = isEnhanced durability = isEnhanced
? { current: finalCurDur, max: finalMaxDur, baseMax: rawBaseDur, isEnhanced: true } ? { current: finalCurDur, max: finalMaxDur, baseMax: rawBaseDur, isEnhanced: true }
: { current: finalCurDur, max: finalMaxDur } : { current: finalCurDur, max: finalMaxDur }
} }
const sockets = rolledSockets > 0 ? rolledSockets : (base as any).gemsockets > 0 ? (base as any).gemsockets : undefined const sockets = explicitSockets > 0 ? explicitSockets : (base as any).gemsockets > 0 ? (base as any).gemsockets : undefined
const displayQuality = const displayQuality =
quality === 'unique' ? 'Unique' : quality === 'unique' ? 'Unique' :

View File

@ -389,7 +389,7 @@ describe('Affix Generator - Quality specific rolls (Issue #104)', () => {
}) })
describe('Rare items: 3 to 6 affixes, <=3 prefixes, <=3 suffixes', () => { describe('Rare items: 3 to 6 affixes, <=3 prefixes, <=3 suffixes', () => {
it('satisfies 3..6 total affixes, 1..3 prefixes, 1..3 suffixes, and picks rare name', () => { it('satisfies 3..6 total affixes, 0..3 prefixes, 0..3 suffixes, and picks rare name', () => {
for (let seed = 1; seed <= 50; seed++) { for (let seed = 1; seed <= 50; seed++) {
const rng = new D2Rng(seed * 77) const rng = new D2Rng(seed * 77)
const rare = rollRareAffixes(swordBase, 30, mockAffixes, mockRareNames, mockIsA, rng) const rare = rollRareAffixes(swordBase, 30, mockAffixes, mockRareNames, mockIsA, rng)
@ -398,10 +398,10 @@ describe('Affix Generator - Quality specific rolls (Issue #104)', () => {
expect(rare.affixes.length).toBeGreaterThanOrEqual(3) expect(rare.affixes.length).toBeGreaterThanOrEqual(3)
expect(rare.affixes.length).toBeLessThanOrEqual(6) expect(rare.affixes.length).toBeLessThanOrEqual(6)
// Up to 3 prefix + up to 3 suffix slots (at least 1 each) // Up to 3 prefix + up to 3 suffix slots
expect(rare.prefixes.length).toBeGreaterThanOrEqual(1) expect(rare.prefixes.length).toBeGreaterThanOrEqual(0)
expect(rare.prefixes.length).toBeLessThanOrEqual(3) expect(rare.prefixes.length).toBeLessThanOrEqual(3)
expect(rare.suffixes.length).toBeGreaterThanOrEqual(1) expect(rare.suffixes.length).toBeGreaterThanOrEqual(0)
expect(rare.suffixes.length).toBeLessThanOrEqual(3) expect(rare.suffixes.length).toBeLessThanOrEqual(3)
// Total equals sum // Total equals sum
@ -685,9 +685,9 @@ describe('Affix Generator - Real MPQ Data Integration (when samples/d2 exists)',
expect(rareSword.alvl).toBe(80) expect(rareSword.alvl).toBe(80)
expect(rareSword.affixes.length).toBeGreaterThanOrEqual(3) expect(rareSword.affixes.length).toBeGreaterThanOrEqual(3)
expect(rareSword.affixes.length).toBeLessThanOrEqual(6) expect(rareSword.affixes.length).toBeLessThanOrEqual(6)
expect(rareSword.prefixes.length).toBeGreaterThanOrEqual(1) expect(rareSword.prefixes.length).toBeGreaterThanOrEqual(0)
expect(rareSword.prefixes.length).toBeLessThanOrEqual(3) expect(rareSword.prefixes.length).toBeLessThanOrEqual(3)
expect(rareSword.suffixes.length).toBeGreaterThanOrEqual(1) expect(rareSword.suffixes.length).toBeGreaterThanOrEqual(0)
expect(rareSword.suffixes.length).toBeLessThanOrEqual(3) expect(rareSword.suffixes.length).toBeLessThanOrEqual(3)
expect(rareSword.rarePrefix).toBeDefined() expect(rareSword.rarePrefix).toBeDefined()
expect(rareSword.rareSuffix).toBeDefined() expect(rareSword.rareSuffix).toBeDefined()

View File

@ -9,6 +9,8 @@ import {
rollStaffMods, rollStaffMods,
resolveAutoPrefixGroup, resolveAutoPrefixGroup,
resolveStaffModsClass, resolveStaffModsClass,
LOW_QUALITY_PREFIXES,
rollItemEtherealAndSockets,
type DropTables, type DropTables,
} from '../src/game/drop-pipeline.ts' } from '../src/game/drop-pipeline.ts'
import { import {
@ -826,3 +828,186 @@ describe('dgrp Exact-Equality Aggregation Rule (Issue #130 / C2)', () => {
}) })
}) })
describe('Superior, Low Quality, Ethereal & Sockets, and Rare Slot-by-Slot Allocation (Issue #131)', () => {
let dropTables: DropTables
beforeAll(() => {
dropTables = getEmbeddedDropTables()
})
it('1. A3 — Superior weapons and armor generate +5..15% Enhanced Damage/Defense in rolledProps & stats and render enhanced blue stats in formatItemTooltip', () => {
const monarchBase = dropTables.getBase('uit')! // Monarch: defense 148
const swordBase = dropTables.getBase('crs')! // Crystal Sword: mindam 5, maxdam 15
for (let s = 1; s <= 30; s++) {
const supArmor = createDroppedItem(monarchBase, 'superior', {
ilvl: 85,
dwInitSeed: s * 1009,
})
expect(supArmor.name).toBe(`Superior ${monarchBase.name}`)
expect(supArmor.rolledProps).toBeDefined()
const acPctProp = supArmor.rolledProps!.find(p => p.code === 'ac%')
expect(acPctProp).toBeDefined()
expect(acPctProp!.value).toBeGreaterThanOrEqual(5)
expect(acPctProp!.value).toBeLessThanOrEqual(15)
expect(supArmor.stats['ac%']).toBe(acPctProp!.value)
const armorTooltip = formatItemTooltip(supArmor)
expect(armorTooltip.lines.map(l => l.text)).toContain(`+${acPctProp!.value}% Enhanced Defense`)
expect(armorTooltip.defense).toBe(Math.floor(monarchBase.defense * (100 + acPctProp!.value) / 100))
expect(armorTooltip.baseDefense).toBe(monarchBase.defense)
const supWeapon = createDroppedItem(swordBase, 'superior', {
ilvl: 85,
dwInitSeed: s * 2003,
})
expect(supWeapon.name).toBe(`Superior ${swordBase.name}`)
expect(supWeapon.rolledProps).toBeDefined()
const dmgPctProp = supWeapon.rolledProps!.find(p => p.code === 'dmg%')
expect(dmgPctProp).toBeDefined()
expect(dmgPctProp!.value).toBeGreaterThanOrEqual(5)
expect(dmgPctProp!.value).toBeLessThanOrEqual(15)
expect(supWeapon.stats['dmg%']).toBe(dmgPctProp!.value)
const weaponTooltip = formatItemTooltip(supWeapon)
expect(weaponTooltip.lines.map(l => l.text)).toContain(`+${dmgPctProp!.value}% Enhanced Damage`)
expect(weaponTooltip.oneHandDamage?.isEnhanced).toBe(true)
expect(weaponTooltip.oneHandDamage?.baseMin).toBe((swordBase as any).mindam)
expect(weaponTooltip.oneHandDamage?.baseMax).toBe((swordBase as any).maxdam)
}
})
it('2. A4 — Low Quality items pick from LOW_QUALITY_PREFIXES (4 subtypes) and apply 1/3 durability & 0.75x base defense/damage penalty', () => {
expect(LOW_QUALITY_PREFIXES).toEqual(['Low Quality', 'Cracked', 'Crude', 'Damaged'])
const monarchBase = dropTables.getBase('uit')! // defense 148, durability 86
const axeBase = dropTables.getBase('btx')! // mindam 12, maxdam 32, twoHandedMindam 21, twoHandedMaxdam 49, durability 32
const seenPrefixes = new Set<string>()
for (let s = 1; s <= 60; s++) {
const lowArmor = createDroppedItem(monarchBase, 'low', {
ilvl: 20,
dwInitSeed: s * 313,
})
const prefixUsed = LOW_QUALITY_PREFIXES.find(p => lowArmor.name === `${p} ${monarchBase.name}`)
expect(prefixUsed).toBeDefined()
seenPrefixes.add(prefixUsed!)
const expectedDur = Math.max(1, Math.floor((monarchBase as any).durability / 3))
expect(lowArmor.maxDurability).toBe(expectedDur)
expect(lowArmor.durability).toBe(expectedDur)
const armorTip = formatItemTooltip(lowArmor)
expect(armorTip.title).toBe(lowArmor.name)
expect(armorTip.defense).toBe(Math.max(1, Math.floor(monarchBase.defense * 0.75)))
expect(armorTip.baseDefense).toBe(monarchBase.defense)
expect(armorTip.durability?.isEnhanced).toBe(true)
expect(armorTip.durability?.max).toBe(expectedDur)
expect(armorTip.durability?.baseMax).toBe((monarchBase as any).durability)
}
// All 4 prefixes must be sampled across seeds
expect(seenPrefixes.size).toBe(4)
const lowWeapon = createDroppedItem(axeBase, 'low', {
ilvl: 20,
dwInitSeed: 777,
})
const weapTip = formatItemTooltip(lowWeapon)
expect(weapTip.twoHandDamage?.isEnhanced).toBe(true)
expect(weapTip.twoHandDamage?.min).toBe(Math.max(1, Math.floor((axeBase as any).twoHandedMindam * 0.75)))
expect(weapTip.twoHandDamage?.max).toBe(Math.max(1, Math.floor((axeBase as any).twoHandedMaxdam * 0.75)))
})
it('3. A5 — Ethereal & Sockets generation and formatItemTooltip 1.5x defense/damage, -10 requirements, and Ethereal/Socketed lines', () => {
const monarchBase = dropTables.getBase('uit')! // def 148, reqstr 156, gemsockets 4
const swordBase = dropTables.getBase('crs')! // mindam 5, maxdam 15, reqstr 43, gemsockets 6
// Test Ethereal + Socketed Armor with +10% Superior ED
const ethSockArmor = createDroppedItem(monarchBase, 'superior', {
ilvl: 85,
dwInitSeed: 42,
ethereal: true,
sockets: 4,
rolledProps: [{ code: 'ac%', min: 10, max: 10, value: 10 }],
})
expect(ethSockArmor.flags?.ethereal).toBe(true)
expect(ethSockArmor.flags?.socketed).toBe(true)
expect((ethSockArmor as any).ethereal).toBe(true)
expect((ethSockArmor as any).sockets).toBe(4)
const armorTip = formatItemTooltip(ethSockArmor)
const expectedEthBaseDef = Math.floor(monarchBase.defense * 1.5)
const expectedFinalDef = Math.floor(expectedEthBaseDef * 1.1)
expect(armorTip.defense).toBe(expectedFinalDef)
expect(armorTip.baseDefense).toBe(monarchBase.defense)
expect(armorTip.reqStr).toBe(Math.max(0, (monarchBase as any).reqstr - 10))
expect(armorTip.baseReqStr).toBe((monarchBase as any).reqstr)
expect(armorTip.lines.some(l => l.text.includes('Ethereal (Cannot be Repaired)') && l.isMagic)).toBe(true)
expect(armorTip.lines.some(l => l.text.includes('Socketed (4)') && l.isMagic)).toBe(true)
// Test Ethereal + Socketed Weapon with +15% Superior ED
const ethSockWeapon = createDroppedItem(swordBase, 'superior', {
ilvl: 85,
dwInitSeed: 43,
ethereal: true,
sockets: 5,
rolledProps: [{ code: 'dmg%', min: 15, max: 15, value: 15 }],
})
const weapTip = formatItemTooltip(ethSockWeapon)
const ethMin = Math.floor((swordBase as any).mindam * 1.5)
const ethMax = Math.floor((swordBase as any).maxdam * 1.5)
expect(weapTip.oneHandDamage?.min).toBe(Math.floor(ethMin * 1.15))
expect(weapTip.oneHandDamage?.max).toBe(Math.floor(ethMax * 1.15))
expect(weapTip.reqStr).toBe(Math.max(0, (swordBase as any).reqstr - 10))
expect(weapTip.lines.some(l => l.text.includes('Ethereal (Cannot be Repaired)'))).toBe(true)
expect(weapTip.lines.some(l => l.text.includes('Socketed (5)'))).toBe(true)
// Verify rollItemEtherealAndSockets never makes Set items ethereal and respects ilvl socket caps
for (let s = 1; s <= 100; s++) {
const setRoll = rollItemEtherealAndSockets(monarchBase, 'set', 85, new D2Rng(s), {
itemTypes: dropTables.itemTypes,
})
expect(setRoll.ethereal).toBe(false)
expect(setRoll.sockets).toBe(0)
// Crystal Sword at ilvl 10: swor maxSock1 is 3 (even though crs gemsockets is 6)
const lowIlvlSwordRoll = rollItemEtherealAndSockets(swordBase, 'normal', 10, new D2Rng(s * 17), {
itemTypes: dropTables.itemTypes,
})
if (lowIlvlSwordRoll.sockets > 0) {
expect(lowIlvlSwordRoll.sockets).toBeLessThanOrEqual(3)
}
}
})
it('4. B3 — rollRareAffixes allocates 3..6 affixes slot-by-slot with <=3 prefixes and <=3 suffixes (allowing 3-prefix/0-suffix or 0-prefix/3-suffix)', () => {
const swordBase = dropTables.getBase('crs')!
let sawThreeZero = false
for (let s = 1; s <= 200; s++) {
const rare = rollRareAffixes(
swordBase,
85,
dropTables.magicAffixes,
dropTables.rareNames,
dropTables.isA,
new D2Rng(s * 1337),
)
expect(rare.affixes.length).toBeGreaterThanOrEqual(3)
expect(rare.affixes.length).toBeLessThanOrEqual(6)
expect(rare.prefixes.length).toBeLessThanOrEqual(3)
expect(rare.suffixes.length).toBeLessThanOrEqual(3)
expect(rare.prefixes.length + rare.suffixes.length).toBe(rare.affixes.length)
if (rare.prefixes.length === 3 && rare.suffixes.length === 0) {
sawThreeZero = true
}
if (rare.prefixes.length === 0 && rare.suffixes.length === 3) {
sawThreeZero = true
}
}
expect(sawThreeZero).toBe(true)
})
})