diff --git a/src/game/affix-generator.ts b/src/game/affix-generator.ts index e14ba9b..790dcf4 100644 --- a/src/game/affix-generator.ts +++ b/src/game/affix-generator.ts @@ -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() const usedAffixes = new Set() 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] diff --git a/src/game/drop-pipeline.ts b/src/game/drop-pipeline.ts index 34c84bc..0b02593 100644 --- a/src/game/drop-pipeline.ts +++ b/src/game/drop-pipeline.ts @@ -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 = {} 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, diff --git a/src/game/item-tooltip.ts b/src/game/item-tooltip.ts index 51cabd0..2e68a13 100644 --- a/src/game/item-tooltip.ts +++ b/src/game/item-tooltip.ts @@ -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' : diff --git a/tests/affix-generator.test.ts b/tests/affix-generator.test.ts index 3e15527..c79274a 100644 --- a/tests/affix-generator.test.ts +++ b/tests/affix-generator.test.ts @@ -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() diff --git a/tests/parity-1.13c.test.ts b/tests/parity-1.13c.test.ts index c4f1ba5..dc0ff81 100644 --- a/tests/parity-1.13c.test.ts +++ b/tests/parity-1.13c.test.ts @@ -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() + + 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) + }) +}) + +