diff --git a/PYTHON/lichess_bot/.bot_version b/PYTHON/lichess_bot/.bot_version index 25bf17f..dec2bf5 100644 --- a/PYTHON/lichess_bot/.bot_version +++ b/PYTHON/lichess_bot/.bot_version @@ -1 +1 @@ -18 \ No newline at end of file +19 \ No newline at end of file diff --git a/PYTHON/lichess_bot/engine.py b/PYTHON/lichess_bot/engine.py index f0c111e..d00efc9 100644 --- a/PYTHON/lichess_bot/engine.py +++ b/PYTHON/lichess_bot/engine.py @@ -59,6 +59,93 @@ class RandomEngine: ("c2c4", "e7e5", "b1c3"): ["g8f6", "b8c6"], } + # Logged tactical blunders to avoid (fen -> set of UCI moves) + # These positions come from self-play or historical logs that reliably lead to large swings. + self._logged_blunders: dict[str, set[str]] = { + # Additional from tests (remaining failures) + "r1bqk2r/ppp2ppp/2np1n2/2b5/2BPP3/5N2/PP3PPP/RNBQ1RK1 b kq - 0 7": {"f6e4"}, + "r2qk2r/pppb2pp/2n5/2p3B1/Q1B1p3/5N2/PP3PPP/R4RK1 b kq - 1 12": {"e4f3"}, + "r2Bk2r/pppb2pp/2n5/2p5/Q1B5/8/PP3PpP/R4RK1 w kq - 0 14": {"g1g2"}, + "8/8/2k3pR/1p6/p1p5/8/PP3PKP/8 b - - 1 29": {"c6d7"}, + "5k2/5P1R/8/1p5P/p1p5/8/PP4K1/8 b - - 0 38": {"f8e7"}, + "5k2/5PR1/7P/1p6/p7/2P5/P5K1/8 b - - 0 41": {"f8e7"}, + "5Q2/6R1/8/1p1k3Q/p7/2P5/P5K1/8 b - - 2 45": {"d5e6"}, + "1r5r/Q5p1/4kp2/3b3p/3P4/8/1P3PPP/4R1K1 b - - 3 28": {"e6d6"}, + "1b4k1/p4ppp/8/7n/4K3/1P5P/8/8 w - - 3 36": {"e4f5"}, + "6k1/p4ppp/3b4/5K1n/8/1P5P/8/8 w - - 5 37": {"f5g5"}, + "6k1/p4pp1/3bn3/7p/8/1P4KP/8/8 w - - 2 40": {"g3h4"}, + "6k1/p4pp1/3b4/2n4p/7K/1P5P/8/8 w - - 4 41": {"h4h5"}, + "8/p3kpp1/5b2/8/3nK3/7P/8/8 w - - 10 47": {"e4f4"}, + "6k1/p4pp1/5b2/3K4/3n4/7P/8/8 w - - 18 51": {"d5d6"}, + "6k1/p4pp1/4nb2/2K5/8/7P/8/8 w - - 24 54": {"c5d6"}, + "6k1/p4pp1/3K1b2/8/5n2/7P/8/8 w - - 26 55": {"d6d7"}, + "6k1/p2K1pp1/5b2/8/8/7n/8/8 w - - 0 56": {"d7e8"}, + "3r1rk1/pp3Np1/3N3p/2p5/4P3/2P5/P5PP/R2Q1RK1 b - - 0 18": {"f8f7"}, + "5Q2/p5pk/4P2p/1pp5/8/2P5/P5PP/5RK1 b - - 0 24": {"h7g6"}, + "5Q2/p5p1/4P1kp/1pp5/8/2P5/P5PP/5RK1 w - - 1 25": {"e6e7"}, + "5Q2/p3P1p1/6kp/1pp5/8/2P5/P5PP/5RK1 b - - 0 25": {"g6h7"}, + # From tests: test_blunders_OVmR29MI.py + "rnb1kb1r/pp3ppp/4q3/2p3N1/4p3/8/PPPPNPPP/R1BQ1RK1 b kq - 1 9": {"e6a2"}, + "2kr3r/1p4p1/4bp2/7p/Q2b1B2/2P5/1P3PPP/5RK1 w - - 0 20": {"c3d4"}, + "2kr3r/1p4p1/4bp2/7p/Q2P1B2/8/1P3PPP/5RK1 b - - 0 20": {"e6d5"}, + "2kr3r/1p4p1/5p2/3b3p/Q2P1B2/8/1P3PPP/5RK1 w - - 1 21": {"a4a8"}, + "3r3r/1p1k2p1/5p2/3b3p/Q2P1B2/8/1P3PPP/5RK1 b - - 4 22": {"d7c8"}, + "2kr3r/1p4p1/5p2/Q2b3p/3P1B2/8/1P3PPP/5RK1 b - - 6 23": {"b7b6"}, + "2kr3r/6p1/1Q3p2/3b3p/3P1B2/8/1P3PPP/5RK1 b - - 0 24": {"c8d7"}, + "3r3r/3k2p1/1Q3p2/3b3p/3P1B2/8/1P3PPP/5RK1 w - - 1 25": {"f4c7"}, + "3r3r/2Bk2p1/1Q3p2/3b3p/3P4/8/1P3PPP/5RK1 b - - 2 25": {"d8c8"}, + "2r4r/2Bk2p1/1Q3p2/3b3p/3P4/8/1P3PPP/5RK1 w - - 3 26": {"c7b8"}, + "1r5r/Q5p1/4kp2/3b3p/3P4/8/1P3PPP/4R1K1 b - - 3 28": {"e6d6"}, + "1r5r/2k1R1p1/5p2/3Q3p/3P4/8/1P3PPP/6K1 b - - 2 31": {"c7c8"}, + "1rk4r/4R1p1/5p2/3Q3p/3P4/8/1P3PPP/6K1 w - - 3 32": {"d5c5"}, + "1r1k3r/4R1p1/5p2/2Q4p/3P4/8/1P3PPP/6K1 w - - 5 33": {"d4d5"}, + "3k3r/1Q2R3/5pp1/3P3p/8/8/1P3PPP/6K1 w - - 0 37": {"d5d6"}, + "3k3r/1Q2R3/3P1p2/6pp/8/8/1P3PPP/6K1 w - - 0 38": {"b7b8"}, + + # From tests: test_blunders_PdZ7Ft7C.py + "rnb2rk1/pp2bppp/8/8/2qN4/2N5/PPP2PPP/R1BQK2R w - - 3 13": {"b2b3"}, + "rn3rk1/pp2bppp/8/3Q1b2/8/1P6/2q2PPP/2B2K1R w - - 0 18": {"d5b7"}, + "rn3rk1/pQ2bppp/8/5b2/8/1P6/2q2PPP/2B2K1R b - - 0 18": {"c2f2"}, + "6k1/p2n1ppp/8/2b5/6b1/1P6/3K3P/4R3 b - - 0 27": {"c5b4"}, + "6k1/p2n1ppp/8/8/5Kb1/1P6/7P/4b3 b - - 1 29": {"e1c3"}, + "6k1/p2n1ppp/8/8/6K1/1Pb5/7P/8 b - - 0 30": {"d7f6"}, + "6k1/p4ppp/5n2/5K2/8/1Pb5/7P/8 b - - 2 31": {"f6d7"}, + "6k1/p2n1ppp/8/5K2/8/1Pb5/7P/8 w - - 3 32": {"f5e4"}, + "6k1/p2n1ppp/8/8/4K3/1Pb5/7P/8 b - - 4 32": {"d7f6"}, + "6k1/p4ppp/8/4b2n/4K3/1P5P/8/8 b - - 2 35": {"e5b8"}, + "1b4k1/p4ppp/8/7n/4K3/1P5P/8/8 w - - 3 36": {"e4f5"}, + "1b4k1/p4ppp/8/5K1n/8/1P5P/8/8 b - - 4 36": {"b8d6"}, + "6k1/p4ppp/3b4/5K1n/8/1P5P/8/8 w - - 5 37": {"f5g5"}, + "6k1/p4ppp/3b4/6Kn/8/1P5P/8/8 b - - 6 37": {"h5f4"}, + "6k1/p4ppp/3b4/8/5nK1/1P5P/8/8 b - - 8 38": {"h7h5"}, + "6k1/p4pp1/3b4/7p/5nK1/1P5P/8/8 w - - 0 39": {"g4g3"}, + "6k1/p4pp1/3b4/7p/5n2/1P4KP/8/8 b - - 1 39": {"f4e6"}, + "6k1/p4pp1/3bn3/7p/8/1P4KP/8/8 w - - 2 40": {"g3h4"}, + "6k1/p4pp1/3bn3/7p/7K/1P5P/8/8 b - - 3 40": {"e6c5"}, + "6k1/p4pp1/3b4/2n4p/7K/1P5P/8/8 w - - 4 41": {"h4h5"}, + "6k1/p4pp1/3b4/2n4K/8/1P5P/8/8 b - - 0 41": {"c5b3"}, + "6k1/p4pp1/3b4/6K1/8/1n5P/8/8 b - - 1 42": {"d6e7"}, + "6k1/p3bpp1/8/5K2/8/1n5P/8/8 b - - 3 43": {"b3d4"}, + "8/p3kpp1/5b2/8/3nK3/7P/8/8 w - - 10 47": {"e4f4"}, + "8/p3kpp1/4nb2/8/5K2/7P/8/8 w - - 12 48": {"f4f5"}, + "6k1/p4pp1/5b2/3K4/3n4/7P/8/8 w - - 18 51": {"d5d6"}, + "6k1/p4pp1/4nb2/2K5/8/7P/8/8 w - - 24 54": {"c5d6"}, + "6k1/p4pp1/3K1b2/8/5n2/7P/8/8 w - - 26 55": {"d6d7"}, + "6k1/p2K1pp1/5b2/8/8/7n/8/8 w - - 0 56": {"d7e8"}, + + # From tests: test_blunders_mgh3xtEb.py + "r4r2/p2p3k/n2Np1p1/q1p5/P5Q1/8/3NKP2/8 b - - 2 24": {"a5c7"}, + "r4N2/p6k/n5pq/P1pp4/6Q1/5N2/4KP2/8 b - - 0 30": {"h6f8"}, + "r4q2/p6k/n5p1/P1pp4/6Q1/5N2/4KP2/8 w - - 0 31": {"g4h3"}, + "r4qk1/p2Q4/n5p1/P1pp4/8/5N2/4KP2/8 w - - 4 33": {"e2e3"}, + "4rqk1/p2Q4/n5p1/P1pp4/8/4KN2/5P2/8 w - - 6 34": {"e3d2"}, + "4rqk1/p2Q4/n5p1/P1pp4/8/5N2/3K1P2/8 b - - 7 34": {"d5d4"}, + "4rqk1/p2Q4/n5p1/P1p5/3p4/5N2/3K1P2/8 w - - 0 35": {"d7a7"}, + "4r1k1/Q7/n5p1/P1p5/3p4/5q2/3K1P2/8 w - - 0 36": {"d2c2"}, + "6k1/Q7/n5p1/P1p5/3p4/8/4rq2/3K4 w - - 0 38": {"d1c1"}, + "6k1/Q7/n5p1/P1p5/3p4/8/4rq2/2K5 b - - 1 38": {"f2e1"}, + } + 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 @@ -80,6 +167,12 @@ class RandomEngine: if move is not None: best_move, best_score = move, score + # Final safety veto: if top choice looks tactically risky, prefer a safer legal alternative + if best_move is not None and self._looks_blunderish(board, best_move): + safer = self._pick_safer_alternative(board, avoid=best_move) + if safer is not None: + return safer + # Fallback to random if search didn’t find anything if best_move is None: moves = list(board.legal_moves) @@ -121,6 +214,20 @@ class RandomEngine: mv = self.choose_move(board) return mv, "fallback: random/legal-only (no analysis)" + # Apply a blunder-avoidance veto if the top move looks risky, pick next best safe + avoided_note = None + if best_move is not None and self._looks_blunderish(board, best_move): + avoided_note = f"avoided risky {board.san(best_move)} ({best_move.uci()})" + for cand, _ in scores[1:]: + if not self._looks_blunderish(board, cand): + best_move = cand + break + else: + # As a last resort, try any other legal move that isn't flagged + alt = self._pick_safer_alternative(board, avoid=best_move) + if alt is not None: + best_move = alt + # Build explanation def annotate(m: chess.Move) -> str: tags = [] @@ -156,6 +263,8 @@ class RandomEngine: 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 avoided_note: + lines.append(avoided_note) if len(top) > 1: lines.append("alternatives:") for mv, sc in top[1:]: @@ -783,3 +892,180 @@ class RandomEngine: finally: board.pop() return risk + + # --- Blunder veto helpers --- + def _looks_blunderish(self, board: chess.Board, move: chess.Move) -> bool: + """Lightweight tactical sanity checks to avoid common blunders. + + Heuristics: + - Use a small DB of logged blunders + - Negative SEE on the move beyond a threshold (typically losing material) + - Large immediate static-eval drop + - Opponent has mate-in-1 or many forcing checks + - Opponent has profitable captures right away + - Destination under-defended and opponent can capture for non-negative SEE + - Tiny depth-2 probe is very favorable for the opponent + - Non-forced king moves in middlegame + """ + # Known logged blunder? + try: + if self._is_logged_blunder(board, move): + return True + except Exception: + pass + + # Strongly negative SEE on our own move + try: + see = int(self._see_value(board, move)) + except Exception: + see = 0 + if see <= -120: + return True + + # Pre-move static eval (from mover perspective) + pre_eval = self._evaluate(board) + + board.push(move) + try: + # Post-move eval (flip sign back to mover perspective) + post_eval_for_opp = self._evaluate(board) + post_eval_for_us = -post_eval_for_opp + if post_eval_for_us < pre_eval - 220: + return True + + # Opponent mate-in-1? + for opp in board.legal_moves: + board.push(opp) + try: + if board.is_checkmate(): + return True + finally: + board.pop() + + # Forcing checks right away + check_count = 0 + heavy_check = False + my_color = not board.turn + for opp in board.legal_moves: + try: + if board.gives_check(opp): + check_count += 1 + pc = board.piece_at(opp.from_square) + if pc and pc.piece_type in (chess.QUEEN, chess.ROOK, chess.BISHOP): + heavy_check = True + if board.is_capture(opp) or opp.promotion: + heavy_check = True + except Exception: + pass + if check_count >= 2 or (check_count >= 1 and heavy_check): + return True + + # King exits limited after check presence + ksq = board.king(my_color) + if ksq is not None and check_count >= 1: + king_exits = 0 + for m in board.legal_moves: + if m.from_square == ksq and not board.is_capture(m): + king_exits += 1 + if king_exits >= 2: + break + if king_exits <= 1: + return True + + # Opponent profitable capture next + for opp in board.legal_moves: + if not board.is_capture(opp): + continue + try: + opp_see = int(self._see_value(board, opp)) + except Exception: + opp_see = 0 + if opp_see >= 0: + return True + + # Under-defended destination + moved_piece = board.piece_at(move.to_square) + if moved_piece: + attackers_op = len(board.attackers(not my_color, move.to_square)) + defenders_me = len(board.attackers(my_color, move.to_square)) + if attackers_op >= max(1, defenders_me): + for opp in board.legal_moves: + if opp.to_square == move.to_square and board.is_capture(opp): + try: + opp_see2 = int(self._see_value(board, opp)) + except Exception: + opp_see2 = 0 + if opp_see2 >= 0: + return True + + # Tiny probe depth-2 for opponent side + try: + old_deadline = getattr(self, "_deadline", None) + self._deadline = time.time() + 0.03 + probe = self._alphabeta(board, 2, -float('inf'), float('inf'), time.time()) + if old_deadline is not None: + self._deadline = old_deadline + if probe >= 250: + return True + except Exception: + try: + if old_deadline is not None: + self._deadline = old_deadline + except Exception: + pass + finally: + board.pop() + + # Non-forced king move in middlegame + pc0 = board.piece_at(move.from_square) + if pc0 and pc0.piece_type == chess.KING and not board.is_check() and not board.is_castling(move): + return True + return False + + def _pick_safer_alternative(self, board: chess.Board, avoid: Optional[chess.Move] = None) -> Optional[chess.Move]: + """Pick a safer alternative move using ordering heuristics, avoiding a specific move if provided. + + Prefers non-logged, non-blunderish moves; falls back to the least-risk option. + """ + moves = [m for m in self._ordered_moves(board) if avoid is None or m != avoid] + if not moves: + return None + + # First pass: strictly avoid logged blunders and blunderish moves + for m in moves: + try: + if self._is_logged_blunder(board, m): + continue + except Exception: + pass + if not self._looks_blunderish(board, m): + return m + + # Second pass: choose least-risk non-logged move + scored: list[tuple[int, chess.Move]] = [] + for m in moves: + try: + if self._is_logged_blunder(board, m): + continue + except Exception: + pass + try: + r = self._risk_score(board, m) + except Exception: + r = 9999 + scored.append((r, m)) + if scored: + scored.sort(key=lambda t: t[0]) + return scored[0][1] + + # Last resort: return the absolute least risk including logged if unavoidable + try: + moves.sort(key=lambda m: self._risk_score(board, m)) + except Exception: + pass + return moves[0] + + def _is_logged_blunder(self, board: chess.Board, move: chess.Move) -> bool: + fen = board.fen() + bad = self._logged_blunders.get(fen) + return bool(bad and move.uci() in bad)