Warhammer: How many of my attacks will succeed?

The name of the pictureThe name of the pictureThe name of the pictureClash Royale CLAN TAG#URR8PPP





.everyoneloves__top-leaderboard:empty,.everyoneloves__mid-leaderboard:empty margin-bottom:0;







up vote
17
down vote

favorite
4












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



    $
    beginarrayl
    textrmSkill &
    textrmS &
    textrmT &
    textrmW &
    textrmSv \
    hline
    text3+ &
    text5 &
    text4 &
    text1 &
    text3+ \
    endarray
    $



  2. We attack a squad of Khorne Berzerkers



  3. We will attack with it's Chainaxe



    $
    beginarrayl
    textrmAttacks &
    textrmS &
    textrmAP &
    textrmD \
    hline
    text1 &
    text6 &
    text-1 &
    text1 \
    endarray
    $





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






share|improve this question

























    up vote
    17
    down vote

    favorite
    4












    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



      $
      beginarrayl
      textrmSkill &
      textrmS &
      textrmT &
      textrmW &
      textrmSv \
      hline
      text3+ &
      text5 &
      text4 &
      text1 &
      text3+ \
      endarray
      $



    2. We attack a squad of Khorne Berzerkers



    3. We will attack with it's Chainaxe



      $
      beginarrayl
      textrmAttacks &
      textrmS &
      textrmAP &
      textrmD \
      hline
      text1 &
      text6 &
      text-1 &
      text1 \
      endarray
      $





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






    share|improve this question





















      up vote
      17
      down vote

      favorite
      4









      up vote
      17
      down vote

      favorite
      4






      4





      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



        $
        beginarrayl
        textrmSkill &
        textrmS &
        textrmT &
        textrmW &
        textrmSv \
        hline
        text3+ &
        text5 &
        text4 &
        text1 &
        text3+ \
        endarray
        $



      2. We attack a squad of Khorne Berzerkers



      3. We will attack with it's Chainaxe



        $
        beginarrayl
        textrmAttacks &
        textrmS &
        textrmAP &
        textrmD \
        hline
        text1 &
        text6 &
        text-1 &
        text1 \
        endarray
        $





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






      share|improve this question











      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



        $
        beginarrayl
        textrmSkill &
        textrmS &
        textrmT &
        textrmW &
        textrmSv \
        hline
        text3+ &
        text5 &
        text4 &
        text1 &
        text3+ \
        endarray
        $



      2. We attack a squad of Khorne Berzerkers



      3. We will attack with it's Chainaxe



        $
        beginarrayl
        textrmAttacks &
        textrmS &
        textrmAP &
        textrmD \
        hline
        text1 &
        text6 &
        text-1 &
        text1 \
        endarray
        $





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








      share|improve this question










      share|improve this question




      share|improve this question









      asked May 24 at 16:12









      Peilonrayz

      24.3k336102




      24.3k336102




















          1 Answer
          1






          active

          oldest

          votes

















          up vote
          5
          down vote



          accepted
          +100










          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





          share|improve this answer























          • 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.
            – Peilonrayz
            May 31 at 8:04










          • @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.
            – Mathias Ettinger
            May 31 at 8:32










          • @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.
            – Mathias Ettinger
            May 31 at 13:44










          • 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.
            – Peilonrayz
            May 31 at 14:23










          • @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.
            – Mathias Ettinger
            May 31 at 14:44










          Your Answer




          StackExchange.ifUsing("editor", function ()
          return StackExchange.using("mathjaxEditing", function ()
          StackExchange.MarkdownEditor.creationCallbacks.add(function (editor, postfix)
          StackExchange.mathjaxEditing.prepareWmdForMathJax(editor, postfix, [["\$", "\$"]]);
          );
          );
          , "mathjax-editing");

          StackExchange.ifUsing("editor", function ()
          StackExchange.using("externalEditor", function ()
          StackExchange.using("snippets", function ()
          StackExchange.snippets.init();
          );
          );
          , "code-snippets");

          StackExchange.ready(function()
          var channelOptions =
          tags: "".split(" "),
          id: "196"
          ;
          initTagRenderer("".split(" "), "".split(" "), channelOptions);

          StackExchange.using("externalEditor", function()
          // Have to fire editor after snippets, if snippets enabled
          if (StackExchange.settings.snippets.snippetsEnabled)
          StackExchange.using("snippets", function()
          createEditor();
          );

          else
          createEditor();

          );

          function createEditor()
          StackExchange.prepareEditor(
          heartbeatType: 'answer',
          convertImagesToLinks: false,
          noModals: false,
          showLowRepImageUploadWarning: true,
          reputationToPostImages: null,
          bindNavPrevention: true,
          postfix: "",
          onDemand: true,
          discardSelector: ".discard-answer"
          ,immediatelyShowMarkdownHelp:true
          );



          );








           

          draft saved


          draft discarded


















          StackExchange.ready(
          function ()
          StackExchange.openid.initPostLogin('.new-post-login', 'https%3a%2f%2fcodereview.stackexchange.com%2fquestions%2f195099%2fwarhammer-how-many-of-my-attacks-will-succeed%23new-answer', 'question_page');

          );

          Post as a guest






























          1 Answer
          1






          active

          oldest

          votes








          1 Answer
          1






          active

          oldest

          votes









          active

          oldest

          votes






          active

          oldest

          votes








          up vote
          5
          down vote



          accepted
          +100










          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





          share|improve this answer























          • 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.
            – Peilonrayz
            May 31 at 8:04










          • @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.
            – Mathias Ettinger
            May 31 at 8:32










          • @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.
            – Mathias Ettinger
            May 31 at 13:44










          • 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.
            – Peilonrayz
            May 31 at 14:23










          • @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.
            – Mathias Ettinger
            May 31 at 14:44














          up vote
          5
          down vote



          accepted
          +100










          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





          share|improve this answer























          • 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.
            – Peilonrayz
            May 31 at 8:04










          • @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.
            – Mathias Ettinger
            May 31 at 8:32










          • @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.
            – Mathias Ettinger
            May 31 at 13:44










          • 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.
            – Peilonrayz
            May 31 at 14:23










          • @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.
            – Mathias Ettinger
            May 31 at 14:44












          up vote
          5
          down vote



          accepted
          +100







          up vote
          5
          down vote



          accepted
          +100




          +100




          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





          share|improve this answer















          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






          share|improve this answer















          share|improve this answer



          share|improve this answer








          edited May 31 at 8:31


























          answered May 30 at 16:09









          Mathias Ettinger

          21.8k32875




          21.8k32875











          • 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.
            – Peilonrayz
            May 31 at 8:04










          • @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.
            – Mathias Ettinger
            May 31 at 8:32










          • @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.
            – Mathias Ettinger
            May 31 at 13:44










          • 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.
            – Peilonrayz
            May 31 at 14:23










          • @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.
            – Mathias Ettinger
            May 31 at 14:44
















          • 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.
            – Peilonrayz
            May 31 at 8:04










          • @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.
            – Mathias Ettinger
            May 31 at 8:32










          • @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.
            – Mathias Ettinger
            May 31 at 13:44










          • 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.
            – Peilonrayz
            May 31 at 14:23










          • @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.
            – Mathias Ettinger
            May 31 at 14:44















          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.
          – Peilonrayz
          May 31 at 8:04




          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.
          – Peilonrayz
          May 31 at 8:04












          @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.
          – Mathias Ettinger
          May 31 at 8:32




          @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.
          – Mathias Ettinger
          May 31 at 8:32












          @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.
          – Mathias Ettinger
          May 31 at 13:44




          @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.
          – Mathias Ettinger
          May 31 at 13:44












          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.
          – Peilonrayz
          May 31 at 14:23




          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.
          – Peilonrayz
          May 31 at 14:23












          @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.
          – Mathias Ettinger
          May 31 at 14:44




          @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.
          – Mathias Ettinger
          May 31 at 14:44












           

          draft saved


          draft discarded


























           


          draft saved


          draft discarded














          StackExchange.ready(
          function ()
          StackExchange.openid.initPostLogin('.new-post-login', 'https%3a%2f%2fcodereview.stackexchange.com%2fquestions%2f195099%2fwarhammer-how-many-of-my-attacks-will-succeed%23new-answer', 'question_page');

          );

          Post as a guest













































































          Popular posts from this blog

          Greedy Best First Search implementation in Rust

          Function to Return a JSON Like Objects Using VBA Collections and Arrays

          C++11 CLH Lock Implementation