16
\$\begingroup\$

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:

  1. Choose a Model to fight with
  2. Choose the Unit(s) to attack
  3. Choose the weapons you'll attack with
  4. Resolve Attacks:

    1. Hit roll: for each attack roll a dice, if the roll is greater or equal to the attacking models Skill the attack hits.
    2. 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+
    3. Allocate wound: You select a model to try and resist the wound.

    4. 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.

    5. Inflict Damage: The model takes the weapons damage, if the unit is reduced to 0 wounds it dies.

An example of this is:

  1. 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} \$

  2. We attack a squad of Khorne Berzerkers

  3. 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} \$

    1. I roll a 3. This is equal to the models Skill.
    2. I roll a 3. This is equal to the required roll. (6 > 4: 3+)
    3. A Khorne Berzerker is selected to take the wound.
    4. My opponent rolls a 3. And since \$3 - 1 < 3\$, the save is failed, and the wound goes through.
    5. 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()
\$\endgroup\$
5
+100
\$\begingroup\$

I won't focus much on the logic behind the code as:

  1. you have more experience of the game than I have, so I’m trusting you on this;
  2. there is a grand total of 0 comments in the code to help bind your logic to the rules, only this post helped me understand your code.

That being said, your Effects enum is redefined later as a class, thus unused, as well as Instance.shots.

Your plot function would also benefit from using a name as parameters so it is easier to know which graphs you’re looking at when generating several simultaneously as in your example; using fig.suptitle(name) should suffice. Even better, make plot accept several Instances at once using **kwargs and have the plt.show() at the end of it to make it self-contained:

def plot(**kwargs):
    for name, instance in kwargs.items():
        fig, axes = plt.subplots(1, 3)
        fig.suptitle(name)

        # rest of old code

    plt.show()

The first thing that appears intriguing is how you go to great length to try and enforce type safety in a language where duck-typing is the norm. I’m still not sure if you really wanted type safety or if it was somewhat a way to provide type hints. Note that if this is the second, you could have used what's already available in the language; even though it would force you to define class-level defaults for all your attributes. But as regard to your Base.__init__, None would be a perfect candidate here.

Before I go removing your TypedProperty, just a note that you can simplify it, since you do not plan on using the other advanced capabilities:

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 getattr(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))
        setattr(obj, self.name, value)

    def __delete__(self, obj):
        delattr(obj, self.name)

I’m also questioning your use of tuple as base class for Damage. You only use it in Instance._damage to compute all possible rolls for the die represented by this Damage class. Not only being able to unpack it in this very function makes it a poor argument for the subclassing, but it feels to me that this Instance._damage method should instead be something like Damage.possible_rolls or Damage.possibilities. And thus Instance would not need to rely on implementation details of Damage.

The same logic could be applied to the saving throws in Instance.save which would better fit as Target.saving_throw(self, armor_penetration). It would also help document the magical constant 7.


I tried using collections.namedtuple to make the code more succint at the expense of type hinting as your BASE._INIT was very close to it. It works but is somewhat ugly, and you willing to allow for missing parameters defaulting to None means you still need some kind of mixin for the same behaviour. Definitions of the classes being:

class OptionalNamedTupleMixin:
    def __new__(self, *args, **kwargs):
        parameters = dict(zip(self._fields, args))
        parameters.update(kwargs)
        for name in self._fields:
            parameters.setdefault(name, None)
        return super().__new__(self, **parameters)


class User(OptionalNamedTupleMixin, namedtuple('User', 'skill')):
    pass


class Weapon(OptionalNamedTupleMixin, namedtuple('Weapon', 'attacks strength ap damage')):
    pass


class Target(OptionalNamedTupleMixin, namedtuple('Target', 'toughness save invulnerable')):
    def saving_throw(self, armor_penetration):
        return min(self.save - armor_penetration, self.invulnerable or 7)



class RoundEffects(OptionalNamedTupleMixin, namedtuple('RoundEffects', 'skip one failed increase')):
    def reroll(self, score):
        if self.failed:
            return score
        return 1 if self.one else 0

    def round(self, score):
        if self.skip:
            return None

        return (
                score + (self.increase or 0),
                self.reroll(score),
        )


class Effects(namedtuple('Effects', 'hit wound')):
    def __new__(self, hit=None, wound=None):
        if hit is None:
            hit = RoundEffects()
        if wound is None:
            wound = RoundEffects()
        return super().__new__(self, hit, wound)


class Instance(namedtuple('Instance', 'user weapon target effects')):
    def __new__(self, user: User, weapon: Weapon, target: Target, effects: Effects=None):
        if effects is None:
            effects = Effects()
        return super().__new__(self, user, weapon, target, effects)

    ...

Please note how non-None default values can be handled more simply by using a specific signature for __new__.

But this code feels less usable than the original version. Most likely because it’s missing type hints at the expense of brevity. So time to look at the other part of your Base subclasses: type hints. They remind me a lot of the upcomming dataclasses (also discussed here). Using them you have simpler, somewhat auto-documented, classes than yours that feels nowhere near as shallow as the use of namedtuples:

#!/usr/bin/env python3.7

from itertools import product
from dataclasses import dataclass, field

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt


class Damage:
    def __init__(self, amount: (int, str, tuple), dice_sides: int = 1):
        if isinstance(amount, tuple):
            amount, dice_sides = amount
        elif isinstance(amount, str):
            amount, dice_sides = (amount.split('d', 1) + [None])[:2]
        self.amount = int(amount or 1)
        self.dice_sides = int(dice_sides or 1)

    def possible_rolls(self):
        all_rolls = product(range(1, self.dice_sides+1), repeat=self.amount)
        return [sum(rolls) for rolls in all_rolls]


@dataclass(frozen=True)
class User:
    skill: int


@dataclass(frozen=True)
class Weapon:
    attacks: Damage
    strength: int
    ap: int
    damage: Damage


@dataclass(frozen=True)
class Target:
    toughness: int
    save: int
    invulnerable: int = 7

    def saving_throw(self, armor_penetration):
        return min(self.save - armor_penetration, self.invulnerable)


@dataclass(frozen=True)
class RoundEffects:
    skip: bool = False
    one: bool = False
    failed: bool = False
    increase: int = 0

    def reroll(self, score):
        if self.failed:
            return score
        return 1 if self.one else 0

    def round(self, score):
        if self.skip:
            return None

        return (
                score + self.increase,
                self.reroll(score),
        )


@dataclass(frozen=True)
class Effects:
    hit: RoundEffects = field(default_factory=RoundEffects)
    wound: RoundEffects = field(default_factory=RoundEffects)


@dataclass(frozen=True)
class Instance:
    user: User
    weapon: Weapon
    target: Target
    effects: Effects = field(default_factory=Effects)

    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 attacks(self):
        return self.weapon.attacks.possible_rolls()

    def damage(self):
        return self.weapon.damage.possible_rolls()

    def hits(self):
        return self.effects.hit.round(self.user.skill)

    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_wl(self):
        save = self.target.saving_throw(self.weapon.ap)
        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 plot(**kwargs):
    for name, instance in kwargs.items():
        fig, axes = plt.subplots(1, 3)
        fig.suptitle(name)

        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)

    plt.show()


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
            )
        )
    )

    khorn2 = Instance(
        User(skill=3),
        Weapon(
            attacks=Damage(2),
            strength=6,
            ap=-1,
            damage=Damage(1)
        ),
        Target(
            toughness=4,
            save=3
        )
    )

    land = Instance(
        User(skill=3),
        Weapon(
            attacks=Damage(2),
            strength=9,
            ap=-3,
            damage=Damage('d6')
        ),
        Target(
            toughness=7,
            save=3
        )
    )

    predator = Instance(
        User(skill=3),
        Weapon(
            attacks=Damage('2d3'),
            strength=7,
            ap=-1,
            damage=Damage('3')
        ),
        Target(
            toughness=7,
            save=3
        )
    )

    plot(khorn=khorn, khorn2=khorn2, land=land, predator=predator)

If, however, you still want type checking, inspect.getfullargspec and the annotations field of its return value may be of some help. Something along the lines of the following class decorator:

def enforce_types(cls):
    spec = inspect.getfullargspec(cls)
    def constructor(*args, **kwargs):
        arg_names = iter(spec.args)
        next(arg_names)  # skip self
        parameters = dict(zip(arg_names, args))
        parameters.update(kwargs)
        for name, value in parameters.items():
            with suppress(KeyError):  # Assume un-annotated parameters can be any type
                types = spec.annotations[name]
                if not isinstance(value, types):
                    raise TypeError('Unexpected type for \'{}\' (expected {} but found {})'.format(name, types, type(value)))
        return cls(**parameters)
    return constructor
\$\endgroup\$
  • \$\begingroup\$ Thanks for the answer, from what I understand you make some good points. And boy did I leave a lot of useless stuff in the code, I'm too used to PyCharm's helpful hints... Whilst I know quacking is the way, I'm also using the code in a REST library, so that there's less of a chance to get bad requests due to incorrect types. It will also mean I can implicitly convert types to the correct ones. And so the type checking is the most important part. Either way, dataclass looks pretty good and has a few of the features I'd want. \$\endgroup\$ – Peilonrayz May 31 '18 at 8:04
  • \$\begingroup\$ @Peilonrayz Added an example of class decorator using inspect inspired from your Base.__init__ to showcase how you could add type checking with minimal cost to the dataclasses. \$\endgroup\$ – Mathias Ettinger May 31 '18 at 8:32
  • \$\begingroup\$ @Peilonrayz I stumbled upon a related question on SO (how to enforce type checking with dataclasses + typing) and posted a more elaborated answer than the last code block here. \$\endgroup\$ – Mathias Ettinger May 31 '18 at 13:44
  • \$\begingroup\$ Damn, dataclasses is starting to look better and better. At work I have been using __origin__ and __args__ to allow me to do class Obj(Namespace): attr = Property[Optional[int]]('dictionary_key'), so I can easily convert to and from a dict too. When I get the time I'll look into dataclasses more, but it looks quite sick TBH. \$\endgroup\$ – Peilonrayz May 31 '18 at 14:23
  • \$\begingroup\$ @Peilonrayz Still digging into typing to further understand it, but note that in most case, you can use typing.NamedTuple instead of dataclasses. You won't have support for mutable default arguments though. \$\endgroup\$ – Mathias Ettinger May 31 '18 at 14:44

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.