diff --git a/proto/paladin.proto b/proto/paladin.proto index 680dad48d4..db7f134c29 100644 --- a/proto/paladin.proto +++ b/proto/paladin.proto @@ -134,6 +134,7 @@ message PaladinOptions { bool IsUsingDivineStormStopAttack = 4; bool IsUsingJudgementStopAttack = 5; bool IsUsingCrusaderStrikeStopAttack = 6; + bool righteousFury = 8; } message RetributionPaladin { diff --git a/sim/core/health.go b/sim/core/health.go index 9ce68842bc..e13aeddb3e 100644 --- a/sim/core/health.go +++ b/sim/core/health.go @@ -156,10 +156,12 @@ func (character *Character) applyHealingModel(healingModel *proto.HealingModel) healthMetrics := character.NewHealthMetrics(ActionID{OtherID: proto.OtherAction_OtherActionHealingModel}) + // Dummy spell for healing callback + healingModelSpell := character.RegisterSpell(SpellConfig{ + ActionID: ActionID{OtherID: proto.OtherAction_OtherActionHealingModel}, + }) + character.RegisterResetEffect(func(sim *Simulation) { - // Hack since we don't have OnHealingReceived aura handlers yet. - //ardentDefenderAura := character.GetAura("Ardent Defender") - willOfTheNecropolisAura := character.GetAura("Will of The Necropolis") // Initialize randomized cadence model timeToNextHeal := DurationFromSeconds(0.0) @@ -171,18 +173,15 @@ func (character *Character) applyHealingModel(healingModel *proto.HealingModel) pa.OnAction = func(sim *Simulation) { // Use modeled HPS to scale heal per tick based on random cadence healPerTick = healingModel.Hps * (float64(timeToNextHeal) / float64(time.Second)) - + totalHeal := healPerTick * character.PseudoStats.HealingTakenMultiplier // Execute the heal - character.GainHealth(sim, healPerTick*character.PseudoStats.HealingTakenMultiplier, healthMetrics) + character.GainHealth(sim, totalHeal, healthMetrics) - // Might use this again in the future to track "absorb" metrics but currently disabled - //if ardentDefenderAura != nil && character.CurrentHealthPercent() >= 0.35 { - // ardentDefenderAura.Deactivate(sim) - //} - - if willOfTheNecropolisAura != nil && character.CurrentHealthPercent() > 0.35 { - willOfTheNecropolisAura.Deactivate(sim) - } + // Callback that can be used by tank specs + result := healingModelSpell.NewResult(&character.Unit) + result.Damage = totalHeal + character.OnHealTaken(sim, healingModelSpell, result) + healingModelSpell.DisposeResult(result) // Random roll for time to next heal. In the case where CadenceVariation exceeds CadenceSeconds, then // CadenceSeconds is treated as the median, with two separate uniform distributions to the left and right diff --git a/sim/paladin/paladin.go b/sim/paladin/paladin.go index 08bd76fa8c..4c3087538e 100644 --- a/sim/paladin/paladin.go +++ b/sim/paladin/paladin.go @@ -108,6 +108,7 @@ func (paladin *Paladin) AddPartyBuffs(_ *proto.PartyBuffs) { } func (paladin *Paladin) Initialize() { + paladin.registerRighteousFury() // Judgement and Seals paladin.registerJudgement() diff --git a/sim/paladin/protection/TestProtection.results b/sim/paladin/protection/TestProtection.results index 70dfc512a0..87ce45eff5 100644 --- a/sim/paladin/protection/TestProtection.results +++ b/sim/paladin/protection/TestProtection.results @@ -100,167 +100,167 @@ dps_results: { key: "TestProtection-Lvl60-AllItems-EmeraldEncrustedBattleplate" value: { dps: 1449.05975 - tps: 1702.73656 + tps: 2731.34101 } } dps_results: { key: "TestProtection-Lvl60-AllItems-Hero'sBrand-231328" value: { dps: 1818.00479 - tps: 2095.2467 + tps: 3835.15827 } } dps_results: { key: "TestProtection-Lvl60-AllItems-Knight-Lieutenant'sImbuedPlate" value: { dps: 1449.12088 - tps: 1703.41348 + tps: 2732.01793 } } dps_results: { key: "TestProtection-Lvl60-AllItems-Knight-Lieutenant'sLamellarPlate" value: { dps: 1531.46579 - tps: 1798.72722 + tps: 2882.3037 } } dps_results: { key: "TestProtection-Lvl60-AllItems-LibramofDraconicDestruction-221457" value: { dps: 1880.89213 - tps: 2160.81905 + tps: 3960.92022 } } dps_results: { key: "TestProtection-Lvl60-AllItems-ObsessedProphet'sPlate" value: { dps: 1652.06632 - tps: 1934.08068 + tps: 3491.09294 } } dps_results: { key: "TestProtection-Lvl60-AllItems-SanctifiedOrb-20512" value: { dps: 1889.23354 - tps: 2169.65251 + tps: 3973.15985 } } dps_results: { key: "TestProtection-Lvl60-AllItems-SoulforgeArmor" value: { dps: 1154.40755 - tps: 1184.21575 + tps: 1782.81781 } } dps_results: { key: "TestProtection-Lvl60-AllItems-ZandalarFreethinker'sBelt-231330" value: { dps: 1567.29072 - tps: 1794.85004 + tps: 3303.06084 } } dps_results: { key: "TestProtection-Lvl60-AllItems-ZandalarFreethinker'sBreastplate-231329" value: { dps: 1842.8433 - tps: 2119.14986 + tps: 3885.16513 } } dps_results: { key: "TestProtection-Lvl60-Average-Default" value: { dps: 1864.69391 - tps: 2142.97601 + tps: 3925.90289 } } dps_results: { key: "TestProtection-Lvl60-Settings-Dwarf-p4prot-P4 Prot-p4prot-FullBuffs-Phase 4 Consumes-LongMultiTarget" value: { dps: 1194.6808 - tps: 2113.07899 + tps: 3931.29347 } } dps_results: { key: "TestProtection-Lvl60-Settings-Dwarf-p4prot-P4 Prot-p4prot-FullBuffs-Phase 4 Consumes-LongSingleTarget" value: { dps: 457.62392 - tps: 686.68623 + tps: 1481.71013 } } dps_results: { key: "TestProtection-Lvl60-Settings-Dwarf-p4prot-P4 Prot-p4prot-FullBuffs-Phase 4 Consumes-ShortSingleTarget" value: { dps: 609.5157 - tps: 901.48453 + tps: 1938.74277 } } dps_results: { key: "TestProtection-Lvl60-Settings-Dwarf-p4prot-P4 Prot-p4prot-NoBuffs-Phase 4 Consumes-LongMultiTarget" value: { dps: 409.47519 - tps: 765.91203 + tps: 1283.12503 } } dps_results: { key: "TestProtection-Lvl60-Settings-Dwarf-p4prot-P4 Prot-p4prot-NoBuffs-Phase 4 Consumes-LongSingleTarget" value: { dps: 138.97267 - tps: 204.40849 + tps: 433.81686 } } dps_results: { key: "TestProtection-Lvl60-Settings-Dwarf-p4prot-P4 Prot-p4prot-NoBuffs-Phase 4 Consumes-ShortSingleTarget" value: { dps: 281.31093 - tps: 395.08441 + tps: 839.09115 } } dps_results: { key: "TestProtection-Lvl60-Settings-Human-p4prot-P4 Prot-p4prot-FullBuffs-Phase 4 Consumes-LongMultiTarget" value: { dps: 1198.66072 - tps: 2120.65531 + tps: 3950.54918 } } dps_results: { key: "TestProtection-Lvl60-Settings-Human-p4prot-P4 Prot-p4prot-FullBuffs-Phase 4 Consumes-LongSingleTarget" value: { dps: 463.49911 - tps: 694.25313 + tps: 1496.79729 } } dps_results: { key: "TestProtection-Lvl60-Settings-Human-p4prot-P4 Prot-p4prot-FullBuffs-Phase 4 Consumes-ShortSingleTarget" value: { dps: 612.37803 - tps: 905.99254 + tps: 1948.62369 } } dps_results: { key: "TestProtection-Lvl60-Settings-Human-p4prot-P4 Prot-p4prot-NoBuffs-Phase 4 Consumes-LongMultiTarget" value: { dps: 404.46316 - tps: 746.67678 + tps: 1246.34729 } } dps_results: { key: "TestProtection-Lvl60-Settings-Human-p4prot-P4 Prot-p4prot-NoBuffs-Phase 4 Consumes-LongSingleTarget" value: { dps: 149.02561 - tps: 211.11276 + tps: 448.28721 } } dps_results: { key: "TestProtection-Lvl60-Settings-Human-p4prot-P4 Prot-p4prot-NoBuffs-Phase 4 Consumes-ShortSingleTarget" value: { dps: 282.58463 - tps: 397.02423 + tps: 843.34946 } } dps_results: { key: "TestProtection-Lvl60-SwitchInFrontOfTarget-Default" value: { dps: 1569.54059 - tps: 1845.34584 + tps: 3378.44378 } } diff --git a/sim/paladin/protection/protection.go b/sim/paladin/protection/protection.go index 8a55a91a0e..1c7b0c7f71 100644 --- a/sim/paladin/protection/protection.go +++ b/sim/paladin/protection/protection.go @@ -31,6 +31,7 @@ func NewProtectionPaladin(character *core.Character, options *proto.Player) *Pro prot := &ProtectionPaladin{ Paladin: pal, primarySeal: protOptions.PrimarySeal, + righteousFury: protOptions.RighteousFury, IsUsingDivineStormStopAttack: protOptions.IsUsingDivineStormStopAttack, IsUsingJudgementStopAttack: protOptions.IsUsingJudgementStopAttack, IsUsingCrusaderStrikeStopAttack: protOptions.IsUsingCrusaderStrikeStopAttack, @@ -48,6 +49,7 @@ type ProtectionPaladin struct { *paladin.Paladin primarySeal proto.PaladinSeal + righteousFury bool IsUsingDivineStormStopAttack bool IsUsingJudgementStopAttack bool IsUsingCrusaderStrikeStopAttack bool diff --git a/sim/paladin/protection/protection_test.go b/sim/paladin/protection/protection_test.go index 22875fb87f..899370998d 100644 --- a/sim/paladin/protection/protection_test.go +++ b/sim/paladin/protection/protection_test.go @@ -99,15 +99,18 @@ var PlayerOptionsSealofRighteousness = &proto.Player_ProtectionPaladin{ } var optionsSealOfCommand = &proto.PaladinOptions{ - PrimarySeal: proto.PaladinSeal_Command, + PrimarySeal: proto.PaladinSeal_Command, + RighteousFury: true, } var optionsSealOfMartyrdom = &proto.PaladinOptions{ - PrimarySeal: proto.PaladinSeal_Martyrdom, + PrimarySeal: proto.PaladinSeal_Martyrdom, + RighteousFury: true, } var optionsSealOfRighteousness = &proto.PaladinOptions{ - PrimarySeal: proto.PaladinSeal_Righteousness, + PrimarySeal: proto.PaladinSeal_Righteousness, + RighteousFury: true, } var ItemFilters = core.ItemFilter{ diff --git a/sim/paladin/righteous_fury.go b/sim/paladin/righteous_fury.go new file mode 100644 index 0000000000..b19dacbcd6 --- /dev/null +++ b/sim/paladin/righteous_fury.go @@ -0,0 +1,58 @@ +package paladin + +import ( + "github.com/wowsims/sod/sim/core" + "github.com/wowsims/sod/sim/core/proto" +) + +func (paladin *Paladin) registerRighteousFury() { + if !paladin.Options.RighteousFury { + return + } + horRune := proto.PaladinRune_RuneHandsHandOfReckoning + hasHoR := paladin.hasRune(horRune) + + actionID := core.ActionID{SpellID: core.TernaryInt32(hasHoR, int32(horRune), 25780)} + + rfThreatMultiplier := 0.6 + core.TernaryFloat64(hasHoR, 0.2, 0.0) + // Improved Righteous Fury is multiplicative. + rfThreatMultiplier *= 1.0 + []float64{0.0, 0.16, 0.33, 0.5}[paladin.Talents.ImprovedRighteousFury] + + paladin.OnSpellRegistered(func(spell *core.Spell) { + if spell.SpellSchool.Matches(core.SpellSchoolHoly) { + spell.ThreatMultiplier *= 1.0 + rfThreatMultiplier + } + }) + + rfAura := core.MakePermanent(&core.Aura{Label: "Righteous Fury", ActionID: actionID}) + + // Passive effects granted by Hand of Reckoning rune; only active if Righteous Fury is on. + if hasHoR { + + // Damage which takes you below 35% health is reduced by 20% (DR component of WotLK's Ardent Defender) + rfDamageReduction := 0.2 + + handler := func(sim *core.Simulation, spell *core.Spell, result *core.SpellResult) { + incomingDamage := result.Damage + if (paladin.CurrentHealth()-incomingDamage)/paladin.MaxHealth() <= 0.35 { + result.Damage -= (paladin.MaxHealth()*0.35 - (paladin.CurrentHealth() - incomingDamage)) * rfDamageReduction + if sim.Log != nil { + paladin.Log(sim, "Righteous Fury absorbs %d damage", int32(incomingDamage-result.Damage)) + } + } + } + + paladin.AddDynamicDamageTakenModifier(handler) + + // Gives you mana when healed by other friendly targets' spells equal to 25% of the amount healed. + horManaMetrics := paladin.NewManaMetrics(actionID) + + rfAura.OnHealTaken = func(aura *core.Aura, sim *core.Simulation, spell *core.Spell, result *core.SpellResult) { + if spell.IsOtherAction(proto.OtherAction_OtherActionHealingModel) { + manaGained := result.Damage * 0.25 + paladin.AddMana(sim, manaGained, horManaMetrics) + } + } + } + paladin.RegisterAura(*rfAura) +} diff --git a/ui/protection_paladin/inputs.ts b/ui/protection_paladin/inputs.ts index 5a6d3221f2..3e9e33dfe5 100644 --- a/ui/protection_paladin/inputs.ts +++ b/ui/protection_paladin/inputs.ts @@ -1,10 +1,9 @@ import * as InputHelpers from '../core/components/input_helpers.js'; import { Player } from '../core/player.js'; -import { Spec } from '../core/proto/common.js'; -import { PaladinSeal, PaladinAura } from '../core/proto/paladin.js'; +import { ItemSlot, Spec } from '../core/proto/common.js'; +import { PaladinRune, PaladinSeal, PaladinAura } from '../core/proto/paladin.js'; import { ActionId } from '../core/proto_utils/action_id.js'; import { TypedEvent } from '../core/typed_event.js'; - // Configuration for spec-specific UI elements on the settings tab. // These don't need to be in a separate file but it keeps things cleaner. @@ -22,6 +21,15 @@ export const AuraSelection = InputHelpers.makeSpecOptionsEnumIconInput({ + fieldName: 'righteousFury', + actionId: (player: Player) => + player.hasRune(ItemSlot.ItemSlotHands, PaladinRune.RuneHandsHandOfReckoning) ? + ActionId.fromSpellId(407627) : ActionId.fromSpellId(25780), + changeEmitter: (player: Player) => + TypedEvent.onAny([player.gearChangeEmitter, player.specOptionsChangeEmitter]), +}); + // The below is used in the custom APL action "Cast Primary Seal". // Only shows SoC if it's talented. export const PrimarySealSelection = InputHelpers.makeSpecOptionsEnumIconInput({ diff --git a/ui/protection_paladin/presets.ts b/ui/protection_paladin/presets.ts index 42a9bce626..320cc4b0dd 100644 --- a/ui/protection_paladin/presets.ts +++ b/ui/protection_paladin/presets.ts @@ -98,6 +98,7 @@ export const DefaultTalents = TalentPresets[Phase.Phase4][0]; export const DefaultOptions = ProtectionPaladinOptions.create({ aura: PaladinAura.SanctityAura, primarySeal: PaladinSeal.Martyrdom, + righteousFury: true, }); export const DefaultConsumes = Consumes.create({ diff --git a/ui/protection_paladin/sim.ts b/ui/protection_paladin/sim.ts index e6dfa91cb6..09e8d1e2e5 100644 --- a/ui/protection_paladin/sim.ts +++ b/ui/protection_paladin/sim.ts @@ -163,7 +163,7 @@ const SPEC_CONFIG = registerSpecConfig(Spec.SpecProtectionPaladin, { }, // IconInputs to include in the 'Player' section on the settings tab. - playerIconInputs: [ProtectionPaladinInputs.PrimarySealSelection, ProtectionPaladinInputs.AuraSelection], + playerIconInputs: [ProtectionPaladinInputs.PrimarySealSelection, ProtectionPaladinInputs.RighteousFuryToggle, ProtectionPaladinInputs.AuraSelection], // Buff and Debuff inputs to include/exclude, overriding the EP-based defaults. includeBuffDebuffInputs: [BuffDebuffInputs.SpellScorchDebuff], excludeBuffDebuffInputs: [],