Fix Points, or "How often do I lifetap?"

Posted on July 16, 2019

Heads up

I’ve moved all the programming and math related bits to the bottom for those interested.

Lifetap

One of the things I love about Warlocks is how nuanced they are and how it ties so well into their class fantasy. Take lifetap, for instance. It allows one to exchange life for mana – pretty metal. This seems exclusively awesome (we’ll never run out of mana)… until we realize that we’re not dpsing while lifetapping.

Ok, so we need to lifetap enough to cast our spells, but any time we aren’t lifetapping is time spent dpsing. One interesting factor is that the mana returned from lifetap scales with shadow damage gear. This is GREAT – it means we can afford to lifetap less often as we acquire better gear.

What we’re looking for

How do we model that? We need to find the appropriate number of lifetaps to cast in a “rotation”. In other words, we’re going to try to find the break even point where we’re generating just as much mana as we’re using. Sure, warlocks also start with a mana pool, but their spells are soo expensive. As an example, shadowbolt is 380 mana vs frostbolt’s 290, not even counting the frostbolt mana reduction talent. Even sitting at 6k mana, a warlock will oom casting shadowbolts in 39.5 seconds, not accounting for other incoming mana sources. For the sake of this post we’ll just concern ourselves with finding the point at which we generate and expend the same amount of mana.

A naive example

According to an old wowwiki page, lifetap has an 80% spellpower coefficient. We’ll also assume one has 2/2 talented improved life tap. Therefore when lifetapping, a warlock gets 504 + 80% of their spell damage in mana.

In one minute (the cooldown of curse of doom), we can cast 1 curse of doom and 23.4 shadowbolts, but that costs a whopping 9192 mana and we’ve generated 0 mana. Ok, lets try something more conservative. 1 curse and 20 shadowbolts. That costs 7900 mana and leaves time for 5.66 lifetaps. That’s 2852.64 mana we’re generating. Ok, not enough, but you get the idea – we can iteratively get closer.

What if we could perform this iteration automatically and find the optimal equilibrium?

Looking at the data

Ok, let’s look at a few different distributions for warlocks with 0 spell damage, 300 spell damage, and 600 spell damage. Alotting time for one curse of doom, these are the break even points for example warlocks of different gear levels:

0 spell damage   -> 15.865 shadowbolts and 12.558 lifetaps

300 spell damage -> 17.726 shadowbolts and 9.457 lifetaps

600 spell damage -> 18.849 shadowbolts and 7.584 lifetaps

This is one of my favorite warlock mechanics: more spell damage frees up more casting time for shadow bolts!

Diminishing returns

It’s important to note that in the previous example, each 300pt increase in shadow damage has diminishing returns with respect to the number of lifetaps it removes from the rotation. This is because the benefits of the +dmg are consolidated into fewer casts. Moving from 0 -> 300 spell damage, we decrease the number of lifetaps by 12.558 - 9.457 = 3.101 lifetaps, freeing up 4.6515s of casting time. However, moving from 300 -> 600 spell damage, we decrease the number of lifetaps by 9.457 - 7.584 = 1.873 lifetaps, freeing only 2.809s of casting time.

Conclusion

In short, there are a few salient points: 1) Since lifetap is increased by spell damage, gaining spell damage results in less lifetap casts and more casting time for shadow bolt. 2) The spell damage benefits for lifetap have diminishing returns.

I hope you’ve enjoyed it so far, but this is the end of the high level overview. Feel free to stop reading if you don’t care about the implementation details, which are a little computer-sciencey.

Here be dragons

Below here is a more technical overview for this post. Code examples are in Haskell, which can be jarring for the uninitiated, but I’ll try to describe it in language-agnostic terms so familiarity with Haskell should be helpful, but unnecessary.

Earlier we described finding “the break even point where we’re generating just as much mana as we’re using”. As it turns out, this is a Fixed point. As an example, try feeding cosine(x) into a calculator (in radians mode), then feed that into cosine and repeat ad nauseam. You’ll notice that it converges to ~0.739085133. How cool is that?

Now, we’re not looking for cosine, but instead the point at which our spell distributions converge such that the mana gained = the mana expended. Fortunately, there is something called a Fixed-point combinator. Fixed point combinators are functions which take functions as arguments and return the fixed points they resolve to, if any exists. Ok, that’s a lot. Really. I spent so much time looking at this stuff before it started to make sense to me (Hell, it only makes a bit of sense to me now). Don’t sweat it if it’s difficult to grok.

Side note

Point being, this allows us to find equilibriums for other functions. This is actually a definition for recursion! The infamous Y combinator is a fixed point combinator and acts as a definition for recursion in languages that don’t explicitly support recursion.

Calculating a fixed point

Circling back on the problem at hand, we’re going to use haskell’s fix fixed-point combinator to determine how much to lifetap. The function that we pass to fix will take two arguments. The first is the recursive aspect, aka a function that does the next attempt in our recursive calculation. The second argument is the spell distribution at hand.

Before showing the lifetap code, here’s a simpler example which will recursively reduce a number until it’s less than or equal to zero.

z 10 yields 0.

For our warlock example, we’ll want to pass it an initial spell distribution that has no reserved time for life tapping. Then, we’ll keep calculating closer and closer spell distributions until we end up with one which generates and expends the same amount of mana.

{-
spellDist calculates the fixed point of a spell rotation which includes lifetaps.
The idea is that for a given spell distribution, you can calculate how many lifetaps are required
to break even. Adding those to the distribution alters it, though. Thus, we use fix to calculate
the fixed point, iteratively adjusting the distribution until we reach an acceptable
threshold of accuracy.
-}
spellDist :: Float -> Dist (Spell Character)
spellDist spellDmg =
  fix approximate initialDist
  where
    -- initialDist represents the initial spell distribution with 0 lifetaps
    -- and 0 reserved seconds of casting lifetap in a cycle (aka one curse of doom duration)
    initialDist = ((spellDistWithReserved spellPrios 0), 0)
    approximate recur (Dist xs, reserved) =
     let totalCost = sum $ map (\(x, p) -> p * manaCost x) xs
         perTap = 504 + 0.8 * spellDmg -- mana gained per lifetap
         lifetaps cost = cost / perTap -- # of lifetaps required to gain x mana
         tapsIn t = t / castTime lifeTap -- # of life taps in a period t
         manaReserved = tapsIn reserved * perTap -- mana gained from lifetapping w/ reserved time
         reservedDiff = lifetaps (manaReserved - totalCost) * castTime lifeTap -- num lifetaps to add/subtract to hit new distribution
         reserved' = reserved - reservedDiff -- how much to reserve next attempt
         finished = abs reservedDiff < 0.001 -- arbitrary finished threshold
     in if finished
        then Dist $ xs ++ [(lifeTap, tapsIn reserved)] -- add lifetaps into reserved space
        else recur (spellDistWithReserved spellPrios reserved', reserved')

Note: I’ve elided the code for spellDistWithReserved, but it will populate a spell distribution and account for a portion of the input space (cast time) to be reserved for something else (lifetap). This importantly allows us to replace low value casts (shadowbolt) with lifetap instead of high value casts (curse of doom).

That’s all for now – I’ve been wanting to write this one for a while.