2025-08-22 17:26:17 +02:00
|
|
|
|
import time
|
2025-08-22 16:49:30 +02:00
|
|
|
|
import random
|
2025-08-22 17:26:17 +02:00
|
|
|
|
from typing import Optional, Tuple
|
2025-08-22 16:49:30 +02:00
|
|
|
|
|
|
|
|
|
|
import chess
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RandomEngine:
|
2025-08-22 17:26:17 +02:00
|
|
|
|
"""A simple engine with a tiny alpha-beta search and material+mobility eval.
|
2025-08-22 16:49:30 +02:00
|
|
|
|
|
2025-08-22 17:26:17 +02:00
|
|
|
|
Keeps the same name for compatibility, but no longer picks purely random moves.
|
2025-08-22 16:49:30 +02:00
|
|
|
|
"""
|
|
|
|
|
|
|
2025-08-22 18:35:59 +02:00
|
|
|
|
def __init__(self, depth: int = 100, max_time_sec: float = 20):
|
2025-08-22 17:26:17 +02:00
|
|
|
|
self.depth = depth
|
|
|
|
|
|
self.max_time_sec = max_time_sec
|
|
|
|
|
|
|
|
|
|
|
|
# Centipawn values
|
|
|
|
|
|
self.piece_values = {
|
|
|
|
|
|
chess.PAWN: 100,
|
|
|
|
|
|
chess.KNIGHT: 320,
|
|
|
|
|
|
chess.BISHOP: 330,
|
|
|
|
|
|
chess.ROOK: 500,
|
|
|
|
|
|
chess.QUEEN: 900,
|
|
|
|
|
|
chess.KING: 0,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-22 18:35:59 +02:00
|
|
|
|
# Tiny hand-crafted opening book (UCIs); used only for the first few plies
|
|
|
|
|
|
# Keys are tuples of UCI moves played so far from the starting position
|
|
|
|
|
|
self.opening_book: dict[tuple[str, ...], list[str]] = {
|
|
|
|
|
|
# As White (start position)
|
|
|
|
|
|
tuple(): ["e2e4", "d2d4", "c2c4", "g1f3"],
|
|
|
|
|
|
# As Black after 1.e4
|
2025-08-23 18:13:53 +02:00
|
|
|
|
("e2e4",): ["e7e5", "c7c5", "e7e6", "c7c6", "d7d6", "g8f6", "d7d5"],
|
2025-08-22 18:35:59 +02:00
|
|
|
|
# As Black after 1.d4
|
|
|
|
|
|
("d2d4",): ["d7d5", "g8f6", "e7e6", "c7c5", "c7c6"],
|
|
|
|
|
|
# As Black after 1.c4
|
|
|
|
|
|
("c2c4",): ["e7e5", "g8f6", "c7c5", "e7e6"],
|
|
|
|
|
|
# As Black after 1.Nf3
|
|
|
|
|
|
("g1f3",): ["g8f6", "d7d5", "c7c5", "e7e6"],
|
|
|
|
|
|
# A couple continuations to avoid silly early queen/rook moves
|
|
|
|
|
|
("e2e4", "e7e5"): ["g1f3", "f1c4", "f1b5", "d2d4"],
|
|
|
|
|
|
("e2e4", "c7c5"): ["g1f3", "d2d4", "c2c3", "b1c3"],
|
|
|
|
|
|
("d2d4", "d7d5"): ["c2c4", "g1f3", "e2e3"],
|
|
|
|
|
|
("d2d4", "g8f6"): ["c2c4", "g1f3", "e2e3"],
|
2025-08-22 21:48:28 +02:00
|
|
|
|
|
|
|
|
|
|
# --- More specific continuations to steer sensible early play ---
|
|
|
|
|
|
# 1.e4 e5 2.Nf3 (Black to move)
|
|
|
|
|
|
("e2e4", "e7e5", "g1f3"): ["b8c6", "g8f6", "f8c5", "d7d6"],
|
|
|
|
|
|
# Italian: 1.e4 e5 2.Nf3 Nc6 3.Bc4 (Black to move)
|
|
|
|
|
|
("e2e4", "e7e5", "g1f3", "b8c6", "f1c4"): ["g8f6", "f8c5", "d7d6"],
|
|
|
|
|
|
# Ruy Lopez: 1.e4 e5 2.Nf3 Nc6 3.Bb5 (Black to move)
|
|
|
|
|
|
("e2e4", "e7e5", "g1f3", "b8c6", "f1b5"): ["a7a6", "g8f6", "f8c5", "d7d6"],
|
|
|
|
|
|
# Scotch: 1.e4 e5 2.Nf3 Nc6 3.d4 (Black to move)
|
|
|
|
|
|
("e2e4", "e7e5", "g1f3", "b8c6", "d2d4"): ["e5d4", "g8f6"],
|
|
|
|
|
|
# Queen's Gambit: 1.d4 d5 2.c4 (Black to move)
|
|
|
|
|
|
("d2d4", "d7d5", "c2c4"): ["e7e6", "c7c6", "d5c4"],
|
|
|
|
|
|
# English: 1.c4 e5 2.Nc3 (Black to move)
|
|
|
|
|
|
("c2c4", "e7e5", "b1c3"): ["g8f6", "b8c6"],
|
2025-08-23 17:57:51 +02:00
|
|
|
|
# Alekhine Defence: 1.e4 Nf6 – avoid 2.d4 hanging e4; prefer 2.e5 or quiet development
|
|
|
|
|
|
("e2e4", "g8f6"): ["e4e5", "b1c3", "d2d3", "b1d2"],
|
2025-08-23 18:13:53 +02:00
|
|
|
|
# Encourage Modern/Pirc setups sensibly: if Black played 1...d6 or 1...g6, develop Bg7 before ...Nf6
|
|
|
|
|
|
("e2e4", "d7d6"): ["d2d4", "g1f3", "b1c3"],
|
|
|
|
|
|
("e2e4", "g7g6"): ["d2d4", "g1f3", "c2c4"],
|
|
|
|
|
|
("e2e4", "g7g6", "d2d4"): ["f8g7", "d7d6", "c7c5"],
|
|
|
|
|
|
("e2e4", "g7g6", "d2d4", "f8g7"): ["g1f3", "c2c4", "b1c3"],
|
|
|
|
|
|
("e2e4", "d7d6", "d2d4"): ["g8f6", "g7g6", "c7c5"],
|
2025-08-22 18:35:59 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-23 21:39:41 +02:00
|
|
|
|
def _log_considered(self, board: chess.Board, scored: list[Tuple[chess.Move, float]]) -> None:
|
|
|
|
|
|
"""Print a single-line summary of considered root moves with their scores.
|
2025-08-23 16:18:24 +02:00
|
|
|
|
|
2025-08-23 21:39:41 +02:00
|
|
|
|
Example: considered: e4 (e2e4) +30.0; Nf3 (g1f3) +15.0; d4 (d2d4) +12.0
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not scored:
|
|
|
|
|
|
return
|
|
|
|
|
|
parts: list[str] = []
|
|
|
|
|
|
for mv, sc in scored:
|
|
|
|
|
|
try:
|
|
|
|
|
|
san = board.san(mv)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
san = ''
|
|
|
|
|
|
parts.append(f"{san} ({mv.uci()}) {sc:+.1f}")
|
|
|
|
|
|
print("considered: " + "; ".join(parts))
|
|
|
|
|
|
|
2025-08-22 18:35:59 +02:00
|
|
|
|
def choose_move(self, board: chess.Board, time_budget_sec: Optional[float] = None) -> Optional[chess.Move]:
|
2025-08-22 17:26:17 +02:00
|
|
|
|
start = time.time()
|
|
|
|
|
|
best_move: Optional[chess.Move] = None
|
2025-08-22 18:35:59 +02:00
|
|
|
|
# Set a per-move deadline used throughout search
|
|
|
|
|
|
time_limit = time_budget_sec if time_budget_sec is not None else self.max_time_sec
|
|
|
|
|
|
self._deadline = start + max(0.01, time_limit)
|
2025-08-22 17:26:17 +02:00
|
|
|
|
best_score = -float("inf") if board.turn else float("inf")
|
2025-08-23 21:39:41 +02:00
|
|
|
|
last_depth_used = 0
|
2025-08-22 17:26:17 +02:00
|
|
|
|
|
2025-08-23 21:39:41 +02:00
|
|
|
|
# Opening book shortcut (very early only)
|
2025-08-22 18:35:59 +02:00
|
|
|
|
book_mv = self._opening_book_move(board)
|
|
|
|
|
|
if book_mv is not None:
|
|
|
|
|
|
return book_mv
|
|
|
|
|
|
|
2025-08-22 17:26:17 +02:00
|
|
|
|
# Iterative deepening up to depth or time limit
|
|
|
|
|
|
for d in range(1, self.depth + 1):
|
2025-08-22 18:35:59 +02:00
|
|
|
|
if time.time() >= self._deadline:
|
2025-08-22 17:26:17 +02:00
|
|
|
|
break
|
|
|
|
|
|
score, move = self._search_root(board, d, start)
|
|
|
|
|
|
if move is not None:
|
|
|
|
|
|
best_move, best_score = move, score
|
2025-08-23 21:39:41 +02:00
|
|
|
|
last_depth_used = d
|
2025-08-22 17:26:17 +02:00
|
|
|
|
|
2025-08-23 21:39:41 +02:00
|
|
|
|
# Log considered moves with scores (no detailed breakdown) using the last completed depth
|
|
|
|
|
|
if last_depth_used > 0 and time.time() < self._deadline:
|
|
|
|
|
|
try:
|
|
|
|
|
|
scored = self._analyze_root(board, last_depth_used, start)
|
|
|
|
|
|
self._log_considered(board, scored)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2025-08-23 16:18:24 +02:00
|
|
|
|
|
2025-08-22 17:26:17 +02:00
|
|
|
|
# Fallback to random if search didn’t find anything
|
|
|
|
|
|
if best_move is None:
|
|
|
|
|
|
moves = list(board.legal_moves)
|
|
|
|
|
|
return random.choice(moves) if moves else None
|
|
|
|
|
|
return best_move
|
|
|
|
|
|
|
2025-08-22 18:35:59 +02:00
|
|
|
|
def choose_move_with_explanation(self, board: chess.Board, time_budget_sec: Optional[float] = None) -> Tuple[Optional[chess.Move], str]:
|
2025-08-23 16:27:41 +02:00
|
|
|
|
"""Return the chosen move and a human-readable explanation with full breakdown.
|
2025-08-22 17:26:17 +02:00
|
|
|
|
|
2025-08-23 16:27:41 +02:00
|
|
|
|
When a book move is chosen, the note explains which book, key, candidates, and why.
|
|
|
|
|
|
When search is used, includes depth, time, node count, top candidates, and for the
|
|
|
|
|
|
selected move a numeric breakdown of evaluation components and risk/SEE details.
|
2025-08-22 17:26:17 +02:00
|
|
|
|
"""
|
|
|
|
|
|
start = time.time()
|
2025-08-22 18:35:59 +02:00
|
|
|
|
# Set a per-move deadline used throughout search
|
|
|
|
|
|
time_limit = time_budget_sec if time_budget_sec is not None else self.max_time_sec
|
|
|
|
|
|
self._deadline = start + max(0.01, time_limit)
|
2025-08-23 16:27:41 +02:00
|
|
|
|
# Lightweight node counter for transparency (only used for explanation)
|
|
|
|
|
|
self._nodes = 0
|
2025-08-22 17:26:17 +02:00
|
|
|
|
depth_used = 0
|
|
|
|
|
|
best_move: Optional[chess.Move] = None
|
|
|
|
|
|
scores: list[Tuple[chess.Move, float]] = []
|
|
|
|
|
|
|
2025-08-22 18:35:59 +02:00
|
|
|
|
# Opening book shortcut
|
|
|
|
|
|
book_mv = self._opening_book_move(board)
|
|
|
|
|
|
if book_mv is not None:
|
2025-08-23 16:27:41 +02:00
|
|
|
|
# Build book explanation: which book, what key, candidates, selection policy
|
|
|
|
|
|
hist = tuple(m.uci() for m in board.move_stack)
|
|
|
|
|
|
key_used: Optional[tuple[str, ...]] = None
|
|
|
|
|
|
candidates: list[str] = []
|
|
|
|
|
|
legal_ucis: list[str] = []
|
|
|
|
|
|
legals = {m.uci(): m for m in board.legal_moves}
|
|
|
|
|
|
for klen in range(len(hist), -1, -1):
|
|
|
|
|
|
key = hist[:klen]
|
|
|
|
|
|
if key in self.opening_book:
|
|
|
|
|
|
key_used = key
|
|
|
|
|
|
candidates = list(self.opening_book[key])
|
|
|
|
|
|
legal_ucis = [u for u in candidates if u in legals]
|
|
|
|
|
|
break
|
|
|
|
|
|
mv_san = None
|
2025-08-22 18:35:59 +02:00
|
|
|
|
try:
|
2025-08-23 16:27:41 +02:00
|
|
|
|
mv_san = board.san(book_mv)
|
2025-08-22 18:35:59 +02:00
|
|
|
|
except Exception:
|
2025-08-23 16:27:41 +02:00
|
|
|
|
pass
|
|
|
|
|
|
annotations = self._annotate_move_simple(board, book_mv)
|
|
|
|
|
|
lines = [
|
|
|
|
|
|
"source=opening-book",
|
|
|
|
|
|
f"book=internal.opening_book key={key_used if key_used is not None else 'N/A'}",
|
|
|
|
|
|
f"history={hist}",
|
|
|
|
|
|
f"candidates={candidates}",
|
|
|
|
|
|
f"legal_candidates={legal_ucis}",
|
|
|
|
|
|
"selection=first-legal-candidate (stable)",
|
|
|
|
|
|
f"chosen={mv_san + ' ' if mv_san else ''}({book_mv.uci()}) reasons=[{annotations}]",
|
|
|
|
|
|
]
|
|
|
|
|
|
return book_mv, "\n".join(lines)
|
2025-08-22 18:35:59 +02:00
|
|
|
|
|
2025-08-22 17:26:17 +02:00
|
|
|
|
# Analyze all legal moves at the root with alpha-beta to given depth/time
|
|
|
|
|
|
for d in range(1, self.depth + 1):
|
2025-08-22 18:35:59 +02:00
|
|
|
|
if time.time() >= self._deadline:
|
2025-08-22 17:26:17 +02:00
|
|
|
|
break
|
|
|
|
|
|
depth_used = d
|
|
|
|
|
|
scores = self._analyze_root(board, d, start)
|
|
|
|
|
|
if scores:
|
|
|
|
|
|
best_move = scores[0][0]
|
|
|
|
|
|
|
2025-08-23 21:39:41 +02:00
|
|
|
|
# Emit a concise log of considered moves and scores
|
|
|
|
|
|
try:
|
|
|
|
|
|
self._log_considered(board, scores)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2025-08-22 17:26:17 +02:00
|
|
|
|
if not scores:
|
|
|
|
|
|
# Fallback
|
|
|
|
|
|
mv = self.choose_move(board)
|
|
|
|
|
|
return mv, "fallback: random/legal-only (no analysis)"
|
|
|
|
|
|
|
2025-08-23 20:27:32 +02:00
|
|
|
|
# Apply the same final safety veto as choose_move (avoid logged/risky blunders)
|
|
|
|
|
|
veto_applied = False
|
|
|
|
|
|
original_best = best_move
|
2025-08-23 21:39:41 +02:00
|
|
|
|
|
2025-08-22 17:26:17 +02:00
|
|
|
|
# Build explanation
|
|
|
|
|
|
def annotate(m: chess.Move) -> str:
|
2025-08-23 16:27:41 +02:00
|
|
|
|
return self._annotate_move_simple(board, m)
|
2025-08-22 17:26:17 +02:00
|
|
|
|
|
|
|
|
|
|
top = scores[:5]
|
2025-08-23 20:27:32 +02:00
|
|
|
|
best_cp = None
|
|
|
|
|
|
# If we changed the choice via veto, try to find the chosen move's score from scores
|
|
|
|
|
|
if best_move is not None:
|
|
|
|
|
|
for mv, sc in scores:
|
|
|
|
|
|
if mv == best_move:
|
|
|
|
|
|
best_cp = sc
|
|
|
|
|
|
break
|
|
|
|
|
|
if best_cp is None:
|
|
|
|
|
|
best_cp = top[0][1]
|
2025-08-23 16:27:41 +02:00
|
|
|
|
elapsed = time.time() - start
|
2025-08-22 17:26:17 +02:00
|
|
|
|
lines = [
|
2025-08-23 16:27:41 +02:00
|
|
|
|
f"source=search depth={depth_used} time={elapsed:.2f}s nodes={getattr(self, '_nodes', 0)} candidates={len(scores)}",
|
2025-08-23 20:27:32 +02:00
|
|
|
|
f"best {board.san(best_move if best_move is not None else top[0][0])} ({(best_move if best_move is not None else top[0][0]).uci()}) score={best_cp:.1f} reasons=[{annotate(best_move if best_move is not None else top[0][0])}]",
|
2025-08-22 17:26:17 +02:00
|
|
|
|
]
|
|
|
|
|
|
if len(top) > 1:
|
|
|
|
|
|
lines.append("alternatives:")
|
|
|
|
|
|
for mv, sc in top[1:]:
|
|
|
|
|
|
delta = sc - best_cp
|
2025-08-23 21:39:41 +02:00
|
|
|
|
# Keep alternatives concise: move + score + delta (no detailed reasons)
|
|
|
|
|
|
try:
|
|
|
|
|
|
san_alt = board.san(mv)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
san_alt = ''
|
|
|
|
|
|
lines.append(f" {san_alt} ({mv.uci()}) score={sc:.1f} delta={delta:+.1f}")
|
2025-08-22 17:26:17 +02:00
|
|
|
|
|
2025-08-23 20:27:32 +02:00
|
|
|
|
if veto_applied and original_best is not None and best_move is not None and original_best != best_move:
|
|
|
|
|
|
# Provide a short note about the veto and why
|
|
|
|
|
|
try:
|
|
|
|
|
|
ann_orig = annotate(original_best)
|
|
|
|
|
|
ann_safe = annotate(best_move)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
ann_orig = ann_safe = ""
|
|
|
|
|
|
lines.append("safety_veto: true")
|
|
|
|
|
|
lines.append(
|
|
|
|
|
|
f" avoided {board.san(original_best)} ({original_best.uci()}) because looks_blunderish=true"
|
|
|
|
|
|
)
|
|
|
|
|
|
# Add quick risk/SEE snapshot for context
|
|
|
|
|
|
try:
|
|
|
|
|
|
see_bad = int(self._see_value(board, original_best))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
see_bad = 0
|
|
|
|
|
|
risk_bad = self._risk_score(board, original_best)
|
|
|
|
|
|
try:
|
|
|
|
|
|
see_safe = int(self._see_value(board, best_move))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
see_safe = 0
|
|
|
|
|
|
risk_safe = self._risk_score(board, best_move)
|
|
|
|
|
|
lines.append(f" avoided_move_details: see={see_bad} risk_total={risk_bad} reasons=[{ann_orig}]")
|
|
|
|
|
|
lines.append(f" chosen_safer: {board.san(best_move)} ({best_move.uci()}) see={see_safe} risk_total={risk_safe} reasons=[{ann_safe}]")
|
|
|
|
|
|
|
2025-08-23 16:27:41 +02:00
|
|
|
|
# Deep-dive numeric breakdown for the chosen move
|
|
|
|
|
|
if best_move is not None:
|
|
|
|
|
|
# SEE and risk details
|
|
|
|
|
|
try:
|
|
|
|
|
|
see_val = int(self._see_value(board, best_move))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
see_val = 0
|
|
|
|
|
|
risk_total = self._risk_score(board, best_move)
|
|
|
|
|
|
risk_qtrap = 0
|
|
|
|
|
|
try:
|
|
|
|
|
|
risk_qtrap = self._queen_trap_risk(board, best_move)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
risk_bxf = 600 if (self._is_early_game(board) and self._is_bishop_sac_on_f2f7(board, best_move)) else 0
|
|
|
|
|
|
|
|
|
|
|
|
# Static evaluation components before and after (mover perspective)
|
|
|
|
|
|
pre_white_score, pre_comp = self._evaluate_components(board)
|
|
|
|
|
|
pre_stm = pre_white_score if board.turn == chess.WHITE else -pre_white_score
|
|
|
|
|
|
|
|
|
|
|
|
board.push(best_move)
|
|
|
|
|
|
try:
|
|
|
|
|
|
post_white_score, post_comp = self._evaluate_components(board)
|
|
|
|
|
|
# After the move, it's opponent to move; flip sign to mover perspective
|
|
|
|
|
|
post_stm = - (post_white_score if board.turn == chess.WHITE else -post_white_score)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
board.pop()
|
|
|
|
|
|
|
|
|
|
|
|
# Tactical delta captured by search beyond static eval
|
|
|
|
|
|
tactical_delta = best_cp - post_stm
|
|
|
|
|
|
|
|
|
|
|
|
# Compose component lines with explanations
|
|
|
|
|
|
def fmt_comps(prefix: str, comp: dict, white_score_val: float, stm_val: float) -> list[str]:
|
|
|
|
|
|
parts = []
|
|
|
|
|
|
parts.append(f"{prefix}: stm_eval={stm_val:.1f} (from white_score={white_score_val:.1f} {'as-is' if (prefix=='pre') else 'flipped to mover'})")
|
|
|
|
|
|
parts.append(" components (white-centric):")
|
|
|
|
|
|
parts.append(f" material={comp['material']} # material balance in centipawns (white - black)")
|
|
|
|
|
|
parts.append(f" doubled_pawns_term={comp['doubled_pawns_term']} # - (white_minus_black_doubled_pawns_penalty)")
|
|
|
|
|
|
parts.append(f" mobility_term={comp['mobility_term']} # weighted (legal_moves_white - legal_moves_black)")
|
|
|
|
|
|
parts.append(f" mobility_white={comp['mob_w']} mobility_black={comp['mob_b']}")
|
|
|
|
|
|
parts.append(f" center_score={comp['center_score']} # piece presence in central squares")
|
|
|
|
|
|
parts.append(f" rook_file_bonus={comp['rook_file_bonus']} # rooks on open files")
|
|
|
|
|
|
parts.append(f" king_safety={comp['safety']} # castled/central king heuristics in middlegame")
|
|
|
|
|
|
parts.append(f" queen_raid_penalty={comp['queen_raid_pen']} # early risky queen raids")
|
|
|
|
|
|
parts.append(f" piece_square_table={comp['pst']} # small piece-square tendencies")
|
|
|
|
|
|
parts.append(f" hanging_pieces_term={comp['hanging_pieces_term']} # - (hanging pieces penalty: white - black)")
|
|
|
|
|
|
return parts
|
|
|
|
|
|
|
|
|
|
|
|
pre_lines = fmt_comps("pre", pre_comp, pre_white_score, pre_stm)
|
|
|
|
|
|
post_lines = fmt_comps("post", post_comp, post_white_score, post_stm)
|
|
|
|
|
|
|
|
|
|
|
|
lines.append("details:")
|
|
|
|
|
|
lines.append(f" see={see_val} # Static Exchange Evaluation of chosen move (>=0 means not losing material immediately)")
|
|
|
|
|
|
lines.append(f" risk_total={risk_total} # aggregate risk score (lower is safer)")
|
|
|
|
|
|
lines.append(f" risk_queen_trap={risk_qtrap} # estimated risk of the queen becoming trapped/over-attacked")
|
|
|
|
|
|
lines.append(f" risk_bishop_sac_f2f7={risk_bxf} # extra risk for early Bxf2/Bxf7 motifs")
|
|
|
|
|
|
lines.append(f" pre_static_eval: {pre_stm:.1f} # mover-perspective before making the move")
|
|
|
|
|
|
lines.append(f" post_static_eval: {post_stm:.1f} # mover-perspective immediately after the move")
|
|
|
|
|
|
lines.append(f" search_score: {best_cp:.1f} # alpha-beta score after quiescence")
|
|
|
|
|
|
lines.append(f" tactical_delta: {tactical_delta:+.1f} # (search_score - post_static_eval), captures/tactics beyond static")
|
|
|
|
|
|
lines.extend(pre_lines)
|
|
|
|
|
|
lines.extend(post_lines)
|
|
|
|
|
|
|
2025-08-22 17:26:17 +02:00
|
|
|
|
return best_move, "\n".join(lines)
|
|
|
|
|
|
|
2025-08-23 19:49:50 +02:00
|
|
|
|
def evaluate_proposed_move_with_suggestion(
|
|
|
|
|
|
self,
|
|
|
|
|
|
state: "chess.Board | str",
|
|
|
|
|
|
proposed_move: "chess.Move | str",
|
|
|
|
|
|
time_budget_sec: Optional[float] = None,
|
|
|
|
|
|
) -> Tuple[Optional[float], str, Optional[chess.Move], str]:
|
|
|
|
|
|
"""Evaluate a proposed move on a given position and also propose the engine's move.
|
|
|
|
|
|
|
|
|
|
|
|
Inputs:
|
|
|
|
|
|
- state: either a chess.Board or a FEN string representing the current position.
|
|
|
|
|
|
- proposed_move: a chess.Move object or a string (UCI preferred, SAN fallback).
|
|
|
|
|
|
- time_budget_sec: optional time limit for each analysis pass.
|
|
|
|
|
|
|
|
|
|
|
|
Returns a tuple: (proposed_score_cp, proposed_explanation, engine_move, engine_explanation)
|
|
|
|
|
|
- proposed_score_cp: search score (centipawns, mover POV) for the proposed move, or None if illegal/unavailable.
|
|
|
|
|
|
- proposed_explanation: a detailed, human-readable breakdown of where the score came from.
|
|
|
|
|
|
- engine_move: the move our engine would play from this position.
|
|
|
|
|
|
- engine_explanation: the usual explanation for the engine's chosen move.
|
|
|
|
|
|
"""
|
|
|
|
|
|
# Normalize board
|
|
|
|
|
|
if isinstance(state, chess.Board):
|
|
|
|
|
|
board = state.copy(stack=False)
|
|
|
|
|
|
else:
|
|
|
|
|
|
try:
|
|
|
|
|
|
board = chess.Board(state)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
# If FEN parsing fails, assume start position
|
|
|
|
|
|
board = chess.Board()
|
|
|
|
|
|
|
|
|
|
|
|
# Parse proposed move
|
|
|
|
|
|
def _parse_move(b: chess.Board, mv: "chess.Move | str") -> Optional[chess.Move]:
|
|
|
|
|
|
if isinstance(mv, chess.Move):
|
|
|
|
|
|
return mv if mv in b.legal_moves else None
|
|
|
|
|
|
# Try UCI first
|
|
|
|
|
|
try:
|
|
|
|
|
|
m = chess.Move.from_uci(str(mv).strip())
|
|
|
|
|
|
if m in b.legal_moves:
|
|
|
|
|
|
return m
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
# Try SAN
|
|
|
|
|
|
try:
|
|
|
|
|
|
m = b.parse_san(str(mv).strip())
|
|
|
|
|
|
if m in b.legal_moves:
|
|
|
|
|
|
return m
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
pmove = _parse_move(board, proposed_move)
|
|
|
|
|
|
|
|
|
|
|
|
# Evaluate proposed move (ensure we set a deadline specifically for this pass)
|
|
|
|
|
|
start = time.time()
|
|
|
|
|
|
budget = time_budget_sec if time_budget_sec is not None else min(1.5, self.max_time_sec)
|
|
|
|
|
|
self._deadline = start + max(0.01, budget)
|
|
|
|
|
|
self._nodes = 0
|
|
|
|
|
|
|
|
|
|
|
|
proposed_score: Optional[float] = None
|
|
|
|
|
|
if pmove is None:
|
|
|
|
|
|
# Still compute our own suggestion below
|
|
|
|
|
|
proposed_expl = (
|
|
|
|
|
|
"source=search\nillegal_or_unavailable_move=true\n"
|
|
|
|
|
|
"note=The proposed move is not legal in the provided position."
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Compute search score for the specific move
|
|
|
|
|
|
# Use full alpha-beta for consistency with choose_move_with_explanation
|
|
|
|
|
|
# Depth is limited by deadline
|
|
|
|
|
|
depth_used = 0
|
|
|
|
|
|
best_score_for_move: Optional[float] = None
|
|
|
|
|
|
# A tiny iterative deepening loop focused on the single move
|
|
|
|
|
|
for d in range(1, self.depth + 1):
|
|
|
|
|
|
if time.time() >= self._deadline:
|
|
|
|
|
|
break
|
|
|
|
|
|
depth_used = d
|
|
|
|
|
|
board.push(pmove)
|
|
|
|
|
|
try:
|
|
|
|
|
|
sc = -self._alphabeta(board, d - 1, -float("inf"), float("inf"), start)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
board.pop()
|
|
|
|
|
|
best_score_for_move = sc
|
|
|
|
|
|
proposed_score = best_score_for_move
|
|
|
|
|
|
|
|
|
|
|
|
# Build a detailed breakdown similar to choose_move_with_explanation
|
|
|
|
|
|
def _move_breakdown(b: chess.Board, m: chess.Move, search_score: float) -> str:
|
|
|
|
|
|
# SEE and risk
|
|
|
|
|
|
try:
|
|
|
|
|
|
see_val = int(self._see_value(b, m))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
see_val = 0
|
|
|
|
|
|
risk_total = self._risk_score(b, m)
|
|
|
|
|
|
try:
|
|
|
|
|
|
risk_qtrap = self._queen_trap_risk(b, m)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
risk_qtrap = 0
|
|
|
|
|
|
risk_bxf = 600 if (self._is_early_game(b) and self._is_bishop_sac_on_f2f7(b, m)) else 0
|
|
|
|
|
|
|
|
|
|
|
|
# Static eval components pre/post (mover perspective)
|
|
|
|
|
|
pre_white_score, pre_comp = self._evaluate_components(b)
|
|
|
|
|
|
pre_stm = pre_white_score if b.turn == chess.WHITE else -pre_white_score
|
|
|
|
|
|
|
|
|
|
|
|
b.push(m)
|
|
|
|
|
|
try:
|
|
|
|
|
|
post_white_score, post_comp = self._evaluate_components(b)
|
|
|
|
|
|
post_stm = - (post_white_score if b.turn == chess.WHITE else -post_white_score)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
b.pop()
|
|
|
|
|
|
|
|
|
|
|
|
tactical_delta = search_score - post_stm
|
|
|
|
|
|
|
|
|
|
|
|
def fmt_comps(prefix: str, comp: dict, white_score_val: float, stm_val: float) -> list[str]:
|
|
|
|
|
|
parts = []
|
|
|
|
|
|
parts.append(f"{prefix}: stm_eval={stm_val:.1f} (from white_score={white_score_val:.1f} {'as-is' if (prefix=='pre') else 'flipped to mover'})")
|
|
|
|
|
|
parts.append(" components (white-centric):")
|
|
|
|
|
|
parts.append(f" material={comp['material']}")
|
|
|
|
|
|
parts.append(f" doubled_pawns_term={comp['doubled_pawns_term']}")
|
|
|
|
|
|
parts.append(f" mobility_term={comp['mobility_term']}")
|
|
|
|
|
|
parts.append(f" mobility_white={comp['mob_w']} mobility_black={comp['mob_b']}")
|
|
|
|
|
|
parts.append(f" center_score={comp['center_score']}")
|
|
|
|
|
|
parts.append(f" rook_file_bonus={comp['rook_file_bonus']}")
|
|
|
|
|
|
parts.append(f" king_safety={comp['safety']}")
|
|
|
|
|
|
parts.append(f" queen_raid_penalty={comp['queen_raid_pen']}")
|
|
|
|
|
|
parts.append(f" piece_square_table={comp['pst']}")
|
|
|
|
|
|
parts.append(f" hanging_pieces_term={comp['hanging_pieces_term']}")
|
|
|
|
|
|
return parts
|
|
|
|
|
|
|
|
|
|
|
|
pre_lines = fmt_comps("pre", pre_comp, pre_white_score, pre_stm)
|
|
|
|
|
|
post_lines = fmt_comps("post", post_comp, post_white_score, post_stm)
|
|
|
|
|
|
|
|
|
|
|
|
elapsed = time.time() - start
|
|
|
|
|
|
nodes = getattr(self, "_nodes", 0)
|
|
|
|
|
|
mv_san = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
mv_san = b.san(m)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
ann = self._annotate_move_simple(b, m)
|
|
|
|
|
|
lines = [
|
|
|
|
|
|
f"source=search depth={depth_used} time={elapsed:.2f}s nodes={nodes}",
|
|
|
|
|
|
f"move {mv_san if mv_san else ''} ({m.uci()}) score={search_score:.1f} reasons=[{ann}]",
|
|
|
|
|
|
"details:",
|
|
|
|
|
|
f" see={see_val}",
|
|
|
|
|
|
f" risk_total={risk_total}",
|
|
|
|
|
|
f" risk_queen_trap={risk_qtrap}",
|
|
|
|
|
|
f" risk_bishop_sac_f2f7={risk_bxf}",
|
|
|
|
|
|
f" pre_static_eval: {pre_stm:.1f}",
|
|
|
|
|
|
f" post_static_eval: {post_stm:.1f}",
|
|
|
|
|
|
f" search_score: {search_score:.1f}",
|
|
|
|
|
|
f" tactical_delta: {tactical_delta:+.1f}",
|
|
|
|
|
|
]
|
|
|
|
|
|
lines.extend(pre_lines)
|
|
|
|
|
|
lines.extend(post_lines)
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
if proposed_score is None:
|
|
|
|
|
|
proposed_expl = (
|
|
|
|
|
|
"source=search\nunable_to_compute_score=true\n"
|
|
|
|
|
|
"note=Time limit reached before evaluating the proposed move."
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
proposed_expl = _move_breakdown(board, pmove, proposed_score)
|
|
|
|
|
|
|
|
|
|
|
|
# Now get the engine's own suggestion (separate pass to keep API simple)
|
|
|
|
|
|
eng_budget = time_budget_sec if time_budget_sec is not None else min(1.5, self.max_time_sec)
|
|
|
|
|
|
move_suggestion, engine_expl = self.choose_move_with_explanation(board, time_budget_sec=eng_budget)
|
|
|
|
|
|
|
|
|
|
|
|
return proposed_score, proposed_expl, move_suggestion, engine_expl
|
|
|
|
|
|
|
2025-08-22 17:26:17 +02:00
|
|
|
|
def _analyze_root(self, board: chess.Board, depth: int, start: float) -> list[Tuple[chess.Move, float]]:
|
|
|
|
|
|
alpha = -float("inf")
|
|
|
|
|
|
beta = float("inf")
|
|
|
|
|
|
scored: list[Tuple[chess.Move, float]] = []
|
|
|
|
|
|
for move in self._ordered_moves(board):
|
2025-08-22 18:35:59 +02:00
|
|
|
|
if time.time() >= self._deadline:
|
2025-08-22 17:26:17 +02:00
|
|
|
|
break
|
|
|
|
|
|
board.push(move)
|
|
|
|
|
|
score = -self._alphabeta(board, depth - 1, -beta, -alpha, start)
|
|
|
|
|
|
board.pop()
|
|
|
|
|
|
scored.append((move, score))
|
|
|
|
|
|
if score > alpha:
|
|
|
|
|
|
alpha = score
|
|
|
|
|
|
if alpha >= beta:
|
|
|
|
|
|
break
|
2025-08-23 15:46:05 +02:00
|
|
|
|
# Prefer higher score; on ties, prefer lower risk
|
|
|
|
|
|
risk_map = {m: self._risk_score(board, m) for m, _ in scored}
|
|
|
|
|
|
scored.sort(key=lambda t: (t[1], -risk_map[t[0]]), reverse=True)
|
2025-08-22 17:26:17 +02:00
|
|
|
|
return scored
|
|
|
|
|
|
|
|
|
|
|
|
def _search_root(self, board: chess.Board, depth: int, start: float) -> Tuple[float, Optional[chess.Move]]:
|
|
|
|
|
|
alpha = -float("inf")
|
|
|
|
|
|
beta = float("inf")
|
|
|
|
|
|
best_move: Optional[chess.Move] = None
|
|
|
|
|
|
best_score = -float("inf")
|
|
|
|
|
|
|
|
|
|
|
|
moves = self._ordered_moves(board)
|
|
|
|
|
|
for move in moves:
|
2025-08-22 18:35:59 +02:00
|
|
|
|
if time.time() >= self._deadline:
|
2025-08-22 17:26:17 +02:00
|
|
|
|
break
|
|
|
|
|
|
board.push(move)
|
|
|
|
|
|
score = -self._alphabeta(board, depth - 1, -beta, -alpha, start)
|
|
|
|
|
|
board.pop()
|
2025-08-22 22:30:47 +02:00
|
|
|
|
# Prefer lower-risk choices on score ties
|
2025-08-22 17:26:17 +02:00
|
|
|
|
if score > best_score:
|
|
|
|
|
|
best_score = score
|
|
|
|
|
|
best_move = move
|
2025-08-22 22:30:47 +02:00
|
|
|
|
elif best_move is not None and (score == best_score or abs(score - best_score) < 1e-3):
|
|
|
|
|
|
if self._risk_score(board, move) < self._risk_score(board, best_move):
|
|
|
|
|
|
best_move = move
|
2025-08-22 17:26:17 +02:00
|
|
|
|
if score > alpha:
|
|
|
|
|
|
alpha = score
|
|
|
|
|
|
if alpha >= beta:
|
|
|
|
|
|
break
|
|
|
|
|
|
return best_score, best_move
|
|
|
|
|
|
|
|
|
|
|
|
def _alphabeta(self, board: chess.Board, depth: int, alpha: float, beta: float, start: float) -> float:
|
|
|
|
|
|
# Time cutoff
|
2025-08-22 18:35:59 +02:00
|
|
|
|
if time.time() >= self._deadline:
|
2025-08-22 17:26:17 +02:00
|
|
|
|
return self._evaluate(board)
|
|
|
|
|
|
|
|
|
|
|
|
# Terminal nodes
|
2025-08-22 18:35:59 +02:00
|
|
|
|
if board.is_game_over():
|
2025-08-22 17:26:17 +02:00
|
|
|
|
return self._evaluate(board)
|
2025-08-22 18:35:59 +02:00
|
|
|
|
if depth == 0:
|
|
|
|
|
|
return self._quiescence(board, alpha, beta, start)
|
2025-08-22 17:26:17 +02:00
|
|
|
|
|
|
|
|
|
|
best = -float("inf")
|
|
|
|
|
|
for move in self._ordered_moves(board):
|
2025-08-23 16:27:41 +02:00
|
|
|
|
# Node counting for transparency
|
|
|
|
|
|
try:
|
|
|
|
|
|
self._nodes += 1
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2025-08-22 17:26:17 +02:00
|
|
|
|
board.push(move)
|
|
|
|
|
|
score = -self._alphabeta(board, depth - 1, -beta, -alpha, start)
|
|
|
|
|
|
board.pop()
|
|
|
|
|
|
if score > best:
|
|
|
|
|
|
best = score
|
|
|
|
|
|
if best > alpha:
|
|
|
|
|
|
alpha = best
|
|
|
|
|
|
if alpha >= beta:
|
|
|
|
|
|
break
|
|
|
|
|
|
return best
|
|
|
|
|
|
|
2025-08-22 18:35:59 +02:00
|
|
|
|
def _quiescence(self, board: chess.Board, alpha: float, beta: float, start: float) -> float:
|
|
|
|
|
|
# Stand-pat
|
2025-08-23 16:27:41 +02:00
|
|
|
|
# Count the node and evaluate
|
|
|
|
|
|
try:
|
|
|
|
|
|
self._nodes += 1
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2025-08-22 18:35:59 +02:00
|
|
|
|
stand_pat = self._evaluate(board)
|
|
|
|
|
|
if stand_pat >= beta:
|
|
|
|
|
|
return beta
|
|
|
|
|
|
if stand_pat > alpha:
|
|
|
|
|
|
alpha = stand_pat
|
|
|
|
|
|
|
|
|
|
|
|
# Explore captures to avoid horizon effect
|
2025-08-22 21:19:10 +02:00
|
|
|
|
# Consider only captures/promotions and order by SEE to reduce blunders
|
|
|
|
|
|
capture_moves: list[tuple[int, chess.Move]] = []
|
2025-08-22 18:35:59 +02:00
|
|
|
|
for move in self._ordered_moves(board):
|
|
|
|
|
|
if time.time() >= self._deadline:
|
|
|
|
|
|
break
|
|
|
|
|
|
if not board.is_capture(move) and not move.promotion:
|
|
|
|
|
|
continue
|
2025-08-22 21:19:10 +02:00
|
|
|
|
try:
|
|
|
|
|
|
capture_moves.append((int(self._see_value(board, move)), move))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
capture_moves.append((0, move))
|
|
|
|
|
|
|
|
|
|
|
|
capture_moves.sort(key=lambda t: t[0], reverse=True)
|
|
|
|
|
|
|
|
|
|
|
|
for _, move in capture_moves:
|
|
|
|
|
|
if time.time() >= self._deadline:
|
|
|
|
|
|
break
|
2025-08-23 16:27:41 +02:00
|
|
|
|
try:
|
|
|
|
|
|
self._nodes += 1
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2025-08-22 18:35:59 +02:00
|
|
|
|
board.push(move)
|
|
|
|
|
|
score = -self._quiescence(board, -beta, -alpha, start)
|
|
|
|
|
|
board.pop()
|
|
|
|
|
|
if score >= beta:
|
|
|
|
|
|
return beta
|
|
|
|
|
|
if score > alpha:
|
|
|
|
|
|
alpha = score
|
|
|
|
|
|
return alpha
|
|
|
|
|
|
|
2025-08-22 17:26:17 +02:00
|
|
|
|
def _ordered_moves(self, board: chess.Board):
|
2025-08-22 21:19:10 +02:00
|
|
|
|
# Move ordering that mixes tactical SEE with simple heuristics
|
2025-08-22 17:26:17 +02:00
|
|
|
|
def score_move(m: chess.Move) -> int:
|
|
|
|
|
|
s = 0
|
2025-08-22 21:19:10 +02:00
|
|
|
|
is_cap = board.is_capture(m)
|
|
|
|
|
|
if is_cap:
|
2025-08-22 17:26:17 +02:00
|
|
|
|
s += 1000
|
|
|
|
|
|
if m.promotion:
|
|
|
|
|
|
s += 800
|
|
|
|
|
|
try:
|
|
|
|
|
|
if board.gives_check(m):
|
2025-08-22 21:19:10 +02:00
|
|
|
|
s += 120
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# SEE: reward good captures and avoid obviously losing moves
|
|
|
|
|
|
try:
|
|
|
|
|
|
see = int(self._see_value(board, m))
|
|
|
|
|
|
if is_cap or see < 0:
|
|
|
|
|
|
s += max(-600, min(600, see))
|
2025-08-23 18:54:56 +02:00
|
|
|
|
# Non-capture pawn push that has negative SEE on destination is bad; demote strongly
|
|
|
|
|
|
if not is_cap and piece and piece.piece_type == chess.PAWN and see < 0:
|
|
|
|
|
|
s -= 180
|
2025-08-22 17:26:17 +02:00
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2025-08-22 18:35:59 +02:00
|
|
|
|
|
|
|
|
|
|
early = self._is_early_game(board)
|
|
|
|
|
|
piece = board.piece_at(m.from_square)
|
|
|
|
|
|
if piece:
|
2025-08-22 22:30:47 +02:00
|
|
|
|
# Heuristic: demote unsound early bishop sacs on f2/f7
|
|
|
|
|
|
if early and self._is_bishop_sac_on_f2f7(board, m):
|
|
|
|
|
|
try:
|
|
|
|
|
|
see_sac = int(self._see_value(board, m))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
see_sac = -300
|
|
|
|
|
|
# Large penalty if SEE is bad or not clearly winning material
|
|
|
|
|
|
if see_sac <= -50:
|
|
|
|
|
|
s -= 1300 # outweigh capture+check bonuses
|
|
|
|
|
|
else:
|
|
|
|
|
|
s -= 600
|
2025-08-22 21:48:28 +02:00
|
|
|
|
# Discourage premature queen adventures in the opening
|
|
|
|
|
|
if piece.piece_type == chess.QUEEN and early:
|
2025-08-23 15:46:05 +02:00
|
|
|
|
# Strongly demote greedy corner rook captures like Qxh8/Qxa8/Qxh1/Qxa1
|
|
|
|
|
|
if is_cap:
|
|
|
|
|
|
victim = board.piece_at(m.to_square)
|
|
|
|
|
|
if victim and victim.piece_type == chess.ROOK and m.to_square in {chess.A8, chess.H8, chess.A1, chess.H1}:
|
|
|
|
|
|
try:
|
|
|
|
|
|
if board.gives_check(m):
|
|
|
|
|
|
s -= 1200
|
|
|
|
|
|
else:
|
|
|
|
|
|
s -= 900
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
s -= 900
|
2025-08-22 21:48:28 +02:00
|
|
|
|
victim = board.piece_at(m.to_square)
|
|
|
|
|
|
# Penalize queen pawn-grabs on edge pawns (a2/b2/g2/h2 or a7/b7/g7/h7)
|
|
|
|
|
|
poison_targets_white = {chess.A7, chess.B7, chess.G7, chess.H7}
|
|
|
|
|
|
poison_targets_black = {chess.A2, chess.B2, chess.G2, chess.H2}
|
|
|
|
|
|
is_poison_target = (
|
|
|
|
|
|
(piece.color == chess.WHITE and m.to_square in poison_targets_white)
|
|
|
|
|
|
or (piece.color == chess.BLACK and m.to_square in poison_targets_black)
|
|
|
|
|
|
)
|
|
|
|
|
|
if is_cap and victim and victim.piece_type == chess.PAWN and is_poison_target:
|
|
|
|
|
|
# If destination is heavily attacked, apply a large penalty
|
|
|
|
|
|
attackers_op = len(board.attackers(not piece.color, m.to_square))
|
|
|
|
|
|
defenders_me = len(board.attackers(piece.color, m.to_square))
|
|
|
|
|
|
if attackers_op >= max(1, defenders_me):
|
|
|
|
|
|
s -= 500
|
|
|
|
|
|
else:
|
|
|
|
|
|
s -= 250
|
|
|
|
|
|
# General small penalty for non-check queen moves before minor development
|
|
|
|
|
|
if not is_cap:
|
|
|
|
|
|
if self._most_minors_undeveloped(board, piece.color):
|
|
|
|
|
|
s -= 160
|
|
|
|
|
|
else:
|
|
|
|
|
|
s -= 60
|
2025-08-22 18:35:59 +02:00
|
|
|
|
if board.is_castling(m):
|
2025-08-22 21:19:10 +02:00
|
|
|
|
s += 650
|
2025-08-22 18:35:59 +02:00
|
|
|
|
if piece.piece_type in (chess.KNIGHT, chess.BISHOP):
|
2025-08-22 21:19:10 +02:00
|
|
|
|
if chess.square_rank(m.from_square) in (0, 7) and not is_cap:
|
|
|
|
|
|
s += 90
|
2025-08-22 18:35:59 +02:00
|
|
|
|
if early and piece.piece_type == chess.KNIGHT:
|
|
|
|
|
|
to_file = chess.square_file(m.to_square)
|
2025-08-22 21:19:10 +02:00
|
|
|
|
if to_file in (0, 7) and not is_cap:
|
|
|
|
|
|
s -= 140
|
2025-08-23 19:37:31 +02:00
|
|
|
|
if piece.piece_type == chess.KING and not board.is_castling(m):
|
|
|
|
|
|
# Strong demotion for early king shuffles; still demote in middlegame if heavy pieces remain
|
|
|
|
|
|
heavy_pieces = sum(1 for p in board.piece_map().values() if p.piece_type in (chess.QUEEN, chess.ROOK))
|
|
|
|
|
|
if early:
|
|
|
|
|
|
s -= 650
|
|
|
|
|
|
elif heavy_pieces >= 2:
|
|
|
|
|
|
s -= 400
|
2025-08-22 18:35:59 +02:00
|
|
|
|
if piece.piece_type == chess.ROOK and early and self._most_minors_undeveloped(board, piece.color):
|
2025-08-22 21:19:10 +02:00
|
|
|
|
s -= 140
|
|
|
|
|
|
if piece.piece_type == chess.QUEEN and early and not is_cap:
|
2025-08-22 18:35:59 +02:00
|
|
|
|
try:
|
|
|
|
|
|
gives_check = board.gives_check(m)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
gives_check = False
|
|
|
|
|
|
if not gives_check:
|
2025-08-22 21:19:10 +02:00
|
|
|
|
s -= 120
|
|
|
|
|
|
if early and not is_cap and not board.is_castling(m):
|
2025-08-22 18:35:59 +02:00
|
|
|
|
if not self._is_start_square(piece.piece_type, piece.color, m.from_square):
|
|
|
|
|
|
to_center = chess.square_file(m.to_square) in (3, 4) and chess.square_rank(m.to_square) in (2, 3, 4, 5)
|
|
|
|
|
|
if not to_center:
|
2025-08-22 21:19:10 +02:00
|
|
|
|
s -= 70
|
|
|
|
|
|
if piece.piece_type == chess.PAWN and early and not is_cap:
|
2025-08-22 18:35:59 +02:00
|
|
|
|
from_file = chess.square_file(m.from_square)
|
|
|
|
|
|
from_rank = chess.square_rank(m.from_square)
|
|
|
|
|
|
to_rank = chess.square_rank(m.to_square)
|
2025-08-22 22:30:47 +02:00
|
|
|
|
# Bishop kick patterns (a6 vs Bb5, h6 vs Bg5, g6 vs Bf5)
|
|
|
|
|
|
if piece.color == chess.BLACK:
|
|
|
|
|
|
if m.from_square == chess.H7 and m.to_square == chess.H6:
|
|
|
|
|
|
tgt = board.piece_at(chess.G5)
|
|
|
|
|
|
if tgt and tgt.color != piece.color and tgt.piece_type == chess.BISHOP:
|
|
|
|
|
|
s += 130
|
|
|
|
|
|
if m.from_square == chess.A7 and m.to_square == chess.A6:
|
|
|
|
|
|
tgt = board.piece_at(chess.B5)
|
|
|
|
|
|
if tgt and tgt.color != piece.color and tgt.piece_type == chess.BISHOP:
|
|
|
|
|
|
s += 120
|
|
|
|
|
|
if m.from_square == chess.G7 and m.to_square == chess.G6:
|
|
|
|
|
|
tgt = board.piece_at(chess.F5)
|
|
|
|
|
|
if tgt and tgt.color != piece.color and tgt.piece_type == chess.BISHOP:
|
|
|
|
|
|
s += 90
|
|
|
|
|
|
else:
|
|
|
|
|
|
if m.from_square == chess.H2 and m.to_square == chess.H3:
|
|
|
|
|
|
tgt = board.piece_at(chess.G4)
|
|
|
|
|
|
if tgt and tgt.color != piece.color and tgt.piece_type == chess.BISHOP:
|
|
|
|
|
|
s += 130
|
|
|
|
|
|
if m.from_square == chess.A2 and m.to_square == chess.A3:
|
|
|
|
|
|
tgt = board.piece_at(chess.B4)
|
|
|
|
|
|
if tgt and tgt.color != piece.color and tgt.piece_type == chess.BISHOP:
|
|
|
|
|
|
s += 120
|
|
|
|
|
|
if m.from_square == chess.G2 and m.to_square == chess.G3:
|
|
|
|
|
|
tgt = board.piece_at(chess.F4)
|
|
|
|
|
|
if tgt and tgt.color != piece.color and tgt.piece_type == chess.BISHOP:
|
|
|
|
|
|
s += 90
|
2025-08-22 21:48:28 +02:00
|
|
|
|
# Discourage early f-pawn push and also random wing pawn thrusts like a/b/g/h
|
2025-08-22 18:35:59 +02:00
|
|
|
|
if from_file == 5:
|
|
|
|
|
|
if piece.color == chess.WHITE and from_rank == 1 and to_rank == 2:
|
2025-08-22 21:19:10 +02:00
|
|
|
|
s -= 140
|
2025-08-22 18:35:59 +02:00
|
|
|
|
if piece.color == chess.BLACK and from_rank == 6 and to_rank == 5:
|
2025-08-22 21:19:10 +02:00
|
|
|
|
s -= 140
|
2025-08-22 21:48:28 +02:00
|
|
|
|
if from_file in (0, 1, 6, 7) and ((piece.color == chess.WHITE and from_rank == 1 and to_rank == 2) or (piece.color == chess.BLACK and from_rank == 6 and to_rank == 5)):
|
2025-08-23 18:54:56 +02:00
|
|
|
|
s -= 120
|
|
|
|
|
|
# Extra demotion for rook-pawn push before castling (weakens king flank)
|
|
|
|
|
|
if from_file in (0, 7):
|
|
|
|
|
|
king_sq = board.king(piece.color)
|
|
|
|
|
|
if king_sq is not None:
|
|
|
|
|
|
# Uncastled or castled short on same wing amplifies the penalty
|
|
|
|
|
|
if king_sq in ((chess.E1, chess.E8) if piece.color == chess.WHITE else (chess.E8, chess.E1)):
|
|
|
|
|
|
s -= 60
|
|
|
|
|
|
if king_sq in (chess.G1, chess.G8):
|
|
|
|
|
|
s -= 60
|
2025-08-22 21:48:28 +02:00
|
|
|
|
# Discourage early c-pawn push to c4/c5 if we already advanced the e-pawn (prevents e5+c5 blunder-y structures)
|
|
|
|
|
|
if from_file == 2:
|
|
|
|
|
|
e_pawn_sq = chess.E2 if piece.color == chess.WHITE else chess.E7
|
|
|
|
|
|
e_advanced = board.piece_at(e_pawn_sq) is None
|
|
|
|
|
|
if e_advanced and ((piece.color == chess.WHITE and from_rank == 1 and to_rank == 3) or (piece.color == chess.BLACK and from_rank == 6 and to_rank == 4)):
|
|
|
|
|
|
s -= 80
|
2025-08-22 18:35:59 +02:00
|
|
|
|
if chess.square_file(m.to_square) in (3, 4):
|
2025-08-23 18:13:53 +02:00
|
|
|
|
s += 30
|
2025-08-23 17:57:51 +02:00
|
|
|
|
# Mid/late game: discourage casual pawn shoves that don't fight the center
|
|
|
|
|
|
if piece.piece_type == chess.PAWN and (not early) and not is_cap and not m.promotion:
|
|
|
|
|
|
to_file = chess.square_file(m.to_square)
|
|
|
|
|
|
# Wing pawn pushes are most suspect
|
|
|
|
|
|
if to_file in (0, 7):
|
2025-08-23 18:13:53 +02:00
|
|
|
|
s -= 180
|
2025-08-23 17:57:51 +02:00
|
|
|
|
elif to_file in (1, 6):
|
2025-08-23 18:13:53 +02:00
|
|
|
|
s -= 130
|
2025-08-23 17:57:51 +02:00
|
|
|
|
elif to_file in (2, 5):
|
2025-08-23 18:13:53 +02:00
|
|
|
|
s -= 90
|
2025-08-23 17:57:51 +02:00
|
|
|
|
else:
|
2025-08-23 18:13:53 +02:00
|
|
|
|
s -= 50
|
2025-08-23 17:57:51 +02:00
|
|
|
|
# If most minors are still on the back rank, further discourage pawn moves
|
|
|
|
|
|
if self._most_minors_undeveloped(board, piece.color):
|
2025-08-23 18:13:53 +02:00
|
|
|
|
s -= 120
|
2025-08-23 18:54:56 +02:00
|
|
|
|
# Extra demotion for rook-pawn storms around our king side in middlegame
|
|
|
|
|
|
if to_file in (0, 7):
|
|
|
|
|
|
king_sq = board.king(piece.color)
|
|
|
|
|
|
if king_sq in (chess.G1, chess.G8, chess.E1, chess.E8):
|
|
|
|
|
|
s -= 60
|
2025-08-23 18:13:53 +02:00
|
|
|
|
# Reward minor piece development when most minors are undeveloped
|
|
|
|
|
|
if piece.piece_type in (chess.KNIGHT, chess.BISHOP) and not is_cap:
|
|
|
|
|
|
if chess.square_rank(m.from_square) in (0, 7):
|
|
|
|
|
|
s += 150
|
|
|
|
|
|
if self._most_minors_undeveloped(board, piece.color):
|
|
|
|
|
|
s += 120
|
|
|
|
|
|
# Small extra for heading toward the center
|
|
|
|
|
|
to_file = chess.square_file(m.to_square)
|
|
|
|
|
|
to_rank = chess.square_rank(m.to_square)
|
|
|
|
|
|
if to_file in (2, 3, 4, 5) and to_rank in (2, 3, 4, 5):
|
|
|
|
|
|
s += 40
|
2025-08-22 17:26:17 +02:00
|
|
|
|
return s
|
|
|
|
|
|
|
2025-08-22 16:49:30 +02:00
|
|
|
|
moves = list(board.legal_moves)
|
2025-08-22 17:26:17 +02:00
|
|
|
|
moves.sort(key=score_move, reverse=True)
|
|
|
|
|
|
return moves
|
|
|
|
|
|
|
|
|
|
|
|
def _evaluate(self, board: chess.Board) -> float:
|
2025-08-22 18:35:59 +02:00
|
|
|
|
# Terminal
|
2025-08-22 17:26:17 +02:00
|
|
|
|
if board.is_checkmate():
|
2025-08-22 18:35:59 +02:00
|
|
|
|
# If it's our turn and we're checkmated, that's bad for us
|
|
|
|
|
|
return -100000
|
2025-08-22 17:26:17 +02:00
|
|
|
|
if board.is_stalemate() or board.is_insufficient_material() or board.can_claim_draw():
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
2025-08-23 16:27:41 +02:00
|
|
|
|
white_score, _ = self._evaluate_components(board)
|
|
|
|
|
|
return white_score if board.turn == chess.WHITE else -white_score
|
|
|
|
|
|
|
|
|
|
|
|
def _evaluate_components(self, board: chess.Board) -> Tuple[float, dict]:
|
|
|
|
|
|
"""Compute the white-centric evaluation and return a components dict for transparency.
|
|
|
|
|
|
|
|
|
|
|
|
Returns a tuple of (white_score, components_dict). The components dict contains
|
|
|
|
|
|
the exact terms that sum to the white-centric score, plus small helper values.
|
|
|
|
|
|
"""
|
|
|
|
|
|
# Base material (white - black)
|
2025-08-22 17:26:17 +02:00
|
|
|
|
material = 0
|
2025-08-22 18:35:59 +02:00
|
|
|
|
piece_map = board.piece_map()
|
|
|
|
|
|
for sq, pc in piece_map.items():
|
|
|
|
|
|
val = self.piece_values[pc.piece_type]
|
|
|
|
|
|
material += val if pc.color == chess.WHITE else -val
|
|
|
|
|
|
|
2025-08-23 16:27:41 +02:00
|
|
|
|
# Doubled pawns penalty (white - black penalty)
|
2025-08-22 18:35:59 +02:00
|
|
|
|
dp_pen = self._doubled_pawns_penalty(board)
|
2025-08-23 16:27:41 +02:00
|
|
|
|
doubled_term = -dp_pen
|
2025-08-22 18:35:59 +02:00
|
|
|
|
|
|
|
|
|
|
# Mobility (white - black) with small weight
|
|
|
|
|
|
mob_w, mob_b = self._mobility(board)
|
|
|
|
|
|
mobility_term = (mob_w - mob_b) * 1.0
|
|
|
|
|
|
|
|
|
|
|
|
# Centralization: reward pieces in the center (white - black)
|
|
|
|
|
|
center = {chess.C3, chess.D3, chess.E3, chess.F3, chess.C4, chess.D4, chess.E4, chess.F4,
|
|
|
|
|
|
chess.C5, chess.D5, chess.E5, chess.F5, chess.C6, chess.D6, chess.E6, chess.F6}
|
|
|
|
|
|
center_score = 0
|
|
|
|
|
|
for sq, pc in piece_map.items():
|
|
|
|
|
|
if sq in center:
|
|
|
|
|
|
w = 10 if pc.piece_type in (chess.KNIGHT, chess.BISHOP) else 5
|
|
|
|
|
|
center_score += w if pc.color == chess.WHITE else -w
|
|
|
|
|
|
|
|
|
|
|
|
# Rooks on open files
|
|
|
|
|
|
rook_file_bonus = 0
|
|
|
|
|
|
for sq, pc in piece_map.items():
|
|
|
|
|
|
if pc.piece_type == chess.ROOK:
|
|
|
|
|
|
file_idx = chess.square_file(sq)
|
|
|
|
|
|
if self._is_open_file(board, file_idx):
|
|
|
|
|
|
rook_file_bonus += 15 if pc.color == chess.WHITE else -15
|
|
|
|
|
|
|
|
|
|
|
|
# King safety: prefer castled in middlegame (queens/rooks present)
|
|
|
|
|
|
safety = 0
|
|
|
|
|
|
heavy_pieces = sum(1 for p in piece_map.values() if p.piece_type in (chess.QUEEN, chess.ROOK))
|
|
|
|
|
|
if heavy_pieces >= 3:
|
|
|
|
|
|
wk_sq = board.king(chess.WHITE)
|
|
|
|
|
|
bk_sq = board.king(chess.BLACK)
|
|
|
|
|
|
safety += self._king_safety_bonus(wk_sq, chess.WHITE)
|
|
|
|
|
|
safety -= self._king_safety_bonus(bk_sq, chess.BLACK)
|
|
|
|
|
|
# Penalize wandering kings early if not castled squares
|
|
|
|
|
|
if self._is_early_game(board):
|
|
|
|
|
|
if wk_sq not in (chess.E1, chess.G1, chess.C1):
|
|
|
|
|
|
safety -= 40
|
|
|
|
|
|
if bk_sq not in (chess.E8, chess.G8, chess.C8):
|
|
|
|
|
|
safety += 40
|
|
|
|
|
|
|
2025-08-22 21:48:28 +02:00
|
|
|
|
# Early queen raid penalty: queen deep in opponent camp in the opening
|
|
|
|
|
|
queen_raid_pen = 0
|
|
|
|
|
|
if self._is_early_game(board):
|
|
|
|
|
|
q_w = board.pieces(chess.QUEEN, chess.WHITE)
|
|
|
|
|
|
q_b = board.pieces(chess.QUEEN, chess.BLACK)
|
|
|
|
|
|
if q_w:
|
|
|
|
|
|
qsq = next(iter(q_w))
|
|
|
|
|
|
# White queen on rank 7/8 is often risky early
|
|
|
|
|
|
if chess.square_rank(qsq) >= 6:
|
|
|
|
|
|
queen_raid_pen -= 30
|
|
|
|
|
|
if q_b:
|
|
|
|
|
|
qsq = next(iter(q_b))
|
|
|
|
|
|
# Black queen on rank 1/2 is often risky early
|
|
|
|
|
|
if chess.square_rank(qsq) <= 1:
|
|
|
|
|
|
queen_raid_pen += 30
|
|
|
|
|
|
|
2025-08-22 18:35:59 +02:00
|
|
|
|
# Piece-square tendencies (small)
|
|
|
|
|
|
pst = self._pst_score(board)
|
|
|
|
|
|
|
2025-08-23 16:27:41 +02:00
|
|
|
|
# Hanging/loose pieces penalty (white - black)
|
2025-08-22 21:19:10 +02:00
|
|
|
|
hanging_pen = self._hanging_pieces_penalty(board)
|
2025-08-23 16:27:41 +02:00
|
|
|
|
hanging_term = -hanging_pen
|
|
|
|
|
|
|
|
|
|
|
|
white_score = material + doubled_term + mobility_term + center_score + rook_file_bonus + safety + queen_raid_pen + pst + hanging_term
|
|
|
|
|
|
comps = {
|
|
|
|
|
|
"material": material,
|
|
|
|
|
|
"doubled_pawns_term": doubled_term,
|
|
|
|
|
|
"mobility_term": mobility_term,
|
|
|
|
|
|
"mob_w": mob_w,
|
|
|
|
|
|
"mob_b": mob_b,
|
|
|
|
|
|
"center_score": center_score,
|
|
|
|
|
|
"rook_file_bonus": rook_file_bonus,
|
|
|
|
|
|
"safety": safety,
|
|
|
|
|
|
"queen_raid_pen": queen_raid_pen,
|
|
|
|
|
|
"pst": pst,
|
|
|
|
|
|
"hanging_pieces_term": hanging_term,
|
|
|
|
|
|
}
|
|
|
|
|
|
return white_score, comps
|
|
|
|
|
|
|
|
|
|
|
|
def _annotate_move_simple(self, board: chess.Board, m: chess.Move) -> str:
|
|
|
|
|
|
"""Return a short, human-friendly tag list for a move."""
|
|
|
|
|
|
tags = []
|
|
|
|
|
|
if board.is_capture(m):
|
|
|
|
|
|
tags.append("capture")
|
|
|
|
|
|
if m.promotion:
|
|
|
|
|
|
try:
|
|
|
|
|
|
tags.append(f"promotes={chess.piece_symbol(m.promotion).upper()}")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
tags.append("promotes")
|
|
|
|
|
|
try:
|
|
|
|
|
|
if board.gives_check(m):
|
|
|
|
|
|
tags.append("check")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if board.is_castling(m):
|
|
|
|
|
|
tags.append("castle")
|
|
|
|
|
|
# Centralization
|
|
|
|
|
|
center = {chess.D4, chess.E4, chess.D5, chess.E5}
|
|
|
|
|
|
if m.to_square in center:
|
|
|
|
|
|
tags.append("center")
|
|
|
|
|
|
# Development: minor piece leaves back rank
|
|
|
|
|
|
piece = board.piece_at(m.from_square)
|
|
|
|
|
|
if piece and piece.piece_type in (chess.KNIGHT, chess.BISHOP):
|
|
|
|
|
|
if chess.square_rank(m.from_square) in (0, 7):
|
|
|
|
|
|
tags.append("develops")
|
|
|
|
|
|
# Rook to (semi-)open file
|
|
|
|
|
|
if piece and piece.piece_type == chess.ROOK:
|
|
|
|
|
|
file_idx = chess.square_file(m.to_square)
|
|
|
|
|
|
if self._is_open_file(board, file_idx):
|
|
|
|
|
|
tags.append("open-file")
|
|
|
|
|
|
return ",".join(tags)
|
2025-08-22 18:35:59 +02:00
|
|
|
|
|
|
|
|
|
|
def _opening_book_move(self, board: chess.Board) -> Optional[chess.Move]:
|
|
|
|
|
|
# Only use book for the first few plies and only from starting positions
|
|
|
|
|
|
if board.move_stack is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if board.fullmove_number > 10:
|
|
|
|
|
|
return None
|
2025-08-23 17:57:51 +02:00
|
|
|
|
# If there's no history (e.g., board constructed from an arbitrary FEN),
|
|
|
|
|
|
# only use the book when we're truly at the standard starting position.
|
|
|
|
|
|
if len(board.move_stack) == 0:
|
|
|
|
|
|
try:
|
|
|
|
|
|
start_board = chess.Board()
|
|
|
|
|
|
if board.board_fen() != start_board.board_fen():
|
|
|
|
|
|
return None
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return None
|
2025-08-22 18:35:59 +02:00
|
|
|
|
# Build UCI history from the start position
|
|
|
|
|
|
hist = tuple(m.uci() for m in board.move_stack)
|
|
|
|
|
|
# Try exact key; also try from a truncated start if someone inserted off-book early
|
|
|
|
|
|
for klen in range(len(hist), -1, -1):
|
|
|
|
|
|
key = hist[:klen]
|
|
|
|
|
|
if key in self.opening_book:
|
|
|
|
|
|
candidates = self.opening_book[key]
|
|
|
|
|
|
# Filter to legal moves only
|
|
|
|
|
|
legals = {m.uci(): m for m in board.legal_moves}
|
|
|
|
|
|
legal_ucis = [u for u in candidates if u in legals]
|
|
|
|
|
|
if legal_ucis:
|
|
|
|
|
|
# Choose the first candidate to be stable; could randomize if desired
|
|
|
|
|
|
return legals[legal_ucis[0]]
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def _is_start_square(self, piece_type: chess.PieceType, color: chess.Color, sq: int) -> bool:
|
|
|
|
|
|
file_idx = chess.square_file(sq)
|
|
|
|
|
|
rank_idx = chess.square_rank(sq)
|
|
|
|
|
|
if piece_type == chess.KING:
|
|
|
|
|
|
return (file_idx, rank_idx) == ((4, 0) if color == chess.WHITE else (4, 7))
|
|
|
|
|
|
if piece_type == chess.QUEEN:
|
|
|
|
|
|
return (file_idx, rank_idx) == ((3, 0) if color == chess.WHITE else (3, 7))
|
|
|
|
|
|
if piece_type == chess.ROOK:
|
|
|
|
|
|
return (file_idx, rank_idx) in ({(0, 0), (7, 0)} if color == chess.WHITE else {(0, 7), (7, 7)})
|
|
|
|
|
|
if piece_type == chess.BISHOP:
|
|
|
|
|
|
return (file_idx, rank_idx) in ({(2, 0), (5, 0)} if color == chess.WHITE else {(2, 7), (5, 7)})
|
|
|
|
|
|
if piece_type == chess.KNIGHT:
|
|
|
|
|
|
return (file_idx, rank_idx) in ({(1, 0), (6, 0)} if color == chess.WHITE else {(1, 7), (6, 7)})
|
|
|
|
|
|
if piece_type == chess.PAWN:
|
|
|
|
|
|
return rank_idx == (1 if color == chess.WHITE else 6)
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
def _pst_score(self, board: chess.Board) -> int:
|
|
|
|
|
|
score = 0
|
|
|
|
|
|
for sq, pc in board.piece_map().items():
|
|
|
|
|
|
file_idx = chess.square_file(sq)
|
|
|
|
|
|
rank_idx = chess.square_rank(sq)
|
|
|
|
|
|
sign = 1 if pc.color == chess.WHITE else -1
|
|
|
|
|
|
if pc.piece_type == chess.KNIGHT:
|
|
|
|
|
|
# Knights: center good, rim bad
|
|
|
|
|
|
if file_idx in (0, 7):
|
|
|
|
|
|
score -= 20 * sign
|
|
|
|
|
|
elif file_idx in (1, 6):
|
|
|
|
|
|
score -= 10 * sign
|
|
|
|
|
|
if rank_idx in (0, 7):
|
|
|
|
|
|
score -= 10 * sign
|
|
|
|
|
|
if (file_idx, rank_idx) in {(2, 2), (3, 2), (4, 2), (5, 2), (2, 3), (3, 3), (4, 3), (5, 3)}:
|
|
|
|
|
|
score += 15 * sign
|
|
|
|
|
|
elif pc.piece_type == chess.BISHOP:
|
|
|
|
|
|
# Bishops: prefer long diagonals and central ranks
|
|
|
|
|
|
if rank_idx in (2, 3, 4, 5):
|
|
|
|
|
|
score += 5 * sign
|
|
|
|
|
|
elif pc.piece_type == chess.PAWN:
|
|
|
|
|
|
# Central pawns advanced are nice
|
|
|
|
|
|
if file_idx in (3, 4):
|
|
|
|
|
|
score += rank_idx * 1 * sign if pc.color == chess.WHITE else (7 - rank_idx) * 1 * sign
|
|
|
|
|
|
return score
|
|
|
|
|
|
|
|
|
|
|
|
def _is_early_game(self, board: chess.Board) -> bool:
|
|
|
|
|
|
# Quick heuristic for opening/middlegame
|
|
|
|
|
|
heavy_pieces = sum(1 for p in board.piece_map().values() if p.piece_type in (chess.QUEEN, chess.ROOK))
|
|
|
|
|
|
return heavy_pieces >= 3 and board.fullmove_number < 15
|
|
|
|
|
|
|
|
|
|
|
|
def _most_minors_undeveloped(self, board: chess.Board, color: chess.Color) -> bool:
|
|
|
|
|
|
# True if 3 or 4 minors still on back rank starting squares
|
|
|
|
|
|
if color == chess.WHITE:
|
|
|
|
|
|
starts = [chess.B1, chess.G1, chess.C1, chess.F1]
|
2025-08-22 17:26:17 +02:00
|
|
|
|
else:
|
2025-08-22 18:35:59 +02:00
|
|
|
|
starts = [chess.B8, chess.G8, chess.C8, chess.F8]
|
|
|
|
|
|
cnt = 0
|
|
|
|
|
|
for sq in starts:
|
|
|
|
|
|
pc = board.piece_at(sq)
|
|
|
|
|
|
if pc and pc.color == color and pc.piece_type in (chess.KNIGHT, chess.BISHOP):
|
|
|
|
|
|
cnt += 1
|
|
|
|
|
|
return cnt >= 3
|
|
|
|
|
|
|
|
|
|
|
|
def _mobility(self, board: chess.Board) -> Tuple[int, int]:
|
|
|
|
|
|
# Count legal moves for both sides using copies
|
|
|
|
|
|
w_board = board if board.turn == chess.WHITE else board.copy(stack=False)
|
|
|
|
|
|
if w_board.turn != chess.WHITE:
|
|
|
|
|
|
w_board.turn = chess.WHITE
|
|
|
|
|
|
b_board = board if board.turn == chess.BLACK else board.copy(stack=False)
|
|
|
|
|
|
if b_board.turn != chess.BLACK:
|
|
|
|
|
|
b_board.turn = chess.BLACK
|
|
|
|
|
|
return sum(1 for _ in w_board.legal_moves), sum(1 for _ in b_board.legal_moves)
|
2025-08-22 17:26:17 +02:00
|
|
|
|
|
2025-08-22 18:35:59 +02:00
|
|
|
|
def _is_open_file(self, board: chess.Board, file_idx: int) -> bool:
|
|
|
|
|
|
# True if no pawns on this file (either color)
|
|
|
|
|
|
for rank in range(8):
|
|
|
|
|
|
sq = chess.square(file_idx, rank)
|
|
|
|
|
|
pc = board.piece_at(sq)
|
|
|
|
|
|
if pc and pc.piece_type == chess.PAWN:
|
|
|
|
|
|
return False
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
def _doubled_pawns_penalty(self, board: chess.Board) -> int:
|
|
|
|
|
|
# Penalty in centipawns for doubled pawns (per extra pawn on a file)
|
|
|
|
|
|
penalty = 0
|
|
|
|
|
|
for color in (chess.WHITE, chess.BLACK):
|
|
|
|
|
|
for file_idx in range(8):
|
|
|
|
|
|
cnt = 0
|
|
|
|
|
|
for rank in range(8):
|
|
|
|
|
|
sq = chess.square(file_idx, rank)
|
|
|
|
|
|
pc = board.piece_at(sq)
|
|
|
|
|
|
if pc and pc.piece_type == chess.PAWN and pc.color == color:
|
|
|
|
|
|
cnt += 1
|
|
|
|
|
|
if cnt > 1:
|
|
|
|
|
|
penalty += (cnt - 1) * 12 * (1 if color == chess.WHITE else -1)
|
|
|
|
|
|
return penalty
|
|
|
|
|
|
|
|
|
|
|
|
def _king_safety_bonus(self, king_sq: int, color: chess.Color) -> int:
|
|
|
|
|
|
# Bonus for castled-like positions in middlegame; penalty for center-exposed kings
|
|
|
|
|
|
if king_sq is None:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
file_idx = chess.square_file(king_sq)
|
|
|
|
|
|
rank_idx = chess.square_rank(king_sq)
|
|
|
|
|
|
if color == chess.WHITE:
|
|
|
|
|
|
if (file_idx, rank_idx) in {(6, 0), (2, 0)}:
|
|
|
|
|
|
return 20
|
|
|
|
|
|
if (file_idx, rank_idx) in {(4, 0), (3, 0)}:
|
|
|
|
|
|
return -10
|
|
|
|
|
|
else:
|
|
|
|
|
|
if (file_idx, rank_idx) in {(6, 7), (2, 7)}:
|
|
|
|
|
|
return 20
|
|
|
|
|
|
if (file_idx, rank_idx) in {(4, 7), (3, 7)}:
|
|
|
|
|
|
return -10
|
|
|
|
|
|
return 0
|
2025-08-22 21:19:10 +02:00
|
|
|
|
|
|
|
|
|
|
# --- Tactical helpers ---
|
|
|
|
|
|
def _see_value(self, board: chess.Board, move: chess.Move) -> int:
|
|
|
|
|
|
"""Static Exchange Evaluation for a move in centipawns.
|
|
|
|
|
|
|
|
|
|
|
|
Positive is good for the side to move. Uses python-chess SEE when available.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if hasattr(board, "see"):
|
|
|
|
|
|
return int(board.see(move))
|
|
|
|
|
|
# Fallback MVV/LVA approximation
|
|
|
|
|
|
victim = board.piece_at(move.to_square)
|
|
|
|
|
|
attacker = board.piece_at(move.from_square)
|
|
|
|
|
|
if not attacker:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
gain = 0
|
|
|
|
|
|
if victim:
|
|
|
|
|
|
gain += self.piece_values.get(victim.piece_type, 0)
|
|
|
|
|
|
gain -= self.piece_values.get(attacker.piece_type, 0)
|
|
|
|
|
|
return gain
|
|
|
|
|
|
|
|
|
|
|
|
def _hanging_pieces_penalty(self, board: chess.Board) -> int:
|
|
|
|
|
|
"""Penalty for pieces that can be captured for non-negative SEE by the opponent."""
|
|
|
|
|
|
pen_white = 0
|
|
|
|
|
|
pen_black = 0
|
|
|
|
|
|
# Evaluate from a neutral board state without mutating turn logic
|
|
|
|
|
|
for sq, pc in board.piece_map().items():
|
|
|
|
|
|
if pc.piece_type == chess.KING:
|
|
|
|
|
|
continue
|
|
|
|
|
|
opp = not pc.color
|
|
|
|
|
|
# If opponent has a legal capture on this square with SEE >= 0, penalize
|
|
|
|
|
|
attackers = board.attackers(opp, sq)
|
|
|
|
|
|
if not attackers:
|
|
|
|
|
|
continue
|
|
|
|
|
|
bad = False
|
|
|
|
|
|
for a in attackers:
|
|
|
|
|
|
m = chess.Move(a, sq)
|
|
|
|
|
|
if m in board.legal_moves:
|
|
|
|
|
|
try:
|
|
|
|
|
|
see_gain = self._see_value(board, m)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
see_gain = self.piece_values.get(pc.piece_type, 0) - 1
|
|
|
|
|
|
if see_gain >= 0:
|
|
|
|
|
|
bad = True
|
|
|
|
|
|
break
|
|
|
|
|
|
if bad:
|
|
|
|
|
|
val = int(self.piece_values.get(pc.piece_type, 0) * 0.33)
|
|
|
|
|
|
if pc.color == chess.WHITE:
|
|
|
|
|
|
pen_white += val
|
|
|
|
|
|
else:
|
|
|
|
|
|
pen_black += val
|
|
|
|
|
|
# Convert to white-centric score
|
|
|
|
|
|
return pen_white - pen_black
|
2025-08-22 22:30:47 +02:00
|
|
|
|
|
|
|
|
|
|
# --- Risk/Pattern helpers ---
|
|
|
|
|
|
def _is_bishop_sac_on_f2f7(self, board: chess.Board, move: chess.Move) -> bool:
|
|
|
|
|
|
pc = board.piece_at(move.from_square)
|
|
|
|
|
|
if not pc or pc.piece_type != chess.BISHOP:
|
|
|
|
|
|
return False
|
|
|
|
|
|
# Only consider captures of the f-pawn on its home square
|
|
|
|
|
|
target = chess.F2 if pc.color == chess.BLACK else chess.F7
|
|
|
|
|
|
if move.to_square != target:
|
|
|
|
|
|
return False
|
|
|
|
|
|
if not board.is_capture(move):
|
|
|
|
|
|
return False
|
|
|
|
|
|
victim = board.piece_at(move.to_square)
|
|
|
|
|
|
if not victim or victim.piece_type != chess.PAWN:
|
|
|
|
|
|
return False
|
|
|
|
|
|
# Typically it's tempting because it's check; if not a check, still likely bad
|
|
|
|
|
|
try:
|
|
|
|
|
|
is_check = board.gives_check(move)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
is_check = False
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
def _risk_score(self, board: chess.Board, move: chess.Move) -> int:
|
|
|
|
|
|
"""Lower is safer. Positive values indicate tactical/material risk for the mover."""
|
|
|
|
|
|
risk = 0
|
|
|
|
|
|
# Negative SEE means we may be losing material on this move
|
|
|
|
|
|
try:
|
|
|
|
|
|
see = int(self._see_value(board, move))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
see = 0
|
|
|
|
|
|
if see < 0:
|
|
|
|
|
|
risk += -see
|
|
|
|
|
|
# Extra risk for early bishop sac on f2/f7
|
|
|
|
|
|
if self._is_early_game(board) and self._is_bishop_sac_on_f2f7(board, move):
|
|
|
|
|
|
risk += 600
|
2025-08-23 15:46:05 +02:00
|
|
|
|
# Queen trap risk (e.g., greedy corner rook grabs like Qxh8?)
|
|
|
|
|
|
try:
|
|
|
|
|
|
risk += self._queen_trap_risk(board, move)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2025-08-23 17:11:15 +02:00
|
|
|
|
# Non-castling king moves in the early/middle game (or when heavy pieces remain) are risky/passive
|
|
|
|
|
|
pc = board.piece_at(move.from_square)
|
|
|
|
|
|
if pc and pc.piece_type == chess.KING and not board.is_castling(move):
|
|
|
|
|
|
heavy_pieces = sum(1 for p in board.piece_map().values() if p.piece_type in (chess.QUEEN, chess.ROOK))
|
2025-08-23 19:37:31 +02:00
|
|
|
|
if self._is_early_game(board):
|
|
|
|
|
|
risk += 350
|
|
|
|
|
|
if heavy_pieces >= 2:
|
|
|
|
|
|
risk += 250
|
2025-08-23 18:54:56 +02:00
|
|
|
|
# Rook-pawn pushes (a/h) are often loosening; penalize when king safety matters
|
|
|
|
|
|
if pc and pc.piece_type == chess.PAWN:
|
|
|
|
|
|
from_file = chess.square_file(move.from_square)
|
|
|
|
|
|
to_file = chess.square_file(move.to_square)
|
|
|
|
|
|
if from_file in (0, 7) and to_file in (0, 7) and not board.is_capture(move) and not move.promotion:
|
|
|
|
|
|
king_sq = board.king(pc.color)
|
|
|
|
|
|
heavy_pieces = sum(1 for p in board.piece_map().values() if p.piece_type in (chess.QUEEN, chess.ROOK))
|
|
|
|
|
|
if self._is_early_game(board) or heavy_pieces >= 2:
|
|
|
|
|
|
risk += 180
|
|
|
|
|
|
if king_sq in (chess.E1, chess.E8, chess.G1, chess.G8):
|
|
|
|
|
|
risk += 120
|
|
|
|
|
|
# Risky promotions to queen into heavy fire without coverage
|
|
|
|
|
|
if pc and pc.piece_type == chess.PAWN and move.promotion == chess.QUEEN:
|
|
|
|
|
|
board.push(move)
|
|
|
|
|
|
try:
|
|
|
|
|
|
qsq = move.to_square
|
|
|
|
|
|
attackers = len(board.attackers(not pc.color, qsq))
|
|
|
|
|
|
defenders = len(board.attackers(pc.color, qsq))
|
|
|
|
|
|
if attackers >= max(1, defenders):
|
|
|
|
|
|
risk += 600
|
|
|
|
|
|
finally:
|
|
|
|
|
|
board.pop()
|
2025-08-23 15:46:05 +02:00
|
|
|
|
return risk
|
|
|
|
|
|
|
|
|
|
|
|
def _queen_trap_risk(self, board: chess.Board, move: chess.Move) -> int:
|
|
|
|
|
|
"""Estimate risk of the mover's queen becoming trapped or heavily attacked after this move.
|
|
|
|
|
|
|
|
|
|
|
|
Adds a notable penalty for queen captures on corner rooks when defenders outweigh attackers
|
|
|
|
|
|
or when the queen has very limited safe mobility from the destination square.
|
|
|
|
|
|
"""
|
|
|
|
|
|
pc = board.piece_at(move.from_square)
|
|
|
|
|
|
if not pc or pc.piece_type != chess.QUEEN:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
# Pre-move info about target square
|
|
|
|
|
|
victim_pre = board.piece_at(move.to_square)
|
|
|
|
|
|
is_corner = move.to_square in {chess.A8, chess.H8, chess.A1, chess.H1}
|
|
|
|
|
|
is_corner_rook_capture = bool(victim_pre and victim_pre.piece_type == chess.ROOK and is_corner)
|
|
|
|
|
|
|
|
|
|
|
|
# Simulate the move
|
|
|
|
|
|
board.push(move)
|
|
|
|
|
|
try:
|
|
|
|
|
|
my_color = not board.turn # after push, side to move flipped; queen belongs to the previous mover
|
|
|
|
|
|
qsq = move.to_square
|
|
|
|
|
|
risk = 0
|
|
|
|
|
|
|
|
|
|
|
|
# If queen moved to a corner, that's typically risky (limited squares)
|
|
|
|
|
|
if qsq in {chess.A8, chess.H8, chess.A1, chess.H1}:
|
|
|
|
|
|
risk += 120
|
|
|
|
|
|
|
|
|
|
|
|
# Count attackers/defenders on the queen's square
|
|
|
|
|
|
attackers = len(board.attackers(not my_color, qsq))
|
|
|
|
|
|
defenders = len(board.attackers(my_color, qsq))
|
|
|
|
|
|
if attackers >= max(1, defenders):
|
|
|
|
|
|
# Heavily attacked or under-defended queen on destination
|
|
|
|
|
|
risk += 350
|
|
|
|
|
|
|
|
|
|
|
|
# Estimate queen mobility: how many immediate moves are not landing on attacked squares
|
|
|
|
|
|
safe_exits = 0
|
|
|
|
|
|
for m in board.legal_moves:
|
|
|
|
|
|
if m.from_square == qsq:
|
|
|
|
|
|
# Quick static safety: avoid landing on currently attacked squares
|
|
|
|
|
|
if not board.is_attacked_by(not my_color, m.to_square):
|
|
|
|
|
|
safe_exits += 1
|
|
|
|
|
|
if safe_exits >= 4:
|
|
|
|
|
|
break
|
|
|
|
|
|
if safe_exits <= 1:
|
|
|
|
|
|
risk += 450
|
|
|
|
|
|
elif safe_exits <= 3:
|
|
|
|
|
|
risk += 200
|
|
|
|
|
|
# Extra penalty if this was a corner rook capture and exits are limited or square is contested
|
|
|
|
|
|
if is_corner_rook_capture:
|
|
|
|
|
|
base = 300
|
|
|
|
|
|
# If heavily attacked or exits are poor, escalate
|
|
|
|
|
|
if attackers >= max(1, defenders) or safe_exits <= 2:
|
|
|
|
|
|
base += 600
|
|
|
|
|
|
# Taking with check is often tempting; still risky. Keep the penalty significant.
|
|
|
|
|
|
risk += base
|
|
|
|
|
|
finally:
|
|
|
|
|
|
board.pop()
|
2025-08-22 22:30:47 +02:00
|
|
|
|
return risk
|