This is a document about another exercise in decision theory applied to video games. The video in this edition is divinity: original sins 2. It's actually very good and it gave me a small nerd snipe.

This is the red prince (thought, I prefer to call him big red). He is a video game character from divinity 2 and he can deal lot's of damage. Like many role playing video games you can make the character deal even more damage by appropriately investing in attribute points that you are given.

There are two stats that are important for the type of buffy attack giant that I want big red to be: strength and wits. Strength increases damage (+5% every point) and wits increases critical strike chance (+1% for every point). A critical strike (let's keep it simple) causes an attack to deal double damage. Given this system, what is the optimal allocation of points?

Automate The Boring Stuff

We can model this system, work out the maths and then come to a conclusion. It's usually the modelling of the system and the conclusion that is the fun part though of such analysis. The maths part in the middle is usually an error prone part that is obligatory but not the part that we love talking about.

Let's automate this avoidable maths with sympy. It is a library that can be interpreted as the python reply to mathematica. It does inference and evaluation of mathematical symbols such that you don't have to.

Let's load up sympy and create some math symbols. We'll assume we have 100 attributes to distribute.

import sympy as sp

s, w, t = sp.symbols("s,w,t")
t = 100
s = t - w
p = w/100
d = 100*(1 + s/20)
e = 2*p*d + (1-p)*d

In this script we have generate a few variables:

  • t is the total number of points to allocate
  • s is the number of points we put in strength
  • w is the number of points we put in wits
  • d is the base damage we deal given our strength attributes
  • e is the expected damage we deal which takes critical strikes into account

If we now evaluate e you'll notice something rather pretty.

> print(e)
w*(5*w + 500)/50 + (-w/100 + 1)*(5*w + 500)

Without extra effort on our part, sympy has inferred and exchanged all know parameters such that e is expressed solely in w. We can even simplify the expression a bit.

> sp.simplify(e)
-w**2/20 + w + 600

Next we would like to differentiate this expression and set it equal to 0. This is something sympy can do too.

> sp.diff(e, w)
w/10 + 10
> sp.solve(sp.diff(e, w), w, 0)
[{w: 10}]

First we differentiate e with regards to w which gives us an expression as a result. We can take this expression, pass it to sp.solve and tell it to solve for w and that the expression needs to equal 0. Next we are told by sympy that there is a unique solution for w.

This is great but we may be interested in something that is a bit more general. Currently we only have the optimum for 100 attribute points. What happens if we remove the total number of attributes from our system.

s, w = sp.symbols("s,w")
p = w/100
d = 100*(1 + s/20)
e = 2*p*d + (1-p)*d

We redefined our symbols and removed t. Sympy can still do all of it's work but the results are slightly different.

> sp.diff(e, w)
(s/20 + 1)*(w + 100)
> sp.diff(e, s)
s/20 + 1
> sp.solve(sp.diff(e, w) - sp.diff(e, s), 0, s)[0]
{s: w + 80}

We now know the relationship between strength and wits if they are optimally chosen independant of the number of total points. Great! We can use this knowledge to create a function that tells us the optimal expected damage output.

Let's first redefine our system.

s = w + 80
p = w/100
d = 100*(1 + s/20)
e = 2*p*d + (1-p)*d

And now explore what our expected optimal damage looks like.

> sp.simplify(e)
(w/20 + 5)*(w + 100)

Maths $\neq$ Interpretation

The boring parts of the maths have been automated. It still needs a lot of interpretation though and most people still prefer to communicate these topics visually. Instead of doing all this with maths, you can also just get there by running a simple bit of R code to calculate everything on your behalf.

total_points <- 200

pltr <- data_frame(strength = 1:total_points, 
                 wits = total_points - strength) %>% 
  complete(strength, wits) %>% 
  mutate(damage = 100*(1 + 0.05*(strength)),
         prob_crit = 0.01*(wits),
         expected = (damage*prob_crit*2 + damage*(1-prob_crit))) %>% 
  arrange(-expected) %>% 
  mutate(total_points = as.factor(strength + wits),
         percentage_wits = wits / (strength+wits),
         expected_maths = (wits/20 + 5)*(wits + 100)) %>% 
  filter(total_points %in% c(40, 60, 80, 100, 120, 150, 180))

ggplot() + 
  geom_line(data=pltr, aes(wits, expected, colour=total_points)) + 
  geom_line(data=pltr, aes(wits, expected_maths)) + 
  ylim(NA, 1200) + 
  ggtitle("expected damage given points invested in wits and total number of attribute points",
          subtitle="Black line shows optimal expected value.")

More Sympy

This was a very simple example but it only touches on a few of the things that sympy can be used for. It doesn't do everything (and isn't a full replacement of matematica in many ways) but it can do things like ...

Integration

If we can do differentiation, you can expect integration to follow.

> x = sp.symbols("x")
> sp.integrate(x/(x**2+2*x+1), x)
log(x + 1) + 1/(x + 1)
> f = x/(x**2+2*x+1)
> sp.integrate(f, (x, 0, 1))
-1/2 + log(2)

Probability Theory

There's even some stuff from probability theory that you might enjoy.

> Y = sp.stats.DiscreteUniform('Y', list(range(3)))
> Z = sp.stats.DiscreteUniform('Z', list(range(5)))
> sp.stats.density(Y).dict
{0: 1/3, 1: 1/3, 2: 1/3}
> sp.stats.density(Z).dict
{0: 1/5, 1: 1/5, 2: 1/5, 3: 1/5, 4: 1/5}
> sp.stats.density(1 + 2*Z).dict
{7: 1/5, 1: 1/5, 3: 1/5, 5: 1/5, 9: 1/5}
> sp.stats.density(Y + Z)
{0: 1/15, 1: 2/15, 2: 1/5, 3: 1/5, 4: 1/5, 5: 2/15, 6: 1/15}

This is pretty nice, but there's some extra stuff that makes all this beyond the realm of curious fun and touching the realm of useful.

> sp.stats.E(Y + Z)
3
> sp.stats.variance(Y + Z)
8/3
> sp.stats.density(Y + Z, given_condition=Z < 2)
{0: 1/6, 1: 1/3, 2: 1/3, 3: 1/6}
> sp.stats.E(Y + Z, given_condition=Z < 2)
3
> sp.stats.P(Y + Z < 3, given_condition=Z < 2)
5/6
> sp.stats.covariance(Y, Z)
0
> sp.stats.covariance(Y, Y+Z)
2/3

There's also support for continous distributions by the way.

> X = sp.stats.Normal('X', 0, 1)
> sp.solve(sp.diff(sp.stats.density(X)(x)))
[0]
> X = sp.stats.Normal('X', 10, 1)
> sp.solve(sp.diff(sp.stats.density(X)(x)))
[10]

Lemma Checking

Sympy has an understanding of functions and mathematical constructs. This means you can have it query lemmas by expanding expressions. For example, if you want to check how to properly construct variance:

> a, b = sp.symbols("a b")
> sp.stats.Variance(a*Y + Z).doit()
a**2*Variance(Y) + 2*a*Covariance(Y, Z) + Variance(Z)

Or if you want to derive derivative rules.

> x = sp.symbols("x")
> f, g = sp.Function("f"), sp.Function("g")
> sp.simplify(sp.diff(f(x)*(g(x))))
f(x)*Derivative(g(x), x) + g(x)*Derivative(f(x), x)

Language Printing

It may be possible that you've been deriving a BigFormula that now needs to be moved to a different language. Sympy has some help for you here. Below we'll print some maths to javascript for example.

> sp.printing.jscode(sp.sin(x/(x**2+2*x+1)))
'Math.sin(x/(Math.pow(x, 2) + 2*x + 1))'
> sp.printing.jscode(sp.diff(sp.sin(x/(x**2+2*x+1))))
'(x*(-2*x - 2)/Math.pow(Math.pow(x, 2) + 2*x + 1, 2) + 1/(Math.pow(x, 2) + 2*x + 1))*Math.cos(x/(Math.pow(x, 2) + 2*x + 1))'

Print Pretty Latex

From jupyter, you merely need to run sp.init_printing() to get pretty maths output.

Python Gospel

Gotta love it when a library does things like this:

There's other features that go outside the scope of this introduction like physics support, differential equations and linear algebra. When you don't feel like doing lot's of repeated maths; this library can help you out. For anything numeric though; I'd stick to numpy.

Conclusion

Sympy is cool. As far as the red prince is concerned though: since you'll only get about 60 attributes in one playthrough, big red's gonna be buff, not witty.

Note that I've been modelling with a lot of assumptions:

  • I don't take items into effect
  • I don't look at team composition
  • I don't consider the other benefits of the wits stat besides critical hit
  • I assume that critical hits double the damage

If you have strong feelings against these assumptions you may want to join in on this conversation on reddit.