diff --git a/Dockerfile b/Dockerfile index 9ffaaa8..197b01a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,8 @@ FROM archlinux:latest WORKDIR /umi -COPY . . +COPY "requirements.txt" . RUN pacman -Syu --noconfirm RUN pacman -S --noconfirm python-pip libffi libsodium ffmpeg RUN pip install -r requirements.txt +COPY . . CMD ["python", "-u", "app.py"] \ No newline at end of file diff --git a/cogs/random.py b/cogs/random.py index 3324297..854e62a 100644 --- a/cogs/random.py +++ b/cogs/random.py @@ -5,6 +5,7 @@ import httpx from decouple import config from collections import Counter import re +from typing import Iterator GUILD = config("DISCORD_GUILD_ID", cast=int) @@ -20,31 +21,193 @@ class Random(Cog): # ============================================================================== @staticmethod - def roll(faces: int = 6, number: int = 1) -> str: - """Roll die(s) using random.org integer generation""" + 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 roll_results(faces: int = 6, number: int = 1) -> str: + 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""" - result = Random.roll(faces=int(faces), number=int(number)) - if faces == 100: # 00 - 99 - result = re.sub('100', '00', result) - result = re.sub(r'\b(\d){1}\b', r'0\1', result) # zero pad single digit - return f"*You rolled {number}d{faces} and got:*\n**{result}**" + 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, query: str = '1d6'): + async def roll_prefix(self, ctx: Context, rolls: str = '1d6'): """Roll some dice (default 1d6)""" - number, faces = query.split('d') - if not number: # handle raw dN rolls, e.g. 'd6' or 'd20' - number = 1 - return await ctx.send(Random.roll_results(faces=int(faces), number=int(number))) - + return await ctx.send(Random.rolls_results(rolls)) + @slash_command( name='roll', guild_ids=[GUILD] @@ -52,10 +215,15 @@ class Random(Cog): 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) + 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 dice (default 1d6)""" - return await ctx.respond(Random.roll_results(faces=faces, number=number)) + """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}') + ) # ============================================================================== diff --git a/pdm.lock b/pdm.lock index 8659c86..c285bbf 100644 --- a/pdm.lock +++ b/pdm.lock @@ -284,8 +284,8 @@ dependencies = [ ] [metadata] -lock_version = "4.0" -content_hash = "sha256:889439b64bada10d9afdf954f0b552fb3a224b91dfac0b20c3531ea0fdf2c69d" +lock_version = "4.1" +content_hash = "sha256:3a6be9c0370a67e849f5f92299e3bc8d4bb2f81a009c80ba91f75a2978b75677" [metadata.files] "aiodns 3.0.0" = [ @@ -958,7 +958,9 @@ content_hash = "sha256:889439b64bada10d9afdf954f0b552fb3a224b91dfac0b20c3531ea0f {url = "https://files.pythonhosted.org/packages/85/c9/d465977afb008f0721e63fdf9a0e8e37bf6f188dc3c787b6ec9568621a48/pycryptodomex-3.15.0-cp35-abi3-win32.whl", hash = "sha256:46b3f05f2f7ac7841053da4e0f69616929ca3c42f238c405f6c3df7759ad2780"}, {url = "https://files.pythonhosted.org/packages/89/71/bd68f1c0654ee9c82a841feb41b8e05328ff4c154eeae98508e51d163256/pycryptodomex-3.15.0-cp35-abi3-manylinux2010_i686.whl", hash = "sha256:65204412d0c6a8e3c41e21e93a5e6054a74fea501afa03046a388cf042e3377a"}, {url = "https://files.pythonhosted.org/packages/89/d2/1666f32e1ab5c2716447821800ecb39764d00453fddb23d45fee4d422bde/pycryptodomex-3.15.0-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:996e1ba717077ce1e6d4849af7a1426f38b07b3d173b879e27d5e26d2e958beb"}, + {url = "https://files.pythonhosted.org/packages/8a/c8/2fa65115759682e9505752e188a038d5cb4ea2f88ff4dba85ca0bfcc21f3/pycryptodomex-3.15.0-cp35-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:04a5d6a17560e987272fc1763e9772a87689a08427b8cbdebe3ca7cba95d6156"}, {url = "https://files.pythonhosted.org/packages/92/97/fafba850c72cffb4e794b827d196a01b1b7ba332686354cc5e01527e1bf1/pycryptodomex-3.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:3709f13ca3852b0b07fc04a2c03b379189232b24007c466be0f605dd4723e9d4"}, + {url = "https://files.pythonhosted.org/packages/95/6f/48b103bea4ccc8351cf4a94af86d9c5799aac8de8882d00f8cbb59fcf703/pycryptodomex-3.15.0-cp27-cp27m-musllinux_1_1_aarch64.whl", hash = "sha256:7db44039cc8b449bd08ab338a074e87093bd170f1a1b76d2fcef8a3e2ee11199"}, {url = "https://files.pythonhosted.org/packages/99/72/1f22fc479774170abc6e10690e412155af071dea9bc9ce37f0a55ff241e1/pycryptodomex-3.15.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:f8be976cec59b11f011f790b88aca67b4ea2bd286578d0bd3e31bcd19afcd3e4"}, {url = "https://files.pythonhosted.org/packages/99/b0/e44654a967809bb8c3225e40f4d58686784b6a3ea3aecae7710296a965ef/pycryptodomex-3.15.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:fc9bc7a9b79fe5c750fc81a307052f8daabb709bdaabb0fb18fb136b66b653b5"}, {url = "https://files.pythonhosted.org/packages/a7/b0/e60dd0b215420dff6423614de65852bcba5e17539ae50c45acb4a29eac97/pycryptodomex-3.15.0-cp27-cp27m-manylinux2014_aarch64.whl", hash = "sha256:78d9621cf0ea35abf2d38fa2ca6d0634eab6c991a78373498ab149953787e5e5"}, @@ -969,6 +971,7 @@ content_hash = "sha256:889439b64bada10d9afdf954f0b552fb3a224b91dfac0b20c3531ea0f {url = "https://files.pythonhosted.org/packages/ce/1c/0f93364b744f75770ac4d928413f89a9978ab96cfe6416d84c808e754c40/pycryptodomex-3.15.0-cp35-abi3-manylinux2014_aarch64.whl", hash = "sha256:b9279adc16e4b0f590ceff581f53a80179b02cba9056010d733eb4196134a870"}, {url = "https://files.pythonhosted.org/packages/e5/3f/4e92e56ee0eefaedbb1bc4e85848b3fbbfed34f929a88e39088b2052ce1f/pycryptodomex-3.15.0-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:e2b12968522a0358b8917fc7b28865acac002f02f4c4c6020fcb264d76bfd06d"}, {url = "https://files.pythonhosted.org/packages/e7/c2/24b88c306d9ee909061e1535b355be43693be2e5557321417ca2448a1125/pycryptodomex-3.15.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:191e73bc84a8064ad1874dba0ebadedd7cce4dedee998549518f2c74a003b2e1"}, + {url = "https://files.pythonhosted.org/packages/f7/4f/84cbd54a5a28cdc59df165c6f4cde9ea9847ae0b9055ebcc5a6b35913ba5/pycryptodomex-3.15.0-cp27-cp27mu-musllinux_1_1_aarch64.whl", hash = "sha256:781efd04ea6762bb2ef7d4fa632c9c89895433744b6c345bd0c239d5ab058dfc"}, {url = "https://files.pythonhosted.org/packages/f8/e5/7e21896a03affd83cb9bc5cbf8576f63c5f6f26cccfb4196c874d466b5c7/pycryptodomex-3.15.0-cp27-cp27mu-manylinux2014_aarch64.whl", hash = "sha256:5676a132169a1c1a3712edf25250722ebc8c9102aa9abd814df063ca8362454f"}, ] "pynacl 1.5.0" = [