diff options
Diffstat (limited to 'bot')
| -rw-r--r-- | bot/seasons/evergreen/battleship.py | 359 | 
1 files changed, 359 insertions, 0 deletions
| diff --git a/bot/seasons/evergreen/battleship.py b/bot/seasons/evergreen/battleship.py new file mode 100644 index 00000000..3e8ea3a7 --- /dev/null +++ b/bot/seasons/evergreen/battleship.py @@ -0,0 +1,359 @@ +import asyncio +import random +import re +import typing +from dataclasses import dataclass + +import discord +from discord.ext import commands + +from bot.constants import Colours + + +@dataclass +class Square: +    """Each square on the battleship grid - if they contain a boat and if they've been aimed at""" + +    boat: typing.Optional[str] +    aimed: bool + + +ships = { +    "Carrier": 5, +    "Battleship": 4, +    "Cruiser": 3, +    "Submarine": 3, +    "Destroyer": 2, +} + +ship_emojis = { +    (True, True): ":fire:", +    (True, False): ":ship:", +    (False, True): ":anger:", +    (False, False): ":ocean:", +} +hidden_emojis = { +    (True, True): ":red_circle:", +    (True, False): ":black_circle:", +    (False, True): ":white_circle:", +    (False, False): ":black_circle:", +} + +letters = ( +    ":stop_button::regional_indicator_a::regional_indicator_b::regional_indicator_c::regional_indicator_d:" +    ":regional_indicator_e::regional_indicator_f::regional_indicator_g::regional_indicator_h:" +    ":regional_indicator_i::regional_indicator_j:" +) + +numbers = [ +    ":one:", +    ":two:", +    ":three:", +    ":four:", +    ":five:", +    ":six:", +    ":seven:", +    ":eight:", +    ":nine:", +    ":keycap_ten:", +] + +grid_typehint = typing.List[typing.List[Square]] + + +class Game: +    """A Battleship Game.""" + +    def __init__(self, bot: commands.Bot, player1: discord.Member, player2: discord.Member) -> None: + +        self.bot = bot +        self.player1 = player1 +        self.player2 = player2 + +        # Message containing Player 1's Own Board +        self.self_player1: typing.Optional[discord.Message] = None + +        # Message containing Player 2's Board Displayed in Player 1's DMs +        self.other_player1: typing.Optional[discord.Message] = None + +        # Message containing Player 2's Own Board +        self.self_player2: typing.Optional[discord.Message] = None + +        # Message containing Player 1's Board Displayed in Player 2's DMs +        self.other_player2: typing.Optional[discord.Message] = None + +        self.grids: typing.Dict[discord.Member, grid_typehint] = {} +        self.grids[self.player1] = [ +            [Square(boat=None, aimed=False) for _ in range(10)] for _ in range(10) +        ] +        self.grids[self.player2] = [ +            [Square(boat=None, aimed=False) for _ in range(10)] for _ in range(10) +        ] + +        self.gameover: bool = False + +        self.turn: typing.Optional[discord.Member] = None +        self.next: typing.Optional[discord.Member] = None + +        self.match: typing.Optional[typing.Match] = None + +        self.setup_grids() + +    @staticmethod +    def format_grid(grid: grid_typehint) -> str: +        """Formats the grid as a list into a string to be output to the DM. Also adds the Letter and Number indexes.""" +        rows = ["".join([number] + row) for number, row in zip(numbers, grid)] +        return "\n".join([letters] + rows) + +    @staticmethod +    def get_square(grid: grid_typehint, square: str) -> Square: +        """Grabs a square from a grid with an inputted key.""" +        index = {"A": 0, "B": 1, "C": 2, "D": 3, "E": 4, "F": 5, "G": 6, "H": 7, "I": 8, "J": 9} +        letter = square[0] +        number = int(square[1:]) + +        return grid[number-1][index[letter]]  # -1 since lists are indexed from 0 + +    def game_over(self) -> None: +        """Removes games from list of current games.""" +        self.bot.get_cog("Battleship").games.remove(self) + +    @staticmethod +    def check_sink(grid: grid_typehint, boat: str) -> bool: +        """Checks if all squares containing a given boat have sunk.""" +        return all(square.aimed for row in grid for square in row if square.boat == boat) + +    @staticmethod +    def check_gameover(grid: grid_typehint) -> bool: +        """Checks if all boats have been sunk.""" +        return all(square.aimed for row in grid for square in row if square.boat) + +    def setup_grids(self) -> None: +        """Places the boats on the grids to initialise the game.""" +        for player in (self.player1, self.player2): +            for name, size in ships.items(): +                while True:  # Repeats if about to overwrite another boat +                    overwrite = False +                    coords = [] +                    if random.choice((True, False)):  # Vertical or Horizontal +                        # Vertical +                        x_coord = random.randint(0, 9) +                        y_coord = random.randint(0, 9 - size) +                        for i in range(size): +                            if self.grids[player][x_coord][y_coord + i].boat:  # Check if there's already a boat +                                overwrite = True +                            coords.append((x_coord, y_coord + i)) +                    else: +                        # Horizontal +                        x_coord = random.randint(0, 9 - size) +                        y_coord = random.randint(0, 9) +                        for i in range(size): +                            if self.grids[player][x_coord + i][y_coord].boat:  # Check if there's already a boat +                                overwrite = True +                            coords.append((x_coord + i, y_coord)) +                    if not overwrite:  # If not overwriting any other boat spaces, break loop +                        break + +                for x, y in coords: +                    self.grids[player][x][y].boat = name + +    async def print_grids(self) -> None: +        """Prints grids to the DM channels.""" +        # Convert squares into Emoji + +        # Player 1's Grid +        self_grid_1 = self.format_grid([ +            [ship_emojis[bool(square.boat), square.aimed] for square in row] +            for row in self.grids[self.player1] +        ]) + +        # Player 2's Grid hidden for Player 1 +        other_grid_1 = self.format_grid([ +            [hidden_emojis[bool(square.boat), square.aimed] for square in row] +            for row in self.grids[self.player2] +        ]) + +        # Player 2's Grid +        self_grid_2 = self.format_grid([ +            [ship_emojis[bool(square.boat), square.aimed] for square in row] +            for row in self.grids[self.player2] +        ]) + +        # Player 1's Grid hidden for Player 2 +        other_grid_2 = self.format_grid([ +            [hidden_emojis[bool(square.boat), square.aimed] for square in row] +            for row in self.grids[self.player1] +        ]) + +        if self.self_player1:  # If messages already exist +            await self.self_player1.edit(content=self_grid_1) +            await self.other_player1.edit(content=other_grid_1) +            await self.self_player2.edit(content=self_grid_2) +            await self.other_player2.edit(content=other_grid_2) +        else: +            self.self_player1 = await self.player1.send(self_grid_1) +            self.other_player1 = await self.player1.send(other_grid_1) +            self.self_player2 = await self.player2.send(self_grid_2) +            self.other_player2 = await self.player2.send(other_grid_2) + +    def predicate(self, message: discord.Message) -> bool: +        """Predicate checking the message typed for each turn.""" +        if message.author == self.turn and message.channel == self.turn.dm_channel: +            self.match = re.match("([A-J]|[a-j]) ?((10)|[1-9])", message.content.strip()) +            if not self.match: +                self.bot.loop.create_task(message.add_reaction("\u274e")) +            return bool(self.match) + +    async def start_game(self) -> None: +        """Begins the game.""" +        await self.player1.send(f"You're playing battleships with {self.player2}.") +        await self.player2.send(f"You're playing battleships with {self.player1}.") + +        alert_messages = [] + +        self.turn = self.player1 +        self.next = self.player2 + +        while True: +            await self.print_grids() + +            turn_message = await self.turn.send( +                "It's your turn! Type the square you want to fire at. Format it like this: A1" +            ) +            await self.next.send("Their turn", delete_after=3.0) +            while True: +                try: +                    await self.bot.wait_for("message", check=self.predicate, timeout=60.0) +                except asyncio.TimeoutError: +                    await self.turn.send("You took too long. Game over!") +                    await self.next.send(f"{self.turn} took too long. Game over!") +                    self.gameover = True +                    break +                else: +                    square = self.get_square(self.grids[self.next], self.match.string) +                    if square.aimed: +                        await self.turn.send("You've already aimed at this square!", delete_after=3.0) +                    else: +                        break + +            if self.gameover: +                self.game_over() +                break + +            square.aimed = True +            await turn_message.delete() +            for message in alert_messages: +                await message.delete() + +            alert_messages = [] +            alert_messages.append(await self.next.send(f"{self.turn} aimed at {self.match.string}!")) + +            if square.boat: +                await self.turn.send("Hit!", delete_after=3.0) +                alert_messages.append(await self.next.send("Hit!")) +                if self.check_sink(self.grids[self.next], square.boat): +                    await self.turn.send(f"You've sunk their {square.boat} ship!", delete_after=3.0) +                    alert_messages.append(await self.next.send(f"Oh no! Your {square.boat} ship sunk!")) +                    if self.check_gameover(self.grids[self.next]): +                        await self.turn.send("You win!") +                        await self.next.send("You lose!") +                        self.gameover = True +                        self.game_over() +                        break +            else: +                await self.turn.send("Miss!", delete_after=3.0) +                alert_messages.append(await self.next.send("Miss!")) + +            self.turn, self.next = self.next, self.turn + + +class Battleship(commands.Cog): +    """Play the classic game Battleships!""" + +    def __init__(self, bot: commands.Bot) -> None: +        self.bot = bot +        self.games: typing.List[Game] = [] +        self.waiting: typing.List[discord.Member] = [] + +    def already_playing(self, player: discord.Member) -> bool: +        """Check if someone is already in a game.""" +        return player in [getattr(game, x) for game in self.games for x in ("player1", "player2")] + +    @commands.group(invoke_without_command=True) +    @commands.guild_only() +    async def battleship(self, ctx: commands.Context) -> None: +        """ +        Play a game of Battleships with someone else! + +        This will set up a message waiting for someone else to react and play along. +        The game takes place entirely in DMs. +        Make sure you have your DMs open so that the bot can message you. +        """ +        if self.already_playing(ctx.author): +            return await ctx.send("You're already playing a game!") + +        if ctx.author in self.waiting: +            return await ctx.send("You've already sent out a request for a player 2") + +        announcement = await ctx.send( +            "**Battleships**: A new game is about to start!\n" +            f"Press :raising_hand: to play against {ctx.author.mention}!" +        ) +        self.waiting.append(ctx.author) +        await announcement.add_reaction("\U0001f64b") + +        def predicate(reaction: discord.Reaction, user: discord.Member): +            if self.already_playing(ctx.author):  # If they've joined a game since requesting a player 2 +                raise asyncio.TimeoutError +            if ( +                user.id not in [ctx.me.id, ctx.author.id] +                and str(reaction.emoji) == "\U0001f64b" +                and reaction.message.id == announcement.id +            ): +                if self.already_playing(user): +                    self.bot.loop.create_task(ctx.send(f"{user.mention} You're already playing a game!")) +                    return False +                return True +            return False + +        try: +            _reaction, user = await self.bot.wait_for("reaction_add", check=predicate, timeout=60.0) +        except asyncio.TimeoutError: +            self.waiting.remove(ctx.author) +            await announcement.delete() +            if self.already_playing(ctx.author): +                return +            return await ctx.send(f"{ctx.author.mention} Seems like there's noone here to play...") +        else: +            await announcement.delete() +            self.waiting.remove(ctx.author) +            try: +                if self.already_playing(ctx.author): +                    return +                game = Game(self.bot, ctx.author, user) +                self.games.append(game) +                await game.start_game() +            except discord.Forbidden: +                await ctx.send( +                    f"{ctx.author.mention} {user.mention}" +                    "Game failed. This is likely due to you not having your DMs open. Check and try again." +                ) +                self.games.remove(game) +            except Exception: +                # Unforseen error so the players aren't stuck in a game +                await ctx.send(f"{ctx.author.mention} {user.mention} An error occured. Game failed") +                self.games.remove(game) +                raise + +    @battleship.command(name="ships", aliases=["boats"]) +    async def battleship_ships(self, ctx: commands.Context) -> None: +        """This lists the ships that are found on the battleship grid.""" +        embed = discord.Embed(colour=Colours.blue) +        embed.add_field(name="Name", value="Carrier\nBattleship\nCruiser\nSubmarine\nDestroyer") +        embed.add_field(name="Size", value="5\n4\n3\n3\n2") +        await ctx.send(embed=embed) + + +def setup(bot: commands.Bot): +    """Cog load.""" +    bot.add_cog(Battleship(bot)) | 
