diff --git a/funcs/roll/dice.py b/funcs/roll/dice.py new file mode 100644 index 0000000..548956c --- /dev/null +++ b/funcs/roll/dice.py @@ -0,0 +1,545 @@ +import logging +import random +import re +import traceback +from heapq import nlargest, nsmallest +from math import floor +from re import IGNORECASE + +import numexpr + +import errors + +log = logging.getLogger(__name__) + +VALID_OPERATORS = 'k|rr|ro|mi|ma|ra|e|p' +VALID_OPERATORS_ARRAY = VALID_OPERATORS.split('|') +VALID_OPERATORS_2 = re.compile('|'.join(["({})".format(i) for i in VALID_OPERATORS_ARRAY])) +DICE_PATTERN = re.compile( + r'^\s*(?:(?:(\d*d\d+)(?:(?:' + VALID_OPERATORS + r')(?:[lh<>]?\d+))*|(\d+)|([-+*/().=])?)\s*(\[.*\])?)(.*?)\s*$', + IGNORECASE) + + +def list_get(index, default, l): + try: + a = l[index] + except IndexError: + a = default + return a + + +def roll(rollStr, adv: int = 0, rollFor='', inline=False, double=False, show_blurbs=True, **kwargs): + roller = Roll() + result = roller.roll(rollStr, adv, rollFor, inline, double, show_blurbs, **kwargs) + return result + + +def get_roll_comment(rollStr): + """Returns: A two-tuple (dice without comment, comment)""" + try: + comment = '' + no_comment = '' + dice_set = re.split('([-+*/().=])', rollStr) + dice_set = [d for d in dice_set if not d in (None, '')] + log.debug("Found dice set: " + str(dice_set)) + for index, dice in enumerate(dice_set): + match = DICE_PATTERN.match(dice) + log.debug("Found dice group: " + str(match.groups())) + no_comment += dice.replace(match.group(5), '') + if match.group(5): + comment = match.group(5) + ''.join(dice_set[index + 1:]) + break + + return no_comment, comment + except: + pass + return rollStr, '' + + +class Roll(object): + def __init__(self, parts=None): + if parts is None: + parts = [] + self.parts = parts + + def get_crit(self): + """Returns: 0 for no crit, 1 for 20, 2 for 1.""" + try: + crit = next(p.get_crit() for p in self.parts if isinstance(p, SingleDiceGroup)) + except StopIteration: + crit = 0 + return crit + + def get_total(self): + """Returns: int""" + return numexpr.evaluate(''.join(p.get_eval() for p in self.parts if not isinstance(p, Comment))) + + # # Dice Roller + def roll(self, rollStr, adv: int = 0, rollFor='', inline=False, double=False, show_blurbs=True, **kwargs): + try: + if '**' in rollStr: + raise errors.InvalidArgument("Exponents are currently disabled.") + self.parts = [] + # split roll string into XdYoptsSel [comment] or Op + # set remainder to comment + # parse each, returning a SingleDiceResult + dice_set = re.split('([-+*/().=])', rollStr) + dice_set = [d for d in dice_set if not d in (None, '')] + log.debug("Found dice set: " + str(dice_set)) + for index, dice in enumerate(dice_set): + match = DICE_PATTERN.match(dice) + log.debug("Found dice group: " + str(match.groups())) + # check if it's dice + if match.group(1): + roll = self.roll_one(dice.replace(match.group(5), ''), adv) + self.parts.append(roll) + # or a constant + elif match.group(2): + self.parts.append(Constant(value=int(match.group(2)), annotation=match.group(4))) + # or an operator + elif not match.group(5): + self.parts.append(Operator(op=match.group(3), annotation=match.group(4))) + + if match.group(5): + self.parts.append(Comment(match.group(5) + ''.join(dice_set[index + 1:]))) + break + + # calculate total + crit = self.get_crit() + try: + total = self.get_total() + except SyntaxError: + raise errors.InvalidArgument("No dice found to roll.") + rolled = ' '.join(str(res) for res in self.parts if not isinstance(res, Comment)) + if rollFor is '': + rollFor = ''.join(str(c) for c in self.parts if isinstance(c, Comment)) + # return final solution + if not inline: + # Builds end result while showing rolls + reply = ' '.join( + str(res) for res in self.parts if not isinstance(res, Comment)) + '\n**Total:** ' + str( + floor(total)) + skeletonReply = reply + rollFor = rollFor if rollFor is not '' else 'Result' + reply = '**{}:** '.format(rollFor) + reply + if show_blurbs: + if adv == 1: + reply += '\n**Rolled with Advantage**' + elif adv == -1: + reply += '\n**Rolled with Disadvantage**' + if crit == 1: + critStr = "\n_**Critical Hit!**_ " + reply += critStr + elif crit == 2: + critStr = "\n_**Critical Fail!**_ " + reply += critStr + else: + # Builds end result while showing rolls + reply = ' '.join(str(res) for res in self.parts if not isinstance(res, Comment)) + ' = `' + str( + floor(total)) + '`' + skeletonReply = reply + rollFor = rollFor if rollFor is not '' else 'Result' + reply = '**{}:** '.format(rollFor) + reply + if show_blurbs: + if adv == 1: + reply += '\n**Rolled with Advantage**' + elif adv == -1: + reply += '\n**Rolled with Disadvantage**' + if crit == 1: + critStr = "\n_**Critical Hit!**_ " + reply += critStr + elif crit == 2: + critStr = "\n_**Critical Fail!**_ " + reply += critStr + reply = re.sub(' +', ' ', reply) + skeletonReply = re.sub(' +', ' ', str(skeletonReply)) + return DiceResult(result=int(floor(total)), verbose_result=reply, crit=crit, rolled=rolled, + skeleton=skeletonReply, raw_dice=self) + except Exception as ex: + if not isinstance(ex, (SyntaxError, KeyError, errors.AvraeException)): + log.error('Error in roll() caused by roll {}:'.format(rollStr)) + traceback.print_exc() + return DiceResult(verbose_result="Invalid input: {}".format(ex)) + + def roll_one(self, dice, adv: int = 0): + result = SingleDiceGroup() + result.rolled = [] + # splits dice and comments + split = re.match(r'^([^\[\]]*?)\s*(\[.*\])?\s*$', dice) + dice = split.group(1).strip() + annotation = split.group(2) + result.annotation = annotation if annotation is not None else '' + # Recognizes dice + obj = re.findall('\d+', dice) + obj = [int(x) for x in obj] + numArgs = len(obj) + + ops = [] + if numArgs == 1: + if not dice.startswith('d'): + raise errors.InvalidArgument('Please pass in the value of the dice.') + numDice = 1 + diceVal = obj[0] + if adv is not 0 and diceVal == 20: + numDice = 2 + ops = ['k', 'h1'] if adv is 1 else ['k', 'l1'] + elif numArgs == 2: + numDice = obj[0] + diceVal = obj[-1] + if adv is not 0 and diceVal == 20: + ops = ['k', 'h' + str(numDice)] if adv is 1 else ['k', 'l' + str(numDice)] + numDice = numDice * 2 + else: # split into xdy and operators + numDice = obj[0] + diceVal = obj[1] + dice = re.split('(\d+d\d+)', dice)[-1] + ops = VALID_OPERATORS_2.split(dice) + ops = [a for a in ops if a is not None] + + # dice repair/modification + if numDice > 300 or diceVal < 1: + raise errors.InvalidArgument('Too many dice rolled.') + + result.max_value = diceVal + result.num_dice = numDice + result.operators = ops + + for _ in range(numDice): + try: + tempdice = SingleDice() + tempdice.value = random.randint(1, diceVal) + tempdice.rolls = [tempdice.value] + tempdice.max_value = diceVal + tempdice.kept = True + result.rolled.append(tempdice) + except: + result.rolled.append(SingleDice()) + + if ops is not None: + + rerollList = [] + reroll_once = [] + keep = None + to_explode = [] + to_reroll_add = [] + + valid_operators = VALID_OPERATORS_ARRAY + last_operator = None + for index, op in enumerate(ops): + if last_operator is not None and op in valid_operators and not op == last_operator: + result.reroll(reroll_once, 1) + reroll_once = [] + result.reroll(rerollList, greedy=True) + rerollList = [] + result.keep(keep) + keep = None + result.reroll(to_reroll_add, 1, keep_rerolled=True, unique=True) + to_reroll_add = [] + result.reroll(to_explode, greedy=True, keep_rerolled=True) + to_explode = [] + if op == 'rr': + rerollList += parse_selectors([list_get(index + 1, 0, ops)], result, greedy=True) + if op == 'k': + keep = [] if keep is None else keep + keep += parse_selectors([list_get(index + 1, 0, ops)], result) + if op == 'p': + keep = [] if keep is None else keep + keep += parse_selectors([list_get(index + 1, 0, ops)], result, inverse=True) + if op == 'ro': + reroll_once += parse_selectors([list_get(index + 1, 0, ops)], result) + if op == 'mi': + _min = list_get(index + 1, 0, ops) + for r in result.rolled: + if r.value < int(_min): + r.update(int(_min)) + if op == 'ma': + _max = list_get(index + 1, 0, ops) + for r in result.rolled: + if r.value > int(_max): + r.update(int(_max)) + if op == 'ra': + to_reroll_add += parse_selectors([list_get(index + 1, 0, ops)], result) + if op == 'e': + to_explode += parse_selectors([list_get(index + 1, 0, ops)], result, greedy=True) + if op in valid_operators: + last_operator = op + result.reroll(reroll_once, 1) + result.reroll(rerollList, greedy=True) + result.keep(keep) + result.reroll(to_reroll_add, 1, keep_rerolled=True, unique=True) + result.reroll(to_explode, greedy=True, keep_rerolled=True) + + return result + + +class Part: + """Class to hold one part of the roll string.""" + pass + + +class SingleDiceGroup(Part): + def __init__(self, num_dice: int = 0, max_value: int = 0, rolled=None, annotation: str = "", result: str = "", + operators=None): + if operators is None: + operators = [] + if rolled is None: + rolled = [] + self.num_dice = num_dice + self.max_value = max_value + self.rolled = rolled # list of SingleDice + self.annotation = annotation + self.result = result + self.operators = operators + + def keep(self, rolls_to_keep): + if rolls_to_keep is None: return + for _roll in self.rolled: + if not _roll.value in rolls_to_keep: + _roll.kept = False + elif _roll.kept: + rolls_to_keep.remove(_roll.value) + + def reroll(self, rerollList, max_iterations=1000, greedy=False, keep_rerolled=False, unique=False): + if not rerollList: return # don't reroll nothing - minor optimization + if unique: + rerollList = list(set(rerollList)) # remove duplicates + if len(rerollList) > 100: + raise OverflowError("Too many dice to reroll (max 100)") + last_index = 0 + count = 0 + should_continue = True + while should_continue: # let's only iterate 250 times for sanity + should_continue = False + if any(d.value in set(rerollList) for d in self.rolled[last_index:] if d.kept and not d.exploded): + should_continue = True + to_extend = [] + for r in self.rolled[last_index:]: # no need to recheck everything + count += 1 + if count > max_iterations: + should_continue = False + if r.value in rerollList and r.kept and not r.exploded: + try: + tempdice = SingleDice() + tempdice.value = random.randint(1, self.max_value) + tempdice.rolls = [tempdice.value] + tempdice.max_value = self.max_value + tempdice.kept = True + to_extend.append(tempdice) + if not keep_rerolled: + r.drop() + else: + r.explode() + except: + to_extend.append(SingleDice()) + if not keep_rerolled: + r.drop() + else: + r.explode() + if not greedy: + rerollList.remove(r.value) + last_index = len(self.rolled) + self.rolled.extend(to_extend) + + def get_total(self): + """Returns: + int - The total value of the dice.""" + return sum(r.value for r in self.rolled if r.kept) + + def get_eval(self): + return str(self.get_total()) + + def get_num_kept(self): + return sum(1 for r in self.rolled if r.kept) + + def get_crit(self): + """Returns: + int - 0 for no crit, 1 for crit, 2 for crit fail.""" + if self.get_num_kept() == 1 and self.max_value == 20: + if self.get_total() == 20: + return 1 + elif self.get_total() == 1: + return 2 + return 0 + + def __str__(self): + return "{0.num_dice}d{0.max_value}{1} ({2}) {0.annotation}".format( + self, ''.join(self.operators), ', '.join(str(r) for r in self.rolled)) + + def to_dict(self): + return {'type': 'dice', 'dice': [d.to_dict() for d in self.rolled], 'annotation': self.annotation, + 'value': self.get_total(), 'is_crit': self.get_crit(), 'num_kept': self.get_num_kept(), + 'text': str(self), 'num_dice': self.num_dice, 'dice_size': self.max_value, 'operators': self.operators} + + +class SingleDice: + def __init__(self, value: int = 0, max_value: int = 0, kept: bool = True, exploded: bool = False): + self.value = value + self.max_value = max_value + self.kept = kept + self.rolls = [value] # list of ints (for X -> Y -> Z) + self.exploded = exploded + + def drop(self): + self.kept = False + + def explode(self): + self.exploded = True + + def update(self, new_value): + self.value = new_value + self.rolls.append(new_value) + + def __str__(self): + formatted_rolls = [str(r) for r in self.rolls] + if int(formatted_rolls[-1]) == self.max_value or int(formatted_rolls[-1]) == 1: + formatted_rolls[-1] = '**' + formatted_rolls[-1] + '**' + if self.exploded: + formatted_rolls[-1] = '__' + formatted_rolls[-1] + '__' + if self.kept: + return ' -> '.join(formatted_rolls) + else: + return '~~' + ' -> '.join(formatted_rolls) + '~~' + + def __repr__(self): + return "".format( + self) + + def to_dict(self): + return {'type': 'single_dice', 'value': self.value, 'size': self.max_value, 'is_kept': self.kept, + 'rolls': self.rolls, 'exploded': self.exploded} + + +class Constant(Part): + def __init__(self, value: int = 0, annotation: str = ""): + self.value = value + self.annotation = annotation if annotation is not None else '' + + def __str__(self): + return "{0.value} {0.annotation}".format(self) + + def get_eval(self): + return str(self.value) + + def to_dict(self): + return {'type': 'constant', 'value': self.value, 'annotation': self.annotation} + + +class Operator(Part): + def __init__(self, op: str = "+", annotation: str = ""): + self.op = op if op is not None else '' + self.annotation = annotation if annotation is not None else '' + + def __str__(self): + return "{0.op} {0.annotation}".format(self) + + def get_eval(self): + return self.op + + def to_dict(self): + return {'type': 'operator', 'value': self.op, 'annotation': self.annotation} + + +class Comment(Part): + def __init__(self, comment: str = ""): + self.comment = comment + + def __str__(self): + return self.comment.strip() + + def to_dict(self): + return {'type': 'comment', 'value': self.comment} + + +def parse_selectors(opts, res, greedy=False, inverse=False): + """Returns a list of ints.""" + for o in range(len(opts)): + if opts[o][0] is 'h': + opts[o] = nlargest(int(opts[o].split('h')[1]), (d.value for d in res.rolled if d.kept)) + elif opts[o][0] is 'l': + opts[o] = nsmallest(int(opts[o].split('l')[1]), (d.value for d in res.rolled if d.kept)) + elif opts[o][0] is '>': + if greedy: + opts[o] = list(range(int(opts[o].split('>')[1]) + 1, res.max_value + 1)) + else: + opts[o] = [d.value for d in res.rolled if d.value > int(opts[o].split('>')[1])] + elif opts[o][0] is '<': + if greedy: + opts[o] = list(range(1, int(opts[o].split('<')[1]))) + else: + opts[o] = [d.value for d in res.rolled if d.value < int(opts[o].split('<')[1])] + out = [] + for o in opts: + if isinstance(o, list): + out.extend(int(l) for l in o) + elif not greedy: + out.extend(int(o) for a in res.rolled if a.value is int(o) and a.kept) + else: + out.append(int(o)) + + if not inverse: + return out + + inverse_out = [] + for rolled in res.rolled: + if rolled.kept and rolled.value in out: + out.remove(rolled.value) + elif rolled.kept: + inverse_out.append(rolled.value) + return inverse_out + + +class DiceResult: + """Class to hold the output of a dice roll.""" + + def __init__(self, result: int = 0, verbose_result: str = '', crit: int = 0, rolled: str = '', skeleton: str = '', + raw_dice: Roll = None): + self.plain = result + self.total = result + self.result = verbose_result + self.crit = crit + self.rolled = rolled + self.skeleton = skeleton if skeleton is not '' else verbose_result + self.raw_dice = raw_dice # Roll + + def __str__(self): + return self.result + + def __repr__(self): + return ''.format(self.total) + + def consolidated(self): + """Gets the most simplified version of the roll string.""" + if self.raw_dice is None: + return "0" + parts = [] # list of (part, annotation) + last_part = "" + for p in self.raw_dice.parts: + if isinstance(p, SingleDiceGroup): + last_part += str(p.get_total()) + else: + last_part += str(p) + if not isinstance(p, Comment) and p.annotation: + parts.append((last_part, p.annotation)) + last_part = "" + if last_part: + parts.append((last_part, "")) + + to_roll = "" + last_annotation = "" + out = "" + for numbers, annotation in parts: + if annotation and annotation != last_annotation and to_roll: + out += f"{roll(to_roll).total:+} {last_annotation}" + to_roll = "" + if annotation: + last_annotation = annotation + to_roll += numbers + if to_roll: + out += f"{roll(to_roll).total:+} {last_annotation}" + out = out.strip('+ ') + return out + + +if __name__ == '__main__': + while True: + print(roll(input().strip())) diff --git a/funcs/roll/errors.py b/funcs/roll/errors.py new file mode 100644 index 0000000..3c7d488 --- /dev/null +++ b/funcs/roll/errors.py @@ -0,0 +1,162 @@ +class AvraeException(Exception): + """A base exception class.""" + + def __init__(self, msg): + super().__init__(msg) + + +class NoCharacter(AvraeException): + """Raised when a user has no active character.""" + + def __init__(self): + super().__init__("You have no character active.") + + +class NoActiveBrew(AvraeException): + """Raised when a user has no active homebrew of a certain type.""" + + def __init__(self): + super().__init__("You have no homebrew of this type active.") + + +class ExternalImportError(AvraeException): + """Raised when something fails to import.""" + + def __init__(self, msg): + super().__init__(msg) + + +class InvalidArgument(AvraeException): + """Raised when an argument is invalid.""" + pass + + +class EvaluationError(AvraeException): + """Raised when a cvar evaluation causes an error.""" + + def __init__(self, original): + super().__init__(f"Error evaluating expression: {original}") + self.original = original + + +class FunctionRequiresCharacter(AvraeException): + """ + Raised when a function that requires a character is called without one. + """ + + def __init__(self, msg=None): + super().__init__(msg or "This alias requires an active character.") + + +class OutdatedSheet(AvraeException): + """Raised when a feature is used that requires an updated sheet.""" + + def __init__(self, msg=None): + super().__init__(msg or "This command requires an updated character sheet. Try running `!update`.") + + +class NoSpellDC(AvraeException): + def __init__(self): + super().__init__("No spell save DC found.") + + +class NoSpellAB(AvraeException): + def __init__(self): + super().__init__("No spell attack bonus found.") + + +class InvalidSaveType(AvraeException): + def __init__(self): + super().__init__("Invalid save type.") + + +class ConsumableException(AvraeException): + """A base exception for consumable exceptions to stem from.""" + pass + + +class ConsumableNotFound(ConsumableException): + """Raised when a consumable is not found.""" + + def __init__(self): + super().__init__("The requested counter does not exist.") + + +class CounterOutOfBounds(ConsumableException): + """Raised when a counter is set to a value out of bounds.""" + + def __init__(self): + super().__init__("The new value is out of bounds.") + + +class NoReset(ConsumableException): + """Raised when a consumable without a reset is reset.""" + + def __init__(self): + super().__init__("The counter does not have a reset value.") + + +class InvalidSpellLevel(ConsumableException): + """Raised when a spell level is invalid.""" + + def __init__(self): + super().__init__("The spell level is invalid.") + + +class SelectionException(AvraeException): + """A base exception for message awaiting exceptions to stem from.""" + pass + + +class NoSelectionElements(SelectionException): + """Raised when get_selection() is called with no choices.""" + + def __init__(self, msg=None): + super().__init__(msg or "There are no choices to select from.") + + +class SelectionCancelled(SelectionException): + """Raised when get_selection() is cancelled or times out.""" + + def __init__(self): + super().__init__("Selection timed out or was cancelled.") + + +class CombatException(AvraeException): + """A base exception for combat-related exceptions to stem from.""" + pass + + +class CombatNotFound(CombatException): + """Raised when a channel is not in combat.""" + + def __init__(self): + super().__init__("This channel is not in combat.") + + +class RequiresContext(CombatException): + """Raised when a combat is committed without context.""" + + def __init__(self): + super().__init__("Combat not contextualized.") + + +class ChannelInCombat(CombatException): + """Raised when a combat is started with an already active combat.""" + + def __init__(self): + super().__init__("Channel already in combat.") + + +class CombatChannelNotFound(CombatException): + """Raised when a combat's channel is not in the channel list.""" + + def __init__(self): + super().__init__("Combat channel does not exist.") + + +class NoCombatants(CombatException): + """Raised when a combat tries to advance turn with no combatants.""" + + def __init__(self): + super().__init__("There are no combatants.")