Last active
September 11, 2016 08:54
-
-
Save cbsmith/34f8356a001646e84c283dd39aa26c8b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"""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