Skip to content
This repository has been archived by the owner on May 10, 2023. It is now read-only.

Commit

Permalink
Merge Upstream
Browse files Browse the repository at this point in the history
  • Loading branch information
TheYoBots committed Jun 6, 2022
1 parent 3c98f8f commit 3df284a
Show file tree
Hide file tree
Showing 5 changed files with 141 additions and 29 deletions.
13 changes: 12 additions & 1 deletion config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
# 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'.
13 changes: 12 additions & 1 deletion config.yml.default
Original file line number Diff line number Diff line change
Expand Up @@ -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
# 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'.
36 changes: 14 additions & 22 deletions lichess-bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import chess.polyglot
import engine_wrapper
import model
import matchmaking
import json
import lichess
import logging
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"]
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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
Expand Down
27 changes: 22 additions & 5 deletions lichess.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import requests
from urllib.parse import urljoin
from requests.exceptions import ConnectionError, HTTPError, ReadTimeout
Expand All @@ -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"
}


Expand Down Expand Up @@ -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,
Expand All @@ -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):
Expand Down Expand Up @@ -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"})
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)
81 changes: 81 additions & 0 deletions matchmaking.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 3df284a

Please sign in to comment.