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')} + +
+ ); +} 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",