1210 lines
48 KiB
Python
1210 lines
48 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""
|
||
|
|
Floating Chess Window for DarkHal 2.0
|
||
|
|
|
||
|
|
A separate window for playing chess against the AI with a visual board.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import tkinter as tk
|
||
|
|
from tkinter import ttk, messagebox, filedialog, scrolledtext
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import os
|
||
|
|
import threading
|
||
|
|
from pathlib import Path
|
||
|
|
import json
|
||
|
|
import datetime
|
||
|
|
|
||
|
|
try:
|
||
|
|
import chess
|
||
|
|
import chess.svg
|
||
|
|
except ImportError:
|
||
|
|
chess = None
|
||
|
|
|
||
|
|
|
||
|
|
class ChessWindow:
|
||
|
|
"""Floating chess game window."""
|
||
|
|
|
||
|
|
def __init__(self, parent, settings_manager):
|
||
|
|
self.parent = parent
|
||
|
|
self.settings = settings_manager
|
||
|
|
self.window = None
|
||
|
|
self.board = None
|
||
|
|
self.engine_process = None
|
||
|
|
self.selected_square = None
|
||
|
|
self.move_history = []
|
||
|
|
self.game_saved = True
|
||
|
|
self.llm_cache = None # Cache for LLM instance
|
||
|
|
self.ai_thinking = False
|
||
|
|
self.flipped_board = False
|
||
|
|
|
||
|
|
if not chess:
|
||
|
|
messagebox.showerror("Missing Dependency",
|
||
|
|
"Please install python-chess: pip install python-chess")
|
||
|
|
return
|
||
|
|
|
||
|
|
self._create_window()
|
||
|
|
|
||
|
|
def _create_window(self):
|
||
|
|
"""Create the floating chess window."""
|
||
|
|
self.window = tk.Toplevel(self.parent)
|
||
|
|
self.window.title("DarkHal Chess - Human vs AI")
|
||
|
|
self.window.geometry("800x900")
|
||
|
|
self.window.resizable(False, False)
|
||
|
|
|
||
|
|
# Set icon
|
||
|
|
try:
|
||
|
|
icon_path = os.path.join(os.path.dirname(__file__), "assets", "Halico.ico")
|
||
|
|
if os.path.exists(icon_path):
|
||
|
|
self.window.iconbitmap(icon_path)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
# Initialize chess board
|
||
|
|
self.board = chess.Board()
|
||
|
|
|
||
|
|
# Create UI
|
||
|
|
self._create_ui()
|
||
|
|
|
||
|
|
# Handle window closing
|
||
|
|
self.window.protocol("WM_DELETE_WINDOW", self._on_closing)
|
||
|
|
|
||
|
|
def _create_ui(self):
|
||
|
|
"""Create the chess UI."""
|
||
|
|
# Title frame
|
||
|
|
title_frame = ttk.Frame(self.window)
|
||
|
|
title_frame.pack(fill=tk.X, padx=10, pady=5)
|
||
|
|
|
||
|
|
title_label = ttk.Label(title_frame, text="DarkHal Chess Engine",
|
||
|
|
font=("Arial", 16, "bold"))
|
||
|
|
title_label.pack()
|
||
|
|
|
||
|
|
subtitle_label = ttk.Label(title_frame, text="Human vs AI Chess Match",
|
||
|
|
font=("Arial", 10))
|
||
|
|
subtitle_label.pack()
|
||
|
|
|
||
|
|
# Game controls frame
|
||
|
|
controls_frame = ttk.LabelFrame(self.window, text="Game Controls", padding=10)
|
||
|
|
controls_frame.pack(fill=tk.X, padx=10, pady=5)
|
||
|
|
|
||
|
|
# Game settings
|
||
|
|
settings_frame = ttk.Frame(controls_frame)
|
||
|
|
settings_frame.pack(fill=tk.X)
|
||
|
|
|
||
|
|
ttk.Label(settings_frame, text="Play as:").grid(row=0, column=0, sticky=tk.W, padx=5)
|
||
|
|
self.play_side_var = tk.StringVar(value="White")
|
||
|
|
ttk.Radiobutton(settings_frame, text="White", variable=self.play_side_var,
|
||
|
|
value="White", command=self._side_changed).grid(row=0, column=1)
|
||
|
|
ttk.Radiobutton(settings_frame, text="Black", variable=self.play_side_var,
|
||
|
|
value="Black", command=self._side_changed).grid(row=0, column=2)
|
||
|
|
|
||
|
|
ttk.Label(settings_frame, text="AI Difficulty:").grid(row=0, column=3, sticky=tk.W, padx=(20, 5))
|
||
|
|
self.difficulty_var = tk.StringVar(value="Medium")
|
||
|
|
ttk.Combobox(settings_frame, textvariable=self.difficulty_var,
|
||
|
|
values=["Easy", "Medium", "Hard", "Expert"],
|
||
|
|
state="readonly", width=10).grid(row=0, column=4)
|
||
|
|
|
||
|
|
# Control buttons
|
||
|
|
buttons_frame = ttk.Frame(controls_frame)
|
||
|
|
buttons_frame.pack(fill=tk.X, pady=10)
|
||
|
|
|
||
|
|
ttk.Button(buttons_frame, text="New Game", command=self._new_game).pack(side=tk.LEFT, padx=5)
|
||
|
|
ttk.Button(buttons_frame, text="Flip Board", command=self._flip_board).pack(side=tk.LEFT, padx=5)
|
||
|
|
ttk.Button(buttons_frame, text="Hint", command=self._get_hint).pack(side=tk.LEFT, padx=5)
|
||
|
|
ttk.Button(buttons_frame, text="Undo", command=self._undo_move).pack(side=tk.LEFT, padx=5)
|
||
|
|
ttk.Button(buttons_frame, text="Analyze", command=self._analyze_position).pack(side=tk.LEFT, padx=5)
|
||
|
|
ttk.Button(buttons_frame, text="Save Game", command=self._save_game).pack(side=tk.LEFT, padx=5)
|
||
|
|
ttk.Button(buttons_frame, text="Load Game", command=self._load_game).pack(side=tk.LEFT, padx=5)
|
||
|
|
|
||
|
|
# Game status
|
||
|
|
status_frame = ttk.Frame(controls_frame)
|
||
|
|
status_frame.pack(fill=tk.X)
|
||
|
|
|
||
|
|
ttk.Label(status_frame, text="Status:").pack(side=tk.LEFT)
|
||
|
|
self.status_var = tk.StringVar(value="Ready to play")
|
||
|
|
self.status_label = ttk.Label(status_frame, textvariable=self.status_var,
|
||
|
|
font=("Arial", 10, "bold"))
|
||
|
|
self.status_label.pack(side=tk.LEFT, padx=10)
|
||
|
|
|
||
|
|
ttk.Label(status_frame, text="Turn:").pack(side=tk.LEFT, padx=(20, 0))
|
||
|
|
self.turn_var = tk.StringVar(value="White")
|
||
|
|
self.turn_label = ttk.Label(status_frame, textvariable=self.turn_var,
|
||
|
|
font=("Arial", 10, "bold"))
|
||
|
|
self.turn_label.pack(side=tk.LEFT, padx=5)
|
||
|
|
|
||
|
|
# Chess board frame
|
||
|
|
board_frame = ttk.LabelFrame(self.window, text="Chess Board", padding=10)
|
||
|
|
board_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
|
||
|
|
|
||
|
|
# Create chess board canvas
|
||
|
|
self.board_canvas = tk.Canvas(board_frame, width=640, height=640, bg="white")
|
||
|
|
self.board_canvas.pack()
|
||
|
|
|
||
|
|
# Bind mouse events
|
||
|
|
self.board_canvas.bind("<Button-1>", self._on_square_click)
|
||
|
|
self.board_canvas.bind("<Motion>", self._on_mouse_motion)
|
||
|
|
|
||
|
|
# Move history frame
|
||
|
|
history_frame = ttk.LabelFrame(self.window, text="Move History", padding=10)
|
||
|
|
history_frame.pack(fill=tk.X, padx=10, pady=5)
|
||
|
|
|
||
|
|
# Move history text
|
||
|
|
self.history_text = tk.Text(history_frame, height=4, wrap=tk.WORD,
|
||
|
|
font=("Consolas", 9), state=tk.DISABLED)
|
||
|
|
history_scroll = ttk.Scrollbar(history_frame, orient="vertical", command=self.history_text.yview)
|
||
|
|
self.history_text.configure(yscrollcommand=history_scroll.set)
|
||
|
|
|
||
|
|
self.history_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||
|
|
history_scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||
|
|
|
||
|
|
# Draw initial board
|
||
|
|
self._draw_board()
|
||
|
|
self._update_status()
|
||
|
|
|
||
|
|
# Start AI if playing as black
|
||
|
|
if self.play_side_var.get() == "Black":
|
||
|
|
self._ai_move()
|
||
|
|
|
||
|
|
def _draw_board(self):
|
||
|
|
"""Draw the chess board and pieces."""
|
||
|
|
self.board_canvas.delete("all")
|
||
|
|
|
||
|
|
# Board dimensions
|
||
|
|
square_size = 80
|
||
|
|
board_size = square_size * 8
|
||
|
|
|
||
|
|
# Colors
|
||
|
|
light_color = "#F0D9B5"
|
||
|
|
dark_color = "#B58863"
|
||
|
|
highlight_color = "#FFFF00"
|
||
|
|
|
||
|
|
# Draw squares
|
||
|
|
for rank in range(8):
|
||
|
|
for file in range(8):
|
||
|
|
x1 = file * square_size
|
||
|
|
y1 = rank * square_size
|
||
|
|
x2 = x1 + square_size
|
||
|
|
y2 = y1 + square_size
|
||
|
|
|
||
|
|
# Determine square color
|
||
|
|
if (rank + file) % 2 == 0:
|
||
|
|
color = light_color
|
||
|
|
else:
|
||
|
|
color = dark_color
|
||
|
|
|
||
|
|
# Highlight selected square
|
||
|
|
square = chess.square(file, 7 - rank)
|
||
|
|
if square == self.selected_square:
|
||
|
|
color = highlight_color
|
||
|
|
|
||
|
|
self.board_canvas.create_rectangle(x1, y1, x2, y2, fill=color, outline="black")
|
||
|
|
|
||
|
|
# Draw coordinates
|
||
|
|
if file == 0: # Left edge - rank labels
|
||
|
|
self.board_canvas.create_text(x1 + 5, y1 + 10, text=str(8 - rank),
|
||
|
|
font=("Arial", 8), anchor="nw")
|
||
|
|
if rank == 7: # Bottom edge - file labels
|
||
|
|
self.board_canvas.create_text(x2 - 10, y2 - 5, text=chr(ord('a') + file),
|
||
|
|
font=("Arial", 8), anchor="se")
|
||
|
|
|
||
|
|
# Draw pieces
|
||
|
|
self._draw_pieces()
|
||
|
|
|
||
|
|
def _draw_pieces(self):
|
||
|
|
"""Draw chess pieces on the board."""
|
||
|
|
square_size = 80
|
||
|
|
|
||
|
|
# Unicode chess pieces
|
||
|
|
piece_symbols = {
|
||
|
|
chess.PAWN: "♟♙", chess.ROOK: "♜♖", chess.KNIGHT: "♞♘",
|
||
|
|
chess.BISHOP: "♝♗", chess.QUEEN: "♛♕", chess.KING: "♚♔"
|
||
|
|
}
|
||
|
|
|
||
|
|
for square in chess.SQUARES:
|
||
|
|
piece = self.board.piece_at(square)
|
||
|
|
if piece:
|
||
|
|
file = chess.square_file(square)
|
||
|
|
rank = chess.square_rank(square)
|
||
|
|
|
||
|
|
x = file * square_size + square_size // 2
|
||
|
|
y = (7 - rank) * square_size + square_size // 2
|
||
|
|
|
||
|
|
# Get piece symbol
|
||
|
|
symbol = piece_symbols[piece.piece_type][0 if piece.color == chess.BLACK else 1]
|
||
|
|
|
||
|
|
self.board_canvas.create_text(x, y, text=symbol, font=("Arial", 48),
|
||
|
|
fill="black" if piece.color == chess.BLACK else "white",
|
||
|
|
anchor="center")
|
||
|
|
|
||
|
|
def _on_square_click(self, event):
|
||
|
|
"""Handle square click events."""
|
||
|
|
if self.board.is_game_over():
|
||
|
|
return
|
||
|
|
|
||
|
|
# Convert canvas coordinates to square
|
||
|
|
square_size = 80
|
||
|
|
file = event.x // square_size
|
||
|
|
rank = 7 - (event.y // square_size)
|
||
|
|
|
||
|
|
if 0 <= file <= 7 and 0 <= rank <= 7:
|
||
|
|
square = chess.square(file, rank)
|
||
|
|
|
||
|
|
if self.selected_square is None:
|
||
|
|
# Select piece
|
||
|
|
piece = self.board.piece_at(square)
|
||
|
|
if piece and piece.color == self.board.turn:
|
||
|
|
# Only allow selecting our pieces on our turn
|
||
|
|
human_color = chess.WHITE if self.play_side_var.get() == "White" else chess.BLACK
|
||
|
|
if self.board.turn == human_color:
|
||
|
|
self.selected_square = square
|
||
|
|
self._draw_board()
|
||
|
|
else:
|
||
|
|
# Try to make move
|
||
|
|
try:
|
||
|
|
move = chess.Move(self.selected_square, square)
|
||
|
|
|
||
|
|
# Check for promotion
|
||
|
|
piece = self.board.piece_at(self.selected_square)
|
||
|
|
if (piece and piece.piece_type == chess.PAWN and
|
||
|
|
((piece.color == chess.WHITE and rank == 7) or
|
||
|
|
(piece.color == chess.BLACK and rank == 0))):
|
||
|
|
# Auto-promote to queen for simplicity
|
||
|
|
move = chess.Move(self.selected_square, square, promotion=chess.QUEEN)
|
||
|
|
|
||
|
|
if move in self.board.legal_moves:
|
||
|
|
self._make_human_move(move)
|
||
|
|
else:
|
||
|
|
messagebox.showwarning("Illegal Move", "That move is not legal!")
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
messagebox.showerror("Move Error", f"Error making move: {e}")
|
||
|
|
|
||
|
|
self.selected_square = None
|
||
|
|
self._draw_board()
|
||
|
|
|
||
|
|
def _on_mouse_motion(self, event):
|
||
|
|
"""Handle mouse motion for hover effects."""
|
||
|
|
# Could add hover highlighting here
|
||
|
|
pass
|
||
|
|
|
||
|
|
def _make_human_move(self, move):
|
||
|
|
"""Make a human move and trigger AI response."""
|
||
|
|
print(f"[CHESS DEBUG] Making human move: {move.uci()}")
|
||
|
|
|
||
|
|
# Get SAN notation BEFORE applying the move
|
||
|
|
san_notation = self.board.san(move)
|
||
|
|
print(f"[CHESS DEBUG] Human move SAN notation: {san_notation}")
|
||
|
|
|
||
|
|
# Add move to board
|
||
|
|
self.board.push(move)
|
||
|
|
print(f"[CHESS DEBUG] Move applied to board. New position: {self.board.fen()}")
|
||
|
|
|
||
|
|
self._add_move_to_history_with_san(move, san_notation)
|
||
|
|
self._draw_board()
|
||
|
|
self._update_status()
|
||
|
|
|
||
|
|
# Check if game is over
|
||
|
|
if self.board.is_game_over():
|
||
|
|
print(f"[CHESS DEBUG] Game is over after human move")
|
||
|
|
self._game_over()
|
||
|
|
return
|
||
|
|
|
||
|
|
print(f"[CHESS DEBUG] Starting AI move in background thread")
|
||
|
|
# AI's turn
|
||
|
|
threading.Thread(target=self._ai_move, daemon=True).start()
|
||
|
|
|
||
|
|
def _ai_move(self):
|
||
|
|
"""Let AI make a move."""
|
||
|
|
print(f"[CHESS DEBUG] AI move started")
|
||
|
|
|
||
|
|
if self.board.is_game_over():
|
||
|
|
print(f"[CHESS DEBUG] Game is over, AI move cancelled")
|
||
|
|
return
|
||
|
|
|
||
|
|
# Check whose turn it is
|
||
|
|
ai_color = chess.BLACK if self.play_side_var.get() == "White" else chess.WHITE
|
||
|
|
ai_color_str = "Black" if ai_color == chess.BLACK else "White"
|
||
|
|
board_turn_str = "Black" if self.board.turn == chess.BLACK else "White"
|
||
|
|
|
||
|
|
if self.board.turn != ai_color:
|
||
|
|
print(f"[CHESS DEBUG] Not AI's turn! Board turn: {board_turn_str}, AI color: {ai_color_str}")
|
||
|
|
return
|
||
|
|
|
||
|
|
print(f"[CHESS DEBUG] AI's turn confirmed. Board turn: {board_turn_str}, AI color: {ai_color_str}")
|
||
|
|
|
||
|
|
# Update status
|
||
|
|
self.window.after(0, lambda: self.status_var.set("AI is thinking..."))
|
||
|
|
|
||
|
|
try:
|
||
|
|
# Get AI move using simple logic for now
|
||
|
|
# In a full implementation, this would call the UCI engine
|
||
|
|
ai_move = self._get_ai_move()
|
||
|
|
|
||
|
|
if ai_move:
|
||
|
|
print(f"[CHESS DEBUG] AI found move: {ai_move.uci()}")
|
||
|
|
self.window.after(0, lambda: self._apply_ai_move(ai_move))
|
||
|
|
else:
|
||
|
|
print(f"[CHESS DEBUG] AI couldn't find a move")
|
||
|
|
self.window.after(0, lambda: self.status_var.set("AI couldn't find a move"))
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
print(f"[CHESS DEBUG] AI move exception: {e}")
|
||
|
|
error_msg = str(e)
|
||
|
|
self.window.after(0, lambda: messagebox.showerror("AI Error", f"AI move failed: {error_msg}"))
|
||
|
|
|
||
|
|
def _get_ai_move(self):
|
||
|
|
"""Get AI move using LLM integration with repetition avoidance."""
|
||
|
|
legal_moves = list(self.board.legal_moves)
|
||
|
|
if not legal_moves:
|
||
|
|
return None
|
||
|
|
|
||
|
|
# Filter out moves that would repeat recent positions
|
||
|
|
filtered_moves = self._filter_repetitive_moves(legal_moves)
|
||
|
|
if not filtered_moves:
|
||
|
|
filtered_moves = legal_moves # Fall back to all legal moves if filtering removes everything
|
||
|
|
|
||
|
|
# Try LLM first
|
||
|
|
llm_move = self._query_llm_for_move()
|
||
|
|
if llm_move and llm_move in filtered_moves:
|
||
|
|
return llm_move
|
||
|
|
elif llm_move and llm_move in legal_moves:
|
||
|
|
print(f"[CHESS DEBUG] LLM suggested repetitive move: {llm_move.uci()}, using fallback")
|
||
|
|
|
||
|
|
# Fallback to strategic heuristics with filtered moves
|
||
|
|
return self._get_strategic_move(filtered_moves)
|
||
|
|
|
||
|
|
def _filter_repetitive_moves(self, legal_moves):
|
||
|
|
"""Filter out moves that would create repetitive positions."""
|
||
|
|
if len(self.board.move_stack) < 4:
|
||
|
|
return legal_moves # Not enough moves to check repetition
|
||
|
|
|
||
|
|
current_fen = self.board.fen().split()[0] # Just board position
|
||
|
|
filtered_moves = []
|
||
|
|
|
||
|
|
for move in legal_moves:
|
||
|
|
# Test if this move would create a repetition
|
||
|
|
self.board.push(move)
|
||
|
|
new_fen = self.board.fen().split()[0]
|
||
|
|
self.board.pop()
|
||
|
|
|
||
|
|
# Check if this position appeared recently
|
||
|
|
is_repetitive = False
|
||
|
|
temp_board = chess.Board()
|
||
|
|
recent_positions = []
|
||
|
|
|
||
|
|
# Build recent position history
|
||
|
|
for historical_move in self.board.move_stack[-6:]:
|
||
|
|
temp_board.push(historical_move)
|
||
|
|
recent_positions.append(temp_board.fen().split()[0])
|
||
|
|
|
||
|
|
# Check if new position would repeat a recent one
|
||
|
|
if new_fen in recent_positions[-4:]: # Last 2 moves
|
||
|
|
is_repetitive = True
|
||
|
|
print(f"[CHESS DEBUG] Filtering repetitive move: {move.uci()}")
|
||
|
|
|
||
|
|
if not is_repetitive:
|
||
|
|
filtered_moves.append(move)
|
||
|
|
|
||
|
|
return filtered_moves if filtered_moves else legal_moves
|
||
|
|
|
||
|
|
def _query_llm_for_move(self):
|
||
|
|
"""Query the LLM for a chess move using DarkHal's main chat system."""
|
||
|
|
try:
|
||
|
|
print(f"[CHESS DEBUG] Starting LLM query for move")
|
||
|
|
|
||
|
|
# Check if chess mode is enabled
|
||
|
|
chess_mode = self.settings.get('model_settings.chess_mode', False)
|
||
|
|
print(f"[CHESS DEBUG] Chess mode enabled: {chess_mode}")
|
||
|
|
|
||
|
|
# Import DarkHal's main chat function
|
||
|
|
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
|
||
|
|
from main import run_prompt
|
||
|
|
|
||
|
|
# Get model path and settings
|
||
|
|
model_path = self.settings.get('paths.last_model_path', '')
|
||
|
|
if not model_path or not os.path.exists(model_path):
|
||
|
|
print(f"[CHESS DEBUG] No valid model path: {model_path}")
|
||
|
|
return None
|
||
|
|
|
||
|
|
# Prepare the chess prompt
|
||
|
|
prompt = self._create_chess_prompt()
|
||
|
|
print(f"[CHESS DEBUG] Created chess prompt: {len(prompt)} characters")
|
||
|
|
|
||
|
|
# Get settings for LLM
|
||
|
|
n_ctx = self.settings.get('model_settings.default_n_ctx', 4096)
|
||
|
|
n_gpu_layers = self.settings.get('model_settings.default_n_gpu_layers', 0)
|
||
|
|
|
||
|
|
# Use multiple attempts with different temperatures
|
||
|
|
max_attempts = 3
|
||
|
|
temperatures = [0.1, 0.3, 0.5]
|
||
|
|
|
||
|
|
for attempt in range(max_attempts):
|
||
|
|
try:
|
||
|
|
print(f"[CHESS DEBUG] Attempt {attempt + 1} with temperature {temperatures[attempt]}")
|
||
|
|
|
||
|
|
# Use DarkHal's run_prompt function (which returns a string directly)
|
||
|
|
response_text = run_prompt(
|
||
|
|
model_path=model_path,
|
||
|
|
prompt=prompt,
|
||
|
|
stream=False,
|
||
|
|
n_ctx=n_ctx,
|
||
|
|
n_gpu_layers=n_gpu_layers,
|
||
|
|
max_tokens=20,
|
||
|
|
chess_mode=chess_mode
|
||
|
|
)
|
||
|
|
|
||
|
|
print(f"[CHESS DEBUG] LLM response: '{response_text}'")
|
||
|
|
|
||
|
|
# Parse the response text directly
|
||
|
|
move = self._parse_move_from_response(response_text)
|
||
|
|
|
||
|
|
if move and move in self.board.legal_moves:
|
||
|
|
print(f"[CHESS DEBUG] Valid move found: {move.uci()} (attempt {attempt + 1})")
|
||
|
|
return move
|
||
|
|
elif move:
|
||
|
|
print(f"[CHESS DEBUG] Invalid move suggested: {move.uci()} not in legal moves")
|
||
|
|
else:
|
||
|
|
print(f"[CHESS DEBUG] Could not parse move from response")
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
print(f"[CHESS DEBUG] LLM attempt {attempt + 1} failed: {e}")
|
||
|
|
import traceback
|
||
|
|
traceback.print_exc()
|
||
|
|
continue
|
||
|
|
|
||
|
|
print(f"[CHESS DEBUG] All LLM attempts failed")
|
||
|
|
return None
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
print(f"[CHESS DEBUG] LLM query failed: {e}")
|
||
|
|
import traceback
|
||
|
|
traceback.print_exc()
|
||
|
|
return None
|
||
|
|
|
||
|
|
def _parse_move_from_response(self, text):
|
||
|
|
"""Parse chess move from LLM response using multiple methods."""
|
||
|
|
# Clean the text
|
||
|
|
text = text.strip().lower()
|
||
|
|
legal_moves = list(self.board.legal_moves)
|
||
|
|
legal_uci = [move.uci() for move in legal_moves]
|
||
|
|
|
||
|
|
# Method 1: Direct UCI format match
|
||
|
|
for move_uci in legal_uci:
|
||
|
|
if move_uci in text:
|
||
|
|
return chess.Move.from_uci(move_uci)
|
||
|
|
|
||
|
|
# Method 2: Look for 4-5 character sequences that could be UCI
|
||
|
|
import re
|
||
|
|
uci_pattern = r'\b[a-h][1-8][a-h][1-8][qrbn]?\b'
|
||
|
|
matches = re.findall(uci_pattern, text)
|
||
|
|
for match in matches:
|
||
|
|
if match in legal_uci:
|
||
|
|
return chess.Move.from_uci(match)
|
||
|
|
|
||
|
|
# Method 3: Try to find SAN notation and convert
|
||
|
|
# Use a safe approach that doesn't call .san() on invalid moves
|
||
|
|
for move in legal_moves:
|
||
|
|
try:
|
||
|
|
# Calculate SAN for this legal move
|
||
|
|
san = self.board.san(move).lower()
|
||
|
|
san_clean = san.replace('+', '').replace('#', '').replace('x', '')
|
||
|
|
if san_clean in text or san in text:
|
||
|
|
return move
|
||
|
|
except:
|
||
|
|
# Skip if SAN calculation fails
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Method 4: Extract first plausible move-like string
|
||
|
|
tokens = text.replace(',', ' ').replace('.', ' ').split()
|
||
|
|
for token in tokens:
|
||
|
|
token = token.strip('.,()[]{}')
|
||
|
|
if len(token) >= 4 and len(token) <= 5:
|
||
|
|
try:
|
||
|
|
# Try as UCI
|
||
|
|
if token in legal_uci:
|
||
|
|
return chess.Move.from_uci(token)
|
||
|
|
except:
|
||
|
|
continue
|
||
|
|
|
||
|
|
return None
|
||
|
|
|
||
|
|
def _get_llm_instance(self):
|
||
|
|
"""Get cached LLM instance."""
|
||
|
|
if self.llm_cache:
|
||
|
|
return self.llm_cache
|
||
|
|
|
||
|
|
try:
|
||
|
|
# Import from main project
|
||
|
|
sys.path.append('..')
|
||
|
|
from llama_cpp import Llama
|
||
|
|
|
||
|
|
# Get model path from settings
|
||
|
|
model_path = self.settings.get('paths.last_model_path', '')
|
||
|
|
if not model_path or not os.path.exists(model_path):
|
||
|
|
return None
|
||
|
|
|
||
|
|
# Create LLM instance
|
||
|
|
self.llm_cache = Llama(
|
||
|
|
model_path=model_path,
|
||
|
|
n_ctx=self.settings.get('model_settings.default_n_ctx', 4096),
|
||
|
|
n_gpu_layers=self.settings.get('model_settings.default_n_gpu_layers', 0),
|
||
|
|
verbose=False
|
||
|
|
)
|
||
|
|
|
||
|
|
return self.llm_cache
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
print(f"Failed to load LLM: {e}")
|
||
|
|
return None
|
||
|
|
|
||
|
|
def _create_chess_prompt(self):
|
||
|
|
"""Create a chess-specific prompt for the LLM with anti-repetition measures."""
|
||
|
|
# Get game context
|
||
|
|
fen = self.board.fen()
|
||
|
|
legal_moves = [move.uci() for move in self.board.legal_moves]
|
||
|
|
# Get move history safely - use stored SAN notation instead of converting
|
||
|
|
move_history = []
|
||
|
|
for item in self.move_history[-10:]:
|
||
|
|
if ' ' in item:
|
||
|
|
move_history.append(item.split(' ')[0]) # Get just the SAN part
|
||
|
|
|
||
|
|
# Determine player color
|
||
|
|
ai_color = "Black" if self.play_side_var.get() == "White" else "White"
|
||
|
|
current_turn = "White" if self.board.turn == chess.WHITE else "Black"
|
||
|
|
|
||
|
|
# Get recent position repetitions
|
||
|
|
recent_positions = []
|
||
|
|
temp_board = chess.Board()
|
||
|
|
for move in self.board.move_stack[-6:]: # Last 3 moves (6 half-moves)
|
||
|
|
temp_board.push(move)
|
||
|
|
recent_positions.append(temp_board.fen().split()[0]) # Just board position, not full FEN
|
||
|
|
|
||
|
|
# Check for repetitive patterns
|
||
|
|
repetition_warning = ""
|
||
|
|
if len(recent_positions) >= 4:
|
||
|
|
if recent_positions[-1] == recent_positions[-3] and recent_positions[-2] == recent_positions[-4]:
|
||
|
|
repetition_warning = "WARNING: Position is repeating! Choose a different strategy to avoid draws."
|
||
|
|
|
||
|
|
# Analyze game phase
|
||
|
|
material_count = len([p for p in str(self.board) if p.isalpha()])
|
||
|
|
if material_count > 28:
|
||
|
|
game_phase = "opening"
|
||
|
|
phase_advice = "Focus on piece development, center control, and king safety."
|
||
|
|
elif material_count > 16:
|
||
|
|
game_phase = "middlegame"
|
||
|
|
phase_advice = "Look for tactical combinations and improve piece coordination."
|
||
|
|
else:
|
||
|
|
game_phase = "endgame"
|
||
|
|
phase_advice = "Activate your king, push passed pawns, and simplify advantageous positions."
|
||
|
|
|
||
|
|
# Create enhanced strategic prompt
|
||
|
|
prompt = f"""You are an expert chess player playing as {ai_color}. It's {current_turn}'s turn.
|
||
|
|
|
||
|
|
Position (FEN): {fen}
|
||
|
|
Game phase: {game_phase}
|
||
|
|
Recent moves: {' '.join(move_history) if move_history else 'Game start'}
|
||
|
|
|
||
|
|
{repetition_warning}
|
||
|
|
|
||
|
|
Available moves (UCI format): {', '.join(legal_moves[:20])}
|
||
|
|
|
||
|
|
Strategic priorities for {game_phase}:
|
||
|
|
{phase_advice}
|
||
|
|
|
||
|
|
Choose the BEST move considering:
|
||
|
|
1. Avoid repeating recent moves or positions
|
||
|
|
2. King safety and piece protection
|
||
|
|
3. {phase_advice.split('.')[0].lower()}
|
||
|
|
4. Tactical opportunities (captures, forks, pins, skewers)
|
||
|
|
5. Long-term strategic advantages
|
||
|
|
|
||
|
|
IMPORTANT: Do not repeat the same move or create position repetitions.
|
||
|
|
|
||
|
|
Respond with ONLY the move in UCI format (e.g., e2e4, g1f3, e7e8q):"""
|
||
|
|
|
||
|
|
return prompt
|
||
|
|
|
||
|
|
def _get_strategic_move(self, legal_moves):
|
||
|
|
"""Get strategic move using chess heuristics."""
|
||
|
|
import random
|
||
|
|
|
||
|
|
scored_moves = []
|
||
|
|
|
||
|
|
for move in legal_moves:
|
||
|
|
score = 0
|
||
|
|
|
||
|
|
# Make move temporarily to evaluate
|
||
|
|
self.board.push(move)
|
||
|
|
|
||
|
|
# Prefer captures with good piece values
|
||
|
|
if self.board.move_stack and self.board.is_capture(self.board.move_stack[-1]):
|
||
|
|
captured_piece = self.board.piece_at(move.to_square)
|
||
|
|
if captured_piece:
|
||
|
|
piece_values = {chess.PAWN: 1, chess.KNIGHT: 3, chess.BISHOP: 3,
|
||
|
|
chess.ROOK: 5, chess.QUEEN: 9, chess.KING: 0}
|
||
|
|
score += piece_values.get(captured_piece.piece_type, 0) * 10
|
||
|
|
|
||
|
|
# Prefer central squares
|
||
|
|
to_square = move.to_square
|
||
|
|
file = chess.square_file(to_square)
|
||
|
|
rank = chess.square_rank(to_square)
|
||
|
|
center_distance = abs(3.5 - file) + abs(3.5 - rank)
|
||
|
|
score += (7 - center_distance) * 2
|
||
|
|
|
||
|
|
# Prefer piece development
|
||
|
|
piece = self.board.piece_at(move.from_square)
|
||
|
|
if piece and piece.piece_type in [chess.KNIGHT, chess.BISHOP]:
|
||
|
|
if chess.square_rank(move.from_square) in [0, 7]: # From back rank
|
||
|
|
score += 15
|
||
|
|
|
||
|
|
# Avoid moving king early (unless castling)
|
||
|
|
if piece and piece.piece_type == chess.KING and not self.board.is_castling(move):
|
||
|
|
if len(self.board.move_stack) < 10: # Early game
|
||
|
|
score -= 10
|
||
|
|
|
||
|
|
# Prefer checks
|
||
|
|
if self.board.is_check():
|
||
|
|
score += 5
|
||
|
|
|
||
|
|
# Avoid leaving pieces hanging
|
||
|
|
if self.board.is_attacked_by(not self.board.turn, move.to_square):
|
||
|
|
score -= piece_values.get(piece.piece_type if piece else chess.PAWN, 0) * 5
|
||
|
|
|
||
|
|
self.board.pop() # Undo temporary move
|
||
|
|
|
||
|
|
scored_moves.append((move, score))
|
||
|
|
|
||
|
|
# Sort by score and add randomness for variety
|
||
|
|
scored_moves.sort(key=lambda x: x[1] + random.random() * 2, reverse=True)
|
||
|
|
return scored_moves[0][0]
|
||
|
|
|
||
|
|
def _apply_ai_move(self, move):
|
||
|
|
"""Apply AI move to the board with validation."""
|
||
|
|
try:
|
||
|
|
print(f"[CHESS DEBUG] Applying AI move: {move.uci()}")
|
||
|
|
|
||
|
|
# Validate move is legal
|
||
|
|
if move not in self.board.legal_moves:
|
||
|
|
print(f"[CHESS DEBUG] Illegal AI move attempted: {move.uci()}")
|
||
|
|
return False
|
||
|
|
|
||
|
|
# Get SAN notation BEFORE applying the move
|
||
|
|
san_notation = self.board.san(move)
|
||
|
|
print(f"[CHESS DEBUG] Move SAN notation: {san_notation}")
|
||
|
|
|
||
|
|
# Apply move
|
||
|
|
self.board.push(move)
|
||
|
|
print(f"[CHESS DEBUG] Move applied to board")
|
||
|
|
|
||
|
|
# Add to history using the pre-calculated SAN
|
||
|
|
self._add_move_to_history_with_san(move, san_notation)
|
||
|
|
self._draw_board()
|
||
|
|
self._update_status()
|
||
|
|
|
||
|
|
print(f"[CHESS DEBUG] AI played: {san_notation} ({move.uci()})")
|
||
|
|
|
||
|
|
if self.board.is_game_over():
|
||
|
|
print(f"[CHESS DEBUG] Game over after AI move")
|
||
|
|
self._game_over()
|
||
|
|
|
||
|
|
return True
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
print(f"[CHESS DEBUG] Error applying AI move: {e}")
|
||
|
|
import traceback
|
||
|
|
traceback.print_exc()
|
||
|
|
return False
|
||
|
|
|
||
|
|
def _add_move_to_history(self, move):
|
||
|
|
"""Add move to history display (deprecated - use _add_move_to_history_with_san)."""
|
||
|
|
# This method is kept for compatibility but should not be used
|
||
|
|
# as it can cause the SAN notation error
|
||
|
|
print(f"[CHESS DEBUG] WARNING: Using deprecated _add_move_to_history method")
|
||
|
|
try:
|
||
|
|
san_notation = self.board.san(move)
|
||
|
|
self._add_move_to_history_with_san(move, san_notation)
|
||
|
|
except Exception as e:
|
||
|
|
print(f"[CHESS DEBUG] Error in deprecated history method: {e}")
|
||
|
|
# Fallback to just UCI notation
|
||
|
|
self._add_move_to_history_with_san(move, move.uci())
|
||
|
|
|
||
|
|
def _add_move_to_history_with_san(self, move, san_notation):
|
||
|
|
"""Add move to history display using pre-calculated SAN notation."""
|
||
|
|
print(f"[CHESS DEBUG] Adding move to history: {san_notation} ({move.uci()})")
|
||
|
|
|
||
|
|
self.history_text.config(state=tk.NORMAL)
|
||
|
|
|
||
|
|
move_num = len(self.board.move_stack) // 2 + 1
|
||
|
|
if len(self.board.move_stack) % 2 == 1: # White move (odd number in stack)
|
||
|
|
self.history_text.insert(tk.END, f"{move_num}. {san_notation} ")
|
||
|
|
else: # Black move (even number in stack)
|
||
|
|
self.history_text.insert(tk.END, f"{san_notation}\n")
|
||
|
|
|
||
|
|
self.history_text.see(tk.END)
|
||
|
|
self.history_text.config(state=tk.DISABLED)
|
||
|
|
|
||
|
|
def _update_status(self):
|
||
|
|
"""Update game status display."""
|
||
|
|
if self.board.is_game_over():
|
||
|
|
if self.board.is_checkmate():
|
||
|
|
winner = "White" if self.board.turn == chess.BLACK else "Black"
|
||
|
|
self.status_var.set(f"Checkmate! {winner} wins!")
|
||
|
|
elif self.board.is_stalemate():
|
||
|
|
self.status_var.set("Stalemate - Draw!")
|
||
|
|
elif self.board.is_insufficient_material():
|
||
|
|
self.status_var.set("Draw - Insufficient material")
|
||
|
|
else:
|
||
|
|
self.status_var.set("Game Over")
|
||
|
|
elif self.board.is_check():
|
||
|
|
self.status_var.set("Check!")
|
||
|
|
else:
|
||
|
|
self.status_var.set("Game in progress")
|
||
|
|
|
||
|
|
# Update turn
|
||
|
|
self.turn_var.set("White" if self.board.turn == chess.WHITE else "Black")
|
||
|
|
|
||
|
|
def _game_over(self):
|
||
|
|
"""Handle game over."""
|
||
|
|
result = "Unknown"
|
||
|
|
if self.board.is_checkmate():
|
||
|
|
winner = "White" if self.board.turn == chess.BLACK else "Black"
|
||
|
|
result = f"{winner} wins by checkmate!"
|
||
|
|
elif self.board.is_stalemate():
|
||
|
|
result = "Draw by stalemate!"
|
||
|
|
elif self.board.is_insufficient_material():
|
||
|
|
result = "Draw by insufficient material!"
|
||
|
|
|
||
|
|
messagebox.showinfo("Game Over", result)
|
||
|
|
|
||
|
|
def _new_game(self):
|
||
|
|
"""Start a new game."""
|
||
|
|
if not self.game_saved:
|
||
|
|
result = messagebox.askyesnocancel("Unsaved Game",
|
||
|
|
"Current game is not saved. Save before starting new game?")
|
||
|
|
if result is True: # Yes - save first
|
||
|
|
if not self._save_game():
|
||
|
|
return # Save cancelled
|
||
|
|
elif result is None: # Cancel
|
||
|
|
return
|
||
|
|
|
||
|
|
self.board = chess.Board()
|
||
|
|
self.selected_square = None
|
||
|
|
self.move_history = []
|
||
|
|
self.game_saved = True
|
||
|
|
|
||
|
|
# Clear history display
|
||
|
|
self.history_text.config(state=tk.NORMAL)
|
||
|
|
self.history_text.delete(1.0, tk.END)
|
||
|
|
self.history_text.config(state=tk.DISABLED)
|
||
|
|
|
||
|
|
self._draw_board()
|
||
|
|
self._update_status()
|
||
|
|
|
||
|
|
# AI goes first if human plays black
|
||
|
|
if self.play_side_var.get() == "Black":
|
||
|
|
threading.Thread(target=self._ai_move, daemon=True).start()
|
||
|
|
|
||
|
|
def _flip_board(self):
|
||
|
|
"""Flip the board view."""
|
||
|
|
self.flipped_board = not self.flipped_board
|
||
|
|
self._draw_board()
|
||
|
|
messagebox.showinfo("Board Flipped", f"Board view {'flipped' if self.flipped_board else 'normal'}")
|
||
|
|
|
||
|
|
def _get_hint(self):
|
||
|
|
"""Get a hint for the current position using AI analysis."""
|
||
|
|
if self.board.is_game_over():
|
||
|
|
messagebox.showinfo("Hint", "Game is over!")
|
||
|
|
return
|
||
|
|
|
||
|
|
if self.ai_thinking:
|
||
|
|
messagebox.showinfo("Hint", "AI is currently thinking. Please wait.")
|
||
|
|
return
|
||
|
|
|
||
|
|
# Check if it's human's turn
|
||
|
|
human_color = chess.WHITE if self.play_side_var.get() == "White" else chess.BLACK
|
||
|
|
if self.board.turn != human_color:
|
||
|
|
messagebox.showinfo("Hint", "It's not your turn!")
|
||
|
|
return
|
||
|
|
|
||
|
|
# Get AI suggestion
|
||
|
|
self.status_var.set("Analyzing position for hint...")
|
||
|
|
|
||
|
|
def get_hint_thread():
|
||
|
|
try:
|
||
|
|
# Use the same AI logic to get best move
|
||
|
|
hint_move = self._get_ai_move()
|
||
|
|
|
||
|
|
if hint_move:
|
||
|
|
from_square = chess.square_name(hint_move.from_square)
|
||
|
|
to_square = chess.square_name(hint_move.to_square)
|
||
|
|
|
||
|
|
# Get piece name
|
||
|
|
piece = self.board.piece_at(hint_move.from_square)
|
||
|
|
piece_name = piece.symbol().upper() if piece else "Piece"
|
||
|
|
|
||
|
|
# Check if it's a special move
|
||
|
|
move_type = ""
|
||
|
|
if self.board.is_capture(hint_move):
|
||
|
|
move_type += " (Capture)"
|
||
|
|
if self.board.is_castling(hint_move):
|
||
|
|
move_type += " (Castling)"
|
||
|
|
if hint_move.promotion:
|
||
|
|
move_type += f" (Promote to {chess.piece_name(hint_move.promotion)})"
|
||
|
|
|
||
|
|
hint_text = f"Suggested move: {piece_name} from {from_square} to {to_square}{move_type}\n\n"
|
||
|
|
hint_text += f"Move notation: {self.board.san(hint_move)}\n"
|
||
|
|
hint_text += f"UCI format: {hint_move.uci()}"
|
||
|
|
|
||
|
|
self.window.after(0, lambda: messagebox.showinfo("Chess Hint", hint_text))
|
||
|
|
else:
|
||
|
|
self.window.after(0, lambda: messagebox.showinfo("Hint", "No good moves found!"))
|
||
|
|
|
||
|
|
self.window.after(0, lambda: self.status_var.set("Ready"))
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
self.window.after(0, lambda: messagebox.showerror("Hint Error", f"Failed to get hint: {e}"))
|
||
|
|
self.window.after(0, lambda: self.status_var.set("Ready"))
|
||
|
|
|
||
|
|
threading.Thread(target=get_hint_thread, daemon=True).start()
|
||
|
|
|
||
|
|
def _undo_move(self):
|
||
|
|
"""Undo the last move(s)."""
|
||
|
|
if self.ai_thinking:
|
||
|
|
messagebox.showinfo("Undo", "Cannot undo while AI is thinking.")
|
||
|
|
return
|
||
|
|
|
||
|
|
if len(self.board.move_stack) >= 2:
|
||
|
|
# Undo both AI and human moves
|
||
|
|
self.board.pop()
|
||
|
|
self.board.pop()
|
||
|
|
self.move_history = self.move_history[:-2]
|
||
|
|
self.game_saved = False
|
||
|
|
|
||
|
|
# Update history display
|
||
|
|
self.history_text.config(state=tk.NORMAL)
|
||
|
|
self.history_text.delete(1.0, tk.END)
|
||
|
|
|
||
|
|
# Rebuild history display
|
||
|
|
for i, move in enumerate(self.move_history):
|
||
|
|
move_num = (i + 2) // 2
|
||
|
|
if i % 2 == 0: # White move
|
||
|
|
self.history_text.insert(tk.END, f"{move_num}. {move} ")
|
||
|
|
else: # Black move
|
||
|
|
self.history_text.insert(tk.END, f"{move}\n")
|
||
|
|
|
||
|
|
self.history_text.config(state=tk.DISABLED)
|
||
|
|
|
||
|
|
self._draw_board()
|
||
|
|
self._update_status()
|
||
|
|
|
||
|
|
elif len(self.board.move_stack) == 1:
|
||
|
|
# Undo only one move
|
||
|
|
self.board.pop()
|
||
|
|
self.move_history = self.move_history[:-1]
|
||
|
|
self.game_saved = False
|
||
|
|
|
||
|
|
self.history_text.config(state=tk.NORMAL)
|
||
|
|
self.history_text.delete(1.0, tk.END)
|
||
|
|
self.history_text.config(state=tk.DISABLED)
|
||
|
|
|
||
|
|
self._draw_board()
|
||
|
|
self._update_status()
|
||
|
|
else:
|
||
|
|
messagebox.showinfo("Undo", "No moves to undo!")
|
||
|
|
|
||
|
|
def _analyze_position(self):
|
||
|
|
"""Analyze current position with detailed information."""
|
||
|
|
if self.ai_thinking:
|
||
|
|
messagebox.showinfo("Analysis", "AI is currently thinking. Please wait.")
|
||
|
|
return
|
||
|
|
|
||
|
|
# Create analysis window
|
||
|
|
analysis_window = tk.Toplevel(self.window)
|
||
|
|
analysis_window.title("Position Analysis")
|
||
|
|
analysis_window.geometry("600x500")
|
||
|
|
analysis_window.transient(self.window)
|
||
|
|
|
||
|
|
# Create notebook for different analysis types
|
||
|
|
notebook = ttk.Notebook(analysis_window)
|
||
|
|
notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
||
|
|
|
||
|
|
# Basic analysis tab
|
||
|
|
basic_frame = ttk.Frame(notebook)
|
||
|
|
notebook.add(basic_frame, text="Basic Info")
|
||
|
|
|
||
|
|
basic_text = tk.Text(basic_frame, wrap=tk.WORD, font=("Consolas", 10))
|
||
|
|
basic_scroll = ttk.Scrollbar(basic_frame, orient="vertical", command=basic_text.yview)
|
||
|
|
basic_text.configure(yscrollcommand=basic_scroll.set)
|
||
|
|
|
||
|
|
basic_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||
|
|
basic_scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||
|
|
|
||
|
|
# Populate basic analysis
|
||
|
|
analysis = f"Position Analysis\n{'='*50}\n\n"
|
||
|
|
analysis += f"FEN: {self.board.fen()}\n\n"
|
||
|
|
analysis += f"Turn: {'White' if self.board.turn == chess.WHITE else 'Black'}\n"
|
||
|
|
analysis += f"Move number: {self.board.fullmove_number}\n"
|
||
|
|
analysis += f"Half-move clock: {self.board.halfmove_clock}\n\n"
|
||
|
|
|
||
|
|
analysis += f"Legal moves: {len(list(self.board.legal_moves))}\n"
|
||
|
|
analysis += f"In check: {'Yes' if self.board.is_check() else 'No'}\n"
|
||
|
|
analysis += f"Can castle kingside: {self.board.has_kingside_castling_rights(self.board.turn)}\n"
|
||
|
|
analysis += f"Can castle queenside: {self.board.has_queenside_castling_rights(self.board.turn)}\n\n"
|
||
|
|
|
||
|
|
# Material count
|
||
|
|
analysis += "Material Count:\n"
|
||
|
|
for color in [chess.WHITE, chess.BLACK]:
|
||
|
|
color_name = "White" if color == chess.WHITE else "Black"
|
||
|
|
analysis += f"\n{color_name}:\n"
|
||
|
|
for piece_type in [chess.PAWN, chess.KNIGHT, chess.BISHOP, chess.ROOK, chess.QUEEN]:
|
||
|
|
count = len(self.board.pieces(piece_type, color))
|
||
|
|
if count > 0:
|
||
|
|
analysis += f" {chess.piece_name(piece_type).title()}s: {count}\n"
|
||
|
|
|
||
|
|
basic_text.insert(1.0, analysis)
|
||
|
|
basic_text.config(state=tk.DISABLED)
|
||
|
|
|
||
|
|
# AI Analysis tab
|
||
|
|
ai_frame = ttk.Frame(notebook)
|
||
|
|
notebook.add(ai_frame, text="AI Analysis")
|
||
|
|
|
||
|
|
ai_text = scrolledtext.ScrolledText(ai_frame, wrap=tk.WORD, font=("Consolas", 10))
|
||
|
|
ai_text.pack(fill=tk.BOTH, expand=True)
|
||
|
|
|
||
|
|
# Get AI analysis
|
||
|
|
def get_ai_analysis():
|
||
|
|
ai_text.insert(tk.END, "Getting AI analysis...\n\n")
|
||
|
|
try:
|
||
|
|
llm = self._get_llm_instance()
|
||
|
|
if llm:
|
||
|
|
analysis_prompt = f"""Analyze this chess position as an expert player:
|
||
|
|
|
||
|
|
Position (FEN): {self.board.fen()}
|
||
|
|
Turn: {'White' if self.board.turn == chess.WHITE else 'Black'}
|
||
|
|
Recent moves: {' '.join([self.board.san(move) for move in self.board.move_stack[-5:]])}
|
||
|
|
|
||
|
|
Provide analysis covering:
|
||
|
|
1. Position evaluation (who's better and why)
|
||
|
|
2. Key tactical and positional themes
|
||
|
|
3. Best moves for the current player
|
||
|
|
4. Strategic plans for both sides
|
||
|
|
5. Critical weaknesses to address
|
||
|
|
|
||
|
|
Analysis:"""
|
||
|
|
|
||
|
|
# Use consistent temperature based on analysis depth
|
||
|
|
response = llm(analysis_prompt, max_tokens=500, temperature=0.4)
|
||
|
|
ai_analysis = response['choices'][0]['text'].strip()
|
||
|
|
|
||
|
|
ai_text.delete(1.0, tk.END)
|
||
|
|
ai_text.insert(1.0, ai_analysis)
|
||
|
|
else:
|
||
|
|
ai_text.delete(1.0, tk.END)
|
||
|
|
ai_text.insert(1.0, "AI analysis not available - no model loaded")
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
ai_text.delete(1.0, tk.END)
|
||
|
|
ai_text.insert(1.0, f"AI analysis failed: {e}")
|
||
|
|
|
||
|
|
threading.Thread(target=get_ai_analysis, daemon=True).start()
|
||
|
|
|
||
|
|
def _save_game(self):
|
||
|
|
"""Save the current game to a PGN file."""
|
||
|
|
if len(self.board.move_stack) == 0:
|
||
|
|
messagebox.showinfo("Save Game", "No moves to save!")
|
||
|
|
return False
|
||
|
|
|
||
|
|
try:
|
||
|
|
# Ask for save location
|
||
|
|
filename = filedialog.asksaveasfilename(
|
||
|
|
title="Save Chess Game",
|
||
|
|
defaultextension=".pgn",
|
||
|
|
filetypes=[("PGN files", "*.pgn"), ("All files", "*.*")],
|
||
|
|
initialname=f"darkhal_chess_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pgn"
|
||
|
|
)
|
||
|
|
|
||
|
|
if not filename:
|
||
|
|
return False
|
||
|
|
|
||
|
|
# Create PGN content
|
||
|
|
pgn_content = self._create_pgn()
|
||
|
|
|
||
|
|
# Write to file
|
||
|
|
with open(filename, 'w', encoding='utf-8') as f:
|
||
|
|
f.write(pgn_content)
|
||
|
|
|
||
|
|
self.game_saved = True
|
||
|
|
messagebox.showinfo("Game Saved", f"Game saved to {os.path.basename(filename)}")
|
||
|
|
return True
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
messagebox.showerror("Save Error", f"Failed to save game: {e}")
|
||
|
|
return False
|
||
|
|
|
||
|
|
def _load_game(self):
|
||
|
|
"""Load a game from a PGN file."""
|
||
|
|
if not self.game_saved:
|
||
|
|
result = messagebox.askyesnocancel("Unsaved Game",
|
||
|
|
"Current game is not saved. Save before loading?")
|
||
|
|
if result is True: # Yes - save first
|
||
|
|
if not self._save_game():
|
||
|
|
return # Save cancelled
|
||
|
|
elif result is None: # Cancel
|
||
|
|
return
|
||
|
|
|
||
|
|
try:
|
||
|
|
# Ask for file to load
|
||
|
|
filename = filedialog.askopenfilename(
|
||
|
|
title="Load Chess Game",
|
||
|
|
filetypes=[("PGN files", "*.pgn"), ("All files", "*.*")]
|
||
|
|
)
|
||
|
|
|
||
|
|
if not filename:
|
||
|
|
return
|
||
|
|
|
||
|
|
# Read PGN file
|
||
|
|
with open(filename, 'r', encoding='utf-8') as f:
|
||
|
|
pgn_content = f.read()
|
||
|
|
|
||
|
|
# Parse and load game
|
||
|
|
if self._parse_pgn(pgn_content):
|
||
|
|
self.game_saved = True
|
||
|
|
messagebox.showinfo("Game Loaded", f"Game loaded from {os.path.basename(filename)}")
|
||
|
|
else:
|
||
|
|
messagebox.showerror("Load Error", "Invalid PGN format or unsupported game")
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
messagebox.showerror("Load Error", f"Failed to load game: {e}")
|
||
|
|
|
||
|
|
def _create_pgn(self):
|
||
|
|
"""Create PGN content from current game."""
|
||
|
|
# PGN headers
|
||
|
|
headers = [
|
||
|
|
'[Event "DarkHal Chess Game"]',
|
||
|
|
f'[Date "{datetime.datetime.now().strftime("%Y.%m.%d")}"]',
|
||
|
|
'[White "Human"]' if self.play_side_var.get() == "White" else '[White "DarkHal AI"]',
|
||
|
|
'[Black "DarkHal AI"]' if self.play_side_var.get() == "White" else '[Black "Human"]',
|
||
|
|
f'[Site "DarkHal 2.0"]',
|
||
|
|
'[Round "1"]'
|
||
|
|
]
|
||
|
|
|
||
|
|
# Game result
|
||
|
|
if self.board.is_game_over():
|
||
|
|
if self.board.is_checkmate():
|
||
|
|
result = "1-0" if self.board.turn == chess.BLACK else "0-1"
|
||
|
|
else:
|
||
|
|
result = "1/2-1/2"
|
||
|
|
else:
|
||
|
|
result = "*"
|
||
|
|
|
||
|
|
headers.append(f'[Result "{result}"]')
|
||
|
|
|
||
|
|
# Create moves section
|
||
|
|
moves = []
|
||
|
|
temp_board = chess.Board()
|
||
|
|
|
||
|
|
for i, move in enumerate(self.board.move_stack):
|
||
|
|
if i % 2 == 0: # White move
|
||
|
|
move_num = (i // 2) + 1
|
||
|
|
moves.append(f"{move_num}. {temp_board.san(move)}")
|
||
|
|
else: # Black move
|
||
|
|
moves.append(temp_board.san(move))
|
||
|
|
temp_board.push(move)
|
||
|
|
|
||
|
|
moves_text = " ".join(moves)
|
||
|
|
if result != "*":
|
||
|
|
moves_text += f" {result}"
|
||
|
|
|
||
|
|
# Combine headers and moves
|
||
|
|
pgn = "\n".join(headers) + "\n\n" + moves_text + "\n"
|
||
|
|
return pgn
|
||
|
|
|
||
|
|
def _parse_pgn(self, pgn_content):
|
||
|
|
"""Parse PGN content and load the game."""
|
||
|
|
try:
|
||
|
|
# Simple PGN parser - extract moves section
|
||
|
|
lines = pgn_content.strip().split('\n')
|
||
|
|
moves_text = ""
|
||
|
|
|
||
|
|
# Find the moves section (after headers)
|
||
|
|
in_moves = False
|
||
|
|
for line in lines:
|
||
|
|
line = line.strip()
|
||
|
|
if not line:
|
||
|
|
continue
|
||
|
|
if line.startswith('['):
|
||
|
|
continue
|
||
|
|
else:
|
||
|
|
moves_text += line + " "
|
||
|
|
in_moves = True
|
||
|
|
|
||
|
|
if not moves_text:
|
||
|
|
return False
|
||
|
|
|
||
|
|
# Clean up moves text
|
||
|
|
moves_text = moves_text.replace('\n', ' ').strip()
|
||
|
|
|
||
|
|
# Remove result markers
|
||
|
|
for result in ['1-0', '0-1', '1/2-1/2', '*']:
|
||
|
|
moves_text = moves_text.replace(result, '').strip()
|
||
|
|
|
||
|
|
# Parse moves
|
||
|
|
self.board = chess.Board()
|
||
|
|
self.history_text.config(state=tk.NORMAL)
|
||
|
|
self.history_text.delete(1.0, tk.END)
|
||
|
|
self.history_text.config(state=tk.DISABLED)
|
||
|
|
|
||
|
|
# Split into tokens and process
|
||
|
|
tokens = moves_text.split()
|
||
|
|
for token in tokens:
|
||
|
|
token = token.strip('.')
|
||
|
|
if not token or token.isdigit():
|
||
|
|
continue
|
||
|
|
|
||
|
|
try:
|
||
|
|
# Try to parse as SAN (Standard Algebraic Notation)
|
||
|
|
move = self.board.parse_san(token)
|
||
|
|
# Get SAN notation before applying move
|
||
|
|
san_notation = self.board.san(move)
|
||
|
|
self.board.push(move)
|
||
|
|
self._add_move_to_history_with_san(move, san_notation)
|
||
|
|
except:
|
||
|
|
# Skip invalid moves
|
||
|
|
continue
|
||
|
|
|
||
|
|
self._draw_board()
|
||
|
|
self._update_status()
|
||
|
|
return True
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
print(f"PGN parsing error: {e}")
|
||
|
|
return False
|
||
|
|
|
||
|
|
def _side_changed(self):
|
||
|
|
"""Handle play side change."""
|
||
|
|
if hasattr(self, 'board') and self.board:
|
||
|
|
# Reset game when side changes
|
||
|
|
self._new_game()
|
||
|
|
|
||
|
|
def _on_closing(self):
|
||
|
|
"""Handle window closing."""
|
||
|
|
if self.engine_process:
|
||
|
|
try:
|
||
|
|
self.engine_process.terminate()
|
||
|
|
except:
|
||
|
|
pass
|
||
|
|
|
||
|
|
self.window.destroy()
|
||
|
|
|
||
|
|
def _on_difficulty_changed(self, event=None):
|
||
|
|
"""Handle difficulty setting change."""
|
||
|
|
difficulty = self.difficulty_var.get()
|
||
|
|
self.status_var.set(f"AI difficulty set to {difficulty}")
|
||
|
|
|
||
|
|
# Clear LLM cache to ensure new difficulty settings take effect
|
||
|
|
self.llm_cache = None
|
||
|
|
|
||
|
|
|
||
|
|
def open_chess_window(parent, settings_manager):
|
||
|
|
"""Open the floating chess window."""
|
||
|
|
ChessWindow(parent, settings_manager)
|