diff --git a/config/i18n.json b/config/i18n.json
index 833e8534d2..2cbe9540d6 100644
--- a/config/i18n.json
+++ b/config/i18n.json
@@ -503,6 +503,8 @@
"Equip": "Equip on {{character}}",
"Exclude": "Excluded Items",
"ExcludeHelp": "Shift + click an item (or drag and drop into this bucket) to build sets without specific gear.",
+ "ExistingBuildStats": "Existing Build Stats",
+ "ExistingBuildStatsNote": "Only showing builds with strictly higher stat tiers.",
"FilterSets": "Filter sets",
"Help": {
"And": "Armor with all of these perks will be used (\"and\")",
@@ -687,6 +689,8 @@
"Error404": "This loadout doesn't exist.",
"Error": "Error getting loadout:"
},
+ "IncludeRuntimeStatBenefits": "Include Font mod stats",
+ "IncludeRuntimeStatBenefitsDesc": "\"Font of ...\" armor mods provide a flat boost to character stats while you have Armor Charges.\n\nWith this setting, DIM considers these mods active and adds their benefits to this Loadout's stats in calculations and optimizations.",
"InGameLoadouts": "In-Game Loadouts",
"ItemLeveling": "Item Leveling",
"LoadoutName": "Loadout name",
@@ -818,6 +822,7 @@
"LoadoutAnalysis": {
"Analyzing": "Analyzing {{numAnalyzed}}/{{numLoadouts}} Loadouts",
"Analyzed": "Analyzed {{numLoadouts}} Loadouts",
+ "BetterStatsAvailableFontNote": "Note: This Loadout uses \"Font of ...\" mods that cause a stat tier to exceed T10. DIM may identify better stats by reducing the amount of excess tiers. If this is undesired, disable \"$t(Loadouts.IncludeRuntimeStatBenefits)\" in the Loadout.",
"MissingItems": {
"Name": "Missing Items",
"Description": "Some of the items in this loadout are no longer in your inventory."
@@ -840,7 +845,7 @@
},
"BetterStatsAvailable": {
"Name": "Better Stats Available",
- "Description": "Choosing different armor or mods for this loadout will allow reaching higher stat tiers."
+ "Description": "Choosing different armor or mods for this loadout will allow reaching higher stat tiers. Choose \"$t(Loadouts.OpenInOptimizer)\" to view better builds."
},
"NotAFullArmorSet": {
"Name": "Not A Full Armor Set",
diff --git a/jest.config.js b/jest.config.js
index d416902064..8720f73b7a 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -27,6 +27,7 @@ export default {
$DIM_VERSION: '1.0.0',
$featureFlags: {
dimApi: true,
+ runLoInBackground: true,
},
},
};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e3205c9952..c303b6e515 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -15,7 +15,7 @@ dependencies:
version: 7.23.2
'@destinyitemmanager/dim-api-types':
specifier: ^1.29.0
- version: 1.29.0
+ version: 1.30.0
'@fortawesome/fontawesome-free':
specifier: ^5.15.4
version: 5.15.4
@@ -1914,8 +1914,8 @@ packages:
postcss-selector-parser: 6.0.13
dev: true
- /@destinyitemmanager/dim-api-types@1.29.0:
- resolution: {integrity: sha512-MRAtLjzXuE+3UrEqu8s01507vBHov97L6fTJUUWIKvTc1JDUg7geAEG+iraP/RgbK7Plpy8jeTwT44z1Jn8GPQ==}
+ /@destinyitemmanager/dim-api-types@1.30.0:
+ resolution: {integrity: sha512-EMDDUHGHqhYRYpdRh1NZDGy0h3p/EyF5hUWPzIR12PwgOJO0ywY7WDKZ9MPiE/DFjTkrZ+tUgSQsorLrWeouvw==}
dev: false
/@discoveryjs/json-ext@0.5.7:
diff --git a/src/app/dim-api/selectors.ts b/src/app/dim-api/selectors.ts
index 415a09b622..378027be8b 100644
--- a/src/app/dim-api/selectors.ts
+++ b/src/app/dim-api/selectors.ts
@@ -1,4 +1,4 @@
-import { defaultLoadoutParameters, DestinyVersion } from '@destinyitemmanager/dim-api-types';
+import { DestinyVersion, defaultLoadoutParameters } from '@destinyitemmanager/dim-api-types';
import { DestinyAccount } from 'app/accounts/destiny-account';
import { currentAccountSelector, destinyVersionSelector } from 'app/accounts/selectors';
import { Settings } from 'app/settings/initial-settings';
diff --git a/src/app/loadout-analyzer/analysis.test.ts b/src/app/loadout-analyzer/analysis.test.ts
new file mode 100644
index 0000000000..f4eb8d52d2
--- /dev/null
+++ b/src/app/loadout-analyzer/analysis.test.ts
@@ -0,0 +1,342 @@
+import { AssumeArmorMasterwork, StatConstraint } from '@destinyitemmanager/dim-api-types';
+import { getBuckets } from 'app/destiny2/d2-buckets';
+import { D2ManifestDefinitions } from 'app/destiny2/d2-definitions';
+import { DimItem } from 'app/inventory/item-types';
+import { DimStore } from 'app/inventory/store-types';
+import { ProcessResult } from 'app/loadout-builder/process-worker/types';
+import { getAutoMods } from 'app/loadout-builder/process/mappers';
+import { runProcess } from 'app/loadout-builder/process/process-wrapper';
+import { ArmorSet, LockableBucketHashes, StatRanges } from 'app/loadout-builder/types';
+import { statTier } from 'app/loadout-builder/utils';
+import { randomSubclassConfiguration } from 'app/loadout-drawer/auto-loadouts';
+import { addItem, setLoadoutParameters } from 'app/loadout-drawer/loadout-drawer-reducer';
+import { Loadout } from 'app/loadout-drawer/loadout-types';
+import {
+ convertToLoadoutItem,
+ newLoadout,
+ newLoadoutFromEquipped,
+} from 'app/loadout-drawer/loadout-utils';
+import { armorStats } from 'app/search/d2-known-values';
+import { BucketHashes, StatHashes } from 'data/d2/generated-enums';
+import { normalToReducedMod } from 'data/d2/reduced-cost-mod-mappings';
+import { produce } from 'immer';
+import _ from 'lodash';
+import {
+ DestinyClass,
+ DestinyProfileResponse,
+} from 'node_modules/bungie-api-ts/destiny2/interfaces';
+import { recoveryModHash } from 'testing/test-item-utils';
+import { getTestDefinitions, getTestProfile, getTestStores } from 'testing/test-utils';
+import { analyzeLoadout } from './analysis';
+import { LoadoutAnalysisContext, LoadoutFinding } from './types';
+
+let defs: D2ManifestDefinitions;
+let allItems: DimItem[];
+let store: DimStore;
+let equippedLoadout: Loadout;
+let context: LoadoutAnalysisContext;
+const voidScavengerModHash = 802695661; // InventoryItem "Void Scavenger"
+
+const analyze = async (
+ loadout: Loadout,
+ worker: typeof noopProcessWorkerMock = noopProcessWorkerMock,
+) => await analyzeLoadout(context, store.id, store.classType, loadout, worker);
+
+function noopProcessWorkerMock(..._args: Parameters): {
+ cleanup: () => void;
+ resultPromise: Promise & { sets: ArmorSet[]; processTime: number }>;
+} {
+ return {
+ cleanup: _.noop,
+ resultPromise: Promise.resolve({
+ combos: 0,
+ processTime: 0,
+ sets: [],
+ processInfo: undefined,
+ statRangesFiltered: Object.fromEntries(
+ armorStats.map((h) => [
+ h,
+ {
+ min: 10,
+ max: 0,
+ },
+ ]),
+ ) as StatRanges,
+ }),
+ };
+}
+
+beforeAll(async () => {
+ let stores: DimStore[];
+ let profileResponse: DestinyProfileResponse;
+ [defs, stores, profileResponse] = await Promise.all([
+ getTestDefinitions(),
+ getTestStores(),
+ getTestProfile(),
+ ]);
+ allItems = stores.flatMap((store) => store.items);
+ store = stores.find((s) => s.classType === DestinyClass.Hunter)!;
+ equippedLoadout = newLoadoutFromEquipped('Test Loadout', store, /* artifactUnlocks */ undefined);
+ const reducedVoidScavengerModHash = normalToReducedMod[voidScavengerModHash];
+ const unlockedPlugs = new Set([reducedVoidScavengerModHash]);
+ context = {
+ allItems,
+ itemCreationContext: {
+ defs,
+ profileResponse,
+ buckets: getBuckets(defs),
+ customStats: [],
+ itemComponents: undefined,
+ },
+ savedLoStatConstraintsByClass: {
+ [DestinyClass.Hunter]: armorStats.map((statHash) => ({ statHash })),
+ },
+ autoModDefs: getAutoMods(defs, unlockedPlugs),
+ unlockedPlugs,
+ };
+});
+
+// One test per finding, each running the analysis twice - once where the finding gets triggered and one where it's not
+describe('basic loadout analysis finding tests', () => {
+ it('finds MissingItems', async () => {
+ const results = await analyze(equippedLoadout);
+ expect(results.findings).not.toContain(LoadoutFinding.MissingItems);
+ const indexThatWillLikelyFailResolution = equippedLoadout.items.findIndex(
+ (i) => !i.socketOverrides && !i.craftedDate,
+ )!;
+ const items = equippedLoadout.items.with(indexThatWillLikelyFailResolution, {
+ ...equippedLoadout.items[indexThatWillLikelyFailResolution],
+ id: '123',
+ });
+ const loadoutWithMissingItem: Loadout = { ...equippedLoadout, items };
+ const resultsWithMissingItem = await analyze(loadoutWithMissingItem);
+ expect(resultsWithMissingItem.findings).toContain(LoadoutFinding.MissingItems);
+ });
+
+ it('finds InvalidMods', async () => {
+ expect(equippedLoadout.parameters!.mods!.length).toBeGreaterThan(0); // please use mods Ben
+ const results = await analyze(equippedLoadout);
+ expect(results.findings).not.toContain(LoadoutFinding.InvalidMods);
+ const loadoutWithDeprecatedMods: Loadout = {
+ ...equippedLoadout,
+ parameters: {
+ ...equippedLoadout.parameters,
+ mods: [...equippedLoadout.parameters!.mods!, 987],
+ },
+ };
+ const resultsWithMissingItem = await analyze(loadoutWithDeprecatedMods);
+ expect(resultsWithMissingItem.findings).toContain(LoadoutFinding.InvalidMods);
+ });
+
+ it('finds EmptyFragmentSlots/TooManyFragments', async () => {
+ const subclass = store.items.find((i) => i.sockets && i.bucket.hash === BucketHashes.Subclass)!;
+ // Abusing this because it should fill the subclass exactly
+ // it'd be neat to write some code for constructing a config that
+ // doesn't exactly rely on running the code under test...
+ let config = randomSubclassConfiguration(defs, subclass)!;
+ const emptyLoadout = newLoadout('Subclass Loadout', [], store.classType);
+ const results = await analyze(addItem(defs, subclass, true, config)(emptyLoadout));
+ expect(results.findings).not.toContain(LoadoutFinding.EmptyFragmentSlots);
+ expect(results.findings).not.toContain(LoadoutFinding.TooManyFragments);
+
+ const maxFragmentIndex = _.max(Object.keys(config).map((idx) => parseInt(idx, 10)))!;
+ const resultsWithTooManyFragments = await analyze(
+ addItem(defs, subclass, true, {
+ ...config,
+ [maxFragmentIndex + 1]: config[maxFragmentIndex],
+ })(emptyLoadout),
+ );
+ expect(resultsWithTooManyFragments.findings).not.toContain(LoadoutFinding.EmptyFragmentSlots);
+ expect(resultsWithTooManyFragments.findings).toContain(LoadoutFinding.TooManyFragments);
+
+ const config2 = { ...config };
+ delete config2[maxFragmentIndex];
+ const resultsWithEmptyFragmentSlots = await analyze(
+ addItem(defs, subclass, true, config2)(emptyLoadout),
+ );
+ expect(resultsWithEmptyFragmentSlots.findings).toContain(LoadoutFinding.EmptyFragmentSlots);
+ expect(resultsWithEmptyFragmentSlots.findings).not.toContain(LoadoutFinding.TooManyFragments);
+ });
+
+ it('finds HasSearchQuery', async () => {
+ const results = await analyze(equippedLoadout);
+ expect(results.findings).not.toContain(LoadoutFinding.LoadoutHasSearchQuery);
+
+ const loadoutWithParameters: Loadout = {
+ ...equippedLoadout,
+ parameters: { ...equippedLoadout.parameters, query: '-is:inloadout' },
+ };
+ const results2 = await analyze(loadoutWithParameters);
+ expect(results2.findings).toContain(LoadoutFinding.LoadoutHasSearchQuery);
+ });
+
+ it('finds UsesSeasonalMods/ModsDontFit', async () => {
+ const items = LockableBucketHashes.map(
+ (hash) =>
+ allItems.find(
+ (i) =>
+ i.classType === store.classType &&
+ i.bucket.hash === hash &&
+ i.energy &&
+ i.tier === 'Legendary',
+ )!,
+ );
+ const loadout = newLoadout(
+ 'UsesSeasonalMods',
+ items.map((item) => convertToLoadoutItem(item, true)),
+ store.classType,
+ );
+
+ const loadoutWithParameters: Loadout = {
+ ...loadout,
+ parameters: {
+ mods: [
+ voidScavengerModHash,
+ voidScavengerModHash,
+ voidScavengerModHash,
+ recoveryModHash,
+ recoveryModHash,
+ recoveryModHash,
+ recoveryModHash,
+ recoveryModHash,
+ ],
+ assumeArmorMasterwork: AssumeArmorMasterwork.All,
+ },
+ };
+
+ // Normal mod costs 3, reduced mod costs 1, but we also have 5 recovery mods for a 4 cost each
+ const results = await analyze(loadoutWithParameters);
+ expect(results.findings).toContain(LoadoutFinding.UsesSeasonalMods);
+ expect(results.findings).not.toContain(LoadoutFinding.ModsDontFit);
+
+ // Now without access to cheap mods
+ const results2 = await analyzeLoadout(
+ { ...context, unlockedPlugs: new Set() },
+ store.id,
+ store.classType,
+ loadoutWithParameters,
+ noopProcessWorkerMock,
+ );
+ expect(results2.findings).not.toContain(LoadoutFinding.UsesSeasonalMods);
+ expect(results2.findings).toContain(LoadoutFinding.ModsDontFit);
+ });
+
+ it('finds DoesNotRespectExotic', async () => {
+ const exotic = allItems.find(
+ (item) => item.bucket.inArmor && item.classType === store.classType && item.isExotic,
+ )!;
+ let loadout = newLoadout(
+ 'exotic loadout',
+ [convertToLoadoutItem(exotic, true)],
+ store.classType,
+ );
+ loadout = setLoadoutParameters({ exoticArmorHash: exotic.hash })(loadout);
+ const result = await analyze(loadout);
+ expect(result.findings).not.toContain(LoadoutFinding.DoesNotRespectExotic);
+
+ loadout.items[0] = { ...loadout.items[0], id: '86774' };
+ const result2 = await analyze(loadout);
+ expect(result2.findings).not.toContain(LoadoutFinding.DoesNotRespectExotic);
+ expect(result2.findings).toContain(LoadoutFinding.MissingItems);
+
+ const differentExotic = allItems.find(
+ (item) =>
+ item.bucket.inArmor &&
+ item.classType === store.classType &&
+ item.isExotic &&
+ item.hash !== exotic.hash,
+ )!;
+ loadout.items[0] = convertToLoadoutItem(differentExotic, true);
+ const result3 = await analyze(loadout);
+ expect(result3.findings).toContain(LoadoutFinding.DoesNotRespectExotic);
+ });
+
+ it('finds DoesNotSatisfyStatConstraints', async () => {
+ const nonMasterworkedArmor = LockableBucketHashes.map(
+ (hash) =>
+ allItems.find(
+ (i) =>
+ i.classType === store.classType &&
+ i.bucket.hash === hash &&
+ i.energy &&
+ (i.bucket.hash !== BucketHashes.ClassArmor || i.energy.energyCapacity >= 5) &&
+ i.tier === 'Legendary' &&
+ !i.masterwork &&
+ i.stats?.every((stat) => stat.statHash !== StatHashes.Recovery || stat.base <= 20),
+ )!,
+ );
+
+ let loadout = newLoadout(
+ 'Non masterworked armor',
+ nonMasterworkedArmor.map((item) => convertToLoadoutItem(item, true)),
+ store.classType,
+ );
+ const baseArmorStatConstraints: StatConstraint[] = armorStats.map((statHash) => ({
+ statHash,
+ minTier: statTier(
+ _.sumBy(
+ nonMasterworkedArmor,
+ (item) => item.stats?.find((s) => s.statHash === statHash)?.base ?? 0,
+ ) + (statHash === StatHashes.Recovery ? 10 : 0),
+ ),
+ }));
+ // The loadout as is hits stats and needs no upgrades to do that
+ loadout = setLoadoutParameters({
+ mods: [recoveryModHash],
+ assumeArmorMasterwork: AssumeArmorMasterwork.None,
+ statConstraints: baseArmorStatConstraints,
+ })(loadout);
+ const result = await analyze(loadout);
+ expect(result.findings).not.toContain(LoadoutFinding.DoesNotSatisfyStatConstraints);
+ expect(result.findings).not.toContain(LoadoutFinding.NeedsArmorUpgrades);
+
+ // Higher tiers, but we're not allowed to upgrade armor
+ const newConstraints = produce(baseArmorStatConstraints, (draft) => {
+ for (const c of draft) {
+ if (c.statHash !== StatHashes.Recovery) {
+ c.minTier = Math.min(10, c.minTier! + 1);
+ } else {
+ // No constraint for recovery
+ c.minTier = 0;
+ }
+ }
+ // Ignore mobility
+ const mobilityIndex = draft.findIndex((stat) => stat.statHash === StatHashes.Mobility);
+ draft.splice(mobilityIndex, 1);
+ });
+ // Also assert that the background auto-optimizer gets called with the correct stat constraints
+ const mockProcess = jest.fn(noopProcessWorkerMock);
+ const result2 = await analyze(
+ setLoadoutParameters({ statConstraints: newConstraints })(loadout),
+ mockProcess,
+ );
+ expect(mockProcess).toHaveBeenCalled();
+ const args = mockProcess.mock.calls[0][0].resolvedStatConstraints;
+ for (const c of args) {
+ expect(c.ignored).toBe(c.statHash === StatHashes.Mobility);
+ if (c.statHash === StatHashes.Recovery) {
+ // The loadout has no constraint for recovery, so it gets the existing loadout stats as the minimum
+ expect(c.minTier).toBe(
+ baseArmorStatConstraints.find((base) => base.statHash === c.statHash)!.minTier,
+ );
+ } else if (c.statHash !== StatHashes.Mobility) {
+ // The loadout does not satisfy stat constraints, but LO gets called with the constraints as minimum
+ expect(c.minTier).toBe(newConstraints.find((n) => n.statHash === c.statHash)!.minTier);
+ }
+ }
+
+ expect(result2.findings).toContain(LoadoutFinding.DoesNotSatisfyStatConstraints);
+ expect(result2.findings).not.toContain(LoadoutFinding.NeedsArmorUpgrades);
+
+ // Now allow upgrading armor - we assume the user wants to upgrade armor as allowed, which
+ // will hit stats (but point out the need for upgrades)
+ const result3 = await analyze(
+ setLoadoutParameters({
+ statConstraints: newConstraints,
+ assumeArmorMasterwork: AssumeArmorMasterwork.All,
+ })(loadout),
+ );
+ expect(result3.findings).not.toContain(LoadoutFinding.DoesNotSatisfyStatConstraints);
+ expect(result3.findings).toContain(LoadoutFinding.NeedsArmorUpgrades);
+ });
+});
diff --git a/src/app/loadout-analyzer/analysis.ts b/src/app/loadout-analyzer/analysis.ts
index b3eaa9a605..90fafb9286 100644
--- a/src/app/loadout-analyzer/analysis.ts
+++ b/src/app/loadout-analyzer/analysis.ts
@@ -1,4 +1,8 @@
-import { AssumeArmorMasterwork } from '@destinyitemmanager/dim-api-types';
+import {
+ AssumeArmorMasterwork,
+ LoadoutParameters,
+ defaultLoadoutParameters,
+} from '@destinyitemmanager/dim-api-types';
import { D2ManifestDefinitions } from 'app/destiny2/d2-definitions';
import { DimItem, PluggableInventoryItemDefinition } from 'app/inventory/item-types';
import { DimCharacterStat } from 'app/inventory/store-types';
@@ -45,13 +49,14 @@ export async function analyzeLoadout(
{
allItems,
autoModDefs,
+ savedLoStatConstraintsByClass,
itemCreationContext,
- savedLoLoadoutParameters: savedLoadoutParameters,
unlockedPlugs,
}: LoadoutAnalysisContext,
storeId: string,
classType: DestinyClass,
loadout: Loadout,
+ worker: typeof runProcess,
): Promise {
const findings = new Set();
const defs = itemCreationContext.defs;
@@ -67,7 +72,14 @@ export async function analyzeLoadout(
const originalLoadoutMods = resolvedLoadout.resolvedMods;
const originalModDefs = originalLoadoutMods.map((mod) => mod.resolvedMod);
- const loadoutParameters = { ...savedLoadoutParameters, ...loadout.parameters };
+ const statOrderForClass = savedLoStatConstraintsByClass[classType];
+ const loadoutParameters: LoadoutParameters = {
+ ...defaultLoadoutParameters,
+ ...(statOrderForClass && { statConstraints: statOrderForClass }),
+ ...loadout.parameters,
+ };
+
+ const includeRuntimeStatBenefits = loadoutParameters.includeRuntimeStatBenefits ?? false;
const subclass = resolvedLoadout.resolvedLoadoutItems.find(
(i) => i.item.bucket.hash === BucketHashes.Subclass,
@@ -105,6 +117,8 @@ export async function analyzeLoadout(
let hasStrictUpgrade = false;
let ineligibleForOptimization = false;
+ let betterStatsAvailableFontNote = false;
+ let existingLoadoutStatsAsStatConstraints: ResolvedStatConstraint[] | undefined;
if (loadoutArmor.length) {
if (loadoutArmor.length < 5) {
findings.add(LoadoutFinding.NotAFullArmorSet);
@@ -175,8 +189,18 @@ export async function analyzeLoadout(
originalModDefs,
armorEnergyRules,
statConstraints,
+ includeRuntimeStatBenefits,
);
const assumedLoadoutStats = statProblems.stats;
+ // If Font mods cause a loadout stats to exceed T10, note this for later
+ if (
+ Object.values(assumedLoadoutStats).some(
+ (stat) =>
+ stat && stat.value >= 110 && stat.breakdown!.some((c) => c.source === 'runtimeEffect'),
+ )
+ ) {
+ betterStatsAvailableFontNote = true;
+ }
needUpgrades ||= statProblems.needsUpgradesForStats;
@@ -230,20 +254,23 @@ export async function analyzeLoadout(
modDefs,
subclass,
classType,
- /* includeRuntimeStatBenefits */ true,
+ includeRuntimeStatBenefits,
);
// Give the event loop a chance after we did a lot of item filtering
await delay(0);
- const strictStatConstraints: ResolvedStatConstraint[] = statConstraints.map((c) => ({
+ existingLoadoutStatsAsStatConstraints = statConstraints.map((c) => ({
...c,
minTier: statTier(assumedLoadoutStats[c.statHash]!.value),
}));
+ const strictStatConstraints: ResolvedStatConstraint[] = statConstraints.map((c) => ({
+ ...c,
+ minTier: Math.max(c.minTier, statTier(assumedLoadoutStats[c.statHash]!.value)),
+ }));
- loadoutParameters.statConstraints = strictStatConstraints;
try {
- const { resultPromise } = runProcess({
+ const { resultPromise } = worker({
anyExotic: loadoutParameters.exoticArmorHash === LOCKED_EXOTIC_ANY_EXOTIC,
armorEnergyRules,
autoModDefs,
@@ -274,12 +301,16 @@ export async function analyzeLoadout(
return {
findings: [...findings],
+ betterStatsAvailableFontNote: hasStrictUpgrade && betterStatsAvailableFontNote,
armorResults: ineligibleForOptimization
? { tag: 'ineligible' }
: {
tag: 'done',
betterStatsAvailable: hasStrictUpgrade ? LoadoutFinding.BetterStatsAvailable : undefined,
loadoutParameters,
+ strictUpgradeStatConstraints: hasStrictUpgrade
+ ? existingLoadoutStatsAsStatConstraints
+ : undefined,
},
};
}
@@ -390,13 +421,22 @@ function getStatProblems(
mods: PluggableInventoryItemDefinition[],
loadoutArmorEnergyRules: ArmorEnergyRules,
resolvedStatConstraints: ResolvedStatConstraint[],
+ includeRuntimeStatBenefits: boolean,
): {
stats: HashLookup;
cantHitStats: boolean;
needsUpgradesForStats: boolean;
} {
const canHitStatsWithRules = (armorEnergyRules: ArmorEnergyRules) => {
- const stats = getLoadoutStats(defs, classType, subclass, loadoutArmor, mods, armorEnergyRules);
+ const stats = getLoadoutStats(
+ defs,
+ classType,
+ subclass,
+ loadoutArmor,
+ mods,
+ includeRuntimeStatBenefits,
+ armorEnergyRules,
+ );
return {
stats,
canHitStats: resolvedStatConstraints.every(
@@ -411,7 +451,7 @@ function getStatProblems(
return {
stats,
- cantHitStats: !canHitStatsWithRules,
+ cantHitStats: !canHitStatsWithUpgrades,
needsUpgradesForStats: canHitStatsWithUpgrades && !canHitStatsAsIs,
};
}
diff --git a/src/app/loadout-analyzer/hooks.tsx b/src/app/loadout-analyzer/hooks.tsx
index 1f1dae9622..f94abcddef 100644
--- a/src/app/loadout-analyzer/hooks.tsx
+++ b/src/app/loadout-analyzer/hooks.tsx
@@ -1,4 +1,4 @@
-import { savedLoadoutParametersSelector } from 'app/dim-api/selectors';
+import { savedLoStatConstraintsByClassSelector } from 'app/dim-api/selectors';
import {
allItemsSelector,
createItemContextSelector,
@@ -40,16 +40,16 @@ const autoOptimizationContextSelector = currySelector(
createSelector(
createItemContextSelector,
unlockedPlugSetItemsSelector.selector,
+ savedLoStatConstraintsByClassSelector,
allItemsSelector,
- savedLoadoutParametersSelector,
autoModSelector,
- (itemCreationContext, unlockedPlugs, allItems, savedLoLoadoutParameters, autoModDefs) =>
+ (itemCreationContext, unlockedPlugs, savedLoStatConstraintsByClass, allItems, autoModDefs) =>
itemCreationContext.defs &&
autoModDefs &&
({
itemCreationContext,
unlockedPlugs,
- savedLoLoadoutParameters,
+ savedLoStatConstraintsByClass,
allItems,
autoModDefs,
} satisfies LoadoutAnalysisContext),
diff --git a/src/app/loadout-analyzer/store.ts b/src/app/loadout-analyzer/store.ts
index b806db6c2e..4f9ea1325d 100644
--- a/src/app/loadout-analyzer/store.ts
+++ b/src/app/loadout-analyzer/store.ts
@@ -1,3 +1,4 @@
+import { runProcess } from 'app/loadout-builder/process/process-wrapper';
import { Loadout } from 'app/loadout-drawer/loadout-types';
import { CancelToken, withCancel } from 'app/utils/cancel';
import { DestinyClass } from 'bungie-api-ts/destiny2';
@@ -342,6 +343,7 @@ async function analysisTask(cancelToken: CancelToken, analyzer: LoadoutBackgroun
task.storeId,
task.classType,
task.loadout,
+ runProcess,
);
analyzer.setAnalysisResult(task.storeId, task.loadout, task.generationNumber, result);
}
diff --git a/src/app/loadout-analyzer/types.ts b/src/app/loadout-analyzer/types.ts
index 57779ee2de..3a0fb85a92 100644
--- a/src/app/loadout-analyzer/types.ts
+++ b/src/app/loadout-analyzer/types.ts
@@ -1,11 +1,13 @@
-import { LoadoutParameters } from '@destinyitemmanager/dim-api-types';
+import { LoadoutParameters, Settings } from '@destinyitemmanager/dim-api-types';
import { DimItem } from 'app/inventory/item-types';
import { ItemCreationContext } from 'app/inventory/store/d2-item-factory';
-import { AutoModDefs } from 'app/loadout-builder/types';
+import { AutoModDefs, ResolvedStatConstraint } from 'app/loadout-builder/types';
/** The analysis results for a single loadout. */
export interface LoadoutAnalysisResult {
findings: LoadoutFinding[];
+ /** A caveat to the "better stats available" note because it might be caused by DIM unintentionally considering font mods active */
+ betterStatsAvailableFontNote: boolean;
/** We took a closer look at the armor in this loadout and determined these results. */
armorResults: ArmorAnalysisResult | undefined;
}
@@ -29,6 +31,8 @@ export type ArmorAnalysisResult =
betterStatsAvailable: LoadoutFinding.BetterStatsAvailable | undefined;
/** If one were to start Loadout Optimizer from here, use these settings. */
loadoutParameters: LoadoutParameters;
+ /** And pass these to the Loadout Builder to show strict upgrades only */
+ strictUpgradeStatConstraints: ResolvedStatConstraint[] | undefined;
};
export const enum LoadoutFinding {
@@ -81,7 +85,7 @@ export const blockAnalysisFindings: LoadoutFinding[] = [
export interface LoadoutAnalysisContext {
unlockedPlugs: Set;
itemCreationContext: ItemCreationContext;
+ savedLoStatConstraintsByClass: Settings['loStatConstraintsByClass'];
allItems: DimItem[];
- savedLoLoadoutParameters: LoadoutParameters;
autoModDefs: AutoModDefs;
}
diff --git a/src/app/loadout-builder/LoadoutBuilder.m.scss b/src/app/loadout-builder/LoadoutBuilder.m.scss
index 5600ecf7bb..d0ccff2c79 100644
--- a/src/app/loadout-builder/LoadoutBuilder.m.scss
+++ b/src/app/loadout-builder/LoadoutBuilder.m.scss
@@ -89,3 +89,32 @@
// Adjust these down a bit
--item-size: 48px;
}
+
+.referenceTiersInfo {
+ background-color: rgba(0, 0, 0, 0.4);
+ padding: 10px;
+ position: relative;
+ max-width: 800px;
+
+ .header {
+ display: flex;
+ flex-flow: row wrap;
+ font-size: 14px;
+ column-gap: 20px;
+ }
+}
+
+.dismissButton {
+ composes: resetButton from '../dim-ui/common.m.scss';
+ padding: 12px 12px 12px 12px;
+ color: rgba(255, 255, 255, 0.5);
+ cursor: pointer;
+ > :global(.app-icon) {
+ height: 24px;
+ width: 24px;
+ font-size: 24px;
+ }
+ position: absolute;
+ right: 0;
+ top: 0;
+}
diff --git a/src/app/loadout-builder/LoadoutBuilder.m.scss.d.ts b/src/app/loadout-builder/LoadoutBuilder.m.scss.d.ts
index 3087456f04..b7d956b124 100644
--- a/src/app/loadout-builder/LoadoutBuilder.m.scss.d.ts
+++ b/src/app/loadout-builder/LoadoutBuilder.m.scss.d.ts
@@ -1,10 +1,13 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
+ 'dismissButton': string;
'guide': string;
+ 'header': string;
'loadoutEditSection': string;
'menuContent': string;
'page': string;
+ 'referenceTiersInfo': string;
'speedReport': string;
'subclassSection': string;
'toolbar': string;
diff --git a/src/app/loadout-builder/LoadoutBuilder.tsx b/src/app/loadout-builder/LoadoutBuilder.tsx
index e3987b8ccd..63f0e9d93d 100644
--- a/src/app/loadout-builder/LoadoutBuilder.tsx
+++ b/src/app/loadout-builder/LoadoutBuilder.tsx
@@ -27,7 +27,14 @@ import { getTotalModStatChanges } from 'app/loadout/stats';
import { useD2Definitions } from 'app/manifest/selectors';
import { searchFilterSelector } from 'app/search/search-filter';
import { useSetSetting, useSetting } from 'app/settings/hooks';
-import { AppIcon, faExclamationTriangle, redoIcon, refreshIcon, undoIcon } from 'app/shell/icons';
+import {
+ AppIcon,
+ disabledIcon,
+ faExclamationTriangle,
+ redoIcon,
+ refreshIcon,
+ undoIcon,
+} from 'app/shell/icons';
import { querySelector, useIsPhonePortrait } from 'app/shell/selectors';
import { emptyObject } from 'app/utils/empty';
import { isClassCompatible, itemCanBeEquippedBy } from 'app/utils/item-utils';
@@ -59,6 +66,7 @@ import StatConstraintEditor from './filter/StatConstraintEditor';
import TierSelect from './filter/TierSelect';
import CompareLoadoutsDrawer from './generated-sets/CompareLoadoutsDrawer';
import GeneratedSets from './generated-sets/GeneratedSets';
+import { ReferenceTiers } from './generated-sets/SetStats';
import { sortGeneratedSets } from './generated-sets/utils';
import { filterItems } from './item-filter';
import { LoadoutBuilderAction, useLbState } from './loadout-builder-reducer';
@@ -68,6 +76,7 @@ import {
ArmorEnergyRules,
LOCKED_EXOTIC_ANY_EXOTIC,
LockableBucketHashes,
+ ResolvedStatConstraint,
loDefaultArmorEnergyRules,
} from './types';
import useEquippedHashes from './useEquippedHashes';
@@ -77,6 +86,7 @@ import useEquippedHashes from './useEquippedHashes';
*/
export default memo(function LoadoutBuilder({
preloadedLoadout,
+ preloadedStrictStatConstraints,
storeId,
}: {
/**
@@ -84,6 +94,7 @@ export default memo(function LoadoutBuilder({
* page.
*/
preloadedLoadout: Loadout | undefined;
+ preloadedStrictStatConstraints: ResolvedStatConstraint[] | undefined;
/**
*A preselected store ID, used when navigating from the Loadouts page.
*/
@@ -104,6 +115,7 @@ export default memo(function LoadoutBuilder({
{
loadout,
resolvedStatConstraints,
+ strictUpgradesStatConstraints,
isEditingExistingLoadout,
pinnedItems,
excludedItems,
@@ -114,7 +126,7 @@ export default memo(function LoadoutBuilder({
canUndo,
},
lbDispatch,
- ] = useLbState(stores, defs, preloadedLoadout, storeId);
+ ] = useLbState(stores, defs, preloadedLoadout, storeId, preloadedStrictStatConstraints);
// For compatibility with LoadoutEdit components
const setLoadout = (updateFn: LoadoutUpdateFunction) =>
lbDispatch({ type: 'setLoadout', updateFn });
@@ -125,6 +137,7 @@ export default memo(function LoadoutBuilder({
const lockedExoticHash = loadoutParameters.exoticArmorHash;
const statConstraints = loadoutParameters.statConstraints!;
const autoStatMods = Boolean(loadoutParameters.autoStatMods);
+ const includeRuntimeStatBenefits = loadoutParameters.includeRuntimeStatBenefits ?? true;
const assumeArmorMasterwork = loadoutParameters.assumeArmorMasterwork;
const classType = loadout.classType;
@@ -244,8 +257,25 @@ export default memo(function LoadoutBuilder({
]);
const modStatChanges = useMemo(
- () => getTotalModStatChanges(defs, modsToAssign, subclass, classType, true),
- [classType, defs, modsToAssign, subclass],
+ () =>
+ getTotalModStatChanges(defs, modsToAssign, subclass, classType, includeRuntimeStatBenefits),
+ [classType, defs, includeRuntimeStatBenefits, modsToAssign, subclass],
+ );
+
+ const effectiveStatConstraints = useMemo(
+ () =>
+ resolvedStatConstraints.map((constraint) => {
+ const strictUpgradeConstraint = strictUpgradesStatConstraints?.find(
+ (c) => c.statHash === constraint.statHash,
+ );
+ return strictUpgradeConstraint && !constraint.ignored
+ ? {
+ ...constraint,
+ minTier: Math.max(constraint.minTier, strictUpgradeConstraint.minTier),
+ }
+ : constraint;
+ }),
+ [resolvedStatConstraints, strictUpgradesStatConstraints],
);
// Run the actual loadout generation process in a web worker
@@ -255,9 +285,10 @@ export default memo(function LoadoutBuilder({
lockedModMap,
modStatChanges,
armorEnergyRules,
- resolvedStatConstraints,
+ resolvedStatConstraints: effectiveStatConstraints,
anyExotic: lockedExoticHash === LOCKED_EXOTIC_ANY_EXOTIC,
autoStatMods,
+ strictUpgrades: Boolean(strictUpgradesStatConstraints),
});
const resultSets = result?.sets;
@@ -462,6 +493,12 @@ export default memo(function LoadoutBuilder({
)}
+ {strictUpgradesStatConstraints && (
+
+ )}
{result && sortedSets?.length ? (
);
}
+
+function ExistingLoadoutStats({
+ lbDispatch,
+ statConstraints,
+}: {
+ lbDispatch: Dispatch;
+ statConstraints: ResolvedStatConstraint[];
+}) {
+ return (
+
+
+ {t('LB.ExistingBuildStats')}
+
+
+ {t('LB.ExistingBuildStatsNote')}
+
lbDispatch({ type: 'dismissComparisonStats' })}
+ aria-label={t('General.Close')}
+ >
+
+
+
+ );
+}
diff --git a/src/app/loadout-builder/LoadoutBuilderContainer.tsx b/src/app/loadout-builder/LoadoutBuilderContainer.tsx
index 04b5528393..44f4f1bb3f 100644
--- a/src/app/loadout-builder/LoadoutBuilderContainer.tsx
+++ b/src/app/loadout-builder/LoadoutBuilderContainer.tsx
@@ -14,6 +14,7 @@ import { createSelector } from 'reselect';
import { DestinyAccount } from '../accounts/destiny-account';
import { allItemsSelector } from '../inventory/selectors';
import LoadoutBuilder from './LoadoutBuilder';
+import { ResolvedStatConstraint } from './types';
const disabledDueToMaintenanceSelector = createSelector(
allItemsSelector,
@@ -38,7 +39,11 @@ export default function LoadoutBuilderContainer({ account }: { account: DestinyA
// Get an entire loadout from state - this is used when optimizing a loadout from within DIM.
const locationState = location.state as
- | { loadout: Loadout | undefined; storeId: string | undefined }
+ | {
+ loadout: Loadout | undefined;
+ storeId: string | undefined;
+ strictUpgradeStatConstraints: ResolvedStatConstraint[] | undefined;
+ }
| undefined;
const preloadedLoadout = locationState?.loadout;
if (preloadedLoadout?.parameters?.query) {
@@ -71,6 +76,7 @@ export default function LoadoutBuilderContainer({ account }: { account: DestinyA
);
diff --git a/src/app/loadout-builder/generated-sets/GeneratedSet.tsx b/src/app/loadout-builder/generated-sets/GeneratedSet.tsx
index 3fe4c0d5e9..f69be0e836 100644
--- a/src/app/loadout-builder/generated-sets/GeneratedSet.tsx
+++ b/src/app/loadout-builder/generated-sets/GeneratedSet.tsx
@@ -26,7 +26,7 @@ import { getPower } from '../utils';
import styles from './GeneratedSet.m.scss';
import GeneratedSetButtons from './GeneratedSetButtons';
import GeneratedSetItem from './GeneratedSetItem';
-import SetStats from './SetStats';
+import { SetStats } from './SetStats';
/**
* A single "stat mix" of builds. Each armor slot contains multiple possibilities,
@@ -226,7 +226,7 @@ function getStatsBreakdown(
autoMods,
/* subclass */ undefined,
classType,
- /* includeRuntimeStatBenefits */ true,
+ /* includeRuntimeStatBenefits */ false, // doesn't matter, auto mods have no runtime stats
);
// We have a bit of a problem where armor mods can come from both
diff --git a/src/app/loadout-builder/generated-sets/SetStats.tsx b/src/app/loadout-builder/generated-sets/SetStats.tsx
index 73f3682aed..b7e2c5eb96 100644
--- a/src/app/loadout-builder/generated-sets/SetStats.tsx
+++ b/src/app/loadout-builder/generated-sets/SetStats.tsx
@@ -6,6 +6,7 @@ import { AppIcon, powerIndicatorIcon } from 'app/shell/icons';
import StatTooltip from 'app/store-stats/StatTooltip';
import { DestinyStatDefinition } from 'bungie-api-ts/destiny2';
import clsx from 'clsx';
+import _ from 'lodash';
import { ArmorStatHashes, ArmorStats, ModStatChanges, ResolvedStatConstraint } from '../types';
import { remEuclid, statTierWithHalf } from '../utils';
import styles from './SetStats.m.scss';
@@ -15,7 +16,7 @@ import { calculateTotalTier, sumEnabledStats } from './utils';
* Displays the overall tier and per-stat tier of a generated loadout set.
*/
// TODO: would be a lot easier if this was just passed a Loadout or FullyResolvedLoadout...
-export default function SetStats({
+export function SetStats({
stats,
getStatsBreakdown,
maxPower,
@@ -44,18 +45,7 @@ export default function SetStats({
return (
-
- {t('LoadoutBuilder.TierNumber', {
- tier: enabledTier,
- })}
-
- {enabledTier !== totalTier && (
-
- {` (${t('LoadoutBuilder.TierNumber', {
- tier: totalTier,
- })})`}
-
- )}
+
{resolvedStatConstraints.map((c) => {
const statHash = c.statHash as ArmorStatHashes;
@@ -132,3 +122,58 @@ function Stat({
);
}
+
+function TotalTier({ totalTier, enabledTier }: { totalTier: number; enabledTier: number }) {
+ return (
+ <>
+
+ {t('LoadoutBuilder.TierNumber', {
+ tier: enabledTier,
+ })}
+
+ {enabledTier !== totalTier && (
+
+ {` (${t('LoadoutBuilder.TierNumber', {
+ tier: totalTier,
+ })})`}
+
+ )}
+ >
+ );
+}
+
+export function ReferenceTiers({
+ resolvedStatConstraints,
+}: {
+ resolvedStatConstraints: ResolvedStatConstraint[];
+}) {
+ const defs = useD2Definitions()!;
+ const totalTier = _.sumBy(resolvedStatConstraints, (c) => c.minTier);
+ const enabledTier = _.sumBy(resolvedStatConstraints, (c) => (c.ignored ? 0 : c.minTier));
+
+ return (
+
+
+ {resolvedStatConstraints.map((c) => {
+ const statHash = c.statHash as ArmorStatHashes;
+ const statDef = defs.Stat.get(statHash);
+ const tier = c.minTier;
+ return (
+
+
+
+ {t('LoadoutBuilder.TierNumber', {
+ tier,
+ })}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/app/loadout-builder/loadout-builder-reducer.ts b/src/app/loadout-builder/loadout-builder-reducer.ts
index e04b2b3dec..156aa5082f 100644
--- a/src/app/loadout-builder/loadout-builder-reducer.ts
+++ b/src/app/loadout-builder/loadout-builder-reducer.ts
@@ -1,13 +1,13 @@
import {
AssumeArmorMasterwork,
- defaultLoadoutParameters,
LoadoutParameters,
StatConstraint,
+ defaultLoadoutParameters,
} from '@destinyitemmanager/dim-api-types';
import { D2ManifestDefinitions } from 'app/destiny2/d2-definitions';
import {
- savedLoadoutParametersSelector,
savedLoStatConstraintsByClassSelector,
+ savedLoadoutParametersSelector,
} from 'app/dim-api/selectors';
import { t } from 'app/i18next-t';
import { DimItem, PluggableInventoryItemDefinition } from 'app/inventory/item-types';
@@ -16,8 +16,8 @@ import { DimStore } from 'app/inventory/store-types';
import { isPluggableItem } from 'app/inventory/store/sockets';
import { getCurrentStore } from 'app/inventory/stores-helpers';
import {
- clearSubclass,
LoadoutUpdateFunction,
+ clearSubclass,
removeMod,
setLoadoutParameters,
updateMods,
@@ -79,6 +79,12 @@ interface LoadoutBuilderConfiguration {
*/
isEditingExistingLoadout: boolean;
+ /**
+ * If we are editing an existing loadout via the "better stats available"
+ * feature, this contains the stats we actually need to exceed.
+ */
+ strictUpgradesStatConstraints: ResolvedStatConstraint[] | undefined;
+
/**
* A copy of `loadout.parameters.statConstraints`, but with ignored stats
* included. This is more convenient to use than the raw `statConstraints` but
@@ -124,6 +130,7 @@ const lbConfigInit = ({
storeId,
savedLoadoutBuilderParameters,
savedStatConstraintsPerClass,
+ strictUpgradesStatConstraints,
}: {
stores: DimStore[];
allItems: DimItem[];
@@ -137,6 +144,7 @@ const lbConfigInit = ({
storeId: string | undefined;
savedLoadoutBuilderParameters: LoadoutParameters;
savedStatConstraintsPerClass: { [classType: number]: StatConstraint[] };
+ strictUpgradesStatConstraints: ResolvedStatConstraint[] | undefined;
}): LoadoutBuilderConfiguration => {
// Preloaded loadouts from the "Optimize Armor" button take priority
const classTypeFromPreloadedLoadout = preloadedLoadout?.classType ?? DestinyClass.Unknown;
@@ -218,6 +226,7 @@ const lbConfigInit = ({
loadout,
isEditingExistingLoadout,
resolvedStatConstraints: resolveStatConstraints(loadoutParameters.statConstraints!),
+ strictUpgradesStatConstraints,
pinnedItems,
excludedItems: emptyObject(),
selectedStoreId,
@@ -265,6 +274,7 @@ type LoadoutBuilderConfigAction =
| { type: 'addGeneralMods'; mods: PluggableInventoryItemDefinition[] }
| { type: 'lockExotic'; lockedExoticHash: number }
| { type: 'removeLockedExotic' }
+ | { type: 'dismissComparisonStats' }
| { type: 'setSearchQuery'; query: string };
type LoadoutBuilderUIAction =
@@ -475,6 +485,8 @@ function lbConfigReducer(defs: D2ManifestDefinitions) {
return updateLoadout(state, setLoadoutParameters({ exoticArmorHash: undefined }));
case 'autoStatModsChanged':
return updateLoadout(state, setLoadoutParameters({ autoStatMods: action.autoStatMods }));
+ case 'dismissComparisonStats':
+ return { ...state, strictUpgradesStatConstraints: undefined };
case 'setSearchQuery':
return updateLoadout(state, setLoadoutParameters({ query: action.query || undefined }));
}
@@ -506,6 +518,7 @@ export function useLbState(
defs: D2ManifestDefinitions,
preloadedLoadout: Loadout | undefined,
storeId: string | undefined,
+ strictUpgradesStatConstraints: ResolvedStatConstraint[] | undefined,
) {
const savedLoadoutBuilderParameters = useSelector(savedLoadoutParametersSelector);
const savedStatConstraintsPerClass = useSelector(savedLoStatConstraintsByClassSelector);
@@ -527,6 +540,7 @@ export function useLbState(
storeId,
savedLoadoutBuilderParameters,
savedStatConstraintsPerClass,
+ strictUpgradesStatConstraints,
}),
);
diff --git a/src/app/loadout-builder/process-worker/process.ts b/src/app/loadout-builder/process-worker/process.ts
index 66d663206a..2d6e8156f8 100644
--- a/src/app/loadout-builder/process-worker/process.ts
+++ b/src/app/loadout-builder/process-worker/process.ts
@@ -429,8 +429,8 @@ export function process(
const value = stats[i] + bonusStats[i];
fullStats[statHash] = value;
- if (strictUpgrades && !hasStrictUpgrade) {
- const statFilter = resolvedStatConstraints[i];
+ const statFilter = resolvedStatConstraints[i];
+ if (!statFilter.ignored && strictUpgrades && !hasStrictUpgrade) {
const tier = Math.min(Math.max(Math.floor(value / 10), 0), 10);
hasStrictUpgrade ||= tier > statFilter.minTier;
}
diff --git a/src/app/loadout-builder/process/useProcess.ts b/src/app/loadout-builder/process/useProcess.ts
index 4a0e071917..18b67d0598 100644
--- a/src/app/loadout-builder/process/useProcess.ts
+++ b/src/app/loadout-builder/process/useProcess.ts
@@ -53,6 +53,7 @@ export function useProcess({
resolvedStatConstraints,
anyExotic,
autoStatMods,
+ strictUpgrades,
}: {
selectedStore: DimStore;
filteredItems: ItemsByBucket;
@@ -62,6 +63,7 @@ export function useProcess({
resolvedStatConstraints: ResolvedStatConstraint[];
anyExotic: boolean;
autoStatMods: boolean;
+ strictUpgrades: boolean;
}) {
const [{ result, processing }, setState] = useState
({
processing: false,
@@ -101,7 +103,7 @@ export function useProcess({
autoStatMods,
getUserItemTag,
stopOnFirstSet: false,
- strictUpgrades: false,
+ strictUpgrades,
});
cleanupRef.current = cleanup;
@@ -145,6 +147,7 @@ export function useProcess({
getUserItemTag,
modStatChanges,
autoModDefs,
+ strictUpgrades,
]);
return { result, processing };
diff --git a/src/app/loadout-drawer/loadout-drawer-reducer.ts b/src/app/loadout-drawer/loadout-drawer-reducer.ts
index bb4a6a56b7..9bbc01988c 100644
--- a/src/app/loadout-drawer/loadout-drawer-reducer.ts
+++ b/src/app/loadout-drawer/loadout-drawer-reducer.ts
@@ -602,6 +602,12 @@ export function changeClearMods(enabled: boolean): LoadoutUpdateFunction {
});
}
+export function changeIncludeRuntimeStats(enabled: boolean): LoadoutUpdateFunction {
+ return setLoadoutParameters({
+ includeRuntimeStatBenefits: enabled,
+ });
+}
+
export function updateMods(mods: number[]): LoadoutUpdateFunction {
return setLoadoutParameters({
mods: mods.map(mapToNonReducedModCostVariant),
diff --git a/src/app/loadout-drawer/loadout-utils.ts b/src/app/loadout-drawer/loadout-utils.ts
index f65c514e81..cb3f7b47e8 100644
--- a/src/app/loadout-drawer/loadout-utils.ts
+++ b/src/app/loadout-drawer/loadout-utils.ts
@@ -284,6 +284,7 @@ export function getLoadoutStats(
subclass: ResolvedLoadoutItem | undefined,
armor: DimItem[],
mods: PluggableInventoryItemDefinition[],
+ includeRuntimeStatBenefits: boolean,
/** Assume armor is masterworked according to these rules when calculating stats */
armorEnergyRules?: ArmorEnergyRules,
) {
@@ -308,7 +309,7 @@ export function getLoadoutStats(
for (const hash of armorStats) {
armorPiecesStats[hash] += itemStats[hash]?.[0].base ?? 0;
armorPiecesStats[hash] +=
- itemEnergy === MAX_ARMOR_ENERGY_CAPACITY
+ itemEnergy === MAX_ARMOR_ENERGY_CAPACITY && item.energy
? MASTERWORK_ARMOR_STAT_BONUS
: energySocket?.plugged?.stats?.[hash] ?? 0;
}
@@ -331,7 +332,7 @@ export function getLoadoutStats(
mods,
subclass,
classType,
- /* includeRuntimeStatBenefits */ true,
+ includeRuntimeStatBenefits,
);
for (const [statHash, value] of Object.entries(modStats)) {
diff --git a/src/app/loadout/LoadoutView.tsx b/src/app/loadout/LoadoutView.tsx
index 5a748866f5..892142e797 100644
--- a/src/app/loadout/LoadoutView.tsx
+++ b/src/app/loadout/LoadoutView.tsx
@@ -8,6 +8,7 @@ import { DimStore } from 'app/inventory/store-types';
import { ItemCreationContext } from 'app/inventory/store/d2-item-factory';
import { findingDisplays } from 'app/loadout-analyzer/finding-display';
import { useAnalyzeLoadout } from 'app/loadout-analyzer/hooks';
+import { LoadoutFinding } from 'app/loadout-analyzer/types';
import { getItemsFromLoadoutItems } from 'app/loadout-drawer/loadout-item-conversion';
import { Loadout, LoadoutItem, ResolvedLoadoutItem } from 'app/loadout-drawer/loadout-types';
import { getLight } from 'app/loadout-drawer/loadout-utils';
@@ -133,12 +134,15 @@ export default function LoadoutView({
if (!display.icon) {
return undefined;
}
+ let description = t(display.description);
+ if (
+ finding === LoadoutFinding.BetterStatsAvailable &&
+ analysis!.result.betterStatsAvailableFontNote
+ ) {
+ description += `\n\n${t('LoadoutAnalysis.BetterStatsAvailableFontNote')}`;
+ }
return (
-
+
{t(display.name)}
);
@@ -163,7 +167,7 @@ export default function LoadoutView({
key={category}
category={category}
subclass={subclass}
- storeId={store.id}
+ store={store}
items={categories[category]}
allMods={modDefinitions}
modsByBucket={modsByBucket}
diff --git a/src/app/loadout/loadout-edit/LoadoutEdit.tsx b/src/app/loadout/loadout-edit/LoadoutEdit.tsx
index b21400b664..fe741877ea 100644
--- a/src/app/loadout/loadout-edit/LoadoutEdit.tsx
+++ b/src/app/loadout/loadout-edit/LoadoutEdit.tsx
@@ -18,6 +18,7 @@ import {
addItem,
applySocketOverrides,
changeClearMods,
+ changeIncludeRuntimeStats,
clearArtifactUnlocks,
clearBucketCategory,
clearLoadoutOptimizerParameters,
@@ -63,6 +64,7 @@ import SubclassPlugDrawer from '../SubclassPlugDrawer';
import { pickSubclass } from '../item-utils';
import { hasVisibleLoadoutParameters } from '../loadout-ui/LoadoutParametersDisplay';
import { useLoadoutMods } from '../mod-assignment-drawer/selectors';
+import { includesRuntimeStatMods } from '../stats';
import styles from './LoadoutEdit.m.scss';
import LoadoutEditBucket, { ArmorExtras } from './LoadoutEditBucket';
import LoadoutEditSection from './LoadoutEditSection';
@@ -429,12 +431,14 @@ export function LoadoutEditModsSection({
const { useUpdater, useDefsStoreUpdater } = useLoadoutUpdaters(store, setLoadout);
const clearUnsetMods = loadout.parameters?.clearMods;
+ const includeRuntimeStats = loadout.parameters?.includeRuntimeStatBenefits ?? true;
const handleUpdateMods = useUpdater(updateMods);
const handleRemoveMod = useUpdater(removeMod);
const handleClearUnsetModsChanged = useUpdater(changeClearMods);
const handleRandomizeMods = useDefsStoreUpdater(randomizeLoadoutMods);
const handleClearMods = useUpdater(clearMods);
+ const handleIncludeRuntimeStats = useUpdater(changeIncludeRuntimeStats);
const handleSyncModsFromEquipped = () => setLoadout(syncModsFromEquipped(store));
return (
@@ -458,6 +462,12 @@ export function LoadoutEditModsSection({
hideShowModPlacements={!showModPlacementsButton}
autoStatMods={autoStatMods}
onAutoStatModsChanged={onAutoStatModsChanged}
+ includeRuntimeStats={includeRuntimeStats}
+ onIncludeRuntimeStatsChanged={
+ loadout.parameters?.mods && includesRuntimeStatMods(loadout.parameters.mods)
+ ? handleIncludeRuntimeStats
+ : undefined
+ }
/>
);
diff --git a/src/app/loadout/loadout-ui/LoadoutItemCategorySection.tsx b/src/app/loadout/loadout-ui/LoadoutItemCategorySection.tsx
index d9485ace73..1a7ebfacff 100644
--- a/src/app/loadout/loadout-ui/LoadoutItemCategorySection.tsx
+++ b/src/app/loadout/loadout-ui/LoadoutItemCategorySection.tsx
@@ -5,14 +5,22 @@ import ItemPopupTrigger from 'app/inventory/ItemPopupTrigger';
import { D2BucketCategory } from 'app/inventory/inventory-buckets';
import { PluggableInventoryItemDefinition } from 'app/inventory/item-types';
import { bucketsSelector } from 'app/inventory/selectors';
-import { LockableBucketHashes } from 'app/loadout-builder/types';
+import { DimStore } from 'app/inventory/store-types';
+import { useAnalyzeLoadout } from 'app/loadout-analyzer/hooks';
+import { LockableBucketHashes, ResolvedStatConstraint } from 'app/loadout-builder/types';
+import {
+ clearBucketCategory,
+ setLoadoutParameters,
+} from 'app/loadout-drawer/loadout-drawer-reducer';
import { Loadout, ResolvedLoadoutItem } from 'app/loadout-drawer/loadout-types';
+import { useD2Definitions } from 'app/manifest/selectors';
import { useIsPhonePortrait } from 'app/shell/selectors';
import { LoadoutCharacterStats } from 'app/store-stats/CharacterStats';
import { emptyArray } from 'app/utils/empty';
import { LookupTable } from 'app/utils/util-types';
import clsx from 'clsx';
import _ from 'lodash';
+import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { BucketPlaceholder } from './BucketPlaceholder';
import { FashionMods } from './FashionMods';
@@ -29,7 +37,7 @@ const categoryStyles: LookupTable = {
export default function LoadoutItemCategorySection({
category,
subclass,
- storeId,
+ store,
items,
allMods,
modsByBucket,
@@ -38,7 +46,7 @@ export default function LoadoutItemCategorySection({
}: {
category: D2BucketCategory;
subclass?: ResolvedLoadoutItem;
- storeId: string;
+ store: DimStore;
items?: ResolvedLoadoutItem[];
allMods: PluggableInventoryItemDefinition[];
modsByBucket: {
@@ -47,7 +55,9 @@ export default function LoadoutItemCategorySection({
loadout: Loadout;
hideOptimizeArmor?: boolean;
}) {
+ const defs = useD2Definitions()!;
const buckets = useSelector(bucketsSelector)!;
+ const analysis = useAnalyzeLoadout(loadout, store, /* active */ !hideOptimizeArmor);
const itemsByBucket = Map.groupBy(items ?? [], (li) => li.item.bucket.hash);
const isPhonePortrait = useIsPhonePortrait();
const bucketOrder =
@@ -63,6 +73,24 @@ export default function LoadoutItemCategorySection({
const isArmor = category === 'Armor';
const hasFashion = isArmor && !_.isEmpty(modsByBucket);
+ const [optimizeLoadout, constraints]: [Loadout, ResolvedStatConstraint[] | undefined] =
+ useMemo(() => {
+ if (
+ analysis?.result.armorResults?.tag === 'done' &&
+ analysis.result.armorResults.betterStatsAvailable
+ ) {
+ return [
+ clearBucketCategory(
+ defs,
+ 'Armor',
+ )(setLoadoutParameters(analysis.result.armorResults.loadoutParameters)(loadout)),
+ analysis.result.armorResults.strictUpgradeStatConstraints,
+ ];
+ } else {
+ return [loadout, undefined];
+ }
+ }, [defs, analysis?.result.armorResults, loadout]);
+
if (isPhonePortrait && !items && !hasFashion) {
return null;
}
@@ -74,7 +102,7 @@ export default function LoadoutItemCategorySection({
{bucketOrder.map((bucket) => (
}
{!hideOptimizeArmor && (
)}
>
diff --git a/src/app/loadout/loadout-ui/LoadoutMods.tsx b/src/app/loadout/loadout-ui/LoadoutMods.tsx
index 0cfaba03bd..074bfb5b99 100644
--- a/src/app/loadout/loadout-ui/LoadoutMods.tsx
+++ b/src/app/loadout/loadout-ui/LoadoutMods.tsx
@@ -1,5 +1,6 @@
import { LoadoutParameters } from '@destinyitemmanager/dim-api-types';
import CheckButton from 'app/dim-ui/CheckButton';
+import { PressTip } from 'app/dim-ui/PressTip';
import { t } from 'app/i18next-t';
import { artifactUnlocksSelector, unlockedPlugSetItemsSelector } from 'app/inventory/selectors';
import { hashesToPluggableItems } from 'app/inventory/store/sockets';
@@ -65,10 +66,12 @@ export const LoadoutMods = memo(function LoadoutMods({
missingSockets,
hideShowModPlacements,
autoStatMods,
+ includeRuntimeStats,
onUpdateMods,
onRemoveMod,
onClearUnsetModsChanged,
onAutoStatModsChanged,
+ onIncludeRuntimeStatsChanged,
}: {
loadout: Loadout;
allMods: ResolvedLoadoutMod[];
@@ -78,11 +81,13 @@ export const LoadoutMods = memo(function LoadoutMods({
missingSockets?: boolean;
/** Are stat mods being chosen automatically by LO? */
autoStatMods?: boolean;
+ includeRuntimeStats?: boolean;
/** If present, show an "Add Mod" button */
onUpdateMods?: (newMods: number[]) => void;
onRemoveMod?: (mod: ResolvedLoadoutMod) => void;
onClearUnsetModsChanged?: (checked: boolean) => void;
onAutoStatModsChanged?: (checked: boolean) => void;
+ onIncludeRuntimeStatsChanged?: (checked: boolean) => void;
}) {
const isPhonePortrait = useIsPhonePortrait();
const getModRenderKey = createGetModRenderKey();
@@ -145,7 +150,10 @@ export const LoadoutMods = memo(function LoadoutMods({
)}
- {(!hideShowModPlacements || onClearUnsetModsChanged || onAutoStatModsChanged) &&
+ {(!hideShowModPlacements ||
+ onClearUnsetModsChanged ||
+ onAutoStatModsChanged ||
+ onIncludeRuntimeStatsChanged) &&
(allMods.length > 0 || onUpdateMods) && (
{!hideShowModPlacements && (
@@ -165,6 +173,18 @@ export const LoadoutMods = memo(function LoadoutMods({
)}
+ {onIncludeRuntimeStatsChanged && (
+
+
+ {t('Loadouts.IncludeRuntimeStatBenefits')}
+
+
+ )}
+
{$featureFlags.loAutoStatMods && onAutoStatModsChanged && (
s.maxTier !== undefined || s.minTier !== undefined))
+ params.statConstraints?.some((s) => s.maxTier !== undefined || s.minTier !== undefined) ||
+ (params.mods &&
+ includesRuntimeStatMods(params.mods) &&
+ (params.includeRuntimeStatBenefits ?? true)))
);
}
@@ -47,6 +51,13 @@ export default function LoadoutParametersDisplay({ params }: { params: LoadoutPa
)}
+ {params.mods &&
+ includesRuntimeStatMods(params.mods) &&
+ (params.includeRuntimeStatBenefits ?? true) && (
+
+ {t('Loadouts.IncludeRuntimeStatBenefits')}
+
+ )}
{statConstraints && (
{' '}
{missingArmor ? t('Loadouts.PickArmor') : t('Loadouts.OpenInOptimizer')}
diff --git a/src/app/loadout/stats.ts b/src/app/loadout/stats.ts
index 76834d6cd2..a105fa3173 100644
--- a/src/app/loadout/stats.ts
+++ b/src/app/loadout/stats.ts
@@ -85,6 +85,13 @@ function getFontMods(mods: PluggableInventoryItemDefinition[]) {
}));
}
+/**
+ * Does this list of mods have mods that dynamically grant stats, such as Font mods?
+ */
+export function includesRuntimeStatMods(modHashes: number[]) {
+ return modHashes.some((mod) => fontModHashToStatHash()[mod] !== undefined);
+}
+
/**
* This sums up the total stat contributions across mods passed in. These are then applied
* to the loadouts after all the items' base stat values have been summed. This mimics how mods
diff --git a/src/app/store-stats/CharacterStats.tsx b/src/app/store-stats/CharacterStats.tsx
index 2f273d5b72..87be321fa2 100644
--- a/src/app/store-stats/CharacterStats.tsx
+++ b/src/app/store-stats/CharacterStats.tsx
@@ -234,7 +234,14 @@ export function LoadoutCharacterStats({
}
}
- const stats = getLoadoutStats(defs, loadout.classType, subclass, equippedItems, allMods);
+ const stats = getLoadoutStats(
+ defs,
+ loadout.classType,
+ subclass,
+ equippedItems,
+ allMods,
+ loadout.parameters?.includeRuntimeStatBenefits ?? true,
+ );
return ;
}
diff --git a/src/locale/en.json b/src/locale/en.json
index 3ef9f764b4..eba78d0357 100644
--- a/src/locale/en.json
+++ b/src/locale/en.json
@@ -496,6 +496,8 @@
"Equip": "Equip on {{character}}",
"Exclude": "Excluded Items",
"ExcludeHelp": "Shift + click an item (or drag and drop into this bucket) to build sets without specific gear.",
+ "ExistingBuildStats": "Existing Build Stats",
+ "ExistingBuildStatsNote": "Only showing builds with strictly higher stat tiers.",
"FilterSets": "Filter sets",
"Help": {
"And": "Armor with all of these perks will be used (\"and\")",
@@ -561,9 +563,10 @@
"Analyzed": "Analyzed {{numLoadouts}} Loadouts",
"Analyzing": "Analyzing {{numAnalyzed}}/{{numLoadouts}} Loadouts",
"BetterStatsAvailable": {
- "Description": "Choosing different armor or mods for this loadout will allow reaching higher stat tiers.",
+ "Description": "Choosing different armor or mods for this loadout will allow reaching higher stat tiers. Choose \"$t(Loadouts.OpenInOptimizer)\" to view better builds.",
"Name": "Better Stats Available"
},
+ "BetterStatsAvailableFontNote": "Note: This Loadout uses \"Font of ...\" mods that cause a stat tier to exceed T10. DIM may identify better stats by reducing the amount of excess tiers. If this is undesired, disable \"$t(Loadouts.IncludeRuntimeStatBenefits)\" in the Loadout.",
"DoesNotRespectExotic": {
"Description": "This loadout's Loadout Optimizer settings specify an exotic choice, but the loadout does not match that exotic.",
"Name": "Wrong Exotic"
@@ -741,6 +744,8 @@
},
"ImportLoadout": "Import Loadout",
"InGameLoadouts": "In-Game Loadouts",
+ "IncludeRuntimeStatBenefits": "Include Font mod stats",
+ "IncludeRuntimeStatBenefitsDesc": "\"Font of ...\" armor mods provide a flat boost to character stats while you have Armor Charges.\n\nWith this setting, DIM considers these mods active and adds their benefits to this Loadout's stats in calculations and optimizations.",
"ItemErrorSummary": "1 item error:",
"ItemErrorSummary_plural": "{{count}} item errors:",
"ItemLeveling": "Item Leveling",