1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
|
import asyncio
import logging
import random
import re
from dataclasses import dataclass
from functools import partial
from typing import Optional
import discord
from discord.ext import commands
from bot.bot import Bot
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: Optional[str]
aimed: bool
Grid = list[list[Square]]
EmojiSet = dict[tuple[bool, bool], str]
@dataclass
class Player:
"""Each player in the game - their messages for the boards and their current grid."""
user: Optional[discord.Member]
board: Optional[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: Bot,
channel: discord.TextChannel,
player1: discord.Member,
player2: discord.Member
):
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: Optional[Player] = None
self.next: Optional[Player] = None
self.match: Optional[re.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].upper()) - 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.fullmatch("([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) -> 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: 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: Bot):
self.bot = bot
self.games: list[Game] = []
self.waiting: 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)
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):
await ctx.send("You're already playing a game!")
return
if ctx.author in self.waiting:
await ctx.send("You've already sent out a request for a player 2.")
return
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()
await ctx.send(f"{ctx.author.mention} Seems like there's no one here to play...")
return
if str(reaction.emoji) == CROSS_EMOJI:
self.waiting.remove(ctx.author)
await announcement.delete()
await ctx.send(f"{ctx.author.mention} Game cancelled.")
return
await announcement.delete()
self.waiting.remove(ctx.author)
if self.already_playing(ctx.author):
return
game = Game(self.bot, ctx.channel, ctx.author, user)
self.games.append(game)
try:
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: Bot) -> None:
"""Load the Battleship Cog."""
bot.add_cog(Battleship(bot))
|