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 2 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
141 changes: 139 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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trinket! is the ! needed as you null check above?


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,12 @@ 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.
if (this.relativeStatCap) {
this.relativeStatCap!.updateCoefficients(coefficients, stat, amount);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could just do: this.relativeStatCap?.updateCoefficients(coefficients, stat, amount); so it will only execute when it's defined. Otherwise the ! is not needed since you null check in the if.

}

// 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 +1012,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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, the ! shouldn't be needed due to the null check in the if`

}

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)
Copy link
Contributor

@1337LutZ 1337LutZ Jan 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A nicer way of doing this would be:

.filter((t): t is EquippedItem => !!t)

So you don't have to say t!.item.id in the map but you just handle the type guard better

.map(t => t!.item.id)
.some(id => itemIds.includes(id));
}

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

Expand Down
Loading