Skip to content

Instantly share code, notes, and snippets.

@cbsmith
Last active September 11, 2016 08:54
Show Gist options
  • Save cbsmith/34f8356a001646e84c283dd39aa26c8b to your computer and use it in GitHub Desktop.
Save cbsmith/34f8356a001646e84c283dd39aa26c8b to your computer and use it in GitHub Desktop.
"""Shadow run attack simulator.
Usage:
shadow_combat.py [--debug] [--limit LIMIT] [--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]] ATTACK_POOL
shadow_combat.py [--debug] [--contact] --threshold THRESHOLD ATTACK_STRING DAMAGE_STRING SOAK_STRING
shadow_combat.py [--debug] [--contact] [-D | --distribution] --threshold THRESHOLD ATTACK_STRING DAMAGE_STRING SOAK_STRING ITERATIONS
shadow_combat.py [--debug] [--contact] ATTACK_STRING DAMAGE_STRING DEFENSE_STRING SOAK_STRING
shadow_combat.py [--debug] [--contact] [-D | --distribution] ATTACK_STRING DAMAGE_STRING DEFENSE_STRING SOAK_STRING ITERATIONS
shadow_combat.py (-h | --help)
shadow_combat.py (-v | --version)
Options:
--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]
-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
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
import re
from numbers import Number
from sys import argv, stderr
from warnings import warn
from docopt import docopt
try:
import random
choice = random.SystemRandom().choice
except:
warn('Could not use SystemRandom; using default random instead', RuntimeWarning)
from random import choice
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__ = ('misses', 'hits', 'phys', 'stun')
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 __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} {:>4}'.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[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):
self.dice = int(dice)
self.limit = int(limit) if limit else None
def hit(self):
return choice(self.OUTCOMES)
def hits(self):
hits = sum(1 for _ in range(self.dice) if self.hit())
return hits if self.limit is None else min(hits, self.limit)
class FixedThreshold(object):
def __init__(self, threshold):
self.threshold = int(threshold)
def hits(self):
return 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())
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
stderr.write('self.armor = {} and armor = {}\n'.format(self.armor, armor))
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):
o = Outcome(misses=1)
assert o.misses > 0
return o
# if dv is not set, then we're just counting hits vs. not hits
if self.dv is None:
o = Outcome(hits=1)
assert o.hits > 0
return o
modified_dv = self.dv + hits
if self.soak is None:
o = Outcome(stun=modified_dv) if self.stun else Outcome(phys=modified_dv)
assert o.hits > 0
return o
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())
o = Outcome(hits=1, stun=damage) if stun else Outcome(hits=1, phys=damage)
assert o.hits > 0
return o
class Simulation(object):
def __init__(self, attack, iterations=10000):
self.attack = attack
self.iterations = int(iterations) if iterations else 10000
self.outcome = None
def results(self):
for _ in range(self.iterations):
d = self.attack.damage()
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'])
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)
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_attack(test, 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')
stun = matches.group(2) == 'S'
ap_string = matches.group(3)
ap = parse_ap_string(ap_string)
dv = int(matches.group(1))
matches = SOAK_RE.match(args['SOAK_STRING'])
if matches is None:
raise InputError(args['SOAK_STRING'], 'Invalid Soak String')
armor = None if matches.group(1) is None else int(matches.group(1))
soak = int(matches.group(2))
return Attack(test, dv, ap, stun, soak, armor, args['--contact'])
else:
return Attack(test, args['--dv'], parse_ap_string(args['--ap']), args['--stun'], args['--soak'], args['--armor'], args['--contact'])
def main(**args):
if args['--debug']:
stderr.write('Args: {}\n'.format(args))
attack_pool = get_attack_pool(args)
threshold = get_threshold(args)
test = Test(attack_pool, threshold=threshold)
attack = get_attack(test, args)
if args['--once']:
print(attack.damage())
return
simulation = Simulation(attack, iterations=args['--iterations'] or args['ITERATIONS'])
distribution = simulation.distribution()
for damage, total in distribution:
if args['--distribution']:
outcome = Outcome(copy=damage)
if outcome.hits == 1:
outcome.hits = total
if outcome.misses == 1:
outcome.misses = total
print('{} or {:>5.2f}%'.format(outcome, total * 100.0 / simulation.iterations))
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