added lichess bot

This commit is contained in:
2024-01-25 23:11:25 +01:00
parent 35d3f456e9
commit 62410d239f
45 changed files with 6990 additions and 1 deletions

View File

@@ -0,0 +1 @@
"""pytest won't search `test_bot/` if there is no `__init__.py` file."""

View File

@@ -0,0 +1,14 @@
"""Remove files created when testing lichess_bot."""
import shutil
import os
from typing import Any
def pytest_sessionfinish(session: Any, exitstatus: Any) -> None:
"""Remove files created when testing lichess_bot."""
shutil.copyfile("lib/correct_lichess.py", "lib/lichess.py")
os.remove("lib/correct_lichess.py")
if os.path.exists("TEMP") and not os.getenv("GITHUB_ACTIONS"):
shutil.rmtree("TEMP")
if os.path.exists("logs"):
shutil.rmtree("logs")

View File

@@ -0,0 +1,228 @@
"""Imitate `lichess.py`. Used in tests."""
import time
import chess
import chess.engine
import json
import logging
import traceback
from lib.timer import seconds, to_msec
from typing import Union, Any, Optional, Generator
logger = logging.getLogger(__name__)
def backoff_handler(details: Any) -> None:
"""Log exceptions inside functions with the backoff decorator."""
logger.debug("Backing off {wait:0.1f} seconds after {tries} tries "
"calling function {target} with args {args} and kwargs {kwargs}".format(**details))
logger.debug(f"Exception: {traceback.format_exc()}")
def is_final(error: Any) -> bool:
"""Mock error handler for tests when a function has a backup decorator."""
logger.debug(error)
return False
class GameStream:
"""Imitate lichess.org's GameStream. Used in tests."""
def __init__(self) -> None:
"""Initialize `self.moves_sent` to an empty string. It stores the moves that we have already sent."""
self.moves_sent = ""
def iter_lines(self) -> Generator[bytes, None, None]:
"""Send the game events to lichess_bot."""
yield json.dumps(
{"id": "zzzzzzzz",
"variant": {"key": "standard",
"name": "Standard",
"short": "Std"},
"clock": {"initial": 60000,
"increment": 2000},
"speed": "bullet",
"perf": {"name": "Bullet"},
"rated": True,
"createdAt": 1600000000000,
"white": {"id": "bo",
"name": "bo",
"title": "BOT",
"rating": 3000},
"black": {"id": "b",
"name": "b",
"title": "BOT",
"rating": 3000,
"provisional": True},
"initialFen": "startpos",
"type": "gameFull",
"state": {"type": "gameState",
"moves": "",
"wtime": 10000,
"btime": 10000,
"winc": 100,
"binc": 100,
"status": "started"}}).encode("utf-8")
time.sleep(1)
while True:
time.sleep(0.001)
with open("./logs/events.txt") as events:
event = events.read()
while True:
try:
with open("./logs/states.txt") as states:
state = states.read().split("\n")
moves = state[0]
board = chess.Board()
for move in moves.split():
board.push_uci(move)
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
self.moves_sent = moves
break
except (IndexError, ValueError):
pass
time.sleep(0.1)
new_game_state = {"type": "gameState",
"moves": moves,
"wtime": int(to_msec(wtime)),
"btime": int(to_msec(btime)),
"winc": 100,
"binc": 100}
if event == "end":
new_game_state["status"] = "outoftime"
new_game_state["winner"] = "black"
yield json.dumps(new_game_state).encode("utf-8")
break
if moves:
new_game_state["status"] = "started"
yield json.dumps(new_game_state).encode("utf-8")
class EventStream:
"""Imitate lichess.org's EventStream. Used in tests."""
def __init__(self, sent_game: bool = False) -> None:
""":param sent_game: If we have already sent the `gameStart` event, so we don't send it again."""
self.sent_game = sent_game
def iter_lines(self) -> Generator[bytes, None, None]:
"""Send the events to lichess_bot."""
if self.sent_game:
yield b''
time.sleep(1)
else:
yield json.dumps(
{"type": "gameStart",
"game": {"id": "zzzzzzzz",
"source": "friend",
"compat": {"bot": True,
"board": True}}}).encode("utf-8")
# Docs: https://lichess.org/api.
class Lichess:
"""Imitate communication with lichess.org."""
def __init__(self, token: str, url: str, version: str) -> None:
"""Has the same parameters as `lichess.Lichess` to be able to be used in its placed without any modification."""
self.baseUrl = url
self.game_accepted = False
self.moves: list[chess.engine.PlayResult] = []
self.sent_game = False
def upgrade_to_bot_account(self) -> None:
"""Isn't used in tests."""
return
def make_move(self, game_id: str, move: chess.engine.PlayResult) -> None:
"""Write a move to `./logs/states.txt`, to be read by the opponent."""
self.moves.append(move)
uci_move = move.move.uci() if move.move else "error"
with open("./logs/states.txt") as file:
contents = file.read().split("\n")
contents[0] += f" {uci_move}"
with open("./logs/states.txt", "w") as file:
file.write("\n".join(contents))
def chat(self, game_id: str, room: str, text: str) -> None:
"""Isn't used in tests."""
return
def abort(self, game_id: str) -> None:
"""Isn't used in tests."""
return
def get_event_stream(self) -> EventStream:
"""Send the `EventStream`."""
events = EventStream(self.sent_game)
self.sent_game = True
return events
def get_game_stream(self, game_id: str) -> GameStream:
"""Send the `GameStream`."""
return GameStream()
def accept_challenge(self, challenge_id: str) -> None:
"""Set `self.game_accepted` to true."""
self.game_accepted = True
def decline_challenge(self, challenge_id: str, reason: str = "generic") -> None:
"""Isn't used in tests."""
return
def get_profile(self) -> dict[str, Union[str, bool, dict[str, str]]]:
"""Return a simple profile for the bot that lichess_bot uses when testing."""
return {"id": "b",
"username": "b",
"online": True,
"title": "BOT",
"url": "https://lichess.org/@/b",
"followable": True,
"following": False,
"blocking": False,
"followsYou": False,
"perfs": {}}
def get_ongoing_games(self) -> list[str]:
"""Return that the bot isn't playing a game."""
return []
def resign(self, game_id: str) -> None:
"""Isn't used in tests."""
return
def get_game_pgn(self, game_id: str) -> str:
"""Return a simple PGN."""
return """
[Event "Test game"]
[Site "pytest"]
[Date "2022.03.11"]
[Round "1"]
[White "bo"]
[Black "b"]
[Result "0-1"]
*
"""
def get_online_bots(self) -> list[dict[str, Union[str, bool]]]:
"""Return that the only bot online is us."""
return [{"username": "b", "online": True}]
def challenge(self, username: str, params: dict[str, str]) -> None:
"""Isn't used in tests."""
return
def cancel(self, challenge_id: str) -> None:
"""Isn't used in tests."""
return
def online_book_get(self, path: str, params: Optional[dict[str, str]] = None) -> None:
"""Isn't used in tests."""
return
def is_online(self, user_id: str) -> bool:
"""Return that a bot is online."""
return True

View File

@@ -0,0 +1,8 @@
pytest==7.4.4
pytest-timeout==2.2.0
flake8==7.0.0
flake8-markdown==0.5.0
flake8-docstrings==1.7.0
mypy==1.8.0
types-requests==2.31.0.20240106
types-PyYAML==6.0.12.12

View File

@@ -0,0 +1,319 @@
"""Test lichess_bot."""
import pytest
import zipfile
import requests
import time
import yaml
import chess
import chess.engine
import threading
import os
import sys
import stat
import shutil
import importlib
from lib import config
import tarfile
from lib.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.")
shutil.copyfile("lib/lichess.py", "lib/correct_lichess.py")
shutil.copyfile("test_bot/lichess.py", "lib/lichess.py")
lichess_bot = importlib.import_module("lichess_bot")
platform = sys.platform
file_extension = ".exe" if platform == "win32" else ""
stockfish_path = f"./TEMP/sf{file_extension}"
def download_sf() -> None:
"""Download Stockfish 15."""
if os.path.exists(stockfish_path):
return
windows_or_linux = "windows" if platform == "win32" else "ubuntu"
sf_base = f"stockfish-{windows_or_linux}-x86-64-modern"
archive_ext = "zip" if platform == "win32" else "tar"
archive_link = f"https://github.com/official-stockfish/Stockfish/releases/download/sf_16/{sf_base}.{archive_ext}"
response = requests.get(archive_link, allow_redirects=True)
archive_name = f"./TEMP/sf_zip.{archive_ext}"
with open(archive_name, "wb") as file:
file.write(response.content)
archive_open = zipfile.ZipFile if archive_ext == "zip" else tarfile.TarFile
with archive_open(archive_name, "r") as archive_ref:
archive_ref.extractall("./TEMP/")
exe_ext = ".exe" if platform == "win32" else ""
shutil.copyfile(f"./TEMP/stockfish/{sf_base}{exe_ext}", stockfish_path)
if windows_or_linux == "ubuntu":
st = os.stat(stockfish_path)
os.chmod(stockfish_path, st.st_mode | stat.S_IEXEC)
def download_lc0() -> None:
"""Download Leela Chess Zero 0.29.0."""
if os.path.exists("./TEMP/lc0.exe"):
return
response = requests.get("https://github.com/LeelaChessZero/lc0/releases/download/v0.29.0/lc0-v0.29.0-windows-cpu-dnnl.zip",
allow_redirects=True)
with open("./TEMP/lc0_zip.zip", "wb") as file:
file.write(response.content)
with zipfile.ZipFile("./TEMP/lc0_zip.zip", "r") as zip_ref:
zip_ref.extractall("./TEMP/")
def download_sjeng() -> None:
"""Download Sjeng."""
if os.path.exists("./TEMP/sjeng.exe"):
return
response = requests.get("https://sjeng.org/ftp/Sjeng112.zip", allow_redirects=True)
with open("./TEMP/sjeng_zip.zip", "wb") as file:
file.write(response.content)
with zipfile.ZipFile("./TEMP/sjeng_zip.zip", "r") as zip_ref:
zip_ref.extractall("./TEMP/")
shutil.copyfile("./TEMP/Release/Sjeng112.exe", "./TEMP/sjeng.exe")
if not os.path.exists("TEMP"):
os.mkdir("TEMP")
download_sf()
if platform == "win32":
download_lc0()
download_sjeng()
logging_level = lichess_bot.logging.DEBUG
lichess_bot.logging_configurer(logging_level, None, None, False)
lichess_bot.logger.info("Downloaded engines")
def thread_for_test() -> None:
"""Play the moves for the opponent of lichess_bot."""
open("./logs/events.txt", "w").close()
open("./logs/states.txt", "w").close()
open("./logs/result.txt", "w").close()
start_time = seconds(10)
increment = seconds(0.1)
board = chess.Board()
wtime = start_time
btime = start_time
with open("./logs/states.txt", "w") as file:
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})
while not board.is_game_over():
if len(board.move_stack) % 2 == 0:
if not board.move_stack:
move = engine.play(board,
chess.engine.Limit(time=1),
ponder=False)
else:
move_timer = Timer()
move = engine.play(board,
chess.engine.Limit(white_clock=to_seconds(wtime) - 2,
white_inc=to_seconds(increment)),
ponder=False)
wtime -= move_timer.time_since_reset()
wtime += increment
engine_move = move.move
if engine_move is None:
raise RuntimeError("Engine attempted to make null move.")
board.push(engine_move)
uci_move = engine_move.uci()
with open("./logs/states.txt") as states:
state_str = states.read()
state = state_str.split("\n")
state[0] += f" {uci_move}"
state_str = "\n".join(state)
with open("./logs/states.txt", "w") as file:
file.write(state_str)
else: # lichess_bot move.
move_timer = Timer()
state2 = state_str
moves_are_correct = False
while state2 == state_str or not moves_are_correct:
with open("./logs/states.txt") as states:
state2 = states.read()
time.sleep(0.001)
moves = state2.split("\n")[0]
temp_board = chess.Board()
moves_are_correct = True
for move_str in moves.split():
try:
temp_board.push_uci(move_str)
except ValueError:
moves_are_correct = False
with open("./logs/states.txt") as states:
state2 = states.read()
if len(board.move_stack) > 1:
btime -= move_timer.time_since_reset()
btime += increment
move_str = state2.split("\n")[0].split(" ")[-1]
board.push_uci(move_str)
time.sleep(0.001)
with open("./logs/states.txt") as states:
state_str = states.read()
state = state_str.split("\n")
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)
with open("./logs/events.txt", "w") as file:
file.write("end")
engine.quit()
outcome = board.outcome()
win = outcome.winner == chess.BLACK if outcome else False
with open("./logs/result.txt", "w") as file:
file.write("1" if win else "0")
def run_bot(raw_config: dict[str, Any], logging_level: int) -> str:
"""Start lichess_bot."""
config.insert_default_values(raw_config)
CONFIG = config.Configuration(raw_config)
lichess_bot.logger.info(lichess_bot.intro())
li = lichess_bot.lichess.Lichess(CONFIG.token, CONFIG.url, lichess_bot.__version__)
user_profile = li.get_profile()
username = user_profile["username"]
if user_profile.get("title") != "BOT":
return "0"
lichess_bot.logger.info(f"Welcome {username}!")
lichess_bot.disable_restart()
thr = threading.Thread(target=thread_for_test)
thr.start()
lichess_bot.start(li, user_profile, CONFIG, logging_level, None, None, one_game=True)
thr.join()
with open("./logs/result.txt") as file:
data = file.read()
return data
@pytest.mark.timeout(150, method="thread")
def test_sf() -> None:
"""Test lichess_bot with Stockfish (UCI)."""
if platform != "linux" and platform != "win32":
assert True
return
if os.path.exists("logs"):
shutil.rmtree("logs")
os.mkdir("logs")
with open("./config.yml.default") as file:
CONFIG = yaml.safe_load(file)
CONFIG["token"] = ""
CONFIG["engine"]["dir"] = "./TEMP/"
CONFIG["engine"]["name"] = f"sf{file_extension}"
CONFIG["engine"]["uci_options"]["Threads"] = 1
CONFIG["pgn_directory"] = "TEMP/sf_game_record"
win = run_bot(CONFIG, logging_level)
shutil.rmtree("logs")
lichess_bot.logger.info("Finished Testing SF")
assert win == "1"
assert os.path.isfile(os.path.join(CONFIG["pgn_directory"],
"bo vs b - zzzzzzzz.pgn"))
@pytest.mark.timeout(150, method="thread")
def test_lc0() -> None:
"""Test lichess_bot with Leela Chess Zero (UCI)."""
if platform != "win32":
assert True
return
if os.path.exists("logs"):
shutil.rmtree("logs")
os.mkdir("logs")
with open("./config.yml.default") as file:
CONFIG = yaml.safe_load(file)
CONFIG["token"] = ""
CONFIG["engine"]["dir"] = "./TEMP/"
CONFIG["engine"]["working_dir"] = "./TEMP/"
CONFIG["engine"]["name"] = "lc0.exe"
CONFIG["engine"]["uci_options"]["Threads"] = 1
CONFIG["engine"]["uci_options"].pop("Hash", None)
CONFIG["engine"]["uci_options"].pop("Move Overhead", None)
CONFIG["pgn_directory"] = "TEMP/lc0_game_record"
win = run_bot(CONFIG, logging_level)
shutil.rmtree("logs")
lichess_bot.logger.info("Finished Testing LC0")
assert win == "1"
assert os.path.isfile(os.path.join(CONFIG["pgn_directory"],
"bo vs b - zzzzzzzz.pgn"))
@pytest.mark.timeout(150, method="thread")
def test_sjeng() -> None:
"""Test lichess_bot with Sjeng (XBoard)."""
if platform != "win32":
assert True
return
if os.path.exists("logs"):
shutil.rmtree("logs")
os.mkdir("logs")
with open("./config.yml.default") as file:
CONFIG = yaml.safe_load(file)
CONFIG["token"] = ""
CONFIG["engine"]["dir"] = "./TEMP/"
CONFIG["engine"]["working_dir"] = "./TEMP/"
CONFIG["engine"]["protocol"] = "xboard"
CONFIG["engine"]["name"] = "sjeng.exe"
CONFIG["engine"]["ponder"] = False
CONFIG["pgn_directory"] = "TEMP/sjeng_game_record"
win = run_bot(CONFIG, logging_level)
shutil.rmtree("logs")
lichess_bot.logger.info("Finished Testing Sjeng")
assert win == "1"
assert os.path.isfile(os.path.join(CONFIG["pgn_directory"],
"bo vs b - zzzzzzzz.pgn"))
@pytest.mark.timeout(150, method="thread")
def test_homemade() -> None:
"""Test lichess_bot with a homemade engine running Stockfish (Homemade)."""
if platform != "linux" and platform != "win32":
assert True
return
strategies_py = "lib/strategies.py"
with open(strategies_py) as file:
original_strategies = file.read()
with open(strategies_py, "a") as file:
file.write(f"""
class Stockfish(ExampleEngine):
def __init__(self, commands, options, stderr, draw_or_resign, **popen_args):
super().__init__(commands, options, stderr, draw_or_resign, **popen_args)
import chess
self.engine = chess.engine.SimpleEngine.popen_uci('{stockfish_path}')
def search(self, board, time_limit, *args):
return self.engine.play(board, time_limit)
""")
if os.path.exists("logs"):
shutil.rmtree("logs")
os.mkdir("logs")
with open("./config.yml.default") as file:
CONFIG = yaml.safe_load(file)
CONFIG["token"] = ""
CONFIG["engine"]["name"] = "Stockfish"
CONFIG["engine"]["protocol"] = "homemade"
CONFIG["pgn_directory"] = "TEMP/homemade_game_record"
win = run_bot(CONFIG, logging_level)
shutil.rmtree("logs")
with open(strategies_py, "w") as file:
file.write(original_strategies)
lichess_bot.logger.info("Finished Testing Homemade")
assert win == "1"
assert os.path.isfile(os.path.join(CONFIG["pgn_directory"],
"bo vs b - zzzzzzzz.pgn"))