mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 16:23:04 +02:00
632 lines
27 KiB
Python
632 lines
27 KiB
Python
import time
|
||
import random
|
||
from typing import Optional, Tuple
|
||
|
||
import chess
|
||
|
||
|
||
class RandomEngine:
|
||
"""A simple engine with a tiny alpha-beta search and material+mobility eval.
|
||
|
||
Keeps the same name for compatibility, but no longer picks purely random moves.
|
||
"""
|
||
|
||
def __init__(self, depth: int = 100, max_time_sec: float = 20):
|
||
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,
|
||
}
|
||
|
||
# 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
|
||
("e2e4",): ["e7e5", "c7c5", "e7e6", "c7c6", "g8f6", "d7d5"],
|
||
# 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"],
|
||
|
||
# --- 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"],
|
||
}
|
||
|
||
def choose_move(self, board: chess.Board, time_budget_sec: Optional[float] = None) -> Optional[chess.Move]:
|
||
start = time.time()
|
||
best_move: Optional[chess.Move] = None
|
||
# 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)
|
||
best_score = -float("inf") if board.turn else float("inf")
|
||
|
||
# Opening book shortcut (very early only)
|
||
book_mv = self._opening_book_move(board)
|
||
if book_mv is not None:
|
||
return book_mv
|
||
|
||
# Iterative deepening up to depth or time limit
|
||
for d in range(1, self.depth + 1):
|
||
if time.time() >= self._deadline:
|
||
break
|
||
score, move = self._search_root(board, d, start)
|
||
if move is not None:
|
||
best_move, best_score = move, score
|
||
|
||
# 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
|
||
|
||
def choose_move_with_explanation(self, board: chess.Board, time_budget_sec: Optional[float] = None) -> Tuple[Optional[chess.Move], str]:
|
||
"""Return the chosen move and a human-readable explanation of top candidates.
|
||
|
||
The explanation lists top candidates with scores and quick annotations.
|
||
"""
|
||
start = time.time()
|
||
# 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)
|
||
depth_used = 0
|
||
best_move: Optional[chess.Move] = None
|
||
scores: list[Tuple[chess.Move, float]] = []
|
||
|
||
# Opening book shortcut
|
||
book_mv = self._opening_book_move(board)
|
||
if book_mv is not None:
|
||
try:
|
||
return book_mv, f"opening-book: {board.san(book_mv)} ({book_mv.uci()})"
|
||
except Exception:
|
||
return book_mv, f"opening-book: {book_mv.uci()}"
|
||
|
||
# Analyze all legal moves at the root with alpha-beta to given depth/time
|
||
for d in range(1, self.depth + 1):
|
||
if time.time() >= self._deadline:
|
||
break
|
||
depth_used = d
|
||
scores = self._analyze_root(board, d, start)
|
||
if scores:
|
||
best_move = scores[0][0]
|
||
|
||
if not scores:
|
||
# Fallback
|
||
mv = self.choose_move(board)
|
||
return mv, "fallback: random/legal-only (no analysis)"
|
||
|
||
# Build explanation
|
||
def annotate(m: chess.Move) -> str:
|
||
tags = []
|
||
if board.is_capture(m):
|
||
tags.append("capture")
|
||
if m.promotion:
|
||
tags.append(f"promotes={chess.piece_symbol(m.promotion).upper()}")
|
||
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
|
||
if board.piece_at(m.from_square) and board.piece_at(m.from_square).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 board.piece_at(m.from_square) and board.piece_at(m.from_square).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)
|
||
|
||
top = scores[:5]
|
||
best_cp = top[0][1]
|
||
lines = [
|
||
f"depth={depth_used} time={time.time()-start:.2f}s candidates={len(scores)}",
|
||
f"best {board.san(top[0][0])} ({top[0][0].uci()}) score={best_cp:.1f} reasons=[{annotate(top[0][0])}]",
|
||
]
|
||
if len(top) > 1:
|
||
lines.append("alternatives:")
|
||
for mv, sc in top[1:]:
|
||
delta = sc - best_cp
|
||
lines.append(f" {board.san(mv)} ({mv.uci()}) score={sc:.1f} delta={delta:+.1f} reasons=[{annotate(mv)}]")
|
||
|
||
return best_move, "\n".join(lines)
|
||
|
||
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):
|
||
if time.time() >= self._deadline:
|
||
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
|
||
scored.sort(key=lambda t: t[1], reverse=True)
|
||
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:
|
||
if time.time() >= self._deadline:
|
||
break
|
||
board.push(move)
|
||
score = -self._alphabeta(board, depth - 1, -beta, -alpha, start)
|
||
board.pop()
|
||
if score > best_score:
|
||
best_score = score
|
||
best_move = move
|
||
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
|
||
if time.time() >= self._deadline:
|
||
return self._evaluate(board)
|
||
|
||
# Terminal nodes
|
||
if board.is_game_over():
|
||
return self._evaluate(board)
|
||
if depth == 0:
|
||
return self._quiescence(board, alpha, beta, start)
|
||
|
||
best = -float("inf")
|
||
for move in self._ordered_moves(board):
|
||
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
|
||
|
||
def _quiescence(self, board: chess.Board, alpha: float, beta: float, start: float) -> float:
|
||
# Stand-pat
|
||
stand_pat = self._evaluate(board)
|
||
if stand_pat >= beta:
|
||
return beta
|
||
if stand_pat > alpha:
|
||
alpha = stand_pat
|
||
|
||
# Explore captures to avoid horizon effect
|
||
# Consider only captures/promotions and order by SEE to reduce blunders
|
||
capture_moves: list[tuple[int, chess.Move]] = []
|
||
for move in self._ordered_moves(board):
|
||
if time.time() >= self._deadline:
|
||
break
|
||
if not board.is_capture(move) and not move.promotion:
|
||
continue
|
||
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
|
||
board.push(move)
|
||
score = -self._quiescence(board, -beta, -alpha, start)
|
||
board.pop()
|
||
if score >= beta:
|
||
return beta
|
||
if score > alpha:
|
||
alpha = score
|
||
return alpha
|
||
|
||
def _ordered_moves(self, board: chess.Board):
|
||
# Move ordering that mixes tactical SEE with simple heuristics
|
||
def score_move(m: chess.Move) -> int:
|
||
s = 0
|
||
is_cap = board.is_capture(m)
|
||
if is_cap:
|
||
s += 1000
|
||
if m.promotion:
|
||
s += 800
|
||
try:
|
||
if board.gives_check(m):
|
||
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))
|
||
except Exception:
|
||
pass
|
||
|
||
early = self._is_early_game(board)
|
||
piece = board.piece_at(m.from_square)
|
||
if piece:
|
||
# Discourage premature queen adventures in the opening
|
||
if piece.piece_type == chess.QUEEN and early:
|
||
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
|
||
if board.is_castling(m):
|
||
s += 650
|
||
if piece.piece_type in (chess.KNIGHT, chess.BISHOP):
|
||
if chess.square_rank(m.from_square) in (0, 7) and not is_cap:
|
||
s += 90
|
||
if early and piece.piece_type == chess.KNIGHT:
|
||
to_file = chess.square_file(m.to_square)
|
||
if to_file in (0, 7) and not is_cap:
|
||
s -= 140
|
||
if piece.piece_type == chess.KING and early and not board.is_castling(m):
|
||
s -= 450
|
||
if piece.piece_type == chess.ROOK and early and self._most_minors_undeveloped(board, piece.color):
|
||
s -= 140
|
||
if piece.piece_type == chess.QUEEN and early and not is_cap:
|
||
try:
|
||
gives_check = board.gives_check(m)
|
||
except Exception:
|
||
gives_check = False
|
||
if not gives_check:
|
||
s -= 120
|
||
if early and not is_cap and not board.is_castling(m):
|
||
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:
|
||
s -= 70
|
||
if piece.piece_type == chess.PAWN and early and not is_cap:
|
||
from_file = chess.square_file(m.from_square)
|
||
from_rank = chess.square_rank(m.from_square)
|
||
to_rank = chess.square_rank(m.to_square)
|
||
# Discourage early f-pawn push and also random wing pawn thrusts like a/b/g/h
|
||
if from_file == 5:
|
||
if piece.color == chess.WHITE and from_rank == 1 and to_rank == 2:
|
||
s -= 140
|
||
if piece.color == chess.BLACK and from_rank == 6 and to_rank == 5:
|
||
s -= 140
|
||
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)):
|
||
s -= 60
|
||
# 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
|
||
if chess.square_file(m.to_square) in (3, 4):
|
||
s += 50
|
||
return s
|
||
|
||
moves = list(board.legal_moves)
|
||
moves.sort(key=score_move, reverse=True)
|
||
return moves
|
||
|
||
def _evaluate(self, board: chess.Board) -> float:
|
||
# Terminal
|
||
if board.is_checkmate():
|
||
# If it's our turn and we're checkmated, that's bad for us
|
||
return -100000
|
||
if board.is_stalemate() or board.is_insufficient_material() or board.can_claim_draw():
|
||
return 0
|
||
|
||
# Base material (white minus black)
|
||
material = 0
|
||
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
|
||
|
||
# Doubled pawns penalty
|
||
dp_pen = self._doubled_pawns_penalty(board)
|
||
|
||
# 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
|
||
|
||
# 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
|
||
|
||
# Piece-square tendencies (small)
|
||
pst = self._pst_score(board)
|
||
|
||
# Hanging/loose pieces penalty
|
||
hanging_pen = self._hanging_pieces_penalty(board)
|
||
|
||
# Aggregate white-centric score then convert to side-to-move via negamax
|
||
white_score = material - dp_pen + mobility_term + center_score + rook_file_bonus + safety + queen_raid_pen + pst - hanging_pen
|
||
return white_score if board.turn == chess.WHITE else -white_score
|
||
|
||
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
|
||
# 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]
|
||
else:
|
||
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)
|
||
|
||
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
|
||
|
||
# --- 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
|