Forkei's picture
Upload folder using huggingface_hub
52a4f3c verified
"""
Lightweight chess AI for Viktor.
Negamax with alpha-beta pruning, quiescence search for tactics, material +
piece-square-table evaluation, and small randomization in the root to avoid
repetition across games. Plays at roughly club level at depth 3.
"""
import random
import chess
# ── Evaluation ──────────────────────────────────────────────────────────────
PIECE_VALUE = {
chess.PAWN: 100,
chess.KNIGHT: 320,
chess.BISHOP: 330,
chess.ROOK: 500,
chess.QUEEN: 900,
chess.KING: 20000,
}
# Piece-square tables from White's perspective (a1 = index 0, h8 = 63)
PST_PAWN = [
0, 0, 0, 0, 0, 0, 0, 0,
5, 10, 10,-20,-20, 10, 10, 5,
5, -5,-10, 0, 0,-10, -5, 5,
0, 0, 0, 20, 20, 0, 0, 0,
5, 5, 10, 25, 25, 10, 5, 5,
10, 10, 20, 30, 30, 20, 10, 10,
50, 50, 50, 50, 50, 50, 50, 50,
0, 0, 0, 0, 0, 0, 0, 0,
]
PST_KNIGHT = [
-50,-40,-30,-30,-30,-30,-40,-50,
-40,-20, 0, 5, 5, 0,-20,-40,
-30, 5, 10, 15, 15, 10, 5,-30,
-30, 0, 15, 20, 20, 15, 0,-30,
-30, 5, 15, 20, 20, 15, 5,-30,
-30, 0, 10, 15, 15, 10, 0,-30,
-40,-20, 0, 0, 0, 0,-20,-40,
-50,-40,-30,-30,-30,-30,-40,-50,
]
PST_BISHOP = [
-20,-10,-10,-10,-10,-10,-10,-20,
-10, 5, 0, 0, 0, 0, 5,-10,
-10, 10, 10, 10, 10, 10, 10,-10,
-10, 0, 10, 10, 10, 10, 0,-10,
-10, 5, 5, 10, 10, 5, 5,-10,
-10, 0, 5, 10, 10, 5, 0,-10,
-10, 0, 0, 0, 0, 0, 0,-10,
-20,-10,-10,-10,-10,-10,-10,-20,
]
PST_ROOK = [
0, 0, 5, 10, 10, 5, 0, 0,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
-5, 0, 0, 0, 0, 0, 0, -5,
5, 10, 10, 10, 10, 10, 10, 5,
0, 0, 0, 0, 0, 0, 0, 0,
]
PST_QUEEN = [
-20,-10,-10, -5, -5,-10,-10,-20,
-10, 0, 5, 0, 0, 0, 0,-10,
-10, 5, 5, 5, 5, 5, 0,-10,
0, 0, 5, 5, 5, 5, 0, -5,
-5, 0, 5, 5, 5, 5, 0, -5,
-10, 0, 5, 5, 5, 5, 0,-10,
-10, 0, 0, 0, 0, 0, 0,-10,
-20,-10,-10, -5, -5,-10,-10,-20,
]
PST_KING_MID = [
20, 30, 10, 0, 0, 10, 30, 20,
20, 20, 0, 0, 0, 0, 20, 20,
-10,-20,-20,-20,-20,-20,-20,-10,
-20,-30,-30,-40,-40,-30,-30,-20,
-30,-40,-40,-50,-50,-40,-40,-30,
-30,-40,-40,-50,-50,-40,-40,-30,
-30,-40,-40,-50,-50,-40,-40,-30,
-30,-40,-40,-50,-50,-40,-40,-30,
]
PST = {
chess.PAWN: PST_PAWN,
chess.KNIGHT: PST_KNIGHT,
chess.BISHOP: PST_BISHOP,
chess.ROOK: PST_ROOK,
chess.QUEEN: PST_QUEEN,
chess.KING: PST_KING_MID,
}
def evaluate(board: chess.Board) -> int:
"""Static evaluation in centipawns, from the side-to-move's perspective."""
if board.is_checkmate():
return -99999
if board.is_stalemate() or board.is_insufficient_material():
return 0
score = 0
for piece_type in PIECE_VALUE:
pst = PST[piece_type]
val = PIECE_VALUE[piece_type]
for sq in board.pieces(piece_type, chess.WHITE):
score += val + pst[sq]
for sq in board.pieces(piece_type, chess.BLACK):
# Mirror the square vertically for Black
score -= val + pst[chess.square_mirror(sq)]
return score if board.turn == chess.WHITE else -score
# ── Search ──────────────────────────────────────────────────────────────────
def _mvv_lva(board: chess.Board, move: chess.Move) -> int:
"""Most Valuable Victim / Least Valuable Attacker — for move ordering."""
if board.is_capture(move):
victim = board.piece_at(move.to_square)
attacker = board.piece_at(move.from_square)
v = PIECE_VALUE.get(victim.piece_type, 0) if victim else 100 # en passant
a = PIECE_VALUE.get(attacker.piece_type, 0) if attacker else 0
return 10 * v - a + 10000
if move.promotion:
return PIECE_VALUE.get(move.promotion, 0) + 5000
if board.gives_check(move):
return 100
return 0
def _ordered_moves(board: chess.Board):
moves = list(board.legal_moves)
moves.sort(key=lambda m: _mvv_lva(board, m), reverse=True)
return moves
def _quiesce(board: chess.Board, alpha: int, beta: int) -> int:
"""Quiescence search — extend through captures to avoid horizon effects."""
stand_pat = evaluate(board)
if stand_pat >= beta:
return beta
if stand_pat > alpha:
alpha = stand_pat
for move in _ordered_moves(board):
if not board.is_capture(move):
continue
board.push(move)
score = -_quiesce(board, -beta, -alpha)
board.pop()
if score >= beta:
return beta
if score > alpha:
alpha = score
return alpha
def _negamax(board: chess.Board, depth: int, alpha: int, beta: int) -> int:
if depth == 0:
return _quiesce(board, alpha, beta)
if board.is_game_over():
return evaluate(board)
best = -1_000_000
for move in _ordered_moves(board):
board.push(move)
score = -_negamax(board, depth - 1, -beta, -alpha)
board.pop()
if score > best:
best = score
if best > alpha:
alpha = best
if alpha >= beta:
break
return best
def choose_move(board: chess.Board, depth: int = 3, randomness: float = 30.0) -> chess.Move:
"""
Pick a move using negamax + alpha-beta.
Args:
board: current position
depth: search depth in plies (3 is club-level, 4 is slow)
randomness: centipawn window — moves within this of the best are
candidates. Keeps Viktor from always playing the same lines.
Returns:
A legal move. Never None (caller must ensure legal moves exist).
"""
scored = []
for move in _ordered_moves(board):
board.push(move)
score = -_negamax(board, depth - 1, -1_000_000, 1_000_000)
board.pop()
scored.append((score, move))
if not scored:
return None
scored.sort(key=lambda s: s[0], reverse=True)
best_score = scored[0][0]
# Any move within `randomness` cp of the best becomes a candidate
candidates = [m for s, m in scored if best_score - s <= randomness]
return random.choice(candidates)
__all__ = ["choose_move", "evaluate"]