diff options
| author | 2022-02-19 20:34:46 +0200 | |
|---|---|---|
| committer | 2022-02-19 20:34:46 +0200 | |
| commit | 31b3543e78f38bbd393af97590090cfab638043e (patch) | |
| tree | 505c8303a6327aa76c854474708983eafdcfa52b /bot/exts/events | |
| parent | add comments and docstrings, remove an unused variable (diff) | |
| parent | Add topics for `#programming-pedagogy` channel (diff) | |
Merge branch 'main' into feat/latex
Diffstat (limited to 'bot/exts/events')
| -rw-r--r-- | bot/exts/events/advent_of_code/_cog.py | 3 | ||||
| -rw-r--r-- | bot/exts/events/advent_of_code/_helpers.py | 2 | ||||
| -rw-r--r-- | bot/exts/events/trivianight/__init__.py | 0 | ||||
| -rw-r--r-- | bot/exts/events/trivianight/_game.py | 192 | ||||
| -rw-r--r-- | bot/exts/events/trivianight/_questions.py | 179 | ||||
| -rw-r--r-- | bot/exts/events/trivianight/_scoreboard.py | 186 | ||||
| -rw-r--r-- | bot/exts/events/trivianight/trivianight.py | 328 | 
7 files changed, 888 insertions, 2 deletions
diff --git a/bot/exts/events/advent_of_code/_cog.py b/bot/exts/events/advent_of_code/_cog.py index 3acfef39..518841d4 100644 --- a/bot/exts/events/advent_of_code/_cog.py +++ b/bot/exts/events/advent_of_code/_cog.py @@ -61,7 +61,8 @@ class AdventOfCode(commands.Cog):          self.status_task.set_name("AoC Status Countdown")          self.status_task.add_done_callback(_helpers.background_task_callback) -        self.completionist_task.start() +        # Don't start task while event isn't running +        # self.completionist_task.start()      @tasks.loop(minutes=10.0)      async def completionist_task(self) -> None: diff --git a/bot/exts/events/advent_of_code/_helpers.py b/bot/exts/events/advent_of_code/_helpers.py index 15b1329d..6c004901 100644 --- a/bot/exts/events/advent_of_code/_helpers.py +++ b/bot/exts/events/advent_of_code/_helpers.py @@ -255,7 +255,7 @@ async def _fetch_leaderboard_data() -> dict[str, Any]:          # Two attempts, one with the original session cookie and one with the fallback session          for attempt in range(1, 3): -            log.info(f"Attempting to fetch leaderboard `{leaderboard.id}` ({attempt}/2)") +            log.debug(f"Attempting to fetch leaderboard `{leaderboard.id}` ({attempt}/2)")              cookies = {"session": leaderboard.session}              try:                  raw_data = await _leaderboard_request(leaderboard_url, leaderboard.id, cookies) diff --git a/bot/exts/events/trivianight/__init__.py b/bot/exts/events/trivianight/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/bot/exts/events/trivianight/__init__.py diff --git a/bot/exts/events/trivianight/_game.py b/bot/exts/events/trivianight/_game.py new file mode 100644 index 00000000..8b012a17 --- /dev/null +++ b/bot/exts/events/trivianight/_game.py @@ -0,0 +1,192 @@ +import time +from random import randrange +from string import ascii_uppercase +from typing import Iterable, NamedTuple, Optional, TypedDict + +DEFAULT_QUESTION_POINTS = 10 +DEFAULT_QUESTION_TIME = 20 + + +class QuestionData(TypedDict): +    """Representing the different 'keys' of the question taken from the JSON.""" + +    number: str +    description: str +    answers: list[str] +    correct: str +    points: Optional[int] +    time: Optional[int] + + +class UserGuess(NamedTuple): +    """Represents the user's guess for a question.""" + +    answer: str +    editable: bool +    elapsed: float + + +class QuestionClosed(RuntimeError): +    """Exception raised when the question is not open for guesses anymore.""" + + +class AlreadyUpdated(RuntimeError): +    """Exception raised when the user has already updated their guess once.""" + + +class AllQuestionsVisited(RuntimeError): +    """Exception raised when all of the questions have been visited.""" + + +class Question: +    """Interface for one question in a trivia night game.""" + +    def __init__(self, data: QuestionData): +        self._data = data +        self._guesses: dict[int, UserGuess] = {} +        self._started = None + +    # These properties are mostly proxies to the underlying data: + +    @property +    def number(self) -> str: +        """The number of the question.""" +        return self._data["number"] + +    @property +    def description(self) -> str: +        """The description of the question.""" +        return self._data["description"] + +    @property +    def answers(self) -> list[tuple[str, str]]: +        """ +        The possible answers for this answer. + +        This is a property that returns a list of letter, answer pairs. +        """ +        return [(ascii_uppercase[i], q) for (i, q) in enumerate(self._data["answers"])] + +    @property +    def correct(self) -> str: +        """The correct answer for this question.""" +        return self._data["correct"] + +    @property +    def max_points(self) -> int: +        """The maximum points that can be awarded for this question.""" +        return self._data.get("points") or DEFAULT_QUESTION_POINTS + +    @property +    def time(self) -> float: +        """The time allowed to answer the question.""" +        return self._data.get("time") or DEFAULT_QUESTION_TIME + +    def start(self) -> float: +        """Start the question and return the time it started.""" +        self._started = time.perf_counter() +        return self._started + +    def _update_guess(self, user: int, answer: str) -> UserGuess: +        """Update an already existing guess.""" +        if self._started is None: +            raise QuestionClosed("Question is not open for answers.") + +        if self._guesses[user][1] is False: +            raise AlreadyUpdated(f"User({user}) has already updated their guess once.") + +        self._guesses[user] = (answer, False, time.perf_counter() - self._started) +        return self._guesses[user] + +    def guess(self, user: int, answer: str) -> UserGuess: +        """Add a guess made by a user to the current question.""" +        if user in self._guesses: +            return self._update_guess(user, answer) + +        if self._started is None: +            raise QuestionClosed("Question is not open for answers.") + +        self._guesses[user] = (answer, True, time.perf_counter() - self._started) +        return self._guesses[user] + +    def stop(self) -> dict[int, UserGuess]: +        """Stop the question and return the guesses that were made.""" +        guesses = self._guesses + +        self._started = None +        self._guesses = {} + +        return guesses + + +class TriviaNightGame: +    """Interface for managing a game of trivia night.""" + +    def __init__(self, data: list[QuestionData]) -> None: +        self._questions = [Question(q) for q in data] +        # A copy of the questions to keep for `.trivianight list` +        self._all_questions = list(self._questions) +        self.current_question: Optional[Question] = None +        self._points = {} +        self._speed = {} + +    def __iter__(self) -> Iterable[Question]: +        return iter(self._questions) + +    def next_question(self, number: str = None) -> Question: +        """ +        Consume one random question from the trivia night game. + +        One question is randomly picked from the list of questions which is then removed and returned. +        """ +        if self.current_question is not None: +            raise RuntimeError("Cannot call next_question() when there is a current question.") + +        if number is not None: +            try: +                question = [q for q in self._all_questions if q.number == int(number)][0] +            except IndexError: +                raise ValueError(f"Question number {number} does not exist.") +        elif len(self._questions) == 0: +            raise AllQuestionsVisited("All of the questions have been visited.") +        else: +            question = self._questions.pop(randrange(len(self._questions))) + +        self.current_question = question +        return question + +    def end_question(self) -> None: +        """ +        End the current question. + +        This method should be called when the question has been answered, it must be called before +        attempting to call `next_question()` again. +        """ +        if self.current_question is None: +            raise RuntimeError("Cannot call end_question() when there is no current question.") + +        self.current_question.stop() +        self.current_question = None + +    def list_questions(self) -> str: +        """ +        List all the questions. + +        This method should be called when `.trivianight list` is called to display the following information: +            - Question number +            - Question description +            - Visited/not visited +        """ +        question_list = [] + +        visited = ":white_check_mark:" +        not_visited = ":x:" + +        for question in self._all_questions: +            formatted_string = ( +                f"**Q{question.number}** {not_visited if question in self._questions else visited}" +                f"\n{question.description}\n\n" +            ) +            question_list.append(formatted_string.rstrip()) + +        return question_list diff --git a/bot/exts/events/trivianight/_questions.py b/bot/exts/events/trivianight/_questions.py new file mode 100644 index 00000000..d6beced9 --- /dev/null +++ b/bot/exts/events/trivianight/_questions.py @@ -0,0 +1,179 @@ +from random import choice +from string import ascii_uppercase + +import discord +from discord import Embed, Interaction +from discord.ui import Button, View + +from bot.constants import Colours, NEGATIVE_REPLIES + +from ._game import AlreadyUpdated, Question, QuestionClosed +from ._scoreboard import Scoreboard + + +class AnswerButton(Button): +    """Button subclass that's used to guess on a particular answer.""" + +    def __init__(self, label: str, question: Question): +        super().__init__(label=label, style=discord.ButtonStyle.green) + +        self.question = question + +    async def callback(self, interaction: Interaction) -> None: +        """ +        When a user interacts with the button, this will be called. + +        Parameters: +            - interaction: an instance of discord.Interaction representing the interaction between the user and the +            button. +        """ +        try: +            guess = self.question.guess(interaction.user.id, self.label) +        except AlreadyUpdated: +            await interaction.response.send_message( +                embed=Embed( +                    title=choice(NEGATIVE_REPLIES), +                    description="You've already changed your answer more than once!", +                    color=Colours.soft_red +                ), +                ephemeral=True +            ) +            return +        except QuestionClosed: +            await interaction.response.send_message( +                embed=Embed( +                    title=choice(NEGATIVE_REPLIES), +                    description="The question is no longer accepting guesses!", +                    color=Colours.soft_red +                ), +                ephemeral=True +            ) +            return + +        if guess[1]: +            await interaction.response.send_message( +                embed=Embed( +                    title="Confirming that...", +                    description=f"You chose answer {self.label}.", +                    color=Colours.soft_green +                ), +                ephemeral=True +            ) +        else: +            # guess[1] is False and they cannot change their answer again. Which +            # indicates that they changed it this time around. +            await interaction.response.send_message( +                embed=Embed( +                    title="Confirming that...", +                    description=f"You changed your answer to answer {self.label}.", +                    color=Colours.soft_green +                ), +                ephemeral=True +            ) + + +class QuestionView(View): +    """View for one trivia night question.""" + +    def __init__(self, question: Question) -> None: +        super().__init__() +        self.question = question + +        for letter, _ in self.question.answers: +            self.add_item(AnswerButton(letter, self.question)) + +    @staticmethod +    def unicodeify(text: str) -> str: +        """ +        Takes `text` and adds zero-width spaces to prevent copy and pasting the question. + +        Parameters: +            - text: A string that represents the question description to 'unicodeify' +        """ +        return "".join( +            f"{letter}\u200b" if letter not in ('\n', '\t', '`', 'p', 'y') else letter +            for idx, letter in enumerate(text) +        ) + +    def create_embed(self) -> Embed: +        """Helper function to create the embed for the current question.""" +        question_embed = Embed( +            title=f"Question {self.question.number}", +            description=self.unicodeify(self.question.description), +            color=Colours.python_yellow +        ) + +        for label, answer in self.question.answers: +            question_embed.add_field(name=f"Answer {label}", value=answer, inline=False) + +        return question_embed + +    def end_question(self, scoreboard: Scoreboard) -> Embed: +        """ +        Ends the question and displays the statistics on who got the question correct, awards points, etc. + +        Returns: +            An embed displaying the correct answers and the % of people that chose each answer. +        """ +        guesses = self.question.stop() + +        labels = ascii_uppercase[:len(self.question.answers)] + +        answer_embed = Embed( +            title=f"The correct answer for Question {self.question.number} was...", +            description=self.question.correct +        ) + +        if len(guesses) != 0: +            answers_chosen = { +                answer_choice: len( +                    tuple(filter(lambda x: x[0] == answer_choice, guesses.values())) +                ) +                for answer_choice in labels +            } + +            answers_chosen = dict( +                sorted(list(answers_chosen.items()), key=lambda item: item[1], reverse=True) +            ) + +            for answer, people_answered in answers_chosen.items(): +                is_correct_answer = dict(self.question.answers)[answer[0]] == self.question.correct + +                # Setting the color of answer_embed to the % of people that got it correct via the mapping +                if is_correct_answer: +                    # Maps the % of people who got it right to a color, from a range of red to green +                    percentage_to_color = [0xFC94A1, 0xFFCCCB, 0xCDFFCC, 0xB0F5AB, 0xB0F5AB] +                    answer_embed.color = percentage_to_color[round(people_answered / len(guesses) * 100) // 25] + +                field_title = ( +                    (":white_check_mark: " if is_correct_answer else "") +                    + f"{people_answered} players ({people_answered / len(guesses) * 100:.1f}%) chose" +                ) + +                # The `ord` function is used here to change the letter to its corresponding position +                answer_embed.add_field( +                    name=field_title, +                    value=self.question.answers[ord(answer) - 65][1], +                    inline=False +                ) + +            # Assign points to users +            for user_id, answer in guesses.items(): +                if dict(self.question.answers)[answer[0]] == self.question.correct: +                    scoreboard.assign_points( +                        int(user_id), +                        points=(1 - (answer[-1] / self.question.time) / 2) * self.question.max_points, +                        speed=answer[-1] +                    ) +                elif answer[-1] <= 2: +                    scoreboard.assign_points( +                        int(user_id), +                        points=-(1 - (answer[-1] / self.question.time) / 2) * self.question.max_points +                    ) +                else: +                    scoreboard.assign_points( +                        int(user_id), +                        points=0 +                    ) + +        return answer_embed diff --git a/bot/exts/events/trivianight/_scoreboard.py b/bot/exts/events/trivianight/_scoreboard.py new file mode 100644 index 00000000..a5a5fcac --- /dev/null +++ b/bot/exts/events/trivianight/_scoreboard.py @@ -0,0 +1,186 @@ +from random import choice + +import discord.ui +from discord import ButtonStyle, Embed, Interaction, Member +from discord.ui import Button, View + +from bot.bot import Bot +from bot.constants import Colours, NEGATIVE_REPLIES + + +class ScoreboardView(View): +    """View for the scoreboard.""" + +    def __init__(self, bot: Bot): +        super().__init__() +        self.bot = bot + +    @staticmethod +    def _int_to_ordinal(number: int) -> str: +        """ +        Converts an integer into an ordinal number, i.e. 1 to 1st. + +        Parameters: +            - number: an integer representing the number to convert to an ordinal number. +        """ +        suffix = ["th", "st", "nd", "rd", "th"][min(number % 10, 4)] +        if (number % 100) in {11, 12, 13}: +            suffix = "th" + +        return str(number) + suffix + +    async def create_main_leaderboard(self) -> Embed: +        """ +        Helper function that iterates through `self.points` to generate the main leaderboard embed. + +        The main leaderboard would be formatted like the following: +        **1**. @mention of the user (# of points) +        along with the 29 other users who made it onto the leaderboard. +        """ +        formatted_string = "" + +        for current_placement, (user, points) in enumerate(self.points.items()): +            if current_placement + 1 > 30: +                break + +            user = await self.bot.fetch_user(int(user)) +            formatted_string += f"**{current_placement + 1}.** {user.mention} " +            formatted_string += f"({points:.1f} pts)\n" +            if (current_placement + 1) % 10 == 0: +                formatted_string += "⎯⎯⎯⎯⎯⎯⎯⎯\n" + +        main_embed = Embed( +            title="Winners of the Trivia Night", +            description=formatted_string, +            color=Colours.python_blue, +        ) + +        return main_embed + +    async def _create_speed_embed(self) -> Embed: +        """ +        Helper function that iterates through `self.speed` to generate a leaderboard embed. + +        The speed leaderboard would be formatted like the following: +        **1**. @mention of the user ([average speed as a float with the precision of one decimal point]s) +        along with the 29 other users who made it onto the leaderboard. +        """ +        formatted_string = "" + +        for current_placement, (user, time_taken) in enumerate(self.speed.items()): +            if current_placement + 1 > 30: +                break + +            user = await self.bot.fetch_user(int(user)) +            formatted_string += f"**{current_placement + 1}.** {user.mention} " +            formatted_string += f"({(time_taken[-1] / time_taken[0]):.1f}s)\n" +            if (current_placement + 1) % 10 == 0: +                formatted_string += "⎯⎯⎯⎯⎯⎯⎯⎯\n" + +        speed_embed = Embed( +            title="Average time taken to answer a question", +            description=formatted_string, +            color=Colours.python_blue +        ) +        return speed_embed + +    def _get_rank(self, member: Member) -> Embed: +        """ +        Gets the member's rank for the points leaderboard and speed leaderboard. + +        Parameters: +            - member: An instance of discord.Member representing the person who is trying to get their rank. +        """ +        rank_embed = Embed(title=f"Ranks for {member.display_name}", color=Colours.python_blue) +        # These are stored as strings so that the last digit can be determined to choose the suffix +        try: +            points_rank = str(list(self.points).index(member.id) + 1) +            speed_rank = str(list(self.speed).index(member.id) + 1) +        except ValueError: +            return Embed( +                title=choice(NEGATIVE_REPLIES), +                description="It looks like you didn't participate in the Trivia Night event!", +                color=Colours.soft_red +            ) + +        rank_embed.add_field( +            name="Total Points", +            value=( +                f"You got {self._int_to_ordinal(int(points_rank))} place" +                f" with {self.points[member.id]:.1f} points." +            ), +            inline=False +        ) + +        rank_embed.add_field( +            name="Average Speed", +            value=( +                f"You got {self._int_to_ordinal(int(speed_rank))} place" +                f" with a time of {(self.speed[member.id][1] / self.speed[member.id][0]):.1f} seconds." +            ), +            inline=False +        ) +        return rank_embed + +    @discord.ui.button(label="Scoreboard for Speed", style=ButtonStyle.green) +    async def speed_leaderboard(self, button: Button, interaction: Interaction) -> None: +        """ +        Send an ephemeral message with the speed leaderboard embed. + +        Parameters: +            - button: The discord.ui.Button instance representing the `Speed Leaderboard` button. +            - interaction: The discord.Interaction instance containing information on the interaction between the user +            and the button. +        """ +        await interaction.response.send_message(embed=await self._create_speed_embed(), ephemeral=True) + +    @discord.ui.button(label="What's my rank?", style=ButtonStyle.blurple) +    async def rank_button(self, button: Button, interaction: Interaction) -> None: +        """ +        Send an ephemeral message with the user's rank for the overall points/average speed. + +        Parameters: +            - button: The discord.ui.Button instance representing the `What's my rank?` button. +            - interaction: The discord.Interaction instance containing information on the interaction between the user +            and the button. +        """ +        await interaction.response.send_message(embed=self._get_rank(interaction.user), ephemeral=True) + + +class Scoreboard: +    """Class for the scoreboard for the Trivia Night event.""" + +    def __init__(self, bot: Bot): +        self._bot = bot +        self._points = {} +        self._speed = {} + +    def assign_points(self, user_id: int, *, points: int = None, speed: float = None) -> None: +        """ +        Assign points or deduct points to/from a certain user. + +        This method should be called once the question has finished and all answers have been registered. +        """ +        if points is not None and user_id not in self._points.keys(): +            self._points[user_id] = points +        elif points is not None: +            self._points[user_id] += points + +        if speed is not None and user_id not in self._speed.keys(): +            self._speed[user_id] = [1, speed] +        elif speed is not None: +            self._speed[user_id] = [ +                self._speed[user_id][0] + 1, self._speed[user_id][1] + speed +            ] + +    async def display(self, speed_leaderboard: bool = False) -> tuple[Embed, View]: +        """Returns the embed of the main leaderboard along with the ScoreboardView.""" +        view = ScoreboardView(self._bot) + +        view.points = dict(sorted(self._points.items(), key=lambda item: item[-1], reverse=True)) +        view.speed = dict(sorted(self._speed.items(), key=lambda item: item[-1][1] / item[-1][0])) + +        return ( +            await view.create_main_leaderboard(), +            view if not speed_leaderboard else await view._create_speed_embed() +        ) diff --git a/bot/exts/events/trivianight/trivianight.py b/bot/exts/events/trivianight/trivianight.py new file mode 100644 index 00000000..18d8327a --- /dev/null +++ b/bot/exts/events/trivianight/trivianight.py @@ -0,0 +1,328 @@ +import asyncio +from json import JSONDecodeError, loads +from random import choice +from typing import Optional + +from discord import Embed +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours, NEGATIVE_REPLIES, POSITIVE_REPLIES, Roles +from bot.utils.pagination import LinePaginator + +from ._game import AllQuestionsVisited, TriviaNightGame +from ._questions import QuestionView +from ._scoreboard import Scoreboard + +# The ID you see below are the Events Lead role ID and the Event Runner Role ID +TRIVIA_NIGHT_ROLES = (Roles.admins, 778361735739998228, 940911658799333408) + + +class TriviaNightCog(commands.Cog): +    """Cog for the Python Trivia Night event.""" + +    def __init__(self, bot: Bot): +        self.bot = bot +        self.game: Optional[TriviaNightGame] = None +        self.scoreboard: Optional[Scoreboard] = None +        self.question_closed: asyncio.Event = None + +    @commands.group(aliases=["tn"], invoke_without_command=True) +    async def trivianight(self, ctx: commands.Context) -> None: +        """ +        The command group for the Python Discord Trivia Night. + +        If invoked without a subcommand (i.e. simply .trivianight), it will explain what the Trivia Night event is. +        """ +        cog_description = Embed( +            title="What is .trivianight?", +            description=( +                "This 'cog' is for the Python Discord's TriviaNight (date tentative)! Compete against other" +                " players in a trivia about Python!" +            ), +            color=Colours.soft_green +        ) +        await ctx.send(embed=cog_description) + +    @trivianight.command() +    @commands.has_any_role(*TRIVIA_NIGHT_ROLES) +    async def load(self, ctx: commands.Context, *, to_load: Optional[str]) -> None: +        """ +        Loads a JSON file from the provided attachment or argument. + +        The JSON provided is formatted where it is a list of dictionaries, each dictionary containing the keys below: +        - number: int (represents the current question #) +        - description: str (represents the question itself) +        - answers: list[str] (represents the different answers possible, must be a length of 4) +        - correct: str (represents the correct answer in terms of what the correct answer is in `answers` +        - time: Optional[int] (represents the timer for the question and how long it should run, default is 10) +        - points: Optional[int] (represents how many points are awarded for each question, default is 10) + +        The load command accepts three different ways of loading in a JSON: +        - an attachment of the JSON file +        - a message link to the attachment/JSON +        - reading the JSON itself via a codeblock or plain text +        """ +        if self.game is not None: +            await ctx.send(embed=Embed( +                title=choice(NEGATIVE_REPLIES), +                description="There is already a trivia night running!", +                color=Colours.soft_red +            )) +            return + +        if ctx.message.attachments: +            json_text = (await ctx.message.attachments[0].read()).decode("utf8") +        elif not to_load: +            raise commands.BadArgument("You didn't attach an attachment nor link a message!") +        elif ( +            to_load.startswith("https://discord.com/channels") +            or to_load.startswith("https://discordapp.com/channels") +        ): +            channel_id, message_id = to_load.split("/")[-2:] +            channel = await ctx.guild.fetch_channel(int(channel_id)) +            message = await channel.fetch_message(int(message_id)) +            if message.attachments: +                json_text = (await message.attachments[0].read()).decode("utf8") +            else: +                json_text = message.content.replace("```", "").replace("json", "").replace("\n", "") +        else: +            json_text = to_load.replace("```", "").replace("json", "").replace("\n", "") + +        try: +            serialized_json = loads(json_text) +        except JSONDecodeError as error: +            raise commands.BadArgument(f"Looks like something went wrong:\n{str(error)}") + +        self.game = TriviaNightGame(serialized_json) +        self.question_closed = asyncio.Event() + +        success_embed = Embed( +            title=choice(POSITIVE_REPLIES), +            description="The JSON was loaded successfully!", +            color=Colours.soft_green +        ) + +        self.scoreboard = Scoreboard(self.bot) + +        await ctx.send(embed=success_embed) + +    @trivianight.command(aliases=('next',)) +    @commands.has_any_role(*TRIVIA_NIGHT_ROLES) +    async def question(self, ctx: commands.Context, question_number: str = None) -> None: +        """ +        Gets a random question from the unanswered question list and lets the user(s) choose the answer. + +        This command will continuously count down until the time limit of the question is exhausted. +        However, if `.trivianight stop` is invoked, the counting down is interrupted to show the final results. +        """ +        if self.game is None: +            await ctx.send(embed=Embed( +                title=choice(NEGATIVE_REPLIES), +                description="There is no trivia night running!", +                color=Colours.soft_red +            )) +            return + +        if self.game.current_question is not None: +            error_embed = Embed( +                title=choice(NEGATIVE_REPLIES), +                description="There is already an ongoing question!", +                color=Colours.soft_red +            ) +            await ctx.send(embed=error_embed) +            return + +        try: +            next_question = self.game.next_question(question_number) +        except AllQuestionsVisited: +            error_embed = Embed( +                title=choice(NEGATIVE_REPLIES), +                description="All of the questions have been used.", +                color=Colours.soft_red +            ) +            await ctx.send(embed=error_embed) +            return + +        await ctx.send("Next question in 3 seconds! Get ready...") +        await asyncio.sleep(3) + +        question_view = QuestionView(next_question) +        question_embed = question_view.create_embed() + +        next_question.start() +        message = await ctx.send(embed=question_embed, view=question_view) + +        # Exponentially sleep less and less until the time limit is reached +        percentage = 1 +        while True: +            percentage *= 0.5 +            duration = next_question.time * percentage + +            await asyncio.wait([self.question_closed.wait()], timeout=duration) + +            if self.question_closed.is_set(): +                await ctx.send(embed=question_view.end_question(self.scoreboard)) +                await message.edit(embed=question_embed, view=None) + +                self.game.end_question() +                self.question_closed.clear() +                return + +            if int(duration) > 1: +                # It is quite ugly to display decimals, the delay for requests to reach Discord +                # cause sub-second accuracy to be quite pointless. +                await ctx.send(f"{int(duration)}s remaining...") +            else: +                # Since each time we divide the percentage by 2 and sleep one half of the halves (then sleep a +                # half, of that half) we must sleep both halves at the end. +                await asyncio.wait([self.question_closed.wait()], timeout=duration) +                if self.question_closed.is_set(): +                    await ctx.send(embed=question_view.end_question(self.scoreboard)) +                    await message.edit(embed=question_embed, view=None) + +                    self.game.end_question() +                    self.question_closed.clear() +                    return +                break + +        await ctx.send(embed=question_view.end_question(self.scoreboard)) +        await message.edit(embed=question_embed, view=None) + +        self.game.end_question() + +    @trivianight.command() +    @commands.has_any_role(*TRIVIA_NIGHT_ROLES) +    async def list(self, ctx: commands.Context) -> None: +        """ +        Display all the questions left in the question bank. + +        Questions are displayed in the following format: +        Q(number): Question description | :white_check_mark: if the question was used otherwise :x:. +        """ +        if self.game is None: +            await ctx.send(embed=Embed( +                title=choice(NEGATIVE_REPLIES), +                description="There is no trivia night running!", +                color=Colours.soft_red +            )) +            return + +        question_list = self.game.list_questions() + +        list_embed = Embed(title="All Trivia Night Questions") + +        if len(question_list) == 1: +            list_embed.description = question_list[0] +            await ctx.send(embed=list_embed) +        else: +            await LinePaginator.paginate( +                question_list, +                ctx, +                list_embed +            ) + +    @trivianight.command() +    @commands.has_any_role(*TRIVIA_NIGHT_ROLES) +    async def stop(self, ctx: commands.Context) -> None: +        """ +        End the ongoing question to show the correct question. + +        This command should be used if the question should be ended early or if the time limit fails +        """ +        if self.game is None: +            await ctx.send(embed=Embed( +                title=choice(NEGATIVE_REPLIES), +                description="There is no trivia night running!", +                color=Colours.soft_red +            )) +            return + +        if self.game.current_question is None: +            error_embed = Embed( +                title=choice(NEGATIVE_REPLIES), +                description="There is no ongoing question!", +                color=Colours.soft_red +            ) +            await ctx.send(embed=error_embed) +            return + +        self.question_closed.set() + +    @trivianight.command() +    @commands.has_any_role(*TRIVIA_NIGHT_ROLES) +    async def end(self, ctx: commands.Context) -> None: +        """ +        Displays the scoreboard view. + +        The scoreboard view consists of the two scoreboards with the 30 players who got the highest points and the +        30 players who had the fastest average response time to a question where they got the question right. + +        The scoreboard view also has a button where the user can see their own rank, points and average speed if they +        didn't make it onto the leaderboard. +        """ +        if self.game is None: +            await ctx.send(embed=Embed( +                title=choice(NEGATIVE_REPLIES), +                description="There is no trivia night running!", +                color=Colours.soft_red +            )) +            return + +        if self.game.current_question is not None: +            error_embed = Embed( +                title=choice(NEGATIVE_REPLIES), +                description="You can't end the event while a question is ongoing!", +                color=Colours.soft_red +            ) +            await ctx.send(embed=error_embed) +            return + +        scoreboard_embed, scoreboard_view = await self.scoreboard.display() +        await ctx.send(embed=scoreboard_embed, view=scoreboard_view) + +    @trivianight.command() +    @commands.has_any_role(*TRIVIA_NIGHT_ROLES) +    async def scoreboard(self, ctx: commands.Context) -> None: +        """ +        Displays the scoreboard. + +        The scoreboard consists of the two scoreboards with the 30 players who got the highest points and the +        30 players who had the fastest average response time to a question where they got the question right. +        """ +        if self.game is None: +            await ctx.send(embed=Embed( +                title=choice(NEGATIVE_REPLIES), +                description="There is no trivia night running!", +                color=Colours.soft_red +            )) +            return + +        if self.game.current_question is not None: +            error_embed = Embed( +                title=choice(NEGATIVE_REPLIES), +                description="You can't end the event while a question is ongoing!", +                color=Colours.soft_red +            ) +            await ctx.send(embed=error_embed) +            return + +        scoreboard_embed, speed_scoreboard = await self.scoreboard.display(speed_leaderboard=True) +        await ctx.send(embeds=(scoreboard_embed, speed_scoreboard)) + +    @trivianight.command() +    @commands.has_any_role(*TRIVIA_NIGHT_ROLES) +    async def end_game(self, ctx: commands.Context) -> None: +        """Ends the ongoing game.""" +        self.game = None + +        await ctx.send(embed=Embed( +            title=choice(POSITIVE_REPLIES), +            description="The game has been stopped.", +            color=Colours.soft_green +        )) + + +def setup(bot: Bot) -> None: +    """Load the TriviaNight cog.""" +    bot.add_cog(TriviaNightCog(bot))  |