umi/cogs/random.py
a 6a7ec8eef7 support more dice notation
squashed commits:
- fix unpacking regex matches for dice notation
- fix missing cast to integer
- fix raw result output
- fix formatting of chat message for raw result
- faster dockerfile builds?
- fancier output format
- fix variable name collision
- remove unnecessary newline
- more robust handling of dF
- fix typo
- fix formatting
- use blank character for dF
2023-01-17 10:47:18 -06:00

273 lines
No EOL
9.7 KiB
Python

import discord
from discord.ext.commands import Cog, command, Context
from discord.commands import slash_command, Option
import httpx
from decouple import config
from collections import Counter
import re
from typing import Iterator
GUILD = config("DISCORD_GUILD_ID", cast=int)
def setup(bot: discord.Bot):
bot.add_cog(Random(bot))
class Random(Cog):
"""Use random.org to provide truly random results"""
def __init__(self, bot: discord.Bot):
self.bot: discord.Bot = bot
print("Initialized Random cog")
# ==============================================================================
@staticmethod
def roll(
number: int = 1,
faces: int = 6,
) -> str:
"""
Roll standard dice using random.org integer generation
Params:
int number How many dice to roll
int faces How many faces on a standard die
"""
columns = number if number < 11 else 10
return httpx.get(f'https://www.random.org/integers/?num={number}&min=1&max={faces}&col={columns}&base=10&format=plain&rnd=new').text
@staticmethod
def parse_dice_notation(input: str) -> Iterator[re.Match[str]]:
"""
Parse dice notation to get numbers, faces, modifiers, and tokens.
https://en.wikipedia.org/wiki/Dice_notation
Dice notation can take the form of AdX+B (chainable):
A how many dice to roll (if not provided, roll once)
dX die with X faces (1-X)
+ add the result to the previous result
- subtract the result from the previous result
B constant modifier
Extensions implemented:
d(a,b,...) custom die with faces defined by a sequence
d% special die with 00-99
dF fudge dice (two +, two -, two blanks)
dF.1 fudge dice variant (one +, one -, four blanks)
dF.2 alias for dF
Unimplemented:
-L subtract the lowest
-H subtract the highest
klL keep lowest L values
khH keep highest H values
xC multiply the result by C
Cx(AdX+B) multiply the dice group by C (including modifier)
/C divide by C?
Params:
str input some dice notation to parse
Returns:
tuple(plus_minus_dice, number, faces, plus_minus_mod, modifier)
str plus_minus_dice a token for adding or removing from total
str number the number of dice to roll
str faces the faces on the dice (sequence or number)
---
str plus_minus_mod a token for adding or subtracting a modifier
str modifier some number to add/subtract from total
"""
pattern = re.compile(
r'''
# dice group
(\A|\+|\-) # start-of-string or plus or minus dice result (group 1)
(\d+)? # number of dice (group 2)
d # the literal 'd'
( # faces (group 3)
\d+ # either plain number
|\(.+?\) # or sequence of strings
|\[\d+:\d+(?:\d+)?\] # or range of numbers as start:end:step
|% # or literal '%' (for 00-99)
|F(?:\.\d+?)? # or literal 'F' (for fudge dice ++--__)
)
# OR
|
# number modifier
(\+|\-) # plus or minus (group 4)
(\d+)? # some number value (group 5)
''',
re.IGNORECASE | re.VERBOSE
)
# returns (plus_minus_dice, number, faces, plus_minus_mod, modifier)
return pattern.finditer(input)
@staticmethod
def roll_result(roll: str) -> str:
"""Calculates the result of a roll and output a summary"""
matches = Random.parse_dice_notation(roll)
total = 0
roll_results = []
for match in matches:
plus_minus_dice, number, faces, plus_minus_mod, modifier = match.groups()
if faces and faces[0] == '(': # non-standard dice sequence (a,b,c,...).
number = int(number) if number else 1 # number may be omitted for single die.
outcomes = faces[1:-1].split(',') # remove parens and get list of faces,
result = Random.roll(number, len(outcomes)) # then roll for an index in that list
result = [outcomes[int(r)-1] for r in result.split()] # that we can then lookup.
try: # we don't actually know if the faces are numeric...
subtotal = sum(map(int, result))
if plus_minus_dice == '-':
total -= subtotal
else:
total += subtotal
except (ValueError, TypeError): # in case faces were not all numeric (for this group or a previous group),
total = None # there is no total.
result = ' '.join(result) # finally, repack the result
roll_results.append( (f'{number}d{faces}', result) ) # and append it to output
elif faces and faces == '%': # special 00-99
number = int(number) if number else 1 # number may be omitted for single die.
result = Random.roll(number, 100) # we roll a d100,
result = re.sub('100', '00', result) # but we sub in 00
result = re.sub(r'\b(\d){1}\b', r'0\1', result) # and zero pad single digits.
try: # this may be part of a larger group of rolls...
subtotal = sum(map(int, result.split()))
if plus_minus_dice == '-':
total -= int(subtotal) # this may fail because total is None
else:
total += int(subtotal) # this may fail because total is None
except (ValueError, TypeError):
total = None
roll_results.append( (f'{number}d{faces}', result) ) # and append it to output
elif faces and faces[0] == 'F': # fudge dice
number = int(number) if number else 1 # number may be omitted for single die.
if faces == 'F' or faces == 'F.2':
outcomes = '(+,+,-,-,␢,␢)'
elif faces == 'F.1':
outcomes = '(+,+,-,-,␢,␢)'
outcomes = outcomes[1:-1].split(',') # remove parens and get list of faces,
result = Random.roll(number, len(outcomes)) # then roll for an index in that list
result = [outcomes[int(r)-1] for r in result.split()] # that we can then lookup.
try: # we don't actually know if the faces are numeric...
subtotal = sum(map(int, result))
if plus_minus_dice == '-':
total -= subtotal
else:
total += subtotal
except (ValueError, TypeError): # in case faces were not all numeric (for this group or a previous group),
total = None # there is no total.
result = ' '.join(result) # finally, repack the result
roll_results.append( (f'{number}d{faces}', f'{result}\n') ) # and append it to output
elif faces: # standard dice
number = int(number) if number else 1 # number may be omitted for single die.
result = Random.roll(number, faces)
try: # this may be part of a larger group of rolls...
subtotal = sum(map(int, result.split()))
if plus_minus_dice == '-':
total -= subtotal # this may fail because total is None
else:
total += subtotal # this may fail because total is None
except (ValueError, TypeError):
total = None
roll_results.append( (f'{number}d{faces}', result) ) # and append it to output
elif modifier: # no dice
try: # this may be part of a larger group of rolls...
if plus_minus_mod == '-':
total -= int(modifier) # this may fail because total is None
elif plus_minus_mod == '+':
total += int(modifier) # this may fail because total is None
except (ValueError, TypeError):
total = None
raw_result_list = []
for raw_roll in roll_results:
raw_result_list.append(f'=== {raw_roll[0]} ===\n**{raw_roll[1]}**')
raw = ''.join(raw_result_list)
output = f"*You rolled {roll} and got **{total}**.*\n\nRaw result:\n{raw}"
if total is None:
output = f"*You rolled {roll} and got:*\n\n{raw}"
return output
@staticmethod
def rolls_results(rolls: str) -> str:
"""Returns a formatted result of dice outcomes"""
results = [Random.roll_result(roll.strip()) for roll in rolls.split(';')]
return '\n'.join(results)
@command(
name='roll',
aliases=['dice']
)
async def roll_prefix(self, ctx: Context, rolls: str = '1d6'):
"""Roll some dice (default 1d6)"""
return await ctx.send(Random.rolls_results(rolls))
@slash_command(
name='roll',
guild_ids=[GUILD]
)
async def roll_slash(self,
ctx: discord.ApplicationContext,
faces: Option(int, "How many faces are on each die?", min_value=1, max_value=1_000_000_000, default=6),
number: Option(int, "How many dies should be rolled?", min_value=1, max_value=10_000, default=1),
modifier: Option(int, "Add this number to the final result", default=0),
):
"""Roll some standard dice with an optional modifier (default 1d6 with +0)"""
if modifier and modifier > 0:
modifier = f'+{modifier}'
return await ctx.respond(
Random.rolls_results(f'{number}d{faces}{modifier}')
)
# ==============================================================================
@staticmethod
def flip(number: int = 1) -> str:
"""Returns a raw list of Heads or Tails outcomes"""
result: str = httpx.get(
f'https://www.random.org/integers/?num={number}&min=1&max=2&col=1&base=10&format=plain&rnd=new'
).text
result = result.replace('1', 'Heads')
result = result.replace('2', 'Tails')
return result
@staticmethod
def flip_results(number: int = 1) -> str:
"""Returns a formatted result of Heads and Tails counts"""
result: str = Random.flip(number)
if len(result) == 6: # \n counts as a character and we don't strip it
return (
f"*You flipped {number} coins and got:*\n"
f"**{result}**"
)
c = Counter(result.split('\n'))
return (
f"*You flipped {number} coins and got:*\n"
f"**{c['Heads']}** Heads\n"
f"**{c['Tails']}** Tails"
)
@command(
name='flip',
aliases=['coin']
)
async def flip_prefix(self, ctx: Context, number: str = 1):
"""Flip a coin (default 1)"""
return await ctx.send(self.flip_results(number))
@slash_command(
name='flip',
guild_ids=[GUILD]
)
async def flip_slash(self,
ctx: discord.ApplicationContext,
number: Option(int, "How many coins should be flipped?", min_value=1, max_value=10_000, default=1)
):
"""Flip a coin (default 1)"""
return await ctx.respond(self.flip_results(number))