diff --git a/proto/warlock.proto b/proto/warlock.proto index b47cded012..79fa4a3fc7 100644 --- a/proto/warlock.proto +++ b/proto/warlock.proto @@ -101,6 +101,9 @@ message WarlockOptions { WeaponImbue weapon_imbue = 3; MaxFireboltRank max_firebolt_rank = 4; bool pet_pool_mana = 5; + bool pet_passive = 6; + bool ignore_life_tap_damage = 7; + double assumed_life_tap_hps = 8; } message Warlock { diff --git a/sim/warlock/dps/dps_warlock_test.go b/sim/warlock/dps/dps_warlock_test.go index e67aa7ff72..fc154915b8 100644 --- a/sim/warlock/dps/dps_warlock_test.go +++ b/sim/warlock/dps/dps_warlock_test.go @@ -54,6 +54,54 @@ func TestWarlockDSRuin(t *testing.T) { })) } +func TestLifeTapAssumedHealing(t *testing.T) { + runWithHPS := func(hps float64) *proto.RaidSimResult { + warlockOptions := *DefaultDestroWarlock.Warlock.Options + warlockOptions.AssumedLifeTapHps = hps + player := core.WithSpec(&proto.Player{ + Class: proto.Class_ClassWarlock, + Race: proto.Race_RaceOrc, + Equipment: core.GetGearSet("../../../ui/warlock/gear_sets", "mc").GearSet, + TalentsString: TalentsSMRuin, + Rotation: core.GetAplRotation("../../../ui/warlock/apls", "rotation").Rotation, + }, &proto.Player_Warlock{Warlock: &proto.Warlock{Options: &warlockOptions}}) + + result := core.RunRaidSim(&proto.RaidSimRequest{ + Raid: core.SinglePlayerRaidProto(player, nil, nil, nil), + Encounter: core.MakeSingleTargetEncounter(0), + SimOptions: &proto.SimOptions{Iterations: 1, IsTest: true, RandomSeed: 1}, + }) + if result.Error != nil { + t.Fatalf("simulation failed: %s", result.Error.Message) + } + return result + } + + withoutHealing := runWithHPS(0) + withHealing := runWithHPS(20000) + withNegativeHealing := runWithHPS(-20000) + withoutHealingDPS := withoutHealing.RaidMetrics.Parties[0].Players[0].Dps.Avg + withHealingDPS := withHealing.RaidMetrics.Parties[0].Players[0].Dps.Avg + withNegativeHealingDPS := withNegativeHealing.RaidMetrics.Parties[0].Players[0].Dps.Avg + if withHealingDPS <= withoutHealingDPS { + t.Fatalf("assumed healing did not improve DPS: without healing %.1f, with healing %.1f", withoutHealingDPS, withHealingDPS) + } + if withNegativeHealingDPS > withoutHealingDPS { + t.Fatalf("negative assumed healing improved DPS: without healing %.1f, with negative healing %.1f", withoutHealingDPS, withNegativeHealingDPS) + } + + for _, resource := range withHealing.RaidMetrics.Parties[0].Players[0].Resources { + if resource.Type == proto.ResourceType_ResourceTypeHealth && + resource.Id.GetOtherId() == proto.OtherAction_OtherActionHealingModel && + resource.ActualGain > 0 { + t.Logf("DPS without healing %.1f, with healing %.1f; restored %.1f health", withoutHealingDPS, withHealingDPS, resource.ActualGain) + return + } + } + + t.Fatal("assumed healing produced no effective health gain") +} + var TalentsSMRuin = "5502203112201105--52500051020001" var TalentsDSRuin = "25002-2050300152201-52500051020001" diff --git a/sim/warlock/lifetap.go b/sim/warlock/lifetap.go index fc2d3a9d34..b1839892c4 100644 --- a/sim/warlock/lifetap.go +++ b/sim/warlock/lifetap.go @@ -49,9 +49,10 @@ func (warlock *Warlock) getLifeTapBaseConfig(rank int) core.SpellConfig { result := spell.CalcDamage(sim, spell.Unit, baseDamage, spell.OutcomeAlwaysHit) restore := result.Damage - if warlock.IsTanking() { - spell.DealDamage(sim, result) + if !warlock.Options.IgnoreLifeTapDamage { + warlock.RemoveHealth(sim, result.Damage) } + spell.DisposeResult(result) warlock.AddMana(sim, restore, manaMetrics) }, @@ -61,10 +62,22 @@ func (warlock *Warlock) getLifeTapBaseConfig(rank int) core.SpellConfig { func (warlock *Warlock) registerLifeTapSpell() { warlock.LifeTap = make([]*core.Spell, 0) for i := 1; i <= LifeTapRanks; i++ { + rank := i config := warlock.getLifeTapBaseConfig(i) if config.RequiredLevel <= int(warlock.Level) { - warlock.LifeTap = append(warlock.LifeTap, warlock.GetOrRegisterSpell(config)) + lifeTap := warlock.GetOrRegisterSpell(config) + lifeTap.ExtraCastCondition = func(sim *core.Simulation, _ *core.Unit) bool { + if warlock.Options.IgnoreLifeTapDamage { + return true + } + + result := lifeTap.CalcDamage(sim, lifeTap.Unit, LifeTapBaseDamage[rank], lifeTap.OutcomeAlwaysHit) + hasEnoughHealth := warlock.CurrentHealth() > result.Damage + lifeTap.DisposeResult(result) + return hasEnoughHealth + } + warlock.LifeTap = append(warlock.LifeTap, lifeTap) } } } diff --git a/sim/warlock/lifetap_healing.go b/sim/warlock/lifetap_healing.go new file mode 100644 index 0000000000..da51a169f7 --- /dev/null +++ b/sim/warlock/lifetap_healing.go @@ -0,0 +1,63 @@ +package warlock + +import ( + "time" + + "github.com/wowsims/classic/sim/core" + "github.com/wowsims/classic/sim/core/proto" +) + +const ( + lifeTapHealingCadenceSeconds = 3.0 + lifeTapHealingCadenceVariationSeconds = 1.0 +) + +func (warlock *Warlock) registerLifeTapHealingModel() { + hps := warlock.Options.AssumedLifeTapHps + if hps == 0 || warlock.Options.IgnoreLifeTapDamage || warlock.IsTanking() { + return + } + + minCadence := max(0.0, lifeTapHealingCadenceSeconds-lifeTapHealingCadenceVariationSeconds) + cadenceVariationLow := lifeTapHealingCadenceSeconds - minCadence + + healthMetrics := warlock.NewHealthMetrics(core.ActionID{OtherID: proto.OtherAction_OtherActionHealingModel}) + healingModelSpell := warlock.RegisterSpell(core.SpellConfig{ + ActionID: core.ActionID{OtherID: proto.OtherAction_OtherActionHealingModel}, + }) + + rollHealingCadence := func(sim *core.Simulation) time.Duration { + signRoll := sim.RandomFloat("Life Tap Healing Cadence Variation Sign") + magnitudeRoll := sim.RandomFloat("Life Tap Healing Cadence Variation Magnitude") + if signRoll < 0.5 { + return core.DurationFromSeconds(minCadence + magnitudeRoll*cadenceVariationLow) + } + return core.DurationFromSeconds(lifeTapHealingCadenceSeconds + magnitudeRoll*lifeTapHealingCadenceVariationSeconds) + } + + warlock.RegisterResetEffect(func(sim *core.Simulation) { + timeToNextHeal := rollHealingCadence(sim) + pa := &core.PendingAction{NextActionAt: sim.CurrentTime + timeToNextHeal} + + pa.OnAction = func(sim *core.Simulation) { + healthDelta := hps * timeToNextHeal.Seconds() + if healthDelta >= 0 { + totalHeal := healthDelta * warlock.PseudoStats.HealingTakenMultiplier + warlock.GainHealth(sim, totalHeal, healthMetrics) + + result := healingModelSpell.NewResult(&warlock.Unit) + result.Damage = totalHeal + warlock.OnHealTaken(sim, healingModelSpell, result) + healingModelSpell.DisposeResult(result) + } else { + warlock.RemoveHealth(sim, -healthDelta) + } + + timeToNextHeal = rollHealingCadence(sim) + pa.NextActionAt = sim.CurrentTime + timeToNextHeal + sim.AddPendingAction(pa) + } + + sim.AddPendingAction(pa) + }) +} diff --git a/sim/warlock/pet.go b/sim/warlock/pet.go index 62949ba935..38176ee272 100644 --- a/sim/warlock/pet.go +++ b/sim/warlock/pet.go @@ -109,6 +109,10 @@ func (warlock *Warlock) makePet(cfg PetConfig, enabledOnStart bool) *WarlockPet wp.AddStatDependency(stats.Intellect, stats.SpellCrit, core.CritPerIntAtLevel[proto.Class_ClassWarrior]*core.SpellCritRatingPerCritChance) // Imps generally don't melee + if warlock.Options.PetPassive { + cfg.AutoAttacks.AutoSwingMelee = false + cfg.AutoAttacks.AutoSwingRanged = false + } wp.EnableAutoAttacks(wp, cfg.AutoAttacks) wp.AutoAttacks.MHConfig().DamageMultiplier *= 1.0 + 0.04*float64(warlock.Talents.UnholyPower) } @@ -157,7 +161,7 @@ func (wp *WarlockPet) ApplyOnPetDisable(newOnPetDisable OnPetDisable) { } func (wp *WarlockPet) ExecuteCustomRotation(sim *core.Simulation) { - if !wp.IsEnabled() || wp.primaryAbility == nil { + if !wp.IsEnabled() || wp.owner.Options.PetPassive || wp.primaryAbility == nil { return } diff --git a/sim/warlock/warlock.go b/sim/warlock/warlock.go index 074d395e81..28a5187a7b 100644 --- a/sim/warlock/warlock.go +++ b/sim/warlock/warlock.go @@ -105,6 +105,7 @@ func (warlock *Warlock) Initialize() { warlock.registerImmolateSpell() warlock.registerShadowBoltSpell() warlock.registerLifeTapSpell() + warlock.registerLifeTapHealingModel() warlock.registerSoulFireSpell() warlock.registerShadowBurnSpell() // warlock.registerSeedSpell() diff --git a/ui/core/components/detailed_results/timeline.tsx b/ui/core/components/detailed_results/timeline.tsx index 5c690401fc..1c3d12a0eb 100644 --- a/ui/core/components/detailed_results/timeline.tsx +++ b/ui/core/components/detailed_results/timeline.tsx @@ -530,7 +530,8 @@ export class Timeline extends ResultComponent { const playerPets = new Map(); player.pets.forEach(petsLog => { const petCastsByAbility = this.getSortedCastsByAbility(petsLog); - if (petCastsByAbility.length > 0) { + const hasResourceLogs = orderedResourceTypes.some(resourceType => petsLog.groupedResourceLogs[resourceType].length > 0); + if (petCastsByAbility.length > 0 || hasResourceLogs) { // Because mulle pets can have the same name and we parse cast logs // by pet name each individual pet ends up with all the casts of pets // with the same name. Because of this we can just grab the first pet diff --git a/ui/core/components/inputs/warlock_inputs.ts b/ui/core/components/inputs/warlock_inputs.ts index 6eb345284b..9ceb252d69 100644 --- a/ui/core/components/inputs/warlock_inputs.ts +++ b/ui/core/components/inputs/warlock_inputs.ts @@ -81,3 +81,37 @@ export const PetPoolManaInput = () => labelTooltip: 'Should Pet keep trying to cast on every mana regen instead of waiting for mana', changeEmitter: (player: Player) => player.changeEmitter, }); + +export const PetPassiveInput = () => + InputHelpers.makeSpecOptionsBooleanInput({ + fieldName: 'petPassive', + label: 'Passive Pet', + labelTooltip: 'Keep the selected pet summoned without attacking or casting abilities.', + showWhen: player => player.getSpecOptions().summon != Summon.NoSummon, + changeEmitter: (player: Player) => player.changeEmitter, + }); + +export const IgnoreLifeTapDamageInput = () => + InputHelpers.makeSpecOptionsBooleanInput({ + fieldName: 'ignoreLifeTapDamage', + label: 'Ignore Life Tap Damage', + labelTooltip: "Life Tap restores mana without reducing the Warlock's health.", + changeEmitter: (player: Player) => player.changeEmitter, + }); + +export const AssumedHealingPerSecondInput = (): InputHelpers.TypedNumberPickerConfig> => ({ + id: 'life-tap-assumed-healing-per-second', + type: 'number', + label: 'Assumed Healing per Second', + labelTooltip: + 'Average incoming healing per second. The default 50 HPS is a conservative estimate of healing available for Life Tap after incidental damage and overhealing. Negative values model net incoming damage. Healing has an average cadence of 3 seconds with 1 second of variation.', + float: true, + changedEvent: player => player.specOptionsChangeEmitter, + getValue: player => player.getSpecOptions().assumedLifeTapHps, + setValue: (eventID, player, newValue) => { + const options = player.getSpecOptions(); + options.assumedLifeTapHps = newValue; + player.setSpecOptions(eventID, options); + }, + enableWhen: player => !player.getSpecOptions().ignoreLifeTapDamage, +}); diff --git a/ui/warlock/presets.ts b/ui/warlock/presets.ts index 126b82dbaf..35f7dbf207 100644 --- a/ui/warlock/presets.ts +++ b/ui/warlock/presets.ts @@ -90,6 +90,7 @@ export const DefaultOptions = WarlockOptions.create({ armor: Armor.DemonArmor, summon: Summon.Succubus, weaponImbue: WarlockWeaponImbue.NoWeaponImbue, + assumedLifeTapHps: 50, }); export const DefaultConsumes = Consumes.create({ diff --git a/ui/warlock/sim.ts b/ui/warlock/sim.ts index b73a6fe945..47405a540d 100644 --- a/ui/warlock/sim.ts +++ b/ui/warlock/sim.ts @@ -2,13 +2,34 @@ import * as BuffDebuffInputs from '../core/components/inputs/buffs_debuffs'; import * as ConsumablesInputs from '../core/components/inputs/consumables.js'; import * as WarlockInputs from '../core/components/inputs/warlock_inputs'; import * as OtherInputs from '../core/components/other_inputs.js'; +import { BooleanPicker } from '../core/components/boolean_picker.js'; +import { ContentBlock } from '../core/components/content_block.js'; import { IndividualSimUI, registerSpecConfig } from '../core/individual_sim_ui.js'; +import { NumberPicker } from '../core/components/number_picker.js'; import { Player } from '../core/player.js'; import { Class, Faction, ItemSlot, PartyBuffs, PseudoStat, Race, Spec, Stat } from '../core/proto/common.js'; import { Stats } from '../core/proto_utils/stats.js'; import { getSpecIcon } from '../core/proto_utils/utils.js'; import * as Presets from './presets.js'; +const LifeTapSection = (parentElem: HTMLElement, simUI: IndividualSimUI) => { + const contentBlock = new ContentBlock(parentElem, 'life-tap-settings', { + header: { title: 'Life Tap' }, + }); + + new BooleanPicker(contentBlock.bodyElement, simUI.player, { + ...WarlockInputs.IgnoreLifeTapDamageInput(), + reverse: true, + inline: true, + }); + new NumberPicker(contentBlock.bodyElement, simUI.player, { + ...WarlockInputs.AssumedHealingPerSecondInput(), + inline: true, + }); + + return contentBlock; +}; + const SPEC_CONFIG = registerSpecConfig(Spec.SpecWarlock, { cssClass: 'warlock-sim-ui', cssScheme: 'warlock', @@ -116,8 +137,9 @@ const SPEC_CONFIG = registerSpecConfig(Spec.SpecWarlock, { petConsumeInputs: [ConsumablesInputs.PetAttackPowerConsumable, ConsumablesInputs.PetAgilityConsumable, ConsumablesInputs.PetStrengthConsumable], // Inputs to include in the 'Other' section on the settings tab. otherInputs: { - inputs: [WarlockInputs.PetPoolManaInput(), OtherInputs.DistanceFromTarget, OtherInputs.ChannelClipDelay], + inputs: [WarlockInputs.PetPassiveInput(), WarlockInputs.PetPoolManaInput(), OtherInputs.DistanceFromTarget, OtherInputs.ChannelClipDelay], }, + customSections: [LifeTapSection], itemSwapConfig: { itemSlots: [ItemSlot.ItemSlotMainHand, ItemSlot.ItemSlotOffHand, ItemSlot.ItemSlotRanged], },