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 [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))