From d575cf8f54808daa8ec51d69b4fe00b1b3b11652 Mon Sep 17 00:00:00 2001 From: DMFriends Date: Sun, 3 Aug 2025 17:58:44 -0400 Subject: [PATCH 01/13] Initial Sudoku commit --- bot/exts/fun/sudoku/__init__.py | 11 + bot/exts/fun/sudoku/_board_generation.py | 119 ++++++++++ bot/exts/fun/sudoku/_sudoku.py | 266 +++++++++++++++++++++++ bot/resources/fun/Roboto-Medium.ttf | Bin 0 -> 168644 bytes bot/resources/fun/sudoku_template.png | Bin 0 -> 11041 bytes 5 files changed, 396 insertions(+) create mode 100644 bot/exts/fun/sudoku/__init__.py create mode 100644 bot/exts/fun/sudoku/_board_generation.py create mode 100644 bot/exts/fun/sudoku/_sudoku.py create mode 100644 bot/resources/fun/Roboto-Medium.ttf create mode 100644 bot/resources/fun/sudoku_template.png diff --git a/bot/exts/fun/sudoku/__init__.py b/bot/exts/fun/sudoku/__init__.py new file mode 100644 index 0000000000..086315c27e --- /dev/null +++ b/bot/exts/fun/sudoku/__init__.py @@ -0,0 +1,11 @@ +import logging + +from bot.bot import Bot +from bot.exts.fun.sudoku._sudoku import Sudoku + +log = logging.getLogger(__name__) + + +def setup(bot: Bot) -> None: + """Load the Sudoku Cog.""" + bot.add_cog(Sudoku(bot)) diff --git a/bot/exts/fun/sudoku/_board_generation.py b/bot/exts/fun/sudoku/_board_generation.py new file mode 100644 index 0000000000..29e865a7b3 --- /dev/null +++ b/bot/exts/fun/sudoku/_board_generation.py @@ -0,0 +1,119 @@ +import random +import copy + + +class GenerateSudokuPuzzle: + """Generates and solves Sudoku puzzles using a backtracking algorithm.""" + def __init__(self): + self.counter = 0 + # Path is for the matplotlib animation + self.path = [] + # Generate the puzzle + self.grid = [[0 for _ in range(6)] for _ in range(6)] + self.generate_puzzle() + + def generate_puzzle(self): + """Generates a new puzzle and solves it.""" + self.generate_solution(self.grid) + # self.print_grid() + self.remove_numbers_from_grid() + self.print_grid() + return + + def print_grid(self): + for row in self.grid: + print(row) + return + + def valid_location(self, grid, row, col, number): + """Returns a bool which determines whether the + number can be placed in the square given by the player.""" + # Checks the row + if number in grid[row]: + return False + + # Checks the column + for i in range(6): + if grid[i][col] == number: + return False + + # Checks the subgrid + for i in range(row, (row + 2)): + for j in range(col, (col + 2)): + if grid[i][j] == number: + return False + + else: + return True + + def find_empty_square(self, grid): + """Return the next empty square coordinates in the grid.""" + for i in range(6): + for j in range(6): + if grid[i][j] == 0: + return [i, j] + return + + def yield_coords(self): + for i in range(0, 36): + yield i // 6, i % 6 + + def generate_solution(self, grid): + """Generates a full solution with backtracking.""" + number_list = [1, 2, 3, 4, 5, 6] + for row, col in self.yield_coords(): + # Find next empty cell + if not grid[row][col] == 0: + continue + + random.shuffle(number_list) + for number in number_list: + if not self.valid_location(grid, row, col, number): + continue + + self.path.append((number, row, col)) + grid[row][col] = number + if not self.find_empty_square(grid): + return True + else: + continue + + # If the grid is full + if self.generate_solution(grid): + return True + break + + return False + + def get_non_empty_squares(self, grid): + """Returns a shuffled list of non-empty squares in the puzzle.""" + non_empty_squares = [] + for i in range(len(grid)): + for j in range(len(grid)): + if grid[i][j] != 0: + non_empty_squares.append((i, j)) + random.shuffle(non_empty_squares) + return non_empty_squares + + def remove_numbers_from_grid(self): + """Remove numbers from the grid to create the puzzle.""" + # Get all non-empty squares from the grid + # non_empty_squares = self.get_non_empty_squares(self.grid) + # non_empty_squares_count = len(non_empty_squares) + rounds = 3 + while rounds > 0 and len(self.get_non_empty_squares(self.grid)) >= 11: + # There should be at least 11 clues for easy puzzles, + # 10 clues for medium puzzles, and 9 clues for hard puzzles. + row, col = non_empty_squares.pop() + non_empty_squares_count -= 1 + # Might need to put the square value back if there is more than one solution + removed_square = self.grid[row][col] + self.grid[row][col] = 0 + # Make a copy of the grid to solve + grid_copy = copy.deepcopy(self.grid) + # Initialize solutions counter to zero + self.counter = 0 + # self.solve_puzzle(grid_copy) + return + +GenerateSudokuPuzzle() \ No newline at end of file diff --git a/bot/exts/fun/sudoku/_sudoku.py b/bot/exts/fun/sudoku/_sudoku.py new file mode 100644 index 0000000000..bc6fd4c52f --- /dev/null +++ b/bot/exts/fun/sudoku/_sudoku.py @@ -0,0 +1,266 @@ +import asyncio +import os +# from asyncio import TimeoutError +from typing import Optional +import random +import time +import io +import enum +from ._board_generation import GenerateSudokuPuzzle + +import discord +from PIL import Image, ImageDraw, ImageFont +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours + +BACKGROUND = (242, 243, 244) +BLACK = 0 +SUDOKU_TEMPLATE_PATH = "bot/resources/fun/sudoku_template.png" +NUM_FONT = ImageFont.truetype("bot/resources/fun/Roboto-Medium.ttf", 80) + + +class CoordinateConverter(commands.Converter): + """Converter used in Sudoku game.""" + async def convert(self, argument: str) -> tuple[int, int]: + """Convert alphanumeric grid coordinates to 2d list index. Eg 'C1'-> (2, 0).""" + argument = sorted(argument.lower()) + if len(argument) != 2: + raise commands.BadArgument("The coordinate must be two characters long.") + if argument[0].isnumeric() and not argument[1].isnumeric(): + number, letter = argument[0], argument[1] + else: + raise commands.BadArgument("The coordinate must comprise of" + "1 letter from A to F, and 1 number from 1 to 6.") + if 0 > int(number) > 10 or letter not in "abcdef": + raise commands.BadArgument("The coordinate must comprise of" + "1 letter from A to F, and 1 number from 1 to 6.") + return ord(letter)-97, int(number)-1 + + +# class Difficulty(enum.Enum): +# """Class for enumerating the difficulty of the Sudoku game.""" +# +# difficulties = {"easy": 1, "medium": 2, "hard": 3} +# difficulty = random.choice(list(difficulties.values())) + + +class SudokuGame: + """Class that contains information regarding the currently running Sudoku game.""" + + def __init__(self, ctx: commands.Context): + self.image = Image.open(SUDOKU_TEMPLATE_PATH) + self.generate_puzzle() + self.running: bool = True + self.invoker: discord.Member = ctx.author + self.started_at = time.time() + + def draw_num(self, digit: int, position: tuple[int, int]) -> Image: + """Draw a number on the Sudoku board.""" + digit = str(digit) + if digit in "123456" and len(digit) == 1: + draw = ImageDraw.Draw(self.image) + draw.text(self.index_to_coord(position), str(digit), fill=BLACK, font=NUM_FONT) + return self.image + + @staticmethod + def index_to_coord(position: tuple[int, int]) -> tuple[int, int]: + """Convert a 2D list index to an x,y coordinate on the Sudoku image.""" + return position[0] * 83 + 100, (position[1]) * 83 + 11 + + @classmethod + def generate_puzzle(cls): + """Generate a valid Sudoku board.""" + generate_puzzle = GenerateSudokuPuzzle() + generate_puzzle.generate_solution() + generate_puzzle.remove_numbers_from_grid() + + @property + def solved(self) -> bool: + """Check if the puzzle has been solved.""" + return self.solution == self.puzzle + + def num_concat(self, num1, num2): + num1 = str(num1) + num2 = str(num2) + num1 += num2 + return num1 + + def time_convert(self, secs): + mins = secs // 60 + secs = secs % 60 + mins = mins % 60 + if secs < 10: + secs_2 = self.num_concat(0, int(float(secs))) + formatted_time = "{0}:{1}".format(int(mins), secs_2) + else: + formatted_time = "{0}:{1}".format(int(mins), int(secs)) + + return formatted_time + + +class Sudoku(commands.Cog): + """Cog for the Sudoku game.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.games: dict[int, SudokuGame] + self.started_at = time.time() + self.ctx = ctx + self.hints: list[time.time] = [] + self.message = None + + async def timer_embed(self, ctx: commands.Context): + current_time = time.time() + time_elapsed = current_time - self.started_at + formatted_time = self.time_convert(time_elapsed) + timer_message = discord.Embed(title="Time Elapsed:", description=formatted_time, color=Colours.blue) + send_timer = await ctx.send(embed=timer_message) + + game = self.games.get(ctx.author.id) + while game: + await asyncio.sleep(1) + current_time = time.time() + time_elapsed = current_time - self.started_at + formatted_time = self.time_convert(time_elapsed) + timer_message.description = formatted_time + await send_timer.edit(embed=timer_message) + + # if coord and digit.isnumeric() and -1 < int(digit) < 10 or digit in "xX": + # # print(f"{coord=}, {digit=}") + # await game.update_board(digit, coord) + # else: + # raise commands.BadArgument + + # while game is in progress: + # print(timer_message) + + def info_embed(self) -> discord.Embed: + """Create an embed that displays game information.""" + # current_time = time.time() + # time_elapsed = current_time - self.started_at + # formatted_time = self.time_convert(time_elapsed) + info_embed = discord.Embed(title="Sudoku Game Information", color=Colours.grass_green) + info_embed.set_author(name=self.invoker.name, icon_url=self.invoker.display_avatar.url) + # info_embed.add_field(name="Current Time (mins:secs)", value=formatted_time) + info_embed.add_field(name="Difficulty", value=self.difficulty, inline=False) + info_embed.add_field(name="Hints Used", value=len(self.hints), inline=False) + info_embed.add_field(name="Progress", value="N/A", inline=False) # add in this variable + return info_embed + + async def update_board(self, digit=None, coord=None): + sudoku_embed = discord.Embed(title="Sudoku", color=Colours.soft_orange) + if digit and coord: + self.draw_num(digit, coord) + board_image = io.BytesIO(b"sudoku.png: \x00\x01") + sudoku_embed.set_image(board_image) + if self.message: + await self.message.delete() + self.message = await self.ctx.send(file=board_image, embed=sudoku_embed, view=SudokuView(self.ctx)) + + def find_empty_square(self, grid): + """Return the next empty square coordinates in the grid.""" + for i in range(6): + for j in range(6): + if grid[i][j] == 0: + return i, j + return + + @commands.group(aliases=["s"], invoke_without_command=True) + async def sudoku(self, ctx: commands.Context) -> None: + """ + Play Sudoku with the bot! + + Sudoku is a grid game where you start with a 9x9 grid, and you are given certain numbers on the + grid. In this version of the game, however, the grid will be a 6x6 one instead of the traditional + 9x9. In the original game, all numbers on the grid are 1-9, and no number can repeat itself in any row, + column, or any of the smaller 3x3 grids. In this version of the game, there are 2x3 smaller grids + instead of 3x3 and numbers 1-6 will be used on the grid. + """ + game = self.games.get(ctx.author.id) + if not game: + await ctx.send("Welcome to Sudoku! Type your guesses like this: `A1 1`") + timer_embed() + + await self.timer_embed(ctx) + await self.start(ctx) + await self.bot.wait_for(event="message") + + @sudoku.command() + async def start(self, ctx: commands.Context, difficulty: str = "Normal") -> None: + """Start a Sudoku game.""" + if self.games.get(ctx.author.id): + await ctx.send("You are already playing a game!") + return + game = self.games[ctx.author.id] = SudokuGame(ctx, difficulty) + await game.update_board() + + @sudoku.command(aliases=["end", "stop"]) + async def finish(self, ctx: commands.Context) -> None: + """End a Sudoku game.""" + game = self.games.get(ctx.author.id) + if game: + if ctx.author == game.invoker: + del self.games[ctx.author.id] + await ctx.send("Ended the current game.") + else: + await ctx.send("Only the owner of the game can end it!") + else: + await ctx.send("You are not playing a game! Type `.s` to begin.") + + @sudoku.command() + async def info(self, ctx: commands.Context) -> None: + """Send info about a currently running Sudoku game.""" + game = self.games.get(ctx.author.id) + if game: + await ctx.send(embed=game.info_embed()) + else: + await ctx.send("This game has ended! Type `.s` to start a new game.") + + @sudoku.command() + async def hint(self, ctx: commands.Context) -> None: + """Fill in one empty square on the Sudoku board.""" + game = self.games.get(ctx.author.id) + if game: + game.hints.append(time.time()) + while True: + empty_coords = self.find_empty_square(game.puzzle) + empty_coords_list = [empty_coords] + + await game.update_board(digit=random.randint(0, 5), coord=random.choice(empty_coords_list)) + break + + +class SudokuView(discord.ui.View): + """A set of buttons to control a Sudoku game.""" + + @discord.ui.button(style=discord.ButtonStyle.green, label="Hint") + async def hint_button(self, *_) -> None: + """Button that fills in one empty square on the Sudoku board.""" + await self.ctx.invoke(self.ctx.bot.get_command("sudoku hint")) + + @discord.ui.button(style=discord.ButtonStyle.primary, label="Game Info") + async def info_button(self, *_) -> None: + """Button that displays information about the current game.""" + await self.ctx.invoke(self.ctx.bot.get_command("sudoku info")) + + @discord.ui.button(style=discord.ButtonStyle.red, label="End Game") + async def end_button(self, *_) -> None: + """Button that ends the current game.""" + await self.ctx.invoke(self.ctx.bot.get_command("sudoku finish")) + self.stop() + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + """Check to ensure that the interacting user is the user who invoked the command.""" + if interaction.user != self.ctx.author: + error_embed = discord.Embed( + description="Sorry, but this button can only be used by the original author.") + await interaction.response.send_message(embed=error_embed, ephemeral=True) + return False + return True + + +def setup(bot: Bot) -> None: + """Load the Sudoku cog.""" + bot.add_cog(Sudoku(bot)) diff --git a/bot/resources/fun/Roboto-Medium.ttf b/bot/resources/fun/Roboto-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e89b0b79a2910f0ac309a1845a9f733bcb568792 GIT binary patch literal 168644 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 yL< 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�fHl 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 R=*=O&{i-6DEqnjT{{kA5Pc{Gm literal 0 HcmV?d00001 From a35e95a8c9145cda887483810b7257ffc3078047 Mon Sep 17 00:00:00 2001 From: DMFriends Date: Sun, 3 Aug 2025 22:16:56 -0400 Subject: [PATCH 02/13] Fixed a few bugs that were causing errors in loading the Sudoku cog --- bot/exts/fun/sudoku/__init__.py | 6 +- ...oard_generation.py => board_generation.py} | 23 ++- bot/exts/fun/sudoku/{_sudoku.py => sudoku.py} | 178 +++++++----------- 3 files changed, 81 insertions(+), 126 deletions(-) rename bot/exts/fun/sudoku/{_board_generation.py => board_generation.py} (88%) rename bot/exts/fun/sudoku/{_sudoku.py => sudoku.py} (62%) diff --git a/bot/exts/fun/sudoku/__init__.py b/bot/exts/fun/sudoku/__init__.py index 086315c27e..f764d491c6 100644 --- a/bot/exts/fun/sudoku/__init__.py +++ b/bot/exts/fun/sudoku/__init__.py @@ -1,11 +1,11 @@ import logging from bot.bot import Bot -from bot.exts.fun.sudoku._sudoku import Sudoku +from bot.exts.fun.sudoku.sudoku import Sudoku log = logging.getLogger(__name__) -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Sudoku Cog.""" - bot.add_cog(Sudoku(bot)) + await bot.add_cog(Sudoku(bot)) diff --git a/bot/exts/fun/sudoku/_board_generation.py b/bot/exts/fun/sudoku/board_generation.py similarity index 88% rename from bot/exts/fun/sudoku/_board_generation.py rename to bot/exts/fun/sudoku/board_generation.py index 29e865a7b3..aaf933a310 100644 --- a/bot/exts/fun/sudoku/_board_generation.py +++ b/bot/exts/fun/sudoku/board_generation.py @@ -25,7 +25,8 @@ def print_grid(self): print(row) return - def valid_location(self, grid, row, col, number): + @staticmethod + def valid_location(grid, row, col, number): """Returns a bool which determines whether the number can be placed in the square given by the player.""" # Checks the row @@ -46,7 +47,8 @@ def valid_location(self, grid, row, col, number): else: return True - def find_empty_square(self, grid): + @staticmethod + def find_empty_square(grid): """Return the next empty square coordinates in the grid.""" for i in range(6): for j in range(6): @@ -54,7 +56,8 @@ def find_empty_square(self, grid): return [i, j] return - def yield_coords(self): + @staticmethod + def yield_coords(): for i in range(0, 36): yield i // 6, i % 6 @@ -85,7 +88,8 @@ def generate_solution(self, grid): return False - def get_non_empty_squares(self, grid): + @staticmethod + def get_non_empty_squares(grid): """Returns a shuffled list of non-empty squares in the puzzle.""" non_empty_squares = [] for i in range(len(grid)): @@ -98,8 +102,8 @@ def get_non_empty_squares(self, grid): def remove_numbers_from_grid(self): """Remove numbers from the grid to create the puzzle.""" # Get all non-empty squares from the grid - # non_empty_squares = self.get_non_empty_squares(self.grid) - # non_empty_squares_count = len(non_empty_squares) + non_empty_squares = self.get_non_empty_squares(self.grid) + non_empty_squares_count = len(non_empty_squares) rounds = 3 while rounds > 0 and len(self.get_non_empty_squares(self.grid)) >= 11: # There should be at least 11 clues for easy puzzles, @@ -107,13 +111,14 @@ def remove_numbers_from_grid(self): row, col = non_empty_squares.pop() non_empty_squares_count -= 1 # Might need to put the square value back if there is more than one solution - removed_square = self.grid[row][col] + # removed_square = self.grid[row][col] self.grid[row][col] = 0 # Make a copy of the grid to solve - grid_copy = copy.deepcopy(self.grid) + # grid_copy = copy.deepcopy(self.grid) # Initialize solutions counter to zero self.counter = 0 # self.solve_puzzle(grid_copy) return -GenerateSudokuPuzzle() \ No newline at end of file + +GenerateSudokuPuzzle() diff --git a/bot/exts/fun/sudoku/_sudoku.py b/bot/exts/fun/sudoku/sudoku.py similarity index 62% rename from bot/exts/fun/sudoku/_sudoku.py rename to bot/exts/fun/sudoku/sudoku.py index bc6fd4c52f..fc3ce7db04 100644 --- a/bot/exts/fun/sudoku/_sudoku.py +++ b/bot/exts/fun/sudoku/sudoku.py @@ -1,12 +1,7 @@ -import asyncio import os -# from asyncio import TimeoutError from typing import Optional import random import time -import io -import enum -from ._board_generation import GenerateSudokuPuzzle import discord from PIL import Image, ImageDraw, ImageFont @@ -18,12 +13,13 @@ BACKGROUND = (242, 243, 244) BLACK = 0 SUDOKU_TEMPLATE_PATH = "bot/resources/fun/sudoku_template.png" -NUM_FONT = ImageFont.truetype("bot/resources/fun/Roboto-Medium.ttf", 80) +NUM_FONT = ImageFont.truetype("bot/resources/fun/Roboto-Medium.ttf", 99) class CoordinateConverter(commands.Converter): """Converter used in Sudoku game.""" - async def convert(self, argument: str) -> tuple[int, int]: + + async def convert(self, ctx: commands.Context, argument: str) -> tuple[int, int]: """Convert alphanumeric grid coordinates to 2d list index. Eg 'C1'-> (2, 0).""" argument = sorted(argument.lower()) if len(argument) != 2: @@ -39,22 +35,20 @@ async def convert(self, argument: str) -> tuple[int, int]: return ord(letter)-97, int(number)-1 -# class Difficulty(enum.Enum): -# """Class for enumerating the difficulty of the Sudoku game.""" -# -# difficulties = {"easy": 1, "medium": 2, "hard": 3} -# difficulty = random.choice(list(difficulties.values())) - - class SudokuGame: - """Class that contains information regarding the currently running Sudoku game.""" + """Class that contains information and regarding Sudoku game.""" - def __init__(self, ctx: commands.Context): + def __init__(self, ctx: commands.Context, difficulty: str): + self.ctx = ctx self.image = Image.open(SUDOKU_TEMPLATE_PATH) - self.generate_puzzle() + self.solution = self.generate_board() + self.puzzle = self.generate_puzzle() self.running: bool = True self.invoker: discord.Member = ctx.author self.started_at = time.time() + self.difficulty: str = difficulty # enum class? + self.hints: list[time.time] = [] + self.message = None def draw_num(self, digit: int, position: tuple[int, int]) -> Image: """Draw a number on the Sudoku board.""" @@ -69,123 +63,75 @@ def index_to_coord(position: tuple[int, int]) -> tuple[int, int]: """Convert a 2D list index to an x,y coordinate on the Sudoku image.""" return position[0] * 83 + 100, (position[1]) * 83 + 11 - @classmethod - def generate_puzzle(cls): - """Generate a valid Sudoku board.""" - generate_puzzle = GenerateSudokuPuzzle() - generate_puzzle.generate_solution() - generate_puzzle.remove_numbers_from_grid() + @staticmethod + def generate_board() -> list[list[int]]: + """Generate a valid Sudoku solution board.""" + pass + + def generate_puzzle(self) -> list[list[int]]: + """Remove numbers from a valid Sudoku solution based on the difficulty. Returns a Sudoku puzzle.""" + self.puzzle = [([0]*6)]*6 + return self.puzzle @property def solved(self) -> bool: """Check if the puzzle has been solved.""" return self.solution == self.puzzle - def num_concat(self, num1, num2): - num1 = str(num1) - num2 = str(num2) - num1 += num2 - return num1 - - def time_convert(self, secs): - mins = secs // 60 - secs = secs % 60 - mins = mins % 60 - if secs < 10: - secs_2 = self.num_concat(0, int(float(secs))) - formatted_time = "{0}:{1}".format(int(mins), secs_2) - else: - formatted_time = "{0}:{1}".format(int(mins), int(secs)) - - return formatted_time - - -class Sudoku(commands.Cog): - """Cog for the Sudoku game.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.games: dict[int, SudokuGame] - self.started_at = time.time() - self.ctx = ctx - self.hints: list[time.time] = [] - self.message = None - - async def timer_embed(self, ctx: commands.Context): - current_time = time.time() - time_elapsed = current_time - self.started_at - formatted_time = self.time_convert(time_elapsed) - timer_message = discord.Embed(title="Time Elapsed:", description=formatted_time, color=Colours.blue) - send_timer = await ctx.send(embed=timer_message) - - game = self.games.get(ctx.author.id) - while game: - await asyncio.sleep(1) - current_time = time.time() - time_elapsed = current_time - self.started_at - formatted_time = self.time_convert(time_elapsed) - timer_message.description = formatted_time - await send_timer.edit(embed=timer_message) - - # if coord and digit.isnumeric() and -1 < int(digit) < 10 or digit in "xX": - # # print(f"{coord=}, {digit=}") - # await game.update_board(digit, coord) - # else: - # raise commands.BadArgument - - # while game is in progress: - # print(timer_message) - def info_embed(self) -> discord.Embed: """Create an embed that displays game information.""" - # current_time = time.time() - # time_elapsed = current_time - self.started_at - # formatted_time = self.time_convert(time_elapsed) + current_time = time.time() info_embed = discord.Embed(title="Sudoku Game Information", color=Colours.grass_green) + info_embed.add_field(name="Player", value=self.invoker.name) + info_embed.add_field(name="Current Time", value=(current_time - self.started_at)) + info_embed.add_field(name="Progress", value="N/A") # add in this variable + info_embed.add_field(name="Difficulty", value=self.difficulty) info_embed.set_author(name=self.invoker.name, icon_url=self.invoker.display_avatar.url) - # info_embed.add_field(name="Current Time (mins:secs)", value=formatted_time) - info_embed.add_field(name="Difficulty", value=self.difficulty, inline=False) - info_embed.add_field(name="Hints Used", value=len(self.hints), inline=False) - info_embed.add_field(name="Progress", value="N/A", inline=False) # add in this variable + info_embed.add_field(name="Hints Used", value=len(self.hints)) return info_embed - async def update_board(self, digit=None, coord=None): + async def sudoku_embed(self, digit=None, coord=None): sudoku_embed = discord.Embed(title="Sudoku", color=Colours.soft_orange) if digit and coord: self.draw_num(digit, coord) - board_image = io.BytesIO(b"sudoku.png: \x00\x01") - sudoku_embed.set_image(board_image) + self.image.save("sudoku.png") + board_image = discord.File("sudoku.png") + sudoku_embed.set_image(url="attachment://sudoku.png") if self.message: await self.message.delete() self.message = await self.ctx.send(file=board_image, embed=sudoku_embed, view=SudokuView(self.ctx)) + os.remove("sudoku.png") + + +class Sudoku(commands.Cog): + """Cog for the Sudoku game.""" - def find_empty_square(self, grid): - """Return the next empty square coordinates in the grid.""" - for i in range(6): - for j in range(6): - if grid[i][j] == 0: - return i, j - return + def __init__(self, bot: Bot): + self.bot = bot + self.games: dict[int:SudokuGame] = {} @commands.group(aliases=["s"], invoke_without_command=True) - async def sudoku(self, ctx: commands.Context) -> None: + async def sudoku(self, ctx: commands.Context, coord: Optional[CoordinateConverter] = None, + digit: Optional[str] = None) -> None: """ Play Sudoku with the bot! Sudoku is a grid game where you start with a 9x9 grid, and you are given certain numbers on the grid. In this version of the game, however, the grid will be a 6x6 one instead of the traditional - 9x9. In the original game, all numbers on the grid are 1-9, and no number can repeat itself in any row, - column, or any of the smaller 3x3 grids. In this version of the game, there are 2x3 smaller grids + 9x9. All numbers on the grid, traditionally, are 1-9, and no number can repeat itself in any row, + column, or any of the smaller 3x3 grids. In this version of the game, it would be 2x3 smaller grids instead of 3x3 and numbers 1-6 will be used on the grid. """ game = self.games.get(ctx.author.id) if not game: - await ctx.send("Welcome to Sudoku! Type your guesses like this: `A1 1`") - timer_embed() - - await self.timer_embed(ctx) + await ctx.send("Welcome to Sudoku! Type your guesses like so: `A1 1`") await self.start(ctx) await self.bot.wait_for(event="message") + if coord and digit.isnumeric() and -1 < int(digit) < 10 or digit in "xX": + # print(f"{coord=}, {digit=}") + await game.sudoku_embed(digit, coord) + else: + raise commands.BadArgument @sudoku.command() async def start(self, ctx: commands.Context, difficulty: str = "Normal") -> None: @@ -194,7 +140,7 @@ async def start(self, ctx: commands.Context, difficulty: str = "Normal") -> None await ctx.send("You are already playing a game!") return game = self.games[ctx.author.id] = SudokuGame(ctx, difficulty) - await game.update_board() + await game.sudoku_embed() @sudoku.command(aliases=["end", "stop"]) async def finish(self, ctx: commands.Context) -> None: @@ -207,16 +153,16 @@ async def finish(self, ctx: commands.Context) -> None: else: await ctx.send("Only the owner of the game can end it!") else: - await ctx.send("You are not playing a game! Type `.s` to begin.") + await ctx.send("You are not playing a Sudoku game! Type `.sudoku start` to begin.") - @sudoku.command() + @sudoku.command(aliases=["who", "information", "score"]) async def info(self, ctx: commands.Context) -> None: """Send info about a currently running Sudoku game.""" game = self.games.get(ctx.author.id) if game: await ctx.send(embed=game.info_embed()) else: - await ctx.send("This game has ended! Type `.s` to start a new game.") + await ctx.send("You are not playing a game!") @sudoku.command() async def hint(self, ctx: commands.Context) -> None: @@ -225,16 +171,21 @@ async def hint(self, ctx: commands.Context) -> None: if game: game.hints.append(time.time()) while True: - empty_coords = self.find_empty_square(game.puzzle) - empty_coords_list = [empty_coords] - - await game.update_board(digit=random.randint(0, 5), coord=random.choice(empty_coords_list)) - break + x, y = random.randint(0, 5), random.randint(0, 5) + if game.puzzle[x][y] == 0: + await game.update_board(digit=random.randint(0, 5), coord=(x, y)) + break class SudokuView(discord.ui.View): """A set of buttons to control a Sudoku game.""" + def __init__(self, ctx: commands.Context): + super(SudokuView, self).__init__() + self.disabled = None + self.ctx = ctx + # self.children[0] + @discord.ui.button(style=discord.ButtonStyle.green, label="Hint") async def hint_button(self, *_) -> None: """Button that fills in one empty square on the Sudoku board.""" @@ -249,7 +200,6 @@ async def info_button(self, *_) -> None: async def end_button(self, *_) -> None: """Button that ends the current game.""" await self.ctx.invoke(self.ctx.bot.get_command("sudoku finish")) - self.stop() async def interaction_check(self, interaction: discord.Interaction) -> bool: """Check to ensure that the interacting user is the user who invoked the command.""" @@ -261,6 +211,6 @@ async def interaction_check(self, interaction: discord.Interaction) -> bool: return True -def setup(bot: Bot) -> None: +async def setup(bot: Bot) -> None: """Load the Sudoku cog.""" - bot.add_cog(Sudoku(bot)) + await bot.add_cog(Sudoku(bot)) From 83abb595a37739ab249328dcc6f2e2e752f4a5d3 Mon Sep 17 00:00:00 2001 From: DMFriends Date: Sun, 3 Aug 2025 23:09:55 -0400 Subject: [PATCH 03/13] Change name of `sudoku_embed` function to `update_board` to align with how this function is being called elsewhere in the program --- bot/exts/fun/sudoku/sudoku.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/fun/sudoku/sudoku.py b/bot/exts/fun/sudoku/sudoku.py index fc3ce7db04..eeb2835415 100644 --- a/bot/exts/fun/sudoku/sudoku.py +++ b/bot/exts/fun/sudoku/sudoku.py @@ -90,7 +90,7 @@ def info_embed(self) -> discord.Embed: info_embed.add_field(name="Hints Used", value=len(self.hints)) return info_embed - async def sudoku_embed(self, digit=None, coord=None): + async def update_board(self, digit=None, coord=None): sudoku_embed = discord.Embed(title="Sudoku", color=Colours.soft_orange) if digit and coord: self.draw_num(digit, coord) From 55bc8dfd9a9509cff2a33600b1cfbd538ad65e21 Mon Sep 17 00:00:00 2001 From: DMFriends Date: Tue, 5 Aug 2025 10:50:28 -0400 Subject: [PATCH 04/13] More Sudoku cog bug fixes --- bot/exts/fun/sudoku/__init__.py | 4 +- ...ard_generation.py => _board_generation.py} | 39 ++++++++----------- bot/exts/fun/sudoku/{sudoku.py => _sudoku.py} | 16 ++++---- 3 files changed, 28 insertions(+), 31 deletions(-) rename bot/exts/fun/sudoku/{board_generation.py => _board_generation.py} (81%) rename bot/exts/fun/sudoku/{sudoku.py => _sudoku.py} (95%) diff --git a/bot/exts/fun/sudoku/__init__.py b/bot/exts/fun/sudoku/__init__.py index f764d491c6..f989ad5c0a 100644 --- a/bot/exts/fun/sudoku/__init__.py +++ b/bot/exts/fun/sudoku/__init__.py @@ -1,11 +1,11 @@ import logging from bot.bot import Bot -from bot.exts.fun.sudoku.sudoku import Sudoku +from ._sudoku import Sudoku log = logging.getLogger(__name__) async def setup(bot: Bot) -> None: - """Load the Sudoku Cog.""" + """Load the Sudoku cog.""" await bot.add_cog(Sudoku(bot)) diff --git a/bot/exts/fun/sudoku/board_generation.py b/bot/exts/fun/sudoku/_board_generation.py similarity index 81% rename from bot/exts/fun/sudoku/board_generation.py rename to bot/exts/fun/sudoku/_board_generation.py index aaf933a310..80dbb9940c 100644 --- a/bot/exts/fun/sudoku/board_generation.py +++ b/bot/exts/fun/sudoku/_board_generation.py @@ -39,8 +39,11 @@ def valid_location(grid, row, col, number): return False # Checks the subgrid - for i in range(row, (row + 2)): - for j in range(col, (col + 2)): + start_row = row - row % 2 + start_col = col - col % 3 + + for i in range(start_row, (start_row + 2)): + for j in range(start_col, (start_col + 2)): if grid[i][j] == number: return False @@ -62,29 +65,24 @@ def yield_coords(): yield i // 6, i % 6 def generate_solution(self, grid): - """Generates a full solution with backtracking.""" number_list = [1, 2, 3, 4, 5, 6] - for row, col in self.yield_coords(): - # Find next empty cell - if not grid[row][col] == 0: - continue - random.shuffle(number_list) - for number in number_list: - if not self.valid_location(grid, row, col, number): - continue + empty = self.find_empty_square(grid) + if not empty: + return True # board is complete - self.path.append((number, row, col)) + row, col = empty + random.shuffle(number_list) + + for number in number_list: + if self.valid_location(grid, row, col, number): grid[row][col] = number - if not self.find_empty_square(grid): + self.path.append((number, row, col)) + + if self.generate_solution(grid): return True - else: - continue - # If the grid is full - if self.generate_solution(grid): - return True - break + grid[row][col] = 0 return False @@ -119,6 +117,3 @@ def remove_numbers_from_grid(self): self.counter = 0 # self.solve_puzzle(grid_copy) return - - -GenerateSudokuPuzzle() diff --git a/bot/exts/fun/sudoku/sudoku.py b/bot/exts/fun/sudoku/_sudoku.py similarity index 95% rename from bot/exts/fun/sudoku/sudoku.py rename to bot/exts/fun/sudoku/_sudoku.py index eeb2835415..b9495623da 100644 --- a/bot/exts/fun/sudoku/sudoku.py +++ b/bot/exts/fun/sudoku/_sudoku.py @@ -10,6 +10,8 @@ from bot.bot import Bot from bot.constants import Colours +from ._board_generation import GenerateSudokuPuzzle + BACKGROUND = (242, 243, 244) BLACK = 0 SUDOKU_TEMPLATE_PATH = "bot/resources/fun/sudoku_template.png" @@ -64,9 +66,9 @@ def index_to_coord(position: tuple[int, int]) -> tuple[int, int]: return position[0] * 83 + 100, (position[1]) * 83 + 11 @staticmethod - def generate_board() -> list[list[int]]: - """Generate a valid Sudoku solution board.""" - pass + def generate_board() -> tuple[list[list[int]], list[list[int]]]: + """Generate a valid Sudoku puzzle.""" + return GenerateSudokuPuzzle().generate_puzzle() def generate_puzzle(self) -> list[list[int]]: """Remove numbers from a valid Sudoku solution based on the difficulty. Returns a Sudoku puzzle.""" @@ -108,7 +110,7 @@ class Sudoku(commands.Cog): def __init__(self, bot: Bot): self.bot = bot - self.games: dict[int:SudokuGame] = {} + self.games: dict[int, SudokuGame] = {} @commands.group(aliases=["s"], invoke_without_command=True) async def sudoku(self, ctx: commands.Context, coord: Optional[CoordinateConverter] = None, @@ -126,10 +128,10 @@ async def sudoku(self, ctx: commands.Context, coord: Optional[CoordinateConverte if not game: await ctx.send("Welcome to Sudoku! Type your guesses like so: `A1 1`") await self.start(ctx) - await self.bot.wait_for(event="message") + await self.bot.wait_for("message") if coord and digit.isnumeric() and -1 < int(digit) < 10 or digit in "xX": # print(f"{coord=}, {digit=}") - await game.sudoku_embed(digit, coord) + await game.update_board(digit, coord) else: raise commands.BadArgument @@ -140,7 +142,7 @@ async def start(self, ctx: commands.Context, difficulty: str = "Normal") -> None await ctx.send("You are already playing a game!") return game = self.games[ctx.author.id] = SudokuGame(ctx, difficulty) - await game.sudoku_embed() + await game.update_board() @sudoku.command(aliases=["end", "stop"]) async def finish(self, ctx: commands.Context) -> None: From 1b3b592d54a275caeb611d220f55ec71b6408706 Mon Sep 17 00:00:00 2001 From: DMFriends Date: Tue, 5 Aug 2025 12:58:00 -0400 Subject: [PATCH 05/13] Fixed button interactions in SudokuView --- bot/exts/fun/sudoku/_sudoku.py | 93 ++++++++++++++++------------------ 1 file changed, 43 insertions(+), 50 deletions(-) diff --git a/bot/exts/fun/sudoku/_sudoku.py b/bot/exts/fun/sudoku/_sudoku.py index b9495623da..d84b97b2e6 100644 --- a/bot/exts/fun/sudoku/_sudoku.py +++ b/bot/exts/fun/sudoku/_sudoku.py @@ -38,10 +38,11 @@ async def convert(self, ctx: commands.Context, argument: str) -> tuple[int, int] class SudokuGame: - """Class that contains information and regarding Sudoku game.""" + """Class that contains helper methods for a Sudoku game.""" - def __init__(self, ctx: commands.Context, difficulty: str): + def __init__(self, ctx: commands.Context, difficulty: str, cog: "Sudoku"): self.ctx = ctx + self.cog = cog self.image = Image.open(SUDOKU_TEMPLATE_PATH) self.solution = self.generate_board() self.puzzle = self.generate_puzzle() @@ -101,7 +102,7 @@ async def update_board(self, digit=None, coord=None): sudoku_embed.set_image(url="attachment://sudoku.png") if self.message: await self.message.delete() - self.message = await self.ctx.send(file=board_image, embed=sudoku_embed, view=SudokuView(self.ctx)) + self.message = await self.ctx.send(file=board_image, embed=sudoku_embed, view=SudokuView(self.ctx, self.cog)) os.remove("sudoku.png") @@ -112,6 +113,10 @@ def __init__(self, bot: Bot): self.bot = bot self.games: dict[int, SudokuGame] = {} + @staticmethod + def is_valid(msg): + return msg.author == ctx.author and msg.channel == ctx.channel + @commands.group(aliases=["s"], invoke_without_command=True) async def sudoku(self, ctx: commands.Context, coord: Optional[CoordinateConverter] = None, digit: Optional[str] = None) -> None: @@ -126,10 +131,10 @@ async def sudoku(self, ctx: commands.Context, coord: Optional[CoordinateConverte """ game = self.games.get(ctx.author.id) if not game: - await ctx.send("Welcome to Sudoku! Type your guesses like so: `A1 1`") + await ctx.send("Welcome to Sudoku! Type your guesses like so: `.sudoku A1 1`") await self.start(ctx) - await self.bot.wait_for("message") - if coord and digit.isnumeric() and -1 < int(digit) < 10 or digit in "xX": + await self.bot.wait_for("message", check=is_valid) + if coord and isinstance(digit, str) and (digit.isnumeric() and 0 <= int(digit) <= 9 or digit in "xX"): # print(f"{coord=}, {digit=}") await game.update_board(digit, coord) else: @@ -141,67 +146,55 @@ async def start(self, ctx: commands.Context, difficulty: str = "Normal") -> None if self.games.get(ctx.author.id): await ctx.send("You are already playing a game!") return - game = self.games[ctx.author.id] = SudokuGame(ctx, difficulty) + game = self.games[ctx.author.id] = SudokuGame(ctx, difficulty, self) await game.update_board() - @sudoku.command(aliases=["end", "stop"]) - async def finish(self, ctx: commands.Context) -> None: - """End a Sudoku game.""" - game = self.games.get(ctx.author.id) - if game: - if ctx.author == game.invoker: - del self.games[ctx.author.id] - await ctx.send("Ended the current game.") - else: - await ctx.send("Only the owner of the game can end it!") - else: - await ctx.send("You are not playing a Sudoku game! Type `.sudoku start` to begin.") - - @sudoku.command(aliases=["who", "information", "score"]) - async def info(self, ctx: commands.Context) -> None: - """Send info about a currently running Sudoku game.""" - game = self.games.get(ctx.author.id) - if game: - await ctx.send(embed=game.info_embed()) - else: - await ctx.send("You are not playing a game!") - - @sudoku.command() - async def hint(self, ctx: commands.Context) -> None: - """Fill in one empty square on the Sudoku board.""" - game = self.games.get(ctx.author.id) - if game: - game.hints.append(time.time()) - while True: - x, y = random.randint(0, 5), random.randint(0, 5) - if game.puzzle[x][y] == 0: - await game.update_board(digit=random.randint(0, 5), coord=(x, y)) - break - class SudokuView(discord.ui.View): """A set of buttons to control a Sudoku game.""" - def __init__(self, ctx: commands.Context): - super(SudokuView, self).__init__() + def __init__(self, ctx: commands.Context, cog: Sudoku): + super().__init__(timeout=120) self.disabled = None self.ctx = ctx - # self.children[0] + # self.games: dict[int, SudokuGame] = {} + self.cog = cog @discord.ui.button(style=discord.ButtonStyle.green, label="Hint") - async def hint_button(self, *_) -> None: + async def hint_button(self, interaction: discord.Interaction, *_) -> None: """Button that fills in one empty square on the Sudoku board.""" - await self.ctx.invoke(self.ctx.bot.get_command("sudoku hint")) + game = self.cog.games.get(interaction.user.id) + if game: + game.hints.append(time.time()) + while True: + x, y = random.randint(0, 5), random.randint(0, 5) + if game.puzzle[x][y] == 0: + await game.update_board(digit=random.randint(0, 5), coord=(x, y)) + break @discord.ui.button(style=discord.ButtonStyle.primary, label="Game Info") - async def info_button(self, *_) -> None: + async def info_button(self, interaction: discord.Interaction, *_) -> None: """Button that displays information about the current game.""" - await self.ctx.invoke(self.ctx.bot.get_command("sudoku info")) + game = self.cog.games.get(interaction.user.id) + if game: + await interaction.response.send_message(embed=game.info_embed(), ephemeral=False) + else: + await interaction.response.send_message("You are not playing a Sudoku game! Type `.sudoku` to " + "begin.", ephemeral=True) @discord.ui.button(style=discord.ButtonStyle.red, label="End Game") - async def end_button(self, *_) -> None: + async def end_button(self, interaction: discord.Interaction, *_) -> None: """Button that ends the current game.""" - await self.ctx.invoke(self.ctx.bot.get_command("sudoku finish")) + game = self.cog.games.get(interaction.user.id) + if game: + if interaction.user == game.invoker: + del self.cog.games[interaction.user.id] + await interaction.response.send_message("Ended the current game.", ephemeral=True) + else: + await interaction.response.send_message("Only the owner of the game can end it!", ephemeral=True) + else: + await interaction.response.send_message("You are not playing a Sudoku game! Type `.sudoku` to " + "begin.", ephemeral=True) async def interaction_check(self, interaction: discord.Interaction) -> bool: """Check to ensure that the interacting user is the user who invoked the command.""" From 3397037c265c5e8108381f408d79f7cd571f00b0 Mon Sep 17 00:00:00 2001 From: Daniel Gu Date: Wed, 6 Aug 2025 06:20:47 +0800 Subject: [PATCH 06/13] feat(sudoku): alternate grid generation --- bot/exts/fun/sudoku/_sudoku_grid.py | 104 ++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 bot/exts/fun/sudoku/_sudoku_grid.py diff --git a/bot/exts/fun/sudoku/_sudoku_grid.py b/bot/exts/fun/sudoku/_sudoku_grid.py new file mode 100644 index 0000000000..122b29ef7d --- /dev/null +++ b/bot/exts/fun/sudoku/_sudoku_grid.py @@ -0,0 +1,104 @@ +from collections import Counter +import random +import copy + + +NUM_GIVEN_DIGITS = 12 + + +class SudokuGrid: + def __init__(self): + # Correct solution to the puzzle + self.solution: list[list[int]] = self.generate_solution() + + # Digits shown to the user + self.puzzle: list[list[int]] = copy.deepcopy(self.solution) + # Track of empty squares used to speed up processing + self.empty_squares: set[tuple[int, int]] = set() + puzzle_digits: Counter[int] = Counter({i: 6 for i in range(1, 7)}) + + # Attempt to remove digits in a random order + positions = [(r, c) for r in range(6) for c in range(6)] + random.shuffle(positions) + for r, c in positions: + digit = self.solution[r][c] + + # Cannot remove 2 digits entirely since it breaks uniqueness + if 0 in puzzle_digits.values() and puzzle_digits[digit] == 1: + continue + + # Remove the digit + self.puzzle[r][c] = 0 + self.empty_squares.add((r, c)) + puzzle_digits -= {digit: 1} + + # If the solution is no longer unique, revert + if not self.has_unique_solution(): + self.puzzle[r][c] = digit + self.empty_squares.remove((r, c)) + puzzle_digits += {digit: 1} + + # Stop when there are 12 given digits + if puzzle_digits.total() <= NUM_GIVEN_DIGITS: + break + + @staticmethod + def generate_solution(): + # Offset added to each row/column, arranged into subgrids + row_boxes = [[0, 1, 2], [3, 4, 5]] + col_boxes = [[0, 3], [1, 4], [2, 5]] + + # Row permutation + for box in row_boxes: + random.shuffle(box) + random.shuffle(row_boxes) + # Column permutation + for box in col_boxes: + random.shuffle(box) + random.shuffle(col_boxes) + + rows = row_boxes[0] + row_boxes[1] + cols = col_boxes[0] + col_boxes[1] + col_boxes[2] + + number_mapping = list(range(1, 7)) + random.shuffle(number_mapping) + + # Create the grid + grid = [ + [number_mapping[(row + col) % 6] for col in cols] + for row in rows + ] + + return grid + + def has_unique_solution(self) -> bool: + # Base case (grid complete) + if not self.empty_squares: + # Return False (i.e. non-unique) if a different solution is found + return self.puzzle == self.solution + + r, c = self.empty_squares.pop() + possible_digits = set(range(1, 7)) + + # Check row + possible_digits -= set(self.puzzle[r]) + # Check column + possible_digits -= set(self.puzzle[i][c] for i in range(6)) + # Check subgrid + sub_r = r - r % 2 + sub_c = c - c % 3 + for i in range(sub_r, sub_r + 2): + for j in range(sub_c, sub_c + 3): + possible_digits.discard(self.puzzle[i][j]) + + # DFS the rest of the solution + is_unique = True + for digit in possible_digits: + self.puzzle[r][c] = digit + if not self.has_unique_solution(): + is_unique = False + break + self.puzzle[r][c] = 0 + self.empty_squares.add((r, c)) + + return is_unique From cf2e5308ccb35c5187fc1da8ab4946289790d924 Mon Sep 17 00:00:00 2001 From: Daniel Gu Date: Thu, 7 Aug 2025 05:01:16 +0800 Subject: [PATCH 07/13] feat(sudoku): add image generation to `SudokuGrid` --- bot/exts/fun/sudoku/_sudoku_grid.py | 52 ++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/bot/exts/fun/sudoku/_sudoku_grid.py b/bot/exts/fun/sudoku/_sudoku_grid.py index 122b29ef7d..2b50cc91a6 100644 --- a/bot/exts/fun/sudoku/_sudoku_grid.py +++ b/bot/exts/fun/sudoku/_sudoku_grid.py @@ -2,11 +2,20 @@ import random import copy +from PIL import Image, ImageDraw, ImageFont + NUM_GIVEN_DIGITS = 12 +BACKGROUND = (242, 243, 244) +BLACK = (0, 0, 0) +SUDOKU_TEMPLATE_PATH = "bot/resources/fun/sudoku_template.png" +NUMBER_FONT = ImageFont.truetype("bot/resources/fun/Roboto-Medium.ttf", 99) + class SudokuGrid: + """A sudoku puzzle.""" + def __init__(self): # Correct solution to the puzzle self.solution: list[list[int]] = self.generate_solution() @@ -42,8 +51,18 @@ def __init__(self): if puzzle_digits.total() <= NUM_GIVEN_DIGITS: break + # Initialize image + self.image: Image.Image = Image.open(SUDOKU_TEMPLATE_PATH) + for x, row in enumerate(self.puzzle): + for y, digit in enumerate(row): + if digit == 0: + continue + self.draw_digit((x, y), digit) + @staticmethod - def generate_solution(): + def generate_solution() -> list[list[int]]: + """Generate a random complete 6x6 sudoku grid.""" + # Offset added to each row/column, arranged into subgrids row_boxes = [[0, 1, 2], [3, 4, 5]] col_boxes = [[0, 3], [1, 4], [2, 5]] @@ -71,7 +90,20 @@ def generate_solution(): return grid + def draw_digit(self, position: tuple[int, int], digit: int): + pos_x = position[0] * 83 + 95 + pos_y = position[1] * 83 + 6 + ImageDraw.Draw(self.image).text( + (pos_x, pos_y), + str(digit), + fill=BLACK, + font=NUMBER_FONT, + align="center", + ) + def has_unique_solution(self) -> bool: + """Brute force search the empty squares to see if an alternate solution exists.""" + # Base case (grid complete) if not self.empty_squares: # Return False (i.e. non-unique) if a different solution is found @@ -102,3 +134,21 @@ def has_unique_solution(self) -> bool: self.empty_squares.add((r, c)) return is_unique + + def is_empty(self, position: tuple[int, int]) -> bool: + """Checks if a given square is empty.""" + return position in self.empty_squares + + def guess(self, position: tuple[int, int], digit: int) -> bool: + """Guess the digit of a given square, and update the board if correct.""" + if not self.is_empty(position): + return False + + row, col = position + if self.solution[row][col] == digit: + self.puzzle[row][col] = digit + self.empty_squares.remove(position) + self.draw_digit(position, digit) + return True + else: + return False From b42ac18a2e5bcbbfb81bda976daf90e1ec07f167 Mon Sep 17 00:00:00 2001 From: Daniel Gu Date: Fri, 8 Aug 2025 04:54:37 +0800 Subject: [PATCH 08/13] feat(sudoku): add difficulty setting to SudokuGrid --- bot/exts/fun/sudoku/_sudoku_grid.py | 35 +++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/bot/exts/fun/sudoku/_sudoku_grid.py b/bot/exts/fun/sudoku/_sudoku_grid.py index 2b50cc91a6..3eb3ac434a 100644 --- a/bot/exts/fun/sudoku/_sudoku_grid.py +++ b/bot/exts/fun/sudoku/_sudoku_grid.py @@ -1,27 +1,41 @@ from collections import Counter +import io import random import copy +from typing import Literal from PIL import Image, ImageDraw, ImageFont +import discord -NUM_GIVEN_DIGITS = 12 - BACKGROUND = (242, 243, 244) BLACK = (0, 0, 0) SUDOKU_TEMPLATE_PATH = "bot/resources/fun/sudoku_template.png" NUMBER_FONT = ImageFont.truetype("bot/resources/fun/Roboto-Medium.ttf", 99) +type SudokuDifficulty = Literal["easy", "medium", "hard"] + + +GIVEN_DIGITS: dict[SudokuDifficulty, int] = { + "easy": 13, + "medium": 11, + "hard": 9, +} + + class SudokuGrid: """A sudoku puzzle.""" - def __init__(self): + def __init__(self, difficulty: SudokuDifficulty): + self.difficulty: SudokuDifficulty = difficulty + # Correct solution to the puzzle self.solution: list[list[int]] = self.generate_solution() # Digits shown to the user self.puzzle: list[list[int]] = copy.deepcopy(self.solution) + # Track of empty squares used to speed up processing self.empty_squares: set[tuple[int, int]] = set() puzzle_digits: Counter[int] = Counter({i: 6 for i in range(1, 7)}) @@ -48,7 +62,7 @@ def __init__(self): puzzle_digits += {digit: 1} # Stop when there are 12 given digits - if puzzle_digits.total() <= NUM_GIVEN_DIGITS: + if puzzle_digits.total() <= GIVEN_DIGITS[difficulty]: break # Initialize image @@ -146,9 +160,22 @@ def guess(self, position: tuple[int, int], digit: int) -> bool: row, col = position if self.solution[row][col] == digit: + # Correct, perform necessary updates self.puzzle[row][col] = digit self.empty_squares.remove(position) self.draw_digit(position, digit) return True else: + # Incorrect return False + + def is_solved(self) -> bool: + """Returns whether the sudoku puzzle is complete.""" + return self.puzzle == self.solution + + def image_file(self) -> discord.File: + """Returns the current board image as a discord.File object.""" + buf = io.BytesIO() + self.image.save(buf, "jpg") + buf.seek(0) + return discord.File(buf, "sudoku.jpg") From 35c8b416e0f481a736b4387d8f4eb7c553379e9a Mon Sep 17 00:00:00 2001 From: DMFriends Date: Fri, 8 Aug 2025 14:32:07 -0400 Subject: [PATCH 09/13] Various Sudoku improvements including compatibility with the new board generation algorithm --- bot/exts/fun/sudoku/_board_generation.py | 119 --------- bot/exts/fun/sudoku/_sudoku.py | 301 +++++++++++++---------- bot/exts/fun/sudoku/_sudoku_grid.py | 65 ++--- 3 files changed, 193 insertions(+), 292 deletions(-) delete mode 100644 bot/exts/fun/sudoku/_board_generation.py diff --git a/bot/exts/fun/sudoku/_board_generation.py b/bot/exts/fun/sudoku/_board_generation.py deleted file mode 100644 index 80dbb9940c..0000000000 --- a/bot/exts/fun/sudoku/_board_generation.py +++ /dev/null @@ -1,119 +0,0 @@ -import random -import copy - - -class GenerateSudokuPuzzle: - """Generates and solves Sudoku puzzles using a backtracking algorithm.""" - def __init__(self): - self.counter = 0 - # Path is for the matplotlib animation - self.path = [] - # Generate the puzzle - self.grid = [[0 for _ in range(6)] for _ in range(6)] - self.generate_puzzle() - - def generate_puzzle(self): - """Generates a new puzzle and solves it.""" - self.generate_solution(self.grid) - # self.print_grid() - self.remove_numbers_from_grid() - self.print_grid() - return - - def print_grid(self): - for row in self.grid: - print(row) - return - - @staticmethod - def valid_location(grid, row, col, number): - """Returns a bool which determines whether the - number can be placed in the square given by the player.""" - # Checks the row - if number in grid[row]: - return False - - # Checks the column - for i in range(6): - if grid[i][col] == number: - return False - - # Checks the subgrid - start_row = row - row % 2 - start_col = col - col % 3 - - for i in range(start_row, (start_row + 2)): - for j in range(start_col, (start_col + 2)): - if grid[i][j] == number: - return False - - else: - return True - - @staticmethod - def find_empty_square(grid): - """Return the next empty square coordinates in the grid.""" - for i in range(6): - for j in range(6): - if grid[i][j] == 0: - return [i, j] - return - - @staticmethod - def yield_coords(): - for i in range(0, 36): - yield i // 6, i % 6 - - def generate_solution(self, grid): - number_list = [1, 2, 3, 4, 5, 6] - - empty = self.find_empty_square(grid) - if not empty: - return True # board is complete - - row, col = empty - random.shuffle(number_list) - - for number in number_list: - if self.valid_location(grid, row, col, number): - grid[row][col] = number - self.path.append((number, row, col)) - - if self.generate_solution(grid): - return True - - grid[row][col] = 0 - - return False - - @staticmethod - def get_non_empty_squares(grid): - """Returns a shuffled list of non-empty squares in the puzzle.""" - non_empty_squares = [] - for i in range(len(grid)): - for j in range(len(grid)): - if grid[i][j] != 0: - non_empty_squares.append((i, j)) - random.shuffle(non_empty_squares) - return non_empty_squares - - def remove_numbers_from_grid(self): - """Remove numbers from the grid to create the puzzle.""" - # Get all non-empty squares from the grid - non_empty_squares = self.get_non_empty_squares(self.grid) - non_empty_squares_count = len(non_empty_squares) - rounds = 3 - while rounds > 0 and len(self.get_non_empty_squares(self.grid)) >= 11: - # There should be at least 11 clues for easy puzzles, - # 10 clues for medium puzzles, and 9 clues for hard puzzles. - row, col = non_empty_squares.pop() - non_empty_squares_count -= 1 - # Might need to put the square value back if there is more than one solution - # removed_square = self.grid[row][col] - self.grid[row][col] = 0 - # Make a copy of the grid to solve - # grid_copy = copy.deepcopy(self.grid) - # Initialize solutions counter to zero - self.counter = 0 - # self.solve_puzzle(grid_copy) - return diff --git a/bot/exts/fun/sudoku/_sudoku.py b/bot/exts/fun/sudoku/_sudoku.py index d84b97b2e6..845009cb4f 100644 --- a/bot/exts/fun/sudoku/_sudoku.py +++ b/bot/exts/fun/sudoku/_sudoku.py @@ -1,109 +1,30 @@ -import os -from typing import Optional +import asyncio +import io import random -import time +from random import choice import discord -from PIL import Image, ImageDraw, ImageFont +from PIL import Image from discord.ext import commands from bot.bot import Bot -from bot.constants import Colours +from bot.constants import Colours, NEGATIVE_REPLIES -from ._board_generation import GenerateSudokuPuzzle - -BACKGROUND = (242, 243, 244) -BLACK = 0 -SUDOKU_TEMPLATE_PATH = "bot/resources/fun/sudoku_template.png" -NUM_FONT = ImageFont.truetype("bot/resources/fun/Roboto-Medium.ttf", 99) - - -class CoordinateConverter(commands.Converter): - """Converter used in Sudoku game.""" - - async def convert(self, ctx: commands.Context, argument: str) -> tuple[int, int]: - """Convert alphanumeric grid coordinates to 2d list index. Eg 'C1'-> (2, 0).""" - argument = sorted(argument.lower()) - if len(argument) != 2: - raise commands.BadArgument("The coordinate must be two characters long.") - if argument[0].isnumeric() and not argument[1].isnumeric(): - number, letter = argument[0], argument[1] - else: - raise commands.BadArgument("The coordinate must comprise of" - "1 letter from A to F, and 1 number from 1 to 6.") - if 0 > int(number) > 10 or letter not in "abcdef": - raise commands.BadArgument("The coordinate must comprise of" - "1 letter from A to F, and 1 number from 1 to 6.") - return ord(letter)-97, int(number)-1 +from ._sudoku_grid import SudokuDifficulty, SudokuGrid class SudokuGame: - """Class that contains helper methods for a Sudoku game.""" + """Class that contains helper constants for a Sudoku game.""" - def __init__(self, ctx: commands.Context, difficulty: str, cog: "Sudoku"): + def __init__(self, ctx: commands.Context, difficulty: SudokuDifficulty, cog: "Sudoku"): self.ctx = ctx self.cog = cog - self.image = Image.open(SUDOKU_TEMPLATE_PATH) - self.solution = self.generate_board() - self.puzzle = self.generate_puzzle() - self.running: bool = True + self.grid = SudokuGrid(difficulty) + self.hints: int = 0 + self.image = self.grid.image + self.board = [row[:] for row in self.grid.puzzle] self.invoker: discord.Member = ctx.author - self.started_at = time.time() - self.difficulty: str = difficulty # enum class? - self.hints: list[time.time] = [] - self.message = None - - def draw_num(self, digit: int, position: tuple[int, int]) -> Image: - """Draw a number on the Sudoku board.""" - digit = str(digit) - if digit in "123456" and len(digit) == 1: - draw = ImageDraw.Draw(self.image) - draw.text(self.index_to_coord(position), str(digit), fill=BLACK, font=NUM_FONT) - return self.image - - @staticmethod - def index_to_coord(position: tuple[int, int]) -> tuple[int, int]: - """Convert a 2D list index to an x,y coordinate on the Sudoku image.""" - return position[0] * 83 + 100, (position[1]) * 83 + 11 - - @staticmethod - def generate_board() -> tuple[list[list[int]], list[list[int]]]: - """Generate a valid Sudoku puzzle.""" - return GenerateSudokuPuzzle().generate_puzzle() - - def generate_puzzle(self) -> list[list[int]]: - """Remove numbers from a valid Sudoku solution based on the difficulty. Returns a Sudoku puzzle.""" - self.puzzle = [([0]*6)]*6 - return self.puzzle - - @property - def solved(self) -> bool: - """Check if the puzzle has been solved.""" - return self.solution == self.puzzle - - def info_embed(self) -> discord.Embed: - """Create an embed that displays game information.""" - current_time = time.time() - info_embed = discord.Embed(title="Sudoku Game Information", color=Colours.grass_green) - info_embed.add_field(name="Player", value=self.invoker.name) - info_embed.add_field(name="Current Time", value=(current_time - self.started_at)) - info_embed.add_field(name="Progress", value="N/A") # add in this variable - info_embed.add_field(name="Difficulty", value=self.difficulty) - info_embed.set_author(name=self.invoker.name, icon_url=self.invoker.display_avatar.url) - info_embed.add_field(name="Hints Used", value=len(self.hints)) - return info_embed - - async def update_board(self, digit=None, coord=None): - sudoku_embed = discord.Embed(title="Sudoku", color=Colours.soft_orange) - if digit and coord: - self.draw_num(digit, coord) - self.image.save("sudoku.png") - board_image = discord.File("sudoku.png") - sudoku_embed.set_image(url="attachment://sudoku.png") - if self.message: - await self.message.delete() - self.message = await self.ctx.send(file=board_image, embed=sudoku_embed, view=SudokuView(self.ctx, self.cog)) - os.remove("sudoku.png") + self.message: discord.Message | None = None class Sudoku(commands.Cog): @@ -114,12 +35,24 @@ def __init__(self, bot: Bot): self.games: dict[int, SudokuGame] = {} @staticmethod - def is_valid(msg): - return msg.author == ctx.author and msg.channel == ctx.channel + def is_valid_guess_format(content: str) -> bool: + parts = content.strip().upper().split() + if len(parts) != 2: + return False + coord, digit = parts + if len(coord) != 2 or coord[0] not in "ABCDEF" or not coord[1].isdigit(): + return False + return (digit.isdigit() and 1 <= int(digit) <= 6) or digit in "Xx" - @commands.group(aliases=["s"], invoke_without_command=True) - async def sudoku(self, ctx: commands.Context, coord: Optional[CoordinateConverter] = None, - digit: Optional[str] = None) -> None: + @staticmethod + def pil_to_discord_file(image: Image.Image, filename: str = "sudoku.png") -> discord.File: + buffer = io.BytesIO() + image.save(buffer, format="PNG") + buffer.seek(0) + return discord.File(buffer, filename=filename) + + @commands.command() + async def sudoku(self, ctx: commands.Context, difficulty: SudokuDifficulty = "normal") -> None: """ Play Sudoku with the bot! @@ -129,25 +62,94 @@ async def sudoku(self, ctx: commands.Context, coord: Optional[CoordinateConverte column, or any of the smaller 3x3 grids. In this version of the game, it would be 2x3 smaller grids instead of 3x3 and numbers 1-6 will be used on the grid. """ - game = self.games.get(ctx.author.id) - if not game: - await ctx.send("Welcome to Sudoku! Type your guesses like so: `.sudoku A1 1`") - await self.start(ctx) - await self.bot.wait_for("message", check=is_valid) - if coord and isinstance(digit, str) and (digit.isnumeric() and 0 <= int(digit) <= 9 or digit in "xX"): - # print(f"{coord=}, {digit=}") - await game.update_board(digit, coord) - else: - raise commands.BadArgument + diff = difficulty.lower() + + valid_difficulties: tuple[SudokuDifficulty, ...] = ("easy", "normal", "hard") + if diff not in valid_difficulties: + await ctx.send("Invalid difficulty! Choose from: easy, normal, hard.") + return - @sudoku.command() - async def start(self, ctx: commands.Context, difficulty: str = "Normal") -> None: - """Start a Sudoku game.""" - if self.games.get(ctx.author.id): + game = self.games.get(ctx.author.id) + if game: await ctx.send("You are already playing a game!") return - game = self.games[ctx.author.id] = SudokuGame(ctx, difficulty, self) - await game.update_board() + + game = SudokuGame(ctx, difficulty, self) + self.games[ctx.author.id] = game + + def is_valid(msg: ctx.message) -> bool: + return msg.author == ctx.author and msg.channel == ctx.channel + + view = SudokuView(ctx, self) + game = self.games.get(ctx.author.id) + await ctx.send("Welcome to Sudoku! Type your guesses like so: `A1 1`\n" + "Note: Hints are marked in blue, guesses are marked in red.\n" + "Hints allowed: Easy: 6; Normal: 4; Hard: 2") + sudoku_embed = discord.Embed(title="Sudoku", color=Colours.soft_orange) + file = self.pil_to_discord_file(game.image) + sudoku_embed.set_image(url="attachment://sudoku.png") + game.message = await ctx.send(embed=sudoku_embed, file=file, view=view) + + game.wait_task = asyncio.create_task( + self.bot.wait_for("message", timeout=120, check=is_valid) + ) + try: + await game.wait_task + except TimeoutError: + timeout_embed = discord.Embed( + title=choice(NEGATIVE_REPLIES), + description="Uh oh! You took too long to respond!", + color=Colours.soft_red + ) + + await ctx.send(ctx.author.mention, embed=timeout_embed) + + view.stop() + for child in view.children: + if isinstance(child, discord.ui.Button): + child.disabled = True + + await game.message.edit(view=view) + return + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + if message.author.bot: + return + + game = self.games.get(message.author.id) + if not game or not game.message: + return + + content = message.content.strip().upper() + if not self.is_valid_guess_format(content): + return # ignore invalid formats + + coord_str, digit_str = message.content.strip().split() + col_letter, row_number = coord_str[0], coord_str[1:] + row = int(row_number) - 1 # e.g. "2" → 1 (0-indexed) + col = ord(col_letter.upper()) - ord("A") # "D" → 3 + + coord = (row, col) + digit = int(digit_str) + + if game.grid.guess(coord, digit): + game.grid.draw_digit(coord, digit, (255, 0, 0)) + + # Convert image to discord.File + file = self.pil_to_discord_file(game.grid.image) + + # Create updated embed + embed = discord.Embed(title="Sudoku", color=Colours.soft_orange) + embed.set_image(url="attachment://sudoku.png") + + # Edit the original message + await game.message.edit(embed=embed, attachments=[file], view=SudokuView(game.ctx, self)) + + if game.grid.is_solved(): + await game.ctx.send("Congratulations! You solved the puzzle!") + else: + await message.add_reaction("❌") class SudokuView(discord.ui.View): @@ -157,30 +159,50 @@ def __init__(self, ctx: commands.Context, cog: Sudoku): super().__init__(timeout=120) self.disabled = None self.ctx = ctx - # self.games: dict[int, SudokuGame] = {} self.cog = cog @discord.ui.button(style=discord.ButtonStyle.green, label="Hint") async def hint_button(self, interaction: discord.Interaction, *_) -> None: """Button that fills in one empty square on the Sudoku board.""" game = self.cog.games.get(interaction.user.id) - if game: - game.hints.append(time.time()) - while True: - x, y = random.randint(0, 5), random.randint(0, 5) - if game.puzzle[x][y] == 0: - await game.update_board(digit=random.randint(0, 5), coord=(x, y)) - break - - @discord.ui.button(style=discord.ButtonStyle.primary, label="Game Info") - async def info_button(self, interaction: discord.Interaction, *_) -> None: - """Button that displays information about the current game.""" - game = self.cog.games.get(interaction.user.id) - if game: - await interaction.response.send_message(embed=game.info_embed(), ephemeral=False) - else: - await interaction.response.send_message("You are not playing a Sudoku game! Type `.sudoku` to " - "begin.", ephemeral=True) + if not game: + await interaction.response.send_message("You're not playing a game!", ephemeral=True) + return + game.hints += 1 + # Find an empty cell and fill it with the correct value + empty_cells = [(i, j) for i in range(6) for j in range(6) if game.grid.puzzle[i][j] == 0] + if not empty_cells: + await interaction.response.send_message("No empty cells left to hint.", ephemeral=True) + return + + # Update internal board + x, y = random.choice(empty_cells) + game.grid.puzzle[x][y] = game.grid.solution[x][y] + game.grid.empty_squares.discard((x, y)) + + # Draw the digit + game.grid.draw_digit((x, y), game.grid.solution[x][y], (0, 0, 255)) + await interaction.response.send_message(f"Hint placed at {chr(65 + y)}{x+1}.", ephemeral=True) + + file = self.cog.pil_to_discord_file(game.grid.image) + + embed = discord.Embed(title="Sudoku", color=Colours.soft_orange) + embed.set_image(url="attachment://sudoku.png") + + if ((game.hints >= 6 and game.grid.difficulty == "easy") or + (game.hints >= 4 and game.grid.difficulty == "normal") or + (game.hints >= 2 and game.grid.difficulty == "hard")): + # await interaction.response.send_message("You ran out of hints!", ephemeral=True) + for child in self.children: + if isinstance(child, discord.ui.Button) and child.label == "Hint": + child.disabled = True + + await interaction.followup.edit_message( + message_id=interaction.message.id, + attachments=[file], + embed=embed, + view=self + ) @discord.ui.button(style=discord.ButtonStyle.red, label="End Game") async def end_button(self, interaction: discord.Interaction, *_) -> None: @@ -189,7 +211,19 @@ async def end_button(self, interaction: discord.Interaction, *_) -> None: if game: if interaction.user == game.invoker: del self.cog.games[interaction.user.id] + + # Cancel the wait task if it's running + wait_task = game.wait_task if hasattr(game, "wait_task") else None + if wait_task and not wait_task.done(): + wait_task.cancel() + + # Disable all buttons in the view + for child in self.children: + if isinstance(child, discord.ui.Button): + child.disabled = True + await interaction.response.send_message("Ended the current game.", ephemeral=True) + await interaction.followup.edit_message(message_id=interaction.message.id, view=self) else: await interaction.response.send_message("Only the owner of the game can end it!", ephemeral=True) else: @@ -199,9 +233,8 @@ async def end_button(self, interaction: discord.Interaction, *_) -> None: async def interaction_check(self, interaction: discord.Interaction) -> bool: """Check to ensure that the interacting user is the user who invoked the command.""" if interaction.user != self.ctx.author: - error_embed = discord.Embed( - description="Sorry, but this button can only be used by the original author.") - await interaction.response.send_message(embed=error_embed, ephemeral=True) + await interaction.response.send_message( + "Sorry, but this button can only be used by the original author.", ephemeral=True) return False return True diff --git a/bot/exts/fun/sudoku/_sudoku_grid.py b/bot/exts/fun/sudoku/_sudoku_grid.py index 3eb3ac434a..b9abf3195c 100644 --- a/bot/exts/fun/sudoku/_sudoku_grid.py +++ b/bot/exts/fun/sudoku/_sudoku_grid.py @@ -1,34 +1,29 @@ -from collections import Counter -import io -import random import copy +import random +from collections import Counter from typing import Literal from PIL import Image, ImageDraw, ImageFont -import discord - - -BACKGROUND = (242, 243, 244) -BLACK = (0, 0, 0) -SUDOKU_TEMPLATE_PATH = "bot/resources/fun/sudoku_template.png" -NUMBER_FONT = ImageFont.truetype("bot/resources/fun/Roboto-Medium.ttf", 99) - - -type SudokuDifficulty = Literal["easy", "medium", "hard"] +type SudokuDifficulty = Literal["easy", "normal", "hard"] GIVEN_DIGITS: dict[SudokuDifficulty, int] = { "easy": 13, - "medium": 11, + "normal": 11, "hard": 9, } +BLACK = (0, 0, 0) +SUDOKU_TEMPLATE_PATH = "bot/resources/fun/sudoku_template.png" +NUMBER_FONT = ImageFont.truetype("bot/resources/fun/Roboto-Medium.ttf", 99) + class SudokuGrid: - """A sudoku puzzle.""" + """Generates and solves Sudoku puzzles.""" - def __init__(self, difficulty: SudokuDifficulty): + def __init__(self, difficulty: SudokuDifficulty = "normal"): self.difficulty: SudokuDifficulty = difficulty + self.given_digits = GIVEN_DIGITS[difficulty] # Correct solution to the puzzle self.solution: list[list[int]] = self.generate_solution() @@ -61,8 +56,8 @@ def __init__(self, difficulty: SudokuDifficulty): self.empty_squares.remove((r, c)) puzzle_digits += {digit: 1} - # Stop when there are 12 given digits - if puzzle_digits.total() <= GIVEN_DIGITS[difficulty]: + # Stop when all the given digits have been set based on the difficulty + if puzzle_digits.total() <= self.given_digits: break # Initialize image @@ -76,7 +71,6 @@ def __init__(self, difficulty: SudokuDifficulty): @staticmethod def generate_solution() -> list[list[int]]: """Generate a random complete 6x6 sudoku grid.""" - # Offset added to each row/column, arranged into subgrids row_boxes = [[0, 1, 2], [3, 4, 5]] col_boxes = [[0, 3], [1, 4], [2, 5]] @@ -85,6 +79,7 @@ def generate_solution() -> list[list[int]]: for box in row_boxes: random.shuffle(box) random.shuffle(row_boxes) + # Column permutation for box in col_boxes: random.shuffle(box) @@ -104,20 +99,20 @@ def generate_solution() -> list[list[int]]: return grid - def draw_digit(self, position: tuple[int, int], digit: int): - pos_x = position[0] * 83 + 95 - pos_y = position[1] * 83 + 6 + def draw_digit(self, position: tuple[int, int], digit: int, fill: tuple[int, int, int] = BLACK) -> None: + """Draws a digit in the given position on the Sudoku board.""" + pos_x = int(position[1]) * 83 + 95 + pos_y = int(position[0]) * 83 + 6 ImageDraw.Draw(self.image).text( (pos_x, pos_y), str(digit), - fill=BLACK, + fill=fill, font=NUMBER_FONT, align="center", ) def has_unique_solution(self) -> bool: """Brute force search the empty squares to see if an alternate solution exists.""" - # Base case (grid complete) if not self.empty_squares: # Return False (i.e. non-unique) if a different solution is found @@ -128,8 +123,10 @@ def has_unique_solution(self) -> bool: # Check row possible_digits -= set(self.puzzle[r]) + # Check column possible_digits -= set(self.puzzle[i][c] for i in range(6)) + # Check subgrid sub_r = r - r % 2 sub_c = c - c % 3 @@ -153,6 +150,10 @@ def is_empty(self, position: tuple[int, int]) -> bool: """Checks if a given square is empty.""" return position in self.empty_squares + def is_solved(self) -> bool: + """Returns whether the sudoku puzzle is complete.""" + return self.puzzle == self.solution + def guess(self, position: tuple[int, int], digit: int) -> bool: """Guess the digit of a given square, and update the board if correct.""" if not self.is_empty(position): @@ -160,22 +161,8 @@ def guess(self, position: tuple[int, int], digit: int) -> bool: row, col = position if self.solution[row][col] == digit: - # Correct, perform necessary updates self.puzzle[row][col] = digit self.empty_squares.remove(position) self.draw_digit(position, digit) return True - else: - # Incorrect - return False - - def is_solved(self) -> bool: - """Returns whether the sudoku puzzle is complete.""" - return self.puzzle == self.solution - - def image_file(self) -> discord.File: - """Returns the current board image as a discord.File object.""" - buf = io.BytesIO() - self.image.save(buf, "jpg") - buf.seek(0) - return discord.File(buf, "sudoku.jpg") + return False From 103f1676f2d13ffe7d9db6238e32bb5398ce96c9 Mon Sep 17 00:00:00 2001 From: Daniel Gu Date: Wed, 13 Aug 2025 22:48:29 +0800 Subject: [PATCH 10/13] feat(sudoku): refactor SudokuGame --- bot/exts/fun/sudoku/_sudoku.py | 255 ++++++++++++---------------- bot/exts/fun/sudoku/_sudoku_grid.py | 20 ++- 2 files changed, 127 insertions(+), 148 deletions(-) diff --git a/bot/exts/fun/sudoku/_sudoku.py b/bot/exts/fun/sudoku/_sudoku.py index 845009cb4f..247cccf364 100644 --- a/bot/exts/fun/sudoku/_sudoku.py +++ b/bot/exts/fun/sudoku/_sudoku.py @@ -1,11 +1,11 @@ import asyncio -import io import random from random import choice +import re import discord -from PIL import Image from discord.ext import commands +from pydis_core.utils.logging import get_logger from bot.bot import Bot from bot.constants import Colours, NEGATIVE_REPLIES @@ -13,18 +13,85 @@ from ._sudoku_grid import SudokuDifficulty, SudokuGrid +log = get_logger(__name__) + + class SudokuGame: """Class that contains helper constants for a Sudoku game.""" def __init__(self, ctx: commands.Context, difficulty: SudokuDifficulty, cog: "Sudoku"): - self.ctx = ctx - self.cog = cog self.grid = SudokuGrid(difficulty) - self.hints: int = 0 - self.image = self.grid.image - self.board = [row[:] for row in self.grid.puzzle] + + self.cog: Sudoku = cog self.invoker: discord.Member = ctx.author + self.channel = ctx.channel self.message: discord.Message | None = None + self.view: SudokuView | None = None + + self.hints: int = 0 + self.correct: int = 0 + self.incorrect: int = 0 + self.wait_task: asyncio.Task | None = None + + async def send_or_update_message(self): + # Create embed + embed_description = ( + f"Hints used: {self.hints}/5\n" + f"Correct / incorrect guesses: {self.correct} / {self.incorrect}" + ) + embed = discord.Embed(title="Sudoku", color=Colours.soft_orange, description=embed_description) + embed.set_image(url="attachment://sudoku.png") + file = self.grid.image_as_discord_file() + + if self.message is None: + self.view = SudokuView(self) + self.message = await self.channel.send(embed=embed, file=file, view=self.view) + else: + await self.message.edit(embed=embed, attachments=[file], view=self.view) + + async def guess(self, coord: tuple[int, int], digit: int, message: discord.Message): + result = self.grid.guess(coord, digit) + if not result: + self.incorrect += 1 + await message.add_reaction("❌") + return + + self.correct += 1 + await message.add_reaction("✅") + + await self.check_solved() + await self.send_or_update_message() + + def get_hint(self) -> tuple[int, int]: + self.hints += 1 + + # Update internal board + x, y = random.choice(list(self.grid.empty_squares)) + self.grid.guess((x, y), self.grid.solution[x][y], (0, 0, 255)) + + if self.hints >= 5: + self.view.hint_button.disabled = True + + return x, y + + async def check_solved(self): + if self.grid.is_solved(): + self.view.stop() + self.view = None + + await self.channel.send("Congratulations! You solved the puzzle!") + self.end_game() + + def end_game(self): + # Cancel the wait task if it's running + if self.wait_task is not None and not self.wait_task.done(): + self.wait_task.cancel() + # Fill in empty squares (if game was aborted) + for x, row in enumerate(self.grid.solution): + for y, digit in enumerate(row): + self.grid.guess((x, y), digit, (0, 255, 0)) + # Remove game from registry + self.cog.games.pop(self.invoker.id) class Sudoku(commands.Cog): @@ -34,25 +101,8 @@ def __init__(self, bot: Bot): self.bot = bot self.games: dict[int, SudokuGame] = {} - @staticmethod - def is_valid_guess_format(content: str) -> bool: - parts = content.strip().upper().split() - if len(parts) != 2: - return False - coord, digit = parts - if len(coord) != 2 or coord[0] not in "ABCDEF" or not coord[1].isdigit(): - return False - return (digit.isdigit() and 1 <= int(digit) <= 6) or digit in "Xx" - - @staticmethod - def pil_to_discord_file(image: Image.Image, filename: str = "sudoku.png") -> discord.File: - buffer = io.BytesIO() - image.save(buffer, format="PNG") - buffer.seek(0) - return discord.File(buffer, filename=filename) - @commands.command() - async def sudoku(self, ctx: commands.Context, difficulty: SudokuDifficulty = "normal") -> None: + async def sudoku(self, ctx: commands.Context, difficulty: str | None = None) -> None: """ Play Sudoku with the bot! @@ -62,34 +112,30 @@ async def sudoku(self, ctx: commands.Context, difficulty: SudokuDifficulty = "no column, or any of the smaller 3x3 grids. In this version of the game, it would be 2x3 smaller grids instead of 3x3 and numbers 1-6 will be used on the grid. """ - diff = difficulty.lower() - - valid_difficulties: tuple[SudokuDifficulty, ...] = ("easy", "normal", "hard") - if diff not in valid_difficulties: - await ctx.send("Invalid difficulty! Choose from: easy, normal, hard.") + if difficulty is None: + await ctx.send("Welcome to sudoku! Start the game by running `.sudoku easy/normal/hard`") return - game = self.games.get(ctx.author.id) - if game: + if ctx.author.id in self.games: await ctx.send("You are already playing a game!") return + difficulty = difficulty.lower() + valid_difficulties: list[SudokuDifficulty] = ["easy", "normal", "hard"] + if difficulty not in valid_difficulties: + await ctx.send("Invalid difficulty! Choose from: easy, normal, hard.") + return + + await ctx.send("Welcome to Sudoku! Type your guesses like so: `A1 1`\n" + "Note: Hints are marked in blue, guesses are marked in red.") + game = SudokuGame(ctx, difficulty, self) + await game.send_or_update_message() self.games[ctx.author.id] = game def is_valid(msg: ctx.message) -> bool: return msg.author == ctx.author and msg.channel == ctx.channel - view = SudokuView(ctx, self) - game = self.games.get(ctx.author.id) - await ctx.send("Welcome to Sudoku! Type your guesses like so: `A1 1`\n" - "Note: Hints are marked in blue, guesses are marked in red.\n" - "Hints allowed: Easy: 6; Normal: 4; Hard: 2") - sudoku_embed = discord.Embed(title="Sudoku", color=Colours.soft_orange) - file = self.pil_to_discord_file(game.image) - sudoku_embed.set_image(url="attachment://sudoku.png") - game.message = await ctx.send(embed=sudoku_embed, file=file, view=view) - game.wait_task = asyncio.create_task( self.bot.wait_for("message", timeout=120, check=is_valid) ) @@ -99,140 +145,65 @@ def is_valid(msg: ctx.message) -> bool: timeout_embed = discord.Embed( title=choice(NEGATIVE_REPLIES), description="Uh oh! You took too long to respond!", - color=Colours.soft_red + color=Colours.soft_red, ) - await ctx.send(ctx.author.mention, embed=timeout_embed) - - view.stop() - for child in view.children: - if isinstance(child, discord.ui.Button): - child.disabled = True - - await game.message.edit(view=view) + game.end_game() + game.send_or_update_message() return @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: if message.author.bot: return - - game = self.games.get(message.author.id) - if not game or not game.message: + if (game := self.games.get(message.author.id)) is None: + return + if (match := re.match(r"^([a-z])([1-6]) ([1-6])$", message.content.lower())) is None: return - content = message.content.strip().upper() - if not self.is_valid_guess_format(content): - return # ignore invalid formats - - coord_str, digit_str = message.content.strip().split() - col_letter, row_number = coord_str[0], coord_str[1:] - row = int(row_number) - 1 # e.g. "2" → 1 (0-indexed) - col = ord(col_letter.upper()) - ord("A") # "D" → 3 - - coord = (row, col) + col_str, row_str, digit_str = match.groups() + col = ord(col_str) - ord("a") + row = int(row_str) - 1 digit = int(digit_str) - if game.grid.guess(coord, digit): - game.grid.draw_digit(coord, digit, (255, 0, 0)) - - # Convert image to discord.File - file = self.pil_to_discord_file(game.grid.image) - - # Create updated embed - embed = discord.Embed(title="Sudoku", color=Colours.soft_orange) - embed.set_image(url="attachment://sudoku.png") - - # Edit the original message - await game.message.edit(embed=embed, attachments=[file], view=SudokuView(game.ctx, self)) - - if game.grid.is_solved(): - await game.ctx.send("Congratulations! You solved the puzzle!") - else: - await message.add_reaction("❌") + await game.guess((row, col), digit, message) class SudokuView(discord.ui.View): """A set of buttons to control a Sudoku game.""" - def __init__(self, ctx: commands.Context, cog: Sudoku): + def __init__(self, game: SudokuGame): super().__init__(timeout=120) - self.disabled = None - self.ctx = ctx - self.cog = cog + self.game = game @discord.ui.button(style=discord.ButtonStyle.green, label="Hint") async def hint_button(self, interaction: discord.Interaction, *_) -> None: """Button that fills in one empty square on the Sudoku board.""" - game = self.cog.games.get(interaction.user.id) - if not game: - await interaction.response.send_message("You're not playing a game!", ephemeral=True) + if self.game.hints >= 5: + await interaction.response.send_message("You ran out of hints!", ephemeral=True) return - game.hints += 1 - # Find an empty cell and fill it with the correct value - empty_cells = [(i, j) for i in range(6) for j in range(6) if game.grid.puzzle[i][j] == 0] - if not empty_cells: - await interaction.response.send_message("No empty cells left to hint.", ephemeral=True) - return - - # Update internal board - x, y = random.choice(empty_cells) - game.grid.puzzle[x][y] = game.grid.solution[x][y] - game.grid.empty_squares.discard((x, y)) - # Draw the digit - game.grid.draw_digit((x, y), game.grid.solution[x][y], (0, 0, 255)) - await interaction.response.send_message(f"Hint placed at {chr(65 + y)}{x+1}.", ephemeral=True) + row, col = self.game.get_hint() - file = self.cog.pil_to_discord_file(game.grid.image) + rows = "123456" + cols = "ABCDEF" + await interaction.response.send_message(f"Hint placed on {rows[row]}{cols[col]}", ephemeral=True) - embed = discord.Embed(title="Sudoku", color=Colours.soft_orange) - embed.set_image(url="attachment://sudoku.png") - - if ((game.hints >= 6 and game.grid.difficulty == "easy") or - (game.hints >= 4 and game.grid.difficulty == "normal") or - (game.hints >= 2 and game.grid.difficulty == "hard")): - # await interaction.response.send_message("You ran out of hints!", ephemeral=True) - for child in self.children: - if isinstance(child, discord.ui.Button) and child.label == "Hint": - child.disabled = True - - await interaction.followup.edit_message( - message_id=interaction.message.id, - attachments=[file], - embed=embed, - view=self - ) + await self.game.check_solved() + await self.game.send_or_update_message() @discord.ui.button(style=discord.ButtonStyle.red, label="End Game") async def end_button(self, interaction: discord.Interaction, *_) -> None: """Button that ends the current game.""" - game = self.cog.games.get(interaction.user.id) - if game: - if interaction.user == game.invoker: - del self.cog.games[interaction.user.id] - - # Cancel the wait task if it's running - wait_task = game.wait_task if hasattr(game, "wait_task") else None - if wait_task and not wait_task.done(): - wait_task.cancel() - - # Disable all buttons in the view - for child in self.children: - if isinstance(child, discord.ui.Button): - child.disabled = True - - await interaction.response.send_message("Ended the current game.", ephemeral=True) - await interaction.followup.edit_message(message_id=interaction.message.id, view=self) - else: - await interaction.response.send_message("Only the owner of the game can end it!", ephemeral=True) + if interaction.user == self.game.invoker: + self.game.end_game() + await interaction.response.send_message("Ended the current game.", ephemeral=True) else: - await interaction.response.send_message("You are not playing a Sudoku game! Type `.sudoku` to " - "begin.", ephemeral=True) + await interaction.response.send_message("Only the owner of the game can end it!", ephemeral=True) async def interaction_check(self, interaction: discord.Interaction) -> bool: """Check to ensure that the interacting user is the user who invoked the command.""" - if interaction.user != self.ctx.author: + if interaction.user != self.game.invoker: await interaction.response.send_message( "Sorry, but this button can only be used by the original author.", ephemeral=True) return False diff --git a/bot/exts/fun/sudoku/_sudoku_grid.py b/bot/exts/fun/sudoku/_sudoku_grid.py index b9abf3195c..33666f84d8 100644 --- a/bot/exts/fun/sudoku/_sudoku_grid.py +++ b/bot/exts/fun/sudoku/_sudoku_grid.py @@ -1,9 +1,11 @@ import copy +import io import random from collections import Counter from typing import Literal from PIL import Image, ImageDraw, ImageFont +import discord type SudokuDifficulty = Literal["easy", "normal", "hard"] @@ -72,8 +74,8 @@ def __init__(self, difficulty: SudokuDifficulty = "normal"): def generate_solution() -> list[list[int]]: """Generate a random complete 6x6 sudoku grid.""" # Offset added to each row/column, arranged into subgrids - row_boxes = [[0, 1, 2], [3, 4, 5]] - col_boxes = [[0, 3], [1, 4], [2, 5]] + row_boxes = [[0, 3], [1, 4], [2, 5]] + col_boxes = [[0, 1, 2], [3, 4, 5]] # Row permutation for box in row_boxes: @@ -85,8 +87,8 @@ def generate_solution() -> list[list[int]]: random.shuffle(box) random.shuffle(col_boxes) - rows = row_boxes[0] + row_boxes[1] - cols = col_boxes[0] + col_boxes[1] + col_boxes[2] + rows = row_boxes[0] + row_boxes[1] + row_boxes[2] + cols = col_boxes[0] + col_boxes[1] number_mapping = list(range(1, 7)) random.shuffle(number_mapping) @@ -154,7 +156,7 @@ def is_solved(self) -> bool: """Returns whether the sudoku puzzle is complete.""" return self.puzzle == self.solution - def guess(self, position: tuple[int, int], digit: int) -> bool: + def guess(self, position: tuple[int, int], digit: int, color: tuple[int, int, int] = (255, 0, 0)) -> bool: """Guess the digit of a given square, and update the board if correct.""" if not self.is_empty(position): return False @@ -163,6 +165,12 @@ def guess(self, position: tuple[int, int], digit: int) -> bool: if self.solution[row][col] == digit: self.puzzle[row][col] = digit self.empty_squares.remove(position) - self.draw_digit(position, digit) + self.draw_digit(position, digit, color) return True return False + + def image_as_discord_file(self, filename: str = "sudoku.png") -> discord.File: + buffer = io.BytesIO() + self.image.save(buffer, format="PNG") + buffer.seek(0) + return discord.File(buffer, filename=filename) From 32030c90ecb191e8bd9003d8c420331bb45842ce Mon Sep 17 00:00:00 2001 From: DMFriends Date: Wed, 13 Aug 2025 16:14:52 -0400 Subject: [PATCH 11/13] Fixed a few bugs that came up after refactoring `SudokuGame`, also changed the correct guess color to green instead of red --- bot/exts/fun/sudoku/_sudoku.py | 71 ++++++++++++++++++----------- bot/exts/fun/sudoku/_sudoku_grid.py | 7 +-- 2 files changed, 49 insertions(+), 29 deletions(-) diff --git a/bot/exts/fun/sudoku/_sudoku.py b/bot/exts/fun/sudoku/_sudoku.py index 247cccf364..4a7d74b7d0 100644 --- a/bot/exts/fun/sudoku/_sudoku.py +++ b/bot/exts/fun/sudoku/_sudoku.py @@ -1,19 +1,18 @@ import asyncio import random -from random import choice import re +from random import choice import discord from discord.ext import commands -from pydis_core.utils.logging import get_logger from bot.bot import Bot from bot.constants import Colours, NEGATIVE_REPLIES from ._sudoku_grid import SudokuDifficulty, SudokuGrid - -log = get_logger(__name__) +BLUE = (0, 0, 255) +RED = (255, 0, 0) class SudokuGame: @@ -33,10 +32,10 @@ def __init__(self, ctx: commands.Context, difficulty: SudokuDifficulty, cog: "Su self.incorrect: int = 0 self.wait_task: asyncio.Task | None = None - async def send_or_update_message(self): + async def send_or_update_message(self) -> None: # Create embed embed_description = ( - f"Hints used: {self.hints}/5\n" + f"Hints used: {self.hints} / 5\n" f"Correct / incorrect guesses: {self.correct} / {self.incorrect}" ) embed = discord.Embed(title="Sudoku", color=Colours.soft_orange, description=embed_description) @@ -49,7 +48,7 @@ async def send_or_update_message(self): else: await self.message.edit(embed=embed, attachments=[file], view=self.view) - async def guess(self, coord: tuple[int, int], digit: int, message: discord.Message): + async def guess(self, coord: tuple[int, int], digit: int, message: discord.Message) -> None: result = self.grid.guess(coord, digit) if not result: self.incorrect += 1 @@ -67,14 +66,14 @@ def get_hint(self) -> tuple[int, int]: # Update internal board x, y = random.choice(list(self.grid.empty_squares)) - self.grid.guess((x, y), self.grid.solution[x][y], (0, 0, 255)) + self.grid.guess((x, y), self.grid.solution[x][y], BLUE) - if self.hints >= 5: + if self.hints > 5: self.view.hint_button.disabled = True return x, y - async def check_solved(self): + async def check_solved(self) -> None: if self.grid.is_solved(): self.view.stop() self.view = None @@ -82,14 +81,21 @@ async def check_solved(self): await self.channel.send("Congratulations! You solved the puzzle!") self.end_game() - def end_game(self): + def end_game(self) -> None: # Cancel the wait task if it's running if self.wait_task is not None and not self.wait_task.done(): self.wait_task.cancel() + + self.view.stop() + for child in self.view.children: + if isinstance(child, discord.ui.Button): + child.disabled = True + # Fill in empty squares (if game was aborted) for x, row in enumerate(self.grid.solution): for y, digit in enumerate(row): - self.grid.guess((x, y), digit, (0, 255, 0)) + self.grid.guess((x, y), digit, RED) + # Remove game from registry self.cog.games.pop(self.invoker.id) @@ -102,7 +108,7 @@ def __init__(self, bot: Bot): self.games: dict[int, SudokuGame] = {} @commands.command() - async def sudoku(self, ctx: commands.Context, difficulty: str | None = None) -> None: + async def sudoku(self, ctx: commands.Context, difficulty: str | None) -> None: """ Play Sudoku with the bot! @@ -112,28 +118,27 @@ async def sudoku(self, ctx: commands.Context, difficulty: str | None = None) -> column, or any of the smaller 3x3 grids. In this version of the game, it would be 2x3 smaller grids instead of 3x3 and numbers 1-6 will be used on the grid. """ - if difficulty is None: - await ctx.send("Welcome to sudoku! Start the game by running `.sudoku easy/normal/hard`") - return - if ctx.author.id in self.games: await ctx.send("You are already playing a game!") return - difficulty = difficulty.lower() - valid_difficulties: list[SudokuDifficulty] = ["easy", "normal", "hard"] - if difficulty not in valid_difficulties: + if not difficulty: + await ctx.send("Please specify a difficulty: `.sudoku easy/normal/hard`") + return + + difficulty_arg = self.parse_difficulty(difficulty) + if not difficulty_arg: await ctx.send("Invalid difficulty! Choose from: easy, normal, hard.") return await ctx.send("Welcome to Sudoku! Type your guesses like so: `A1 1`\n" "Note: Hints are marked in blue, guesses are marked in red.") - game = SudokuGame(ctx, difficulty, self) + game = SudokuGame(ctx, difficulty_arg, self) await game.send_or_update_message() self.games[ctx.author.id] = game - def is_valid(msg: ctx.message) -> bool: + def is_valid(msg: discord.Message) -> bool: return msg.author == ctx.author and msg.channel == ctx.channel game.wait_task = asyncio.create_task( @@ -149,9 +154,20 @@ def is_valid(msg: ctx.message) -> bool: ) await ctx.send(ctx.author.mention, embed=timeout_embed) game.end_game() - game.send_or_update_message() + await game.send_or_update_message() return + @staticmethod + def parse_difficulty(diff: str) -> SudokuDifficulty | None: + diff = diff.lower() + if diff == "easy": + return "easy" + if diff == "normal": + return "normal" + if diff == "hard": + return "hard" + return None + @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: if message.author.bot: @@ -179,15 +195,17 @@ def __init__(self, game: SudokuGame): @discord.ui.button(style=discord.ButtonStyle.green, label="Hint") async def hint_button(self, interaction: discord.Interaction, *_) -> None: """Button that fills in one empty square on the Sudoku board.""" - if self.game.hints >= 5: + if self.game.hints > 5: await interaction.response.send_message("You ran out of hints!", ephemeral=True) + + await interaction.followup.edit_message(message_id=interaction.message.id, view=self) return row, col = self.game.get_hint() - rows = "123456" cols = "ABCDEF" - await interaction.response.send_message(f"Hint placed on {rows[row]}{cols[col]}", ephemeral=True) + rows = "123456" + await interaction.response.send_message(f"Hint placed at {cols[col]}{rows[row]}.", ephemeral=True) await self.game.check_solved() await self.game.send_or_update_message() @@ -198,6 +216,7 @@ async def end_button(self, interaction: discord.Interaction, *_) -> None: if interaction.user == self.game.invoker: self.game.end_game() await interaction.response.send_message("Ended the current game.", ephemeral=True) + await self.game.send_or_update_message() else: await interaction.response.send_message("Only the owner of the game can end it!", ephemeral=True) diff --git a/bot/exts/fun/sudoku/_sudoku_grid.py b/bot/exts/fun/sudoku/_sudoku_grid.py index 33666f84d8..f0fa0f148b 100644 --- a/bot/exts/fun/sudoku/_sudoku_grid.py +++ b/bot/exts/fun/sudoku/_sudoku_grid.py @@ -4,8 +4,8 @@ from collections import Counter from typing import Literal -from PIL import Image, ImageDraw, ImageFont import discord +from PIL import Image, ImageDraw, ImageFont type SudokuDifficulty = Literal["easy", "normal", "hard"] @@ -16,6 +16,7 @@ } BLACK = (0, 0, 0) +GREEN = (9, 150, 21) SUDOKU_TEMPLATE_PATH = "bot/resources/fun/sudoku_template.png" NUMBER_FONT = ImageFont.truetype("bot/resources/fun/Roboto-Medium.ttf", 99) @@ -23,7 +24,7 @@ class SudokuGrid: """Generates and solves Sudoku puzzles.""" - def __init__(self, difficulty: SudokuDifficulty = "normal"): + def __init__(self, difficulty: SudokuDifficulty): self.difficulty: SudokuDifficulty = difficulty self.given_digits = GIVEN_DIGITS[difficulty] @@ -156,7 +157,7 @@ def is_solved(self) -> bool: """Returns whether the sudoku puzzle is complete.""" return self.puzzle == self.solution - def guess(self, position: tuple[int, int], digit: int, color: tuple[int, int, int] = (255, 0, 0)) -> bool: + def guess(self, position: tuple[int, int], digit: int, color: tuple[int, int, int] = GREEN) -> bool: """Guess the digit of a given square, and update the board if correct.""" if not self.is_empty(position): return False From 4db9bdf0626b7870177c50936504869a0bc3d34e Mon Sep 17 00:00:00 2001 From: DMFriends Date: Wed, 13 Aug 2025 21:50:10 -0400 Subject: [PATCH 12/13] Small docstring update for `SudokuGame` --- bot/exts/fun/sudoku/_sudoku.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/fun/sudoku/_sudoku.py b/bot/exts/fun/sudoku/_sudoku.py index 4a7d74b7d0..a2f2b7d5b8 100644 --- a/bot/exts/fun/sudoku/_sudoku.py +++ b/bot/exts/fun/sudoku/_sudoku.py @@ -16,7 +16,7 @@ class SudokuGame: - """Class that contains helper constants for a Sudoku game.""" + """Class that contains helper methods for a Sudoku game.""" def __init__(self, ctx: commands.Context, difficulty: SudokuDifficulty, cog: "Sudoku"): self.grid = SudokuGrid(difficulty) From 340e560f97c72ecd0f5dc320268262029e5c1760 Mon Sep 17 00:00:00 2001 From: DMFriends Date: Wed, 13 Aug 2025 22:02:36 -0400 Subject: [PATCH 13/13] Fix lint error --- bot/exts/fun/sudoku/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/fun/sudoku/__init__.py b/bot/exts/fun/sudoku/__init__.py index f989ad5c0a..59f4b9a4ce 100644 --- a/bot/exts/fun/sudoku/__init__.py +++ b/bot/exts/fun/sudoku/__init__.py @@ -1,6 +1,7 @@ import logging from bot.bot import Bot + from ._sudoku import Sudoku log = logging.getLogger(__name__)