diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Dockerfile | 2 | ||||
| -rw-r--r-- | Pipfile | 2 | ||||
| -rw-r--r-- | Pipfile.lock | 52 | ||||
| -rw-r--r-- | azure-pipelines.yml | 4 | ||||
| -rw-r--r-- | bot/seasons/evergreen/battleship.py | 444 | 
6 files changed, 454 insertions, 51 deletions
@@ -1,5 +1,6 @@  # bot (project-specific)  log/* +data/* @@ -1,4 +1,4 @@ -FROM python:3.7-slim +FROM python:3.8-slim  # Set pip to have cleaner logs and no saved cache  ENV PIP_NO_CACHE_DIR=false \ @@ -25,7 +25,7 @@ pep8-naming = "~=0.9"  pre-commit = "~=2.1"  [requires] -python_version = "3.7" +python_version = "3.8"  [scripts]  start = "python -m bot" diff --git a/Pipfile.lock b/Pipfile.lock index 659a046c..426514e5 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,11 +1,11 @@  {      "_meta": {          "hash": { -            "sha256": "4cd9801f890f8087b7a46b239264e6b09d4c29c35223118add96bed0af22b475" +            "sha256": "b117417a1dbcc28039ecac9579d54efa6437c621f0132eb06a8aa4f990d30a00"          },          "pipfile-spec": 6,          "requires": { -            "python_version": "3.7" +            "python_version": "3.8"          },          "sources": [              { @@ -431,14 +431,6 @@              ],              "version": "==1.4.11"          }, -        "importlib-metadata": { -            "hashes": [ -                "sha256:06f5b3a99029c7134207dd882428a66992a9de2bef7c2b699b5641f9886c3302", -                "sha256:b97607a1a18a5100839aec1dc26a1ea17ee0d93b20b0f008d80a5a050afb200b" -            ], -            "markers": "python_version < '3.8'", -            "version": "==1.5.0" -        },          "mccabe": {              "hashes": [                  "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", @@ -526,46 +518,12 @@              ],              "version": "==0.10.0"          }, -        "typed-ast": { -            "hashes": [ -                "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", -                "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", -                "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", -                "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", -                "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", -                "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", -                "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", -                "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", -                "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", -                "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", -                "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", -                "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", -                "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", -                "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", -                "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", -                "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", -                "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", -                "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", -                "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", -                "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", -                "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" -            ], -            "markers": "python_version < '3.8'", -            "version": "==1.4.1" -        },          "virtualenv": {              "hashes": [ -                "sha256:0c04c7e8e0314470b4c2b43740ff68be1c62bb3fdef8309341ff1daea60d49d1", -                "sha256:1f0369d068d9761b5c1ed7b44dad1ec124727eb10bc7f4aaefbba0cdca3bd924" +                "sha256:5eba85dfa176fde0425b9b3042ed83f05a1b6309a616b8a3e2a9a94f4bfa27b7", +                "sha256:99f131be2f90ff2a8fd711261a27845b6c50fc008bef815e710c7fa844eb1467"              ], -            "version": "==20.0.8" -        }, -        "zipp": { -            "hashes": [ -                "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", -                "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" -            ], -            "version": "==3.1.0" +            "version": "==20.0.9"          }      }  } diff --git a/azure-pipelines.yml b/azure-pipelines.yml index d56261a6..687fdc1e 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -5,7 +5,7 @@ jobs:      displayName: 'Lint & Test'      pool: -      vmImage: 'Ubuntu 16.04' +      vmImage: 'Ubuntu 18.04'      variables:        PIP_CACHE_DIR: ".cache/pip" @@ -22,7 +22,7 @@ jobs:          displayName: 'Set Python version'          name: PythonVersion          inputs: -          versionSpec: '3.7.x' +          versionSpec: '3.8.x'            addToPath: true        - script: pip3 install pipenv 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")  |