diff --git a/config/feature-flags.ts b/config/feature-flags.ts index ac7bed23ed..ed81aecbc6 100644 --- a/config/feature-flags.ts +++ b/config/feature-flags.ts @@ -51,6 +51,8 @@ export function makeFeatureFlags(env: { customStatWeights: false, // On the Loadouts page, run Loadout Optimizer to find better tiers for loadouts. runLoInBackground: true, + // Loadout Optimizer "Armor to Farm" planner (stat-target planner, #11832) + loFarmingPlanner: !env.release, // Whether to allow setting in-game loadout identifiers on DIM loadouts. editInGameLoadoutIdentifiers: false, // Whether to sync DIM API data instead of loading everything diff --git a/config/i18n.json b/config/i18n.json index 253d6523ea..6edd856645 100644 --- a/config/i18n.json +++ b/config/i18n.json @@ -674,6 +674,28 @@ "SetBonusModWarning": "This set contains an item with a configurable set bonus mod. Manually apply the correct mod to activate the desired set bonus.", "ExoticClassItemPerks": "If you want specific perks, use searches like exactperk:\"spirit of verity\". Click perks in the Optimizer results to add or remove them from the item filter.", "ExoticSpecialCategory": "Special", + "FarmingPlanner": "Armor to Farm (Prototype)", + "FarmingPlannerAlreadyBuildable": "Your targets are buildable from armor you already have.", + "FarmingPlannerAnyExoticMissing": "You have no available exotic, so one of the planned pieces must be an exotic drop instead.", + "FarmingPlannerEnable": "Suggest armor to farm to meet stat goals", + "FarmingPlannerExoticMissing": "You have no available copy of the chosen exotic, so its slot is planned as an ideal drop.", + "FarmingPlannerFarmExotic": "…with one of these as a new exotic drop", + "FarmingPlannerFinePrint": "Builds around the chosen exotic and keeps your best owned armor. Farmed pieces assume ideal Tier {{tier}} legendary drops.", + "FarmingPlannerFinePrintIdeal": "Plans every unpinned slot as an ideal Tier {{tier}} legendary drop (the chosen exotic included).", + "FarmingPlannerFromSet": "…with at least {{numPieces}} of these from {{set}}", + "FarmingPlannerKeep": "Combined with your:", + "FarmingPlannerKeepOwned": "Keep armor I already own", + "FarmingPlannerMod": "+10 {{stat}} mod", + "FarmingPlannerModMinor": "+5 {{stat}} mod", + "FarmingPlannerNeed_one": "You need {{count}} new piece to complete this:", + "FarmingPlannerNeed_other": "You need {{count}} new pieces to complete this:", + "FarmingPlannerNeedIdeal_one": "You need {{count}} piece to complete this:", + "FarmingPlannerNeedIdeal_other": "You need {{count}} pieces to complete this:", + "FarmingPlannerNoTargets": "Set minimum stat values to see which armor you still need to farm.", + "FarmingPlannerSetImpossible": "The required set bonuses can't fit alongside these constraints.", + "FarmingPlannerTuning": "farmed piece tuned +5 {{stat}}", + "FarmingPlannerTuningUncredited": "Set a stat to Ignore to let farmed pieces spend their tuning slot.", + "FarmingPlannerUnreachable": "Not reachable even with ideal drops ({{points}} stat points short). Closest:", "Filter": "Settings", "Legendary": "Legendary", "LockItem": "Pin item", diff --git a/src/app/loadout-builder/LoadoutBuilder.tsx b/src/app/loadout-builder/LoadoutBuilder.tsx index 53ee997e06..d67e6db5e2 100644 --- a/src/app/loadout-builder/LoadoutBuilder.tsx +++ b/src/app/loadout-builder/LoadoutBuilder.tsx @@ -68,6 +68,7 @@ import CompareLoadoutsDrawer from './generated-sets/CompareLoadoutsDrawer'; import GeneratedSets from './generated-sets/GeneratedSets'; import { ReferenceConstraints } from './generated-sets/SetStats'; import { sortGeneratedSets } from './generated-sets/utils'; +import HypotheticalPlanner from './hypothetical/HypotheticalPlanner'; import { filterItems } from './item-filter'; import { LoadoutBuilderAction, useLbState } from './loadout-builder-reducer'; import { useLoVendorItems } from './loadout-builder-vendors'; @@ -384,6 +385,22 @@ export default memo(function LoadoutBuilder({ lbDispatch={lbDispatch} className={styles.loadoutEditSection} /> + {$featureFlags.loFarmingPlanner && ( + 0 ? 'found' : 'none'} + className={styles.loadoutEditSection} + /> + )} {isPhonePortrait && (
    diff --git a/src/app/loadout-builder/hypothetical/HypotheticalPlanner.m.scss b/src/app/loadout-builder/hypothetical/HypotheticalPlanner.m.scss new file mode 100644 index 0000000000..f9039622f5 --- /dev/null +++ b/src/app/loadout-builder/hypothetical/HypotheticalPlanner.m.scss @@ -0,0 +1,62 @@ +.verdict { + margin: 4px 0; + font-weight: bold; +} + +.recipe { + list-style: none; + margin: 4px 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; + + li { + display: flex; + align-items: center; + gap: 6px; + } +} + +.count { + font-weight: bold; + min-width: 22px; + text-align: right; +} + +.icon { + width: 16px; + height: 16px; +} + +.tertiary { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; + opacity: 0.7; +} + +.setNote { + opacity: 0.8; + font-style: italic; +} + +.keep { + margin: 4px 0; + opacity: 0.8; +} + +.keepItems { + --item-size: 40px; + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} + +.fineprint { + margin: 4px 0 0; + font-size: 10px; + opacity: 0.6; +} diff --git a/src/app/loadout-builder/hypothetical/HypotheticalPlanner.m.scss.d.ts b/src/app/loadout-builder/hypothetical/HypotheticalPlanner.m.scss.d.ts new file mode 100644 index 0000000000..ce06560a05 --- /dev/null +++ b/src/app/loadout-builder/hypothetical/HypotheticalPlanner.m.scss.d.ts @@ -0,0 +1,15 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'count': string; + 'fineprint': string; + 'icon': string; + 'keep': string; + 'keepItems': string; + 'recipe': string; + 'setNote': string; + 'tertiary': string; + 'verdict': string; +} +export const cssExports: CssExports; +export = cssExports; diff --git a/src/app/loadout-builder/hypothetical/HypotheticalPlanner.tsx b/src/app/loadout-builder/hypothetical/HypotheticalPlanner.tsx new file mode 100644 index 0000000000..de7de11dc0 --- /dev/null +++ b/src/app/loadout-builder/hypothetical/HypotheticalPlanner.tsx @@ -0,0 +1,540 @@ +import { SetBonusCounts } from '@destinyitemmanager/dim-api-types'; +import BungieImage from 'app/dim-ui/BungieImage'; +import CheckButton from 'app/dim-ui/CheckButton'; +import CollapsibleTitle from 'app/dim-ui/CollapsibleTitle'; +import { t } from 'app/i18next-t'; +import ConnectedInventoryItem from 'app/inventory/ConnectedInventoryItem'; +import DraggableInventoryItem from 'app/inventory/DraggableInventoryItem'; +import { DimItem, PluggableInventoryItemDefinition } from 'app/inventory/item-types'; +import ItemPopupTrigger from 'app/inventory/ItemPopupTrigger'; +import { calculateAssumedMasterworkStats } from 'app/loadout-drawer/loadout-utils'; +import { calculateAssumedItemEnergy } from 'app/loadout/armor-upgrade-utils'; +import { ModMap } from 'app/loadout/mod-assignment-utils'; +import { useD2Definitions } from 'app/manifest/selectors'; +import { armorStats } from 'app/search/d2-known-values'; +import { filterMap, mapValues, sumBy } from 'app/utils/collections'; +import { getArmor3StatFocus } from 'app/utils/item-utils'; +import { errorLog } from 'app/utils/log'; +import { getArmorArchetype } from 'app/utils/socket-utils'; +import { releaseProxy, wrap } from 'comlink'; +import { memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { allItemsSelector } from '../../inventory/selectors'; +import { mapAutoMods } from '../process/mappers'; +import { useAutoMods } from '../process/useProcess'; +import { + ArmorBucketHashes, + ArmorEnergyRules, + ArmorStats, + DesiredStatRange, + ItemsByBucket, + LOCKED_EXOTIC_ANY_EXOTIC, + ModStatChanges, + PinnedItems, +} from '../types'; +import { + buildHypotheticalBlocks, + deriveArmor3ArchetypeModel, + isArmor3ModelSourceItem, + MAX_GEAR_TIER, + SetBonusRequirement, + tuningVariantStats, +} from './hypothetical-items'; +import * as styles from './HypotheticalPlanner.m.scss'; +import { + PlannerExoticMode, + PlannerInputs, + PlannerPiece, + PlannerResult, + totalFarmCount, +} from './planner'; + +const modEnergyCost = (mod: PluggableInventoryItemDefinition) => + mod.plug.energyCost?.energyCost ?? 0; + +function createPlannerWorker() { + const instance = new Worker( + /* webpackChunkName: "planner-worker" */ new URL('./PlannerWorker', import.meta.url), + { type: 'module' }, + ); + const worker = wrap(instance); + const cleanup = () => { + worker[releaseProxy](); + instance.terminate(); + }; + return { worker, cleanup }; +} + +interface PlanState { + result: PlannerResult; + planTimeMs: number; +} + +/** + * Run the planner in a web worker whenever the inputs change, keeping the + * last result while a new one computes. A still-running computation is + * terminated (worker and all) when new inputs arrive so stale work never + * blocks fresh work — the search is synchronous inside the worker, so + * termination is the only way to cancel it. An idle worker is deliberately + * kept alive between runs to skip the spawn cost on the common path. + */ +function usePlannerWorker(inputs: PlannerInputs | undefined): PlanState | undefined { + const [planState, setPlanState] = useState(); + const workerRef = useRef>(undefined); + const busyRef = useRef(false); + + useEffect( + () => () => { + workerRef.current?.cleanup(); + workerRef.current = undefined; + }, + [], + ); + + useEffect(() => { + if (!inputs) { + setPlanState(undefined); + return; + } + if (busyRef.current) { + // The previous computation is still running on stale inputs — kill it. + workerRef.current?.cleanup(); + workerRef.current = undefined; + } + workerRef.current ??= createPlannerWorker(); + busyRef.current = true; + let cancelled = false; + const start = performance.now(); + workerRef.current.worker.planForTargets(inputs).then( + (result) => { + busyRef.current = false; + if (!cancelled) { + setPlanState({ result, planTimeMs: performance.now() - start }); + } + }, + (e: unknown) => { + busyRef.current = false; + if (!cancelled) { + errorLog('planner prototype', 'planner worker failed', e); + } + }, + ); + return () => { + cancelled = true; + }; + }, [inputs]); + + return planState; +} + +/** + * Stat-target planner — https://github.com/DestinyItemManager/DIM/issues/11832 + * + * Answers "what armor do I still need to farm to hit these stat targets?" + * Keeps as many owned pieces as possible (the locked exotic, pinned items, + * and pieces contributing to required set bonuses included) and fills the + * remaining slots with ideal hypothetical drops. The search itself runs in a + * web worker (see planner.ts / PlannerWorker.ts). + */ +export default memo(function HypotheticalPlanner({ + desiredStatRanges, + filteredItems, + pinnedItems, + lockedExoticHash, + setBonuses, + modStatChanges, + armorEnergyRules, + autoStatMods, + lockedModMap, + storeId, + ownedSets, + className, +}: { + desiredStatRanges: DesiredStatRange[]; + filteredItems: ItemsByBucket; + pinnedItems: PinnedItems; + lockedExoticHash: number | undefined; + setBonuses: SetBonusCounts; + modStatChanges: ModStatChanges; + armorEnergyRules: ArmorEnergyRules; + autoStatMods: boolean; + lockedModMap: ModMap; + storeId: string; + /** + * Whether the real Loadout Optimizer worker found sets meeting the targets + * from owned armor. The worker models things the planner doesn't (artifice + * sockets, every exotic copy), so when it found sets there's nothing to farm + * no matter what the planner's model says. 'pending' means it's still + * searching — we say nothing rather than tell someone to farm for a build + * that's about to resolve as already buildable. + */ + ownedSets: 'pending' | 'found' | 'none'; + className?: string; +}) { + const defs = useD2Definitions()!; + const allItems = useSelector(allItemsSelector); + const autoModDefs = useAutoMods(storeId); + + // Whether to build around owned armor (minimize farming) or plan the ideal + // build from drops alone. The locked exotic and pins are respected either way. + const [keepOwned, setKeepOwned] = useState(true); + + // Opt-in: this is extra work on top of what LO already does, so it stays off + // until asked for. Gating `inputs` (not just the rendering) is what makes it + // an actual off switch — collapsing the section would still run the worker. + const [enabled, setEnabled] = useState(false); + + // Let stat slider drags repaint before we recompute the plan. + const deferredStatRanges = useDeferredValue(desiredStatRanges); + + // Whether the section renders at all — a cheap probe, unlike the model. + const hasModelSource = useMemo(() => allItems.some(isArmor3ModelSourceItem), [allItems]); + + // Deriving the model scans all items (and, once per manifest, the whole + // InventoryItem table), so don't pay for it until the feature is enabled. + const modelAndBlocks = useMemo(() => { + if (!enabled) { + return undefined; + } + const model = deriveArmor3ArchetypeModel(allItems, defs); + return model && { model, blocks: buildHypotheticalBlocks(model) }; + }, [enabled, allItems, defs]); + + const hasTargets = deferredStatRanges.some((r) => r.maxStat > 0 && r.minStat > 0); + // Farmed pieces only get credit for their tuning slot when there's a stat to + // dump the paired -5 into, so say so when there isn't one. + const hasIgnoredStat = deferredStatRanges.some((r) => r.maxStat === 0); + + // What the +10/+5 general mods cost per stat, via the LO worker's own mapping. + const autoModCosts = useMemo( + () => + mapValues(mapAutoMods(autoModDefs).generalMods, (mods) => + mods ? { major: mods.majorMod.cost, minor: mods.minorMod.cost } : undefined, + ), + [autoModDefs], + ); + + // Energy consumed on each slot by locked bucket-specific mods (helmet mods etc.). + const bucketSpecificCosts = useMemo( + () => + ArmorBucketHashes.map((bucketHash) => + sumBy(lockedModMap.bucketSpecificMods[bucketHash] ?? [], modEnergyCost), + ), + [lockedModMap], + ); + + // Identify the exact roll: "Geomag Stabilizers (Grenadier / Class)". + const describeItem = useCallback( + (item: DimItem) => { + const archetypeName = getArmorArchetype(item)?.displayProperties.name; + const focus = getArmor3StatFocus(item); + const tertiaryName = + focus.length === 3 ? defs.Stat.get(focus[2])?.displayProperties.name : undefined; + return archetypeName && tertiaryName + ? `${item.name} (${archetypeName} / ${tertiaryName})` + : item.name; + }, + [defs], + ); + + // Plain candidate pieces for the worker, plus the id → DimItem mapping to + // resolve its answer back to real items. Computed once per inventory change + // rather than per stat-slider change. + const mapped = useMemo(() => { + const itemsById = new Map(); + const piecesByBucket = ArmorBucketHashes.map((bucketHash, bucketIdx) => + filteredItems[bucketHash] + // Guard against stat-less (e.g. classified) items poisoning sums with NaN + .filter((item) => item.stats?.length) + .flatMap((item): PlannerPiece[] => { + itemsById.set(item.id, item); + const stats = calculateAssumedMasterworkStats(item, armorEnergyRules); + const piece: PlannerPiece = { + id: item.id, + itemId: item.id, + isExotic: item.isExotic, + name: describeItem(item), + stats: stats as ArmorStats, + setBonusHash: item.setBonus?.hash, + energy: + calculateAssumedItemEnergy(item, armorEnergyRules) - bucketSpecificCosts[bucketIdx], + }; + // One candidate per way the item's tuning slot could be plugged. The + // untuned piece stays in the running — a tuning mod always dumps a + // stat somewhere, which isn't always worth the stat it adds. + return [ + piece, + ...tuningVariantStats(item, stats).map( + ({ modHash, stats: tunedStats }): PlannerPiece => { + const variantId = `${item.id}|tuned|${modHash}`; + itemsById.set(variantId, item); + return { ...piece, id: variantId, stats: tunedStats as ArmorStats }; + }, + ), + ]; + }), + ); + return { piecesByBucket, itemsById }; + }, [filteredItems, armorEnergyRules, describeItem, bucketSpecificCosts]); + + const inputs = useMemo((): PlannerInputs | undefined => { + if (!enabled || !modelAndBlocks || !hasTargets) { + return undefined; + } + + let exoticMode: PlannerExoticMode = { type: 'none' }; + if (lockedExoticHash === LOCKED_EXOTIC_ANY_EXOTIC) { + exoticMode = { type: 'any' }; + } else if (lockedExoticHash !== undefined && lockedExoticHash > 0) { + const bucketHash = defs.InventoryItem.get(lockedExoticHash)?.inventory?.bucketTypeHash; + const bucketIndex = bucketHash !== undefined ? ArmorBucketHashes.indexOf(bucketHash) : -1; + if (bucketIndex >= 0) { + exoticMode = { type: 'locked', bucketIndex }; + } + } + + const setBonusRequirements: SetBonusRequirement[] = Object.keys(setBonuses) + .map((setHash) => ({ + setHash: Number(setHash), + count: setBonuses[Number(setHash)] ?? 0, + })) + .filter((r) => r.count > 0); + + return { + blocks: modelAndBlocks.blocks, + desiredStatRanges: deferredStatRanges, + modStatTotals: mapValues(modStatChanges, (stat) => stat.value), + piecesByBucket: mapped.piecesByBucket, + pinnedIds: ArmorBucketHashes.map((bucketHash) => pinnedItems[bucketHash]?.id), + exoticMode, + keepOwned, + setBonusRequirements, + // Mirror the worker (process-utils' precalculateStructures): auto stat + // mods use the general sockets not taken by user-locked general mods, + // and none at all when the toggle is off. + numGeneralMods: autoStatMods ? Math.max(0, 5 - lockedModMap.generalMods.length) : 0, + autoModCosts, + lockedGeneralModCosts: lockedModMap.generalMods.map(modEnergyCost), + bucketSpecificCosts, + }; + }, [ + modelAndBlocks, + hasTargets, + deferredStatRanges, + lockedExoticHash, + defs, + mapped, + pinnedItems, + setBonuses, + modStatChanges, + autoStatMods, + autoModCosts, + lockedModMap, + bucketSpecificCosts, + keepOwned, + enabled, + ]); + + const planState = usePlannerWorker(inputs); + + if (!hasModelSource) { + return null; + } + + const plan = planState?.result; + const farmCount = plan ? totalFarmCount(plan.farm) : 0; + const keepItems: DimItem[] = plan + ? filterMap([plan.exoticId, ...plan.keepIds], (id) => + id !== undefined ? mapped.itemsById.get(id) : undefined, + ) + : []; + // The worker is ground truth for owned armor — it models artifice sockets and + // every exotic copy, which the planner's model doesn't. + const alreadyBuildable = keepOwned && ownedSets === 'found'; + // Its verdict decides whether we say anything at all, so wait for it. + const awaitingWorker = keepOwned && ownedSets === 'pending'; + + const modLines = plan + ? armorStats.flatMap((statHash) => { + const statDef = defs.Stat.get(statHash); + const stat = statDef?.displayProperties.name ?? statHash; + const kinds = [ + { + suffix: 'major', + numMods: plan.modsPerStat[statHash], + label: t('LoadoutBuilder.FarmingPlannerMod', { stat }), + }, + { + suffix: 'minor', + numMods: plan.minorModsPerStat[statHash], + label: t('LoadoutBuilder.FarmingPlannerModMinor', { stat }), + }, + { + suffix: 'tuning', + numMods: plan.tunesPerStat[statHash], + label: t('LoadoutBuilder.FarmingPlannerTuning', { stat }), + }, + ]; + return kinds + .filter(({ numMods }) => numMods > 0) + .map(({ suffix, numMods, label }) => ({ + key: `${statHash}-${suffix}`, + numMods, + label, + statDef, + })); + }) + : []; + + // The single outcome line. The worker-verified "already buildable" and the + // planner's own zero-farm result read the same to the user. + let verdict: string | undefined; + if (plan) { + if (alreadyBuildable || (plan.shortfall === 0 && farmCount === 0)) { + verdict = t('LoadoutBuilder.FarmingPlannerAlreadyBuildable'); + } else if (plan.shortfall > 0) { + verdict = t('LoadoutBuilder.FarmingPlannerUnreachable', { points: plan.shortfall }); + } else if (keepOwned) { + verdict = t('LoadoutBuilder.FarmingPlannerNeed', { count: farmCount }); + } else { + verdict = t('LoadoutBuilder.FarmingPlannerNeedIdeal', { count: farmCount }); + } + } + + return ( + + + {t('LoadoutBuilder.FarmingPlannerEnable')} + + {enabled && ( + + {t('LoadoutBuilder.FarmingPlannerKeepOwned')} + + )} + {!enabled ? null : !hasTargets ? ( +
    {t('LoadoutBuilder.FarmingPlannerNoTargets')}
    + ) : ( + plan && + planState && + !awaitingWorker && ( + <> +
    {verdict}
    + {!alreadyBuildable && ( + <> + {farmCount > 0 && ( +
      + {plan.farm.map(({ block, count }) => { + const archetypeDef = defs.InventoryItem.get(block.archetypePlugHash); + const statDef = defs.Stat.get(block.tertiaryStatHash); + return ( +
    • + {count}× + {archetypeDef && ( + + )} + {block.archetypeName} + + {statDef && ( + + )} + {statDef?.displayProperties.name} + +
    • + ); + })} + {plan.farmExotic && !plan.anyExoticMissing && ( +
    • + {t('LoadoutBuilder.FarmingPlannerFarmExotic')} +
    • + )} + {plan.farmFromSets.map(({ setHash, count }) => ( +
    • + {t('LoadoutBuilder.FarmingPlannerFromSet', { + numPieces: count, + set: + defs.EquipableItemSet.get(setHash)?.displayProperties.name ?? setHash, + })} +
    • + ))} +
    + )} + {modLines.length > 0 && ( +
      + {modLines.map(({ key, numMods, label, statDef }) => ( +
    • + {numMods}× + {statDef && ( + + )} + {label} +
    • + ))} +
    + )} + {(farmCount > 0 || plan.shortfall > 0) && keepItems.length > 0 && ( +
    + {t('LoadoutBuilder.FarmingPlannerKeep')} +
    + {keepItems.map((item) => ( + + + {(ref, onClick) => ( + + )} + + + ))} +
    +
    + )} + {plan.exoticMissing && ( +
    + {t('LoadoutBuilder.FarmingPlannerExoticMissing')} +
    + )} + {plan.anyExoticMissing && ( +
    + {t('LoadoutBuilder.FarmingPlannerAnyExoticMissing')} +
    + )} + {plan.setBonusUnsatisfiable && ( +
    + {t('LoadoutBuilder.FarmingPlannerSetImpossible')} +
    + )} + + )} +
    + {keepOwned + ? t('LoadoutBuilder.FarmingPlannerFinePrint', { tier: MAX_GEAR_TIER }) + : t('LoadoutBuilder.FarmingPlannerFinePrintIdeal', { tier: MAX_GEAR_TIER })}{' '} + {!hasIgnoredStat && t('LoadoutBuilder.FarmingPlannerTuningUncredited')} +
    + {/* Debug timings for evaluating the search — deliberately not localized. */} +
    + {plan.combosExamined.toLocaleString()} combinations,{' '} + {Math.round(planState.planTimeMs)}ms +
    + + ) + )} +
    + ); +}); diff --git a/src/app/loadout-builder/hypothetical/PlannerWorker.ts b/src/app/loadout-builder/hypothetical/PlannerWorker.ts new file mode 100644 index 0000000000..1b5a3459d2 --- /dev/null +++ b/src/app/loadout-builder/hypothetical/PlannerWorker.ts @@ -0,0 +1,10 @@ +import { expose } from 'comlink'; +import { planForTargets } from './planner'; + +const exports = { + planForTargets, +}; + +export type PlannerWorker = typeof exports; + +expose(exports); diff --git a/src/app/loadout-builder/hypothetical/armor-archetypes.json b/src/app/loadout-builder/hypothetical/armor-archetypes.json new file mode 100644 index 0000000000..68e775171d --- /dev/null +++ b/src/app/loadout-builder/hypothetical/armor-archetypes.json @@ -0,0 +1,14 @@ +{ + "351770835": [1943323491, 4244567218], + "544009373": [2996146975, 144602215], + "549468645": [392767087, 1943323491], + "1418248448": [144602215, 392767087], + "1687144140": [4244567218, 2996146975], + "1807652646": [2996146975, 1735777505], + "2222960133": [1735777505, 1943323491], + "2230428468": [1943323491, 2996146975], + "2503381935": [392767087, 1735777505], + "2937665788": [1735777505, 144602215], + "3349393475": [4244567218, 392767087], + "4227065942": [144602215, 4244567218] +} diff --git a/src/app/loadout-builder/hypothetical/hypothetical-acquisitions.test.ts b/src/app/loadout-builder/hypothetical/hypothetical-acquisitions.test.ts new file mode 100644 index 0000000000..27c7b7a99a --- /dev/null +++ b/src/app/loadout-builder/hypothetical/hypothetical-acquisitions.test.ts @@ -0,0 +1,445 @@ +import { armorStats } from 'app/search/d2-known-values'; +import { StatHashes } from 'data/d2/generated-enums'; +import { ArmorStatHashes, ArmorStats, DesiredStatRange } from '../types'; +import { + HypotheticalArmorBlock, + planBestComposition, + planMinimumAcquisitions, + PlannerOwnedPiece, + zeroArmorStats, +} from './hypothetical-items'; +import { planForTargets, PlannerInputs, PlannerPiece, totalFarmCount } from './planner'; + +/** + * Synthetic-fixture tests for the acquisition planner — no manifest needed. + * These cover the bug-prone areas: pins, set-bonus deficits, the ideal-bound + * fallback, and mod/energy accounting. + */ + +const G = StatHashes.Grenade as ArmorStatHashes; +const S = StatHashes.Super as ArmorStatHashes; +const C = StatHashes.Class as ArmorStatHashes; +const M = StatHashes.Melee as ArmorStatHashes; +const H = StatHashes.Health as ArmorStatHashes; +const W = StatHashes.Weapons as ArmorStatHashes; + +/** A tier-5-shaped hypothetical block: primary 30 / secondary 25 / tertiary 20 / rest 5. */ +function makeBlock( + primary: ArmorStatHashes, + secondary: ArmorStatHashes, + tertiary: ArmorStatHashes, + plugHash: number, +): HypotheticalArmorBlock { + const stats = zeroArmorStats(); + for (const statHash of armorStats) { + stats[statHash] = 5; + } + stats[primary] = 30; + stats[secondary] = 25; + stats[tertiary] = 20; + return { + name: `block-${plugHash}-${tertiary}`, + archetypePlugHash: plugHash, + archetypeName: `archetype-${plugHash}`, + tertiaryStatHash: tertiary, + stats, + }; +} + +const BLOCKS = [ + makeBlock(G, S, C, 1), + makeBlock(G, C, S, 2), + makeBlock(S, G, M, 3), + makeBlock(H, C, W, 4), +]; + +function makeOwned( + name: string, + statValues: Partial>, + extra?: Partial, +): PlannerOwnedPiece { + const stats = zeroArmorStats(); + for (const statHash of armorStats) { + const value = statValues[statHash]; + if (value !== undefined) { + stats[statHash] = value; + } + } + return { name, stats, ...extra }; +} + +function makeRanges( + targets: Partial>, + ignored: ArmorStatHashes[] = [], +): DesiredStatRange[] { + return armorStats.map((statHash) => ({ + statHash, + minStat: targets[statHash] ?? 0, + maxStat: ignored.includes(statHash) ? 0 : 200, + })); +} + +const modTotal = (mods: ArmorStats) => armorStats.reduce((total, h) => total + mods[h], 0); + +describe('planMinimumAcquisitions', () => { + it('keeps owned pieces and farms nothing when targets are already met', () => { + const ownedByBucket = Array.from({ length: 5 }, (_, i) => [ + makeOwned(`owned-${i}`, { [G]: 20 }), + ]); + const plan = planMinimumAcquisitions({ + blocks: BLOCKS, + desiredStatRanges: makeRanges({ [G]: 100 }), + ownedByBucket, + numGeneralMods: 0, + }); + expect(plan.shortfall).toBe(0); + expect(totalFarmCount(plan.farm)).toBe(0); + expect(plan.keep).toHaveLength(5); + }); + + it('farms the minimum number of new pieces', () => { + // Owned pieces give 10 Grenade each, ideal drops 30. Target 110 requires + // 10k + 30(5-k) >= 110, so at most 2 owned pieces can stay. + const ownedByBucket = Array.from({ length: 5 }, (_, i) => [ + makeOwned(`owned-${i}`, { [G]: 10 }), + ]); + const plan = planMinimumAcquisitions({ + blocks: BLOCKS, + desiredStatRanges: makeRanges({ [G]: 110 }), + ownedByBucket, + numGeneralMods: 0, + }); + expect(plan.shortfall).toBe(0); + expect(totalFarmCount(plan.farm)).toBe(3); + expect(plan.keep).toHaveLength(2); + }); + + it('honors pinned slots even when the pinned piece is bad', () => { + // The pin contributes nothing toward the target, so more must be farmed. + const pinned = makeOwned('pinned', { [W]: 30 }); + const ownedByBucket = [ + [pinned], + ...Array.from({ length: 4 }, (_, i) => [makeOwned(`owned-${i}`, { [G]: 10 })]), + ]; + const plan = planMinimumAcquisitions({ + blocks: BLOCKS, + desiredStatRanges: makeRanges({ [G]: 110 }), + ownedByBucket, + requiredSlots: [0], + numGeneralMods: 0, + }); + expect(plan.shortfall).toBe(0); + expect(plan.keep.map((p) => p.name)).toContain('pinned'); + // 0 + 10k + 30(4-k) >= 110 forces k = 0: keep only the pin, farm 4. + expect(totalFarmCount(plan.farm)).toBe(4); + }); + + it('counts how many farmed pieces must come from a required set', () => { + const SET = 111; + const ownedByBucket = [ + [makeOwned('set-piece-1', { [G]: 20 }, { setBonusHash: SET })], + [makeOwned('set-piece-2', { [G]: 20 }, { setBonusHash: SET })], + [], + [], + [], + ]; + const plan = planMinimumAcquisitions({ + blocks: BLOCKS, + desiredStatRanges: makeRanges({ [G]: 100 }), + ownedByBucket, + setBonusRequirements: [{ setHash: SET, count: 4 }], + numGeneralMods: 0, + }); + expect(plan.shortfall).toBe(0); + expect(plan.keep).toHaveLength(2); + expect(totalFarmCount(plan.farm)).toBe(3); + expect(plan.farmFromSets).toEqual([{ setHash: SET, count: 2 }]); + expect(plan.setBonusUnsatisfiable).toBe(false); + }); + + it('degrades gracefully when set bonuses cannot be satisfied', () => { + // The pinned piece is not part of the set and only 4 pieces can be farmed, + // so a 5-piece set bonus is impossible — but we still return a plan. + const ownedByBucket = [[makeOwned('pinned', { [G]: 20 })], [], [], [], []]; + const plan = planMinimumAcquisitions({ + blocks: BLOCKS, + desiredStatRanges: makeRanges({ [G]: 50 }), + ownedByBucket, + requiredSlots: [0], + setBonusRequirements: [{ setHash: 111, count: 5 }], + numGeneralMods: 0, + }); + expect(plan.shortfall).toBe(0); + expect(plan.setBonusUnsatisfiable).toBe(true); + }); + + it('falls back to the ideal bound when targets are unreachable', () => { + const ownedByBucket = Array.from({ length: 5 }, (_, i) => [ + makeOwned(`owned-${i}`, { [G]: 10 }), + ]); + const plan = planMinimumAcquisitions({ + blocks: BLOCKS, + desiredStatRanges: makeRanges({ [G]: 200 }), + ownedByBucket, + numGeneralMods: 0, + }); + // Best possible is 5 ideal drops at 30 = 150, so 50 short; keeping owned + // (weaker) pieces can't help, so the ideal composition is the answer. + expect(plan.shortfall).toBe(50); + expect(plan.keep).toHaveLength(0); + expect(totalFarmCount(plan.farm)).toBe(5); + }); +}); + +describe('farmed-piece tuning', () => { + // 5 farmed blocks reach Grenade 150; the target is 5 higher than that. + const plan = (ignored: ArmorStatHashes[]) => + planMinimumAcquisitions({ + blocks: BLOCKS, + desiredStatRanges: makeRanges({ [G]: 155 }, ignored), + // Five empty slots: nothing owned, so all five are farmed. + ownedByBucket: [[], [], [], [], []], + numGeneralMods: 0, + }); + + it('spends a farmed tuning slot to close the last few points', () => { + const result = plan([H]); + expect(result.shortfall).toBe(0); + expect(result.tunesPerStat[G]).toBe(1); + }); + + it('grants no tuning when there is no stat to dump into', () => { + // Every stat is wanted, so the -5 would cost as much as the +5 buys. + const result = plan([]); + expect(result.shortfall).toBe(5); + expect(modTotal(result.tunesPerStat)).toBe(0); + }); + + it('gives kept pieces no farmed tuning — only the farmed ones', () => { + // Four owned pieces at Grenade 30 plus one farmed block also reach 150, + // so only that single farmed piece brings a tuning slot: +5, not +25. + const result = planMinimumAcquisitions({ + blocks: BLOCKS, + desiredStatRanges: makeRanges({ [G]: 155 }, [H]), + ownedByBucket: [ + ...Array.from({ length: 4 }, (_, i) => [makeOwned(`owned-${i}`, { [G]: 30 })]), + [], + ], + numGeneralMods: 0, + }); + expect(result.shortfall).toBe(0); + expect(modTotal(result.tunesPerStat)).toBe(1); + }); +}); + +describe('mod and energy accounting', () => { + it('prefers a minor mod when it closes the gap', () => { + // 5 ideal drops give Grenade 150; 155 needs one +5. + const plan = planBestComposition(BLOCKS, makeRanges({ [G]: 155 }), 5); + expect(plan.shortfall).toBe(0); + expect(plan.minorModsPerStat[G]).toBe(1); + expect(plan.modsPerStat[G]).toBe(0); + }); + + it('uses a major mod for a full 10-point gap', () => { + const plan = planBestComposition(BLOCKS, makeRanges({ [G]: 160 }), 5); + expect(plan.shortfall).toBe(0); + expect(plan.modsPerStat[G]).toBe(1); + expect(plan.minorModsPerStat[G]).toBe(0); + }); + + it('respects numGeneralMods = 0', () => { + const plan = planBestComposition(BLOCKS, makeRanges({ [G]: 160 }), 0); + expect(plan.shortfall).toBe(10); + expect(modTotal(plan.modsPerStat)).toBe(0); + expect(modTotal(plan.minorModsPerStat)).toBe(0); + }); + + it('falls back to minor mods when energy cannot fit a major', () => { + const plan = planBestComposition(BLOCKS, makeRanges({ [G]: 160 }), 5, undefined, 5, { + autoModCosts: { [G]: { major: 4, minor: 2 } }, + energyBudgets: [3, 3, 3, 3, 3], + }); + // A +10 costs 4 but no piece has more than 3 energy; two +5s (cost 2) work. + expect(plan.shortfall).toBe(0); + expect(plan.modsPerStat[G]).toBe(0); + expect(plan.minorModsPerStat[G]).toBe(2); + }); + + it('reserves energy for locked general mods', () => { + // Four locked mods (cost 4) claim the four 4-energy pieces; the one free + // socket sits on a 2-energy piece where only the +5 (cost 2) fits. + const plan = planBestComposition(BLOCKS, makeRanges({ [G]: 160 }), 1, undefined, 5, { + autoModCosts: { [G]: { major: 4, minor: 2 } }, + lockedGeneralModCosts: [4, 4, 4, 4], + energyBudgets: [4, 4, 4, 4, 2], + }); + expect(plan.shortfall).toBe(5); + expect(plan.modsPerStat[G]).toBe(0); + expect(plan.minorModsPerStat[G]).toBe(1); + }); + + it('accounts for low-energy owned pieces in the acquisition search', () => { + // Keeping all five owned pieces reaches Grenade 150 of 160; the +10 mod + // (cost 4) doesn't fit on any 3-energy owned piece, so two +5s close it. + const ownedByBucket = Array.from({ length: 5 }, (_, i) => [ + makeOwned(`owned-${i}`, { [G]: 30 }, { energy: 3 }), + ]); + const plan = planMinimumAcquisitions({ + blocks: BLOCKS, + desiredStatRanges: makeRanges({ [G]: 160 }), + ownedByBucket, + numGeneralMods: 5, + autoModCosts: { [G]: { major: 4, minor: 2 } }, + }); + expect(plan.shortfall).toBe(0); + expect(totalFarmCount(plan.farm)).toBe(0); + expect(plan.keep).toHaveLength(5); + expect(plan.minorModsPerStat[G]).toBe(2); + expect(plan.modsPerStat[G]).toBe(0); + }); +}); + +describe('planForTargets (worker orchestration)', () => { + function makePiece( + id: string, + statValues: Partial>, + isExotic = false, + itemId = id, + ): PlannerPiece { + return { ...makeOwned(id, statValues), id, itemId, isExotic }; + } + + it('pins by item, so every tuning variant of the pinned item stays eligible', () => { + // Same item, two tuning options. Only the second gets the set to 150. + const untuned = makePiece('helm|untuned', { [G]: 25 }, false, 'helm'); + const tuned = makePiece('helm|tuned', { [G]: 30 }, false, 'helm'); + const result = planForTargets( + makeInputs({ + piecesByBucket: [[untuned, tuned], [], [], [], []], + pinnedIds: ['helm', undefined, undefined, undefined, undefined], + desiredStatRanges: makeRanges({ [G]: 150 }), + }), + ); + expect(result.shortfall).toBe(0); + expect(result.keepIds).toContain('helm|tuned'); + }); + + function makeInputs(overrides: Partial): PlannerInputs { + return { + blocks: BLOCKS, + desiredStatRanges: makeRanges({ [G]: 150 }), + modStatTotals: zeroArmorStats(), + piecesByBucket: [[], [], [], [], []], + pinnedIds: [undefined, undefined, undefined, undefined, undefined], + exoticMode: { type: 'none' }, + keepOwned: true, + setBonusRequirements: [], + numGeneralMods: 0, + lockedGeneralModCosts: [], + bucketSpecificCosts: [0, 0, 0, 0, 0], + ...overrides, + }; + } + + it('flags a locked exotic the user does not own and farms its slot', () => { + const result = planForTargets(makeInputs({ exoticMode: { type: 'locked', bucketIndex: 0 } })); + expect(result.exoticMissing).toBe(true); + expect(result.exoticId).toBeUndefined(); + expect(result.shortfall).toBe(0); + expect(totalFarmCount(result.farm)).toBe(5); + }); + + it('builds around the best owned copy of a locked exotic', () => { + const exotic = makePiece('geomag', { [G]: 30, [S]: 25 }, true); + const worse = makePiece('geomag-weak', { [G]: 30 }, true); + const result = planForTargets( + makeInputs({ + piecesByBucket: [[], [], [worse, exotic], [], []], + desiredStatRanges: makeRanges({ [G]: 150, [S]: 25 }), + exoticMode: { type: 'locked', bucketIndex: 2 }, + }), + ); + expect(result.exoticMissing).toBe(false); + expect(result.exoticId).toBe('geomag'); + expect(result.shortfall).toBe(0); + // The exotic covers its slot; the other four are farmed. + expect(totalFarmCount(result.farm)).toBe(4); + expect(result.keepIds).not.toContain('geomag'); + // An equally good plan that costs an extra drop is not an improvement. + expect(result.farmExotic).toBe(false); + }); + + it('farms a fresh copy of a locked exotic when the owned one falls short', () => { + const weakExotic = makePiece('geomag-weak', { [G]: 5 }, true); + const result = planForTargets( + makeInputs({ + piecesByBucket: [[], [], [weakExotic], [], []], + desiredStatRanges: makeRanges({ [G]: 150 }), + exoticMode: { type: 'locked', bucketIndex: 2 }, + }), + ); + // Owned: 5 + 4×30 = 125. Farmed: 5×30 = 150. + expect(result.farmExotic).toBe(true); + expect(result.exoticId).toBeUndefined(); + expect(result.exoticMissing).toBe(false); + expect(result.shortfall).toBe(0); + expect(totalFarmCount(result.farm)).toBe(5); + }); + + it('Any Exotic: tries each slot and picks the exotic that minimizes the gap', () => { + const weak = makePiece('weak-exotic', { [W]: 30 }, true); + const strong = makePiece('strong-exotic', { [G]: 30 }, true); + const result = planForTargets( + makeInputs({ + piecesByBucket: [[weak], [strong], [], [], []], + desiredStatRanges: makeRanges({ [G]: 150 }), + exoticMode: { type: 'any' }, + }), + ); + // Only the Grenade exotic reaches 30 + 4×30 = 150. + expect(result.exoticId).toBe('strong-exotic'); + expect(result.shortfall).toBe(0); + expect(result.anyExoticMissing).toBe(false); + expect(result.farmExotic).toBe(false); + }); + + it('Any Exotic: farms a new exotic when no owned copy is good enough', () => { + const wrongStat = makePiece('weapons-exotic', { [W]: 30 }, true); + const result = planForTargets( + makeInputs({ + piecesByBucket: [[wrongStat], [], [], [], []], + desiredStatRanges: makeRanges({ [G]: 150 }), + exoticMode: { type: 'any' }, + }), + ); + // Keeping it: 0 + 4×30 = 120. Farming one: 5×30 = 150. + expect(result.farmExotic).toBe(true); + expect(result.exoticId).toBeUndefined(); + // They do own an exotic — it just isn't worth using. + expect(result.anyExoticMissing).toBe(false); + expect(result.shortfall).toBe(0); + }); + + it('Any Exotic with no owned exotics degrades to ideal drops plus a note', () => { + const result = planForTargets(makeInputs({ exoticMode: { type: 'any' } })); + expect(result.anyExoticMissing).toBe(true); + expect(result.exoticId).toBeUndefined(); + expect(result.shortfall).toBe(0); + expect(totalFarmCount(result.farm)).toBe(5); + }); + + it('Any Exotic respects a pinned exotic as the chosen one', () => { + const pinnedExotic = makePiece('pinned-exotic', { [W]: 30 }, true); + const betterExotic = makePiece('better-exotic', { [G]: 30 }, true); + const result = planForTargets( + makeInputs({ + piecesByBucket: [[pinnedExotic], [betterExotic], [], [], []], + pinnedIds: ['pinned-exotic', undefined, undefined, undefined, undefined], + desiredStatRanges: makeRanges({ [G]: 120 }), + exoticMode: { type: 'any' }, + }), + ); + expect(result.exoticId).toBe('pinned-exotic'); + }); +}); diff --git a/src/app/loadout-builder/hypothetical/hypothetical-items.ts b/src/app/loadout-builder/hypothetical/hypothetical-items.ts new file mode 100644 index 0000000000..d1a0b82b99 --- /dev/null +++ b/src/app/loadout-builder/hypothetical/hypothetical-items.ts @@ -0,0 +1,1323 @@ +import { D2ManifestDefinitions } from 'app/destiny2/d2-definitions'; +import { DimItem } from 'app/inventory/item-types'; +import { isPluggableItem } from 'app/inventory/store/sockets'; +import { + isPlugStatActive, + mapAndFilterInvestmentStats, +} from 'app/inventory/store/stats-conditional'; +import { calculateAssumedMasterworkStats } from 'app/loadout-drawer/loadout-utils'; +import { MAX_STAT } from 'app/loadout/known-values'; +import { armorStats } from 'app/search/d2-known-values'; +import { sumBy } from 'app/utils/collections'; +import { getArmor3StatFocus, isArmor3 } from 'app/utils/item-utils'; +import { weakMemoize } from 'app/utils/memoize'; +import { getArmor3TuningSocket, getArmorArchetype } from 'app/utils/socket-utils'; +import { emptyPlugHashes } from 'data/d2/empty-plug-hashes'; +import { PlugCategoryHashes } from 'data/d2/generated-enums'; +import { ProcessItem } from '../process-worker/types'; +import { + ArmorStatHashes, + ArmorStats, + DesiredStatRange, + majorStatBoost, + minorStatBoost, + permissiveArmorEnergyRules, + tuningStatBoost, +} from '../types'; +import armorArchetypeStats from './armor-archetypes.json'; + +/** + * Stat-target planner — https://github.com/DestinyItemManager/DIM/issues/11832 + * + * A "stat-target planner" needs to reason about armor the user does not own. + * The insight that keeps this from blowing up combinatorially: the stat block + * of a hypothetical Armor 3.0 legendary is fully determined by + * (archetype, tertiary stat) at a given gear tier — 12 archetypes x 4 + * tertiaries = 48 stat-distinct pieces, identical across all five slots. + * + * DIM has no static table of archetype stat distributions, so we combine two + * sources: archetype identities from the manifest's archetype plugs (complete, + * covers archetypes the user has never seen — but their primary/secondary + * stats only exist as localized description text, so the parsing here is + * en-manifest-only) plus the user's own items (locale-independent, and the + * source of per-tier stat values via DIM's assumed-masterwork pipeline). + * A real implementation should generate the archetype table in d2ai instead, + * like the other plug-set constants in loadout-builder/types.ts. + */ + +export interface Armor3Archetype { + plugHash: number; + name: string; + primaryStatHash: ArmorStatHashes; + secondaryStatHash: ArmorStatHashes; + /** Tertiary stats seen on real items of this archetype (validation aid). */ + observedTertiaries: Set; +} + +/** + * Assumed-masterwork stat values for one gear tier. At the maximum tier these + * are deterministic; at lower tiers stats roll in a small range and these are + * the best values observed (an upper bound). + */ +export interface Armor3TierValues { + /** The archetype's primary stat, e.g. 30 at tier 5. */ + primaryValue: number; + /** The archetype's secondary stat, e.g. 25 at tier 5. */ + secondaryValue: number; + /** The rolled tertiary stat, e.g. 20 at tier 5. */ + tertiaryValue: number; + /** The three stats not part of the roll (masterwork bonus), e.g. 5. */ + baselineValue: number; +} + +export interface Armor3ArchetypeModel { + archetypes: Armor3Archetype[]; + /** Stat values by gear tier (1-5): tiers seen in the sample, plus MAX_GEAR_TIER. */ + valuesByTier: Map; + /** The tier hypothetical blocks are built at — always MAX_GEAR_TIER. */ + gearTier: number; +} + +/** The highest tier Armor 3.0 can drop at. Tuning slots only unlock here. */ +export const MAX_GEAR_TIER = 5; + +/** + * Canonical assumed-masterwork values at MAX_GEAR_TIER, used when the player + * owns nothing that good and we have no sample to read them off. Deterministic + * at the max tier, unlike the ranges that roll at lower tiers. + */ +const maxGearTierValues: Armor3TierValues = { + primaryValue: 30, + secondaryValue: 25, + tertiaryValue: 20, + baselineValue: 5, +}; + +/** + * Energy capacity of a fully masterworked piece at MAX_GEAR_TIER. Tier 5 armor + * has 11, everything below has 10 — see `maxEnergyCapacity` in d2-known-values. + * Hypothetical pieces are tier 5 by construction, so they get 11; owned pieces + * are passed their real capacity and only fall back to this. + */ +export const MAX_ENERGY = 11; + +/** Cap on blocks the owned search considers (it materializes multisets). */ +export const MAX_SEARCH_BLOCKS = 24; + +/** A hypothetical armor piece, i.e. one stat-distinct (archetype, tertiary) combination. */ +export interface HypotheticalArmorBlock { + /** e.g. "Gunner / tertiary 4043523819" */ + name: string; + archetypePlugHash: number; + archetypeName: string; + tertiaryStatHash: ArmorStatHashes; + stats: ArmorStats; +} + +/** An ArmorStats object with every stat at 0. */ +export function zeroArmorStats(): ArmorStats { + return Object.fromEntries(armorStats.map((statHash) => [statHash, 0])) as ArmorStats; +} + +/** Is this an item the planner's stat model can be derived from? */ +export function isArmor3ModelSourceItem(item: DimItem) { + return ( + item.bucket.inArmor && + item.rarity === 'Legendary' && + item.tier > 0 && + isArmor3(item) && + Boolean(item.stats) + ); +} + +/** + * Every way this owned piece's tuning slot could be plugged, as stat blocks. + * Unlike a farmed piece — which we assume rolls its tuning wherever we want — + * an owned legendary can only take the tuning mods its slot actually rolled. + * (Exotics have no such restriction and expose all of them.) + * + * This deliberately keeps none of the LO mapper's dump-stat filtering: that + * filter needs the desired stat ranges, and the planner ranks and caps these + * candidates itself. Staying independent of the stat ranges is what lets the + * caller rebuild these on inventory changes alone rather than per slider drag. + */ +export function tuningVariantStats( + item: DimItem, + stats: { [statHash: number]: number }, +): { modHash: number; stats: { [statHash: number]: number } }[] { + const tuningSocket = getArmor3TuningSocket(item); + if (!tuningSocket?.reusablePlugItems?.length) { + return []; + } + const plugDefsByHash = new Map( + tuningSocket.plugSet?.plugs.map((p) => [p.plugDef.hash, p.plugDef]), + ); + const variants: { modHash: number; stats: { [statHash: number]: number } }[] = []; + for (const { plugItemHash, enabled } of tuningSocket.reusablePlugItems) { + if (!enabled || emptyPlugHashes.has(plugItemHash)) { + continue; + } + const def = plugDefsByHash.get(plugItemHash); + if (!def || !isPluggableItem(def) || !def.investmentStats?.length) { + continue; + } + const tunedStats = { ...stats }; + for (const { statTypeHash, activationRule, value } of mapAndFilterInvestmentStats(def)) { + if ( + armorStats.includes(statTypeHash) && + isPlugStatActive(activationRule, { item, statHash: statTypeHash }) + ) { + tunedStats[statTypeHash] = Math.min(MAX_STAT, tunedStats[statTypeHash] + value); + } + } + variants.push({ modHash: def.hash, stats: tunedStats }); + } + return variants; +} + +/** + * All armor archetypes from the manifest. The primary/secondary stats come + * from the armor-archetypes.json table next to this file — the defs carry no + * structured stat data for these plugs, so the table is hand-maintained for + * now. It deliberately does NOT live in src/data/d2, which the d2ai build + * clears and regenerates; shipping this for real means generating it there + * instead (see the module comment above). Archetypes the table doesn't know + * yet fall back to parsing the plug description ("Primary Stat: X\nSecondary + * Stat: Y"), which only works on an English manifest. Memoized per manifest + * since it scans the whole InventoryItem table. + */ +export const archetypesFromManifest = weakMemoize( + (defs: D2ManifestDefinitions): Armor3Archetype[] => { + const archetypes: Armor3Archetype[] = []; + const known = new Set(); + for (const [plugHashStr, [primaryStatHash, secondaryStatHash]] of Object.entries( + armorArchetypeStats, + )) { + const plugHash = Number(plugHashStr); + const def = defs.InventoryItem.get(plugHash); + if (def?.displayProperties.name) { + known.add(plugHash); + archetypes.push({ + plugHash, + name: def.displayProperties.name, + primaryStatHash, + secondaryStatHash, + observedTertiaries: new Set(), + }); + } + } + + const statHashByName = new Map( + armorStats.map((statHash) => [defs.Stat.get(statHash)?.displayProperties.name, statHash]), + ); + for (const def of Object.values(defs.InventoryItem.getAll())) { + if ( + def.plug?.plugCategoryHash !== PlugCategoryHashes.ArmorArchetypes || + !def.displayProperties?.name || + known.has(def.hash) + ) { + continue; + } + const match = /Primary Stat: (.+)\nSecondary Stat: (.+)/.exec( + def.displayProperties.description, + ); + const primaryStatHash = match && statHashByName.get(match[1].trim()); + const secondaryStatHash = match && statHashByName.get(match[2].trim()); + if (primaryStatHash && secondaryStatHash) { + archetypes.push({ + plugHash: def.hash, + name: def.displayProperties.name, + primaryStatHash, + secondaryStatHash, + observedTertiaries: new Set(), + }); + } + } + return archetypes; + }, +); + +/** + * Derive the archetype stat model: archetype identities from the manifest + * (authoritative where parseable) plus the user's items (fallback for + * non-English manifests, and the source of per-tier stat values). Returns + * undefined if the items contain no usable Armor 3.0 legendaries (we need at + * least one to establish per-tier stat values). + */ +export function deriveArmor3ArchetypeModel( + allItems: DimItem[], + defs?: D2ManifestDefinitions, +): Armor3ArchetypeModel | undefined { + const archetypes = new Map(); + + // Seed from the manifest first so a single weirdly-rolled item can't + // register a wrong primary/secondary for an archetype. + if (defs) { + for (const archetype of archetypesFromManifest(defs)) { + // Fresh observedTertiaries so the memoized manifest entries stay pure. + archetypes.set(archetype.plugHash, { ...archetype, observedTertiaries: new Set() }); + } + } + + const valuesByTier = new Map(); + let observedTier = 0; + + for (const item of allItems) { + if (!isArmor3ModelSourceItem(item)) { + continue; + } + const archetypePlug = getArmorArchetype(item); + const focus = getArmor3StatFocus(item) as ArmorStatHashes[]; + if (!archetypePlug || focus.length !== 3) { + continue; + } + const [primary, secondary, tertiary] = focus; + + // Archetype identity (which stats it boosts) doesn't depend on tier. + let archetype = archetypes.get(archetypePlug.hash); + if (!archetype) { + archetype = { + plugHash: archetypePlug.hash, + name: archetypePlug.displayProperties.name, + primaryStatHash: primary, + secondaryStatHash: secondary, + observedTertiaries: new Set(), + }; + archetypes.set(archetypePlug.hash, archetype); + } else if (archetype.primaryStatHash !== primary || archetype.secondaryStatHash !== secondary) { + // Inconsistent with the manifest or previous observations — skip rather + // than poison the model. The validation test surfaces these. + continue; + } + archetype.observedTertiaries.add(tertiary); + + // Stat values scale with gear tier, so collect them per tier. + observedTier = Math.max(observedTier, item.tier); + let values = valuesByTier.get(item.tier); + if (!values) { + values = { primaryValue: 0, secondaryValue: 0, tertiaryValue: 0, baselineValue: 0 }; + valuesByTier.set(item.tier, values); + } + const stats = calculateAssumedMasterworkStats(item, permissiveArmorEnergyRules); + values.primaryValue = Math.max(values.primaryValue, stats[primary]); + values.secondaryValue = Math.max(values.secondaryValue, stats[secondary]); + values.tertiaryValue = Math.max(values.tertiaryValue, stats[tertiary]); + for (const statHash of armorStats) { + if (statHash !== primary && statHash !== secondary && statHash !== tertiary) { + values.baselineValue = Math.max(values.baselineValue, stats[statHash]); + } + } + } + + // Without at least one real item we can't sanity-check the model at all. + if (!observedTier) { + return undefined; + } + + // Hypothetical pieces are future drops, so they're planned at the highest + // tier armor can roll, not the best tier this player happens to own — + // otherwise a player without max-tier armor is told a reachable target + // is impossible. + if (!valuesByTier.has(MAX_GEAR_TIER)) { + valuesByTier.set(MAX_GEAR_TIER, { ...maxGearTierValues }); + } + + return { archetypes: [...archetypes.values()], valuesByTier, gearTier: MAX_GEAR_TIER }; +} + +/** + * The stat block the model predicts for a piece with this archetype and + * tertiary stat at the given gear tier. + */ +export function predictStats( + model: Armor3ArchetypeModel, + archetype: Armor3Archetype, + tertiaryStatHash: ArmorStatHashes, + tier: number, +): ArmorStats | undefined { + const values = model.valuesByTier.get(tier); + if (!values) { + return undefined; + } + return Object.fromEntries( + armorStats.map((statHash) => [ + statHash, + statHash === archetype.primaryStatHash + ? values.primaryValue + : statHash === archetype.secondaryStatHash + ? values.secondaryValue + : statHash === tertiaryStatHash + ? values.tertiaryValue + : values.baselineValue, + ]), + ) as ArmorStats; +} + +/** + * Enumerate every stat-distinct hypothetical armor piece at the model's gear + * tier (always MAX_GEAR_TIER): each archetype with each possible tertiary stat + * (any armor stat not already primary/secondary). + */ +export function buildHypotheticalBlocks(model: Armor3ArchetypeModel): HypotheticalArmorBlock[] { + const blocks: HypotheticalArmorBlock[] = []; + for (const archetype of model.archetypes) { + for (const tertiaryStatHash of armorStats) { + if ( + tertiaryStatHash === archetype.primaryStatHash || + tertiaryStatHash === archetype.secondaryStatHash + ) { + continue; + } + blocks.push({ + name: `${archetype.name} / tertiary ${tertiaryStatHash}`, + archetypePlugHash: archetype.plugHash, + archetypeName: archetype.name, + tertiaryStatHash, + stats: predictStats(model, archetype, tertiaryStatHash, model.gearTier)!, + }); + } + } + return blocks; +} + +/** + * Turn a hypothetical block into a ProcessItem the existing LO worker can + * consume unmodified. Fully masterworked, non-exotic, no set bonus. + */ +export function hypotheticalProcessItem( + block: HypotheticalArmorBlock, + idSuffix: string, +): ProcessItem { + return { + id: `hypothetical|${block.archetypeName}|${block.tertiaryStatHash}|${idSuffix}`, + name: block.name, + isExotic: false, + isArtifice: false, + remainingEnergyCapacity: MAX_ENERGY, + power: 10, + stats: { ...block.stats }, + }; +} + +/** + * Keep the blocks most relevant to the targeted stats, to bound search size. + * + * Every block at a given tier has the same multiset of stat values (just on + * different stats), so a straight relevance sort ties and would arbitrarily + * drop whole archetypes. Instead: within each archetype, drop all but one + * block whose tertiary lands on an ignored stat (they're interchangeable), + * order the rest targeted-tertiary-first, and select round-robin across + * archetypes so every archetype stays represented. + */ +export function pruneBlocksForTargets( + blocks: HypotheticalArmorBlock[], + desiredStatRanges: DesiredStatRange[], + limit: number, +): HypotheticalArmorBlock[] { + if (blocks.length <= limit) { + return blocks; + } + // A stat with a max but no min is neither targeted nor ignored: it ranks + // last, but its blocks aren't collapsed the way ignored-stat blocks are. + const targeted = desiredStatRanges + .filter((r) => r.maxStat > 0 && r.minStat > 0) + .map(({ statHash }): ArmorStatHashes => statHash); + const ignored = new Set( + desiredStatRanges.filter((r) => r.maxStat === 0).map(({ statHash }) => statHash), + ); + + // Group by archetype, preserving block order within each group. + const groups = Map.groupBy(blocks, (block) => block.archetypePlugHash); + + const rank = (block: HypotheticalArmorBlock) => { + const idx = targeted.indexOf(block.tertiaryStatHash); + return idx >= 0 ? idx : targeted.length; + }; + for (const [plugHash, group] of groups) { + // Blocks whose tertiary is on an ignored stat are interchangeable — keep one. + let keptIgnored = false; + const deduped = group.filter((block) => { + if (!ignored.has(block.tertiaryStatHash)) { + return true; + } + if (keptIgnored) { + return false; + } + keptIgnored = true; + return true; + }); + deduped.sort((a, b) => rank(a) - rank(b)); + groups.set(plugHash, deduped); + } + + // Round-robin: the best block of each archetype, then the second-best, etc. + const result: HypotheticalArmorBlock[] = []; + for (let i = 0; result.length < limit; i++) { + let added = false; + for (const group of groups.values()) { + if (i < group.length) { + result.push(group[i]); + added = true; + if (result.length >= limit) { + break; + } + } + } + if (!added) { + break; + } + } + return result; +} + +/** Energy costs of the +10/+5 general stat mods for each stat. */ +export type PlannerAutoModCosts = { + [statHash in ArmorStatHashes]?: { major: number; minor: number }; +}; + +/** Optional energy-aware mod modeling inputs. Without them, mods are free. */ +export interface PlannerModOptions { + /** Energy costs of the auto stat mods per stat. */ + autoModCosts?: PlannerAutoModCosts; + /** Energy costs of user-locked general mods (they occupy sockets and energy). */ + lockedGeneralModCosts?: number[]; + /** Remaining energy of every piece in the set (fixed pieces + planned slots). */ + energyBudgets?: number[]; +} + +/** Precomputed per-plan-call mod data, in enabled-stat order. */ +interface ModContext { + /** General sockets available for auto stat mods. */ + numAutoMods: number; + majorCosts: number[]; + minorCosts: number[]; + /** Locked general mods' costs, descending — they claim pieces before auto mods. */ + lockedCosts: number[]; + /** The largest cost that might need to fit on a piece. */ + maxCost: number; + /** + * Whether a farmed piece's tuning slot is worth a free +5 to a targeted stat. + * A farmed drop can be assumed to roll its tuning wherever we want, and the + * paired -5 is free when there's an ignored stat to dump into. With every + * stat targeted the -5 would cost as much as the +5 buys, so we grant + * nothing rather than model the redistribution. Owned pieces don't use this + * — their tuning is fixed at drop time and comes in as stat variants. + */ + tuningPerFarmedPiece: number; +} + +function buildModContext( + numAutoMods: number, + statOrder: ArmorStatHashes[], + autoModCosts?: PlannerAutoModCosts, + lockedGeneralModCosts?: number[], + desiredStatRanges?: DesiredStatRange[], +): ModContext { + const majorCosts = statOrder.map((statHash) => autoModCosts?.[statHash]?.major ?? 0); + const minorCosts = statOrder.map((statHash) => autoModCosts?.[statHash]?.minor ?? 0); + const lockedCosts = [...(lockedGeneralModCosts ?? [])].sort((a, b) => b - a); + const maxCost = Math.max(0, ...majorCosts, ...minorCosts, ...lockedCosts); + const hasDumpStat = Boolean(desiredStatRanges?.some((r) => r.maxStat === 0)); + return { + numAutoMods, + majorCosts, + minorCosts, + lockedCosts, + maxCost, + tuningPerFarmedPiece: hasDumpStat ? tuningStatBoost : 0, + }; +} + +/** + * Spend each farmed piece's tuning slot on the largest remaining need. Free in + * both energy and sockets, so this runs before the general mods rather than + * competing with them. Mutates `needed` and `tunes`; returns the new shortfall. + */ +function applyFarmedTuning( + needed: number[], + tunes: number[], + shortfall: number, + numFarmed: number, + ctx: ModContext, +): number { + if (!ctx.tuningPerFarmedPiece) { + return shortfall; + } + for (let piece = 0; piece < numFarmed && shortfall > 0; piece++) { + let bestStat = -1; + let bestReduction = 0; + for (let s = 0; s < needed.length; s++) { + const reduction = Math.min(needed[s], ctx.tuningPerFarmedPiece); + if (reduction > bestReduction) { + bestStat = s; + bestReduction = reduction; + } + } + if (bestStat < 0) { + break; + } + needed[bestStat] -= bestReduction; + shortfall -= bestReduction; + tunes[bestStat]++; + } + return shortfall; +} + +/** + * Remove the smallest budget that fits `cost` from `budgets` (descending), + * in place and allocation-free. When nothing fits, the smallest budget is + * consumed anyway — the planner errs on the optimistic side. + */ +function consumeSmallestFittingBudget(budgets: number[], cost: number) { + let pick = budgets.length - 1; + for (let i = budgets.length - 1; i >= 0; i--) { + if (budgets[i] >= cost) { + pick = i; + break; + } + } + budgets.copyWithin(pick, pick + 1); + budgets.length--; +} + +function refillScratch(scratch: number[], source: number[]): number[] { + scratch.length = source.length; + for (let i = 0; i < source.length; i++) { + scratch[i] = source[i]; + } + return scratch; +} + +/** + * Reserve pieces for the user's locked general mods (each piece has one + * general socket) and return the energy budgets left for auto mods, sorted + * descending. Each locked mod takes the smallest budget that fits it, keeping + * the big budgets available for auto mods. + */ +function budgetsAfterLockedMods(budgets: number[], ctx: ModContext): number[] { + const remaining = [...budgets].sort((a, b) => b - a); + for (const cost of ctx.lockedCosts) { + if (!remaining.length) { + break; + } + consumeSmallestFittingBudget(remaining, cost); + } + return remaining; +} + +/** + * Greedily spend up to ctx.numAutoMods general stat mods (+10 major or +5 + * minor) on the largest remaining needs. When `budgets` is given (descending, + * after locked general mods), every mod must fit the energy of some remaining + * piece; without it mods are unconstrained. Mutates `needed`, `majors`, + * `minors` and `budgets`; returns the remaining shortfall. + */ +function applyGreedyMods( + needed: number[], + majors: number[], + minors: number[], + shortfall: number, + ctx: ModContext, + budgets?: number[], +): number { + for (let socket = 0; socket < ctx.numAutoMods && shortfall > 0; socket++) { + if (budgets?.length === 0) { + break; + } + let bestStat = -1; + let bestReduction = 0; + let bestCost = 0; + let bestIsMajor = true; + for (let s = 0; s < needed.length; s++) { + const need = needed[s]; + if (need === 0) { + continue; + } + let reduction = need > majorStatBoost ? majorStatBoost : need; + let cost = ctx.majorCosts[s]; + let isMajor = true; + // An equal-reduction minor is strictly better when it's cheaper. + if (need <= minorStatBoost && ctx.minorCosts[s] <= cost) { + cost = ctx.minorCosts[s]; + isMajor = false; + } + // budgets[0] is the largest remaining budget — the mod fits iff it fits there. + if (budgets && budgets[0] < cost) { + // The preferred mod doesn't fit anywhere; fall back to the minor. + if (isMajor && budgets[0] >= ctx.minorCosts[s]) { + reduction = need > minorStatBoost ? minorStatBoost : need; + cost = ctx.minorCosts[s]; + isMajor = false; + } else { + continue; + } + } + if (reduction > bestReduction || (reduction === bestReduction && cost < bestCost)) { + bestStat = s; + bestReduction = reduction; + bestCost = cost; + bestIsMajor = isMajor; + } + } + if (bestStat < 0) { + break; + } + needed[bestStat] -= bestReduction; + shortfall -= bestReduction; + if (bestIsMajor) { + majors[bestStat]++; + } else { + minors[bestStat]++; + } + if (budgets) { + consumeSmallestFittingBudget(budgets, bestCost); + } + } + return shortfall; +} + +/** Collapse a list of block indices into {block, count} entries. */ +function tallyBlocks(indices: number[], blocks: HypotheticalArmorBlock[]) { + const countsByIndex = new Map(); + for (const idx of indices) { + countsByIndex.set(idx, (countsByIndex.get(idx) ?? 0) + 1); + } + return [...countsByIndex.entries()].map(([idx, count]) => ({ block: blocks[idx], count })); +} + +/** Spread per-enabled-stat mod counts back into a full ArmorStats object. */ +function modsToArmorStats(mods: number[], statOrder: ArmorStatHashes[]): ArmorStats { + const result = zeroArmorStats(); + for (let s = 0; s < statOrder.length; s++) { + result[statOrder[s]] = mods[s]; + } + return result; +} + +export interface HypotheticalPlan { + /** Total stat points short of the target after armor + stat mods. 0 = reachable. */ + shortfall: number; + /** The recommended composition: how many pieces of each block to farm. */ + counts: { block: HypotheticalArmorBlock; count: number }[]; + /** Stat totals from the armor alone. */ + armorTotals: ArmorStats; + /** Number of +10 general stat mods assigned per stat. */ + modsPerStat: ArmorStats; + /** Number of +5 general stat mods assigned per stat. */ + minorModsPerStat: ArmorStats; + /** Number of farmed pieces to tune +5 into each stat. */ + tunesPerStat: ArmorStats; + /** How many 5-piece compositions were examined. */ + combosExamined: number; +} + +/** + * Find the 5-piece composition of hypothetical blocks that best satisfies the + * stat targets, allowing for auto stat mods on top. `baseStats` (mods, + * subclass, a locked exotic) are added to every composition; when set, the + * composition covers 5 - (pieces included in baseStats) slots via numSlots. + * + * Because hypothetical pieces are slot-interchangeable, sets are multisets: + * we enumerate index combinations i0 <= i1 <= ... <= i4, which is C(n+4, 5) + * combinations instead of n^5 — for n=48 that's ~2.6M instead of ~255M. + * + * Simplifications vs. the real worker: set bonuses are ignored; each farmed + * piece's tuning slot is assumed to roll a free +5 into the neediest stat + * (only when some stat is ignored, to absorb the paired -5); stat mods are up + * to numGeneralMods majors (+10) or minors (+5) assigned greedily, respecting + * per-piece energy budgets when modOptions provides them. + */ +export function planBestComposition( + blocks: HypotheticalArmorBlock[], + desiredStatRanges: DesiredStatRange[], + numGeneralMods = 5, + baseStats?: ArmorStats, + numSlots = 5, + modOptions?: PlannerModOptions, +): HypotheticalPlan { + const n = blocks.length; + // Ignored stats (max 0) are clamped to 0 and can't contribute to the score + // or the shortfall, so skip them entirely in the hot loop. + const enabledRanges = desiredStatRanges.filter((r) => r.maxStat > 0); + const numStats = enabledRanges.length; + const statOrder = enabledRanges.map(({ statHash }): ArmorStatHashes => statHash); + const minStats = enabledRanges.map((r) => r.minStat); + const maxStats = enabledRanges.map((r) => r.maxStat); + // Per-block stat arrays in enabled-stat order, for tight inner loops. + const blockStats = blocks.map((block) => statOrder.map((statHash) => block.stats[statHash])); + const base = statOrder.map((statHash) => baseStats?.[statHash] ?? 0); + + const ctx = buildModContext( + numGeneralMods, + statOrder, + modOptions?.autoModCosts, + modOptions?.lockedGeneralModCosts, + desiredStatRanges, + ); + // Energy budgets only matter when some cost exceeds some piece's budget; + // otherwise every mod fits everywhere and we can skip the bookkeeping. + const energyBudgets = modOptions?.energyBudgets; + const autoBudgets = + energyBudgets && ctx.maxCost > Math.min(...energyBudgets) + ? budgetsAfterLockedMods(energyBudgets, ctx) + : undefined; + const budgetScratch = autoBudgets ? new Array(autoBudgets.length) : undefined; + + let combosExamined = 0; + let bestShortfall = Number.MAX_SAFE_INTEGER; + let bestScore = -1; + let bestIndices: number[] | undefined; + let bestMajors: number[] | undefined; + let bestMinors: number[] | undefined; + let bestTunes: number[] | undefined; + + // Partial sums hoisted out of the inner loops, plus scratch arrays, all + // reused across iterations to avoid allocation. partials[d] holds the sum of + // base + the first d chosen blocks. + const partials = Array.from({ length: numSlots }, () => new Array(numStats)); + const needed = new Array(numStats); + const majors = new Array(numStats); + const minors = new Array(numStats); + const tunes = new Array(numStats); + const indices = new Array(numSlots); + + const evaluate = (prev: number[], lastIdx: number) => { + combosExamined++; + const last = blockStats[lastIdx]; + let shortfall = 0; + let score = 0; + for (let s = 0; s < numStats; s++) { + const value = Math.min(prev[s] + last[s], maxStats[s]); + const need = minStats[s] - value; + needed[s] = need > 0 ? need : 0; + shortfall += needed[s]; + score += value; + majors[s] = 0; + minors[s] = 0; + tunes[s] = 0; + } + if (shortfall > 0) { + // Every slot here is a farmed piece, so every slot brings a tuning slot. + shortfall = applyFarmedTuning(needed, tunes, shortfall, numSlots, ctx); + } + if (shortfall > 0) { + const budgets = + autoBudgets && budgetScratch ? refillScratch(budgetScratch, autoBudgets) : undefined; + shortfall = applyGreedyMods(needed, majors, minors, shortfall, ctx, budgets); + } + if (shortfall < bestShortfall || (shortfall === bestShortfall && score > bestScore)) { + bestShortfall = shortfall; + bestScore = score; + bestIndices = indices.slice(); + bestMajors = majors.slice(); + bestMinors = minors.slice(); + bestTunes = tunes.slice(); + } + }; + + // Enumerate non-decreasing index tuples of length numSlots. `prev` holds + // base + the blocks chosen at shallower depths. + const enumerate = (depth: number, start: number) => { + const prev = depth === 0 ? base : partials[depth - 1]; + for (let i = start; i < n; i++) { + indices[depth] = i; + if (depth === numSlots - 1) { + evaluate(prev, i); + } else { + const partial = partials[depth]; + const stats = blockStats[i]; + for (let s = 0; s < numStats; s++) { + partial[s] = prev[s] + stats[s]; + } + enumerate(depth + 1, i); + } + } + }; + if (numSlots > 0 && n > 0 && numStats > 0) { + enumerate(0, 0); + } + + if (!bestIndices || !bestMajors || !bestMinors || !bestTunes) { + // No blocks or no enabled stats — nothing to plan. + return { + shortfall: 0, + counts: [], + armorTotals: zeroArmorStats(), + modsPerStat: zeroArmorStats(), + minorModsPerStat: zeroArmorStats(), + tunesPerStat: zeroArmorStats(), + combosExamined, + }; + } + + const counts = tallyBlocks(bestIndices, blocks); + const armorTotals = zeroArmorStats(); + for (const { block, count } of counts) { + for (const statHash of armorStats) { + armorTotals[statHash] += block.stats[statHash] * count; + } + } + + return { + shortfall: bestShortfall, + counts, + armorTotals, + modsPerStat: modsToArmorStats(bestMajors, statOrder), + minorModsPerStat: modsToArmorStats(bestMinors, statOrder), + tunesPerStat: modsToArmorStats(bestTunes, statOrder), + combosExamined, + }; +} + +/** An owned armor piece the acquisition planner may keep in the build. */ +export interface PlannerOwnedPiece { + /** Display name of the owned item. */ + name: string; + stats: ArmorStats; + /** The set bonus this piece contributes to, if any. */ + setBonusHash?: number; + /** Energy left for stat mods (after locked bucket-specific mods). Default 10. */ + energy?: number; +} + +export interface SetBonusRequirement { + setHash: number; + count: number; +} + +export interface AcquisitionPlan { + /** Stat points still missing at the best solution; 0 = targets reachable. */ + shortfall: number; + /** Hypothetical pieces to farm. */ + farm: { block: HypotheticalArmorBlock; count: number }[]; + /** Owned pieces to keep alongside the farmed pieces. */ + keep: T[]; + /** How many of the farmed pieces must come from each required set. */ + farmFromSets: { setHash: number; count: number }[]; + /** True if the set bonus requirements can't be satisfied at all. */ + setBonusUnsatisfiable: boolean; + /** Number of +10 general stat mods assigned per stat. */ + modsPerStat: ArmorStats; + /** Number of +5 general stat mods assigned per stat. */ + minorModsPerStat: ArmorStats; + /** Number of farmed pieces to tune +5 into each stat. */ + tunesPerStat: ArmorStats; + /** How many combinations were examined. */ + combosExamined: number; +} + +/** + * Find the smallest number of new (hypothetical, ideal-drop) armor pieces that + * completes the user's stat targets, keeping as many owned pieces as possible. + * + * The search first computes the ideal-drops-everywhere answer over the full + * block list as an exact bound: if even that falls short, keeping owned + * (weaker) pieces can't help, so we return the ideal composition as the + * "closest" result without the expensive owned search. Otherwise, for each + * farm-count m (ascending), we try every choice of which slots keep owned + * armor, every combination of owned candidates in those slots, and every + * multiset of m hypothetical blocks (pruned to searchBlockLimit) for the rest, + * returning at the first m with a feasible solution. Set bonus requirements + * count owned pieces of the set plus farmed pieces (all archetypes drop from + * all sources, so a farmed piece can always come from the required set). + * + * Same simplifications as planBestComposition: set bonuses aside, farmed + * pieces get a free tuning +5 and stat mods are assigned greedily, respecting + * per-piece energy when the energy inputs are provided. + */ +export function planMinimumAcquisitions({ + blocks, + desiredStatRanges, + modStatTotals, + fixedPieces = [], + ownedByBucket = [], + requiredSlots = [], + setBonusRequirements = [], + numGeneralMods = 5, + searchBlockLimit = MAX_SEARCH_BLOCKS, + autoModCosts, + lockedGeneralModCosts, + fixedPieceEnergies, + farmedEnergyBySlot, + boundCache, +}: { + blocks: HypotheticalArmorBlock[]; + desiredStatRanges: DesiredStatRange[]; + /** Stat contributions (mods, subclass) that apply regardless of armor. */ + modStatTotals?: ArmorStats; + /** Stat blocks of pieces locked into the build (e.g. the chosen exotic). */ + fixedPieces?: ArmorStats[]; + /** Owned candidate pieces for each remaining slot. Length = slots to fill. */ + ownedByBucket?: T[][]; + /** Indices into ownedByBucket that must keep an owned piece (pinned items). */ + requiredSlots?: number[]; + setBonusRequirements?: SetBonusRequirement[]; + numGeneralMods?: number; + /** Cap on blocks considered in the owned search (multisets are materialized). */ + searchBlockLimit?: number; + /** Energy costs of the auto stat mods; without this, mods are assumed free. */ + autoModCosts?: PlannerAutoModCosts; + /** Energy costs of user-locked general mods. */ + lockedGeneralModCosts?: number[]; + /** Energy left for stat mods on each fixed piece (parallel to fixedPieces). */ + fixedPieceEnergies?: number[]; + /** Energy a farmed piece would have in each slot (10 minus that slot's locked mod costs). */ + farmedEnergyBySlot?: number[]; + /** + * Cross-call cache for the full-block ideal bound. Only pass the same object + * to calls whose bound inputs (blocks, ranges, base stats, slot count, + * energy budgets) are identical — e.g. the farmed-exotic candidates of one + * "Any Exotic" plan, which differ only in owned candidates. + */ + boundCache?: { plan?: HypotheticalPlan }; +}): AcquisitionPlan { + const enabledRanges = desiredStatRanges.filter((r) => r.maxStat > 0); + const numStats = enabledRanges.length; + const statOrder = enabledRanges.map(({ statHash }): ArmorStatHashes => statHash); + const minStats = enabledRanges.map((r) => r.minStat); + const maxStats = enabledRanges.map((r) => r.maxStat); + const numSlots = ownedByBucket.length; + + const baseTotals = zeroArmorStats(); + for (const statHash of armorStats) { + baseTotals[statHash] = + (modStatTotals?.[statHash] ?? 0) + sumBy(fixedPieces, (piece) => piece[statHash]); + } + + const reqSetHashes = setBonusRequirements.map((r) => r.setHash); + const reqSetCounts = setBonusRequirements.map((r) => r.count); + const reqSetTotal = sumBy(setBonusRequirements, (r) => r.count); + + // Energy left for stat mods per piece (MAX_ENERGY = a masterworked piece + // with no other mods; matches hypotheticalProcessItem). + const fixedEnergies = fixedPieces.map((_, i) => fixedPieceEnergies?.[i] ?? MAX_ENERGY); + const farmedEnergies = ownedByBucket.map((_, i) => farmedEnergyBySlot?.[i] ?? MAX_ENERGY); + const ctx = buildModContext( + numGeneralMods, + statOrder, + autoModCosts, + lockedGeneralModCosts, + desiredStatRanges, + ); + // Energy budgets only bind when some mod cost exceeds some piece's budget. + let minBudget = Math.min(...fixedEnergies, ...farmedEnergies); + for (const list of ownedByBucket) { + for (const piece of list) { + minBudget = Math.min(minBudget, piece.energy ?? MAX_ENERGY); + } + } + const constrained = ctx.maxCost > minBudget; + + // Exact ideal bound over the FULL block list, which doubles as the answer + // when no owned candidates are provided (ideal mode) or when the targets + // are unreachable even with perfect drops everywhere. + const cachedBound = boundCache?.plan; + const bound = + cachedBound ?? + planBestComposition(blocks, desiredStatRanges, numGeneralMods, baseTotals, numSlots, { + autoModCosts, + lockedGeneralModCosts, + energyBudgets: [...fixedEnergies, ...farmedEnergies], + }); + if (boundCache) { + boundCache.plan = bound; + } + // A cached bound did no new work, so it contributes nothing to the count. + const boundCombos = cachedBound ? 0 : bound.combosExamined; + const boundAsPlan = (): AcquisitionPlan => ({ + shortfall: bound.shortfall, + farm: bound.counts, + keep: [], + farmFromSets: setBonusRequirements + .map((r) => ({ setHash: r.setHash, count: Math.min(r.count, numSlots) })) + .filter((r) => r.count > 0), + setBonusUnsatisfiable: reqSetTotal > numSlots, + modsPerStat: bound.modsPerStat, + minorModsPerStat: bound.minorModsPerStat, + tunesPerStat: bound.tunesPerStat, + combosExamined: boundCombos, + }); + + const anyOwned = ownedByBucket.some((list) => list.length > 0); + if (bound.shortfall > 0 || !anyOwned || numStats === 0) { + return boundAsPlan(); + } + + // The owned search materializes multiset sums, so bound the block list. + const searchBlocks = pruneBlocksForTargets(blocks, desiredStatRanges, searchBlockLimit); + + const base = statOrder.map((statHash) => baseTotals[statHash]); + const ownedVecs = ownedByBucket.map((list) => + list.map((piece) => ({ + piece, + stats: statOrder.map((statHash) => piece.stats[statHash]), + energy: piece.energy ?? MAX_ENERGY, + })), + ); + // Component-wise best owned stats per bucket, for upper-bound pruning. + const bestOwnedVec = ownedVecs.map((candidates) => { + const best = new Array(numStats).fill(0); + for (const { stats } of candidates) { + for (let s = 0; s < numStats; s++) { + best[s] = Math.max(best[s], stats[s]); + } + } + return best; + }); + const blockVecs = searchBlocks.map((block) => statOrder.map((statHash) => block.stats[statHash])); + + // Stat sums for every multiset of search blocks, built lazily per size. + interface MultisetEntry { + sum: number[]; + indices: number[]; + } + const multisetsBySize: MultisetEntry[][] = [ + [{ sum: new Array(numStats).fill(0), indices: [] }], + ]; + // Component-wise max across each level's sums, for upper-bound pruning. + const maxMultisetSum: number[][] = [new Array(numStats).fill(0)]; + const ensureMultisets = (m: number) => { + while (multisetsBySize.length <= m) { + const entries: MultisetEntry[] = []; + const maxSum = new Array(numStats).fill(0); + for (const entry of multisetsBySize[multisetsBySize.length - 1]) { + const minIdx = entry.indices.length ? entry.indices[entry.indices.length - 1] : 0; + for (let i = minIdx; i < blockVecs.length; i++) { + const sum = entry.sum.map((v, s) => v + blockVecs[i][s]); + for (let s = 0; s < numStats; s++) { + maxSum[s] = Math.max(maxSum[s], sum[s]); + } + entries.push({ sum, indices: [...entry.indices, i] }); + } + } + multisetsBySize.push(entries); + maxMultisetSum.push(maxSum); + } + }; + + let combosExamined = boundCombos; + let setBonusUnsatisfiable = false; + interface Best { + shortfall: number; + score: number; + m: number; + keptOwned: { bucket: number; index: number }[]; + multisetIndices: number[]; + majors: number[]; + minors: number[]; + tunes: number[]; + setDeficits: number[]; + } + let best: Best | undefined; + + const needed = new Array(numStats); + const majors = new Array(numStats); + const minors = new Array(numStats); + const tunes = new Array(numStats); + const chosen: { bucket: number; index: number }[] = []; + // Per-depth scratch arrays so the recursion allocates nothing per node. + const partialStack = Array.from({ length: numSlots + 1 }, () => new Array(numStats)); + const setCountsStack = Array.from( + { length: numSlots + 1 }, + () => new Array(reqSetHashes.length), + ); + // Energy budgets for the composition being evaluated (constrained mode only): + // set up once per leaf of the owned recursion, copied per evaluate since + // applyGreedyMods consumes them. + let leafAutoBudgets: number[] | undefined; + const budgetScratch: number[] = []; + + const evaluate = (partial: number[], multiset: MultisetEntry, m: number, deficits: number[]) => { + combosExamined++; + let shortfall = 0; + let score = 0; + for (let s = 0; s < numStats; s++) { + const value = Math.min(partial[s] + multiset.sum[s], maxStats[s]); + const need = minStats[s] - value; + needed[s] = need > 0 ? need : 0; + shortfall += needed[s]; + score += value; + majors[s] = 0; + minors[s] = 0; + tunes[s] = 0; + } + if (shortfall > 0) { + // Kept pieces already carry their own (fixed) tuning in their stats. + shortfall = applyFarmedTuning(needed, tunes, shortfall, m, ctx); + } + if (shortfall > 0) { + const budgets = leafAutoBudgets ? refillScratch(budgetScratch, leafAutoBudgets) : undefined; + shortfall = applyGreedyMods(needed, majors, minors, shortfall, ctx, budgets); + } + // Preference order: fewer missing stat points, then fewer farmed pieces, + // then higher total stats. The middle term is what makes this a + // minimum-acquisition search rather than a best-stats search. + let better = false; + if (!best) { + better = true; + } else if (shortfall < best.shortfall) { + better = true; + } else if (shortfall === best.shortfall) { + better = m < best.m || (m === best.m && score > best.score); + } + if (better) { + best = { + shortfall, + score, + m, + keptOwned: chosen.slice(), + multisetIndices: multiset.indices, + majors: majors.slice(), + minors: minors.slice(), + tunes: tunes.slice(), + setDeficits: deficits.slice(), + }; + } + }; + + /** Would this subset's best case (best owned per slot + best multiset) even reach the targets? */ + const subsetUpperBoundFeasible = (keepSlots: number[], m: number) => { + let shortfall = 0; + const maxSum = maxMultisetSum[m]; + for (let s = 0; s < numStats; s++) { + let value = base[s] + maxSum[s]; + for (const bucket of keepSlots) { + value += bestOwnedVec[bucket][s]; + } + const need = minStats[s] - Math.min(value, maxStats[s]); + needed[s] = need > 0 ? need : 0; + shortfall += needed[s]; + majors[s] = 0; + minors[s] = 0; + tunes[s] = 0; + } + if (shortfall > 0) { + shortfall = applyFarmedTuning(needed, tunes, shortfall, m, ctx); + } + // Deliberately unconstrained by energy — this must stay an upper bound. + return shortfall <= 0 || applyGreedyMods(needed, majors, minors, shortfall, ctx) <= 0; + }; + + const required = new Set(requiredSlots); + const maxM = numSlots - required.size; + + for (let m = 0; m <= maxM; m++) { + ensureMultisets(m); + const multisets = multisetsBySize[m]; + for (const keepSlots of kSubsets(numSlots, numSlots - m)) { + if ( + keepSlots.some((bucket) => ownedVecs[bucket].length === 0) || + ![...required].every((r) => keepSlots.includes(r)) || + !subsetUpperBoundFeasible(keepSlots, m) + ) { + continue; + } + for (let s = 0; s < numStats; s++) { + partialStack[0][s] = base[s]; + } + setCountsStack[0].fill(0); + // Energy budgets of the farmed pieces: the slots this subset doesn't keep. + const farmSlotEnergies = constrained + ? farmedEnergies.filter((_, slot) => !keepSlots.includes(slot)) + : undefined; + const recur = (depth: number) => { + const partial = partialStack[depth]; + const setCounts = setCountsStack[depth]; + if (depth === keepSlots.length) { + let deficitTotal = 0; + const deficits = reqSetCounts.map((count, i) => { + const deficit = Math.max(0, count - setCounts[i]); + deficitTotal += deficit; + return deficit; + }); + if (deficitTotal > m) { + // Not enough farmed pieces to cover the set bonus. At the last + // possible m, degrade gracefully rather than returning nothing. + if (m < maxM) { + return; + } + setBonusUnsatisfiable = true; + } + leafAutoBudgets = farmSlotEnergies + ? budgetsAfterLockedMods( + [ + ...fixedEnergies, + ...chosen.map(({ bucket, index }) => ownedVecs[bucket][index].energy), + ...farmSlotEnergies, + ], + ctx, + ) + : undefined; + for (const multiset of multisets) { + evaluate(partial, multiset, m, deficits); + } + return; + } + const bucket = keepSlots[depth]; + const candidates = ownedVecs[bucket]; + const nextPartial = partialStack[depth + 1]; + const nextSetCounts = setCountsStack[depth + 1]; + for (let i = 0; i < candidates.length; i++) { + const owned = candidates[i]; + for (let s = 0; s < numStats; s++) { + nextPartial[s] = partial[s] + owned.stats[s]; + } + const setBonusHash = owned.piece.setBonusHash; + for (let r = 0; r < reqSetHashes.length; r++) { + nextSetCounts[r] = + setCounts[r] + + (setBonusHash !== undefined && reqSetHashes[r] === setBonusHash ? 1 : 0); + } + chosen.push({ bucket, index: i }); + recur(depth + 1); + chosen.pop(); + } + }; + recur(0); + } + if (best?.shortfall === 0 && best.m === m) { + break; + } + } + + // The pruned owned search can miss compositions the full-block bound found; + // if it came up short while the ideal bound is feasible, fall back to the + // ideal answer rather than reporting a false shortfall. + if (!best || best.shortfall > 0) { + return boundAsPlan(); + } + + const result = best; + return { + shortfall: result.shortfall, + farm: tallyBlocks(result.multisetIndices, searchBlocks), + keep: result.keptOwned.map(({ bucket, index }) => ownedVecs[bucket][index].piece), + farmFromSets: setBonusRequirements + .map((r, i) => ({ setHash: r.setHash, count: result.setDeficits[i] })) + .filter((r) => r.count > 0), + setBonusUnsatisfiable, + modsPerStat: modsToArmorStats(result.majors, statOrder), + minorModsPerStat: modsToArmorStats(result.minors, statOrder), + tunesPerStat: modsToArmorStats(result.tunes, statOrder), + combosExamined, + }; +} + +/** All k-element subsets of [0, n), each in ascending order. */ +function kSubsets(n: number, k: number): number[][] { + const results: number[][] = []; + const current: number[] = []; + const recur = (start: number) => { + if (current.length === k) { + results.push(current.slice()); + return; + } + for (let i = start; i <= n - (k - current.length); i++) { + current.push(i); + recur(i + 1); + current.pop(); + } + }; + recur(0); + return results; +} diff --git a/src/app/loadout-builder/hypothetical/hypothetical-planner.test.ts b/src/app/loadout-builder/hypothetical/hypothetical-planner.test.ts new file mode 100644 index 0000000000..7bef463cce --- /dev/null +++ b/src/app/loadout-builder/hypothetical/hypothetical-planner.test.ts @@ -0,0 +1,439 @@ +import { jest } from '@jest/globals'; +import { D2ManifestDefinitions } from 'app/destiny2/d2-definitions'; +import { DimItem } from 'app/inventory/item-types'; +import { calculateAssumedMasterworkStats } from 'app/loadout-drawer/loadout-utils'; +import { armorStats } from 'app/search/d2-known-values'; +import { getArmor3StatFocus } from 'app/utils/item-utils'; +import { infoLog } from 'app/utils/log'; +import { getArmorArchetype } from 'app/utils/socket-utils'; +import { DestinyClass } from 'bungie-api-ts/destiny2'; +import { StatHashes } from 'data/d2/generated-enums'; +import { getTestDefinitions, getTestStores } from 'testing/test-utils'; +import { process as runLoProcess } from '../process-worker/process'; +import { ProcessItemsByBucket } from '../process-worker/types'; +import { getAutoMods, mapAutoMods, mapDimItemToProcessItems } from '../process/mappers'; +import { + ArmorBucketHashes, + ArmorStatHashes, + ArmorStats, + DesiredStatRange, + permissiveArmorEnergyRules, +} from '../types'; +import { + archetypesFromManifest, + Armor3ArchetypeModel, + buildHypotheticalBlocks, + deriveArmor3ArchetypeModel, + HypotheticalArmorBlock, + hypotheticalProcessItem, + MAX_GEAR_TIER, + planBestComposition, + predictStats, + pruneBlocksForTargets, + zeroArmorStats, +} from './hypothetical-items'; + +/** + * Feasibility prototype for https://github.com/DestinyItemManager/DIM/issues/11832 + * (a stat-target planner that suggests what armor to farm). + * + * These tests derive the hypothetical-armor stat model from the real test + * profile, validate it against every owned item, and benchmark both a + * dedicated multiset enumerator and the unmodified LO worker over the + * hypothetical item space. + */ + +jest.setTimeout(600_000); + +/** The example target from the issue: 160 Grenade / 100 Super, plus some Weapons. */ +const EXAMPLE_TARGETS: { [statHash: number]: number } = { + [StatHashes.Grenade]: 160, + [StatHashes.Super]: 100, + [StatHashes.Weapons]: 60, +}; + +function makeDesiredStatRanges(targets: { [statHash: number]: number }): DesiredStatRange[] { + return armorStats.map((statHash) => ({ + statHash, + minStat: targets[statHash] ?? 0, + maxStat: 200, + })); +} + +describe('stat-target planner prototype (#11832)', () => { + let defs: D2ManifestDefinitions; + let allItems: DimItem[]; + let model: Armor3ArchetypeModel; + let blocks: HypotheticalArmorBlock[]; + let desiredStatRanges: DesiredStatRange[]; + + const statName = (statHash: number) => + defs.Stat.get(statHash)?.displayProperties.name ?? `${statHash}`; + + const describeBlock = (block: HypotheticalArmorBlock) => + `${block.archetypeName} (tertiary: ${statName(block.tertiaryStatHash)})`; + + const describeStats = (stats: ArmorStats) => + armorStats.map((h) => `${statName(h)} ${stats[h]}`).join(', '); + + /** Run the unmodified LO worker with the standard options these tests share. */ + const runWorker = (filteredItems: ProcessItemsByBucket, ranges: DesiredStatRange[]) => + runLoProcess( + 0, + { + filteredItems, + modStatTotals: zeroArmorStats(), + lockedMods: { generalMods: [], activityMods: [] }, + setBonuses: {}, + requiredPerks: [], + desiredStatRanges: ranges, + anyExotic: false, + autoModOptions: mapAutoMods(getAutoMods(defs, new Set())), + autoStatMods: true, + strictUpgrades: false, + stopOnFirstSet: false, + }, + () => { + /* progress not needed */ + }, + ); + + beforeAll(async () => { + const [defsResult, stores] = await Promise.all([getTestDefinitions(), getTestStores()]); + defs = defsResult; + allItems = stores.flatMap((s) => s.items); + model = deriveArmor3ArchetypeModel(allItems, defs)!; + expect(model).toBeDefined(); + blocks = buildHypotheticalBlocks(model); + desiredStatRanges = makeDesiredStatRanges(EXAMPLE_TARGETS); + }); + + it('derives the archetype stat grid from real armor', () => { + for (const [tier, values] of [...model.valuesByTier.entries()].sort(([a], [b]) => a - b)) { + infoLog( + 'planner prototype', + `tier ${tier} values: primary ${values.primaryValue}, secondary ${values.secondaryValue}, tertiary ${values.tertiaryValue}, baseline ${values.baselineValue}`, + ); + } + for (const archetype of model.archetypes) { + infoLog( + 'planner prototype', + ` ${archetype.name}: ${statName(archetype.primaryStatHash)} / ${statName( + archetype.secondaryStatHash, + )}, observed tertiaries: ${[...archetype.observedTertiaries].map(statName).join(', ')}`, + ); + } + infoLog( + 'planner prototype', + `hypothetical space: ${blocks.length} stat-distinct pieces per slot`, + ); + + // 12 archetypes since the mid-2026 update added six new ones. + expect(model.archetypes.length).toBeGreaterThanOrEqual(12); + + // The manifest-parsed primary/secondary must agree with what the user's + // real items show for every archetype covered by both sources. + const fromManifest = archetypesFromManifest(defs); + for (const archetype of model.archetypes) { + if (archetype.observedTertiaries.size === 0) { + continue; // manifest-only, nothing to cross-check + } + const manifestEntry = fromManifest.find((a) => a.plugHash === archetype.plugHash); + expect(manifestEntry?.primaryStatHash).toBe(archetype.primaryStatHash); + expect(manifestEntry?.secondaryStatHash).toBe(archetype.secondaryStatHash); + } + + // Blocks are always planned at the tier drops can actually roll, never at + // whatever the player happens to own — otherwise someone without max-tier + // armor gets told a reachable target is impossible. + expect(model.gearTier).toBe(MAX_GEAR_TIER); + + const bestValues = model.valuesByTier.get(model.gearTier)!; + expect(bestValues.primaryValue).toBeGreaterThan(bestValues.secondaryValue); + expect(bestValues.secondaryValue).toBeGreaterThan(bestValues.tertiaryValue); + expect(bestValues.tertiaryValue).toBeGreaterThan(bestValues.baselineValue); + expect(bestValues.baselineValue).toBeGreaterThan(0); + }); + + it('predicts the stats of every owned armor 3.0 legendary at its own tier', () => { + // Empirical finding: only the maximum gear tier is deterministic. At lower + // tiers stats roll in a small range below the tier's best value, so the + // model's per-tier values are an upper bound there. The planner only ever + // builds hypothetical pieces at the max tier, where prediction is exact. + let checked = 0; + let exactAtBestTier = 0; + const violations: string[] = []; + for (const item of allItems) { + if (!item.bucket.inArmor || item.rarity !== 'Legendary' || item.tier <= 0 || !item.stats) { + continue; + } + const archetypePlug = getArmorArchetype(item); + const focus = getArmor3StatFocus(item); + if (!archetypePlug || focus.length !== 3) { + continue; + } + const archetype = model.archetypes.find((a) => a.plugHash === archetypePlug.hash); + const predicted = + archetype && predictStats(model, archetype, focus[2] as ArmorStatHashes, item.tier); + if (!predicted) { + violations.push(`${item.name}: no model entry for ${archetypePlug.displayProperties.name}`); + continue; + } + checked++; + const actual = calculateAssumedMasterworkStats(item, permissiveArmorEnergyRules); + let exact = true; + for (const statHash of armorStats) { + if (actual[statHash] > predicted[statHash]) { + violations.push( + `${item.name} (tier ${item.tier} ${archetypePlug.displayProperties.name}): ` + + `${statName(statHash)} predicted at most ${predicted[statHash]}, actual ${actual[statHash]}`, + ); + } + exact &&= actual[statHash] === predicted[statHash]; + } + if (item.tier === model.gearTier) { + if (!exact) { + violations.push(`${item.name}: not an exact match at the best tier`); + } else { + exactAtBestTier++; + } + } + } + infoLog( + 'planner prototype', + `validated ${checked} owned armor 3.0 legendaries; ` + + `${exactAtBestTier} tier-${model.gearTier} items matched the grid exactly`, + ); + expect(checked).toBeGreaterThan(0); + expect(exactAtBestTier).toBeGreaterThan(0); + expect(violations).toEqual([]); + }); + + it('plans the ideal composition over the abstract space (multiset enumerator)', () => { + const start = performance.now(); + const plan = planBestComposition(blocks, desiredStatRanges); + const ms = performance.now() - start; + + infoLog( + 'planner prototype', + `multiset enumerator: ${plan.combosExamined} compositions in ${ms.toFixed(1)}ms`, + ); + infoLog('planner prototype', ` shortfall: ${plan.shortfall} (0 = target reachable)`); + for (const { block, count } of plan.counts) { + infoLog('planner prototype', ` ${count}x ${describeBlock(block)}`); + } + infoLog('planner prototype', ` armor totals: ${describeStats(plan.armorTotals)}`); + infoLog('planner prototype', ` +10 mods: ${describeStats(plan.modsPerStat)}`); + + expect(ms).toBeLessThan(30_000); + // 160 grenade / 100 super / 60 weapons should be reachable with ideal drops + expect(plan.shortfall).toBe(0); + + // And an impossible target should be detected as impossible, with the gap quantified. + const impossible = makeDesiredStatRanges(Object.fromEntries(armorStats.map((h) => [h, 200]))); + const impossiblePlan = planBestComposition(blocks, impossible); + infoLog( + 'planner prototype', + `impossible-target shortfall: ${impossiblePlan.shortfall} stat points`, + ); + expect(impossiblePlan.shortfall).toBeGreaterThan(0); + }); + + it('cross-validates planner verdicts against the LO worker', async () => { + // The correctness guarantee: for a battery of targets, the planner and the + // real optimizer worker must agree. If the planner says reachable, its + // exact recipe fed into the worker must produce a set meeting every + // minimum. If the planner says unreachable over the FULL block space, the + // worker searching a subset of that space must find nothing. + const cases: { name: string; targets: { [statHash: number]: number } }[] = [ + { name: 'issue example', targets: EXAMPLE_TARGETS }, + { + name: 'user build', + targets: { + [StatHashes.Melee]: 70, + [StatHashes.Grenade]: 100, + [StatHashes.Super]: 170, + [StatHashes.Class]: 100, + }, + }, + { + name: 'balanced 100s', + targets: { + [StatHashes.Health]: 100, + [StatHashes.Melee]: 100, + [StatHashes.Grenade]: 100, + [StatHashes.Super]: 100, + }, + }, + { + name: 'impossible pair', + targets: { [StatHashes.Grenade]: 200, [StatHashes.Super]: 200 }, + }, + ]; + + for (const { name, targets } of cases) { + const ranges = makeDesiredStatRanges(targets); + const plan = planBestComposition(blocks, ranges); + + if (plan.shortfall === 0) { + // Materialize exactly the planner's recipe, one piece per slot. + const pieces = plan.counts.flatMap(({ block, count }) => + Array.from({ length: count }, () => block), + ); + expect(pieces).toHaveLength(5); + const filteredItems = Object.fromEntries( + ArmorBucketHashes.map((bucketHash, i) => [ + bucketHash, + [hypotheticalProcessItem(pieces[i], `${bucketHash}`)], + ]), + ) as ProcessItemsByBucket; + + const result = await runWorker(filteredItems, ranges); + infoLog( + 'planner prototype', + `cross-validate "${name}": planner reachable, worker found ${result.processInfo.numValidSets} valid sets`, + ); + expect(result.processInfo.numValidSets).toBeGreaterThan(0); + const best = result.sets[0]; + for (const statHash of armorStats) { + const minStat = targets[statHash]; + if (minStat !== undefined) { + expect(best.stats[statHash]).toBeGreaterThanOrEqual(minStat); + } + } + } else { + // The worker searches a subset of the planner's space, so any valid + // set it finds would contradict the planner's "unreachable". + const relevantBlocks = pruneBlocksForTargets(blocks, ranges, 24); + const filteredItems = Object.fromEntries( + ArmorBucketHashes.map((bucketHash) => [ + bucketHash, + relevantBlocks.map((block) => hypotheticalProcessItem(block, `${bucketHash}`)), + ]), + ) as ProcessItemsByBucket; + + const result = await runWorker(filteredItems, ranges); + infoLog( + 'planner prototype', + `cross-validate "${name}": planner short ${plan.shortfall}, worker found ${result.processInfo.numValidSets} valid sets`, + ); + expect(result.processInfo.numValidSets).toBe(0); + } + } + }); + + it('runs the hypothetical space through the unmodified LO worker', async () => { + // With 12 archetypes, 48^5 ≈ 255M ordered combos is too slow to brute-force + // through the worker; a real integration would pre-filter hypothetical + // candidates the way item-filter.ts prunes real items. Keep the 24 blocks + // most relevant to the targeted stats — the same pruning the acquisition + // planner uses. (The multiset enumerator above is the better approach + // anyway — it covers the full space.) + const relevantBlocks = pruneBlocksForTargets(blocks, desiredStatRanges, 24); + infoLog( + 'planner prototype', + `pruned hypothetical space from ${blocks.length} to ${relevantBlocks.length} blocks for the worker path`, + ); + + const filteredItems = Object.fromEntries( + ArmorBucketHashes.map((bucketHash) => [ + bucketHash, + relevantBlocks.map((block) => hypotheticalProcessItem(block, `${bucketHash}`)), + ]), + ) as ProcessItemsByBucket; + + const start = performance.now(); + const result = await runWorker(filteredItems, desiredStatRanges); + const ms = performance.now() - start; + + infoLog( + 'planner prototype', + `LO worker over hypothetical space: ${result.combos} combos in ${ms.toFixed(0)}ms ` + + `(${Math.round((result.combos / ms) * 1000)} combos/s), ` + + `${result.processInfo.numValidSets} valid sets`, + ); + expect(result.sets.length).toBeGreaterThan(0); + + const best = result.sets[0]; + const recipe = best.armor.map((id) => { + const block = blocks.find((b) => + id.startsWith(`hypothetical|${b.archetypeName}|${b.tertiaryStatHash}|`), + ); + return block ? describeBlock(block) : id; + }); + infoLog('planner prototype', ` best set: ${recipe.join(' + ')}`); + infoLog('planner prototype', ` best set stats: ${describeStats(best.stats)}`); + }); + + it('diffs the target against the best the user can actually build', async () => { + // Pick the class with the most Armor 3.0 legendaries in the profile. + const armor3Legendaries = allItems.filter( + (i) => + i.bucket.inArmor && + i.rarity === 'Legendary' && + i.tier > 0 && + i.classType !== DestinyClass.Unknown && + i.stats, + ); + const byClass = Object.groupBy(armor3Legendaries, (i) => i.classType); + const [classType, classItems] = Object.entries(byClass).sort( + ([, a], [, b]) => b.length - a.length, + )[0]; + infoLog( + 'planner prototype', + `diffing with ${classItems.length} owned armor 3.0 legendaries (class ${classType})`, + ); + + // Crude stand-in for LO's real item filtering: top 20 per slot by stat total. + // The real feature would reuse filterItems/useProcess. + const filteredItems = Object.fromEntries( + ArmorBucketHashes.map((bucketHash) => { + const processItems = classItems + .filter((i) => i.bucket.hash === bucketHash) + .map( + (dimItem) => + mapDimItemToProcessItems({ + dimItem, + armorEnergyRules: permissiveArmorEnergyRules, + desiredStatRanges, + autoStatMods: false, + })[0], + ); + processItems.sort( + (a, b) => + armorStats.reduce((acc, h) => acc + b.stats[h], 0) - + armorStats.reduce((acc, h) => acc + a.stats[h], 0), + ); + return [bucketHash, processItems.slice(0, 20)]; + }), + ) as ProcessItemsByBucket; + + const start = performance.now(); + const result = await runWorker(filteredItems, desiredStatRanges); + const ms = performance.now() - start; + infoLog( + 'planner prototype', + `LO worker over owned armor: ${result.combos} combos in ${ms.toFixed(0)}ms, ` + + `${result.processInfo.numValidSets} sets meeting the target`, + ); + + const plan = planBestComposition(blocks, desiredStatRanges); + if (result.sets.length) { + infoLog( + 'planner prototype', + ` best owned set stats: ${describeStats(result.sets[0].stats)}`, + ); + } else { + infoLog('planner prototype', ' target NOT reachable with owned armor'); + } + const verdictSuffix = plan.shortfall ? ` (short ${plan.shortfall} points)` : ''; + infoLog( + 'planner prototype', + ` planner verdict: target ${plan.shortfall === 0 ? 'IS' : 'is NOT'} reachable with ideal drops${verdictSuffix}`, + ); + for (const { block, count } of plan.counts) { + infoLog('planner prototype', ` farm ${count}x ${describeBlock(block)}`); + } + expect(result.combos).toBeGreaterThan(0); + }); +}); diff --git a/src/app/loadout-builder/hypothetical/planner.ts b/src/app/loadout-builder/hypothetical/planner.ts new file mode 100644 index 0000000000..a6b5732c81 --- /dev/null +++ b/src/app/loadout-builder/hypothetical/planner.ts @@ -0,0 +1,302 @@ +import { sumBy } from 'app/utils/collections'; +import { maxBy } from 'es-toolkit'; +import { ArmorStatHashes, ArmorStats, DesiredStatRange } from '../types'; +import { + AcquisitionPlan, + HypotheticalArmorBlock, + HypotheticalPlan, + MAX_ENERGY, + MAX_SEARCH_BLOCKS, + planMinimumAcquisitions, + PlannerAutoModCosts, + PlannerOwnedPiece, + SetBonusRequirement, +} from './hypothetical-items'; + +/** + * The full planning orchestration for the stat-target planner, as one pure + * function over structured-cloneable data so it can run in a web worker. + * All manifest-dependent work (item mapping, exotic bucket lookup, display + * names) happens on the main thread before building PlannerInputs. + */ + +/** + * How many owned candidates to consider per slot (plus set-bonus pieces). + * Candidates are tuning variants, not items, and the owned search is + * O(candidates^slots) — so raising this trades run time for the number of + * distinct items that survive alongside their variants. + */ +const OWNED_PER_SLOT = 12; +/** How many extra set-bonus candidates to consider per slot per required set. */ +const OWNED_PER_SLOT_PER_SET = 4; + +/** + * A candidate owned piece, referencing its DimItem only by id. One DimItem + * yields several pieces when its tuning slot has more than one option, so `id` + * identifies the variant and `itemId` the underlying item. + */ +export interface PlannerPiece extends PlannerOwnedPiece { + id: string; + itemId: string; + isExotic: boolean; +} + +/** How many pieces a plan asks you to farm in total. */ +export function totalFarmCount(farm: { count: number }[]) { + return sumBy(farm, ({ count }) => count); +} + +export type PlannerExoticMode = + /** No exotic constraint. */ + | { type: 'none' } + /** The set must include one exotic ("Any Exotic"). */ + | { type: 'any' } + /** A specific exotic is locked; it lives in this slot (ArmorBucketHashes index). */ + | { type: 'locked'; bucketIndex: number }; + +export interface PlannerInputs { + blocks: HypotheticalArmorBlock[]; + desiredStatRanges: DesiredStatRange[]; + /** Stat contributions (mods, subclass) that apply regardless of armor. */ + modStatTotals: ArmorStats; + /** All candidate pieces per slot, in ArmorBucketHashes order. */ + piecesByBucket: PlannerPiece[][]; + /** Pinned item id per slot (ArmorBucketHashes order), if any. */ + pinnedIds: (string | undefined)[]; + exoticMode: PlannerExoticMode; + /** Keep owned armor (minimize farming) vs. plan ideal drops everywhere. */ + keepOwned: boolean; + setBonusRequirements: SetBonusRequirement[]; + /** General sockets available for auto stat mods. */ + numGeneralMods: number; + autoModCosts?: PlannerAutoModCosts; + /** Energy costs of user-locked general mods. */ + lockedGeneralModCosts: number[]; + /** Energy consumed by locked bucket-specific mods per slot. */ + bucketSpecificCosts: number[]; +} + +export interface PlannerResult extends Omit, 'keep'> { + /** Item ids of the owned pieces to keep (excluding the exotic). */ + keepIds: string[]; + /** Item id of the exotic copy the plan builds around, if any. */ + exoticId: string | undefined; + /** The plan farms a new exotic rather than using an owned copy. */ + farmExotic: boolean; + /** The user locked an exotic they have no available copy of. */ + exoticMissing: boolean; + /** "Any Exotic" is selected but the user owns no available exotic. */ + anyExoticMissing: boolean; +} + +export function planForTargets({ + blocks, + desiredStatRanges, + modStatTotals, + piecesByBucket, + pinnedIds, + exoticMode, + keepOwned, + setBonusRequirements, + numGeneralMods, + autoModCosts, + lockedGeneralModCosts, + bucketSpecificCosts, +}: PlannerInputs): PlannerResult { + const numBuckets = piecesByBucket.length; + const enabledStats = desiredStatRanges + .filter((r) => r.maxStat > 0) + .map(({ statHash }): ArmorStatHashes => statHash); + const statTotal = (stats: ArmorStats) => + enabledStats.reduce((total, statHash) => total + stats[statHash], 0); + + // A pin names a DimItem, so it admits every tuning variant of that item. + const pinnedVariants = pinnedIds.map((id, bucketIdx) => + id !== undefined ? piecesByBucket[bucketIdx].filter((p) => p.itemId === id) : [], + ); + // The single best variant of each pinned item, for picking the exotic. + const pinnedPieces = pinnedVariants.map((variants) => + maxBy(variants, (piece) => statTotal(piece.stats)), + ); + + // Best owned legendary candidates per bucket (top pieces overall plus the + // best pieces from each required set so set bonuses stay satisfiable) — + // invariant across the exotic candidates tried below, so computed once. + const ownedCandidatesByBucket = piecesByBucket.map((entries) => { + if (!keepOwned) { + return []; + } + const scored = entries + .filter((piece) => !piece.isExotic) + .map((piece) => ({ piece, total: statTotal(piece.stats) })); + scored.sort((a, b) => b.total - a.total); + const kept = new Set(scored.slice(0, OWNED_PER_SLOT).map((s) => s.piece)); + for (const { setHash } of setBonusRequirements) { + for (const { piece } of scored + .filter((s) => s.piece.setBonusHash === setHash) + .slice(0, OWNED_PER_SLOT_PER_SET)) { + kept.add(piece); + } + } + return [...kept]; + }); + + // The full-block ideal bound is identical for every farmed-exotic candidate + // (no fixed piece, all five slots farmable), so they share one computation. + const farmedBoundCache: { plan?: HypotheticalPlan } = {}; + + // Run one acquisition plan with the given exotic (or none) locked into its + // slot. Pinned items are locked into their slots; other slots get the owned + // candidates computed above, or nothing when keepOwned is off (ideal drops). + const planWithExotic = ( + exoticPiece: PlannerPiece | undefined, + exoticBucketIdx: number | undefined, + ): AcquisitionPlan => { + const remainingBuckets: number[] = []; + for (let bucketIdx = 0; bucketIdx < numBuckets; bucketIdx++) { + if (!(exoticPiece && bucketIdx === exoticBucketIdx)) { + remainingBuckets.push(bucketIdx); + } + } + const requiredSlots: number[] = []; + const ownedByBucket = remainingBuckets.map((bucketIdx, slotIndex) => { + if (pinnedIds[bucketIdx] !== undefined) { + const variants = pinnedVariants[bucketIdx]; + if (variants.length) { + requiredSlots.push(slotIndex); + return variants; + } + return []; + } + if (!exoticPiece && bucketIdx === exoticBucketIdx) { + return []; + } + return ownedCandidatesByBucket[bucketIdx]; + }); + const farmedEnergyBySlot = remainingBuckets.map( + (bucketIdx) => MAX_ENERGY - bucketSpecificCosts[bucketIdx], + ); + + return planMinimumAcquisitions({ + blocks, + desiredStatRanges, + modStatTotals, + fixedPieces: exoticPiece ? [exoticPiece.stats] : [], + ownedByBucket, + requiredSlots, + setBonusRequirements, + numGeneralMods, + searchBlockLimit: MAX_SEARCH_BLOCKS, + autoModCosts, + lockedGeneralModCosts, + fixedPieceEnergies: exoticPiece ? [exoticPiece.energy ?? MAX_ENERGY] : [], + farmedEnergyBySlot, + boundCache: exoticPiece ? undefined : farmedBoundCache, + }); + }; + + const bestExoticIn = (bucketIdx: number) => + maxBy( + piecesByBucket[bucketIdx].filter((piece) => piece.isExotic), + (piece) => statTotal(piece.stats), + ); + + let result: AcquisitionPlan; + let exoticId: string | undefined; + let farmExotic = false; + let exoticMissing = false; + let anyExoticMissing = false; + let combosTotal = 0; + + // Farming a new exotic is a real option: exotics roll the same archetypes as + // legendaries, so an ideal exotic drop is stat-identical to a hypothetical + // block. Passing no exotic piece leaves its bucket in the farmable slots. + if (exoticMode.type === 'any') { + // "Any Exotic": the set must include one exotic. Try each slot with the + // user's best owned exotic there — and with a farmed one — and take the + // best outcome. A pinned exotic decides the slot; a pinned legendary rules + // its slot out. + const pinnedExoticBucket = pinnedPieces.findIndex((piece) => piece?.isExotic); + const candidates: { piece: PlannerPiece | undefined; bucketIdx: number }[] = []; + let ownsAnyExotic = false; + for (let bucketIdx = 0; bucketIdx < numBuckets; bucketIdx++) { + if (pinnedExoticBucket >= 0 && bucketIdx !== pinnedExoticBucket) { + continue; + } + if (pinnedIds[bucketIdx] !== undefined && !pinnedPieces[bucketIdx]?.isExotic) { + continue; + } + const piece = pinnedPieces[bucketIdx] ?? bestExoticIn(bucketIdx); + if (piece) { + candidates.push({ piece, bucketIdx }); + ownsAnyExotic = true; + } + if (pinnedIds[bucketIdx] === undefined) { + candidates.push({ piece: undefined, bucketIdx }); + } + } + // No owned exotic anywhere — every candidate farms one, so say so. + anyExoticMissing = !ownsAnyExotic; + // Owned candidates first. Each candidate is a full search, so once an owned + // exotic reaches the target we can drop every farmed candidate untried: + // farming costs a strictly greater farm count and can only win on a lower + // shortfall, which no longer exists. Same answer, roughly half the work. + candidates.sort((a, b) => (a.piece ? 0 : 1) - (b.piece ? 0 : 1)); + let best: { plan: AcquisitionPlan; piece: PlannerPiece | undefined } | undefined; + for (const { piece, bucketIdx } of candidates) { + if (!piece && best?.plan.shortfall === 0) { + continue; + } + const candidatePlan = planWithExotic(piece, bucketIdx); + combosTotal += candidatePlan.combosExamined; + if ( + !best || + candidatePlan.shortfall < best.plan.shortfall || + (candidatePlan.shortfall === best.plan.shortfall && + totalFarmCount(candidatePlan.farm) < totalFarmCount(best.plan.farm)) + ) { + best = { plan: candidatePlan, piece }; + } + } + result = best!.plan; + exoticId = best!.piece?.id; + farmExotic = !best!.piece; + } else if (exoticMode.type === 'locked') { + // The locked exotic occupies its slot with the user's best owned copy. + // filterItems already restricted the exotic's bucket to matching copies + // (by hash or name), so any exotic there is the locked one. + const exoticPiece = bestExoticIn(exoticMode.bucketIndex); + // With no available copy, its slot gets farmed, approximated by an ideal + // legendary block. + exoticMissing = !exoticPiece; + result = planWithExotic(exoticPiece, exoticMode.bucketIndex); + combosTotal += result.combosExamined; + exoticId = exoticPiece?.id; + // Only prefer farming a fresh copy when it strictly beats the owned one — an + // equal plan that costs an extra drop is not an improvement. So when the + // owned copy already reaches the target, skip the second search entirely. + if (exoticPiece && result.shortfall > 0) { + const farmedPlan = planWithExotic(undefined, exoticMode.bucketIndex); + combosTotal += farmedPlan.combosExamined; + if (farmedPlan.shortfall < result.shortfall) { + result = farmedPlan; + exoticId = undefined; + farmExotic = true; + } + } + } else { + result = planWithExotic(undefined, undefined); + combosTotal += result.combosExamined; + } + + const { keep, ...rest } = result; + return { + ...rest, + combosExamined: combosTotal, + keepIds: keep.map((piece) => piece.id), + exoticId, + farmExotic, + exoticMissing, + anyExoticMissing, + }; +} diff --git a/src/locale/en.json b/src/locale/en.json index 3c59810361..abeb045432 100644 --- a/src/locale/en.json +++ b/src/locale/en.json @@ -709,6 +709,28 @@ "ExistingLoadout": "Existing Loadout", "Exotic": "Exotic Armor", "ExoticSpecialCategory": "Special", + "FarmingPlanner": "Armor to Farm (Prototype)", + "FarmingPlannerAlreadyBuildable": "Your targets are buildable from armor you already have.", + "FarmingPlannerAnyExoticMissing": "You have no available exotic, so one of the planned pieces must be an exotic drop instead.", + "FarmingPlannerEnable": "Suggest armor to farm to meet stat goals", + "FarmingPlannerExoticMissing": "You have no available copy of the chosen exotic, so its slot is planned as an ideal drop.", + "FarmingPlannerFarmExotic": "…with one of these as a new exotic drop", + "FarmingPlannerFinePrint": "Builds around the chosen exotic and keeps your best owned armor. Farmed pieces assume ideal Tier {{tier}} legendary drops.", + "FarmingPlannerFinePrintIdeal": "Plans every unpinned slot as an ideal Tier {{tier}} legendary drop (the chosen exotic included).", + "FarmingPlannerFromSet": "…with at least {{numPieces}} of these from {{set}}", + "FarmingPlannerKeep": "Combined with your:", + "FarmingPlannerKeepOwned": "Keep armor I already own", + "FarmingPlannerMod": "+10 {{stat}} mod", + "FarmingPlannerModMinor": "+5 {{stat}} mod", + "FarmingPlannerNeedIdeal_one": "You need {{count}} piece to complete this:", + "FarmingPlannerNeedIdeal_other": "You need {{count}} pieces to complete this:", + "FarmingPlannerNeed_one": "You need {{count}} new piece to complete this:", + "FarmingPlannerNeed_other": "You need {{count}} new pieces to complete this:", + "FarmingPlannerNoTargets": "Set minimum stat values to see which armor you still need to farm.", + "FarmingPlannerSetImpossible": "The required set bonuses can't fit alongside these constraints.", + "FarmingPlannerTuning": "farmed piece tuned +5 {{stat}}", + "FarmingPlannerTuningUncredited": "Set a stat to Ignore to let farmed pieces spend their tuning slot.", + "FarmingPlannerUnreachable": "Not reachable even with ideal drops ({{points}} stat points short). Closest:", "Filter": "Settings", "IgnoreStat": "If unchecked, Loadout Optimizer will pretend this stat doesn't exist when building sets", "IncreaseStatPriority": "Increase stat priority",