feat(drop/tooltip): 实现Superior/LowQuality属性、Ethereal无形与Socket打孔生成及Rare逐槽分配 (fixes #131)
This commit is contained in:
parent
3da41f5fc7
commit
75eb4f4f4b
|
|
@ -440,61 +440,18 @@ export function rollRareAffixes(
|
|||
)
|
||||
const name = rareNameParts.length > 0 ? rareNameParts.join(' ') : base.name
|
||||
|
||||
// 2. Determine slot distribution:
|
||||
// Total affixes: 3 to 6 (at least 1 prefix and 1 suffix, max 3 each)
|
||||
// 2. Determine slot distribution (1.13c D2Game!6FC31350):
|
||||
// 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 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 usedAffixes = new Set<MagicAffix>()
|
||||
const rolledPrefixes: RolledAffix[] = []
|
||||
const rolledSuffixes: RolledAffix[] = []
|
||||
|
||||
// Roll target prefix slots
|
||||
for (let i = 0; i < targetPrefixCount; i++) {
|
||||
const candidates = getEligibleCandidates(
|
||||
affixes.prefixes,
|
||||
itemTypeCode,
|
||||
predicate,
|
||||
{ alvl, isRare: true },
|
||||
usedGroups,
|
||||
usedAffixes,
|
||||
)
|
||||
const picked = pickWeightedAffix(candidates, rng)
|
||||
if (!picked) break
|
||||
usedAffixes.add(picked)
|
||||
if (picked.group > 0) {
|
||||
usedGroups.add(picked.group)
|
||||
}
|
||||
rolledPrefixes.push(createRolledAffix(picked, rng))
|
||||
}
|
||||
|
||||
// Roll target suffix slots
|
||||
for (let i = 0; i < targetSuffixCount; i++) {
|
||||
const candidates = getEligibleCandidates(
|
||||
affixes.suffixes,
|
||||
itemTypeCode,
|
||||
predicate,
|
||||
{ alvl, isRare: true },
|
||||
usedGroups,
|
||||
usedAffixes,
|
||||
)
|
||||
const picked = pickWeightedAffix(candidates, rng)
|
||||
if (!picked) break
|
||||
usedAffixes.add(picked)
|
||||
if (picked.group > 0) {
|
||||
usedGroups.add(picked.group)
|
||||
}
|
||||
rolledSuffixes.push(createRolledAffix(picked, rng))
|
||||
}
|
||||
|
||||
// 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 tryRollSide = (wantPrefix: boolean): boolean => {
|
||||
if (wantPrefix) {
|
||||
if (rolledPrefixes.length >= 3) return false
|
||||
const candidates = getEligibleCandidates(
|
||||
affixes.prefixes,
|
||||
itemTypeCode,
|
||||
|
|
@ -504,18 +461,15 @@ export function rollRareAffixes(
|
|||
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 (!picked) return false
|
||||
usedAffixes.add(picked)
|
||||
if (picked.group > 0) {
|
||||
usedGroups.add(picked.group)
|
||||
}
|
||||
}
|
||||
|
||||
if (rolledSuffixes.length < 3) {
|
||||
rolledPrefixes.push(createRolledAffix(picked, rng))
|
||||
return true
|
||||
} else {
|
||||
if (rolledSuffixes.length >= 3) return false
|
||||
const candidates = getEligibleCandidates(
|
||||
affixes.suffixes,
|
||||
itemTypeCode,
|
||||
|
|
@ -525,18 +479,29 @@ export function rollRareAffixes(
|
|||
usedAffixes,
|
||||
)
|
||||
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 (!picked) return false
|
||||
usedAffixes.add(picked)
|
||||
if (picked.group > 0) {
|
||||
usedGroups.add(picked.group)
|
||||
}
|
||||
rolledSuffixes.push(createRolledAffix(picked, rng))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for (let slot = 0; slot < totalAffixes; slot++) {
|
||||
let pickPrefix: boolean
|
||||
if (rolledPrefixes.length >= 3) {
|
||||
pickPrefix = false
|
||||
} else if (rolledSuffixes.length >= 3) {
|
||||
pickPrefix = true
|
||||
} else {
|
||||
pickPrefix = rng.rand(2) === 0
|
||||
}
|
||||
|
||||
if (!rolledAny) break
|
||||
if (!tryRollSide(pickPrefix)) {
|
||||
tryRollSide(!pickPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
const allRolled = [...rolledPrefixes, ...rolledSuffixes]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
@ -439,6 +513,10 @@ export function createDroppedItem(
|
|||
readonly rareAffixes?: GeneratedRareAffixes | undefined
|
||||
readonly autoMagicAffix?: RolledAutoMagic | null | 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 dwInitSeed: number
|
||||
readonly durabilityMultiplier?: number | undefined
|
||||
|
|
@ -446,11 +524,12 @@ export function createDroppedItem(
|
|||
},
|
||||
): Item {
|
||||
const quality = QUALITY_TIER_TO_ITEM_QUALITY[qualityTier] ?? ItemQuality.NORMAL
|
||||
const rawFlags = ItemFlag.IDENTIFIED
|
||||
let rawFlags = ItemFlag.IDENTIFIED
|
||||
|
||||
let name = base.name
|
||||
let prefix = null
|
||||
let suffix = null
|
||||
let qualitySubtype: number | undefined = undefined
|
||||
const stats: Record<string, number> = {}
|
||||
const rolledProps: RolledItemProp[] = []
|
||||
|
||||
|
|
@ -470,34 +549,45 @@ export function createDroppedItem(
|
|||
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) {
|
||||
name = options.uniqueItem.index
|
||||
for (const prop of options.uniqueItem.props) {
|
||||
if (prop.code) {
|
||||
const val = resolveItemPropValue(prop)
|
||||
rolledProps.push({
|
||||
code: prop.code,
|
||||
param: prop.par,
|
||||
min: prop.min,
|
||||
max: prop.max,
|
||||
value: val,
|
||||
})
|
||||
stats[prop.code] = (stats[prop.code] ?? 0) + val
|
||||
if (!options.rolledProps || options.rolledProps.length === 0) {
|
||||
for (const prop of options.uniqueItem.props) {
|
||||
if (prop.code) {
|
||||
const val = resolveItemPropValue(prop)
|
||||
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) {
|
||||
const val = resolveItemPropValue(prop)
|
||||
rolledProps.push({
|
||||
code: prop.code,
|
||||
param: prop.par,
|
||||
min: prop.min,
|
||||
max: prop.max,
|
||||
value: val,
|
||||
})
|
||||
stats[prop.code] = (stats[prop.code] ?? 0) + val
|
||||
if (!options.rolledProps || options.rolledProps.length === 0) {
|
||||
for (const prop of options.setItem.props) {
|
||||
if (prop.code) {
|
||||
const val = resolveItemPropValue(prop)
|
||||
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) {
|
||||
|
|
@ -560,8 +650,51 @@ export function createDroppedItem(
|
|||
}
|
||||
} else if (qualityTier === 'superior') {
|
||||
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') {
|
||||
name = `Cracked ${base.name}`
|
||||
qualitySubtype = propRng.rand(4)
|
||||
const lowPrefix = LOW_QUALITY_PREFIXES[qualitySubtype] ?? 'Cracked'
|
||||
name = `${lowPrefix} ${base.name}`
|
||||
}
|
||||
|
||||
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 mult = options.durabilityMultiplier ?? 1
|
||||
let durability: number | undefined = undefined
|
||||
let maxDurability: number | undefined = undefined
|
||||
|
||||
if (typeof rawDurability === 'number' && rawDurability > 0) {
|
||||
maxDurability = Math.round(rawDurability * mult)
|
||||
if (qualityTier === 'low') {
|
||||
maxDurability = Math.max(1, Math.floor(rawDurability / 3))
|
||||
} else {
|
||||
maxDurability = Math.round(rawDurability * mult)
|
||||
}
|
||||
durability = maxDurability
|
||||
}
|
||||
|
||||
|
|
@ -637,6 +787,9 @@ export function createDroppedItem(
|
|||
uniqueId: options.dwInitSeed,
|
||||
durability,
|
||||
maxDurability,
|
||||
...(qualitySubtype !== undefined ? { qualitySubtype } : {}),
|
||||
...(isEthereal ? { ethereal: true } : {}),
|
||||
...(socketCount > 0 ? { sockets: socketCount, totalSockets: socketCount } : {}),
|
||||
...(options.uniqueItem ? { uniqueId: options.dwInitSeed, uniqueItemDef: options.uniqueItem } : {}),
|
||||
...(options.setItem ? { setId: options.setItem.id, setItemDef: options.setItem } : {}),
|
||||
...(options.rareAffixes ? { rolledRareAffixes: options.rareAffixes } : {}),
|
||||
|
|
@ -815,8 +968,15 @@ export function executeDropPipeline(
|
|||
roll -= weight
|
||||
}
|
||||
}
|
||||
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'unique', nLevel, itemRng, {
|
||||
uniqueItem: chosenUnique,
|
||||
itemTypes: dropTables.itemTypes,
|
||||
})
|
||||
drops.push(createDroppedItem(effectiveBase, 'unique', {
|
||||
uniqueItem: chosenUnique,
|
||||
ethereal: ethAndSock.ethereal,
|
||||
sockets: ethAndSock.sockets,
|
||||
itemTypes: dropTables.itemTypes,
|
||||
ilvl: nLevel,
|
||||
dwInitSeed,
|
||||
itemRng,
|
||||
|
|
@ -854,8 +1014,11 @@ export function executeDropPipeline(
|
|||
roll -= weight
|
||||
}
|
||||
}
|
||||
// 1.13c Ground Truth: Set items NEVER spawn Ethereal
|
||||
drops.push(createDroppedItem(effectiveBase, 'set', {
|
||||
setItem: chosenSet,
|
||||
ethereal: false,
|
||||
itemTypes: dropTables.itemTypes,
|
||||
ilvl: nLevel,
|
||||
dwInitSeed,
|
||||
itemRng,
|
||||
|
|
@ -899,10 +1062,16 @@ export function executeDropPipeline(
|
|||
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
|
||||
? rollStaffMods(effectiveBase, nLevel, dropTables.itemTypes, itemRng)
|
||||
: undefined
|
||||
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'rare', nLevel, itemRng, {
|
||||
itemTypes: dropTables.itemTypes,
|
||||
})
|
||||
drops.push(createDroppedItem(effectiveBase, 'rare', {
|
||||
rareAffixes,
|
||||
autoMagicAffix,
|
||||
staffMods,
|
||||
ethereal: ethAndSock.ethereal,
|
||||
sockets: ethAndSock.sockets,
|
||||
itemTypes: dropTables.itemTypes,
|
||||
ilvl: nLevel,
|
||||
dwInitSeed,
|
||||
durabilityMultiplier,
|
||||
|
|
@ -940,10 +1109,16 @@ export function executeDropPipeline(
|
|||
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
|
||||
? rollStaffMods(effectiveBase, nLevel, dropTables.itemTypes, itemRng)
|
||||
: undefined
|
||||
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'magic', nLevel, itemRng, {
|
||||
itemTypes: dropTables.itemTypes,
|
||||
})
|
||||
drops.push(createDroppedItem(effectiveBase, 'magic', {
|
||||
magicAffixes,
|
||||
autoMagicAffix,
|
||||
staffMods,
|
||||
ethereal: ethAndSock.ethereal,
|
||||
sockets: ethAndSock.sockets,
|
||||
itemTypes: dropTables.itemTypes,
|
||||
ilvl: nLevel,
|
||||
dwInitSeed,
|
||||
durabilityMultiplier,
|
||||
|
|
@ -970,9 +1145,15 @@ export function executeDropPipeline(
|
|||
: undefined
|
||||
|
||||
if (quality === 'superior') {
|
||||
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'superior', nLevel, itemRng, {
|
||||
itemTypes: dropTables.itemTypes,
|
||||
})
|
||||
drops.push(createDroppedItem(effectiveBase, 'superior', {
|
||||
autoMagicAffix,
|
||||
staffMods,
|
||||
ethereal: ethAndSock.ethereal,
|
||||
sockets: ethAndSock.sockets,
|
||||
itemTypes: dropTables.itemTypes,
|
||||
ilvl: nLevel,
|
||||
dwInitSeed,
|
||||
itemRng,
|
||||
|
|
@ -981,9 +1162,15 @@ export function executeDropPipeline(
|
|||
}
|
||||
|
||||
if (quality === 'low') {
|
||||
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'low', nLevel, itemRng, {
|
||||
itemTypes: dropTables.itemTypes,
|
||||
})
|
||||
drops.push(createDroppedItem(effectiveBase, 'low', {
|
||||
autoMagicAffix,
|
||||
staffMods,
|
||||
ethereal: ethAndSock.ethereal,
|
||||
sockets: ethAndSock.sockets,
|
||||
itemTypes: dropTables.itemTypes,
|
||||
ilvl: nLevel,
|
||||
dwInitSeed,
|
||||
itemRng,
|
||||
|
|
@ -992,9 +1179,15 @@ export function executeDropPipeline(
|
|||
}
|
||||
|
||||
// Default: normal quality
|
||||
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'normal', nLevel, itemRng, {
|
||||
itemTypes: dropTables.itemTypes,
|
||||
})
|
||||
drops.push(createDroppedItem(effectiveBase, 'normal', {
|
||||
autoMagicAffix,
|
||||
staffMods,
|
||||
ethereal: ethAndSock.ethereal,
|
||||
sockets: ethAndSock.sockets,
|
||||
itemTypes: dropTables.itemTypes,
|
||||
ilvl: nLevel,
|
||||
dwInitSeed,
|
||||
itemRng,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
export interface FormattedStatLine {
|
||||
text: string
|
||||
color?: 'blue' | 'white' | 'gold' | 'green' | 'orange' | 'yellow' | 'red' | 'gray'
|
||||
isMagic?: boolean | undefined
|
||||
}
|
||||
|
||||
export interface FormattedItemTooltip {
|
||||
|
|
@ -1709,18 +1710,17 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
|||
}
|
||||
} else if (quality === 'superior') {
|
||||
qualityColor = '#ffffff'
|
||||
title = `Superior ${base.name}`
|
||||
if (base.kind === 'weapon') {
|
||||
title = item.name && item.name.startsWith('Superior ') ? item.name : `Superior ${base.name}`
|
||||
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 })
|
||||
} else {
|
||||
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') {
|
||||
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) {
|
||||
rawProps.push(...item.rolledProps)
|
||||
}
|
||||
|
|
@ -1738,10 +1738,11 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
|||
lines.push({
|
||||
text: formatPropertyCode(prop.code, prop.value, prop.min, prop.max, prop.param),
|
||||
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 flatMinDmg = 0
|
||||
let flatMaxDmg = 0
|
||||
|
|
@ -1771,26 +1772,72 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
|||
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
|
||||
let defense: number | undefined = undefined
|
||||
let baseDefense: number | undefined = undefined
|
||||
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
|
||||
if (finalDef !== rawBaseDef) {
|
||||
if (finalDef !== rawBaseDef || totalAcPct !== 0 || flatAc !== 0 || quality === 'low' || isEthereal) {
|
||||
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) => {
|
||||
let finalMin = Math.floor(baseMin * (100 + totalEdPct) / 100) + flatMinDmg
|
||||
let finalMax = Math.floor(baseMax * (100 + totalEdPct) / 100) + flatMaxDmg
|
||||
let effMin = baseMin
|
||||
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) {
|
||||
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
|
||||
? { min: finalMin, max: finalMax, baseMin, baseMax, isEnhanced: true }
|
||||
: { min: baseMin, max: baseMax }
|
||||
|
|
@ -1812,7 +1859,10 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
|||
let reqStr: number | undefined = undefined
|
||||
let baseReqStr: number | undefined = undefined
|
||||
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
|
||||
if (finalReqStr !== rawBaseReqStr) {
|
||||
baseReqStr = rawBaseReqStr
|
||||
|
|
@ -1823,7 +1873,10 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
|||
let reqDex: number | undefined = undefined
|
||||
let baseReqDex: number | undefined = undefined
|
||||
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
|
||||
if (finalReqDex !== rawBaseReqDex) {
|
||||
baseReqDex = rawBaseReqDex
|
||||
|
|
@ -1841,13 +1894,13 @@ export function formatItemTooltip(item: Item): FormattedItemTooltip {
|
|||
if (item.durability && item.maxDurability) {
|
||||
const finalMaxDur = Math.floor(item.maxDurability * (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
|
||||
? { current: finalCurDur, max: finalMaxDur, baseMax: rawBaseDur, isEnhanced: true }
|
||||
: { 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 =
|
||||
quality === 'unique' ? 'Unique' :
|
||||
|
|
|
|||
|
|
@ -389,7 +389,7 @@ describe('Affix Generator - Quality specific rolls (Issue #104)', () => {
|
|||
})
|
||||
|
||||
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++) {
|
||||
const rng = new D2Rng(seed * 77)
|
||||
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).toBeLessThanOrEqual(6)
|
||||
|
||||
// Up to 3 prefix + up to 3 suffix slots (at least 1 each)
|
||||
expect(rare.prefixes.length).toBeGreaterThanOrEqual(1)
|
||||
// Up to 3 prefix + up to 3 suffix slots
|
||||
expect(rare.prefixes.length).toBeGreaterThanOrEqual(0)
|
||||
expect(rare.prefixes.length).toBeLessThanOrEqual(3)
|
||||
expect(rare.suffixes.length).toBeGreaterThanOrEqual(1)
|
||||
expect(rare.suffixes.length).toBeGreaterThanOrEqual(0)
|
||||
expect(rare.suffixes.length).toBeLessThanOrEqual(3)
|
||||
|
||||
// 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.affixes.length).toBeGreaterThanOrEqual(3)
|
||||
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.suffixes.length).toBeGreaterThanOrEqual(1)
|
||||
expect(rareSword.suffixes.length).toBeGreaterThanOrEqual(0)
|
||||
expect(rareSword.suffixes.length).toBeLessThanOrEqual(3)
|
||||
expect(rareSword.rarePrefix).toBeDefined()
|
||||
expect(rareSword.rareSuffix).toBeDefined()
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import {
|
|||
rollStaffMods,
|
||||
resolveAutoPrefixGroup,
|
||||
resolveStaffModsClass,
|
||||
LOW_QUALITY_PREFIXES,
|
||||
rollItemEtherealAndSockets,
|
||||
type DropTables,
|
||||
} from '../src/game/drop-pipeline.ts'
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue