aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--Dockerfile2
-rw-r--r--Pipfile2
-rw-r--r--Pipfile.lock52
-rw-r--r--azure-pipelines.yml4
-rw-r--r--bot/seasons/evergreen/battleship.py444
6 files changed, 454 insertions, 51 deletions
diff --git a/.gitignore b/.gitignore
index d95585b7..d3d2bb8d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
# bot (project-specific)
log/*
+data/*
diff --git a/Dockerfile b/Dockerfile
index 940f90d5..b8a8a1e4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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 \
diff --git a/Pipfile b/Pipfile
index 0c4b2353..1c260a69 100644
--- a/Pipfile
+++ b/Pipfile
@@ -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")