feat(drop/tooltip): 实现Superior/LowQuality属性、Ethereal无形与Socket打孔生成及Rare逐槽分配 (fixes #131)
This commit is contained in:
parent
3da41f5fc7
commit
75eb4f4f4b
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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' :
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue