diff --git a/crimsobot/bot.py b/crimsobot/bot.py index a603518e..37748409 100644 --- a/crimsobot/bot.py +++ b/crimsobot/bot.py @@ -9,8 +9,17 @@ from crimsobot import db from crimsobot.context import CrimsoContext from crimsobot.data.img import CAPTION_RULES, IMAGE_RULES -from crimsobot.exceptions import (BadCaption, LocationNotFound, NoImageFound, NoMatchingTarotCard, - NotDirectMessage, StrictInputFailed, ZoomNotValid) +from crimsobot.exceptions import ( + BadCaption, + LocationNotFound, + NoImageFound, + NoMatchingTarotCard, + NotAnInteger, + NotDirectMessage, + OutOfBounds, + StrictInputFailed, + ZoomNotValid + ) from crimsobot.help_command import PaginatedHelpCommand from crimsobot.models.ban import Ban from crimsobot.utils import markov as m, tools as c @@ -154,6 +163,16 @@ async def on_command_error(self, ctx: commands.Context, error: Exception) -> Non traceback_needed = False msg_to_user = f'Zoom level **{error.original.zoom}** not good!' + if isinstance(error.original, NotAnInteger): + error_type = '**not good with math**' + traceback_needed = False + msg_to_user = f'**{error.original.guess}** is not an integer!' + + if isinstance(error.original, OutOfBounds): + error_type = '**not good with math**' + traceback_needed = False + msg_to_user = f'**{error.original.guess}** is out of bounds!' + if isinstance(error.original, NoImageFound): error_type = '**NO IMAGE**' traceback_needed = False @@ -193,7 +212,7 @@ async def on_command_error(self, ctx: commands.Context, error: Exception) -> Non color_name='orange', footer='bad at computer. bad at computer!', ) - await ctx.send(embed=embed, delete_after=10) + await ctx.send(embed=embed, delete_after=20) except discord.errors.Forbidden: error_type = 'FORBIDDEN' traceback_needed = True diff --git a/crimsobot/cogs/games.py b/crimsobot/cogs/games.py index 4c3587c4..bd8614ce 100644 --- a/crimsobot/cogs/games.py +++ b/crimsobot/cogs/games.py @@ -564,16 +564,12 @@ async def glb_plays(self, ctx: commands.Context, page: int = 1) -> None: await ctx.send(embed=embed) @commands.command(brief='A daily guessing game.') - async def daily(self, ctx: commands.Context, lucky_number: int) -> None: + async def daily(self, ctx: commands.Context, *, lucky_number: str) -> None: """Guess a number 1-100 and get a daily award! Good luck hitting the big jackpot! """ - # exception handling - if not 1 <= lucky_number <= 100: - raise commands.BadArgument('Lucky number is out of bounds.') - # pass to helper and spit out result in an embed embed = await crimsogames.daily(ctx.message.author, lucky_number) diff --git a/crimsobot/data/games/__init__.py b/crimsobot/data/games/__init__.py index 1e65412b..caea9659 100644 --- a/crimsobot/data/games/__init__.py +++ b/crimsobot/data/games/__init__.py @@ -50,5 +50,6 @@ def get_keys(self) -> dict: # these are what should be imported by other scripts STORIES = [MadlibsStory(story) for story in _madlibs_stories] CRINGO_RULES = _ruleset['cringo'] +DAILY_RULES = _ruleset['daily'] EMOJISTORY_RULES = _ruleset['emojistory'] MADLIBS_RULES = _ruleset['madlibs'] diff --git a/crimsobot/data/games/rules.yaml b/crimsobot/data/games/rules.yaml index 8f346513..3a6a47fe 100644 --- a/crimsobot/data/games/rules.yaml +++ b/crimsobot/data/games/rules.yaml @@ -25,6 +25,35 @@ cringo: 2: 7 4: 9 6: 9 +daily: + award: + lose: 10 + win: 500 + not_yet: + - Patience… + - Calm down! + - 🫸 Please wait. + - Touch grass! + - Play something else! + - Nothing better to do? + - 🛑 + - Access denied. + wrong_guess: + - heck! + - frick! + - Womp womp. + - 😩 + - Aw shucks. + - Why even bother? + - Oh bother! + - What a bungle! + - Not good! + - Ay Dios. + - So close! (Or not; I didn't look.) + - 99 ways to lose, and you found one! + - oof. + - bruh moment. + - Congratulations! You lost. emojistory: join_timer: 90 minimum_length: 5 diff --git a/crimsobot/exceptions.py b/crimsobot/exceptions.py index a3ea83b9..5361b72b 100644 --- a/crimsobot/exceptions.py +++ b/crimsobot/exceptions.py @@ -38,3 +38,13 @@ class NoEmojiFound(Exception): class BadCaption(Exception): pass + + +class NotAnInteger(Exception): + def __init__(self, guess: str) -> None: + self.guess = guess + + +class OutOfBounds(Exception): + def __init__(self, guess: str) -> None: + self.guess = guess diff --git a/crimsobot/utils/games.py b/crimsobot/utils/games.py index 26017530..a1be998c 100644 --- a/crimsobot/utils/games.py +++ b/crimsobot/utils/games.py @@ -1,12 +1,16 @@ import random +import re +import sys from collections import Counter -from datetime import datetime -from typing import List, Tuple, Union +from datetime import datetime, timezone +from typing import Any, List, Tuple, Union import discord from discord import Embed from discord.ext import commands +from crimsobot.data.games import DAILY_RULES +from crimsobot.exceptions import NotAnInteger, OutOfBounds from crimsobot.models.currency_account import CurrencyAccount from crimsobot.models.guess_statistic import GuessStatistic from crimsobot.utils import tools as c @@ -82,57 +86,123 @@ async def win(discord_user: DiscordUser, amount: float) -> None: account.add_to_balance(amount) await account.save() +# simple_eval() by MestreLion via StackOverflow with changes in exception handling +# https://stackoverflow.com/a/65945969 + +# Kept outside simple_eval() just for performance +_re_simple_eval = re.compile(rb'd([\x00-\xFF]+)S\x00') + + +def simple_eval(expr: str) -> Any: + try: + c = compile(expr, 'userinput', 'eval') + except SyntaxError: + raise SyntaxError(f'Malformed expression: {expr}') + + m = _re_simple_eval.fullmatch(c.co_code) + + if not m: + raise SyntaxError(f'Not a simple algebraic expression: {expr}') + + try: + return c.co_consts[int.from_bytes(m.group(1), sys.byteorder)] + except IndexError: + raise SyntaxError(f'Expression not evaluated as constant: {expr}') + + +def integer_in_range(number_in: Any, guess_str: str, low: int, high: int) -> Any: + # is 'number' a number? + try: + number = float(number_in) + except ValueError: + raise ValueError(f'{guess_str} is not a number!') + except TypeError: + raise ValueError(f'{guess_str} is not a number!') + + # is 'number' (close enough to) an integer? + try: + delta = abs(number - round(number)) + if delta > 1e-10: # arbitrary limit + raise NotAnInteger(guess_str) + except OverflowError: # e.g. infinity will fail at delta + raise OutOfBounds(guess_str) + + # is 'number' in range? + if not low <= number <= high: + raise OutOfBounds(guess_str) + + # enforce type + number = int(number) + + return number + + +async def daily(discord_user: DiscordUser, guess: str) -> Embed: + # is the guess in range? + try: + lucky_number = integer_in_range(guess, guess, 1, 100) + # if the guess is not already a positive integer [1 - 100]... + except ValueError: + # ...first check if it's a math expression... + try: + lucky_number = simple_eval(guess) + + # but if the answer is not an integer 1-100... + lucky_number = integer_in_range(lucky_number, guess, 1, 100) + # ...and if it's bounced from simple_eval(), try it as a string + except SyntaxError: + # find sum of remaining characters a-z::1-26 + lucky_number = 0 + + for char in guess.lower(): + letter_value = (ord(char) - 96) + if char.isalpha() and 1 <= letter_value <= 26: + lucky_number += letter_value + else: + pass + + lucky_number = integer_in_range(lucky_number, guess, 1, 100) + # final catchment for strings with sums outside of bounds + except ValueError: # the last bastion + raise OutOfBounds(str(guess)) -async def daily(discord_user: DiscordUser, lucky_number: int) -> Embed: # fetch account account = await CurrencyAccount.get_by_discord_user(discord_user) # type: CurrencyAccount # get current time and time last run now = datetime.utcnow() + now = datetime.now(timezone.utc) last = account.ran_daily_at # check if dates are same; if so, gotta wait if last and last.strftime('%Y-%m-%d') == now.strftime('%Y-%m-%d'): - title = 'Patience...' - award_string = 'Daily award resets at midnight UTC ( local).' + title = random.choice(DAILY_RULES['not_yet']) + award_string = 'The Daily game resets at midnight UTC.' thumb = 'clock' color = 'orange' + footer = None + # if no wait, then check if winner or loser else: winning_number = random.randint(1, 100) if winning_number == lucky_number: - daily_award = 500 + daily_award = DAILY_RULES['award']['win'] title = 'JACKPOT!' - wrong = '' # they're not wrong! + if_wrong = '' # they're not wrong! thumb = 'moneymouth' color = 'green' + footer = f'Your guess: {lucky_number} · Congratulations!' else: - daily_award = 10 - - title_choices = [ - '*heck*', - '*frick*', - '*womp womp*', - '😩', - 'Aw shucks.', - 'Why even bother?', - 'Oh, bother!', - 'What a bungle!', - 'Not good!', - 'Ay Dios.', - "So close! (Or not; I didn't look.)", - '99 ways to lose, and you found one!', - 'oof', - 'bruh moment', - 'Congratulations! You lost.', - ] - title = random.choice(title_choices) - wrong = 'The winning number this time was **{}**, but no worries:'.format(winning_number) + daily_award = DAILY_RULES['award']['lose'] + + title = random.choice(DAILY_RULES['wrong_guess']) + if_wrong = f'The winning number this time was **{winning_number}**. ' thumb = 'crimsoCOIN' color = 'yellow' + footer = f'Your guess: {lucky_number} · Thanks for playing!' # update daily then save account.ran_daily_at = now @@ -141,17 +211,18 @@ async def daily(discord_user: DiscordUser, lucky_number: int) -> Embed: # update their balance now (will repoen and reclose user) await win(discord_user, daily_award) - award_string = '{} You have been awarded your daily **\u20A2{:.2f}**!'.format(wrong, daily_award) - thumb = thumb - color = color + # finish up the award string with amount won + award_string = f'{if_wrong}You have been awarded **\u20A2{daily_award:.2f}**!' - # the embed to return + # embed to return embed = c.crimbed( title=title, descr=award_string, thumb_name=thumb, color_name=color, + footer=footer, ) + return embed