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