From 122cdc793a56bcee2282ed34c6a233801dce80f2 Mon Sep 17 00:00:00 2001 From: Mark Harrison Date: Sat, 5 Aug 2023 00:37:58 -0700 Subject: [PATCH 01/20] Use timedelta instead of bare int and float The use of timedeltas is better since the programmer does not have to keep track of what unit of time is being stored (ns, ms, sec, min, hrs, etc.). However, the current code is very verbose thanks to all the datetime.timedelta(seconds=) calls. Some helper functions are needed to shorten up the code. --- engine_wrapper.py | 55 +++++++++++++++++++++++--------------------- lichess-bot.py | 22 ++++++++++-------- lichess.py | 11 +++++---- matchmaking.py | 17 +++++++------- model.py | 8 +++---- test_bot/lichess.py | 7 +++--- test_bot/test_bot.py | 25 ++++++++++---------- timer.py | 24 +++++++++---------- 8 files changed, 87 insertions(+), 82 deletions(-) diff --git a/engine_wrapper.py b/engine_wrapper.py index 3e8f6f331..0313ddb20 100644 --- a/engine_wrapper.py +++ b/engine_wrapper.py @@ -8,7 +8,7 @@ import chess import subprocess import logging -import time +import datetime import random from collections import Counter from collections.abc import Generator, Callable @@ -102,11 +102,11 @@ def play_move(self, board: chess.Board, game: model.Game, li: lichess.Lichess, - start_time: int, - move_overhead: int, + start_time: datetime.datetime, + move_overhead: datetime.timedelta, can_ponder: bool, is_correspondence: bool, - correspondence_move_time: int, + correspondence_move_time: datetime.timedelta, engine_cfg: config.Configuration) -> None: """ Play a move. @@ -167,7 +167,7 @@ def add_go_commands(self, time_limit: chess.engine.Limit) -> chess.engine.Limit: """Add extra commands to send to the engine. For example, to search for 1000 nodes or up to depth 10.""" movetime = self.go_commands.movetime if movetime is not None: - movetime_sec = float(movetime) / 1000 + movetime_sec = datetime.timedelta(seconds=movetime) / datetime.timedelta(milliseconds=1) if time_limit.time is None or time_limit.time > movetime_sec: time_limit.time = movetime_sec time_limit.depth = self.go_commands.depth @@ -561,8 +561,8 @@ def getHomemadeEngine(name: str) -> type[MinimalEngine]: return engine -def single_move_time(board: chess.Board, game: model.Game, search_time: int, - start_time: int, move_overhead: int) -> chess.engine.Limit: +def single_move_time(board: chess.Board, game: model.Game, search_time: datetime.timedelta, + start_time: datetime.datetime, move_overhead: datetime.timedelta) -> chess.engine.Limit: """ Calculate time to search in correspondence games. @@ -573,13 +573,13 @@ def single_move_time(board: chess.Board, game: model.Game, search_time: int, :param move_overhead: The time it takes to communicate between the engine and lichess-bot. :return: The time to choose a move. """ - pre_move_time = int((time.perf_counter_ns() - start_time) / 1e6) + pre_move_time = datetime.datetime.now() - start_time overhead = pre_move_time + move_overhead wb = "w" if board.turn == chess.WHITE else "b" - clock_time = max(0, game.state[f"{wb}time"] - overhead) + clock_time = max(datetime.timedelta(), datetime.timedelta(milliseconds=game.state[f"{wb}time"]) - overhead) search_time = min(search_time, clock_time) - logger.info(f"Searching for time {search_time} for game {game.id}") - return chess.engine.Limit(time=search_time / 1000, clock_id="correspondence") + logger.info(f"Searching for time {search_time.total_seconds()} seconds for game {game.id}") + return chess.engine.Limit(time=search_time.total_seconds(), clock_id="correspondence") def first_move_time(game: model.Game) -> chess.engine.Limit: @@ -589,13 +589,16 @@ def first_move_time(game: model.Game) -> chess.engine.Limit: :param game: The game that the bot is playing. :return: The time to choose the first move. """ - # Need to hardcode first movetime (10000 ms) since Lichess has 30 sec limit. - search_time = 10000 - logger.info(f"Searching for time {search_time} for game {game.id}") - return chess.engine.Limit(time=search_time / 1000, clock_id="first move") + # Need to hardcode first movetime (10 s) since Lichess has 30 sec limit. + search_time = 10 + logger.info(f"Searching for time {search_time} seconds for game {game.id}") + return chess.engine.Limit(time=search_time, clock_id="first move") -def game_clock_time(board: chess.Board, game: model.Game, start_time: int, move_overhead: int) -> chess.engine.Limit: +def game_clock_time(board: chess.Board, + game: model.Game, + start_time: datetime.datetime, + move_overhead: datetime.timedelta) -> chess.engine.Limit: """ Get the time to play by the engine in realtime games. @@ -605,10 +608,10 @@ def game_clock_time(board: chess.Board, game: model.Game, start_time: int, move_ :param move_overhead: The time it takes to communicate between the engine and lichess-bot. :return: The time to play a move. """ - pre_move_time = int((time.perf_counter_ns() - start_time) / 1e6) + pre_move_time = datetime.datetime.now() - start_time overhead = pre_move_time + move_overhead wb = "w" if board.turn == chess.WHITE else "b" - game.state[f"{wb}time"] = max(0, game.state[f"{wb}time"] - overhead) + game.state[f"{wb}time"] = max(0., game.state[f"{wb}time"] - overhead / datetime.timedelta(milliseconds=1)) logger.info("Searching for wtime {wtime} btime {btime}".format_map(game.state) + f" for game {game.id}") return chess.engine.Limit(white_clock=game.state["wtime"] / 1000, black_clock=game.state["btime"] / 1000, @@ -719,8 +722,8 @@ def get_chessdb_move(li: lichess.Lichess, board: chess.Board, game: model.Game, """Get a move from chessdb.cn's opening book.""" wb = "w" if board.turn == chess.WHITE else "b" use_chessdb = chessdb_cfg.enabled - time_left = game.state[f"{wb}time"] - min_time = chessdb_cfg.min_time * 1000 + time_left = datetime.timedelta(milliseconds=game.state[f"{wb}time"]) + min_time = datetime.timedelta(seconds=chessdb_cfg.min_time) if not use_chessdb or time_left < min_time or board.uci_variant != "chess": return None, None @@ -759,8 +762,8 @@ def get_lichess_cloud_move(li: lichess.Lichess, board: chess.Board, game: model. lichess_cloud_cfg: config.Configuration) -> tuple[Optional[str], Optional[chess.engine.InfoDict]]: """Get a move from the lichess's cloud analysis.""" wb = "w" if board.turn == chess.WHITE else "b" - time_left = game.state[f"{wb}time"] - min_time = lichess_cloud_cfg.min_time * 1000 + time_left = datetime.timedelta(milliseconds=game.state[f"{wb}time"]) + min_time = datetime.timedelta(seconds=lichess_cloud_cfg.min_time) use_lichess_cloud = lichess_cloud_cfg.enabled if not use_lichess_cloud or time_left < min_time: return None, None @@ -812,8 +815,8 @@ def get_opening_explorer_move(li: lichess.Lichess, board: chess.Board, game: mod opening_explorer_cfg: config.Configuration) -> Optional[str]: """Get a move from lichess's opening explorer.""" wb = "w" if board.turn == chess.WHITE else "b" - time_left = game.state[f"{wb}time"] - min_time = opening_explorer_cfg.min_time * 1000 + time_left = datetime.timedelta(milliseconds=game.state[f"{wb}time"]) + min_time = datetime.timedelta(seconds=opening_explorer_cfg.min_time) source = opening_explorer_cfg.source if not opening_explorer_cfg.enabled or time_left < min_time or source == "master" and board.uci_variant != "chess": return None @@ -864,9 +867,9 @@ def get_online_egtb_move(li: lichess.Lichess, board: chess.Board, game: model.Ga wb = "w" if board.turn == chess.WHITE else "b" pieces = chess.popcount(board.occupied) source = online_egtb_cfg.source - minimum_time = online_egtb_cfg.min_time * 1000 + minimum_time = datetime.timedelta(seconds=online_egtb_cfg.min_time) if (not use_online_egtb - or game.state[f"{wb}time"] < minimum_time + or datetime.timedelta(milliseconds=game.state[f"{wb}time"]) < minimum_time or board.uci_variant not in ["chess", "antichess", "atomic"] and source == "lichess" or board.uci_variant != "chess" diff --git a/lichess-bot.py b/lichess-bot.py index 2cec558ce..1ad9861ef 100644 --- a/lichess-bot.py +++ b/lichess-bot.py @@ -13,6 +13,7 @@ import matchmaking import signal import time +import datetime import backoff import os import io @@ -104,14 +105,14 @@ def watch_control_stream(control_queue: CONTROL_QUEUE_TYPE, li: lichess.Lichess) control_queue.put_nowait({"type": "terminated", "error": error}) -def do_correspondence_ping(control_queue: CONTROL_QUEUE_TYPE, period: int) -> None: +def do_correspondence_ping(control_queue: CONTROL_QUEUE_TYPE, period: datetime.timedelta) -> None: """ Tell the engine to check the correspondence games. :param period: How many seconds to wait before sending a correspondence ping. """ while not terminated: - time.sleep(period) + time.sleep(period.total_seconds()) control_queue.put_nowait({"type": "correspondence_ping"}) @@ -222,7 +223,7 @@ def start(li: lichess.Lichess, user_profile: USER_PROFILE_TYPE, config: Configur control_stream.start() correspondence_pinger = multiprocessing.Process(target=do_correspondence_ping, args=(control_queue, - config.correspondence.checkin_period)) + datetime.timedelta(seconds=config.correspondence.checkin_period))) correspondence_pinger.start() correspondence_queue: CORRESPONDENCE_QUEUE_TYPE = manager.Queue() @@ -299,7 +300,7 @@ def lichess_bot_main(li: lichess.Lichess, if game["gameId"] not in startup_correspondence_games) low_time_games: list[EVENT_GETATTR_GAME_TYPE] = [] - last_check_online_time = Timer(60 * 60) # one hour interval + last_check_online_time = Timer(datetime.timedelta(hours=1)) matchmaker = matchmaking.Matchmaking(li, config, user_profile) matchmaker.show_earliest_challenge_time() @@ -581,14 +582,14 @@ def play_game(li: lichess.Lichess, is_correspondence = game.speed == "correspondence" correspondence_cfg = config.correspondence - correspondence_move_time = correspondence_cfg.move_time * 1000 + correspondence_move_time = datetime.timedelta(seconds=correspondence_cfg.move_time) correspondence_disconnect_time = correspondence_cfg.disconnect_time engine_cfg = config.engine ponder_cfg = correspondence_cfg if is_correspondence else engine_cfg can_ponder = ponder_cfg.uci_ponder or ponder_cfg.ponder - move_overhead = config.move_overhead - delay_seconds = config.rate_limiting_delay / 1000 + move_overhead = datetime.timedelta(milliseconds=config.move_overhead) + delay_seconds = datetime.timedelta(milliseconds=config.rate_limiting_delay) keyword_map: defaultdict[str, str] = defaultdict(str, me=game.me.name, opponent=game.opponent.name) hello = get_greeting("hello", config.greeting, keyword_map) @@ -613,7 +614,7 @@ def play_game(li: lichess.Lichess, if not is_game_over(game) and is_engine_move(game, prior_game, board): disconnect_time = correspondence_disconnect_time say_hello(conversation, hello, hello_spectators, board) - start_time = time.perf_counter_ns() + start_time = datetime.datetime.now() fake_thinking(config, board, game) print_move_number(board) move_attempted = True @@ -626,7 +627,7 @@ def play_game(li: lichess.Lichess, is_correspondence, correspondence_move_time, engine_cfg) - time.sleep(delay_seconds) + time.sleep(delay_seconds.total_seconds()) elif is_game_over(game): tell_user_game_result(game, board) engine.send_game_result(game, board) @@ -634,7 +635,8 @@ def play_game(li: lichess.Lichess, conversation.send_message("spectator", goodbye_spectators) wb = "w" if board.turn == chess.WHITE else "b" - terminate_time = (upd[f"{wb}time"] + upd[f"{wb}inc"]) / 1000 + 60 + terminate_time = int((datetime.timedelta(milliseconds=upd[f"{wb}time"] + upd[f"{wb}inc"]) + + datetime.timedelta(seconds=60)).total_seconds()) game.ping(abort_time, terminate_time, disconnect_time) prior_game = copy.deepcopy(game) elif u_type == "ping" and should_exit_game(board, game, prior_game, li, is_correspondence): diff --git a/lichess.py b/lichess.py index ea8a94154..be20121e0 100644 --- a/lichess.py +++ b/lichess.py @@ -8,6 +8,7 @@ import logging import traceback from collections import defaultdict +import datetime from timer import Timer from typing import Optional, Union, Any import chess.engine @@ -131,7 +132,7 @@ def api_get(self, endpoint_name: str, *template_args: str, response = self.session.get(url, params=params, timeout=timeout, stream=stream) if is_new_rate_limit(response): - delay = 1 if endpoint_name == "move" else 60 + delay = datetime.timedelta(seconds=1 if endpoint_name == "move" else 60) self.set_rate_limit_delay(path_template, delay) response.raise_for_status() @@ -213,7 +214,7 @@ def api_post(self, response = self.session.post(url, data=data, headers=headers, params=params, json=payload, timeout=2) if is_new_rate_limit(response): - self.set_rate_limit_delay(path_template, 60) + self.set_rate_limit_delay(path_template, datetime.timedelta(seconds=60)) if raise_for_status: response.raise_for_status() @@ -231,10 +232,10 @@ def get_path_template(self, endpoint_name: str) -> str: path_template = ENDPOINTS[endpoint_name] if self.is_rate_limited(path_template): raise RateLimited(f"{path_template} is rate-limited. " - f"Will retry in {int(self.rate_limit_time_left(path_template))} seconds.") + f"Will retry in {self.rate_limit_time_left(path_template).total_seconds()} seconds.") return path_template - def set_rate_limit_delay(self, path_template: str, delay_time: int) -> None: + def set_rate_limit_delay(self, path_template: str, delay_time: datetime.timedelta) -> None: """ Set a delay to a path template if it was rate limited. @@ -248,7 +249,7 @@ def is_rate_limited(self, path_template: str) -> bool: """Check if a path template is rate limited.""" return not self.rate_limit_timers[path_template].is_expired() - def rate_limit_time_left(self, path_template: str) -> float: + def rate_limit_time_left(self, path_template: str) -> datetime.timedelta: """How much time is left until we can use the path template normally.""" return self.rate_limit_timers[path_template].time_until_expiration() diff --git a/matchmaking.py b/matchmaking.py index e956bca5a..17ba9b6e1 100644 --- a/matchmaking.py +++ b/matchmaking.py @@ -18,7 +18,6 @@ daily_challenges_file_name = "daily_challenge_times.txt" timestamp_format = "%Y-%m-%d %H:%M:%S\n" -one_day_seconds = datetime.timedelta(days=1).total_seconds() def read_daily_challenges() -> DAILY_TIMERS_TYPE: @@ -27,7 +26,7 @@ def read_daily_challenges() -> DAILY_TIMERS_TYPE: try: with open(daily_challenges_file_name) as file: for line in file: - timers.append(Timer(one_day_seconds, datetime.datetime.strptime(line, timestamp_format))) + timers.append(Timer(datetime.timedelta(days=1), datetime.datetime.strptime(line, timestamp_format))) except FileNotFoundError: pass @@ -50,10 +49,10 @@ def __init__(self, li: lichess.Lichess, config: Configuration, user_profile: USE self.variants = list(filter(lambda variant: variant != "fromPosition", config.challenge.variants)) self.matchmaking_cfg = config.matchmaking self.user_profile = user_profile - self.last_challenge_created_delay = Timer(25) # The challenge expires 20 seconds after creating it. - self.last_game_ended_delay = Timer(self.matchmaking_cfg.challenge_timeout * 60) - self.last_user_profile_update_time = Timer(5 * 60) # 5 minutes. - self.min_wait_time = 60 # Wait 60 seconds before creating a new challenge to avoid hitting the api rate limits. + self.last_challenge_created_delay = Timer(datetime.timedelta(seconds=25)) # Challenges expire after 20 seconds. + self.last_game_ended_delay = Timer(datetime.timedelta(minutes=self.matchmaking_cfg.challenge_timeout)) + self.last_user_profile_update_time = Timer(datetime.timedelta(minutes=5)) + self.min_wait_time = datetime.timedelta(seconds=60) # Wait before new challenge to avoid api rate limits. self.challenge_id: str = "" self.daily_challenges: DAILY_TIMERS_TYPE = read_daily_challenges() @@ -124,8 +123,8 @@ def update_daily_challenge_record(self) -> None: etc. """ self.daily_challenges = [timer for timer in self.daily_challenges if not timer.is_expired()] - self.daily_challenges.append(Timer(one_day_seconds)) - self.min_wait_time = 60 * ((len(self.daily_challenges) // 50) + 1) + self.daily_challenges.append(Timer(datetime.timedelta(days=1))) + self.min_wait_time = datetime.timedelta(seconds=60) * ((len(self.daily_challenges) // 50) + 1) write_daily_challenges(self.daily_challenges) def perf(self) -> dict[str, dict[str, Any]]: @@ -244,7 +243,7 @@ def show_earliest_challenge_time(self) -> None: postgame_timeout = self.last_game_ended_delay.time_until_expiration() time_to_next_challenge = self.min_wait_time - self.last_challenge_created_delay.time_since_reset() time_left = max(postgame_timeout, time_to_next_challenge) - earliest_challenge_time = datetime.datetime.now() + datetime.timedelta(seconds=time_left) + earliest_challenge_time = datetime.datetime.now() + time_left challenges = "challenge" + ("" if len(self.daily_challenges) == 1 else "s") logger.info(f"Next challenge will be created after {earliest_challenge_time.strftime('%X')} " f"({len(self.daily_challenges)} {challenges} in last 24 hours)") diff --git a/model.py b/model.py index d425a567c..1429aa47d 100644 --- a/model.py +++ b/model.py @@ -150,7 +150,7 @@ def __init__(self, game_info: dict[str, Any], username: str, base_url: str, abor self.id: str = game_info["id"] self.speed = game_info.get("speed") clock = game_info.get("clock") or {} - ten_years_in_ms = 1000 * 3600 * 24 * 365 * 10 + ten_years_in_ms = 10 * datetime.timedelta(days=365) / datetime.timedelta(milliseconds=1) self.clock_initial = clock.get("initial", ten_years_in_ms) self.clock_increment = clock.get("increment", 0) self.perf_name = (game_info.get("perf") or {}).get("name", "{perf?}") @@ -223,9 +223,9 @@ def should_disconnect_now(self) -> bool: def my_remaining_seconds(self) -> float: """How many seconds we have left.""" - wtime: int = self.state["wtime"] - btime: int = self.state["btime"] - return (wtime if self.is_white else btime) / 1000 + wtime = datetime.timedelta(milliseconds=self.state["wtime"]) + btime = datetime.timedelta(milliseconds=self.state["btime"]) + return (wtime if self.is_white else btime).total_seconds() def result(self) -> str: """Get the result of the game.""" diff --git a/test_bot/lichess.py b/test_bot/lichess.py index 22c1fc915..afd762bdd 100644 --- a/test_bot/lichess.py +++ b/test_bot/lichess.py @@ -68,7 +68,7 @@ def iter_lines(self) -> Generator[bytes, None, None]: board = chess.Board() for move in moves.split(): board.push_uci(move) - wtime, btime = state[1].split(",") + wtime, btime = [float(n) for n in state[1].split(",")] if len(moves) <= len(self.moves_sent) and not event: time.sleep(0.001) continue @@ -76,12 +76,11 @@ def iter_lines(self) -> Generator[bytes, None, None]: break except (IndexError, ValueError): pass - wtime_int, wtime_int = float(wtime), float(btime) time.sleep(0.1) new_game_state = {"type": "gameState", "moves": moves, - "wtime": int(wtime_int * 1000), - "btime": int(wtime_int * 1000), + "wtime": int(wtime * 1000), + "btime": int(wtime * 1000), "winc": 100, "binc": 100} if event == "end": diff --git a/test_bot/test_bot.py b/test_bot/test_bot.py index 5c633aa3f..247feb98a 100644 --- a/test_bot/test_bot.py +++ b/test_bot/test_bot.py @@ -3,6 +3,7 @@ import zipfile import requests import time +import datetime import yaml import chess import chess.engine @@ -85,15 +86,15 @@ def thread_for_test() -> None: open("./logs/states.txt", "w").close() open("./logs/result.txt", "w").close() - start_time = 10. - increment = 0.1 + start_time = datetime.timedelta(seconds=10) + increment = datetime.timedelta(seconds=0.1) board = chess.Board() wtime = start_time btime = start_time with open("./logs/states.txt", "w") as file: - file.write(f"\n{wtime},{btime}") + file.write(f"\n{wtime.total_seconds()},{btime.total_seconds()}") engine = chess.engine.SimpleEngine.popen_uci(stockfish_path) engine.configure({"Skill Level": 0, "Move Overhead": 1000, "Use NNUE": False}) @@ -105,13 +106,13 @@ def thread_for_test() -> None: chess.engine.Limit(time=1), ponder=False) else: - start_time = time.perf_counter_ns() + move_start_time = datetime.datetime.now() move = engine.play(board, - chess.engine.Limit(white_clock=wtime - 2, - white_inc=increment), + chess.engine.Limit(white_clock=wtime.total_seconds() - 2, + white_inc=increment.total_seconds()), ponder=False) - end_time = time.perf_counter_ns() - wtime -= (end_time - start_time) / 1e9 + move_end_time = datetime.datetime.now() + wtime -= (move_end_time - move_start_time) wtime += increment engine_move = move.move if engine_move is None: @@ -128,7 +129,7 @@ def thread_for_test() -> None: file.write(state_str) else: # lichess-bot move. - start_time = time.perf_counter_ns() + move_start_time = datetime.datetime.now() state2 = state_str moves_are_correct = False while state2 == state_str or not moves_are_correct: @@ -145,9 +146,9 @@ def thread_for_test() -> None: moves_are_correct = False with open("./logs/states.txt") as states: state2 = states.read() - end_time = time.perf_counter_ns() + move_end_time = datetime.datetime.now() if len(board.move_stack) > 1: - btime -= (end_time - start_time) / 1e9 + btime -= (move_end_time - move_start_time) btime += increment move_str = state2.split("\n")[0].split(" ")[-1] board.push_uci(move_str) @@ -156,7 +157,7 @@ def thread_for_test() -> None: with open("./logs/states.txt") as states: state_str = states.read() state = state_str.split("\n") - state[1] = f"{wtime},{btime}" + state[1] = f"{wtime.total_seconds()},{btime.total_seconds()}" state_str = "\n".join(state) with open("./logs/states.txt", "w") as file: file.write(state_str) diff --git a/timer.py b/timer.py index c9f6bcf72..1cbc042a3 100644 --- a/timer.py +++ b/timer.py @@ -1,24 +1,24 @@ """A timer for use in lichess-bot.""" -import time import datetime -from typing import Optional +from typing import Optional, Union class Timer: """A timer for use in lichess-bot.""" - def __init__(self, duration: float = 0, backdated_start: Optional[datetime.datetime] = None) -> None: + def __init__(self, duration: Union[datetime.timedelta, float] = 0, + backdated_start: Optional[datetime.datetime] = None) -> None: """ Start the timer. - :param duration: The duration of the timer. + :param duration: The duration of the timer. If duration is a float, then the unit is seconds. :param backdated_start: When the timer started. Used to keep the timers between sessions. """ - self.duration = duration + self.duration = duration if isinstance(duration, datetime.timedelta) else datetime.timedelta(seconds=duration) self.reset() if backdated_start: time_already_used = datetime.datetime.now() - backdated_start - self.starting_time -= time_already_used.total_seconds() + self.starting_time -= time_already_used def is_expired(self) -> bool: """Check if a timer is expired.""" @@ -26,16 +26,16 @@ def is_expired(self) -> bool: def reset(self) -> None: """Reset the timer.""" - self.starting_time = time.time() + self.starting_time = datetime.datetime.now() - def time_since_reset(self) -> float: + def time_since_reset(self) -> datetime.timedelta: """How much time has passed.""" - return time.time() - self.starting_time + return datetime.datetime.now() - self.starting_time - def time_until_expiration(self) -> float: + def time_until_expiration(self) -> datetime.timedelta: """How much time is left until it expires.""" - return max(0., self.duration - self.time_since_reset()) + return max(datetime.timedelta(), self.duration - self.time_since_reset()) def starting_timestamp(self) -> datetime.datetime: """When the timer started.""" - return datetime.datetime.now() - datetime.timedelta(seconds=self.time_since_reset()) + return datetime.datetime.now() - self.time_since_reset() From b26bd29bead4af161f61e741bfa1a1cdec1f1164 Mon Sep 17 00:00:00 2001 From: Mark Harrison Date: Sun, 6 Aug 2023 15:58:14 -0700 Subject: [PATCH 02/20] Replace datetime.datetime.now() with time.time() datetime.now() can give inaccurate time intervals due to clocks changing (daylight saving time, leap seconds, NTP synchronization, etc.). time.time() is not affected by these. --- matchmaking.py | 10 ++++++---- timer.py | 17 +++++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/matchmaking.py b/matchmaking.py index 17ba9b6e1..a454ff887 100644 --- a/matchmaking.py +++ b/matchmaking.py @@ -17,8 +17,6 @@ logger = logging.getLogger(__name__) daily_challenges_file_name = "daily_challenge_times.txt" -timestamp_format = "%Y-%m-%d %H:%M:%S\n" - def read_daily_challenges() -> DAILY_TIMERS_TYPE: """Read the challenges we have created in the past 24 hours from a text file.""" @@ -26,7 +24,11 @@ def read_daily_challenges() -> DAILY_TIMERS_TYPE: try: with open(daily_challenges_file_name) as file: for line in file: - timers.append(Timer(datetime.timedelta(days=1), datetime.datetime.strptime(line, timestamp_format))) + try: + timestamp = float(line) + except ValueError: + timestamp = None + timers.append(Timer(datetime.timedelta(days=1), timestamp)) except FileNotFoundError: pass @@ -37,7 +39,7 @@ def write_daily_challenges(daily_challenges: DAILY_TIMERS_TYPE) -> None: """Write the challenges we have created in the past 24 hours to a text file.""" with open(daily_challenges_file_name, "w") as file: for timer in daily_challenges: - file.write(timer.starting_timestamp().strftime(timestamp_format)) + file.write(f"{timer.starting_timestamp()}\n") class Matchmaking: diff --git a/timer.py b/timer.py index 1cbc042a3..b5ee5f5d3 100644 --- a/timer.py +++ b/timer.py @@ -1,4 +1,5 @@ """A timer for use in lichess-bot.""" +import time import datetime from typing import Optional, Union @@ -7,17 +8,17 @@ class Timer: """A timer for use in lichess-bot.""" def __init__(self, duration: Union[datetime.timedelta, float] = 0, - backdated_start: Optional[datetime.datetime] = None) -> None: + backdated_timestamp: Optional[float] = None) -> None: """ Start the timer. :param duration: The duration of the timer. If duration is a float, then the unit is seconds. - :param backdated_start: When the timer started. Used to keep the timers between sessions. + :param backdated_timestamp: When the timer started. Used to keep the timers between sessions. """ self.duration = duration if isinstance(duration, datetime.timedelta) else datetime.timedelta(seconds=duration) self.reset() - if backdated_start: - time_already_used = datetime.datetime.now() - backdated_start + if backdated_timestamp is not None: + time_already_used = time.time() - backdated_timestamp self.starting_time -= time_already_used def is_expired(self) -> bool: @@ -26,16 +27,16 @@ def is_expired(self) -> bool: def reset(self) -> None: """Reset the timer.""" - self.starting_time = datetime.datetime.now() + self.starting_time = time.time() def time_since_reset(self) -> datetime.timedelta: """How much time has passed.""" - return datetime.datetime.now() - self.starting_time + return datetime.timedelta(seconds=time.time() - self.starting_time) def time_until_expiration(self) -> datetime.timedelta: """How much time is left until it expires.""" return max(datetime.timedelta(), self.duration - self.time_since_reset()) - def starting_timestamp(self) -> datetime.datetime: + def starting_timestamp(self) -> float: """When the timer started.""" - return datetime.datetime.now() - self.time_since_reset() + return time.time() - self.time_since_reset().total_seconds() From d67c80209c0874b7c6fee3017b21b9ceaa549c4d Mon Sep 17 00:00:00 2001 From: Mark Harrison Date: Sun, 6 Aug 2023 20:22:45 -0700 Subject: [PATCH 03/20] Create seconds() and msec() functions Provide a more compact way of creating datetime.timedeltas. All variables that hold time duration values are now timedeltas. The only exception is the Matchmaking method choose_opponent(). Here, time values are chosen from a configuration file and passed around unchanged. The conversion from ints to timedeltas and back to ints seems redundant. --- conversation.py | 3 ++- engine_wrapper.py | 48 ++++++++++++++++++++++++----------------------- lichess-bot.py | 21 ++++++++++----------- lichess.py | 6 +++--- matchmaking.py | 17 +++++++++-------- model.py | 25 ++++++++++++------------ timer.py | 35 +++++++++++++++++++++++++++++----- 7 files changed, 92 insertions(+), 63 deletions(-) diff --git a/conversation.py b/conversation.py index 6f843a0e5..bc1f352a2 100644 --- a/conversation.py +++ b/conversation.py @@ -5,6 +5,7 @@ from engine_wrapper import EngineWrapper from lichess import Lichess from collections.abc import Sequence +from timer import seconds MULTIPROCESSING_LIST_TYPE = Sequence[model.Challenge] logger = logging.getLogger(__name__) @@ -53,7 +54,7 @@ def command(self, line: ChatLine, cmd: str) -> None: if cmd == "commands" or cmd == "help": self.send_reply(line, "Supported commands: !wait (wait a minute for my first move), !name, !howto, !eval, !queue") elif cmd == "wait" and self.game.is_abortable(): - self.game.ping(60, 120, 120) + self.game.ping(seconds(60), seconds(120), seconds(120)) self.send_reply(line, "Waiting 60 seconds...") elif cmd == "name": name = self.game.me.name diff --git a/engine_wrapper.py b/engine_wrapper.py index 0313ddb20..ba0bfe47d 100644 --- a/engine_wrapper.py +++ b/engine_wrapper.py @@ -17,6 +17,7 @@ import model import lichess from config import Configuration +from timer import msec, seconds from typing import Any, Optional, Union OPTIONS_TYPE = dict[str, Any] MOVE_INFO_TYPE = dict[str, Any] @@ -165,11 +166,11 @@ def play_move(self, def add_go_commands(self, time_limit: chess.engine.Limit) -> chess.engine.Limit: """Add extra commands to send to the engine. For example, to search for 1000 nodes or up to depth 10.""" - movetime = self.go_commands.movetime - if movetime is not None: - movetime_sec = datetime.timedelta(seconds=movetime) / datetime.timedelta(milliseconds=1) - if time_limit.time is None or time_limit.time > movetime_sec: - time_limit.time = movetime_sec + movetime_cfg = self.go_commands.movetime + if movetime_cfg is not None: + movetime = msec(movetime_cfg) + if time_limit.time is None or seconds(time_limit.time) > movetime: + time_limit.time = movetime.total_seconds() time_limit.depth = self.go_commands.depth time_limit.nodes = self.go_commands.nodes return time_limit @@ -576,7 +577,7 @@ def single_move_time(board: chess.Board, game: model.Game, search_time: datetime pre_move_time = datetime.datetime.now() - start_time overhead = pre_move_time + move_overhead wb = "w" if board.turn == chess.WHITE else "b" - clock_time = max(datetime.timedelta(), datetime.timedelta(milliseconds=game.state[f"{wb}time"]) - overhead) + clock_time = max(msec(0), msec(game.state[f"{wb}time"]) - overhead) search_time = min(search_time, clock_time) logger.info(f"Searching for time {search_time.total_seconds()} seconds for game {game.id}") return chess.engine.Limit(time=search_time.total_seconds(), clock_id="correspondence") @@ -589,10 +590,10 @@ def first_move_time(game: model.Game) -> chess.engine.Limit: :param game: The game that the bot is playing. :return: The time to choose the first move. """ - # Need to hardcode first movetime (10 s) since Lichess has 30 sec limit. - search_time = 10 + # Need to hardcode first movetime since Lichess has 30 sec limit. + search_time = seconds(10) logger.info(f"Searching for time {search_time} seconds for game {game.id}") - return chess.engine.Limit(time=search_time, clock_id="first move") + return chess.engine.Limit(time=search_time.total_seconds(), clock_id="first move") def game_clock_time(board: chess.Board, @@ -610,13 +611,14 @@ def game_clock_time(board: chess.Board, """ pre_move_time = datetime.datetime.now() - start_time overhead = pre_move_time + move_overhead + times = {side: msec(game.state[side]) for side in ["wtime", "btime"]} wb = "w" if board.turn == chess.WHITE else "b" - game.state[f"{wb}time"] = max(0., game.state[f"{wb}time"] - overhead / datetime.timedelta(milliseconds=1)) - logger.info("Searching for wtime {wtime} btime {btime}".format_map(game.state) + f" for game {game.id}") - return chess.engine.Limit(white_clock=game.state["wtime"] / 1000, - black_clock=game.state["btime"] / 1000, - white_inc=game.state["winc"] / 1000, - black_inc=game.state["binc"] / 1000, + times[f"{wb}time"] = max(msec(0), times[f"{wb}time"] - overhead) + logger.info("Searching for wtime {wtime} btime {btime}".format_map(times) + f" for game {game.id}") + return chess.engine.Limit(white_clock=times["wtime"].total_seconds(), + black_clock=times["btime"].total_seconds(), + white_inc=msec(game.state["winc"]).total_seconds(), + black_inc=msec(game.state["binc"]).total_seconds(), clock_id="real time") @@ -722,8 +724,8 @@ def get_chessdb_move(li: lichess.Lichess, board: chess.Board, game: model.Game, """Get a move from chessdb.cn's opening book.""" wb = "w" if board.turn == chess.WHITE else "b" use_chessdb = chessdb_cfg.enabled - time_left = datetime.timedelta(milliseconds=game.state[f"{wb}time"]) - min_time = datetime.timedelta(seconds=chessdb_cfg.min_time) + time_left = msec(game.state[f"{wb}time"]) + min_time = seconds(chessdb_cfg.min_time) if not use_chessdb or time_left < min_time or board.uci_variant != "chess": return None, None @@ -762,8 +764,8 @@ def get_lichess_cloud_move(li: lichess.Lichess, board: chess.Board, game: model. lichess_cloud_cfg: config.Configuration) -> tuple[Optional[str], Optional[chess.engine.InfoDict]]: """Get a move from the lichess's cloud analysis.""" wb = "w" if board.turn == chess.WHITE else "b" - time_left = datetime.timedelta(milliseconds=game.state[f"{wb}time"]) - min_time = datetime.timedelta(seconds=lichess_cloud_cfg.min_time) + time_left = msec(game.state[f"{wb}time"]) + min_time = seconds(lichess_cloud_cfg.min_time) use_lichess_cloud = lichess_cloud_cfg.enabled if not use_lichess_cloud or time_left < min_time: return None, None @@ -815,8 +817,8 @@ def get_opening_explorer_move(li: lichess.Lichess, board: chess.Board, game: mod opening_explorer_cfg: config.Configuration) -> Optional[str]: """Get a move from lichess's opening explorer.""" wb = "w" if board.turn == chess.WHITE else "b" - time_left = datetime.timedelta(milliseconds=game.state[f"{wb}time"]) - min_time = datetime.timedelta(seconds=opening_explorer_cfg.min_time) + time_left = msec(game.state[f"{wb}time"]) + min_time = seconds(opening_explorer_cfg.min_time) source = opening_explorer_cfg.source if not opening_explorer_cfg.enabled or time_left < min_time or source == "master" and board.uci_variant != "chess": return None @@ -867,9 +869,9 @@ def get_online_egtb_move(li: lichess.Lichess, board: chess.Board, game: model.Ga wb = "w" if board.turn == chess.WHITE else "b" pieces = chess.popcount(board.occupied) source = online_egtb_cfg.source - minimum_time = datetime.timedelta(seconds=online_egtb_cfg.min_time) + minimum_time = seconds(online_egtb_cfg.min_time) if (not use_online_egtb - or datetime.timedelta(milliseconds=game.state[f"{wb}time"]) < minimum_time + or msec(game.state[f"{wb}time"]) < minimum_time or board.uci_variant not in ["chess", "antichess", "atomic"] and source == "lichess" or board.uci_variant != "chess" diff --git a/lichess-bot.py b/lichess-bot.py index 1ad9861ef..d2ae0adcb 100644 --- a/lichess-bot.py +++ b/lichess-bot.py @@ -23,7 +23,7 @@ import yaml from config import load_config, Configuration from conversation import Conversation, ChatLine -from timer import Timer +from timer import Timer, seconds, msec, hours from requests.exceptions import ChunkedEncodingError, ConnectionError, HTTPError, ReadTimeout from asyncio.exceptions import TimeoutError as MoveTimeout from rich.logging import RichHandler @@ -223,7 +223,7 @@ def start(li: lichess.Lichess, user_profile: USER_PROFILE_TYPE, config: Configur control_stream.start() correspondence_pinger = multiprocessing.Process(target=do_correspondence_ping, args=(control_queue, - datetime.timedelta(seconds=config.correspondence.checkin_period))) + seconds(config.correspondence.checkin_period))) correspondence_pinger.start() correspondence_queue: CORRESPONDENCE_QUEUE_TYPE = manager.Queue() @@ -300,7 +300,7 @@ def lichess_bot_main(li: lichess.Lichess, if game["gameId"] not in startup_correspondence_games) low_time_games: list[EVENT_GETATTR_GAME_TYPE] = [] - last_check_online_time = Timer(datetime.timedelta(hours=1)) + last_check_online_time = Timer(hours(1)) matchmaker = matchmaking.Matchmaking(li, config, user_profile) matchmaker.show_earliest_challenge_time() @@ -570,7 +570,7 @@ def play_game(li: lichess.Lichess, # Initial response of stream will be the full game info. Store it. initial_state = json.loads(next(lines).decode("utf-8")) logger.debug(f"Initial state: {initial_state}") - abort_time = config.abort_time + abort_time = seconds(config.abort_time) game = model.Game(initial_state, user_profile["username"], li.baseUrl, abort_time) with engine_wrapper.create_engine(config) as engine: @@ -582,14 +582,14 @@ def play_game(li: lichess.Lichess, is_correspondence = game.speed == "correspondence" correspondence_cfg = config.correspondence - correspondence_move_time = datetime.timedelta(seconds=correspondence_cfg.move_time) - correspondence_disconnect_time = correspondence_cfg.disconnect_time + correspondence_move_time = seconds(correspondence_cfg.move_time) + correspondence_disconnect_time = seconds(correspondence_cfg.disconnect_time) engine_cfg = config.engine ponder_cfg = correspondence_cfg if is_correspondence else engine_cfg can_ponder = ponder_cfg.uci_ponder or ponder_cfg.ponder - move_overhead = datetime.timedelta(milliseconds=config.move_overhead) - delay_seconds = datetime.timedelta(milliseconds=config.rate_limiting_delay) + move_overhead = msec(config.move_overhead) + delay_seconds = msec(config.rate_limiting_delay) keyword_map: defaultdict[str, str] = defaultdict(str, me=game.me.name, opponent=game.opponent.name) hello = get_greeting("hello", config.greeting, keyword_map) @@ -597,7 +597,7 @@ def play_game(li: lichess.Lichess, hello_spectators = get_greeting("hello_spectators", config.greeting, keyword_map) goodbye_spectators = get_greeting("goodbye_spectators", config.greeting, keyword_map) - disconnect_time = correspondence_disconnect_time if not game.state.get("moves") else 0 + disconnect_time = correspondence_disconnect_time if not game.state.get("moves") else seconds(0) prior_game = None board = chess.Board() upd: dict[str, Any] = game.state @@ -635,8 +635,7 @@ def play_game(li: lichess.Lichess, conversation.send_message("spectator", goodbye_spectators) wb = "w" if board.turn == chess.WHITE else "b" - terminate_time = int((datetime.timedelta(milliseconds=upd[f"{wb}time"] + upd[f"{wb}inc"]) - + datetime.timedelta(seconds=60)).total_seconds()) + terminate_time = msec(upd[f"{wb}time"]) + msec(upd[f"{wb}inc"]) + seconds(60) game.ping(abort_time, terminate_time, disconnect_time) prior_game = copy.deepcopy(game) elif u_type == "ping" and should_exit_game(board, game, prior_game, li, is_correspondence): diff --git a/lichess.py b/lichess.py index be20121e0..3a7888c60 100644 --- a/lichess.py +++ b/lichess.py @@ -9,7 +9,7 @@ import traceback from collections import defaultdict import datetime -from timer import Timer +from timer import Timer, seconds from typing import Optional, Union, Any import chess.engine JSON_REPLY_TYPE = dict[str, Any] @@ -132,7 +132,7 @@ def api_get(self, endpoint_name: str, *template_args: str, response = self.session.get(url, params=params, timeout=timeout, stream=stream) if is_new_rate_limit(response): - delay = datetime.timedelta(seconds=1 if endpoint_name == "move" else 60) + delay = seconds(1 if endpoint_name == "move" else 60) self.set_rate_limit_delay(path_template, delay) response.raise_for_status() @@ -214,7 +214,7 @@ def api_post(self, response = self.session.post(url, data=data, headers=headers, params=params, json=payload, timeout=2) if is_new_rate_limit(response): - self.set_rate_limit_delay(path_template, datetime.timedelta(seconds=60)) + self.set_rate_limit_delay(path_template, seconds(60)) if raise_for_status: response.raise_for_status() diff --git a/matchmaking.py b/matchmaking.py index a454ff887..d37a8dd25 100644 --- a/matchmaking.py +++ b/matchmaking.py @@ -2,7 +2,7 @@ import random import logging import model -from timer import Timer +from timer import Timer, seconds, minutes, days from collections import defaultdict from collections.abc import Sequence import lichess @@ -18,6 +18,7 @@ daily_challenges_file_name = "daily_challenge_times.txt" + def read_daily_challenges() -> DAILY_TIMERS_TYPE: """Read the challenges we have created in the past 24 hours from a text file.""" timers: DAILY_TIMERS_TYPE = [] @@ -28,7 +29,7 @@ def read_daily_challenges() -> DAILY_TIMERS_TYPE: timestamp = float(line) except ValueError: timestamp = None - timers.append(Timer(datetime.timedelta(days=1), timestamp)) + timers.append(Timer(days(1), timestamp)) except FileNotFoundError: pass @@ -51,10 +52,10 @@ def __init__(self, li: lichess.Lichess, config: Configuration, user_profile: USE self.variants = list(filter(lambda variant: variant != "fromPosition", config.challenge.variants)) self.matchmaking_cfg = config.matchmaking self.user_profile = user_profile - self.last_challenge_created_delay = Timer(datetime.timedelta(seconds=25)) # Challenges expire after 20 seconds. - self.last_game_ended_delay = Timer(datetime.timedelta(minutes=self.matchmaking_cfg.challenge_timeout)) - self.last_user_profile_update_time = Timer(datetime.timedelta(minutes=5)) - self.min_wait_time = datetime.timedelta(seconds=60) # Wait before new challenge to avoid api rate limits. + self.last_challenge_created_delay = Timer(seconds(25)) # Challenges expire after 20 seconds. + self.last_game_ended_delay = Timer(minutes(self.matchmaking_cfg.challenge_timeout)) + self.last_user_profile_update_time = Timer(minutes(5)) + self.min_wait_time = seconds(60) # Wait before new challenge to avoid api rate limits. self.challenge_id: str = "" self.daily_challenges: DAILY_TIMERS_TYPE = read_daily_challenges() @@ -125,8 +126,8 @@ def update_daily_challenge_record(self) -> None: etc. """ self.daily_challenges = [timer for timer in self.daily_challenges if not timer.is_expired()] - self.daily_challenges.append(Timer(datetime.timedelta(days=1))) - self.min_wait_time = datetime.timedelta(seconds=60) * ((len(self.daily_challenges) // 50) + 1) + self.daily_challenges.append(Timer(days(1))) + self.min_wait_time = seconds(60) * ((len(self.daily_challenges) // 50) + 1) write_daily_challenges(self.daily_challenges) def perf(self) -> dict[str, dict[str, Any]]: diff --git a/model.py b/model.py index 1429aa47d..df2ac2542 100644 --- a/model.py +++ b/model.py @@ -4,7 +4,7 @@ import logging import datetime from enum import Enum -from timer import Timer +from timer import Timer, msec, seconds, days from config import Configuration from typing import Any from collections import defaultdict @@ -144,15 +144,15 @@ class Termination(str, Enum): class Game: """Store information about a game.""" - def __init__(self, game_info: dict[str, Any], username: str, base_url: str, abort_time: int) -> None: + def __init__(self, game_info: dict[str, Any], username: str, base_url: str, abort_time: datetime.timedelta) -> None: """:param abort_time: How long to wait before aborting the game.""" self.username = username self.id: str = game_info["id"] self.speed = game_info.get("speed") clock = game_info.get("clock") or {} - ten_years_in_ms = 10 * datetime.timedelta(days=365) / datetime.timedelta(milliseconds=1) - self.clock_initial = clock.get("initial", ten_years_in_ms) - self.clock_increment = clock.get("increment", 0) + ten_years_in_ms = 10 * days(365) / msec(1) + self.clock_initial = msec(clock.get("initial", ten_years_in_ms)) + self.clock_increment = msec(clock.get("increment", 0)) self.perf_name = (game_info.get("perf") or {}).get("name", "{perf?}") self.variant_name = game_info["variant"]["name"] self.mode = "rated" if game_info.get("rated") else "casual" @@ -166,10 +166,11 @@ def __init__(self, game_info: dict[str, Any], username: str, base_url: str, abor self.me = self.white if self.is_white else self.black self.opponent = self.black if self.is_white else self.white self.base_url = base_url - self.game_start = datetime.datetime.fromtimestamp(game_info["createdAt"] / 1000, tz=datetime.timezone.utc) + self.game_start = datetime.datetime.fromtimestamp(msec(game_info["createdAt"]).total_seconds(), + tz=datetime.timezone.utc) self.abort_time = Timer(abort_time) - self.terminate_time = Timer((self.clock_initial + self.clock_increment) / 1000 + abort_time + 60) - self.disconnect_time = Timer(0) + self.terminate_time = Timer(self.clock_initial + self.clock_increment + abort_time + seconds(60)) + self.disconnect_time = Timer(seconds(0)) def url(self) -> str: """Get the url of the game.""" @@ -188,7 +189,7 @@ def pgn_event(self) -> str: def time_control(self) -> str: """Get the time control of the game.""" - return f"{int(self.clock_initial/1000)}+{int(self.clock_increment/1000)}" + return f"{int(self.clock_initial.total_seconds())}+{int(self.clock_increment.total_seconds())}" def is_abortable(self) -> bool: """Whether the game can be aborted.""" @@ -196,7 +197,7 @@ def is_abortable(self) -> bool: # than two moves (one from each player) have been played. return " " not in self.state["moves"] - def ping(self, abort_in: int, terminate_in: int, disconnect_in: int) -> None: + def ping(self, abort_in: datetime.timedelta, terminate_in: datetime.timedelta, disconnect_in: datetime.timedelta) -> None: """ Tell the bot when to abort, terminate, and disconnect from a game. @@ -223,8 +224,8 @@ def should_disconnect_now(self) -> bool: def my_remaining_seconds(self) -> float: """How many seconds we have left.""" - wtime = datetime.timedelta(milliseconds=self.state["wtime"]) - btime = datetime.timedelta(milliseconds=self.state["btime"]) + wtime = msec(self.state["wtime"]) + btime = msec(self.state["btime"]) return (wtime if self.is_white else btime).total_seconds() def result(self) -> str: diff --git a/timer.py b/timer.py index b5ee5f5d3..f51962c42 100644 --- a/timer.py +++ b/timer.py @@ -1,13 +1,38 @@ """A timer for use in lichess-bot.""" import time import datetime -from typing import Optional, Union +from typing import Optional + + +def msec(time_in_msec: float) -> datetime.timedelta: + """Create a timedelta duration in milliseconds.""" + return datetime.timedelta(milliseconds=time_in_msec) + + +def seconds(time_in_sec: float) -> datetime.timedelta: + """Create a timedelta duration in seconds.""" + return datetime.timedelta(seconds=time_in_sec) + + +def minutes(time_in_minutes: float) -> datetime.timedelta: + """Create a timedelta duration in minutes.""" + return datetime.timedelta(minutes=time_in_minutes) + + +def hours(time_in_hours: float) -> datetime.timedelta: + """Create a timedelta duration in hours.""" + return datetime.timedelta(minutes=time_in_hours) + + +def days(time_in_days: float) -> datetime.timedelta: + """Create a timedelta duration in minutes.""" + return datetime.timedelta(days=time_in_days) class Timer: """A timer for use in lichess-bot.""" - def __init__(self, duration: Union[datetime.timedelta, float] = 0, + def __init__(self, duration: datetime.timedelta = seconds(0), backdated_timestamp: Optional[float] = None) -> None: """ Start the timer. @@ -15,7 +40,7 @@ def __init__(self, duration: Union[datetime.timedelta, float] = 0, :param duration: The duration of the timer. If duration is a float, then the unit is seconds. :param backdated_timestamp: When the timer started. Used to keep the timers between sessions. """ - self.duration = duration if isinstance(duration, datetime.timedelta) else datetime.timedelta(seconds=duration) + self.duration = duration self.reset() if backdated_timestamp is not None: time_already_used = time.time() - backdated_timestamp @@ -31,11 +56,11 @@ def reset(self) -> None: def time_since_reset(self) -> datetime.timedelta: """How much time has passed.""" - return datetime.timedelta(seconds=time.time() - self.starting_time) + return seconds(time.time() - self.starting_time) def time_until_expiration(self) -> datetime.timedelta: """How much time is left until it expires.""" - return max(datetime.timedelta(), self.duration - self.time_since_reset()) + return max(seconds(0), self.duration - self.time_since_reset()) def starting_timestamp(self) -> float: """When the timer started.""" From a3aaecfbd0785c1423eff3e9539286481ace031b Mon Sep 17 00:00:00 2001 From: Mark Harrison Date: Mon, 7 Aug 2023 01:40:10 -0700 Subject: [PATCH 04/20] Create string functions for duration display --- engine_wrapper.py | 8 ++++---- lichess.py | 4 ++-- model.py | 4 ++-- timer.py | 10 ++++++++++ 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/engine_wrapper.py b/engine_wrapper.py index ba0bfe47d..c0347caed 100644 --- a/engine_wrapper.py +++ b/engine_wrapper.py @@ -17,7 +17,7 @@ import model import lichess from config import Configuration -from timer import msec, seconds +from timer import msec, seconds, msec_str, sec_str from typing import Any, Optional, Union OPTIONS_TYPE = dict[str, Any] MOVE_INFO_TYPE = dict[str, Any] @@ -579,7 +579,7 @@ def single_move_time(board: chess.Board, game: model.Game, search_time: datetime wb = "w" if board.turn == chess.WHITE else "b" clock_time = max(msec(0), msec(game.state[f"{wb}time"]) - overhead) search_time = min(search_time, clock_time) - logger.info(f"Searching for time {search_time.total_seconds()} seconds for game {game.id}") + logger.info(f"Searching for time {sec_str(search_time)} seconds for game {game.id}") return chess.engine.Limit(time=search_time.total_seconds(), clock_id="correspondence") @@ -592,7 +592,7 @@ def first_move_time(game: model.Game) -> chess.engine.Limit: """ # Need to hardcode first movetime since Lichess has 30 sec limit. search_time = seconds(10) - logger.info(f"Searching for time {search_time} seconds for game {game.id}") + logger.info(f"Searching for time {sec_str(search_time)} seconds for game {game.id}") return chess.engine.Limit(time=search_time.total_seconds(), clock_id="first move") @@ -614,7 +614,7 @@ def game_clock_time(board: chess.Board, times = {side: msec(game.state[side]) for side in ["wtime", "btime"]} wb = "w" if board.turn == chess.WHITE else "b" times[f"{wb}time"] = max(msec(0), times[f"{wb}time"] - overhead) - logger.info("Searching for wtime {wtime} btime {btime}".format_map(times) + f" for game {game.id}") + logger.info(f"Searching for wtime {msec_str(times['wtime'])} btime {msec_str(times['btime'])} for game {game.id}") return chess.engine.Limit(white_clock=times["wtime"].total_seconds(), black_clock=times["btime"].total_seconds(), white_inc=msec(game.state["winc"]).total_seconds(), diff --git a/lichess.py b/lichess.py index 3a7888c60..74dcde2ba 100644 --- a/lichess.py +++ b/lichess.py @@ -9,7 +9,7 @@ import traceback from collections import defaultdict import datetime -from timer import Timer, seconds +from timer import Timer, seconds, sec_str from typing import Optional, Union, Any import chess.engine JSON_REPLY_TYPE = dict[str, Any] @@ -232,7 +232,7 @@ def get_path_template(self, endpoint_name: str) -> str: path_template = ENDPOINTS[endpoint_name] if self.is_rate_limited(path_template): raise RateLimited(f"{path_template} is rate-limited. " - f"Will retry in {self.rate_limit_time_left(path_template).total_seconds()} seconds.") + f"Will retry in {sec_str(self.rate_limit_time_left(path_template))} seconds.") return path_template def set_rate_limit_delay(self, path_template: str, delay_time: datetime.timedelta) -> None: diff --git a/model.py b/model.py index df2ac2542..8baadd418 100644 --- a/model.py +++ b/model.py @@ -4,7 +4,7 @@ import logging import datetime from enum import Enum -from timer import Timer, msec, seconds, days +from timer import Timer, msec, seconds, days, sec_str from config import Configuration from typing import Any from collections import defaultdict @@ -189,7 +189,7 @@ def pgn_event(self) -> str: def time_control(self) -> str: """Get the time control of the game.""" - return f"{int(self.clock_initial.total_seconds())}+{int(self.clock_increment.total_seconds())}" + return f"{sec_str(self.clock_initial)}+{sec_str(self.clock_increment)}" def is_abortable(self) -> bool: """Whether the game can be aborted.""" diff --git a/timer.py b/timer.py index f51962c42..9e680e712 100644 --- a/timer.py +++ b/timer.py @@ -9,11 +9,21 @@ def msec(time_in_msec: float) -> datetime.timedelta: return datetime.timedelta(milliseconds=time_in_msec) +def msec_str(duration: datetime.timedelta) -> str: + """Return a string with the duration value in whole number milliseconds.""" + return f"{round(duration/msec(1))}" + + def seconds(time_in_sec: float) -> datetime.timedelta: """Create a timedelta duration in seconds.""" return datetime.timedelta(seconds=time_in_sec) +def sec_str(duration: datetime.timedelta) -> str: + """Return a string with the duration value in whole number seconds.""" + return f"{round(duration.total_seconds())}" + + def minutes(time_in_minutes: float) -> datetime.timedelta: """Create a timedelta duration in minutes.""" return datetime.timedelta(minutes=time_in_minutes) From 19948b4c7196f2410bb02d050f99519899580227 Mon Sep 17 00:00:00 2001 From: Mark Harrison Date: Wed, 9 Aug 2023 22:25:58 -0700 Subject: [PATCH 05/20] Use timedelta for fake_think_time() --- engine_wrapper.py | 7 ++++--- lichess-bot.py | 8 ++++---- model.py | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/engine_wrapper.py b/engine_wrapper.py index 97a1c32ce..d30b3616f 100644 --- a/engine_wrapper.py +++ b/engine_wrapper.py @@ -9,6 +9,7 @@ import subprocess import logging import datetime +import time import random from collections import Counter from collections.abc import Generator, Callable @@ -109,7 +110,7 @@ def play_move(self, is_correspondence: bool, correspondence_move_time: datetime.timedelta, engine_cfg: config.Configuration, - min_time: float) -> None: + min_time: datetime.timedelta) -> None: """ Play a move. @@ -160,9 +161,9 @@ def play_move(self, best_move = self.search(board, time_limit, can_ponder, draw_offered, best_move) # Heed min_time - elapsed = (time.perf_counter_ns() - start_time) / 1e9 + elapsed = datetime.datetime.now() - start_time if elapsed < min_time: - time.sleep(min_time - elapsed) + time.sleep((min_time - elapsed).total_seconds()) self.add_comment(best_move, board) self.print_stats() diff --git a/lichess-bot.py b/lichess-bot.py index 9d535d632..179d5718f 100644 --- a/lichess-bot.py +++ b/lichess-bot.py @@ -629,7 +629,7 @@ def play_game(li: lichess.Lichess, correspondence_move_time, engine_cfg, fake_think_time(config, board, game)) - time.sleep(delay_seconds) + time.sleep(delay_seconds.total_seconds()) elif is_game_over(game): tell_user_game_result(game, board) engine.send_game_result(game, board) @@ -673,12 +673,12 @@ def say_hello(conversation: Conversation, hello: str, hello_spectators: str, boa conversation.send_message("spectator", hello_spectators) -def fake_think_time(config: Configuration, board: chess.Board, game: model.Game) -> float: +def fake_think_time(config: Configuration, board: chess.Board, game: model.Game) -> datetime.timedelta: """Calculate how much time we should wait for fake_think_time.""" - sleep = 0.0 + sleep = seconds(0.0) if config.fake_think_time and len(board.move_stack) > 9: - remaining = max(0, game.my_remaining_seconds() - config.move_overhead / 1000) + remaining = max(seconds(0), game.my_remaining_seconds() - msec(config.move_overhead)) delay = remaining * 0.025 accel = 0.99 ** (len(board.move_stack) - 10) sleep = delay * accel diff --git a/model.py b/model.py index 8baadd418..3856c8f4c 100644 --- a/model.py +++ b/model.py @@ -222,11 +222,11 @@ def should_disconnect_now(self) -> bool: """Whether we should disconnect form the game.""" return self.disconnect_time.is_expired() - def my_remaining_seconds(self) -> float: + def my_remaining_seconds(self) -> datetime.timedelta: """How many seconds we have left.""" wtime = msec(self.state["wtime"]) btime = msec(self.state["btime"]) - return (wtime if self.is_white else btime).total_seconds() + return wtime if self.is_white else btime def result(self) -> str: """Get the result of the game.""" From a004371c46e76f920633f64b4348b88f1733c98b Mon Sep 17 00:00:00 2001 From: Mark Harrison Date: Wed, 9 Aug 2023 22:44:11 -0700 Subject: [PATCH 06/20] More descriptive description for Timer class --- timer.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/timer.py b/timer.py index 9e680e712..f8230bb17 100644 --- a/timer.py +++ b/timer.py @@ -40,7 +40,18 @@ def days(time_in_days: float) -> datetime.timedelta: class Timer: - """A timer for use in lichess-bot.""" + """ + A timer for use in lichess-bot. An instance of timer can be used both as a countdown timer and a stopwatch. + + If the duration argument in the __init__() method is greater than zero, then + the method is_expired() indicates when the intial duration has passed. The + method time_until_expiration() gives the amount of time left until the timer + expires. + + Regardless of the initial duration (event if it's zero), a timer can be used + as a stopwatch by calling time_since_reset() to get the amount of time since + the timer was created or since it was last reset. + """ def __init__(self, duration: datetime.timedelta = seconds(0), backdated_timestamp: Optional[float] = None) -> None: From 00bb81f0a75084c2d924be1c125740e6ac8be8e5 Mon Sep 17 00:00:00 2001 From: Mark Harrison Date: Wed, 9 Aug 2023 22:46:33 -0700 Subject: [PATCH 07/20] Use datetime to record challenge timestamps --- matchmaking.py | 9 +++------ timer.py | 4 ++-- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/matchmaking.py b/matchmaking.py index d37a8dd25..87cbef75e 100644 --- a/matchmaking.py +++ b/matchmaking.py @@ -17,6 +17,7 @@ logger = logging.getLogger(__name__) daily_challenges_file_name = "daily_challenge_times.txt" +timestamp_format = "%Y-%m-%d %H:%M:%S\n" def read_daily_challenges() -> DAILY_TIMERS_TYPE: @@ -25,11 +26,7 @@ def read_daily_challenges() -> DAILY_TIMERS_TYPE: try: with open(daily_challenges_file_name) as file: for line in file: - try: - timestamp = float(line) - except ValueError: - timestamp = None - timers.append(Timer(days(1), timestamp)) + timers.append(Timer(days(1), datetime.datetime.strptime(line, timestamp_format))) except FileNotFoundError: pass @@ -40,7 +37,7 @@ def write_daily_challenges(daily_challenges: DAILY_TIMERS_TYPE) -> None: """Write the challenges we have created in the past 24 hours to a text file.""" with open(daily_challenges_file_name, "w") as file: for timer in daily_challenges: - file.write(f"{timer.starting_timestamp()}\n") + file.write(f"{timer.starting_timestamp(timestamp_format)}\n") class Matchmaking: diff --git a/timer.py b/timer.py index f8230bb17..4f198b89a 100644 --- a/timer.py +++ b/timer.py @@ -83,6 +83,6 @@ def time_until_expiration(self) -> datetime.timedelta: """How much time is left until it expires.""" return max(seconds(0), self.duration - self.time_since_reset()) - def starting_timestamp(self) -> float: + def starting_timestamp(self, format: str) -> str: """When the timer started.""" - return time.time() - self.time_since_reset().total_seconds() + return (datetime.datetime.now() - self.time_since_reset()).strftime(format) From a4c2e55fa344042c51f1de6d65e7311bb4f1bcac Mon Sep 17 00:00:00 2001 From: Mark Harrison Date: Wed, 9 Aug 2023 22:47:45 -0700 Subject: [PATCH 08/20] Use time.monotonic() instead of time.time() time.monotonic() only increases and is not affected by clock changes. --- timer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/timer.py b/timer.py index 4f198b89a..6210c8cdf 100644 --- a/timer.py +++ b/timer.py @@ -54,7 +54,7 @@ class Timer: """ def __init__(self, duration: datetime.timedelta = seconds(0), - backdated_timestamp: Optional[float] = None) -> None: + backdated_timestamp: Optional[datetime.datetime] = None) -> None: """ Start the timer. @@ -73,11 +73,11 @@ def is_expired(self) -> bool: def reset(self) -> None: """Reset the timer.""" - self.starting_time = time.time() + self.starting_time = time.monotonic() def time_since_reset(self) -> datetime.timedelta: """How much time has passed.""" - return seconds(time.time() - self.starting_time) + return seconds(time.monotonic() - self.starting_time) def time_until_expiration(self) -> datetime.timedelta: """How much time is left until it expires.""" From 89bb2c055a962aa2c3effe90db2f21d81d60d96f Mon Sep 17 00:00:00 2001 From: Mark Harrison Date: Wed, 9 Aug 2023 22:50:29 -0700 Subject: [PATCH 09/20] Improve parameter documentation --- timer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/timer.py b/timer.py index 6210c8cdf..cdd78ee6f 100644 --- a/timer.py +++ b/timer.py @@ -58,8 +58,8 @@ def __init__(self, duration: datetime.timedelta = seconds(0), """ Start the timer. - :param duration: The duration of the timer. If duration is a float, then the unit is seconds. - :param backdated_timestamp: When the timer started. Used to keep the timers between sessions. + :param duration: The duration of time before Timer.is_expired() returns True. + :param backdated_timestamp: When the timer should have started. Used to keep the timers between sessions. """ self.duration = duration self.reset() From c39950bfed36317f43e5bdf6853e8fbebc7cf298 Mon Sep 17 00:00:00 2001 From: Mark Harrison Date: Wed, 9 Aug 2023 23:23:06 -0700 Subject: [PATCH 10/20] Replace datetime.now() with perf_counter() The result of datetime.datetime.now() is affected by clock changes like DST and may not always give accurate timestamps. Use time.perf_counter() because it is monotonic (never decreases) and has sub-microsecond resolution. --- engine_wrapper.py | 12 ++++++------ lichess-bot.py | 2 +- timer.py | 8 ++++---- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/engine_wrapper.py b/engine_wrapper.py index d30b3616f..7937b18d7 100644 --- a/engine_wrapper.py +++ b/engine_wrapper.py @@ -104,7 +104,7 @@ def play_move(self, board: chess.Board, game: model.Game, li: lichess.Lichess, - start_time: datetime.datetime, + start_time: float, move_overhead: datetime.timedelta, can_ponder: bool, is_correspondence: bool, @@ -161,7 +161,7 @@ def play_move(self, best_move = self.search(board, time_limit, can_ponder, draw_offered, best_move) # Heed min_time - elapsed = datetime.datetime.now() - start_time + elapsed = seconds(time.perf_counter() - start_time) if elapsed < min_time: time.sleep((min_time - elapsed).total_seconds()) @@ -579,7 +579,7 @@ def getHomemadeEngine(name: str) -> type[MinimalEngine]: def single_move_time(board: chess.Board, game: model.Game, search_time: datetime.timedelta, - start_time: datetime.datetime, move_overhead: datetime.timedelta) -> chess.engine.Limit: + start_time: float, move_overhead: datetime.timedelta) -> chess.engine.Limit: """ Calculate time to search in correspondence games. @@ -590,7 +590,7 @@ def single_move_time(board: chess.Board, game: model.Game, search_time: datetime :param move_overhead: The time it takes to communicate between the engine and lichess-bot. :return: The time to choose a move. """ - pre_move_time = datetime.datetime.now() - start_time + pre_move_time = seconds(time.perf_counter() - start_time) overhead = pre_move_time + move_overhead wb = "w" if board.turn == chess.WHITE else "b" clock_time = max(msec(0), msec(game.state[f"{wb}time"]) - overhead) @@ -614,7 +614,7 @@ def first_move_time(game: model.Game) -> chess.engine.Limit: def game_clock_time(board: chess.Board, game: model.Game, - start_time: datetime.datetime, + start_time: float, move_overhead: datetime.timedelta) -> chess.engine.Limit: """ Get the time to play by the engine in realtime games. @@ -625,7 +625,7 @@ def game_clock_time(board: chess.Board, :param move_overhead: The time it takes to communicate between the engine and lichess-bot. :return: The time to play a move. """ - pre_move_time = datetime.datetime.now() - start_time + pre_move_time = seconds(time.perf_counter() - start_time) overhead = pre_move_time + move_overhead times = {side: msec(game.state[side]) for side in ["wtime", "btime"]} wb = "w" if board.turn == chess.WHITE else "b" diff --git a/lichess-bot.py b/lichess-bot.py index 179d5718f..97a92051d 100644 --- a/lichess-bot.py +++ b/lichess-bot.py @@ -616,7 +616,7 @@ def play_game(li: lichess.Lichess, if not is_game_over(game) and is_engine_move(game, prior_game, board): disconnect_time = correspondence_disconnect_time say_hello(conversation, hello, hello_spectators, board) - start_time = datetime.datetime.now() + start_time = time.perf_counter() print_move_number(board) move_attempted = True engine.play_move(board, diff --git a/timer.py b/timer.py index cdd78ee6f..2818711d5 100644 --- a/timer.py +++ b/timer.py @@ -64,8 +64,8 @@ def __init__(self, duration: datetime.timedelta = seconds(0), self.duration = duration self.reset() if backdated_timestamp is not None: - time_already_used = time.time() - backdated_timestamp - self.starting_time -= time_already_used + time_already_used = datetime.datetime.now() - backdated_timestamp + self.starting_time -= time_already_used.total_seconds() def is_expired(self) -> bool: """Check if a timer is expired.""" @@ -73,11 +73,11 @@ def is_expired(self) -> bool: def reset(self) -> None: """Reset the timer.""" - self.starting_time = time.monotonic() + self.starting_time = time.perf_counter() def time_since_reset(self) -> datetime.timedelta: """How much time has passed.""" - return seconds(time.monotonic() - self.starting_time) + return seconds(time.perf_counter() - self.starting_time) def time_until_expiration(self) -> datetime.timedelta: """How much time is left until it expires.""" From 5ecdc75f6ad454025887821121906f0a9f93f837 Mon Sep 17 00:00:00 2001 From: Mark Harrison Date: Wed, 9 Aug 2023 23:40:18 -0700 Subject: [PATCH 11/20] Use Timer to track pre-move time --- engine_wrapper.py | 18 +++++++++--------- lichess-bot.py | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/engine_wrapper.py b/engine_wrapper.py index 7937b18d7..1ecae6a43 100644 --- a/engine_wrapper.py +++ b/engine_wrapper.py @@ -18,7 +18,7 @@ import model import lichess from config import Configuration -from timer import msec, seconds, msec_str, sec_str +from timer import Timer, msec, seconds, msec_str, sec_str from typing import Any, Optional, Union OPTIONS_TYPE = dict[str, Any] MOVE_INFO_TYPE = dict[str, Any] @@ -104,7 +104,7 @@ def play_move(self, board: chess.Board, game: model.Game, li: lichess.Lichess, - start_time: float, + setup_timer: Timer, move_overhead: datetime.timedelta, can_ponder: bool, is_correspondence: bool, @@ -154,14 +154,14 @@ def play_move(self, time_limit = first_move_time(game) can_ponder = False # No pondering after the first move since a new clock starts afterwards. elif is_correspondence: - time_limit = single_move_time(board, game, correspondence_move_time, start_time, move_overhead) + time_limit = single_move_time(board, game, correspondence_move_time, setup_timer, move_overhead) else: - time_limit = game_clock_time(board, game, start_time, move_overhead) + time_limit = game_clock_time(board, game, setup_timer, move_overhead) best_move = self.search(board, time_limit, can_ponder, draw_offered, best_move) # Heed min_time - elapsed = seconds(time.perf_counter() - start_time) + elapsed = setup_timer.time_since_reset() if elapsed < min_time: time.sleep((min_time - elapsed).total_seconds()) @@ -579,7 +579,7 @@ def getHomemadeEngine(name: str) -> type[MinimalEngine]: def single_move_time(board: chess.Board, game: model.Game, search_time: datetime.timedelta, - start_time: float, move_overhead: datetime.timedelta) -> chess.engine.Limit: + setup_timer: Timer, move_overhead: datetime.timedelta) -> chess.engine.Limit: """ Calculate time to search in correspondence games. @@ -590,7 +590,7 @@ def single_move_time(board: chess.Board, game: model.Game, search_time: datetime :param move_overhead: The time it takes to communicate between the engine and lichess-bot. :return: The time to choose a move. """ - pre_move_time = seconds(time.perf_counter() - start_time) + pre_move_time = setup_timer.time_since_reset() overhead = pre_move_time + move_overhead wb = "w" if board.turn == chess.WHITE else "b" clock_time = max(msec(0), msec(game.state[f"{wb}time"]) - overhead) @@ -614,7 +614,7 @@ def first_move_time(game: model.Game) -> chess.engine.Limit: def game_clock_time(board: chess.Board, game: model.Game, - start_time: float, + setup_timer: Timer, move_overhead: datetime.timedelta) -> chess.engine.Limit: """ Get the time to play by the engine in realtime games. @@ -625,7 +625,7 @@ def game_clock_time(board: chess.Board, :param move_overhead: The time it takes to communicate between the engine and lichess-bot. :return: The time to play a move. """ - pre_move_time = seconds(time.perf_counter() - start_time) + pre_move_time = setup_timer.time_since_reset() overhead = pre_move_time + move_overhead times = {side: msec(game.state[side]) for side in ["wtime", "btime"]} wb = "w" if board.turn == chess.WHITE else "b" diff --git a/lichess-bot.py b/lichess-bot.py index 97a92051d..1266e5cf9 100644 --- a/lichess-bot.py +++ b/lichess-bot.py @@ -616,13 +616,13 @@ def play_game(li: lichess.Lichess, if not is_game_over(game) and is_engine_move(game, prior_game, board): disconnect_time = correspondence_disconnect_time say_hello(conversation, hello, hello_spectators, board) - start_time = time.perf_counter() + setup_timer = Timer() print_move_number(board) move_attempted = True engine.play_move(board, game, li, - start_time, + setup_timer, move_overhead, can_ponder, is_correspondence, From 401c6fbc361d2fa24fe662110f040de646fbdddf Mon Sep 17 00:00:00 2001 From: Mark Harrison Date: Thu, 10 Aug 2023 00:46:25 -0700 Subject: [PATCH 12/20] Use Timer for test_bot move times --- test_bot/test_bot.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/test_bot/test_bot.py b/test_bot/test_bot.py index 247feb98a..0d0dc8a58 100644 --- a/test_bot/test_bot.py +++ b/test_bot/test_bot.py @@ -14,6 +14,7 @@ import shutil import importlib import config +from timer import Timer from typing import Any if __name__ == "__main__": sys.exit(f"The script {os.path.basename(__file__)} should only be run by pytest.") @@ -106,13 +107,12 @@ def thread_for_test() -> None: chess.engine.Limit(time=1), ponder=False) else: - move_start_time = datetime.datetime.now() + move_timer = Timer() move = engine.play(board, chess.engine.Limit(white_clock=wtime.total_seconds() - 2, white_inc=increment.total_seconds()), ponder=False) - move_end_time = datetime.datetime.now() - wtime -= (move_end_time - move_start_time) + wtime -= move_timer.time_since_reset() wtime += increment engine_move = move.move if engine_move is None: @@ -129,7 +129,7 @@ def thread_for_test() -> None: file.write(state_str) else: # lichess-bot move. - move_start_time = datetime.datetime.now() + move_timer = Timer() state2 = state_str moves_are_correct = False while state2 == state_str or not moves_are_correct: @@ -146,9 +146,8 @@ def thread_for_test() -> None: moves_are_correct = False with open("./logs/states.txt") as states: state2 = states.read() - move_end_time = datetime.datetime.now() if len(board.move_stack) > 1: - btime -= (move_end_time - move_start_time) + btime -= move_timer.time_since_reset() btime += increment move_str = state2.split("\n")[0].split(" ")[-1] board.push_uci(move_str) From 814e53728b7849d32ee013a3cc19582950203c2e Mon Sep 17 00:00:00 2001 From: Mark Harrison Date: Thu, 10 Aug 2023 00:54:27 -0700 Subject: [PATCH 13/20] Use timedeltas in test GameStream --- test_bot/lichess.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test_bot/lichess.py b/test_bot/lichess.py index afd762bdd..58bf8e0b5 100644 --- a/test_bot/lichess.py +++ b/test_bot/lichess.py @@ -5,6 +5,7 @@ import json import logging import traceback +from timer import msec, seconds from typing import Union, Any, Optional, Generator logger = logging.getLogger(__name__) @@ -68,7 +69,7 @@ def iter_lines(self) -> Generator[bytes, None, None]: board = chess.Board() for move in moves.split(): board.push_uci(move) - wtime, btime = [float(n) for n in state[1].split(",")] + wtime, btime = [seconds(float(n)) for n in state[1].split(",")] if len(moves) <= len(self.moves_sent) and not event: time.sleep(0.001) continue @@ -79,8 +80,8 @@ def iter_lines(self) -> Generator[bytes, None, None]: time.sleep(0.1) new_game_state = {"type": "gameState", "moves": moves, - "wtime": int(wtime * 1000), - "btime": int(wtime * 1000), + "wtime": int(wtime / msec(1)), + "btime": int(btime / msec(1)), "winc": 100, "binc": 100} if event == "end": From 9590f2bcec5c9ef117939f019539798c3ba9e79e Mon Sep 17 00:00:00 2001 From: Mark Harrison Date: Thu, 10 Aug 2023 01:49:34 -0700 Subject: [PATCH 14/20] Remove time suffix from variable name --- lichess-bot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lichess-bot.py b/lichess-bot.py index 1266e5cf9..9a5a1f523 100644 --- a/lichess-bot.py +++ b/lichess-bot.py @@ -591,7 +591,7 @@ def play_game(li: lichess.Lichess, ponder_cfg = correspondence_cfg if is_correspondence else engine_cfg can_ponder = ponder_cfg.uci_ponder or ponder_cfg.ponder move_overhead = msec(config.move_overhead) - delay_seconds = msec(config.rate_limiting_delay) + delay = msec(config.rate_limiting_delay) keyword_map: defaultdict[str, str] = defaultdict(str, me=game.me.name, opponent=game.opponent.name) hello = get_greeting("hello", config.greeting, keyword_map) @@ -629,7 +629,7 @@ def play_game(li: lichess.Lichess, correspondence_move_time, engine_cfg, fake_think_time(config, board, game)) - time.sleep(delay_seconds.total_seconds()) + time.sleep(delay.total_seconds()) elif is_game_over(game): tell_user_game_result(game, board) engine.send_game_result(game, board) From 3c472248595cdf7311a313d087f56d69c6ff7772 Mon Sep 17 00:00:00 2001 From: Mark Harrison Date: Thu, 10 Aug 2023 01:52:48 -0700 Subject: [PATCH 15/20] Remove time unit from function name --- lichess-bot.py | 2 +- model.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lichess-bot.py b/lichess-bot.py index 9a5a1f523..2b8f9360a 100644 --- a/lichess-bot.py +++ b/lichess-bot.py @@ -678,7 +678,7 @@ def fake_think_time(config: Configuration, board: chess.Board, game: model.Game) sleep = seconds(0.0) if config.fake_think_time and len(board.move_stack) > 9: - remaining = max(seconds(0), game.my_remaining_seconds() - msec(config.move_overhead)) + remaining = max(seconds(0), game.my_remaining_time() - msec(config.move_overhead)) delay = remaining * 0.025 accel = 0.99 ** (len(board.move_stack) - 10) sleep = delay * accel diff --git a/model.py b/model.py index 3856c8f4c..ddf70bbba 100644 --- a/model.py +++ b/model.py @@ -222,7 +222,7 @@ def should_disconnect_now(self) -> bool: """Whether we should disconnect form the game.""" return self.disconnect_time.is_expired() - def my_remaining_seconds(self) -> datetime.timedelta: + def my_remaining_time(self) -> datetime.timedelta: """How many seconds we have left.""" wtime = msec(self.state["wtime"]) btime = msec(self.state["btime"]) From 324c04c201f07e0d3b64810cf0bb7119c67e08e9 Mon Sep 17 00:00:00 2001 From: Mark Harrison Date: Thu, 10 Aug 2023 01:55:24 -0700 Subject: [PATCH 16/20] Remove extraneous newline --- matchmaking.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/matchmaking.py b/matchmaking.py index 87cbef75e..6f666dfa1 100644 --- a/matchmaking.py +++ b/matchmaking.py @@ -37,7 +37,7 @@ def write_daily_challenges(daily_challenges: DAILY_TIMERS_TYPE) -> None: """Write the challenges we have created in the past 24 hours to a text file.""" with open(daily_challenges_file_name, "w") as file: for timer in daily_challenges: - file.write(f"{timer.starting_timestamp(timestamp_format)}\n") + file.write(timer.starting_timestamp(timestamp_format)) class Matchmaking: From 0adfb88bc5e2fd7cbd8162a2db4a1cebd9647084 Mon Sep 17 00:00:00 2001 From: Mark Harrison Date: Thu, 10 Aug 2023 02:04:35 -0700 Subject: [PATCH 17/20] Spelling correction --- timer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/timer.py b/timer.py index 2818711d5..b6fe13f62 100644 --- a/timer.py +++ b/timer.py @@ -48,7 +48,7 @@ class Timer: method time_until_expiration() gives the amount of time left until the timer expires. - Regardless of the initial duration (event if it's zero), a timer can be used + Regardless of the initial duration (even if it's zero), a timer can be used as a stopwatch by calling time_since_reset() to get the amount of time since the timer was created or since it was last reset. """ From 6c1dd366b17effef3699c522a473db7047b5ff55 Mon Sep 17 00:00:00 2001 From: Mark Harrison Date: Thu, 10 Aug 2023 21:33:46 -0700 Subject: [PATCH 18/20] More conversion functions 1. to_seconcds() and to_msec() convert a timedelta into a float with the given time unit. For consistency, to_seconds() replaces .total_seconds(). 2. years() to create t timedelta given a number of years (365 days). --- engine_wrapper.py | 18 +++++++++--------- lichess-bot.py | 6 +++--- model.py | 6 +++--- test_bot/lichess.py | 6 +++--- test_bot/test_bot.py | 8 ++++---- timer.py | 21 ++++++++++++++++++--- 6 files changed, 40 insertions(+), 25 deletions(-) diff --git a/engine_wrapper.py b/engine_wrapper.py index 1ecae6a43..d495d990c 100644 --- a/engine_wrapper.py +++ b/engine_wrapper.py @@ -18,7 +18,7 @@ import model import lichess from config import Configuration -from timer import Timer, msec, seconds, msec_str, sec_str +from timer import Timer, msec, seconds, msec_str, sec_str, to_seconds from typing import Any, Optional, Union OPTIONS_TYPE = dict[str, Any] MOVE_INFO_TYPE = dict[str, Any] @@ -163,7 +163,7 @@ def play_move(self, # Heed min_time elapsed = setup_timer.time_since_reset() if elapsed < min_time: - time.sleep((min_time - elapsed).total_seconds()) + time.sleep(to_seconds(min_time - elapsed)) self.add_comment(best_move, board) self.print_stats() @@ -178,7 +178,7 @@ def add_go_commands(self, time_limit: chess.engine.Limit) -> chess.engine.Limit: if movetime_cfg is not None: movetime = msec(movetime_cfg) if time_limit.time is None or seconds(time_limit.time) > movetime: - time_limit.time = movetime.total_seconds() + time_limit.time = to_seconds(movetime) time_limit.depth = self.go_commands.depth time_limit.nodes = self.go_commands.nodes return time_limit @@ -596,7 +596,7 @@ def single_move_time(board: chess.Board, game: model.Game, search_time: datetime clock_time = max(msec(0), msec(game.state[f"{wb}time"]) - overhead) search_time = min(search_time, clock_time) logger.info(f"Searching for time {sec_str(search_time)} seconds for game {game.id}") - return chess.engine.Limit(time=search_time.total_seconds(), clock_id="correspondence") + return chess.engine.Limit(time=to_seconds(search_time), clock_id="correspondence") def first_move_time(game: model.Game) -> chess.engine.Limit: @@ -609,7 +609,7 @@ def first_move_time(game: model.Game) -> chess.engine.Limit: # Need to hardcode first movetime since Lichess has 30 sec limit. search_time = seconds(10) logger.info(f"Searching for time {sec_str(search_time)} seconds for game {game.id}") - return chess.engine.Limit(time=search_time.total_seconds(), clock_id="first move") + return chess.engine.Limit(time=to_seconds(search_time), clock_id="first move") def game_clock_time(board: chess.Board, @@ -631,10 +631,10 @@ def game_clock_time(board: chess.Board, wb = "w" if board.turn == chess.WHITE else "b" times[f"{wb}time"] = max(msec(0), times[f"{wb}time"] - overhead) logger.info(f"Searching for wtime {msec_str(times['wtime'])} btime {msec_str(times['btime'])} for game {game.id}") - return chess.engine.Limit(white_clock=times["wtime"].total_seconds(), - black_clock=times["btime"].total_seconds(), - white_inc=msec(game.state["winc"]).total_seconds(), - black_inc=msec(game.state["binc"]).total_seconds(), + return chess.engine.Limit(white_clock=to_seconds(times["wtime"]), + black_clock=to_seconds(times["btime"]), + white_inc=to_seconds(msec(game.state["winc"])), + black_inc=to_seconds(msec(game.state["binc"])), clock_id="real time") diff --git a/lichess-bot.py b/lichess-bot.py index 2b8f9360a..354d30bc0 100644 --- a/lichess-bot.py +++ b/lichess-bot.py @@ -24,7 +24,7 @@ import traceback from config import load_config, Configuration from conversation import Conversation, ChatLine -from timer import Timer, seconds, msec, hours +from timer import Timer, seconds, msec, hours, to_seconds from requests.exceptions import ChunkedEncodingError, ConnectionError, HTTPError, ReadTimeout from asyncio.exceptions import TimeoutError as MoveTimeout from rich.logging import RichHandler @@ -113,7 +113,7 @@ def do_correspondence_ping(control_queue: CONTROL_QUEUE_TYPE, period: datetime.t :param period: How many seconds to wait before sending a correspondence ping. """ while not terminated: - time.sleep(period.total_seconds()) + time.sleep(to_seconds(period)) control_queue.put_nowait({"type": "correspondence_ping"}) @@ -629,7 +629,7 @@ def play_game(li: lichess.Lichess, correspondence_move_time, engine_cfg, fake_think_time(config, board, game)) - time.sleep(delay.total_seconds()) + time.sleep(to_seconds(delay)) elif is_game_over(game): tell_user_game_result(game, board) engine.send_game_result(game, board) diff --git a/model.py b/model.py index ddf70bbba..5bd92600d 100644 --- a/model.py +++ b/model.py @@ -4,7 +4,7 @@ import logging import datetime from enum import Enum -from timer import Timer, msec, seconds, days, sec_str +from timer import Timer, msec, seconds, sec_str, to_msec, to_seconds, years from config import Configuration from typing import Any from collections import defaultdict @@ -150,7 +150,7 @@ def __init__(self, game_info: dict[str, Any], username: str, base_url: str, abor self.id: str = game_info["id"] self.speed = game_info.get("speed") clock = game_info.get("clock") or {} - ten_years_in_ms = 10 * days(365) / msec(1) + ten_years_in_ms = to_msec(years(10)) self.clock_initial = msec(clock.get("initial", ten_years_in_ms)) self.clock_increment = msec(clock.get("increment", 0)) self.perf_name = (game_info.get("perf") or {}).get("name", "{perf?}") @@ -166,7 +166,7 @@ def __init__(self, game_info: dict[str, Any], username: str, base_url: str, abor self.me = self.white if self.is_white else self.black self.opponent = self.black if self.is_white else self.white self.base_url = base_url - self.game_start = datetime.datetime.fromtimestamp(msec(game_info["createdAt"]).total_seconds(), + self.game_start = datetime.datetime.fromtimestamp(to_seconds(msec(game_info["createdAt"])), tz=datetime.timezone.utc) self.abort_time = Timer(abort_time) self.terminate_time = Timer(self.clock_initial + self.clock_increment + abort_time + seconds(60)) diff --git a/test_bot/lichess.py b/test_bot/lichess.py index 58bf8e0b5..15e486ee7 100644 --- a/test_bot/lichess.py +++ b/test_bot/lichess.py @@ -5,7 +5,7 @@ import json import logging import traceback -from timer import msec, seconds +from timer import seconds, to_msec from typing import Union, Any, Optional, Generator logger = logging.getLogger(__name__) @@ -80,8 +80,8 @@ def iter_lines(self) -> Generator[bytes, None, None]: time.sleep(0.1) new_game_state = {"type": "gameState", "moves": moves, - "wtime": int(wtime / msec(1)), - "btime": int(btime / msec(1)), + "wtime": int(to_msec(wtime)), + "btime": int(to_msec(btime)), "winc": 100, "binc": 100} if event == "end": diff --git a/test_bot/test_bot.py b/test_bot/test_bot.py index 0d0dc8a58..a7e2f7243 100644 --- a/test_bot/test_bot.py +++ b/test_bot/test_bot.py @@ -14,7 +14,7 @@ import shutil import importlib import config -from timer import Timer +from timer import Timer, to_seconds from typing import Any if __name__ == "__main__": sys.exit(f"The script {os.path.basename(__file__)} should only be run by pytest.") @@ -109,8 +109,8 @@ def thread_for_test() -> None: else: move_timer = Timer() move = engine.play(board, - chess.engine.Limit(white_clock=wtime.total_seconds() - 2, - white_inc=increment.total_seconds()), + chess.engine.Limit(white_clock=to_seconds(wtime) - 2, + white_inc=to_seconds(increment)), ponder=False) wtime -= move_timer.time_since_reset() wtime += increment @@ -156,7 +156,7 @@ def thread_for_test() -> None: with open("./logs/states.txt") as states: state_str = states.read() state = state_str.split("\n") - state[1] = f"{wtime.total_seconds()},{btime.total_seconds()}" + state[1] = f"{to_seconds(wtime)},{to_seconds(btime)}" state_str = "\n".join(state) with open("./logs/states.txt", "w") as file: file.write(state_str) diff --git a/timer.py b/timer.py index b6fe13f62..07c658912 100644 --- a/timer.py +++ b/timer.py @@ -9,9 +9,14 @@ def msec(time_in_msec: float) -> datetime.timedelta: return datetime.timedelta(milliseconds=time_in_msec) +def to_msec(duration: datetime.timedelta) -> float: + """Return a bare number representing the length of the duration in milliseconds.""" + return duration / msec(1) + + def msec_str(duration: datetime.timedelta) -> str: """Return a string with the duration value in whole number milliseconds.""" - return f"{round(duration/msec(1))}" + return f"{round(to_msec(duration))}" def seconds(time_in_sec: float) -> datetime.timedelta: @@ -19,9 +24,14 @@ def seconds(time_in_sec: float) -> datetime.timedelta: return datetime.timedelta(seconds=time_in_sec) +def to_seconds(duration: datetime.timedelta) -> float: + """Return a bare number representing the length of the duration in seconds.""" + return duration.total_seconds() + + def sec_str(duration: datetime.timedelta) -> str: """Return a string with the duration value in whole number seconds.""" - return f"{round(duration.total_seconds())}" + return f"{round(to_seconds(duration))}" def minutes(time_in_minutes: float) -> datetime.timedelta: @@ -39,6 +49,11 @@ def days(time_in_days: float) -> datetime.timedelta: return datetime.timedelta(days=time_in_days) +def years(time_in_years: float) -> datetime.timedelta: + """Create a timedelta duration in median years--i.e., 365 days.""" + return days(365 * time_in_years) + + class Timer: """ A timer for use in lichess-bot. An instance of timer can be used both as a countdown timer and a stopwatch. @@ -65,7 +80,7 @@ def __init__(self, duration: datetime.timedelta = seconds(0), self.reset() if backdated_timestamp is not None: time_already_used = datetime.datetime.now() - backdated_timestamp - self.starting_time -= time_already_used.total_seconds() + self.starting_time -= to_seconds(time_already_used) def is_expired(self) -> bool: """Check if a timer is expired.""" From f47cbd656fa5fc62f3ec13b286dede88534991cc Mon Sep 17 00:00:00 2001 From: Mark Harrison Date: Sat, 12 Aug 2023 01:04:48 -0700 Subject: [PATCH 19/20] Consistently use to_seconds() --- test_bot/test_bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_bot/test_bot.py b/test_bot/test_bot.py index a7e2f7243..935e354ae 100644 --- a/test_bot/test_bot.py +++ b/test_bot/test_bot.py @@ -95,7 +95,7 @@ def thread_for_test() -> None: btime = start_time with open("./logs/states.txt", "w") as file: - file.write(f"\n{wtime.total_seconds()},{btime.total_seconds()}") + file.write(f"\n{to_seconds(wtime)},{to_seconds(btime)}") engine = chess.engine.SimpleEngine.popen_uci(stockfish_path) engine.configure({"Skill Level": 0, "Move Overhead": 1000, "Use NNUE": False}) From 74c6657b1503f67f6eb98b7fda8bb62e9ba0942d Mon Sep 17 00:00:00 2001 From: Mark Harrison Date: Sat, 12 Aug 2023 01:16:24 -0700 Subject: [PATCH 20/20] Use functions from timer instead of datetime --- test_bot/test_bot.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test_bot/test_bot.py b/test_bot/test_bot.py index 935e354ae..a8189bf80 100644 --- a/test_bot/test_bot.py +++ b/test_bot/test_bot.py @@ -3,7 +3,6 @@ import zipfile import requests import time -import datetime import yaml import chess import chess.engine @@ -14,7 +13,7 @@ import shutil import importlib import config -from timer import Timer, to_seconds +from timer import Timer, to_seconds, seconds from typing import Any if __name__ == "__main__": sys.exit(f"The script {os.path.basename(__file__)} should only be run by pytest.") @@ -87,8 +86,8 @@ def thread_for_test() -> None: open("./logs/states.txt", "w").close() open("./logs/result.txt", "w").close() - start_time = datetime.timedelta(seconds=10) - increment = datetime.timedelta(seconds=0.1) + start_time = seconds(10) + increment = seconds(0.1) board = chess.Board() wtime = start_time