""" 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"]