diff --git a/README.md b/README.md index f524cc794..88f8bfd49 100644 --- a/README.md +++ b/README.md @@ -219,18 +219,40 @@ will precede the `go` command to start thinking with `sd 5`. The other `go_comma -rated -casual ``` - - `greeting`: Send messages via chat to the bot's opponent. The string `{me}` will be replaced by the bot's lichess account name. The string `{opponent}` will be replaced by the opponent's lichess account name. Any other word between curly brackets will be removed. If you want to put a curly bracket in the message, use two: `{{` or `}}`. - - `hello`: Message to send to opponent before the bot makes its first move. - - `goodbye`: Message to send to opponent once the game is over. +- `greeting`: Send messages via chat to the bot's opponent. The string `{me}` will be replaced by the bot's lichess account name. The string `{opponent}` will be replaced by the opponent's lichess account name. Any other word between curly brackets will be removed. If you want to put a curly bracket in the message, use two: `{{` or `}}`. + - `hello`: Message to send to opponent before the bot makes its first move. + - `goodbye`: Message to send to opponent once the game is over. ```yml greeting: hello: Hi, {opponent}! I'm {me}. Good luck! goodbye: Good game! ``` - - `pgn_directory`: Write a record of every game played in PGN format to files in this directory. Each bot move will be annotated with the bot's calculated score and principal variation. The score is written with a tag of the form `[%eval s,d]`, where `s` is the score in pawns (positive means white has the advantage), and `d` is the depth of the search. Each game will be written to a uniquely named file. +- `pgn_directory`: Write a record of every game played in PGN format to files in this directory. Each bot move will be annotated with the bot's calculated score and principal variation. The score is written with a tag of the form `[%eval s,d]`, where `s` is the score in pawns (positive means white has the advantage), and `d` is the depth of the search. Each game will be written to a uniquely named file. ```yml pgn_directory: "game_records" ``` +- `matchmaking`: Challenge a random bot. + - `allow_matchmaking`: Whether to challenge other bots. + - `challenge_variant`: The variant for the challenges. If set to `random` a variant from the ones enabled in `challenge.variants` will be chosen on random. + - `challenge_timeout`: The time (in minutes) the bot has to be idle before it creates a challenge. + - `challenge_initial_time`: The initial time (in seconds) for the challenges. + - `challenge_increment`: The increment (in seconds) for the challenges. + - `challenge_days`: The days for a correspondence challenge. If this option is enabled, a correspondence challenge will be created even if `challenge_initial_time` is enabled. + - `opponent_min_rating`: The minimum rating of the opponent bot. The minimum rating in lichess is 600. + - `opponent_max_rating`: The maximum rating of the opponent bot. The maximum rating in lichess is 4000. + - `challenge_mode`: Possible options are `casual`, `rated` and `random`. +```yml +matchmaking: + allow_matchmaking: false + challenge_variant: "random" + challenge_timeout: 30 + challenge_initial_time: 60 + challenge_increment: 3 +# challenge_days: 2 + opponent_min_rating: 600 + opponent_max_rating: 4000 + challenge_mode: "random" +``` ## Lichess Upgrade to Bot Account **WARNING: This is irreversible. [Read more about upgrading to bot account](https://lichess.org/api#operation/botAccountUpgrade).** diff --git a/config.yml.default b/config.yml.default index 9fa8f7601..cd906f22b 100644 --- a/config.yml.default +++ b/config.yml.default @@ -126,3 +126,14 @@ greeting: 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 + +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'. diff --git a/lichess-bot.py b/lichess-bot.py index fc1d580d8..9be0e99f5 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": diff --git a/lichess.py b/lichess.py index 197e876d5..3f9fefd06 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): @@ -119,3 +125,14 @@ def get_game_pgn(self, game_id): return self.api_get(ENDPOINTS["export"].format(game_id), get_raw_text=True, 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) diff --git a/matchmaking.py b/matchmaking.py new file mode 100644 index 000000000..0f78d79fd --- /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