From b0e9d8bc075f0f99411b789c921bb86c3c574838 Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Thu, 7 May 2026 01:01:07 -0700 Subject: [PATCH 1/6] Add support for Set Bonus mod socket on event items Only shows the set bonus as active if the mod itself is enabled Switches from fotlWildcard in loadout optimizer to seeing if mod socket is visible Handles mod socket presence when showing set bonus picker Handles mod socket plug when using syncFromEquipped --- config/i18n.json | 1 + src/app/item-popup/SetBonus.tsx | 12 ++++-- .../filter/LoadoutOptimizerSetBonus.tsx | 41 +++++++++++++++---- .../generated-sets/GeneratedSet.tsx | 4 +- .../generated-sets/SetStats.tsx | 8 ++-- src/app/loadout-builder/item-filter.ts | 4 +- .../loadout-builder/process-worker/process.ts | 20 +++++---- .../loadout-builder/process-worker/types.ts | 5 +++ src/app/loadout-builder/process/mappers.ts | 3 +- src/app/loadout/known-values.ts | 24 ++++++++++- src/app/utils/socket-utils.ts | 23 +++++++++++ src/locale/en.json | 2 +- 12 files changed, 113 insertions(+), 34 deletions(-) diff --git a/config/i18n.json b/config/i18n.json index fe02f846d3..c2ee47d574 100644 --- a/config/i18n.json +++ b/config/i18n.json @@ -665,6 +665,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..33882eee0b 100644 --- a/src/app/loadout-builder/filter/LoadoutOptimizerSetBonus.tsx +++ b/src/app/loadout-builder/filter/LoadoutOptimizerSetBonus.tsx @@ -11,7 +11,8 @@ 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, uniqBy } 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 { Dispatch, memo, useMemo, useState } from 'react'; @@ -47,13 +48,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) { @@ -116,15 +119,35 @@ function findSetBonuses( vendorItems: DimItem[], classType: DestinyClass, ): SetBonusCounts { + const candidateItems = [...allItems, ...vendorItems].filter( + (item) => item.classType === classType && isLoadoutBuilderItem(item), + ); // One item from each bucket with a set bonus const setBonusExemplars = uniqBy( - [...allItems, ...vendorItems].filter( - (item) => item.classType === classType && item.setBonus && isLoadoutBuilderItem(item), - ), + candidateItems.filter((item) => item.setBonus), (item) => `${item.setBonus!.hash}-${item.bucket.hash}`, ); // Get the max number of items we could have available for each set bonus - return countBy(setBonusExemplars, (i) => i.setBonus!.hash); + const counts = countBy(setBonusExemplars, (i) => i.setBonus!.hash) as SetBonusCounts; + + // Get buckets in which wildcards exist + const wildcardBuckets = new Set( + candidateItems.filter(getSetBonusModSocket).map((i) => i.bucket.hash), + ); + for (const setHash of Object.keys(counts).map(Number)) { + // Get buckets for which this set bonus has items + const setBuckets = new Set( + setBonusExemplars.filter((i) => i.setBonus!.hash === setHash).map((i) => i.bucket.hash), + ); + // Fill missing buckets with wildcards + for (const b of wildcardBuckets) { + if (!setBuckets.has(b)) { + counts[setHash] = (counts[setHash] ?? 0) + 1; + } + } + } + + return counts; } export function SetBonusPicker({ diff --git a/src/app/loadout-builder/generated-sets/GeneratedSet.tsx b/src/app/loadout-builder/generated-sets/GeneratedSet.tsx index 4fa6cb3358..e2ad2abc80 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(getSetBonusModSocket)} />
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 2bacd106ff..4a95344b82 100644 --- a/src/app/loadout-builder/item-filter.ts +++ b/src/app/loadout-builder/item-filter.ts @@ -3,13 +3,13 @@ import { D2ManifestDefinitions } from 'app/destiny2/d2-definitions'; import { DimItem, PluggableInventoryItemDefinition } from 'app/inventory/item-types'; 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 { getSetBonusModSocket } from 'app/utils/socket-utils'; import { computeStatDupeLower } from 'app/utils/stats'; import { BucketHashes } from 'data/d2/generated-enums'; import { sum } from 'es-toolkit'; @@ -195,7 +195,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 84c2d681d4..798a92f75a 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'; @@ -242,8 +242,13 @@ export async function process( continue; } - // Check set bonus requirements - let wildcardAvailable = true; + // Set bonuses; each slot can use one wildcard if present + let wildcardsRemaining = + (helm.hasSetBonusModSocket ? 1 : 0) + + (gaunt.hasSetBonusModSocket ? 1 : 0) + + (chest.hasSetBonusModSocket ? 1 : 0) + + (leg.hasSetBonusModSocket ? 1 : 0) + + (classItem.hasSetBonusModSocket ? 1 : 0); for (let i = 0; i < setBonusHashes.length; i++) { const setHash = setBonusHashes[i]; const setNeededCount = setBonusCounts[i]; @@ -254,12 +259,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 0dd54800d2..a7c1cbed8b 100644 --- a/src/app/loadout-builder/process-worker/types.ts +++ b/src/app/loadout-builder/process-worker/types.ts @@ -32,6 +32,11 @@ export interface ProcessItem { /** The activity (raid) mod type that can be slotted on this item, if any. */ compatibleActivityMod?: string; setBonus?: 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 9e7b7ccd73..7e74beb183 100644 --- a/src/app/loadout-builder/process/mappers.ts +++ b/src/app/loadout-builder/process/mappers.ts @@ -14,7 +14,7 @@ import { import { armorStats } from 'app/search/d2-known-values'; import { filterMap, mapValues, sumBy } from 'app/utils/collections'; import { compareBy } from 'app/utils/comparators'; -import { getArmor3TuningSocket } from 'app/utils/socket-utils'; +import { getArmor3TuningSocket, getSetBonusModSocket } from 'app/utils/socket-utils'; import { emptyPlugHashes } from 'data/d2/empty-plug-hashes'; import { StatHashes } from 'data/d2/generated-enums'; import { minBy } from 'es-toolkit'; @@ -92,6 +92,7 @@ export function mapDimItemToProcessItems({ remainingEnergyCapacity: capacity - modsCost, compatibleActivityMod: compatibleActivityMod, setBonus: setBonus?.hash, + hasSetBonusModSocket: getSetBonusModSocket(dimItem) ? true : undefined, }; const tuningSocket = getArmor3TuningSocket(dimItem); 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/utils/socket-utils.ts b/src/app/utils/socket-utils.ts index c375b34b49..68be31a392 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, @@ -764,3 +765,25 @@ 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. */ +export function getSetBonusModSocket(item: DimItem) { + return item.sockets?.allSockets.find( + (s) => + 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 38f60908e1..b2db8639fd 100644 --- a/src/locale/en.json +++ b/src/locale/en.json @@ -705,7 +705,6 @@ "Exotic": "Exotic Armor", "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", - "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", @@ -750,6 +749,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", From 796223b6cca880327cfd21dcf5e66554ab122036 Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Thu, 7 May 2026 21:38:52 -0700 Subject: [PATCH 2/6] Show set bonuses in the set bonus picker if they're attainable using wildcards --- .../filter/LoadoutOptimizerSetBonus.tsx | 38 +++++++++++++------ .../loadout-builder/process-worker/process.ts | 12 ++++-- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/src/app/loadout-builder/filter/LoadoutOptimizerSetBonus.tsx b/src/app/loadout-builder/filter/LoadoutOptimizerSetBonus.tsx index 33882eee0b..26c2d3724c 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,6 +8,7 @@ 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'; @@ -115,6 +117,7 @@ export default LoadoutOptimizerSetBonus; * many pieces could be used for each. */ function findSetBonuses( + defs: D2ManifestDefinitions, allItems: DimItem[], vendorItems: DimItem[], classType: DestinyClass, @@ -134,16 +137,27 @@ function findSetBonuses( const wildcardBuckets = new Set( candidateItems.filter(getSetBonusModSocket).map((i) => i.bucket.hash), ); - for (const setHash of Object.keys(counts).map(Number)) { - // Get buckets for which this set bonus has items - const setBuckets = new Set( - setBonusExemplars.filter((i) => i.setBonus!.hash === setHash).map((i) => i.bucket.hash), - ); - // Fill missing buckets with wildcards - for (const b of wildcardBuckets) { - if (!setBuckets.has(b)) { - counts[setHash] = (counts[setHash] ?? 0) + 1; - } + if (wildcardBuckets.size) { + const availableSets = new Set([ + // Sets with owned pieces + ...Object.keys(counts).map(Number), + // Plus any set that wildcards can fill on their own (none in practice so far) + ...Object.values(setBonusModToSet).filter((setHash) => { + const setDef = defs.EquipableItemSet.get(setHash); + return ( + setDef && + wildcardBuckets.size >= Math.min(...setDef.setPerks.map((p) => p.requiredSetCount)) + ); + }), + ]); + for (const setHash of availableSets) { + // Get buckets for which this set bonus has items + const setBuckets = new Set( + setBonusExemplars.filter((i) => i.setBonus!.hash === setHash).map((i) => i.bucket.hash), + ); + // Add one wildcard per missing bucket + const wildcardCount = [...wildcardBuckets].filter((b) => !setBuckets.has(b)).length; + counts[setHash] = (counts[setHash] ?? 0) + wildcardCount; } } @@ -178,8 +192,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/process-worker/process.ts b/src/app/loadout-builder/process-worker/process.ts index 798a92f75a..94b00a3868 100644 --- a/src/app/loadout-builder/process-worker/process.ts +++ b/src/app/loadout-builder/process-worker/process.ts @@ -199,21 +199,25 @@ 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 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 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 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 legStats = statsCache.get(leg)!; innerloop: for (let classItemIdx = 0; classItemIdx < classItems.length; classItemIdx++) { const classItem = classItems[classItemIdx]; @@ -244,10 +248,10 @@ export async function process( // Set bonuses; each slot can use one wildcard if present let wildcardsRemaining = - (helm.hasSetBonusModSocket ? 1 : 0) + - (gaunt.hasSetBonusModSocket ? 1 : 0) + - (chest.hasSetBonusModSocket ? 1 : 0) + - (leg.hasSetBonusModSocket ? 1 : 0) + + helmWildcard + + gauntWildcard + + chestWildcard + + legWildcard + (classItem.hasSetBonusModSocket ? 1 : 0); for (let i = 0; i < setBonusHashes.length; i++) { const setHash = setBonusHashes[i]; From 82b14cec48d1f0aaabdf00062c89d5fb9d442e77 Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Fri, 8 May 2026 03:40:10 -0700 Subject: [PATCH 3/6] Consistent formatting for classItemWildcard --- src/app/loadout-builder/process-worker/process.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/app/loadout-builder/process-worker/process.ts b/src/app/loadout-builder/process-worker/process.ts index 94b00a3868..ae79f82570 100644 --- a/src/app/loadout-builder/process-worker/process.ts +++ b/src/app/loadout-builder/process-worker/process.ts @@ -232,6 +232,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 @@ -248,11 +249,7 @@ export async function process( // Set bonuses; each slot can use one wildcard if present let wildcardsRemaining = - helmWildcard + - gauntWildcard + - chestWildcard + - legWildcard + - (classItem.hasSetBonusModSocket ? 1 : 0); + helmWildcard + gauntWildcard + chestWildcard + legWildcard + classItemWildcard; for (let i = 0; i < setBonusHashes.length; i++) { const setHash = setBonusHashes[i]; const setNeededCount = setBonusCounts[i]; From 4bdf07c918359b956c44a17357474ef6caa8560b Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Fri, 8 May 2026 03:53:33 -0700 Subject: [PATCH 4/6] Rework findSetBonuses wildcard handling Hopefully this is more readable --- .../filter/LoadoutOptimizerSetBonus.tsx | 69 +++++++++---------- 1 file changed, 32 insertions(+), 37 deletions(-) diff --git a/src/app/loadout-builder/filter/LoadoutOptimizerSetBonus.tsx b/src/app/loadout-builder/filter/LoadoutOptimizerSetBonus.tsx index 26c2d3724c..76f5ad5a71 100644 --- a/src/app/loadout-builder/filter/LoadoutOptimizerSetBonus.tsx +++ b/src/app/loadout-builder/filter/LoadoutOptimizerSetBonus.tsx @@ -13,10 +13,10 @@ 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 { filterMap, 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'; @@ -122,46 +122,41 @@ function findSetBonuses( vendorItems: DimItem[], classType: DestinyClass, ): SetBonusCounts { - const candidateItems = [...allItems, ...vendorItems].filter( - (item) => item.classType === classType && isLoadoutBuilderItem(item), - ); - // One item from each bucket with a set bonus - const setBonusExemplars = uniqBy( - candidateItems.filter((item) => item.setBonus), - (item) => `${item.setBonus!.hash}-${item.bucket.hash}`, - ); - // Get the max number of items we could have available for each set bonus - const counts = countBy(setBonusExemplars, (i) => i.setBonus!.hash) as SetBonusCounts; + // For each set bonus, the buckets where contributing piece exists + const bucketsBySet: Record> = {}; + // Buckets where a wildcard item exists + const wildcardBuckets = new Set(); - // Get buckets in which wildcards exist - const wildcardBuckets = new Set( - candidateItems.filter(getSetBonusModSocket).map((i) => i.bucket.hash), - ); + 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) { - const availableSets = new Set([ - // Sets with owned pieces - ...Object.keys(counts).map(Number), - // Plus any set that wildcards can fill on their own (none in practice so far) - ...Object.values(setBonusModToSet).filter((setHash) => { - const setDef = defs.EquipableItemSet.get(setHash); - return ( - setDef && - wildcardBuckets.size >= Math.min(...setDef.setPerks.map((p) => p.requiredSetCount)) - ); - }), - ]); - for (const setHash of availableSets) { - // Get buckets for which this set bonus has items - const setBuckets = new Set( - setBonusExemplars.filter((i) => i.setBonus!.hash === setHash).map((i) => i.bucket.hash), - ); - // Add one wildcard per missing bucket - const wildcardCount = [...wildcardBuckets].filter((b) => !setBuckets.has(b)).length; - counts[setHash] = (counts[setHash] ?? 0) + wildcardCount; + 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 counts; + return mapValues( + bucketsBySet, + (buckets) => buckets.size + [...wildcardBuckets].filter((b) => !buckets.has(b)).length, + ); } export function SetBonusPicker({ From e44ab0cab3f69aab160e334cc7bbd7c4628f89eb Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Mon, 11 May 2026 16:52:31 -0700 Subject: [PATCH 5/6] Add modslot:setbonus search filter --- src/app/loadout-builder/generated-sets/GeneratedSet.tsx | 2 +- src/app/search/items/search-filters/sockets.ts | 9 ++++++++- src/app/utils/socket-utils.ts | 9 ++++++--- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/app/loadout-builder/generated-sets/GeneratedSet.tsx b/src/app/loadout-builder/generated-sets/GeneratedSet.tsx index e2ad2abc80..71cafcc7f6 100644 --- a/src/app/loadout-builder/generated-sets/GeneratedSet.tsx +++ b/src/app/loadout-builder/generated-sets/GeneratedSet.tsx @@ -173,7 +173,7 @@ export default memo(function GeneratedSet({ existingLoadoutName={overlappingLoadout?.name} equippedHashes={equippedHashes} setBonusStatus={setBonusStatus} - setBonusModWarning={set.armor.some(getSetBonusModSocket)} + setBonusModWarning={set.armor.some((i) => getSetBonusModSocket(i))} />
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 fd77a76771..1bf37cb6e3 100644 --- a/src/app/utils/socket-utils.ts +++ b/src/app/utils/socket-utils.ts @@ -774,11 +774,14 @@ 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. */ -export function getSetBonusModSocket(item: DimItem) { +/** + * 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) => - s.visibleInGame && + (ignoreVisibility || s.visibleInGame) && socketContainsPlugWithCategory( s, PlugCategoryHashes.CoreGearSystemsEventGearItemSetsSelectors, From aad3a9d064f1f3c0ecefd0543bb86652b4b560c5 Mon Sep 17 00:00:00 2001 From: FlaminSarge Date: Thu, 14 May 2026 02:55:52 -0700 Subject: [PATCH 6/6] Use non-optional boolean for hasSetBonusModSocket --- src/app/loadout-builder/process/mappers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/loadout-builder/process/mappers.ts b/src/app/loadout-builder/process/mappers.ts index fb1b3dbc95..c22ef8c324 100644 --- a/src/app/loadout-builder/process/mappers.ts +++ b/src/app/loadout-builder/process/mappers.ts @@ -103,7 +103,7 @@ export function mapDimItemToProcessItems({ remainingEnergyCapacity: capacity - modsCost, compatibleActivityMod: compatibleActivityMod, setBonus: setBonus?.hash, - hasSetBonusModSocket: getSetBonusModSocket(dimItem) ? true : undefined, + hasSetBonusModSocket: Boolean(getSetBonusModSocket(dimItem)), intrinsicPerks, };