Skip to content

Commit

Permalink
Merge pull request #1313 from wowsims/auto_reforge
Browse files Browse the repository at this point in the history
Implement relative stat caps for "highest stat" trinket procs
  • Loading branch information
NerdEgghead authored Jan 19, 2025
2 parents 60f6cee + 4a6ae3e commit 33c755e
Show file tree
Hide file tree
Showing 3 changed files with 145 additions and 32 deletions.
31 changes: 1 addition & 30 deletions ui/core/components/stat_weights_action.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ export class EpWeightsMenu extends BaseModal {
const result = await this.simUI.player.computeStatWeights(
TypedEvent.nextEventID(),
this.epStats,
this.makePseudoStatsForSim(),
this.epPseudoStats,
this.epReferenceStat,
progress => {
this.setSimProgress(progress);
Expand Down Expand Up @@ -406,35 +406,6 @@ export class EpWeightsMenu extends BaseModal {
this.buildSavedEPWeightsPicker();
}

// Make sure that the appropriate school-specific versions of Hit/Crit for a given spec are configured as epPseudoStats before generating the back-end stat weights request. This functionality
// allows individual spec configs to omit these PseudoStats so that they do not appear in the menu and potentially confuse users, while still guaranteeing that the school-specific components of
// the Hit Rating and Crit Rating EPs are separately calculated and stored when required for auto-Reforge.
private makePseudoStatsForSim() {
const processedPseudoStatsList = this.epPseudoStats.slice();
const tertiaryStatList = [PseudoStat.PseudoStatPhysicalHitPercent, PseudoStat.PseudoStatSpellHitPercent, PseudoStat.PseudoStatPhysicalCritPercent, PseudoStat.PseudoStatSpellCritPercent];

// Do one pass to check that all school-specific capped stats are included.
for (const tertiaryStat of tertiaryStatList) {
if (!this.epPseudoStats.includes(tertiaryStat) && this.simUI.hasCapForPseudoStat(tertiaryStat)) {
processedPseudoStatsList.push(tertiaryStat);
}
}

// Then do a second pass to possibly include the *other* school as well, since the back-end will sum the two school-specific EPs to determine the EP for the
// parent Rating stat.
for (const tertiaryStat of tertiaryStatList) {
const siblingStat = UnitStat.getSiblingPseudoStat(tertiaryStat)!;

// The heuristic we use is to exclude sibling stats from the config if they are not even displayed in the sim UI, since this means it should be safe to
// assume 0 EP value for the other school variant and save on computation time.
if (processedPseudoStatsList.includes(tertiaryStat) && !processedPseudoStatsList.includes(siblingStat) && this.simUI.hasDisplayPseudoStat(siblingStat)) {
processedPseudoStatsList.push(siblingStat);
}
}

return processedPseudoStatsList;
}

private setSimProgress(progress: ProgressMetrics) {
this.resultsViewer.setContent(
<div className="results-sim">
Expand Down
139 changes: 137 additions & 2 deletions ui/core/components/suggest_reforges_action.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,92 @@ export type ReforgeOptimizerOptions = {
additionalSoftCapTooltipInformation?: StatTooltipContent;
};

// Used to force a particular proc from trinkets like Matrix Restabilizer and Apparatus of Khaz'goroth.
class RelativeStatCap {
static relevantStats: Stat[] = [Stat.StatCritRating, Stat.StatHasteRating, Stat.StatMasteryRating];
readonly forcedHighestStat: UnitStat;
readonly constrainedStats: UnitStat[];
readonly constraintKeys: string[];

// Not comprehensive, add any other relevant offsets here as needed.
static procTrinketOffsets: Map<Stat, Map<number, number>> = new Map([
[
Stat.StatCritRating,
new Map([
[69167, 460], // Vessel of Acceleration (H)
[68995, 410], // Vessel of Acceleration (N)
]),
],
[
Stat.StatHasteRating,
new Map([
[69112, 1730], // The Hungerer (H)
[68927, 1532], // The Hungerer (N)
]),
],
[
Stat.StatMasteryRating,
new Map([
]),
],
]);

static canEnable(player: Player<any>): boolean {
const variableStatTrinkets: number[] = [69150, 68994, 69113, 68972];
return player.getGear().hasTrinketFromOptions(variableStatTrinkets);
}

constructor(forcedHighestStat: Stat, playerClass: Class) {
if (!RelativeStatCap.relevantStats.includes(forcedHighestStat)) {
throw new Error('Forced highest stat must be either Crit, Haste, or Mastery!');
}

this.forcedHighestStat = UnitStat.fromStat(forcedHighestStat);
this.constrainedStats = RelativeStatCap.relevantStats.filter(stat => stat !== forcedHighestStat).map(stat => UnitStat.fromStat(stat));
this.constraintKeys = this.constrainedStats.map(unitStat => this.forcedHighestStat.getShortName(playerClass) + "Minus" + unitStat.getShortName(playerClass));
}

updateCoefficients(coefficients: YalpsCoefficients, stat: Stat, amount: number) {
if (!RelativeStatCap.relevantStats.includes(stat)) {
return;
}

for (const [idx, constrainedStat] of this.constrainedStats.entries()) {
const coefficientKey = this.constraintKeys[idx];
const currentValue = coefficients.get(coefficientKey) || 0;

if (this.forcedHighestStat.equalsStat(stat)) {
coefficients.set(coefficientKey, currentValue + amount);
} else if (constrainedStat.equalsStat(stat)) {
coefficients.set(coefficientKey, currentValue - amount);
}
}
}

updateConstraints(constraints: YalpsConstraints, gear: Gear, baseStats: Stats) {
for (const [idx, constrainedStat] of this.constrainedStats.entries()) {
const weightedStatsArray = new Stats().withUnitStat(this.forcedHighestStat, 1).withUnitStat(constrainedStat, -1);
let minReforgeContribution = 1 - baseStats.computeEP(weightedStatsArray);
const procOffsetMap = RelativeStatCap.procTrinketOffsets.get(constrainedStat.getStat())!;

for (const trinket of gear.getTrinkets()) {
if (!trinket) {
continue;
}

const trinketId = trinket.item.id;

if (procOffsetMap.has(trinketId)) {
minReforgeContribution += procOffsetMap.get(trinketId)!;
break;
}
}

constraints.set(this.constraintKeys[idx], greaterEq(minReforgeContribution));
}
}
}

export class ReforgeOptimizer {
protected readonly simUI: IndividualSimUI<any>;
protected readonly player: Player<any>;
Expand All @@ -95,6 +181,7 @@ export class ReforgeOptimizer {
protected previousGear: Gear | null = null;
protected previousReforges = new Map<ItemSlot, ReforgeData>();
protected currentReforges = new Map<ItemSlot, ReforgeData>();
protected relativeStatCap: RelativeStatCap | null = null;

constructor(simUI: IndividualSimUI<any>, options?: ReforgeOptimizerOptions) {
this.simUI = simUI;
Expand Down Expand Up @@ -364,6 +451,44 @@ export class ReforgeOptimizer {
});
}

const forcedProcInput = new EnumPicker(null, this.player, {
id: 'reforge-optimizer-force-stat-proc',
label: 'Force Matrix/Apparatus proc',
values: [
{ name: 'Any', value: -1 },
...[...RelativeStatCap.relevantStats].map(stat => {
return {
name: UnitStat.fromStat(stat).getShortName(this.playerClass),
value: stat,
};
}),
],
changedEvent: () => this.player.gearChangeEmitter,
getValue: () => {
if (!this.relativeStatCap) {
return -1;
} else {
return this.relativeStatCap!.forcedHighestStat.getStat();
}
},
setValue: (_eventID, _player, newValue) => {
if (newValue == -1) {
this.relativeStatCap = null;
} else {
this.relativeStatCap = new RelativeStatCap(newValue, this.playerClass);
}
},
showWhen: () => {
const canEnable = RelativeStatCap.canEnable(this.player);

if (!canEnable) {
this.relativeStatCap = null;
}

return canEnable;
},
});

const freezeItemSlotsInput = new BooleanPicker(null, this.player, {
id: 'reforge-optimizer-freeze-item-slots',
label: 'Freeze item slots',
Expand Down Expand Up @@ -391,6 +516,7 @@ export class ReforgeOptimizer {
description: descriptionRef.value!,
})}
{useSoftCapBreakpointsInput?.rootElem}
{forcedProcInput.rootElem}
{this.buildSoftCapBreakpointsLimiter({ useSoftCapBreakpointsInput })}
{freezeItemSlotsInput.rootElem}
{this.buildFrozenSlotsInputs()}
Expand Down Expand Up @@ -764,7 +890,7 @@ export class ReforgeOptimizer {

// Set up YALPS model
const variables = this.buildYalpsVariables(baseGear, validatedWeights);
const constraints = this.buildYalpsConstraints(baseGear);
const constraints = this.buildYalpsConstraints(baseGear, baseStats);

// Solve in multiple passes to enforce caps
await this.solveModel(baseGear, validatedWeights, reforgeCaps, reforgeSoftCaps, variables, constraints, 75000);
Expand Down Expand Up @@ -859,6 +985,10 @@ export class ReforgeOptimizer {
this.setPseudoStatCoefficient(coefficients, PseudoStat.PseudoStatSpellHitPercent, appliedAmount);
}

// If a highest Stat constraint is to be enforced, then update the
// associated coefficient if applicable.
this.relativeStatCap?.updateCoefficients(coefficients, stat, amount);

// If the pre-cap EP for the root stat is non-zero, then apply
// the root stat directly and don't look for any children.
if (preCapEPs.getStat(stat) != 0) {
Expand All @@ -880,13 +1010,18 @@ export class ReforgeOptimizer {
coefficients.set(PseudoStat[pseudoStat], currentValue + amount);
}

buildYalpsConstraints(gear: Gear): YalpsConstraints {
buildYalpsConstraints(gear: Gear, baseStats: Stats): YalpsConstraints {
const constraints = new Map<string, Constraint>();

for (const slot of gear.getItemSlots()) {
constraints.set(ItemSlot[slot], lessEq(1));
}

if (this.relativeStatCap) {
const statsWithoutBaseMastery = baseStats.addStat(Stat.StatMasteryRating, -this.player.getBaseMastery() * Mechanics.MASTERY_RATING_PER_MASTERY_POINT);
this.relativeStatCap.updateConstraints(constraints, gear, statsWithoutBaseMastery);
}

return constraints;
}

Expand Down
7 changes: 7 additions & 0 deletions ui/core/proto_utils/gear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,13 @@ export class Gear extends BaseGear {
.includes(itemId);
}

hasTrinketFromOptions(itemIds: number[]): boolean {
return this.getTrinkets()
.filter((t): t is EquippedItem => !!t)
.map(t => t.item.id)
.some(id => itemIds.includes(id));
}

hasRelic(itemId: number): boolean {
const relicItem = this.getEquippedItem(ItemSlot.ItemSlotRanged);

Expand Down

0 comments on commit 33c755e

Please sign in to comment.