Warhammer: How many of my attacks will succeed?
Clash Royale CLAN TAG#URR8PPP
.everyoneloves__top-leaderboard:empty,.everyoneloves__mid-leaderboard:empty margin-bottom:0;
up vote
17
down vote
favorite
Me and a couple of mates sometimes play a game called Warhammer.
When playing the game you have options of what each model attacks.
This can lead to situations where you know if you shoot with 100% of your units into one enemy unit you know the unit will be killed, but you don't know how many you should fire to kill the target unit.
And so I decided to write a little program that would help find out how many models I should shoot with into an enemy.
Combat in Warhammer is pretty basic, however some added complexity can come from additional rules on specific units or weapons.
The core rules when attacking another unit with a model is:
- Choose a Model to fight with
- Choose the Unit(s) to attack
- Choose the weapons you'll attack with
Resolve Attacks:
- Hit roll: for each attack roll a dice, if the roll is greater or equal to the attacking models Skill the attack hits.
Wound roll: This is the same as hitting, however what you roll is based on the weapons Strength and the targets Toughness.
- S >= 2T: 2+
- S > T: 3+
- S == T: 4+
- S < T: 5+
- S <= T: 6+
Allocate wound: You select a model to try and resist the wound.
Saving Throw: Roll a dice and add armor penetration to the roll, if it's greater than the models save then no damage is inflicted.
There are also 'invulnerable saves', which work the same way as normal saves, but aren't affected by armor penetration.
- Inflict Damage: The model takes the weapons damage, if the unit is reduced to 0 wounds it dies.
An example of this is:
We select a Khorne Berzerker
$
beginarrayl
textrmSkill &
textrmS &
textrmT &
textrmW &
textrmSv \
hline
text3+ &
text5 &
text4 &
text1 &
text3+ \
endarray
$We attack a squad of Khorne Berzerkers
We will attack with it's Chainaxe
$
beginarrayl
textrmAttacks &
textrmS &
textrmAP &
textrmD \
hline
text1 &
text6 &
text-1 &
text1 \
endarray
$- I roll a 3. This is equal to the models Skill.
- I roll a 3. This is equal to the required roll. (6 > 4: 3+)
- A Khorne Berzerker is selected to take the wound.
- My opponent rolls a 3. And since $3 - 1 < 3$, the save is failed, and the wound goes through.
- One enemy model dies.
There are some additional common effects:
- Some units allow others to re-roll failed hit rolls, hit rolls of one, failed wound rolls and wound rolls of one. However you can only re-roll a roll once, so you couldn't re-roll a hit of 1 and then re-roll a hit of 2. But you can re-roll a failed hit and then re-roll a failed wound.
- Some things allow you to add to your hit and wound rolls.
- Some things allow you to skip your hit or wound phase. Flame throwers normally auto hit, and so skip their hit phase.
And so I wrote some code to show the percentage of attacks that will be lost, and at what stage.
And the average amount of attacks and damage each weapon will have.
from functools import wraps
import enum
from collections import Counter
from itertools import product
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
class TypedProperty:
def __init__(self, name, *types):
types = [type(None) if t is None else t for t in types]
if not all(isinstance(t, type) for t in types):
raise ValueError('All arguments to `types` must inherit from type.')
self.types = tuple(types)
self.name = name
def __get__(self, obj, _):
return self._get(obj, self.name)
def __set__(self, obj, value):
if not isinstance(value, self.types):
raise TypeError('Value value must inherit one of self.types'.format(value=value, self=self))
self._set(obj, self.name, value)
def __delete__(self, obj):
self._delete(obj, self.name)
def get(self, fn):
self._get = fn
return self
def set(self, fn):
self._set = fn
return self
def delete(self, fn):
self._delete = fn
return self
@staticmethod
def _get(self, name):
return getattr(self, name)
@staticmethod
def _set(self, name, value):
setattr(self, name, value)
@staticmethod
def _delete(self, name):
delattr(self, name)
class Damage(tuple):
def __new__(self, value):
if isinstance(value, tuple):
pass
elif isinstance(value, int):
value = (value, None)
elif not isinstance(value, str):
raise TypeError('Value must be an int, tuple or str')
else:
value = tuple(value.split('d', 1) + [None])[:2]
value = (i or None for i in value)
value = tuple(int(i) if i is not None else 1 for i in value)
return super().__new__(self, value)
class Effects(enum.Enum):
SKIP_HIT = 0
HIT_ONE = 1
HIT_FAILED = 2
WOUND_ONE = 3
WOUND_FAILED = 4
class Base:
_INIT = tuple()
def __init__(self, *args, **kwargs):
values = self._read_args(args, kwargs)
for name in self._INIT:
setattr(self, name, values.get(name, None))
def _read_args(self, args, kwargs):
values = dict(zip(self._INIT, args))
values.update(kwargs)
return values
class User(Base):
_INIT=tuple('skill'.split())
skill=TypedProperty('_skill', int)
class Weapon(Base):
_INIT=tuple('attacks strength ap damage'.split())
attacks=TypedProperty('_attacks', Damage)
strength=TypedProperty('_strength', int)
ap=TypedProperty('_ap', int)
damage=TypedProperty('_damage', Damage)
class Target(Base):
_INIT=tuple('toughness save invulnerable'.split())
toughness=TypedProperty('_toughness', int)
save=TypedProperty('_save', int)
invulnerable=TypedProperty('_invulnerable', int, None)
class RoundEffects(Base):
_INIT=tuple('skip one failed increase'.split())
skip=TypedProperty('_skip', bool, None)
one=TypedProperty('_one', bool, None)
failed=TypedProperty('_failed', bool, None)
increase=TypedProperty('_increase', int, None)
def reroll(self, score):
if self.failed:
return score
if self.one:
return 1
return 0
def round(self, score):
if self.skip:
return None
return (
score + (self.increase or 0),
self.reroll(score)
)
class Effects(Base):
_INIT=tuple('hit wound'.split())
hit=TypedProperty('_hit', RoundEffects)
wound=TypedProperty('_wound', RoundEffects)
def __init__(self, *args, **kwargs):
kwargs = self._read_args(args, kwargs)
for key in 'hit wound'.split():
if kwargs.get(key, None) is None:
kwargs[key] = RoundEffects()
super().__init__(**kwargs)
class Instance(Base):
_INIT=tuple('user weapon target effects'.split())
user=TypedProperty('_user', User)
weapon=TypedProperty('_weapon', Weapon)
target=TypedProperty('_target', Target)
effects=TypedProperty('_effects', Effects)
def __init__(self, *args, **kwargs):
kwargs = self._read_args(args, kwargs)
if kwargs.get('effects', None) is None:
kwargs['effects'] = Effects()
super().__init__(**kwargs)
def _damage(self, damage):
amount, variable = damage
variable = tuple(range(1, variable+1))
return [sum(ns) for ns in product(variable, repeat=amount)]
def attacks(self):
return self._damage(self.weapon.attacks)
def shots(self):
return self.weapon.attacks
def hits(self):
return self.effects.hit.round(self.user.skill)
def _round(self, damage):
if damage is None:
return (0, 100)
needed, reroll = damage
values = tuple(range(6))
rolls = np.array([
v
for n in values
for v in (values if n < reroll else [n] * 6)
])
ratio = np.bincount(rolls >= needed)
return ratio * 100 / np.sum(ratio)
def hits_wl(self):
return self._round(self.hits())
def damage_roll(self):
s = self.weapon.strength
t = self.target.toughness
if s >= t * 2:
return 2
if s > t:
return 3
if s == t:
return 4
if s * 2 <= t:
return 6
if s < t:
return 5
def wounds(self):
return self.effects.wound.round(self.damage_roll())
def wounds_wl(self):
return self._round(self.wounds())
def save(self):
return min(
self.target.save - self.weapon.ap,
self.target.invulnerable or 7
)
def save_wl(self):
save = self.save()
ratio = np.array((7 - save, save - 1))
return ratio * 100 / np.sum(ratio)
def win_loss(self):
wls = [
self.hits_wl(),
self.wounds_wl(),
self.save_wl()
]
failed = 0
for loss, _ in wls:
win = 100 - failed
loss = loss * win / 100
yield loss
failed += loss
yield 100 - failed
def damage(self):
return self._damage(self.weapon.damage)
def plot(instance):
fig, axes = plt.subplots(1, 3)
win_loss = list(instance.win_loss())
df = pd.DataFrame(
[
win_loss[:1] + [0, 0] + [sum(win_loss[1:])],
win_loss[:2] + [0] + [sum(win_loss[2:])],
win_loss
],
columns=['Miss', 'Prevented', 'Saved', 'Passed'],
index=['Hit', 'Wound', 'Save']
)
df.plot.bar(stacked=True, ax=axes[1]).set_ylim(0, 100)
attacks = instance.attacks()
damage = instance.damage()
limit = max(max(attacks), max(damage))
limit = int((limit + 1) * 1.1)
pd.DataFrame(attacks).boxplot(return_type='axes', ax=axes[0]).set_ylim(0, limit)
pd.DataFrame(damage).boxplot(return_type='axes', ax=axes[2]).set_ylim(0, limit)
if __name__ == '__main__':
khorn = Instance(
User(skill=3),
Weapon(
attacks=Damage(2),
strength=6,
ap=-1,
damage=Damage(1)
),
Target(
toughness=4,
save=3
),
Effects(
RoundEffects(
failed=True
),
RoundEffects(
failed=True
)
)
)
plot(khorn)
khorn2 = Instance(
User(skill=3),
Weapon(
attacks=Damage(2),
strength=6,
ap=-1,
damage=Damage(1)
),
Target(
toughness=4,
save=3
)
)
plot(khorn2)
land = Instance(
User(skill=3),
Weapon(
attacks=Damage(2),
strength=9,
ap=-3,
damage=Damage('d6')
),
Target(
toughness=7,
save=3
)
)
plot(land)
predator = Instance(
User(skill=3),
Weapon(
attacks=Damage('2d3'),
strength=7,
ap=-1,
damage=Damage('3')
),
Target(
toughness=7,
save=3
)
)
plot(predator)
plt.show()
python object-oriented python-3.x numpy pandas
add a comment |Â
up vote
17
down vote
favorite
Me and a couple of mates sometimes play a game called Warhammer.
When playing the game you have options of what each model attacks.
This can lead to situations where you know if you shoot with 100% of your units into one enemy unit you know the unit will be killed, but you don't know how many you should fire to kill the target unit.
And so I decided to write a little program that would help find out how many models I should shoot with into an enemy.
Combat in Warhammer is pretty basic, however some added complexity can come from additional rules on specific units or weapons.
The core rules when attacking another unit with a model is:
- Choose a Model to fight with
- Choose the Unit(s) to attack
- Choose the weapons you'll attack with
Resolve Attacks:
- Hit roll: for each attack roll a dice, if the roll is greater or equal to the attacking models Skill the attack hits.
Wound roll: This is the same as hitting, however what you roll is based on the weapons Strength and the targets Toughness.
- S >= 2T: 2+
- S > T: 3+
- S == T: 4+
- S < T: 5+
- S <= T: 6+
Allocate wound: You select a model to try and resist the wound.
Saving Throw: Roll a dice and add armor penetration to the roll, if it's greater than the models save then no damage is inflicted.
There are also 'invulnerable saves', which work the same way as normal saves, but aren't affected by armor penetration.
- Inflict Damage: The model takes the weapons damage, if the unit is reduced to 0 wounds it dies.
An example of this is:
We select a Khorne Berzerker
$
beginarrayl
textrmSkill &
textrmS &
textrmT &
textrmW &
textrmSv \
hline
text3+ &
text5 &
text4 &
text1 &
text3+ \
endarray
$We attack a squad of Khorne Berzerkers
We will attack with it's Chainaxe
$
beginarrayl
textrmAttacks &
textrmS &
textrmAP &
textrmD \
hline
text1 &
text6 &
text-1 &
text1 \
endarray
$- I roll a 3. This is equal to the models Skill.
- I roll a 3. This is equal to the required roll. (6 > 4: 3+)
- A Khorne Berzerker is selected to take the wound.
- My opponent rolls a 3. And since $3 - 1 < 3$, the save is failed, and the wound goes through.
- One enemy model dies.
There are some additional common effects:
- Some units allow others to re-roll failed hit rolls, hit rolls of one, failed wound rolls and wound rolls of one. However you can only re-roll a roll once, so you couldn't re-roll a hit of 1 and then re-roll a hit of 2. But you can re-roll a failed hit and then re-roll a failed wound.
- Some things allow you to add to your hit and wound rolls.
- Some things allow you to skip your hit or wound phase. Flame throwers normally auto hit, and so skip their hit phase.
And so I wrote some code to show the percentage of attacks that will be lost, and at what stage.
And the average amount of attacks and damage each weapon will have.
from functools import wraps
import enum
from collections import Counter
from itertools import product
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
class TypedProperty:
def __init__(self, name, *types):
types = [type(None) if t is None else t for t in types]
if not all(isinstance(t, type) for t in types):
raise ValueError('All arguments to `types` must inherit from type.')
self.types = tuple(types)
self.name = name
def __get__(self, obj, _):
return self._get(obj, self.name)
def __set__(self, obj, value):
if not isinstance(value, self.types):
raise TypeError('Value value must inherit one of self.types'.format(value=value, self=self))
self._set(obj, self.name, value)
def __delete__(self, obj):
self._delete(obj, self.name)
def get(self, fn):
self._get = fn
return self
def set(self, fn):
self._set = fn
return self
def delete(self, fn):
self._delete = fn
return self
@staticmethod
def _get(self, name):
return getattr(self, name)
@staticmethod
def _set(self, name, value):
setattr(self, name, value)
@staticmethod
def _delete(self, name):
delattr(self, name)
class Damage(tuple):
def __new__(self, value):
if isinstance(value, tuple):
pass
elif isinstance(value, int):
value = (value, None)
elif not isinstance(value, str):
raise TypeError('Value must be an int, tuple or str')
else:
value = tuple(value.split('d', 1) + [None])[:2]
value = (i or None for i in value)
value = tuple(int(i) if i is not None else 1 for i in value)
return super().__new__(self, value)
class Effects(enum.Enum):
SKIP_HIT = 0
HIT_ONE = 1
HIT_FAILED = 2
WOUND_ONE = 3
WOUND_FAILED = 4
class Base:
_INIT = tuple()
def __init__(self, *args, **kwargs):
values = self._read_args(args, kwargs)
for name in self._INIT:
setattr(self, name, values.get(name, None))
def _read_args(self, args, kwargs):
values = dict(zip(self._INIT, args))
values.update(kwargs)
return values
class User(Base):
_INIT=tuple('skill'.split())
skill=TypedProperty('_skill', int)
class Weapon(Base):
_INIT=tuple('attacks strength ap damage'.split())
attacks=TypedProperty('_attacks', Damage)
strength=TypedProperty('_strength', int)
ap=TypedProperty('_ap', int)
damage=TypedProperty('_damage', Damage)
class Target(Base):
_INIT=tuple('toughness save invulnerable'.split())
toughness=TypedProperty('_toughness', int)
save=TypedProperty('_save', int)
invulnerable=TypedProperty('_invulnerable', int, None)
class RoundEffects(Base):
_INIT=tuple('skip one failed increase'.split())
skip=TypedProperty('_skip', bool, None)
one=TypedProperty('_one', bool, None)
failed=TypedProperty('_failed', bool, None)
increase=TypedProperty('_increase', int, None)
def reroll(self, score):
if self.failed:
return score
if self.one:
return 1
return 0
def round(self, score):
if self.skip:
return None
return (
score + (self.increase or 0),
self.reroll(score)
)
class Effects(Base):
_INIT=tuple('hit wound'.split())
hit=TypedProperty('_hit', RoundEffects)
wound=TypedProperty('_wound', RoundEffects)
def __init__(self, *args, **kwargs):
kwargs = self._read_args(args, kwargs)
for key in 'hit wound'.split():
if kwargs.get(key, None) is None:
kwargs[key] = RoundEffects()
super().__init__(**kwargs)
class Instance(Base):
_INIT=tuple('user weapon target effects'.split())
user=TypedProperty('_user', User)
weapon=TypedProperty('_weapon', Weapon)
target=TypedProperty('_target', Target)
effects=TypedProperty('_effects', Effects)
def __init__(self, *args, **kwargs):
kwargs = self._read_args(args, kwargs)
if kwargs.get('effects', None) is None:
kwargs['effects'] = Effects()
super().__init__(**kwargs)
def _damage(self, damage):
amount, variable = damage
variable = tuple(range(1, variable+1))
return [sum(ns) for ns in product(variable, repeat=amount)]
def attacks(self):
return self._damage(self.weapon.attacks)
def shots(self):
return self.weapon.attacks
def hits(self):
return self.effects.hit.round(self.user.skill)
def _round(self, damage):
if damage is None:
return (0, 100)
needed, reroll = damage
values = tuple(range(6))
rolls = np.array([
v
for n in values
for v in (values if n < reroll else [n] * 6)
])
ratio = np.bincount(rolls >= needed)
return ratio * 100 / np.sum(ratio)
def hits_wl(self):
return self._round(self.hits())
def damage_roll(self):
s = self.weapon.strength
t = self.target.toughness
if s >= t * 2:
return 2
if s > t:
return 3
if s == t:
return 4
if s * 2 <= t:
return 6
if s < t:
return 5
def wounds(self):
return self.effects.wound.round(self.damage_roll())
def wounds_wl(self):
return self._round(self.wounds())
def save(self):
return min(
self.target.save - self.weapon.ap,
self.target.invulnerable or 7
)
def save_wl(self):
save = self.save()
ratio = np.array((7 - save, save - 1))
return ratio * 100 / np.sum(ratio)
def win_loss(self):
wls = [
self.hits_wl(),
self.wounds_wl(),
self.save_wl()
]
failed = 0
for loss, _ in wls:
win = 100 - failed
loss = loss * win / 100
yield loss
failed += loss
yield 100 - failed
def damage(self):
return self._damage(self.weapon.damage)
def plot(instance):
fig, axes = plt.subplots(1, 3)
win_loss = list(instance.win_loss())
df = pd.DataFrame(
[
win_loss[:1] + [0, 0] + [sum(win_loss[1:])],
win_loss[:2] + [0] + [sum(win_loss[2:])],
win_loss
],
columns=['Miss', 'Prevented', 'Saved', 'Passed'],
index=['Hit', 'Wound', 'Save']
)
df.plot.bar(stacked=True, ax=axes[1]).set_ylim(0, 100)
attacks = instance.attacks()
damage = instance.damage()
limit = max(max(attacks), max(damage))
limit = int((limit + 1) * 1.1)
pd.DataFrame(attacks).boxplot(return_type='axes', ax=axes[0]).set_ylim(0, limit)
pd.DataFrame(damage).boxplot(return_type='axes', ax=axes[2]).set_ylim(0, limit)
if __name__ == '__main__':
khorn = Instance(
User(skill=3),
Weapon(
attacks=Damage(2),
strength=6,
ap=-1,
damage=Damage(1)
),
Target(
toughness=4,
save=3
),
Effects(
RoundEffects(
failed=True
),
RoundEffects(
failed=True
)
)
)
plot(khorn)
khorn2 = Instance(
User(skill=3),
Weapon(
attacks=Damage(2),
strength=6,
ap=-1,
damage=Damage(1)
),
Target(
toughness=4,
save=3
)
)
plot(khorn2)
land = Instance(
User(skill=3),
Weapon(
attacks=Damage(2),
strength=9,
ap=-3,
damage=Damage('d6')
),
Target(
toughness=7,
save=3
)
)
plot(land)
predator = Instance(
User(skill=3),
Weapon(
attacks=Damage('2d3'),
strength=7,
ap=-1,
damage=Damage('3')
),
Target(
toughness=7,
save=3
)
)
plot(predator)
plt.show()
python object-oriented python-3.x numpy pandas
add a comment |Â
up vote
17
down vote
favorite
up vote
17
down vote
favorite
Me and a couple of mates sometimes play a game called Warhammer.
When playing the game you have options of what each model attacks.
This can lead to situations where you know if you shoot with 100% of your units into one enemy unit you know the unit will be killed, but you don't know how many you should fire to kill the target unit.
And so I decided to write a little program that would help find out how many models I should shoot with into an enemy.
Combat in Warhammer is pretty basic, however some added complexity can come from additional rules on specific units or weapons.
The core rules when attacking another unit with a model is:
- Choose a Model to fight with
- Choose the Unit(s) to attack
- Choose the weapons you'll attack with
Resolve Attacks:
- Hit roll: for each attack roll a dice, if the roll is greater or equal to the attacking models Skill the attack hits.
Wound roll: This is the same as hitting, however what you roll is based on the weapons Strength and the targets Toughness.
- S >= 2T: 2+
- S > T: 3+
- S == T: 4+
- S < T: 5+
- S <= T: 6+
Allocate wound: You select a model to try and resist the wound.
Saving Throw: Roll a dice and add armor penetration to the roll, if it's greater than the models save then no damage is inflicted.
There are also 'invulnerable saves', which work the same way as normal saves, but aren't affected by armor penetration.
- Inflict Damage: The model takes the weapons damage, if the unit is reduced to 0 wounds it dies.
An example of this is:
We select a Khorne Berzerker
$
beginarrayl
textrmSkill &
textrmS &
textrmT &
textrmW &
textrmSv \
hline
text3+ &
text5 &
text4 &
text1 &
text3+ \
endarray
$We attack a squad of Khorne Berzerkers
We will attack with it's Chainaxe
$
beginarrayl
textrmAttacks &
textrmS &
textrmAP &
textrmD \
hline
text1 &
text6 &
text-1 &
text1 \
endarray
$- I roll a 3. This is equal to the models Skill.
- I roll a 3. This is equal to the required roll. (6 > 4: 3+)
- A Khorne Berzerker is selected to take the wound.
- My opponent rolls a 3. And since $3 - 1 < 3$, the save is failed, and the wound goes through.
- One enemy model dies.
There are some additional common effects:
- Some units allow others to re-roll failed hit rolls, hit rolls of one, failed wound rolls and wound rolls of one. However you can only re-roll a roll once, so you couldn't re-roll a hit of 1 and then re-roll a hit of 2. But you can re-roll a failed hit and then re-roll a failed wound.
- Some things allow you to add to your hit and wound rolls.
- Some things allow you to skip your hit or wound phase. Flame throwers normally auto hit, and so skip their hit phase.
And so I wrote some code to show the percentage of attacks that will be lost, and at what stage.
And the average amount of attacks and damage each weapon will have.
from functools import wraps
import enum
from collections import Counter
from itertools import product
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
class TypedProperty:
def __init__(self, name, *types):
types = [type(None) if t is None else t for t in types]
if not all(isinstance(t, type) for t in types):
raise ValueError('All arguments to `types` must inherit from type.')
self.types = tuple(types)
self.name = name
def __get__(self, obj, _):
return self._get(obj, self.name)
def __set__(self, obj, value):
if not isinstance(value, self.types):
raise TypeError('Value value must inherit one of self.types'.format(value=value, self=self))
self._set(obj, self.name, value)
def __delete__(self, obj):
self._delete(obj, self.name)
def get(self, fn):
self._get = fn
return self
def set(self, fn):
self._set = fn
return self
def delete(self, fn):
self._delete = fn
return self
@staticmethod
def _get(self, name):
return getattr(self, name)
@staticmethod
def _set(self, name, value):
setattr(self, name, value)
@staticmethod
def _delete(self, name):
delattr(self, name)
class Damage(tuple):
def __new__(self, value):
if isinstance(value, tuple):
pass
elif isinstance(value, int):
value = (value, None)
elif not isinstance(value, str):
raise TypeError('Value must be an int, tuple or str')
else:
value = tuple(value.split('d', 1) + [None])[:2]
value = (i or None for i in value)
value = tuple(int(i) if i is not None else 1 for i in value)
return super().__new__(self, value)
class Effects(enum.Enum):
SKIP_HIT = 0
HIT_ONE = 1
HIT_FAILED = 2
WOUND_ONE = 3
WOUND_FAILED = 4
class Base:
_INIT = tuple()
def __init__(self, *args, **kwargs):
values = self._read_args(args, kwargs)
for name in self._INIT:
setattr(self, name, values.get(name, None))
def _read_args(self, args, kwargs):
values = dict(zip(self._INIT, args))
values.update(kwargs)
return values
class User(Base):
_INIT=tuple('skill'.split())
skill=TypedProperty('_skill', int)
class Weapon(Base):
_INIT=tuple('attacks strength ap damage'.split())
attacks=TypedProperty('_attacks', Damage)
strength=TypedProperty('_strength', int)
ap=TypedProperty('_ap', int)
damage=TypedProperty('_damage', Damage)
class Target(Base):
_INIT=tuple('toughness save invulnerable'.split())
toughness=TypedProperty('_toughness', int)
save=TypedProperty('_save', int)
invulnerable=TypedProperty('_invulnerable', int, None)
class RoundEffects(Base):
_INIT=tuple('skip one failed increase'.split())
skip=TypedProperty('_skip', bool, None)
one=TypedProperty('_one', bool, None)
failed=TypedProperty('_failed', bool, None)
increase=TypedProperty('_increase', int, None)
def reroll(self, score):
if self.failed:
return score
if self.one:
return 1
return 0
def round(self, score):
if self.skip:
return None
return (
score + (self.increase or 0),
self.reroll(score)
)
class Effects(Base):
_INIT=tuple('hit wound'.split())
hit=TypedProperty('_hit', RoundEffects)
wound=TypedProperty('_wound', RoundEffects)
def __init__(self, *args, **kwargs):
kwargs = self._read_args(args, kwargs)
for key in 'hit wound'.split():
if kwargs.get(key, None) is None:
kwargs[key] = RoundEffects()
super().__init__(**kwargs)
class Instance(Base):
_INIT=tuple('user weapon target effects'.split())
user=TypedProperty('_user', User)
weapon=TypedProperty('_weapon', Weapon)
target=TypedProperty('_target', Target)
effects=TypedProperty('_effects', Effects)
def __init__(self, *args, **kwargs):
kwargs = self._read_args(args, kwargs)
if kwargs.get('effects', None) is None:
kwargs['effects'] = Effects()
super().__init__(**kwargs)
def _damage(self, damage):
amount, variable = damage
variable = tuple(range(1, variable+1))
return [sum(ns) for ns in product(variable, repeat=amount)]
def attacks(self):
return self._damage(self.weapon.attacks)
def shots(self):
return self.weapon.attacks
def hits(self):
return self.effects.hit.round(self.user.skill)
def _round(self, damage):
if damage is None:
return (0, 100)
needed, reroll = damage
values = tuple(range(6))
rolls = np.array([
v
for n in values
for v in (values if n < reroll else [n] * 6)
])
ratio = np.bincount(rolls >= needed)
return ratio * 100 / np.sum(ratio)
def hits_wl(self):
return self._round(self.hits())
def damage_roll(self):
s = self.weapon.strength
t = self.target.toughness
if s >= t * 2:
return 2
if s > t:
return 3
if s == t:
return 4
if s * 2 <= t:
return 6
if s < t:
return 5
def wounds(self):
return self.effects.wound.round(self.damage_roll())
def wounds_wl(self):
return self._round(self.wounds())
def save(self):
return min(
self.target.save - self.weapon.ap,
self.target.invulnerable or 7
)
def save_wl(self):
save = self.save()
ratio = np.array((7 - save, save - 1))
return ratio * 100 / np.sum(ratio)
def win_loss(self):
wls = [
self.hits_wl(),
self.wounds_wl(),
self.save_wl()
]
failed = 0
for loss, _ in wls:
win = 100 - failed
loss = loss * win / 100
yield loss
failed += loss
yield 100 - failed
def damage(self):
return self._damage(self.weapon.damage)
def plot(instance):
fig, axes = plt.subplots(1, 3)
win_loss = list(instance.win_loss())
df = pd.DataFrame(
[
win_loss[:1] + [0, 0] + [sum(win_loss[1:])],
win_loss[:2] + [0] + [sum(win_loss[2:])],
win_loss
],
columns=['Miss', 'Prevented', 'Saved', 'Passed'],
index=['Hit', 'Wound', 'Save']
)
df.plot.bar(stacked=True, ax=axes[1]).set_ylim(0, 100)
attacks = instance.attacks()
damage = instance.damage()
limit = max(max(attacks), max(damage))
limit = int((limit + 1) * 1.1)
pd.DataFrame(attacks).boxplot(return_type='axes', ax=axes[0]).set_ylim(0, limit)
pd.DataFrame(damage).boxplot(return_type='axes', ax=axes[2]).set_ylim(0, limit)
if __name__ == '__main__':
khorn = Instance(
User(skill=3),
Weapon(
attacks=Damage(2),
strength=6,
ap=-1,
damage=Damage(1)
),
Target(
toughness=4,
save=3
),
Effects(
RoundEffects(
failed=True
),
RoundEffects(
failed=True
)
)
)
plot(khorn)
khorn2 = Instance(
User(skill=3),
Weapon(
attacks=Damage(2),
strength=6,
ap=-1,
damage=Damage(1)
),
Target(
toughness=4,
save=3
)
)
plot(khorn2)
land = Instance(
User(skill=3),
Weapon(
attacks=Damage(2),
strength=9,
ap=-3,
damage=Damage('d6')
),
Target(
toughness=7,
save=3
)
)
plot(land)
predator = Instance(
User(skill=3),
Weapon(
attacks=Damage('2d3'),
strength=7,
ap=-1,
damage=Damage('3')
),
Target(
toughness=7,
save=3
)
)
plot(predator)
plt.show()
python object-oriented python-3.x numpy pandas
Me and a couple of mates sometimes play a game called Warhammer.
When playing the game you have options of what each model attacks.
This can lead to situations where you know if you shoot with 100% of your units into one enemy unit you know the unit will be killed, but you don't know how many you should fire to kill the target unit.
And so I decided to write a little program that would help find out how many models I should shoot with into an enemy.
Combat in Warhammer is pretty basic, however some added complexity can come from additional rules on specific units or weapons.
The core rules when attacking another unit with a model is:
- Choose a Model to fight with
- Choose the Unit(s) to attack
- Choose the weapons you'll attack with
Resolve Attacks:
- Hit roll: for each attack roll a dice, if the roll is greater or equal to the attacking models Skill the attack hits.
Wound roll: This is the same as hitting, however what you roll is based on the weapons Strength and the targets Toughness.
- S >= 2T: 2+
- S > T: 3+
- S == T: 4+
- S < T: 5+
- S <= T: 6+
Allocate wound: You select a model to try and resist the wound.
Saving Throw: Roll a dice and add armor penetration to the roll, if it's greater than the models save then no damage is inflicted.
There are also 'invulnerable saves', which work the same way as normal saves, but aren't affected by armor penetration.
- Inflict Damage: The model takes the weapons damage, if the unit is reduced to 0 wounds it dies.
An example of this is:
We select a Khorne Berzerker
$
beginarrayl
textrmSkill &
textrmS &
textrmT &
textrmW &
textrmSv \
hline
text3+ &
text5 &
text4 &
text1 &
text3+ \
endarray
$We attack a squad of Khorne Berzerkers
We will attack with it's Chainaxe
$
beginarrayl
textrmAttacks &
textrmS &
textrmAP &
textrmD \
hline
text1 &
text6 &
text-1 &
text1 \
endarray
$- I roll a 3. This is equal to the models Skill.
- I roll a 3. This is equal to the required roll. (6 > 4: 3+)
- A Khorne Berzerker is selected to take the wound.
- My opponent rolls a 3. And since $3 - 1 < 3$, the save is failed, and the wound goes through.
- One enemy model dies.
There are some additional common effects:
- Some units allow others to re-roll failed hit rolls, hit rolls of one, failed wound rolls and wound rolls of one. However you can only re-roll a roll once, so you couldn't re-roll a hit of 1 and then re-roll a hit of 2. But you can re-roll a failed hit and then re-roll a failed wound.
- Some things allow you to add to your hit and wound rolls.
- Some things allow you to skip your hit or wound phase. Flame throwers normally auto hit, and so skip their hit phase.
And so I wrote some code to show the percentage of attacks that will be lost, and at what stage.
And the average amount of attacks and damage each weapon will have.
from functools import wraps
import enum
from collections import Counter
from itertools import product
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
class TypedProperty:
def __init__(self, name, *types):
types = [type(None) if t is None else t for t in types]
if not all(isinstance(t, type) for t in types):
raise ValueError('All arguments to `types` must inherit from type.')
self.types = tuple(types)
self.name = name
def __get__(self, obj, _):
return self._get(obj, self.name)
def __set__(self, obj, value):
if not isinstance(value, self.types):
raise TypeError('Value value must inherit one of self.types'.format(value=value, self=self))
self._set(obj, self.name, value)
def __delete__(self, obj):
self._delete(obj, self.name)
def get(self, fn):
self._get = fn
return self
def set(self, fn):
self._set = fn
return self
def delete(self, fn):
self._delete = fn
return self
@staticmethod
def _get(self, name):
return getattr(self, name)
@staticmethod
def _set(self, name, value):
setattr(self, name, value)
@staticmethod
def _delete(self, name):
delattr(self, name)
class Damage(tuple):
def __new__(self, value):
if isinstance(value, tuple):
pass
elif isinstance(value, int):
value = (value, None)
elif not isinstance(value, str):
raise TypeError('Value must be an int, tuple or str')
else:
value = tuple(value.split('d', 1) + [None])[:2]
value = (i or None for i in value)
value = tuple(int(i) if i is not None else 1 for i in value)
return super().__new__(self, value)
class Effects(enum.Enum):
SKIP_HIT = 0
HIT_ONE = 1
HIT_FAILED = 2
WOUND_ONE = 3
WOUND_FAILED = 4
class Base:
_INIT = tuple()
def __init__(self, *args, **kwargs):
values = self._read_args(args, kwargs)
for name in self._INIT:
setattr(self, name, values.get(name, None))
def _read_args(self, args, kwargs):
values = dict(zip(self._INIT, args))
values.update(kwargs)
return values
class User(Base):
_INIT=tuple('skill'.split())
skill=TypedProperty('_skill', int)
class Weapon(Base):
_INIT=tuple('attacks strength ap damage'.split())
attacks=TypedProperty('_attacks', Damage)
strength=TypedProperty('_strength', int)
ap=TypedProperty('_ap', int)
damage=TypedProperty('_damage', Damage)
class Target(Base):
_INIT=tuple('toughness save invulnerable'.split())
toughness=TypedProperty('_toughness', int)
save=TypedProperty('_save', int)
invulnerable=TypedProperty('_invulnerable', int, None)
class RoundEffects(Base):
_INIT=tuple('skip one failed increase'.split())
skip=TypedProperty('_skip', bool, None)
one=TypedProperty('_one', bool, None)
failed=TypedProperty('_failed', bool, None)
increase=TypedProperty('_increase', int, None)
def reroll(self, score):
if self.failed:
return score
if self.one:
return 1
return 0
def round(self, score):
if self.skip:
return None
return (
score + (self.increase or 0),
self.reroll(score)
)
class Effects(Base):
_INIT=tuple('hit wound'.split())
hit=TypedProperty('_hit', RoundEffects)
wound=TypedProperty('_wound', RoundEffects)
def __init__(self, *args, **kwargs):
kwargs = self._read_args(args, kwargs)
for key in 'hit wound'.split():
if kwargs.get(key, None) is None:
kwargs[key] = RoundEffects()
super().__init__(**kwargs)
class Instance(Base):
_INIT=tuple('user weapon target effects'.split())
user=TypedProperty('_user', User)
weapon=TypedProperty('_weapon', Weapon)
target=TypedProperty('_target', Target)
effects=TypedProperty('_effects', Effects)
def __init__(self, *args, **kwargs):
kwargs = self._read_args(args, kwargs)
if kwargs.get('effects', None) is None:
kwargs['effects'] = Effects()
super().__init__(**kwargs)
def _damage(self, damage):
amount, variable = damage
variable = tuple(range(1, variable+1))
return [sum(ns) for ns in product(variable, repeat=amount)]
def attacks(self):
return self._damage(self.weapon.attacks)
def shots(self):
return self.weapon.attacks
def hits(self):
return self.effects.hit.round(self.user.skill)
def _round(self, damage):
if damage is None:
return (0, 100)
needed, reroll = damage
values = tuple(range(6))
rolls = np.array([
v
for n in values
for v in (values if n < reroll else [n] * 6)
])
ratio = np.bincount(rolls >= needed)
return ratio * 100 / np.sum(ratio)
def hits_wl(self):
return self._round(self.hits())
def damage_roll(self):
s = self.weapon.strength
t = self.target.toughness
if s >= t * 2:
return 2
if s > t:
return 3
if s == t:
return 4
if s * 2 <= t:
return 6
if s < t:
return 5
def wounds(self):
return self.effects.wound.round(self.damage_roll())
def wounds_wl(self):
return self._round(self.wounds())
def save(self):
return min(
self.target.save - self.weapon.ap,
self.target.invulnerable or 7
)
def save_wl(self):
save = self.save()
ratio = np.array((7 - save, save - 1))
return ratio * 100 / np.sum(ratio)
def win_loss(self):
wls = [
self.hits_wl(),
self.wounds_wl(),
self.save_wl()
]
failed = 0
for loss, _ in wls:
win = 100 - failed
loss = loss * win / 100
yield loss
failed += loss
yield 100 - failed
def damage(self):
return self._damage(self.weapon.damage)
def plot(instance):
fig, axes = plt.subplots(1, 3)
win_loss = list(instance.win_loss())
df = pd.DataFrame(
[
win_loss[:1] + [0, 0] + [sum(win_loss[1:])],
win_loss[:2] + [0] + [sum(win_loss[2:])],
win_loss
],
columns=['Miss', 'Prevented', 'Saved', 'Passed'],
index=['Hit', 'Wound', 'Save']
)
df.plot.bar(stacked=True, ax=axes[1]).set_ylim(0, 100)
attacks = instance.attacks()
damage = instance.damage()
limit = max(max(attacks), max(damage))
limit = int((limit + 1) * 1.1)
pd.DataFrame(attacks).boxplot(return_type='axes', ax=axes[0]).set_ylim(0, limit)
pd.DataFrame(damage).boxplot(return_type='axes', ax=axes[2]).set_ylim(0, limit)
if __name__ == '__main__':
khorn = Instance(
User(skill=3),
Weapon(
attacks=Damage(2),
strength=6,
ap=-1,
damage=Damage(1)
),
Target(
toughness=4,
save=3
),
Effects(
RoundEffects(
failed=True
),
RoundEffects(
failed=True
)
)
)
plot(khorn)
khorn2 = Instance(
User(skill=3),
Weapon(
attacks=Damage(2),
strength=6,
ap=-1,
damage=Damage(1)
),
Target(
toughness=4,
save=3
)
)
plot(khorn2)
land = Instance(
User(skill=3),
Weapon(
attacks=Damage(2),
strength=9,
ap=-3,
damage=Damage('d6')
),
Target(
toughness=7,
save=3
)
)
plot(land)
predator = Instance(
User(skill=3),
Weapon(
attacks=Damage('2d3'),
strength=7,
ap=-1,
damage=Damage('3')
),
Target(
toughness=7,
save=3
)
)
plot(predator)
plt.show()
python object-oriented python-3.x numpy pandas
asked May 24 at 16:12
Peilonrayz
24.3k336102
24.3k336102
add a comment |Â
add a comment |Â
1 Answer
1
active
oldest
votes
up vote
5
down vote
accepted
I won't focus much on the logic behind the code as:
- you have more experience of the game than I have, so IâÂÂm trusting you on this;
- 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 Instance
s 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 namedtuple
s:
#!/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
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 usinginspect
inspired from yourBase.__init__
to showcase how you could add type checking with minimal cost to thedataclasses
.
â Mathias Ettinger
May 31 at 8:32
@Peilonrayz I stumbled upon a related question on SO (how to enforce type checking withdataclasses
+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 doclass 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 intodataclasses
more, but it looks quite sick TBH.
â Peilonrayz
May 31 at 14:23
@Peilonrayz Still digging intotyping
to further understand it, but note that in most case, you can usetyping.NamedTuple
instead ofdataclasses
. You won't have support for mutable default arguments though.
â Mathias Ettinger
May 31 at 14:44
add a comment |Â
1 Answer
1
active
oldest
votes
1 Answer
1
active
oldest
votes
active
oldest
votes
active
oldest
votes
up vote
5
down vote
accepted
I won't focus much on the logic behind the code as:
- you have more experience of the game than I have, so IâÂÂm trusting you on this;
- 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 Instance
s 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 namedtuple
s:
#!/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
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 usinginspect
inspired from yourBase.__init__
to showcase how you could add type checking with minimal cost to thedataclasses
.
â Mathias Ettinger
May 31 at 8:32
@Peilonrayz I stumbled upon a related question on SO (how to enforce type checking withdataclasses
+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 doclass 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 intodataclasses
more, but it looks quite sick TBH.
â Peilonrayz
May 31 at 14:23
@Peilonrayz Still digging intotyping
to further understand it, but note that in most case, you can usetyping.NamedTuple
instead ofdataclasses
. You won't have support for mutable default arguments though.
â Mathias Ettinger
May 31 at 14:44
add a comment |Â
up vote
5
down vote
accepted
I won't focus much on the logic behind the code as:
- you have more experience of the game than I have, so IâÂÂm trusting you on this;
- 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 Instance
s 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 namedtuple
s:
#!/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
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 usinginspect
inspired from yourBase.__init__
to showcase how you could add type checking with minimal cost to thedataclasses
.
â Mathias Ettinger
May 31 at 8:32
@Peilonrayz I stumbled upon a related question on SO (how to enforce type checking withdataclasses
+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 doclass 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 intodataclasses
more, but it looks quite sick TBH.
â Peilonrayz
May 31 at 14:23
@Peilonrayz Still digging intotyping
to further understand it, but note that in most case, you can usetyping.NamedTuple
instead ofdataclasses
. You won't have support for mutable default arguments though.
â Mathias Ettinger
May 31 at 14:44
add a comment |Â
up vote
5
down vote
accepted
up vote
5
down vote
accepted
I won't focus much on the logic behind the code as:
- you have more experience of the game than I have, so IâÂÂm trusting you on this;
- 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 Instance
s 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 namedtuple
s:
#!/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
I won't focus much on the logic behind the code as:
- you have more experience of the game than I have, so IâÂÂm trusting you on this;
- 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 Instance
s 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 namedtuple
s:
#!/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
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 usinginspect
inspired from yourBase.__init__
to showcase how you could add type checking with minimal cost to thedataclasses
.
â Mathias Ettinger
May 31 at 8:32
@Peilonrayz I stumbled upon a related question on SO (how to enforce type checking withdataclasses
+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 doclass 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 intodataclasses
more, but it looks quite sick TBH.
â Peilonrayz
May 31 at 14:23
@Peilonrayz Still digging intotyping
to further understand it, but note that in most case, you can usetyping.NamedTuple
instead ofdataclasses
. You won't have support for mutable default arguments though.
â Mathias Ettinger
May 31 at 14:44
add a comment |Â
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 usinginspect
inspired from yourBase.__init__
to showcase how you could add type checking with minimal cost to thedataclasses
.
â Mathias Ettinger
May 31 at 8:32
@Peilonrayz I stumbled upon a related question on SO (how to enforce type checking withdataclasses
+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 doclass 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 intodataclasses
more, but it looks quite sick TBH.
â Peilonrayz
May 31 at 14:23
@Peilonrayz Still digging intotyping
to further understand it, but note that in most case, you can usetyping.NamedTuple
instead ofdataclasses
. 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
add a comment |Â
Sign up or log in
StackExchange.ready(function ()
StackExchange.helpers.onClickDraftSave('#login-link');
);
Sign up using Google
Sign up using Facebook
Sign up using Email and Password
Post as a guest
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
Sign up or log in
StackExchange.ready(function ()
StackExchange.helpers.onClickDraftSave('#login-link');
);
Sign up using Google
Sign up using Facebook
Sign up using Email and Password
Post as a guest
Sign up or log in
StackExchange.ready(function ()
StackExchange.helpers.onClickDraftSave('#login-link');
);
Sign up using Google
Sign up using Facebook
Sign up using Email and Password
Post as a guest
Sign up or log in
StackExchange.ready(function ()
StackExchange.helpers.onClickDraftSave('#login-link');
);
Sign up using Google
Sign up using Facebook
Sign up using Email and Password
Sign up using Google
Sign up using Facebook
Sign up using Email and Password