test / bot /cogs /board_games.py
mtaaz's picture
Upload 94 files
91c7f83 verified
from __future__ import annotations
import random
from dataclasses import dataclass
import discord
from discord.ext import commands
from bot.i18n import get_cmd_desc
from bot.emojis import ui
FILES = "abcdefgh"
@dataclass
class BoardGameSession:
game: str
players: tuple[int, int] # second player can be 0 for bot
turn: int
board: list[list[str]]
tournament_name: str | None = None
class BoardMoveModal(discord.ui.Modal, title="Play Move"):
move = discord.ui.TextInput(label="Move", placeholder="e2e4 / d3 / 4", max_length=10)
def __init__(self, cog: "BoardGames") -> None:
super().__init__(timeout=None)
self.cog = cog
async def on_submit(self, interaction: discord.Interaction) -> None:
if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
await interaction.response.send_message("Server text channels only.", ephemeral=True)
return
err = await self.cog._play_turn(
guild=interaction.guild,
channel=interaction.channel,
author_id=interaction.user.id,
move=str(self.move.value).strip(),
)
if err:
await interaction.response.send_message(err, ephemeral=True)
else:
await interaction.response.send_message("βœ… Move accepted.", ephemeral=True)
class BoardActionView(discord.ui.View):
def __init__(self, cog: "BoardGames") -> None:
super().__init__(timeout=None)
self.cog = cog
@discord.ui.button(label="Play Move", style=discord.ButtonStyle.primary, emoji=ui("controller"), custom_id="board_play")
async def play_move(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
await interaction.response.send_modal(BoardMoveModal(self.cog))
@discord.ui.button(label="Forfeit", style=discord.ButtonStyle.danger, emoji=ui("no"), custom_id="board_forfeit")
async def forfeit(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
await interaction.response.send_message("Server text channels only.", ephemeral=True)
return
err = await self.cog._forfeit(
guild=interaction.guild,
channel=interaction.channel,
author_id=interaction.user.id,
)
if err:
await interaction.response.send_message(err, ephemeral=True)
return
await interaction.response.send_message("🏳️ Forfeit processed.", ephemeral=True)
@discord.ui.button(label="End Game", style=discord.ButtonStyle.danger, emoji=ui("lock"), custom_id="board_end")
async def end_game(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
await interaction.response.send_message("Server text channels only.", ephemeral=True)
return
session = self.cog.sessions.get(interaction.channel.id)
if not session:
await interaction.response.send_message("No active board game in this channel.", ephemeral=True)
return
if interaction.user.id not in session.players and not interaction.user.guild_permissions.manage_channels:
await interaction.response.send_message("Only players/admins can end this game.", ephemeral=True)
return
self.cog.sessions.pop(interaction.channel.id, None)
await interaction.response.send_message("πŸ”’ Game session ended.", ephemeral=True)
if interaction.channel.name.startswith("game-"):
try:
await interaction.channel.delete(reason=f"Game room closed by {interaction.user}")
except Exception:
pass
class Connect4ActionView(BoardActionView):
def __init__(self, cog: "BoardGames") -> None:
super().__init__(cog)
self.clear_items()
for col in range(1, 8):
button = discord.ui.Button(label=str(col), style=discord.ButtonStyle.secondary, row=0 if col <= 4 else 1)
async def _drop(interaction: discord.Interaction, c: int = col) -> None:
if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
await interaction.response.send_message("Server text channels only.", ephemeral=True)
return
err = await self.cog._play_turn(
guild=interaction.guild,
channel=interaction.channel,
author_id=interaction.user.id,
move=str(c),
)
if err:
await interaction.response.send_message(err, ephemeral=True)
else:
await interaction.response.send_message(f"βœ… Dropped in column {c}", ephemeral=True)
button.callback = _drop
self.add_item(button)
forfeit_btn = discord.ui.Button(label="Forfeit", style=discord.ButtonStyle.danger, emoji=ui("no"), row=2, custom_id="board_forfeit_c4")
end_btn = discord.ui.Button(label="End Game", style=discord.ButtonStyle.danger, emoji=ui("lock"), row=2, custom_id="board_end_c4")
async def _forfeit(interaction: discord.Interaction) -> None:
if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
await interaction.response.send_message("Server text channels only.", ephemeral=True)
return
err = await self.cog._forfeit(guild=interaction.guild, channel=interaction.channel, author_id=interaction.user.id)
if err:
await interaction.response.send_message(err, ephemeral=True)
return
await interaction.response.send_message("🏳️ Forfeit processed.", ephemeral=True)
forfeit_btn.callback = _forfeit
self.add_item(forfeit_btn)
async def _end(interaction: discord.Interaction) -> None:
if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
await interaction.response.send_message("Server text channels only.", ephemeral=True)
return
session = self.cog.sessions.get(interaction.channel.id)
if not session:
await interaction.response.send_message("No active board game in this channel.", ephemeral=True)
return
if interaction.user.id not in session.players and not interaction.user.guild_permissions.manage_channels:
await interaction.response.send_message("Only players/admins can end this game.", ephemeral=True)
return
self.cog.sessions.pop(interaction.channel.id, None)
await interaction.response.send_message("πŸ”’ Game session ended.", ephemeral=True)
if interaction.channel.name.startswith("game-"):
try:
await interaction.channel.delete(reason=f"Game room closed by {interaction.user}")
except Exception:
pass
end_btn.callback = _end
self.add_item(end_btn)
import random as _random
class QuickRPSView(discord.ui.View):
def __init__(self) -> None:
super().__init__(timeout=60)
@discord.ui.button(label="Rock πŸͺ¨", style=discord.ButtonStyle.primary)
async def rock(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
await self._resolve(interaction, "rock")
@discord.ui.button(label="Paper πŸ“„", style=discord.ButtonStyle.primary)
async def paper(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
await self._resolve(interaction, "paper")
@discord.ui.button(label="Scissors βœ‚οΈ", style=discord.ButtonStyle.primary)
async def scissors(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
await self._resolve(interaction, "scissors")
async def _resolve(self, interaction: discord.Interaction, choice: str) -> None:
bot_choice = _random.choice(["rock", "paper", "scissors"])
emoji_map = {"rock": "πŸͺ¨", "paper": "πŸ“„", "scissors": "βœ‚οΈ"}
result_map = {("rock", "scissors"): "win", ("paper", "rock"): "win", ("scissors", "paper"): "win"}
if choice == bot_choice:
result = "🀝 Tie!"
elif (choice, bot_choice) in result_map:
result = "πŸŽ‰ You win!"
else:
result = "πŸ’€ You lose!"
await interaction.response.send_message(f"You: {emoji_map[choice]} | Bot: {emoji_map[bot_choice]}\n{result}", ephemeral=True)
self.stop()
class GamePanelView(discord.ui.View):
def __init__(self, cog: "BoardGames") -> None:
super().__init__(timeout=None)
self.cog = cog
async def _start(self, interaction: discord.Interaction, game: str) -> None:
if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
await interaction.response.send_message("Server text channels only.", ephemeral=True)
return
ok = await self.cog.start_game_session_interaction(interaction, game)
if ok:
await interaction.response.send_message(f"βœ… Started **{game}** vs bot.", ephemeral=True)
@discord.ui.button(label="Chess", style=discord.ButtonStyle.primary, emoji=ui("chess"), row=0, custom_id="board_chess")
async def chess(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
await self._start(interaction, "chess")
@discord.ui.button(label="Checkers", style=discord.ButtonStyle.secondary, emoji=ui("joystick"), row=0, custom_id="board_checkers")
async def checkers(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
await self._start(interaction, "checkers")
@discord.ui.button(label="Connect4", style=discord.ButtonStyle.success, emoji=ui("joystick"), row=0, custom_id="board_connect4")
async def connect4(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
await self._start(interaction, "connect4")
@discord.ui.button(label="Othello", style=discord.ButtonStyle.blurple, emoji=ui("joystick"), row=0, custom_id="board_othello")
async def othello(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
await self._start(interaction, "othello")
@discord.ui.button(label="TicTacToe", style=discord.ButtonStyle.primary, emoji=ui("x"), row=1, custom_id="board_tictactoe")
async def tictactoe(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
await interaction.response.send_message("Server text channels only.", ephemeral=True)
return
await interaction.response.send_message(f"❎ **TicTacToe**: {interaction.user.mention} vs πŸ€– Bot\nUse `/xo` to play!", ephemeral=True)
@discord.ui.button(label="Rock Paper Scissors", style=discord.ButtonStyle.secondary, emoji="βœ‚οΈ", row=1, custom_id="board_rps")
async def rps(self, interaction: discord.Interaction, _: discord.ui.Button) -> None:
if not interaction.guild:
await interaction.response.send_message("Server only.", ephemeral=True)
return
await interaction.response.send_message(
"Choose your move:",
view=QuickRPSView(),
ephemeral=True,
)
class BoardGames(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
self.sessions: dict[int, BoardGameSession] = {}
async def cog_load(self) -> None:
self.bot.add_view(GamePanelView(self))
self.bot.add_view(BoardActionView(self))
def _coord(self, token: str) -> tuple[int, int] | None:
token = token.strip().lower()
if len(token) != 2 or token[0] not in FILES or token[1] not in "12345678":
return None
x = FILES.index(token[0])
y = 8 - int(token[1])
return y, x
def _coord_to_text(self, y: int, x: int) -> str:
return f"{FILES[x]}{8-y}"
def _current_player(self, s: BoardGameSession) -> int:
return s.players[s.turn]
def _render(self, s: BoardGameSession) -> str:
if s.game == "connect4":
lines = ["1️⃣2️⃣3️⃣4️⃣5️⃣6️⃣7️⃣"]
for row in s.board:
lines.append("".join(row))
return "\n".join(lines)
if s.game == "othello":
lines = ["β¬›πŸ‡¦πŸ‡§πŸ‡¨πŸ‡©πŸ‡ͺπŸ‡«πŸ‡¬πŸ‡­"]
for y, row in enumerate(s.board):
lines.append(f"{8-y}️⃣" + "".join(row))
return "\n".join(lines)
lines = [" πŸ‡¦ πŸ‡§ πŸ‡¨ πŸ‡© πŸ‡ͺ πŸ‡« πŸ‡¬ πŸ‡­"]
for y, row in enumerate(s.board):
lines.append(f"{8-y} " + " ".join(row))
return "\n".join(lines)
def _make_chess(self) -> list[list[str]]:
return [
list("β™œβ™žβ™β™›β™šβ™β™žβ™œ"),
list("β™Ÿβ™Ÿβ™Ÿβ™Ÿβ™Ÿβ™Ÿβ™Ÿβ™Ÿ"),
list("β–«β–«β–«β–«β–«β–«β–«β–«"),
list("β–«β–«β–«β–«β–«β–«β–«β–«"),
list("β–«β–«β–«β–«β–«β–«β–«β–«"),
list("β–«β–«β–«β–«β–«β–«β–«β–«"),
list("β™™β™™β™™β™™β™™β™™β™™β™™"),
list("β™–β™˜β™—β™•β™”β™—β™˜β™–"),
]
def _make_checkers(self) -> list[list[str]]:
board = [["β–«" for _ in range(8)] for _ in range(8)]
for y in range(3):
for x in range(8):
if (x + y) % 2 == 1:
board[y][x] = "πŸ”΄"
for y in range(5, 8):
for x in range(8):
if (x + y) % 2 == 1:
board[y][x] = "βšͺ"
return board
def _make_connect4(self) -> list[list[str]]:
return [["⚫" for _ in range(7)] for _ in range(6)]
def _make_othello(self) -> list[list[str]]:
b = [["🟩" for _ in range(8)] for _ in range(8)]
b[3][3] = "βšͺ"
b[3][4] = "⚫"
b[4][3] = "⚫"
b[4][4] = "βšͺ"
return b
async def _announce(self, ctx: commands.Context, s: BoardGameSession, text: str) -> None:
p1 = f"<@{s.players[0]}>"
p2 = "πŸ€– Bot" if s.players[1] == 0 else f"<@{s.players[1]}>"
turn = self._current_player(s)
tname = "πŸ€– Bot" if turn == 0 else f"<@{turn}>"
await ctx.send(
f"**{s.game.title()}** | {p1} vs {p2}\n{text}\nTurn: {tname}\n{self._render(s)}"
)
async def start_game_session(
self,
ctx: commands.Context,
game: str,
opponent: discord.Member | None = None,
tournament_name: str | None = None,
) -> bool:
game = game.lower().strip()
if game not in {"chess", "checkers", "connect4", "othello"}:
await ctx.reply("Supported: chess, checkers, connect4, othello")
return False
if ctx.channel.id in self.sessions:
await ctx.reply("There is already an active board game in this channel.")
return False
opponent_id = opponent.id if opponent else 0
if opponent_id == ctx.author.id:
await ctx.reply("Choose another player or leave opponent empty for bot.")
return False
if game == "chess":
board = self._make_chess()
elif game == "checkers":
board = self._make_checkers()
elif game == "connect4":
board = self._make_connect4()
else:
board = self._make_othello()
s = BoardGameSession(game=game, players=(ctx.author.id, opponent_id), turn=0, board=board, tournament_name=tournament_name)
self.sessions[ctx.channel.id] = s
await self._announce(ctx.channel, s, "Game started. Use buttons or `/board_move`.")
return True
async def start_game_session_interaction(self, interaction: discord.Interaction, game: str) -> bool:
if not interaction.guild or not isinstance(interaction.channel, discord.TextChannel):
return False
game = game.lower().strip()
if game not in {"chess", "checkers", "connect4", "othello"}:
await interaction.followup.send("Supported: chess, checkers, connect4, othello", ephemeral=True)
return False
if interaction.channel.id in self.sessions:
await interaction.followup.send("There is already an active board game in this channel.", ephemeral=True)
return False
if game == "chess":
board = self._make_chess()
elif game == "checkers":
board = self._make_checkers()
elif game == "connect4":
board = self._make_connect4()
else:
board = self._make_othello()
s = BoardGameSession(game=game, players=(interaction.user.id, 0), turn=0, board=board)
self.sessions[interaction.channel.id] = s
await self._announce(interaction.channel, s, "Game started from panel.")
return True
async def start_tournament_duel(
self,
ctx: commands.Context,
game: str,
player1: discord.Member,
player2: discord.Member | None,
tournament_name: str,
) -> bool:
if ctx.channel.id in self.sessions:
await ctx.reply("There is already an active board game in this channel.")
return False
game = game.lower().strip()
if game == "chess":
board = self._make_chess()
elif game == "checkers":
board = self._make_checkers()
elif game == "connect4":
board = self._make_connect4()
elif game == "othello":
board = self._make_othello()
else:
await ctx.reply("Supported: chess, checkers, connect4, othello")
return False
s = BoardGameSession(
game=game,
players=(player1.id, player2.id if player2 else 0),
turn=0,
board=board,
tournament_name=tournament_name,
)
self.sessions[ctx.channel.id] = s
await self._announce(ctx.channel, s, f"Tournament duel: **{tournament_name}**")
return True
def _in_bounds(self, y: int, x: int) -> bool:
return 0 <= y < 8 and 0 <= x < 8
def _is_white(self, p: str) -> bool:
return p in "β™™β™–β™˜β™—β™•β™”"
def _is_black(self, p: str) -> bool:
return p in "β™Ÿβ™œβ™žβ™β™›β™š"
def _enemy(self, p: str, white_turn: bool) -> bool:
return self._is_black(p) if white_turn else self._is_white(p)
def _friend(self, p: str, white_turn: bool) -> bool:
return self._is_white(p) if white_turn else self._is_black(p)
def _chess_legal(self, b: list[list[str]], src: tuple[int, int], dst: tuple[int, int], white_turn: bool) -> bool:
sy, sx = src
dy, dx = dst
if not (self._in_bounds(sy, sx) and self._in_bounds(dy, dx)):
return False
piece = b[sy][sx]
if piece == "β–«" or not self._friend(piece, white_turn):
return False
target = b[dy][dx]
if target != "β–«" and self._friend(target, white_turn):
return False
vy = dy - sy
vx = dx - sx
ady, adx = abs(vy), abs(vx)
def clear_line(stepy: int, stepx: int) -> bool:
y, x = sy + stepy, sx + stepx
while (y, x) != (dy, dx):
if b[y][x] != "β–«":
return False
y += stepy
x += stepx
return True
if piece in "β™™β™Ÿ":
direction = -1 if piece == "β™™" else 1
start_row = 6 if piece == "β™™" else 1
if vx == 0 and target == "β–«":
if vy == direction:
return True
if sy == start_row and vy == 2 * direction and b[sy + direction][sx] == "β–«":
return True
if adx == 1 and vy == direction and target != "β–«" and self._enemy(target, white_turn):
return True
return False
if piece in "β™–β™œ":
if sx == dx and clear_line(1 if vy > 0 else -1, 0):
return True
if sy == dy and clear_line(0, 1 if vx > 0 else -1):
return True
return False
if piece in "♗♝":
if adx == ady and clear_line(1 if vy > 0 else -1, 1 if vx > 0 else -1):
return True
return False
if piece in "β™•β™›":
if sx == dx and clear_line(1 if vy > 0 else -1, 0):
return True
if sy == dy and clear_line(0, 1 if vx > 0 else -1):
return True
if adx == ady and clear_line(1 if vy > 0 else -1, 1 if vx > 0 else -1):
return True
return False
if piece in "β™˜β™ž":
return (ady, adx) in {(1, 2), (2, 1)}
if piece in "β™”β™š":
return max(ady, adx) == 1
return False
def _checkers_legal(self, b: list[list[str]], src: tuple[int, int], dst: tuple[int, int], white_turn: bool) -> bool:
sy, sx = src
dy, dx = dst
if not (self._in_bounds(sy, sx) and self._in_bounds(dy, dx)):
return False
me = "βšͺ" if white_turn else "πŸ”΄"
enemy = "πŸ”΄" if white_turn else "βšͺ"
if b[sy][sx] != me or b[dy][dx] != "β–«":
return False
vy, vx = dy - sy, dx - sx
direction = -1 if white_turn else 1
if abs(vx) == 1 and vy == direction:
return True
if abs(vx) == 2 and vy == 2 * direction:
my, mx = sy + direction, sx + (1 if vx > 0 else -1)
return b[my][mx] == enemy
return False
def _apply_checkers_capture(self, b: list[list[str]], src: tuple[int, int], dst: tuple[int, int], white_turn: bool) -> None:
sy, sx = src
dy, dx = dst
if abs(dy - sy) == 2:
my, mx = (sy + dy) // 2, (sx + dx) // 2
b[my][mx] = "β–«"
def _connect4_drop(self, b: list[list[str]], col: int, white_turn: bool) -> tuple[int, int] | None:
token = "πŸ”΅" if white_turn else "🟠"
for y in range(len(b) - 1, -1, -1):
if b[y][col] == "⚫":
b[y][col] = token
return y, col
return None
def _has_four(self, b: list[list[str]], y: int, x: int) -> bool:
token = b[y][x]
if token == "⚫":
return False
dirs = [(1, 0), (0, 1), (1, 1), (1, -1)]
h, w = len(b), len(b[0])
for dy, dx in dirs:
c = 1
for sign in (1, -1):
ny, nx = y, x
while True:
ny += dy * sign
nx += dx * sign
if not (0 <= ny < h and 0 <= nx < w) or b[ny][nx] != token:
break
c += 1
if c >= 4:
return True
return False
def _othello_flips(self, b: list[list[str]], y: int, x: int, black_turn: bool) -> list[tuple[int, int]]:
me = "⚫" if black_turn else "βšͺ"
enemy = "βšͺ" if black_turn else "⚫"
if not self._in_bounds(y, x) or b[y][x] != "🟩":
return []
flips: list[tuple[int, int]] = []
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
if dy == 0 and dx == 0:
continue
path: list[tuple[int, int]] = []
ny, nx = y + dy, x + dx
while self._in_bounds(ny, nx) and b[ny][nx] == enemy:
path.append((ny, nx))
ny += dy
nx += dx
if path and self._in_bounds(ny, nx) and b[ny][nx] == me:
flips.extend(path)
return flips
async def _finish(self, ctx: commands.Context, winner_id: int | None, reason: str) -> None:
s = self.sessions.pop(ctx.channel.id, None)
if not s:
return
if winner_id is None:
await ctx.send(f"🀝 Draw | {reason}")
return
if winner_id == 0:
await ctx.send(f"πŸ€– Bot won | {reason}")
return
await ctx.send(f"πŸ† Winner: <@{winner_id}> | {reason}")
if s.tournament_name:
await self.bot.db.execute(
"UPDATE tournaments SET winner_id = ?, status = 'finished' WHERE guild_id = ? AND name = ?",
winner_id,
ctx.guild.id,
s.tournament_name,
)
async def _bot_move(self, ctx: commands.Context) -> None:
s = self.sessions.get(ctx.channel.id)
if not s or s.players[1] != 0 or self._current_player(s) != 0:
return
if s.game == "connect4":
valid = [c for c in range(7) if s.board[0][c] == "⚫"]
col = random.choice(valid)
spot = self._connect4_drop(s.board, col, white_turn=False)
if spot and self._has_four(s.board, spot[0], spot[1]):
await self._announce(ctx.channel, s, f"Bot played column {col + 1}.")
await self._finish(ctx, 0, "Four in a row!")
return
s.turn = 0
await self._announce(ctx.channel, s, f"Bot played column {col + 1}.")
return
if s.game == "othello":
legal: list[tuple[int, int, list[tuple[int, int]]]] = []
for y in range(8):
for x in range(8):
flips = self._othello_flips(s.board, y, x, black_turn=False)
if flips:
legal.append((y, x, flips))
if not legal:
s.turn = 0
await self._announce(ctx.channel, s, "Bot has no valid move and passes.")
return
y, x, flips = random.choice(legal)
s.board[y][x] = "βšͺ"
for fy, fx in flips:
s.board[fy][fx] = "βšͺ"
s.turn = 0
await self._announce(ctx.channel, s, f"Bot played {self._coord_to_text(y, x)}")
return
# chess/checkers bot move: random pseudo legal
moves: list[tuple[tuple[int, int], tuple[int, int]]] = []
for sy in range(8):
for sx in range(8):
for dy in range(8):
for dx in range(8):
if s.game == "chess":
if self._chess_legal(s.board, (sy, sx), (dy, dx), white_turn=False):
moves.append(((sy, sx), (dy, dx)))
elif self._checkers_legal(s.board, (sy, sx), (dy, dx), white_turn=False):
moves.append(((sy, sx), (dy, dx)))
if not moves:
await self._finish(ctx, s.players[0], "Bot has no legal moves.")
return
src, dst = random.choice(moves)
sy, sx = src
dy, dx = dst
target = s.board[dy][dx]
if s.game == "checkers":
self._apply_checkers_capture(s.board, src, dst, white_turn=False)
s.board[dy][dx] = s.board[sy][sx]
s.board[sy][sx] = "β–«"
s.turn = 0
await self._announce(ctx.channel, s, f"Bot played {self._coord_to_text(sy, sx)}{self._coord_to_text(dy, dx)}")
if s.game == "chess" and target == "β™”":
await self._finish(ctx, 0, "White king captured.")
@commands.hybrid_command(name="boardgames", hidden=True, description=get_cmd_desc("commands.boardgames.boardgames_desc"), with_app_command=False)
async def boardgames(self, ctx: commands.Context) -> None:
embed = discord.Embed(title="Board Games", color=discord.Color.blurple())
embed.description = (
"β€’ chess\n"
"β€’ checkers\n"
"β€’ connect4\n"
"β€’ othello\n\n"
"Use `/board_start <game> [opponent]` then `/board_move`"
)
await ctx.reply(embed=embed)
@commands.hybrid_command(name="board_start", hidden=True, description=get_cmd_desc("commands.boardgames.board_start_desc"), with_app_command=False)
async def board_start(self, ctx: commands.Context, game: str, opponent: discord.Member | None = None) -> None:
await self.start_game_session(ctx, game, opponent)
@commands.hybrid_command(name="chess", hidden=True, description=get_cmd_desc("commands.boardgames.chess_desc"), with_app_command=False)
async def chess(self, ctx: commands.Context, opponent: discord.Member | None = None) -> None:
await self.start_game_session(ctx, "chess", opponent)
@commands.hybrid_command(name="checkers", hidden=True, description=get_cmd_desc("commands.boardgames.checkers_desc"), with_app_command=False)
async def checkers(self, ctx: commands.Context, opponent: discord.Member | None = None) -> None:
await self.start_game_session(ctx, "checkers", opponent)
@commands.hybrid_command(name="connect4", hidden=True, description=get_cmd_desc("commands.boardgames.connect4_desc"), with_app_command=False)
async def connect4(self, ctx: commands.Context, opponent: discord.Member | None = None) -> None:
await self.start_game_session(ctx, "connect4", opponent)
@commands.hybrid_command(name="othello", hidden=True, description=get_cmd_desc("commands.boardgames.othello_desc"), with_app_command=False)
async def othello(self, ctx: commands.Context, opponent: discord.Member | None = None) -> None:
await self.start_game_session(ctx, "othello", opponent)
async def _play_turn(self, *, guild: discord.Guild, channel: discord.TextChannel, author_id: int, move: str) -> str | None:
s = self.sessions.get(channel.id)
if not s:
return "No active board game in this channel."
if self._current_player(s) != author_id:
return "Not your turn."
is_first_player = author_id == s.players[0]
if s.game == "connect4":
try:
col = int(move) - 1
except ValueError:
return "Connect4 move is column number 1-7"
if not 0 <= col < 7:
return "Column must be 1-7"
spot = self._connect4_drop(s.board, col, white_turn=is_first_player)
if not spot:
return "Column is full."
if self._has_four(s.board, spot[0], spot[1]):
await self._announce(channel, s, f"Move: {col + 1}")
winner = author_id if author_id != 0 else None
await self._finish_ctxless(guild, channel, winner, "Four in a row!")
return None
if all(s.board[0][i] != "⚫" for i in range(7)):
await self._announce(channel, s, f"Move: {col + 1}")
await self._finish_ctxless(guild, channel, None, "Board full")
return None
s.turn = 1 - s.turn
await self._announce(channel, s, f"Move: {col + 1}")
if s.players[1] == 0:
await self._bot_move_channel(guild, channel)
return None
if s.game == "othello":
c = self._coord(move)
if not c:
return "Use coordinate like d3"
y, x = c
black_turn = is_first_player
flips = self._othello_flips(s.board, y, x, black_turn=black_turn)
if not flips:
return "Illegal move."
s.board[y][x] = "⚫" if black_turn else "βšͺ"
for fy, fx in flips:
s.board[fy][fx] = "⚫" if black_turn else "βšͺ"
s.turn = 1 - s.turn
await self._announce(channel, s, f"Move: {move.lower()}")
if s.players[1] == 0:
await self._bot_move_channel(guild, channel)
return None
if len(move) != 4:
return "Use move format like e2e4"
src = self._coord(move[:2])
dst = self._coord(move[2:])
if not src or not dst:
return "Invalid coordinates. Example: e2e4"
legal = self._chess_legal(s.board, src, dst, white_turn=is_first_player) if s.game == "chess" else self._checkers_legal(s.board, src, dst, white_turn=is_first_player)
if not legal:
return "Illegal move."
sy, sx = src
dy, dx = dst
target = s.board[dy][dx]
if s.game == "checkers":
self._apply_checkers_capture(s.board, src, dst, white_turn=is_first_player)
piece = s.board[sy][sx]
s.board[dy][dx] = piece
s.board[sy][sx] = "β–«"
if piece == "β™™" and dy == 0:
s.board[dy][dx] = "β™•"
if piece == "β™Ÿ" and dy == 7:
s.board[dy][dx] = "β™›"
if s.game == "chess" and target in {"β™š", "β™”"}:
await self._announce(channel, s, f"Move: {move.lower()}")
await self._finish_ctxless(guild, channel, author_id, "King captured.")
return None
s.turn = 1 - s.turn
await self._announce(channel, s, f"Move: {move.lower()}")
if s.players[1] == 0:
await self._bot_move_channel(guild, channel)
return None
async def _finish_ctxless(self, guild: discord.Guild, channel: discord.TextChannel, winner_id: int | None, reason: str) -> None:
s = self.sessions.pop(channel.id, None)
if not s:
return
if winner_id is None:
await channel.send(f"🀝 Draw | {reason}")
return
if winner_id == 0:
await channel.send(f"πŸ€– Bot won | {reason}")
return
await channel.send(f"πŸ† Winner: <@{winner_id}> | {reason}")
if s.tournament_name:
await self.bot.db.execute(
"UPDATE tournaments SET winner_id = ?, status = 'finished' WHERE guild_id = ? AND name = ?",
winner_id,
guild.id,
s.tournament_name,
)
async def _bot_move_channel(self, guild: discord.Guild, channel: discord.TextChannel) -> None:
class Dummy:
pass
d=Dummy(); d.guild=guild; d.channel=channel
await self._bot_move(d)
async def _forfeit(self, *, guild: discord.Guild, channel: discord.TextChannel, author_id: int) -> str | None:
s = self.sessions.get(channel.id)
if not s:
return "No active board game in this channel."
if author_id not in s.players:
return "Only players can forfeit."
winner = s.players[1] if author_id == s.players[0] else s.players[0]
await self._finish_ctxless(guild, channel, None if winner == 0 else winner, "Forfeit")
return None
@commands.hybrid_command(name="board_move", hidden=True, description=get_cmd_desc("commands.boardgames.board_move_desc"), with_app_command=False)
async def board_move(self, ctx: commands.Context, move: str) -> None:
if not ctx.guild or not isinstance(ctx.channel, discord.TextChannel):
await ctx.reply("Server text channels only.")
return
err = await self._play_turn(guild=ctx.guild, channel=ctx.channel, author_id=ctx.author.id, move=move)
if err:
await ctx.reply(err)
@commands.hybrid_command(name="games_panel", hidden=True, description=get_cmd_desc("commands.boardgames.games_panel_desc"), with_app_command=False)
async def games_panel(self, ctx: commands.Context) -> None:
await ctx.reply("<:animatedarrowgreen:1477261279428087979> This panel is deprecated. Use `/gamehub` for the improved game experience.")
@commands.hybrid_command(name="board_forfeit", hidden=True, description=get_cmd_desc("commands.boardgames.board_forfeit_desc"), with_app_command=False)
async def board_forfeit(self, ctx: commands.Context) -> None:
if not ctx.guild or not isinstance(ctx.channel, discord.TextChannel):
await ctx.reply("Server text channels only.")
return
err = await self._forfeit(guild=ctx.guild, channel=ctx.channel, author_id=ctx.author.id)
if err:
await ctx.reply(err)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(BoardGames(bot))