Background

When I began to write the first draft of the previous post, I planned to exapend the model by including healing options, or range attacks. However, somebody pointed out that at small values of AC it might be more beneficial to upgrade the damage rather than the armor. This didn’t seem intuitive enough for me to let it slide, and I’ve decided to do the calculation of the a broad range of AC , damage, and HP . Indeed, there is a region that upgrading the damage die is the best option, however, it is always preferable to have extremely high AC than damage die. The code I provide here can be easily changed to count for different opponent, and for damage bonuses.

The Math

At the root of many simulations, including mines, lies the concept of Markov chain. This is also true for the calculations in this post. The exact details of Markov chain are not important to understand this post. It is only important to understand the assumption that what happens every round isn’t related to the history of the rounds, but only on the condition at when a character takes an action. This is not true for real battle, or even for “real” DnD battle, but at their current level, the simulations are actually Markov chains. That is, the fact that the player’s attack succeeded at round doesn’t affect the player choice, or his probabilities at the subsequent rounds. All the history parameters are accumulated into the various probabilities at the beginning of the round. The importance of this for our calculation is that the probability of dealing total damage of at step is

(1)

This equation is somewhat hard to read if you are not used to reading math; I’ll try and break it slowly. At the beginning of every turn, there is a set of possible of total damages the opponent has sustained

each has its own probability

this is the what the indexes in equation (1) represent.

In addition, there is the possible damage set, the end result of the attack and damage dice results.

with probabilities

this is represented by the index . This set includes the option to deal no damage (by missing), or extra damage (via critical).

similarly, this is a possible total damage at the end of this turn, which is again, a part of a set

this set holds the probabilities that we want to find

The (sum) signs at the equation (1) means adding going over every member of the set, and doing whatever action written right to it, and summing over all of this. The parts index with means what we change through the summing. The is called a delta function which is equal to 1 when the condition in the parenthesis is equal to 0, or 0 otherwise. Its role there is to negate any combination of damage that is not equal exactly to the total damage that we want to calculate. The set of probabilities at the beginning of each turn is exactly the same as the set of probabilities calculated at the previous round at the turn of the same opponent.

In this model, there are three paths to reach each damage, missing, heating with regular damage, scoring critical hit.

Missing

To reach a certain damage via miss, we require that damage to be at the s set, and that we will miss the attack. The probability under these conditions is

. (2)

that is, the chance of reaching this point with the opponent having exactly the same damage, and missing this turn. is the chance of the opponent not winning previous round, and is the 0.05 ( AC -1).

Via non critical damage

The chance of hitting your opponent, not dealing critical damage, and reaching exactly damage via one path

(3)

note that there based on the damage die, there are several valid options, and they should all be accounted for. = 0.05 ( 19-AC ) is the chance of heat and not dealing critical damage. I use 19 and not 20 due to the critical option. The is exactly one over the damage die maximum value.

Via critical damage

This is similar, though not exactly identical, to the non critical route, and it reads. The important thing to remember is there is some over lap between the possible values of damage here and with the

(3)

Now it is only matter of plugin everything appropriately into equation (1). Summing this by hand is possible, but it is a tedious work. Instead, I used python to calculate the probabilities up to machine precision. The calculation span the entire range of AC from 2 to 20, damage die from 2 to 12, and HP from 1 to 80. For each HP of the player I’ve generated a surface of probabilities in the AC -damage die plain.

Animation 1: The probability of wining a battle at various HP , AC , and damage die. For each frame of the animation, the HP is constant, and the surface map the entire AC and damage die range. The color scale moves between red for wining probability 1 (sure win) and blue for 0 (sure loose).



The interesting data in animation 1 is not the probability of win, but rather, its derivative with respect to AC and damage die1. Derivatives check how a function reacts with respect to a change in one of its variable, which is what we are looking for: should I prefer to improve my AC or my damage die at a given point. Note that what I’m doing here is not exactly a derivative, since the values are discrete, rather than continues. Furthermore, the system add some extra constraint where I’m not allowed to improve both at one. At least not in the current calculations/simulations.

Animation 2: The “best choice” of upgrade at a specific `HP`. The arrow indicate the suggested upgrade, and their size are a measure of the significance of the effect.



In animation 2 I present my interpretation of the derivative. At a certain HP at each point in the AC -damage die plain, I’ve checked the 2 possible options, upgrade the AC , or the damage die, and draw an arrow in the direction which had higher increase in the winning probability as showed in animation 1. The size of the arrows is a measure of the strength of the effect, and is proportional both within each snapshot, or through all the snapshots. Looking at the animation I notice for several things. The most prominent is the value of high AC ; having values of it are provide much better chance of winning both at low values of HP and damage die. The more interesting part though, is the intermediate, which I completely missed at my previous post. If you have low chance of winning, focus on the damage, and only after a certain spot, shift your focus to improving your AC . Further more, there is a clear straight line that separates between the two region. Such lines usually suggest a simple function that unites the three parameters. This is probably related to the encounter monster building instructions.

Eventhough the data displayed here is specific to my standard opponents. The conclusion at the edges should be relevant for a wider range of conditions; it is based on the probabilities and the ability to either sustain a punch, or deliver a harder one. Another insight, that I find much more important, is that as DMs, we should strive to direct our encounters to that intermediate region. At this region, encounters will be much more interesting, and every decision the character will have, right or wrong, will have an interesting impact on the encoutner. Finally there are much more aspects to consider, even for this model. For example, how would upgrading the attack probability affect the probabilities. I don’t discuss this as it can be mapped to changing the opponent HP or the attack bonus.

Conclusion

This is how I understand the plots, I’m curious if any of you sees things differently. What we have here is a contest where two opponents compete to reach a value. A player can choose one of two tactics, improve his score capabilities (damage), or hinder his opponent’s ones( AC ). At each round, each of the players have an average value of damage. This value includes the probability of hit, and the damage. The HP value can be normalized into this factor by fixing the damage probability. This is why the plots have a symmetric quality. And this this the cause of the distinct line at the arrow animations. Usually, a character has an advantage it should strengthen, damage or AC in this case. This is due to the sigmoid shape of probability plots. However, the same sigmoid means also that at some stage, it is not beneficial to improve your strong point, but rather the weaker one.

The code

This is the code I’ve used to calculate the data. It took me about half an hour to generate data in the figures. This is a long time, so I’ve included options to store the data, and load it from the hard drive.

import numpy as np from mpl_toolkits.mplot3d import axes3d import matplotlib.pyplot as plt from collections import deque from matplotlib import cm import matplotlib.animation as animation class robot: #This is the class that does the "fighting" #In the simulator, it does the die rolling, here #it holds probabilites def __init__(self,ac,damage,hp,name): self.name =name #The name is used to identify between classes #For now it serves the same function as isplayer variable in the #simulator self.ac = ac self.damage = damage self.pd = 1.0/damage self.hp =hp self.P = [{0:1}] self.pwin = 0 def __str__(self): #Use this if you want to print info. Not use in current scheme return self.name + ' ' + str(self.pwin) def update_win(self,ohp): #Update the chance of this character to win in current scenario #I add the inputed ohp for the already calculated chance of win for v in self.P[-1]: if v >= ohp: self.pwin += self.P[-1][v] def calc_round(comb,phere): #Calulcate the probabilites of each damage in this round next_r = dict(comb[0].P[-1]) #this is the possible damages form last round #Chance of hit, the 0.05 factor is for d20, the -1 is a correction for 20 phit = (20-comb[1].ac)*0.05 pk = 0.05 #chance of critical p0 = 1-phit-pk #chance of miss pk*= phere #multiplied by the chance of actually reaching this possition phit*=phere p0*=phere for v in next_r: #calculating the chance of miss, no change in damage if v >= comb[1].hp: next_r[v] = 0 next_r[v] *= p0 for d0 in comb[0].P[-1]: #applying if d0 >= comb[1].hp: continue #skipping cases where the opponent is already dead for di in range (1,comb[0].damage+1): #all possible damages d = d0 + di p = comb[0].pd*comb[0].P[-1][d0]*phit if d >= comb[1].hp: #place all of this in single slot, this is not really important d = comb[1].hp if not d in next_r: next_r[d] = 0 next_r[d] += p d = d0 + di*2 #critical p = comb[0].pd*comb[0].P[-1][d0]*pk if d >= comb[1].hp: d = comb[1].hp if not d in next_r: next_r[d] = 0 next_r[d] += p comb[0].P.append(dict(next_r)) #adding to probabilities list. I'm not using this vector apart from last member def fight(comb): #this function manage the fight wina = 0 #the chance of a to win. while True: phere = 0#this holds the probability of reaching this far in battle psum = 0#the sum over all probilites. This is actuall phere of previous round for v in comb[1].P[-1]: if v < comb[0].hp: phere += comb[1].P[-1][v] #otherwise, I'm not in this round psum += comb[1].P[-1][v] phere /= psum #this nurmalizing prevents double coutning of phere calc_round(comb,phere) #roudn comb[0].update_win(comb[1].hp) #update the winning probability if comb[0].name == 'a': if wina == comb[0].pwin and wina > 0: #this is the acurecy treshold for this machine return comb[0].pwin, comb[1].pwin break else: wina = comb[0].pwin comb.rotate() #comb is deque type for this line. def do_duo(ac,damage,hp): #this function create a combatant pair, with the player (named a) and the #standard opponent, switching between initiatives player = robot(ac,damage,hp,'a') npc = robot(16,8,30,'b') comb = deque([player,npc]) win1 = fight(comb) player = robot(ac,damage,hp,'a') npc = robot(16,8,30,'b') comb = deque([npc,player]) win2 = fight(comb) wina = (win1[0] + win2[0])/2 #For now, no initiaive bias return wina def load_probs(): fig = plt.figure() ax = fig.add_axes([0.1,0.1,0.8,0.8],projection='3d') fig.suptitle('') #Damage manipulation ims = [] f = open ('spectrum.dat').readlines() #read probilities hpo = 0 aco = 0 d = 0 table = {} # the table is used for the upgrade directions for line in f: ac,d,hp,p = [float(v) for v in line.split()] key = (ac,d,hp) #list are not valid keys table[key] = p if not hp == hpo: if not hpo == 0: #new freame ims.append((ax.plot_surface(AC,D,P,rstride=1,cstride=1,cmap=cm.coolwarm),)) hpo = hp AC = [] D = [] P = [] if not ac == aco: #new ac, add another "line" to the 2d array for 2d plot aco = ac AC.append([]) D.append([]) P.append([]) AC[-1].append(ac) D[-1].append(d) P[-1].append(p) ax.view_init(elev=20,azim=-135) im_ani = animation.ArtistAnimation(fig, ims, interval=200, repeat_delay=500,blit=True) writer = animation.FFMpegWriter(fps=5,bitrate=1200) im_ani.save('/tmp/surface.mp4',writer=writer) print 'created movie' return table def calc_prob(): #calculating proabilities f = open ('spectrum.dat','w') #used for storing table = {} for hp in range (1,81): print hp for ac in range (2,21): AC.append([]) D.append([]) P.append([]) for d in range (2,13): AC[-1].append(ac) D[-1].append(d) p = do_duo(ac,d,hp) P[-1].append(p) line = '{} {} {} {}

'.format(ac,d,hp,p) key = (hp,ac,d) table [key] = p f.write(line) ims.append((ax.plot_wireframe(AC, D, P,rstride=1, cstride=1),)) f.flush() f.close() return key def calc_direction(table): #Plotting the gradient imsdiv = [] for hp in range (1,81): #couldn't find a way to place hp on any frame #so I don't use animation here, but create the gif #out of the images figdiv = plt.figure() axdiv = figdiv.add_axes([0.1,0.1,0.8,0.8]) X = [] #ac Y = [] #damage U = [] #upgrade ac V = [] #upgrade damage #I allow here for increase of 1 in damage, i.e 1d4, 1d5,1d6 ... for ac in range (2,21): for d in range (2,13): X.append(ac) Y.append(d) key = (ac,d,hp) #current position keyac = (ac+1,d,hp) #upgrading the ac by 1 keyd = (ac,d+1,hp) #upgrading the damage by 1 p0 = table[key] if keyac in table: pac = table[keyac] - p0 else: #for edges pac = 0 if keyd in table: pd = table[keyd] - p0 else: pd = 0 if pac == 0 and pd == 0: #edge or "no advance" U.append(0) V.append(0) elif pac > pd: #ac is preferable U.append(pac) V.append(0) elif pac < pd: #damage is prefereable U.append(0) V.append(pd) axdiv.quiver(X,Y,U,V,scale=5) #plot arrows if hp < 10: figdiv.suptitle('Hit Points = ' + str(hp)) else: figdiv.suptitle('Hit Points =' + str(hp)) axdiv.set_xlim(1,21) axdiv.set_ylim(1,13) axdiv.set_xlabel('AC') axdiv.set_ylabel('Damage Die') if hp < 10: figdiv.savefig('/tmp/div0{}.png'.format(hp)) else: figdiv.savefig('/tmp/div{}.png'.format(hp)) plt.close() #free the memory table = load_probs() calc_direction(table)

1. See also gradient for multidimensional derivative. back