Skip to content

Commit

Permalink
Add melee crit cap display (#3847)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
secretbis committed Oct 14, 2023
1 parent 87b96cf commit 3d99fec
Show file tree
Hide file tree
Showing 2 changed files with 187 additions and 7 deletions.
118 changes: 113 additions & 5 deletions ui/core/components/character_stats.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -19,6 +19,7 @@ export type StatMods = { talents: Stats };
export class CharacterStats extends Component {
readonly stats: Array<Stat>;
readonly valueElems: Array<HTMLTableCellElement>;
readonly meleeCritCapValueElem: HTMLTableCellElement | undefined;

private readonly player: Player<any>;
private readonly modifyDisplayStats?: (player: Player<any>) => StatMods;
Expand All @@ -45,7 +46,7 @@ export class CharacterStats extends Component {
const row = (
<tr
className='character-stats-table-row'
>
>
<td className="character-stats-table-label">{statName}</td>
<td className="character-stats-table-value">
{this.bonusStatsLink(stat)}
Expand All @@ -58,8 +59,23 @@ export class CharacterStats extends Component {
this.valueElems.push(valueElem);
});

if(this.shouldShowMeleeCritCap(player)) {
const row = (
<tr
className='character-stats-table-row'
>
<td className="character-stats-table-label">Melee Crit Cap</td>
<td className="character-stats-table-value"></td>
</tr>);

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);
});
}
Expand Down Expand Up @@ -90,7 +106,7 @@ export class CharacterStats extends Component {
this.stats.forEach((stat, idx) => {
let valueElem = (
<a
href="javascript:void(0)"
href="javascript:void(0)"
className="stat-value-link"
attributes={{role:"button"}}>
{`${this.statDisplayString(finalStats, finalStats, stat)} `}
Expand All @@ -110,7 +126,7 @@ export class CharacterStats extends Component {
valueElem.classList.add('text-danger');
}

let tooltipContent =
let tooltipContent =
<div>
<div className="character-stats-tooltip-row">
<span>Base:</span>
Expand Down Expand Up @@ -154,6 +170,76 @@ export class CharacterStats extends Component {
html: true,
});
});

if(this.meleeCritCapValueElem) {
const meleeCritCapInfo = player.getMeleeCritCapInfo();

const valueElem = (
<a
href="javascript:void(0)"
className="stat-value-link"
attributes={{role:"button"}}>
{`${this.meleeCritCapDisplayString(player, finalStats)} `}
</a>
)

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 = (
<div>
<div className="character-stats-tooltip-row">
<span>Glancing:</span>
<span>{`${meleeCritCapInfo.glancing.toFixed(2)}%`}</span>
</div>
<div className="character-stats-tooltip-row">
<span>Suppression:</span>
<span>{`${meleeCritCapInfo.suppression.toFixed(2)}%`}</span>
</div>
<div className="character-stats-tooltip-row">
<span>To Hit Cap:</span>
<span>{`${meleeCritCapInfo.remainingMeleeHitCap.toFixed(2)}%`}</span>
</div>
<div className="character-stats-tooltip-row">
<span>To Exp Cap:</span>
<span>{`${meleeCritCapInfo.remainingExpertiseCap.toFixed(2)}%`}</span>
</div>
<div className="character-stats-tooltip-row">
<span>Debuffs:</span>
<span>{`${meleeCritCapInfo.debuffCrit.toFixed(2)}%`}</span>
</div>
{meleeCritCapInfo.specSpecificOffset != 0 &&
<div className="character-stats-tooltip-row">
<span>Spec Offsets:</span>
<span>{`${meleeCritCapInfo.specSpecificOffset.toFixed(2)}%`}</span>
</div>
}
<div className="character-stats-tooltip-row">
<span>Final Crit Cap:</span>
<span>{`${meleeCritCapInfo.baseCritCap.toFixed(2)}%`}</span>
</div>
<hr/>
<div className="character-stats-tooltip-row">
<span>Can Raise By:</span>
<span>{`${(meleeCritCapInfo.remainingExpertiseCap + meleeCritCapInfo.remainingMeleeHitCap).toFixed(2)}%`}</span>
</div>
</div>
);

Tooltip.getOrCreateInstance(valueElem, {
title: tooltipContent,
html: true,
});
}
}

private statDisplayString(stats: Stats, deltaStats: Stats, stat: Stat): string {
Expand Down Expand Up @@ -262,4 +348,26 @@ export class CharacterStats extends Component {

return link as HTMLElement;
}

private shouldShowMeleeCritCap(player: Player<any>): boolean {
return [
Spec.SpecDeathknight,
Spec.SpecEnhancementShaman,
Spec.SpecFeralDruid,
Spec.SpecRetributionPaladin,
Spec.SpecRogue,
Spec.SpecWarrior
].includes(player.spec);
}

private meleeCritCapDisplayString(player: Player<any>, 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)}%`;
}
}
76 changes: 74 additions & 2 deletions ui/core/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<SpecType extends Spec> = (player: Player<SpecType>) => APLRotation;
export type SimpleRotationGenerator<SpecType extends Spec> = (player: Player<SpecType>, simpleRotation: SpecRotation<SpecType>, cooldowns: Cooldowns) => APLRotation;

Expand Down Expand Up @@ -657,6 +674,61 @@ export class Player<SpecType extends Spec> {
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<Spec.SpecEnhancementShaman>).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<SpecType> {
if (aplLaunchStatuses[this.spec] == LaunchStatus.Launched) {
const jsonStr = this.aplRotation.simple?.specRotationJson || '';
Expand Down Expand Up @@ -1390,7 +1462,7 @@ export class Player<SpecType extends Spec> {
this.setRotation(eventID, rot as SpecRotation<SpecType>);
}
const opt = this.getSpecOptions() as SpecOptions<ShamanSpecs>;

// Update Bloodlust to be part of rotation instead of options to support APL casting bloodlust.
if (opt.bloodlust) {
opt.bloodlust = false;
Expand Down

0 comments on commit 3d99fec

Please sign in to comment.