diff options
| author | 2020-03-21 14:30:06 +0100 | |
|---|---|---|
| committer | 2020-03-21 14:30:06 +0100 | |
| commit | 561b95e59c5ba4f358010fc0143ae291fcacabf2 (patch) | |
| tree | d02a0042a3961b9143dce181bd4028fec7d7eb7e /bot/seasons/evergreen/battleship.py | |
| parent | Deseasonify: define all seasons in one place (diff) | |
| parent | Merge pull request #380 from python-discord/gitignore-data (diff) | |
Merge master into seasonal-purge
Most importantly this adds the Python 3.8 bump, plus unrelated features
merged in the meantime.
Diffstat (limited to 'bot/seasons/evergreen/battleship.py')
| -rw-r--r-- | bot/seasons/evergreen/battleship.py | 444 | 
1 files changed, 444 insertions, 0 deletions
| diff --git a/bot/seasons/evergreen/battleship.py b/bot/seasons/evergreen/battleship.py new file mode 100644 index 00000000..9b8aaa48 --- /dev/null +++ b/bot/seasons/evergreen/battleship.py @@ -0,0 +1,444 @@ +import asyncio +import logging +import random +import re +import typing +from dataclasses import dataclass +from functools import partial + +import discord +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + + +@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 + + +Grid = typing.List[typing.List[Square]] +EmojiSet = typing.Dict[typing.Tuple[bool, bool], str] + + +@dataclass +class Player: +    """Each player in the game - their messages for the boards and their current grid.""" + +    user: discord.Member +    board: discord.Message +    opponent_board: discord.Message +    grid: Grid + + +# The name of the ship and its size +SHIPS = { +    "Carrier": 5, +    "Battleship": 4, +    "Cruiser": 3, +    "Submarine": 3, +    "Destroyer": 2, +} + + +# For these two variables, the first boolean is whether the square is a ship (True) or not (False). +# The second boolean is whether the player has aimed for that square (True) or not (False) + +# This is for the player's own board which shows the location of their own ships. +SHIP_EMOJIS = { +    (True, True): ":fire:", +    (True, False): ":ship:", +    (False, True): ":anger:", +    (False, False): ":ocean:", +} + +# This is for the opposing player's board which only shows aimed locations. +HIDDEN_EMOJIS = { +    (True, True): ":red_circle:", +    (True, False): ":black_circle:", +    (False, True): ":white_circle:", +    (False, False): ":black_circle:", +} + +# For the top row of the board +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:" +) + +# For the first column of the board +NUMBERS = [ +    ":one:", +    ":two:", +    ":three:", +    ":four:", +    ":five:", +    ":six:", +    ":seven:", +    ":eight:", +    ":nine:", +    ":keycap_ten:", +] + +CROSS_EMOJI = "\u274e" +HAND_RAISED_EMOJI = "\U0001f64b" + + +class Game: +    """A Battleship Game.""" + +    def __init__( +        self, +        bot: commands.Bot, +        channel: discord.TextChannel, +        player1: discord.Member, +        player2: discord.Member +    ) -> None: + +        self.bot = bot +        self.public_channel = channel + +        self.p1 = Player(player1, None, None, self.generate_grid()) +        self.p2 = Player(player2, None, None, self.generate_grid()) + +        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.surrender: bool = False + +        self.setup_grids() + +    @staticmethod +    def generate_grid() -> Grid: +        """Generates a grid by instantiating the Squares.""" +        return [[Square(None, False) for _ in range(10)] for _ in range(10)] + +    @staticmethod +    def format_grid(player: Player, emojiset: EmojiSet) -> str: +        """ +        Gets and formats the grid as a list into a string to be output to the DM. + +        Also adds the Letter and Number indexes. +        """ +        grid = [ +            [emojiset[bool(square.boat), square.aimed] for square in row] +            for row in player.grid +        ] + +        rows = ["".join([number] + row) for number, row in zip(NUMBERS, grid)] +        return "\n".join([LETTERS] + rows) + +    @staticmethod +    def get_square(grid: Grid, square: str) -> Square: +        """Grabs a square from a grid with an inputted key.""" +        index = ord(square[0]) - ord("A") +        number = int(square[1:]) + +        return grid[number-1][index]  # -1 since lists are indexed from 0 + +    async def game_over( +        self, +        *, +        winner: discord.Member, +        loser: discord.Member +    ) -> None: +        """Removes games from list of current games and announces to public chat.""" +        await self.public_channel.send(f"Game Over! {winner.mention} won against {loser.mention}") + +        for player in (self.p1, self.p2): +            grid = self.format_grid(player, SHIP_EMOJIS) +            await self.public_channel.send(f"{player.user}'s Board:\n{grid}") + +    @staticmethod +    def check_sink(grid: Grid, 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) -> 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.p1, self.p2): +            for name, size in SHIPS.items(): +                while True:  # Repeats if about to overwrite another boat +                    ship_collision = False +                    coords = [] + +                    coord1 = random.randint(0, 9) +                    coord2 = random.randint(0, 10 - size) + +                    if random.choice((True, False)):  # Vertical or Horizontal +                        x, y = coord1, coord2 +                        xincr, yincr = 0, 1 +                    else: +                        x, y = coord2, coord1 +                        xincr, yincr = 1, 0 + +                    for i in range(size): +                        new_x = x + (xincr * i) +                        new_y = y + (yincr * i) +                        if player.grid[new_x][new_y].boat:  # Check if there's already a boat +                            ship_collision = True +                            break +                        coords.append((new_x, new_y)) +                    if not ship_collision:  # If not overwriting any other boat spaces, break loop +                        break + +                for x, y in coords: +                    player.grid[x][y].boat = name + +    async def print_grids(self) -> None: +        """Prints grids to the DM channels.""" +        # Convert squares into Emoji + +        boards = [ +            self.format_grid(player, emojiset) +            for emojiset in (HIDDEN_EMOJIS, SHIP_EMOJIS) +            for player in (self.p1, self.p2) +        ] + +        locations = ( +            (self.p2, "opponent_board"), (self.p1, "opponent_board"), +            (self.p1, "board"), (self.p2, "board") +        ) + +        for board, location in zip(boards, locations): +            player, attr = location +            if getattr(player, attr): +                await getattr(player, attr).edit(content=board) +            else: +                setattr(player, attr, await player.user.send(board)) + +    def predicate(self, message: discord.Message) -> bool: +        """Predicate checking the message typed for each turn.""" +        if message.author == self.turn.user and message.channel == self.turn.user.dm_channel: +            if message.content.lower() == "surrender": +                self.surrender = True +                return True +            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(CROSS_EMOJI)) +            return bool(self.match) + +    async def take_turn(self) -> typing.Optional[Square]: +        """Lets the player who's turn it is choose a square.""" +        square = None +        turn_message = await self.turn.user.send( +            "It's your turn! Type the square you want to fire at. Format it like this: A1\n" +            "Type `surrender` to give up" +        ) +        await self.next.user.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.user.send("You took too long. Game over!") +                await self.next.user.send(f"{self.turn.user} took too long. Game over!") +                await self.public_channel.send( +                    f"Game over! {self.turn.user.mention} timed out so {self.next.user.mention} wins!" +                ) +                self.gameover = True +                break +            else: +                if self.surrender: +                    await self.next.user.send(f"{self.turn.user} surrendered. Game over!") +                    await self.public_channel.send( +                        f"Game over! {self.turn.user.mention} surrendered to {self.next.user.mention}!" +                    ) +                    self.gameover = True +                    break +                square = self.get_square(self.next.grid, self.match.string) +                if square.aimed: +                    await self.turn.user.send("You've already aimed at this square!", delete_after=3.0) +                else: +                    break +        await turn_message.delete() +        return square + +    async def hit(self, square: Square, alert_messages: typing.List[discord.Message]) -> None: +        """Occurs when a player successfully aims for a ship.""" +        await self.turn.user.send("Hit!", delete_after=3.0) +        alert_messages.append(await self.next.user.send("Hit!")) +        if self.check_sink(self.next.grid, square.boat): +            await self.turn.user.send(f"You've sunk their {square.boat} ship!", delete_after=3.0) +            alert_messages.append(await self.next.user.send(f"Oh no! Your {square.boat} ship sunk!")) +            if self.check_gameover(self.next.grid): +                await self.turn.user.send("You win!") +                await self.next.user.send("You lose!") +                self.gameover = True +                await self.game_over(winner=self.turn.user, loser=self.next.user) + +    async def start_game(self) -> None: +        """Begins the game.""" +        await self.p1.user.send(f"You're playing battleship with {self.p2.user}.") +        await self.p2.user.send(f"You're playing battleship with {self.p1.user}.") + +        alert_messages = [] + +        self.turn = self.p1 +        self.next = self.p2 + +        while True: +            await self.print_grids() + +            if self.gameover: +                return + +            square = await self.take_turn() +            if not square: +                return +            square.aimed = True + +            for message in alert_messages: +                await message.delete() + +            alert_messages = [] +            alert_messages.append(await self.next.user.send(f"{self.turn.user} aimed at {self.match.string}!")) + +            if square.boat: +                await self.hit(square, alert_messages) +                if self.gameover: +                    return +            else: +                await self.turn.user.send("Miss!", delete_after=3.0) +                alert_messages.append(await self.next.user.send("Miss!")) + +            self.turn, self.next = self.next, self.turn + + +class Battleship(commands.Cog): +    """Play the classic game Battleship!""" + +    def __init__(self, bot: commands.Bot) -> None: +        self.bot = bot +        self.games: typing.List[Game] = [] +        self.waiting: typing.List[discord.Member] = [] + +    def predicate( +        self, +        ctx: commands.Context, +        announcement: discord.Message, +        reaction: discord.Reaction, +        user: discord.Member +    ) -> bool: +        """Predicate checking the criteria for the announcement message.""" +        if self.already_playing(ctx.author):  # If they've joined a game since requesting a player 2 +            return True  # Is dealt with later on +        if ( +            user.id not in (ctx.me.id, ctx.author.id) +            and str(reaction.emoji) == HAND_RAISED_EMOJI +            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!")) +                self.bot.loop.create_task(announcement.remove_reaction(reaction, user)) +                return False + +            if user in self.waiting: +                self.bot.loop.create_task(ctx.send( +                    f"{user.mention} Please cancel your game first before joining another one." +                )) +                self.bot.loop.create_task(announcement.remove_reaction(reaction, user)) +                return False + +            return True + +        if ( +            user.id == ctx.author.id +            and str(reaction.emoji) == CROSS_EMOJI +            and reaction.message.id == announcement.id +        ): +            return True +        return False + +    def already_playing(self, player: discord.Member) -> bool: +        """Check if someone is already in a game.""" +        return any(player in (game.p1.user, game.p2.user) for game in self.games) + +    @commands.group(invoke_without_command=True) +    @commands.guild_only() +    async def battleship(self, ctx: commands.Context) -> None: +        """ +        Play a game of Battleship 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( +            "**Battleship**: A new game is about to start!\n" +            f"Press {HAND_RAISED_EMOJI} to play against {ctx.author.mention}!\n" +            f"(Cancel the game with {CROSS_EMOJI}.)" +        ) +        self.waiting.append(ctx.author) +        await announcement.add_reaction(HAND_RAISED_EMOJI) +        await announcement.add_reaction(CROSS_EMOJI) + +        try: +            reaction, user = await self.bot.wait_for( +                "reaction_add", +                check=partial(self.predicate, ctx, announcement), +                timeout=60.0 +            ) +        except asyncio.TimeoutError: +            self.waiting.remove(ctx.author) +            await announcement.delete() +            return await ctx.send(f"{ctx.author.mention} Seems like there's no one here to play...") + +        if str(reaction.emoji) == CROSS_EMOJI: +            self.waiting.remove(ctx.author) +            await announcement.delete() +            return await ctx.send(f"{ctx.author.mention} Game cancelled.") + +        await announcement.delete() +        self.waiting.remove(ctx.author) +        if self.already_playing(ctx.author): +            return +        try: +            game = Game(self.bot, ctx.channel, ctx.author, user) +            self.games.append(game) +            await game.start_game() +            self.games.remove(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: +            # End the game in the event of an unforseen error so the players aren't stuck in a game +            await ctx.send(f"{ctx.author.mention} {user.mention} An error occurred. Game failed") +            self.games.remove(game) +            raise + +    @battleship.command(name="ships", aliases=["boats"]) +    async def battleship_ships(self, ctx: commands.Context) -> None: +        """Lists the ships that are found on the battleship grid.""" +        embed = discord.Embed(colour=Colours.blue) +        embed.add_field(name="Name", value="\n".join(SHIPS)) +        embed.add_field(name="Size", value="\n".join(str(size) for size in SHIPS.values())) +        await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: +    """Cog load.""" +    bot.add_cog(Battleship(bot)) +    log.info("Battleship cog loaded") | 
