Repository: DestinyItemManager/DIM
Pull request #5510 (2020-07-26 08:19:36)

Feature/mod picker in process
Got the mod picker into process. It seems nice and quick which is good. The only thing left to do is handle class items properly and I think it is ready for beta.

Edit: I ended up adding a link to https://destinyitemmanager.fandom.com/wiki/Loadout_Optimizer#A_Warning_on_Mods_and_Stats. There was too much info to try and convey in that little message up top so I thought that might be better. It also might be worth someone having a read over it to make sure I haven't rambled too much.

![image](https://user-images.githubusercontent.com/7344652/88476066-c10b7480-cf78-11ea-8d80-a74d9380fbd2.png)


ryan-rushton: Yes it should only show things that are locked with this. I think its more useful as you can see where the app is assigning the mods to in your build.

Patch:
diff --git a/src/app/loadout-builder/LoadoutBuilder.tsx b/src/app/loadout-builder/LoadoutBuilder.tsx
index 193f000d7..ef579c9ba 100644
--- a/src/app/loadout-builder/LoadoutBuilder.tsx
+++ b/src/app/loadout-builder/LoadoutBuilder.tsx
@@ -8,7 +8,7 @@ import CharacterSelect from '../dim-ui/CharacterSelect';
 import { DimStore, D2Store } from '../inventory/store-types';
 import { RootState } from 'app/store/reducers';
 import GeneratedSets from './generated-sets/GeneratedSets';
-import { filterGeneratedSets } from './generated-sets/utils';
+import { sortGeneratedSets } from './generated-sets/utils';
 import { isLoadoutBuilderItem } from './utils';
 import { filterItems } from './preProcessFilter';
 import { StatTypes, ItemsByBucket, statKeys, statHashToType } from './types';
@@ -172,8 +172,8 @@ function LoadoutBuilder({
   const combosWithoutCaps = result?.combosWithoutCaps || 0;
 
   const filteredSets = useMemo(
-    () => filterGeneratedSets(lockedMap, lockedArmor2Mods, statOrder, enabledStats, result?.sets),
-    [lockedMap, lockedArmor2Mods, statOrder, enabledStats, result?.sets]
+    () => sortGeneratedSets(lockedMap, statOrder, enabledStats, result?.sets),
+    [lockedMap, statOrder, enabledStats, result?.sets]
   );
 
   const loadingNodeRef = useRef<HTMLDivElement>(null);
diff --git a/src/app/loadout-builder/generated-sets/GeneratedSet.tsx b/src/app/loadout-builder/generated-sets/GeneratedSet.tsx
index 74cae5ec9..fa35d2210 100644
--- a/src/app/loadout-builder/generated-sets/GeneratedSet.tsx
+++ b/src/app/loadout-builder/generated-sets/GeneratedSet.tsx
@@ -60,13 +60,7 @@ function GeneratedSet({
   return (
     <div className={styles.build} style={style} ref={forwardedRef}>
       <div className={styles.header}>
-        <SetStats
-          defs={defs}
-          set={set}
-          statOrder={statOrder}
-          enabledStats={enabledStats}
-          lockedArmor2Mods={lockedArmor2Mods}
-        />
+        <SetStats defs={defs} set={set} statOrder={statOrder} enabledStats={enabledStats} />
         <GeneratedSetButtons
           numSets={numSets}
           set={set}
@@ -84,7 +78,7 @@ function GeneratedSet({
             locked={lockedMap[item.bucket.hash]}
             lbDispatch={lbDispatch}
             statValues={set.firstValidSetStatChoices[index]}
-            lockedMods={assignedMods[item.hash]}
+            lockedMods={assignedMods[item.id]}
             statOrder={statOrder}
           />
         ))}
diff --git a/src/app/loadout-builder/generated-sets/SetStats.tsx b/src/app/loadout-builder/generated-sets/SetStats.tsx
index a407f8cb8..5bbbedbcd 100644
--- a/src/app/loadout-builder/generated-sets/SetStats.tsx
+++ b/src/app/loadout-builder/generated-sets/SetStats.tsx
@@ -4,24 +4,22 @@ import PressTip from 'app/dim-ui/PressTip';
 import { AppIcon, faExclamationTriangle, powerIndicatorIcon } from 'app/shell/icons';
 import BungieImage from 'app/dim-ui/BungieImage';
 import { DestinyStatDefinition } from 'bungie-api-ts/destiny2';
-import { ArmorSet, statHashes, LockedArmor2ModMap, StatTypes } from '../types';
+import { ArmorSet, statHashes, StatTypes } from '../types';
 import { calculateTotalTier, sumEnabledStats } from './utils';
 import { t } from 'app/i18next-t';
 import { statTier } from '../utils';
 import { getPossiblyIncorrectStats } from 'app/utils/item-utils';
 import { D2ManifestDefinitions } from 'app/destiny2/d2-definitions';
 import styles from './SetStats.m.scss';
-import { armor2PlugCategoryHashesByName } from 'app/search/d2-known-values';
 
 interface Props {
   defs: D2ManifestDefinitions;
   set: ArmorSet;
   statOrder: StatTypes[];
   enabledStats: Set<StatTypes>;
-  lockedArmor2Mods: LockedArmor2ModMap;
 }
 
-function SetStats({ defs, set, statOrder, enabledStats, lockedArmor2Mods }: Props) {
+function SetStats({ defs, set, statOrder, enabledStats }: Props) {
   const stats = _.mapValues(statHashes, (statHash) => defs.Stat.get(statHash));
   const totalTier = calculateTotalTier(set.stats);
   const enabledTier = sumEnabledStats(set.stats, enabledStats);
@@ -29,27 +27,6 @@ function SetStats({ defs, set, statOrder, enabledStats, lockedArmor2Mods }: Prop
 
   const displayStats = { ...set.stats };
 
-  // Add general mod vaues for display purposes
-  if ($featureFlags.armor2ModPicker) {
-    for (const lockedMod of lockedArmor2Mods[armor2PlugCategoryHashesByName.general]) {
-      for (const stat of lockedMod.mod.investmentStats) {
-        if (stat.statTypeHash === statHashes.Mobility) {
-          displayStats.Mobility += stat.value;
-        } else if (stat.statTypeHash === statHashes.Recovery) {
-          displayStats.Recovery += stat.value;
-        } else if (stat.statTypeHash === statHashes.Resilience) {
-          displayStats.Resilience += stat.value;
-        } else if (stat.statTypeHash === statHashes.Intellect) {
-          displayStats.Intellect += stat.value;
-        } else if (stat.statTypeHash === statHashes.Discipline) {
-          displayStats.Discipline += stat.value;
-        } else if (stat.statTypeHash === statHashes.Strength) {
-          displayStats.Strength += stat.value;
-        }
-      }
-    }
-  }
-
   return (
     <div>
       <span>
diff --git a/src/app/loadout-builder/generated-sets/utils.ts b/src/app/loadout-builder/generated-sets/utils.ts
index ff1bde451..4bbb4f7c1 100644
--- a/src/app/loadout-builder/generated-sets/utils.ts
+++ b/src/app/loadout-builder/generated-sets/utils.ts
@@ -1,10 +1,9 @@
 import _ from 'lodash';
 import { DimItem } from '../../inventory/item-types';
-import { ArmorSet, StatTypes, LockedMap, LockedArmor2ModMap } from '../types';
+import { ArmorSet, StatTypes, LockedMap } from '../types';
 import { count } from '../../utils/util';
 import { chainComparator, compareBy, Comparator } from 'app/utils/comparators';
 import { statKeys } from '../types';
-import { canSetTakeGeneralAndSeasonalMods } from '../mod-utils';
 import { statTier } from '../utils';
 
 function getComparatorsForMatchedSetSorting(statOrder: StatTypes[], enabledStats: Set<StatTypes>) {
@@ -21,13 +20,8 @@ function getComparatorsForMatchedSetSorting(statOrder: StatTypes[], enabledStats
   return comparators;
 }
 
-/**
- * Filter sets down based on stat filters, locked perks, etc.
- * TODO This needs to become a sorter, not a filter. All 'filtering' should be done in process.
- */
-export function filterGeneratedSets(
+export function sortGeneratedSets(
   lockedMap: LockedMap,
-  lockedArmor2ModMap: LockedArmor2ModMap,
   statOrder: StatTypes[],
   enabledStats: Set<StatTypes>,
   sets?: readonly ArmorSet[]
@@ -36,22 +30,8 @@ export function filterGeneratedSets(
     return;
   }
 
-  let matchedSets = Array.from(sets);
-
-  matchedSets = matchedSets.filter((set) => {
-    // TODO this is too restrictive as there may be other combinations that can take the mods
-    if (
-      $featureFlags.armor2ModPicker &&
-      !canSetTakeGeneralAndSeasonalMods(set.firstValidSet, lockedArmor2ModMap)
-    ) {
-      return false;
-    }
-
-    return true;
-  });
-
   // TODO Can these two sorts be merged?
-  matchedSets = matchedSets.sort(
+  let matchedSets = Array.from(sets).sort(
     chainComparator(...getComparatorsForMatchedSetSorting(statOrder, enabledStats))
   );
 
diff --git a/src/app/loadout-builder/hooks/useProcess.ts b/src/app/loadout-builder/hooks/useProcess.ts
index 42aab084e..3a676cf1a 100644
--- a/src/app/loadout-builder/hooks/useProcess.ts
+++ b/src/app/loadout-builder/hooks/useProcess.ts
@@ -1,4 +1,5 @@
 import { wrap, releaseProxy } from 'comlink';
+import _ from 'lodash';
 import { useEffect, useState, useMemo } from 'react';
 import {
   ItemsByBucket,
@@ -9,21 +10,17 @@ import {
   MinMaxIgnored,
   MinMax,
   LockedModBase,
-  statHashToType,
+  ModPickerCategories,
 } from '../types';
-import { DimItem, DimSocket, DimSockets, D2Item } from 'app/inventory/item-types';
+import { DimItem } from 'app/inventory/item-types';
+import { ProcessItemsByBucket } from '../processWorker/types';
 import {
-  ProcessItemsByBucket,
-  ProcessItem,
-  ProcessArmorSet,
-  ProcessSocket,
-  ProcessSockets,
-  ProcessModMetadata,
-} from '../processWorker/types';
-import {
-  getSpecialtySocketMetadata,
-  getSpecialtySocketMetadataByPlugCategoryHash,
-} from 'app/utils/item-utils';
+  mapDimItemToProcessItem,
+  mapSeasonalModsToProcessMods,
+  getTotalModStatChanges,
+  hydrateArmorSet,
+  mapArmor2ModToProcessMod,
+} from '../processWorker/mappers';
 
 interface ProcessState {
   processing: boolean;
@@ -36,8 +33,6 @@ interface ProcessState {
   currentCleanup: (() => void) | null;
 }
 
-type ItemsById = { [id: string]: DimItem };
-
 /**
  * Hook to process all the stat groups for LO in a web worker.
  */
@@ -78,7 +73,7 @@ export function useProcess(
     setState({ processing: true, result, currentCleanup: cleanup });
 
     const processItems: ProcessItemsByBucket = {};
-    const itemsById: ItemsById = {};
+    const itemsById: { [id: string]: DimItem } = {};
 
     for (const [key, items] of Object.entries(filteredItems)) {
       processItems[key] = [];
@@ -95,9 +90,13 @@ export function useProcess(
       .process(
         processItems,
         lockedItems,
-        mapSeasonalModsToSeasonsArray(lockedSeasonalMods),
-        getTotalSeasonalModStatChanges(lockedSeasonalMods),
-        lockedArmor2ModMap,
+        mapSeasonalModsToProcessMods(lockedSeasonalMods),
+        getTotalModStatChanges(
+          $featureFlags.armor2ModPicker
+            ? [...lockedArmor2ModMap[ModPickerCategories.general], ...lockedArmor2ModMap.seasonal]
+            : lockedSeasonalMods
+        ),
+        _.mapValues(lockedArmor2ModMap, (mods) => mods.map((mod) => mapArmor2ModToProcessMod(mod))),
         assumeMasterwork,
         statOrder,
         statFilters,
@@ -185,130 +184,3 @@ function createWorker() {
 
   return { worker, cleanup };
 }
-
-function mapDimSocketToProcessSocket(dimSocket: DimSocket): ProcessSocket {
-  return {
-    plug: dimSocket.plug && {
-      stats: dimSocket.plug.stats,
-      plugItemHash: dimSocket.plug.plugItem.hash,
-    },
-    plugOptions: dimSocket.plugOptions.map((dimPlug) => ({
-      stats: dimPlug.stats,
-      plugItemHash: dimPlug.plugItem.hash,
-    })),
-  };
-}
-
-function mapSeasonalModsToSeasonsArray(
-  lockedSeasonalMods: readonly LockedModBase[]
-): ProcessModMetadata[] {
-  const metadatas = lockedSeasonalMods.map((mod) => ({
-    mod,
-    metadata: getSpecialtySocketMetadataByPlugCategoryHash(mod.mod.plug.plugCategoryHash),
-  }));
-
-  const modMetadata: ProcessModMetadata[] = [];
-  for (const entry of metadatas) {
-    if (entry?.metadata) {
-      modMetadata.push({
-        season: entry.metadata.season,
-        tag: entry.metadata.tag,
-        energyType: entry.mod.mod.plug.energyCost.energyType,
-      });
-    }
-  }
-
-  return modMetadata;
-}
-
-/**
- * This sums up the total stat contributions across locked seasonal mods. These are then applied
- * to the loadouts after all the items base values have been summed. This mimics how seasonal mods
- * effect stat values in game.
- */
-function getTotalSeasonalModStatChanges(lockedSeasonalMods: readonly LockedModBase[]) {
-  const totals: { [stat in StatTypes]: number } = {
-    Mobility: 0,
-    Recovery: 0,
-    Resilience: 0,
-    Intellect: 0,
-    Discipline: 0,
-    Strength: 0,
-  };
-
-  for (const mod of lockedSeasonalMods) {
-    for (const stat of mod.mod.investmentStats) {
-      const statType = statHashToType[stat.statTypeHash];
-      if (statType) {
-        totals[statType] += stat.value;
-      }
-    }
-  }
-
-  return totals;
-}
-
-function mapDimSocketsToProcessSockets(dimSockets: DimSockets): ProcessSockets {
-  return {
-    sockets: dimSockets.sockets.map(mapDimSocketToProcessSocket),
-    categories: dimSockets.categories.map((category) => ({
-      categoryStyle: category.category.categoryStyle,
-      sockets: category.sockets.map(mapDimSocketToProcessSocket),
-    })),
-  };
-}
-
-function mapDimItemToProcessItem(dimItem: D2Item): ProcessItem {
-  const { bucket, id, type, name, equippingLabel, basePower, stats } = dimItem;
-
-  const statMap: { [statHash: number]: number } = {};
-  const baseStatMap: { [statHash: number]: number } = {};
-
-  if (stats) {
-    for (const { statHash, value, base } of stats) {
-      statMap[statHash] = value;
-      baseStatMap[statHash] = base;
-    }
-  }
-
-  const modMetadata = getSpecialtySocketMetadata(dimItem);
-
-  return {
-    bucketHash: bucket.hash,
-    id,
-    type,
-    name,
-    equippingLabel,
-    basePower,
-    stats: statMap,
-    baseStats: baseStatMap,
-    sockets: dimItem.sockets && mapDimSocketsToProcessSockets(dimItem.sockets),
-    energyType: dimItem.energy?.energyType,
-    season: modMetadata?.season,
-    compatibleModSeasons: modMetadata?.compatibleTags,
-  };
-}
-
-export function hydrateArmorSet(processed: ProcessArmorSet, itemsById: ItemsById): ArmorSet {
-  const sets: ArmorSet['sets'] = [];
-
-  for (const processSet of processed.sets) {
-    const armor: DimItem[][] = [];
-
-    for (const itemIds of processSet.armor) {
-      armor.push(itemIds.map((id) => itemsById[id]));
-    }
-
-    sets.push({ armor, statChoices: processSet.statChoices });
-  }
-
-  const firstValidSet: DimItem[] = processed.firstValidSet.map((id) => itemsById[id]);
-
-  return {
-    sets,
-    firstValidSet,
-    firstValidSetStatChoices: processed.firstValidSetStatChoices,
-    stats: processed.stats,
-    maxPower: processed.maxPower,
-  };
-}
diff --git a/src/app/loadout-builder/mod-utils.ts b/src/app/loadout-builder/mod-utils.ts
index 82e45722a..4384da226 100644
--- a/src/app/loadout-builder/mod-utils.ts
+++ b/src/app/loadout-builder/mod-utils.ts
@@ -1,17 +1,15 @@
 import { DimItem } from '../inventory/item-types';
 import _ from 'lodash';
-import { LockableBuckets, LockedArmor2ModMap, LockedArmor2Mod } from './types';
+import { LockedArmor2ModMap, LockedArmor2Mod } from './types';
 import { DestinyEnergyType } from 'bungie-api-ts/destiny2';
-import { getSpecialtySocketMetadata } from 'app/utils/item-utils';
+import {
+  sortProcessModsOrProcessItems,
+  canTakeAllSeasonalMods,
+  canTakeAllGeneralMods,
+} from './processWorker/processUtils';
+import { mapArmor2ModToProcessMod, mapDimItemToProcessItem } from './processWorker/mappers';
 import { armor2PlugCategoryHashesByName } from 'app/search/d2-known-values';
 
-const energyOrder = [
-  DestinyEnergyType.Void,
-  DestinyEnergyType.Thermal,
-  DestinyEnergyType.Arc,
-  DestinyEnergyType.Any,
-];
-
 /**
  * Checks that:
  *   1. The armour piece is Armour 2.0
@@ -27,48 +25,17 @@ export const doEnergiesMatch = (mod: LockedArmor2Mod, item: DimItem) =>
  * Assignes the general mods to armour pieces in assignments, including the energy specific ones
  * i.e. Void Resist ect
  *
- * assignments is mutated in this function as it tracks assigned mods for a particular armour set
+ * assignments is mutated in this function as it tracks assigned mods for a particular armour set.
  */
 function assignGeneralMods(
   setToMatch: readonly DimItem[],
   generalMods: LockedArmor2Mod[],
-  assignments: Record<number, LockedArmor2Mod[]>
+  assignments: Record<string, number[]>
 ): void {
-  const armour2Items = setToMatch.filter((item) => item.isDestiny2() && item.energy);
-  if (generalMods && armour2Items.length < generalMods.length) {
-    return;
-  }
-
-  const generalModsByEnergyType = _.groupBy(
-    generalMods,
-    (mod) => mod.mod.plug.energyCost.energyType
-  );
-
-  const armourByEnergyType = _.groupBy(
-    setToMatch,
-    (item) => item.isDestiny2() && item.energy?.energyType
-  );
+  // Mods need to be sorted before being passed to the assignment function
+  const sortedMods = generalMods.map(mapArmor2ModToProcessMod).sort(sortProcessModsOrProcessItems);
 
-  let piecesLeft = [...setToMatch];
-  for (const energyType of energyOrder) {
-    if (generalModsByEnergyType[energyType]) {
-      for (let i = 0; i < generalModsByEnergyType[energyType].length; i++) {
-        const mod = generalModsByEnergyType[energyType][i];
-        if (
-          energyType !== DestinyEnergyType.Any &&
-          armourByEnergyType[energyType] &&
-          i < armourByEnergyType[energyType].length
-        ) {
-          const piece = armourByEnergyType[energyType][i];
-          assignments[piece.hash].push(mod);
-          piecesLeft = piecesLeft.filter((item) => item !== piece);
-        } else if (energyType === DestinyEnergyType.Any && piecesLeft.length) {
-          assignments[piecesLeft[0].hash].push(mod);
-          piecesLeft.shift();
-        }
-      }
-    }
-  }
+  canTakeAllGeneralMods(sortedMods, setToMatch.map(mapDimItemToProcessItem), assignments);
 }
 
 /**
@@ -79,10 +46,10 @@ function assignGeneralMods(
 function assignModsForSlot(
   item: DimItem,
   mods: LockedArmor2Mod[],
-  assignments: Record<number, LockedArmor2Mod[]>
+  assignments: Record<string, number[]>
 ): void {
   if (!mods?.length || mods.every((mod) => doEnergiesMatch(mod, item))) {
-    assignments[item.hash] = [...assignments[item.hash], ...mods];
+    assignments[item.id] = [...assignments[item.id], ...mods.map((mod) => mod.mod.hash)];
   }
 }
 
@@ -94,70 +61,22 @@ function assignModsForSlot(
 function assignAllSeasonalMods(
   setToMatch: readonly DimItem[],
   seasonalMods: readonly LockedArmor2Mod[],
-  assignments: Record<number, LockedArmor2Mod[]>
+  assignments: Record<string, number[]>
 ): void {
-  const firstValidSetArmor2Count = setToMatch.reduce(
-    (total, item) => (item.isDestiny2() && item.energy ? total + 1 : total),
-    0
-  );
-
-  if (!seasonalMods || seasonalMods.length > 5 || seasonalMods.length > firstValidSetArmor2Count) {
-    return;
-  }
-
-  const modsByArmorBucket = {};
-
-  // Build up an array of possible mods for each item in the set.
-  for (const mod of seasonalMods) {
-    for (const item of setToMatch) {
-      const itemModCategories =
-        getSpecialtySocketMetadata(item)?.compatiblePlugCategoryHashes || [];
-
-      if (itemModCategories.includes(mod.mod.plug.plugCategoryHash) && doEnergiesMatch(mod, item)) {
-        if (!modsByArmorBucket[item.bucket.hash]) {
-          modsByArmorBucket[item.bucket.hash] = [];
-        }
+  // Mods need to be sorted before being passed to the assignment function
+  const sortedMods = seasonalMods.map(mapArmor2ModToProcessMod).sort(sortProcessModsOrProcessItems);
 
-        modsByArmorBucket[item.bucket.hash].push(mod);
-      }
-    }
-  }
-
-  // From the possible mods try and find a combination that includes all seasonal mods
-  for (const helmetMod of modsByArmorBucket[LockableBuckets.helmet] || [null]) {
-    for (const armsMod of modsByArmorBucket[LockableBuckets.gauntlets] || [null]) {
-      for (const chestMod of modsByArmorBucket[LockableBuckets.chest] || [null]) {
-        for (const legsMod of modsByArmorBucket[LockableBuckets.leg] || [null]) {
-          for (const classMod of modsByArmorBucket[LockableBuckets.classitem] || [null]) {
-            const setMods = [helmetMod, armsMod, chestMod, legsMod, classMod];
-            const applicableMods = setMods.filter(Boolean);
-            const containsAllLocked =
-              seasonalMods.every((item) => applicableMods.includes(item)) &&
-              _.uniq(applicableMods).length === applicableMods.length;
-
-            if (containsAllLocked) {
-              for (let i = 0; i < setMods.length; i++) {
-                if (setMods[i]) {
-                  assignModsForSlot(setToMatch[i], [setMods[i]], assignments);
-                }
-              }
-              return;
-            }
-          }
-        }
-      }
-    }
-  }
+  canTakeAllSeasonalMods(sortedMods, setToMatch.map(mapDimItemToProcessItem), assignments);
 }
 
 export function assignModsToArmorSet(
   setToMatch: readonly DimItem[],
   lockedArmor2Mods: LockedArmor2ModMap
-): Record<number, LockedArmor2Mod[]> {
-  const assignments: Record<number, LockedArmor2Mod[]> = {};
+): Record<string, LockedArmor2Mod[]> {
+  const assignments: Record<string, number[]> = {};
 
   for (const item of setToMatch) {
-    assignments[item.hash] = [];
+    assignments[item.id] = [];
   }
 
   assignGeneralMods(
@@ -194,32 +113,6 @@ export function assignModsToArmorSet(
 
   assignAllSeasonalMods(setToMatch, lockedArmor2Mods.seasonal, assignments);
 
-  return assignments;
-}
-
-// Should be in the same order as first valid set
-export function canSetTakeGeneralAndSeasonalMods(
-  set: readonly DimItem[],
-  lockedArmor2Mods: LockedArmor2ModMap
-) {
-  const assignments: Record<number, LockedArmor2Mod[]> = {};
-
-  for (const item of set) {
-    assignments[item.hash] = [];
-  }
-
-  // we ignore slot specific mods as they are prefiltered so should match up
-  assignGeneralMods(set, lockedArmor2Mods[armor2PlugCategoryHashesByName.general], assignments);
-  assignAllSeasonalMods(set, lockedArmor2Mods.seasonal, assignments);
-
-  let assignmentCount = 0;
-  for (const slotAssignments of Object.values(assignments)) {
-    assignmentCount += slotAssignments.length;
-  }
-
-  return (
-    assignmentCount ===
-    lockedArmor2Mods[armor2PlugCategoryHashesByName.general].length +
-      lockedArmor2Mods.seasonal.length
-  );
+  const modsByHash = _.keyBy(Object.values(lockedArmor2Mods).flat(), (mod) => mod.mod.hash);
+  return _.mapValues(assignments, (modHashes) => modHashes.map((modHash) => modsByHash[modHash]));
 }
diff --git a/src/app/loadout-builder/processWorker/mappers.ts b/src/app/loadout-builder/processWorker/mappers.ts
new file mode 100644
index 000000000..74b8f5019
--- /dev/null
+++ b/src/app/loadout-builder/processWorker/mappers.ts
@@ -0,0 +1,171 @@
+import _ from 'lodash';
+import { DimSocket, DimSockets, D2Item, DimItem } from '../../inventory/item-types';
+import { ProcessSocket, ProcessMod, ProcessSockets, ProcessItem, ProcessArmorSet } from './types';
+import { LockedModBase, ArmorSet, statHashToType, StatTypes, LockedArmor2Mod } from '../types';
+import {
+  getSpecialtySocketMetadataByPlugCategoryHash,
+  getSpecialtySocketMetadata,
+} from '../../utils/item-utils';
+import { DestinyItemInvestmentStatDefinition } from 'bungie-api-ts/destiny2';
+
+function mapDimSocketToProcessSocket(dimSocket: DimSocket): ProcessSocket {
+  return {
+    plug: dimSocket.plug && {
+      stats: dimSocket.plug.stats,
+      plugItemHash: dimSocket.plug.plugItem.hash,
+    },
+    plugOptions: dimSocket.plugOptions.map((dimPlug) => ({
+      stats: dimPlug.stats,
+      plugItemHash: dimPlug.plugItem.hash,
+    })),
+  };
+}
+
+/**
+ * Maps the seasonal mods from the PerkPicker to ProcessMods.
+ *
+ * TODO When ModPicker is fully merged delete this.
+ */
+export function mapSeasonalModsToProcessMods(
+  lockedSeasonalMods: readonly LockedModBase[]
+): ProcessMod[] {
+  const metadatas = lockedSeasonalMods.map((mod) => ({
+    mod,
+    metadata: getSpecialtySocketMetadataByPlugCategoryHash(mod.mod.plug.plugCategoryHash),
+  }));
+
+  const modMetadata: ProcessMod[] = [];
+  for (const entry of metadatas) {
+    modMetadata.push({
+      hash: entry.mod.mod.hash,
+      season: entry.metadata?.season,
+      tag: entry.metadata?.tag,
+      energyType: entry.mod.mod.plug.energyCost.energyType,
+      investmentStats: entry.mod.mod.investmentStats.map(({ statTypeHash, value }) => ({
+        statTypeHash,
+        value,
+      })),
+    });
+  }
+
+  return modMetadata;
+}
+
+export function mapArmor2ModToProcessMod(mod: LockedArmor2Mod): ProcessMod {
+  const processMod = {
+    hash: mod.mod.hash,
+    energyType: mod.mod.plug.energyCost.energyType,
+    investmentStats: mod.mod.investmentStats,
+  };
+
+  if (mod.category === 'seasonal') {
+    const metadata = getSpecialtySocketMetadataByPlugCategoryHash(mod.mod.plug.plugCategoryHash);
+    return {
+      ...processMod,
+      season: metadata?.season,
+      tag: metadata?.tag,
+    };
+  }
+
+  return processMod;
+}
+
+/**
+ * This sums up the total stat contributions across mods passed in. These are then applied
+ * to the loadouts after all the items base values have been summed. This mimics how seasonal mods
+ * effect stat values in game and allows us to do some preprocessing.
+ *
+ * For the Mod Picker this can be used for seasonal and general mods. For mods in perk picker this is
+ * just for the seasonal mods.
+ */
+export function getTotalModStatChanges(
+  lockedSeasonalMods: readonly { mod: { investmentStats: DestinyItemInvestmentStatDefinition[] } }[]
+) {
+  const totals: { [stat in StatTypes]: number } = {
+    Mobility: 0,
+    Recovery: 0,
+    Resilience: 0,
+    Intellect: 0,
+    Discipline: 0,
+    Strength: 0,
+  };
+
+  for (const mod of lockedSeasonalMods) {
+    for (const stat of mod.mod.investmentStats) {
+      const statType = statHashToType[stat.statTypeHash];
+      if (statType) {
+        totals[statType] += stat.value;
+      }
+    }
+  }
+
+  return totals;
+}
+
+function mapDimSocketsToProcessSockets(dimSockets: DimSockets): ProcessSockets {
+  return {
+    sockets: dimSockets.sockets.map(mapDimSocketToProcessSocket),
+    categories: dimSockets.categories.map((category) => ({
+      categoryStyle: category.category.categoryStyle,
+      sockets: category.sockets.map(mapDimSocketToProcessSocket),
+    })),
+  };
+}
+
+export function mapDimItemToProcessItem(dimItem: D2Item): ProcessItem {
+  const { bucket, id, type, name, equippingLabel, basePower, stats } = dimItem;
+
+  const statMap: { [statHash: number]: number } = {};
+  const baseStatMap: { [statHash: number]: number } = {};
+
+  if (stats) {
+    for (const { statHash, value, base } of stats) {
+      statMap[statHash] = value;
+      baseStatMap[statHash] = base;
+    }
+  }
+
+  const modMetadata = getSpecialtySocketMetadata(dimItem);
+
+  return {
+    bucketHash: bucket.hash,
+    id,
+    type,
+    name,
+    equippingLabel,
+    basePower,
+    stats: statMap,
+    baseStats: baseStatMap,
+    sockets: dimItem.sockets && mapDimSocketsToProcessSockets(dimItem.sockets),
+    energyType: dimItem.energy?.energyType,
+    season: modMetadata?.season,
+    compatibleModSeasons: modMetadata?.compatibleTags,
+  };
+}
+
+export function hydrateArmorSet(
+  processed: ProcessArmorSet,
+  itemsById: { [id: string]: DimItem }
+): ArmorSet {
+  const sets: ArmorSet['sets'] = [];
+
+  for (const processSet of processed.sets) {
+    const armor: DimItem[][] = [];
+
+    for (const itemIds of processSet.armor) {
+      armor.push(itemIds.map((id) => itemsById[id]));
+    }
+
+    sets.push({ armor, statChoices: processSet.statChoices });
+  }
+
+  const firstValidSet: DimItem[] = processed.firstValidSet.map((id) => itemsById[id]);
+
+  return {
+    sets,
+    firstValidSet,
+    firstValidSetStatChoices: processed.firstValidSetStatChoices,
+    stats: processed.stats,
+    maxPower: processed.maxPower,
+  };
+}
diff --git a/src/app/loadout-builder/processWorker/process.ts b/src/app/loadout-builder/processWorker/process.ts
index 7257cc902..bea6e6719 100644
--- a/src/app/loadout-builder/processWorker/process.ts
+++ b/src/app/loadout-builder/processWorker/process.ts
@@ -4,8 +4,6 @@ import {
   StatTypes,
   LockedItemType,
   LockedMap,
-  LockedArmor2Mod,
-  LockedArmor2ModMap,
   MinMaxIgnored,
   MinMax,
 } from '../types';
@@ -17,10 +15,15 @@ import {
   ProcessItem,
   ProcessArmorSet,
   IntermediateProcessArmorSet,
-  ProcessModMetadata,
+  LockedArmor2ProcessMods,
+  ProcessMod,
 } from './types';
 import { DestinySocketCategoryStyle } from 'bungie-api-ts/destiny2';
-import { canTakeAllSeasonalMods, sortProcessModMetadataOrProcessItem } from './processUtils';
+import {
+  canTakeAllSeasonalMods,
+  sortProcessModsOrProcessItems,
+  canTakeAllGeneralMods,
+} from './processUtils';
 import { armor2PlugCategoryHashesByName } from '../../search/d2-known-values';
 
 const RETURNED_ARMOR_SETS = 200;
@@ -89,13 +92,14 @@ function insertIntoSetTracker(
 /**
  * This processes all permutations of armor to build sets
  * @param filteredItems pared down list of items to process sets from
+ * @param modStatTotals Stats that are applied to final stat totals, think general and seasonal mod stats
  */
 export function process(
   filteredItems: ProcessItemsByBucket,
   lockedItems: LockedMap,
-  processedSeasonalMods: ProcessModMetadata[],
-  seasonalModStatTotals: { [stat in StatTypes]: number },
-  lockedArmor2ModMap: LockedArmor2ModMap,
+  processedSeasonalMods: ProcessMod[],
+  modStatTotals: { [stat in StatTypes]: number },
+  lockedArmor2ModMap: LockedArmor2ProcessMods,
   assumeMasterwork: boolean,
   statOrder: StatTypes[],
   statFilters: { [stat in StatTypes]: MinMaxIgnored },
@@ -108,7 +112,7 @@ export function process(
 } {
   const pstart = performance.now();
 
-  processedSeasonalMods.sort(sortProcessModMetadataOrProcessItem);
+  processedSeasonalMods.sort(sortProcessModsOrProcessItems);
 
   // Memoize the function that turns string stat-keys back into numbers to save garbage.
   // Writing our own memoization instead of using _.memoize is 2x faster.
@@ -274,7 +278,7 @@ export function process(
               let index = 1;
               let statRangeExceeded = false;
               for (const statKey of orderedConsideredStats) {
-                stats[statKey] += seasonalModStatTotals[statKey];
+                stats[statKey] += modStatTotals[statKey];
                 const tier = statTier(stats[statKey]);
 
                 if (tier > statRanges[statKey].max) {
@@ -310,9 +314,17 @@ export function process(
                 }
               }
 
+              // For armour 2 mods we ignore slot specific mods as we prefilter items based on energy requirements
               if (
-                processedSeasonalMods.length &&
-                !canTakeAllSeasonalMods(processedSeasonalMods, firstValidSet)
+                (processedSeasonalMods.length &&
+                  !canTakeAllSeasonalMods(processedSeasonalMods, firstValidSet)) ||
+                (lockedArmor2ModMap[armor2PlugCategoryHashesByName.general].length &&
+                  !canTakeAllGeneralMods(
+                    lockedArmor2ModMap[armor2PlugCategoryHashesByName.general],
+                    firstValidSet
+                  )) ||
+                (lockedArmor2ModMap.seasonal.length &&
+                  !canTakeAllSeasonalMods(lockedArmor2ModMap.seasonal, firstValidSet))
               ) {
                 continue;
               }
@@ -396,7 +408,7 @@ function byStatMix(
   assumeMasterwork: boolean,
   orderedStatValues: number[],
   lockedItems?: readonly LockedItemType[],
-  lockedArmor2Mods?: LockedArmor2Mod[]
+  lockedArmor2Mods?: ProcessMod[]
 ) {
   const lockedModStats: { [statHash: number]: number } = {};
   // Handle old armour mods
@@ -414,7 +426,7 @@ function byStatMix(
   // Handle armour 2.0 mods
   if (lockedArmor2Mods) {
     for (const lockedMod of lockedArmor2Mods) {
-      for (const stat of lockedMod.mod.investmentStats) {
+      for (const stat of lockedMod.investmentStats) {
         lockedModStats[stat.statTypeHash] = lockedModStats[stat.statTypeHash] || 0;
         lockedModStats[stat.statTypeHash] += stat.value;
       }
diff --git a/src/app/loadout-builder/processWorker/processUtils.ts b/src/app/loadout-builder/processWorker/processUtils.ts
index acf7d0a09..7e046b051 100644
--- a/src/app/loadout-builder/processWorker/processUtils.ts
+++ b/src/app/loadout-builder/processWorker/processUtils.ts
@@ -1,4 +1,4 @@
-import { ProcessModMetadata } from './types';
+import { ProcessMod } from './types';
 import { DestinyEnergyType } from 'bungie-api-ts/destiny2';
 
 interface SortParam {
@@ -7,10 +7,15 @@ interface SortParam {
 }
 
 export interface ProcessItemSubset extends SortParam {
+  id: string;
   compatibleModSeasons?: string[];
 }
 
-export function sortProcessModMetadataOrProcessItem(a: SortParam, b: SortParam) {
+/**
+ * This sorting function is pivitol in the algorithm to figure out it seasonal mods can be slotted into
+ * a list of items. It sorts by season and then energyType in descending order.
+ */
+export function sortProcessModsOrProcessItems(a: SortParam, b: SortParam) {
   if (a.season && b.season) {
     // any energy is 0 so check undefined rather than falsey
     if (a.season === b.season && a.energyType !== undefined && b.energyType !== undefined) {
@@ -24,17 +29,37 @@ export function sortProcessModMetadataOrProcessItem(a: SortParam, b: SortParam)
   return -1;
 }
 
+/**
+ * This sorting function is pivitol in the algorithm to figure out it general mods can be slotted into
+ * a list of items. It sorts by energyType in descending order.
+ */
+export function sortGeneralModsOrProcessItem(a: SortParam, b: SortParam) {
+  // any energy is 0 so check undefined rather than falsey
+  if (a.energyType !== undefined && b.energyType !== undefined) {
+    return b.energyType - a.energyType;
+  } else if (a.energyType === undefined) {
+    return 1;
+  }
+
+  return -1;
+}
+
 /**
  * See if we can slot all the locked seasonal mods.
  *
- * @param processedMods These mods must be sorted in the same manner as the item sort function below.
+ * This function need to be kept inline with ../mod-utils#assignAllSeasonalMods.
+ *
+ * @param processedMods These mods must be sorted by sortProcessModsOrProcessItems.
  * @param items The process items to test for mod slotting.
+ * @param assignments This is an optional object that tracks item ids to mod hashes so
+ *  that mods can be displayed for items in the UI. If passed in it is mutated.
  */
 export function canTakeAllSeasonalMods(
-  processedMods: ProcessModMetadata[],
-  items: ProcessItemSubset[]
+  processedMods: ProcessMod[],
+  items: ProcessItemSubset[],
+  assignments?: Record<string, number[]>
 ) {
-  const sortedItems = [...items].sort(sortProcessModMetadataOrProcessItem);
+  const sortedItems = Array.from(items).sort(sortProcessModsOrProcessItems);
 
   let modIndex = 0;
   let itemIndex = 0;
@@ -42,11 +67,61 @@ export function canTakeAllSeasonalMods(
   // Loop over the items and mods in parallel and see if they can be slotted.
   // due to Any energy mods needing to consider skipped items we reset item index after each splice.
   while (modIndex < processedMods.length && itemIndex < sortedItems.length) {
-    const { energyType, tag } = processedMods[modIndex];
+    const { energyType, tag, hash } = processedMods[modIndex];
+    if (!tag) {
+      // This should never happen but if it does we ignore seasonal requirements and log the warning.
+      console.warn('Optimiser: Found seasonal mod without season details.');
+      return true;
+    }
     if (
       (sortedItems[itemIndex].energyType === energyType || energyType === DestinyEnergyType.Any) &&
       sortedItems[itemIndex].compatibleModSeasons?.includes(tag)
     ) {
+      if (assignments) {
+        assignments[sortedItems[itemIndex].id].push(hash);
+      }
+      sortedItems.splice(itemIndex, 1);
+      modIndex += 1;
+      itemIndex = 0;
+    } else {
+      itemIndex += 1;
+    }
+  }
+
+  // This will indicate we have iterated over all the mods, it will overshoot the length on success.
+  return processedMods.length === modIndex;
+}
+
+/**
+ * See if we can slot all the locked general mods.
+ *
+ * This function need to be kept inline with ../mod-utils#assignAllGeneralMods.
+ *
+ * @param processedMods These mods must be sorted by sortGeneralModsOrProcessItem.
+ * @param items The process items to test for mod slotting.
+ * @param assignments This is an optional object that tracks item ids to mod hashes so
+ *  that mods can be displayed for items in the UI. If passed in it is mutated.
+ */
+export function canTakeAllGeneralMods(
+  processedMods: ProcessMod[],
+  items: ProcessItemSubset[],
+  assignments?: Record<string, number[]>
+) {
+  const sortedItems = Array.from(items).sort(sortGeneralModsOrProcessItem);
+
+  let modIndex = 0;
+  let itemIndex = 0;
+
+  // Loop over the items and mods in parallel and see if they can be slotted.
+  // We need to reset the index after a match to ensure that mods with the Any energy type
+  // use up armour items that didn't match an energy type/season first.
+  while (modIndex < processedMods.length && itemIndex < sortedItems.length) {
+    const { energyType, hash } = processedMods[modIndex];
+
+    if (sortedItems[itemIndex].energyType === energyType || energyType === DestinyEnergyType.Any) {
+      if (assignments) {
+        assignments[sortedItems[itemIndex].id].push(hash);
+      }
       sortedItems.splice(itemIndex, 1);
       modIndex += 1;
       itemIndex = 0;
diff --git a/src/app/loadout-builder/processWorker/types.ts b/src/app/loadout-builder/processWorker/types.ts
index cb4ffe90b..e11b90667 100644
--- a/src/app/loadout-builder/processWorker/types.ts
+++ b/src/app/loadout-builder/processWorker/types.ts
@@ -1,4 +1,4 @@
-import { StatTypes } from '../types';
+import { StatTypes, ModPickerCategory } from '../types';
 import { DestinyEnergyType } from 'bungie-api-ts/destiny2';
 
 export interface ProcessPlug {
@@ -92,8 +92,21 @@ export interface IntermediateProcessArmorSet {
   maxPower: number;
 }
 
-export interface ProcessModMetadata {
-  season: number;
-  tag: string;
+interface ProcessStat {
+  statTypeHash: number;
+  value: number;
+}
+
+export interface ProcessMod {
+  hash: number;
   energyType: DestinyEnergyType;
+  investmentStats: ProcessStat[];
+  /** This should only be available in seasonal mods */
+  season?: number;
+  /** This should only be available in seasonal mods */
+  tag?: string;
 }
+
+export type LockedArmor2ProcessMods = {
+  [T in ModPickerCategory]: ProcessMod[];
+};


Test patch:
diff --git a/src/app/loadout-builder/processWorker/processUtils.test.ts b/src/app/loadout-builder/processWorker/processUtils.test.ts
index a713cfdcb..ea1c666ad 100644
--- a/src/app/loadout-builder/processWorker/processUtils.test.ts
+++ b/src/app/loadout-builder/processWorker/processUtils.test.ts
@@ -1,24 +1,31 @@
 import {
   canTakeAllSeasonalMods,
-  sortProcessModMetadataOrProcessItem,
+  sortProcessModsOrProcessItems,
   ProcessItemSubset,
 } from './processUtils';
-import { ProcessModMetadata } from './types';
+import { ProcessMod } from './types';
 import { DestinyEnergyType } from 'bungie-api-ts/destiny2';
 
-function getMod(season: number, tag: string, energyType: DestinyEnergyType): ProcessModMetadata {
-  return { season, tag, energyType };
+function getMod(
+  season: number,
+  tag: string,
+  energyType: DestinyEnergyType,
+  hash?: number
+): ProcessMod {
+  return { season, tag, energyType, investmentStats: [], hash: hash || 0 };
 }
 
 function getItem(
   season: number,
   energyType: DestinyEnergyType,
-  compatibleModSeasons?: string[]
+  compatibleModSeasons?: string[],
+  id?: string
 ): ProcessItemSubset {
   return {
     energyType,
     season,
     compatibleModSeasons,
+    id: id || 'id',
   };
 }
 
@@ -33,7 +40,7 @@ describe('Can slot seasonal mods', () => {
       getMod(11, 'arrivals', 3),
       getMod(11, 'arrivals', 3),
       getMod(10, 'worthy', 3),
-    ].sort(sortProcessModMetadataOrProcessItem);
+    ].sort(sortProcessModsOrProcessItems);
 
     const items = [
       getItem(11, 3, ['arrivals', 'worthy']),
@@ -50,7 +57,7 @@ describe('Can slot seasonal mods', () => {
       getMod(11, 'arrivals', 2),
       getMod(11, 'arrivals', 1),
       getMod(11, 'arrivals', 0),
-    ].sort(sortProcessModMetadataOrProcessItem);
+    ].sort(sortProcessModsOrProcessItems);
 
     const items = [
       getItem(11, 3, ['arrivals', 'worthy']),
@@ -67,7 +74,7 @@ describe('Can slot seasonal mods', () => {
       getMod(11, 'arrivals', 0),
       getMod(11, 'arrivals', 0),
       getMod(11, 'arrivals', 0),
-    ].sort(sortProcessModMetadataOrProcessItem);
+    ].sort(sortProcessModsOrProcessItems);
 
     const items = [
       getItem(11, 3, ['arrivals', 'worthy']),
@@ -80,7 +87,7 @@ describe('Can slot seasonal mods', () => {
   });
 
   it('passes with a single mod', () => {
-    const mods = [getMod(11, 'arrivals', 1)].sort(sortProcessModMetadataOrProcessItem);
+    const mods = [getMod(11, 'arrivals', 1)].sort(sortProcessModsOrProcessItems);
 
     const items = [
       getItem(11, 3, ['arrivals', 'worthy']),
@@ -94,7 +101,7 @@ describe('Can slot seasonal mods', () => {
 
   it('passes when the first item takes no mods', () => {
     const mods = [getMod(7, 'opulent', 0), getMod(7, 'opulent', 0)].sort(
-      sortProcessModMetadataOrProcessItem
+      sortProcessModsOrProcessItems
     );
 
     const items = [
@@ -117,7 +124,7 @@ describe("Can't slot seasonal mods", () => {
       getMod(11, 'arrivals', 3),
       getMod(11, 'arrivals', 3),
       getMod(11, 'arrivals', 1),
-    ].sort(sortProcessModMetadataOrProcessItem);
+    ].sort(sortProcessModsOrProcessItems);
 
     const items = [
       getItem(11, 3, ['arrivals', 'worthy']),
@@ -131,7 +138,7 @@ describe("Can't slot seasonal mods", () => {
 
   it('fails when a season mismatches', () => {
     const mods = [getMod(11, 'arrivals', 3), getMod(11, 'arrivals', 3), getMod(9, 'dawn', 3)].sort(
-      sortProcessModMetadataOrProcessItem
+      sortProcessModsOrProcessItems
     );
 
     const items = [
@@ -149,7 +156,7 @@ describe("Can't slot seasonal mods", () => {
       getMod(11, 'arrivals', 3),
       getMod(11, 'arrivals', 3),
       getMod(11, 'arrivals', 3),
-    ].sort(sortProcessModMetadataOrProcessItem);
+    ].sort(sortProcessModsOrProcessItems);
 
     const items = [
       getItem(11, 3, ['arrivals', 'worthy']),
@@ -163,7 +170,7 @@ describe("Can't slot seasonal mods", () => {
 
   it('fails when first slot cant be used and only one item to take both mods', () => {
     const mods = [getMod(7, 'opulent', 0), getMod(7, 'opulent', 0)].sort(
-      sortProcessModMetadataOrProcessItem
+      sortProcessModsOrProcessItems
     );
 
     const items = [
@@ -186,7 +193,7 @@ describe('Sorting works for mods and items', () => {
       getMod(11, 'arrivals', 3),
       getMod(11, 'arrivals', 2),
       getMod(11, 'arrivals', 1),
-    ].sort(sortProcessModMetadataOrProcessItem);
+    ].sort(sortProcessModsOrProcessItems);
 
     const items = [
       getItem(11, 1, ['arrivals', 'worthy']),
@@ -203,7 +210,7 @@ describe('Sorting works for mods and items', () => {
       getMod(11, 'arrivals', 1),
       getMod(11, 'arrivals', 2),
       getMod(11, 'arrivals', 3),
-    ].sort(sortProcessModMetadataOrProcessItem);
+    ].sort(sortProcessModsOrProcessItems);
 
     const items = [
       getItem(11, 3, ['arrivals', 'worthy']),
@@ -217,7 +224,7 @@ describe('Sorting works for mods and items', () => {
 
   it('passes when mods are sorted for season', () => {
     const mods = [getMod(9, 'dawn', 1), getMod(10, 'worthy', 1), getMod(11, 'arrivals', 1)].sort(
-      sortProcessModMetadataOrProcessItem
+      sortProcessModsOrProcessItems
     );
 
     const items = [
@@ -232,7 +239,7 @@ describe('Sorting works for mods and items', () => {
 
   it('passes when items are sorted for season', () => {
     const mods = [getMod(11, 'arrivals', 1), getMod(10, 'worthy', 1), getMod(9, 'dawn', 1)].sort(
-      sortProcessModMetadataOrProcessItem
+      sortProcessModsOrProcessItems
     );
 
     const items = [