From ec4d86c823ebdf590583097418bcdf1ae1b5176f Mon Sep 17 00:00:00 2001 From: Suhail Date: Mon, 5 Aug 2019 18:38:49 +0100 Subject: Battleships Game --- bot/seasons/evergreen/battleship.py | 359 ++++++++++++++++++++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 bot/seasons/evergreen/battleship.py (limited to 'bot') 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)) -- cgit v1.2.3 From 254113c929b16e3add4be6af6a0fac35f55c9a2d Mon Sep 17 00:00:00 2001 From: Suhail Date: Mon, 5 Aug 2019 20:04:24 +0100 Subject: Post results and boards to initial channel --- bot/seasons/evergreen/battleship.py | 40 +++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) (limited to 'bot') diff --git a/bot/seasons/evergreen/battleship.py b/bot/seasons/evergreen/battleship.py index 3e8ea3a7..70d9a520 100644 --- a/bot/seasons/evergreen/battleship.py +++ b/bot/seasons/evergreen/battleship.py @@ -64,9 +64,16 @@ 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: + def __init__( + self, + bot: commands.Bot, + channel: discord.TextChannel, + player1: discord.Member, + player2: discord.Member + ) -> None: self.bot = bot + self.public_channel = channel self.player1 = player1 self.player2 = player2 @@ -114,8 +121,29 @@ class Game: 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.""" + async def game_over( + self, + *, + timeout: bool = False, + winner: typing.Optional[discord.Member] = None, + loser: typing.Optional[discord.Member] = None + ) -> None: + """Removes games from list of current games and announces to public chat.""" + if not timeout: # If someone won and not the game timed out + await self.public_channel.send(f"Game Over! {winner.mention} won against {loser.mention}") + self_grid_1 = self.format_grid([ + [ship_emojis[bool(square.boat), square.aimed] for square in row] + for row in self.grids[self.player1] + ]) + + self_grid_2 = self.format_grid([ + [ship_emojis[bool(square.boat), square.aimed] for square in row] + for row in self.grids[self.player2] + ]) + + await self.public_channel.send(f"{self.player1}'s Board:\n{self_grid_1}") + await self.public_channel.send(f"{self.player2}'s Board:\n{self_grid_2}") + self.bot.get_cog("Battleship").games.remove(self) @staticmethod @@ -237,7 +265,7 @@ class Game: break if self.gameover: - self.game_over() + await self.game_over(timeout=True) break square.aimed = True @@ -258,7 +286,7 @@ class Game: await self.turn.send("You win!") await self.next.send("You lose!") self.gameover = True - self.game_over() + await self.game_over(winner=self.turn, loser=self.next) break else: await self.turn.send("Miss!", delete_after=3.0) @@ -330,7 +358,7 @@ class Battleship(commands.Cog): try: if self.already_playing(ctx.author): return - game = Game(self.bot, ctx.author, user) + game = Game(self.bot, ctx.channel, ctx.author, user) self.games.append(game) await game.start_game() except discord.Forbidden: -- cgit v1.2.3 From edeb2a19d2884b7e0041fa00df0a6c0682677b40 Mon Sep 17 00:00:00 2001 From: Suhail Date: Fri, 7 Feb 2020 00:20:12 +0000 Subject: Applied suggestions from code review for Battleships --- bot/seasons/evergreen/battleship.py | 408 ++++++++++++++++++------------------ 1 file changed, 209 insertions(+), 199 deletions(-) (limited to 'bot') diff --git a/bot/seasons/evergreen/battleship.py b/bot/seasons/evergreen/battleship.py index 70d9a520..9bca2bfc 100644 --- a/bot/seasons/evergreen/battleship.py +++ b/bot/seasons/evergreen/battleship.py @@ -1,51 +1,73 @@ 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""" + """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 = { +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 + + +SHIPS = { "Carrier": 5, "Battleship": 4, "Cruiser": 3, "Submarine": 3, "Destroyer": 2, -} +} # The name of the ship and its size + -ship_emojis = { +# 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) +SHIP_EMOJIS = { (True, True): ":fire:", (True, False): ":ship:", (False, True): ":anger:", (False, False): ":ocean:", -} -hidden_emojis = { +} # This is for the player's own board which shows the location of their own ships. + +HIDDEN_EMOJIS = { (True, True): ":red_circle:", (True, False): ":black_circle:", (False, True): ":white_circle:", (False, False): ":black_circle:", -} +} # This is for the opposing player's board which only shows aimed locations. -letters = ( +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 top row of the board -numbers = [ +NUMBERS = [ ":one:", ":two:", ":three:", @@ -56,9 +78,10 @@ numbers = [ ":eight:", ":nine:", ":keycap_ten:", -] +] # For the first column of the board -grid_typehint = typing.List[typing.List[Square]] +CROSS_EMOJI = "\u274e" +HAND_RAISED_EMOJI = "\U0001f64b" class Game: @@ -74,28 +97,9 @@ class Game: self.bot = bot self.public_channel = channel - 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.p1 = Player(player1, None, None, self.generate_grid()) + self.p2 = Player(player2, None, None, self.generate_grid()) self.gameover: bool = False @@ -107,211 +111,227 @@ class Game: 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) + 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 get_square(grid: grid_typehint, square: str) -> Square: + 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 = {"A": 0, "B": 1, "C": 2, "D": 3, "E": 4, "F": 5, "G": 6, "H": 7, "I": 8, "J": 9} - letter = square[0] + index = ord(square[0]) - ord("A") number = int(square[1:]) - return grid[number-1][index[letter]] # -1 since lists are indexed from 0 + return grid[number-1][index] # -1 since lists are indexed from 0 async def game_over( self, *, - timeout: bool = False, - winner: typing.Optional[discord.Member] = None, - loser: typing.Optional[discord.Member] = None + winner: discord.Member, + loser: discord.Member ) -> None: """Removes games from list of current games and announces to public chat.""" - if not timeout: # If someone won and not the game timed out - await self.public_channel.send(f"Game Over! {winner.mention} won against {loser.mention}") - self_grid_1 = self.format_grid([ - [ship_emojis[bool(square.boat), square.aimed] for square in row] - for row in self.grids[self.player1] - ]) - - self_grid_2 = self.format_grid([ - [ship_emojis[bool(square.boat), square.aimed] for square in row] - for row in self.grids[self.player2] - ]) - - await self.public_channel.send(f"{self.player1}'s Board:\n{self_grid_1}") - await self.public_channel.send(f"{self.player2}'s Board:\n{self_grid_2}") + await self.public_channel.send(f"Game Over! {winner.mention} won against {loser.mention}") - self.bot.get_cog("Battleship").games.remove(self) + 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_typehint, boat: str) -> bool: + 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_typehint) -> bool: + 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.player1, self.player2): - for name, size in ships.items(): + for player in (self.p1, self.p2): + for name, size in SHIPS.items(): while True: # Repeats if about to overwrite another boat overwrite = False coords = [] + + coord1 = random.randint(0, 9) + coord2 = random.randint(0, 10 - size) + 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)) + x, y = coord1, coord2 + xincr, yincr = 0, 1 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)) + 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 + overwrite = True + coords.append((new_x, new_y)) 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 + player.grid[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) + 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 and message.channel == self.turn.dm_channel: + if message.author == self.turn.user and message.channel == self.turn.user.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")) + 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" + ) + 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!") + self.gameover = True + break + else: + 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.player1.send(f"You're playing battleships with {self.player2}.") - await self.player2.send(f"You're playing battleships with {self.player1}.") + 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.player1 - self.next = self.player2 + self.turn = self.p1 + self.next = self.p2 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: - await self.game_over(timeout=True) - break + return + square = await self.take_turn() 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}!")) + alert_messages.append(await self.next.user.send(f"{self.turn.user} 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 - await self.game_over(winner=self.turn, loser=self.next) - break + await self.hit(square, alert_messages) + if self.gameover: + return else: - await self.turn.send("Miss!", delete_after=3.0) - alert_messages.append(await self.next.send("Miss!")) + 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 Battleships!""" + """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 + ) -> typing.Optional[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!")) + return False + return True + return False + 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")] + 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 Battleships with someone else! + 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. @@ -324,64 +344,54 @@ class Battleship(commands.Cog): 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" + "**Battleship**: 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 + await announcement.add_reaction(HAND_RAISED_EMOJI) try: - _reaction, user = await self.bot.wait_for("reaction_add", check=predicate, timeout=60.0) + _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() - 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.channel, 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 + return await ctx.send(f"{ctx.author.mention} Seems like there's no one here to play...") + + 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: - """This lists the ships that are found on the battleship grid.""" + """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): +def setup(bot: commands.Bot) -> None: """Cog load.""" bot.add_cog(Battleship(bot)) + log.info("Battleship cog loaded") -- cgit v1.2.3 From 3d7aaa55aeae430388f6a57c0636ef9c34aa2ab1 Mon Sep 17 00:00:00 2001 From: Suhail Date: Sun, 1 Mar 2020 21:35:58 +0000 Subject: Applied suggestions from code review for Battleships --- bot/seasons/evergreen/battleship.py | 78 ++++++++++++++++++++++++++++--------- 1 file changed, 60 insertions(+), 18 deletions(-) (limited to 'bot') diff --git a/bot/seasons/evergreen/battleship.py b/bot/seasons/evergreen/battleship.py index 9bca2bfc..bc076d7f 100644 --- a/bot/seasons/evergreen/battleship.py +++ b/bot/seasons/evergreen/battleship.py @@ -36,38 +36,39 @@ class Player: grid: Grid -SHIPS = { +SHIPS = { # The name of the ship and its size "Carrier": 5, "Battleship": 4, "Cruiser": 3, "Submarine": 3, "Destroyer": 2, -} # The name of the ship and its size +} # 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) -SHIP_EMOJIS = { + +SHIP_EMOJIS = { # This is for the player's own board which shows the location of their own ships. (True, True): ":fire:", (True, False): ":ship:", (False, True): ":anger:", (False, False): ":ocean:", -} # This is for the player's own board which shows the location of their own ships. +} -HIDDEN_EMOJIS = { +HIDDEN_EMOJIS = { # This is for the opposing player's board which only shows aimed locations. (True, True): ":red_circle:", (True, False): ":black_circle:", (False, True): ":white_circle:", (False, False): ":black_circle:", -} # This is for the opposing player's board which only shows aimed locations. +} -LETTERS = ( +LETTERS = ( # For the top row of the board ":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 top row of the board +) -NUMBERS = [ +NUMBERS = [ # For the first column of the board ":one:", ":two:", ":three:", @@ -78,7 +79,7 @@ NUMBERS = [ ":eight:", ":nine:", ":keycap_ten:", -] # For the first column of the board +] CROSS_EMOJI = "\u274e" HAND_RAISED_EMOJI = "\U0001f64b" @@ -107,6 +108,7 @@ class Game: self.next: typing.Optional[discord.Member] = None self.match: typing.Optional[typing.Match] = None + self.surrender: bool = False self.setup_grids() @@ -166,7 +168,7 @@ class Game: for player in (self.p1, self.p2): for name, size in SHIPS.items(): while True: # Repeats if about to overwrite another boat - overwrite = False + ship_collision = False coords = [] coord1 = random.randint(0, 9) @@ -183,9 +185,10 @@ class Game: 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 - overwrite = True + ship_collision = True + break coords.append((new_x, new_y)) - if not overwrite: # If not overwriting any other boat spaces, break loop + if not ship_collision: # If not overwriting any other boat spaces, break loop break for x, y in coords: @@ -216,6 +219,9 @@ class Game: 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)) @@ -225,7 +231,8 @@ class Game: """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" + "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: @@ -234,9 +241,19 @@ class Game: 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) @@ -275,6 +292,8 @@ class Game: return square = await self.take_turn() + if not square: + return square.aimed = True for message in alert_messages: @@ -308,7 +327,7 @@ class Battleship(commands.Cog): announcement: discord.Message, reaction: discord.Reaction, user: discord.Member - ) -> typing.Optional[bool]: + ) -> 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 @@ -319,7 +338,23 @@ class Battleship(commands.Cog): ): 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 @@ -345,13 +380,15 @@ class Battleship(commands.Cog): announcement = await ctx.send( "**Battleship**: A new game is about to start!\n" - f"Press :raising_hand: to play against {ctx.author.mention}!" + 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, user = await self.bot.wait_for( "reaction_add", check=partial(self.predicate, ctx, announcement), timeout=60.0 @@ -361,6 +398,11 @@ class Battleship(commands.Cog): 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): @@ -372,7 +414,7 @@ class Battleship(commands.Cog): self.games.remove(game) except discord.Forbidden: await ctx.send( - f"{ctx.author.mention} {user.mention}" + 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) -- cgit v1.2.3 From cab6e1cec2d2b1e303db8dd218fcee109dc67739 Mon Sep 17 00:00:00 2001 From: Suhail Date: Thu, 5 Mar 2020 12:12:03 +0000 Subject: Battleships - alter positioning of some comments --- bot/seasons/evergreen/battleship.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) (limited to 'bot') diff --git a/bot/seasons/evergreen/battleship.py b/bot/seasons/evergreen/battleship.py index bc076d7f..d54c0000 100644 --- a/bot/seasons/evergreen/battleship.py +++ b/bot/seasons/evergreen/battleship.py @@ -36,7 +36,8 @@ class Player: grid: Grid -SHIPS = { # The name of the ship and its size +# The name of the ship and its size +SHIPS = { "Carrier": 5, "Battleship": 4, "Cruiser": 3, @@ -48,27 +49,31 @@ SHIPS = { # The name of the ship and its size # 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) -SHIP_EMOJIS = { # This is for the player's own board which shows the location of their own ships. +# 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:", } -HIDDEN_EMOJIS = { # This is for the opposing player's board which only shows aimed locations. +# 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:", } -LETTERS = ( # For the top row of the board +# 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:" ) -NUMBERS = [ # For the first column of the board +# For the first column of the board +NUMBERS = [ ":one:", ":two:", ":three:", -- cgit v1.2.3 From fcc2152e62bc67f41e4c8030bbd1d48dc875c471 Mon Sep 17 00:00:00 2001 From: Suhail6inkling <38522108+Suhail6inkling@users.noreply.github.com> Date: Fri, 20 Mar 2020 05:18:44 +0000 Subject: Battleships - change hard-coded strings in subcommand Co-Authored-By: Karlis S. <45097959+ks129@users.noreply.github.com> --- bot/seasons/evergreen/battleship.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'bot') diff --git a/bot/seasons/evergreen/battleship.py b/bot/seasons/evergreen/battleship.py index d54c0000..9b8aaa48 100644 --- a/bot/seasons/evergreen/battleship.py +++ b/bot/seasons/evergreen/battleship.py @@ -433,8 +433,8 @@ class Battleship(commands.Cog): 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="Carrier\nBattleship\nCruiser\nSubmarine\nDestroyer") - embed.add_field(name="Size", value="5\n4\n3\n3\n2") + 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) -- cgit v1.2.3