Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 8 additions & 4 deletions src/app/item-popup/SetBonus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }[];
Expand Down Expand Up @@ -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,
)}
/>
))}
Expand Down
64 changes: 48 additions & 16 deletions src/app/loadout-builder/filter/LoadoutOptimizerSetBonus.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<number, Set<number>> = {};
// Buckets where a wildcard item exists
const wildcardBuckets = new Set<number>();

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({
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/app/loadout-builder/generated-sets/GeneratedSet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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))}
/>
<div className={styles.build}>
<div className={styles.items}>
Expand Down
8 changes: 4 additions & 4 deletions src/app/loadout-builder/generated-sets/SetStats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function TierlessSetStats({
existingLoadoutName,
equippedHashes,
setBonusStatus,
fotlWarning,
setBonusModWarning,
}: {
stats: ArmorStats;
getStatsBreakdown: () => ModStatChanges;
Expand All @@ -46,7 +46,7 @@ export function TierlessSetStats({
existingLoadoutName?: string;
equippedHashes: Set<number>;
setBonusStatus: ActiveSetBonusInfo;
fotlWarning?: boolean;
setBonusModWarning?: boolean;
}) {
const defs = useD2Definitions()!;
const totalStats = sum(Object.values(stats));
Expand Down Expand Up @@ -95,8 +95,8 @@ export function TierlessSetStats({
{maxPower}
</span>
<SetBonusesStatus setBonusStatus={setBonusStatus} />
{fotlWarning && (
<PressTip tooltip={t('LoadoutBuilder.FOTLWildcardWarning')}>
{setBonusModWarning && (
<PressTip tooltip={t('LoadoutBuilder.SetBonusModWarning')}>
<AlertIcon className={styles.statBarWarningIcon} />
</PressTip>
)}
Expand Down
5 changes: 2 additions & 3 deletions src/app/loadout-builder/item-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -216,7 +215,7 @@ export function filterItems({
firstPassFilteredItems = firstPassFilteredItems.filter(
(item) =>
(item.setBonus && includeOnlySetBonusHashes.includes(item.setBonus.hash)) ||
fotlWildcardHashes.has(item.hash),
getSetBonusModSocket(item),
);
}

Expand Down
21 changes: 12 additions & 9 deletions src/app/loadout-builder/process-worker/process.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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++) {
Expand All @@ -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
Expand All @@ -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];
Expand All @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/app/loadout-builder/process-worker/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/app/loadout-builder/process/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -102,6 +103,7 @@ export function mapDimItemToProcessItems({
remainingEnergyCapacity: capacity - modsCost,
compatibleActivityMod: compatibleActivityMod,
setBonus: setBonus?.hash,
hasSetBonusModSocket: Boolean(getSetBonusModSocket(dimItem)),
intrinsicPerks,
};

Expand Down
24 changes: 22 additions & 2 deletions src/app/loadout/known-values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, number> = {
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
};
9 changes: 8 additions & 1 deletion src/app/search/items/search-filters/sockets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { enhancedVersion } from 'app/utils/perk-utils';
import {
countEnhancedPerks,
getIntrinsicArmorPerkSocket,
getSetBonusModSocket,
getSocketsByCategoryHash,
matchesCuratedRoll,
} from 'app/utils/socket-utils';
Expand All @@ -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(
Expand All @@ -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}` : '';
},
Expand Down
Loading