From 3d99fec0e5df227756623072270d62f68723e2ea Mon Sep 17 00:00:00 2001 From: secretbis <56484534+secretbis@users.noreply.github.com> Date: Sat, 14 Oct 2023 07:07:17 -0700 Subject: [PATCH] Add melee crit cap display (#3847) Added in the form of a new spec-gated row in the character stats pane Text is green if below crit cap, red if above, white if equal Tooltip includes several details about said crit cap, including mechanics modifiers, cap final value, and how much the cap can be raised. Resolves #2774 --- ui/core/components/character_stats.tsx | 118 +++++++++++++++++++++++-- ui/core/player.ts | 76 +++++++++++++++- 2 files changed, 187 insertions(+), 7 deletions(-) diff --git a/ui/core/components/character_stats.tsx b/ui/core/components/character_stats.tsx index 9323f59b41..c67e16974b 100644 --- a/ui/core/components/character_stats.tsx +++ b/ui/core/components/character_stats.tsx @@ -1,4 +1,4 @@ -import { Stat, Class, PseudoStat } from '..//proto/common.js'; +import { Stat, Class, PseudoStat, Spec } from '..//proto/common.js'; import { TristateEffect } from '..//proto/common.js' import { getClassStatName, statOrder } from '..//proto_utils/names.js'; import { Stats } from '..//proto_utils/stats.js'; @@ -19,6 +19,7 @@ export type StatMods = { talents: Stats }; export class CharacterStats extends Component { readonly stats: Array; readonly valueElems: Array; + readonly meleeCritCapValueElem: HTMLTableCellElement | undefined; private readonly player: Player; private readonly modifyDisplayStats?: (player: Player) => StatMods; @@ -45,7 +46,7 @@ export class CharacterStats extends Component { const row = ( + > {statName} {this.bonusStatsLink(stat)} @@ -58,8 +59,23 @@ export class CharacterStats extends Component { this.valueElems.push(valueElem); }); + if(this.shouldShowMeleeCritCap(player)) { + const row = ( + + Melee Crit Cap + + ); + + table.appendChild(row); + this.meleeCritCapValueElem = row.getElementsByClassName('character-stats-table-value')[0] as HTMLTableCellElement; + } else { + this.meleeCritCapValueElem = undefined; + } + this.updateStats(player); - TypedEvent.onAny([player.currentStatsEmitter, player.sim.changeEmitter]).on(() => { + TypedEvent.onAny([player.currentStatsEmitter, player.sim.changeEmitter, player.talentsChangeEmitter]).on(() => { this.updateStats(player); }); } @@ -90,7 +106,7 @@ export class CharacterStats extends Component { this.stats.forEach((stat, idx) => { let valueElem = ( {`${this.statDisplayString(finalStats, finalStats, stat)} `} @@ -110,7 +126,7 @@ export class CharacterStats extends Component { valueElem.classList.add('text-danger'); } - let tooltipContent = + let tooltipContent =
Base: @@ -154,6 +170,76 @@ export class CharacterStats extends Component { html: true, }); }); + + if(this.meleeCritCapValueElem) { + const meleeCritCapInfo = player.getMeleeCritCapInfo(); + + const valueElem = ( + + {`${this.meleeCritCapDisplayString(player, finalStats)} `} + + ) + + const capDelta = meleeCritCapInfo.playerCritCapDelta; + if (capDelta == 0) { + valueElem.classList.add('text-white'); + } else if (capDelta > 0) { + valueElem.classList.add('text-danger'); + } else if (capDelta < 0) { + valueElem.classList.add('text-success'); + } + + this.meleeCritCapValueElem.querySelector('.stat-value-link')?.remove(); + this.meleeCritCapValueElem.prepend(valueElem); + + const tooltipContent = ( +
+
+ Glancing: + {`${meleeCritCapInfo.glancing.toFixed(2)}%`} +
+
+ Suppression: + {`${meleeCritCapInfo.suppression.toFixed(2)}%`} +
+
+ To Hit Cap: + {`${meleeCritCapInfo.remainingMeleeHitCap.toFixed(2)}%`} +
+
+ To Exp Cap: + {`${meleeCritCapInfo.remainingExpertiseCap.toFixed(2)}%`} +
+
+ Debuffs: + {`${meleeCritCapInfo.debuffCrit.toFixed(2)}%`} +
+ {meleeCritCapInfo.specSpecificOffset != 0 && +
+ Spec Offsets: + {`${meleeCritCapInfo.specSpecificOffset.toFixed(2)}%`} +
+ } +
+ Final Crit Cap: + {`${meleeCritCapInfo.baseCritCap.toFixed(2)}%`} +
+
+
+ Can Raise By: + {`${(meleeCritCapInfo.remainingExpertiseCap + meleeCritCapInfo.remainingMeleeHitCap).toFixed(2)}%`} +
+
+ ); + + Tooltip.getOrCreateInstance(valueElem, { + title: tooltipContent, + html: true, + }); + } } private statDisplayString(stats: Stats, deltaStats: Stats, stat: Stat): string { @@ -262,4 +348,26 @@ export class CharacterStats extends Component { return link as HTMLElement; } + + private shouldShowMeleeCritCap(player: Player): boolean { + return [ + Spec.SpecDeathknight, + Spec.SpecEnhancementShaman, + Spec.SpecFeralDruid, + Spec.SpecRetributionPaladin, + Spec.SpecRogue, + Spec.SpecWarrior + ].includes(player.spec); + } + + private meleeCritCapDisplayString(player: Player, finalStats: Stats): string { + const playerCritCapDelta = player.getMeleeCritCap(); + + if(playerCritCapDelta === 0.0) { + return 'Exact'; + } + + const prefix = playerCritCapDelta > 0 ? 'Over by ' : 'Under by '; + return `${prefix} ${Math.abs(playerCritCapDelta).toFixed(2)}%`; + } } diff --git a/ui/core/player.ts b/ui/core/player.ts index ff69dbb170..a6f8747acc 100644 --- a/ui/core/player.ts +++ b/ui/core/player.ts @@ -82,7 +82,7 @@ import { ShamanSpecs, } from './proto_utils/utils.js'; - +import * as Mechanics from './constants/mechanics.js'; import { getLanguageCode } from './constants/lang.js'; import { EventID, TypedEvent } from './typed_event.js'; import { Party, MAX_PARTY_SIZE } from './party.js'; @@ -189,6 +189,23 @@ export class UnitMetadataList { } } +export interface MeleeCritCapInfo { + meleeCrit: number, + meleeHit: number, + expertise: number, + suppression: number, + glancing: number, + debuffCrit: number, + hasOffhandWeapon: boolean, + meleeHitCap: number, + expertiseCap: number, + remainingMeleeHitCap: number, + remainingExpertiseCap: number, + baseCritCap: number, + specSpecificOffset: number, + playerCritCapDelta: number +} + export type AutoRotationGenerator = (player: Player) => APLRotation; export type SimpleRotationGenerator = (player: Player, simpleRotation: SpecRotation, cooldowns: Cooldowns) => APLRotation; @@ -657,6 +674,61 @@ export class Player { this.bonusStatsChangeEmitter.emit(eventID); } + getMeleeCritCapInfo(): MeleeCritCapInfo { + const meleeCrit = (this.currentStats.finalStats?.stats[Stat.StatMeleeCrit] || 0.0) / Mechanics.MELEE_CRIT_RATING_PER_CRIT_CHANCE; + const meleeHit = (this.currentStats.finalStats?.stats[Stat.StatMeleeHit] || 0.0) / Mechanics.MELEE_HIT_RATING_PER_HIT_CHANCE; + const expertise = (this.currentStats.finalStats?.stats[Stat.StatExpertise] || 0.0) / Mechanics.EXPERTISE_PER_QUARTER_PERCENT_REDUCTION / 4; + const agility = (this.currentStats.finalStats?.stats[Stat.StatAgility] || 0.0) / this.getClass(); + const suppression = 4.8; + const glancing = 24.0; + + const hasOffhandWeapon = this.getGear().getEquippedItem(ItemSlot.ItemSlotOffHand)?.item.weaponSpeed !== undefined; + const meleeHitCap = hasOffhandWeapon ? 27.0 : 8.0; + const expertiseCap = this.getInFrontOfTarget() ? 20.5 : 6.5; + + const remainingMeleeHitCap = Math.max(meleeHitCap - meleeHit, 0.0); + const remainingExpertiseCap = Math.max(expertiseCap - expertise, 0.0) + + let specSpecificOffset = 0.0; + + if(this.spec === Spec.SpecEnhancementShaman) { + // Elemental Devastation uptime is near 100% + const ranks = (this as Player).getTalents().elementalDevastation; + specSpecificOffset = 3.0 * ranks; + } + + let debuffCrit = 0.0; + + const debuffs = this.sim.raid.getDebuffs(); + if (debuffs.totemOfWrath || debuffs.heartOfTheCrusader || debuffs.masterPoisoner) { + debuffCrit = 3.0; + } + + const baseCritCap = 100.0 - glancing + suppression - remainingMeleeHitCap - remainingExpertiseCap - specSpecificOffset; + const playerCritCapDelta = meleeCrit - baseCritCap + debuffCrit; + + return { + meleeCrit, + meleeHit, + expertise, + suppression, + glancing, + debuffCrit, + hasOffhandWeapon, + meleeHitCap, + expertiseCap, + remainingMeleeHitCap, + remainingExpertiseCap, + baseCritCap, + specSpecificOffset, + playerCritCapDelta + }; + } + + getMeleeCritCap() { + return this.getMeleeCritCapInfo().playerCritCapDelta + } + getRotation(): SpecRotation { if (aplLaunchStatuses[this.spec] == LaunchStatus.Launched) { const jsonStr = this.aplRotation.simple?.specRotationJson || ''; @@ -1390,7 +1462,7 @@ export class Player { this.setRotation(eventID, rot as SpecRotation); } const opt = this.getSpecOptions() as SpecOptions; - + // Update Bloodlust to be part of rotation instead of options to support APL casting bloodlust. if (opt.bloodlust) { opt.bloodlust = false;