diff --git a/src/components/sections/results/table/ResultTableRow.jsx b/src/components/sections/results/table/ResultTableRow.jsx
index e32456520..e2fc45589 100644
--- a/src/components/sections/results/table/ResultTableRow.jsx
+++ b/src/components/sections/results/table/ResultTableRow.jsx
@@ -89,37 +89,52 @@ const ResultTableRow = ({
) : null}
- {padCellArray(
- maxSlotsLength,
- character.gear.map((affix, index) => {
- let textDecoration;
- if (exoticRarity(affix, index) && mostCommonRarity !== 'exotic')
- textDecoration = 'underline dotted #ffa405';
- if (!exoticRarity(affix, index) && mostCommonRarity !== 'ascended')
- textDecoration = 'underline dotted #fb3e8d';
+ {character.gear.length > 1 ? (
+ padCellArray(
+ maxSlotsLength,
+ character.gear.map((affix, index) => {
+ let textDecoration;
+ if (exoticRarity(affix, index) && mostCommonRarity !== 'exotic')
+ textDecoration = 'underline dotted #ffa405';
+ if (!exoticRarity(affix, index) && mostCommonRarity !== 'ascended')
+ textDecoration = 'underline dotted #fb3e8d';
- const affixFragments = affix.split(/(?=[A-Z])/).filter((fragment) => fragment !== 'And');
- const multiWordAffix = affixFragments.length > 1;
+ const affixFragments = affix
+ .split(/(?=[A-Z])/)
+ .filter((fragment) => fragment !== 'And');
+ const multiWordAffix = affixFragments.length > 1;
- const shortAffix = affixFragments
- .map((fragment) => fragment.slice(0, multiWordAffix ? 3 : 4))
- .join('');
+ const shortAffix = affixFragments
+ .map((fragment) => fragment.slice(0, multiWordAffix ? 3 : 4))
+ .join('');
- return (
-
-
- {shortAffix}
-
-
- );
- }),
+ return (
+
+
+ {shortAffix}
+
+
+ );
+ }),
+ )
+ ) : (
+
+
+ {character.gear[0]}
+
+
)}
{padCellArray(
2,
diff --git a/src/state/optimizer-parallel/optimizerSetup.ts b/src/state/optimizer-parallel/optimizerSetup.ts
index 877f8a260..adffca5a6 100644
--- a/src/state/optimizer-parallel/optimizerSetup.ts
+++ b/src/state/optimizer-parallel/optimizerSetup.ts
@@ -102,6 +102,7 @@ export function createSettings(reduxState: RootState): Settings {
secondaryMaxInfusions,
infusionMode,
slots,
+ affixes,
affixesArray,
identicalArmor,
identicalRing,
@@ -140,6 +141,7 @@ export function createSettings(reduxState: RootState): Settings {
secondaryInfusion,
secondaryMaxInfusions,
infusionMode,
+ affixes,
slots,
affixesArray,
identicalArmor,
diff --git a/src/state/optimizer/combinatorics.ts b/src/state/optimizer/combinatorics.ts
new file mode 100644
index 000000000..b157bd78e
--- /dev/null
+++ b/src/state/optimizer/combinatorics.ts
@@ -0,0 +1,67 @@
+/* eslint-disable id-length */
+/* eslint-disable import/prefer-default-export */
+
+// warning: not optimized
+function binomialCoefficient(n: number, k: number) {
+ let result = 1;
+ for (let i = 1; i <= k; i++) {
+ result *= (n + 1 - i) / i;
+ }
+ return Math.round(result);
+}
+
+/**
+ * Generates every integer partition of n of exactly length k.
+ * Also known as the stars and bars problem.
+ *
+ * @example
+ * console.log([...iteratePartitions(5, 3)])
+ *
+ * // [
+ * // [0, 0, 5],
+ * // [0, 1, 4],
+ * // [0, 2, 3],
+ * // ...
+ * // [4, 1, 0],
+ * // [5, 0, 0]
+ * // ]
+ *
+ * @param {number} n - total items to partition
+ * @param {number} k - partitions
+ * @param {boolean} mutate - whether to repeatedly yield references to the same mutated array (faster)
+ * @yields {number[]}
+ */
+export function* iteratePartitions(
+ n: number,
+ k: number,
+ mutate = false,
+): Generator {
+ if (k < 2) {
+ yield [n];
+ return;
+ }
+
+ const current: number[] = new Array(k).fill(0);
+
+ function* inner(currentCup: number, currentBalls: number): Generator {
+ if (currentCup === k - 1) {
+ current[currentCup] = currentBalls;
+ yield mutate ? current : [...current];
+ } else {
+ for (let i = 0; i <= currentBalls; i++) {
+ current[currentCup] = i;
+ for (const result of inner(currentCup + 1, currentBalls - i)) {
+ yield result;
+ }
+ }
+ }
+ }
+
+ for (const result of inner(0, n)) {
+ yield result;
+ }
+}
+
+export function iteratePartitionCount(n: number, k: number) {
+ return binomialCoefficient(k + n - 1, n);
+}
diff --git a/src/state/optimizer/optimizer.ts b/src/state/optimizer/optimizer.ts
index 61ef58b20..991ccfb9e 100644
--- a/src/state/optimizer/optimizer.ts
+++ b/src/state/optimizer/optimizer.ts
@@ -2,6 +2,7 @@
import type { ExtraFilterMode } from '../slices/controlsSlice';
import type { ExtrasType } from '../slices/extras';
import type { RootState } from '../store';
+import { iteratePartitionCount } from './combinatorics';
import {
CalculateGenerator,
Character,
@@ -19,29 +20,40 @@ interface Combination extends ExtrasCombinationEntry {
done: boolean;
list: Character[];
calculationRuns: number;
+
+ heuristicDisabled?: boolean;
+ heuristicBestResult?: Character;
+
+ heuristicCore?: OptimizerCore;
+ heuristicCalculation?: CalculateGenerator;
+ heuristicDone?: boolean;
+ heuristicCalculationRuns?: number;
}
type Result =
| {
percent: number;
+ heuristicsPercent?: number;
isChanged: true;
list: Character[];
filteredLists: Record;
}
| {
percent: number;
+ heuristicsPercent?: number;
isChanged: false;
list: undefined;
filteredLists: undefined;
};
-export function* calculate(reduxState: RootState) {
+export function* calculate(reduxState: RootState, inputCombinations?: Combination[]) {
/**
* set up input
*/
- const combinations: Combination[] = setupCombinations(reduxState).map(
- ([combination, settings]) => {
+ const combinations =
+ inputCombinations ??
+ setupCombinations(reduxState).map(([combination, settings]): Combination => {
const core = new OptimizerCore(settings);
const calculation = core.calculate();
return {
@@ -53,8 +65,7 @@ export function* calculate(reduxState: RootState) {
list: [],
calculationRuns: 0,
};
- },
- );
+ });
/**
* iteration
@@ -62,7 +73,8 @@ export function* calculate(reduxState: RootState) {
const { rankby, runsAfterThisSlot } = combinations[0].core.settings;
const calculationTotal = runsAfterThisSlot[0];
- const globalCalculationTotal = calculationTotal * combinations.length;
+ const globalCalculationTotal =
+ calculationTotal * combinations.filter(({ heuristicDisabled }) => !heuristicDisabled).length;
let i = 0;
@@ -76,7 +88,7 @@ export function* calculate(reduxState: RootState) {
const currentIndex = i;
i = (i + 1) % combinations.length;
- if (combination.done) continue;
+ if (combination.done || combination.heuristicDisabled) continue;
const {
value: { isChanged, calculationRuns, newList },
@@ -92,7 +104,7 @@ export function* calculate(reduxState: RootState) {
combination.list = newList;
combination.done = Boolean(done);
- const everyCombinationDone = combinations.every((comb) => comb.done);
+ const everyCombinationDone = combinations.every((comb) => comb.done || comb.heuristicDisabled);
const createResult = () => {
if (!isChanged) {
@@ -103,7 +115,9 @@ export function* calculate(reduxState: RootState) {
}
const normalList = combinations
- .flatMap(({ list }) => list)
+ .flatMap(({ list, heuristicBestResult }) =>
+ list.length === 0 && heuristicBestResult ? [heuristicBestResult] : list,
+ )
.filter((character) => character.attributes[rankby] >= globalWorstScore)
.sort((a, b) => characterLT(a, b, rankby))
.slice(0, 50);
@@ -112,7 +126,7 @@ export function* calculate(reduxState: RootState) {
globalWorstScore = normalList[normalList.length - 1].attributes[rankby];
const combinationBestResults = combinations
- .map(({ list }) => list[0])
+ .map(({ list, heuristicBestResult }) => list[0] ?? heuristicBestResult)
.filter(Boolean)
.sort((a, b) => characterLT(a, b, rankby));
@@ -161,9 +175,173 @@ export function* calculate(reduxState: RootState) {
}
}
+interface HeuristicCombination extends Combination {
+ heuristicCore: OptimizerCore;
+ heuristicCalculation: CalculateGenerator;
+ heuristicDone: boolean;
+ heuristicCalculationRuns: number;
+}
+
+export function* calculateHeuristic(reduxState: RootState) {
+ // 28 closely matches a single shoulderpiece
+ // 118 closely matches both a single shoulderpiece and a single back item, but is much slower
+ const split = 28;
+
+ const targetCombinationCount = 10;
+
+ /**
+ * set up input
+ */
+
+ const normalCombinations: Combination[] = setupCombinations(reduxState).map(
+ ([combination, settings]) => {
+ const core = new OptimizerCore(settings);
+ const calculation = core.calculate();
+ return {
+ ...combination,
+ settings,
+ core,
+ calculation,
+ done: false,
+ list: [],
+ calculationRuns: 0,
+ };
+ },
+ );
+
+ // don't do any heuristic stuff with few combinations
+ if (normalCombinations.length < targetCombinationCount)
+ return yield* calculate(reduxState, normalCombinations);
+
+ /**
+ * iteration
+ */
+
+ const { rankby, affixes } = normalCombinations[0].core.settings;
+
+ const calculationTotal = iteratePartitionCount(split, affixes.length);
+ const globalCalculationTotal = calculationTotal * normalCombinations.length;
+
+ const combinations: HeuristicCombination[] = normalCombinations.map((comb) => {
+ const heuristicCore = new OptimizerCore(comb.settings);
+
+ return {
+ ...comb,
+ heuristicCore,
+ heuristicCalculation: heuristicCore.calculateHeuristic(split),
+ heuristicDone: false,
+ heuristicCalculationRuns: 0,
+ };
+ });
+
+ //
+
+ let i = 0;
+
+ let iterationTimer = Date.now();
+
+ while (true) {
+ const combination = combinations[i];
+
+ const currentIndex = i;
+ i = (i + 1) % combinations.length;
+
+ if (combination.heuristicDone) continue;
+
+ const {
+ value: { isChanged, calculationRuns, newList },
+ done,
+ } = combination.heuristicCalculation.next();
+
+ combination.heuristicCalculationRuns = calculationRuns ?? 0;
+
+ const globalCalculationRuns = combinations.reduce(
+ (prev, cur) => prev + cur.heuristicCalculationRuns,
+ 0,
+ );
+ console.log(
+ `option ${currentIndex} progress: ${calculationRuns} / ${calculationTotal}. total progress: ${globalCalculationRuns} / ${globalCalculationTotal}`,
+ );
+
+ // eslint-disable-next-line prefer-destructuring
+ combination.heuristicBestResult = newList[0];
+ combination.heuristicDone = Boolean(done);
+ const everyCombinationDone = combinations.every((comb) => comb.heuristicDone);
+
+ const createResult = () => {
+ if (!isChanged) {
+ return {
+ percent: 0,
+ heuristicsPercent: Math.floor((globalCalculationRuns * 100) / globalCalculationTotal),
+ isChanged,
+ } as Result;
+ }
+
+ const combinationBestResults = combinations
+ .map(({ heuristicBestResult }) => heuristicBestResult)
+ .filter(Boolean)
+ .sort((a, b) => characterLT(a, b, rankby));
+
+ const normalList = combinationBestResults.slice(0, 50);
+
+ const findExtraBestResults = (...extrasTypes: ExtrasType[]) => {
+ const result: Character[] = [];
+ combinationBestResults.forEach((character) => {
+ const isWorse = result.some((prevChar) => {
+ const sameExtra = extrasTypes.every(
+ (extra) =>
+ prevChar.settings.extrasCombination[extra] ===
+ character.settings.extrasCombination[extra],
+ );
+
+ return sameExtra && prevChar.results!.value > character.results!.value;
+ });
+ if (!isWorse) result.push(character);
+ });
+ return result;
+ };
+
+ const filteredLists: Record = {
+ Combinations: normalList,
+ Sigils: findExtraBestResults('Sigil1', 'Sigil2'),
+ Runes: findExtraBestResults('Runes'),
+ Relics: findExtraBestResults('Relics'),
+ Nourishment: findExtraBestResults('Nourishment'),
+ Enhancement: findExtraBestResults('Enhancement'),
+ };
+
+ return {
+ percent: 0,
+ heuristicsPercent: Math.floor((globalCalculationRuns * 100) / globalCalculationTotal),
+ isChanged,
+ list: normalList,
+ filteredLists,
+ } as Result;
+ };
+
+ if (everyCombinationDone) {
+ // return createResult();
+ combinations.sort((a, b) =>
+ a.heuristicBestResult && b.heuristicBestResult
+ ? characterLT(a.heuristicBestResult, b.heuristicBestResult, rankby)
+ : 0,
+ );
+ combinations.slice(targetCombinationCount).forEach((comb) => {
+ comb.heuristicDisabled = true;
+ });
+ return yield* calculate(reduxState, combinations);
+ }
+
+ if (Date.now() - iterationTimer > UPDATE_MS) {
+ yield createResult();
+ iterationTimer = Date.now();
+ }
+ }
+}
+
let generator: ReturnType;
export const setup = (reduxState: RootState) => {
- generator = calculate(reduxState);
+ generator = calculateHeuristic(reduxState);
};
export const next = () => generator.next();
diff --git a/src/state/optimizer/optimizerCore.ts b/src/state/optimizer/optimizerCore.ts
index 90238419f..69d46e4d9 100644
--- a/src/state/optimizer/optimizerCore.ts
+++ b/src/state/optimizer/optimizerCore.ts
@@ -7,6 +7,7 @@
import { allAttributePointKeys } from '../../assets/modifierdata/metadata';
import type {
AffixName,
+ AffixNameOrCustom,
ConditionName,
DamagingConditionName,
DerivedAttributeName,
@@ -21,6 +22,7 @@ import { Attributes, INFUSION_BONUS, conditionData, conditionDataWvW } from '../
import { enumArrayIncludes, objectKeys } from '../../utils/usefulFunctions';
import type { ExtrasCombination, ShouldDisplayExtras } from '../slices/extras';
import type { GameMode } from '../slices/userSettings';
+import { iteratePartitions } from './combinatorics';
import type {
AppliedModifier,
CachedFormState,
@@ -103,6 +105,8 @@ export const clamp = (input: number, min: number, max: number): number => {
return input;
};
+const roundOne = (num: number) => Math.round(num * 10) / 10;
+
/*
* ------------------------------------------------------------------------
* Core Optimizer Logic
@@ -150,6 +154,9 @@ export interface OptimizerCoreSettingsPerCalculation {
affixesArray: AffixName[][];
affixStatsArray: [AttributeName, number][][][];
+ affixes: AffixNameOrCustom[];
+ heuristicsCorners?: [AttributeName, number][][];
+
shouldDisplayExtras: ShouldDisplayExtras;
cachedFormState: CachedFormState;
}
@@ -377,6 +384,76 @@ export class OptimizerCore {
};
}
+ /**
+ * A generator function that iterates synchronously through simulated builds, updating the list
+ * object with the best results. Yields periodically to allow UI to be updated or cancelled.
+ *
+ * Remember, a generator's next() function returns a plain object { value, done }.
+ *
+ * @param {number} split
+ *
+ * @yields {{done: boolean, value: {isChanged: boolean, percent: number}}} result
+ * yields {boolean} result.done - true if the calculation is finished
+ * yields {number} result.value.isChanged - true if this.list has been mutated
+ * yields {number} result.value.calculationRuns - the calculation progress
+ */
+ *calculateHeuristic(split: number) {
+ const { settings } = this;
+ const { affixes, heuristicsCorners } = settings;
+
+ if (!heuristicsCorners) {
+ return {
+ isChanged: true,
+ calculationRuns: 0,
+ newList: [],
+ };
+ }
+
+ let calculationRuns = 0;
+
+ let iterationTimer = Date.now();
+ let cycles = 0;
+ this.isChanged = true;
+
+ for (const partition of iteratePartitions(split, heuristicsCorners.length, true)) {
+ cycles++;
+
+ // pause to update UI
+ if (cycles % 1000 === 0 && Date.now() - iterationTimer > UPDATE_MS) {
+ yield {
+ isChanged: this.isChanged,
+ calculationRuns,
+ newList: this.list,
+ };
+ this.isChanged = false;
+ iterationTimer = Date.now();
+ }
+
+ const gearStats: GearStats = {};
+
+ partition.forEach((num, i) =>
+ heuristicsCorners[i].forEach(([stat, value]) => {
+ gearStats[stat] = (gearStats[stat] ?? 0) + (value * num) / split;
+ }),
+ );
+
+ const percentages = `Estimate: ${partition
+ .map((num, i) => `${roundOne((num / split) * 100)}% ${affixes[i]}`)
+ .join(', ')}`;
+
+ calculationRuns++;
+
+ // this.applyInfusionsFunction is aliased to the correct applyInfusions[mode] function during setup
+ this.applyInfusionsFunction([percentages as unknown as AffixName], gearStats);
+ }
+
+ return {
+ isChanged: this.isChanged,
+ calculationRuns,
+ newList: this.list,
+ };
+ }
+
createCharacter(
gear: Gear,
gearStats: GearStats,
diff --git a/src/state/optimizer/optimizerSetup.ts b/src/state/optimizer/optimizerSetup.ts
index cd285c5cd..2bcb8c9d5 100644
--- a/src/state/optimizer/optimizerSetup.ts
+++ b/src/state/optimizer/optimizerSetup.ts
@@ -508,6 +508,28 @@ export function createSettingsPerCalculation(
}),
);
+ // for heuristics
+ // like affixes, but each entry is an array of stats given by using that affix in every availible slot
+ // e.g. berserker with no forced affixes -> [[Power, 1381],[Precision, 961],[Ferocity, 961]]
+ const settings_heuristicsCorners = affixes.map((forcedAffix) => {
+ const statTotals: Record = {};
+ settings_affixesArray.forEach((possibleAffixes, slotindex) => {
+ const affix = possibleAffixes.includes(forcedAffix) ? forcedAffix : possibleAffixes[0];
+
+ const item = exotics?.[affix]?.[slotindex]
+ ? settings_slots[slotindex].exo
+ : settings_slots[slotindex].asc;
+ const bonuses = objectEntries(item[Affix[affix].type]);
+ for (const [type, bonus] of bonuses) {
+ for (const stat of Affix[affix].bonuses[type] ?? []) {
+ statTotals[stat] = (statTotals[stat] || 0) + bonus;
+ }
+ }
+ });
+
+ return Object.entries(statTotals);
+ });
+
// used to keep the progress counter in sync when skipping identical gear combinations.
const settings_runsAfterThisSlot: OptimizerCoreSettings['runsAfterThisSlot'] = [];
for (let index = 0; index < settings_affixesArray.length; index++) {
@@ -581,6 +603,8 @@ export function createSettingsPerCalculation(
affixStatsArray: settings_affixStatsArray,
runsAfterThisSlot: settings_runsAfterThisSlot,
gameMode,
+ affixes,
+ heuristicsCorners: settings_heuristicsCorners,
};
return settings;
diff --git a/src/state/sagas/calculationSaga.ts b/src/state/sagas/calculationSaga.ts
index 1b9bb1030..25ba8fc4b 100644
--- a/src/state/sagas/calculationSaga.ts
+++ b/src/state/sagas/calculationSaga.ts
@@ -54,8 +54,8 @@ function* runCalc() {
// eslint-disable-next-line no-loop-func
const result = yield* call(() => nextPromise);
- const { percent, isChanged, list, filteredLists } = result.value;
- currentPercent = percent;
+ const { percent, heuristicsPercent, isChanged, list, filteredLists } = result.value;
+ currentPercent = heuristicsPercent ?? percent;
if (isChanged) {
// shallow freeze as a performance optimization; immer freezes recursively instead by default