Files
dark_hal/chess_window.py

1210 lines
48 KiB
Python
Raw Normal View History

2026-03-13 12:56:43 -07:00
#!/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)