diff options
Diffstat (limited to 'bot/exts/evergreen')
| -rw-r--r-- | bot/exts/evergreen/__init__.py | 0 | ||||
| -rw-r--r-- | bot/exts/evergreen/battleship.py | 448 | ||||
| -rw-r--r-- | bot/exts/evergreen/catify.py | 86 | ||||
| -rw-r--r-- | bot/exts/evergreen/coinflip.py | 53 | ||||
| -rw-r--r-- | bot/exts/evergreen/connect_four.py | 452 | ||||
| -rw-r--r-- | bot/exts/evergreen/duck_game.py | 356 | ||||
| -rw-r--r-- | bot/exts/evergreen/fun.py | 250 | ||||
| -rw-r--r-- | bot/exts/evergreen/game.py | 485 | ||||
| -rw-r--r-- | bot/exts/evergreen/magic_8ball.py | 30 | ||||
| -rw-r--r-- | bot/exts/evergreen/minesweeper.py | 270 | ||||
| -rw-r--r-- | bot/exts/evergreen/movie.py | 205 | ||||
| -rw-r--r-- | bot/exts/evergreen/recommend_game.py | 51 | ||||
| -rw-r--r-- | bot/exts/evergreen/rps.py | 57 | ||||
| -rw-r--r-- | bot/exts/evergreen/space.py | 236 | ||||
| -rw-r--r-- | bot/exts/evergreen/speedrun.py | 26 | ||||
| -rw-r--r-- | bot/exts/evergreen/status_codes.py | 87 | ||||
| -rw-r--r-- | bot/exts/evergreen/tic_tac_toe.py | 335 | ||||
| -rw-r--r-- | bot/exts/evergreen/trivia_quiz.py | 593 | ||||
| -rw-r--r-- | bot/exts/evergreen/wonder_twins.py | 49 | ||||
| -rw-r--r-- | bot/exts/evergreen/xkcd.py | 91 |
20 files changed, 0 insertions, 4160 deletions
diff --git a/bot/exts/evergreen/__init__.py b/bot/exts/evergreen/__init__.py deleted file mode 100644 index e69de29b..00000000 --- a/bot/exts/evergreen/__init__.py +++ /dev/null diff --git a/bot/exts/evergreen/battleship.py b/bot/exts/evergreen/battleship.py deleted file mode 100644 index f4351954..00000000 --- a/bot/exts/evergreen/battleship.py +++ /dev/null @@ -1,448 +0,0 @@ -import asyncio -import logging -import random -import re -from dataclasses import dataclass -from functools import partial -from typing import Optional - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours - -log = logging.getLogger(__name__) - - -@dataclass -class Square: - """Each square on the battleship grid - if they contain a boat and if they've been aimed at.""" - - boat: Optional[str] - aimed: bool - - -Grid = list[list[Square]] -EmojiSet = dict[tuple[bool, bool], str] - - -@dataclass -class Player: - """Each player in the game - their messages for the boards and their current grid.""" - - user: Optional[discord.Member] - board: Optional[discord.Message] - opponent_board: discord.Message - grid: Grid - - -# The name of the ship and its size -SHIPS = { - "Carrier": 5, - "Battleship": 4, - "Cruiser": 3, - "Submarine": 3, - "Destroyer": 2, -} - - -# For these two variables, the first boolean is whether the square is a ship (True) or not (False). -# The second boolean is whether the player has aimed for that square (True) or not (False) - -# This is for the player's own board which shows the location of their own ships. -SHIP_EMOJIS = { - (True, True): ":fire:", - (True, False): ":ship:", - (False, True): ":anger:", - (False, False): ":ocean:", -} - -# This is for the opposing player's board which only shows aimed locations. -HIDDEN_EMOJIS = { - (True, True): ":red_circle:", - (True, False): ":black_circle:", - (False, True): ":white_circle:", - (False, False): ":black_circle:", -} - -# For the top row of the board -LETTERS = ( - ":stop_button::regional_indicator_a::regional_indicator_b::regional_indicator_c::regional_indicator_d:" - ":regional_indicator_e::regional_indicator_f::regional_indicator_g::regional_indicator_h:" - ":regional_indicator_i::regional_indicator_j:" -) - -# For the first column of the board -NUMBERS = [ - ":one:", - ":two:", - ":three:", - ":four:", - ":five:", - ":six:", - ":seven:", - ":eight:", - ":nine:", - ":keycap_ten:", -] - -CROSS_EMOJI = "\u274e" -HAND_RAISED_EMOJI = "\U0001f64b" - - -class Game: - """A Battleship Game.""" - - def __init__( - self, - bot: Bot, - channel: discord.TextChannel, - player1: discord.Member, - player2: discord.Member - ): - - self.bot = bot - self.public_channel = channel - - self.p1 = Player(player1, None, None, self.generate_grid()) - self.p2 = Player(player2, None, None, self.generate_grid()) - - self.gameover: bool = False - - self.turn: Optional[discord.Member] = None - self.next: Optional[discord.Member] = None - - self.match: Optional[re.Match] = None - self.surrender: bool = False - - self.setup_grids() - - @staticmethod - def generate_grid() -> Grid: - """Generates a grid by instantiating the Squares.""" - return [[Square(None, False) for _ in range(10)] for _ in range(10)] - - @staticmethod - def format_grid(player: Player, emojiset: EmojiSet) -> str: - """ - Gets and formats the grid as a list into a string to be output to the DM. - - Also adds the Letter and Number indexes. - """ - grid = [ - [emojiset[bool(square.boat), square.aimed] for square in row] - for row in player.grid - ] - - rows = ["".join([number] + row) for number, row in zip(NUMBERS, grid)] - return "\n".join([LETTERS] + rows) - - @staticmethod - def get_square(grid: Grid, square: str) -> Square: - """Grabs a square from a grid with an inputted key.""" - index = ord(square[0].upper()) - ord("A") - number = int(square[1:]) - - return grid[number-1][index] # -1 since lists are indexed from 0 - - async def game_over( - self, - *, - winner: discord.Member, - loser: discord.Member - ) -> None: - """Removes games from list of current games and announces to public chat.""" - await self.public_channel.send(f"Game Over! {winner.mention} won against {loser.mention}") - - for player in (self.p1, self.p2): - grid = self.format_grid(player, SHIP_EMOJIS) - await self.public_channel.send(f"{player.user}'s Board:\n{grid}") - - @staticmethod - def check_sink(grid: Grid, boat: str) -> bool: - """Checks if all squares containing a given boat have sunk.""" - return all(square.aimed for row in grid for square in row if square.boat == boat) - - @staticmethod - def check_gameover(grid: Grid) -> bool: - """Checks if all boats have been sunk.""" - return all(square.aimed for row in grid for square in row if square.boat) - - def setup_grids(self) -> None: - """Places the boats on the grids to initialise the game.""" - for player in (self.p1, self.p2): - for name, size in SHIPS.items(): - while True: # Repeats if about to overwrite another boat - ship_collision = False - coords = [] - - coord1 = random.randint(0, 9) - coord2 = random.randint(0, 10 - size) - - if random.choice((True, False)): # Vertical or Horizontal - x, y = coord1, coord2 - xincr, yincr = 0, 1 - else: - x, y = coord2, coord1 - xincr, yincr = 1, 0 - - for i in range(size): - new_x = x + (xincr * i) - new_y = y + (yincr * i) - if player.grid[new_x][new_y].boat: # Check if there's already a boat - ship_collision = True - break - coords.append((new_x, new_y)) - if not ship_collision: # If not overwriting any other boat spaces, break loop - break - - for x, y in coords: - player.grid[x][y].boat = name - - async def print_grids(self) -> None: - """Prints grids to the DM channels.""" - # Convert squares into Emoji - - boards = [ - self.format_grid(player, emojiset) - for emojiset in (HIDDEN_EMOJIS, SHIP_EMOJIS) - for player in (self.p1, self.p2) - ] - - locations = ( - (self.p2, "opponent_board"), (self.p1, "opponent_board"), - (self.p1, "board"), (self.p2, "board") - ) - - for board, location in zip(boards, locations): - player, attr = location - if getattr(player, attr): - await getattr(player, attr).edit(content=board) - else: - setattr(player, attr, await player.user.send(board)) - - def predicate(self, message: discord.Message) -> bool: - """Predicate checking the message typed for each turn.""" - if message.author == self.turn.user and message.channel == self.turn.user.dm_channel: - if message.content.lower() == "surrender": - self.surrender = True - return True - self.match = re.fullmatch("([A-J]|[a-j]) ?((10)|[1-9])", message.content.strip()) - if not self.match: - self.bot.loop.create_task(message.add_reaction(CROSS_EMOJI)) - return bool(self.match) - - async def take_turn(self) -> Optional[Square]: - """Lets the player who's turn it is choose a square.""" - square = None - turn_message = await self.turn.user.send( - "It's your turn! Type the square you want to fire at. Format it like this: A1\n" - "Type `surrender` to give up." - ) - await self.next.user.send("Their turn", delete_after=3.0) - while True: - try: - await self.bot.wait_for("message", check=self.predicate, timeout=60.0) - except asyncio.TimeoutError: - await self.turn.user.send("You took too long. Game over!") - await self.next.user.send(f"{self.turn.user} took too long. Game over!") - await self.public_channel.send( - f"Game over! {self.turn.user.mention} timed out so {self.next.user.mention} wins!" - ) - self.gameover = True - break - else: - if self.surrender: - await self.next.user.send(f"{self.turn.user} surrendered. Game over!") - await self.public_channel.send( - f"Game over! {self.turn.user.mention} surrendered to {self.next.user.mention}!" - ) - self.gameover = True - break - square = self.get_square(self.next.grid, self.match.string) - if square.aimed: - await self.turn.user.send("You've already aimed at this square!", delete_after=3.0) - else: - break - await turn_message.delete() - return square - - async def hit(self, square: Square, alert_messages: list[discord.Message]) -> None: - """Occurs when a player successfully aims for a ship.""" - await self.turn.user.send("Hit!", delete_after=3.0) - alert_messages.append(await self.next.user.send("Hit!")) - if self.check_sink(self.next.grid, square.boat): - await self.turn.user.send(f"You've sunk their {square.boat} ship!", delete_after=3.0) - alert_messages.append(await self.next.user.send(f"Oh no! Your {square.boat} ship sunk!")) - if self.check_gameover(self.next.grid): - await self.turn.user.send("You win!") - await self.next.user.send("You lose!") - self.gameover = True - await self.game_over(winner=self.turn.user, loser=self.next.user) - - async def start_game(self) -> None: - """Begins the game.""" - await self.p1.user.send(f"You're playing battleship with {self.p2.user}.") - await self.p2.user.send(f"You're playing battleship with {self.p1.user}.") - - alert_messages = [] - - self.turn = self.p1 - self.next = self.p2 - - while True: - await self.print_grids() - - if self.gameover: - return - - square = await self.take_turn() - if not square: - return - square.aimed = True - - for message in alert_messages: - await message.delete() - - alert_messages = [] - alert_messages.append(await self.next.user.send(f"{self.turn.user} aimed at {self.match.string}!")) - - if square.boat: - await self.hit(square, alert_messages) - if self.gameover: - return - else: - await self.turn.user.send("Miss!", delete_after=3.0) - alert_messages.append(await self.next.user.send("Miss!")) - - self.turn, self.next = self.next, self.turn - - -class Battleship(commands.Cog): - """Play the classic game Battleship!""" - - def __init__(self, bot: Bot): - self.bot = bot - self.games: list[Game] = [] - self.waiting: list[discord.Member] = [] - - def predicate( - self, - ctx: commands.Context, - announcement: discord.Message, - reaction: discord.Reaction, - user: discord.Member - ) -> bool: - """Predicate checking the criteria for the announcement message.""" - if self.already_playing(ctx.author): # If they've joined a game since requesting a player 2 - return True # Is dealt with later on - if ( - user.id not in (ctx.me.id, ctx.author.id) - and str(reaction.emoji) == HAND_RAISED_EMOJI - and reaction.message.id == announcement.id - ): - if self.already_playing(user): - self.bot.loop.create_task(ctx.send(f"{user.mention} You're already playing a game!")) - self.bot.loop.create_task(announcement.remove_reaction(reaction, user)) - return False - - if user in self.waiting: - self.bot.loop.create_task(ctx.send( - f"{user.mention} Please cancel your game first before joining another one." - )) - self.bot.loop.create_task(announcement.remove_reaction(reaction, user)) - return False - - return True - - if ( - user.id == ctx.author.id - and str(reaction.emoji) == CROSS_EMOJI - and reaction.message.id == announcement.id - ): - return True - return False - - def already_playing(self, player: discord.Member) -> bool: - """Check if someone is already in a game.""" - 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! - - This will set up a message waiting for someone else to react and play along. - The game takes place entirely in DMs. - Make sure you have your DMs open so that the bot can message you. - """ - if self.already_playing(ctx.author): - await ctx.send("You're already playing a game!") - return - - if ctx.author in self.waiting: - await ctx.send("You've already sent out a request for a player 2.") - return - - announcement = await ctx.send( - "**Battleship**: A new game is about to start!\n" - f"Press {HAND_RAISED_EMOJI} to play against {ctx.author.mention}!\n" - f"(Cancel the game with {CROSS_EMOJI}.)" - ) - self.waiting.append(ctx.author) - await announcement.add_reaction(HAND_RAISED_EMOJI) - await announcement.add_reaction(CROSS_EMOJI) - - try: - reaction, user = await self.bot.wait_for( - "reaction_add", - check=partial(self.predicate, ctx, announcement), - timeout=60.0 - ) - except asyncio.TimeoutError: - self.waiting.remove(ctx.author) - await announcement.delete() - await ctx.send(f"{ctx.author.mention} Seems like there's no one here to play...") - return - - if str(reaction.emoji) == CROSS_EMOJI: - self.waiting.remove(ctx.author) - await announcement.delete() - await ctx.send(f"{ctx.author.mention} Game cancelled.") - return - - await announcement.delete() - self.waiting.remove(ctx.author) - if self.already_playing(ctx.author): - return - game = Game(self.bot, ctx.channel, ctx.author, user) - self.games.append(game) - try: - await game.start_game() - self.games.remove(game) - except discord.Forbidden: - await ctx.send( - f"{ctx.author.mention} {user.mention} " - "Game failed. This is likely due to you not having your DMs open. Check and try again." - ) - self.games.remove(game) - except Exception: - # End the game in the event of an unforseen error so the players aren't stuck in a game - await ctx.send(f"{ctx.author.mention} {user.mention} An error occurred. Game failed.") - self.games.remove(game) - raise - - @battleship.command(name="ships", aliases=("boats",)) - async def battleship_ships(self, ctx: commands.Context) -> None: - """Lists the ships that are found on the battleship grid.""" - embed = discord.Embed(colour=Colours.blue) - embed.add_field(name="Name", value="\n".join(SHIPS)) - embed.add_field(name="Size", value="\n".join(str(size) for size in SHIPS.values())) - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the Battleship Cog.""" - bot.add_cog(Battleship(bot)) diff --git a/bot/exts/evergreen/catify.py b/bot/exts/evergreen/catify.py deleted file mode 100644 index 32dfae09..00000000 --- a/bot/exts/evergreen/catify.py +++ /dev/null @@ -1,86 +0,0 @@ -import random -from contextlib import suppress -from typing import Optional - -from discord import AllowedMentions, Embed, Forbidden -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Cats, Colours, NEGATIVE_REPLIES -from bot.utils import helpers - - -class Catify(commands.Cog): - """Cog for the catify command.""" - - @commands.command(aliases=("ᓚᘏᗢify", "ᓚᘏᗢ")) - @commands.cooldown(1, 5, commands.BucketType.user) - async def catify(self, ctx: commands.Context, *, text: Optional[str]) -> None: - """ - Convert the provided text into a cat themed sentence by interspercing cats throughout text. - - If no text is given then the users nickname is edited. - """ - if not text: - display_name = ctx.author.display_name - - if len(display_name) > 26: - embed = Embed( - title=random.choice(NEGATIVE_REPLIES), - description=( - "Your display name is too long to be catified! " - "Please change it to be under 26 characters." - ), - color=Colours.soft_red - ) - await ctx.send(embed=embed) - return - - else: - display_name += f" | {random.choice(Cats.cats)}" - - await ctx.send(f"Your catified nickname is: `{display_name}`", allowed_mentions=AllowedMentions.none()) - - with suppress(Forbidden): - await ctx.author.edit(nick=display_name) - else: - if len(text) >= 1500: - embed = Embed( - title=random.choice(NEGATIVE_REPLIES), - description="Submitted text was too large! Please submit something under 1500 characters.", - color=Colours.soft_red - ) - await ctx.send(embed=embed) - return - - string_list = text.split() - for index, name in enumerate(string_list): - name = name.lower() - if "cat" in name: - if random.randint(0, 5) == 5: - string_list[index] = name.replace("cat", f"**{random.choice(Cats.cats)}**") - else: - string_list[index] = name.replace("cat", random.choice(Cats.cats)) - for element in Cats.cats: - if element in name: - string_list[index] = name.replace(element, "cat") - - string_len = len(string_list) // 3 or len(string_list) - - for _ in range(random.randint(1, string_len)): - # insert cat at random index - if random.randint(0, 5) == 5: - string_list.insert(random.randint(0, len(string_list)), f"**{random.choice(Cats.cats)}**") - else: - string_list.insert(random.randint(0, len(string_list)), random.choice(Cats.cats)) - - text = helpers.suppress_links(" ".join(string_list)) - await ctx.send( - f">>> {text}", - allowed_mentions=AllowedMentions.none() - ) - - -def setup(bot: Bot) -> None: - """Loads the catify cog.""" - bot.add_cog(Catify()) diff --git a/bot/exts/evergreen/coinflip.py b/bot/exts/evergreen/coinflip.py deleted file mode 100644 index 804306bd..00000000 --- a/bot/exts/evergreen/coinflip.py +++ /dev/null @@ -1,53 +0,0 @@ -import random - -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Emojis - - -class CoinSide(commands.Converter): - """Class used to convert the `side` parameter of coinflip command.""" - - HEADS = ("h", "head", "heads") - TAILS = ("t", "tail", "tails") - - async def convert(self, ctx: commands.Context, side: str) -> str: - """Converts the provided `side` into the corresponding string.""" - side = side.lower() - if side in self.HEADS: - return "heads" - - if side in self.TAILS: - return "tails" - - raise commands.BadArgument(f"{side!r} is not a valid coin side.") - - -class CoinFlip(commands.Cog): - """Cog for the CoinFlip command.""" - - @commands.command(name="coinflip", aliases=("flip", "coin", "cf")) - async def coinflip_command(self, ctx: commands.Context, side: CoinSide = None) -> None: - """ - Flips a coin. - - If `side` is provided will state whether you guessed the side correctly. - """ - flipped_side = random.choice(["heads", "tails"]) - - message = f"{ctx.author.mention} flipped **{flipped_side}**. " - if not side: - await ctx.send(message) - return - - if side == flipped_side: - message += f"You guessed correctly! {Emojis.lemon_hyperpleased}" - else: - message += f"You guessed incorrectly. {Emojis.lemon_pensive}" - await ctx.send(message) - - -def setup(bot: Bot) -> None: - """Loads the coinflip cog.""" - bot.add_cog(CoinFlip()) diff --git a/bot/exts/evergreen/connect_four.py b/bot/exts/evergreen/connect_four.py deleted file mode 100644 index 647bb2b7..00000000 --- a/bot/exts/evergreen/connect_four.py +++ /dev/null @@ -1,452 +0,0 @@ -import asyncio -import random -from functools import partial -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 - -NUMBERS = list(Emojis.number_emojis.values()) -CROSS_EMOJI = Emojis.incident_unactioned - -Coordinate = Optional[tuple[int, int]] -EMOJI_CHECK = Union[discord.Emoji, str] - - -class Game: - """A Connect 4 Game.""" - - def __init__( - self, - bot: Bot, - channel: discord.TextChannel, - player1: discord.Member, - player2: Optional[discord.Member], - tokens: list[str], - size: int = 7 - ): - self.bot = bot - self.channel = channel - self.player1 = player1 - self.player2 = player2 or AI(self.bot, game=self) - self.tokens = tokens - - self.grid = self.generate_board(size) - self.grid_size = size - - self.unicode_numbers = NUMBERS[:self.grid_size] - - self.message = None - - self.player_active = None - self.player_inactive = None - - @staticmethod - def generate_board(size: int) -> list[list[int]]: - """Generate the connect 4 board.""" - return [[0 for _ in range(size)] for _ in range(size)] - - async def print_grid(self) -> None: - """Formats and outputs the Connect Four grid to the channel.""" - title = ( - f"Connect 4: {self.player1.display_name}" - f" VS {self.bot.user.display_name if isinstance(self.player2, AI) else self.player2.display_name}" - ) - - rows = [" ".join(self.tokens[s] for s in row) for row in self.grid] - first_row = " ".join(x for x in NUMBERS[:self.grid_size]) - formatted_grid = "\n".join([first_row] + rows) - embed = discord.Embed(title=title, description=formatted_grid) - - if self.message: - await self.message.edit(embed=embed) - else: - self.message = await self.channel.send(content="Loading...") - for emoji in self.unicode_numbers: - await self.message.add_reaction(emoji) - await self.message.add_reaction(CROSS_EMOJI) - await self.message.edit(content=None, embed=embed) - - async def game_over(self, action: str, player1: discord.user, player2: discord.user) -> None: - """Announces to public chat.""" - if action == "win": - await self.channel.send(f"Game Over! {player1.mention} won against {player2.mention}") - elif action == "draw": - await self.channel.send(f"Game Over! {player1.mention} {player2.mention} It's A Draw :tada:") - elif action == "quit": - await self.channel.send(f"{self.player1.mention} surrendered. Game over!") - await self.print_grid() - - async def start_game(self) -> None: - """Begins the game.""" - self.player_active, self.player_inactive = self.player1, self.player2 - - while True: - await self.print_grid() - - if isinstance(self.player_active, AI): - coords = self.player_active.play() - if not coords: - await self.game_over( - "draw", - self.bot.user if isinstance(self.player_active, AI) else self.player_active, - self.bot.user if isinstance(self.player_inactive, AI) else self.player_inactive, - ) - else: - coords = await self.player_turn() - - if not coords: - return - - if self.check_win(coords, 1 if self.player_active == self.player1 else 2): - await self.game_over( - "win", - self.bot.user if isinstance(self.player_active, AI) else self.player_active, - self.bot.user if isinstance(self.player_inactive, AI) else self.player_inactive, - ) - return - - self.player_active, self.player_inactive = self.player_inactive, self.player_active - - def predicate(self, reaction: discord.Reaction, user: discord.Member) -> bool: - """The predicate to check for the player's reaction.""" - return ( - reaction.message.id == self.message.id - and user.id == self.player_active.id - and str(reaction.emoji) in (*self.unicode_numbers, CROSS_EMOJI) - ) - - async def player_turn(self) -> Coordinate: - """Initiate the player's turn.""" - message = await self.channel.send( - f"{self.player_active.mention}, it's your turn! React with the column you want to place your token in." - ) - player_num = 1 if self.player_active == self.player1 else 2 - while True: - try: - reaction, user = await self.bot.wait_for("reaction_add", check=self.predicate, timeout=30.0) - except asyncio.TimeoutError: - await self.channel.send(f"{self.player_active.mention}, you took too long. Game over!") - return - else: - await message.delete() - if str(reaction.emoji) == CROSS_EMOJI: - await self.game_over("quit", self.player_active, self.player_inactive) - return - - await self.message.remove_reaction(reaction, user) - - column_num = self.unicode_numbers.index(str(reaction.emoji)) - column = [row[column_num] for row in self.grid] - - for row_num, square in reversed(list(enumerate(column))): - if not square: - self.grid[row_num][column_num] = player_num - return row_num, column_num - message = await self.channel.send(f"Column {column_num + 1} is full. Try again") - - def check_win(self, coords: Coordinate, player_num: int) -> bool: - """Check that placing a counter here would cause the player to win.""" - vertical = [(-1, 0), (1, 0)] - horizontal = [(0, 1), (0, -1)] - forward_diag = [(-1, 1), (1, -1)] - backward_diag = [(-1, -1), (1, 1)] - axes = [vertical, horizontal, forward_diag, backward_diag] - - for axis in axes: - counters_in_a_row = 1 # The initial counter that is compared to - for (row_incr, column_incr) in axis: - row, column = coords - row += row_incr - column += column_incr - - while 0 <= row < self.grid_size and 0 <= column < self.grid_size: - if self.grid[row][column] == player_num: - counters_in_a_row += 1 - row += row_incr - column += column_incr - else: - break - if counters_in_a_row >= 4: - return True - return False - - -class AI: - """The Computer Player for Single-Player games.""" - - def __init__(self, bot: Bot, game: Game): - self.game = game - self.mention = bot.user.mention - - def get_possible_places(self) -> list[Coordinate]: - """Gets all the coordinates where the AI could possibly place a counter.""" - possible_coords = [] - for column_num in range(self.game.grid_size): - column = [row[column_num] for row in self.game.grid] - for row_num, square in reversed(list(enumerate(column))): - if not square: - possible_coords.append((row_num, column_num)) - break - return possible_coords - - def check_ai_win(self, coord_list: list[Coordinate]) -> Optional[Coordinate]: - """ - Check AI win. - - Check if placing a counter in any possible coordinate would cause the AI to win - with 10% chance of not winning and returning None - """ - if random.randint(1, 10) == 1: - return - for coords in coord_list: - if self.game.check_win(coords, 2): - return coords - - def check_player_win(self, coord_list: list[Coordinate]) -> Optional[Coordinate]: - """ - Check Player win. - - Check if placing a counter in possible coordinates would stop the player - from winning with 25% of not blocking them and returning None. - """ - if random.randint(1, 4) == 1: - return - for coords in coord_list: - if self.game.check_win(coords, 1): - return coords - - @staticmethod - def random_coords(coord_list: list[Coordinate]) -> Coordinate: - """Picks a random coordinate from the possible ones.""" - return random.choice(coord_list) - - def play(self) -> Union[Coordinate, bool]: - """ - Plays for the AI. - - Gets all possible coords, and determins the move: - 1. coords where it can win. - 2. coords where the player can win. - 3. Random coord - The first possible value is choosen. - """ - possible_coords = self.get_possible_places() - - if not possible_coords: - return False - - coords = ( - self.check_ai_win(possible_coords) - or self.check_player_win(possible_coords) - or self.random_coords(possible_coords) - ) - - row, column = coords - self.game.grid[row][column] = 2 - return coords - - -class ConnectFour(commands.Cog): - """Connect Four. The Classic Vertical Four-in-a-row Game!""" - - def __init__(self, bot: Bot): - self.bot = bot - self.games: list[Game] = [] - self.waiting: list[discord.Member] = [] - - self.tokens = [":white_circle:", ":blue_circle:", ":red_circle:"] - - self.max_board_size = 9 - self.min_board_size = 5 - - async def check_author(self, ctx: commands.Context, board_size: int) -> bool: - """Check if the requester is free and the board size is correct.""" - if self.already_playing(ctx.author): - await ctx.send("You're already playing a game!") - return False - - if ctx.author in self.waiting: - await ctx.send("You've already sent out a request for a player 2") - return False - - if not self.min_board_size <= board_size <= self.max_board_size: - await ctx.send( - f"{board_size} is not a valid board size. A valid board size is " - f"between `{self.min_board_size}` and `{self.max_board_size}`." - ) - return False - - return True - - def get_player( - self, - ctx: commands.Context, - announcement: discord.Message, - reaction: discord.Reaction, - user: discord.Member - ) -> bool: - """Predicate checking the criteria for the announcement message.""" - if self.already_playing(ctx.author): # If they've joined a game since requesting a player 2 - return True # Is dealt with later on - - if ( - user.id not in (ctx.me.id, ctx.author.id) - and str(reaction.emoji) == Emojis.hand_raised - and reaction.message.id == announcement.id - ): - if self.already_playing(user): - self.bot.loop.create_task(ctx.send(f"{user.mention} You're already playing a game!")) - self.bot.loop.create_task(announcement.remove_reaction(reaction, user)) - return False - - if user in self.waiting: - self.bot.loop.create_task(ctx.send( - f"{user.mention} Please cancel your game first before joining another one." - )) - self.bot.loop.create_task(announcement.remove_reaction(reaction, user)) - return False - - return True - - if ( - user.id == ctx.author.id - and str(reaction.emoji) == CROSS_EMOJI - and reaction.message.id == announcement.id - ): - return True - return False - - def already_playing(self, player: discord.Member) -> bool: - """Check if someone is already in a game.""" - return any(player in (game.player1, game.player2) for game in self.games) - - @staticmethod - def check_emojis( - e1: EMOJI_CHECK, e2: EMOJI_CHECK - ) -> tuple[bool, Optional[str]]: - """Validate the emojis, the user put.""" - if isinstance(e1, str) and emojis.count(e1) != 1: - return False, e1 - if isinstance(e2, str) and emojis.count(e2) != 1: - return False, e2 - return True, None - - async def _play_game( - self, - ctx: commands.Context, - user: Optional[discord.Member], - board_size: int, - emoji1: str, - emoji2: str - ) -> None: - """Helper for playing a game of connect four.""" - self.tokens = [":white_circle:", str(emoji1), str(emoji2)] - game = None # if game fails to intialize in try...except - - try: - game = Game(self.bot, ctx.channel, ctx.author, user, self.tokens, size=board_size) - self.games.append(game) - await game.start_game() - self.games.remove(game) - except Exception: - # End the game in the event of an unforeseen error so the players aren't stuck in a game - await ctx.send(f"{ctx.author.mention} {user.mention if user else ''} An error occurred. Game failed.") - if game in self.games: - self.games.remove(game) - raise - - @guild_only() - @commands.group( - invoke_without_command=True, - aliases=("4inarow", "connect4", "connectfour", "c4"), - case_insensitive=True - ) - async def connect_four( - self, - ctx: commands.Context, - board_size: int = 7, - emoji1: EMOJI_CHECK = "\U0001f535", - emoji2: EMOJI_CHECK = "\U0001f534" - ) -> None: - """ - Play the classic game of Connect Four with someone! - - Sets up a message waiting for someone else to react and play along. - The game will start once someone has reacted. - All inputs will be through reactions. - """ - check, emoji = self.check_emojis(emoji1, emoji2) - if not check: - raise commands.EmojiNotFound(emoji) - - check_author_result = await self.check_author(ctx, board_size) - if not check_author_result: - return - - announcement = await ctx.send( - "**Connect Four**: A new game is about to start!\n" - f"Press {Emojis.hand_raised} to play against {ctx.author.mention}!\n" - f"(Cancel the game with {CROSS_EMOJI}.)" - ) - self.waiting.append(ctx.author) - await announcement.add_reaction(Emojis.hand_raised) - await announcement.add_reaction(CROSS_EMOJI) - - try: - reaction, user = await self.bot.wait_for( - "reaction_add", - check=partial(self.get_player, ctx, announcement), - timeout=60.0 - ) - except asyncio.TimeoutError: - self.waiting.remove(ctx.author) - await announcement.delete() - await ctx.send( - f"{ctx.author.mention} Seems like there's no one here to play. " - f"Use `{ctx.prefix}{ctx.invoked_with} ai` to play against a computer." - ) - return - - if str(reaction.emoji) == CROSS_EMOJI: - self.waiting.remove(ctx.author) - await announcement.delete() - await ctx.send(f"{ctx.author.mention} Game cancelled.") - return - - await announcement.delete() - self.waiting.remove(ctx.author) - if self.already_playing(ctx.author): - return - - 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, - ctx: commands.Context, - board_size: int = 7, - emoji1: EMOJI_CHECK = "\U0001f535", - emoji2: EMOJI_CHECK = "\U0001f534" - ) -> None: - """Play Connect Four against a computer player.""" - check, emoji = self.check_emojis(emoji1, emoji2) - if not check: - raise commands.EmojiNotFound(emoji) - - check_author_result = await self.check_author(ctx, board_size) - if not check_author_result: - return - - await self._play_game(ctx, None, board_size, str(emoji1), str(emoji2)) - - -def setup(bot: Bot) -> None: - """Load ConnectFour Cog.""" - bot.add_cog(ConnectFour(bot)) diff --git a/bot/exts/evergreen/duck_game.py b/bot/exts/evergreen/duck_game.py deleted file mode 100644 index d592f3df..00000000 --- a/bot/exts/evergreen/duck_game.py +++ /dev/null @@ -1,356 +0,0 @@ -import asyncio -import random -import re -from collections import defaultdict -from io import BytesIO -from itertools import product -from pathlib import Path -from urllib.parse import urlparse - -import discord -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.utils.decorators import with_role - - -DECK = list(product(*[(0, 1, 2)]*4)) - -GAME_DURATION = 180 - -# Scoring -CORRECT_SOLN = 1 -INCORRECT_SOLN = -1 -CORRECT_GOOSE = 2 -INCORRECT_GOOSE = -1 - -# Distribution of minimum acceptable solutions at board generation. -# This is for gameplay reasons, to shift the number of solutions per board up, -# while still making the end of the game unpredictable. -# Note: this is *not* the same as the distribution of number of solutions. - -SOLN_DISTR = 0, 0.05, 0.05, 0.1, 0.15, 0.25, 0.2, 0.15, .05 - -IMAGE_PATH = Path("bot", "resources", "evergreen", "all_cards.png") -FONT_PATH = Path("bot", "resources", "evergreen", "LuckiestGuy-Regular.ttf") -HELP_IMAGE_PATH = Path("bot", "resources", "evergreen", "ducks_help_ex.png") - -ALL_CARDS = Image.open(IMAGE_PATH) -LABEL_FONT = ImageFont.truetype(str(FONT_PATH), size=16) -CARD_WIDTH = 155 -CARD_HEIGHT = 97 - -EMOJI_WRONG = "\u274C" - -ANSWER_REGEX = re.compile(r'^\D*(\d+)\D+(\d+)\D+(\d+)\D*$') - -HELP_TEXT = """ -**Each card has 4 features** -Color, Number, Hat, and Accessory - -**A valid flight** -3 cards where each feature is either all the same or all different - -**Call "GOOSE"** -if you think there are no more flights - -**+1** for each valid flight -**+2** for a correct "GOOSE" call -**-1** for any wrong answer - -The first flight below is invalid: the first card has swords while the other two have no accessory.\ - It would be valid if the first card was empty-handed, or one of the other two had paintbrushes. - -The second flight is valid because there are no 2:1 splits; each feature is either all the same or all different. -""" - - -def assemble_board_image(board: list[tuple[int]], rows: int, columns: int) -> Image: - """Cut and paste images representing the given cards into an image representing the board.""" - new_im = Image.new("RGBA", (CARD_WIDTH*columns, CARD_HEIGHT*rows)) - draw = ImageDraw.Draw(new_im) - for idx, card in enumerate(board): - card_image = get_card_image(card) - row, col = divmod(idx, columns) - top, left = row * CARD_HEIGHT, col * CARD_WIDTH - new_im.paste(card_image, (left, top)) - draw.text( - xy=(left+5, top+5), # magic numbers are buffers for the card labels - text=str(idx), - fill=(0, 0, 0), - font=LABEL_FONT, - ) - return new_im - - -def get_card_image(card: tuple[int]) -> Image: - """Slice the image containing all the cards to get just this card.""" - # The master card image file should have 9x9 cards, - # arranged such that their features can be interpreted as ordered trinary. - row, col = divmod(as_trinary(card), 9) - x1 = col * CARD_WIDTH - x2 = x1 + CARD_WIDTH - y1 = row * CARD_HEIGHT - y2 = y1 + CARD_HEIGHT - return ALL_CARDS.crop((x1, y1, x2, y2)) - - -def as_trinary(card: tuple[int]) -> int: - """Find the card's unique index by interpreting its features as trinary.""" - return int(''.join(str(x) for x in card), base=3) - - -class DuckGame: - """A class for a single game.""" - - def __init__( - self, - rows: int = 4, - columns: int = 3, - minimum_solutions: int = 1, - ): - """ - Take samples from the deck to generate a board. - - Args: - rows (int, optional): Rows in the game board. Defaults to 4. - columns (int, optional): Columns in the game board. Defaults to 3. - minimum_solutions (int, optional): Minimum acceptable number of solutions in the board. Defaults to 1. - """ - self.rows = rows - self.columns = columns - size = rows * columns - - self._solutions = None - self.claimed_answers = {} - self.scores = defaultdict(int) - self.editing_embed = asyncio.Lock() - - self.board = random.sample(DECK, size) - while len(self.solutions) < minimum_solutions: - self.board = random.sample(DECK, size) - - @property - def board(self) -> list[tuple[int]]: - """Accesses board property.""" - return self._board - - @board.setter - def board(self, val: list[tuple[int]]) -> None: - """Erases calculated solutions if the board changes.""" - self._solutions = None - self._board = val - - @property - def solutions(self) -> None: - """Calculate valid solutions and cache to avoid redoing work.""" - if self._solutions is None: - self._solutions = set() - for idx_a, card_a in enumerate(self.board): - for idx_b, card_b in enumerate(self.board[idx_a+1:], start=idx_a+1): - # Two points determine a line, and there are exactly 3 points per line in {0,1,2}^4. - # The completion of a line will only be a duplicate point if the other two points are the same, - # which is prevented by the triangle iteration. - completion = tuple( - feat_a if feat_a == feat_b else 3-feat_a-feat_b - for feat_a, feat_b in zip(card_a, card_b) - ) - try: - idx_c = self.board.index(completion) - except ValueError: - continue - - # Indices within the solution are sorted to detect duplicate solutions modulo order. - solution = tuple(sorted((idx_a, idx_b, idx_c))) - self._solutions.add(solution) - - return self._solutions - - -class DuckGamesDirector(commands.Cog): - """A cog for running Duck Duck Duck Goose games.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.current_games = {} - - @commands.group( - name='duckduckduckgoose', - aliases=['dddg', 'ddg', 'duckduckgoose', 'duckgoose'], - invoke_without_command=True - ) - @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.""" - if ctx.channel.id in self.current_games: - await ctx.send("There's already a game running!") - return - - minimum_solutions, = random.choices(range(len(SOLN_DISTR)), weights=SOLN_DISTR) - game = DuckGame(minimum_solutions=minimum_solutions) - game.running = True - self.current_games[ctx.channel.id] = game - - game.embed_msg = await self.send_board_embed(ctx, game) - await asyncio.sleep(GAME_DURATION) - - # Checking for the channel ID in the currently running games is not sufficient. - # The game could have been ended by a player, and a new game already started in the same channel. - if game.running: - try: - del self.current_games[ctx.channel.id] - await self.end_game(ctx.channel, game, end_message="Time's up!") - except KeyError: - pass - - @commands.Cog.listener() - async def on_message(self, msg: discord.Message) -> None: - """Listen for messages and process them as answers if appropriate.""" - if msg.author.bot: - return - - channel = msg.channel - if channel.id not in self.current_games: - return - - game = self.current_games[channel.id] - if msg.content.strip().lower() == 'goose': - # If all of the solutions have been claimed, i.e. the "goose" call is correct. - if len(game.solutions) == len(game.claimed_answers): - try: - del self.current_games[channel.id] - game.scores[msg.author] += CORRECT_GOOSE - await self.end_game(channel, game, end_message=f"{msg.author.display_name} GOOSED!") - except KeyError: - pass - else: - await msg.add_reaction(EMOJI_WRONG) - game.scores[msg.author] += INCORRECT_GOOSE - return - - # Valid answers contain 3 numbers. - if not (match := re.match(ANSWER_REGEX, msg.content)): - return - answer = tuple(sorted(int(m) for m in match.groups())) - - # Be forgiving for answers that use indices not on the board. - if not all(0 <= n < len(game.board) for n in answer): - return - - # Also be forgiving for answers that have already been claimed (and avoid penalizing for racing conditions). - if answer in game.claimed_answers: - return - - 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) - 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.""" - image = assemble_board_image(game.board, game.rows, game.columns) - with BytesIO() as image_stream: - image.save(image_stream, format="png") - image_stream.seek(0) - file = discord.File(fp=image_stream, filename="board.png") - embed = discord.Embed( - title="Duck Duck Duck Goose!", - color=Colours.bright_green, - footer="" - ) - 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 with game.editing_embed: - game_embed, = game.embed_msg.embeds - old_footer = game_embed.footer.text - if old_footer == discord.Embed.Empty: - old_footer = "" - game_embed.set_footer(text=f"{old_footer}\n{str(answer):12s} - {author.display_name}") - await self.edit_embed_with_image(game.embed_msg, game_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.""" - game.running = False - - scoreboard_embed = discord.Embed( - title=end_message, - color=discord.Color.dark_purple(), - ) - scores = sorted( - game.scores.items(), - key=lambda item: item[1], - reverse=True, - ) - scoreboard = "Final scores:\n\n" - scoreboard += "\n".join(f"{member.display_name}: {score}" for member, score in scores) - scoreboard_embed.description = scoreboard - await channel.send(embed=scoreboard_embed) - - missed = [ans for ans in game.solutions if ans not in game.claimed_answers] - if missed: - missed_text = "Flights everyone missed:\n" + "\n".join(f"{ans}" for ans in missed) - else: - missed_text = "All the flights were found!" - - game_embed, = game.embed_msg.embeds - old_footer = game_embed.footer.text - if old_footer == discord.Embed.Empty: - old_footer = "" - embed_as_dict = game_embed.to_dict() # Cannot set embed color after initialization - embed_as_dict["color"] = discord.Color.red().value - game_embed = discord.Embed.from_dict(embed_as_dict) - game_embed.set_footer( - text=f"{old_footer.rstrip()}\n\n{missed_text}" - ) - await self.edit_embed_with_image(game.embed_msg, game_embed) - - @start_game.command(name="help") - async def show_rules(self, ctx: commands.Context) -> None: - """Explain the rules of the game.""" - await self.send_help_embed(ctx) - - @start_game.command(name="stop") - @with_role(*MODERATION_ROLES) - async def stop_game(self, ctx: commands.Context) -> None: - """Stop a currently running game. Only available to mods.""" - try: - game = self.current_games.pop(ctx.channel.id) - except KeyError: - await ctx.send("No game currently running in this channel") - return - await self.end_game(ctx.channel, game, end_message="Game canceled.") - - @staticmethod - async def send_help_embed(ctx: commands.Context) -> discord.Message: - """Send rules embed.""" - embed = discord.Embed( - title="Compete against other players to find valid flights!", - color=discord.Color.dark_purple(), - ) - embed.description = HELP_TEXT - file = discord.File(HELP_IMAGE_PATH, filename="help.png") - embed.set_image(url="attachment://help.png") - embed.set_footer( - text="Tip: using Discord's compact message display mode can help keep the board on the screen" - ) - return await ctx.send(file=file, embed=embed) - - @staticmethod - async def edit_embed_with_image(msg: discord.Message, embed: discord.Embed) -> None: - """Edit an embed without the attached image going wonky.""" - attach_name = urlparse(embed.image.url).path.split("/")[-1] - embed.set_image(url=f"attachment://{attach_name}") - await msg.edit(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the DuckGamesDirector cog.""" - bot.add_cog(DuckGamesDirector(bot)) diff --git a/bot/exts/evergreen/fun.py b/bot/exts/evergreen/fun.py deleted file mode 100644 index 4bbfe859..00000000 --- a/bot/exts/evergreen/fun.py +++ /dev/null @@ -1,250 +0,0 @@ -import functools -import json -import logging -import random -from collections.abc import Iterable -from pathlib import Path -from typing import Callable, Optional, Union - -from discord import Embed, Message -from discord.ext import commands -from discord.ext.commands import BadArgument, Cog, Context, MessageConverter, clean_content - -from bot import utils -from bot.bot import Bot -from bot.constants import Client, Colours, Emojis -from bot.utils import helpers - -log = logging.getLogger(__name__) - -UWU_WORDS = { - "fi": "fwi", - "l": "w", - "r": "w", - "some": "sum", - "th": "d", - "thing": "fing", - "tho": "fo", - "you're": "yuw'we", - "your": "yur", - "you": "yuw", -} - - -def caesar_cipher(text: str, offset: int) -> Iterable[str]: - """ - Implements a lazy Caesar Cipher algorithm. - - Encrypts a `text` given a specific integer `offset`. The sign - of the `offset` dictates the direction in which it shifts to, - with a negative value shifting to the left, and a positive - value shifting to the right. - """ - for char in text: - if not char.isascii() or not char.isalpha() or char.isspace(): - yield char - continue - - case_start = 65 if char.isupper() else 97 - true_offset = (ord(char) - case_start + offset) % 26 - - yield chr(case_start + true_offset) - - -class Fun(Cog): - """A collection of general commands for fun.""" - - def __init__(self, bot: Bot): - self.bot = bot - - self._caesar_cipher_embed = json.loads(Path("bot/resources/evergreen/caesar_info.json").read_text("UTF-8")) - - @staticmethod - def _get_random_die() -> str: - """Generate a random die emoji, ready to be sent on Discord.""" - die_name = f"dice_{random.randint(1, 6)}" - return getattr(Emojis, die_name) - - @commands.command() - async def roll(self, ctx: Context, num_rolls: int = 1) -> None: - """Outputs a number of random dice emotes (up to 6).""" - if 1 <= num_rolls <= 6: - dice = " ".join(self._get_random_die() for _ in range(num_rolls)) - await ctx.send(dice) - else: - raise BadArgument(f"`{Client.prefix}roll` only supports between 1 and 6 rolls.") - - @commands.command(name="uwu", aliases=("uwuwize", "uwuify",)) - async def uwu_command(self, ctx: Context, *, text: clean_content(fix_channel_mentions=True)) -> None: - """Converts a given `text` into it's uwu equivalent.""" - conversion_func = functools.partial( - utils.replace_many, replacements=UWU_WORDS, ignore_case=True, match_case=True - ) - text, embed = await Fun._get_text_and_embed(ctx, text) - # Convert embed if it exists - if embed is not None: - embed = Fun._convert_embed(conversion_func, embed) - converted_text = conversion_func(text) - converted_text = helpers.suppress_links(converted_text) - # Don't put >>> if only embed present - if converted_text: - converted_text = f">>> {converted_text.lstrip('> ')}" - await ctx.send(content=converted_text, embed=embed) - - @commands.command(name="randomcase", aliases=("rcase", "randomcaps", "rcaps",)) - async def randomcase_command(self, ctx: Context, *, text: clean_content(fix_channel_mentions=True)) -> None: - """Randomly converts the casing of a given `text`.""" - def conversion_func(text: str) -> str: - """Randomly converts the casing of a given string.""" - return "".join( - char.upper() if round(random.random()) else char.lower() for char in text - ) - text, embed = await Fun._get_text_and_embed(ctx, text) - # Convert embed if it exists - if embed is not None: - embed = Fun._convert_embed(conversion_func, embed) - converted_text = conversion_func(text) - converted_text = helpers.suppress_links(converted_text) - # Don't put >>> if only embed present - if converted_text: - converted_text = f">>> {converted_text.lstrip('> ')}" - await ctx.send(content=converted_text, embed=embed) - - @commands.group(name="caesarcipher", aliases=("caesar", "cc",)) - async def caesarcipher_group(self, ctx: Context) -> None: - """ - Translates a message using the Caesar Cipher. - - See `decrypt`, `encrypt`, and `info` subcommands. - """ - if ctx.invoked_subcommand is None: - await ctx.invoke(self.bot.get_command("help"), "caesarcipher") - - @caesarcipher_group.command(name="info") - async def caesarcipher_info(self, ctx: Context) -> None: - """Information about the Caesar Cipher.""" - embed = Embed.from_dict(self._caesar_cipher_embed) - embed.colour = Colours.dark_green - - await ctx.send(embed=embed) - - @staticmethod - async def _caesar_cipher(ctx: Context, offset: int, msg: str, left_shift: bool = False) -> None: - """ - Given a positive integer `offset`, translates and sends the given `msg`. - - Performs a right shift by default unless `left_shift` is specified as `True`. - - Also accepts a valid Discord Message ID or link. - """ - if offset < 0: - await ctx.send(":no_entry: Cannot use a negative offset.") - return - - if left_shift: - offset = -offset - - def conversion_func(text: str) -> str: - """Encrypts the given string using the Caesar Cipher.""" - return "".join(caesar_cipher(text, offset)) - - text, embed = await Fun._get_text_and_embed(ctx, msg) - - if embed is not None: - embed = Fun._convert_embed(conversion_func, embed) - - converted_text = conversion_func(text) - - if converted_text: - converted_text = f">>> {converted_text.lstrip('> ')}" - - await ctx.send(content=converted_text, embed=embed) - - @caesarcipher_group.command(name="encrypt", aliases=("rightshift", "rshift", "enc",)) - async def caesarcipher_encrypt(self, ctx: Context, offset: int, *, msg: str) -> None: - """ - Given a positive integer `offset`, encrypt the given `msg`. - - Performs a right shift of the letters in the message. - - Also accepts a valid Discord Message ID or link. - """ - await self._caesar_cipher(ctx, offset, msg, left_shift=False) - - @caesarcipher_group.command(name="decrypt", aliases=("leftshift", "lshift", "dec",)) - async def caesarcipher_decrypt(self, ctx: Context, offset: int, *, msg: str) -> None: - """ - Given a positive integer `offset`, decrypt the given `msg`. - - Performs a left shift of the letters in the message. - - Also accepts a valid Discord Message ID or link. - """ - await self._caesar_cipher(ctx, offset, msg, left_shift=True) - - @staticmethod - async def _get_text_and_embed(ctx: Context, text: str) -> tuple[str, Optional[Embed]]: - """ - Attempts to extract the text and embed from a possible link to a discord Message. - - Does not retrieve the text and embed from the Message if it is in a channel the user does - not have read permissions in. - - Returns a tuple of: - str: If `text` is a valid discord Message, the contents of the message, else `text`. - Optional[Embed]: The embed if found in the valid Message, else None - """ - embed = None - - msg = await Fun._get_discord_message(ctx, text) - # Ensure the user has read permissions for the channel the message is in - if isinstance(msg, Message): - permissions = msg.channel.permissions_for(ctx.author) - if permissions.read_messages: - text = msg.clean_content - # Take first embed because we can't send multiple embeds - if msg.embeds: - embed = msg.embeds[0] - - return (text, embed) - - @staticmethod - async def _get_discord_message(ctx: Context, text: str) -> Union[Message, str]: - """ - Attempts to convert a given `text` to a discord Message object and return it. - - Conversion will succeed if given a discord Message ID or link. - Returns `text` if the conversion fails. - """ - try: - text = await MessageConverter().convert(ctx, text) - except commands.BadArgument: - log.debug(f"Input '{text:.20}...' is not a valid Discord Message") - return text - - @staticmethod - def _convert_embed(func: Callable[[str, ], str], embed: Embed) -> Embed: - """ - Converts the text in an embed using a given conversion function, then return the embed. - - Only modifies the following fields: title, description, footer, fields - """ - embed_dict = embed.to_dict() - - embed_dict["title"] = func(embed_dict.get("title", "")) - embed_dict["description"] = func(embed_dict.get("description", "")) - - if "footer" in embed_dict: - embed_dict["footer"]["text"] = func(embed_dict["footer"].get("text", "")) - - if "fields" in embed_dict: - for field in embed_dict["fields"]: - field["name"] = func(field.get("name", "")) - field["value"] = func(field.get("value", "")) - - return Embed.from_dict(embed_dict) - - -def setup(bot: Bot) -> None: - """Load the Fun cog.""" - bot.add_cog(Fun(bot)) diff --git a/bot/exts/evergreen/game.py b/bot/exts/evergreen/game.py deleted file mode 100644 index f9c150e6..00000000 --- a/bot/exts/evergreen/game.py +++ /dev/null @@ -1,485 +0,0 @@ -import difflib -import logging -import random -import re -from asyncio import sleep -from datetime import datetime as dt, timedelta -from enum import IntEnum -from typing import Any, Optional - -from aiohttp import ClientSession -from discord import Embed -from discord.ext import tasks -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.constants import STAFF_ROLES, Tokens -from bot.utils.decorators import with_role -from bot.utils.extensions import invoke_help_command -from bot.utils.pagination import ImagePaginator, LinePaginator - -# Base URL of IGDB API -BASE_URL = "https://api.igdb.com/v4" - -CLIENT_ID = Tokens.igdb_client_id -CLIENT_SECRET = Tokens.igdb_client_secret - -# The number of seconds before expiry that we attempt to re-fetch a new access token -ACCESS_TOKEN_RENEWAL_WINDOW = 60*60*24*2 - -# URL to request API access token -OAUTH_URL = "https://id.twitch.tv/oauth2/token" - -OAUTH_PARAMS = { - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "grant_type": "client_credentials" -} - -BASE_HEADERS = { - "Client-ID": CLIENT_ID, - "Accept": "application/json" -} - -logger = logging.getLogger(__name__) - -REGEX_NON_ALPHABET = re.compile(r"[^a-z0-9]", re.IGNORECASE) - -# --------- -# TEMPLATES -# --------- - -# Body templates -# Request body template for get_games_list -GAMES_LIST_BODY = ( - "fields cover.image_id, first_release_date, total_rating, name, storyline, url, platforms.name, status," - "involved_companies.company.name, summary, age_ratings.category, age_ratings.rating, total_rating_count;" - "{sort} {limit} {offset} {genre} {additional}" -) - -# Request body template for get_companies_list -COMPANIES_LIST_BODY = ( - "fields name, url, start_date, logo.image_id, developed.name, published.name, description;" - "offset {offset}; limit {limit};" -) - -# Request body template for games search -SEARCH_BODY = 'fields name, url, storyline, total_rating, total_rating_count; limit 50; search "{term}";' - -# Pages templates -# Game embed layout -GAME_PAGE = ( - "**[{name}]({url})**\n" - "{description}" - "**Release Date:** {release_date}\n" - "**Rating:** {rating}/100 :star: (based on {rating_count} ratings)\n" - "**Platforms:** {platforms}\n" - "**Status:** {status}\n" - "**Age Ratings:** {age_ratings}\n" - "**Made by:** {made_by}\n\n" - "{storyline}" -) - -# .games company command page layout -COMPANY_PAGE = ( - "**[{name}]({url})**\n" - "{description}" - "**Founded:** {founded}\n" - "**Developed:** {developed}\n" - "**Published:** {published}" -) - -# For .games search command line layout -GAME_SEARCH_LINE = ( - "**[{name}]({url})**\n" - "{rating}/100 :star: (based on {rating_count} ratings)\n" -) - -# URL templates -COVER_URL = "https://images.igdb.com/igdb/image/upload/t_cover_big/{image_id}.jpg" -LOGO_URL = "https://images.igdb.com/igdb/image/upload/t_logo_med/{image_id}.png" - -# Create aliases for complex genre names -ALIASES = { - "Role-playing (rpg)": ["Role playing", "Rpg"], - "Turn-based strategy (tbs)": ["Turn based strategy", "Tbs"], - "Real time strategy (rts)": ["Real time strategy", "Rts"], - "Hack and slash/beat 'em up": ["Hack and slash"] -} - - -class GameStatus(IntEnum): - """Game statuses in IGDB API.""" - - Released = 0 - Alpha = 2 - Beta = 3 - Early = 4 - Offline = 5 - Cancelled = 6 - Rumored = 7 - - -class AgeRatingCategories(IntEnum): - """IGDB API Age Rating categories IDs.""" - - ESRB = 1 - PEGI = 2 - - -class AgeRatings(IntEnum): - """PEGI/ESRB ratings IGDB API IDs.""" - - Three = 1 - Seven = 2 - Twelve = 3 - Sixteen = 4 - Eighteen = 5 - RP = 6 - EC = 7 - E = 8 - E10 = 9 - T = 10 - M = 11 - AO = 12 - - -class Games(Cog): - """Games Cog contains commands that collect data from IGDB.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.http_session: ClientSession = bot.http_session - - self.genres: dict[str, int] = {} - self.headers = BASE_HEADERS - - self.bot.loop.create_task(self.renew_access_token()) - - async def renew_access_token(self) -> None: - """Refeshes V4 access token a number of seconds before expiry. See `ACCESS_TOKEN_RENEWAL_WINDOW`.""" - while True: - async with self.http_session.post(OAUTH_URL, params=OAUTH_PARAMS) as resp: - result = await resp.json() - if resp.status != 200: - # If there is a valid access token continue to use that, - # otherwise unload cog. - if "Authorization" in self.headers: - time_delta = timedelta(seconds=ACCESS_TOKEN_RENEWAL_WINDOW) - logger.error( - "Failed to renew IGDB access token. " - f"Current token will last for {time_delta} " - f"OAuth response message: {result['message']}" - ) - else: - logger.warning( - "Invalid OAuth credentials. Unloading Games cog. " - f"OAuth response message: {result['message']}" - ) - self.bot.remove_cog("Games") - - return - - self.headers["Authorization"] = f"Bearer {result['access_token']}" - - # Attempt to renew before the token expires - next_renewal = result["expires_in"] - ACCESS_TOKEN_RENEWAL_WINDOW - - time_delta = timedelta(seconds=next_renewal) - logger.info(f"Successfully renewed access token. Refreshing again in {time_delta}") - - # This will be true the first time this loop runs. - # Since we now have an access token, its safe to start this task. - if self.genres == {}: - self.refresh_genres_task.start() - await sleep(next_renewal) - - @tasks.loop(hours=24.0) - async def refresh_genres_task(self) -> None: - """Refresh genres in every hour.""" - try: - await self._get_genres() - except Exception as e: - logger.warning(f"There was error while refreshing genres: {e}") - return - logger.info("Successfully refreshed genres.") - - def cog_unload(self) -> None: - """Cancel genres refreshing start when unloading Cog.""" - self.refresh_genres_task.cancel() - logger.info("Successfully stopped Genres Refreshing task.") - - async def _get_genres(self) -> None: - """Create genres variable for games command.""" - body = "fields name; limit 100;" - async with self.http_session.post(f"{BASE_URL}/genres", data=body, headers=self.headers) as resp: - result = await resp.json() - genres = {genre["name"].capitalize(): genre["id"] for genre in result} - - # Replace complex names with names from ALIASES - for genre_name, genre in genres.items(): - if genre_name in ALIASES: - for alias in ALIASES[genre_name]: - self.genres[alias] = genre - else: - self.genres[genre_name] = genre - - @group(name="games", aliases=("game",), invoke_without_command=True) - async def games(self, ctx: Context, amount: Optional[int] = 5, *, genre: Optional[str]) -> None: - """ - Get random game(s) by genre from IGDB. Use .games genres command to get all available genres. - - Also support amount parameter, what max is 25 and min 1, default 5. Supported formats: - - .games <genre> - - .games <amount> <genre> - """ - # When user didn't specified genre, send help message - if genre is None: - await invoke_help_command(ctx) - return - - # Capitalize genre for check - genre = "".join(genre).capitalize() - - # Check for amounts, max is 25 and min 1 - if not 1 <= amount <= 25: - await ctx.send("Your provided amount is out of range. Our minimum is 1 and maximum 25.") - return - - # Get games listing, if genre don't exist, show error message with possibilities. - # Offset must be random, due otherwise we will get always same result (offset show in which position should - # API start returning result) - try: - games = await self.get_games_list(amount, self.genres[genre], offset=random.randint(0, 150)) - except KeyError: - possibilities = await self.get_best_results(genre) - # If there is more than 1 possibilities, show these. - # If there is only 1 possibility, use it as genre. - # Otherwise send message about invalid genre. - if len(possibilities) > 1: - display_possibilities = "`, `".join(p[1] for p in possibilities) - await ctx.send( - f"Invalid genre `{genre}`. " - f"{f'Maybe you meant `{display_possibilities}`?' if display_possibilities else ''}" - ) - return - elif len(possibilities) == 1: - games = await self.get_games_list( - amount, self.genres[possibilities[0][1]], offset=random.randint(0, 150) - ) - genre = possibilities[0][1] - else: - await ctx.send(f"Invalid genre `{genre}`.") - return - - # Create pages and paginate - pages = [await self.create_page(game) for game in games] - - await ImagePaginator.paginate(pages, ctx, Embed(title=f"Random {genre.title()} Games")) - - @games.command(name="top", aliases=("t",)) - async def top(self, ctx: Context, amount: int = 10) -> None: - """ - Get current Top games in IGDB. - - Support amount parameter. Max is 25, min is 1. - """ - if not 1 <= amount <= 25: - await ctx.send("Your provided amount is out of range. Our minimum is 1 and maximum 25.") - return - - games = await self.get_games_list(amount, sort="total_rating desc", - additional_body="where total_rating >= 90; sort total_rating_count desc;") - - pages = [await self.create_page(game) for game in games] - await ImagePaginator.paginate(pages, ctx, Embed(title=f"Top {amount} Games")) - - @games.command(name="genres", aliases=("genre", "g")) - async def genres(self, ctx: Context) -> None: - """Get all available genres.""" - await ctx.send(f"Currently available genres: {', '.join(f'`{genre}`' for genre in self.genres)}") - - @games.command(name="search", aliases=("s",)) - async def search(self, ctx: Context, *, search_term: str) -> None: - """Find games by name.""" - lines = await self.search_games(search_term) - - await LinePaginator.paginate(lines, ctx, Embed(title=f"Game Search Results: {search_term}"), empty=False) - - @games.command(name="company", aliases=("companies",)) - async def company(self, ctx: Context, amount: int = 5) -> None: - """ - Get random Game Companies companies from IGDB API. - - Support amount parameter. Max is 25, min is 1. - """ - if not 1 <= amount <= 25: - await ctx.send("Your provided amount is out of range. Our minimum is 1 and maximum 25.") - return - - # Get companies listing. Provide limit for limiting how much companies will be returned. Get random offset to - # get (almost) every time different companies (offset show in which position should API start returning result) - companies = await self.get_companies_list(limit=amount, offset=random.randint(0, 150)) - pages = [await self.create_company_page(co) for co in companies] - - await ImagePaginator.paginate(pages, ctx, Embed(title="Random Game Companies")) - - @with_role(*STAFF_ROLES) - @games.command(name="refresh", aliases=("r",)) - async def refresh_genres_command(self, ctx: Context) -> None: - """Refresh .games command genres.""" - try: - await self._get_genres() - except Exception as e: - await ctx.send(f"There was error while refreshing genres: `{e}`") - return - await ctx.send("Successfully refreshed genres.") - - async def get_games_list( - self, - amount: int, - genre: Optional[str] = None, - sort: Optional[str] = None, - additional_body: str = "", - offset: int = 0 - ) -> list[dict[str, Any]]: - """ - Get list of games from IGDB API by parameters that is provided. - - Amount param show how much games this get, genre is genre ID and at least one genre in game must this when - provided. Sort is sorting by specific field and direction, ex. total_rating desc/asc (total_rating is field, - desc/asc is direction). Additional_body is field where you can pass extra search parameters. Offset show start - position in API. - """ - # Create body of IGDB API request, define fields, sorting, offset, limit and genre - params = { - "sort": f"sort {sort};" if sort else "", - "limit": f"limit {amount};", - "offset": f"offset {offset};" if offset else "", - "genre": f"where genres = ({genre});" if genre else "", - "additional": additional_body - } - body = GAMES_LIST_BODY.format(**params) - - # Do request to IGDB API, create headers, URL, define body, return result - async with self.http_session.post(url=f"{BASE_URL}/games", data=body, headers=self.headers) as resp: - return await resp.json() - - async def create_page(self, data: dict[str, Any]) -> tuple[str, str]: - """Create content of Game Page.""" - # Create cover image URL from template - url = COVER_URL.format(**{"image_id": data["cover"]["image_id"] if "cover" in data else ""}) - - # Get release date separately with checking - release_date = dt.utcfromtimestamp(data["first_release_date"]).date() if "first_release_date" in data else "?" - - # Create Age Ratings value - rating = ", ".join( - f"{AgeRatingCategories(age['category']).name} {AgeRatings(age['rating']).name}" - for age in data["age_ratings"] - ) if "age_ratings" in data else "?" - - companies = [c["company"]["name"] for c in data["involved_companies"]] if "involved_companies" in data else "?" - - # Create formatting for template page - formatting = { - "name": data["name"], - "url": data["url"], - "description": f"{data['summary']}\n\n" if "summary" in data else "\n", - "release_date": release_date, - "rating": round(data["total_rating"] if "total_rating" in data else 0, 2), - "rating_count": data["total_rating_count"] if "total_rating_count" in data else "?", - "platforms": ", ".join(platform["name"] for platform in data["platforms"]) if "platforms" in data else "?", - "status": GameStatus(data["status"]).name if "status" in data else "?", - "age_ratings": rating, - "made_by": ", ".join(companies), - "storyline": data["storyline"] if "storyline" in data else "" - } - page = GAME_PAGE.format(**formatting) - - return page, url - - async def search_games(self, search_term: str) -> list[str]: - """Search game from IGDB API by string, return listing of pages.""" - lines = [] - - # Define request body of IGDB API request and do request - body = SEARCH_BODY.format(**{"term": search_term}) - - async with self.http_session.post(url=f"{BASE_URL}/games", data=body, headers=self.headers) as resp: - data = await resp.json() - - # Loop over games, format them to good format, make line and append this to total lines - for game in data: - formatting = { - "name": game["name"], - "url": game["url"], - "rating": round(game["total_rating"] if "total_rating" in game else 0, 2), - "rating_count": game["total_rating_count"] if "total_rating" in game else "?" - } - line = GAME_SEARCH_LINE.format(**formatting) - lines.append(line) - - return lines - - async def get_companies_list(self, limit: int, offset: int = 0) -> list[dict[str, Any]]: - """ - Get random Game Companies from IGDB API. - - Limit is parameter, that show how much movies this should return, offset show in which position should API start - returning results. - """ - # Create request body from template - body = COMPANIES_LIST_BODY.format(**{ - "limit": limit, - "offset": offset - }) - - async with self.http_session.post(url=f"{BASE_URL}/companies", data=body, headers=self.headers) as resp: - return await resp.json() - - async def create_company_page(self, data: dict[str, Any]) -> tuple[str, str]: - """Create good formatted Game Company page.""" - # Generate URL of company logo - url = LOGO_URL.format(**{"image_id": data["logo"]["image_id"] if "logo" in data else ""}) - - # Try to get found date of company - founded = dt.utcfromtimestamp(data["start_date"]).date() if "start_date" in data else "?" - - # Generate list of games, that company have developed or published - developed = ", ".join(game["name"] for game in data["developed"]) if "developed" in data else "?" - published = ", ".join(game["name"] for game in data["published"]) if "published" in data else "?" - - formatting = { - "name": data["name"], - "url": data["url"], - "description": f"{data['description']}\n\n" if "description" in data else "\n", - "founded": founded, - "developed": developed, - "published": published - } - page = COMPANY_PAGE.format(**formatting) - - return page, url - - async def get_best_results(self, query: str) -> list[tuple[float, str]]: - """Get best match result of genre when original genre is invalid.""" - results = [] - for genre in self.genres: - ratios = [difflib.SequenceMatcher(None, query, genre).ratio()] - for word in REGEX_NON_ALPHABET.split(genre): - ratios.append(difflib.SequenceMatcher(None, query, word).ratio()) - results.append((round(max(ratios), 2), genre)) - return sorted((item for item in results if item[0] >= 0.60), reverse=True)[:4] - - -def setup(bot: Bot) -> None: - """Load the Games cog.""" - # Check does IGDB API key exist, if not, log warning and don't load cog - if not Tokens.igdb_client_id: - logger.warning("No IGDB client ID. Not loading Games cog.") - return - if not Tokens.igdb_client_secret: - logger.warning("No IGDB client secret. Not loading Games cog.") - return - bot.add_cog(Games(bot)) diff --git a/bot/exts/evergreen/magic_8ball.py b/bot/exts/evergreen/magic_8ball.py deleted file mode 100644 index 28ddcea0..00000000 --- a/bot/exts/evergreen/magic_8ball.py +++ /dev/null @@ -1,30 +0,0 @@ -import json -import logging -import random -from pathlib import Path - -from discord.ext import commands - -from bot.bot import Bot - -log = logging.getLogger(__name__) - -ANSWERS = json.loads(Path("bot/resources/evergreen/magic8ball.json").read_text("utf8")) - - -class Magic8ball(commands.Cog): - """A Magic 8ball command to respond to a user's question.""" - - @commands.command(name="8ball") - async def output_answer(self, ctx: commands.Context, *, question: str) -> None: - """Return a Magic 8ball answer from answers list.""" - if len(question.split()) >= 3: - answer = random.choice(ANSWERS) - await ctx.send(answer) - else: - await ctx.send("Usage: .8ball <question> (minimum length of 3 eg: `will I win?`)") - - -def setup(bot: Bot) -> None: - """Load the Magic8Ball Cog.""" - bot.add_cog(Magic8ball()) diff --git a/bot/exts/evergreen/minesweeper.py b/bot/exts/evergreen/minesweeper.py deleted file mode 100644 index a48b5051..00000000 --- a/bot/exts/evergreen/minesweeper.py +++ /dev/null @@ -1,270 +0,0 @@ -import logging -from collections.abc import Iterator -from dataclasses import dataclass -from random import randint, random -from typing import Union - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Client -from bot.utils.converters import CoordinateConverter -from bot.utils.exceptions import UserNotPlayingError -from bot.utils.extensions import invoke_help_command - -MESSAGE_MAPPING = { - 0: ":stop_button:", - 1: ":one:", - 2: ":two:", - 3: ":three:", - 4: ":four:", - 5: ":five:", - 6: ":six:", - 7: ":seven:", - 8: ":eight:", - 9: ":nine:", - 10: ":keycap_ten:", - "bomb": ":bomb:", - "hidden": ":grey_question:", - "flag": ":flag_black:", - "x": ":x:" -} - -log = logging.getLogger(__name__) - - -GameBoard = list[list[Union[str, int]]] - - -@dataclass -class Game: - """The data for a game.""" - - board: GameBoard - revealed: GameBoard - dm_msg: discord.Message - chat_msg: discord.Message - activated_on_server: bool - - -class Minesweeper(commands.Cog): - """Play a game of Minesweeper.""" - - def __init__(self): - self.games: dict[int, Game] = {} - - @commands.group(name="minesweeper", aliases=("ms",), invoke_without_command=True) - async def minesweeper_group(self, ctx: commands.Context) -> None: - """Commands for Playing Minesweeper.""" - await invoke_help_command(ctx) - - @staticmethod - def get_neighbours(x: int, y: int) -> Iterator[tuple[int, int]]: - """Get all the neighbouring x and y including it self.""" - for x_ in [x - 1, x, x + 1]: - for y_ in [y - 1, y, y + 1]: - if x_ != -1 and x_ != 10 and y_ != -1 and y_ != 10: - yield x_, y_ - - def generate_board(self, bomb_chance: float) -> GameBoard: - """Generate a 2d array for the board.""" - board: GameBoard = [ - [ - "bomb" if random() <= bomb_chance else "number" - for _ in range(10) - ] for _ in range(10) - ] - - # make sure there is always a free cell - board[randint(0, 9)][randint(0, 9)] = "number" - - for y, row in enumerate(board): - for x, cell in enumerate(row): - if cell == "number": - # calculate bombs near it - bombs = 0 - for x_, y_ in self.get_neighbours(x, y): - if board[y_][x_] == "bomb": - bombs += 1 - board[y][x] = bombs - return board - - @staticmethod - def format_for_discord(board: GameBoard) -> str: - """Format the board as a string for Discord.""" - discord_msg = ( - ":stop_button: :regional_indicator_a: :regional_indicator_b: :regional_indicator_c: " - ":regional_indicator_d: :regional_indicator_e: :regional_indicator_f: :regional_indicator_g: " - ":regional_indicator_h: :regional_indicator_i: :regional_indicator_j:\n\n" - ) - rows = [] - for row_number, row in enumerate(board): - new_row = f"{MESSAGE_MAPPING[row_number + 1]} " - new_row += " ".join(MESSAGE_MAPPING[cell] for cell in row) - rows.append(new_row) - - discord_msg += "\n".join(rows) - return discord_msg - - @minesweeper_group.command(name="start") - async def start_command(self, ctx: commands.Context, bomb_chance: float = .2) -> None: - """Start a game of Minesweeper.""" - if ctx.author.id in self.games: # Player is already playing - await ctx.send(f"{ctx.author.mention} you already have a game running!", delete_after=2) - await ctx.message.delete(delay=2) - return - - try: - await ctx.author.send( - f"Play by typing: `{Client.prefix}ms reveal xy [xy]` or `{Client.prefix}ms flag xy [xy]` \n" - f"Close the game with `{Client.prefix}ms end`\n" - ) - except discord.errors.Forbidden: - log.debug(f"{ctx.author.name} ({ctx.author.id}) has disabled DMs from server members.") - await ctx.send(f":x: {ctx.author.mention}, please enable DMs to play minesweeper.") - return - - # Add game to list - board: GameBoard = self.generate_board(bomb_chance) - revealed_board: GameBoard = [["hidden"] * 10 for _ in range(10)] - dm_msg = await ctx.author.send(f"Here's your board!\n{self.format_for_discord(revealed_board)}") - - if ctx.guild: - await ctx.send(f"{ctx.author.mention} is playing Minesweeper.") - chat_msg = await ctx.send(f"Here's their board!\n{self.format_for_discord(revealed_board)}") - else: - chat_msg = None - - self.games[ctx.author.id] = Game( - board=board, - revealed=revealed_board, - dm_msg=dm_msg, - chat_msg=chat_msg, - activated_on_server=ctx.guild is not None - ) - - async def update_boards(self, ctx: commands.Context) -> None: - """Update both playing boards.""" - game = self.games[ctx.author.id] - await game.dm_msg.delete() - game.dm_msg = await ctx.author.send(f"Here's your board!\n{self.format_for_discord(game.revealed)}") - if game.activated_on_server: - await game.chat_msg.edit(content=f"Here's their board!\n{self.format_for_discord(game.revealed)}") - - @commands.dm_only() - @minesweeper_group.command(name="flag") - async def flag_command(self, ctx: commands.Context, *coordinates: CoordinateConverter) -> None: - """Place multiple flags on the board.""" - if ctx.author.id not in self.games: - raise UserNotPlayingError - board: GameBoard = self.games[ctx.author.id].revealed - for x, y in coordinates: - if board[y][x] == "hidden": - board[y][x] = "flag" - - await self.update_boards(ctx) - - @staticmethod - def reveal_bombs(revealed: GameBoard, board: GameBoard) -> None: - """Reveals all the bombs.""" - for y, row in enumerate(board): - for x, cell in enumerate(row): - if cell == "bomb": - revealed[y][x] = cell - - async def lost(self, ctx: commands.Context) -> None: - """The player lost the game.""" - game = self.games[ctx.author.id] - self.reveal_bombs(game.revealed, game.board) - await ctx.author.send(":fire: You lost! :fire:") - if game.activated_on_server: - await game.chat_msg.channel.send(f":fire: {ctx.author.mention} just lost Minesweeper! :fire:") - - async def won(self, ctx: commands.Context) -> None: - """The player won the game.""" - game = self.games[ctx.author.id] - await ctx.author.send(":tada: You won! :tada:") - if game.activated_on_server: - await game.chat_msg.channel.send(f":tada: {ctx.author.mention} just won Minesweeper! :tada:") - - def reveal_zeros(self, revealed: GameBoard, board: GameBoard, x: int, y: int) -> None: - """Recursively reveal adjacent cells when a 0 cell is encountered.""" - for x_, y_ in self.get_neighbours(x, y): - if revealed[y_][x_] != "hidden": - continue - revealed[y_][x_] = board[y_][x_] - if board[y_][x_] == 0: - self.reveal_zeros(revealed, board, x_, y_) - - async def check_if_won(self, ctx: commands.Context, revealed: GameBoard, board: GameBoard) -> bool: - """Checks if a player has won.""" - if any( - revealed[y][x] in ["hidden", "flag"] and board[y][x] != "bomb" - for x in range(10) - for y in range(10) - ): - return False - else: - await self.won(ctx) - return True - - async def reveal_one( - self, - ctx: commands.Context, - revealed: GameBoard, - board: GameBoard, - x: int, - y: int - ) -> bool: - """ - Reveal one square. - - return is True if the game ended, breaking the loop in `reveal_command` and deleting the game. - """ - revealed[y][x] = board[y][x] - if board[y][x] == "bomb": - await self.lost(ctx) - revealed[y][x] = "x" # mark bomb that made you lose with a x - return True - elif board[y][x] == 0: - self.reveal_zeros(revealed, board, x, y) - return await self.check_if_won(ctx, revealed, board) - - @commands.dm_only() - @minesweeper_group.command(name="reveal") - async def reveal_command(self, ctx: commands.Context, *coordinates: CoordinateConverter) -> None: - """Reveal multiple cells.""" - if ctx.author.id not in self.games: - raise UserNotPlayingError - game = self.games[ctx.author.id] - revealed: GameBoard = game.revealed - board: GameBoard = game.board - - for x, y in coordinates: - # reveal_one returns True if the revealed cell is a bomb or the player won, ending the game - if await self.reveal_one(ctx, revealed, board, x, y): - await self.update_boards(ctx) - del self.games[ctx.author.id] - break - else: - await self.update_boards(ctx) - - @minesweeper_group.command(name="end") - async def end_command(self, ctx: commands.Context) -> None: - """End your current game.""" - if ctx.author.id not in self.games: - raise UserNotPlayingError - game = self.games[ctx.author.id] - game.revealed = game.board - await self.update_boards(ctx) - new_msg = f":no_entry: Game canceled. :no_entry:\n{game.dm_msg.content}" - await game.dm_msg.edit(content=new_msg) - if game.activated_on_server: - await game.chat_msg.edit(content=new_msg) - del self.games[ctx.author.id] - - -def setup(bot: Bot) -> None: - """Load the Minesweeper cog.""" - bot.add_cog(Minesweeper()) diff --git a/bot/exts/evergreen/movie.py b/bot/exts/evergreen/movie.py deleted file mode 100644 index a04eeb41..00000000 --- a/bot/exts/evergreen/movie.py +++ /dev/null @@ -1,205 +0,0 @@ -import logging -import random -from enum import Enum -from typing import Any - -from aiohttp import ClientSession -from discord import Embed -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.constants import Tokens -from bot.utils.extensions import invoke_help_command -from bot.utils.pagination import ImagePaginator - -# Define base URL of TMDB -BASE_URL = "https://api.themoviedb.org/3/" - -logger = logging.getLogger(__name__) - -# Define movie params, that will be used for every movie request -MOVIE_PARAMS = { - "api_key": Tokens.tmdb, - "language": "en-US" -} - - -class MovieGenres(Enum): - """Movies Genre names and IDs.""" - - Action = "28" - Adventure = "12" - Animation = "16" - Comedy = "35" - Crime = "80" - Documentary = "99" - Drama = "18" - Family = "10751" - Fantasy = "14" - History = "36" - Horror = "27" - Music = "10402" - Mystery = "9648" - Romance = "10749" - Science = "878" - Thriller = "53" - Western = "37" - - -class Movie(Cog): - """Movie Cog contains movies command that grab random movies from TMDB.""" - - def __init__(self, bot: Bot): - self.http_session: ClientSession = bot.http_session - - @group(name="movies", aliases=("movie",), invoke_without_command=True) - async def movies(self, ctx: Context, genre: str = "", amount: int = 5) -> None: - """ - Get random movies by specifying genre. Also support amount parameter, that define how much movies will be shown. - - Default 5. Use .movies genres to get all available genres. - """ - # Check is there more than 20 movies specified, due TMDB return 20 movies - # per page, so this is max. Also you can't get less movies than 1, just logic - if amount > 20: - await ctx.send("You can't get more than 20 movies at once. (TMDB limits)") - return - elif amount < 1: - await ctx.send("You can't get less than 1 movie.") - return - - # Capitalize genre for getting data from Enum, get random page, send help when genre don't exist. - genre = genre.capitalize() - try: - result = await self.get_movies_data(self.http_session, MovieGenres[genre].value, 1) - except KeyError: - await invoke_help_command(ctx) - return - - # Check if "results" is in result. If not, throw error. - if "results" not in result: - err_msg = ( - f"There is problem while making TMDB API request. Response Code: {result['status_code']}, " - f"{result['status_message']}." - ) - await ctx.send(err_msg) - logger.warning(err_msg) - - # Get random page. Max page is last page where is movies with this genre. - page = random.randint(1, result["total_pages"]) - - # Get movies list from TMDB, check if results key in result. When not, raise error. - movies = await self.get_movies_data(self.http_session, MovieGenres[genre].value, page) - if "results" not in movies: - err_msg = f"There is problem while making TMDB API request. Response Code: {result['status_code']}, " \ - f"{result['status_message']}." - await ctx.send(err_msg) - logger.warning(err_msg) - - # Get all pages and embed - pages = await self.get_pages(self.http_session, movies, amount) - embed = await self.get_embed(genre) - - await ImagePaginator.paginate(pages, ctx, embed) - - @movies.command(name="genres", aliases=("genre", "g")) - async def genres(self, ctx: Context) -> None: - """Show all currently available genres for .movies command.""" - await ctx.send(f"Current available genres: {', '.join('`' + genre.name + '`' for genre in MovieGenres)}") - - async def get_movies_data(self, client: ClientSession, genre_id: str, page: int) -> list[dict[str, Any]]: - """Return JSON of TMDB discover request.""" - # Define params of request - params = { - "api_key": Tokens.tmdb, - "language": "en-US", - "sort_by": "popularity.desc", - "include_adult": "false", - "include_video": "false", - "page": page, - "with_genres": genre_id - } - - url = BASE_URL + "discover/movie" - - # Make discover request to TMDB, return result - async with client.get(url, params=params) as resp: - return await resp.json() - - async def get_pages(self, client: ClientSession, movies: dict[str, Any], amount: int) -> list[tuple[str, str]]: - """Fetch all movie pages from movies dictionary. Return list of pages.""" - pages = [] - - for i in range(amount): - movie_id = movies["results"][i]["id"] - movie = await self.get_movie(client, movie_id) - - page, img = await self.create_page(movie) - pages.append((page, img)) - - return pages - - async def get_movie(self, client: ClientSession, movie: int) -> dict[str, Any]: - """Get Movie by movie ID from TMDB. Return result dictionary.""" - if not isinstance(movie, int): - raise ValueError("Error while fetching movie from TMDB, movie argument must be integer. ") - url = BASE_URL + f"movie/{movie}" - - async with client.get(url, params=MOVIE_PARAMS) as resp: - return await resp.json() - - async def create_page(self, movie: dict[str, Any]) -> tuple[str, str]: - """Create page from TMDB movie request result. Return formatted page + image.""" - text = "" - - # Add title + tagline (if not empty) - text += f"**{movie['title']}**\n" - if movie["tagline"]: - text += f"{movie['tagline']}\n\n" - else: - text += "\n" - - # Add other information - text += f"**Rating:** {movie['vote_average']}/10 :star:\n" - text += f"**Release Date:** {movie['release_date']}\n\n" - - text += "__**Production Information**__\n" - - companies = movie["production_companies"] - countries = movie["production_countries"] - - text += f"**Made by:** {', '.join(company['name'] for company in companies)}\n" - text += f"**Made in:** {', '.join(country['name'] for country in countries)}\n\n" - - text += "__**Some Numbers**__\n" - - budget = f"{movie['budget']:,d}" if movie['budget'] else "?" - revenue = f"{movie['revenue']:,d}" if movie['revenue'] else "?" - - if movie["runtime"] is not None: - duration = divmod(movie["runtime"], 60) - else: - duration = ("?", "?") - - text += f"**Budget:** ${budget}\n" - text += f"**Revenue:** ${revenue}\n" - text += f"**Duration:** {f'{duration[0]} hour(s) {duration[1]} minute(s)'}\n\n" - - text += movie["overview"] - - img = f"http://image.tmdb.org/t/p/w200{movie['poster_path']}" - - # Return page content and image - return text, img - - async def get_embed(self, name: str) -> Embed: - """Return embed of random movies. Uses name in title.""" - embed = Embed(title=f"Random {name} Movies") - embed.set_footer(text="This product uses the TMDb API but is not endorsed or certified by TMDb.") - embed.set_thumbnail(url="https://i.imgur.com/LtFtC8H.png") - return embed - - -def setup(bot: Bot) -> None: - """Load the Movie Cog.""" - bot.add_cog(Movie(bot)) diff --git a/bot/exts/evergreen/recommend_game.py b/bot/exts/evergreen/recommend_game.py deleted file mode 100644 index bdd3acb1..00000000 --- a/bot/exts/evergreen/recommend_game.py +++ /dev/null @@ -1,51 +0,0 @@ -import json -import logging -from pathlib import Path -from random import shuffle - -import discord -from discord.ext import commands - -from bot.bot import Bot - -log = logging.getLogger(__name__) -game_recs = [] - -# Populate the list `game_recs` with resource files -for rec_path in Path("bot/resources/evergreen/game_recs").glob("*.json"): - data = json.loads(rec_path.read_text("utf8")) - game_recs.append(data) -shuffle(game_recs) - - -class RecommendGame(commands.Cog): - """Commands related to recommending games.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.index = 0 - - @commands.command(name="recommendgame", aliases=("gamerec",)) - async def recommend_game(self, ctx: commands.Context) -> None: - """Sends an Embed of a random game recommendation.""" - if self.index >= len(game_recs): - self.index = 0 - shuffle(game_recs) - game = game_recs[self.index] - self.index += 1 - - author = self.bot.get_user(int(game["author"])) - - # Creating and formatting Embed - embed = discord.Embed(color=discord.Colour.blue()) - if author is not None: - embed.set_author(name=author.name, icon_url=author.display_avatar.url) - embed.set_image(url=game["image"]) - embed.add_field(name=f"Recommendation: {game['title']}\n{game['link']}", value=game["description"]) - - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Loads the RecommendGame cog.""" - bot.add_cog(RecommendGame(bot)) diff --git a/bot/exts/evergreen/rps.py b/bot/exts/evergreen/rps.py deleted file mode 100644 index c6bbff46..00000000 --- a/bot/exts/evergreen/rps.py +++ /dev/null @@ -1,57 +0,0 @@ -from random import choice - -from discord.ext import commands - -from bot.bot import Bot - -CHOICES = ["rock", "paper", "scissors"] -SHORT_CHOICES = ["r", "p", "s"] - -# Using a dictionary instead of conditions to check for the winner. -WINNER_DICT = { - "r": { - "r": 0, - "p": -1, - "s": 1, - }, - "p": { - "r": 1, - "p": 0, - "s": -1, - }, - "s": { - "r": -1, - "p": 1, - "s": 0, - } -} - - -class RPS(commands.Cog): - """Rock Paper Scissors. The Classic Game!""" - - @commands.command(case_insensitive=True) - async def rps(self, ctx: commands.Context, move: str) -> None: - """Play the classic game of Rock Paper Scissors with your own sir-lancebot!""" - move = move.lower() - player_mention = ctx.author.mention - - if move not in CHOICES and move not in SHORT_CHOICES: - raise commands.BadArgument(f"Invalid move. Please make move from options: {', '.join(CHOICES).upper()}.") - - bot_move = choice(CHOICES) - # value of player_result will be from (-1, 0, 1) as (lost, tied, won). - player_result = WINNER_DICT[move[0]][bot_move[0]] - - if player_result == 0: - message_string = f"{player_mention} You and Sir Lancebot played {bot_move}, it's a tie." - await ctx.send(message_string) - elif player_result == 1: - await ctx.send(f"Sir Lancebot played {bot_move}! {player_mention} won!") - else: - await ctx.send(f"Sir Lancebot played {bot_move}! {player_mention} lost!") - - -def setup(bot: Bot) -> None: - """Load the RPS Cog.""" - bot.add_cog(RPS(bot)) diff --git a/bot/exts/evergreen/space.py b/bot/exts/evergreen/space.py deleted file mode 100644 index 48ad0f96..00000000 --- a/bot/exts/evergreen/space.py +++ /dev/null @@ -1,236 +0,0 @@ -import logging -import random -from datetime import date, datetime -from typing import Any, Optional -from urllib.parse import urlencode - -from discord import Embed -from discord.ext import tasks -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.constants import Tokens -from bot.utils.converters import DateConverter -from bot.utils.extensions import invoke_help_command - -logger = logging.getLogger(__name__) - -NASA_BASE_URL = "https://api.nasa.gov" -NASA_IMAGES_BASE_URL = "https://images-api.nasa.gov" -NASA_EPIC_BASE_URL = "https://epic.gsfc.nasa.gov" - -APOD_MIN_DATE = date(1995, 6, 16) - - -class Space(Cog): - """Space Cog contains commands, that show images, facts or other information about space.""" - - def __init__(self, bot: Bot): - self.http_session = bot.http_session - - self.rovers = {} - self.get_rovers.start() - - def cog_unload(self) -> None: - """Cancel `get_rovers` task when Cog will unload.""" - self.get_rovers.cancel() - - @tasks.loop(hours=24) - async def get_rovers(self) -> None: - """Get listing of rovers from NASA API and info about their start and end dates.""" - data = await self.fetch_from_nasa("mars-photos/api/v1/rovers") - - for rover in data["rovers"]: - self.rovers[rover["name"].lower()] = { - "min_date": rover["landing_date"], - "max_date": rover["max_date"], - "max_sol": rover["max_sol"] - } - - @group(name="space", invoke_without_command=True) - async def space(self, ctx: Context) -> None: - """Head command that contains commands about space.""" - await invoke_help_command(ctx) - - @space.command(name="apod") - async def apod(self, ctx: Context, date: Optional[str]) -> None: - """ - Get Astronomy Picture of Day from NASA API. Date is optional parameter, what formatting is YYYY-MM-DD. - - If date is not specified, this will get today APOD. - """ - params = {} - # Parse date to params, when provided. Show error message when invalid formatting - if date: - try: - apod_date = datetime.strptime(date, "%Y-%m-%d").date() - except ValueError: - await ctx.send(f"Invalid date {date}. Please make sure your date is in format YYYY-MM-DD.") - return - - now = datetime.now().date() - if APOD_MIN_DATE > apod_date or now < apod_date: - await ctx.send(f"Date must be between {APOD_MIN_DATE.isoformat()} and {now.isoformat()} (today).") - return - - params["date"] = apod_date.isoformat() - - result = await self.fetch_from_nasa("planetary/apod", params) - - await ctx.send( - embed=self.create_nasa_embed( - f"Astronomy Picture of the Day - {result['date']}", - result["explanation"], - result["url"] - ) - ) - - @space.command(name="nasa") - async def nasa(self, ctx: Context, *, search_term: Optional[str]) -> None: - """Get random NASA information/facts + image. Support `search_term` parameter for more specific search.""" - params = { - "media_type": "image" - } - if search_term: - params["q"] = search_term - - # Don't use API key, no need for this. - data = await self.fetch_from_nasa("search", params, NASA_IMAGES_BASE_URL, use_api_key=False) - if len(data["collection"]["items"]) == 0: - await ctx.send(f"Can't find any items with search term `{search_term}`.") - return - - item = random.choice(data["collection"]["items"]) - - await ctx.send( - embed=self.create_nasa_embed( - item["data"][0]["title"], - item["data"][0]["description"], - item["links"][0]["href"] - ) - ) - - @space.command(name="epic") - async def epic(self, ctx: Context, date: Optional[str]) -> None: - """Get a random image of the Earth from the NASA EPIC API. Support date parameter, format is YYYY-MM-DD.""" - if date: - try: - show_date = datetime.strptime(date, "%Y-%m-%d").date().isoformat() - except ValueError: - await ctx.send(f"Invalid date {date}. Please make sure your date is in format YYYY-MM-DD.") - return - else: - show_date = None - - # Don't use API key, no need for this. - data = await self.fetch_from_nasa( - f"api/natural{f'/date/{show_date}' if show_date else ''}", - base=NASA_EPIC_BASE_URL, - use_api_key=False - ) - if len(data) < 1: - await ctx.send("Can't find any images in this date.") - return - - item = random.choice(data) - - year, month, day = item["date"].split(" ")[0].split("-") - image_url = f"{NASA_EPIC_BASE_URL}/archive/natural/{year}/{month}/{day}/jpg/{item['image']}.jpg" - - await ctx.send( - embed=self.create_nasa_embed( - "Earth Image", item["caption"], image_url, f" \u2022 Identifier: {item['identifier']}" - ) - ) - - @space.group(name="mars", invoke_without_command=True) - async def mars( - self, - ctx: Context, - date: Optional[DateConverter], - rover: str = "curiosity" - ) -> None: - """ - Get random Mars image by date. Support both SOL (martian solar day) and earth date and rovers. - - Earth date formatting is YYYY-MM-DD. Use `.space mars dates` to get all currently available rovers. - """ - rover = rover.lower() - if rover not in self.rovers: - await ctx.send( - ( - f"Invalid rover `{rover}`.\n" - f"**Rovers:** `{'`, `'.join(f'{r.capitalize()}' for r in self.rovers)}`" - ) - ) - return - - # When date not provided, get random SOL date between 0 and rover's max. - if date is None: - date = random.randint(0, self.rovers[rover]["max_sol"]) - - params = {} - if isinstance(date, int): - params["sol"] = date - else: - params["earth_date"] = date.date().isoformat() - - result = await self.fetch_from_nasa(f"mars-photos/api/v1/rovers/{rover}/photos", params) - if len(result["photos"]) < 1: - err_msg = ( - f"We can't find result in date " - f"{date.date().isoformat() if isinstance(date, datetime) else f'{date} SOL'}.\n" - f"**Note:** Dates must match with rover's working dates. Please use `{ctx.prefix}space mars dates` to " - "see working dates for each rover." - ) - await ctx.send(err_msg) - return - - item = random.choice(result["photos"]) - await ctx.send( - embed=self.create_nasa_embed( - f"{item['rover']['name']}'s {item['camera']['full_name']} Mars Image", "", item["img_src"], - ) - ) - - @mars.command(name="dates", aliases=("d", "date", "rover", "rovers", "r")) - async def dates(self, ctx: Context) -> None: - """Get current available rovers photo date ranges.""" - await ctx.send("\n".join( - f"**{r.capitalize()}:** {i['min_date']} **-** {i['max_date']}" for r, i in self.rovers.items() - )) - - async def fetch_from_nasa( - self, - endpoint: str, - additional_params: Optional[dict[str, Any]] = None, - base: Optional[str] = NASA_BASE_URL, - use_api_key: bool = True - ) -> dict[str, Any]: - """Fetch information from NASA API, return result.""" - params = {} - if use_api_key: - params["api_key"] = Tokens.nasa - - # Add additional parameters to request parameters only when they provided by user - if additional_params is not None: - params.update(additional_params) - - async with self.http_session.get(url=f"{base}/{endpoint}?{urlencode(params)}") as resp: - return await resp.json() - - def create_nasa_embed(self, title: str, description: str, image: str, footer: Optional[str] = "") -> Embed: - """Generate NASA commands embeds. Required: title, description and image URL, footer (addition) is optional.""" - return Embed( - title=title, - description=description - ).set_image(url=image).set_footer(text="Powered by NASA API" + footer) - - -def setup(bot: Bot) -> None: - """Load the Space cog.""" - if not Tokens.nasa: - logger.warning("Can't find NASA API key. Not loading Space Cog.") - return - - bot.add_cog(Space(bot)) diff --git a/bot/exts/evergreen/speedrun.py b/bot/exts/evergreen/speedrun.py deleted file mode 100644 index 774eff81..00000000 --- a/bot/exts/evergreen/speedrun.py +++ /dev/null @@ -1,26 +0,0 @@ -import json -import logging -from pathlib import Path -from random import choice - -from discord.ext import commands - -from bot.bot import Bot - -log = logging.getLogger(__name__) - -LINKS = json.loads(Path("bot/resources/evergreen/speedrun_links.json").read_text("utf8")) - - -class Speedrun(commands.Cog): - """Commands about the video game speedrunning community.""" - - @commands.command(name="speedrun") - async def get_speedrun(self, ctx: commands.Context) -> None: - """Sends a link to a video of a random speedrun.""" - await ctx.send(choice(LINKS)) - - -def setup(bot: Bot) -> None: - """Load the Speedrun cog.""" - bot.add_cog(Speedrun()) diff --git a/bot/exts/evergreen/status_codes.py b/bot/exts/evergreen/status_codes.py deleted file mode 100644 index 501cbe0a..00000000 --- a/bot/exts/evergreen/status_codes.py +++ /dev/null @@ -1,87 +0,0 @@ -from random import choice - -import discord -from discord.ext import commands - -from bot.bot import Bot - -HTTP_DOG_URL = "https://httpstatusdogs.com/img/{code}.jpg" -HTTP_CAT_URL = "https://http.cat/{code}.jpg" -STATUS_TEMPLATE = "**Status: {code}**" -ERR_404 = "Unable to find status floof for {code}." -ERR_UNKNOWN = "Error attempting to retrieve status floof for {code}." -ERROR_LENGTH_EMBED = discord.Embed( - title="Input status code does not exist", - description="The range of valid status codes is 100 to 599", -) - - -class HTTPStatusCodes(commands.Cog): - """ - Fetch an image depicting HTTP status codes as a dog or a cat. - - If neither animal is selected a cat or dog is chosen randomly for the given status code. - """ - - def __init__(self, bot: Bot): - self.bot = bot - - @commands.group( - name="http_status", - aliases=("status", "httpstatus"), - invoke_without_command=True, - ) - async def http_status_group(self, ctx: commands.Context, code: int) -> None: - """Choose a cat or dog randomly for the given status code.""" - subcmd = choice((self.http_cat, self.http_dog)) - await subcmd(ctx, code) - - @http_status_group.command(name="cat") - async def http_cat(self, ctx: commands.Context, code: int) -> None: - """Send a cat version of the requested HTTP status code.""" - if code in range(100, 600): - await self.build_embed(url=HTTP_CAT_URL.format(code=code), ctx=ctx, code=code) - return - await ctx.send(embed=ERROR_LENGTH_EMBED) - - @http_status_group.command(name="dog") - async def http_dog(self, ctx: commands.Context, code: int) -> None: - """Send a dog version of the requested HTTP status code.""" - if code in range(100, 600): - await self.build_embed(url=HTTP_DOG_URL.format(code=code), ctx=ctx, code=code) - return - await ctx.send(embed=ERROR_LENGTH_EMBED) - - async def build_embed(self, url: str, ctx: commands.Context, code: int) -> None: - """Attempt to build and dispatch embed. Append error message instead if something goes wrong.""" - async with self.bot.http_session.get(url, allow_redirects=False) as response: - if response.status in range(200, 300): - await ctx.send( - embed=discord.Embed( - title=STATUS_TEMPLATE.format(code=code) - ).set_image(url=url) - ) - elif response.status in (302, 404): # dog URL returns 302 instead of 404 - if "dog" in url: - await ctx.send( - embed=discord.Embed( - title=ERR_404.format(code=code) - ).set_image(url="https://httpstatusdogs.com/img/404.jpg") - ) - return - await ctx.send( - embed=discord.Embed( - title=ERR_404.format(code=code) - ).set_image(url="https://http.cat/404.jpg") - ) - else: - await ctx.send( - embed=discord.Embed( - title=STATUS_TEMPLATE.format(code=code) - ).set_footer(text=ERR_UNKNOWN.format(code=code)) - ) - - -def setup(bot: Bot) -> None: - """Load the HTTPStatusCodes cog.""" - bot.add_cog(HTTPStatusCodes(bot)) diff --git a/bot/exts/evergreen/tic_tac_toe.py b/bot/exts/evergreen/tic_tac_toe.py deleted file mode 100644 index 5c4f8051..00000000 --- a/bot/exts/evergreen/tic_tac_toe.py +++ /dev/null @@ -1,335 +0,0 @@ -import asyncio -import random -from typing import Callable, Optional, Union - -import discord -from discord.ext.commands import Cog, Context, check, group, guild_only - -from bot.bot import Bot -from bot.constants import Emojis -from bot.utils.pagination import LinePaginator - -CONFIRMATION_MESSAGE = ( - "{opponent}, {requester} wants to play Tic-Tac-Toe against you." - f"\nReact to this message with {Emojis.confirmation} to accept or with {Emojis.decline} to decline." -) - - -def check_win(board: dict[int, str]) -> bool: - """Check from board, is any player won game.""" - return any( - ( - # Horizontal - board[1] == board[2] == board[3], - board[4] == board[5] == board[6], - board[7] == board[8] == board[9], - # Vertical - board[1] == board[4] == board[7], - board[2] == board[5] == board[8], - board[3] == board[6] == board[9], - # Diagonal - board[1] == board[5] == board[9], - board[3] == board[5] == board[7], - ) - ) - - -class Player: - """Class that contains information about player and functions that interact with player.""" - - def __init__(self, user: discord.User, ctx: Context, symbol: str): - self.user = user - self.ctx = ctx - self.symbol = symbol - - async def get_move(self, board: dict[int, str], msg: discord.Message) -> tuple[bool, Optional[int]]: - """ - Get move from user. - - Return is timeout reached and position of field what user will fill when timeout don't reach. - """ - def check_for_move(r: discord.Reaction, u: discord.User) -> bool: - """Check does user who reacted is user who we want, message is board and emoji is in board values.""" - return ( - u.id == self.user.id - and msg.id == r.message.id - and r.emoji in board.values() - and r.emoji in Emojis.number_emojis.values() - ) - - try: - react, _ = await self.ctx.bot.wait_for("reaction_add", timeout=30.0, check=check_for_move) - except asyncio.TimeoutError: - return True, None - else: - return False, list(Emojis.number_emojis.keys())[list(Emojis.number_emojis.values()).index(react.emoji)] - - def __str__(self) -> str: - """Return mention of user.""" - return self.user.mention - - -class AI: - """Tic Tac Toe AI class for against computer gaming.""" - - def __init__(self, symbol: str): - self.symbol = symbol - - async def get_move(self, 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())] - - for symbol in (Emojis.o_square, Emojis.x_square): - for move in possible_moves: - board_copy = board.copy() - board_copy[move] = symbol - if check_win(board_copy): - return False, move - - open_corners = [i for i in possible_moves if i in (1, 3, 7, 9)] - if len(open_corners) > 0: - return False, random.choice(open_corners) - - if 5 in possible_moves: - return False, 5 - - open_edges = [i for i in possible_moves if i in (2, 4, 6, 8)] - return False, random.choice(open_edges) - - def __str__(self) -> str: - """Return `AI` as user name.""" - return "AI" - - -class Game: - """Class that contains information and functions about Tic Tac Toe game.""" - - def __init__(self, players: list[Union[Player, AI]], ctx: Context): - self.players = players - self.ctx = ctx - self.board = { - 1: Emojis.number_emojis[1], - 2: Emojis.number_emojis[2], - 3: Emojis.number_emojis[3], - 4: Emojis.number_emojis[4], - 5: Emojis.number_emojis[5], - 6: Emojis.number_emojis[6], - 7: Emojis.number_emojis[7], - 8: Emojis.number_emojis[8], - 9: Emojis.number_emojis[9] - } - - self.current = self.players[0] - self.next = self.players[1] - - self.winner: Optional[Union[Player, AI]] = None - self.loser: Optional[Union[Player, AI]] = None - self.over = False - self.canceled = False - self.draw = False - - async def get_confirmation(self) -> tuple[bool, Optional[str]]: - """ - Ask does user want to play TicTacToe against requester. First player is always requester. - - This return tuple that have: - - first element boolean (is game accepted?) - - (optional, only when first element is False, otherwise None) reason for declining. - """ - confirm_message = await self.ctx.send( - CONFIRMATION_MESSAGE.format( - opponent=self.players[1].user.mention, - requester=self.players[0].user.mention - ) - ) - await confirm_message.add_reaction(Emojis.confirmation) - await confirm_message.add_reaction(Emojis.decline) - - def confirm_check(reaction: discord.Reaction, user: discord.User) -> bool: - """Check is user who reacted from who this was requested, message is confirmation and emoji is valid.""" - return ( - reaction.emoji in (Emojis.confirmation, Emojis.decline) - and reaction.message.id == confirm_message.id - and user == self.players[1].user - ) - - try: - reaction, user = await self.ctx.bot.wait_for( - "reaction_add", - timeout=60.0, - check=confirm_check - ) - except asyncio.TimeoutError: - self.over = True - self.canceled = True - await confirm_message.delete() - return False, "Running out of time... Cancelled game." - - await confirm_message.delete() - if reaction.emoji == Emojis.confirmation: - return True, None - else: - self.over = True - self.canceled = True - return False, "User declined" - - async def add_reactions(self, msg: discord.Message) -> None: - """Add number emojis to message.""" - for nr in Emojis.number_emojis.values(): - await msg.add_reaction(nr) - - def format_board(self) -> str: - """Get formatted tic-tac-toe board for message.""" - board = list(self.board.values()) - return "\n".join( - (f"{board[line]} {board[line + 1]} {board[line + 2]}" for line in range(0, len(board), 3)) - ) - - async def play(self) -> None: - """Start and handle game.""" - await self.ctx.send("It's time for the game! Let's begin.") - board = await self.ctx.send( - embed=discord.Embed(description=self.format_board()) - ) - await self.add_reactions(board) - - for _ in range(9): - if isinstance(self.current, Player): - announce = await self.ctx.send( - f"{self.current.user.mention}, it's your turn! " - "React with an emoji to take your go." - ) - timeout, pos = await self.current.get_move(self.board, board) - if isinstance(self.current, Player): - await announce.delete() - if timeout: - await self.ctx.send(f"{self.current.user.mention} ran out of time. Canceling game.") - self.over = True - self.canceled = True - return - self.board[pos] = self.current.symbol - await board.edit( - embed=discord.Embed(description=self.format_board()) - ) - await board.clear_reaction(Emojis.number_emojis[pos]) - if check_win(self.board): - self.winner = self.current - self.loser = self.next - await self.ctx.send( - f":tada: {self.current} won this game! :tada:" - ) - await board.clear_reactions() - break - self.current, self.next = self.next, self.current - if not self.winner: - self.draw = True - await self.ctx.send("It's a DRAW!") - self.over = True - - -def is_channel_free() -> Callable: - """Check is channel where command will be invoked free.""" - async def predicate(ctx: Context) -> bool: - return all(game.channel != ctx.channel for game in ctx.cog.games if not game.over) - return check(predicate) - - -def is_requester_free() -> Callable: - """Check is requester not already in any game.""" - async def predicate(ctx: Context) -> bool: - return all( - ctx.author not in (player.user for player in game.players) for game in ctx.cog.games if not game.over - ) - return check(predicate) - - -class TicTacToe(Cog): - """TicTacToe cog contains tic-tac-toe game commands.""" - - 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) - async def tic_tac_toe(self, ctx: Context, opponent: Optional[discord.User]) -> None: - """Tic Tac Toe game. Play against friends or AI. Use reactions to add your mark to field.""" - if opponent == ctx.author: - await ctx.send("You can't play against yourself.") - return - if opponent is not None and not all( - opponent not in (player.user for player in g.players) for g in ctx.cog.games if not g.over - ): - await ctx.send("Opponent is already in game.") - return - if opponent is None: - game = Game( - [Player(ctx.author, ctx, Emojis.x_square), AI(Emojis.o_square)], - ctx - ) - else: - game = Game( - [Player(ctx.author, ctx, Emojis.x_square), Player(opponent, ctx, Emojis.o_square)], - ctx - ) - self.games.append(game) - if opponent is not None: - if opponent.bot: # check whether the opponent is a bot or not - await ctx.send("You can't play Tic-Tac-Toe with bots!") - return - - confirmed, msg = await game.get_confirmation() - - if not confirmed: - if msg: - await ctx.send(msg) - return - await game.play() - - @tic_tac_toe.group(name="history", aliases=("log",), invoke_without_command=True) - async def tic_tac_toe_logs(self, ctx: Context) -> None: - """Show most recent tic-tac-toe games.""" - if len(self.games) < 1: - await ctx.send("No recent games.") - return - log_games = [] - for i, game in enumerate(self.games): - if game.over and not game.canceled: - if game.draw: - log_games.append( - f"**#{i+1}**: {game.players[0]} vs {game.players[1]} (draw)" - ) - else: - log_games.append( - f"**#{i+1}**: {game.winner} :trophy: vs {game.loser}" - ) - await LinePaginator.paginate( - log_games, - ctx, - discord.Embed(title="Most recent Tic Tac Toe games") - ) - - @tic_tac_toe_logs.command(name="show", aliases=("s",)) - async def show_tic_tac_toe_board(self, ctx: Context, game_id: int) -> None: - """View game board by ID (ID is possible to get by `.tictactoe history`).""" - if len(self.games) < game_id: - await ctx.send("Game don't exist.") - return - game = self.games[game_id - 1] - - if game.draw: - description = f"{game.players[0]} vs {game.players[1]} (draw)\n\n{game.format_board()}" - else: - description = f"{game.winner} :trophy: vs {game.loser}\n\n{game.format_board()}" - - embed = discord.Embed( - title=f"Match #{game_id} Game Board", - description=description, - ) - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the TicTacToe cog.""" - bot.add_cog(TicTacToe()) diff --git a/bot/exts/evergreen/trivia_quiz.py b/bot/exts/evergreen/trivia_quiz.py deleted file mode 100644 index aa4020d6..00000000 --- a/bot/exts/evergreen/trivia_quiz.py +++ /dev/null @@ -1,593 +0,0 @@ -import asyncio -import json -import logging -import operator -import random -from dataclasses import dataclass -from pathlib import Path -from typing import Callable, Optional - -import discord -from discord.ext import commands -from rapidfuzz import fuzz - -from bot.bot import Bot -from bot.constants import Colours, NEGATIVE_REPLIES, Roles - -logger = logging.getLogger(__name__) - -DEFAULT_QUESTION_LIMIT = 6 -STANDARD_VARIATION_TOLERANCE = 88 -DYNAMICALLY_GEN_VARIATION_TOLERANCE = 97 - -WRONG_ANS_RESPONSE = [ - "No one answered correctly!", - "Better luck next time...", -] - -N_PREFIX_STARTS_AT = 5 -N_PREFIXES = [ - "penta", "hexa", "hepta", "octa", "nona", - "deca", "hendeca", "dodeca", "trideca", "tetradeca", -] - -PLANETS = [ - ("1st", "Mercury"), - ("2nd", "Venus"), - ("3rd", "Earth"), - ("4th", "Mars"), - ("5th", "Jupiter"), - ("6th", "Saturn"), - ("7th", "Uranus"), - ("8th", "Neptune"), -] - -TAXONOMIC_HIERARCHY = [ - "species", "genus", "family", "order", - "class", "phylum", "kingdom", "domain", -] - -UNITS_TO_BASE_UNITS = { - "hertz": ("(unit of frequency)", "s^-1"), - "newton": ("(unit of force)", "m*kg*s^-2"), - "pascal": ("(unit of pressure & stress)", "m^-1*kg*s^-2"), - "joule": ("(unit of energy & quantity of heat)", "m^2*kg*s^-2"), - "watt": ("(unit of power)", "m^2*kg*s^-3"), - "coulomb": ("(unit of electric charge & quantity of electricity)", "s*A"), - "volt": ("(unit of voltage & electromotive force)", "m^2*kg*s^-3*A^-1"), - "farad": ("(unit of capacitance)", "m^-2*kg^-1*s^4*A^2"), - "ohm": ("(unit of electric resistance)", "m^2*kg*s^-3*A^-2"), - "weber": ("(unit of magnetic flux)", "m^2*kg*s^-2*A^-1"), - "tesla": ("(unit of magnetic flux density)", "kg*s^-2*A^-1"), -} - - -@dataclass(frozen=True) -class QuizEntry: - """Dataclass for a quiz entry (a question and a string containing answers separated by commas).""" - - question: str - answer: str - - -def linear_system(q_format: str, a_format: str) -> QuizEntry: - """Generate a system of linear equations with two unknowns.""" - x, y = random.randint(2, 5), random.randint(2, 5) - answer = a_format.format(x, y) - - coeffs = random.sample(range(1, 6), 4) - - question = q_format.format( - coeffs[0], - coeffs[1], - coeffs[0] * x + coeffs[1] * y, - coeffs[2], - coeffs[3], - coeffs[2] * x + coeffs[3] * y, - ) - - return QuizEntry(question, answer) - - -def mod_arith(q_format: str, a_format: str) -> QuizEntry: - """Generate a basic modular arithmetic question.""" - quotient, m, b = random.randint(30, 40), random.randint(10, 20), random.randint(200, 350) - ans = random.randint(0, 9) # max remainder is 9, since the minimum modulus is 10 - a = quotient * m + ans - b - - question = q_format.format(a, b, m) - answer = a_format.format(ans) - - return QuizEntry(question, answer) - - -def ngonal_prism(q_format: str, a_format: str) -> QuizEntry: - """Generate a question regarding vertices on n-gonal prisms.""" - n = random.randint(0, len(N_PREFIXES) - 1) - - question = q_format.format(N_PREFIXES[n]) - answer = a_format.format((n + N_PREFIX_STARTS_AT) * 2) - - return QuizEntry(question, answer) - - -def imag_sqrt(q_format: str, a_format: str) -> QuizEntry: - """Generate a negative square root question.""" - ans_coeff = random.randint(3, 10) - - question = q_format.format(ans_coeff ** 2) - answer = a_format.format(ans_coeff) - - return QuizEntry(question, answer) - - -def binary_calc(q_format: str, a_format: str) -> QuizEntry: - """Generate a binary calculation question.""" - a = random.randint(15, 20) - b = random.randint(10, a) - oper = random.choice( - ( - ("+", operator.add), - ("-", operator.sub), - ("*", operator.mul), - ) - ) - - # if the operator is multiplication, lower the values of the two operands to make it easier - if oper[0] == "*": - a -= 5 - b -= 5 - - question = q_format.format(a, oper[0], b) - answer = a_format.format(oper[1](a, b)) - - return QuizEntry(question, answer) - - -def solar_system(q_format: str, a_format: str) -> QuizEntry: - """Generate a question on the planets of the Solar System.""" - planet = random.choice(PLANETS) - - question = q_format.format(planet[0]) - answer = a_format.format(planet[1]) - - return QuizEntry(question, answer) - - -def taxonomic_rank(q_format: str, a_format: str) -> QuizEntry: - """Generate a question on taxonomic classification.""" - level = random.randint(0, len(TAXONOMIC_HIERARCHY) - 2) - - question = q_format.format(TAXONOMIC_HIERARCHY[level]) - answer = a_format.format(TAXONOMIC_HIERARCHY[level + 1]) - - return QuizEntry(question, answer) - - -def base_units_convert(q_format: str, a_format: str) -> QuizEntry: - """Generate a SI base units conversion question.""" - unit = random.choice(list(UNITS_TO_BASE_UNITS)) - - question = q_format.format( - unit + " " + UNITS_TO_BASE_UNITS[unit][0] - ) - answer = a_format.format( - UNITS_TO_BASE_UNITS[unit][1] - ) - - return QuizEntry(question, answer) - - -DYNAMIC_QUESTIONS_FORMAT_FUNCS = { - 201: linear_system, - 202: mod_arith, - 203: ngonal_prism, - 204: imag_sqrt, - 205: binary_calc, - 301: solar_system, - 302: taxonomic_rank, - 303: base_units_convert, -} - - -class TriviaQuiz(commands.Cog): - """A cog for all quiz commands.""" - - def __init__(self, bot: Bot): - self.bot = bot - - self.game_status = {} # A variable to store the game status: either running or not running. - self.game_owners = {} # A variable to store the person's ID who started the quiz game in a channel. - - self.questions = self.load_questions() - self.question_limit = 0 - - self.player_scores = {} # A variable to store all player's scores for a bot session. - self.game_player_scores = {} # A variable to store temporary game player's scores. - - self.categories = { - "general": "Test your general knowledge.", - "retro": "Questions related to retro gaming.", - "math": "General questions about mathematics ranging from grade 8 to grade 12.", - "science": "Put your understanding of science to the test!", - "cs": "A large variety of computer science questions.", - "python": "Trivia on our amazing language, Python!", - } - - @staticmethod - def load_questions() -> dict: - """Load the questions from the JSON file.""" - p = Path("bot", "resources", "evergreen", "trivia_quiz.json") - - return json.loads(p.read_text(encoding="utf-8")) - - @commands.group(name="quiz", aliases=["trivia"], invoke_without_command=True) - async def quiz_game(self, ctx: commands.Context, category: Optional[str], questions: Optional[int]) -> None: - """ - Start a quiz! - - Questions for the quiz can be selected from the following categories: - - general: Test your general knowledge. - - retro: Questions related to retro gaming. - - math: General questions about mathematics ranging from grade 8 to grade 12. - - science: Put your understanding of science to the test! - - cs: A large variety of computer science questions. - - python: Trivia on our amazing language, Python! - - (More to come!) - """ - if ctx.channel.id not in self.game_status: - self.game_status[ctx.channel.id] = False - - if ctx.channel.id not in self.game_player_scores: - self.game_player_scores[ctx.channel.id] = {} - - # Stop game if running. - if self.game_status[ctx.channel.id]: - await ctx.send( - "Game is already running... " - f"do `{self.bot.command_prefix}quiz stop`" - ) - return - - # Send embed showing available categories if inputted category is invalid. - if category is None: - category = random.choice(list(self.categories)) - - category = category.lower() - if category not in self.categories: - embed = self.category_embed() - await ctx.send(embed=embed) - return - - topic = self.questions[category] - topic_length = len(topic) - - if questions is None: - self.question_limit = DEFAULT_QUESTION_LIMIT - else: - if questions > topic_length: - await ctx.send( - embed=self.make_error_embed( - f"This category only has {topic_length} questions. " - "Please input a lower value!" - ) - ) - return - - elif questions < 1: - await ctx.send( - embed=self.make_error_embed( - "You must choose to complete at least one question. " - f"(or enter nothing for the default value of {DEFAULT_QUESTION_LIMIT + 1} questions)" - ) - ) - return - - else: - self.question_limit = questions - 1 - - # Start game if not running. - if not self.game_status[ctx.channel.id]: - self.game_owners[ctx.channel.id] = ctx.author - self.game_status[ctx.channel.id] = True - start_embed = self.make_start_embed(category) - - await ctx.send(embed=start_embed) # send an embed with the rules - await asyncio.sleep(5) - - done_question = [] - hint_no = 0 - answers = None - - while self.game_status[ctx.channel.id]: - # Exit quiz if number of questions for a round are already sent. - if len(done_question) > self.question_limit and hint_no == 0: - await ctx.send("The round has ended.") - await self.declare_winner(ctx.channel, self.game_player_scores[ctx.channel.id]) - - self.game_status[ctx.channel.id] = False - del self.game_owners[ctx.channel.id] - self.game_player_scores[ctx.channel.id] = {} - - break - - # If no hint has been sent or any time alert. Basically if hint_no = 0 means it is a new question. - if hint_no == 0: - # Select a random question which has not been used yet. - while True: - question_dict = random.choice(topic) - if question_dict["id"] not in done_question: - done_question.append(question_dict["id"]) - break - - if "dynamic_id" not in question_dict: - question = question_dict["question"] - answers = question_dict["answer"].split(", ") - - var_tol = STANDARD_VARIATION_TOLERANCE - else: - format_func = DYNAMIC_QUESTIONS_FORMAT_FUNCS[question_dict["dynamic_id"]] - - quiz_entry = format_func( - question_dict["question"], - question_dict["answer"], - ) - - question, answers = quiz_entry.question, quiz_entry.answer - answers = [answers] - - var_tol = DYNAMICALLY_GEN_VARIATION_TOLERANCE - - embed = discord.Embed( - colour=Colours.gold, - title=f"Question #{len(done_question)}", - description=question, - ) - - if img_url := question_dict.get("img_url"): - embed.set_image(url=img_url) - - await ctx.send(embed=embed) - - def check_func(variation_tolerance: int) -> Callable[[discord.Message], bool]: - def contains_correct_answer(m: discord.Message) -> bool: - return m.channel == ctx.channel and any( - fuzz.ratio(answer.lower(), m.content.lower()) > variation_tolerance - for answer in answers - ) - - return contains_correct_answer - - try: - msg = await self.bot.wait_for("message", check=check_func(var_tol), timeout=10) - except asyncio.TimeoutError: - # In case of TimeoutError and the game has been stopped, then do nothing. - if not self.game_status[ctx.channel.id]: - break - - if hint_no < 2: - hint_no += 1 - - if "hints" in question_dict: - hints = question_dict["hints"] - - await ctx.send(f"**Hint #{hint_no}\n**{hints[hint_no - 1]}") - else: - await ctx.send(f"{30 - hint_no * 10}s left!") - - # Once hint or time alerts has been sent 2 times, the hint_no value will be 3 - # If hint_no > 2, then it means that all hints/time alerts have been sent. - # Also means that the answer is not yet given and the bot sends the answer and the next question. - else: - if self.game_status[ctx.channel.id] is False: - break - - response = random.choice(WRONG_ANS_RESPONSE) - await ctx.send(response) - - await self.send_answer( - ctx.channel, - answers, - False, - question_dict, - self.question_limit - len(done_question) + 1, - ) - await asyncio.sleep(1) - - hint_no = 0 # Reset the hint counter so that on the next round, it's in the initial state - - await self.send_score(ctx.channel, self.game_player_scores[ctx.channel.id]) - await asyncio.sleep(2) - else: - if self.game_status[ctx.channel.id] is False: - break - - points = 100 - 25 * hint_no - if msg.author in self.game_player_scores[ctx.channel.id]: - self.game_player_scores[ctx.channel.id][msg.author] += points - else: - self.game_player_scores[ctx.channel.id][msg.author] = points - - # Also updating the overall scoreboard. - if msg.author in self.player_scores: - self.player_scores[msg.author] += points - else: - self.player_scores[msg.author] = points - - hint_no = 0 - - await ctx.send(f"{msg.author.mention} got the correct answer :tada: {points} points!") - - await self.send_answer( - ctx.channel, - answers, - True, - question_dict, - self.question_limit - len(done_question) + 1, - ) - await self.send_score(ctx.channel, self.game_player_scores[ctx.channel.id]) - - await asyncio.sleep(2) - - def make_start_embed(self, category: str) -> discord.Embed: - """Generate a starting/introduction embed for the quiz.""" - start_embed = discord.Embed( - colour=Colours.blue, - title="A quiz game is starting!", - description=( - f"This game consists of {self.question_limit + 1} questions.\n\n" - "**Rules: **\n" - "1. Only enclose your answer in backticks when the question tells you to.\n" - "2. If the question specifies an answer format, follow it or else it won't be accepted.\n" - "3. You have 30s per question. Points for each question reduces by 25 after 10s or after a hint.\n" - "4. No cheating and have fun!\n\n" - f"**Category**: {category}" - ), - ) - - return start_embed - - @staticmethod - def make_error_embed(desc: str) -> discord.Embed: - """Generate an error embed with the given description.""" - error_embed = discord.Embed( - colour=Colours.soft_red, - title=random.choice(NEGATIVE_REPLIES), - description=desc, - ) - - return error_embed - - @quiz_game.command(name="stop") - async def stop_quiz(self, ctx: commands.Context) -> None: - """ - Stop a quiz game if its running in the channel. - - Note: Only mods or the owner of the quiz can stop it. - """ - try: - 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 - ): - self.game_status[ctx.channel.id] = False - del self.game_owners[ctx.channel.id] - self.game_player_scores[ctx.channel.id] = {} - - await ctx.send("Quiz stopped.") - await self.declare_winner(ctx.channel, self.game_player_scores[ctx.channel.id]) - - else: - await ctx.send(f"{ctx.author.mention}, you are not authorised to stop this game :ghost:!") - else: - await ctx.send("No quiz running.") - except KeyError: - await ctx.send("No quiz running.") - - @quiz_game.command(name="leaderboard") - async def leaderboard(self, ctx: commands.Context) -> None: - """View everyone's score for this bot session.""" - await self.send_score(ctx.channel, self.player_scores) - - @staticmethod - async def send_score(channel: discord.TextChannel, player_data: dict) -> None: - """Send the current scores of players in the game channel.""" - if len(player_data) == 0: - await channel.send("No one has made it onto the leaderboard yet.") - return - - embed = discord.Embed( - colour=Colours.blue, - title="Score Board", - description="", - ) - - sorted_dict = sorted(player_data.items(), key=operator.itemgetter(1), reverse=True) - for item in sorted_dict: - embed.description += f"{item[0]}: {item[1]}\n" - - await channel.send(embed=embed) - - @staticmethod - async def declare_winner(channel: discord.TextChannel, player_data: dict) -> None: - """Announce the winner of the quiz in the game channel.""" - if player_data: - highest_points = max(list(player_data.values())) - no_of_winners = list(player_data.values()).count(highest_points) - - # Check if more than 1 player has highest points. - if no_of_winners > 1: - winners = [] - points_copy = list(player_data.values()).copy() - - for _ in range(no_of_winners): - index = points_copy.index(highest_points) - winners.append(list(player_data.keys())[index]) - points_copy[index] = 0 - - winners_mention = " ".join(winner.mention for winner in winners) - else: - author_index = list(player_data.values()).index(highest_points) - winner = list(player_data.keys())[author_index] - winners_mention = winner.mention - - await channel.send( - f"Congratulations {winners_mention} :tada: " - f"You have won this quiz game with a grand total of {highest_points} points!" - ) - - def category_embed(self) -> discord.Embed: - """Build an embed showing all available trivia categories.""" - embed = discord.Embed( - colour=Colours.blue, - title="The available question categories are:", - description="", - ) - - embed.set_footer(text="If a category is not chosen, a random one will be selected.") - - for cat, description in self.categories.items(): - embed.description += ( - f"**- {cat.capitalize()}**\n" - f"{description.capitalize()}\n" - ) - - return embed - - @staticmethod - async def send_answer( - channel: discord.TextChannel, - answers: list[str], - answer_is_correct: bool, - question_dict: dict, - q_left: int, - ) -> None: - """Send the correct answer of a question to the game channel.""" - info = question_dict.get("info") - - plurality = " is" if len(answers) == 1 else "s are" - - embed = discord.Embed( - color=Colours.bright_green, - title=( - ("You got it! " if answer_is_correct else "") - + f"The correct answer{plurality} **`{', '.join(answers)}`**\n" - ), - description="", - ) - - if info is not None: - embed.description += f"**Information**\n{info}\n\n" - - embed.description += ( - ("Let's move to the next question." if q_left > 0 else "") - + f"\nRemaining questions: {q_left}" - ) - await channel.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the TriviaQuiz cog.""" - bot.add_cog(TriviaQuiz(bot)) diff --git a/bot/exts/evergreen/wonder_twins.py b/bot/exts/evergreen/wonder_twins.py deleted file mode 100644 index 40edf785..00000000 --- a/bot/exts/evergreen/wonder_twins.py +++ /dev/null @@ -1,49 +0,0 @@ -import random -from pathlib import Path - -import yaml -from discord.ext.commands import Cog, Context, command - -from bot.bot import Bot - - -class WonderTwins(Cog): - """Cog for a Wonder Twins inspired command.""" - - def __init__(self): - with open(Path.cwd() / "bot" / "resources" / "evergreen" / "wonder_twins.yaml", "r", encoding="utf-8") as f: - info = yaml.load(f, Loader=yaml.FullLoader) - self.water_types = info["water_types"] - self.objects = info["objects"] - self.adjectives = info["adjectives"] - - @staticmethod - def append_onto(phrase: str, insert_word: str) -> str: - """Appends one word onto the end of another phrase in order to format with the proper determiner.""" - if insert_word.endswith("s"): - phrase = phrase.split() - del phrase[0] - phrase = " ".join(phrase) - - insert_word = insert_word.split()[-1] - return " ".join([phrase, insert_word]) - - def format_phrase(self) -> str: - """Creates a transformation phrase from available words.""" - adjective = random.choice((None, random.choice(self.adjectives))) - object_name = random.choice(self.objects) - water_type = random.choice(self.water_types) - - if adjective: - object_name = self.append_onto(adjective, object_name) - return f"{object_name} of {water_type}" - - @command(name="formof", aliases=("wondertwins", "wondertwin", "fo")) - async def form_of(self, ctx: Context) -> None: - """Command to send a Wonder Twins inspired phrase to the user invoking the command.""" - await ctx.send(f"Form of {self.format_phrase()}!") - - -def setup(bot: Bot) -> None: - """Load the WonderTwins cog.""" - bot.add_cog(WonderTwins()) diff --git a/bot/exts/evergreen/xkcd.py b/bot/exts/evergreen/xkcd.py deleted file mode 100644 index b56c53d9..00000000 --- a/bot/exts/evergreen/xkcd.py +++ /dev/null @@ -1,91 +0,0 @@ -import logging -import re -from random import randint -from typing import Optional, Union - -from discord import Embed -from discord.ext import tasks -from discord.ext.commands import Cog, Context, command - -from bot.bot import Bot -from bot.constants import Colours - -log = logging.getLogger(__name__) - -COMIC_FORMAT = re.compile(r"latest|[0-9]+") -BASE_URL = "https://xkcd.com" - - -class XKCD(Cog): - """Retrieving XKCD comics.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.latest_comic_info: dict[str, Union[str, int]] = {} - self.get_latest_comic_info.start() - - def cog_unload(self) -> None: - """Cancels refreshing of the task for refreshing the most recent comic info.""" - self.get_latest_comic_info.cancel() - - @tasks.loop(minutes=30) - async def get_latest_comic_info(self) -> None: - """Refreshes latest comic's information ever 30 minutes. Also used for finding a random comic.""" - async with self.bot.http_session.get(f"{BASE_URL}/info.0.json") as resp: - if resp.status == 200: - self.latest_comic_info = await resp.json() - else: - log.debug(f"Failed to get latest XKCD comic information. Status code {resp.status}") - - @command(name="xkcd") - async def fetch_xkcd_comics(self, ctx: Context, comic: Optional[str]) -> None: - """ - Getting an xkcd comic's information along with the image. - - To get a random comic, don't type any number as an argument. To get the latest, type 'latest'. - """ - embed = Embed(title=f"XKCD comic '{comic}'") - - embed.colour = Colours.soft_red - - if comic and (comic := re.match(COMIC_FORMAT, comic)) is None: - embed.description = "Comic parameter should either be an integer or 'latest'." - await ctx.send(embed=embed) - return - - comic = randint(1, self.latest_comic_info["num"]) if comic is None else comic.group(0) - - if comic == "latest": - info = self.latest_comic_info - else: - async with self.bot.http_session.get(f"{BASE_URL}/{comic}/info.0.json") as resp: - if resp.status == 200: - info = await resp.json() - else: - embed.title = f"XKCD comic #{comic}" - embed.description = f"{resp.status}: Could not retrieve xkcd comic #{comic}." - log.debug(f"Retrieving xkcd comic #{comic} failed with status code {resp.status}.") - await ctx.send(embed=embed) - return - - embed.title = f"XKCD comic #{info['num']}" - embed.description = info["alt"] - embed.url = f"{BASE_URL}/{info['num']}" - - if info["img"][-3:] in ("jpg", "png", "gif"): - embed.set_image(url=info["img"]) - date = f"{info['year']}/{info['month']}/{info['day']}" - embed.set_footer(text=f"{date} - #{info['num']}, \'{info['safe_title']}\'") - embed.colour = Colours.soft_green - else: - embed.description = ( - "The selected comic is interactive, and cannot be displayed within an embed.\n" - f"Comic can be viewed [here](https://xkcd.com/{info['num']})." - ) - - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the XKCD cog.""" - bot.add_cog(XKCD(bot)) |