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