diff options
| -rw-r--r-- | bot/seasons/evergreen/minesweeper.py | 241 | 
1 files changed, 131 insertions, 110 deletions
| diff --git a/bot/seasons/evergreen/minesweeper.py b/bot/seasons/evergreen/minesweeper.py index 9e7fad12..60e0c2ae 100644 --- a/bot/seasons/evergreen/minesweeper.py +++ b/bot/seasons/evergreen/minesweeper.py @@ -1,21 +1,42 @@ +import logging  import typing +from dataclasses import dataclass  from random import random +import discord  from discord.ext import commands -GameBoard = typing.List[typing.List[typing.Union[str, int]]] -DictOfGames = typing.Dict[int, typing.Dict] - - -class CordConverter(commands.Converter): -    """Converter for cords.""" - -    async def convert(self, ctx, cord: str) -> typing.Tuple[int, int]: -        """Take in a cord string and turn it into x, y""" -        if not 2 <= len(cord) <= 3: +from bot.constants import Client + +MESSAGE_MAPPING = { +    0: ":stop_button:", +    1: ":one:", +    2: ":two:", +    3: ":three:", +    4: ":four:", +    5: ":five:", +    6: ":six:", +    7: ":seven:", +    8: ":eight:", +    9: ":nine:", +    10: ":keycap_ten:", +    "bomb": ":bomb:", +    "hidden": ":grey_question:", +    "flag": ":triangular_flag_on_post:" +} + +log = logging.getLogger(__name__) + + +class CoordinateConverter(commands.Converter): +    """Converter for Coordinates.""" + +    async def convert(self, ctx, Coordinate: str) -> typing.Tuple[int, int]: +        """Take in a Coordinate string and turn it into x, y""" +        if not 2 <= len(Coordinate) <= 3:              raise commands.ArgumentParsingError() -        value1 = cord[0] -        value2 = cord[1:] +        value1 = Coordinate[0] +        value2 = Coordinate[1:]          if not value2.isdigit():              raise commands.ArgumentParsingError()          x = ord(value1) - 97 @@ -25,53 +46,53 @@ class CordConverter(commands.Converter):          return x, y +GameDict = typing.List[typing.List[typing.Union[str, int]]] + + +@dataclass +class Game: +    """The data for a game.""" + +    board: GameDict +    revealed: GameDict +    dm_msg: discord.message +    chat_msg: discord.message + + +DictOfGames = typing.Dict[int, Game] + +  class Minesweeper(commands.Cog): -    """Play a game of minesweeper.""" +    """Play a game of Minesweeper."""      def __init__(self, bot: commands.Bot) -> None:          self.games: DictOfGames = {}  # Store the currently running games      @staticmethod -    def is_bomb(cell: typing.Union[str, int]) -> int: -        """Returns 1 if `cell` is a bomb if not 0""" -        return cell == "bomb" +    def get_neighbours(x: int, y: int) -> typing.Generator: +        """Get all the neighbouring x and y including it self.""" +        for x_ in [x - 1, x, x + 1]: +            for y_ in [y - 1, y, y + 1]: +                if x_ != -1 and x_ != 10 and y_ != -1 and y_ != 10: +                    yield x_, y_ -    def generate_board(self, bomb_chance: float) -> GameBoard: +    def generate_board(self, bomb_chance: float) -> GameDict:          """Generate a 2d array for the board.""" -        board: GameBoard = [["bomb" if random() <= bomb_chance else "number" for _ in range(10)] for _ in range(10)] +        board: GameDict = [["bomb" if random() <= bomb_chance else "number" for _ in range(10)] for _ in range(10)]          for y, row in enumerate(board):              for x, cell in enumerate(row):                  if cell == "number":                      # calculate bombs near it                      bombs = 0 -                    for x_ in [x - 1, x, x + 1]: -                        for y_ in [y - 1, y, y + 1]: -                            if x_ != -1 and x_ != 10 and y_ != -1 and y_ != 10 and board[y_][x_] == "bomb": -                                bombs += 1 - +                    for x_, y_ in self.get_neighbours(x, y): +                        if board[y_][x_] == "bomb": +                            bombs += 1                      board[y][x] = bombs          return board      @staticmethod -    def format_for_discord(board: GameBoard) -> str: -        """Format the board to a string for discord.""" -        mapping = { -            0: ":stop_button:", -            1: ":one:", -            2: ":two:", -            3: ":three:", -            4: ":four:", -            5: ":five:", -            6: ":six:", -            7: ":seven:", -            8: ":eight:", -            9: ":nine:", -            10: ":keycap_ten:", -            "bomb": ":bomb:", -            "hidden": ":grey_question:", -            "flag": ":triangular_flag_on_post:" -        } - +    def format_for_discord(board: GameDict) -> str: +        """Format the board as a string for Discord."""          discord_msg = (              ":stop_button:    :regional_indicator_a::regional_indicator_b::regional_indicator_c:"              ":regional_indicator_d::regional_indicator_e::regional_indicator_f::regional_indicator_g:" @@ -79,9 +100,9 @@ class Minesweeper(commands.Cog):          )          rows: typing.List[str] = []          for row_number, row in enumerate(board): -            new_row = mapping[row_number + 1] + "    " +            new_row = MESSAGE_MAPPING[row_number + 1] + "    "              for cell in row: -                new_row += mapping[cell] +                new_row += MESSAGE_MAPPING[cell]              rows.append(new_row)          discord_msg += "\n".join(rows) @@ -89,124 +110,124 @@ class Minesweeper(commands.Cog):      @commands.command(name="minesweeper")      async def minesweeper_command(self, ctx: commands.Context, bomb_chance: float = .2) -> None: -        """Start a game of minesweeper.""" -        if ctx.author.id in self.games.keys():  # Player is already playing -            msg = await ctx.send(f"{ctx.author.mention} you already have a game running") +        """Start a game of Minesweeper.""" +        if ctx.author.id in self.games:  # Player is already playing +            msg = await ctx.send(f"{ctx.author.mention} you already have a game running!")              await msg.delete(delay=2)              await ctx.message.delete(delay=2)              return          # Add game to list -        board: GameBoard = self.generate_board(bomb_chance) -        revealed_board: GameBoard = [["hidden" for _ in range(10)] for _ in range(10)] +        board: GameDict = self.generate_board(bomb_chance) +        revealed_board: GameDict = [["hidden"] * 10 for _ in range(10)] -        await ctx.send(f"{ctx.author.mention} is playing minesweeper") +        await ctx.send(f"{ctx.author.mention} is playing Minesweeper")          chat_msg = await ctx.send(self.format_for_discord(revealed_board)) -        await ctx.author.send("play by typing: `.reveal xy [xy]` or `.flag xy [xy]` \n" -                              "close the game with `.end`\n" -                              "cords must be in format `<letter><number>`") +        await ctx.author.send( +            f"Play by typing: `{Client.prefix}reveal xy [xy]` or `{Client.prefix}flag xy [xy]` \n" +            "Close the game with `.end`\n" +            "Coordinates must be in format `<letter><number>`" +        )          dm_msg = await ctx.author.send(self.format_for_discord(revealed_board)) -        self.games[ctx.author.id] = { -            "board": board, -            "revealed": revealed_board, -            "dm_msg": dm_msg, -            "chat_msg": chat_msg -        } +        self.games[ctx.author.id] = Game( +            board=board, +            revealed=revealed_board, +            dm_msg=dm_msg, +            chat_msg=chat_msg +        ) -    async def reload_board(self, ctx: commands.Context) -> None: +    async def update_boards(self, ctx: commands.Context) -> None:          """Update both playing boards."""          game = self.games[ctx.author.id] -        await game["dm_msg"].delete() -        game["dm_msg"] = await ctx.author.send(self.format_for_discord(game["revealed"])) -        await game["chat_msg"].edit(content=self.format_for_discord(game["revealed"])) +        await game.dm_msg.delete() +        game.dm_msg = await ctx.author.send(self.format_for_discord(game.revealed)) +        await game.chat_msg.edit(content=self.format_for_discord(game.revealed))      @commands.dm_only()      @commands.command(name="flag") -    async def flag_command(self, ctx: commands.Context, *cords: CordConverter) -> None: +    async def flag_command(self, ctx: commands.Context, *Coordinates: CoordinateConverter) -> None:          """Place multiple flags on the board""" -        board: GameBoard = self.games[ctx.author.id]["revealed"] -        for x, y in cords: +        board: GameDict = self.games[ctx.author.id].revealed +        for x, y in Coordinates:              if board[y][x] == "hidden":                  board[y][x] = "flag" -        await self.reload_board(ctx) +        await self.update_boards(ctx)      async def lost(self, ctx: commands.Context) -> None:          """The player lost the game"""          game = self.games[ctx.author.id] -        game["revealed"] = game["board"] -        await self.reload_board(ctx) -        await ctx.author.send(":fire: You lost :fire: ") -        await game["chat_msg"].channel.send(f":fire: {ctx.author.mention} just lost minesweeper :fire:") -        del self.games[ctx.author.id] +        game.revealed = game.board +        await ctx.author.send(":fire: You lost! :fire: ") +        await game.chat_msg.channel.send(f":fire: {ctx.author.mention} just lost minesweeper :fire:")      async def won(self, ctx: commands.Context) -> None:          """The player won the game"""          game = self.games[ctx.author.id] -        game["revealed"] = game["board"] -        await self.reload_board(ctx) +        game.revealed = game.board          await ctx.author.send(":tada:  You won! :tada: ") -        await game["chat_msg"].channel.send(f":tada: {ctx.author.mention} just won minesweeper :tada:") -        del self.games[ctx.author.id] - -    def reveal_zeros(self, revealed: GameBoard, board: GameBoard, x: int, y: int) -> None: -        """Used when a 0 is encountered to do a flood fill""" -        for x_ in [x - 1, x, x + 1]: -            for y_ in [y - 1, y, y + 1]: -                if x_ == -1 or x_ == 10 or y_ == -1 or y_ == 10 or revealed[y_][x_] != "hidden": -                    continue -                revealed[y_][x_] = board[y_][x_] -                if board[y_][x_] == 0: -                    self.reveal_zeros(revealed, board, x_, y_) - -    async def check_if_won(self, ctx, revealed: GameBoard, board: GameBoard) -> bool: +        await game.chat_msg.channel.send(f":tada: {ctx.author.mention} just won minesweeper :tada:") + +    def reveal_zeros(self, revealed: GameDict, board: GameDict, x: int, y: int) -> None: +        """Recursively reveal adjacent cells when a 0 cell is encountered.""" +        for x_, y_ in self.get_neighbours(x, y): +            if revealed[y_][x_] != "hidden": +                continue +            revealed[y_][x_] = board[y_][x_] +            if board[y_][x_] == 0: +                self.reveal_zeros(revealed, board, x_, y_) + +    async def check_if_won(self, ctx, revealed: GameDict, board: GameDict) -> bool:          """Checks if a player has won""" -        for x_ in range(10): -            for y_ in range(10): -                if revealed[y_][x_] == "hidden" and board[y_][x_] != "bomb": -                    return True +        for x in range(10): +            for y in range(10): +                if revealed[y][x] == "hidden" and board[y][x] != "bomb": +                    return False          else:              await self.won(ctx) -            return False +            return True -    async def reveal_one(self, ctx: commands.Context, revealed: GameBoard, board: GameBoard, x: int, y: int) -> bool: +    async def reveal_one(self, ctx: commands.Context, revealed: GameDict, board: GameDict, x: int, y: int) -> bool:          """Reveal one square."""          revealed[y][x] = board[y][x]          if board[y][x] == "bomb":              await self.lost(ctx) -            return False +            return True  # game ended          elif board[y][x] == 0:              self.reveal_zeros(revealed, board, x, y) -        return await self.check_if_won(ctx, revealed, board) +        won = await self.check_if_won(ctx, revealed, board) +        return won      @commands.dm_only()      @commands.command(name="reveal") -    async def reveal_command(self, ctx: commands.Context, *cords: CordConverter) -> None: +    async def reveal_command(self, ctx: commands.Context, *Coordinates: CoordinateConverter) -> None:          """Reveal multiple cells"""          game = self.games[ctx.author.id] -        revealed: GameBoard = game["revealed"] -        board: GameBoard = game["board"] - -        reload_board = True -        for x, y in cords: -            if not await self.reveal_one(ctx, revealed, board, x, y): -                reload_board = False -        if reload_board: -            await self.reload_board(ctx) +        revealed: GameDict = game.revealed +        board: GameDict = game.board + +        for x, y in Coordinates: +            if await self.reveal_one(ctx, revealed, board, x, y):  # game ended +                await self.update_boards(ctx) +                del self.games[ctx.author.id] +                break +        else: +            await self.update_boards(ctx)      @commands.command(name="end")      async def end_command(self, ctx: commands.Context):          """End the current game"""          game = self.games[ctx.author.id] -        game["revealed"] = game["board"] -        await self.reload_board(ctx) +        game.revealed = game.board +        await self.update_boards(ctx)          await ctx.author.send(":no_entry: you canceled the game :no_entry:") -        await game["chat_msg"].channel.send(f"{ctx.author.mention} just canceled minesweeper") +        await game.chat_msg.channel.send(f"{ctx.author.mention} just canceled minesweeper")          del self.games[ctx.author.id]  def setup(bot: commands.Bot) -> None: -    """Cog load.""" +    """Load the Minesweeper cog."""      bot.add_cog(Minesweeper(bot)) +    log.info("minesweeper cog loaded") | 
