Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement relative stat caps for "highest stat" trinket procs #1313

Merged
merged 4 commits into from
Jan 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading