diff --git a/zulip_bots/zulip_bots/bots/rollcake/rollcake.py b/zulip_bots/zulip_bots/bots/rollcake/rollcake.py new file mode 100644 index 0000000..f3e694c --- /dev/null +++ b/zulip_bots/zulip_bots/bots/rollcake/rollcake.py @@ -0,0 +1,170 @@ +import random +import re + +USAGE = '''@ me to roll RPG dice. Examples: + +@**Rollcake** 2d6+2 + +@**Rollcake** pbta -1 adv +*(Powered by the Apocalypse, -1 penalty, roll with advantage: 3d6, add highest 2, subtract 1)* + +@**Rollcake** fitd 2 +*(Forged in the Dark, action rating 2: 2d6, take highest)* + +@**Rollcake** sbr 3 danger +*(Sparked by the Resistance, dice pool 3, dangerous: 3d10, remove highest 2, take highest remaining)* + +@**Rollcake** void 3 +*(Voidheart Symphony city roll, stress gauge 3: 2d6, check if either/both are above 3)* +''' + +PBTA_RE = re.compile(r'(pbta|apoc(alypse)?|void(heart)? castle)\s+(?P[-+]?[0-9])(\s+(?Padv|dis))?') +FITD_RE = re.compile(r'(fitd|forged)\s+(?P[0-9])\b') +SBR_RE = re.compile(r'(sbr|res(istance)?)\s+(?P[0-9])(\s+(?Prisk|dang))?') +VOID_RE = re.compile(r'(void(heart)?( city)?)\s+(?P[0-6])(\s+(?Padv|dis))?') +DICE_RE = re.compile(r'\b(?P[0-9]*)d(?P[0-9]+)\s*(?P[-+][0-9]+)?') + +def roll(count, sides): + return [random.randint(1, sides) for i in range(count)] + +def handle_roll(content): + content = content.lower() + + dice = DICE_RE.search(content) + if dice: + count = int(dice.group('count') or '1') + sides = int(dice.group('sides')) + mod = int(dice.group('mod') or '0') + results = roll(count, sides) + total = sum(results) + mod + return 'Total: **{}**\n(max {}, min {})\nRolls: {}'.format( + total, + max(results), + min(results), + ', '.join(str(n) for n in results), + ) + + pbta = PBTA_RE.search(content) + if pbta: + mod = int(pbta.group('mod') or '0') + adv = pbta.group('adv') + if adv: + results = roll(3, 6) + idx = results.index(min(results) if adv == 'adv' else max(results)) + else: + results = roll(2, 6) + idx = -1 + total = sum(n for (i, n) in enumerate(results) if i != idx) + mod + if total >= 10: + outcome = 'Strong hit' + elif total >= 7: + outcome = 'Weak hit' + else: + outcome = 'Miss' + return '**{}**\n**{}**\nRolls: {}'.format( + outcome, + total, + ', '.join( + ('~~{}~~' if i == idx else '{}').format(n) + for (i, n) in enumerate(results) + ) + ) + + void = VOID_RE.search(content) + if void: + stress = int(void.group('stress')) + adv = void.group('adv') + if adv: + results = roll(3, 6) + idx = results.index(min(results) if adv == 'adv' else max(results)) + else: + results = roll(2, 6) + idx = -1 + count = sum(1 for (i, n) in enumerate(results) if i != idx and n > stress) + return '**{}**\nRolls: {}'.format( + ['Miss', 'Weak hit', 'Strong hit'][count], + ', '.join( + ('~~{}~~' if i == idx else '{}').format(n) + for (i, n) in enumerate(results) + ) + ) + + fitd = FITD_RE.search(content) + if fitd: + rating = int(fitd.group('rating')) + if rating == 0: + results = roll(2, 6) + idx = results.index(max(results)) + value = min(results) + crit = False + else: + results = roll(rating, 6) + idx = -1 + value = max(results) + crit = results.count(6) >= 2 + if crit: + outcome = 'Crit' + elif value == 6: + outcome = 'Full success' + elif value >= 4: + outcome = 'Mixed success' + else: + outcome = 'Failure' + return '**{}**\n**{}**\nRolls: {}'.format( + outcome, + "6, 6" if crit else value, + ', '.join( + ('~~{}~~' if i == idx else '{}').format(n) + for (i, n) in enumerate(results) + ) + ) + + sbr = SBR_RE.search(content) + if sbr: + pool = int(sbr.group('pool')) + risk = sbr.group('risk') + remove = 2 if risk == 'dang' else (1 if risk == 'risk' else 0) + if remove >= pool: + result = roll(1, 10)[0] + return '**{}**\n{}'.format( + 'Success at a cost' if result == 10 else 'Failure', + result, + ) + results = roll(pool, 10) + indices = list(range(pool)) + indices.sort(key=lambda i: results[i], reverse=True) + indices = indices[:remove] + best = max(n for (i, n) in enumerate(results) if i not in indices) + if best == 10: + outcome = 'Critical success' + elif best >= 8: + outcome = 'Success' + elif best >= 6: + outcome = 'Success at a cost' + elif best >= 2: + outcome = 'Failure' + else: + outcome = 'Critical failure' + return '**{}**\n**{}**\nRolls: {}'.format( + outcome, + best, + ', '.join( + ('~~{}~~' if i in indices else '{}').format(n) + for (i, n) in enumerate(results) + ) + ) + + return USAGE + +class RollcakeHandler(object): + ''' + A Zulip bot to roll RPG dice. + ''' + + def usage(self): + return USAGE + + def handle_message(self, message, bot_handler): + bot_handler.send_reply(message, handle_roll(message['content'])) + +handler_class = RollcakeHandler