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.",