diff --git a/config/i18n.json b/config/i18n.json index effa3b242f..86cd6fb441 100644 --- a/config/i18n.json +++ b/config/i18n.json @@ -670,6 +670,7 @@ "ExcludeVendors": "Search \"not:vendor\" to exclude vendor items from Loadout Optimizer.", "ExistingLoadout": "Existing Loadout", "FOTLWildcardWarning": "This set contains a Festival of the Lost mask. Manually apply the correct mod to activate desired set bonuses.", + "SetBonusModWarning": "This set contains an item with a configurable set bonus mod. Manually apply the correct mod to activate the desired set bonus.", "ExoticClassItemPerks": "If you want specific perks, use searches like exactperk:\"spirit of verity\". Click perks in the Optimizer results to add or remove them from the item filter.", "ExoticSpecialCategory": "Special", "Filter": "Settings", diff --git a/src/app/item-popup/SetBonus.tsx b/src/app/item-popup/SetBonus.tsx index 06ac834aa2..f7e22b8caf 100644 --- a/src/app/item-popup/SetBonus.tsx +++ b/src/app/item-popup/SetBonus.tsx @@ -8,6 +8,7 @@ import { DimStore } from 'app/inventory/store-types'; import { useCurrentSetBonus } from 'app/inventory/store/hooks'; import { useD2Definitions } from 'app/manifest/selectors'; import { compareByIndex } from 'app/utils/comparators'; +import { getActiveSetBonusHash } from 'app/utils/socket-utils'; import { DestinyEquipableItemSetDefinition, DestinySandboxPerkDefinition, @@ -37,14 +38,15 @@ export function getSetBonusStatus(defs: D2ManifestDefinitions, items: DimItem[]) const displayIterable = []; for (const item of equippedArmor) { - if (item.setBonus) { - (possibleBonusSets[item.setBonus.hash] ??= []).push(item); + const setBonusHash = getActiveSetBonusHash(item); + if (setBonusHash) { + (possibleBonusSets[setBonusHash] ??= []).push(item); } } for (const h in possibleBonusSets) { const matchingSetItems = possibleBonusSets[h]; - const possibleBonus = matchingSetItems[0].setBonus!; + const possibleBonus = defs.EquipableItemSet.get(Number(h)); const info: { bonusDef: DestinyEquipableItemSetDefinition; activePerks: { requiredSetCount: number; perkDef: DestinySandboxPerkDefinition }[]; @@ -208,7 +210,9 @@ function ContributingArmor({ src={i.icon} className={clsx( styles.gearListIcon, - setBonus.setItems.includes(i.hash) || styles.unsatisfied, + setBonus.setItems.includes(i.hash) || + getActiveSetBonusHash(i) === setBonus.hash || + styles.unsatisfied, )} /> ))} diff --git a/src/app/loadout-builder/filter/LoadoutOptimizerSetBonus.tsx b/src/app/loadout-builder/filter/LoadoutOptimizerSetBonus.tsx index f517830659..76f5ad5a71 100644 --- a/src/app/loadout-builder/filter/LoadoutOptimizerSetBonus.tsx +++ b/src/app/loadout-builder/filter/LoadoutOptimizerSetBonus.tsx @@ -1,4 +1,5 @@ import { SetBonusCounts } from '@destinyitemmanager/dim-api-types'; +import { D2ManifestDefinitions } from 'app/destiny2/d2-definitions'; import Sheet from 'app/dim-ui/Sheet'; import { SheetHorizontalScrollContainer } from 'app/dim-ui/SheetHorizontalScrollContainer'; import { TileGrid, TileGridTile } from 'app/dim-ui/TileGrid'; @@ -7,13 +8,15 @@ import { t } from 'app/i18next-t'; import { DimItem } from 'app/inventory/item-types'; import { allItemsSelector } from 'app/inventory/selectors'; import { SetBonus, SetPerkIcon } from 'app/item-popup/SetBonus'; +import { setBonusModToSet } from 'app/loadout/known-values'; import LoadoutEditSection from 'app/loadout/loadout-edit/LoadoutEditSection'; import { isLoadoutBuilderItem } from 'app/loadout/loadout-item-utils'; import { useD2Definitions } from 'app/manifest/selectors'; import { useIsPhonePortrait } from 'app/shell/selectors'; -import { uniqBy } from 'app/utils/collections'; +import { filterMap, mapValues, minOf } from 'app/utils/collections'; +import { getActiveSetBonusHash, getSetBonusModSocket } from 'app/utils/socket-utils'; import { DestinyClass, DestinyItemSetPerkDefinition } from 'bungie-api-ts/destiny2'; -import { countBy, sum } from 'es-toolkit'; +import { sum } from 'es-toolkit'; import { Dispatch, memo, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; import { LoadoutBuilderAction } from '../loadout-builder-reducer'; @@ -47,13 +50,15 @@ const LoadoutOptimizerSetBonus = memo(function LoadoutOptimizerSetBonus({ const hidePicker = () => setShowSetBonusPicker(false); const handleSyncFromEquipped = () => { - const equippedSetBonuses = allItems.filter( - (i) => i.equipped && isLoadoutBuilderItem(i) && i.owner === storeId && i.setBonus, + const equippedSetBonuses = filterMap(allItems, (i) => + i.equipped && isLoadoutBuilderItem(i) && i.owner === storeId + ? getActiveSetBonusHash(i) + : undefined, ); const newSetBonuses: SetBonusCounts = {}; - for (const item of equippedSetBonuses) { - newSetBonuses[item.setBonus!.hash] = (newSetBonuses[item.setBonus!.hash] || 0) + 1; + for (const setHash of equippedSetBonuses) { + newSetBonuses[setHash] = (newSetBonuses[setHash] || 0) + 1; } for (const setHash in newSetBonuses) { @@ -112,19 +117,46 @@ export default LoadoutOptimizerSetBonus; * many pieces could be used for each. */ function findSetBonuses( + defs: D2ManifestDefinitions, allItems: DimItem[], vendorItems: DimItem[], classType: DestinyClass, ): SetBonusCounts { - // One item from each bucket with a set bonus - const setBonusExemplars = uniqBy( - [...allItems, ...vendorItems].filter( - (item) => item.classType === classType && item.setBonus && isLoadoutBuilderItem(item), - ), - (item) => `${item.setBonus!.hash}-${item.bucket.hash}`, + // For each set bonus, the buckets where contributing piece exists + const bucketsBySet: Record> = {}; + // Buckets where a wildcard item exists + const wildcardBuckets = new Set(); + + for (const item of [...allItems, ...vendorItems]) { + if (item.classType !== classType || !isLoadoutBuilderItem(item)) { + continue; + } + if (item.setBonus) { + (bucketsBySet[item.setBonus.hash] ??= new Set()).add(item.bucket.hash); + } + if (getSetBonusModSocket(item)) { + wildcardBuckets.add(item.bucket.hash); + } + } + + // Seed sets that wildcards could fill on their own (none in practice so far) + if (wildcardBuckets.size) { + for (const setHash of Object.values(setBonusModToSet)) { + const setDef = defs.EquipableItemSet.get(setHash); + if ( + setDef && + !(setHash in bucketsBySet) && + wildcardBuckets.size >= minOf(setDef.setPerks, (p) => p.requiredSetCount) + ) { + bucketsBySet[setHash] = new Set(); + } + } + } + + return mapValues( + bucketsBySet, + (buckets) => buckets.size + [...wildcardBuckets].filter((b) => !buckets.has(b)).length, ); - // Get the max number of items we could have available for each set bonus - return countBy(setBonusExemplars, (i) => i.setBonus!.hash); } export function SetBonusPicker({ @@ -155,8 +187,8 @@ export function SetBonusPicker({ const allItems = useSelector(allItemsSelector); const possibleSetBonuses = useMemo( - () => findSetBonuses(allItems, vendorItems, classType), - [allItems, vendorItems, classType], + () => findSetBonuses(defs, allItems, vendorItems, classType), + [defs, allItems, vendorItems, classType], ); // Only allow choosing set bonuses the user has items for diff --git a/src/app/loadout-builder/generated-sets/GeneratedSet.tsx b/src/app/loadout-builder/generated-sets/GeneratedSet.tsx index 4fa6cb3358..71cafcc7f6 100644 --- a/src/app/loadout-builder/generated-sets/GeneratedSet.tsx +++ b/src/app/loadout-builder/generated-sets/GeneratedSet.tsx @@ -4,7 +4,6 @@ import { DimItem, PluggableInventoryItemDefinition } from 'app/inventory/item-ty import { DimStore, statSourceOrder } from 'app/inventory/store-types'; import { getSetBonusStatus } from 'app/item-popup/SetBonus'; import { calculateAssumedMasterworkStats } from 'app/loadout-drawer/loadout-utils'; -import { fotlWildcardHashes } from 'app/loadout/known-values'; import { Loadout } from 'app/loadout/loadout-types'; import { fitMostMods } from 'app/loadout/mod-assignment-utils'; import { getTotalModStatChanges } from 'app/loadout/stats'; @@ -13,6 +12,7 @@ import { armorStats } from 'app/search/d2-known-values'; import { mapValues } from 'app/utils/collections'; import { compareByIndex } from 'app/utils/comparators'; import { errorLog } from 'app/utils/log'; +import { getSetBonusModSocket } from 'app/utils/socket-utils'; import { DestinyClass } from 'bungie-api-ts/destiny2'; import { StatHashes } from 'data/d2/generated-enums'; import { intersectionBy, once } from 'es-toolkit'; @@ -173,7 +173,7 @@ export default memo(function GeneratedSet({ existingLoadoutName={overlappingLoadout?.name} equippedHashes={equippedHashes} setBonusStatus={setBonusStatus} - fotlWarning={set.armor.some((i) => fotlWildcardHashes.has(i.hash))} + setBonusModWarning={set.armor.some((i) => getSetBonusModSocket(i))} />
diff --git a/src/app/loadout-builder/generated-sets/SetStats.tsx b/src/app/loadout-builder/generated-sets/SetStats.tsx index fe15c7cbe9..631bc4fa5c 100644 --- a/src/app/loadout-builder/generated-sets/SetStats.tsx +++ b/src/app/loadout-builder/generated-sets/SetStats.tsx @@ -35,7 +35,7 @@ export function TierlessSetStats({ existingLoadoutName, equippedHashes, setBonusStatus, - fotlWarning, + setBonusModWarning, }: { stats: ArmorStats; getStatsBreakdown: () => ModStatChanges; @@ -46,7 +46,7 @@ export function TierlessSetStats({ existingLoadoutName?: string; equippedHashes: Set; setBonusStatus: ActiveSetBonusInfo; - fotlWarning?: boolean; + setBonusModWarning?: boolean; }) { const defs = useD2Definitions()!; const totalStats = sum(Object.values(stats)); @@ -95,8 +95,8 @@ export function TierlessSetStats({ {maxPower} - {fotlWarning && ( - + {setBonusModWarning && ( + )} diff --git a/src/app/loadout-builder/item-filter.ts b/src/app/loadout-builder/item-filter.ts index a245362c7c..980b1c6f6f 100644 --- a/src/app/loadout-builder/item-filter.ts +++ b/src/app/loadout-builder/item-filter.ts @@ -4,14 +4,13 @@ import { DimItem, PluggableInventoryItemDefinition } from 'app/inventory/item-ty import { isExoticClassItemWithPerks } from 'app/inventory/store/exotic-class-item'; import { calculateAssumedMasterworkStats } from 'app/loadout-drawer/loadout-utils'; import { calculateAssumedItemEnergy } from 'app/loadout/armor-upgrade-utils'; -import { fotlWildcardHashes } from 'app/loadout/known-values'; import { ModMap, assignBucketSpecificMods } from 'app/loadout/mod-assignment-utils'; import { armorStats } from 'app/search/d2-known-values'; import { ItemFilter } from 'app/search/filter-types'; import { sumBy } from 'app/utils/collections'; import { getModTypeTagByPlugCategoryHash, getSpecialtySocketMetadata } from 'app/utils/item-utils'; import { warnLog } from 'app/utils/log'; -import { getExtraIntrinsicPerkHashes } from 'app/utils/socket-utils'; +import { getExtraIntrinsicPerkHashes, getSetBonusModSocket } from 'app/utils/socket-utils'; import { computeStatDupeLower } from 'app/utils/stats'; import { BucketHashes } from 'data/d2/generated-enums'; import { sum } from 'es-toolkit'; @@ -216,7 +215,7 @@ export function filterItems({ firstPassFilteredItems = firstPassFilteredItems.filter( (item) => (item.setBonus && includeOnlySetBonusHashes.includes(item.setBonus.hash)) || - fotlWildcardHashes.has(item.hash), + getSetBonusModSocket(item), ); } diff --git a/src/app/loadout-builder/process-worker/process.ts b/src/app/loadout-builder/process-worker/process.ts index 6e0d05fcc7..f49a34dbda 100644 --- a/src/app/loadout-builder/process-worker/process.ts +++ b/src/app/loadout-builder/process-worker/process.ts @@ -1,5 +1,5 @@ import { SetBonusCounts } from '@destinyitemmanager/dim-api-types'; -import { fotlWildcardHashes, MAX_STAT } from 'app/loadout/known-values'; +import { MAX_STAT } from 'app/loadout/known-values'; import { compact, filterMap } from 'app/utils/collections'; import { BucketHashes } from 'data/d2/generated-enums'; import { sum } from 'es-toolkit'; @@ -212,24 +212,28 @@ export async function process( const helm = helms[helmIdx]; const helmExotic = Number(helm.isExotic); const helmArtifice = Number(helm.isArtifice); + const helmWildcard = helm.hasSetBonusModSocket ? 1 : 0; const helmPerks = perkCount(helm); const helmStats = statsCache.get(helm)!; for (let gauntIdx = 0; gauntIdx < gauntlets.length; gauntIdx++) { const gaunt = gauntlets[gauntIdx]; const gauntletExotic = Number(gaunt.isExotic); const gauntArtifice = Number(gaunt.isArtifice); + const gauntWildcard = gaunt.hasSetBonusModSocket ? 1 : 0; const gauntPerks = perkCount(gaunt); const gauntStats = statsCache.get(gaunt)!; for (let chestIdx = 0; chestIdx < chests.length; chestIdx++) { const chest = chests[chestIdx]; const chestExotic = Number(chest.isExotic); const chestArtifice = Number(chest.isArtifice); + const chestWildcard = chest.hasSetBonusModSocket ? 1 : 0; const chestPerks = perkCount(chest); const chestStats = statsCache.get(chest)!; for (let legIdx = 0; legIdx < legs.length; legIdx++) { const leg = legs[legIdx]; const legExotic = Number(leg.isExotic); const legArtifice = Number(leg.isArtifice); + const legWildcard = leg.hasSetBonusModSocket ? 1 : 0; const legPerks = perkCount(leg); const legStats = statsCache.get(leg)!; innerloop: for (let classItemIdx = 0; classItemIdx < classItems.length; classItemIdx++) { @@ -245,6 +249,7 @@ export async function process( const classItemExotic = Number(classItem.isExotic); const classItemArtifice = Number(classItem.isArtifice); + const classItemWildcard = classItem.hasSetBonusModSocket ? 1 : 0; const classItemStats = statsCache.get(classItem)!; // Check exotic constraints @@ -270,8 +275,9 @@ export async function process( } } - // Check set bonus requirements - let wildcardAvailable = true; + // Set bonuses; each slot can use one wildcard if present + let wildcardsRemaining = + helmWildcard + gauntWildcard + chestWildcard + legWildcard + classItemWildcard; for (let i = 0; i < setBonusHashes.length; i++) { const setHash = setBonusHashes[i]; const setNeededCount = setBonusCounts[i]; @@ -282,12 +288,9 @@ export async function process( Number(leg.setBonus === setHash) + Number(classItem.setBonus === setHash); if (setCount < setNeededCount) { - if ( - wildcardAvailable && - fotlWildcardHashes.has(helm.hash!) && - setCount + 1 === setNeededCount - ) { - wildcardAvailable = false; + const wildcardsNeeded = setNeededCount - setCount; + if (wildcardsRemaining >= wildcardsNeeded) { + wildcardsRemaining -= wildcardsNeeded; } else { setStatistics.skipReasons.insufficientSetBonus += 1; continue innerloop; diff --git a/src/app/loadout-builder/process-worker/types.ts b/src/app/loadout-builder/process-worker/types.ts index 81098da334..654b60af6d 100644 --- a/src/app/loadout-builder/process-worker/types.ts +++ b/src/app/loadout-builder/process-worker/types.ts @@ -34,6 +34,11 @@ export interface ProcessItem { setBonus?: number; /** The intrinsic perk plug hashes on this item (e.g. exotic class item perks). */ intrinsicPerks?: number[]; + /** + * True if this item has a configurable set bonus selector socket — let it + * wildcard a single missing piece of any requested set bonus. + */ + hasSetBonusModSocket?: boolean; /** * This is a pre-set tuning mod on the item. This hash should be passed along * to the ArmorSet.statMods list. diff --git a/src/app/loadout-builder/process/mappers.ts b/src/app/loadout-builder/process/mappers.ts index 702f1625c5..c22ef8c324 100644 --- a/src/app/loadout-builder/process/mappers.ts +++ b/src/app/loadout-builder/process/mappers.ts @@ -18,6 +18,7 @@ import { getArmor3TuningSocket, getExtraIntrinsicPerkSockets, getIntrinsicArmorPerkSocket, + getSetBonusModSocket, } from 'app/utils/socket-utils'; import { emptyPlugHashes } from 'data/d2/empty-plug-hashes'; import { StatHashes } from 'data/d2/generated-enums'; @@ -102,6 +103,7 @@ export function mapDimItemToProcessItems({ remainingEnergyCapacity: capacity - modsCost, compatibleActivityMod: compatibleActivityMod, setBonus: setBonus?.hash, + hasSetBonusModSocket: Boolean(getSetBonusModSocket(dimItem)), intrinsicPerks, }; diff --git a/src/app/loadout/known-values.ts b/src/app/loadout/known-values.ts index 06fc534dae..313459b143 100644 --- a/src/app/loadout/known-values.ts +++ b/src/app/loadout/known-values.ts @@ -47,5 +47,25 @@ export const edgeOfFateReleased = D2CalculatedSeason >= 27; // bit before it releases. Afterwards they will be the same. export const EFFECTIVE_MAX_STAT = edgeOfFateReleased ? MAX_STAT : 10 * MAX_TIER; -/** These 3 helmets can be configured to contribute to any set bonus */ -export const fotlWildcardHashes = new Set([4095816113, 2462335932, 2390807586]); +// TODO: this should probably go in d2ai +export const setBonusModToSet: Record = { + 139044974: 2132906400, // New Demotic + 139044987: 2391762223, // Swordmaster + 721111598: 239346083, // Techsec + 721111611: 3252452908, // Last Discipline + 1012508294: 2258577662, // Wild Anthem + 1012508307: 3734029045, // Ferropotent + 1220635053: 3259216565, // Twofold Crown + 1220635064: 1083114430, // Bushido + 1404854454: 1223381128, // AION Renewal + 1530138662: 2450108908, // Wayward Psyche (Set) + 1841728090: 4222859846, // Sage Protector + 2824493179: 1007956300, // Accretion -> Collective Psyche + 2872740129: 2151917545, // Thriving Survivor + 3573256294: 2947197258, // Disaster Corps (Set) + 3573256307: 2751989785, // Smoke Jumper (Set) + 3782433407: 3737690559, // Lustrous + 3834187337: 305966751, // Iron Panoply (Set) + 3874641219: 2839368623, // Shrewd Survivor + 4119627352: 894715166, // AION Adapter +}; diff --git a/src/app/search/items/search-filters/sockets.ts b/src/app/search/items/search-filters/sockets.ts index db59f6357b..bdb8877c02 100644 --- a/src/app/search/items/search-filters/sockets.ts +++ b/src/app/search/items/search-filters/sockets.ts @@ -12,6 +12,7 @@ import { enhancedVersion } from 'app/utils/perk-utils'; import { countEnhancedPerks, getIntrinsicArmorPerkSocket, + getSetBonusModSocket, getSocketsByCategoryHash, matchesCuratedRoll, } from 'app/utils/socket-utils'; @@ -30,11 +31,14 @@ export const modslotFilter = { keywords: 'modslot', description: tl('Filter.ModSlot'), format: 'query', - suggestions: modSlotTags.concat(['none', 'activity']), + suggestions: modSlotTags.concat(['none', 'activity', 'setbonus']), destinyVersion: 2, filter: ({ filterValue }) => (item) => { + if (filterValue === 'setbonus') { + return Boolean(getSetBonusModSocket(item, true)); + } const modSocketTag = getSpecialtySocketMetadata(item)?.slotTag; return Boolean( @@ -44,6 +48,9 @@ export const modslotFilter = { ); }, fromItem: (item) => { + if (getSetBonusModSocket(item, true)) { + return 'modslot:setbonus'; + } const modSocketTag = getSpecialtySocketMetadata(item)?.slotTag; return modSocketTag ? `modslot:${modSocketTag}` : ''; }, diff --git a/src/app/utils/socket-utils.ts b/src/app/utils/socket-utils.ts index 3d8f659d1e..1bf37cb6e3 100644 --- a/src/app/utils/socket-utils.ts +++ b/src/app/utils/socket-utils.ts @@ -9,6 +9,7 @@ import { } from 'app/inventory/item-types'; import { craftedSocketCategoryHash, mementoSocketCategoryHash } from 'app/inventory/store/crafted'; import { isDeepsightResonanceSocket } from 'app/inventory/store/deepsight'; +import { setBonusModToSet } from 'app/loadout/known-values'; import { D2PlugCategoryByStatHash, GhostActivitySocketTypeHashes, @@ -772,3 +773,28 @@ function socketIsWeaponComponent(socket: DimSocket) { export function getWeaponComponentSockets(item: DimItem) { return (item.sockets?.allSockets ?? []).filter(socketIsWeaponComponent); } + +/** + * The set bonus selector socket on an item (FOTL/Guardian Games), if usable/visible. + * 2nd param determines whether to return sockets that aren't visible. + */ +export function getSetBonusModSocket(item: DimItem, ignoreVisibility = false) { + return item.sockets?.allSockets.find( + (s) => + (ignoreVisibility || s.visibleInGame) && + socketContainsPlugWithCategory( + s, + PlugCategoryHashes.CoreGearSystemsEventGearItemSetsSelectors, + ), + ); +} + +/** Hash of the set bonus this item currently contributes to, including via a plugged selector mod. */ +export function getActiveSetBonusHash(item: DimItem): number | undefined { + if (item.setBonus) { + return item.setBonus.hash; + } + const socket = getSetBonusModSocket(item); + // TODO: make sure 'enabled' is correct when the next event happens + return socket?.plugged?.enabled ? setBonusModToSet[socket.plugged.plugDef.hash] : undefined; +} diff --git a/src/locale/en.json b/src/locale/en.json index 66d4880865..5c62d6e203 100644 --- a/src/locale/en.json +++ b/src/locale/en.json @@ -709,7 +709,6 @@ "ExistingLoadout": "Existing Loadout", "Exotic": "Exotic Armor", "ExoticSpecialCategory": "Special", - "FOTLWildcardWarning": "This set contains a Festival of the Lost mask. Manually apply the correct mod to activate desired set bonuses.", "Filter": "Settings", "IgnoreStat": "If unchecked, Loadout Optimizer will pretend this stat doesn't exist when building sets", "IncreaseStatPriority": "Increase stat priority", @@ -756,6 +755,7 @@ "ProcessingSets": "Finding highest stat sets...", "SaveAs": "Save as", "SetBonus": "Set Bonuses", + "SetBonusModWarning": "This set contains an item with a configurable set bonus mod. Manually apply the correct mod to activate the desired set bonus.", "SpeedReport": "Evaluated {{combos, number}} combinations in {{time}} seconds using {{cpus}} CPU cores.", "StatConstraints": "Stat Priorities & Ranges", "StatMax": "Max",