diff --git a/proto/ui.proto b/proto/ui.proto index 4972baba9c..5b21d12c04 100644 --- a/proto/ui.proto +++ b/proto/ui.proto @@ -385,6 +385,13 @@ message SavedGearSet { UnitStats bonus_stats_stats = 3; } +message SavedStatWeightSettings { + repeated Stat excluded_stats = 1; + repeated PseudoStat excluded_pseudo_stats = 2; + int32 api_version = 3; // Needed in case the Stat or PseudoStat enum orderings ever change + +} + // Local storage data for other settings. message SavedSettings { RaidBuffs raid_buffs = 1; diff --git a/sim/core/statweight.go b/sim/core/statweight.go index 088c2ca0d2..1eeb63361e 100644 --- a/sim/core/statweight.go +++ b/sim/core/statweight.go @@ -170,6 +170,7 @@ func buildStatWeightRequests(swr *proto.StatWeightsRequest) *proto.StatWeightReq for _, s := range statsToWeigh { stat := stats.UnitStatFromStat(s) statMod := defaultStatMod + if stat.EqualsStat(stats.Armor) || stat.EqualsStat(stats.BonusArmor) { statMod = defaultStatMod * 10 } diff --git a/ui/core/components/detailed_results/timeline.tsx b/ui/core/components/detailed_results/timeline.tsx index c491ec2f6d..e64b133a7d 100644 --- a/ui/core/components/detailed_results/timeline.tsx +++ b/ui/core/components/detailed_results/timeline.tsx @@ -191,6 +191,7 @@ export class Timeline extends ResultComponent { const { dpsResourcesPlotOptions, rotationLabels, rotationTimeline, rotationHiddenIdsContainer, rotationTimelineTimeRulerImage } = cachedData; this.rotationLabels.replaceChildren(...rotationLabels.cloneNode(true).childNodes); this.rotationTimeline.replaceChildren(...rotationTimeline.cloneNode(true).childNodes); + this.rotationHiddenIdsContainer.replaceChildren(...rotationHiddenIdsContainer.cloneNode(true).childNodes); this.dpsResourcesPlot.updateOptions(dpsResourcesPlotOptions); diff --git a/ui/core/components/stat_weights_action.tsx b/ui/core/components/stat_weights_action.tsx index 6e685b7bc7..c9cd76b577 100644 --- a/ui/core/components/stat_weights_action.tsx +++ b/ui/core/components/stat_weights_action.tsx @@ -2,13 +2,16 @@ import clsx from 'clsx'; import tippy from 'tippy.js'; import { ref } from 'tsx-vanilla'; +import { CURRENT_API_VERSION } from '../constants/other'; import { IndividualSimUI } from '../individual_sim_ui.jsx'; import { Player } from '../player.js'; -import { ProgressMetrics, StatWeightsResult, StatWeightValues } from '../proto/api.js'; +import { ProgressMetrics, StatWeightsResult, StatWeightValues } from '../proto/api'; import { PseudoStat, Stat, UnitStats } from '../proto/common.js'; +import { SavedStatWeightSettings } from '../proto/ui'; import { getStatName } from '../proto_utils/names.js'; import { Stats, UnitStat } from '../proto_utils/stats.js'; import { RequestTypes } from '../sim_signal_manager'; +import { SimUI } from '../sim_ui'; import { EventID, TypedEvent } from '../typed_event.js'; import { stDevToConf90 } from '../utils.js'; import { BaseModal } from './base_modal.jsx'; @@ -16,9 +19,119 @@ import { BooleanPicker } from './pickers/boolean_picker.js'; import { NumberPicker } from './pickers/number_picker.js'; import { ResultsViewer } from './results_viewer.jsx'; import { renderSavedEPWeights } from './saved_data_managers/ep_weights'; +export class StatWeightActionSettings { + private readonly storageKey: string; + readonly changeEmitter = new TypedEvent(); + + _excludedStats: Stat[] = []; + _excludedPseudoStats: PseudoStat[] = []; + + constructor(simUI: SimUI) { + this.storageKey = simUI.getStorageKey('__statweight_settings__'); + this.changeEmitter.on(() => { + const json = SavedStatWeightSettings.toJsonString(this.toProto()); + window.localStorage.setItem(this.storageKey, json); + }); + } + + set excludedStats(value: Stat[]) { + this._excludedStats = value; + } + get excludedStats(): Stat[] { + return this._excludedStats.slice(); + } + + set excludedPseudoStats(value: PseudoStat[]) { + this._excludedPseudoStats = value; + } + get excludedPseudoStats(): PseudoStat[] { + return this._excludedPseudoStats.slice(); + } + + static updateProtoVersion(_: SavedStatWeightSettings) { + // No-op, as there are no proto version migrations currently + } + + applyDefaults(eventID: EventID) { + this.excludedStats = []; + this.excludedPseudoStats = []; + this.changeEmitter.emit(eventID); + } + + load(eventID: EventID) { + const storageValue = window.localStorage.getItem(this.storageKey); + if (storageValue) { + const settingsProto = SavedStatWeightSettings.fromJsonString(storageValue, { ignoreUnknownFields: true }); + StatWeightActionSettings.updateProtoVersion(settingsProto); + + const { excludedStats, excludedPseudoStats } = settingsProto; + this.excludedStats = excludedStats || []; + this.excludedPseudoStats = excludedPseudoStats || []; + this.changeEmitter.emit(eventID); + } + } -export const addStatWeightsAction = (simUI: IndividualSimUI) => { - const epWeightsModal = new EpWeightsMenu(simUI); + toProto(): SavedStatWeightSettings { + return SavedStatWeightSettings.create({ + apiVersion: CURRENT_API_VERSION, + excludedStats: this.excludedStats, + excludedPseudoStats: this.excludedPseudoStats, + }); + } + + /** + * Check if a stat should be excluded from weight calculation. + * @param stat + * @returns true if stat should be excluded. + */ + isStatExcludedFromCalc(stat: Stat): boolean { + return !!this.excludedStats.includes(stat); + } + + /** + * Check if a pseudostat should be excluded from weight calculation. + * @param pseudoStat + * @returns true if pseudostat should be excluded. + */ + isPseudoStatExcludedFromCalc(pseudoStat: PseudoStat): boolean { + return !!this.excludedPseudoStats.includes(pseudoStat); + } + + /** + * Check if a unitstat should be excluded from weight calculation. + * @param unitstat + * @returns true if unitstat should be excluded. + */ + isUnitStatExcludedFromCalc(unitstat: UnitStat): boolean { + return unitstat.isStat() ? this.isStatExcludedFromCalc(unitstat.getStat()) : this.isPseudoStatExcludedFromCalc(unitstat.getPseudoStat()); + } + + /** + * Set whether a stat should be excluded from calculation. + * @param stat + * @param exclude + */ + setStatExcluded(eventID: EventID, stat: UnitStat, exclude: boolean) { + const updateStatEntry = (s: T, target: T[]) => { + const currentIdx = target.indexOf(s); + if (exclude) { + if (currentIdx === -1) target.push(s); + } else if (currentIdx !== -1) { + target.splice(currentIdx, 1); + } + return target; + }; + if (stat.isStat()) { + this.excludedStats = updateStatEntry(stat.getStat(), this.excludedStats); + } else { + this.excludedPseudoStats = updateStatEntry(stat.getPseudoStat(), this.excludedPseudoStats); + } + this.changeEmitter.emit(eventID); + } +} + +export const addStatWeightsAction = (simUI: IndividualSimUI, settings: StatWeightActionSettings) => { + const epWeightsModal = new EpWeightsMenu(simUI, settings); simUI.addAction('Stat Weights', 'ep-weights-action', () => { epWeightsModal.open(); }); @@ -55,6 +168,7 @@ export class EpWeightsMenu extends BaseModal { private readonly table: HTMLElement; private readonly tableBody: HTMLElement; private readonly resultsViewer: ResultsViewer; + private readonly settings: StatWeightActionSettings; private statsType: string; private epStats: Stat[]; @@ -62,7 +176,7 @@ export class EpWeightsMenu extends BaseModal { private epReferenceStat: Stat; private showAllStats = false; - constructor(simUI: IndividualSimUI) { + constructor(simUI: IndividualSimUI, settings: StatWeightActionSettings) { super(simUI.rootElem, 'ep-weights-menu', { ...getModalConfig(simUI), disposeOnClose: false }); this.header?.insertAdjacentElement('afterbegin',
Calculate Stat Weights
); @@ -71,6 +185,7 @@ export class EpWeightsMenu extends BaseModal { this.epStats = this.simUI.individualConfig.epStats; this.epPseudoStats = this.simUI.individualConfig.epPseudoStats || []; this.epReferenceStat = this.simUI.individualConfig.epReferenceStat; + this.settings = settings; const statsTable = this.buildStatsTable(); const containerRef = ref(); @@ -138,6 +253,7 @@ export class EpWeightsMenu extends BaseModal { Stat + Update {statsTable.map(({ metric, type, label, metricRef }) => { const isAction = type === 'action'; return ( @@ -152,6 +268,7 @@ export class EpWeightsMenu extends BaseModal { EP Ratio + {statsTable .filter(({ type }) => type !== 'action') .map(({ metric, type, ratioRef }) => ( @@ -277,10 +394,13 @@ export class EpWeightsMenu extends BaseModal { } }); + const epStatsToCalc = this.epStats.filter(s => !this.settings.isStatExcludedFromCalc(s)); + const epPseudoStatsToCalc = this.epPseudoStats.filter(ps => !this.settings.isPseudoStatExcludedFromCalc(ps)); + const result = await this.simUI.player.computeStatWeights( TypedEvent.nextEventID(), - this.epStats, - this.epPseudoStats, + epStatsToCalc, + epPseudoStatsToCalc, this.epReferenceStat, progress => { this.setSimProgress(progress); @@ -326,7 +446,7 @@ export class EpWeightsMenu extends BaseModal { }); button.addEventListener('click', () => { - this.simUI.player.setEpWeights(TypedEvent.nextEventID(), Stats.fromProto(weightsFunc())); + this.setEpWeightsWithoutExcluded(Stats.fromProto(weightsFunc())); this.updateTable(); }); }; @@ -384,7 +504,7 @@ export class EpWeightsMenu extends BaseModal { const scaledTmiEp = Stats.fromProto(results.tmi!.epValues).scale(epRatios[4]); const scaledPDeathEp = Stats.fromProto(results.pDeath!.epValues).scale(epRatios[5]); const newEp = scaledDpsEp.add(scaledHpsEp).add(scaledTpsEp).add(scaledDtpsEp).add(scaledTmiEp).add(scaledPDeathEp); - this.simUI.player.setEpWeights(TypedEvent.nextEventID(), newEp); + this.setEpWeightsWithoutExcluded(newEp); } else { const scaledDpsWeights = Stats.fromProto(results.dps!.weights).scale(epRatios[0]); const scaledHpsWeights = Stats.fromProto(results.hps!.weights).scale(epRatios[1]); @@ -398,7 +518,7 @@ export class EpWeightsMenu extends BaseModal { .add(scaledDtpsWeights) .add(scaledTmiWeights) .add(scaledPDeathWeights); - this.simUI.player.setEpWeights(TypedEvent.nextEventID(), newWeights); + this.setEpWeightsWithoutExcluded(newWeights); } this.updateTable(); }); @@ -406,6 +526,32 @@ export class EpWeightsMenu extends BaseModal { this.buildSavedEPWeightsPicker(); } + /** + * Set new ep weights while leaving excluded stats at their old value. + * @param newWeights + */ + private setEpWeightsWithoutExcluded(newWeights: Stats) { + const { excludedStats, excludedPseudoStats } = this.settings; + const oldWeights = this.simUI.player.getEpWeights(); + for (const stat of excludedStats) { + newWeights = newWeights.withStat(stat, oldWeights.getStat(stat)); + } + for (const pseudoStat of excludedPseudoStats) { + newWeights = newWeights.withPseudoStat(pseudoStat, oldWeights.getPseudoStat(pseudoStat)); + } + this.simUI.player.setEpWeights(TypedEvent.nextEventID(), newWeights); + } + + /** + * Check if a specific stat is included in the EP stats for this spec. + * @param stat + * @returns + */ + private isEpStat(stat: UnitStat) { + if (stat.isStat()) return this.epStats.includes(stat.getStat()); + return this.epPseudoStats.includes(stat.getPseudoStat()); + } + private setSimProgress(progress: ProgressMetrics) { this.resultsViewer.setContent(
@@ -440,13 +586,15 @@ export class EpWeightsMenu extends BaseModal { } private makeTableRow(stat: UnitStat): HTMLElement { - const result = this.simUI.prevEpSimResult; + const result = !this.settings.isUnitStatExcludedFromCalc(stat) ? this.simUI.prevEpSimResult : null; const epRatios = this.simUI.player.getEpRatios(); const rowTotalEp = scaledEpValue(stat, epRatios, result); const currentEpRef = ref(); + const includeToggleRef = ref(); const row = ( {stat.getFullName(this.simUI.player.getClass())} + {this.makeTableRowCells(stat, result?.dps, 'damage-metrics', rowTotalEp, epRatios[0])} {this.makeTableRowCells(stat, result?.hps, 'healing-metrics', rowTotalEp, epRatios[1])} {this.makeTableRowCells(stat, result?.tps, 'threat-metrics', rowTotalEp, epRatios[2])} @@ -457,6 +605,16 @@ export class EpWeightsMenu extends BaseModal { ) as HTMLElement; + if (includeToggleRef.value && this.isEpStat(stat)) { + new BooleanPicker(includeToggleRef.value, this, { + id: 'sw-stat-toggle-' + stat.getFullName(this.simUI.player.getClass()), + getValue: epWeightsModal => !epWeightsModal.settings.isUnitStatExcludedFromCalc(stat), + setValue: (eventID, epWeightsModal, newValue) => epWeightsModal.settings.setStatExcluded(eventID, stat, !newValue), + changedEvent: epWeightsModal => epWeightsModal.settings.changeEmitter, + enableWhen: epWeightsModal => !stat.isStat() || epWeightsModal.epReferenceStat != stat.getStat(), + }); + } + const currentEpCell = currentEpRef.value!; new NumberPicker(currentEpCell, this.simUI.player, { id: `ep-weight-stat-${stat}`, @@ -620,7 +778,15 @@ export class EpWeightsMenu extends BaseModal { if (stat.isStat()) { return true; } else { - return [PseudoStat.PseudoStatMainHandDps, PseudoStat.PseudoStatOffHandDps, PseudoStat.PseudoStatRangedDps, PseudoStat.PseudoStatPhysicalHitPercent, PseudoStat.PseudoStatSpellHitPercent, PseudoStat.PseudoStatPhysicalCritPercent, PseudoStat.PseudoStatSpellCritPercent].includes(stat.getPseudoStat()); + return [ + PseudoStat.PseudoStatMainHandDps, + PseudoStat.PseudoStatOffHandDps, + PseudoStat.PseudoStatRangedDps, + PseudoStat.PseudoStatPhysicalHitPercent, + PseudoStat.PseudoStatSpellHitPercent, + PseudoStat.PseudoStatPhysicalCritPercent, + PseudoStat.PseudoStatSpellCritPercent, + ].includes(stat.getPseudoStat()); } }); diff --git a/ui/core/individual_sim_ui.tsx b/ui/core/individual_sim_ui.tsx index 1041382b71..79104ed8c1 100644 --- a/ui/core/individual_sim_ui.tsx +++ b/ui/core/individual_sim_ui.tsx @@ -27,7 +27,7 @@ import * as InputHelpers from './components/input_helpers'; import { ItemNotice } from './components/item_notice/item_notice'; import { addRaidSimAction, RaidSimResultsManager } from './components/raid_sim_action'; import { SavedDataConfig } from './components/saved_data_manager'; -import { addStatWeightsAction, EpWeightsMenu } from './components/stat_weights_action'; +import { addStatWeightsAction, EpWeightsMenu, StatWeightActionSettings } from './components/stat_weights_action'; import { SimSettingCategories } from './constants/sim_settings'; import * as Tooltips from './constants/tooltips'; import { getSpecLaunchStatus, LaunchStatus, simLaunchStatuses } from './launched_sims'; @@ -212,6 +212,7 @@ export interface Settings { export abstract class IndividualSimUI extends SimUI { readonly player: Player; readonly individualConfig: IndividualSimUIConfig; + private readonly statWeightActionSettings: StatWeightActionSettings; private raidSimResultsManager: RaidSimResultsManager | null; epWeightsModal: EpWeightsMenu | null = null; @@ -238,6 +239,7 @@ export abstract class IndividualSimUI extends SimUI { this.raidSimResultsManager = null; this.prevEpIterations = 0; this.prevEpSimResult = null; + this.statWeightActionSettings = new StatWeightActionSettings(this); if (!isDevMode() && getSpecLaunchStatus(this.player) === LaunchStatus.Unlaunched) { this.handleSimUnlaunched(); @@ -396,13 +398,15 @@ export abstract class IndividualSimUI extends SimUI { const jsonStr = IndividualSimSettings.toJsonString(this.toProto()); window.localStorage.setItem(this.getSettingsStorageKey(), jsonStr); }); + + this.statWeightActionSettings.load(initEventID); }); } private addSidebarComponents() { this.raidSimResultsManager = addRaidSimAction(this); this.sim.waitForInit().then(() => { - this.epWeightsModal = addStatWeightsAction(this); + this.epWeightsModal = addStatWeightsAction(this, this.statWeightActionSettings); }); new CharacterStats( @@ -579,6 +583,8 @@ export abstract class IndividualSimUI extends SimUI { } else { this.sim.raid.setTanks(eventID, []); } + + this.statWeightActionSettings.applyDefaults(eventID); } }); } diff --git a/ui/core/proto_utils/action_id.ts b/ui/core/proto_utils/action_id.ts index 49fe426814..4de58eb2b9 100644 --- a/ui/core/proto_utils/action_id.ts +++ b/ui/core/proto_utils/action_id.ts @@ -717,7 +717,8 @@ export class ActionId { } else if (this.otherId) { return 'other-' + this.otherId; } else { - throw new Error('Empty action id!'); + console.error('Empty action id!'); + return ''; } }