diff --git a/techsupport_bot/commands/hangman.py b/techsupport_bot/commands/hangman.py index 12300e9c..fc599db2 100644 --- a/techsupport_bot/commands/hangman.py +++ b/techsupport_bot/commands/hangman.py @@ -9,6 +9,7 @@ import discord import ui from core import auxiliary, cogs, extensionconfig +from discord import app_commands from discord.ext import commands if TYPE_CHECKING: @@ -35,19 +36,31 @@ async def setup(bot: bot.TechSupportBot) -> None: class HangmanGame: - """Class for the game hangman. + """ + A class that represents a game of Hangman. + + The game includes the logic for tracking the word to be guessed, the guesses made, + and the state of the hangman figure based on incorrect guesses. It also supports + additional functionality such as adding more guesses and determining whether the game + is finished or failed. Attributes: - HANG_PICS (list[str]): The list of hangman pictures - FINAL_STEP (int): The last step of the hangman game + HANG_PICS (list[str]): The list of hangman pictures. + word (str): The word that players need to guess. + guesses (set): A set of guessed letters. + step (int): The current number of incorrect guesses made. + max_guesses (int): The maximum number of incorrect guesses allowed before the game ends. + started (datetime): The UTC timestamp of when the game was started. + id (UUID): A unique identifier for the game. finished (bool): Determines if the game has been finished or not failed (bool): Determines if the players failed to guess the word Args: - word (str): The word to start the game with + word (str): The word for the game. It must be an alphabetic string without underscores. + max_guesses (int, optional): The maximum number of incorrect guesses allowed. Raises: - ValueError: A valid alphabetic word wasn't provided + ValueError: A valid alphabetic word wasn't provided. """ HANG_PICS: list[str] = [ @@ -108,14 +121,14 @@ class HangmanGame: | =========""", ] - FINAL_STEP: int = len(HANG_PICS) - 1 - def __init__(self: Self, word: str) -> None: + def __init__(self: Self, word: str, max_guesses: int = 6) -> None: if not word or "_" in word or not word.isalpha(): raise ValueError("valid word must be provided") self.word = word self.guesses = set() self.step = 0 + self.max_guesses = max_guesses self.started = datetime.datetime.utcnow() self.id = uuid.uuid4() @@ -139,7 +152,11 @@ def draw_hang_state(self: Self) -> str: Returns: str: The str representation of the correct picture """ - return self.HANG_PICS[self.step] + picture_index = min( + len(self.HANG_PICS) - 1, # Maximum valid index + int(self.step / self.max_guesses * (len(self.HANG_PICS) - 1)), + ) + return self.HANG_PICS[picture_index] def guess(self: Self, letter: str) -> bool: """Registers a guess to the given game @@ -168,8 +185,18 @@ def guess(self: Self, letter: str) -> bool: @property def finished(self: Self) -> bool: - """Method to finish the game of hangman.""" - if self.step < 0 or self.step >= self.FINAL_STEP: + """ + Determines if the game of Hangman is finished. + + The game is considered finished if: + - The number of incorrect guesses (`step`) is greater than or + equal to the maximum allowed (`max_guesses`). + - All letters in the word have been correctly guessed, meaning the game has been won. + + Returns: + bool: True if the game is finished (either won or lost), False otherwise. + """ + if self.step < 0 or self.step >= self.max_guesses: return True if all(letter in self.guesses for letter in self.word): return True @@ -177,13 +204,24 @@ def finished(self: Self) -> bool: @property def failed(self: Self) -> bool: - """Method in case the game wasn't successful.""" - if self.step >= self.FINAL_STEP: + """ + Determines if the game was unsuccessful. + + The game is considered a failure when the number of incorrect guesses (`step`) + equals or exceeds the maximum allowed guesses (`max_guesses`), meaning the players + failed to guess the word within the allowed attempts. + + Returns: + bool: True if the game was unsuccessful (i.e., the number of incorrect guesses + is greater than or equal to the maximum allowed), False otherwise. + """ + if self.step >= self.max_guesses: return True return False def guessed(self: Self, letter: str) -> bool: - """Method to know if a letter has already been guessed + """ + Method to know if a letter has already been guessed Args: letter (str): The letter to check if it has been guessed @@ -201,9 +239,32 @@ def guessed(self: Self, letter: str) -> bool: return True return False + def remaining_guesses(self: Self) -> int: + """ + Calculates the number of guesses remaining in the game. + + The remaining guesses are determined by subtracting the number of incorrect + guesses (`step`) from the maximum allowed guesses (`max_guesses`). + + Returns: + int: The number of guesses the players have left. + """ + return self.max_guesses - self.step + + def add_guesses(self: Self, num_guesses: int) -> None: + """ + Increases the total number of allowed guesses in the game. + + Args: + num_guesses (int): The number of additional guesses to add to the + current maximum allowed guesses. + """ + self.max_guesses += num_guesses + async def can_stop_game(ctx: commands.Context) -> bool: - """Checks if a user has the ability to stop the running game + """ + Checks if a user has the ability to stop the running game Args: ctx (commands.Context): The context in which the stop command was run @@ -221,6 +282,9 @@ async def can_stop_game(ctx: commands.Context) -> bool: raise AttributeError("could not find hangman cog when checking game states") game_data = cog.games.get(ctx.channel.id) + if not game_data: + return True + user = game_data.get("user") if getattr(user, "id", 0) == ctx.author.id: return True @@ -243,10 +307,19 @@ async def can_stop_game(ctx: commands.Context) -> bool: class HangmanCog(cogs.BaseCog): - """Class to define the hangman game.""" + """Class to define the Hangman game. + + Args: + bot (commands.Bot): The bot instance that this cog is a part of. - async def preconfig(self: Self) -> None: - """Method to preconfig the game.""" + Attributes: + games (dict): A dictionary to store ongoing games, where the keys are + player identifiers and the values are the current game state. + hangman_app_group (app_commands.Group): The command group for the Hangman extension. + """ + + def __init__(self: Self, bot: commands.Bot) -> None: + super().__init__(bot) self.games = {} @commands.guild_only() @@ -263,64 +336,85 @@ async def hangman(self: Self, ctx: commands.Context) -> None: # Executed if there are no/invalid args supplied await auxiliary.extension_help(self, ctx, self.__module__[9:]) - @hangman.command( + hangman_app_group: app_commands.Group = app_commands.Group( + name="hangman", description="Command Group for the Hangman Extension" + ) + + @hangman_app_group.command( name="start", - description="Starts a hangman game in the current channel", - usage="[word]", + description="Start a Hangman game in the current channel.", + extras={"module": "hangman"}, ) - async def start_game(self: Self, ctx: commands.Context, word: str) -> None: - """Method to start the hangman game and delete the original message. - This is a command and should be access via discord + async def start_game( + self: Self, interaction: discord.Interaction, word: str + ) -> None: + """Slash command to start a hangman game. Args: - ctx (commands.Context): The context in which the command occured - word (str): The word to state the hangman game with + interaction (discord.Interaction): The interaction object from Discord. + word (str): The word to start the Hangman game with. """ - # delete the message so the word is not seen - await ctx.message.delete() + # Ensure only the command's author can see this interaction + await interaction.response.defer(ephemeral=True) - game_data = self.games.get(ctx.channel.id) + # Check if the provided word is too long + if len(word) >= 85: + await interaction.followup.send( + "The word must be less than 256 characters.", ephemeral=True + ) + return + + # Check if a game is already active in the channel + game_data = self.games.get(interaction.channel_id) if game_data: - # if there is a game currently, - # get user who started it + # Check if the game owner wants to overwrite the current game user = game_data.get("user") - if getattr(user, "id", 0) == ctx.author.id: + if user.id == interaction.user.id: view = ui.Confirm() await view.send( - message=( - "There is a current game in progress. Would you like to end it?" - ), - channel=ctx.channel, - author=ctx.author, + message="There is a current game in progress. Do you want to end it?", + channel=interaction.channel, + author=interaction.user, ) - await view.wait() - if view.value is ui.ConfirmResponse.TIMEOUT: - return - if view.value is ui.ConfirmResponse.DENIED: - await auxiliary.send_deny_embed( - message="The current game was not ended", channel=ctx.channel + if view.value in [ + ui.ConfirmResponse.TIMEOUT, + ui.ConfirmResponse.DENIED, + ]: + await interaction.followup.send( + "The current game was not ended.", ephemeral=True ) return - del self.games[ctx.channel.id] + # Remove the existing game + del self.games[interaction.channel_id] else: - await auxiliary.send_deny_embed( - message="There is a game in progress for this channel", - channel=ctx.channel, + await interaction.followup.send( + "A game is already in progress for this channel.", ephemeral=True ) return - game = HangmanGame(word=word) - embed = await self.generate_game_embed(ctx, game) - message = await ctx.channel.send(embed=embed) - self.games[ctx.channel.id] = { - "user": ctx.author, + # Validate the provided word + try: + game = HangmanGame(word=word.lower()) + except ValueError as e: + await interaction.followup.send(f"Invalid word: {e}", ephemeral=True) + return + + # Create and send the initial game embed + embed = await self.generate_game_embed(interaction, game) + message = await interaction.channel.send(embed=embed) + self.games[interaction.channel_id] = { + "user": interaction.user, "game": game, "message": message, "last_guesser": None, } + await interaction.followup.send( + "The Hangman game has started with a hidden word!", ephemeral=True + ) + @hangman.command( name="guess", description="Guesses a letter for the current hangman game", @@ -333,22 +427,28 @@ async def guess(self: Self, ctx: commands.Context, letter: str) -> None: ctx (commands.Context): The context in which the command was run in letter (str): The letter the user is trying to guess """ - if len(letter) > 1 or not letter.isalpha(): + game_data = self.games.get(ctx.channel.id) + if not game_data: await auxiliary.send_deny_embed( - message="You can only guess a letter", channel=ctx.channel + message="There is no game in progress for this channel", + channel=ctx.channel, ) return - game_data = self.games.get(ctx.channel.id) - if not game_data: + if ctx.author == game_data.get("user"): await auxiliary.send_deny_embed( - message="There is no game in progress for this channel", + message="You cannot guess letters because you started this game!", channel=ctx.channel, ) return - game = game_data.get("game") + if len(letter) > 1 or not letter.isalpha(): + await auxiliary.send_deny_embed( + message="You can only guess a letter", channel=ctx.channel + ) + return + game = game_data.get("game") if game.guessed(letter): await auxiliary.send_deny_embed( message="That letter has already been guessed", channel=ctx.channel @@ -367,28 +467,46 @@ async def guess(self: Self, ctx: commands.Context, letter: str) -> None: await ctx.send(content=content) async def generate_game_embed( - self: Self, ctx: commands.Context, game: HangmanGame + self: Self, + ctx_or_interaction: discord.Interaction | commands.Context, + game: HangmanGame, ) -> discord.Embed: - """Takes a game state and makes it into a pretty embed - Does not send the embed + """ + Generates an embed representing the current state of the Hangman game. Args: - ctx (commands.Context): The context in which the game command needing - a drawing was called in - game (HangmanGame): The hangman game to draw into an embed + ctx_or_interaction (discord.Interaction | commands.Context): + The context or interaction used to generate the embed, which provides + information about the user and the message. + game (HangmanGame): The current instance of the Hangman game, used to + retrieve game state, including word state, remaining guesses, and the + hangman drawing. Returns: - discord.Embed: The ready and styled embed containing the current state of the game + discord.Embed: An embed displaying the current game state, including + the hangman drawing, word state, remaining guesses, guessed letters, + and the footer indicating the game status and creator. """ hangman_drawing = game.draw_hang_state() hangman_word = game.draw_word_state() + # Determine the guild ID + guild_id = None + if isinstance(ctx_or_interaction, commands.Context): + guild_id = ctx_or_interaction.guild.id if ctx_or_interaction.guild else None + elif isinstance(ctx_or_interaction, discord.Interaction): + guild_id = ctx_or_interaction.guild_id + + # Fetch the prefix manually since get_prefix expects a Message + if guild_id and str(guild_id) in self.bot.guild_configs: + prefix = self.bot.guild_configs[str(guild_id)].command_prefix + else: + prefix = self.file_config.bot_config.default_prefix - prefix = await self.bot.get_prefix(ctx.message) embed = discord.Embed( title=f"`{hangman_word}`", description=( - f"Type `{prefix}help extension hangman` for more info\n\n" - f" ```{hangman_drawing}```" + f"Type `{prefix}help hangman` for more info\n\n" + f"```{hangman_drawing}```" ), ) @@ -400,10 +518,26 @@ async def generate_game_embed( footer_text = "Word guessed! Nice job!" else: embed.color = discord.Color.gold() - footer_text = f"{game.FINAL_STEP - game.step} wrong guesses left!" + embed.add_field( + name=f"Remaining Guesses {str(game.remaining_guesses())}", + value="\u200b", + inline=False, + ) + embed.add_field( + name="Guessed Letters", + value=", ".join(game.guesses) or "None", + inline=False, + ) - embed.set_footer(text=footer_text) + # Determine the game creator based on interaction type + if isinstance(ctx_or_interaction, discord.Interaction): + footer_text = f"Game started by {ctx_or_interaction.user}" + elif isinstance(ctx_or_interaction, commands.Context): + footer_text = f"Game started by {ctx_or_interaction.author}" + else: + footer_text = " " + embed.set_footer(text=footer_text) return embed @hangman.command(name="redraw", description="Redraws the current hangman game") @@ -444,7 +578,8 @@ async def stop(self: Self, ctx: commands.Context) -> None: game_data = self.games.get(ctx.channel.id) if not game_data: await auxiliary.send_deny_embed( - "There is no game in progress for this channel", channel=ctx.channel + message="There is no game in progress for this channel", + channel=ctx.channel, ) return @@ -471,3 +606,59 @@ async def stop(self: Self, ctx: commands.Context) -> None: message=f"That game is now finished. The word was: `{word}`", channel=ctx.channel, ) + + @hangman.command( + name="add_guesses", + description="Allows the creator of the game to give more guesses", + usage="[number_of_guesses]", + ) + async def add_guesses( + self: Self, ctx: commands.Context, number_of_guesses: int + ) -> None: + """Discord command to allow the game creator to add more guesses. + + Args: + ctx (commands.Context): The context in which the command was run. + number_of_guesses (int): The number of guesses to add. + """ + if number_of_guesses <= 0: + await auxiliary.send_deny_embed( + message="The number of guesses must be a positive integer.", + channel=ctx.channel, + ) + return + + game_data = self.games.get(ctx.channel.id) + if not game_data: + await auxiliary.send_deny_embed( + message="There is no game in progress for this channel", + channel=ctx.channel, + ) + return + + # Ensure only the creator of the game can add guesses + game_author = game_data.get("user") + if ctx.author.id != game_author.id: + await auxiliary.send_deny_embed( + message="Only the creator of the game can add more guesses.", + channel=ctx.channel, + ) + return + + game = game_data.get("game") + + # Add the new guesses + game.add_guesses(number_of_guesses) + + # Notify the channel + await ctx.send( + content=( + f"{number_of_guesses} guesses have been added! " + f"Total guesses remaining: {game.remaining_guesses()}" + ) + ) + + # Update the game embed + embed = await self.generate_game_embed(ctx, game) + message = game_data.get("message") + await message.edit(embed=embed)