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;