|
from collections import Counter |
|
|
|
import chess |
|
import gradio as gr |
|
import pandas as pd |
|
from gradio_chessboard import Chessboard |
|
|
|
|
|
def get_position(fen: str) -> dict: |
|
""" |
|
Describe the current chess position from a FEN string, plus a material summary. |
|
|
|
Attempts to classify the opening, and if successful, adds the opening information to the position. |
|
Otherwise, it adds a piece map with the current pieces and the list of legal moves. |
|
|
|
Args: |
|
fen (str): The FEN string representing the chess position. |
|
|
|
""" |
|
board = chess.Board(fen) |
|
|
|
position = { |
|
"turn": _get_color_name(board.turn), |
|
"castling": { |
|
"white": { |
|
"kingside": board.has_kingside_castling_rights(chess.WHITE), |
|
"queenside": board.has_queenside_castling_rights(chess.WHITE), |
|
}, |
|
"black": { |
|
"kingside": board.has_kingside_castling_rights(chess.BLACK), |
|
"queenside": board.has_queenside_castling_rights(chess.BLACK), |
|
}, |
|
}, |
|
"en_passant": chess.square_name(board.ep_square) if board.ep_square else None, |
|
"mate": board.is_checkmate(), |
|
"stalemate": board.is_stalemate(), |
|
} |
|
|
|
opening = classify_opening(board.fen()) |
|
if "error" not in opening: |
|
|
|
position["opening"] = opening |
|
elif board.fen() == board.starting_fen: |
|
|
|
position["opening"] = {"name": "Starting Position"} |
|
else: |
|
|
|
position["pieces"] = ( |
|
[ |
|
f"{chess.square_name(s)}: {_get_color_name(p.color)} {chess.piece_name(p.piece_type)}" |
|
for s, p in board.piece_map().items() |
|
], |
|
) |
|
position["legal_moves"] = ([move.uci() for move in board.legal_moves],) |
|
|
|
white_counts = Counter( |
|
piece.piece_type |
|
for square, piece in board.piece_map().items() |
|
if piece.color == chess.WHITE |
|
) |
|
black_counts = Counter( |
|
piece.piece_type |
|
for square, piece in board.piece_map().items() |
|
if piece.color == chess.BLACK |
|
) |
|
|
|
def format_counts(counter): |
|
order = [chess.QUEEN, chess.ROOK, chess.BISHOP, chess.KNIGHT, chess.PAWN] |
|
symbol_map = { |
|
chess.QUEEN: "Q", |
|
chess.ROOK: "R", |
|
chess.BISHOP: "B", |
|
chess.KNIGHT: "N", |
|
chess.PAWN: "P", |
|
} |
|
parts = [] |
|
for p_type in order: |
|
cnt = counter.get(p_type, 0) |
|
parts.append(f"{symbol_map[p_type]}={cnt}") |
|
return ", ".join(parts) |
|
|
|
material_count = { |
|
"white": format_counts(white_counts), |
|
"black": format_counts(black_counts), |
|
} |
|
|
|
diff = { |
|
p_type: white_counts.get(p_type, 0) - black_counts.get(p_type, 0) |
|
for p_type in (chess.QUEEN, chess.ROOK, chess.BISHOP, chess.KNIGHT, chess.PAWN) |
|
} |
|
|
|
white_adv = [(ptype, diff[ptype]) for ptype in diff if diff[ptype] > 0] |
|
black_adv = [(ptype, -diff[ptype]) for ptype in diff if diff[ptype] < 0] |
|
|
|
def summarize_advantages(side_name, adv_list): |
|
""" |
|
adv_list: list of tuples (piece_type, count), count > 0 |
|
Returns phrases like "1 rook and 2 pawns" |
|
""" |
|
if not adv_list: |
|
return "" |
|
piece_names = { |
|
chess.QUEEN: "queen", |
|
chess.ROOK: "rook", |
|
chess.BISHOP: "bishop", |
|
chess.KNIGHT: "knight", |
|
chess.PAWN: "pawn", |
|
} |
|
parts = [] |
|
for ptype, cnt in adv_list: |
|
name = piece_names[ptype] |
|
|
|
if cnt > 1: |
|
name += "s" |
|
parts.append(f"{cnt} {name}") |
|
|
|
joined = " and ".join(parts) |
|
return f"{side_name} is up {joined}" |
|
|
|
white_summary = summarize_advantages("White", white_adv) |
|
black_summary = summarize_advantages("Black", black_adv) |
|
|
|
if white_summary and black_summary: |
|
|
|
imbalance = f"Mixed: {white_summary}; {black_summary}" |
|
elif white_summary: |
|
imbalance = white_summary |
|
elif black_summary: |
|
imbalance = black_summary |
|
else: |
|
imbalance = "Material is equal" |
|
|
|
position["material_count"] = material_count |
|
position["imbalance"] = imbalance |
|
|
|
return position |
|
|
|
|
|
def get_square_info(fen: str, square_name: str) -> dict: |
|
"""Get information about a specific square in the chess position. |
|
|
|
This function retrieves the piece on the specified square, as well as the attackers and defenders of that square. |
|
|
|
Args: |
|
fen (str): The FEN string representing the chess position. |
|
square_name (str): The name of the square (e.g., 'e4'). |
|
""" |
|
board = chess.Board(fen) |
|
square = chess.parse_square(square_name) |
|
return { |
|
"square": square_name, |
|
"piece": _get_piece_info_on_square(board, square), |
|
"attackers/defenders": [ |
|
_get_attackers(board, square, color) for color in (chess.WHITE, chess.BLACK) |
|
], |
|
} |
|
|
|
|
|
def get_top_moves(fen: str, top_n: int = 5) -> dict: |
|
"""Get the top N moves for a given chess position using StockFish. |
|
|
|
Returns a list of the top moves with their absolute scores (in centipawns) and whether they are leading to a mate. |
|
|
|
DISCLAIMER: This function uses the Stockfish chess engine, ONLY use it if explicitly allowed. |
|
|
|
Args: |
|
fen (str): The FEN string representing the chess position. |
|
top_n (int): The number of top moves to return. |
|
""" |
|
import chess.engine |
|
|
|
board = chess.Board(fen) |
|
with chess.engine.SimpleEngine.popen_uci("/usr/games/stockfish") as engine: |
|
info = engine.analyse(board, chess.engine.Limit(time=2.0), multipv=top_n) |
|
top_moves = [ |
|
{ |
|
"move": move["pv"][0].uci(), |
|
"score": move["score"].white().score(), |
|
"mate": move["score"].is_mate(), |
|
} |
|
for move in info |
|
] |
|
return {"top_moves": top_moves} |
|
|
|
|
|
def analyze_pawn_structure(fen): |
|
""" |
|
Analyze pawn‐structure features for both White and Black from a given FEN string. |
|
|
|
Args: |
|
fen (str): The FEN string representing the chess position. |
|
""" |
|
board = chess.Board(fen) |
|
|
|
white_pawns = list(board.pieces(chess.PAWN, chess.WHITE)) |
|
black_pawns = list(board.pieces(chess.PAWN, chess.BLACK)) |
|
|
|
def pawn_islands_and_doubles(pawn_squares): |
|
""" |
|
Given a list of pawn squares (for one color), compute: |
|
- num_islands: how many contiguous runs of files have at least one pawn |
|
- doubled_files: [file_letters ...] where there are 2+ pawns on that file |
|
- files_with_pawns: set of file indices that have ≥1 pawn |
|
- file_to_count: dict mapping file→count_of_pawns |
|
""" |
|
file_counts = {} |
|
for sq in pawn_squares: |
|
f = chess.square_file(sq) |
|
file_counts[f] = file_counts.get(f, 0) + 1 |
|
|
|
files_with_pawns = set(file_counts.keys()) |
|
|
|
|
|
num_islands = 0 |
|
in_run = False |
|
for f in range(8): |
|
if f in files_with_pawns: |
|
if not in_run: |
|
num_islands += 1 |
|
in_run = True |
|
else: |
|
in_run = False |
|
|
|
doubled_files = [ |
|
chess.FILE_NAMES[f] for f, cnt in file_counts.items() if cnt > 1 |
|
] |
|
|
|
return num_islands, doubled_files, files_with_pawns, file_counts |
|
|
|
|
|
w_islands, w_doubled, w_files, w_file_count = pawn_islands_and_doubles(white_pawns) |
|
|
|
b_islands, b_doubled, b_files, b_file_count = pawn_islands_and_doubles(black_pawns) |
|
|
|
|
|
def find_isolated(pawn_sqs, files_with, color): |
|
""" |
|
Returns [square_name ...] where each pawn is isolated: |
|
- its file f has no friendly pawn on f-1 or f+1. |
|
""" |
|
isolated = [] |
|
for sq in pawn_sqs: |
|
f = chess.square_file(sq) |
|
|
|
if (f - 1) not in files_with and (f + 1) not in files_with: |
|
isolated.append(chess.square_name(sq)) |
|
return isolated |
|
|
|
w_isolated = find_isolated(white_pawns, w_files, chess.WHITE) |
|
b_isolated = find_isolated(black_pawns, b_files, chess.BLACK) |
|
|
|
|
|
def find_passed(pawn_sqs, enemy_sqs, is_white): |
|
""" |
|
For each pawn of 'is_white' color: |
|
- Let (f,r) be its file and rank index (0..7), where r=0 means rank 1, r=7 means rank 8. |
|
- If is_white: check enemy pawns on files f-1,f,f+1 with rank_index > r. If none, it's passed. |
|
- If black: check enemy pawns on files f-1,f,f+1 with rank_index < r. If none, it's passed. |
|
""" |
|
passed = [] |
|
|
|
enemy_positions = [ |
|
(chess.square_file(e), chess.square_rank(e)) for e in enemy_sqs |
|
] |
|
|
|
for sq in pawn_sqs: |
|
f = chess.square_file(sq) |
|
r = chess.square_rank(sq) |
|
is_passed = True |
|
|
|
for ef, er in enemy_positions: |
|
if abs(ef - f) <= 1: |
|
if is_white: |
|
if er > r: |
|
|
|
is_passed = False |
|
break |
|
else: |
|
if er < r: |
|
is_passed = False |
|
break |
|
if is_passed: |
|
passed.append(chess.square_name(sq)) |
|
|
|
return passed |
|
|
|
w_passed = find_passed(white_pawns, black_pawns, True) |
|
b_passed = find_passed(black_pawns, white_pawns, False) |
|
|
|
|
|
|
|
|
|
def find_backward(pawn_sqs, friend_sqs, enemy_sqs, is_white): |
|
""" |
|
For each pawn sq of this color: |
|
- Let f,r be its file/rank |
|
- Condition A: No friendly pawn on file f-1 or f+1 with rank ≤ r (for white) or ≥ r (for black) |
|
- Condition B: The square in front (r+1 for white; r-1 for black) is either occupied or attacked by an enemy pawn |
|
- If both hold → mark as backward. |
|
""" |
|
backward = [] |
|
|
|
friend_pos = [ |
|
(chess.square_file(fsq), chess.square_rank(fsq)) for fsq in friend_sqs |
|
] |
|
enemy_pawn_positions = set(enemy_sqs) |
|
|
|
for sq in pawn_sqs: |
|
f = chess.square_file(sq) |
|
r = chess.square_rank(sq) |
|
|
|
|
|
has_support = False |
|
for ff, rr in friend_pos: |
|
if abs(ff - f) == 1: |
|
if is_white: |
|
if rr <= r: |
|
has_support = True |
|
break |
|
else: |
|
if rr >= r: |
|
has_support = True |
|
break |
|
if has_support: |
|
continue |
|
|
|
|
|
if is_white: |
|
if r == 7: |
|
continue |
|
front_sq = chess.square(f, r + 1) |
|
else: |
|
if r == 0: |
|
continue |
|
front_sq = chess.square(f, r - 1) |
|
|
|
|
|
if board.piece_at(front_sq) is not None: |
|
blocked = True |
|
else: |
|
|
|
attackers = board.attackers( |
|
chess.BLACK if is_white else chess.WHITE, front_sq |
|
) |
|
|
|
attacked_by_pawn = False |
|
for attacker_sq in attackers: |
|
p = board.piece_at(attacker_sq) |
|
if ( |
|
p is not None |
|
and p.piece_type == chess.PAWN |
|
and p.color != board.piece_at(sq).color |
|
): |
|
attacked_by_pawn = True |
|
break |
|
blocked = attacked_by_pawn |
|
|
|
if blocked: |
|
backward.append(chess.square_name(sq)) |
|
|
|
return backward |
|
|
|
w_backward = find_backward(white_pawns, white_pawns, black_pawns, True) |
|
b_backward = find_backward(black_pawns, black_pawns, white_pawns, False) |
|
|
|
|
|
|
|
|
|
def find_break_sqs(pawn_sqs, is_white): |
|
""" |
|
For each pawn sq: |
|
- Compute front = (f, r+1) if white; (f, r-1) if black |
|
- If front is on board, empty, and has an enemy pawn on one of its diagonals, add front. |
|
""" |
|
breaks = set() |
|
for sq in pawn_sqs: |
|
f = chess.square_file(sq) |
|
r = chess.square_rank(sq) |
|
|
|
if is_white and r == 7: |
|
continue |
|
if not is_white and r == 0: |
|
continue |
|
|
|
if is_white: |
|
front = chess.square(f, r + 1) |
|
|
|
diag1 = chess.square(f - 1, r + 1) if f > 0 else None |
|
diag2 = chess.square(f + 1, r + 1) if f < 7 else None |
|
enemy_color = chess.BLACK |
|
else: |
|
front = chess.square(f, r - 1) |
|
diag1 = chess.square(f - 1, r - 1) if f > 0 else None |
|
diag2 = chess.square(f + 1, r - 1) if f < 7 else None |
|
enemy_color = chess.WHITE |
|
|
|
|
|
if board.piece_at(front) is not None: |
|
continue |
|
|
|
|
|
for diag in (diag1, diag2): |
|
if diag is not None: |
|
piece = board.piece_at(diag) |
|
if ( |
|
piece is not None |
|
and piece.piece_type == chess.PAWN |
|
and piece.color == enemy_color |
|
): |
|
breaks.add(front) |
|
break |
|
|
|
return [chess.square_name(sq) for sq in sorted(breaks)] |
|
|
|
w_breaks = find_break_sqs(white_pawns, True) |
|
b_breaks = find_break_sqs(black_pawns, False) |
|
|
|
|
|
return { |
|
"pawn_islands": {"white": w_islands, "black": b_islands}, |
|
"doubled_pawns": {"white": w_doubled, "black": b_doubled}, |
|
"isolated_pawns": {"white": w_isolated, "black": b_isolated}, |
|
"passed_pawns": {"white": w_passed, "black": b_passed}, |
|
"backward_pawns": {"white": w_backward, "black": b_backward}, |
|
"break_squares": {"white": w_breaks, "black": b_breaks}, |
|
} |
|
|
|
|
|
def analyze_tactical_patterns(fen): |
|
""" |
|
Analyze immediate tactical patterns from a given FEN string. |
|
|
|
This function detects: |
|
- Potential knight forks and double attacks (refering to next move) |
|
- Pins, skewers, discovered attacks and x‐ray attacks in the current position. |
|
|
|
Args: |
|
fen (str): The FEN string representing the chess position. |
|
""" |
|
|
|
board = chess.Board(fen) |
|
|
|
piece_name = { |
|
chess.PAWN: "pawn", |
|
chess.KNIGHT: "knight", |
|
chess.BISHOP: "bishop", |
|
chess.ROOK: "rook", |
|
chess.QUEEN: "queen", |
|
chess.KING: "king", |
|
} |
|
|
|
def find_forks_and_double_attacks(color): |
|
""" |
|
For each legal move by 'color', detect: |
|
- Knight forks: moved knight attacks ≥2 enemy pieces |
|
- Double attacks: moved non-knight piece attacks ≥2 enemy pieces |
|
Returns two lists of descriptive strings. |
|
""" |
|
forks = [] |
|
double_attacks = [] |
|
b = board.copy() |
|
b.turn = color |
|
|
|
for move in b.legal_moves: |
|
moving_piece = b.piece_at(move.from_square) |
|
if moving_piece is None: |
|
continue |
|
|
|
b.push(move) |
|
to_sq = move.to_square |
|
attacked_squares = b.attacks(to_sq) |
|
attacked_pieces = [] |
|
for sq in attacked_squares: |
|
piece = b.piece_at(sq) |
|
if piece is not None and piece.color != color: |
|
attacked_pieces.append((sq, piece)) |
|
|
|
if len(attacked_pieces) >= 2: |
|
mover_symbol = moving_piece.symbol().upper() |
|
dest = chess.square_name(to_sq) |
|
targets = [ |
|
f"{piece_name[p.piece_type]} on {chess.square_name(sq)}" |
|
for sq, p in attacked_pieces |
|
] |
|
target_str = " and ".join(targets) |
|
if moving_piece.piece_type == chess.KNIGHT: |
|
forks.append(f"{mover_symbol}{dest} forks {target_str}") |
|
else: |
|
double_attacks.append( |
|
f"{mover_symbol}{dest} double‐attacks {target_str}" |
|
) |
|
b.pop() |
|
|
|
return forks, double_attacks |
|
|
|
def find_pins(color): |
|
""" |
|
Find pinned pieces of 'color'. For each pinned piece, identify the pinning piece. |
|
Returns list of descriptive strings. |
|
""" |
|
pins = [] |
|
king_sq = board.king(color) |
|
if king_sq is None: |
|
return pins |
|
|
|
for sq in ( |
|
board.pieces(chess.PAWN, color) |
|
| board.pieces(chess.KNIGHT, color) |
|
| board.pieces(chess.BISHOP, color) |
|
| board.pieces(chess.ROOK, color) |
|
| board.pieces(chess.QUEEN, color) |
|
): |
|
if sq == king_sq: |
|
continue |
|
if board.is_pinned(color, sq): |
|
|
|
f_k, r_k = chess.square_file(king_sq), chess.square_rank(king_sq) |
|
f_p, r_p = chess.square_file(sq), chess.square_rank(sq) |
|
df = f_p - f_k |
|
dr = r_p - r_k |
|
|
|
df_norm = (df // abs(df)) if df != 0 else 0 |
|
dr_norm = (dr // abs(dr)) if dr != 0 else 0 |
|
|
|
cur_f, cur_r = f_p + df_norm, r_p + dr_norm |
|
while 0 <= cur_f < 8 and 0 <= cur_r < 8: |
|
cur_sq = chess.square(cur_f, cur_r) |
|
piece = board.piece_at(cur_sq) |
|
if piece is not None and piece.color != color: |
|
|
|
if dr_norm == 0 and piece.piece_type in ( |
|
chess.ROOK, |
|
chess.QUEEN, |
|
): |
|
pinning = piece |
|
elif df_norm == 0 and piece.piece_type in ( |
|
chess.ROOK, |
|
chess.QUEEN, |
|
): |
|
pinning = piece |
|
elif abs(df_norm) == abs(dr_norm) and piece.piece_type in ( |
|
chess.BISHOP, |
|
chess.QUEEN, |
|
): |
|
pinning = piece |
|
else: |
|
pinning = None |
|
if pinning is not None: |
|
pin_sym = pinning.symbol().upper() |
|
pin_sq = chess.square_name(cur_sq) |
|
pinned_sym = board.piece_at(sq).piece_type |
|
pinned_name = piece_name[board.piece_at(sq).piece_type] |
|
pinned_sq_name = chess.square_name(sq) |
|
king_sq_name = chess.square_name(king_sq) |
|
pins.append( |
|
f"{pin_sym}{pin_sq} pins {pinned_name} on {pinned_sq_name} to king on {king_sq_name}" |
|
) |
|
break |
|
if piece is not None: |
|
|
|
break |
|
cur_f += df_norm |
|
cur_r += dr_norm |
|
|
|
return pins |
|
|
|
def find_skewers(color): |
|
""" |
|
Find static skewers: slider attacks a high-value enemy piece, behind it on same ray is a lower-value enemy piece. |
|
Returns list of descriptive strings. |
|
""" |
|
skewers = [] |
|
enemy_color = not color |
|
|
|
for s_sq in ( |
|
board.pieces(chess.BISHOP, color) |
|
| board.pieces(chess.ROOK, color) |
|
| board.pieces(chess.QUEEN, color) |
|
): |
|
s_f, s_r = chess.square_file(s_sq), chess.square_rank(s_sq) |
|
|
|
directions = [] |
|
if board.piece_at(s_sq).piece_type == chess.BISHOP: |
|
directions = [(-1, -1), (-1, 1), (1, -1), (1, 1)] |
|
elif board.piece_at(s_sq).piece_type == chess.ROOK: |
|
directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] |
|
else: |
|
directions = [ |
|
(-1, -1), |
|
(-1, 1), |
|
(1, -1), |
|
(1, 1), |
|
(-1, 0), |
|
(1, 0), |
|
(0, -1), |
|
(0, 1), |
|
] |
|
|
|
for df, dr in directions: |
|
cur_f, cur_r = s_f + df, s_r + dr |
|
|
|
first_found = False |
|
first_sq = None |
|
first_piece = None |
|
while 0 <= cur_f < 8 and 0 <= cur_r < 8: |
|
sq = chess.square(cur_f, cur_r) |
|
piece = board.piece_at(sq) |
|
if piece is not None: |
|
if not first_found and piece.color == enemy_color: |
|
first_found = True |
|
first_sq = sq |
|
first_piece = piece |
|
else: |
|
if first_found and piece.color == enemy_color: |
|
|
|
|
|
values = { |
|
chess.KING: 1000, |
|
chess.QUEEN: 9, |
|
chess.ROOK: 5, |
|
chess.BISHOP: 3, |
|
chess.KNIGHT: 3, |
|
chess.PAWN: 1, |
|
} |
|
if ( |
|
values[first_piece.piece_type] |
|
> values[piece.piece_type] |
|
): |
|
s_sym = board.piece_at(s_sq).symbol().upper() |
|
s_sq_name = chess.square_name(s_sq) |
|
high_name = piece_name[first_piece.piece_type] |
|
high_sq = chess.square_name(first_sq) |
|
low_name = piece_name[piece.piece_type] |
|
low_sq = chess.square_name(sq) |
|
skewers.append( |
|
f"{s_sym}{s_sq_name} skewers {high_name} on {high_sq} to {low_name} on {low_sq}" |
|
) |
|
break |
|
else: |
|
|
|
break |
|
cur_f += df |
|
cur_r += dr |
|
|
|
return skewers |
|
|
|
def find_discovered_attacks(color): |
|
""" |
|
Static discovered‐attack patterns: a friendly slider is currently blocked by one friendly piece from attacking an enemy target. |
|
Returns list of descriptive strings. |
|
""" |
|
discovered = [] |
|
enemy_color = not color |
|
|
|
for s_sq in ( |
|
board.pieces(chess.BISHOP, color) |
|
| board.pieces(chess.ROOK, color) |
|
| board.pieces(chess.QUEEN, color) |
|
): |
|
s_f, s_r = chess.square_file(s_sq), chess.square_rank(s_sq) |
|
|
|
if board.piece_at(s_sq).piece_type == chess.BISHOP: |
|
directions = [(-1, -1), (-1, 1), (1, -1), (1, 1)] |
|
elif board.piece_at(s_sq).piece_type == chess.ROOK: |
|
directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] |
|
else: |
|
directions = [ |
|
(-1, -1), |
|
(-1, 1), |
|
(1, -1), |
|
(1, 1), |
|
(-1, 0), |
|
(1, 0), |
|
(0, -1), |
|
(0, 1), |
|
] |
|
|
|
for df, dr in directions: |
|
cur_f, cur_r = s_f + df, s_r + dr |
|
blocker_sq = None |
|
blocker_piece = None |
|
while 0 <= cur_f < 8 and 0 <= cur_r < 8: |
|
sq = chess.square(cur_f, cur_r) |
|
piece = board.piece_at(sq) |
|
if piece is not None: |
|
if piece.color == color and blocker_sq is None: |
|
|
|
blocker_sq = sq |
|
blocker_piece = piece |
|
else: |
|
|
|
if blocker_sq is not None and piece.color == enemy_color: |
|
|
|
s_sym = board.piece_at(s_sq).symbol().upper() |
|
blocker_name = piece_name[blocker_piece.piece_type] |
|
blocker_loc = chess.square_name(blocker_sq) |
|
target_name = piece_name[piece.piece_type] |
|
target_loc = chess.square_name(sq) |
|
discovered.append( |
|
f"Moving {blocker_name} from {blocker_loc} uncovers {s_sym}{chess.square_name(s_sq)} attacking {target_name} on {target_loc}" |
|
) |
|
break |
|
cur_f += df |
|
cur_r += dr |
|
|
|
return discovered |
|
|
|
def find_xray_attacks(color): |
|
""" |
|
Static x‐ray attacks: slider attacks through one piece (friendly or enemy) to an enemy target behind it. |
|
Returns list of descriptive strings. |
|
""" |
|
xray = [] |
|
enemy_color = not color |
|
|
|
for s_sq in ( |
|
board.pieces(chess.BISHOP, color) |
|
| board.pieces(chess.ROOK, color) |
|
| board.pieces(chess.QUEEN, color) |
|
): |
|
s_f, s_r = chess.square_file(s_sq), chess.square_rank(s_sq) |
|
if board.piece_at(s_sq).piece_type == chess.BISHOP: |
|
directions = [(-1, -1), (-1, 1), (1, -1), (1, 1)] |
|
elif board.piece_at(s_sq).piece_type == chess.ROOK: |
|
directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] |
|
else: |
|
directions = [ |
|
(-1, -1), |
|
(-1, 1), |
|
(1, -1), |
|
(1, 1), |
|
(-1, 0), |
|
(1, 0), |
|
(0, -1), |
|
(0, 1), |
|
] |
|
|
|
for df, dr in directions: |
|
cur_f, cur_r = s_f + df, s_r + dr |
|
first_blocker = None |
|
first_blocker_sq = None |
|
while 0 <= cur_f < 8 and 0 <= cur_r < 8: |
|
sq = chess.square(cur_f, cur_r) |
|
piece = board.piece_at(sq) |
|
if piece is not None: |
|
if first_blocker is None: |
|
first_blocker = piece |
|
first_blocker_sq = sq |
|
else: |
|
if piece.color == enemy_color: |
|
|
|
s_sym = board.piece_at(s_sq).symbol().upper() |
|
target_name = piece_name[piece.piece_type] |
|
target_loc = chess.square_name(sq) |
|
blocker_name = piece_name[first_blocker.piece_type] |
|
blocker_loc = chess.square_name(first_blocker_sq) |
|
xray.append( |
|
f"{s_sym}{chess.square_name(s_sq)} x‐rays {target_name} on {target_loc} through {blocker_name} on {blocker_loc}" |
|
) |
|
break |
|
cur_f += df |
|
cur_r += dr |
|
|
|
return xray |
|
|
|
|
|
result = { |
|
"forks": {"white": [], "black": []}, |
|
"double_attacks": {"white": [], "black": []}, |
|
"pins": {"white": [], "black": []}, |
|
"skewers": {"white": [], "black": []}, |
|
"discovered_attacks": {"white": [], "black": []}, |
|
"xray_attacks": {"white": [], "black": []}, |
|
} |
|
|
|
|
|
w_forks, w_double = find_forks_and_double_attacks(chess.WHITE) |
|
result["forks"]["white"] = w_forks |
|
result["double_attacks"]["white"] = w_double |
|
result["pins"]["white"] = find_pins(chess.WHITE) |
|
result["skewers"]["white"] = find_skewers(chess.WHITE) |
|
result["discovered_attacks"]["white"] = find_discovered_attacks(chess.WHITE) |
|
result["xray_attacks"]["white"] = find_xray_attacks(chess.WHITE) |
|
|
|
|
|
b_forks, b_double = find_forks_and_double_attacks(chess.BLACK) |
|
result["forks"]["black"] = b_forks |
|
result["double_attacks"]["black"] = b_double |
|
result["pins"]["black"] = find_pins(chess.BLACK) |
|
result["skewers"]["black"] = find_skewers(chess.BLACK) |
|
result["discovered_attacks"]["black"] = find_discovered_attacks(chess.BLACK) |
|
result["xray_attacks"]["black"] = find_xray_attacks(chess.BLACK) |
|
|
|
return result |
|
|
|
|
|
def evaluate_king_safety(fen): |
|
""" |
|
Evaluate king safety for both White and Black from a given FEN string. |
|
|
|
Args: |
|
fen (str): The FEN string representing the chess position. |
|
""" |
|
board = chess.Board(fen) |
|
|
|
def get_shield_and_files(color): |
|
""" |
|
For 'color', find: |
|
- pawn_shield: count of own pawns directly in front of king on files f-1,f,f+1. |
|
- max_shield: maximum possible shield pawns (1-3 depending on king file at edge). |
|
- open_or_semi_open_files: list of file names (adjacent to king) that are open or semi-open. |
|
""" |
|
king_sq = board.king(color) |
|
if king_sq is None: |
|
return 0, 0, [] |
|
|
|
kf = chess.square_file(king_sq) |
|
kr = chess.square_rank(king_sq) |
|
|
|
ranks_dir = 1 if color == chess.WHITE else -1 |
|
shield_rank = kr + ranks_dir |
|
files_to_check = [f for f in (kf - 1, kf, kf + 1) if 0 <= f < 8] |
|
max_shield = len(files_to_check) |
|
|
|
|
|
shield_count = 0 |
|
for f in files_to_check: |
|
sq = chess.square(f, shield_rank) if 0 <= shield_rank < 8 else None |
|
if sq is not None: |
|
piece = board.piece_at(sq) |
|
if ( |
|
piece is not None |
|
and piece.piece_type == chess.PAWN |
|
and piece.color == color |
|
): |
|
shield_count += 1 |
|
|
|
|
|
open_or_semi_open = [] |
|
for f in files_to_check: |
|
|
|
pawns_on_file = [ |
|
board.piece_at(chess.square(f, r)) |
|
for r in range(8) |
|
if (p := board.piece_at(chess.square(f, r))) is not None |
|
and p.piece_type == chess.PAWN |
|
] |
|
has_friendly = any(p.color == color for p in pawns_on_file) |
|
has_enemy = any(p.color != color for p in pawns_on_file) |
|
file_name = chess.FILE_NAMES[f] |
|
if not pawns_on_file: |
|
|
|
open_or_semi_open.append(file_name) |
|
elif has_enemy and not has_friendly: |
|
|
|
open_or_semi_open.append(file_name) |
|
|
|
return shield_count, max_shield, open_or_semi_open |
|
|
|
def get_attacker_count(color): |
|
""" |
|
Count unique enemy pieces attacking any of the up to 8 squares adjacent to the king. |
|
""" |
|
king_sq = board.king(color) |
|
if king_sq is None: |
|
return 0 |
|
enemy_color = not color |
|
kf = chess.square_file(king_sq) |
|
kr = chess.square_rank(king_sq) |
|
|
|
attackers = set() |
|
|
|
for df in (-1, 0, 1): |
|
for dr in (-1, 0, 1): |
|
if df == 0 and dr == 0: |
|
continue |
|
f = kf + df |
|
r = kr + dr |
|
if 0 <= f < 8 and 0 <= r < 8: |
|
sq = chess.square(f, r) |
|
for attacker_sq in board.attackers(enemy_color, sq): |
|
attackers.add(attacker_sq) |
|
return len(attackers) |
|
|
|
def compute_shelter_score(shield_count, max_shield, open_count, attacker_count): |
|
""" |
|
Compute a composite shelter score in [0, 1], combining: |
|
- shield_factor: shield_count / max_shield |
|
- file_factor: 1 - (open_count / max_shield) |
|
- attacker_factor: 1 - min(attacker_count, 8) / 8 |
|
Return average of the three, rounded to 2 decimals. |
|
""" |
|
if max_shield == 0: |
|
shield_factor = 0 |
|
file_factor = 0 |
|
else: |
|
shield_factor = shield_count / max_shield |
|
file_factor = 1 - (open_count / max_shield) |
|
attacker_factor = 1 - min(attacker_count, 8) / 8 |
|
return round((shield_factor + file_factor + attacker_factor) / 3, 2) |
|
|
|
result = { |
|
"pawn_shield": {"white": "", "black": ""}, |
|
"open_files": {"white": [], "black": []}, |
|
"attacker_count": {"white": 0, "black": 0}, |
|
"shelter_score": {"white": 0.0, "black": 0.0}, |
|
} |
|
|
|
|
|
w_shield, w_max_shield, w_open = get_shield_and_files(chess.WHITE) |
|
w_attackers = get_attacker_count(chess.WHITE) |
|
w_shelter = compute_shelter_score(w_shield, w_max_shield, len(w_open), w_attackers) |
|
result["pawn_shield"]["white"] = f"{w_shield} of {w_max_shield} shield pawns" |
|
result["open_files"]["white"] = w_open |
|
result["attacker_count"]["white"] = w_attackers |
|
result["shelter_score"]["white"] = w_shelter |
|
|
|
|
|
b_shield, b_max_shield, b_open = get_shield_and_files(chess.BLACK) |
|
b_attackers = get_attacker_count(chess.BLACK) |
|
b_shelter = compute_shelter_score(b_shield, b_max_shield, len(b_open), b_attackers) |
|
result["pawn_shield"]["black"] = f"{b_shield} of {b_max_shield} shield pawns" |
|
result["open_files"]["black"] = b_open |
|
result["attacker_count"]["black"] = b_attackers |
|
result["shelter_score"]["black"] = b_shelter |
|
|
|
return result |
|
|
|
|
|
def classify_opening(fen: str) -> dict: |
|
""" |
|
Attempt to classify a chess opening using the Lichess openings database. |
|
Return the ECO code, name, moves and main sub-variations of the opening. |
|
|
|
Args: |
|
fen (str): The FEN string representing the chess position. |
|
""" |
|
board = chess.Board(fen) |
|
epd_key = board.epd() |
|
|
|
df = _load_lichess_openings() |
|
match = df[df["epd"] == epd_key] |
|
if match.empty: |
|
return {"error": f"No ECO code found for position: {fen}"} |
|
|
|
eco_code = match.iloc[0]["eco"] |
|
opening_name = match.iloc[0]["name"] |
|
base_pgn = match.iloc[0]["pgn"] |
|
base_uci = match.iloc[0]["uci"] |
|
base_len = len(base_uci.split()) |
|
|
|
def next_move(uci_str: str) -> str | None: |
|
parts = uci_str.split() |
|
if not parts[:base_len] == base_uci.split(): |
|
return None |
|
return parts[base_len] if len(parts) > base_len else None |
|
|
|
df["next_move"] = df["uci"].apply(next_move) |
|
subs = ( |
|
df[df["next_move"].notna()] |
|
.sort_values("uci") |
|
.drop_duplicates("next_move", keep="first") |
|
) |
|
|
|
subvariants = [ |
|
{"name": row["name"], "pgn": row["pgn"], "fen": row["epd"]} |
|
for _, row in subs.iterrows() |
|
] |
|
|
|
return { |
|
"eco": eco_code, |
|
"name": opening_name, |
|
"pgn": base_pgn, |
|
"subvariants": subvariants, |
|
} |
|
|
|
|
|
def find_opening_by_name(name: str) -> dict: |
|
""" |
|
Search for a chess opening by its name in the Lichess openings database. |
|
Return the ECO code, name, PGN, FEN and sub-variations of a chess opening by its name. |
|
The name is matched case-insensitively. |
|
|
|
Args: |
|
name (str): The name of the chess opening to search for (e.g. Caro-Kann Defense: Advance Variation). |
|
""" |
|
df = _load_lichess_openings() |
|
|
|
mask = df["name"].str.contains(name, case=False, regex=False) |
|
matches = df[mask] |
|
if matches.empty: |
|
return {"error": f"No opening found matching name: '{name}'"} |
|
|
|
row = matches.iloc[0] |
|
eco_code = row["eco"] |
|
full_name = row["name"] |
|
base_pgn = row["pgn"] |
|
base_uci = row["uci"] |
|
epd = row["epd"] |
|
fen = f"{epd} 0 1" |
|
|
|
base_moves = base_uci.split() |
|
base_len = len(base_moves) |
|
|
|
def next_move(uci_str: str) -> str | None: |
|
parts = uci_str.split() |
|
if not parts[:base_len] == base_uci.split(): |
|
return None |
|
return parts[base_len] if len(parts) > base_len else None |
|
|
|
df["next_move"] = df["uci"].apply(next_move) |
|
subs = ( |
|
df[df["next_move"].notna()] |
|
.sort_values("uci") |
|
.drop_duplicates("next_move", keep="first") |
|
) |
|
|
|
subvariants = [ |
|
{"name": sub_row["name"], "pgn": sub_row["pgn"], "fen": sub_row["epd"]} |
|
for _, sub_row in subs.iterrows() |
|
] |
|
|
|
return { |
|
"eco": eco_code, |
|
"name": full_name, |
|
"pgn": base_pgn, |
|
"fen": fen, |
|
"subvariants": subvariants, |
|
} |
|
|
|
|
|
def _get_color_name(color: chess.Color) -> str: |
|
return "white" if color == chess.WHITE else "black" |
|
|
|
|
|
def _get_piece_info_on_square(board: chess.Board, square: chess.Square) -> str: |
|
piece = board.piece_at(square) |
|
if piece is None: |
|
return f"No piece on {chess.square_name(square)}" |
|
color = _get_color_name(piece.color) |
|
result = f"There is a {color} {chess.piece_name(piece.piece_type)} on {chess.square_name(square)}." |
|
legal_moves = [ |
|
chess.square_name(m.to_square) |
|
for m in board.legal_moves |
|
if m.from_square == square |
|
] |
|
if not legal_moves: |
|
result += f" It can't move because" |
|
if board.turn != piece.color: |
|
result += f" it is not {_get_color_name(piece.color)}'s turn." |
|
elif board.is_pinned(piece.color, square): |
|
result += " it is pinned." |
|
elif board.is_check(): |
|
result += f" it is a check and the {chess.piece_name(piece.piece_type)} can't block" |
|
else: |
|
result += " it is blocked." |
|
result += f" However, it attacks the following squares: {', '.join([chess.square_name(s) for s in board.attacks(square)])}." |
|
else: |
|
result += f" It can move to the following squares: {', '.join(legal_moves)}." |
|
return result |
|
|
|
|
|
def _get_attackers(board: chess.Board, square: chess.Square, color: chess.Color) -> str: |
|
piece = board.piece_at(square) |
|
title = "attackers" if piece is None or piece.color != color else "defenders" |
|
attackers = board.attackers(color, square) |
|
color_name = _get_color_name(color) |
|
if not attackers: |
|
return f"No {color_name} {title} for {chess.square_name(square)}" |
|
return ( |
|
f"{len(attackers)} {color_name.title()} {title} for {chess.square_name(square)}: " |
|
+ ", ".join( |
|
[ |
|
f"{chess.piece_name(board.piece_at(s).piece_type)} on {chess.square_name(s)}" |
|
for s in attackers |
|
] |
|
) |
|
) |
|
|
|
|
|
def _load_lichess_openings( |
|
path_prefix: str = "/app/data/lichess_openings/dist/", |
|
) -> pd.DataFrame: |
|
"""Load Lichess openings data from TSV files. |
|
Assumes files 'a.tsv', 'b.tsv', 'c.tsv', 'd.tsv', 'e.tsv' are in path_prefix. |
|
Each has columns: eco, name, pgn, uci, epd. |
|
""" |
|
files = [f"{path_prefix}{vol}.tsv" for vol in ("a", "b", "c", "d", "e")] |
|
dfs = [] |
|
for fn in files: |
|
df = pd.read_csv(fn, sep="\t", usecols=["eco", "name", "pgn", "uci", "epd"]) |
|
dfs.append(df) |
|
return pd.concat(dfs, ignore_index=True) |
|
|
|
|
|
get_position_tool = gr.Interface( |
|
fn=get_position, |
|
inputs=Chessboard(label="FEN String"), |
|
outputs=gr.JSON(label="Chess Position"), |
|
title="Chess Position Viewer", |
|
description="Enter a FEN string to view the current chess position.", |
|
) |
|
|
|
get_square_info_tool = gr.Interface( |
|
fn=get_square_info, |
|
inputs=[Chessboard(label="FEN String"), gr.Textbox(label="Square Name")], |
|
outputs=gr.JSON(label="Square Info"), |
|
title="Chess Square Info", |
|
description="Enter a FEN string and a square name (e.g., 'e4') to get information about the piece on that square.", |
|
) |
|
|
|
get_top_moves_tool = gr.Interface( |
|
fn=get_top_moves, |
|
inputs=[Chessboard(label="FEN String"), gr.Number(value=5, label="Top N Moves")], |
|
outputs=gr.JSON(label="Top Moves"), |
|
title="Top Moves Analyzer", |
|
description="Enter a FEN string to get the top moves for the current position using StockFish.", |
|
) |
|
|
|
analyze_pawn_structure_tool = gr.Interface( |
|
fn=analyze_pawn_structure, |
|
inputs=Chessboard(label="FEN String"), |
|
outputs=gr.JSON(label="Pawn Structure Analysis"), |
|
title="Pawn Structure Analyzer", |
|
description="Enter a FEN string to analyze the pawn structure features for both White and Black.", |
|
) |
|
|
|
analyze_tactical_patterns_tool = gr.Interface( |
|
fn=analyze_tactical_patterns, |
|
inputs=Chessboard(label="FEN String"), |
|
outputs=gr.JSON(label="Tactical Patterns Analysis"), |
|
title="Tactical Patterns Analyzer", |
|
description="Enter a FEN string to analyze immediate tactical patterns for both White and Black.", |
|
) |
|
|
|
evaluate_king_safety_tool = gr.Interface( |
|
fn=evaluate_king_safety, |
|
inputs=Chessboard(label="FEN String"), |
|
outputs=gr.JSON(label="King Safety Evaluation"), |
|
title="King Safety Evaluator", |
|
description="Enter a FEN string to evaluate the safety of both kings in the current position.", |
|
) |
|
|
|
classify_opening_tool = gr.Interface( |
|
fn=classify_opening, |
|
inputs=Chessboard(label="FEN String"), |
|
outputs=gr.JSON(label="Opening Classification"), |
|
title="Opening Classifier", |
|
description="Enter a FEN string to classify the opening and get its ECO code, name, and sub-variations.", |
|
) |
|
|
|
find_opening_by_name_tool = gr.Interface( |
|
fn=find_opening_by_name, |
|
inputs=gr.Textbox(label="Opening Name"), |
|
outputs=gr.JSON(label="Opening Details"), |
|
title="Find Opening by Name", |
|
description="Enter the name of a chess opening to find its ECO code, PGN, FEN, and sub-variations.", |
|
) |
|
|
|
app = gr.TabbedInterface( |
|
[ |
|
get_position_tool, |
|
get_square_info_tool, |
|
get_top_moves_tool, |
|
analyze_pawn_structure_tool, |
|
analyze_tactical_patterns_tool, |
|
evaluate_king_safety_tool, |
|
classify_opening_tool, |
|
find_opening_by_name_tool, |
|
], |
|
tab_names=[ |
|
"Get Position", |
|
"Get Square Info", |
|
"Get Top Moves", |
|
"Analyze Pawn Structure", |
|
"Analyze Tactical Patterns", |
|
"Evaluate King Safety", |
|
"Classify Opening", |
|
"Find Opening by Name", |
|
], |
|
title="Chess Tools", |
|
) |
|
|
|
if __name__ == "__main__": |
|
app.launch(mcp_server=True) |
|
|