diff --git a/config/i18n.json b/config/i18n.json index 6f1111ad83..6d43004ac0 100644 --- a/config/i18n.json +++ b/config/i18n.json @@ -205,7 +205,8 @@ "Season": "Shows loadouts by which season of Destiny 2 they were last modified in.", "FashionOnly": "Shows loadouts that contain only fashion (shaders or ornaments).", "ModsOnly": "Shows loadouts that only contain armor mods.", - "Subclass": "Shows loadouts whose subclass name or damage type partially matches the filter text." + "Subclass": "Shows loadouts whose subclass name or damage type partially matches the filter text.", + "LoadoutLight": "Shows loadouts based on their calculated light level. Use the pinnaclecap or softcap keyword instead of a number to refer to the current season's power limits." }, "Filter": { "Adept": "\\(Adept\\)", diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c6642dd01c..eaac2c3be2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,4 +1,5 @@ ## Next +* Add `light:` filter to the loadout search, for searching loadouts that equip all weapon and armor slots. ## 8.23.0 (2024-06-09) diff --git a/src/app/search/loadouts/__snapshots__/loadout-search-filter.test.ts.snap b/src/app/search/loadouts/__snapshots__/loadout-search-filter.test.ts.snap index 00ebc8df6e..418723c20b 100644 --- a/src/app/search/loadouts/__snapshots__/loadout-search-filter.test.ts.snap +++ b/src/app/search/loadouts/__snapshots__/loadout-search-filter.test.ts.snap @@ -13,8 +13,10 @@ exports[`buildSearchConfig generates a reasonable filter map: key-value filters "exactcontains", "exactname", "keyword", + "light", "name", "notes", + "power", "season", "subclass", ] diff --git a/src/app/search/loadouts/search-filters/freeform.ts b/src/app/search/loadouts/search-filters/freeform.ts index 074b68c373..5239fbdfcb 100644 --- a/src/app/search/loadouts/search-filters/freeform.ts +++ b/src/app/search/loadouts/search-filters/freeform.ts @@ -3,11 +3,13 @@ import { tl } from 'app/i18next-t'; import { DimItem } from 'app/inventory/item-types'; import { getHashtagsFromNote } from 'app/inventory/note-hashtags'; import { DimStore } from 'app/inventory/store-types'; -import { findItemForLoadout, getModsFromLoadout } from 'app/loadout-drawer/loadout-utils'; +import { findItemForLoadout, getLight, getModsFromLoadout } from 'app/loadout-drawer/loadout-utils'; import { Loadout } from 'app/loadout/loadout-types'; +import { powerLevelByKeyword } from 'app/search/power-levels'; import { matchText, plainString } from 'app/search/text-utils'; +import { filterMap } from 'app/utils/collections'; import { emptyArray } from 'app/utils/empty'; -import { isClassCompatible } from 'app/utils/item-utils'; +import { isClassCompatible, itemCanBeEquippedByStoreId } from 'app/utils/item-utils'; import { BucketHashes } from 'data/d2/generated-enums'; import _ from 'lodash'; import { FilterDefinition } from '../../filter-types'; @@ -41,6 +43,67 @@ function isLoadoutCompatibleWithStore(loadout: Loadout, store: DimStore | undefi return !store || isClassCompatible(loadout.classType, store.classType); } +type EquippedItemBuckets = Record; + +/** Convenience check for items that contribute to power level */ +function equipsAllItemsForPowerLevel(items: EquippedItemBuckets): Boolean { + return ( + (items[BucketHashes.KineticWeapons]?.length > 0 && + items[BucketHashes.EnergyWeapons]?.length > 0 && + items[BucketHashes.PowerWeapons]?.length > 0 && + items[BucketHashes.Helmet]?.length > 0 && + items[BucketHashes.Gauntlets]?.length > 0 && + items[BucketHashes.ChestArmor]?.length > 0 && + items[BucketHashes.LegArmor]?.length > 0 && + items[BucketHashes.ClassArmor]?.length > 0) ?? + false + ); +} + +/** Convenience function to get all items that contribute to power level */ +function allLoadoutItemsForPowerLevel(items: EquippedItemBuckets): DimItem[] { + return [ + items[BucketHashes.KineticWeapons], + items[BucketHashes.EnergyWeapons], + items[BucketHashes.PowerWeapons], + items[BucketHashes.Helmet], + items[BucketHashes.Gauntlets], + items[BucketHashes.ChestArmor], + items[BucketHashes.LegArmor], + items[BucketHashes.ClassArmor], + ].flat(); +} + +/** + * Simplified version of getItemsFromLoadoutItems that doesn't generate warnItems, and only + * converts equipped armor and weapons that can be equipped. + */ +function getEquippedItemsFromLoadout( + loadout: Loadout, + d2Definitions: D2ManifestDefinitions, + allItems: DimItem[], + store: DimStore, +): EquippedItemBuckets { + // We have two big requirements here: + // 1. items must be weapons or armor + // 2. items must be able to be equipped by the character + // This may not be sufficient, but for the moment it seems good enough + const dimItems = filterMap(loadout.items, (loadoutItem) => { + if (loadoutItem.equip) { + const newItem = findItemForLoadout(d2Definitions, allItems, store.id, loadoutItem); + if ( + newItem && + (newItem.bucket.inWeapons || newItem.bucket.inArmor) && + itemCanBeEquippedByStoreId(newItem, store.id, loadout.classType, true) + ) { + return newItem; + } + } + }); + // Resolve this into an object that tells us what we need to know + return Object.groupBy(dimItems, (item) => item.bucket.hash); +} + const freeformFilters: FilterDefinition< Loadout, LoadoutFilterContext, @@ -204,6 +267,42 @@ const freeformFilters: FilterDefinition< return (loadout) => test(loadout.name) || Boolean(loadout.notes && test(loadout.notes)); }, }, + { + keywords: ['light', 'power'], + /* t('Filter.PowerKeywords') */ + description: tl('LoadoutFilter.LoadoutLight'), + format: 'range', + overload: powerLevelByKeyword, + filter: ({ compare, allItems, d2Definitions, selectedLoadoutsStore }) => { + if (!d2Definitions || !selectedLoadoutsStore || !allItems) { + return () => false; + } + return (loadout: Loadout) => { + if (!isLoadoutCompatibleWithStore(loadout, selectedLoadoutsStore)) { + return false; + } + + // Get the equipped items that contribute to the power level (weapons, armor) + const equippedItems = getEquippedItemsFromLoadout( + loadout, + d2Definitions, + allItems, + selectedLoadoutsStore, + ); + + // Require that the loadout has an item in all weapon + armor slots + if (!equipsAllItemsForPowerLevel(equippedItems)) { + return false; + } + + // Calculate light level of items + const lightLevel = Math.floor( + getLight(selectedLoadoutsStore, allLoadoutItemsForPowerLevel(equippedItems)), + ); + return Boolean(compare!(lightLevel)); + }; + }, + }, ]; export default freeformFilters; diff --git a/src/locale/en.json b/src/locale/en.json index 995cd38a4d..de1318c96a 100644 --- a/src/locale/en.json +++ b/src/locale/en.json @@ -707,6 +707,7 @@ "LoadoutFilter": { "Contains": "Shows loadouts which have an item or a mod matching the filter text. Search for items with spaces in their name using quotes.", "FashionOnly": "Shows loadouts that contain only fashion (shaders or ornaments).", + "LoadoutLight": "Shows loadouts based on their calculated light level. Use the pinnaclecap or softcap keyword instead of a number to refer to the current season's power limits.", "ModsOnly": "Shows loadouts that only contain armor mods.", "Name": "Shows loadouts whose name matches (exactname:) or partially matches (name:) the filter text. Search for entire phrases using quotes.", "Notes": "Search for loadouts by their notes field.",