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 (-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 | |
""" | |
from functools import total_ordering | |
from itertools import groupby | |
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 | |
@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 | |
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 0 | |
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 | |
def main(**args): | |
if args['--debug']: | |
stderr.write('Args: {}\n'.format(args)) | |
attack_pool = Pool(args['ATTACK_POOL'], args['--limit']) | |
threshold = None | |
if args['--threshold']: | |
threshold = FixedThreshold(args['--threshold']) | |
if args['--opposed_pool']: | |
threshold = Pool(args['--opposed_pool'], args['--opposed_limit']) | |
test = Test(attack_pool, threshold=threshold) | |
attack = Attack(test, args['--dv'], args['--ap'], args['--stun'], args['--soak'], args['--armor'], args['--contact']) | |
if args['--once']: | |
print(attack.damage()) | |
return | |
simulation = Simulation(attack, iterations=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