diff --git a/ui/core/components/character_stats.tsx b/ui/core/components/character_stats.tsx index fa11dcbd1..965d69228 100644 --- a/ui/core/components/character_stats.tsx +++ b/ui/core/components/character_stats.tsx @@ -2,8 +2,9 @@ import tippy from 'tippy.js'; import { ref } from 'tsx-vanilla'; import * as Mechanics from '../constants/mechanics.js'; -import { Player } from '../player.js'; -import { HandType, ItemSlot, PseudoStat, Spec, Stat, WeaponType } from '../proto/common.js'; +import { MeleeCritCapInfo, Player } from '../player.js'; +import { ItemSlot, PseudoStat, Spec, Stat, WeaponType } from '../proto/common.js'; +import { slotNames } from '../proto_utils/names'; import { Stats, UnitStat } from '../proto_utils/stats.js'; import { EventID, TypedEvent } from '../typed_event.js'; import { Component } from './component.js'; @@ -82,7 +83,6 @@ const statGroups = new Map>([ 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; @@ -118,23 +118,27 @@ export class CharacterStats extends Component { const valueElem = row.getElementsByClassName('character-stats-table-value')[0] as HTMLTableCellElement; this.valueElems.push(valueElem); + + if (stat.isStat() && stat.getStat() === Stat.StatMeleeCrit && this.shouldShowMeleeCritCap(player)) { + const critCapRow = ( + + Melee Crit Cap + + {/* Hacky placeholder for spacing */} + + + + ); + body.appendChild(critCapRow); + + const critCapValueElem = critCapRow.getElementsByClassName('character-stats-table-value')[0] as HTMLTableCellElement; + this.valueElems.push(critCapValueElem); + } }); table.appendChild(body); }); - if (this.shouldShowMeleeCritCap(player)) { - const row = ( - - Melee Crit Cap - - - ); - - table.appendChild(row); - this.meleeCritCapValueElem = row.getElementsByClassName('character-stats-table-value')[0] as HTMLTableCellElement; - } - this.updateStats(player); TypedEvent.onAny([player.currentStatsEmitter, player.sim.changeEmitter, player.talentsChangeEmitter]).on(() => { this.updateStats(player); @@ -164,7 +168,8 @@ export class CharacterStats extends Component { const finalStats = Stats.fromProto(playerStats.finalStats).add(statMods.talents).add(statMods.buffs).add(debuffStats); - this.stats.forEach((stat, idx) => { + let idx = 0; + this.stats.forEach(stat => { const bonusStatValue = bonusStats.getUnitStat(stat); let contextualClass: string; if (bonusStatValue === 0) { @@ -240,30 +245,14 @@ export class CharacterStats extends Component { Axes {this.weaponSkillDisplayString(talentsStats, PseudoStat.PseudoStatAxesSkill)} -
- 2H Axes - {this.weaponSkillDisplayString(talentsStats, PseudoStat.PseudoStatTwoHandedAxesSkill)} -
Daggers {this.weaponSkillDisplayString(talentsStats, PseudoStat.PseudoStatDaggersSkill)}
- {/* Commenting out feral combat skill since not present in Classic. - {player.spec === Spec.SpecFeralDruid && ( -
- Feral Combat - {this.weaponSkillDisplayString(talentsStats, PseudoStat.PseudoStatFeralCombatSkill)} -
- )} - */}
Maces {this.weaponSkillDisplayString(talentsStats, PseudoStat.PseudoStatMacesSkill)}
-
- 2H Maces - {this.weaponSkillDisplayString(talentsStats, PseudoStat.PseudoStatTwoHandedMacesSkill)} -
Polearms {this.weaponSkillDisplayString(talentsStats, PseudoStat.PseudoStatPolearmsSkill)} @@ -276,16 +265,28 @@ export class CharacterStats extends Component { Swords {this.weaponSkillDisplayString(talentsStats, PseudoStat.PseudoStatSwordsSkill)}
-
- 2H Swords - {this.weaponSkillDisplayString(talentsStats, PseudoStat.PseudoStatTwoHandedSwordsSkill)} -
Unarmed {this.weaponSkillDisplayString(talentsStats, PseudoStat.PseudoStatUnarmedSkill)}
, ); + tooltipContent.appendChild( +
+
+ 2H Axes + {this.weaponSkillDisplayString(talentsStats, PseudoStat.PseudoStatTwoHandedAxesSkill)} +
+
+ 2H Maces + {this.weaponSkillDisplayString(talentsStats, PseudoStat.PseudoStatTwoHandedMacesSkill)} +
+
+ 2H Swords + {this.weaponSkillDisplayString(talentsStats, PseudoStat.PseudoStatTwoHandedSwordsSkill)} +
+
, + ); } else if (stat.isStat() && stat.getStat() === Stat.StatSpellHit) { tooltipContent.appendChild(
@@ -315,139 +316,103 @@ export class CharacterStats extends Component {
, ); - } - - tippy(statLinkElem, { - content: tooltipContent, - }); - }); - - if (this.meleeCritCapValueElem) { - const has2hWeapon = player.getGear().getEquippedItem(ItemSlot.ItemSlotMainHand)?.item.handType == HandType.HandTypeTwoHand; - const mhWeapon: WeaponType = player.getGear().getEquippedItem(ItemSlot.ItemSlotMainHand)?.item.weaponType as WeaponType; - const ohWeapon: WeaponType = player.getGear().getEquippedItem(ItemSlot.ItemSlotOffHand)?.item.weaponType as WeaponType; - const mhCritCapInfo = player.getMeleeCritCapInfo(mhWeapon, has2hWeapon); - const ohCritCapInfo = player.getMeleeCritCapInfo(ohWeapon, has2hWeapon); - - const playerCritCapDelta = mhCritCapInfo.playerCritCapDelta; - - if (playerCritCapDelta === 0.0) { - const prefix = 'Exact'; - } - - const prefix = playerCritCapDelta > 0 ? 'Over by ' : 'Under by '; - - const valueElem = ( - - {`${prefix} ${Math.abs(playerCritCapDelta).toFixed(2)}%`} - - ); + } else if (stat.isStat() && stat.getStat() === Stat.StatMeleeCrit && this.shouldShowMeleeCritCap(player)) { + idx++; - const capDelta = mhCritCapInfo.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'); - } + const gear = player.getGear(); + const mhWeaponType = gear.getEquippedItem(ItemSlot.ItemSlotMainHand)?.item?.weaponType as WeaponType; + const ohWeaponType = gear.getEquippedItem(ItemSlot.ItemSlotOffHand)?.item?.weaponType as WeaponType; + const mhCritCapInfo = player.getMeleeCritCapInfo(mhWeaponType); + const ohCritCapInfo = player.getMeleeCritCapInfo(ohWeaponType); - this.meleeCritCapValueElem.querySelector('.stat-value-link')?.remove(); - this.meleeCritCapValueElem.prepend(valueElem); + const playerCritCapDelta = mhCritCapInfo.playerCritCapDelta; + const prefix = playerCritCapDelta === 0.0 ? 'Exact ' : playerCritCapDelta > 0 ? 'Over by ' : 'Under by '; - const tooltipContent = ( -
-
- Main Hand - -
-
-
- Glancing: - {`${mhCritCapInfo.glancing.toFixed(2)}%`} -
-
- Suppression: - {`${mhCritCapInfo.suppression.toFixed(2)}%`} -
-
- White Miss: - {`${mhCritCapInfo.remainingMeleeHitCap.toFixed(2)}%`} -
-
- Dodge: - {`${mhCritCapInfo.dodgeCap.toFixed(2)}%`} -
-
- Parry: - {`${mhCritCapInfo.parryCap.toFixed(2)}%`} -
- {mhCritCapInfo.specSpecificOffset != 0 && ( -
- Spec Offsets: - {`${mhCritCapInfo.specSpecificOffset.toFixed(2)}%`} -
- )} -
- Final Crit Cap: - {`${mhCritCapInfo.baseCritCap.toFixed(2)}%`} -
-
- Can Raise By: - {`${mhCritCapInfo.remainingMeleeHitCap.toFixed(2)}%`} + const mhCritCapLinkElem = ( + + {`${prefix} ${Math.abs(playerCritCapDelta).toFixed(2)}%`} + + ) + + const capDelta = mhCritCapInfo.playerCritCapDelta; + if (capDelta === 0) { + mhCritCapLinkElem.classList.add('text-white'); + } else if (capDelta > 0) { + mhCritCapLinkElem.classList.add('text-danger'); + } else if (capDelta < 0) { + mhCritCapLinkElem.classList.add('text-success'); + } + + this.valueElems[idx].querySelector('.stat-value-link-container')?.remove(); + this.valueElems[idx].prepend( +
+ {mhCritCapLinkElem}
- {!has2hWeapon && ( -
-
+ ); -
- Off Hand - -
-
-
- Glancing: - {`${ohCritCapInfo.glancing.toFixed(2)}%`} -
-
- Suppression: - {`${ohCritCapInfo.suppression.toFixed(2)}%`} -
-
- White Miss: - {`${ohCritCapInfo.remainingMeleeHitCap.toFixed(2)}%`} -
-
- Dodge: - {`${ohCritCapInfo.dodgeCap.toFixed(2)}%`} -
-
- Parry: - {`${ohCritCapInfo.parryCap.toFixed(2)}%`} -
- {ohCritCapInfo.specSpecificOffset != 0 && ( -
- Spec Offsets: - {`${ohCritCapInfo.specSpecificOffset.toFixed(2)}%`} + tippy(mhCritCapLinkElem, { + content: ( +
+ {this.critCapTooltip(mhCritCapInfo, ohCritCapInfo)} + {player.hasDualWieldPenalty() && ( +
+ + Crit cap assuming perfect queued ability uptime.
)} -
- Final Crit Cap: - {`${ohCritCapInfo.baseCritCap.toFixed(2)}%`} -
-
- Can Raise By: - {`${ohCritCapInfo.remainingMeleeHitCap.toFixed(2)}%`} -
- )} -
- ); + ), + maxWidth: '90vw', + }); + + if (player.hasDualWieldPenalty()) { + const mhCritCapInfoDWPenalty = player.getMeleeCritCapInfo(mhWeaponType, true); + const ohCritCapInfoDWPenalty = player.getMeleeCritCapInfo(ohWeaponType, true); + + const playerCritCapDelta = mhCritCapInfoDWPenalty.playerCritCapDelta; + const prefix = playerCritCapDelta === 0.0 ? 'Exact ' : playerCritCapDelta > 0 ? 'Over by ' : 'Under by '; + + const ohCritCapLinkElem = ( + + {`${prefix} ${Math.abs(playerCritCapDelta).toFixed(2)}%`} + + ) + + mhCritCapLinkElem.insertAdjacentElement('afterend', ohCritCapLinkElem) + + const capDelta = mhCritCapInfoDWPenalty.playerCritCapDelta; + if (capDelta === 0) { + ohCritCapLinkElem.classList.add('text-white'); + } else if (capDelta > 0) { + ohCritCapLinkElem.classList.add('text-danger'); + } else if (capDelta < 0) { + ohCritCapLinkElem.classList.add('text-success'); + } + + tippy(ohCritCapLinkElem, { + content: ( +
+ {this.critCapTooltip(mhCritCapInfoDWPenalty, ohCritCapInfoDWPenalty)} +
+ + Crit cap with dual-wield penalty. +
+
+ ), + maxWidth: '90vw', + }); + } - tippy(valueElem, { + + } + + tippy(statLinkElem, { content: tooltipContent, + maxWidth: '90vw', }); - } + + idx++; + }); } private statDisplayString(player: Player, stats: Stats, deltaStats: Stats, unitStat: UnitStat): string { @@ -518,6 +483,93 @@ export class CharacterStats extends Component { return `${(stats.getPseudoStat(pseudoStat) + stats.getStat(Stat.StatSpellHit)).toFixed(2)}%`; } + private critCapTooltip(mhCritCapInfo: MeleeCritCapInfo, ohCritCapInfo: MeleeCritCapInfo): JSX.Element { + return ( +
+
+
+
{slotNames.get(ItemSlot.ItemSlotMainHand)}
+
+
+ Glancing: + {`${mhCritCapInfo.glancing.toFixed(2)}%`} +
+
+ Suppression: + {`${mhCritCapInfo.suppression.toFixed(2)}%`} +
+
+ White Miss: + {`${mhCritCapInfo.remainingMeleeHitCap.toFixed(2)}%`} +
+
+ Dodge: + {`${mhCritCapInfo.dodgeCap.toFixed(2)}%`} +
+
+ Parry: + {`${mhCritCapInfo.parryCap.toFixed(2)}%`} +
+ {mhCritCapInfo.specSpecificOffset != 0 && ( +
+ Spec Offsets: + {`${mhCritCapInfo.specSpecificOffset.toFixed(2)}%`} +
+ )} +
+ Final Crit Cap: + {`${mhCritCapInfo.baseCritCap.toFixed(2)}%`} +
+
+ Can Raise By: + {`${mhCritCapInfo.remainingMeleeHitCap.toFixed(2)}%`} +
+
+ {this.player.getGear().hasOffHandWeapon() && ( +
+
+
{slotNames.get(ItemSlot.ItemSlotOffHand)}
+
+
+ Glancing: + {`${ohCritCapInfo.glancing.toFixed(2)}%`} +
+
+ Suppression: + {`${ohCritCapInfo.suppression.toFixed(2)}%`} +
+
+ White Miss: + {`${ohCritCapInfo.remainingMeleeHitCap.toFixed(2)}%`} +
+
+ Dodge: + {`${ohCritCapInfo.dodgeCap.toFixed(2)}%`} +
+
+ Parry: + {`${ohCritCapInfo.parryCap.toFixed(2)}%`} +
+ {ohCritCapInfo.specSpecificOffset != 0 && ( +
+ Spec Offsets: + {`${ohCritCapInfo.specSpecificOffset.toFixed(2)}%`} +
+ )} +
+ Final Crit Cap: + {`${ohCritCapInfo.baseCritCap.toFixed(2)}%`} +
+
+ Can Raise By: + {`${ohCritCapInfo.remainingMeleeHitCap.toFixed(2)}%`} +
+
+ )} +
+ ); + } + private getDebuffStats(): Stats { const debuffStats = new Stats(); diff --git a/ui/core/player.ts b/ui/core/player.ts index 5456dc4bd..b121d419c 100644 --- a/ui/core/player.ts +++ b/ui/core/player.ts @@ -687,11 +687,19 @@ export class Player { this.bonusStatsChangeEmitter.emit(eventID); } - getMeleeCritCapInfo(weapon: WeaponType, has2hWeapon: boolean): MeleeCritCapInfo { + hasDualWieldPenalty(): boolean { + return this.getClass() === Class.ClassWarrior && this.getGear().isDualWielding(); + } + + getMeleeCritCapInfo(weapon: WeaponType, useDWPenality?: boolean): MeleeCritCapInfo { let targetLevel = 63; // Initializes at level 63 until UI is loaded if (this.sim.encounter.targets) { targetLevel = this.sim.encounter?.primaryTarget.level; } + + const has2hWeapon = this.getGear().hasTwoHandedWeapon(); + const hasOffhandWeapon = this.getGear().hasOffHandWeapon(); + const levelDiff = targetLevel - Mechanics.MAX_CHARACTER_LEVEL; const defenderDefense = targetLevel * 5; const glancing = (1 + levelDiff) * 10.0; @@ -701,7 +709,6 @@ export class Player { 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 hasOffhandWeapon = this.getGear().getEquippedItem(ItemSlot.ItemSlotOffHand)?.item.weaponType !== undefined; const getWeaponSkillForWeaponType = (skill: PseudoStat) => this.currentStats.talentsStats?.pseudoStats[skill] || 0.0; @@ -745,7 +752,7 @@ export class Player { const skillDiff = defenderDefense - weaponSkill; // Due to warrior HS bug, hit cap for crit cap calculation ignores the 19% penalty let meleeHitCap = skillDiff <= 10 ? 5.0 + skillDiff * 0.1 : 5.0 + skillDiff * 0.2 + (skillDiff - 10) * 0.2; - meleeHitCap = hasOffhandWeapon && this.spec !== Spec.SpecWarrior ? meleeHitCap + 19.0 : meleeHitCap + 0.0; + meleeHitCap = !this.hasDualWieldPenalty() || useDWPenality ? meleeHitCap + 19.0 : meleeHitCap + 0.0; const dodgeCap = 5.0 + skillDiff * 0.1; let parryCap = 0.0; diff --git a/ui/core/proto_utils/gear.ts b/ui/core/proto_utils/gear.ts index 4bf80e55f..19315f290 100644 --- a/ui/core/proto_utils/gear.ts +++ b/ui/core/proto_utils/gear.ts @@ -1,4 +1,4 @@ -import { EquipmentSpec, ItemSlot, ItemSpec, ItemSwap, Profession, SimDatabase, SimEnchant, SimItem } from '../proto/common.js'; +import { EquipmentSpec, HandType, ItemSlot, ItemSpec, ItemSwap, Profession, SimDatabase, SimEnchant, SimItem } from '../proto/common.js'; import { UIEnchant as Enchant, UIItem as Item } from '../proto/ui.js'; import { isBluntWeaponType, isSharpWeaponType } from '../proto_utils/utils.js'; import { distinct, equalsOrBothNull, getEnumValues } from '../utils.js'; @@ -124,6 +124,18 @@ export class Gear extends BaseGear { return new Gear(this.withEquippedItemInternal(newSlot, newItem)); } + isDualWielding(): boolean { + return this.getEquippedItem(ItemSlot.ItemSlotMainHand) !== null && this.hasOffHandWeapon(); + } + + hasTwoHandedWeapon(): boolean { + return this.getEquippedItem(ItemSlot.ItemSlotMainHand)?.item.handType === HandType.HandTypeTwoHand; + } + + hasOffHandWeapon(): boolean { + return this.getEquippedItem(ItemSlot.ItemSlotOffHand)?.item?.weaponType !== undefined; + } + getTrinkets(): Array { return [this.getEquippedItem(ItemSlot.ItemSlotTrinket1), this.getEquippedItem(ItemSlot.ItemSlotTrinket2)]; }