aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Bluenix <[email protected]>2021-12-12 22:30:44 +0100
committerGravatar Shom770 <[email protected]>2022-02-09 18:13:37 -0500
commit33269228576ffb7d48187fd9cd0297d8b0b657e3 (patch)
treedda282eb36d4cefa9cc8fa6fb4f2cb786d25d0c5
parentAdd Question representation for trivia night data (diff)
Restructure trivia night game around new Question representation
-rw-r--r--bot/exts/events/trivianight/_game.py60
-rw-r--r--bot/exts/events/trivianight/_questions.py253
-rw-r--r--bot/exts/events/trivianight/trivianight.py161
3 files changed, 179 insertions, 295 deletions
diff --git a/bot/exts/events/trivianight/_game.py b/bot/exts/events/trivianight/_game.py
index 086d0de6..aac745a7 100644
--- a/bot/exts/events/trivianight/_game.py
+++ b/bot/exts/events/trivianight/_game.py
@@ -1,4 +1,5 @@
import time
+from random import randrange
from string import ascii_uppercase
from typing import Iterable, Optional, TypedDict
@@ -25,6 +26,14 @@ UserGuess = tuple[
]
+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 Question:
"""Interface for one question in a trivia night game."""
@@ -76,10 +85,10 @@ class Question:
def _update_guess(self, user: int, answer: str) -> UserGuess:
"""Update an already existing guess."""
if self._started is None:
- raise RuntimeError("Question is not open for answers.")
+ raise QuestionClosed("Question is not open for answers.")
if self._guesses[user][1] is False:
- raise RuntimeError(f"User({user}) has already updated their guess once.")
+ 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]
@@ -90,7 +99,7 @@ class Question:
return self._update_guess(user, answer)
if self._started is None:
- raise RuntimeError("Question is not open for answers.")
+ raise QuestionClosed("Question is not open for answers.")
self._guesses[user] = (answer, True, time.perf_counter() - self._started)
return self._guesses[user]
@@ -103,3 +112,48 @@ class Question:
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]
+ self.current_question: Optional[Question] = None
+
+ 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._questions if q.number == number][0]
+ except IndexError:
+ raise ValueError(f"Question number {number} does not exist.")
+ else:
+ question = self._questions.pop(randrange(len(self._questions)))
+
+ self.current_question = question
+ self.current_question.start()
+ 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
diff --git a/bot/exts/events/trivianight/_questions.py b/bot/exts/events/trivianight/_questions.py
index 0ab657d2..1d7bd4a9 100644
--- a/bot/exts/events/trivianight/_questions.py
+++ b/bot/exts/events/trivianight/_questions.py
@@ -1,4 +1,5 @@
from random import choice, randrange
+from string import ascii_uppercase
from time import perf_counter
from typing import Optional, TypedDict, Union
@@ -9,28 +10,18 @@ from discord.ui import Button, View
from bot.constants import Colours, NEGATIVE_REPLIES
from . import UserScore
+from ._game import Question, AlreadyUpdated, QuestionClosed
from ._scoreboard import Scoreboard
-class CurrentQuestion(TypedDict):
- """Representing the different 'keys' of the question taken from the JSON."""
+class AnswerButton(Button):
+ """Button subclass that's used to guess on a particular answer."""
- number: str
- description: str
- answers: list[str]
- correct: str
- points: Optional[int]
- time: Optional[int]
-
-
-class QuestionButton(Button):
- """Button subclass for the options of the questions."""
-
- def __init__(self, label: str, users_picked: dict, view: View):
- self.users_picked = users_picked
- self._view = view
+ 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.
@@ -39,114 +30,108 @@ class QuestionButton(Button):
- interaction: an instance of discord.Interaction representing the interaction between the user and the
button.
"""
- original_message = interaction.message
- original_embed = original_message.embeds[0]
-
- if interaction.user.id not in self.users_picked.keys():
- people_answered = original_embed.footer.text
- people_answered = f"{int(people_answered[0]) + 1} " \
- f"{'person has' if int(people_answered[0]) + 1 == 1 else 'people have'} answered"
- original_embed.set_footer(text=people_answered)
- await original_message.edit(embed=original_embed, view=self._view)
- self.users_picked[interaction.user.id] = [self.label, True, perf_counter() - self._time]
+ try:
+ guess = self.question.guess(interaction.user.id, self.label)
+ except AlreadyUpdated:
await interaction.response.send_message(
embed=Embed(
- title="Confirming that..",
- description=f"You chose answer {self.label}.",
- color=Colours.soft_green
+ title=choice(NEGATIVE_REPLIES),
+ description="You've already changed your answer more than once!",
+ color=Colours.soft_red
),
ephemeral=True
)
- elif self.users_picked[interaction.user.id][1] is True:
- self.users_picked[interaction.user.id] = [
- self.label, False, perf_counter() - self._time
- ]
+ 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
+ ),
+ )
+ return
+
+ if guess[1]:
await interaction.response.send_message(
embed=Embed(
title="Confirming that..",
- description=f"You changed your answer to answer {self.label}.",
+ 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=choice(NEGATIVE_REPLIES),
- description="You've already changed your answer more than once!",
- color=Colours.soft_red
+ title="Confirming that..",
+ description=f"You changed your answer to answer {self.label}.",
+ color=Colours.soft_green
),
ephemeral=True
)
class QuestionView(View):
- """View for the questions."""
+ """View for one trivia night question."""
- def __init__(self):
+ def __init__(self, question: Question) -> None:
super().__init__()
- self.current_question: CurrentQuestion
- self.users_picked = {}
+ self.question = question
- self.active_question = False
+ for letter, _ in self.question.answers:
+ self.add_item(AnswerButton(letter, self.question))
- def create_current_question(self) -> tuple[Embed, int]:
+ @staticmethod
+ def unicodeify(text: str) -> str:
"""
- Helper function to create the embed for the current question.
+ Takes `text` and adds zero-width spaces to prevent copy and pasting the question.
- Returns an embed containing the question along with each answer choice in the form of a view,
- along with the integer representing the time limit of the current question.
+ Parameters:
+ - text: A string that represents the question description to 'unicodeify'
"""
- self.current_labels = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"[:len(self.current_question["answers"])]
+ 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.current_question['number']}",
- description=self.current_question["obfuscated_description"],
+ title=f"Question {self.question.number}",
+ description=self.unicodeify(self.question.description),
color=Colours.python_yellow
)
- if "_previously_visited" not in self.current_question.keys():
- self.buttons = [QuestionButton(label, self.users_picked, self) for label in self.current_labels]
- for button in self.buttons:
- self.add_item(button)
- for label, answer in zip(self.current_labels, self.current_question["answers"]):
+ for label, answer in self.question.answers:
question_embed.add_field(name=f"Answer {label}", value=answer, inline=False)
- question_embed.set_footer(text="0 people have answered")
- current_time = perf_counter()
- for button in self.buttons:
- button._time = current_time
-
- time_limit = self.current_question.get("time", 10)
-
- self.active_question = True
+ return question_embed
- return question_embed, time_limit
-
- def end_question(self) -> tuple[dict, Embed, int, int]:
+ def end_question(self) -> Embed:
"""
Ends the question and displays the statistics on who got the question correct, awards points, etc.
Returns:
- - a dictionary containing all the people who answered and whether or not they got it correct
- - an embed displaying the correct answers and the % of people that chose each answer.
- - an integer showing the time limit of the current question in seconds
- - an integer showing the amount of points the question will award* to those that got it correct
+ An embed displaying the correct answers and the % of people that chose each answer.
"""
- labels = self.current_labels
- label = labels[self.current_question["answers"].index(self.current_question["correct"])]
- return_dict = {name: (*info, info[0] == label) for name, info in self.users_picked.items()}
- all_players = list(self.users_picked.items())
+ guesses = self.question.stop()
+
+ labels = ascii_uppercase[:len(self.question.answers)]
+ correct = [label for (label, description) in self.question.answers if description == self.question.correct]
answer_embed = Embed(
- title=f"The correct answer for Question {self.current_question['number']} was..",
- description=self.current_question["correct"]
+ title=f"The correct answer for Question {self.question.number} was..",
+ description=self.question.correct
)
- if len(all_players) != 0:
+ if len(guesses) != 0:
answers_chosen = {
answer_choice: len(
- tuple(filter(lambda x: x[0] == answer_choice, self.users_picked.values()))
- ) / len(all_players)
+ tuple(filter(lambda x: x[0] == correct, guesses.values()))
+ ) / len(guesses)
for answer_choice in labels
}
@@ -160,118 +145,8 @@ class QuestionView(View):
# The `ord` function is used here to change the letter to its corresponding position
answer_embed.add_field(
name=f"{percent * 100:.1f}% of players chose",
- value=self.current_question['answers'][ord(answer) - 65],
+ value=self.question.answers[ord(answer) - 65][1],
inline=False
)
- self.users_picked = {}
-
- for button in self.buttons:
- button.users_picked = self.users_picked
-
- time_limit = self.current_question.get("time", 10)
- question_points = self.current_question.get("points", 10)
-
- self.active_question = False
-
- return return_dict, answer_embed, time_limit, question_points
-
-
-class Questions:
- """An interface to use from the TriviaNight cog for questions."""
-
- def __init__(self, scoreboard: Scoreboard):
- self.scoreboard = scoreboard
- self.questions = []
-
- def set_questions(self, questions: list) -> None:
- """
- Setting `self.questions` dynamically via a function to set it.
-
- Parameters:
- - questions: a list representing all the questions, which is essentially the JSON provided
- to load the questions
- """
- self.questions = questions
-
- def next_question(self, number: int = None) -> Union[Embed, None]:
- """
- Chooses a random unvisited question from the question bank.
-
- If the number parameter is specified, it'll head to that specific question.
-
- Parameters:
- - number: An optional integer representing the question number (only used for `.trivianight question` calls)
- """
- if all("visited" in question.keys() for question in self.questions) and number is None:
- return Embed(
- title=choice(NEGATIVE_REPLIES),
- description="All of the questions in the question bank have been used.",
- color=Colours.soft_red
- )
-
- if number is None:
- question_number = randrange(0, len(self.questions))
- while "visited" in self.questions[question_number].keys():
- question_number = randrange(0, len(self.questions))
- else:
- question_number = number - 1
-
- if "visited" in self.questions[question_number].keys():
- self.questions[question_number]["_previously_visited"] = True
- self.questions[question_number]["visited"] = True
-
- # The `self.view` refers to the QuestionView
- self.view.current_question = self.questions[question_number]
-
- def list_questions(self) -> Union[Embed, str]:
- """
- Lists all questions from the question bank.
-
- It will put the following into a message:
- - Question number
- - Question description
- - If the question was already 'visited' (displayed)
- """
- if not self.questions:
- return Embed(
- title=choice(NEGATIVE_REPLIES),
- description="No questions are currently loaded in!",
- color=Colours.soft_red
- )
- spaces = len(
- sorted(
- self.questions, key=lambda question: len(question['description'])
- )[-1]["description"]
- ) + 3
- formatted_string = ""
- for question in self.questions:
- question_description = question["description"]
- formatted_string += f"`Q{question['number']}: {question_description}" \
- f"{' ' * (spaces - len(question_description) + 2)}" \
- f"|` {':x:' if not question.get('visited') else ':white_check_mark:'}\n"
-
- return formatted_string.strip()
-
- def current_question(self) -> tuple[Embed, QuestionView]:
- """Returns an embed entailing the current question as an embed with a view."""
- return self.view.create_current_question(), self.view
-
- def end_question(self) -> Embed:
- """
- Terminates answering of the question and displays the correct answer.
-
- The function returns an embed containing the information about the question such as the following:
- - % of people that chose each option
- - the correct answer
- """
- scores, answer_embed, time_limit, total_points = self.view.end_question()
- for user, score in scores.items():
- time_taken = score[2]
- point_calculation = (1 - (time_taken / time_limit) / 2) * total_points
- if score[-1] is True:
- self.scoreboard[UserScore(user)] = {"points": point_calculation, "speed": time_taken}
- elif score[-1] is False and score[2] <= 2:
- # Get the negative of the point_calculation to deduct it
- self.scoreboard[UserScore(user)] = {"points": -point_calculation}
return answer_embed
diff --git a/bot/exts/events/trivianight/trivianight.py b/bot/exts/events/trivianight/trivianight.py
index 59b809b1..86da0c3a 100644
--- a/bot/exts/events/trivianight/trivianight.py
+++ b/bot/exts/events/trivianight/trivianight.py
@@ -4,11 +4,13 @@ from random import choice
from typing import Optional
from discord import Embed
+from discord.colour import Color
from discord.ext import commands
from bot.bot import Bot
from bot.constants import Colours, NEGATIVE_REPLIES, POSITIVE_REPLIES, Roles
+from ._game import TriviaNightGame
from ._questions import QuestionView, Questions
from ._scoreboard import Scoreboard, ScoreboardView
@@ -16,37 +18,12 @@ from ._scoreboard import Scoreboard, ScoreboardView
TRIVIA_NIGHT_ROLES = (Roles.admin, 78361735739998228)
-class TriviaNight(commands.Cog):
+class TriviaNightCog(commands.Cog):
"""Cog for the Python Trivia Night event."""
def __init__(self, bot: Bot):
self.bot = bot
- self.scoreboard = Scoreboard()
- self.questions = Questions(self.scoreboard)
-
- def setup_views(self, questions: dict) -> None:
- """
- Sets up the views for `self.questions` and `self.scoreboard` respectively.
-
- Parameters:
- - questions: The dictionary to set the questions for self.questions to use.
- """
- self.questions.view = QuestionView()
- self.scoreboard.view = ScoreboardView(self.bot)
- self.questions.set_questions(questions)
-
- @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)
- )
+ self.game: Optional[TriviaNightGame] = None
@commands.group(aliases=["tn"], invoke_without_command=True)
async def trivianight(self, ctx: commands.Context) -> None:
@@ -84,6 +61,14 @@ class TriviaNight(commands.Cog):
- 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:
@@ -105,10 +90,7 @@ class TriviaNight(commands.Cog):
except JSONDecodeError:
raise commands.BadArgument("Invalid JSON")
- for idx, question in enumerate(serialized_json):
- serialized_json[idx]["obfuscated_description"] = self.unicodeify(question["description"])
-
- self.setup_views(serialized_json)
+ self.game = TriviaNightGame(serialized_json)
success_embed = Embed(
title=choice(POSITIVE_REPLIES),
@@ -117,35 +99,24 @@ class TriviaNight(commands.Cog):
)
await ctx.send(embed=success_embed)
- @trivianight.command()
- @commands.has_any_role(*TRIVIA_NIGHT_ROLES)
- async def reset(self, ctx: commands.Context) -> None:
- """Resets previous questions and scoreboards."""
- all_questions = self.questions.questions
-
- for question in all_questions:
- if "visited" in question.keys():
- del question["visited"]
-
- self.setup_views(list(all_questions))
-
- success_embed = Embed(
- title=choice(POSITIVE_REPLIES),
- description="The scoreboards were reset and questions reset!",
- color=Colours.soft_green
- )
- await ctx.send(embed=success_embed)
-
- @trivianight.command()
+ @trivianight.command(aliases=('next',))
@commands.has_any_role(*TRIVIA_NIGHT_ROLES)
- async def next(self, ctx: commands.Context) -> None:
+ 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.questions.view.active_question is True:
+ 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!",
@@ -154,65 +125,40 @@ class TriviaNight(commands.Cog):
await ctx.send(embed=error_embed)
return
- next_question = self.questions.next_question()
- if isinstance(next_question, Embed):
- await ctx.send(embed=next_question)
- return
+ next_question = self.game.next_question(question_number)
- (question_embed, time_limit), question_view = self.questions.current_question()
- message = await ctx.send(embed=question_embed, view=question_view)
+ question_view = QuestionView(next_question)
+ question_embed = question_view.create_embed()
- for time_remaining in range(time_limit, -1, -1):
- if self.questions.view.active_question is False:
- await ctx.send(embed=self.questions.end_question())
- await message.edit(embed=question_embed, view=None)
- return
-
- await asyncio.sleep(1)
- if time_remaining % 5 == 0 and time_remaining not in (time_limit, 0):
- await ctx.send(f"{time_remaining}s remaining")
-
- await ctx.send(embed=self.questions.end_question())
- await message.edit(embed=question_embed, view=None)
-
- @trivianight.command()
- @commands.has_any_role(*TRIVIA_NIGHT_ROLES)
- async def question(self, ctx: commands.Context, question_number: int) -> None:
- """
- Gets a question from the question bank depending on the question number provided.
-
- The logic of this command is similar to `.trivianight next`, with the only difference being that you need to
- specify the question number.
-
- Parameters:
- - question_number: An integer represents the question number to go to (i.e. .trivianight question 5).
- """
- question = self.questions.next_question(question_number)
- if isinstance(question, Embed):
- await ctx.send(embed=question)
- return
-
- (question_embed, time_limit), question_view = self.questions.current_question()
+ next_question.start()
message = await ctx.send(embed=question_embed, view=question_view)
- for time_remaining in range(time_limit, -1, -1):
- if self.questions.view.active_question is False:
- await ctx.send(embed=self.questions.end_question())
- await message.edit(embed=question_embed, view=None)
- return
-
- await asyncio.sleep(1)
- if time_remaining % 5 == 0 and time_remaining not in (time_limit, 0):
- await ctx.send(f"{time_remaining}s remaining")
+ # 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.sleep(duration)
+ 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.sleep(duration)
+ break
- await ctx.send(embed=self.questions.end_question())
+ await ctx.send(embed=question_view.end_question())
await message.edit(embed=question_embed, view=None)
+ question_view.stop()
@trivianight.command()
@commands.has_any_role(*TRIVIA_NIGHT_ROLES)
async def list(self, ctx: commands.Context) -> None:
"""
- Displays all the questions from the question bank.
+ 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:.
@@ -255,7 +201,15 @@ class TriviaNight(commands.Cog):
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.questions.view.active_question is True:
+ 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!",
@@ -266,8 +220,9 @@ class TriviaNight(commands.Cog):
scoreboard_embed, scoreboard_view = await self.scoreboard.display()
await ctx.send(embed=scoreboard_embed, view=scoreboard_view)
+ self.game = None
def setup(bot: Bot) -> None:
"""Load the TriviaNight cog."""
- bot.add_cog(TriviaNight(bot))
+ bot.add_cog(TriviaNightCog(bot))