From 59e6f807b776c43f9babf5b55b5398e4eb77293c Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 22 Aug 2025 21:48:28 +0200 Subject: [PATCH] feat: log failures in connecting to lichess --- PYTHON/lichess_bot/.bot_version | 2 +- PYTHON/lichess_bot/engine.py | 65 +++++++++++++- PYTHON/lichess_bot/lichess_api.py | 87 +++++++++++++++---- PYTHON/lichess_bot/main.py | 22 +++-- .../stockfish_analysis/analyze_chess_game.py | 2 +- 5 files changed, 152 insertions(+), 26 deletions(-) diff --git a/PYTHON/lichess_bot/.bot_version b/PYTHON/lichess_bot/.bot_version index bf0d87a..9a03714 100644 --- a/PYTHON/lichess_bot/.bot_version +++ b/PYTHON/lichess_bot/.bot_version @@ -1 +1 @@ -4 \ No newline at end of file +10 \ No newline at end of file diff --git a/PYTHON/lichess_bot/engine.py b/PYTHON/lichess_bot/engine.py index f088397..e5e7bdd 100644 --- a/PYTHON/lichess_bot/engine.py +++ b/PYTHON/lichess_bot/engine.py @@ -43,6 +43,20 @@ class RandomEngine: ("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]: @@ -275,6 +289,30 @@ class RandomEngine: 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): @@ -304,11 +342,20 @@ class RandomEngine: 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 @@ -371,6 +418,22 @@ class RandomEngine: 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) @@ -378,7 +441,7 @@ class RandomEngine: 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 + pst - hanging_pen + 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]: diff --git a/PYTHON/lichess_bot/lichess_api.py b/PYTHON/lichess_bot/lichess_api.py index b390f6d..ad7e504 100644 --- a/PYTHON/lichess_bot/lichess_api.py +++ b/PYTHON/lichess_bot/lichess_api.py @@ -20,28 +20,74 @@ class LichessAPI: "User-Agent": "minimal-lichess-bot/0.1 (+https://lichess.org)" }) + def _request(self, method: str, url: str, *, raise_for_status: bool = False, **kwargs) -> requests.Response: + """Wrapper around session.request that logs every request/response. + + - Logs start (method+URL) and end (status, elapsed). + - On 4xx/5xx, logs a warning with a small snippet of the response body. + - Optionally raises for status. + """ + t0 = time.monotonic() + logging.info(f"HTTP {method} {url} -> sending") + try: + r = self.session.request(method, url, **kwargs) + except Exception as e: + logging.error(f"HTTP {method} {url} -> exception: {e}") + raise + elapsed = time.monotonic() - t0 + status = r.status_code + if status >= 400: + # Log a brief error body snippet if available + snippet = None + try: + text = r.text or "" + snippet = text[:200].replace("\n", " ") + except Exception: + snippet = None + if snippet: + logging.warning(f"HTTP {method} {url} -> {status} in {elapsed:.2f}s body='{snippet}'") + else: + logging.warning(f"HTTP {method} {url} -> {status} in {elapsed:.2f}s") + else: + logging.info(f"HTTP {method} {url} -> {status} in {elapsed:.2f}s") + if raise_for_status: + r.raise_for_status() + return r + def stream_events(self) -> Generator[Dict, None, None]: url = f"{LICHESS_API}/api/stream/event" - with self.session.get(url, stream=True, timeout=60) as r: - r.raise_for_status() - for line in r.iter_lines(decode_unicode=True): - if not line: + backoff = 0.5 + while True: + try: + # Use NDJSON Accept and no timeout for long-lived stream + headers = {"Accept": "application/x-ndjson"} + with self._request("GET", url, headers=headers, stream=True, timeout=None) as r: + r.raise_for_status() + backoff = 0.5 # reset on success + for line in r.iter_lines(decode_unicode=True): + if not line: + continue + try: + yield json.loads(line) + except json.JSONDecodeError: + logging.debug(f"Skipping non-JSON line: {line}") + except requests.HTTPError as e: + status = getattr(e.response, "status_code", None) + if status == 429: + logging.warning("Event stream hit 429; backing off") + time.sleep(backoff) + backoff = min(8.0, backoff * 2) continue - try: - yield json.loads(line) - except json.JSONDecodeError: - logging.debug(f"Skipping non-JSON line: {line}") + raise def accept_challenge(self, challenge_id: str) -> None: url = f"{LICHESS_API}/api/challenge/{challenge_id}/accept" - r = self.session.post(url, timeout=30) - r.raise_for_status() + self._request("POST", url, timeout=30, raise_for_status=True) def decline_challenge(self, challenge_id: str, reason: str = "generic") -> None: url = f"{LICHESS_API}/api/challenge/{challenge_id}/decline" data = {"reason": reason} - r = self.session.post(url, data=data, timeout=30) - r.raise_for_status() + self._request("POST", url, data=data, timeout=30, raise_for_status=True) def join_game_stream(self, game_id: str, my_color: Optional[str]) -> Tuple[chess.Board, str]: """Deprecated: use stream_game_events and parse initial state there.""" @@ -49,7 +95,8 @@ class LichessAPI: url = f"{LICHESS_API}/api/board/game/stream/{game_id}" board = chess.Board() color = my_color or "white" - with self.session.get(url, stream=True, timeout=60) as r: + headers = {"Accept": "application/x-ndjson"} + with self._request("GET", url, headers=headers, stream=True, timeout=None) as r: r.raise_for_status() for line in r.iter_lines(decode_unicode=True): if not line: @@ -80,7 +127,8 @@ class LichessAPI: def stream_game_events(self, game_id: str) -> Generator[Dict, None, None]: url = f"{LICHESS_API}/api/board/game/stream/{game_id}" - with self.session.get(url, stream=True, timeout=60) as r: + headers = {"Accept": "application/x-ndjson"} + with self._request("GET", url, headers=headers, stream=True, timeout=None) as r: r.raise_for_status() for line in r.iter_lines(decode_unicode=True): if not line: @@ -92,10 +140,15 @@ class LichessAPI: def make_move(self, game_id: str, move: chess.Move) -> None: url = f"{LICHESS_API}/api/board/game/{game_id}/move/{move.uci()}" - r = self.session.post(url, timeout=30) + r = self._request("POST", url, timeout=30) + if r.status_code in (400, 409): + # Likely not our turn or move already played; do not retry to avoid spam + r.raise_for_status() + return if r.status_code == 429: + logging.warning(f"HTTP POST {url} -> 429; retrying once after 0.5s") time.sleep(0.5) - r = self.session.post(url, timeout=30) + r = self._request("POST", url, timeout=30) r.raise_for_status() def get_game_state(self, game_id: str) -> Optional[Dict]: @@ -104,7 +157,7 @@ class LichessAPI: def get_my_user_id(self) -> Optional[str]: url = f"{LICHESS_API}/api/account" - r = self.session.get(url, timeout=30) + r = self._request("GET", url, timeout=30) if r.status_code == 200: return r.json().get("id") return None diff --git a/PYTHON/lichess_bot/main.py b/PYTHON/lichess_bot/main.py index 140a404..9741efb 100644 --- a/PYTHON/lichess_bot/main.py +++ b/PYTHON/lichess_bot/main.py @@ -59,6 +59,9 @@ def run_bot(log_level: str = "INFO", decline_correspondence: bool = False) -> No black_name: Optional[str] = None site_url: Optional[str] = None try: + # Only send moves on authoritative gameState events to avoid race + # conditions right after gameFull arrives. + seen_game_full = False for event in api.stream_game_events(game_id): et = event.get("type") if et in ("gameFull", "gameState"): @@ -92,6 +95,7 @@ def run_bot(log_level: str = "INFO", decline_correspondence: bool = False) -> No elif me == black_id: color = "black" logging.info(f"Game {game_id}: joined as {color} (gameFull)") + seen_game_full = True else: moves = event.get("moves", "") status = event.get("status") @@ -132,7 +136,9 @@ def run_bot(log_level: str = "INFO", decline_correspondence: bool = False) -> No logging.info( f"Game {game_id}: turn={'white' if is_white_turn else 'black'}, my_turn={my_turn}" ) - if my_turn: + # Only move on 'gameState' events; skip making a move on initial 'gameFull' + allow_move = (et == "gameState") + if my_turn and allow_move: # Compute a per-move time budget (seconds) based on remaining time # Heuristic: use min( max_time_sec, max(0.05, 0.6 * my_time_left/remaining_moves + inc) ) # Estimate remaining moves as 30 - ply/2 bounded to [10, 60] @@ -149,11 +155,15 @@ def run_bot(log_level: str = "INFO", decline_correspondence: bool = False) -> No logging.info(f"Game {game_id}: no legal moves (game likely over)") break try: - logging.info(f"Game {game_id}: playing {move.uci()} (budget={budget:.2f}s, my_time_left={time_left_sec:.1f}s, inc={inc_sec:.2f}s)") - if game_log_path: - with open(game_log_path, "a") as lf: - lf.write(f"ply {last_handled_len+1}: {move.uci()}\n{reason}\n\n") - api.make_move(game_id, move) + # Double-check legality just before sending to avoid 400s when state changed. + if move not in board.legal_moves: + logging.info(f"Game {game_id}: selected move no longer legal; skipping send") + else: + logging.info(f"Game {game_id}: playing {move.uci()} (budget={budget:.2f}s, my_time_left={time_left_sec:.1f}s, inc={inc_sec:.2f}s)") + if game_log_path: + with open(game_log_path, "a") as lf: + lf.write(f"ply {last_handled_len+1}: {move.uci()}\n{reason}\n\n") + api.make_move(game_id, move) except Exception as e: logging.warning(f"Game {game_id}: move {move.uci()} failed: {e}") # Mark this position as handled (whether or not we moved) diff --git a/PYTHON/stockfish_analysis/analyze_chess_game.py b/PYTHON/stockfish_analysis/analyze_chess_game.py index b5391bf..24b44fd 100644 --- a/PYTHON/stockfish_analysis/analyze_chess_game.py +++ b/PYTHON/stockfish_analysis/analyze_chess_game.py @@ -122,7 +122,7 @@ def main(): ap.add_argument("file", help="Path to a PGN file or a log containing a PGN section") ap.add_argument("--engine", default="stockfish", help="Path to stockfish executable (default: stockfish)") # Exactly one of time or depth may be provided; default to time - ap.add_argument("--time", type=float, default=0.2, help="Analysis time per evaluation in seconds (default: 0.2)") + ap.add_argument("--time", type=float, default=4, help="Analysis time per evaluation in seconds (default: 0.2)") ap.add_argument("--depth", type=int, default=None, help="Fixed depth per evaluation (overrides --time)") args = ap.parse_args()