Skip to content

Instantly share code, notes, and snippets.

@cbsmith cbsmith/shadow_combat.py
Last active Sep 11, 2016

Embed
What would you like to do?
"""Shadow run attack simulator.
Usage:
shadow_combat.py [--debug] [--limit LIMIT | -6 | --rule_of_six] [--threshold THRESHOLD | --opposed_pool OPPOSED [--opposed_limit OPPOSED_LIMIT]] [--dv DV --stun [--soak SOAK] [--armor ARMOR [--ap AP]]] [--contact] [--once | -o | [--iterations ITERATIONS] [-D | --distribution]] [--multi ATTACKS] [--min DAMAGE] ATTACK_POOL
shadow_combat.py [--debug] [--contact] [-6 | --rule_of_six] --threshold THRESHOLD ATTACK_STRING DAMAGE_STRING SOAK_STRING
shadow_combat.py [--debug] [--contact] [-D | --distribution] [-6 | --rule_of_six] --threshold THRESHOLD ATTACK_STRING DAMAGE_STRING SOAK_STRING ITERATIONS
shadow_combat.py [--debug] [--contact] [-6 | --rule_of_six] ATTACK_STRING DAMAGE_STRING DEFENSE_STRING SOAK_STRING
shadow_combat.py [--debug] [--contact] [-D | --distribution] [-6 | --rule_of_six] ATTACK_STRING DAMAGE_STRING DEFENSE_STRING SOAK_STRING ITERATIONS
shadow_combat.py (-h | --help)
shadow_combat.py (-v | --version)
Options:
-6 --rule_of_six rule of six applies
--limit LIMIT attack dice limit
--threshold THRESHOLD threshold for test
--opposed_pool OPPOSED opposed dice pool
--opposed_limit OPPOSED_LIMIT opposed dice limit
--dv DV the damage value of the attack
--soak SOAK defense base soak dice (does not include armor)
--armor ARMOR armor dice
--ap AP armor piercing [default: 0]
--stun dv is stun damage, not physical
--contact if this is a contact only attack [default: False]
--multi ATTACKS split attack over ATTACKS [default: 1]
-o --once simulate a single action only
-D --distribution print the distribution of attacks
--iterations ITERATIONS total iterations to run through [default: 10000]
-h --help show this
-v --version print version
--debug turn on debug printing
--min DAMAGE minimum_damage goal
ATTACK_STRING format is 18[7] meaning 18 dice in the pool, with a limit of 7
DAMAGE_STRING format is 7Pv-4 meaning 7 physical damage, with -4 AP
DEFENSE_STRING format is same as ATTACK_STRING... without a limit it'd just be 18
SOAK_STRING format is 12v3 meaning 12 armor, 3 soak dice or just v3 for the case where there is no armor involved
"""
from functools import total_ordering
from itertools import groupby, chain
import re
from numbers import Number
from sys import argv, stderr, stdout
from warnings import warn
from docopt import docopt
try:
import random
sys_random = random.SystemRandom()
choice, randint = sys_random.choice, sys_random.randint
except:
warn('Could not use SystemRandom; using default random instead', RuntimeWarning)
from random import choice, randint
class InputError(Exception):
"""Exception raised for errors in input
Attributes:
expr -- input expression in which the error occurred
msg -- explanation of the error
"""
def __init__(self, expr, mesg):
self.expr = expr
self.msg = mesg
@total_ordering
class Outcome(object):
'Represents the outcome of one or more attacks'
__slots__ = ('hits', 'phys', 'stun', 'misses')
SINGULAR = {
'misses': 'miss',
'hits': 'hit',
'phys': 'phys',
'stun': 'stun'
}
def __init__(self, misses=0, hits=None, phys=0, stun=0, copy=None):
if copy is None:
assert isinstance(misses, int) and isinstance(phys, int) and isinstance(stun, int)
self.misses = misses
self.phys = phys
self.stun = stun
# if hits is None, set it to an implied value based on damage
if hits is None:
self.hits = 1 if (self.phys > 0 or self.stun > 0) else 0
else:
self.hits = hits
assert isinstance(self.hits, int)
else:
assert misses == 0 and hits is None and phys == 0 and stun == 0
assert isinstance(copy, Outcome)
self.misses = copy.misses
self.hits = copy.hits
self.phys = copy.phys
self.stun = copy.stun
def __iadd__(self, other):
self.misses += other.misses
self.hits += other.hits
self.phys += other.phys
self.stun += other.stun
return self
def __isub__(self, other):
self.misses -= other.misses
self.hits -= other.hits
self.phys -= other.phys
self.stun -= other.stun
return self
def __imul__(self, other):
assert isinstance(other, Number)
self.misses *= other
self.hits *= other
self.phys *= other
self.stun *= other
return self
def __itruediv__(self, other):
assert isinstance(other, Number)
self.misses /= other
self.hits /= other
self.phys /= other
self.stun /= other
return self
def __add__(self, other):
return Outcome(copy=self).__iadd__(other)
def __sub__(self, other):
return Outcome(copy=self).__isub__(other)
def __mul__(self, other):
return Outcome(copy=self).__imul__(other)
def __truediv__(self, other):
return Outcome(copy=self).__itruediv__(other)
def __radd__(self, other):
assert other == 0
return Outcome(copy=self)
def __rsub__(self, other):
assert other == 0
return Outcome() - Outcome(copy=self)
def __rmul__(self, other):
return self.__mul__(other)
def __rtruediv__(self, other):
return self.__truediv__(other)
@staticmethod
def singular(attribute):
return Outcome.SINGULAR.get(attribute, attribute)
def __str__(self):
if self.hits > 0 and self.stun == 0 and self.phys == 0:
return '{:>5} {:>4}, no damage'.format(self.hits, 'hit' if self.hits == 1 else 'hits')
answer = ', '.join('{:>5} {:>8}'.format(*x) for x in ((getattr(self, attr), (self.singular(attr) if getattr(self, attr) == 1 else attr)) for attr in self.__slots__) if x[1] == 'miss' or x[0] > 0)
return '{:22}'.format(answer)
def __repr__(self):
return 'Outcome({}, {}, {}, {})'.format(self.misses, self.hits, self.phys, self.stun)
def __lt__(self, other):
if self.phys < other.phys:
return True
if self.phys == other.phys:
if self.stun < other.stun:
return True
if self.stun == other.stun:
if self.hits < other.hits:
return True
if self.hits == other.hits:
return self.misses > other.misses # more misses means overall you did worse
return False
def __eq__(self, other):
return self.misses == other.misses and self.hits == other.hits and self.phys == other.phys and self.stun == other.stun
class Pool(object):
OUTCOMES = (False, False, True) # we could do False, False, False, False, True, True... but that is needlessly inefficient
def __init__(self, dice, limit=None, rule_of_six=False):
if rule_of_six:
assert limit is None
self.dice = int(dice)
self.limit = int(limit) if limit else None
self.rule_of_six = rule_of_six
def hit(self):
return choice(self.OUTCOMES)
def hit_generator(self):
if self.rule_of_six:
dice = self.dice
while dice > 0:
roll = randint(1, 6)
yield roll > 4
if roll != 6:
dice -= 1
else:
for _ in range(self.dice):
yield self.hit()
def hits(self):
hits = sum(1 for hit in self.hit_generator() if hit)
return hits if self.limit is None else min(hits, self.limit)
def split(self, parts=1):
base = self.dice // parts
more_than_base = (base + 1 for _ in range(self.dice % parts))
rest = (base for _ in range(parts - (self.dice % parts)))
return (Pool(dice, self.limit, self.rule_of_six) for dice in chain(more_than_base, rest))
def __str__(self):
return '{}[{}]'.format(self.dice, self.limit) if self.limit else '{}{}'.format(self.dice, '*' if self.rule_of_six else '')
def __repr__(self):
return 'Pool(dice={}, limit={}, rule_of_six={})'.format(self.dice, self.limit, self.rule_of_six)
class FixedThreshold(object):
def __init__(self, threshold):
self.threshold = int(threshold)
def hits(self):
return self.threshold
def __str__(self):
return '({})'.format(self.threshold)
def __repr__(self):
return 'FixedThreshold({})'.format(self.threshold)
class Test(object):
def __init__(self, pool, threshold=None):
self.pool = pool
self.threshold = threshold
def test(self):
hits = self.pool.hits()
return hits if self.threshold is None else hits - self.threshold.hits()
def net_hits(self):
return max(0, self.test())
def __str__(self):
return '{} vs {}'.format(self.pool, self.threshold)
def __repr__(self):
return 'Test(pool={}, threshold={})'.format(repr(self.pool), repr(self.threshold))
class Attack(object):
def __init__(self, test, dv=None, ap=0, stun=False, soak=None, armor=None, contact=False):
self.test = test
self.dv = int(dv) if dv else None
self.stun = stun
self.soak = int(soak) if soak else None
self.armor = int(armor) if armor else None
self.ap = int(ap) if ap else 0
self.contact = contact
def damage(self):
hits = self.test.net_hits()
if (hits < 0) or (hits == 0 and not self.contact):
return Outcome(misses=1)
# if dv is not set, then we're just counting hits vs. not hits
if self.dv is None:
return Outcome(hits=1)
modified_dv = self.dv + hits
if self.soak is None:
return Outcome(stun=modified_dv) if self.stun else Outcome(phys=modified_dv)
stun = self.stun
soak = self.soak
if self.armor is not None:
armor_value = self.armor + self.ap
if armor_value > 0:
if armor_value >= modified_dv:
stun = True
soak += armor_value
soak_pool = Pool(soak)
damage = max(0, modified_dv - soak_pool.hits())
return Outcome(hits=1, stun=damage) if stun else Outcome(hits=1, phys=damage)
def __str__(self):
ap_val = self.ap if self.ap < 0 else ('+' + self.ap if self.ap > 0 else '')
base = '{}{} {}{}{}'.format(self.test,
' contact' if self.contact else '',
self.dv,
'S' if self.stun else 'P',
ap_val)
return base + 'soak: {}v{}'.format(self.armor, self.soak) if self.armor or self.soak else base
def __repr__(self):
return 'Attack(test={}, dv={}, ap={}, stun={}, soak={}, armor={}, contact={})'.format(repr(self.test), repr(self.dv), repr(self.ap), repr(self.stun), repr(self.soak), repr(self.armor), repr(self.contact))
class Simulation(object):
def __init__(self, attacks, iterations=10000, debug=False):
self.attacks = tuple(attacks)
if debug:
stderr.write('Performing {} iterations of [{}]\n'.format(iterations, ', '.join(map(str, self.attacks))))
self.iterations = int(iterations) if iterations else 10000
self.outcome = None
def results(self):
for _ in range(self.iterations):
d = sum(attack.damage() for attack in self.attacks)
assert d.misses != 0 or d.hits != 0
yield d
def distribution(self):
pred = lambda x: (x.phys, x.stun)
results = sorted(self.results(), reverse=True)
for damage, g in groupby(results):
total = sum(1 for _ in g)
yield damage, total
if self.outcome is None:
self.outcome = damage * total
else:
self.outcome += damage * total
def mean(self):
return self.outcome * 1.0 / self.iterations
ATTACK_RE = re.compile(r'^(\d+)(?:\[(\d+)\])?$')
DEFENSE_RE = ATTACK_RE
DAMAGE_RE = re.compile(r'^(\d+)(P|S)(?:v((?:\+|-)\d+))?$')
SOAK_RE = re.compile(r'^(\d+)?v(\d+)$')
def get_attack_pool(args):
if args['ATTACK_POOL']:
return Pool(args['ATTACK_POOL'], args['--limit'], args['--rule_of_six'])
elif args['ATTACK_STRING']:
matcher = ATTACK_RE.match(args['ATTACK_STRING'])
if matcher is None:
raise InputError(args['ATTACK_STRING'], 'Invalid Attack String')
limit = matcher.group(2)
return Pool(matcher.group(1), limit or None, args['--rule_of_six'])
else:
raise InputError(' '.join(args), 'No attacker parameters found')
def get_threshold(args):
if args['--threshold']:
return FixedThreshold(args['--threshold'])
if args['--opposed_pool']:
return Pool(args['--opposed_pool'], args['--opposed_limit'])
if args['DEFENSE_STRING']:
matches = DEFENSE_RE.match(args['DEFENSE_STRING'])
if matches is None:
raise InputError(args['DEFENSE_STRING'], 'Invalid Defense String')
limit = matches.group(2)
return Pool(matches.group(1), limit or None)
else:
return None
def parse_ap_string(ap_string):
if ap_string[0] == '+':
return int(ap_string[1:])
return -int((ap_string[1:] if ap_string[0] == '-' else ap_string))
def get_tests(args):
attack_pool = get_attack_pool(args)
threshold = get_threshold(args)
multi_attacks = int(args['--multi']) if args['--multi'] else 1
if args['--debug']:
stderr.write('Splitting in to {} attacks\n'.format(multi_attacks))
return (Test(pool, threshold) for pool in attack_pool.split(multi_attacks))
def get_attacks(tests, args):
attack_args = {}
if args['DAMAGE_STRING'] and args['SOAK_STRING']:
matches = DAMAGE_RE.match(args['DAMAGE_STRING'])
if matches is None:
raise InputError(args['DAMAGE_STRING'], 'Invalid Damage String')
assert matches.group(2) in ('S', 'P')
attack_args['stun'] = matches.group(2) == 'S'
ap_string = matches.group(3)
attack_args['ap'] = None if ap_string is None else parse_ap_string(ap_string)
attack_args['dv'] = int(matches.group(1))
matches = SOAK_RE.match(args['SOAK_STRING'])
if matches is None:
raise InputError(args['SOAK_STRING'], 'Invalid Soak String')
attack_args['armor'] = None if matches.group(1) is None else int(matches.group(1))
attack_args['soak'] = int(matches.group(2))
else:
attack_args['dv'] = args['--dv']
attack_args['ap'] = parse_ap_string(args['--ap'])
attack_args['stun'] = args['--stun']
attack_args['soak'] = args['--soak']
attack_args['armor'] = args['--armor']
attack_args['contact'] = args['--contact']
for test in tests:
yield Attack(test, **attack_args)
def main(**args):
if args['--debug']:
stderr.write('Args: {}\n'.format(args))
tests = get_tests(args)
attacks = get_attacks(tests, args)
if args['--once']:
for attack in attacks:
print(attack.damage())
return
simulation = Simulation(attacks, args['--iterations'] or args['ITERATIONS'], args['--debug'])
distribution = simulation.distribution()
min_damage = args['--min']
if min_damage:
min_damage = int(min_damage)
min_outcomes = 0
for outcome, total in distribution:
if min_damage and outcome.phys >= min_damage:
min_outcomes += total
if args['--distribution']:
print('{} for {} or {:>5.2f}%'.format(outcome, total, total * 100.0 / simulation.iterations))
if min_damage:
print('{:>5.2f}% above {}P'.format(min_outcomes * 100.0 / simulation.iterations, min_damage))
print('mean: {}'.format(simulation.mean()))
if __name__ == '__main__':
arguments = docopt(__doc__, version='Rolls 0.0')
main(**arguments)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.