From 3df284a63497e8a07b6878b3b59b18e2b9422b73 Mon Sep 17 00:00:00 2001 From: TheYoBots Date: Mon, 6 Jun 2022 12:44:31 +0000 Subject: [PATCH] Merge Upstream https://github.com/ShailChoksi/lichess-bot/pull/465 https://github.com/ShailChoksi/lichess-bot/pull/473 --- config.yml | 13 +++++++- config.yml.default | 13 +++++++- lichess-bot.py | 36 ++++++++------------- lichess.py | 27 +++++++++++++--- matchmaking.py | 81 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 141 insertions(+), 29 deletions(-) create mode 100644 matchmaking.py diff --git a/config.yml b/config.yml index 110efcbc..d506e1e5 100644 --- a/config.yml +++ b/config.yml @@ -125,4 +125,15 @@ greeting: hello: "Hi! I'm {me}. Good luck! Type !help for a list of commands I can respond to." # Message to send to chat at the start of a game goodbye: "Good game!" # Message to send to chat at the end of a game -# pgn_directory: "game_records" # A directory where PGN-format records of the bot's games are kept \ No newline at end of file +# pgn_directory: "game_records" # A directory where PGN-format records of the bot's games are kept + +matchmaking: + allow_matchmaking: false # Set it to 'true' to challenge other bots. + challenge_variant: "random" # If set to 'random', the bot will choose one variant from the variants enabled in 'challenge.variants'. + challenge_timeout: 30 # Create a challenge after being idle for 'challenge_timeout' minutes. + challenge_initial_time: 60 # Initial time in seconds of the challenge. + challenge_increment: 3 # Increment in seconds of the challenge. +# challenge_days: 2 # Days for correspondence challenge. If this option is enabled, a correspondence challenge will be created even if 'challenge_initial_time' is enabled. + opponent_min_rating: 600 # Opponents rating should be above this value (600 is the minimum rating in lichess). + opponent_max_rating: 4000 # Opponents rating should be below this value (4000 is the maximum rating in lichess). + challenge_mode: "random" # Set it to the mode in which challenges are sent. Possible options are 'casual', 'rated' and 'random'. \ No newline at end of file diff --git a/config.yml.default b/config.yml.default index 06e9af2d..bba05112 100644 --- a/config.yml.default +++ b/config.yml.default @@ -125,4 +125,15 @@ greeting: hello: "Hi! I'm {me}. Good luck! Type !help for a list of commands I can respond to." # Message to send to chat at the start of a game goodbye: "Good game!" # Message to send to chat at the end of a game -# pgn_directory: "game_records" # A directory where PGN-format records of the bot's games are kept \ No newline at end of file +# pgn_directory: "game_records" # A directory where PGN-format records of the bot's games are kept + +matchmaking: + allow_matchmaking: false # Set it to 'true' to challenge other bots. + challenge_variant: "random" # If set to 'random', the bot will choose one variant from the variants enabled in 'challenge.variants'. + challenge_timeout: 30 # Create a challenge after being idle for 'challenge_timeout' minutes. + challenge_initial_time: 60 # Initial time in seconds of the challenge. + challenge_increment: 3 # Increment in seconds of the challenge. +# challenge_days: 2 # Days for correspondence challenge. If this option is enabled, a correspondence challenge will be created even if 'challenge_initial_time' is enabled. + opponent_min_rating: 600 # Opponents rating should be above this value (600 is the minimum rating in lichess). + opponent_max_rating: 4000 # Opponents rating should be below this value (4000 is the maximum rating in lichess). + challenge_mode: "random" # Set it to the mode in which challenges are sent. Possible options are 'casual', 'rated' and 'random'. \ No newline at end of file diff --git a/lichess-bot.py b/lichess-bot.py index 5cdc245c..38106a5b 100644 --- a/lichess-bot.py +++ b/lichess-bot.py @@ -5,6 +5,7 @@ import chess.polyglot import engine_wrapper import model +import matchmaking import json import lichess import logging @@ -117,6 +118,7 @@ def start(li, user_profile, config, logging_level, log_filename, one_game=False) correspondence_queue.put("") startup_correspondence_games = [game["gameId"] for game in li.get_ongoing_games() if game["perf"] == "correspondence"] wait_for_correspondence_ping = False + matchmaker = matchmaking.Matchmaking(li, config, user_profile["username"]) busy_processes = 0 queued_processes = 0 @@ -145,6 +147,7 @@ def start(li, user_profile, config, logging_level, log_filename, one_game=False) break elif event["type"] == "local_game_done": busy_processes -= 1 + matchmaker.last_game_ended = time.time() logger.info(f"+++ Process Free. Total Queued: {queued_processes}. Total Used: {busy_processes}") if one_game: break @@ -156,7 +159,7 @@ def start(li, user_profile, config, logging_level, log_filename, one_game=False) list_c = list(challenge_queue) list_c.sort(key=lambda c: -c.score()) challenge_queue = list_c - else: + elif chlng.id != matchmaker.challenge_id: try: reason = "generic" challenge = config["challenge"] @@ -176,6 +179,8 @@ def start(li, user_profile, config, logging_level, log_filename, one_game=False) pass elif event["type"] == "gameStart": game_id = event["game"]["id"] + if matchmaker.challenge_id == game_id: + matchmaker.challenge_id = None if game_id in startup_correspondence_games: logger.info(f'--- Enqueue {config["url"] + game_id}') correspondence_queue.put(game_id) @@ -220,6 +225,10 @@ def start(li, user_profile, config, logging_level, log_filename, one_game=False) logger.info(f"Skip missing {chlng}") queued_processes -= 1 + if queued_processes + busy_processes < min(max_games, 1) and not challenge_queue and matchmaker.should_create_challenge(): + logger.info("Challenging a random bot") + matchmaker.challenge() + control_queue.task_done() logger.info("Terminated") @@ -280,9 +289,10 @@ def play_game(li, game_id, control_queue, user_profile, config, challenge_queue, else: binary_chunk = next(lines) upd = json.loads(binary_chunk.decode("utf-8")) if binary_chunk else None - logger.debug(f"Game state: {upd}") u_type = upd["type"] if upd else "ping" + if u_type != "ping": + logger.debug(f"Game state: {upd}") if u_type == "chatLine": conversation.react(ChatLine(upd), game) elif u_type == "gameState": @@ -696,29 +706,11 @@ def print_pgn_game_record(li, config, game, board, engine): game_file_name = "".join(c for c in game_file_name if c not in '<>:"/\\|?*') game_path = os.path.join(game_directory, game_file_name) - # When lichess sends a move with two comments (say a clock comment and an opening label), - # these comments are separately brace-delimited--e.g., { [%clk 0:01:00] } { A40 Australian Defense }. - # When chess.pgn.read_game() parses these comments, a newline joins them into a single comment. - # This class overrides chess.pgn.GameBuilder.visit_comment() in order to replace the newline - # joiner with a space. - class Lichess_Game_Builder(chess.pgn.GameBuilder): - def visit_comment(self, comment): - if self.in_variation or (self.variation_stack[-1].parent is None and self.variation_stack[-1].is_end()): - # Add as a comment for the current node if in the middle of - # a variation. Add as a comment for the game if the comment - # starts before any move. - new_comment = [self.variation_stack[-1].comment, comment] - self.variation_stack[-1].comment = " ".join(new_comment).strip() - else: - # Otherwise, it is a starting comment. - new_comment = [self.starting_comment, comment] - self.starting_comment = " ".join(new_comment).strip() - - lichess_game_record = chess.pgn.read_game(io.StringIO(li.get_game_pgn(game.id)), Visitor=Lichess_Game_Builder) + lichess_game_record = chess.pgn.read_game(io.StringIO(li.get_game_pgn(game.id))) try: # Recall previously written PGN file to retain engine evaluations. with open(game_path) as game_data: - game_record = chess.pgn.read_game(game_data, Visitor=Lichess_Game_Builder) + game_record = chess.pgn.read_game(game_data) game_record.headers.update(lichess_game_record.headers) except FileNotFoundError: game_record = lichess_game_record diff --git a/lichess.py b/lichess.py index c1fbc722..a9993541 100644 --- a/lichess.py +++ b/lichess.py @@ -1,3 +1,4 @@ +import json import requests from urllib.parse import urljoin from requests.exceptions import ConnectionError, HTTPError, ReadTimeout @@ -19,7 +20,10 @@ "decline": "/api/challenge/{}/decline", "upgrade": "/api/bot/account/upgrade", "resign": "/api/bot/game/{}/resign", - "export": "/game/export/{}" + "export": "/game/export/{}", + "online_bots": "/api/bot/online", + "challenge": "/api/challenge/{}", + "cancel": "/api/challenge/{}/cancel" } @@ -52,6 +56,7 @@ def api_get(self, path, raise_for_status=True, get_raw_text=False, params=None): response = self.session.get(url, timeout=2, params=params) if raise_for_status: response.raise_for_status() + response.encoding = "utf-8" return response.text if get_raw_text else response.json() @backoff.on_exception(backoff.constant, @@ -61,11 +66,12 @@ def api_get(self, path, raise_for_status=True, get_raw_text=False, params=None): giveup=is_final, backoff_log_level=logging.DEBUG, giveup_log_level=logging.DEBUG) - def api_post(self, path, data=None, headers=None, params=None): + def api_post(self, path, data=None, headers=None, params=None, payload=None, raise_for_status=True): logging.getLogger("backoff").setLevel(self.logging_level) url = urljoin(self.baseUrl, path) - response = self.session.post(url, data=data, headers=headers, params=params, timeout=2) - response.raise_for_status() + response = self.session.post(url, data=data, headers=headers, params=params, json=payload, timeout=2) + if raise_for_status: + response.raise_for_status() return response.json() def get_game(self, game_id): @@ -118,4 +124,15 @@ def set_user_agent(self, username): def get_game_pgn(self, game_id): return self.api_get(ENDPOINTS["export"].format(game_id), get_raw_text=True, - params={"literate": "true"}) \ No newline at end of file + params={"literate": "true"}) + + def get_online_bots(self): + online_bots = self.api_get(ENDPOINTS["online_bots"], get_raw_text=True) + online_bots = list(filter(bool, online_bots.split("\n"))) + return list(map(lambda bot: json.loads(bot), online_bots)) + + def challenge(self, username, params): + return self.api_post(ENDPOINTS["challenge"].format(username), payload=params, raise_for_status=False) + + def cancel(self, challenge_id): + return self.api_post(ENDPOINTS["cancel"].format(challenge_id), raise_for_status=False) \ No newline at end of file diff --git a/matchmaking.py b/matchmaking.py new file mode 100644 index 00000000..baa09f76 --- /dev/null +++ b/matchmaking.py @@ -0,0 +1,81 @@ +import random +import time +import logging + +logger = logging.getLogger(__name__) + + +class Matchmaking: + def __init__(self, li, config, username): + self.li = li + self.variants = list(filter(lambda variant: variant != "fromPosition", config["challenge"]["variants"])) + self.matchmaking_cfg = config.get("matchmaking") or {} + self.username = username + self.last_challenge_created = time.time() + self.last_game_ended = time.time() + self.challenge_expire_time = 25 # The challenge expires 20 seconds after creating it. + self.challenge_id = None + + def should_create_challenge(self): + matchmaking_enabled = self.matchmaking_cfg.get("allow_matchmaking") + time_has_passed = self.last_game_ended + ((self.matchmaking_cfg.get("challenge_timeout") or 30) * 60) < time.time() + challenge_expired = self.last_challenge_created + self.challenge_expire_time < time.time() and self.challenge_id + # Wait 20 seconds before creating a new challenge to avoid hitting the api rate limits. + twenty_seconds_passed = self.last_challenge_created + 20 < time.time() + if challenge_expired: + self.li.cancel(self.challenge_id) + logger.debug(f"Challenge id {self.challenge_id} cancelled.") + return matchmaking_enabled and (time_has_passed or challenge_expired) and twenty_seconds_passed + + def create_challenge(self, username, base_time, increment, days, variant): + mode = self.matchmaking_cfg.get("challenge_mode") or "random" + if mode == "random": + mode = random.choice(["casual", "rated"]) + rated = mode == "rated" + params = {"rated": rated, "variant": variant} + if days: + params["days"] = days + else: + params["clock.limit"] = base_time + params["clock.increment"] = increment + challenge_id = self.li.challenge(username, params).get("challenge", {}).get("id") + return challenge_id + + def choose_opponent(self): + variant = self.matchmaking_cfg.get("challenge_variant") or "random" + if variant == "random": + variant = random.choice(self.variants) + base_time = self.matchmaking_cfg.get("challenge_initial_time", 60) + increment = self.matchmaking_cfg.get("challenge_increment", 2) + days = self.matchmaking_cfg.get("challenge_days") + game_duration = base_time + increment * 40 + if variant != "standard": + game_type = variant + elif days: + game_type = "correspondence" + elif game_duration < 179: + game_type = "bullet" + elif game_duration < 479: + game_type = "blitz" + elif game_duration < 1499: + game_type = "rapid" + else: + game_type = "classical" + + min_rating = self.matchmaking_cfg.get("opponent_min_rating") or 600 + max_rating = self.matchmaking_cfg.get("opponent_max_rating") or 4000 + + online_bots = self.li.get_online_bots() + online_bots = list(filter(lambda bot: bot["username"] != self.username and not bot.get("disabled") and + min_rating <= ((bot["perfs"].get(game_type) or {}).get("rating") or 0) <= max_rating, + online_bots)) + bot_username = random.choice(online_bots)["username"] if online_bots else None + return bot_username, base_time, increment, days, variant + + def challenge(self): + bot_username, base_time, increment, days, variant = self.choose_opponent() + logger.info(f"Will challenge {bot_username} for a {variant} game.") + challenge_id = self.create_challenge(bot_username, base_time, increment, days, variant) if bot_username else None + logger.info(f"Challenge id is {challenge_id}.") + self.last_challenge_created = time.time() + self.challenge_id = challenge_id \ No newline at end of file