diff options
Diffstat (limited to 'bot/exts/fun')
-rw-r--r-- | bot/exts/fun/anagram.py | 110 | ||||
-rw-r--r-- | bot/exts/fun/duck_game.py | 42 | ||||
-rw-r--r-- | bot/exts/fun/hangman.py | 182 | ||||
-rw-r--r-- | bot/exts/fun/quack.py | 75 | ||||
-rw-r--r-- | bot/exts/fun/tic_tac_toe.py | 16 | ||||
-rw-r--r-- | bot/exts/fun/trivia_quiz.py | 426 |
6 files changed, 657 insertions, 194 deletions
diff --git a/bot/exts/fun/anagram.py b/bot/exts/fun/anagram.py new file mode 100644 index 00000000..9aee5f18 --- /dev/null +++ b/bot/exts/fun/anagram.py @@ -0,0 +1,110 @@ +import asyncio +import json +import logging +import random +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours + +log = logging.getLogger(__name__) + +TIME_LIMIT = 60 + +# anagram.json file contains all the anagrams +with open(Path("bot/resources/fun/anagram.json"), "r") as f: + ANAGRAMS_ALL = json.load(f) + + +class AnagramGame: + """ + Used for creating instances of anagram games. + + Once multiple games can be run at the same time, this class' instances + can be used for keeping track of each anagram game. + """ + + def __init__(self, scrambled: str, correct: list[str]) -> None: + self.scrambled = scrambled + self.correct = set(correct) + + self.winners = set() + + async def message_creation(self, message: discord.Message) -> None: + """Check if the message is a correct answer and remove it from the list of answers.""" + if message.content.lower() in self.correct: + self.winners.add(message.author.mention) + self.correct.remove(message.content.lower()) + + +class Anagram(commands.Cog): + """Cog for the Anagram game command.""" + + def __init__(self, bot: Bot): + self.bot = bot + + self.games: dict[int, AnagramGame] = {} + + @commands.command(name="anagram", aliases=("anag", "gram", "ag")) + @commands.guild_only() + async def anagram_command(self, ctx: commands.Context) -> None: + """ + Given shuffled letters, rearrange them into anagrams. + + Show an embed with scrambled letters which if rearranged can form words. + After a specific amount of time, list the correct answers and whether someone provided a + correct answer. + """ + if self.games.get(ctx.channel.id): + await ctx.send("An anagram is already being solved in this channel!") + return + + scrambled_letters, correct = random.choice(list(ANAGRAMS_ALL.items())) + + game = AnagramGame(scrambled_letters, correct) + self.games[ctx.channel.id] = game + + anagram_embed = discord.Embed( + title=f"Find anagrams from these letters: '{scrambled_letters.upper()}'", + description=f"You have {TIME_LIMIT} seconds to find correct words.", + colour=Colours.purple, + ) + + await ctx.send(embed=anagram_embed) + await asyncio.sleep(TIME_LIMIT) + + if game.winners: + win_list = ", ".join(game.winners) + content = f"Well done {win_list} for getting it right!" + else: + content = "Nobody got it right." + + answer_embed = discord.Embed( + title=f"The words were: `{'`, `'.join(ANAGRAMS_ALL[game.scrambled])}`!", + colour=Colours.pink, + ) + + await ctx.send(content, embed=answer_embed) + + # Game is finished, let's remove it from the dict + self.games.pop(ctx.channel.id) + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Check a message for an anagram attempt and pass to an ongoing game.""" + if message.author.bot or not message.guild: + return + + game = self.games.get(message.channel.id) + if not game: + return + + await game.message_creation(message) + + +def setup(bot: Bot) -> None: + """Load the Anagram cog.""" + bot.add_cog(Anagram(bot)) diff --git a/bot/exts/fun/duck_game.py b/bot/exts/fun/duck_game.py index 1ef7513f..10b03a49 100644 --- a/bot/exts/fun/duck_game.py +++ b/bot/exts/fun/duck_game.py @@ -11,7 +11,7 @@ from PIL import Image, ImageDraw, ImageFont from discord.ext import commands from bot.bot import Bot -from bot.constants import Colours, MODERATION_ROLES +from bot.constants import MODERATION_ROLES from bot.utils.decorators import with_role DECK = list(product(*[(0, 1, 2)]*4)) @@ -130,6 +130,9 @@ class DuckGame: while len(self.solutions) < minimum_solutions: self.board = random.sample(DECK, size) + self.board_msg = None + self.found_msg = None + @property def board(self) -> list[tuple[int]]: """Accesses board property.""" @@ -181,7 +184,7 @@ class DuckGamesDirector(commands.Cog): ) @commands.cooldown(rate=1, per=2, type=commands.BucketType.channel) async def start_game(self, ctx: commands.Context) -> None: - """Generate a board, send the game embed, and end the game after a time limit.""" + """Start a new Duck Duck Duck Goose game.""" if ctx.channel.id in self.current_games: await ctx.send("There's already a game running!") return @@ -191,8 +194,8 @@ class DuckGamesDirector(commands.Cog): game.running = True self.current_games[ctx.channel.id] = game - game.msg_content = "" - game.embed_msg = await self.send_board_embed(ctx, game) + game.board_msg = await self.send_board_embed(ctx, game) + game.found_msg = await self.send_found_embed(ctx) await asyncio.sleep(GAME_DURATION) # Checking for the channel ID in the currently running games is not sufficient. @@ -245,13 +248,13 @@ class DuckGamesDirector(commands.Cog): if answer in game.solutions: game.claimed_answers[answer] = msg.author game.scores[msg.author] += CORRECT_SOLN - await self.display_claimed_answer(game, msg.author, answer) + await self.append_to_found_embed(game, f"{str(answer):12s} - {msg.author.display_name}") else: await msg.add_reaction(EMOJI_WRONG) game.scores[msg.author] += INCORRECT_SOLN async def send_board_embed(self, ctx: commands.Context, game: DuckGame) -> discord.Message: - """Create and send the initial game embed. This will be edited as the game goes on.""" + """Create and send an embed to display the board.""" image = assemble_board_image(game.board, game.rows, game.columns) with BytesIO() as image_stream: image.save(image_stream, format="png") @@ -259,19 +262,27 @@ class DuckGamesDirector(commands.Cog): file = discord.File(fp=image_stream, filename="board.png") embed = discord.Embed( title="Duck Duck Duck Goose!", - color=Colours.bright_green, + color=discord.Color.dark_purple(), ) embed.set_image(url="attachment://board.png") return await ctx.send(embed=embed, file=file) - async def display_claimed_answer(self, game: DuckGame, author: discord.Member, answer: tuple[int]) -> None: - """Add a claimed answer to the game embed.""" + async def send_found_embed(self, ctx: commands.Context) -> discord.Message: + """Create and send an embed to display claimed answers. This will be edited as the game goes on.""" + # Can't be part of the board embed because of discord.py limitations with editing an embed with an image. + embed = discord.Embed( + title="Flights Found", + color=discord.Color.dark_purple(), + ) + return await ctx.send(embed=embed) + + async def append_to_found_embed(self, game: DuckGame, text: str) -> None: + """Append text to the claimed answers embed.""" async with game.editing_embed: - # We specifically edit the message contents instead of the embed - # Because we load in the image from the file, editing any portion of the embed - # Does weird things to the image and this works around that weirdness - game.msg_content = f"{game.msg_content}\n{str(answer):12s} - {author.display_name}" - await game.embed_msg.edit(content=game.msg_content) + found_embed, = game.found_msg.embeds + old_desc = found_embed.description or "" + found_embed.description = f"{old_desc.rstrip()}\n{text}" + await game.found_msg.edit(embed=found_embed) async def end_game(self, channel: discord.TextChannel, game: DuckGame, end_message: str) -> None: """Edit the game embed to reflect the end of the game and mark the game as not running.""" @@ -296,8 +307,7 @@ class DuckGamesDirector(commands.Cog): missed_text = "Flights everyone missed:\n" + "\n".join(f"{ans}" for ans in missed) else: missed_text = "All the flights were found!" - - await game.embed_msg.edit(content=f"{missed_text}") + await self.append_to_found_embed(game, f"\n{missed_text}") @start_game.command(name="help") async def show_rules(self, ctx: commands.Context) -> None: diff --git a/bot/exts/fun/hangman.py b/bot/exts/fun/hangman.py new file mode 100644 index 00000000..a2c8c735 --- /dev/null +++ b/bot/exts/fun/hangman.py @@ -0,0 +1,182 @@ +from asyncio import TimeoutError +from pathlib import Path +from random import choice + +from discord import Embed, Message +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours, NEGATIVE_REPLIES + +# Defining all words in the list of words as a global variable +ALL_WORDS = Path("bot/resources/fun/hangman_words.txt").read_text().splitlines() + +# Defining a dictionary of images that will be used for the game to represent the hangman person +IMAGES = { + 6: "https://cdn.discordapp.com/attachments/859123972884922418/888133201497837598/hangman0.png", + 5: "https://cdn.discordapp.com/attachments/859123972884922418/888133595259084800/hangman1.png", + 4: "https://cdn.discordapp.com/attachments/859123972884922418/888134194474139688/hangman2.png", + 3: "https://cdn.discordapp.com/attachments/859123972884922418/888133758069395466/hangman3.png", + 2: "https://cdn.discordapp.com/attachments/859123972884922418/888133786724859924/hangman4.png", + 1: "https://cdn.discordapp.com/attachments/859123972884922418/888133828831477791/hangman5.png", + 0: "https://cdn.discordapp.com/attachments/859123972884922418/888133845449338910/hangman6.png", +} + + +class Hangman(commands.Cog): + """ + Cog for the Hangman game. + + Hangman is a classic game where the user tries to guess a word, with a limited amount of tries. + """ + + def __init__(self, bot: Bot): + self.bot = bot + + @staticmethod + def create_embed(tries: int, user_guess: str) -> Embed: + """ + Helper method that creates the embed where the game information is shown. + + This includes how many letters the user has guessed so far, and the hangman photo itself. + """ + hangman_embed = Embed( + title="Hangman", + color=Colours.python_blue, + ) + hangman_embed.set_image(url=IMAGES[tries]) + hangman_embed.add_field( + name=f"You've guessed `{user_guess}` so far.", + value="Guess the word by sending a message with a letter!" + ) + hangman_embed.set_footer(text=f"Tries remaining: {tries}") + return hangman_embed + + @commands.command() + async def hangman( + self, + ctx: commands.Context, + min_length: int = 0, + max_length: int = 25, + min_unique_letters: int = 0, + max_unique_letters: int = 25, + ) -> None: + """ + Play hangman against the bot, where you have to guess the word it has provided! + + The arguments for this command mean: + - min_length: the minimum length you want the word to be (i.e. 2) + - max_length: the maximum length you want the word to be (i.e. 5) + - min_unique_letters: the minimum unique letters you want the word to have (i.e. 4) + - max_unique_letters: the maximum unique letters you want the word to have (i.e. 7) + """ + # Filtering the list of all words depending on the configuration + filtered_words = [ + word for word in ALL_WORDS + if min_length < len(word) < max_length + and min_unique_letters < len(set(word)) < max_unique_letters + ] + + if not filtered_words: + filter_not_found_embed = Embed( + title=choice(NEGATIVE_REPLIES), + description="No words could be found that fit all filters specified.", + color=Colours.soft_red, + ) + await ctx.send(embed=filter_not_found_embed) + return + + word = choice(filtered_words) + # `pretty_word` is used for comparing the indices where the guess of the user is similar to the word + # The `user_guess` variable is prettified by adding spaces between every dash, and so is the `pretty_word` + pretty_word = ''.join([f"{letter} " for letter in word])[:-1] + user_guess = ("_ " * len(word))[:-1] + tries = 6 + guessed_letters = set() + + def check(msg: Message) -> bool: + return msg.author == ctx.author and msg.channel == ctx.channel + + original_message = await ctx.send(embed=Embed( + title="Hangman", + description="Loading game...", + color=Colours.soft_green + )) + + # Game loop + while user_guess.replace(' ', '') != word: + # Edit the message to the current state of the game + await original_message.edit(embed=self.create_embed(tries, user_guess)) + + try: + message = await self.bot.wait_for( + event="message", + timeout=60.0, + check=check + ) + except TimeoutError: + timeout_embed = Embed( + title=choice(NEGATIVE_REPLIES), + description="Looks like the bot timed out! You must send a letter within 60 seconds.", + color=Colours.soft_red, + ) + await original_message.edit(embed=timeout_embed) + return + + # If the user enters a capital letter as their guess, it is automatically converted to a lowercase letter + normalized_content = message.content.lower() + # The user should only guess one letter per message + if len(normalized_content) > 1: + letter_embed = Embed( + title=choice(NEGATIVE_REPLIES), + description="You can only send one letter at a time, try again!", + color=Colours.dark_green, + ) + await ctx.send(embed=letter_embed, delete_after=4) + continue + + # Checks for repeated guesses + elif normalized_content in guessed_letters: + already_guessed_embed = Embed( + title=choice(NEGATIVE_REPLIES), + description=f"You have already guessed `{normalized_content}`, try again!", + color=Colours.dark_green, + ) + await ctx.send(embed=already_guessed_embed, delete_after=4) + continue + + # Checks for correct guesses from the user + elif normalized_content in word: + positions = {idx for idx, letter in enumerate(pretty_word) if letter == normalized_content} + user_guess = "".join( + [normalized_content if index in positions else dash for index, dash in enumerate(user_guess)] + ) + + else: + tries -= 1 + + if tries <= 0: + losing_embed = Embed( + title="You lost.", + description=f"The word was `{word}`.", + color=Colours.soft_red, + ) + await original_message.edit(embed=self.create_embed(tries, user_guess)) + await ctx.send(embed=losing_embed) + return + + guessed_letters.add(normalized_content) + + # The loop exited meaning that the user has guessed the word + await original_message.edit(embed=self.create_embed(tries, user_guess)) + win_embed = Embed( + title="You won!", + description=f"The word was `{word}`.", + color=Colours.grass_green + ) + await ctx.send(embed=win_embed) + + +def setup(bot: Bot) -> None: + """Load the Hangman cog.""" + bot.add_cog(Hangman(bot)) diff --git a/bot/exts/fun/quack.py b/bot/exts/fun/quack.py new file mode 100644 index 00000000..0c228aed --- /dev/null +++ b/bot/exts/fun/quack.py @@ -0,0 +1,75 @@ +import logging +import random +from typing import Literal, Optional + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours, NEGATIVE_REPLIES + +API_URL = 'https://quackstack.pythondiscord.com' + +log = logging.getLogger(__name__) + + +class Quackstack(commands.Cog): + """Cog used for wrapping Quackstack.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.command() + async def quack( + self, + ctx: commands.Context, + ducktype: Literal["duck", "manduck"] = "duck", + *, + seed: Optional[str] = None + ) -> None: + """ + Use the Quackstack API to generate a random duck. + + If a seed is provided, a duck is generated based on the given seed. + Either "duck" or "manduck" can be provided to change the duck type generated. + """ + ducktype = ducktype.lower() + quackstack_url = f"{API_URL}/{ducktype}" + params = {} + if seed is not None: + try: + seed = int(seed) + except ValueError: + # We just need to turn the string into an integer any way possible + seed = int.from_bytes(seed.encode(), "big") + params["seed"] = seed + + async with self.bot.http_session.get(quackstack_url, params=params) as response: + error_embed = discord.Embed( + title=random.choice(NEGATIVE_REPLIES), + description="The request failed. Please try again later.", + color=Colours.soft_red, + ) + if response.status != 200: + log.error(f"Response to Quackstack returned code {response.status}") + await ctx.send(embed=error_embed) + return + + data = await response.json() + file = data["file"] + + embed = discord.Embed( + title=f"Quack! Here's a {ducktype} for you.", + description=f"A {ducktype} from Quackstack.", + color=Colours.grass_green, + url=f"{API_URL}/docs" + ) + + embed.set_image(url=API_URL + file) + + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Loads the Quack cog.""" + bot.add_cog(Quackstack(bot)) diff --git a/bot/exts/fun/tic_tac_toe.py b/bot/exts/fun/tic_tac_toe.py index 5c4f8051..946b6f7b 100644 --- a/bot/exts/fun/tic_tac_toe.py +++ b/bot/exts/fun/tic_tac_toe.py @@ -72,10 +72,12 @@ class Player: class AI: """Tic Tac Toe AI class for against computer gaming.""" - def __init__(self, symbol: str): + def __init__(self, bot_user: discord.Member, symbol: str): + self.user = bot_user self.symbol = symbol - async def get_move(self, board: dict[int, str], _: discord.Message) -> tuple[bool, int]: + @staticmethod + async def get_move(board: dict[int, str], _: discord.Message) -> tuple[bool, int]: """Get move from AI. AI use Minimax strategy.""" possible_moves = [i for i, emoji in board.items() if emoji in list(Emojis.number_emojis.values())] @@ -97,8 +99,8 @@ class AI: return False, random.choice(open_edges) def __str__(self) -> str: - """Return `AI` as user name.""" - return "AI" + """Return mention of @Sir Lancebot.""" + return self.user.mention class Game: @@ -107,6 +109,7 @@ class Game: def __init__(self, players: list[Union[Player, AI]], ctx: Context): self.players = players self.ctx = ctx + self.channel = ctx.channel self.board = { 1: Emojis.number_emojis[1], 2: Emojis.number_emojis[2], @@ -173,7 +176,8 @@ class Game: self.canceled = True return False, "User declined" - async def add_reactions(self, msg: discord.Message) -> None: + @staticmethod + async def add_reactions(msg: discord.Message) -> None: """Add number emojis to message.""" for nr in Emojis.number_emojis.values(): await msg.add_reaction(nr) @@ -265,7 +269,7 @@ class TicTacToe(Cog): return if opponent is None: game = Game( - [Player(ctx.author, ctx, Emojis.x_square), AI(Emojis.o_square)], + [Player(ctx.author, ctx, Emojis.x_square), AI(ctx.me, Emojis.o_square)], ctx ) else: diff --git a/bot/exts/fun/trivia_quiz.py b/bot/exts/fun/trivia_quiz.py index cf9e6cd3..712c8a12 100644 --- a/bot/exts/fun/trivia_quiz.py +++ b/bot/exts/fun/trivia_quiz.py @@ -3,190 +3,211 @@ import json import logging import operator import random +import re +import string +from collections import defaultdict from dataclasses import dataclass +from datetime import datetime, timedelta from pathlib import Path from typing import Callable, Optional import discord -from discord.ext import commands +from discord.ext import commands, tasks from rapidfuzz import fuzz from bot.bot import Bot -from bot.constants import Colours, NEGATIVE_REPLIES, Roles +from bot.constants import Client, Colours, NEGATIVE_REPLIES, Roles logger = logging.getLogger(__name__) -DEFAULT_QUESTION_LIMIT = 6 +DEFAULT_QUESTION_LIMIT = 7 STANDARD_VARIATION_TOLERANCE = 88 DYNAMICALLY_GEN_VARIATION_TOLERANCE = 97 +MAX_ERROR_FETCH_TRIES = 3 + 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", -] +RULES = ( + "No cheating and have fun!", + "Points for each question reduces by 25 after 10s or after a hint. Total time is 30s per question" +) -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"), -} +WIKI_FEED_API_URL = "https://en.wikipedia.org/api/rest_v1/feed/featured/{date}" +TRIVIA_QUIZ_ICON = ( + "https://raw.githubusercontent.com/python-discord/branding/main/icons/trivia_quiz/trivia-quiz-dist.png" +) @dataclass(frozen=True) class QuizEntry: - """Dataclass for a quiz entry (a question and a string containing answers separated by commas).""" + """Stores quiz entry (a question and a list of answers).""" 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) + answers: list[str] + var_tol: int + + +class DynamicQuestionGen: + """Class that contains functions to generate math/science questions for TriviaQuiz Cog.""" + + 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"), + } + + @classmethod + def linear_system(cls, 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) + return QuizEntry(question, [answer], DYNAMICALLY_GEN_VARIATION_TOLERANCE) + @classmethod + def mod_arith(cls, 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 -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(a, b, m) + answer = a_format.format(ans) - question = q_format.format(N_PREFIXES[n]) - answer = a_format.format((n + N_PREFIX_STARTS_AT) * 2) + return QuizEntry(question, [answer], DYNAMICALLY_GEN_VARIATION_TOLERANCE) - return QuizEntry(question, answer) + @classmethod + def ngonal_prism(cls, q_format: str, a_format: str) -> QuizEntry: + """Generate a question regarding vertices on n-gonal prisms.""" + n = random.randint(0, len(cls.N_PREFIXES) - 1) + question = q_format.format(cls.N_PREFIXES[n]) + answer = a_format.format((n + cls.N_PREFIX_STARTS_AT) * 2) -def imag_sqrt(q_format: str, a_format: str) -> QuizEntry: - """Generate a negative square root question.""" - ans_coeff = random.randint(3, 10) + return QuizEntry(question, [answer], DYNAMICALLY_GEN_VARIATION_TOLERANCE) - question = q_format.format(ans_coeff ** 2) - answer = a_format.format(ans_coeff) + @classmethod + def imag_sqrt(cls, q_format: str, a_format: str) -> QuizEntry: + """Generate a negative square root question.""" + ans_coeff = random.randint(3, 10) - return QuizEntry(question, answer) + question = q_format.format(ans_coeff ** 2) + answer = a_format.format(ans_coeff) + return QuizEntry(question, [answer], DYNAMICALLY_GEN_VARIATION_TOLERANCE) -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), + @classmethod + def binary_calc(cls, 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 + # 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)) + question = q_format.format(a, oper[0], b) + answer = a_format.format(oper[1](a, b)) - return QuizEntry(question, answer) + return QuizEntry(question, [answer], DYNAMICALLY_GEN_VARIATION_TOLERANCE) + @classmethod + def solar_system(cls, q_format: str, a_format: str) -> QuizEntry: + """Generate a question on the planets of the Solar System.""" + planet = random.choice(cls.PLANETS) -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]) - question = q_format.format(planet[0]) - answer = a_format.format(planet[1]) + return QuizEntry(question, [answer], DYNAMICALLY_GEN_VARIATION_TOLERANCE) - return QuizEntry(question, answer) + @classmethod + def taxonomic_rank(cls, q_format: str, a_format: str) -> QuizEntry: + """Generate a question on taxonomic classification.""" + level = random.randint(0, len(cls.TAXONOMIC_HIERARCHY) - 2) + question = q_format.format(cls.TAXONOMIC_HIERARCHY[level]) + answer = a_format.format(cls.TAXONOMIC_HIERARCHY[level + 1]) -def taxonomic_rank(q_format: str, a_format: str) -> QuizEntry: - """Generate a question on taxonomic classification.""" - level = random.randint(0, len(TAXONOMIC_HIERARCHY) - 2) + return QuizEntry(question, [answer], DYNAMICALLY_GEN_VARIATION_TOLERANCE) - question = q_format.format(TAXONOMIC_HIERARCHY[level]) - answer = a_format.format(TAXONOMIC_HIERARCHY[level + 1]) + @classmethod + def base_units_convert(cls, q_format: str, a_format: str) -> QuizEntry: + """Generate a SI base units conversion question.""" + unit = random.choice(list(cls.UNITS_TO_BASE_UNITS)) - 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] - ) + question = q_format.format( + unit + " " + cls.UNITS_TO_BASE_UNITS[unit][0] + ) + answer = a_format.format( + cls.UNITS_TO_BASE_UNITS[unit][1] + ) - return QuizEntry(question, answer) + return QuizEntry(question, [answer], DYNAMICALLY_GEN_VARIATION_TOLERANCE) 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, + 201: DynamicQuestionGen.linear_system, + 202: DynamicQuestionGen.mod_arith, + 203: DynamicQuestionGen.ngonal_prism, + 204: DynamicQuestionGen.imag_sqrt, + 205: DynamicQuestionGen.binary_calc, + 301: DynamicQuestionGen.solar_system, + 302: DynamicQuestionGen.taxonomic_rank, + 303: DynamicQuestionGen.base_units_convert, } @@ -202,7 +223,7 @@ class TriviaQuiz(commands.Cog): 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.player_scores = defaultdict(int) # 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 = { @@ -212,8 +233,72 @@ class TriviaQuiz(commands.Cog): "science": "Put your understanding of science to the test!", "cs": "A large variety of computer science questions.", "python": "Trivia on our amazing language, Python!", + "wikipedia": "Guess the title of random wikipedia passages.", } + self.get_wiki_questions.start() + + def cog_unload(self) -> None: + """Cancel `get_wiki_questions` task when Cog will unload.""" + self.get_wiki_questions.cancel() + + @tasks.loop(hours=24.0) + async def get_wiki_questions(self) -> None: + """Get yesterday's most read articles from wikipedia and format them like trivia questions.""" + error_fetches = 0 + wiki_questions = [] + # trivia_quiz.json follows a pattern, every new category starts with the next century. + start_id = 501 + yesterday = datetime.strftime(datetime.now() - timedelta(1), '%Y/%m/%d') + + while error_fetches < MAX_ERROR_FETCH_TRIES: + async with self.bot.http_session.get(url=WIKI_FEED_API_URL.format(date=yesterday)) as r: + if r.status != 200: + error_fetches += 1 + continue + raw_json = await r.json() + articles_raw = raw_json["mostread"]["articles"] + + for article in articles_raw: + question = article.get("extract") + if not question: + continue + + # Normalize the wikipedia article title to remove all punctuations from it + for word in re.split(r"[\s-]", title := article["normalizedtitle"]): + cleaned_title = re.sub( + rf'\b{word.strip(string.punctuation)}\b', word, title, flags=re.IGNORECASE + ) + + # Since the extract contains the article name sometimes this would replace all the matching words + # in that article with *** of that length. + # NOTE: This removes the "answer" for 99% of the cases, but sometimes the wikipedia article is + # very different from the words in the extract, for example the title would be the nickname of a + # person (Bob Ross) whereas in the extract it would the full name (Robert Norman Ross) so it comes + # out as (Robert Norman ****) and (Robert Norman Ross) won't be a right answer :( + for word in re.split(r"[\s-]", cleaned_title): + word = word.strip(string.punctuation) + secret_word = r"\*" * len(word) + question = re.sub(rf'\b{word}\b', f"**{secret_word}**", question, flags=re.IGNORECASE) + + formatted_article_question = { + "id": start_id, + "question": f"Guess the title of the Wikipedia article.\n\n{question}", + "answer": cleaned_title, + "info": article["extract"] + } + start_id += 1 + wiki_questions.append(formatted_article_question) + + # If everything has gone smoothly until now, we can break out of the while loop + break + + if error_fetches < MAX_ERROR_FETCH_TRIES: + self.questions["wikipedia"] = wiki_questions.copy() + else: + del self.categories["wikipedia"] + logger.warning(f"Not loading wikipedia guess questions, hit max error fetches: {MAX_ERROR_FETCH_TRIES}.") + @staticmethod def load_questions() -> dict: """Load the questions from the JSON file.""" @@ -221,7 +306,7 @@ class TriviaQuiz(commands.Cog): return json.loads(p.read_text(encoding="utf-8")) - @commands.group(name="quiz", aliases=["trivia"], invoke_without_command=True) + @commands.group(name="quiz", aliases=("trivia", "triviaquiz"), invoke_without_command=True) async def quiz_game(self, ctx: commands.Context, category: Optional[str], questions: Optional[int]) -> None: """ Start a quiz! @@ -233,6 +318,7 @@ class TriviaQuiz(commands.Cog): - science: Put your understanding of science to the test! - cs: A large variety of computer science questions. - python: Trivia on our amazing language, Python! + - wikipedia: Guess the title of random wikipedia passages. (More to come!) """ @@ -246,7 +332,7 @@ class TriviaQuiz(commands.Cog): if self.game_status[ctx.channel.id]: await ctx.send( "Game is already running... " - f"do `{self.bot.command_prefix}quiz stop`" + f"do `{Client.prefix}quiz stop`" ) return @@ -264,7 +350,7 @@ class TriviaQuiz(commands.Cog): topic_length = len(topic) if questions is None: - self.question_limit = DEFAULT_QUESTION_LIMIT + self.question_limit = min(DEFAULT_QUESTION_LIMIT, topic_length) else: if questions > topic_length: await ctx.send( @@ -279,13 +365,13 @@ class TriviaQuiz(commands.Cog): 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)" + f"(or enter nothing for the default value of {DEFAULT_QUESTION_LIMIT} questions)" ) ) return else: - self.question_limit = questions - 1 + self.question_limit = questions # Start game if not running. if not self.game_status[ctx.channel.id]: @@ -296,13 +382,13 @@ class TriviaQuiz(commands.Cog): await ctx.send(embed=start_embed) # send an embed with the rules await asyncio.sleep(5) - done_question = [] + done_questions = [] hint_no = 0 - answers = None + quiz_entry = 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: + if len(done_questions) == 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]) @@ -317,32 +403,27 @@ class TriviaQuiz(commands.Cog): # 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"]) + if question_dict["id"] not in done_questions: + done_questions.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 + quiz_entry = QuizEntry( + question_dict["question"], + quiz_answers if isinstance(quiz_answers := question_dict["answer"], list) else [quiz_answers], + 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, + title=f"Question #{len(done_questions)}", + description=quiz_entry.question, ) if img_url := question_dict.get("img_url"): @@ -354,13 +435,13 @@ class TriviaQuiz(commands.Cog): 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 + for answer in quiz_entry.answers ) return contains_correct_answer try: - msg = await self.bot.wait_for("message", check=check_func(var_tol), timeout=10) + msg = await self.bot.wait_for("message", check=check_func(quiz_entry.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]: @@ -388,10 +469,10 @@ class TriviaQuiz(commands.Cog): await self.send_answer( ctx.channel, - answers, + quiz_entry.answers, False, question_dict, - self.question_limit - len(done_question) + 1, + self.question_limit - len(done_questions), ) await asyncio.sleep(1) @@ -421,10 +502,10 @@ class TriviaQuiz(commands.Cog): await self.send_answer( ctx.channel, - answers, + quiz_entry.answers, True, question_dict, - self.question_limit - len(done_question) + 1, + self.question_limit - len(done_questions), ) await self.send_score(ctx.channel, self.game_player_scores[ctx.channel.id]) @@ -432,19 +513,18 @@ class TriviaQuiz(commands.Cog): def make_start_embed(self, category: str) -> discord.Embed: """Generate a starting/introduction embed for the quiz.""" + rules = "\n".join([f"{index}: {rule}" for index, rule in enumerate(RULES, start=1)]) + start_embed = discord.Embed( - colour=Colours.blue, - title="A quiz game is starting!", + title="Quiz game 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}" + f"Each game consists of {self.question_limit} questions.\n" + f"**Rules :**\n{rules}" + f"\n **Category** : {category}" ), + colour=Colours.blue ) + start_embed.set_thumbnail(url=TRIVIA_QUIZ_ICON) return start_embed @@ -503,6 +583,7 @@ class TriviaQuiz(commands.Cog): title="Score Board", description="", ) + embed.set_thumbnail(url=TRIVIA_QUIZ_ICON) sorted_dict = sorted(player_data.items(), key=operator.itemgetter(1), reverse=True) for item in sorted_dict: @@ -534,8 +615,8 @@ class TriviaQuiz(commands.Cog): 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!" + f"{winners_mention} Congratulations " + f"on winning this quiz game with a grand total of {highest_points} points :tada:" ) def category_embed(self) -> discord.Embed: @@ -578,7 +659,8 @@ class TriviaQuiz(commands.Cog): description="", ) - if info is not None: + # Don't check for info is not None, as we want to filter out empty strings. + if info: embed.description += f"**Information**\n{info}\n\n" embed.description += ( |