1779 lines
87 KiB
Python
1779 lines
87 KiB
Python
import discord
|
|
from discord import ui
|
|
import chess
|
|
import chess.engine
|
|
import chess.pgn
|
|
import platform
|
|
import os
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
import io
|
|
import asyncio
|
|
from typing import Optional, List, Union
|
|
|
|
# --- Chess board image generation function ---
|
|
def generate_board_image(board: chess.Board, last_move: Optional[chess.Move] = None, perspective_white: bool = True, valid_moves: Optional[List[chess.Move]] = None) -> discord.File:
|
|
"""Generates an image representation of the chess board.
|
|
|
|
Args:
|
|
board: The chess board to render
|
|
last_move: The last move made, to highlight source and destination squares
|
|
perspective_white: Whether to show the board from white's perspective
|
|
valid_moves: Optional list of valid moves to highlight with dots
|
|
"""
|
|
SQUARE_SIZE = 60
|
|
BOARD_SIZE = 8 * SQUARE_SIZE
|
|
LIGHT_COLOR = (240, 217, 181) # Light wood
|
|
DARK_COLOR = (181, 136, 99) # Dark wood
|
|
HIGHLIGHT_LIGHT = (205, 210, 106, 180) # Semi-transparent yellow for light squares
|
|
HIGHLIGHT_DARK = (170, 162, 58, 180) # Semi-transparent yellow for dark squares
|
|
VALID_MOVE_COLOR = (100, 100, 100, 180) # Semi-transparent dark gray for valid move dots
|
|
MARGIN = 30 # Add margin for rank and file labels
|
|
TOTAL_SIZE = BOARD_SIZE + 2 * MARGIN
|
|
|
|
# Create image with margins
|
|
img = Image.new("RGB", (TOTAL_SIZE, TOTAL_SIZE), (50, 50, 50)) # Dark gray background
|
|
draw = ImageDraw.Draw(img, "RGBA") # Use RGBA for transparency support # Load the bundled DejaVu Sans font
|
|
font = None
|
|
label_font = None
|
|
font_size = int(SQUARE_SIZE * 0.8)
|
|
label_font_size = int(SQUARE_SIZE * 0.4)
|
|
try:
|
|
# Construct path relative to this script file
|
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(SCRIPT_DIR)) # Go up two levels from games dir
|
|
FONT_DIR_NAME = "dejavusans" # Directory specified by user
|
|
FONT_FILE_NAME = "DejaVuSans.ttf"
|
|
font_path = os.path.join(PROJECT_ROOT, FONT_DIR_NAME, FONT_FILE_NAME)
|
|
|
|
font = ImageFont.truetype(font_path, font_size)
|
|
label_font = ImageFont.truetype(font_path, label_font_size)
|
|
print(f"[Debug] Loaded font from bundled path: {font_path}")
|
|
except IOError:
|
|
print(f"Warning: Could not load bundled font at '{font_path}'. Using default font. Chess pieces might not render correctly.")
|
|
font = ImageFont.load_default() # Fallback
|
|
label_font = ImageFont.load_default() # Fallback for labels too # Determine squares to highlight based on the last move
|
|
highlight_squares = set()
|
|
if last_move:
|
|
highlight_squares.add(last_move.from_square)
|
|
highlight_squares.add(last_move.to_square)
|
|
|
|
for rank in range(8):
|
|
for file in range(8):
|
|
square = chess.square(file, rank)
|
|
# Flip board if perspective is black
|
|
display_rank = rank if perspective_white else 7 - rank
|
|
display_file = file if perspective_white else 7 - file
|
|
|
|
x0 = MARGIN + display_file * SQUARE_SIZE
|
|
y0 = MARGIN + (7 - display_rank) * SQUARE_SIZE # Y is inverted in PIL
|
|
x1 = x0 + SQUARE_SIZE
|
|
y1 = y0 + SQUARE_SIZE
|
|
|
|
# Draw square color
|
|
is_light = (rank + file) % 2 != 0
|
|
color = LIGHT_COLOR if is_light else DARK_COLOR
|
|
draw.rectangle([x0, y0, x1, y1], fill=color)
|
|
|
|
# Draw highlight if applicable
|
|
if square in highlight_squares:
|
|
highlight_color = HIGHLIGHT_LIGHT if is_light else HIGHLIGHT_DARK
|
|
draw.rectangle([x0, y0, x1, y1], fill=highlight_color)
|
|
|
|
# Load piece images from the pieces-png directory
|
|
PIECES_DIR = os.path.join(PROJECT_ROOT, "pieces-png")
|
|
piece_images = {}
|
|
for color in ["white", "black"]:
|
|
for piece in ["king", "queen", "rook", "bishop", "knight", "pawn"]:
|
|
piece_key = f"{color}-{piece}"
|
|
piece_path = os.path.join(PIECES_DIR, f"{piece_key}.png")
|
|
try:
|
|
piece_images[piece_key] = Image.open(piece_path).convert("RGBA")
|
|
except IOError:
|
|
print(f"Warning: Could not load image for {piece_key} at {piece_path}.")
|
|
|
|
# Draw pieces using PNG images
|
|
for rank in range(8):
|
|
for file in range(8):
|
|
square = chess.square(file, rank)
|
|
# Flip board if perspective is black
|
|
display_rank = rank if perspective_white else 7 - rank
|
|
display_file = file if perspective_white else 7 - file
|
|
|
|
x0 = MARGIN + display_file * SQUARE_SIZE
|
|
y0 = MARGIN + (7 - display_rank) * SQUARE_SIZE # Y is inverted in PIL
|
|
|
|
# Draw piece
|
|
piece = board.piece_at(square)
|
|
if piece:
|
|
piece_color = "white" if piece.color == chess.WHITE else "black"
|
|
piece_type = piece.piece_type
|
|
piece_name = {
|
|
chess.KING: "king",
|
|
chess.QUEEN: "queen",
|
|
chess.ROOK: "rook",
|
|
chess.BISHOP: "bishop",
|
|
chess.KNIGHT: "knight",
|
|
chess.PAWN: "pawn"
|
|
}.get(piece_type, None)
|
|
if piece_name:
|
|
piece_key = f"{piece_color}-{piece_name}"
|
|
piece_image = piece_images.get(piece_key)
|
|
if piece_image:
|
|
# Use Image.Resampling.LANCZOS instead of Image.ANTIALIAS
|
|
piece_image_resized = piece_image.resize((SQUARE_SIZE, SQUARE_SIZE), Image.Resampling.LANCZOS)
|
|
img.paste(piece_image_resized, (x0, y0), piece_image_resized)
|
|
|
|
# Draw valid move dots if provided
|
|
if valid_moves:
|
|
valid_dest_squares = set()
|
|
for move in valid_moves:
|
|
valid_dest_squares.add(move.to_square)
|
|
|
|
for square in valid_dest_squares:
|
|
file = chess.square_file(square)
|
|
rank = chess.square_rank(square)
|
|
|
|
# Flip coordinates if perspective is black
|
|
display_rank = rank if perspective_white else 7 - rank
|
|
display_file = file if perspective_white else 7 - file
|
|
|
|
# Calculate center of square for dot
|
|
center_x = MARGIN + display_file * SQUARE_SIZE + SQUARE_SIZE // 2
|
|
center_y = MARGIN + (7 - display_rank) * SQUARE_SIZE + SQUARE_SIZE // 2
|
|
|
|
# Draw a circle (dot) to indicate valid move
|
|
dot_radius = SQUARE_SIZE // 6
|
|
draw.ellipse(
|
|
[(center_x - dot_radius, center_y - dot_radius),
|
|
(center_x + dot_radius, center_y + dot_radius)],
|
|
fill=VALID_MOVE_COLOR
|
|
)
|
|
|
|
# Draw file labels (a-h) along the bottom
|
|
text_color = (220, 220, 220) # Light gray color for labels
|
|
for file in range(8):
|
|
# Determine the correct file label based on perspective
|
|
display_file = file if perspective_white else 7 - file
|
|
file_label = chr(97 + display_file) # 97 is ASCII for 'a'
|
|
|
|
# Position for the file label (bottom)
|
|
x = MARGIN + file * SQUARE_SIZE + SQUARE_SIZE // 2
|
|
y = MARGIN + 8 * SQUARE_SIZE + MARGIN // 2
|
|
|
|
# Calculate text position for centering
|
|
try:
|
|
bbox = draw.textbbox((0, 0), file_label, font=label_font)
|
|
text_width = bbox[2] - bbox[0]
|
|
text_height = bbox[3] - bbox[1]
|
|
text_x = x - text_width // 2
|
|
text_y = y - text_height // 2
|
|
except AttributeError:
|
|
# Fallback for older Pillow versions
|
|
try:
|
|
text_width, text_height = draw.textsize(file_label, font=label_font)
|
|
except:
|
|
text_width, text_height = label_font.getsize(file_label)
|
|
text_x = x - text_width // 2
|
|
text_y = y - text_height // 2
|
|
|
|
draw.text((text_x, text_y), file_label, fill=text_color, font=label_font)
|
|
|
|
# Draw rank labels (1-8) along the side
|
|
for rank in range(8):
|
|
# Determine the correct rank label based on perspective
|
|
display_rank = rank if perspective_white else 7 - rank
|
|
rank_label = str(8 - display_rank) # Ranks go from 8 to 1
|
|
|
|
# Position for the rank label (left side)
|
|
x = MARGIN // 2
|
|
y = MARGIN + display_rank * SQUARE_SIZE + SQUARE_SIZE // 2
|
|
|
|
# Calculate text position for centering
|
|
try:
|
|
bbox = draw.textbbox((0, 0), rank_label, font=label_font)
|
|
text_width = bbox[2] - bbox[0]
|
|
text_height = bbox[3] - bbox[1]
|
|
text_x = x - text_width // 2
|
|
text_y = y - text_height // 2
|
|
except AttributeError:
|
|
# Fallback for older Pillow versions
|
|
try:
|
|
text_width, text_height = draw.textsize(rank_label, font=label_font)
|
|
except:
|
|
text_width, text_height = label_font.getsize(rank_label)
|
|
text_x = x - text_width // 2
|
|
text_y = y - text_height // 2
|
|
|
|
draw.text((text_x, text_y), rank_label, fill=text_color, font=label_font)
|
|
|
|
# Save image to a bytes buffer
|
|
img_byte_arr = io.BytesIO()
|
|
img.save(img_byte_arr, format='PNG')
|
|
img_byte_arr.seek(0)
|
|
|
|
return discord.File(fp=img_byte_arr, filename="chess_board.png")
|
|
|
|
# --- Chess Game Modal for Move Input ---
|
|
class MoveInputModal(ui.Modal, title='Enter Your Move'):
|
|
move_input = ui.TextInput(
|
|
label='Move (e.g., e4, Nf3, O-O)',
|
|
placeholder='Enter move in algebraic notation (SAN or UCI)',
|
|
required=True,
|
|
style=discord.TextStyle.short,
|
|
max_length=10 # e.g., e8=Q# is 5, allow some buffer
|
|
)
|
|
|
|
def __init__(self, game_view: Union['ChessView', 'ChessBotView']):
|
|
super().__init__(timeout=120.0) # 2 minute timeout for modal
|
|
self.game_view = game_view
|
|
|
|
async def on_submit(self, interaction: discord.Interaction):
|
|
move_text = self.move_input.value.strip()
|
|
board = self.game_view.board
|
|
move = None
|
|
|
|
try:
|
|
move = board.parse_san(move_text)
|
|
if not board.is_legal(move):
|
|
await interaction.response.send_message(
|
|
f"Illegal move: '{move_text}' is not valid in the current position.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
except ValueError:
|
|
try:
|
|
move = board.parse_uci(move_text)
|
|
if not board.is_legal(move):
|
|
await interaction.response.send_message(
|
|
f"Illegal move: '{move_text}' is not valid in the current position.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
except ValueError:
|
|
await interaction.response.send_message(
|
|
f"Invalid move format or illegal move: '{move_text}'. Use algebraic notation (e.g., Nf3, e4, O-O) or UCI (e.g., e2e4).",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Check if the parsed move is legal
|
|
if move not in board.legal_moves:
|
|
# Try to provide the SAN representation of the attempted move for clarity
|
|
try:
|
|
move_san = board.san(move)
|
|
except ValueError: # If the move itself was fundamentally invalid (e.g., piece doesn't exist)
|
|
move_san = move_text # Fallback to user input
|
|
await interaction.response.send_message(
|
|
f"Illegal move: '{move_san}' is not legal in the current position.",
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Defer interaction here as move processing might take time (esp. for bot game)
|
|
await interaction.response.defer() # Acknowledge modal submission
|
|
|
|
# Process the valid move in the respective view
|
|
if isinstance(self.game_view, ChessView):
|
|
await self.game_view.handle_move(interaction, move)
|
|
elif isinstance(self.game_view, ChessBotView):
|
|
await self.game_view.handle_player_move(interaction, move)
|
|
|
|
async def on_error(self, interaction: discord.Interaction, error: Exception):
|
|
print(f"Error in MoveInputModal: {error}")
|
|
try:
|
|
if interaction.response.is_done():
|
|
await interaction.followup.send("An error occurred submitting your move.", ephemeral=True)
|
|
else:
|
|
await interaction.response.send_message("An error occurred submitting your move.", ephemeral=True)
|
|
except Exception as e:
|
|
print(f"Failed to send error response in MoveInputModal: {e}")
|
|
|
|
# --- Chess Game (Player vs Player) ---
|
|
class ChessView(ui.View):
|
|
def __init__(self, white_player: discord.Member, black_player: discord.Member, board: Optional[chess.Board] = None):
|
|
super().__init__(timeout=600.0) # 10 minute timeout
|
|
self.white_player = white_player
|
|
self.black_player = black_player
|
|
self.board = board if board else chess.Board() # Use provided board or create new
|
|
# Determine current player based on board state
|
|
self.current_player = self.white_player if self.board.turn == chess.WHITE else self.black_player
|
|
self.message: Optional[discord.Message] = None
|
|
self.last_move: Optional[chess.Move] = None # Store last move for highlighting
|
|
self.white_dm_message: Optional[discord.Message] = None # DM message for white player
|
|
self.black_dm_message: Optional[discord.Message] = None # DM message for black player
|
|
|
|
# Button-driven move selection state
|
|
self.move_selection_mode = False # Whether we're in button-driven move selection mode
|
|
self.selected_file = None # Selected file (0-7) during move selection
|
|
self.selected_rank = None # Selected rank (0-7) during move selection
|
|
self.selected_square = None # Selected square (0-63) during move selection
|
|
self.valid_moves = [] # List of valid moves from the selected square
|
|
self.game_pgn = chess.pgn.Game() # Initialize PGN game object
|
|
self.game_pgn.headers["Event"] = "Discord Chess Game"
|
|
self.game_pgn.headers["Site"] = "Discord"
|
|
self.game_pgn.headers["White"] = self.white_player.display_name
|
|
self.game_pgn.headers["Black"] = self.black_player.display_name
|
|
# If starting from a non-standard position, set FEN header and setup board
|
|
if board:
|
|
self.game_pgn.setup(board) # Setup PGN from the board state
|
|
else: # Standard starting position
|
|
# Setup with the initial board state even if it's standard, ensures node exists
|
|
self.game_pgn.setup(self.board)
|
|
self.pgn_node = self.game_pgn # Track the current node for adding moves
|
|
|
|
# Add control buttons
|
|
self.add_item(self.MakeMoveButton())
|
|
self.add_item(self.SelectMoveButton())
|
|
self.add_item(self.ResignButton())
|
|
|
|
# --- Button Definitions ---
|
|
|
|
class MakeMoveButton(ui.Button):
|
|
def __init__(self):
|
|
super().__init__(label="Make Move", style=discord.ButtonStyle.primary, custom_id="chess_make_move")
|
|
|
|
async def callback(self, interaction: discord.Interaction):
|
|
view: 'ChessView' = self.view
|
|
# Check if it's the correct player's turn before showing modal
|
|
if interaction.user != view.current_player:
|
|
await interaction.response.send_message("It's not your turn!", ephemeral=True)
|
|
return
|
|
# Open the modal for move input
|
|
await interaction.response.send_modal(MoveInputModal(game_view=view))
|
|
|
|
class SelectMoveButton(ui.Button):
|
|
"""Button to start the button-driven move selection process."""
|
|
def __init__(self):
|
|
super().__init__(label="Select Move", style=discord.ButtonStyle.primary, custom_id="chess_select_move")
|
|
|
|
async def callback(self, interaction: discord.Interaction):
|
|
view: 'ChessView' = self.view
|
|
# Check if it's the correct player's turn
|
|
if interaction.user != view.current_player:
|
|
await interaction.response.send_message("It's not your turn!", ephemeral=True)
|
|
return
|
|
|
|
# Start the move selection process
|
|
view.move_selection_mode = True
|
|
view.selected_file = None
|
|
view.selected_rank = None
|
|
view.selected_square = None
|
|
view.valid_moves = []
|
|
|
|
# Show file selection buttons
|
|
await view.show_file_selection(interaction)
|
|
|
|
class ResignButton(ui.Button):
|
|
def __init__(self):
|
|
super().__init__(label="Resign", style=discord.ButtonStyle.danger, custom_id="chess_resign")
|
|
|
|
async def callback(self, interaction: discord.Interaction):
|
|
view: 'ChessView' = self.view
|
|
resigning_player = interaction.user
|
|
# Check if the resigner is part of the game
|
|
if resigning_player.id not in [view.white_player.id, view.black_player.id]:
|
|
await interaction.response.send_message("You are not part of this game.", ephemeral=True)
|
|
return
|
|
winner = view.black_player if resigning_player == view.white_player else view.white_player
|
|
await view.end_game(interaction, f"{resigning_player.mention} resigned. {winner.mention} wins! 🏳️")
|
|
|
|
# --- Button Classes for Move Selection ---
|
|
|
|
class FileButton(ui.Button):
|
|
"""Button for selecting a file (A-H) in the first phase of move selection."""
|
|
def __init__(self, file_idx: int):
|
|
self.file_idx = file_idx
|
|
file_label = chr(65 + file_idx) # 65 is ASCII for 'A'
|
|
super().__init__(label=file_label, style=discord.ButtonStyle.primary)
|
|
|
|
async def callback(self, interaction: discord.Interaction):
|
|
view: 'ChessView' = self.view
|
|
|
|
# Basic checks
|
|
if interaction.user != view.current_player:
|
|
await interaction.response.send_message("It's not your turn!", ephemeral=True)
|
|
return
|
|
|
|
# Store the selected file and show rank buttons
|
|
view.selected_file = self.file_idx
|
|
view.selected_rank = None
|
|
view.selected_square = None
|
|
|
|
# Show rank selection buttons
|
|
await view.show_rank_selection(interaction)
|
|
|
|
class RankButton(ui.Button):
|
|
"""Button for selecting a rank (1-8) in the first phase of move selection."""
|
|
def __init__(self, rank_idx: int):
|
|
self.rank_idx = rank_idx
|
|
rank_label = str(8 - rank_idx) # Ranks are displayed as 8 to 1
|
|
super().__init__(label=rank_label, style=discord.ButtonStyle.primary)
|
|
|
|
async def callback(self, interaction: discord.Interaction):
|
|
view: 'ChessView' = self.view
|
|
|
|
# Basic checks
|
|
if interaction.user != view.current_player:
|
|
await interaction.response.send_message("It's not your turn!", ephemeral=True)
|
|
return
|
|
|
|
# Calculate the square index
|
|
file_idx = view.selected_file
|
|
rank_idx = self.rank_idx
|
|
square = chess.square(file_idx, 7 - rank_idx) # Convert to chess.py square index
|
|
|
|
# Check if the square has a piece of the current player's color
|
|
piece = view.board.piece_at(square)
|
|
if piece is None or piece.color != view.board.turn:
|
|
await interaction.response.send_message("You must select a square with one of your pieces.", ephemeral=True)
|
|
# Go back to file selection
|
|
await view.show_file_selection(interaction)
|
|
return
|
|
|
|
# Find valid moves from this square
|
|
valid_moves = [move for move in view.board.legal_moves if move.from_square == square]
|
|
if not valid_moves:
|
|
await interaction.response.send_message("This piece has no legal moves.", ephemeral=True)
|
|
# Go back to file selection
|
|
await view.show_file_selection(interaction)
|
|
return
|
|
|
|
# Store the selected square and valid moves
|
|
view.selected_square = square
|
|
view.valid_moves = valid_moves
|
|
|
|
# Show valid move buttons
|
|
await view.show_valid_moves(interaction)
|
|
|
|
class MoveButton(ui.Button):
|
|
"""Button for selecting a destination square in the second phase of move selection."""
|
|
def __init__(self, move: chess.Move):
|
|
self.move = move
|
|
# Get the destination square coordinates
|
|
file_idx = chess.square_file(move.to_square)
|
|
rank_idx = chess.square_rank(move.to_square)
|
|
# Create label in algebraic notation (e.g., "e4")
|
|
label = f"{chr(97 + file_idx)}{rank_idx + 1}"
|
|
super().__init__(label=label, style=discord.ButtonStyle.success)
|
|
|
|
async def callback(self, interaction: discord.Interaction):
|
|
view: 'ChessView' = self.view
|
|
|
|
# Basic checks
|
|
if interaction.user != view.current_player:
|
|
await interaction.response.send_message("It's not your turn!", ephemeral=True)
|
|
return
|
|
|
|
# Execute the move
|
|
await interaction.response.defer() # Acknowledge the interaction
|
|
await view.handle_move(interaction, self.move)
|
|
|
|
# --- Button-Driven Move Selection Methods ---
|
|
|
|
async def show_file_selection(self, interaction: discord.Interaction):
|
|
"""Shows buttons for selecting a file (A-H)."""
|
|
# Clear existing buttons
|
|
self.clear_items()
|
|
|
|
# Add file selection buttons (A-H)
|
|
for file_idx in range(8):
|
|
self.add_item(self.FileButton(file_idx))
|
|
|
|
# Add a cancel button to return to normal view
|
|
cancel_button = ui.Button(label="Cancel", style=discord.ButtonStyle.secondary, custom_id="cancel_move_selection")
|
|
cancel_button.callback = self._cancel_move_selection_callback
|
|
self.add_item(cancel_button)
|
|
|
|
# Update the message
|
|
turn_color = "White" if self.board.turn == chess.WHITE else "Black"
|
|
content = f"Chess: {self.white_player.mention} (White) vs {self.black_player.mention} (Black)\n\nSelect a file (A-H) to choose a piece.\nTurn: **{self.current_player.mention}** ({turn_color})"
|
|
board_image = generate_board_image(self.board, self.last_move, perspective_white=(self.current_player == self.white_player))
|
|
|
|
if interaction.response.is_done():
|
|
await interaction.edit_original_response(content=content, attachments=[board_image], view=self)
|
|
else:
|
|
await interaction.response.edit_message(content=content, attachments=[board_image], view=self)
|
|
|
|
async def show_rank_selection(self, interaction: discord.Interaction):
|
|
"""Shows buttons for selecting a rank (1-8)."""
|
|
# Clear existing buttons
|
|
self.clear_items()
|
|
|
|
# Add rank selection buttons (1-8)
|
|
for rank_idx in range(8):
|
|
self.add_item(self.RankButton(rank_idx))
|
|
|
|
# Add a back button to return to file selection
|
|
back_button = ui.Button(label="Back", style=discord.ButtonStyle.secondary, custom_id="back_to_file_selection")
|
|
back_button.callback = self._back_to_file_selection_callback
|
|
self.add_item(back_button)
|
|
|
|
# Add a cancel button to return to normal view
|
|
cancel_button = ui.Button(label="Cancel", style=discord.ButtonStyle.secondary, custom_id="cancel_move_selection")
|
|
cancel_button.callback = self._cancel_move_selection_callback
|
|
self.add_item(cancel_button)
|
|
|
|
# Update the message
|
|
turn_color = "White" if self.board.turn == chess.WHITE else "Black"
|
|
file_letter = chr(65 + self.selected_file) # Convert to A-H
|
|
content = f"Chess: {self.white_player.mention} (White) vs {self.black_player.mention} (Black)\n\nSelected file {file_letter}. Now select a rank (1-8).\nTurn: **{self.current_player.mention}** ({turn_color})"
|
|
board_image = generate_board_image(self.board, self.last_move, perspective_white=(self.current_player == self.white_player))
|
|
|
|
if interaction.response.is_done():
|
|
await interaction.edit_original_response(content=content, attachments=[board_image], view=self)
|
|
else:
|
|
await interaction.response.edit_message(content=content, attachments=[board_image], view=self)
|
|
|
|
async def show_valid_moves(self, interaction: discord.Interaction):
|
|
"""Shows buttons for selecting a destination square from valid moves."""
|
|
# Clear existing buttons
|
|
self.clear_items()
|
|
|
|
# Add buttons for each valid move
|
|
for move in self.valid_moves:
|
|
self.add_item(self.MoveButton(move))
|
|
|
|
# Add a back button to return to file selection
|
|
back_button = ui.Button(label="Back", style=discord.ButtonStyle.secondary, custom_id="back_to_file_selection")
|
|
back_button.callback = self._back_to_file_selection_callback
|
|
self.add_item(back_button)
|
|
|
|
# Add a cancel button to return to normal view
|
|
cancel_button = ui.Button(label="Cancel", style=discord.ButtonStyle.secondary, custom_id="cancel_move_selection")
|
|
cancel_button.callback = self._cancel_move_selection_callback
|
|
self.add_item(cancel_button)
|
|
|
|
# Update the message with valid move dots
|
|
turn_color = "White" if self.board.turn == chess.WHITE else "Black"
|
|
file_letter = chr(65 + self.selected_file) # Convert to A-H
|
|
rank_number = 8 - chess.square_rank(self.selected_square) # Convert to 1-8
|
|
content = f"Chess: {self.white_player.mention} (White) vs {self.black_player.mention} (Black)\n\nSelected piece at {file_letter}{rank_number}. Choose a destination square.\nTurn: **{self.current_player.mention}** ({turn_color})"
|
|
board_image = generate_board_image(
|
|
self.board,
|
|
self.last_move,
|
|
perspective_white=(self.current_player == self.white_player),
|
|
valid_moves=self.valid_moves
|
|
)
|
|
|
|
if interaction.response.is_done():
|
|
await interaction.edit_original_response(content=content, attachments=[board_image], view=self)
|
|
else:
|
|
await interaction.response.edit_message(content=content, attachments=[board_image], view=self)
|
|
|
|
async def _back_to_file_selection_callback(self, interaction: discord.Interaction):
|
|
"""Callback for the 'Back' button to return to file selection."""
|
|
if interaction.user != self.current_player:
|
|
await interaction.response.send_message("It's not your turn!", ephemeral=True)
|
|
return
|
|
await self.show_file_selection(interaction)
|
|
|
|
async def _cancel_move_selection_callback(self, interaction: discord.Interaction):
|
|
"""Callback for the 'Cancel' button to exit move selection mode."""
|
|
if interaction.user != self.current_player:
|
|
await interaction.response.send_message("It's not your turn!", ephemeral=True)
|
|
return
|
|
|
|
# Reset move selection state
|
|
self.move_selection_mode = False
|
|
self.selected_file = None
|
|
self.selected_rank = None
|
|
self.selected_square = None
|
|
self.valid_moves = []
|
|
|
|
# Restore normal view
|
|
self.clear_items()
|
|
self.add_item(self.MakeMoveButton())
|
|
self.add_item(self.SelectMoveButton())
|
|
self.add_item(self.ResignButton())
|
|
|
|
# Update the message
|
|
await self.update_message(interaction, "Move selection cancelled. ")
|
|
|
|
# --- Helper Methods ---
|
|
|
|
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
|
"""Checks are now mostly handled within button callbacks for clarity."""
|
|
# Basic check: is the user part of the game?
|
|
if interaction.user.id not in [self.white_player.id, self.black_player.id]:
|
|
await interaction.response.send_message("You are not part of this game.", ephemeral=True)
|
|
return False
|
|
# Specific turn checks are done in MakeMoveButton callback and MoveInputModal submission
|
|
return True
|
|
|
|
async def _get_dm_content(self, player_perspective: discord.Member, result: Optional[str] = None) -> str:
|
|
"""Generates the FEN and PGN content for the DM from a specific player's perspective."""
|
|
fen = self.board.fen()
|
|
opponent = self.black_player if player_perspective == self.white_player else self.white_player
|
|
opponent_color_str = "Black" if player_perspective == self.white_player else "White"
|
|
|
|
# Update PGN headers if result is provided and game is over
|
|
if result:
|
|
pgn_result_code = "*" # Default for ongoing or unknown
|
|
if result in ["1-0", "0-1", "1/2-1/2"]:
|
|
pgn_result_code = result
|
|
elif "wins" in result:
|
|
if self.white_player.mention in result: pgn_result_code = "1-0"
|
|
elif self.black_player.mention in result: pgn_result_code = "0-1"
|
|
elif "draw" in result:
|
|
pgn_result_code = "1/2-1/2"
|
|
# Only update if not already set or if changing from '*'
|
|
if "Result" not in self.game_pgn.headers or self.game_pgn.headers["Result"] == "*":
|
|
self.game_pgn.headers["Result"] = pgn_result_code
|
|
|
|
# Use an exporter for cleaner PGN output
|
|
exporter = chess.pgn.StringExporter(headers=True, variations=True, comments=True)
|
|
pgn_string = self.game_pgn.accept(exporter)
|
|
# Limit PGN length in DM preview
|
|
pgn_preview = pgn_string[:1500] + "..." if len(pgn_string) > 1500 else pgn_string
|
|
|
|
content = f"Use `/loadchess` to restore this game from FEN or PGN.\n\n" \
|
|
f"**Game vs {opponent.display_name}** ({opponent_color_str})\n\n" \
|
|
f"**FEN:**\n`{fen}`\n\n" \
|
|
f"**PGN:**\n```pgn\n{pgn_preview}\n```"
|
|
|
|
if result:
|
|
content += f"\n\n**Status:** {result}" # Always show the descriptive status message
|
|
|
|
return content
|
|
|
|
async def _send_or_update_dm(self, player: discord.Member, result: Optional[str] = None):
|
|
"""Sends or updates the DM with FEN and PGN for a specific player."""
|
|
is_white = (player == self.white_player)
|
|
dm_message_attr = "white_dm_message" if is_white else "black_dm_message"
|
|
dm_message: Optional[discord.Message] = getattr(self, dm_message_attr, None)
|
|
|
|
try:
|
|
content = await self._get_dm_content(player_perspective=player, result=result)
|
|
dm_channel = player.dm_channel or await player.create_dm()
|
|
|
|
if dm_message:
|
|
try:
|
|
await dm_message.edit(content=content)
|
|
# print(f"Successfully edited DM for {player.display_name}") # Debug
|
|
return # Edited successfully
|
|
except discord.NotFound:
|
|
print(f"DM message for {player.display_name} not found, will send a new one.")
|
|
setattr(self, dm_message_attr, None)
|
|
dm_message = None
|
|
except discord.Forbidden:
|
|
print(f"Cannot edit DM for {player.display_name} (Forbidden). DMs might be closed or message deleted.")
|
|
setattr(self, dm_message_attr, None)
|
|
dm_message = None
|
|
except discord.HTTPException as e:
|
|
print(f"HTTP error editing DM for {player.display_name}: {e}. Will try sending.")
|
|
setattr(self, dm_message_attr, None)
|
|
dm_message = None
|
|
|
|
if dm_message is None:
|
|
new_dm_message = await dm_channel.send(content=content)
|
|
setattr(self, dm_message_attr, new_dm_message)
|
|
# print(f"Successfully sent new DM to {player.display_name}") # Debug
|
|
|
|
except discord.Forbidden:
|
|
print(f"Cannot send DM to {player.display_name} (Forbidden). User likely has DMs disabled.")
|
|
setattr(self, dm_message_attr, None)
|
|
except discord.HTTPException as e:
|
|
print(f"Failed to send/edit DM for {player.display_name}: {e}")
|
|
setattr(self, dm_message_attr, None)
|
|
except Exception as e:
|
|
print(f"Unexpected error sending/updating DM for {player.display_name}: {e}")
|
|
setattr(self, dm_message_attr, None)
|
|
|
|
async def handle_move(self, interaction: discord.Interaction, move: chess.Move):
|
|
"""Handles a validated legal move submitted via the modal."""
|
|
self.board.push(move)
|
|
self.last_move = move # Store for highlighting
|
|
|
|
# Switch turns
|
|
self.current_player = self.black_player if self.current_player == self.white_player else self.white_player
|
|
|
|
# Check for game end
|
|
outcome = self.board.outcome()
|
|
if outcome:
|
|
await self.end_game(interaction, self.get_game_over_message(outcome))
|
|
return
|
|
|
|
# Restore default buttons before updating message
|
|
self.clear_items()
|
|
self.add_item(self.MakeMoveButton())
|
|
self.add_item(self.SelectMoveButton())
|
|
self.add_item(self.ResignButton())
|
|
|
|
# Update the message with the new board state
|
|
await self.update_message(interaction)
|
|
|
|
async def update_message(self, interaction_or_message: Union[discord.Interaction, discord.Message], status_prefix: str = ""):
|
|
"""Updates the game message with the current board image and status."""
|
|
turn_color = "White" if self.board.turn == chess.WHITE else "Black"
|
|
status = f"{status_prefix}Turn: **{self.current_player.mention}** ({turn_color})"
|
|
if self.board.is_check():
|
|
status += " **Check!**"
|
|
|
|
fen_string = self.board.fen()
|
|
content = f"Chess: {self.white_player.mention} (White) vs {self.black_player.mention} (Black)\n\n{status}\nFEN: `{fen_string}`"
|
|
board_image = generate_board_image(self.board, self.last_move, perspective_white=(self.current_player == self.white_player))
|
|
|
|
# Determine how to edit the message
|
|
try:
|
|
if isinstance(interaction_or_message, discord.Interaction):
|
|
# If interaction hasn't been responded to (e.g., initial send)
|
|
if not interaction_or_message.response.is_done():
|
|
await interaction_or_message.response.edit_message(content=content, attachments=[board_image], view=self)
|
|
# If interaction was deferred (e.g., after modal submit)
|
|
else:
|
|
await interaction_or_message.edit_original_response(content=content, attachments=[board_image], view=self)
|
|
elif isinstance(interaction_or_message, discord.Message):
|
|
await interaction_or_message.edit(content=content, attachments=[board_image], view=self)
|
|
except (discord.NotFound, discord.HTTPException) as e:
|
|
print(f"ChessView: Failed to update message: {e}")
|
|
# Handle potential errors like message deleted or permissions lost
|
|
|
|
def get_game_over_message(self, outcome: chess.Outcome) -> str:
|
|
"""Generates the game over message based on the outcome."""
|
|
if outcome.winner == chess.WHITE:
|
|
winner_mention = self.white_player.mention
|
|
loser_mention = self.black_player.mention
|
|
elif outcome.winner == chess.BLACK:
|
|
winner_mention = self.black_player.mention
|
|
loser_mention = self.white_player.mention
|
|
else: # Draw
|
|
winner_mention = "Nobody" # Or maybe mention both?
|
|
|
|
termination_reason = outcome.termination.name.replace("_", " ").title()
|
|
|
|
if outcome.winner is not None:
|
|
message = f"Game Over! **{winner_mention}** ({'White' if outcome.winner == chess.WHITE else 'Black'}) wins by {termination_reason}! 🎉"
|
|
else: # Draw
|
|
message = f"Game Over! It's a draw by {termination_reason}! 🤝"
|
|
|
|
return message
|
|
|
|
async def end_game(self, interaction: discord.Interaction, message_content: str):
|
|
"""Ends the game, disables buttons, stops the engine, and updates the message."""
|
|
await self.disable_all_buttons()
|
|
|
|
# Update DMs with the final result
|
|
dm_update_tasks = [
|
|
self._send_or_update_dm(self.white_player, result=message_content),
|
|
self._send_or_update_dm(self.black_player, result=message_content)
|
|
]
|
|
await asyncio.gather(*dm_update_tasks)
|
|
|
|
# Generate the final board image - ensure it's properly created
|
|
board_image = generate_board_image(self.board, self.last_move, perspective_white=True) # Final board perspective
|
|
|
|
try:
|
|
if interaction.response.is_done():
|
|
# If interaction was already responded to, use followup
|
|
try:
|
|
await interaction.followup.send(content=message_content, file=board_image)
|
|
except discord.HTTPException as e:
|
|
print(f"Failed to send followup: {e}")
|
|
# Fallback to channel send if followup fails
|
|
if interaction.channel:
|
|
await interaction.channel.send(content=message_content, file=board_image)
|
|
else:
|
|
# Edit the interaction response if still valid
|
|
try:
|
|
await interaction.response.edit_message(content=message_content, attachments=[board_image], view=self)
|
|
except discord.HTTPException as e:
|
|
print(f"Failed to edit message: {e}")
|
|
# Fallback to sending a new message
|
|
if interaction.channel:
|
|
await interaction.channel.send(content=message_content, file=board_image)
|
|
except discord.NotFound:
|
|
# If the original message is gone, send a new message
|
|
if interaction.channel:
|
|
await interaction.channel.send(content=message_content, file=board_image)
|
|
except Exception as e:
|
|
print(f"ChessView: Failed to edit or send game end message: {e}")
|
|
# Last resort fallback - try to send a message to the channel if we can access it
|
|
try:
|
|
if interaction.channel:
|
|
await interaction.channel.send(content=message_content, file=board_image)
|
|
elif self.message and self.message.channel:
|
|
await self.message.channel.send(content=message_content, file=board_image)
|
|
except Exception as inner_e:
|
|
print(f"Final fallback also failed: {inner_e}")
|
|
|
|
self.stop()
|
|
|
|
async def disable_all_buttons(self):
|
|
for item in self.children:
|
|
if isinstance(item, ui.Button):
|
|
item.disabled = True
|
|
# Don't edit the message here, let end_game or on_timeout handle the final update
|
|
|
|
async def on_timeout(self):
|
|
if self.message and not self.is_finished():
|
|
await self.disable_all_buttons()
|
|
timeout_msg = f"Chess game between {self.white_player.mention} and {self.black_player.mention} timed out."
|
|
board_image = generate_board_image(self.board, self.last_move, perspective_white=True) # Default perspective on timeout
|
|
try:
|
|
await self.message.edit(content=timeout_msg, attachments=[board_image], view=self)
|
|
except (discord.NotFound, discord.Forbidden, discord.HTTPException):
|
|
pass # Ignore if message is gone or cannot be edited
|
|
self.stop()
|
|
|
|
# --- Chess Bot Game ---
|
|
|
|
# Define paths relative to the script location for better portability
|
|
def get_stockfish_path():
|
|
"""Returns the appropriate Stockfish path based on the OS."""
|
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(SCRIPT_DIR)) # Go up two levels from games dir
|
|
|
|
STOCKFISH_PATH_WINDOWS = os.path.join(PROJECT_ROOT, "stockfish-windows-x86-64-avx2", "stockfish", "stockfish-windows-x86-64-avx2.exe")
|
|
STOCKFISH_PATH_LINUX = os.path.join(PROJECT_ROOT, "stockfish-ubuntu-x86-64-avx2", "stockfish", "stockfish-ubuntu-x86-64-avx2")
|
|
|
|
system = platform.system()
|
|
if system == "Windows":
|
|
if not os.path.exists(STOCKFISH_PATH_WINDOWS):
|
|
raise FileNotFoundError(f"Stockfish not found at expected Windows path: {STOCKFISH_PATH_WINDOWS}")
|
|
return STOCKFISH_PATH_WINDOWS
|
|
elif system == "Linux":
|
|
# Check for execute permissions on Linux
|
|
if not os.path.exists(STOCKFISH_PATH_LINUX):
|
|
raise FileNotFoundError(f"Stockfish not found at expected Linux path: {STOCKFISH_PATH_LINUX}")
|
|
if not os.access(STOCKFISH_PATH_LINUX, os.X_OK):
|
|
print(f"Warning: Stockfish at {STOCKFISH_PATH_LINUX} does not have execute permissions. Attempting to set...")
|
|
try:
|
|
os.chmod(STOCKFISH_PATH_LINUX, 0o755) # Add execute permissions
|
|
if not os.access(STOCKFISH_PATH_LINUX, os.X_OK): # Check again
|
|
raise OSError(f"Failed to set execute permissions for Stockfish at {STOCKFISH_PATH_LINUX}")
|
|
except Exception as e:
|
|
raise OSError(f"Error setting execute permissions for Stockfish: {e}")
|
|
return STOCKFISH_PATH_LINUX
|
|
else:
|
|
raise OSError(f"Unsupported operating system '{system}' for Stockfish.")
|
|
|
|
class ChessBotButton(ui.Button['ChessBotView']):
|
|
def __init__(self, x: int, y: int, piece_symbol: Optional[str] = None):
|
|
# Unicode chess pieces
|
|
self.pieces = {
|
|
'r': '♜', 'n': '♞', 'b': '♝', 'q': '♛', 'k': '♚', 'p': '♟',
|
|
'R': '♖', 'N': '♘', 'B': '♗', 'Q': '♕', 'K': '♔', 'P': '♙',
|
|
None: ' ' # Use a space for empty squares
|
|
}
|
|
self.x = x
|
|
self.y = y
|
|
self.piece_symbol = piece_symbol
|
|
|
|
# Set button style and label based on square color
|
|
is_dark = (x + y) % 2 != 0
|
|
style = discord.ButtonStyle.secondary if is_dark else discord.ButtonStyle.primary
|
|
label = self.pieces.get(piece_symbol, ' ') # Get piece representation or space
|
|
# REMOVED row=y parameter
|
|
super().__init__(style=style, label=label if label != ' ' else '') # Use em-space for empty squares
|
|
|
|
async def callback(self, interaction: discord.Interaction):
|
|
assert self.view is not None
|
|
view: ChessBotView = self.view
|
|
|
|
# Check if it's the player's turn and the engine is ready
|
|
if interaction.user != view.player:
|
|
await interaction.response.send_message("This is not your game!", ephemeral=True)
|
|
return
|
|
if view.board.turn != view.player_color:
|
|
await interaction.response.send_message("It's not your turn!", ephemeral=True)
|
|
return
|
|
if view.engine is None or view.is_thinking:
|
|
await interaction.response.send_message("Please wait for the bot to finish thinking or start.", ephemeral=True)
|
|
return
|
|
|
|
# Process the move
|
|
await view.handle_square_click(interaction, self.x, self.y)
|
|
|
|
class ChessBotView(ui.View):
|
|
# Maps skill level (0-20) to typical ELO ratings for context
|
|
SKILL_ELO_MAP = {
|
|
0: 800, 1: 900, 2: 1000, 3: 1100, 4: 1200, 5: 1300, 6: 1400, 7: 1500, 8: 1600, 9: 1700,
|
|
10: 1800, 11: 1900, 12: 2000, 13: 2100, 14: 2200, 15: 2300, 16: 2400, 17: 2500, 18: 2600,
|
|
19: 2700, 20: 2800
|
|
}
|
|
|
|
def __init__(self, player: discord.Member, player_color: chess.Color, variant: str = "standard", skill_level: int = 10, think_time: float = 1.0, board: Optional[chess.Board] = None):
|
|
super().__init__(timeout=900.0) # 15 minute timeout
|
|
self.player = player
|
|
self.player_color = player_color # The color the human player chose to play as
|
|
self.bot_color = not player_color
|
|
self.variant = variant.lower()
|
|
self.message: Optional[discord.Message] = None
|
|
self.engine: Optional[chess.engine.UciProtocol] = None # Use the async UciProtocol
|
|
self._engine_transport: Optional[asyncio.SubprocessTransport] = None # Store transport for closing
|
|
self.skill_level = max(0, min(20, skill_level)) # Clamp skill level
|
|
self.think_time = max(0.1, min(5.0, think_time)) # Clamp think time
|
|
self.is_thinking = False # Flag to prevent interaction during bot's turn
|
|
self.last_move: Optional[chess.Move] = None # Store last move for highlighting
|
|
self.player_dm_message: Optional[discord.Message] = None # DM message for the player
|
|
|
|
# Button-driven move selection state
|
|
self.move_selection_mode = False # Whether we're in button-driven move selection mode
|
|
self.selected_file = None # Selected file (0-7) during move selection
|
|
self.selected_rank = None # Selected rank (0-7) during move selection
|
|
self.selected_square = None # Selected square (0-63) during move selection
|
|
self.valid_moves = [] # List of valid moves from the selected square
|
|
|
|
# Initialize board - Use provided board or create new based on variant
|
|
if board:
|
|
self.board = board
|
|
# Infer variant from loaded board
|
|
self.variant = "chess960" if self.board.chess960 else "standard"
|
|
self.initial_fen = self.board.fen() if self.variant == "chess960" else None
|
|
else:
|
|
self.variant = variant.lower()
|
|
if self.variant == "chess960":
|
|
self.board = chess.Board(chess960=True)
|
|
self.initial_fen = self.board.fen()
|
|
else: # Standard chess
|
|
self.board = chess.Board()
|
|
self.initial_fen = None
|
|
|
|
# Initialize PGN tracking
|
|
self.game_pgn = chess.pgn.Game()
|
|
self.game_pgn.headers["Event"] = f"Discord Chess Bot Game (Skill {self.skill_level})"
|
|
self.game_pgn.headers["Site"] = "Discord"
|
|
self.game_pgn.headers["White"] = player.display_name if player_color == chess.WHITE else f"Bot (Skill {self.skill_level})"
|
|
self.game_pgn.headers["Black"] = player.display_name if player_color == chess.BLACK else f"Bot (Skill {self.skill_level})"
|
|
# If starting from a non-standard position (loaded board), set up PGN
|
|
if board:
|
|
self.game_pgn.setup(board)
|
|
else:
|
|
self.game_pgn.setup(self.board) # Setup even for standard start
|
|
self.pgn_node = self.game_pgn # Start at the root node
|
|
|
|
# Add control buttons
|
|
self.add_item(self.MakeMoveButton())
|
|
self.add_item(self.SelectMoveButton())
|
|
self.add_item(self.ResignButton())
|
|
|
|
# --- Button Definitions ---
|
|
|
|
class FileButton(ui.Button):
|
|
"""Button for selecting a file (A-H) in the first phase of move selection."""
|
|
def __init__(self, file_idx: int):
|
|
self.file_idx = file_idx
|
|
file_label = chr(65 + file_idx) # 65 is ASCII for 'A'
|
|
super().__init__(label=file_label, style=discord.ButtonStyle.primary)
|
|
|
|
async def callback(self, interaction: discord.Interaction):
|
|
view: 'ChessBotView' = self.view
|
|
|
|
# Basic checks
|
|
if interaction.user != view.player:
|
|
await interaction.response.send_message("This is not your game!", ephemeral=True)
|
|
return
|
|
if view.board.turn != view.player_color:
|
|
await interaction.response.send_message("It's not your turn!", ephemeral=True)
|
|
return
|
|
if view.is_thinking:
|
|
await interaction.response.send_message("The bot is thinking, please wait.", ephemeral=True)
|
|
return
|
|
|
|
# Store the selected file and show rank buttons
|
|
view.selected_file = self.file_idx
|
|
view.selected_rank = None
|
|
view.selected_square = None
|
|
|
|
# Show rank selection buttons
|
|
await view.show_rank_selection(interaction)
|
|
|
|
class RankButton(ui.Button):
|
|
"""Button for selecting a rank (1-8) in the first phase of move selection."""
|
|
def __init__(self, rank_idx: int):
|
|
self.rank_idx = rank_idx
|
|
rank_label = str(8 - rank_idx) # Ranks are displayed as 8 to 1
|
|
super().__init__(label=rank_label, style=discord.ButtonStyle.primary)
|
|
|
|
async def callback(self, interaction: discord.Interaction):
|
|
view: 'ChessBotView' = self.view
|
|
|
|
# Basic checks
|
|
if interaction.user != view.player:
|
|
await interaction.response.send_message("This is not your game!", ephemeral=True)
|
|
return
|
|
if view.board.turn != view.player_color:
|
|
await interaction.response.send_message("It's not your turn!", ephemeral=True)
|
|
return
|
|
if view.is_thinking:
|
|
await interaction.response.send_message("The bot is thinking, please wait.", ephemeral=True)
|
|
return
|
|
|
|
# Calculate the square index
|
|
file_idx = view.selected_file
|
|
rank_idx = self.rank_idx
|
|
square = chess.square(file_idx, 7 - rank_idx) # Convert to chess.py square index
|
|
|
|
# Check if the square has a piece of the player's color
|
|
piece = view.board.piece_at(square)
|
|
if piece is None or piece.color != view.player_color:
|
|
await interaction.response.send_message("You must select a square with one of your pieces.", ephemeral=True)
|
|
# Go back to file selection
|
|
await view.show_file_selection(interaction)
|
|
return
|
|
|
|
# Find valid moves from this square
|
|
valid_moves = [move for move in view.board.legal_moves if move.from_square == square]
|
|
if not valid_moves:
|
|
await interaction.response.send_message("This piece has no legal moves.", ephemeral=True)
|
|
# Go back to file selection
|
|
await view.show_file_selection(interaction)
|
|
return
|
|
|
|
# Store the selected square and valid moves
|
|
view.selected_square = square
|
|
view.valid_moves = valid_moves
|
|
|
|
# Show valid move buttons
|
|
await view.show_valid_moves(interaction)
|
|
|
|
class MoveButton(ui.Button):
|
|
"""Button for selecting a destination square in the second phase of move selection."""
|
|
def __init__(self, move: chess.Move):
|
|
self.move = move
|
|
# Get the destination square coordinates
|
|
file_idx = chess.square_file(move.to_square)
|
|
rank_idx = chess.square_rank(move.to_square)
|
|
# Create label in algebraic notation (e.g., "e4")
|
|
label = f"{chr(97 + file_idx)}{rank_idx + 1}"
|
|
super().__init__(label=label, style=discord.ButtonStyle.success)
|
|
|
|
async def callback(self, interaction: discord.Interaction):
|
|
view: 'ChessBotView' = self.view
|
|
|
|
# Basic checks
|
|
if interaction.user != view.player:
|
|
await interaction.response.send_message("This is not your game!", ephemeral=True)
|
|
return
|
|
if view.board.turn != view.player_color:
|
|
await interaction.response.send_message("It's not your turn!", ephemeral=True)
|
|
return
|
|
if view.is_thinking:
|
|
await interaction.response.send_message("The bot is thinking, please wait.", ephemeral=True)
|
|
return
|
|
|
|
# Execute the move
|
|
await interaction.response.defer() # Acknowledge the interaction
|
|
await view.handle_player_move(interaction, self.move)
|
|
|
|
class MakeMoveButton(ui.Button):
|
|
def __init__(self):
|
|
super().__init__(label="Make Move", style=discord.ButtonStyle.primary, custom_id="chessbot_make_move")
|
|
|
|
async def callback(self, interaction: discord.Interaction):
|
|
view: 'ChessBotView' = self.view
|
|
# Check turn and thinking state
|
|
if interaction.user != view.player:
|
|
await interaction.response.send_message("This is not your game!", ephemeral=True)
|
|
return
|
|
if view.board.turn != view.player_color:
|
|
await interaction.response.send_message("It's not your turn!", ephemeral=True)
|
|
return
|
|
if view.is_thinking:
|
|
await interaction.response.send_message("The bot is thinking, please wait.", ephemeral=True)
|
|
return
|
|
if view.engine is None:
|
|
await interaction.response.send_message("The engine is not running.", ephemeral=True)
|
|
return
|
|
if view.is_thinking: # Added check here as well
|
|
await interaction.response.send_message("The bot is thinking, please wait.", ephemeral=True)
|
|
return
|
|
|
|
# Open the modal for move input
|
|
await interaction.response.send_modal(MoveInputModal(game_view=view))
|
|
|
|
class SelectMoveButton(ui.Button):
|
|
"""Button to start the button-driven move selection process."""
|
|
def __init__(self):
|
|
super().__init__(label="Select Move", style=discord.ButtonStyle.primary, custom_id="chessbot_select_move")
|
|
|
|
async def callback(self, interaction: discord.Interaction):
|
|
view: 'ChessBotView' = self.view
|
|
# Check turn and thinking state
|
|
if interaction.user != view.player:
|
|
await interaction.response.send_message("This is not your game!", ephemeral=True)
|
|
return
|
|
if view.board.turn != view.player_color:
|
|
await interaction.response.send_message("It's not your turn!", ephemeral=True)
|
|
return
|
|
if view.is_thinking:
|
|
await interaction.response.send_message("The bot is thinking, please wait.", ephemeral=True)
|
|
return
|
|
if view.engine is None:
|
|
await interaction.response.send_message("The engine is not running.", ephemeral=True)
|
|
return
|
|
|
|
# Start the move selection process
|
|
view.move_selection_mode = True
|
|
view.selected_file = None
|
|
view.selected_rank = None
|
|
view.selected_square = None
|
|
view.valid_moves = []
|
|
|
|
# Show file selection buttons
|
|
await view.show_file_selection(interaction)
|
|
|
|
class ResignButton(ui.Button):
|
|
def __init__(self):
|
|
super().__init__(label="Resign", style=discord.ButtonStyle.danger, custom_id="chessbot_resign")
|
|
|
|
async def callback(self, interaction: discord.Interaction):
|
|
view: 'ChessBotView' = self.view
|
|
if interaction.user != view.player:
|
|
await interaction.response.send_message("This is not your game!", ephemeral=True)
|
|
return
|
|
# Bot wins on player resignation
|
|
await view.end_game(interaction, f"{view.player.mention} resigned. Bot wins! 🏳️")
|
|
|
|
# --- Engine and Game Logic ---
|
|
|
|
async def start_engine(self):
|
|
"""Initializes the Stockfish engine using the async UCI protocol."""
|
|
engine_protocol = None
|
|
transport = None
|
|
try:
|
|
stockfish_path = get_stockfish_path()
|
|
print(f"[Debug] OS: {platform.system()}, Path used: {stockfish_path}")
|
|
|
|
# Use the async popen_uci
|
|
print("[Debug] Awaiting chess.engine.popen_uci...")
|
|
transport, engine_protocol = await chess.engine.popen_uci(stockfish_path)
|
|
print(f"[Debug] popen_uci successful. Protocol type: {type(engine_protocol)}")
|
|
self.engine = engine_protocol # This is the UciProtocol object
|
|
self._engine_transport = transport
|
|
|
|
# Configure Stockfish options using the configure method (corrected approach)
|
|
# NOTE: The user feedback mentioned using configure on the 'engine object'.
|
|
# However, in this code, self.engine IS the UciProtocol object returned by popen_uci.
|
|
# If UciProtocol doesn't have configure, this might still fail.
|
|
# Let's try the user's suggestion directly first.
|
|
print("[Debug] Configuring engine using configure (async)...")
|
|
options_to_set = {"Skill Level": self.skill_level}
|
|
if self.variant == "chess960":
|
|
# UCI_Chess960 option typically expects a boolean or string "true"/"false".
|
|
# Assuming configure handles this conversion or expects boolean.
|
|
options_to_set["UCI_Chess960"] = True
|
|
await self.engine.configure(options_to_set) # Use configure as suggested
|
|
print("[Debug] Configuration successful.")
|
|
|
|
# Position is set implicitly when calling play/analyse or explicitly via send_command
|
|
# No explicit position call needed here.
|
|
print("[Debug] Engine configured. Position will be set on first play/analyse call.")
|
|
|
|
print(f"Stockfish engine configured for {self.variant} with skill level {self.skill_level}.")
|
|
|
|
except FileNotFoundError as e:
|
|
print(f"[Error] Stockfish executable not found: {e}")
|
|
self.engine = None
|
|
# Notify the user in the channel if the message exists
|
|
if self.message:
|
|
# ... (rest of existing error handling for this block)
|
|
try:
|
|
if hasattr(self, '_interaction') and self._interaction and not self._interaction.response.is_done():
|
|
await self._interaction.followup.send(f"Error: Could not start the chess engine: {e}", ephemeral=True)
|
|
else:
|
|
await self.message.channel.send(f"Error: Could not start the chess engine: {e}")
|
|
except (discord.Forbidden, discord.HTTPException):
|
|
pass
|
|
if not self.is_finished(): self.stop()
|
|
except OSError as e:
|
|
print(f"[Error] OS error during engine start: {e}")
|
|
self.engine = None
|
|
# Notify the user in the channel if the message exists
|
|
if self.message:
|
|
# ... (rest of existing error handling for this block)
|
|
try:
|
|
if hasattr(self, '_interaction') and self._interaction and not self._interaction.response.is_done():
|
|
await self._interaction.followup.send(f"Error: Could not start the chess engine: {e}", ephemeral=True)
|
|
else:
|
|
await self.message.channel.send(f"Error: Could not start the chess engine: {e}")
|
|
except (discord.Forbidden, discord.HTTPException):
|
|
pass
|
|
if not self.is_finished(): self.stop()
|
|
except chess.engine.EngineError as e:
|
|
print(f"[Error] Chess engine error during start/config: {e}")
|
|
if engine_protocol:
|
|
try: await engine_protocol.quit()
|
|
except: pass
|
|
if transport:
|
|
transport.close()
|
|
self.engine = None
|
|
self._engine_transport = None
|
|
# Notify the user in the channel if the message exists
|
|
if self.message:
|
|
# ... (rest of existing error handling for this block)
|
|
try:
|
|
if hasattr(self, '_interaction') and self._interaction and not self._interaction.response.is_done():
|
|
await self._interaction.followup.send(f"Error: Could not start the chess engine: {e}", ephemeral=True)
|
|
else:
|
|
await self.message.channel.send(f"Error: Could not start the chess engine: {e}")
|
|
except (discord.Forbidden, discord.HTTPException):
|
|
pass
|
|
if not self.is_finished(): self.stop()
|
|
except Exception as e:
|
|
# Catch the specific error if possible, otherwise print generic
|
|
print(f"[Error] Unexpected error during engine start: {e}")
|
|
print(f"[Debug] Type of error: {type(e)}") # Print the type of the exception
|
|
if "can't be used in 'await' expression" in str(e):
|
|
print("[Debug] Caught the specific 'await' expression error.")
|
|
if engine_protocol:
|
|
try: await engine_protocol.quit()
|
|
except: pass
|
|
if transport:
|
|
transport.close()
|
|
self.engine = None
|
|
self._engine_transport = None
|
|
# Notify the user in the channel if the message exists
|
|
if self.message:
|
|
try:
|
|
# Use followup if interaction is available and not done
|
|
if hasattr(self, '_interaction') and self._interaction and not self._interaction.response.is_done():
|
|
await self._interaction.followup.send(f"Error: Could not start the chess engine: {e}", ephemeral=True)
|
|
else:
|
|
await self.message.channel.send(f"Error: Could not start the chess engine: {e}")
|
|
except (discord.Forbidden, discord.HTTPException):
|
|
pass # Can't send message
|
|
if not self.is_finished():
|
|
self.stop() # Stop the view if engine fails and view hasn't already stopped
|
|
|
|
async def handle_player_move(self, interaction: discord.Interaction, move: chess.Move):
|
|
"""Handles the player's validated legal move."""
|
|
# Add move to PGN
|
|
self.pgn_node = self.pgn_node.add_variation(move)
|
|
|
|
self.board.push(move)
|
|
self.last_move = move
|
|
|
|
# Reset selection mode state before updating message
|
|
self.move_selection_mode = False
|
|
self.selected_file = None
|
|
self.selected_rank = None
|
|
self.selected_square = None
|
|
self.valid_moves = []
|
|
|
|
# Update player's DM
|
|
asyncio.create_task(self._send_or_update_dm())
|
|
|
|
# Check game state *after* player's move
|
|
outcome = self.board.outcome()
|
|
if outcome:
|
|
await self.end_game(interaction, self.get_game_over_message(outcome))
|
|
return
|
|
|
|
# Update message to show player's move and indicate bot's turn
|
|
await self.update_message(interaction, status_prefix="Bot is thinking...")
|
|
|
|
# Trigger bot's move asynchronously
|
|
# We don't await this directly, as it can take time.
|
|
# The update_message above gives immediate feedback.
|
|
asyncio.create_task(self.make_bot_move())
|
|
|
|
async def make_bot_move(self):
|
|
"""Lets the Stockfish engine make a move using the async protocol."""
|
|
if self.engine is None or self.board.turn != self.bot_color or self.is_thinking or self.is_finished():
|
|
return # Engine not ready, not bot's turn, already thinking, or game ended
|
|
|
|
self.is_thinking = True
|
|
try:
|
|
# Position is set implicitly by the play method when passed the board
|
|
# No explicit position call needed here.
|
|
|
|
# Use the protocol's play method (ASYNC)
|
|
print("[Debug] Awaiting engine.play...")
|
|
result = await self.engine.play(self.board, chess.engine.Limit(time=self.think_time))
|
|
print(f"[Debug] engine.play completed. Result: {result}")
|
|
|
|
# Check if the view is still active before proceeding
|
|
if self.is_finished():
|
|
print("ChessBotView: Game ended while bot was thinking.")
|
|
return
|
|
|
|
if result.move:
|
|
# Add bot's move to PGN
|
|
self.pgn_node = self.pgn_node.add_variation(result.move)
|
|
|
|
self.board.push(result.move)
|
|
self.last_move = result.move
|
|
|
|
# Update player's DM
|
|
asyncio.create_task(self._send_or_update_dm())
|
|
|
|
# Check game state *after* bot's move
|
|
outcome = self.board.outcome()
|
|
if outcome:
|
|
# Need a way to update the message; use self.message if available
|
|
if self.message:
|
|
# Pass the message object directly to end_game
|
|
await self.end_game(self.message, self.get_game_over_message(outcome))
|
|
else: # Should not happen if game started correctly
|
|
print("ChessBotView Error: Cannot end game after bot move, self.message is None.")
|
|
return # Important: return after ending the game
|
|
|
|
# Restore default buttons for player's turn
|
|
if self.message and not self.is_finished(): # Check if view is still active
|
|
self.clear_items()
|
|
self.add_item(self.MakeMoveButton())
|
|
self.add_item(self.SelectMoveButton())
|
|
self.add_item(self.ResignButton())
|
|
# Now update the message
|
|
await self.update_message(self.message, status_prefix="Your turn.")
|
|
else:
|
|
print("ChessBotView: Engine returned no best move (result.move is None).")
|
|
if self.message and not self.is_finished():
|
|
await self.update_message(self.message, status_prefix="Bot failed to find a move. Your turn?")
|
|
|
|
except (chess.engine.EngineError, chess.engine.EngineTerminatedError, Exception) as e:
|
|
print(f"Error during bot move analysis: {e}")
|
|
if self.message and not self.is_finished():
|
|
try:
|
|
# Try to inform the user about the error
|
|
await self.update_message(self.message, status_prefix=f"Error during bot move: {e}. Your turn?")
|
|
except: pass # Ignore errors editing message here
|
|
# Consider stopping the game if the engine has issues
|
|
await self.stop_engine()
|
|
if not self.is_finished():
|
|
self.stop() # Stop the view as well
|
|
finally:
|
|
# Ensure is_thinking is reset even if errors occur or game ends mid-thought
|
|
self.is_thinking = False
|
|
|
|
# --- Message and State Management ---
|
|
|
|
async def update_message(self, interaction_or_message: Union[discord.Interaction, discord.Message], status_prefix: str = ""):
|
|
"""Updates the game message with the current board image and status."""
|
|
content = self.get_board_message(status_prefix)
|
|
|
|
# Determine if we need to show valid move dots (only when showing valid move buttons)
|
|
show_valid_move_dots = self.move_selection_mode and self.selected_square is not None and self.valid_moves
|
|
|
|
board_image = generate_board_image(
|
|
self.board,
|
|
self.last_move,
|
|
perspective_white=(self.player_color == chess.WHITE),
|
|
valid_moves=self.valid_moves if show_valid_move_dots else None
|
|
)
|
|
|
|
# NOTE: Button setup is now handled by the calling function (e.g., handle_player_move, make_bot_move, _cancel_move_selection_callback)
|
|
# This method only updates content and attachments.
|
|
|
|
try:
|
|
if isinstance(interaction_or_message, discord.Interaction):
|
|
# If interaction hasn't been responded to (e.g., initial send)
|
|
if not interaction_or_message.response.is_done():
|
|
await interaction_or_message.response.edit_message(content=content, attachments=[board_image], view=self)
|
|
# If interaction was deferred (e.g., after modal submit)
|
|
else:
|
|
await interaction_or_message.edit_original_response(content=content, attachments=[board_image], view=self)
|
|
elif isinstance(interaction_or_message, discord.Message):
|
|
await interaction_or_message.edit(content=content, attachments=[board_image], view=self)
|
|
except (discord.NotFound, discord.HTTPException) as e:
|
|
print(f"ChessBotView: Failed to update message: {e}")
|
|
# If message update fails, stop the game to prevent inconsistent state
|
|
await self.stop_engine()
|
|
self.stop()
|
|
|
|
def get_board_message(self, status_prefix: str) -> str:
|
|
"""Generates the message content including status and whose turn it is."""
|
|
turn_color_name = "White" if self.board.turn == chess.WHITE else "Black"
|
|
player_mention = self.player.mention
|
|
elo = self.SKILL_ELO_MAP.get(self.skill_level, "Unknown")
|
|
variant_name = "Chess960" if self.variant == "chess960" else "Standard Chess"
|
|
|
|
title = f"{variant_name}: {player_mention} ({'White' if self.player_color == chess.WHITE else 'Black'}) vs Bot (Skill: {self.skill_level}/20, ~{elo} ELO)"
|
|
|
|
# Determine turn indicator string
|
|
if self.board.turn == self.player_color:
|
|
turn_indicator = f"Turn: **Your ({turn_color_name})**"
|
|
else:
|
|
turn_indicator = f"Turn: **Bot ({turn_color_name})**"
|
|
|
|
# Add check indicator
|
|
check_indicator = ""
|
|
if self.board.is_check():
|
|
check_indicator = " **Check!**"
|
|
|
|
return f"{title}\n\n{status_prefix}{check_indicator}\n{turn_indicator}"
|
|
|
|
def get_game_over_message(self, outcome: chess.Outcome) -> str:
|
|
"""Generates the game over message based on the outcome."""
|
|
winner_text = ""
|
|
if outcome.winner == self.player_color:
|
|
winner_text = f"{self.player.mention} ({'White' if self.player_color == chess.WHITE else 'Black'}) wins!"
|
|
elif outcome.winner == self.bot_color:
|
|
winner_text = f"Bot ({'White' if self.bot_color == chess.WHITE else 'Black'}) wins!"
|
|
else:
|
|
winner_text = "It's a draw!"
|
|
|
|
termination_reason = outcome.termination.name.replace("_", " ").title()
|
|
return f"Game Over! **{winner_text} by {termination_reason}**"
|
|
|
|
async def end_game(self, interaction_or_message: Union[discord.Interaction, discord.Message], message_content: str):
|
|
"""Ends the game, disables buttons, stops the engine, and updates the message."""
|
|
if self.is_finished(): return # Avoid double execution
|
|
|
|
await self.disable_all_buttons()
|
|
await self.stop_engine() # Ensure engine is closed before stopping view
|
|
|
|
# Update DM with final result
|
|
await self._send_or_update_dm(result=message_content)
|
|
|
|
# Ensure a valid board image is generated
|
|
try:
|
|
board_image = generate_board_image(self.board, self.last_move, perspective_white=(self.player_color == chess.WHITE)) # Show final board
|
|
except Exception as img_error:
|
|
print(f"Error generating final board image: {img_error}")
|
|
# Create a fallback message if image generation fails
|
|
message_content += "\n\n*Note: Could not generate final board image.*"
|
|
board_image = None
|
|
|
|
# Use a consistent way to get the interaction or message object
|
|
target_message = None
|
|
interaction = None
|
|
channel = None
|
|
|
|
if isinstance(interaction_or_message, discord.Interaction):
|
|
interaction = interaction_or_message
|
|
channel = interaction.channel
|
|
# Try to get the original message if possible
|
|
try:
|
|
if interaction.response.is_done():
|
|
target_message = await interaction.original_response()
|
|
except (discord.NotFound, discord.HTTPException) as e:
|
|
print(f"Could not get original response: {e}")
|
|
target_message = None
|
|
elif isinstance(interaction_or_message, discord.Message):
|
|
target_message = interaction_or_message
|
|
channel = target_message.channel
|
|
|
|
# If we still don't have a channel but have a message stored, use that
|
|
if not channel and self.message:
|
|
channel = self.message.channel
|
|
if not target_message:
|
|
target_message = self.message
|
|
|
|
# Try multiple approaches to send the final game state
|
|
success = False
|
|
|
|
# 1. Try using the interaction if available
|
|
if interaction and not success:
|
|
try:
|
|
if interaction.response.is_done():
|
|
# If interaction was deferred or responded to, try to edit original response
|
|
try:
|
|
if board_image:
|
|
await interaction.edit_original_response(content=message_content, attachments=[board_image], view=self)
|
|
else:
|
|
await interaction.edit_original_response(content=message_content, view=self)
|
|
success = True
|
|
except (discord.NotFound, discord.HTTPException) as e:
|
|
print(f"Failed to edit original response: {e}")
|
|
else:
|
|
# If interaction is fresh, edit its message
|
|
try:
|
|
if board_image:
|
|
await interaction.response.edit_message(content=message_content, attachments=[board_image], view=self)
|
|
else:
|
|
await interaction.response.edit_message(content=message_content, view=self)
|
|
success = True
|
|
except (discord.NotFound, discord.HTTPException) as e:
|
|
print(f"Failed to edit message via response: {e}")
|
|
# Try to send a followup if editing fails
|
|
try:
|
|
if board_image:
|
|
await interaction.followup.send(content=message_content, file=board_image)
|
|
else:
|
|
await interaction.followup.send(content=message_content)
|
|
success = True
|
|
except (discord.NotFound, discord.HTTPException) as followup_e:
|
|
print(f"Failed to send followup: {followup_e}")
|
|
except Exception as e:
|
|
print(f"Error using interaction for end game: {e}")
|
|
|
|
# 2. Try using the target message if available
|
|
if target_message and not success:
|
|
try:
|
|
if board_image:
|
|
await target_message.edit(content=message_content, attachments=[board_image], view=self)
|
|
else:
|
|
await target_message.edit(content=message_content, view=self)
|
|
success = True
|
|
except (discord.NotFound, discord.HTTPException) as e:
|
|
print(f"Failed to edit target message: {e}")
|
|
|
|
# 3. Last resort: send a new message to the channel
|
|
if channel and not success:
|
|
try:
|
|
if board_image:
|
|
await channel.send(content=message_content, file=board_image)
|
|
else:
|
|
await channel.send(content=message_content)
|
|
success = True
|
|
except (discord.Forbidden, discord.HTTPException) as e:
|
|
print(f"Failed to send new message to channel: {e}")
|
|
|
|
if not success:
|
|
print("ChessBotView: All attempts to send game end message failed")
|
|
|
|
self.stop() # Stop the view itself AFTER attempting message update
|
|
|
|
async def disable_all_buttons(self):
|
|
for item in self.children:
|
|
if isinstance(item, ui.Button):
|
|
item.disabled = True
|
|
# Don't edit the message here, let end_game or on_timeout handle the final update
|
|
|
|
async def stop_engine(self):
|
|
"""Safely quits the chess engine using the async protocol and transport."""
|
|
engine_protocol = self.engine
|
|
transport = self._engine_transport
|
|
self.engine = None # Set to None immediately
|
|
self._engine_transport = None # Clear transport reference
|
|
|
|
if engine_protocol:
|
|
try:
|
|
# protocol.quit() is ASYNC
|
|
print("[Debug] Awaiting engine.quit()...")
|
|
await engine_protocol.quit()
|
|
print("Stockfish engine quit command sent successfully.")
|
|
except (chess.engine.EngineError, chess.engine.EngineTerminatedError, Exception) as e:
|
|
print(f"Error sending quit command to Stockfish engine: {e}")
|
|
|
|
if transport:
|
|
try:
|
|
print("[Debug] Closing engine transport...")
|
|
transport.close()
|
|
print("Engine transport closed.")
|
|
except Exception as e:
|
|
print(f"Error closing engine transport: {e}")
|
|
|
|
|
|
async def on_timeout(self):
|
|
if not self.is_finished(): # Only act if not already stopped
|
|
timeout_msg = f"Chess game for {self.player.mention} timed out."
|
|
await self.end_game(self.message, timeout_msg) # Use end_game to handle cleanup and message update
|
|
|
|
async def on_error(self, interaction: discord.Interaction, error: Exception, item: ui.Item):
|
|
print(f"Error in ChessBotView interaction (item: {item}): {error}")
|
|
# Try to send an ephemeral message about the error
|
|
try:
|
|
if interaction.response.is_done():
|
|
await interaction.followup.send(f"An error occurred: {error}", ephemeral=True)
|
|
else:
|
|
await interaction.response.send_message(f"An error occurred: {error}", ephemeral=True)
|
|
except Exception as e:
|
|
print(f"ChessBotView: Failed to send error response: {e}")
|
|
|
|
# Stop the game on error to be safe
|
|
await self.end_game(interaction, f"An error occurred, stopping the game: {error}")
|
|
|
|
# --- Button-Driven Move Selection Methods ---
|
|
|
|
async def show_file_selection(self, interaction: discord.Interaction):
|
|
"""Shows buttons for selecting a file (A-H)."""
|
|
# Clear existing buttons
|
|
self.clear_items()
|
|
|
|
# Add file selection buttons (A-H)
|
|
for file_idx in range(8):
|
|
self.add_item(self.FileButton(file_idx))
|
|
|
|
# Add a cancel button to return to normal view
|
|
cancel_button = ui.Button(label="Cancel", style=discord.ButtonStyle.secondary, custom_id="cancel_move_selection")
|
|
cancel_button.callback = self._cancel_move_selection_callback
|
|
self.add_item(cancel_button)
|
|
|
|
# Update the message
|
|
content = self.get_board_message("Select a file (A-H) to choose a piece. ")
|
|
board_image = generate_board_image(self.board, self.last_move, perspective_white=(self.player_color == chess.WHITE))
|
|
|
|
if interaction.response.is_done():
|
|
await interaction.edit_original_response(content=content, attachments=[board_image], view=self)
|
|
else:
|
|
await interaction.response.edit_message(content=content, attachments=[board_image], view=self)
|
|
|
|
async def show_rank_selection(self, interaction: discord.Interaction):
|
|
"""Shows buttons for selecting a rank (1-8)."""
|
|
# Clear existing buttons
|
|
self.clear_items()
|
|
|
|
# Add rank selection buttons (1-8)
|
|
for rank_idx in range(8):
|
|
self.add_item(self.RankButton(rank_idx))
|
|
|
|
# Add a back button to return to file selection
|
|
back_button = ui.Button(label="Back", style=discord.ButtonStyle.secondary, custom_id="back_to_file_selection")
|
|
back_button.callback = self._back_to_file_selection_callback
|
|
self.add_item(back_button)
|
|
|
|
# Add a cancel button to return to normal view
|
|
cancel_button = ui.Button(label="Cancel", style=discord.ButtonStyle.secondary, custom_id="cancel_move_selection")
|
|
cancel_button.callback = self._cancel_move_selection_callback
|
|
self.add_item(cancel_button)
|
|
|
|
# Update the message
|
|
file_letter = chr(65 + self.selected_file) # Convert to A-H
|
|
content = self.get_board_message(f"Selected file {file_letter}. Now select a rank (1-8). ")
|
|
board_image = generate_board_image(self.board, self.last_move, perspective_white=(self.player_color == chess.WHITE))
|
|
|
|
if interaction.response.is_done():
|
|
await interaction.edit_original_response(content=content, attachments=[board_image], view=self)
|
|
else:
|
|
await interaction.response.edit_message(content=content, attachments=[board_image], view=self)
|
|
|
|
async def show_valid_moves(self, interaction: discord.Interaction):
|
|
"""Shows buttons for selecting a destination square from valid moves."""
|
|
# Clear existing buttons
|
|
self.clear_items()
|
|
|
|
# Add buttons for each valid move
|
|
for move in self.valid_moves:
|
|
self.add_item(self.MoveButton(move))
|
|
|
|
# Add a back button to return to file selection
|
|
back_button = ui.Button(label="Back", style=discord.ButtonStyle.secondary, custom_id="back_to_file_selection")
|
|
back_button.callback = self._back_to_file_selection_callback
|
|
self.add_item(back_button)
|
|
|
|
# Add a cancel button to return to normal view
|
|
cancel_button = ui.Button(label="Cancel", style=discord.ButtonStyle.secondary, custom_id="cancel_move_selection")
|
|
cancel_button.callback = self._cancel_move_selection_callback
|
|
self.add_item(cancel_button)
|
|
|
|
# Update the message with valid move dots
|
|
file_letter = chr(65 + self.selected_file) # Convert to A-H
|
|
rank_number = 8 - chess.square_rank(self.selected_square) # Convert to 1-8
|
|
content = self.get_board_message(f"Selected piece at {file_letter}{rank_number}. Choose a destination square. ")
|
|
board_image = generate_board_image(
|
|
self.board,
|
|
self.last_move,
|
|
perspective_white=(self.player_color == chess.WHITE),
|
|
valid_moves=self.valid_moves
|
|
)
|
|
|
|
if interaction.response.is_done():
|
|
await interaction.edit_original_response(content=content, attachments=[board_image], view=self)
|
|
else:
|
|
await interaction.response.edit_message(content=content, attachments=[board_image], view=self)
|
|
|
|
async def _back_to_file_selection_callback(self, interaction: discord.Interaction):
|
|
"""Callback for the 'Back' button to return to file selection."""
|
|
if interaction.user != self.player:
|
|
await interaction.response.send_message("This is not your game!", ephemeral=True)
|
|
return
|
|
await self.show_file_selection(interaction)
|
|
|
|
async def _cancel_move_selection_callback(self, interaction: discord.Interaction):
|
|
"""Callback for the 'Cancel' button to exit move selection mode."""
|
|
if interaction.user != self.player:
|
|
await interaction.response.send_message("This is not your game!", ephemeral=True)
|
|
return
|
|
|
|
# Reset move selection state
|
|
self.move_selection_mode = False
|
|
self.selected_file = None
|
|
self.selected_rank = None
|
|
self.selected_square = None
|
|
self.valid_moves = []
|
|
|
|
# Restore normal view
|
|
self.clear_items()
|
|
self.add_item(self.MakeMoveButton())
|
|
self.add_item(self.SelectMoveButton())
|
|
self.add_item(self.ResignButton())
|
|
|
|
# Update the message
|
|
content = self.get_board_message("Move selection cancelled. ")
|
|
board_image = generate_board_image(self.board, self.last_move, perspective_white=(self.player_color == chess.WHITE))
|
|
|
|
if interaction.response.is_done():
|
|
await interaction.edit_original_response(content=content, attachments=[board_image], view=self)
|
|
else:
|
|
await interaction.response.edit_message(content=content, attachments=[board_image], view=self)
|
|
|
|
async def handle_square_click(self, interaction: discord.Interaction, x: int, y: int):
|
|
"""Legacy method for handling square clicks from ChessBotButton."""
|
|
# This method is kept for backward compatibility
|
|
await interaction.response.send_message(
|
|
"Please use the 'Select Move' button for the new button-driven move selection interface.",
|
|
ephemeral=True
|
|
)
|
|
|
|
# --- DM Helper Methods (Adapted for Bot Game) ---
|
|
|
|
async def _get_dm_content(self, result: Optional[str] = None) -> str:
|
|
"""Generates the FEN and PGN content for the player's DM."""
|
|
fen = self.board.fen()
|
|
opponent_name = f"Bot (Skill {self.skill_level})"
|
|
opponent_color_str = "Black" if self.player_color == chess.WHITE else "White"
|
|
|
|
# Update PGN headers if result is provided and game is over
|
|
if result:
|
|
pgn_result_code = "*" # Default
|
|
if result in ["1-0", "0-1", "1/2-1/2"]:
|
|
pgn_result_code = result
|
|
elif "wins" in result:
|
|
if (self.player_color == chess.WHITE and "White" in result) or \
|
|
(self.player_color == chess.BLACK and "Black" in result):
|
|
pgn_result_code = "1-0" if self.player_color == chess.WHITE else "0-1" # Player won
|
|
else:
|
|
pgn_result_code = "0-1" if self.player_color == chess.WHITE else "1-0" # Bot won
|
|
elif "draw" in result:
|
|
pgn_result_code = "1/2-1/2"
|
|
# Only update if not already set or if changing from '*'
|
|
if "Result" not in self.game_pgn.headers or self.game_pgn.headers["Result"] == "*":
|
|
self.game_pgn.headers["Result"] = pgn_result_code
|
|
|
|
# Use an exporter for cleaner PGN output
|
|
exporter = chess.pgn.StringExporter(headers=True, variations=True, comments=True)
|
|
pgn_string = self.game_pgn.accept(exporter)
|
|
pgn_preview = pgn_string[:1500] + "..." if len(pgn_string) > 1500 else pgn_string
|
|
|
|
content = f"**Game vs {opponent_name}** ({opponent_color_str})\n\n" \
|
|
f"**FEN:**\n`{fen}`\n\n" \
|
|
f"**PGN:**\n```pgn\n{pgn_preview}\n```"
|
|
|
|
if result:
|
|
content += f"\n\n**Status:** {result}"
|
|
|
|
return content
|
|
|
|
async def _send_or_update_dm(self, result: Optional[str] = None):
|
|
"""Sends or updates the DM with FEN and PGN for the human player."""
|
|
player = self.player
|
|
dm_message = self.player_dm_message
|
|
|
|
try:
|
|
content = await self._get_dm_content(result=result)
|
|
dm_channel = player.dm_channel or await player.create_dm()
|
|
|
|
if dm_message:
|
|
try:
|
|
await dm_message.edit(content=content)
|
|
return # Edited successfully
|
|
except discord.NotFound:
|
|
print(f"DM message for {player.display_name} not found, will send a new one.")
|
|
self.player_dm_message = None
|
|
dm_message = None
|
|
except discord.Forbidden:
|
|
print(f"Cannot edit DM for {player.display_name} (Forbidden).")
|
|
self.player_dm_message = None
|
|
dm_message = None
|
|
except discord.HTTPException as e:
|
|
print(f"HTTP error editing DM for {player.display_name}: {e}. Will try sending.")
|
|
self.player_dm_message = None
|
|
dm_message = None
|
|
|
|
if dm_message is None:
|
|
new_dm_message = await dm_channel.send(content=content)
|
|
self.player_dm_message = new_dm_message
|
|
|
|
except discord.Forbidden:
|
|
print(f"Cannot send DM to {player.display_name} (Forbidden). User likely has DMs disabled.")
|
|
self.player_dm_message = None
|
|
except discord.HTTPException as e:
|
|
print(f"Failed to send/edit DM for {player.display_name}: {e}")
|
|
self.player_dm_message = None
|
|
except Exception as e:
|
|
print(f"Unexpected error sending/updating DM for {player.display_name}: {e}")
|
|
self.player_dm_message = None
|