Me and a couple of mates sometimes play a game called Warhammer. When playing the game you have options of what each model attacks. This can lead to situations where you know if you shoot with 100% of your units into one enemy unit you know the unit will be killed, but you don't know how many you should fire to kill the target unit. And so I decided to write a little program that would help find out how many models I should shoot with into an enemy.
Combat in Warhammer is pretty basic, however some added complexity can come from additional rules on specific units or weapons. The core rules when attacking another unit with a model is:
- Choose a Model to fight with
- Choose the Unit(s) to attack
- Choose the weapons you'll attack with
Resolve Attacks:
- Hit roll: for each attack roll a dice, if the roll is greater or equal to the attacking models Skill the attack hits.
Wound roll: This is the same as hitting, however what you roll is based on the weapons Strength and the targets Toughness.
- S >= 2T: 2+
- S > T: 3+
- S == T: 4+
- S < T: 5+
- S <= T: 6+
Allocate wound: You select a model to try and resist the wound.
Saving Throw: Roll a dice and add armor penetration to the roll, if it's greater than the models save then no damage is inflicted.
There are also 'invulnerable saves', which work the same way as normal saves, but aren't affected by armor penetration.
- Inflict Damage: The model takes the weapons damage, if the unit is reduced to 0 wounds it dies.
An example of this is:
We select a Khorne Berzerker
\$ \begin{array}{l|l|l|l|l} \textrm{Skill} & \textrm{S} & \textrm{T} & \textrm{W} & \textrm{Sv} \\ \hline \text{3+} & \text{5} & \text{4} & \text{1} & \text{3+} \\ \end{array} \$
We attack a squad of Khorne Berzerkers
We will attack with it's Chainaxe
\$ \begin{array}{l|l|l|l} \textrm{Attacks} & \textrm{S} & \textrm{AP} & \textrm{D} \\ \hline \text{1} & \text{6} & \text{-1} & \text{1} \\ \end{array} \$
-
- I roll a 3. This is equal to the models Skill.
- I roll a 3. This is equal to the required roll. (6 > 4: 3+)
- A Khorne Berzerker is selected to take the wound.
- My opponent rolls a 3. And since \$3 - 1 < 3\$, the save is failed, and the wound goes through.
- One enemy model dies.
There are some additional common effects:
- Some units allow others to re-roll failed hit rolls, hit rolls of one, failed wound rolls and wound rolls of one. However you can only re-roll a roll once, so you couldn't re-roll a hit of 1 and then re-roll a hit of 2. But you can re-roll a failed hit and then re-roll a failed wound.
- Some things allow you to add to your hit and wound rolls.
- Some things allow you to skip your hit or wound phase. Flame throwers normally auto hit, and so skip their hit phase.
And so I wrote some code to show the percentage of attacks that will be lost, and at what stage. And the average amount of attacks and damage each weapon will have.
from functools import wraps
import enum
from collections import Counter
from itertools import product
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
class TypedProperty:
def __init__(self, name, *types):
types = [type(None) if t is None else t for t in types]
if not all(isinstance(t, type) for t in types):
raise ValueError('All arguments to `types` must inherit from type.')
self.types = tuple(types)
self.name = name
def __get__(self, obj, _):
return self._get(obj, self.name)
def __set__(self, obj, value):
if not isinstance(value, self.types):
raise TypeError('Value {value} must inherit one of {self.types}'.format(value=value, self=self))
self._set(obj, self.name, value)
def __delete__(self, obj):
self._delete(obj, self.name)
def get(self, fn):
self._get = fn
return self
def set(self, fn):
self._set = fn
return self
def delete(self, fn):
self._delete = fn
return self
@staticmethod
def _get(self, name):
return getattr(self, name)
@staticmethod
def _set(self, name, value):
setattr(self, name, value)
@staticmethod
def _delete(self, name):
delattr(self, name)
class Damage(tuple):
def __new__(self, value):
if isinstance(value, tuple):
pass
elif isinstance(value, int):
value = (value, None)
elif not isinstance(value, str):
raise TypeError('Value must be an int, tuple or str')
else:
value = tuple(value.split('d', 1) + [None])[:2]
value = (i or None for i in value)
value = tuple(int(i) if i is not None else 1 for i in value)
return super().__new__(self, value)
class Effects(enum.Enum):
SKIP_HIT = 0
HIT_ONE = 1
HIT_FAILED = 2
WOUND_ONE = 3
WOUND_FAILED = 4
class Base:
_INIT = tuple()
def __init__(self, *args, **kwargs):
values = self._read_args(args, kwargs)
for name in self._INIT:
setattr(self, name, values.get(name, None))
def _read_args(self, args, kwargs):
values = dict(zip(self._INIT, args))
values.update(kwargs)
return values
class User(Base):
_INIT=tuple('skill'.split())
skill=TypedProperty('_skill', int)
class Weapon(Base):
_INIT=tuple('attacks strength ap damage'.split())
attacks=TypedProperty('_attacks', Damage)
strength=TypedProperty('_strength', int)
ap=TypedProperty('_ap', int)
damage=TypedProperty('_damage', Damage)
class Target(Base):
_INIT=tuple('toughness save invulnerable'.split())
toughness=TypedProperty('_toughness', int)
save=TypedProperty('_save', int)
invulnerable=TypedProperty('_invulnerable', int, None)
class RoundEffects(Base):
_INIT=tuple('skip one failed increase'.split())
skip=TypedProperty('_skip', bool, None)
one=TypedProperty('_one', bool, None)
failed=TypedProperty('_failed', bool, None)
increase=TypedProperty('_increase', int, None)
def reroll(self, score):
if self.failed:
return score
if self.one:
return 1
return 0
def round(self, score):
if self.skip:
return None
return (
score + (self.increase or 0),
self.reroll(score)
)
class Effects(Base):
_INIT=tuple('hit wound'.split())
hit=TypedProperty('_hit', RoundEffects)
wound=TypedProperty('_wound', RoundEffects)
def __init__(self, *args, **kwargs):
kwargs = self._read_args(args, kwargs)
for key in 'hit wound'.split():
if kwargs.get(key, None) is None:
kwargs[key] = RoundEffects()
super().__init__(**kwargs)
class Instance(Base):
_INIT=tuple('user weapon target effects'.split())
user=TypedProperty('_user', User)
weapon=TypedProperty('_weapon', Weapon)
target=TypedProperty('_target', Target)
effects=TypedProperty('_effects', Effects)
def __init__(self, *args, **kwargs):
kwargs = self._read_args(args, kwargs)
if kwargs.get('effects', None) is None:
kwargs['effects'] = Effects()
super().__init__(**kwargs)
def _damage(self, damage):
amount, variable = damage
variable = tuple(range(1, variable+1))
return [sum(ns) for ns in product(variable, repeat=amount)]
def attacks(self):
return self._damage(self.weapon.attacks)
def shots(self):
return self.weapon.attacks
def hits(self):
return self.effects.hit.round(self.user.skill)
def _round(self, damage):
if damage is None:
return (0, 100)
needed, reroll = damage
values = tuple(range(6))
rolls = np.array([
v
for n in values
for v in (values if n < reroll else [n] * 6)
])
ratio = np.bincount(rolls >= needed)
return ratio * 100 / np.sum(ratio)
def hits_wl(self):
return self._round(self.hits())
def damage_roll(self):
s = self.weapon.strength
t = self.target.toughness
if s >= t * 2:
return 2
if s > t:
return 3
if s == t:
return 4
if s * 2 <= t:
return 6
if s < t:
return 5
def wounds(self):
return self.effects.wound.round(self.damage_roll())
def wounds_wl(self):
return self._round(self.wounds())
def save(self):
return min(
self.target.save - self.weapon.ap,
self.target.invulnerable or 7
)
def save_wl(self):
save = self.save()
ratio = np.array((7 - save, save - 1))
return ratio * 100 / np.sum(ratio)
def win_loss(self):
wls = [
self.hits_wl(),
self.wounds_wl(),
self.save_wl()
]
failed = 0
for loss, _ in wls:
win = 100 - failed
loss = loss * win / 100
yield loss
failed += loss
yield 100 - failed
def damage(self):
return self._damage(self.weapon.damage)
def plot(instance):
fig, axes = plt.subplots(1, 3)
win_loss = list(instance.win_loss())
df = pd.DataFrame(
[
win_loss[:1] + [0, 0] + [sum(win_loss[1:])],
win_loss[:2] + [0] + [sum(win_loss[2:])],
win_loss
],
columns=['Miss', 'Prevented', 'Saved', 'Passed'],
index=['Hit', 'Wound', 'Save']
)
df.plot.bar(stacked=True, ax=axes[1]).set_ylim(0, 100)
attacks = instance.attacks()
damage = instance.damage()
limit = max(max(attacks), max(damage))
limit = int((limit + 1) * 1.1)
pd.DataFrame(attacks).boxplot(return_type='axes', ax=axes[0]).set_ylim(0, limit)
pd.DataFrame(damage).boxplot(return_type='axes', ax=axes[2]).set_ylim(0, limit)
if __name__ == '__main__':
khorn = Instance(
User(skill=3),
Weapon(
attacks=Damage(2),
strength=6,
ap=-1,
damage=Damage(1)
),
Target(
toughness=4,
save=3
),
Effects(
RoundEffects(
failed=True
),
RoundEffects(
failed=True
)
)
)
plot(khorn)
khorn2 = Instance(
User(skill=3),
Weapon(
attacks=Damage(2),
strength=6,
ap=-1,
damage=Damage(1)
),
Target(
toughness=4,
save=3
)
)
plot(khorn2)
land = Instance(
User(skill=3),
Weapon(
attacks=Damage(2),
strength=9,
ap=-3,
damage=Damage('d6')
),
Target(
toughness=7,
save=3
)
)
plot(land)
predator = Instance(
User(skill=3),
Weapon(
attacks=Damage('2d3'),
strength=7,
ap=-1,
damage=Damage('3')
),
Target(
toughness=7,
save=3
)
)
plot(predator)
plt.show()