Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions proto/warlock.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
48 changes: 48 additions & 0 deletions sim/warlock/dps/dps_warlock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
19 changes: 16 additions & 3 deletions sim/warlock/lifetap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand All @@ -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)
}
}
}
63 changes: 63 additions & 0 deletions sim/warlock/lifetap_healing.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
6 changes: 5 additions & 1 deletion sim/warlock/pet.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}

Expand Down
1 change: 1 addition & 0 deletions sim/warlock/warlock.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ func (warlock *Warlock) Initialize() {
warlock.registerImmolateSpell()
warlock.registerShadowBoltSpell()
warlock.registerLifeTapSpell()
warlock.registerLifeTapHealingModel()
warlock.registerSoulFireSpell()
warlock.registerShadowBurnSpell()
// warlock.registerSeedSpell()
Expand Down
3 changes: 2 additions & 1 deletion ui/core/components/detailed_results/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,8 @@ export class Timeline extends ResultComponent {
const playerPets = new Map<string, UnitMetrics>();
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
Expand Down
34 changes: 34 additions & 0 deletions ui/core/components/inputs/warlock_inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,37 @@ export const PetPoolManaInput = <SpecType extends WarlockSpecs>() =>
labelTooltip: 'Should Pet keep trying to cast on every mana regen instead of waiting for mana',
changeEmitter: (player: Player<SpecType>) => player.changeEmitter,
});

export const PetPassiveInput = <SpecType extends WarlockSpecs>() =>
InputHelpers.makeSpecOptionsBooleanInput<SpecType>({
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<SpecType>) => player.changeEmitter,
});

export const IgnoreLifeTapDamageInput = <SpecType extends WarlockSpecs>() =>
InputHelpers.makeSpecOptionsBooleanInput<SpecType>({
fieldName: 'ignoreLifeTapDamage',
label: 'Ignore Life Tap Damage',
labelTooltip: "Life Tap restores mana without reducing the Warlock's health.",
changeEmitter: (player: Player<SpecType>) => player.changeEmitter,
});

export const AssumedHealingPerSecondInput = <SpecType extends WarlockSpecs>(): InputHelpers.TypedNumberPickerConfig<Player<SpecType>> => ({
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,
});
1 change: 1 addition & 0 deletions ui/warlock/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
24 changes: 23 additions & 1 deletion ui/warlock/sim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Spec.SpecWarlock>) => {
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',
Expand Down Expand Up @@ -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],
},
Expand Down