diff options
Diffstat (limited to 'bot/exts/fun')
-rw-r--r-- | bot/exts/fun/anagram.py | 109 | ||||
-rw-r--r-- | bot/exts/fun/battleship.py | 1 | ||||
-rw-r--r-- | bot/exts/fun/connect_four.py | 3 | ||||
-rw-r--r-- | bot/exts/fun/duck_game.py | 42 | ||||
-rw-r--r-- | bot/exts/fun/game.py | 32 | ||||
-rw-r--r-- | bot/exts/fun/latex.py | 130 | ||||
-rw-r--r-- | bot/exts/fun/madlibs.py | 148 | ||||
-rw-r--r-- | bot/exts/fun/quack.py | 75 | ||||
-rw-r--r-- | bot/exts/fun/snakes/_utils.py | 25 | ||||
-rw-r--r-- | bot/exts/fun/tic_tac_toe.py | 19 | ||||
-rw-r--r-- | bot/exts/fun/trivia_quiz.py | 6 |
11 files changed, 547 insertions, 43 deletions
diff --git a/bot/exts/fun/anagram.py b/bot/exts/fun/anagram.py new file mode 100644 index 00000000..79280fa9 --- /dev/null +++ b/bot/exts/fun/anagram.py @@ -0,0 +1,109 @@ +import asyncio +import json +import logging +import random +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours + +log = logging.getLogger(__name__) + +TIME_LIMIT = 60 + +# anagram.json file contains all the anagrams +with open(Path("bot/resources/fun/anagram.json"), "r") as f: + ANAGRAMS_ALL = json.load(f) + + +class AnagramGame: + """ + Used for creating instances of anagram games. + + Once multiple games can be run at the same time, this class' instances + can be used for keeping track of each anagram game. + """ + + def __init__(self, scrambled: str, correct: list[str]) -> None: + self.scrambled = scrambled + self.correct = set(correct) + + self.winners = set() + + async def message_creation(self, message: discord.Message) -> None: + """Check if the message is a correct answer and remove it from the list of answers.""" + if message.content.lower() in self.correct: + self.winners.add(message.author.mention) + self.correct.remove(message.content.lower()) + + +class Anagram(commands.Cog): + """Cog for the Anagram game command.""" + + def __init__(self, bot: Bot): + self.bot = bot + + self.games: dict[int, AnagramGame] = {} + + @commands.command(name="anagram", aliases=("anag", "gram", "ag")) + async def anagram_command(self, ctx: commands.Context) -> None: + """ + Given shuffled letters, rearrange them into anagrams. + + Show an embed with scrambled letters which if rearranged can form words. + After a specific amount of time, list the correct answers and whether someone provided a + correct answer. + """ + if self.games.get(ctx.channel.id): + await ctx.send("An anagram is already being solved in this channel!") + return + + scrambled_letters, correct = random.choice(list(ANAGRAMS_ALL.items())) + + game = AnagramGame(scrambled_letters, correct) + self.games[ctx.channel.id] = game + + anagram_embed = discord.Embed( + title=f"Find anagrams from these letters: '{scrambled_letters.upper()}'", + description=f"You have {TIME_LIMIT} seconds to find correct words.", + colour=Colours.purple, + ) + + await ctx.send(embed=anagram_embed) + await asyncio.sleep(TIME_LIMIT) + + if game.winners: + win_list = ", ".join(game.winners) + content = f"Well done {win_list} for getting it right!" + else: + content = "Nobody got it right." + + answer_embed = discord.Embed( + title=f"The words were: `{'`, `'.join(ANAGRAMS_ALL[game.scrambled])}`!", + colour=Colours.pink, + ) + + await ctx.send(content, embed=answer_embed) + + # Game is finished, let's remove it from the dict + self.games.pop(ctx.channel.id) + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Check a message for an anagram attempt and pass to an ongoing game.""" + if message.author.bot or not message.guild: + return + + game = self.games.get(message.channel.id) + if not game: + return + + await game.message_creation(message) + + +def setup(bot: Bot) -> None: + """Load the Anagram cog.""" + bot.add_cog(Anagram(bot)) diff --git a/bot/exts/fun/battleship.py b/bot/exts/fun/battleship.py index f4351954..beff196f 100644 --- a/bot/exts/fun/battleship.py +++ b/bot/exts/fun/battleship.py @@ -369,7 +369,6 @@ class Battleship(commands.Cog): return any(player in (game.p1.user, game.p2.user) for game in self.games) @commands.group(invoke_without_command=True) - @commands.guild_only() async def battleship(self, ctx: commands.Context) -> None: """ Play a game of Battleship with someone else! diff --git a/bot/exts/fun/connect_four.py b/bot/exts/fun/connect_four.py index 647bb2b7..f53695d5 100644 --- a/bot/exts/fun/connect_four.py +++ b/bot/exts/fun/connect_four.py @@ -6,7 +6,6 @@ from typing import Optional, Union import discord import emojis from discord.ext import commands -from discord.ext.commands import guild_only from bot.bot import Bot from bot.constants import Emojis @@ -361,7 +360,6 @@ class ConnectFour(commands.Cog): self.games.remove(game) raise - @guild_only() @commands.group( invoke_without_command=True, aliases=("4inarow", "connect4", "connectfour", "c4"), @@ -426,7 +424,6 @@ class ConnectFour(commands.Cog): await self._play_game(ctx, user, board_size, str(emoji1), str(emoji2)) - @guild_only() @connect_four.command(aliases=("bot", "computer", "cpu")) async def ai( self, diff --git a/bot/exts/fun/duck_game.py b/bot/exts/fun/duck_game.py index 1ef7513f..10b03a49 100644 --- a/bot/exts/fun/duck_game.py +++ b/bot/exts/fun/duck_game.py @@ -11,7 +11,7 @@ from PIL import Image, ImageDraw, ImageFont from discord.ext import commands from bot.bot import Bot -from bot.constants import Colours, MODERATION_ROLES +from bot.constants import MODERATION_ROLES from bot.utils.decorators import with_role DECK = list(product(*[(0, 1, 2)]*4)) @@ -130,6 +130,9 @@ class DuckGame: while len(self.solutions) < minimum_solutions: self.board = random.sample(DECK, size) + self.board_msg = None + self.found_msg = None + @property def board(self) -> list[tuple[int]]: """Accesses board property.""" @@ -181,7 +184,7 @@ class DuckGamesDirector(commands.Cog): ) @commands.cooldown(rate=1, per=2, type=commands.BucketType.channel) async def start_game(self, ctx: commands.Context) -> None: - """Generate a board, send the game embed, and end the game after a time limit.""" + """Start a new Duck Duck Duck Goose game.""" if ctx.channel.id in self.current_games: await ctx.send("There's already a game running!") return @@ -191,8 +194,8 @@ class DuckGamesDirector(commands.Cog): game.running = True self.current_games[ctx.channel.id] = game - game.msg_content = "" - game.embed_msg = await self.send_board_embed(ctx, game) + game.board_msg = await self.send_board_embed(ctx, game) + game.found_msg = await self.send_found_embed(ctx) await asyncio.sleep(GAME_DURATION) # Checking for the channel ID in the currently running games is not sufficient. @@ -245,13 +248,13 @@ class DuckGamesDirector(commands.Cog): if answer in game.solutions: game.claimed_answers[answer] = msg.author game.scores[msg.author] += CORRECT_SOLN - await self.display_claimed_answer(game, msg.author, answer) + await self.append_to_found_embed(game, f"{str(answer):12s} - {msg.author.display_name}") else: await msg.add_reaction(EMOJI_WRONG) game.scores[msg.author] += INCORRECT_SOLN async def send_board_embed(self, ctx: commands.Context, game: DuckGame) -> discord.Message: - """Create and send the initial game embed. This will be edited as the game goes on.""" + """Create and send an embed to display the board.""" image = assemble_board_image(game.board, game.rows, game.columns) with BytesIO() as image_stream: image.save(image_stream, format="png") @@ -259,19 +262,27 @@ class DuckGamesDirector(commands.Cog): file = discord.File(fp=image_stream, filename="board.png") embed = discord.Embed( title="Duck Duck Duck Goose!", - color=Colours.bright_green, + color=discord.Color.dark_purple(), ) embed.set_image(url="attachment://board.png") return await ctx.send(embed=embed, file=file) - async def display_claimed_answer(self, game: DuckGame, author: discord.Member, answer: tuple[int]) -> None: - """Add a claimed answer to the game embed.""" + async def send_found_embed(self, ctx: commands.Context) -> discord.Message: + """Create and send an embed to display claimed answers. This will be edited as the game goes on.""" + # Can't be part of the board embed because of discord.py limitations with editing an embed with an image. + embed = discord.Embed( + title="Flights Found", + color=discord.Color.dark_purple(), + ) + return await ctx.send(embed=embed) + + async def append_to_found_embed(self, game: DuckGame, text: str) -> None: + """Append text to the claimed answers embed.""" async with game.editing_embed: - # We specifically edit the message contents instead of the embed - # Because we load in the image from the file, editing any portion of the embed - # Does weird things to the image and this works around that weirdness - game.msg_content = f"{game.msg_content}\n{str(answer):12s} - {author.display_name}" - await game.embed_msg.edit(content=game.msg_content) + found_embed, = game.found_msg.embeds + old_desc = found_embed.description or "" + found_embed.description = f"{old_desc.rstrip()}\n{text}" + await game.found_msg.edit(embed=found_embed) async def end_game(self, channel: discord.TextChannel, game: DuckGame, end_message: str) -> None: """Edit the game embed to reflect the end of the game and mark the game as not running.""" @@ -296,8 +307,7 @@ class DuckGamesDirector(commands.Cog): missed_text = "Flights everyone missed:\n" + "\n".join(f"{ans}" for ans in missed) else: missed_text = "All the flights were found!" - - await game.embed_msg.edit(content=f"{missed_text}") + await self.append_to_found_embed(game, f"\n{missed_text}") @start_game.command(name="help") async def show_rules(self, ctx: commands.Context) -> None: diff --git a/bot/exts/fun/game.py b/bot/exts/fun/game.py index f9c150e6..5f56bef7 100644 --- a/bot/exts/fun/game.py +++ b/bot/exts/fun/game.py @@ -118,6 +118,7 @@ class GameStatus(IntEnum): Offline = 5 Cancelled = 6 Rumored = 7 + Delisted = 8 class AgeRatingCategories(IntEnum): @@ -125,6 +126,11 @@ class AgeRatingCategories(IntEnum): ESRB = 1 PEGI = 2 + CERO = 3 + USK = 4 + GRAC = 5 + CLASS_IND = 6 + ACB = 7 class AgeRatings(IntEnum): @@ -142,6 +148,32 @@ class AgeRatings(IntEnum): T = 10 M = 11 AO = 12 + CERO_A = 13 + CERO_B = 14 + CERO_C = 15 + CERO_D = 16 + CERO_Z = 17 + USK_0 = 18 + USK_6 = 19 + USK_12 = 20 + USK_18 = 21 + GRAC_ALL = 22 + GRAC_Twelve = 23 + GRAC_Fifteen = 24 + GRAC_Eighteen = 25 + GRAC_TESTING = 26 + CLASS_IND_L = 27 + CLASS_IND_Ten = 28 + CLASS_IND_Twelve = 29 + CLASS_IND_Fourteen = 30 + CLASS_IND_Sixteen = 31 + CLASS_IND_Eighteen = 32 + ACB_G = 33 + ACB_PG = 34 + ACB_M = 35 + ACB_MA15 = 36 + ACB_R18 = 37 + ACB_RC = 38 class Games(Cog): diff --git a/bot/exts/fun/latex.py b/bot/exts/fun/latex.py new file mode 100644 index 00000000..d43ec8c4 --- /dev/null +++ b/bot/exts/fun/latex.py @@ -0,0 +1,130 @@ +import hashlib +import re +import string +from io import BytesIO +from pathlib import Path +from typing import BinaryIO, Optional + +import discord +from PIL import Image +from discord.ext import commands + +from bot.bot import Bot + +FORMATTED_CODE_REGEX = re.compile( + r"(?P<delim>(?P<block>```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block + r"(?(block)(?:(?P<lang>[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline) + r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code + r"(?P<code>.*?)" # extract all code inside the markup + r"\s*" # any more whitespace before the end of the code markup + r"(?P=delim)", # match the exact same delimiter from the start again + re.DOTALL | re.IGNORECASE, # "." also matches newlines, case insensitive +) + +LATEX_API_URL = "https://rtex.probablyaweb.site/api/v2" +PASTEBIN_URL = "https://paste.pythondiscord.com" + +THIS_DIR = Path(__file__).parent +CACHE_DIRECTORY = THIS_DIR / "_latex_cache" +CACHE_DIRECTORY.mkdir(exist_ok=True) +TEMPLATE = string.Template(Path("bot/resources/fun/latex_template.txt").read_text()) + +PAD = 10 + + +def _prepare_input(text: str) -> str: + """Extract latex from a codeblock, if it is in one.""" + if match := FORMATTED_CODE_REGEX.match(text): + return match.group("code") + else: + return text + + +def _process_image(data: bytes, out_file: BinaryIO) -> None: + """Read `data` as an image file, and paste it on a white background.""" + image = Image.open(BytesIO(data)).convert("RGBA") + width, height = image.size + background = Image.new("RGBA", (width + 2 * PAD, height + 2 * PAD), "WHITE") + + # paste the image on the background, using the same image as the mask + # when an RGBA image is passed as the mask, its alpha band is used. + # this has the effect of skipping pasting the pixels where the image is transparent. + background.paste(image, (PAD, PAD), image) + background.save(out_file) + + +class InvalidLatexError(Exception): + """Represents an error caused by invalid latex.""" + + def __init__(self, logs: Optional[str]): + super().__init__(logs) + self.logs = logs + + +class Latex(commands.Cog): + """Renders latex.""" + + def __init__(self, bot: Bot): + self.bot = bot + + async def _generate_image(self, query: str, out_file: BinaryIO) -> None: + """Make an API request and save the generated image to cache.""" + payload = {"code": query, "format": "png"} + async with self.bot.http_session.post(LATEX_API_URL, data=payload, raise_for_status=True) as response: + response_json = await response.json() + if response_json["status"] != "success": + raise InvalidLatexError(logs=response_json.get("log")) + async with self.bot.http_session.get( + f"{LATEX_API_URL}/{response_json['filename']}", + raise_for_status=True + ) as response: + _process_image(await response.read(), out_file) + + async def _upload_to_pastebin(self, text: str) -> Optional[str]: + """Uploads `text` to the paste service, returning the url if successful.""" + try: + async with self.bot.http_session.post( + PASTEBIN_URL + "/documents", + data=text, + raise_for_status=True + ) as response: + response_json = await response.json() + if "key" in response_json: + return f"{PASTEBIN_URL}/{response_json['key']}.txt?noredirect" + except Exception: + # 400 (Bad Request) means there are too many characters + pass + + @commands.command() + @commands.max_concurrency(1, commands.BucketType.guild, wait=True) + async def latex(self, ctx: commands.Context, *, query: str) -> None: + """Renders the text in latex and sends the image.""" + query = _prepare_input(query) + + # the hash of the query is used as the filename in the cache. + query_hash = hashlib.md5(query.encode()).hexdigest() + image_path = CACHE_DIRECTORY / f"{query_hash}.png" + async with ctx.typing(): + if not image_path.exists(): + try: + with open(image_path, "wb") as out_file: + await self._generate_image(TEMPLATE.substitute(text=query), out_file) + except InvalidLatexError as err: + embed = discord.Embed(title="Failed to render input.") + if err.logs is None: + embed.description = "No logs available." + else: + logs_paste_url = await self._upload_to_pastebin(err.logs) + if logs_paste_url: + embed.description = f"[View Logs]({logs_paste_url})" + else: + embed.description = "Couldn't upload logs." + await ctx.send(embed=embed) + image_path.unlink() + return + await ctx.send(file=discord.File(image_path, "latex.png")) + + +def setup(bot: Bot) -> None: + """Load the Latex Cog.""" + bot.add_cog(Latex(bot)) diff --git a/bot/exts/fun/madlibs.py b/bot/exts/fun/madlibs.py new file mode 100644 index 00000000..21708e53 --- /dev/null +++ b/bot/exts/fun/madlibs.py @@ -0,0 +1,148 @@ +import json +from asyncio import TimeoutError +from pathlib import Path +from random import choice +from typing import TypedDict + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours, NEGATIVE_REPLIES + +TIMEOUT = 60.0 + + +class MadlibsTemplate(TypedDict): + """Structure of a template in the madlibs JSON file.""" + + title: str + blanks: list[str] + value: list[str] + + +class Madlibs(commands.Cog): + """Cog for the Madlibs game.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.templates = self._load_templates() + self.edited_content = {} + self.checks = set() + + @staticmethod + def _load_templates() -> list[MadlibsTemplate]: + madlibs_stories = Path("bot/resources/fun/madlibs_templates.json") + + with open(madlibs_stories) as file: + return json.load(file) + + @staticmethod + def madlibs_embed(part_of_speech: str, number_of_inputs: int) -> discord.Embed: + """Method to generate an embed with the game information.""" + madlibs_embed = discord.Embed(title="Madlibs", color=Colours.python_blue) + + madlibs_embed.add_field( + name="Enter a word that fits the given part of speech!", + value=f"Part of speech: {part_of_speech}\n\nMake sure not to spam, or you may get auto-muted!" + ) + + madlibs_embed.set_footer(text=f"Inputs remaining: {number_of_inputs}") + + return madlibs_embed + + @commands.Cog.listener() + async def on_message_edit(self, _: discord.Message, after: discord.Message) -> None: + """A listener that checks for message edits from the user.""" + for check in self.checks: + if check(after): + break + else: + return + + self.edited_content[after.id] = after.content + + @commands.command() + @commands.max_concurrency(1, per=commands.BucketType.user) + async def madlibs(self, ctx: commands.Context) -> None: + """ + Play Madlibs with the bot! + + Madlibs is a game where the player is asked to enter a word that + fits a random part of speech (e.g. noun, adjective, verb, plural noun, etc.) + a random amount of times, depending on the story chosen by the bot at the beginning. + """ + random_template = choice(self.templates) + + def author_check(message: discord.Message) -> bool: + return message.channel.id == ctx.channel.id and message.author.id == ctx.author.id + + self.checks.add(author_check) + + loading_embed = discord.Embed( + title="Madlibs", description="Loading your Madlibs game...", color=Colours.python_blue + ) + original_message = await ctx.send(embed=loading_embed) + + submitted_words = {} + + for i, part_of_speech in enumerate(random_template["blanks"]): + inputs_left = len(random_template["blanks"]) - i + + madlibs_embed = self.madlibs_embed(part_of_speech, inputs_left) + await original_message.edit(embed=madlibs_embed) + + try: + message = await self.bot.wait_for(event="message", check=author_check, timeout=TIMEOUT) + except TimeoutError: + timeout_embed = discord.Embed( + title=choice(NEGATIVE_REPLIES), + description="Uh oh! You took too long to respond!", + color=Colours.soft_red + ) + + await ctx.send(ctx.author.mention, embed=timeout_embed) + + for msg_id in submitted_words: + self.edited_content.pop(msg_id, submitted_words[msg_id]) + + self.checks.remove(author_check) + + return + + submitted_words[message.id] = message.content + + blanks = [self.edited_content.pop(msg_id, submitted_words[msg_id]) for msg_id in submitted_words] + + self.checks.remove(author_check) + + story = [] + for value, blank in zip(random_template["value"], blanks): + story.append(f"{value}__{blank}__") + + # In each story template, there is always one more "value" + # (fragment from the story) than there are blanks (words that the player enters) + # so we need to compensate by appending the last line of the story again. + story.append(random_template["value"][-1]) + + story_embed = discord.Embed( + title=random_template["title"], + description="".join(story), + color=Colours.bright_green + ) + + story_embed.set_footer(text=f"Generated for {ctx.author}", icon_url=ctx.author.display_avatar.url) + + await ctx.send(embed=story_embed) + + @madlibs.error + async def handle_madlibs_error(self, ctx: commands.Context, error: commands.CommandError) -> None: + """Error handler for the Madlibs command.""" + if isinstance(error, commands.MaxConcurrencyReached): + await ctx.send("You are already playing Madlibs!") + error.handled = True + + +def setup(bot: Bot) -> None: + """Load the Madlibs cog.""" + bot.add_cog(Madlibs(bot)) diff --git a/bot/exts/fun/quack.py b/bot/exts/fun/quack.py new file mode 100644 index 00000000..0c228aed --- /dev/null +++ b/bot/exts/fun/quack.py @@ -0,0 +1,75 @@ +import logging +import random +from typing import Literal, Optional + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours, NEGATIVE_REPLIES + +API_URL = 'https://quackstack.pythondiscord.com' + +log = logging.getLogger(__name__) + + +class Quackstack(commands.Cog): + """Cog used for wrapping Quackstack.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.command() + async def quack( + self, + ctx: commands.Context, + ducktype: Literal["duck", "manduck"] = "duck", + *, + seed: Optional[str] = None + ) -> None: + """ + Use the Quackstack API to generate a random duck. + + If a seed is provided, a duck is generated based on the given seed. + Either "duck" or "manduck" can be provided to change the duck type generated. + """ + ducktype = ducktype.lower() + quackstack_url = f"{API_URL}/{ducktype}" + params = {} + if seed is not None: + try: + seed = int(seed) + except ValueError: + # We just need to turn the string into an integer any way possible + seed = int.from_bytes(seed.encode(), "big") + params["seed"] = seed + + async with self.bot.http_session.get(quackstack_url, params=params) as response: + error_embed = discord.Embed( + title=random.choice(NEGATIVE_REPLIES), + description="The request failed. Please try again later.", + color=Colours.soft_red, + ) + if response.status != 200: + log.error(f"Response to Quackstack returned code {response.status}") + await ctx.send(embed=error_embed) + return + + data = await response.json() + file = data["file"] + + embed = discord.Embed( + title=f"Quack! Here's a {ducktype} for you.", + description=f"A {ducktype} from Quackstack.", + color=Colours.grass_green, + url=f"{API_URL}/docs" + ) + + embed.set_image(url=API_URL + file) + + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Loads the Quack cog.""" + bot.add_cog(Quackstack(bot)) diff --git a/bot/exts/fun/snakes/_utils.py b/bot/exts/fun/snakes/_utils.py index de51339d..182fa9d9 100644 --- a/bot/exts/fun/snakes/_utils.py +++ b/bot/exts/fun/snakes/_utils.py @@ -6,13 +6,14 @@ import math import random from itertools import product from pathlib import Path +from typing import Union from PIL import Image from PIL.ImageDraw import ImageDraw -from discord import File, Member, Reaction +from discord import File, Member, Reaction, User from discord.ext.commands import Cog, Context -from bot.constants import Roles +from bot.constants import MODERATION_ROLES SNAKE_RESOURCES = Path("bot/resources/fun/snakes").absolute() @@ -395,7 +396,7 @@ class SnakeAndLaddersGame: Listen for reactions until players have joined, and the game has been started. """ - def startup_event_check(reaction_: Reaction, user_: Member) -> bool: + def startup_event_check(reaction_: Reaction, user_: Union[User, Member]) -> bool: """Make sure that this reaction is what we want to operate on.""" return ( all(( @@ -460,7 +461,7 @@ class SnakeAndLaddersGame: await self.cancel_game() return # We're done, no reactions for the last 5 minutes - async def _add_player(self, user: Member) -> None: + async def _add_player(self, user: Union[User, Member]) -> None: """Add player to game.""" self.players.append(user) self.player_tiles[user.id] = 1 @@ -469,7 +470,7 @@ class SnakeAndLaddersGame: im = Image.open(io.BytesIO(avatar_bytes)).resize((BOARD_PLAYER_SIZE, BOARD_PLAYER_SIZE)) self.avatar_images[user.id] = im - async def player_join(self, user: Member) -> None: + async def player_join(self, user: Union[User, Member]) -> None: """ Handle players joining the game. @@ -495,7 +496,7 @@ class SnakeAndLaddersGame: delete_after=10 ) - async def player_leave(self, user: Member) -> bool: + async def player_leave(self, user: Union[User, Member]) -> bool: """ Handle players leaving the game. @@ -530,7 +531,7 @@ class SnakeAndLaddersGame: await self.channel.send("**Snakes and Ladders**: Game has been canceled.") self._destruct() - async def start_game(self, user: Member) -> None: + async def start_game(self, user: Union[User, Member]) -> None: """ Allow the game author to begin the game. @@ -551,7 +552,7 @@ class SnakeAndLaddersGame: async def start_round(self) -> None: """Begin the round.""" - def game_event_check(reaction_: Reaction, user_: Member) -> bool: + def game_event_check(reaction_: Reaction, user_: Union[User, Member]) -> bool: """Make sure that this reaction is what we want to operate on.""" return ( all(( @@ -644,7 +645,7 @@ class SnakeAndLaddersGame: if not is_surrendered: await self._complete_round() - async def player_roll(self, user: Member) -> None: + async def player_roll(self, user: Union[User, Member]) -> None: """Handle the player's roll.""" if user.id not in self.player_tiles: await self.channel.send(user.mention + " You are not in the match.", delete_after=10) @@ -691,7 +692,7 @@ class SnakeAndLaddersGame: await self.channel.send("**Snakes and Ladders**: " + winner.mention + " has won the game! :tada:") self._destruct() - def _check_winner(self) -> Member: + def _check_winner(self) -> Union[User, Member]: """Return a winning member if we're in the post-round state and there's a winner.""" if self.state != "post_round": return None @@ -716,6 +717,6 @@ class SnakeAndLaddersGame: return x_level, y_level @staticmethod - def _is_moderator(user: Member) -> bool: + def _is_moderator(user: Union[User, Member]) -> bool: """Return True if the user is a Moderator.""" - return any(Roles.moderator == role.id for role in user.roles) + return any(role.id in MODERATION_ROLES for role in getattr(user, 'roles', [])) diff --git a/bot/exts/fun/tic_tac_toe.py b/bot/exts/fun/tic_tac_toe.py index 5c4f8051..5dd38a81 100644 --- a/bot/exts/fun/tic_tac_toe.py +++ b/bot/exts/fun/tic_tac_toe.py @@ -3,7 +3,7 @@ import random from typing import Callable, Optional, Union import discord -from discord.ext.commands import Cog, Context, check, group, guild_only +from discord.ext.commands import Cog, Context, check, group from bot.bot import Bot from bot.constants import Emojis @@ -72,10 +72,12 @@ class Player: class AI: """Tic Tac Toe AI class for against computer gaming.""" - def __init__(self, symbol: str): + def __init__(self, bot_user: discord.Member, symbol: str): + self.user = bot_user self.symbol = symbol - async def get_move(self, board: dict[int, str], _: discord.Message) -> tuple[bool, int]: + @staticmethod + async def get_move(board: dict[int, str], _: discord.Message) -> tuple[bool, int]: """Get move from AI. AI use Minimax strategy.""" possible_moves = [i for i, emoji in board.items() if emoji in list(Emojis.number_emojis.values())] @@ -97,8 +99,8 @@ class AI: return False, random.choice(open_edges) def __str__(self) -> str: - """Return `AI` as user name.""" - return "AI" + """Return mention of @Sir Lancebot.""" + return self.user.mention class Game: @@ -107,6 +109,7 @@ class Game: def __init__(self, players: list[Union[Player, AI]], ctx: Context): self.players = players self.ctx = ctx + self.channel = ctx.channel self.board = { 1: Emojis.number_emojis[1], 2: Emojis.number_emojis[2], @@ -173,7 +176,8 @@ class Game: self.canceled = True return False, "User declined" - async def add_reactions(self, msg: discord.Message) -> None: + @staticmethod + async def add_reactions(msg: discord.Message) -> None: """Add number emojis to message.""" for nr in Emojis.number_emojis.values(): await msg.add_reaction(nr) @@ -249,7 +253,6 @@ class TicTacToe(Cog): def __init__(self): self.games: list[Game] = [] - @guild_only() @is_channel_free() @is_requester_free() @group(name="tictactoe", aliases=("ttt", "tic"), invoke_without_command=True) @@ -265,7 +268,7 @@ class TicTacToe(Cog): return if opponent is None: game = Game( - [Player(ctx.author, ctx, Emojis.x_square), AI(Emojis.o_square)], + [Player(ctx.author, ctx, Emojis.x_square), AI(ctx.me, Emojis.o_square)], ctx ) else: diff --git a/bot/exts/fun/trivia_quiz.py b/bot/exts/fun/trivia_quiz.py index 236586b0..4a1cec5b 100644 --- a/bot/exts/fun/trivia_quiz.py +++ b/bot/exts/fun/trivia_quiz.py @@ -16,7 +16,7 @@ from discord.ext import commands, tasks from rapidfuzz import fuzz from bot.bot import Bot -from bot.constants import Colours, NEGATIVE_REPLIES, Roles +from bot.constants import Client, Colours, MODERATION_ROLES, NEGATIVE_REPLIES logger = logging.getLogger(__name__) @@ -332,7 +332,7 @@ class TriviaQuiz(commands.Cog): if self.game_status[ctx.channel.id]: await ctx.send( "Game is already running... " - f"do `{self.bot.command_prefix}quiz stop`" + f"do `{Client.prefix}quiz stop`" ) return @@ -550,7 +550,7 @@ class TriviaQuiz(commands.Cog): if self.game_status[ctx.channel.id]: # Check if the author is the game starter or a moderator. if ctx.author == self.game_owners[ctx.channel.id] or any( - Roles.moderator == role.id for role in ctx.author.roles + role.id in MODERATION_ROLES for role in getattr(ctx.author, 'roles', []) ): self.game_status[ctx.channel.id] = False del self.game_owners[ctx.channel.id] |