├── requirements.txt ├── lichess-bot ├── engines │ └── README.md ├── test_bot │ ├── __init__.py │ ├── conftest.py │ ├── lichess.py │ └── test_bot.py ├── .gitignore ├── requirements.txt ├── versioning.yml ├── test-requirements.txt ├── wiki │ ├── Upgrade-to-a-BOT-account.md │ ├── How-to-create-a-Lichess-OAuth-token.md │ ├── Create-a-custom-engine.md │ ├── How-to-Run-lichess‐bot.md │ ├── Home.md │ ├── How-to-Install.md │ ├── Setup-the-engine.md │ └── Configure-lichess-bot.md ├── .github │ ├── dependabot.yml │ ├── ISSUE_TEMPLATE │ │ ├── feature_request.md │ │ └── bug_report.md │ └── workflows │ │ ├── mypy.yml │ │ ├── update_version.py │ │ ├── versioning.yml │ │ ├── python-test.yml │ │ ├── python-build.yml │ │ └── sync-wiki.yml ├── README.md ├── timer.py ├── conversation.py ├── strategies.py ├── model.py ├── config.yml.default ├── config.yml ├── matchmaking.py ├── lichess.py └── config.py ├── config.json ├── uci_engine.py ├── README.md ├── puzzle_solver.py ├── generate_pgn_puzzles.py └── chessllm.py /requirements.txt: -------------------------------------------------------------------------------- 1 | python-chess 2 | requests 3 | zstandard 4 | -------------------------------------------------------------------------------- /lichess-bot/engines/README.md: -------------------------------------------------------------------------------- 1 | Put your engines and opening books here. -------------------------------------------------------------------------------- /lichess-bot/test_bot/__init__.py: -------------------------------------------------------------------------------- 1 | """pytest won't search `test_bot/` if there is no `__init__.py` file.""" 2 | -------------------------------------------------------------------------------- /lichess-bot/.gitignore: -------------------------------------------------------------------------------- 1 | config.yml 2 | __pycache__ 3 | test_bot/__pycache__ 4 | /engines/* 5 | !/engines/README.md 6 | -------------------------------------------------------------------------------- /lichess-bot/requirements.txt: -------------------------------------------------------------------------------- 1 | chess==1.10.0 2 | PyYAML==6.0.1 3 | requests==2.31.0 4 | backoff==2.2.1 5 | rich==13.5.3 6 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "model": "gpt-3.5-turbo-instruct", 3 | "temperature": 0, 4 | "num_lookahead_tokens": 20 5 | } 6 | -------------------------------------------------------------------------------- /lichess-bot/versioning.yml: -------------------------------------------------------------------------------- 1 | lichess_bot_version: 2023.9.20.1 2 | minimum_python_version: '3.9' 3 | deprecated_python_version: '3.8' 4 | deprecation_date: 2023-05-01 5 | -------------------------------------------------------------------------------- /lichess-bot/test-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest==7.4.2 2 | pytest-timeout==2.1.0 3 | flake8==6.1.0 4 | flake8-markdown==0.5.0 5 | flake8-docstrings==1.7.0 6 | mypy==1.5.1 7 | types-requests==2.31.0.2 8 | types-PyYAML==6.0.12.11 9 | -------------------------------------------------------------------------------- /lichess-bot/wiki/Upgrade-to-a-BOT-account.md: -------------------------------------------------------------------------------- 1 | # Upgrading to a BOT account 2 | **WARNING: This is irreversible. [Read more about upgrading to bot account](https://lichess.org/api#operation/botAccountUpgrade).** 3 | - run `python3 lichess-bot.py -u`. 4 | 5 | **Next step**: [Setup the engine](https://github.com/lichess-bot-devs/lichess-bot/wiki/Setup-the-engine) 6 | 7 | **Previous step**: [Create a Lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token) -------------------------------------------------------------------------------- /lichess-bot/test_bot/conftest.py: -------------------------------------------------------------------------------- 1 | """Remove files created when testing lichess-bot.""" 2 | import shutil 3 | import os 4 | from typing import Any 5 | 6 | 7 | def pytest_sessionfinish(session: Any, exitstatus: Any) -> None: 8 | """Remove files created when testing lichess-bot.""" 9 | shutil.copyfile("correct_lichess.py", "lichess.py") 10 | os.remove("correct_lichess.py") 11 | if os.path.exists("TEMP") and not os.getenv("GITHUB_ACTIONS"): 12 | shutil.rmtree("TEMP") 13 | if os.path.exists("logs"): 14 | shutil.rmtree("logs") 15 | -------------------------------------------------------------------------------- /lichess-bot/.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: "daily" 17 | -------------------------------------------------------------------------------- /lichess-bot/.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /lichess-bot/.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Run lichess-bot with '...' commands [e.g. `python lichess-bot.py -v`] 16 | 2. See error 17 | 18 | **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | **Logs** 22 | Upload `lichess_bot_auto_logs\old.log`, `lichess_bot_auto_logs\recent.log`, and other logs/screenshots of the error. 23 | 24 | **Desktop (please complete the following information):** 25 | - OS: [e.g. Windows] 26 | - Python Version [e.g. 3.11] 27 | 28 | **Additional context** 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /lichess-bot/.github/workflows/mypy.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies and run mypy 2 | 3 | name: Mypy 4 | 5 | on: 6 | push: 7 | branches: [ master ] 8 | pull_request: 9 | branches: [ master ] 10 | 11 | jobs: 12 | mypy: 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | matrix: 16 | os: [windows-latest] 17 | python: [3.9, "3.11"] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Python ${{ matrix.python }} 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: ${{ matrix.python }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install -r requirements.txt 29 | pip install -r test-requirements.txt 30 | - name: Run mypy 31 | run: | 32 | mypy --strict . 33 | -------------------------------------------------------------------------------- /lichess-bot/.github/workflows/update_version.py: -------------------------------------------------------------------------------- 1 | """Automatically updates the lichess-bot version.""" 2 | import yaml 3 | import datetime 4 | import os 5 | 6 | with open("versioning.yml") as version_file: 7 | versioning_info = yaml.safe_load(version_file) 8 | 9 | current_version = versioning_info["lichess_bot_version"] 10 | 11 | utc_datetime = datetime.datetime.utcnow() 12 | new_version = f"{utc_datetime.year}.{utc_datetime.month}.{utc_datetime.day}." 13 | if current_version.startswith(new_version): 14 | current_version_list = current_version.split(".") 15 | current_version_list[-1] = str(int(current_version_list[-1]) + 1) 16 | new_version = ".".join(current_version_list) 17 | else: 18 | new_version += "1" 19 | 20 | versioning_info["lichess_bot_version"] = new_version 21 | 22 | with open("versioning.yml", "w") as version_file: 23 | yaml.dump(versioning_info, version_file, sort_keys=False) 24 | 25 | with open(os.environ['GITHUB_OUTPUT'], 'a') as fh: 26 | print(f"new_version={new_version}", file=fh) 27 | -------------------------------------------------------------------------------- /lichess-bot/.github/workflows/versioning.yml: -------------------------------------------------------------------------------- 1 | name: Versioning 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | versioning: 9 | if: github.repository == 'lichess-bot-devs/lichess-bot' 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Python 3.11 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: "3.11" 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install PyYAML 21 | - name: Update version 22 | id: new-version 23 | run: python .github/workflows/update_version.py 24 | - name: Auto update version 25 | run: | 26 | git config --global user.email "actions@github.com" 27 | git config --global user.name "actions-user" 28 | git add versioning.yml 29 | git commit -m "Auto update version to ${{ steps.new-version.outputs.new_version }}" 30 | git push origin HEAD:master 31 | -------------------------------------------------------------------------------- /lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token.md: -------------------------------------------------------------------------------- 1 | # Creating a lichess OAuth token 2 | - Create an account for your bot on [Lichess.org](https://lichess.org/signup). 3 | - **NOTE: If you have previously played games on an existing account, you will not be able to use it as a bot account.** 4 | - Once your account has been created and you are logged in, [create a personal OAuth2 token with the "Play games with the bot API" (`bot:play`) scope](https://lichess.org/account/oauth/token/create?scopes[]=bot:play&description=lichess-bot) selected and a description added. 5 | - A `token` (e.g. `xxxxxxxxxxxxxxxx`) will be displayed. Store this in the `config.yml` file as the `token` field. You can also set the token in the environment variable `$LICHESS_BOT_TOKEN`. 6 | - **NOTE: You won't see this token again on Lichess, so do save it.** 7 | 8 | **Next step**: [Upgrade to a BOT account](https://github.com/lichess-bot-devs/lichess-bot/wiki/Upgrade-to-a-BOT-account) 9 | 10 | **Previous step**: [Install lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Install) 11 | -------------------------------------------------------------------------------- /lichess-bot/wiki/Create-a-custom-engine.md: -------------------------------------------------------------------------------- 1 | ## Creating a custom engine 2 | As an alternative to creating an entire chess engine and implementing one of the communication protocols (`UCI` or `XBoard`), a bot can also be created by writing a single class with a single method. The `search()` method in this new class takes the current board and the game clock as arguments and should return a move based on whatever criteria the coder desires. 3 | 4 | Steps to create a homemade bot: 5 | 6 | 1. Do all the steps in the [How to Install](#how-to-install) 7 | 2. In the `config.yml`, change the engine protocol to `homemade` 8 | 3. Create a class in some file that extends `MinimalEngine` (in `strategies.py`). 9 | - Look at the `strategies.py` file to see some examples. 10 | - If you don't know what to implement, look at the `EngineWrapper` or `UCIEngine` class. 11 | - You don't have to create your own engine, even though it's an "EngineWrapper" class.
12 | The examples just implement `search`. 13 | 4. In the `config.yml`, change the name from `engine_name` to the name of your class 14 | - In this case, you could change it to: 15 | 16 | `name: "RandomMove"` -------------------------------------------------------------------------------- /lichess-bot/.github/workflows/python-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies and run tests 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python Test 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | test: 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest, windows-latest] 18 | python: [3.9, "3.11"] 19 | 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Python ${{ matrix.python }} 24 | uses: actions/setup-python@v4 25 | with: 26 | python-version: ${{ matrix.python }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install -r requirements.txt 31 | pip install -r test-requirements.txt 32 | - name: Restore engines 33 | id: cache-temp-restore 34 | uses: actions/cache/restore@v3 35 | with: 36 | path: | 37 | TEMP 38 | key: ${{ matrix.os }}-engines 39 | - name: Test with pytest 40 | run: | 41 | pytest --log-cli-level=10 42 | - name: Save engines 43 | id: cache-temp-save 44 | uses: actions/cache/save@v3 45 | with: 46 | path: | 47 | TEMP 48 | key: ${{ steps.cache-temp-restore.outputs.cache-primary-key }} 49 | -------------------------------------------------------------------------------- /lichess-bot/.github/workflows/python-build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies and lint 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python Build 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest, macos-latest, windows-latest] 18 | python: [3.9, "3.10", "3.11"] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Python ${{ matrix.python }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install -r requirements.txt 30 | pip install -r test-requirements.txt 31 | - name: Lint with flake8 32 | run: | 33 | # stop the build if there are Python syntax errors or undefined names 34 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 35 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide. 36 | # W503 and W504 are mutually exclusive. W504 is considered the best practice now. 37 | flake8 . --count --max-complexity=10 --max-line-length=127 --statistics --ignore=D,W503 38 | - name: Lint with flake8-markdown 39 | run: | 40 | flake8-markdown "*.md" 41 | flake8-markdown "wiki/*.md" 42 | - name: Lint with flake8-docstrings 43 | run: | 44 | flake8 . --count --max-line-length=127 --statistics --select=D 45 | -------------------------------------------------------------------------------- /lichess-bot/wiki/How-to-Run-lichess‐bot.md: -------------------------------------------------------------------------------- 1 | ## Running lichess-bot 2 | After activating the virtual environment created in the installation steps (the `source` line for Linux and Macs or the `activate` script for Windows), run 3 | ``` 4 | python3 lichess-bot.py 5 | ``` 6 | The working directory for the engine execution will be the lichess-bot directory. If your engine requires files located elsewhere, make sure they are specified by absolute path or copy the files to an appropriate location inside the lichess-bot directory. 7 | 8 | To output more information (including your engine's thinking output and debugging information), the `-v` option can be passed to lichess-bot: 9 | ``` 10 | python3 lichess-bot.py -v 11 | ``` 12 | 13 | If you want to disable automatic logging: 14 | ``` 15 | python3 lichess-bot.py --disable_auto_logging 16 | ``` 17 | 18 | If you want to record the output to a log file, add the `-l` or `--logfile` along with a file name: 19 | ``` 20 | python3 lichess-bot.py --logfile log.txt 21 | ``` 22 | 23 | If you want to specify a different config file, add the `--config` along with a file name: 24 | ``` 25 | python3 lichess-bot.py --config config2.yml 26 | ``` 27 | 28 | ### Running as a service 29 | - Here's an example systemd service definition: 30 | ```ini 31 | [Unit] 32 | Description=lichess-bot 33 | After=network-online.target 34 | Wants=network-online.target 35 | 36 | [Service] 37 | Environment="PYTHONUNBUFFERED=1" 38 | ExecStart=/usr/bin/python3 /home/thibault/lichess-bot/lichess-bot.py 39 | WorkingDirectory=/home/thibault/lichess-bot/ 40 | User=thibault 41 | Group=thibault 42 | Restart=always 43 | 44 | [Install] 45 | WantedBy=multi-user.target 46 | ``` 47 | 48 | ## Quitting 49 | - Press `CTRL+C`. 50 | - It may take some time to quit. 51 | 52 | **Previous step**: [Configure lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/Configure-lichess-bot) -------------------------------------------------------------------------------- /lichess-bot/.github/workflows/sync-wiki.yml: -------------------------------------------------------------------------------- 1 | name: Sync wiki 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - 'wiki/**' 9 | - 'README.md' 10 | 11 | jobs: 12 | sync: 13 | if: github.repository == 'lichess-bot-devs/lichess-bot' 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout Repository 17 | uses: actions/checkout@v4 18 | - name: Checkout wiki 19 | uses: actions/checkout@v4 20 | with: 21 | repository: "${{ github.repository }}.wiki" 22 | path: "lichess-bot.wiki" 23 | - name: Set path 24 | uses: dorny/paths-filter@v2 25 | id: changes 26 | with: 27 | filters: | 28 | wiki: 29 | - 'wiki/**' 30 | home: 31 | - 'wiki/Home.md' 32 | readme: 33 | - 'README.md' 34 | - name: Prevent Conflicts 35 | if: | 36 | steps.changes.outputs.home == 'true' && 37 | steps.changes.outputs.readme == 'true' 38 | run: | 39 | echo "Error: Conflicting changes. Edit either the README.md file or the wiki/Home.md file, not both." 40 | exit 1 41 | - name: Set github bot global config 42 | run: | 43 | git config --global user.email "actions@github.com" 44 | git config --global user.name "actions-user" 45 | - name: Sync README.md to wiki/Home.md 46 | if: steps.changes.outputs.readme == 'true' 47 | run: | 48 | cp -r $GITHUB_WORKSPACE/README.md $GITHUB_WORKSPACE/wiki/Home.md 49 | git add wiki/Home.md 50 | git commit -m "Auto update wiki/Home.md" 51 | git push 52 | - name: Sync wiki/Home.md to README.md 53 | if: steps.changes.outputs.home == 'true' 54 | run: | 55 | cp -r $GITHUB_WORKSPACE/wiki/Home.md $GITHUB_WORKSPACE/README.md 56 | git add README.md 57 | git commit -m "Auto update README.md" 58 | git push 59 | - name: Sync all files to wiki 60 | run: | 61 | cp -r $GITHUB_WORKSPACE/wiki/* $GITHUB_WORKSPACE/lichess-bot.wiki 62 | cd $GITHUB_WORKSPACE/lichess-bot.wiki 63 | git add . 64 | git commit -m "Auto update wiki" 65 | git push 66 | -------------------------------------------------------------------------------- /lichess-bot/README.md: -------------------------------------------------------------------------------- 1 | # lichess-bot 2 | [![Python Build](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-build.yml/badge.svg)](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-build.yml) 3 | [![Python Test](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-test.yml/badge.svg)](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-test.yml) 4 | [![Mypy](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/mypy.yml/badge.svg)](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/mypy.yml) 5 | 6 | A bridge between [Lichess Bot API](https://lichess.org/api#tag/Bot) and bots. 7 | 8 | ## Features 9 | Supports: 10 | - Every variant and time control 11 | - UCI, XBoard, and Homemade engines 12 | - Matchmaking 13 | - Offering Draw / Resigning 14 | - Saving games as PGN 15 | - Local & Online Opening Books 16 | - Local & Online Endgame Tablebases 17 | 18 | ## Steps 19 | 1. [Install lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Install) 20 | 2. [Create a lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token) 21 | 3. [Upgrade to a BOT account](https://github.com/lichess-bot-devs/lichess-bot/wiki/Upgrade-to-a-BOT-account) 22 | 4. [Setup the engine](https://github.com/lichess-bot-devs/lichess-bot/wiki/Setup-the-engine) 23 | 5. [Configure lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/Configure-lichess-bot) 24 | 6. [Run lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Run-lichess%E2%80%90bot) 25 | 26 | ## Advanced options 27 | - [Create a custom engine](https://github.com/lichess-bot-devs/lichess-bot/wiki/Create-a-custom-engine) 28 | 29 |
30 | 31 | ## Acknowledgements 32 | Thanks to the Lichess team, especially T. Alexander Lystad and Thibault Duplessis for working with the LeelaChessZero team to get this API up. Thanks to the [Niklas Fiekas](https://github.com/niklasf) and his [python-chess](https://github.com/niklasf/python-chess) code which allows engine communication seamlessly. 33 | 34 | ## License 35 | lichess-bot is licensed under the AGPLv3 (or any later version at your option). Check out the [LICENSE file](https://github.com/lichess-bot-devs/lichess-bot/blob/master/LICENSE) for the full text. 36 | -------------------------------------------------------------------------------- /lichess-bot/wiki/Home.md: -------------------------------------------------------------------------------- 1 | # lichess-bot 2 | [![Python Build](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-build.yml/badge.svg)](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-build.yml) 3 | [![Python Test](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-test.yml/badge.svg)](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-test.yml) 4 | [![Mypy](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/mypy.yml/badge.svg)](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/mypy.yml) 5 | 6 | A bridge between [Lichess Bot API](https://lichess.org/api#tag/Bot) and bots. 7 | 8 | ## Features 9 | Supports: 10 | - Every variant and time control 11 | - UCI, XBoard, and Homemade engines 12 | - Matchmaking 13 | - Offering Draw / Resigning 14 | - Saving games as PGN 15 | - Local & Online Opening Books 16 | - Local & Online Endgame Tablebases 17 | 18 | ## Steps 19 | 1. [Install lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Install) 20 | 2. [Create a lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token) 21 | 3. [Upgrade to a BOT account](https://github.com/lichess-bot-devs/lichess-bot/wiki/Upgrade-to-a-BOT-account) 22 | 4. [Setup the engine](https://github.com/lichess-bot-devs/lichess-bot/wiki/Setup-the-engine) 23 | 5. [Configure lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/Configure-lichess-bot) 24 | 6. [Run lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Run-lichess%E2%80%90bot) 25 | 26 | ## Advanced options 27 | - [Create a custom engine](https://github.com/lichess-bot-devs/lichess-bot/wiki/Create-a-custom-engine) 28 | 29 |
30 | 31 | ## Acknowledgements 32 | Thanks to the Lichess team, especially T. Alexander Lystad and Thibault Duplessis for working with the LeelaChessZero team to get this API up. Thanks to the [Niklas Fiekas](https://github.com/niklasf) and his [python-chess](https://github.com/niklasf/python-chess) code which allows engine communication seamlessly. 33 | 34 | ## License 35 | lichess-bot is licensed under the AGPLv3 (or any later version at your option). Check out the [LICENSE file](https://github.com/lichess-bot-devs/lichess-bot/blob/master/LICENSE) for the full text. 36 | -------------------------------------------------------------------------------- /lichess-bot/wiki/How-to-Install.md: -------------------------------------------------------------------------------- 1 | ### Mac/Linux 2 | - **NOTE: Only Python 3.9 or later is supported!** 3 | - Download the repo into lichess-bot directory. 4 | - Navigate to the directory in cmd/Terminal: `cd lichess-bot`. 5 | - Install pip: `apt install python3-pip`. 6 | - In non-Ubuntu distros, replace `apt` with the correct package manager (`pacman` in Arch, `dnf` in Fedora, `brew` in Mac, etc.), package name, and installation command. 7 | - Install virtualenv: `apt install python3-virtualenv`. 8 | - Setup virtualenv: `apt install python3-venv`. 9 | ``` 10 | python3 -m venv venv # If this fails you probably need to add Python3 to your PATH. 11 | virtualenv venv -p python3 12 | source ./venv/bin/activate 13 | python3 -m pip install -r requirements.txt 14 | ``` 15 | - Copy `config.yml.default` to `config.yml`. 16 | 17 | **Next step**: [Create a Lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token) 18 | 19 | ### Windows 20 | - **NOTE: Only Python 3.9 or later is supported!** 21 | - If needed, install Python: 22 | - [Download Python here](https://www.python.org/downloads/). 23 | - When installing, enable "add Python to PATH". 24 | - If the Python version is at least 3.10, a default local install works. 25 | - If the Python version is 3.9, choose "Custom installation", keep the defaults on the Optional Features page, and choose "Install for all users" in the Advanced Options page. 26 | - Start Terminal, PowerShell, cmd, or your preferred command prompt. 27 | - Upgrade pip: `py -m pip install --upgrade pip`. 28 | - Download the repo into lichess-bot directory. 29 | - Navigate to the directory: `cd [folder's address]` (for example, `cd C:\Users\username\repos\lichess-bot`). 30 | - Install virtualenv: `py -m pip install virtualenv`. 31 | - Setup virtualenv: 32 | ``` 33 | py -m venv venv # If this fails you probably need to add Python3 to your PATH. 34 | venv\Scripts\activate 35 | pip install -r requirements.txt 36 | ``` 37 | PowerShell note: If the `activate` command does not work in PowerShell, execute `Set-ExecutionPolicy RemoteSigned` first and choose `Y` there (you may need to run Powershell as administrator). After you execute the script, change execution policy back with `Set-ExecutionPolicy Restricted` and pressing `Y`. 38 | - Copy `config.yml.default` to `config.yml`. 39 | 40 | **Next step**: [Create a Lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token) -------------------------------------------------------------------------------- /lichess-bot/wiki/Setup-the-engine.md: -------------------------------------------------------------------------------- 1 | # Setup the engine 2 | ## For all engines 3 | Within the file `config.yml`: 4 | - Enter the directory containing the engine executable in the `engine: dir` field. 5 | - Enter the executable name in the `engine: name` field (In Windows you may need to type a name with ".exe", like "lczero.exe") 6 | - If you want the engine to run in a different directory (e.g., if the engine needs to read or write files at a certain location), enter that directory in the `engine: working_dir` field. 7 | - If this field is blank or missing, the current directory will be used. 8 | - IMPORTANT NOTE: If this field is used, the running engine will look for files and directories (Syzygy tablebases, for example) relative to this path, not the directory where lichess-bot was launched. Files and folders specified with absolute paths are unaffected. 9 | 10 | As an optional convenience, there is a folder named `engines` within the lichess-bot folder where you can copy your engine and all the files it needs. This is the default executable location in the `config.yml.default` file. 11 | 12 | ## For Leela Chess Zero 13 | ### LeelaChessZero: Mac/Linux 14 | - Download the weights for the id you want to play from [here](https://lczero.org/play/networks/bestnets/). 15 | - Extract the weights from the zip archive and rename it to `latest.txt`. 16 | - For Mac/Linux, build the lczero binary yourself following [LeelaChessZero/lc0/README](https://github.com/LeelaChessZero/lc0/blob/master/README.md). 17 | - Copy both the files into the `engine.dir` directory. 18 | - Change the `engine.name` and `engine.engine_options.weights` keys in `config.yml` file to `lczero` and `weights.pb.gz`. 19 | - You can specify the number of `engine.uci_options.threads` in the `config.yml` file as well. 20 | - To start: `python3 lichess-bot.py`. 21 | 22 | ### LeelaChessZero: Windows CPU 2021 23 | - For Windows modern CPUs, download the lczero binary from the [latest Lc0 release](https://github.com/LeelaChessZero/lc0/releases) (e.g. `lc0-v0.27.0-windows-cpu-dnnl.zip`). 24 | - Unzip the file, it comes with `lc0.exe` , `dnnl.dll`, and a weights file example, `703810.pb.gz` (amongst other files). 25 | - All three main files need to be copied to the `engines` directory. 26 | - The `lc0.exe` should be doubleclicked and the windows safesearch warning about it being unsigned should be cleared (be careful and be sure you have the genuine file). 27 | - Change the `engine.name` key in the `config.yml` file to `lc0.exe`, no need to edit the `config.yml` file concerning the weights file as the `lc0.exe` will use whatever `*.pb.gz` is in the same folder (have only one `*pb.gz` file in the `engines` directory). 28 | - To start: `python3 lichess-bot.py`. 29 | 30 | **Next step**: [Configure lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/Configure-lichess-bot) 31 | 32 | **Previous step**: [Upgrade to a BOT accout](https://github.com/lichess-bot-devs/lichess-bot/wiki/Upgrade-to-a-BOT-account) -------------------------------------------------------------------------------- /uci_engine.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | ## Copyright (C) 2023, Nicholas Carlini . 4 | ## 5 | ## This program is free software: you can redistribute it and/or modify 6 | ## it under the terms of the GNU General Public License as published by 7 | ## the Free Software Foundation, either version 3 of the License, or 8 | ## (at your option) any later version. 9 | ## 10 | ## This program is distributed in the hope that it will be useful, 11 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | ## GNU General Public License for more details. 14 | ## 15 | ## You should have received a copy of the GNU General Public License 16 | ## along with this program. If not, see . 17 | 18 | import chess 19 | from chessllm import ChessLLM 20 | import json 21 | 22 | def main(): 23 | 24 | config = json.loads(open("config.json").read()) 25 | game = chess.pgn.Game() 26 | 27 | while True: 28 | line = input() 29 | log.write(f"Got line: {repr(line)}\n") 30 | log.flush() 31 | if line == "uci": 32 | print(f"id name chess-llm-with-{config['model']}") 33 | print("id author Nicholas Carlini") 34 | print("uciok") 35 | elif line == "isready": 36 | print("readyok") 37 | elif line.startswith("position"): 38 | parts = line.split(" ", 1) 39 | 40 | moves_list = [] 41 | if "moves" in parts[1]: 42 | _, moves_str = parts[1].split("moves") 43 | moves_list = moves_str.strip().split() 44 | 45 | if parts[1].startswith("startpos"): 46 | board = chess.Board() 47 | else: 48 | raise 49 | 50 | for move_uci in moves_list: 51 | move = chess.Move.from_uci(move_uci) 52 | board.push(move) 53 | log.write(f"now position {repr(board)}\n") 54 | log.flush() 55 | 56 | 57 | elif line.startswith("go"): 58 | log.write("info string Starting search\n") 59 | log.flush() 60 | 61 | move = engine.get_best_move(board) 62 | try: 63 | log.write("Have move " + move + "\n") 64 | uci_move = board.push_san(move).uci() 65 | except: 66 | log.write(f"info string Invalid move: {repr(move)}\n") 67 | log.flush() 68 | 69 | moves = list(board.legal_moves) 70 | move = random.choice(moves) 71 | uci_move = move.uci() 72 | 73 | print(f"info pv {uci_move}") 74 | print(f"bestmove {uci_move}") 75 | elif line == "quit": 76 | break 77 | 78 | 79 | if __name__ == "__main__": 80 | log = open("/tmp/log.txt", "a") 81 | api_key = open("OPENAI_API_KEY").read().strip() 82 | config = json.loads(open("config.json").read()) 83 | engine = ChessLLM(api_key, config) 84 | main() 85 | 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ChessLLM 2 | 3 | This is a project to play chess against Large Language Models (LLMs). 4 | Currently it only supports the OpenAI text completion API, 5 | and has only been tested on GPT-3.5-turbo-instruct. 6 | (Because this is the only model I've seen that plays chess well.) 7 | 8 | This code is very bare-bones. It's just enough to make things run. 9 | Maybe in the future I'll extend it to make it better. 10 | 11 | If you're reading this and this message is still here, 12 | you can play a version of this bot running under my API key 13 | [by clicking here](https://lichess.org/?user=gpt35-turbo-instruct#friend). 14 | 15 | 16 | ## What is this? 17 | 18 | This is a chess engine that plays chess by prompting GPT-3.5-turbo-instruct. 19 | To do this it passes the entire game state as a PGN, and the model plays 20 | whatever move the language model responds with. So in order to respond to 21 | a standard King's pawn opening I prompt the model with 22 | 23 | [White "Garry Kasparov"] 24 | [Black "Magnus Carlsen"] 25 | [Result "1/2-1/2"] 26 | [WhiteElo "2900"] 27 | [BlackElo "2800"] 28 | 29 | 1. e4 30 | 31 | And then it responds `e5` which means my engine should return the move `e5`. 32 | 33 | 34 | ## Installing 35 | 36 | This project has minimal dependencies: just python-chess and requests to 37 | run the UCI engine, or some additional dependencies to the lichess bot 38 | in the lichess-bot folder. 39 | 40 | pip install -r requirements.txt 41 | pip install -r lichess-bot/requirements.txt # if you want to run the bot 42 | 43 | 44 | ## Getting Started 45 | 46 | ### Add your OpenAI key 47 | 48 | Put your key in a file called `OPENAI_API_KEY`. 49 | 50 | ### UCI engine 51 | 52 | If you already have a UCI-compliant chess engine downloaded and want to play 53 | against the model you can pass `./uci_engine.py`. 54 | 55 | 56 | ### Lichess bot 57 | 58 | The lichess-bot directory is a fork of the [lichess-bot](https://github.com/lichess-bot-devs/lichess-bot) project with a few hacks so that my bot talks a bit more and explains what it's doing. 59 | If you want to make a lichess bot, you'll first need to create a new account, 60 | then get an API key, and turn it into a bot. The steps to do this are 61 | described: [in the lichess-bot README](lichess-bot/README.md). 62 | 63 | Once you've done that put your API key as the first line of `config.yml`. 64 | 65 | 66 | ## Next steps 67 | 68 | I highly doubt I'll do any of these things, but here are some things 69 | I may want to do. (Or you can do!) 70 | 71 | - Search: what happens if instead of predicting the top-1 move you predict 72 | different moves and take the "best"? How do you choose "best"? 73 | 74 | - Resign if lost: how can you detect if the game is lost and then just 75 | resign if it's obviously over? 76 | 77 | - Other models: right now this works for just OpenAI's text completion models. 78 | It would be great to hook this up to other models if any of them 79 | become reasonably good at chess. 80 | 81 | 82 | ## License: GPL v3 83 | 84 | This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3. 85 | 86 | This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. 87 | 88 | You should have received a copy of the GNU General Public License along with this program. If not, see http://www.gnu.org/licenses/. -------------------------------------------------------------------------------- /puzzle_solver.py: -------------------------------------------------------------------------------- 1 | ## Copyright (C) 2023, Nicholas Carlini . 2 | ## 3 | ## This program is free software: you can redistribute it and/or modify 4 | ## it under the terms of the GNU General Public License as published by 5 | ## the Free Software Foundation, either version 3 of the License, or 6 | ## (at your option) any later version. 7 | ## 8 | ## This program is distributed in the hope that it will be useful, 9 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | ## GNU General Public License for more details. 12 | ## 13 | ## You should have received a copy of the GNU General Public License 14 | ## along with this program. If not, see . 15 | 16 | import chess 17 | import numpy as np 18 | import io 19 | import json 20 | import chessllm 21 | import csv 22 | 23 | def convert_pgn_to_game(pgn_moves): 24 | pgn = io.StringIO(pgn_moves) 25 | game = chess.pgn.read_game(pgn) 26 | if len(game.errors) > 0: 27 | return None 28 | return game 29 | 30 | def solve_puzzle(board, solution): 31 | solution = solution.split() 32 | while True: 33 | if len(solution) > 0: 34 | opponent_move, *solution = solution 35 | board.push_san(opponent_move) 36 | else: 37 | break 38 | 39 | guess_next_move = engine.get_best_move(board) 40 | 41 | real_next_move, *solution = solution 42 | if guess_next_move != real_next_move: 43 | try: 44 | board.push_san(guess_next_move) 45 | if board.is_checkmate(): 46 | # Lichess puzzles allow multiple mate-in-1 solutions 47 | return True 48 | except: 49 | pass 50 | return False 51 | board.push_san(guess_next_move) 52 | return True 53 | 54 | def main(): 55 | 56 | ok = [[] for _ in range(30)] 57 | 58 | with open("pgn_puzzles.csv", 'r') as f: 59 | reader = csv.reader(f) 60 | for puzzleid, rating, pgn, solution in list(reader): 61 | rating = int(rating)//200 62 | if len(ok[rating]) >= 20: continue 63 | 64 | board = chess.Board() 65 | 66 | # Iterate over the moves and apply them to the board 67 | for move in convert_pgn_to_game(pgn).mainline_moves(): 68 | board.push(move) 69 | 70 | is_right = solve_puzzle(board, solution) 71 | 72 | ok[rating].append(is_right) 73 | for i,x in enumerate(ok): 74 | print('rating',i*200, 'acc',np.mean(x)) 75 | 76 | if True: 77 | import matplotlib.pyplot as plt 78 | # Remove nan values and get their indices 79 | ratings = [np.mean(x) for x in ok] 80 | non_nan_indices = [i for i, val in enumerate(ratings) if not np.isnan(val)] 81 | non_nan_values = [ratings[i] for i in non_nan_indices] 82 | 83 | # Create bucket ranges 84 | bucket_ranges = [(i*200, (i+1)*200) for i in non_nan_indices] 85 | bucket_labels = [f"{low}-{high}" for low, high in bucket_ranges] 86 | 87 | # Plotting 88 | plt.figure(figsize=(8, 4)) 89 | plt.bar(bucket_labels, non_nan_values) 90 | plt.xlabel('Puzzle Rating (Elo)') 91 | plt.ylabel('Probability correct') 92 | plt.title('Ratings vs. Buckets') 93 | plt.xticks(rotation=45) 94 | plt.tight_layout() 95 | plt.savefig("/tmp/a.png", dpi=600) 96 | 97 | 98 | if __name__ == "__main__": 99 | api_key = open("OPENAI_API_KEY").read().strip() 100 | config = json.loads(open("config.json").read()) 101 | engine = chessllm.ChessLLM(api_key, config, num_lookahead_tokens=30) 102 | main() 103 | 104 | -------------------------------------------------------------------------------- /lichess-bot/timer.py: -------------------------------------------------------------------------------- 1 | """A timer for use in lichess-bot.""" 2 | import time 3 | import datetime 4 | from typing import Optional 5 | 6 | 7 | def msec(time_in_msec: float) -> datetime.timedelta: 8 | """Create a timedelta duration in milliseconds.""" 9 | return datetime.timedelta(milliseconds=time_in_msec) 10 | 11 | 12 | def to_msec(duration: datetime.timedelta) -> float: 13 | """Return a bare number representing the length of the duration in milliseconds.""" 14 | return duration / msec(1) 15 | 16 | 17 | def msec_str(duration: datetime.timedelta) -> str: 18 | """Return a string with the duration value in whole number milliseconds.""" 19 | return str(round(to_msec(duration))) 20 | 21 | 22 | def seconds(time_in_sec: float) -> datetime.timedelta: 23 | """Create a timedelta duration in seconds.""" 24 | return datetime.timedelta(seconds=time_in_sec) 25 | 26 | 27 | def to_seconds(duration: datetime.timedelta) -> float: 28 | """Return a bare number representing the length of the duration in seconds.""" 29 | return duration.total_seconds() 30 | 31 | 32 | def sec_str(duration: datetime.timedelta) -> str: 33 | """Return a string with the duration value in whole number seconds.""" 34 | return str(round(to_seconds(duration))) 35 | 36 | 37 | def minutes(time_in_minutes: float) -> datetime.timedelta: 38 | """Create a timedelta duration in minutes.""" 39 | return datetime.timedelta(minutes=time_in_minutes) 40 | 41 | 42 | def hours(time_in_hours: float) -> datetime.timedelta: 43 | """Create a timedelta duration in hours.""" 44 | return datetime.timedelta(hours=time_in_hours) 45 | 46 | 47 | def days(time_in_days: float) -> datetime.timedelta: 48 | """Create a timedelta duration in minutes.""" 49 | return datetime.timedelta(days=time_in_days) 50 | 51 | 52 | def years(time_in_years: float) -> datetime.timedelta: 53 | """Create a timedelta duration in median years--i.e., 365 days.""" 54 | return days(365) * time_in_years 55 | 56 | 57 | class Timer: 58 | """ 59 | A timer for use in lichess-bot. An instance of timer can be used both as a countdown timer and a stopwatch. 60 | 61 | If the duration argument in the __init__() method is greater than zero, then 62 | the method is_expired() indicates when the intial duration has passed. The 63 | method time_until_expiration() gives the amount of time left until the timer 64 | expires. 65 | 66 | Regardless of the initial duration (even if it's zero), a timer can be used 67 | as a stopwatch by calling time_since_reset() to get the amount of time since 68 | the timer was created or since it was last reset. 69 | """ 70 | 71 | def __init__(self, duration: datetime.timedelta = seconds(0), 72 | backdated_timestamp: Optional[datetime.datetime] = None) -> None: 73 | """ 74 | Start the timer. 75 | 76 | :param duration: The duration of time before Timer.is_expired() returns True. 77 | :param backdated_timestamp: When the timer should have started. Used to keep the timers between sessions. 78 | """ 79 | self.duration = duration 80 | self.reset() 81 | if backdated_timestamp is not None: 82 | time_already_used = datetime.datetime.now() - backdated_timestamp 83 | self.starting_time -= to_seconds(time_already_used) 84 | 85 | def is_expired(self) -> bool: 86 | """Check if a timer is expired.""" 87 | return self.time_since_reset() >= self.duration 88 | 89 | def reset(self) -> None: 90 | """Reset the timer.""" 91 | self.starting_time = time.perf_counter() 92 | 93 | def time_since_reset(self) -> datetime.timedelta: 94 | """How much time has passed.""" 95 | return seconds(time.perf_counter() - self.starting_time) 96 | 97 | def time_until_expiration(self) -> datetime.timedelta: 98 | """How much time is left until it expires.""" 99 | return max(seconds(0), self.duration - self.time_since_reset()) 100 | 101 | def starting_timestamp(self, format: str) -> str: 102 | """When the timer started.""" 103 | return (datetime.datetime.now() - self.time_since_reset()).strftime(format) 104 | -------------------------------------------------------------------------------- /lichess-bot/conversation.py: -------------------------------------------------------------------------------- 1 | """Allows lichess-bot to send messages to the chat.""" 2 | from __future__ import annotations 3 | import logging 4 | import model 5 | from engine_wrapper import EngineWrapper 6 | from lichess import Lichess 7 | from collections.abc import Sequence 8 | from timer import seconds 9 | MULTIPROCESSING_LIST_TYPE = Sequence[model.Challenge] 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class Conversation: 15 | """Enables the bot to communicate with its opponent and the spectators.""" 16 | 17 | def __init__(self, game: model.Game, engine: EngineWrapper, li: Lichess, version: str, 18 | challenge_queue: MULTIPROCESSING_LIST_TYPE) -> None: 19 | """ 20 | Communication between lichess-bot and the game chats. 21 | 22 | :param game: The game that the bot will send messages to. 23 | :param engine: The engine playing the game. 24 | :param li: A class that is used for communication with lichess. 25 | :param version: The lichess-bot version. 26 | :param challenge_queue: The active challenges the bot has. 27 | """ 28 | self.game = game 29 | self.engine = engine 30 | self.li = li 31 | self.version = version 32 | self.challengers = challenge_queue 33 | 34 | command_prefix = "!" 35 | 36 | def react(self, line: ChatLine) -> None: 37 | """ 38 | React to a received message. 39 | 40 | :param line: Information about the message. 41 | """ 42 | logger.info(f'*** {self.game.url()} [{line.room}] {line.username}: {line.text}') 43 | if line.text[0] == self.command_prefix: 44 | self.command(line, line.text[1:].lower()) 45 | 46 | def command(self, line: ChatLine, cmd: str) -> None: 47 | """ 48 | Reacts to the specific commands in the chat. 49 | 50 | :param line: Information about the message. 51 | :param cmd: The command to react to. 52 | """ 53 | from_self = line.username == self.game.username 54 | if cmd == "commands" or cmd == "help": 55 | self.send_reply(line, "Supported commands: !wait (wait a minute for my first move), !name, !howto, !eval, !queue") 56 | elif cmd == "wait" and self.game.is_abortable(): 57 | self.game.ping(seconds(60), seconds(120), seconds(120)) 58 | self.send_reply(line, "Waiting 60 seconds...") 59 | elif cmd == "name": 60 | name = self.game.me.name 61 | self.send_reply(line, f"{name} running {self.engine.name()} (lichess-bot v{self.version})") 62 | elif cmd == "howto": 63 | self.send_reply(line, "How to run: Check out 'Lichess Bot API'") 64 | elif cmd == "eval" and (from_self or line.room == "spectator"): 65 | stats = self.engine.get_stats(for_chat=True) 66 | self.send_reply(line, ", ".join(stats)) 67 | elif cmd == "eval": 68 | self.send_reply(line, "I don't tell that to my opponent, sorry.") 69 | elif cmd == "queue": 70 | if self.challengers: 71 | challengers = ", ".join([f"@{challenger.challenger.name}" for challenger in reversed(self.challengers)]) 72 | self.send_reply(line, f"Challenge queue: {challengers}") 73 | else: 74 | self.send_reply(line, "No challenges queued.") 75 | 76 | def send_reply(self, line: ChatLine, reply: str) -> None: 77 | """ 78 | Send the reply to the chat. 79 | 80 | :param line: Information about the original message that we reply to. 81 | :param reply: The reply to send. 82 | """ 83 | logger.info(f'*** {self.game.url()} [{line.room}] {self.game.username}: {reply}') 84 | self.li.chat(self.game.id, line.room, reply) 85 | 86 | def send_message(self, room: str, message: str) -> None: 87 | """Send the message to the chat.""" 88 | if message: 89 | self.send_reply(ChatLine({"room": room, "username": "", "text": ""}), message) 90 | 91 | 92 | class ChatLine: 93 | """Information about the message.""" 94 | 95 | def __init__(self, message_info: dict[str, str]) -> None: 96 | """Information about the message.""" 97 | self.room = message_info["room"] 98 | """Whether the message was sent in the chat room or in the spectator room.""" 99 | self.username = message_info["username"] 100 | """The username of the account that sent the message.""" 101 | self.text = message_info["text"] 102 | """The message sent.""" 103 | -------------------------------------------------------------------------------- /lichess-bot/strategies.py: -------------------------------------------------------------------------------- 1 | """ 2 | Some example strategies for people who want to create a custom, homemade bot. 3 | 4 | With these classes, bot makers will not have to implement the UCI or XBoard interfaces themselves. 5 | """ 6 | 7 | from __future__ import annotations 8 | import chess 9 | from chess.engine import PlayResult 10 | import random 11 | from engine_wrapper import MinimalEngine 12 | from typing import Any, Union 13 | import logging 14 | MOVE = Union[chess.engine.PlayResult, list[chess.Move]] 15 | 16 | 17 | # Use this logger variable to print messages to the console or log files. 18 | # logger.info("message") will always print "message" to the console or log file. 19 | # logger.debug("message") will only print "message" if verbose logging is enabled. 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class ExampleEngine(MinimalEngine): 24 | """An example engine that all homemade engines inherit.""" 25 | 26 | pass 27 | 28 | 29 | # Strategy names and ideas from tom7's excellent eloWorld video 30 | 31 | class RandomMove(ExampleEngine): 32 | """Get a random move.""" 33 | 34 | def search(self, board: chess.Board, *args: Any) -> PlayResult: 35 | """Choose a random move.""" 36 | return PlayResult(random.choice(list(board.legal_moves)), None) 37 | 38 | 39 | class Alphabetical(ExampleEngine): 40 | """Get the first move when sorted by san representation.""" 41 | 42 | def search(self, board: chess.Board, *args: Any) -> PlayResult: 43 | """Choose the first move alphabetically.""" 44 | moves = list(board.legal_moves) 45 | moves.sort(key=board.san) 46 | return PlayResult(moves[0], None) 47 | 48 | 49 | class FirstMove(ExampleEngine): 50 | """Get the first move when sorted by uci representation.""" 51 | 52 | def search(self, board: chess.Board, *args: Any) -> PlayResult: 53 | """Choose the first move alphabetically in uci representation.""" 54 | moves = list(board.legal_moves) 55 | moves.sort(key=str) 56 | return PlayResult(moves[0], None) 57 | 58 | 59 | class ComboEngine(ExampleEngine): 60 | """ 61 | Get a move using multiple different methods. 62 | 63 | This engine demonstrates how one can use `time_limit`, `draw_offered`, and `root_moves`. 64 | """ 65 | 66 | def search(self, board: chess.Board, time_limit: chess.engine.Limit, ponder: bool, draw_offered: bool, 67 | root_moves: MOVE) -> chess.engine.PlayResult: 68 | """ 69 | Choose a move using multiple different methods. 70 | 71 | :param board: The current position. 72 | :param time_limit: Conditions for how long the engine can search (e.g. we have 10 seconds and search up to depth 10). 73 | :param ponder: Whether the engine can ponder after playing a move. 74 | :param draw_offered: Whether the bot was offered a draw. 75 | :param root_moves: If it is a list, the engine should only play a move that is in `root_moves`. 76 | :return: The move to play. 77 | """ 78 | if isinstance(time_limit.time, int): 79 | my_time = time_limit.time 80 | my_inc = 0 81 | elif board.turn == chess.WHITE: 82 | my_time = time_limit.white_clock if isinstance(time_limit.white_clock, int) else 0 83 | my_inc = time_limit.white_inc if isinstance(time_limit.white_inc, int) else 0 84 | else: 85 | my_time = time_limit.black_clock if isinstance(time_limit.black_clock, int) else 0 86 | my_inc = time_limit.black_inc if isinstance(time_limit.black_inc, int) else 0 87 | 88 | possible_moves = root_moves if isinstance(root_moves, list) else list(board.legal_moves) 89 | 90 | if my_time / 60 + my_inc > 10: 91 | # Choose a random move. 92 | move = random.choice(possible_moves) 93 | else: 94 | # Choose the first move alphabetically in uci representation. 95 | possible_moves.sort(key=str) 96 | move = possible_moves[0] 97 | return PlayResult(move, None, draw_offered=draw_offered) 98 | 99 | class LLM(ExampleEngine): 100 | def __init__(self, *args, **kwargs): 101 | super().__init__(*args, **kwargs) 102 | import sys 103 | import json 104 | sys.path.append("..") 105 | from chessllm import ChessLLM 106 | api_key = open("../OPENAI_API_KEY").read().strip() 107 | config = json.loads(open("../config.json").read()) 108 | self.my_engine = ChessLLM(api_key, config) 109 | 110 | def search(self, board: chess.Board, *args: Any) -> PlayResult: 111 | conversation = args[-1] 112 | 113 | new_board = board.copy() 114 | move = self.my_engine.get_best_move(board, conversation=conversation) 115 | new_board.push_san(move) 116 | 117 | move = new_board.peek() 118 | 119 | return PlayResult(move, None) 120 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /generate_pgn_puzzles.py: -------------------------------------------------------------------------------- 1 | ## Copyright (C) 2023, Nicholas Carlini . 2 | ## 3 | ## This program is free software: you can redistribute it and/or modify 4 | ## it under the terms of the GNU General Public License as published by 5 | ## the Free Software Foundation, either version 3 of the License, or 6 | ## (at your option) any later version. 7 | ## 8 | ## This program is distributed in the hope that it will be useful, 9 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | ## GNU General Public License for more details. 12 | ## 13 | ## You should have received a copy of the GNU General Public License 14 | ## along with this program. If not, see . 15 | 16 | import requests 17 | import time 18 | import chess 19 | import chess.pgn 20 | import io 21 | import csv 22 | import os 23 | import re 24 | import pickle 25 | 26 | 27 | def download_and_decompress(url): 28 | os.popen("wget " + url).read() 29 | compressed_filename = os.path.basename(url) 30 | time.sleep(1) 31 | os.popen("zstd -d " + compressed_filename).read() 32 | os.remove(compressed_filename) 33 | 34 | 35 | def get_data(): 36 | download_and_decompress("https://database.lichess.org/standard/lichess_db_standard_rated_2016-02.pgn.zst") 37 | download_and_decompress("https://database.lichess.org/lichess_db_puzzle.csv.zst") 38 | 39 | 40 | import re 41 | 42 | def generate_mapping(filename): 43 | mapping = {} 44 | 45 | # Regular expression pattern to match the game ID from the Site URL 46 | site_pattern = re.compile(r'\[Site "https://lichess.org/([a-zA-Z0-9]+)"]') 47 | 48 | # Open the file in binary mode to compute byte offsets 49 | with open(filename, 'rb') as f: 50 | line = f.readline() 51 | while line: 52 | match = site_pattern.search(line.decode('utf-8')) 53 | if match: 54 | current_game_id = match.group(1) 55 | mapping[current_game_id] = f.tell() - len(line) # Get the starting byte offset of this line 56 | line = f.readline() 57 | 58 | return mapping 59 | 60 | def fetch_game_moves(filename, game_id, offset): 61 | moves = [] 62 | with open(filename, 'r') as f: 63 | f.seek(offset) 64 | pgn = f.read(10000).split("[Event")[0] 65 | 66 | 67 | return pgn 68 | 69 | def convert_pgn_to_game(pgn_moves): 70 | pgn = io.StringIO(pgn_moves) 71 | game = chess.pgn.read_game(pgn) 72 | if len(game.errors) > 0: 73 | return None 74 | return game 75 | 76 | def process_puzzles(csv_filename, games_filename, mapping): 77 | 78 | extracted_puzzles = [] 79 | 80 | with open(csv_filename, 'r') as f: 81 | reader = csv.reader(f) 82 | next(reader) 83 | for row in reader: 84 | game_url, uci_moves = row[8], row[2].split() 85 | game_id = game_url.split('.org/')[1] 86 | 87 | move_num = game_url.split('#')[-1] 88 | if 'Some' in move_num: 89 | move_num = move_num.split("(")[1][:-1] 90 | move_num = int(move_num) 91 | 92 | game_id = game_id.split("/")[0].split("#")[0] 93 | rating = int(row[3]) 94 | 95 | if game_id in mapping: 96 | pgn = fetch_game_moves(games_filename, game_id, mapping[game_id]) 97 | game = convert_pgn_to_game(pgn) 98 | if game is None: continue 99 | 100 | board = game.board() 101 | 102 | for move in list(game.mainline_moves())[:move_num-1]: 103 | board.push(move) 104 | 105 | new_board = board.copy() 106 | 107 | try: 108 | solution = [] 109 | for move in uci_moves: 110 | m = chess.Move.from_uci(move) 111 | solution.append(new_board.san(m)) 112 | new_board.push(m) 113 | except: 114 | print("Board import failed") 115 | continue 116 | 117 | extracted_puzzles.append((row[0], 118 | rating, 119 | board, 120 | solution, 121 | )) 122 | print(len(extracted_puzzles)) 123 | 124 | with open("pgn_puzzles.csv", "w") as f: 125 | writer = csv.writer(f) 126 | for uid, rating, board, solution in extracted_puzzles: 127 | writer.writerow((uid, rating, 128 | str(chess.pgn.Game().from_board(board)).split("\n")[-1][:-2], 129 | " ".join(solution))) 130 | 131 | 132 | 133 | if __name__ == "__main__": 134 | get_data() 135 | 136 | filename = "lichess_db_standard_rated_2016-02.pgn" 137 | mapping = generate_mapping(filename) 138 | 139 | with open('mapping.pickle', 'wb') as f: 140 | pickle.dump(mapping, f) 141 | 142 | process_puzzles("lichess_db_puzzle.csv", filename, pickle.load(open("mapping.pickle","rb"))) 143 | -------------------------------------------------------------------------------- /chessllm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ## Copyright (C) 2023, Nicholas Carlini . 4 | ## 5 | ## This program is free software: you can redistribute it and/or modify 6 | ## it under the terms of the GNU General Public License as published by 7 | ## the Free Software Foundation, either version 3 of the License, or 8 | ## (at your option) any later version. 9 | ## 10 | ## This program is distributed in the hope that it will be useful, 11 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | ## GNU General Public License for more details. 14 | ## 15 | ## You should have received a copy of the GNU General Public License 16 | ## along with this program. If not, see . 17 | 18 | import requests 19 | import json 20 | 21 | import os 22 | import chess 23 | import chess.engine 24 | import chess.pgn 25 | import random 26 | import pickle 27 | import sys 28 | 29 | class ChessLLM: 30 | def __init__(self, api_key, config, **override): 31 | self.config = config 32 | for k,v in override.items(): 33 | config[k] = v 34 | if os.path.exists("cache.p"): 35 | with open("cache.p", "rb") as f: 36 | self.cache = pickle.load(f) 37 | else: 38 | self.cache = {} 39 | print("Loading cache with", len(self.cache), "entries") 40 | self.api_key = api_key 41 | 42 | def get_query_pgn(self, board): 43 | pgn = str(chess.pgn.Game().from_board(board)) 44 | 45 | if board.outcome() is None: 46 | pgn = pgn[:-1].strip() 47 | else: 48 | print("Game is over; no moves valid") 49 | return None 50 | 51 | if board.turn == chess.WHITE: 52 | if board.fullmove_number == 1: 53 | pgn = pgn + "\n\n1." 54 | else: 55 | pgn += ' '+str(board.fullmove_number)+"." 56 | 57 | with_header = f"""[White "Magnus Carlsen"]\n[Black "Garry Kasparov"]\n[WhiteElo "2900"]\n[BlackElo "2800"]\n\n"""+pgn.split("\n\n")[1] 58 | 59 | return with_header 60 | 61 | def try_moves(self, board, next_text): 62 | board = board.copy() 63 | moves = next_text.split() 64 | ok_moves = [] 65 | for move in moves: 66 | if '.' in move: 67 | continue 68 | try: 69 | board.push_san(move) 70 | ok_moves.append(move) 71 | except: 72 | break 73 | 74 | return ok_moves 75 | 76 | def get_best_move(self, board, num_tokens=None, conversation=None): 77 | if num_tokens is None: 78 | num_tokens = self.config['num_lookahead_tokens'] 79 | assert num_tokens >= 9, "A single move might take as many as 9 tokens (3 for the number + 6 for, e.g., 'N3xg5+)." 80 | 81 | if board.fen() in self.cache: 82 | out = self.cache[board.fen()] 83 | if conversation: 84 | if board.ply() > 0: 85 | conversation.send_message("player", f"You played a move already in my cache (because I predicted it or someone already played it)! Returning {out}.") 86 | conversation.send_message("spectator", f"Player played a move already in my cache (because I predicted it or someone already played it). Returning {out}.") 87 | return out 88 | 89 | pgn_to_query = self.get_query_pgn(board) 90 | 91 | if conversation: 92 | conversation.send_message("player", f"Querying {self.config['model']} with ... {pgn_to_query.split(']')[-1][-90:]}") 93 | conversation.send_message("spectator", f"Querying {self.config['model']} with ... {pgn_to_query.split(']')[-1][-90:]}") 94 | 95 | next_text = self.make_request(pgn_to_query, num_tokens) 96 | if next_text[:2] == "-O": 97 | next_text = self.make_request(pgn_to_query+" ", num_tokens) 98 | 99 | if conversation: 100 | conversation.send_message("spectator", f"Received reply of '{next_text}'") 101 | 102 | next_moves = self.try_moves(board, next_text) 103 | 104 | if len(next_moves) == 0: 105 | conversation.send_message("player", f"Tried to make an invalid move.") 106 | conversation.send_message("spectator", f"Tried to make an invalid move.") 107 | return None 108 | 109 | if conversation: 110 | conversation.send_message("player", f"Received reply and making move {next_moves[0]}.") 111 | 112 | new_board = board.copy() 113 | for move in next_moves: 114 | self.cache[new_board.fen()] = move 115 | new_board.push_san(move) 116 | 117 | with open("cache.p", "wb") as f: 118 | pickle.dump(self.cache, f) 119 | return next_moves[0] 120 | 121 | def make_request(self, content, num_tokens): 122 | url = "https://api.openai.com/v1/completions" 123 | headers = { 124 | "Content-Type": "application/json", 125 | "Authorization": f"Bearer {self.api_key}" 126 | } 127 | data = { 128 | "model": self.config['model'], 129 | "prompt": content, 130 | "temperature": self.config['temperature'], 131 | "max_tokens": num_tokens, 132 | } 133 | 134 | 135 | #sys.stderr.write(repr(data)+"\n") 136 | response = requests.post(url, headers=headers, data=json.dumps(data)) 137 | response = response.json()['choices'][0]['text'] 138 | #sys.stderr.write(response+"\n") 139 | 140 | return response 141 | 142 | 143 | -------------------------------------------------------------------------------- /lichess-bot/test_bot/lichess.py: -------------------------------------------------------------------------------- 1 | """Imitate `lichess.py`. Used in tests.""" 2 | import time 3 | import chess 4 | import chess.engine 5 | import json 6 | import logging 7 | import traceback 8 | from timer import seconds, to_msec 9 | from typing import Union, Any, Optional, Generator 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def backoff_handler(details: Any) -> None: 15 | """Log exceptions inside functions with the backoff decorator.""" 16 | logger.debug("Backing off {wait:0.1f} seconds after {tries} tries " 17 | "calling function {target} with args {args} and kwargs {kwargs}".format(**details)) 18 | logger.debug(f"Exception: {traceback.format_exc()}") 19 | 20 | 21 | class GameStream: 22 | """Imitate lichess.org's GameStream. Used in tests.""" 23 | 24 | def __init__(self) -> None: 25 | """Initialize `self.moves_sent` to an empty string. It stores the moves that we have already sent.""" 26 | self.moves_sent = "" 27 | 28 | def iter_lines(self) -> Generator[bytes, None, None]: 29 | """Send the game events to lichess-bot.""" 30 | yield json.dumps( 31 | {"id": "zzzzzzzz", 32 | "variant": {"key": "standard", 33 | "name": "Standard", 34 | "short": "Std"}, 35 | "clock": {"initial": 60000, 36 | "increment": 2000}, 37 | "speed": "bullet", 38 | "perf": {"name": "Bullet"}, 39 | "rated": True, 40 | "createdAt": 1600000000000, 41 | "white": {"id": "bo", 42 | "name": "bo", 43 | "title": "BOT", 44 | "rating": 3000}, 45 | "black": {"id": "b", 46 | "name": "b", 47 | "title": "BOT", 48 | "rating": 3000, 49 | "provisional": True}, 50 | "initialFen": "startpos", 51 | "type": "gameFull", 52 | "state": {"type": "gameState", 53 | "moves": "", 54 | "wtime": 10000, 55 | "btime": 10000, 56 | "winc": 100, 57 | "binc": 100, 58 | "status": "started"}}).encode("utf-8") 59 | time.sleep(1) 60 | while True: 61 | time.sleep(0.001) 62 | with open("./logs/events.txt") as events: 63 | event = events.read() 64 | while True: 65 | try: 66 | with open("./logs/states.txt") as states: 67 | state = states.read().split("\n") 68 | moves = state[0] 69 | board = chess.Board() 70 | for move in moves.split(): 71 | board.push_uci(move) 72 | wtime, btime = [seconds(float(n)) for n in state[1].split(",")] 73 | if len(moves) <= len(self.moves_sent) and not event: 74 | time.sleep(0.001) 75 | continue 76 | self.moves_sent = moves 77 | break 78 | except (IndexError, ValueError): 79 | pass 80 | time.sleep(0.1) 81 | new_game_state = {"type": "gameState", 82 | "moves": moves, 83 | "wtime": int(to_msec(wtime)), 84 | "btime": int(to_msec(btime)), 85 | "winc": 100, 86 | "binc": 100} 87 | if event == "end": 88 | new_game_state["status"] = "outoftime" 89 | new_game_state["winner"] = "black" 90 | yield json.dumps(new_game_state).encode("utf-8") 91 | break 92 | if moves: 93 | new_game_state["status"] = "started" 94 | yield json.dumps(new_game_state).encode("utf-8") 95 | 96 | 97 | class EventStream: 98 | """Imitate lichess.org's EventStream. Used in tests.""" 99 | 100 | def __init__(self, sent_game: bool = False) -> None: 101 | """:param sent_game: If we have already sent the `gameStart` event, so we don't send it again.""" 102 | self.sent_game = sent_game 103 | 104 | def iter_lines(self) -> Generator[bytes, None, None]: 105 | """Send the events to lichess-bot.""" 106 | if self.sent_game: 107 | yield b'' 108 | time.sleep(1) 109 | else: 110 | yield json.dumps( 111 | {"type": "gameStart", 112 | "game": {"id": "zzzzzzzz", 113 | "source": "friend", 114 | "compat": {"bot": True, 115 | "board": True}}}).encode("utf-8") 116 | 117 | 118 | # Docs: https://lichess.org/api. 119 | class Lichess: 120 | """Imitate communication with lichess.org.""" 121 | 122 | def __init__(self, token: str, url: str, version: str) -> None: 123 | """Has the same parameters as `lichess.Lichess` to be able to be used in its placed without any modification.""" 124 | self.baseUrl = url 125 | self.game_accepted = False 126 | self.moves: list[chess.engine.PlayResult] = [] 127 | self.sent_game = False 128 | 129 | def upgrade_to_bot_account(self) -> None: 130 | """Isn't used in tests.""" 131 | return 132 | 133 | def make_move(self, game_id: str, move: chess.engine.PlayResult) -> None: 134 | """Write a move to `./logs/states.txt`, to be read by the opponent.""" 135 | self.moves.append(move) 136 | uci_move = move.move.uci() if move.move else "error" 137 | with open("./logs/states.txt") as file: 138 | contents = file.read().split("\n") 139 | contents[0] += f" {uci_move}" 140 | with open("./logs/states.txt", "w") as file: 141 | file.write("\n".join(contents)) 142 | 143 | def chat(self, game_id: str, room: str, text: str) -> None: 144 | """Isn't used in tests.""" 145 | return 146 | 147 | def abort(self, game_id: str) -> None: 148 | """Isn't used in tests.""" 149 | return 150 | 151 | def get_event_stream(self) -> EventStream: 152 | """Send the `EventStream`.""" 153 | events = EventStream(self.sent_game) 154 | self.sent_game = True 155 | return events 156 | 157 | def get_game_stream(self, game_id: str) -> GameStream: 158 | """Send the `GameStream`.""" 159 | return GameStream() 160 | 161 | def accept_challenge(self, challenge_id: str) -> None: 162 | """Set `self.game_accepted` to true.""" 163 | self.game_accepted = True 164 | 165 | def decline_challenge(self, challenge_id: str, reason: str = "generic") -> None: 166 | """Isn't used in tests.""" 167 | return 168 | 169 | def get_profile(self) -> dict[str, Union[str, bool, dict[str, str]]]: 170 | """Return a simple profile for the bot that lichess-bot uses when testing.""" 171 | return {"id": "b", 172 | "username": "b", 173 | "online": True, 174 | "title": "BOT", 175 | "url": "https://lichess.org/@/b", 176 | "followable": True, 177 | "following": False, 178 | "blocking": False, 179 | "followsYou": False, 180 | "perfs": {}} 181 | 182 | def get_ongoing_games(self) -> list[str]: 183 | """Return that the bot isn't playing a game.""" 184 | return [] 185 | 186 | def resign(self, game_id: str) -> None: 187 | """Isn't used in tests.""" 188 | return 189 | 190 | def get_game_pgn(self, game_id: str) -> str: 191 | """Return a simple PGN.""" 192 | return """ 193 | [Event "Test game"] 194 | [Site "pytest"] 195 | [Date "2022.03.11"] 196 | [Round "1"] 197 | [White "Engine"] 198 | [Black "Engine"] 199 | [Result "0-1"] 200 | 201 | * 202 | """ 203 | 204 | def get_online_bots(self) -> list[dict[str, Union[str, bool]]]: 205 | """Return that the only bot online is us.""" 206 | return [{"username": "b", "online": True}] 207 | 208 | def challenge(self, username: str, params: dict[str, str]) -> None: 209 | """Isn't used in tests.""" 210 | return 211 | 212 | def cancel(self, challenge_id: str) -> None: 213 | """Isn't used in tests.""" 214 | return 215 | 216 | def online_book_get(self, path: str, params: Optional[dict[str, str]] = None) -> None: 217 | """Isn't used in tests.""" 218 | return 219 | 220 | def is_online(self, user_id: str) -> bool: 221 | """Return that a bot is online.""" 222 | return True 223 | -------------------------------------------------------------------------------- /lichess-bot/test_bot/test_bot.py: -------------------------------------------------------------------------------- 1 | """Test lichess-bot.""" 2 | import pytest 3 | import zipfile 4 | import requests 5 | import time 6 | import yaml 7 | import chess 8 | import chess.engine 9 | import threading 10 | import os 11 | import sys 12 | import stat 13 | import shutil 14 | import importlib 15 | import config 16 | from timer import Timer, to_seconds, seconds 17 | from typing import Any 18 | if __name__ == "__main__": 19 | sys.exit(f"The script {os.path.basename(__file__)} should only be run by pytest.") 20 | shutil.copyfile("lichess.py", "correct_lichess.py") 21 | shutil.copyfile("test_bot/lichess.py", "lichess.py") 22 | lichess_bot = importlib.import_module("lichess-bot") 23 | 24 | platform = sys.platform 25 | file_extension = ".exe" if platform == "win32" else "" 26 | stockfish_path = f"./TEMP/sf{file_extension}" 27 | 28 | 29 | def download_sf() -> None: 30 | """Download Stockfish 15.""" 31 | if os.path.exists(stockfish_path): 32 | return 33 | windows_or_linux = "win" if platform == "win32" else "linux" 34 | base_name = f"stockfish_15_{windows_or_linux}_x64" 35 | exec_name = "stockfish_15_x64" 36 | zip_link = f"https://files.stockfishchess.org/files/{base_name}.zip" 37 | response = requests.get(zip_link, allow_redirects=True) 38 | with open("./TEMP/sf_zip.zip", "wb") as file: 39 | file.write(response.content) 40 | with zipfile.ZipFile("./TEMP/sf_zip.zip", "r") as zip_ref: 41 | zip_ref.extractall("./TEMP/") 42 | shutil.copyfile(f"./TEMP/{base_name}/{exec_name}{file_extension}", stockfish_path) 43 | if windows_or_linux == "linux": 44 | st = os.stat(stockfish_path) 45 | os.chmod(stockfish_path, st.st_mode | stat.S_IEXEC) 46 | 47 | 48 | def download_lc0() -> None: 49 | """Download Leela Chess Zero 0.29.0.""" 50 | if os.path.exists("./TEMP/lc0.exe"): 51 | return 52 | response = requests.get("https://github.com/LeelaChessZero/lc0/releases/download/v0.29.0/lc0-v0.29.0-windows-cpu-dnnl.zip", 53 | allow_redirects=True) 54 | with open("./TEMP/lc0_zip.zip", "wb") as file: 55 | file.write(response.content) 56 | with zipfile.ZipFile("./TEMP/lc0_zip.zip", "r") as zip_ref: 57 | zip_ref.extractall("./TEMP/") 58 | 59 | 60 | def download_sjeng() -> None: 61 | """Download Sjeng.""" 62 | if os.path.exists("./TEMP/sjeng.exe"): 63 | return 64 | response = requests.get("https://sjeng.org/ftp/Sjeng112.zip", allow_redirects=True) 65 | with open("./TEMP/sjeng_zip.zip", "wb") as file: 66 | file.write(response.content) 67 | with zipfile.ZipFile("./TEMP/sjeng_zip.zip", "r") as zip_ref: 68 | zip_ref.extractall("./TEMP/") 69 | shutil.copyfile("./TEMP/Release/Sjeng112.exe", "./TEMP/sjeng.exe") 70 | 71 | 72 | if not os.path.exists("TEMP"): 73 | os.mkdir("TEMP") 74 | download_sf() 75 | if platform == "win32": 76 | download_lc0() 77 | download_sjeng() 78 | logging_level = lichess_bot.logging.DEBUG 79 | lichess_bot.logging_configurer(logging_level, None, None, False) 80 | lichess_bot.logger.info("Downloaded engines") 81 | 82 | 83 | def thread_for_test() -> None: 84 | """Play the moves for the opponent of lichess-bot.""" 85 | open("./logs/events.txt", "w").close() 86 | open("./logs/states.txt", "w").close() 87 | open("./logs/result.txt", "w").close() 88 | 89 | start_time = seconds(10) 90 | increment = seconds(0.1) 91 | 92 | board = chess.Board() 93 | wtime = start_time 94 | btime = start_time 95 | 96 | with open("./logs/states.txt", "w") as file: 97 | file.write(f"\n{to_seconds(wtime)},{to_seconds(btime)}") 98 | 99 | engine = chess.engine.SimpleEngine.popen_uci(stockfish_path) 100 | engine.configure({"Skill Level": 0, "Move Overhead": 1000, "Use NNUE": False}) 101 | 102 | while not board.is_game_over(): 103 | if len(board.move_stack) % 2 == 0: 104 | if not board.move_stack: 105 | move = engine.play(board, 106 | chess.engine.Limit(time=1), 107 | ponder=False) 108 | else: 109 | move_timer = Timer() 110 | move = engine.play(board, 111 | chess.engine.Limit(white_clock=to_seconds(wtime) - 2, 112 | white_inc=to_seconds(increment)), 113 | ponder=False) 114 | wtime -= move_timer.time_since_reset() 115 | wtime += increment 116 | engine_move = move.move 117 | if engine_move is None: 118 | raise RuntimeError("Engine attempted to make null move.") 119 | board.push(engine_move) 120 | 121 | uci_move = engine_move.uci() 122 | with open("./logs/states.txt") as states: 123 | state_str = states.read() 124 | state = state_str.split("\n") 125 | state[0] += f" {uci_move}" 126 | state_str = "\n".join(state) 127 | with open("./logs/states.txt", "w") as file: 128 | file.write(state_str) 129 | 130 | else: # lichess-bot move. 131 | move_timer = Timer() 132 | state2 = state_str 133 | moves_are_correct = False 134 | while state2 == state_str or not moves_are_correct: 135 | with open("./logs/states.txt") as states: 136 | state2 = states.read() 137 | time.sleep(0.001) 138 | moves = state2.split("\n")[0] 139 | temp_board = chess.Board() 140 | moves_are_correct = True 141 | for move_str in moves.split(): 142 | try: 143 | temp_board.push_uci(move_str) 144 | except ValueError: 145 | moves_are_correct = False 146 | with open("./logs/states.txt") as states: 147 | state2 = states.read() 148 | if len(board.move_stack) > 1: 149 | btime -= move_timer.time_since_reset() 150 | btime += increment 151 | move_str = state2.split("\n")[0].split(" ")[-1] 152 | board.push_uci(move_str) 153 | 154 | time.sleep(0.001) 155 | with open("./logs/states.txt") as states: 156 | state_str = states.read() 157 | state = state_str.split("\n") 158 | state[1] = f"{to_seconds(wtime)},{to_seconds(btime)}" 159 | state_str = "\n".join(state) 160 | with open("./logs/states.txt", "w") as file: 161 | file.write(state_str) 162 | 163 | with open("./logs/events.txt", "w") as file: 164 | file.write("end") 165 | engine.quit() 166 | outcome = board.outcome() 167 | win = outcome.winner == chess.BLACK if outcome else False 168 | with open("./logs/result.txt", "w") as file: 169 | file.write("1" if win else "0") 170 | 171 | 172 | def run_bot(raw_config: dict[str, Any], logging_level: int) -> str: 173 | """Start lichess-bot.""" 174 | config.insert_default_values(raw_config) 175 | CONFIG = config.Configuration(raw_config) 176 | lichess_bot.logger.info(lichess_bot.intro()) 177 | li = lichess_bot.lichess.Lichess(CONFIG.token, CONFIG.url, lichess_bot.__version__) 178 | 179 | user_profile = li.get_profile() 180 | username = user_profile["username"] 181 | if user_profile.get("title") != "BOT": 182 | return "0" 183 | lichess_bot.logger.info(f"Welcome {username}!") 184 | lichess_bot.disable_restart() 185 | 186 | thr = threading.Thread(target=thread_for_test) 187 | thr.start() 188 | lichess_bot.start(li, user_profile, CONFIG, logging_level, None, None, one_game=True) 189 | thr.join() 190 | 191 | with open("./logs/result.txt") as file: 192 | data = file.read() 193 | return data 194 | 195 | 196 | @pytest.mark.timeout(150, method="thread") 197 | def test_sf() -> None: 198 | """Test lichess-bot with Stockfish (UCI).""" 199 | if platform != "linux" and platform != "win32": 200 | assert True 201 | return 202 | if os.path.exists("logs"): 203 | shutil.rmtree("logs") 204 | os.mkdir("logs") 205 | with open("./config.yml.default") as file: 206 | CONFIG = yaml.safe_load(file) 207 | CONFIG["token"] = "" 208 | CONFIG["engine"]["dir"] = "./TEMP/" 209 | CONFIG["engine"]["name"] = f"sf{file_extension}" 210 | CONFIG["engine"]["uci_options"]["Threads"] = 1 211 | CONFIG["pgn_directory"] = "TEMP/sf_game_record" 212 | win = run_bot(CONFIG, logging_level) 213 | shutil.rmtree("logs") 214 | lichess_bot.logger.info("Finished Testing SF") 215 | assert win == "1" 216 | assert os.path.isfile(os.path.join(CONFIG["pgn_directory"], 217 | "bo vs b - zzzzzzzz.pgn")) 218 | 219 | 220 | @pytest.mark.timeout(150, method="thread") 221 | def test_lc0() -> None: 222 | """Test lichess-bot with Leela Chess Zero (UCI).""" 223 | if platform != "win32": 224 | assert True 225 | return 226 | if os.path.exists("logs"): 227 | shutil.rmtree("logs") 228 | os.mkdir("logs") 229 | with open("./config.yml.default") as file: 230 | CONFIG = yaml.safe_load(file) 231 | CONFIG["token"] = "" 232 | CONFIG["engine"]["dir"] = "./TEMP/" 233 | CONFIG["engine"]["working_dir"] = "./TEMP/" 234 | CONFIG["engine"]["name"] = "lc0.exe" 235 | CONFIG["engine"]["uci_options"]["Threads"] = 1 236 | CONFIG["engine"]["uci_options"].pop("Hash", None) 237 | CONFIG["engine"]["uci_options"].pop("Move Overhead", None) 238 | CONFIG["pgn_directory"] = "TEMP/lc0_game_record" 239 | win = run_bot(CONFIG, logging_level) 240 | shutil.rmtree("logs") 241 | lichess_bot.logger.info("Finished Testing LC0") 242 | assert win == "1" 243 | assert os.path.isfile(os.path.join(CONFIG["pgn_directory"], 244 | "bo vs b - zzzzzzzz.pgn")) 245 | 246 | 247 | @pytest.mark.timeout(150, method="thread") 248 | def test_sjeng() -> None: 249 | """Test lichess-bot with Sjeng (XBoard).""" 250 | if platform != "win32": 251 | assert True 252 | return 253 | if os.path.exists("logs"): 254 | shutil.rmtree("logs") 255 | os.mkdir("logs") 256 | with open("./config.yml.default") as file: 257 | CONFIG = yaml.safe_load(file) 258 | CONFIG["token"] = "" 259 | CONFIG["engine"]["dir"] = "./TEMP/" 260 | CONFIG["engine"]["working_dir"] = "./TEMP/" 261 | CONFIG["engine"]["protocol"] = "xboard" 262 | CONFIG["engine"]["name"] = "sjeng.exe" 263 | CONFIG["engine"]["ponder"] = False 264 | CONFIG["pgn_directory"] = "TEMP/sjeng_game_record" 265 | win = run_bot(CONFIG, logging_level) 266 | shutil.rmtree("logs") 267 | lichess_bot.logger.info("Finished Testing Sjeng") 268 | assert win == "1" 269 | assert os.path.isfile(os.path.join(CONFIG["pgn_directory"], 270 | "bo vs b - zzzzzzzz.pgn")) 271 | 272 | 273 | @pytest.mark.timeout(150, method="thread") 274 | def test_homemade() -> None: 275 | """Test lichess-bot with a homemade engine running Stockfish (Homemade).""" 276 | if platform != "linux" and platform != "win32": 277 | assert True 278 | return 279 | with open("strategies.py") as file: 280 | original_strategies = file.read() 281 | 282 | with open("strategies.py", "a") as file: 283 | file.write(f""" 284 | class Stockfish(ExampleEngine): 285 | def __init__(self, commands, options, stderr, draw_or_resign, **popen_args): 286 | super().__init__(commands, options, stderr, draw_or_resign, **popen_args) 287 | import chess 288 | self.engine = chess.engine.SimpleEngine.popen_uci('{stockfish_path}') 289 | 290 | def search(self, board, time_limit, *args): 291 | return self.engine.play(board, time_limit) 292 | """) 293 | if os.path.exists("logs"): 294 | shutil.rmtree("logs") 295 | os.mkdir("logs") 296 | with open("./config.yml.default") as file: 297 | CONFIG = yaml.safe_load(file) 298 | CONFIG["token"] = "" 299 | CONFIG["engine"]["name"] = "Stockfish" 300 | CONFIG["engine"]["protocol"] = "homemade" 301 | CONFIG["pgn_directory"] = "TEMP/homemade_game_record" 302 | win = run_bot(CONFIG, logging_level) 303 | shutil.rmtree("logs") 304 | with open("strategies.py", "w") as file: 305 | file.write(original_strategies) 306 | lichess_bot.logger.info("Finished Testing Homemade") 307 | assert win == "1" 308 | assert os.path.isfile(os.path.join(CONFIG["pgn_directory"], 309 | "bo vs b - zzzzzzzz.pgn")) 310 | -------------------------------------------------------------------------------- /lichess-bot/model.py: -------------------------------------------------------------------------------- 1 | """Store information about a challenge, game or player in a class.""" 2 | import math 3 | from urllib.parse import urljoin 4 | import logging 5 | import datetime 6 | from enum import Enum 7 | from timer import Timer, msec, seconds, sec_str, to_msec, to_seconds, years 8 | from config import Configuration 9 | from typing import Any 10 | from collections import defaultdict 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class Challenge: 16 | """Store information about a challenge.""" 17 | 18 | def __init__(self, challenge_info: dict[str, Any], user_profile: dict[str, Any]) -> None: 19 | """:param user_profile: Information about our bot.""" 20 | self.id = challenge_info["id"] 21 | self.rated = challenge_info["rated"] 22 | self.variant = challenge_info["variant"]["key"] 23 | self.perf_name = challenge_info["perf"]["name"] 24 | self.speed = challenge_info["speed"] 25 | self.increment: int = challenge_info.get("timeControl", {}).get("increment") 26 | self.base: int = challenge_info.get("timeControl", {}).get("limit") 27 | self.days: int = challenge_info.get("timeControl", {}).get("daysPerTurn") 28 | self.challenger = Player(challenge_info.get("challenger") or {}) 29 | self.opponent = Player(challenge_info.get("destUser") or {}) 30 | self.from_self = self.challenger.name == user_profile["username"] 31 | 32 | def is_supported_variant(self, challenge_cfg: Configuration) -> bool: 33 | """Check whether the variant is supported.""" 34 | return self.variant in challenge_cfg.variants 35 | 36 | def is_supported_time_control(self, challenge_cfg: Configuration) -> bool: 37 | """Check whether the time control is supported.""" 38 | speeds = challenge_cfg.time_controls 39 | increment_max: int = challenge_cfg.max_increment 40 | increment_min: int = challenge_cfg.min_increment 41 | base_max: int = challenge_cfg.max_base 42 | base_min: int = challenge_cfg.min_base 43 | days_max: int = challenge_cfg.max_days 44 | days_min: int = challenge_cfg.min_days 45 | 46 | if self.speed not in speeds: 47 | return False 48 | 49 | require_non_zero_increment = (self.challenger.is_bot 50 | and self.speed == "bullet" 51 | and challenge_cfg.bullet_requires_increment) 52 | increment_min = max(increment_min, 1 if require_non_zero_increment else 0) 53 | 54 | if self.base is not None and self.increment is not None: 55 | # Normal clock game 56 | return (increment_min <= self.increment <= increment_max 57 | and base_min <= self.base <= base_max) 58 | elif self.days is not None: 59 | # Correspondence game 60 | return days_min <= self.days <= days_max 61 | else: 62 | # Unlimited game 63 | return days_max == math.inf 64 | 65 | def is_supported_mode(self, challenge_cfg: Configuration) -> bool: 66 | """Check whether the mode is supported.""" 67 | return ("rated" if self.rated else "casual") in challenge_cfg.modes 68 | 69 | def is_supported_recent(self, config: Configuration, recent_bot_challenges: defaultdict[str, list[Timer]]) -> bool: 70 | """Check whether we have played a lot of games with this opponent recently. Only used when the oppoennt is a BOT.""" 71 | # Filter out old challenges 72 | recent_bot_challenges[self.challenger.name] = [timer for timer 73 | in recent_bot_challenges[self.challenger.name] 74 | if not timer.is_expired()] 75 | max_recent_challenges = config.max_recent_bot_challenges 76 | return (not self.challenger.is_bot 77 | or max_recent_challenges is None 78 | or len(recent_bot_challenges[self.challenger.name]) < max_recent_challenges) 79 | 80 | def decline_due_to(self, requirement_met: bool, decline_reason: str) -> str: 81 | """ 82 | Get the reason lichess-bot declined an incoming challenge. 83 | 84 | :param requirement_met: Whether a requirement is met. 85 | :param decline_reason: The reason we declined the challenge if the requirement wasn't met. 86 | :return: `decline_reason` if `requirement_met` is false else returns an empty string. 87 | """ 88 | return "" if requirement_met else decline_reason 89 | 90 | def is_supported(self, config: Configuration, 91 | recent_bot_challenges: defaultdict[str, list[Timer]]) -> tuple[bool, str]: 92 | """Whether the challenge is supported.""" 93 | try: 94 | if self.from_self: 95 | return True, "" 96 | 97 | allowed_opponents: list[str] = list(filter(None, config.allow_list)) or [self.challenger.name] 98 | decline_reason = (self.decline_due_to(config.accept_bot or not self.challenger.is_bot, "noBot") 99 | or self.decline_due_to(not config.only_bot or self.challenger.is_bot, "onlyBot") 100 | or self.decline_due_to(self.is_supported_time_control(config), "timeControl") 101 | or self.decline_due_to(self.is_supported_variant(config), "variant") 102 | or self.decline_due_to(self.is_supported_mode(config), "casual" if self.rated else "rated") 103 | or self.decline_due_to(self.challenger.name not in config.block_list, "generic") 104 | or self.decline_due_to(self.challenger.name in allowed_opponents, "generic") 105 | or self.decline_due_to(self.is_supported_recent(config, recent_bot_challenges), "later")) 106 | 107 | return not decline_reason, decline_reason 108 | 109 | except Exception: 110 | logger.exception(f"Error while checking challenge {self.id}:") 111 | return False, "generic" 112 | 113 | def score(self) -> int: 114 | """Give a rating estimate to the opponent.""" 115 | rated_bonus = 200 if self.rated else 0 116 | challenger_master_title = self.challenger.title if not self.challenger.is_bot else None 117 | titled_bonus = 200 if challenger_master_title else 0 118 | challenger_rating_int = self.challenger.rating or 0 119 | return challenger_rating_int + rated_bonus + titled_bonus 120 | 121 | def mode(self) -> str: 122 | """Get the mode of the challenge (rated or casual).""" 123 | return "rated" if self.rated else "casual" 124 | 125 | def __str__(self) -> str: 126 | """Get a string representation of `Challenge`.""" 127 | return f"{self.perf_name} {self.mode()} challenge from {self.challenger} ({self.id})" 128 | 129 | def __repr__(self) -> str: 130 | """Get a string representation of `Challenge`.""" 131 | return self.__str__() 132 | 133 | 134 | class Termination(str, Enum): 135 | """The possible game terminations.""" 136 | 137 | MATE = "mate" 138 | TIMEOUT = "outoftime" 139 | RESIGN = "resign" 140 | ABORT = "aborted" 141 | DRAW = "draw" 142 | 143 | 144 | class Game: 145 | """Store information about a game.""" 146 | 147 | def __init__(self, game_info: dict[str, Any], username: str, base_url: str, abort_time: datetime.timedelta) -> None: 148 | """:param abort_time: How long to wait before aborting the game.""" 149 | self.username = username 150 | self.id: str = game_info["id"] 151 | self.speed = game_info.get("speed") 152 | clock = game_info.get("clock") or {} 153 | ten_years_in_ms = to_msec(years(10)) 154 | self.clock_initial = msec(clock.get("initial", ten_years_in_ms)) 155 | self.clock_increment = msec(clock.get("increment", 0)) 156 | self.perf_name = (game_info.get("perf") or {}).get("name", "{perf?}") 157 | self.variant_name = game_info["variant"]["name"] 158 | self.mode = "rated" if game_info.get("rated") else "casual" 159 | self.white = Player(game_info["white"]) 160 | self.black = Player(game_info["black"]) 161 | self.initial_fen = game_info.get("initialFen") 162 | self.state: dict[str, Any] = game_info["state"] 163 | self.is_white = (self.white.name or "").lower() == username.lower() 164 | self.my_color = "white" if self.is_white else "black" 165 | self.opponent_color = "black" if self.is_white else "white" 166 | self.me = self.white if self.is_white else self.black 167 | self.opponent = self.black if self.is_white else self.white 168 | self.base_url = base_url 169 | self.game_start = datetime.datetime.fromtimestamp(to_seconds(msec(game_info["createdAt"])), 170 | tz=datetime.timezone.utc) 171 | self.abort_time = Timer(abort_time) 172 | self.terminate_time = Timer(self.clock_initial + self.clock_increment + abort_time + seconds(60)) 173 | self.disconnect_time = Timer(seconds(0)) 174 | 175 | def url(self) -> str: 176 | """Get the url of the game.""" 177 | return f"{self.short_url()}/{self.my_color}" 178 | 179 | def short_url(self) -> str: 180 | """Get the short url of the game.""" 181 | return urljoin(self.base_url, self.id) 182 | 183 | def pgn_event(self) -> str: 184 | """Get the event to write in the PGN file.""" 185 | if self.variant_name in ["Standard", "From Position"]: 186 | return f"{self.mode.title()} {self.perf_name.title()} game" 187 | else: 188 | return f"{self.mode.title()} {self.variant_name} game" 189 | 190 | def time_control(self) -> str: 191 | """Get the time control of the game.""" 192 | return f"{sec_str(self.clock_initial)}+{sec_str(self.clock_increment)}" 193 | 194 | def is_abortable(self) -> bool: 195 | """Whether the game can be aborted.""" 196 | # Moves are separated by spaces. A game is abortable when less 197 | # than two moves (one from each player) have been played. 198 | return " " not in self.state["moves"] 199 | 200 | def ping(self, abort_in: datetime.timedelta, terminate_in: datetime.timedelta, disconnect_in: datetime.timedelta) -> None: 201 | """ 202 | Tell the bot when to abort, terminate, and disconnect from a game. 203 | 204 | :param abort_in: How many seconds to wait before aborting. 205 | :param terminate_in: How many seconds to wait before terminating. 206 | :param disconnect_in: How many seconds to wait before disconnecting. 207 | """ 208 | if self.is_abortable(): 209 | self.abort_time = Timer(abort_in) 210 | self.terminate_time = Timer(terminate_in) 211 | self.disconnect_time = Timer(disconnect_in) 212 | 213 | def should_abort_now(self) -> bool: 214 | """Whether we should abort the game.""" 215 | return self.is_abortable() and self.abort_time.is_expired() 216 | 217 | def should_terminate_now(self) -> bool: 218 | """Whether we should terminate the game.""" 219 | return self.terminate_time.is_expired() 220 | 221 | def should_disconnect_now(self) -> bool: 222 | """Whether we should disconnect form the game.""" 223 | return self.disconnect_time.is_expired() 224 | 225 | def my_remaining_time(self) -> datetime.timedelta: 226 | """How many seconds we have left.""" 227 | wtime = msec(self.state["wtime"]) 228 | btime = msec(self.state["btime"]) 229 | return wtime if self.is_white else btime 230 | 231 | def result(self) -> str: 232 | """Get the result of the game.""" 233 | class GameEnding(str, Enum): 234 | WHITE_WINS = "1-0" 235 | BLACK_WINS = "0-1" 236 | DRAW = "1/2-1/2" 237 | INCOMPLETE = "*" 238 | 239 | winner = self.state.get("winner") 240 | termination = self.state.get("status") 241 | 242 | if winner == "white": 243 | result = GameEnding.WHITE_WINS 244 | elif winner == "black": 245 | result = GameEnding.BLACK_WINS 246 | elif termination in [Termination.DRAW, Termination.TIMEOUT]: 247 | result = GameEnding.DRAW 248 | else: 249 | result = GameEnding.INCOMPLETE 250 | 251 | return result.value 252 | 253 | def __str__(self) -> str: 254 | """Get a string representation of `Game`.""" 255 | return f"{self.url()} {self.perf_name} vs {self.opponent} ({self.id})" 256 | 257 | def __repr__(self) -> str: 258 | """Get a string representation of `Game`.""" 259 | return self.__str__() 260 | 261 | 262 | class Player: 263 | """Store information about a player.""" 264 | 265 | def __init__(self, player_info: dict[str, Any]) -> None: 266 | """:param player_info: Contains information about a player.""" 267 | self.title = player_info.get("title") 268 | self.rating = player_info.get("rating") 269 | self.provisional = player_info.get("provisional") 270 | self.aiLevel = player_info.get("aiLevel") 271 | self.is_bot = self.title == "BOT" or self.aiLevel is not None 272 | self.name: str = f"AI level {self.aiLevel}" if self.aiLevel else player_info.get("name", "") 273 | 274 | def __str__(self) -> str: 275 | """Get a string representation of `Player`.""" 276 | if self.aiLevel: 277 | return self.name 278 | else: 279 | rating = f'{self.rating}{"?" if self.provisional else ""}' 280 | return f'{self.title or ""} {self.name} ({rating})'.strip() 281 | 282 | def __repr__(self) -> str: 283 | """Get a string representation of `Player`.""" 284 | return self.__str__() 285 | -------------------------------------------------------------------------------- /lichess-bot/config.yml.default: -------------------------------------------------------------------------------- 1 | token: "xxxxxxxxxxxxxxxxxxxxxx" # Lichess OAuth2 Token. 2 | url: "https://lichess.org/" # Lichess base URL. 3 | 4 | engine: # Engine settings. 5 | dir: "./engines/" # Directory containing the engine. This can be an absolute path or one relative to lichess-bot/. 6 | name: "engine_name" # Binary name of the engine to use. 7 | working_dir: "" # Directory where the chess engine will read and write files. If blank or missing, the current directory is used. 8 | # NOTE: If working_dir is set, the engine will look for files and directories relative to this directory, not where lichess-bot was launched. Absolute paths are unaffected. 9 | protocol: "uci" # "uci", "xboard" or "homemade" 10 | ponder: true # Think on opponent's time. 11 | 12 | polyglot: 13 | enabled: false # Activate polyglot book. 14 | book: 15 | standard: # List of book file paths for variant standard. 16 | - engines/book1.bin 17 | - engines/book2.bin 18 | # atomic: # List of book file paths for variant atomic. 19 | # - engines/atomicbook1.bin 20 | # - engines/atomicbook2.bin 21 | # etc. 22 | # Use the same pattern for 'chess960', 'giveaway' (antichess), 'crazyhouse', 'horde', 'kingofthehill', 'racingkings' and '3check' as well. 23 | min_weight: 1 # Does not select moves with weight below min_weight (min 0, max: 65535). 24 | selection: "weighted_random" # Move selection is one of "weighted_random", "uniform_random" or "best_move" (but not below the min_weight in the 2nd and 3rd case). 25 | max_depth: 20 # How many moves from the start to take from the book. 26 | 27 | draw_or_resign: 28 | resign_enabled: false # Whether or not the bot should resign. 29 | resign_score: -1000 # If the score is less than or equal to this value, the bot resigns (in cp). 30 | resign_for_egtb_minus_two: true # If true the bot will resign in positions where the online_egtb returns a wdl of -2. 31 | resign_moves: 3 # How many moves in a row the score has to be below the resign value. 32 | offer_draw_enabled: true # Whether or not the bot should offer/accept draw. 33 | offer_draw_score: 0 # If the absolute value of the score is less than or equal to this value, the bot offers/accepts draw (in cp). 34 | offer_draw_for_egtb_zero: true # If true the bot will offer/accept draw in positions where the online_egtb returns a wdl of 0. 35 | offer_draw_moves: 10 # How many moves in a row the absolute value of the score has to be below the draw value. 36 | offer_draw_pieces: 10 # Only if the pieces on board are less than or equal to this value, the bot offers/accepts draw. 37 | 38 | online_moves: 39 | max_out_of_book_moves: 10 # Stop using online opening books after they don't have a move for 'max_out_of_book_moves' positions. Doesn't apply to the online endgame tablebases. 40 | max_retries: 2 # The maximum amount of retries when getting an online move. 41 | chessdb_book: 42 | enabled: false # Whether or not to use chessdb book. 43 | min_time: 20 # Minimum time (in seconds) to use chessdb book. 44 | move_quality: "good" # One of "all", "good", "best". 45 | min_depth: 20 # Only for move_quality: "best". 46 | lichess_cloud_analysis: 47 | enabled: false # Whether or not to use lichess cloud analysis. 48 | min_time: 20 # Minimum time (in seconds) the bot must have to use cloud analysis. 49 | move_quality: "best" # One of "good", "best". 50 | max_score_difference: 50 # Only for move_quality: "good". The maximum score difference (in cp) between the best move and the other moves. 51 | min_depth: 20 52 | min_knodes: 0 53 | lichess_opening_explorer: 54 | enabled: false 55 | min_time: 20 56 | source: "masters" # One of "lichess", "masters", "player" 57 | player_name: "" # The lichess username. Leave empty for the bot's username to be used. Used only when source is "player". 58 | sort: "winrate" # One of "winrate", "games_played" 59 | min_games: 10 # Minimum number of times a move must have been played to be chosen. 60 | online_egtb: 61 | enabled: false # Whether or not to enable online endgame tablebases. 62 | min_time: 20 # Minimum time (in seconds) the bot must have to use online EGTBs. 63 | max_pieces: 7 # Maximum number of pieces on the board to use endgame tablebases. 64 | source: "lichess" # One of "lichess", "chessdb". 65 | move_quality: "best" # One of "best" or "suggest" (it takes all the moves with the same WDL and tells the engine to only consider these; will move instantly if there is only 1 "good" move). 66 | 67 | lichess_bot_tbs: # The tablebases list here will be read by lichess-bot, not the engine. 68 | syzygy: 69 | enabled: false # Whether or not to use local syzygy endgame tablebases. 70 | paths: # Paths to Syzygy endgame tablebases. 71 | - "engines/syzygy" 72 | max_pieces: 7 # Maximum number of pieces in the endgame tablebase. 73 | move_quality: "best" # One of "best" or "suggest" (it takes all the moves with the same WDL and tells the engine to only consider these; will move instantly if there is only 1 "good" move). 74 | gaviota: 75 | enabled: false # Whether or not to use local gaviota endgame tablebases. 76 | paths: 77 | - "engines/gaviota" 78 | max_pieces: 5 79 | min_dtm_to_consider_as_wdl_1: 120 # The minimum DTM to consider as syzygy WDL=1/-1. Set to 100 to disable. 80 | move_quality: "best" # One of "best" or "suggest" (it takes all the moves with the same WDL and tells the engine to only consider these; will move instantly if there is only 1 "good" move). 81 | 82 | # engine_options: # Any custom command line params to pass to the engine. 83 | # cpuct: 3.1 84 | 85 | homemade_options: 86 | # Hash: 256 87 | 88 | uci_options: # Arbitrary UCI options passed to the engine. 89 | Move Overhead: 100 # Increase if your bot flags games too often. 90 | Threads: 4 # Max CPU threads the engine can use. 91 | Hash: 512 # Max memory (in megabytes) the engine can allocate. 92 | SyzygyPath: "./syzygy/" # Paths to Syzygy endgame tablebases that the engine reads. 93 | UCI_ShowWDL: true # Show the chance of the engine winning. 94 | # go_commands: # Additional options to pass to the UCI go command. 95 | # nodes: 1 # Search so many nodes only. 96 | # depth: 5 # Search depth ply only. 97 | # movetime: 1000 # Integer. Search exactly movetime milliseconds. 98 | 99 | # xboard_options: # Arbitrary XBoard options passed to the engine. 100 | # cores: "4" 101 | # memory: "4096" 102 | # egtpath: # Directory containing egtb (endgame tablabases), relative to this project. For 'xboard' engines. 103 | # gaviota: "Gaviota path" 104 | # nalimov: "Nalimov Path" 105 | # scorpio: "Scorpio Path" 106 | # syzygy: "Syzygy Path" 107 | # go_commands: # Additional options to pass to the XBoard go command. 108 | # depth: 5 # Search depth ply only. 109 | # Do note that the go commands 'movetime' and 'nodes' are invalid and may cause bad time management for XBoard engines. 110 | 111 | silence_stderr: false # Some engines (yes you, Leela) are very noisy. 112 | 113 | abort_time: 30 # Time to abort a game in seconds when there is no activity. 114 | fake_think_time: false # Artificially slow down the bot to pretend like it's thinking. 115 | rate_limiting_delay: 0 # Time (in ms) to delay after sending a move to prevent "Too Many Requests" errors. 116 | move_overhead: 2000 # Increase if your bot flags games too often. 117 | 118 | correspondence: 119 | move_time: 60 # Time in seconds to search in correspondence games. 120 | checkin_period: 300 # How often to check for opponent moves in correspondence games after disconnecting. 121 | disconnect_time: 150 # Time before disconnecting from a correspondence game. 122 | ponder: false # Ponder in correspondence games the bot is connected to. 123 | 124 | challenge: # Incoming challenges. 125 | concurrency: 1 # Number of games to play simultaneously. 126 | sort_by: "best" # Possible values: "best" and "first". 127 | accept_bot: true # Accepts challenges coming from other bots. 128 | only_bot: false # Accept challenges by bots only. 129 | max_increment: 20 # Maximum amount of increment to accept a challenge in seconds. The max is 180. Set to 0 for no increment. 130 | min_increment: 0 # Minimum amount of increment to accept a challenge in seconds. 131 | max_base: 1800 # Maximum amount of base time to accept a challenge in seconds. The max is 10800 (3 hours). 132 | min_base: 0 # Minimum amount of base time to accept a challenge in seconds. 133 | max_days: 14 # Maximum number of days per move to accept a challenge for a correspondence game. 134 | # Unlimited games can be accepted by removing this field or specifying .inf 135 | min_days: 1 # Minimum number of days per move to accept a challenge for a correspondence game. 136 | variants: # Chess variants to accept (https://lichess.org/variant). 137 | - standard 138 | # - fromPosition 139 | # - antichess 140 | # - atomic 141 | # - chess960 142 | # - crazyhouse 143 | # - horde 144 | # - kingOfTheHill 145 | # - racingKings 146 | # - threeCheck 147 | time_controls: # Time controls to accept. 148 | - bullet 149 | - blitz 150 | - rapid 151 | - classical 152 | # - correspondence 153 | modes: # Game modes to accept. 154 | - casual # Unrated games. 155 | - rated # Rated games - must comment if the engine doesn't try to win. 156 | # block_list: # List of users from which the challenges are always declined. 157 | # - user1 158 | # - user2 159 | # allow_list: # List of users from which challenges are exclusively accepted, all others being declined. If empty, challenges from all users may be accepted. 160 | # - user3 161 | # - user4 162 | # recent_bot_challenge_age: 60 # Maximum age of a bot challenge to be considered recent in seconds 163 | # max_recent_bot_challenges: 2 # Maximum number of recent challenges that can be accepted from the same bot 164 | bullet_requires_increment: False # Require that bullet game challenges from bots have a non-zero increment 165 | 166 | greeting: 167 | # Optional substitution keywords (include curly braces): 168 | # {opponent} to insert opponent's name 169 | # {me} to insert bot's name 170 | # Any other words in curly braces will be removed. 171 | hello: "Hi! I'm {me}. Good luck! Type !help for a list of commands I can respond to." # Message to send to opponent chat at the start of a game 172 | goodbye: "Good game!" # Message to send to opponent chat at the end of a game 173 | hello_spectators: "Hi! I'm {me}. Type !help for a list of commands I can respond to." # Message to send to spectator chat at the start of a game 174 | goodbye_spectators: "Thanks for watching!" # Message to send to spectator chat at the end of a game 175 | 176 | # pgn_directory: "game_records" # A directory where PGN-format records of the bot's games are kept 177 | # pgn_file_grouping: "game" # How to group games into files. Options are "game", "opponent", and "all" 178 | # "game" (default) - every game is written to a different file named "{White name} vs. {Black name} - {lichess game ID}.pgn" 179 | # "opponent" - every game with a given opponent is written to a file named "{Bot name} games vs. {Opponent name}.pgn" 180 | # "all" - every game is written to a single file named "{Bot name} games.pgn" 181 | 182 | matchmaking: 183 | allow_matchmaking: false # Set it to 'true' to challenge other bots. 184 | challenge_variant: "random" # If set to 'random', the bot will choose one variant from the variants enabled in 'challenge.variants'. 185 | challenge_timeout: 30 # Create a challenge after being idle for 'challenge_timeout' minutes. The minimum is 1 minute. 186 | challenge_initial_time: # Initial time in seconds of the challenge (to be chosen at random). 187 | - 60 188 | - 180 189 | challenge_increment: # Increment in seconds of the challenge (to be chosen at random). 190 | - 1 191 | - 2 192 | # challenge_days: # Days for correspondence challenge (to be chosen at random). 193 | # - 1 194 | # - 2 195 | # opponent_min_rating: 600 # Opponents rating should be above this value (600 is the minimum rating in lichess). 196 | # opponent_max_rating: 4000 # Opponents rating should be below this value (4000 is the maximum rating in lichess). 197 | opponent_rating_difference: 300 # The maximum difference in rating between the bot's rating and opponent's rating. 198 | opponent_allow_tos_violation: false # Set to 'true' to allow challenging bots that violated the Lichess Terms of Service. 199 | challenge_mode: "random" # Set it to the mode in which challenges are sent. Possible options are 'casual', 'rated' and 'random'. 200 | challenge_filter: none # If a bot declines a challenge, do not issue a similar challenge to that bot. Possible options are 'none', 'coarse', and 'fine'. 201 | # block_list: # The list of bots that will not be challenged 202 | # - user1 203 | # - user2 204 | 205 | # overrides: # List of overrides for the matchmaking specifications above. When a challenge is created, either the default specification above or one of the overrides will be randomly chosen. 206 | # bullet_only_horde: # Name of the override. Can be anything as long as each override has a unique name ("bullet_only_horde" and "easy_chess960" in these examples). 207 | # challenge_variant: "horde" # List of options to override. Only the options mentioned will change when making the challenge. The rest will follow the default matchmaking options above. 208 | # challenge_initial_time: 209 | # - 1 210 | # - 2 211 | # challenge_increment: 212 | # - 0 213 | # - 1 214 | # 215 | # easy_chess960: 216 | # challenge_variant: "chess960" 217 | # opponent_min_rating: 400 218 | # opponent_max_rating: 1200 219 | # opponent_rating_difference: 220 | # challenge_mode: casual 221 | # 222 | # no_pressure_correspondence: 223 | # challenge_initial_time: 224 | # challenge_increment: 225 | # challenge_days: 226 | # - 2 227 | # - 3 228 | # challenge_mode: casual 229 | # 230 | # The following configurations cannot be overridden: allow_matchmaking, challenge_timeout, challenge_filter and block_list. 231 | -------------------------------------------------------------------------------- /lichess-bot/config.yml: -------------------------------------------------------------------------------- 1 | token: "XXXXXXXXXXXXXXXXXX" # Lichess OAuth2 Token. 2 | url: "https://lichess.org/" # Lichess base URL. 3 | 4 | engine: # Engine settings. 5 | dir: "engines/" # Directory containing the engine. This can be an absolute path or one relative to lichess-bot/. 6 | name: "LLM" # Binary name of the engine to use. 7 | working_dir: "" # Directory where the chess engine will read and write files. If blank or missing, the current directory is used. 8 | # NOTE: If working_dir is set, the engine will look for files and directories relative to this directory, not where lichess-bot was launched. Absolute paths are unaffected. 9 | protocol: "homemade" # "uci", "xboard" or "homemade" 10 | ponder: true # Think on opponent's time. 11 | 12 | polyglot: 13 | enabled: false # Activate polyglot book. 14 | book: 15 | standard: # List of book file paths for variant standard. 16 | - engines/book1.bin 17 | - engines/book2.bin 18 | # atomic: # List of book file paths for variant atomic. 19 | # - engines/atomicbook1.bin 20 | # - engines/atomicbook2.bin 21 | # etc. 22 | # Use the same pattern for 'chess960', 'giveaway' (antichess), 'crazyhouse', 'horde', 'kingofthehill', 'racingkings' and '3check' as well. 23 | min_weight: 1 # Does not select moves with weight below min_weight (min 0, max: 65535). 24 | selection: "weighted_random" # Move selection is one of "weighted_random", "uniform_random" or "best_move" (but not below the min_weight in the 2nd and 3rd case). 25 | max_depth: 20 # How many moves from the start to take from the book. 26 | 27 | draw_or_resign: 28 | resign_enabled: false # Whether or not the bot should resign. 29 | resign_score: -1000 # If the score is less than or equal to this value, the bot resigns (in cp). 30 | resign_for_egtb_minus_two: true # If true the bot will resign in positions where the online_egtb returns a wdl of -2. 31 | resign_moves: 3 # How many moves in a row the score has to be below the resign value. 32 | offer_draw_enabled: true # Whether or not the bot should offer/accept draw. 33 | offer_draw_score: 0 # If the absolute value of the score is less than or equal to this value, the bot offers/accepts draw (in cp). 34 | offer_draw_for_egtb_zero: true # If true the bot will offer/accept draw in positions where the online_egtb returns a wdl of 0. 35 | offer_draw_moves: 10 # How many moves in a row the absolute value of the score has to be below the draw value. 36 | offer_draw_pieces: 10 # Only if the pieces on board are less than or equal to this value, the bot offers/accepts draw. 37 | 38 | online_moves: 39 | max_out_of_book_moves: 10 # Stop using online opening books after they don't have a move for 'max_out_of_book_moves' positions. Doesn't apply to the online endgame tablebases. 40 | max_retries: 2 # The maximum amount of retries when getting an online move. 41 | chessdb_book: 42 | enabled: false # Whether or not to use chessdb book. 43 | min_time: 20 # Minimum time (in seconds) to use chessdb book. 44 | move_quality: "good" # One of "all", "good", "best". 45 | min_depth: 20 # Only for move_quality: "best". 46 | lichess_cloud_analysis: 47 | enabled: false # Whether or not to use lichess cloud analysis. 48 | min_time: 20 # Minimum time (in seconds) the bot must have to use cloud analysis. 49 | move_quality: "best" # One of "good", "best". 50 | max_score_difference: 50 # Only for move_quality: "good". The maximum score difference (in cp) between the best move and the other moves. 51 | min_depth: 20 52 | min_knodes: 0 53 | lichess_opening_explorer: 54 | enabled: false 55 | min_time: 20 56 | source: "masters" # One of "lichess", "masters", "player" 57 | player_name: "" # The lichess username. Leave empty for the bot's username to be used. Used only when source is "player". 58 | sort: "winrate" # One of "winrate", "games_played" 59 | min_games: 10 # Minimum number of times a move must have been played to be chosen. 60 | online_egtb: 61 | enabled: false # Whether or not to enable online endgame tablebases. 62 | min_time: 20 # Minimum time (in seconds) the bot must have to use online EGTBs. 63 | max_pieces: 7 # Maximum number of pieces on the board to use endgame tablebases. 64 | source: "lichess" # One of "lichess", "chessdb". 65 | move_quality: "best" # One of "best" or "suggest" (it takes all the moves with the same WDL and tells the engine to only consider these; will move instantly if there is only 1 "good" move). 66 | 67 | lichess_bot_tbs: # The tablebases list here will be read by lichess-bot, not the engine. 68 | syzygy: 69 | enabled: false # Whether or not to use local syzygy endgame tablebases. 70 | paths: # Paths to Syzygy endgame tablebases. 71 | - "engines/syzygy" 72 | max_pieces: 7 # Maximum number of pieces in the endgame tablebase. 73 | move_quality: "best" # One of "best" or "suggest" (it takes all the moves with the same WDL and tells the engine to only consider these; will move instantly if there is only 1 "good" move). 74 | gaviota: 75 | enabled: false # Whether or not to use local gaviota endgame tablebases. 76 | paths: 77 | - "engines/gaviota" 78 | max_pieces: 5 79 | min_dtm_to_consider_as_wdl_1: 120 # The minimum DTM to consider as syzygy WDL=1/-1. Set to 100 to disable. 80 | move_quality: "best" # One of "best" or "suggest" (it takes all the moves with the same WDL and tells the engine to only consider these; will move instantly if there is only 1 "good" move). 81 | 82 | # engine_options: # Any custom command line params to pass to the engine. 83 | # cpuct: 3.1 84 | 85 | homemade_options: 86 | # Hash: 256 87 | 88 | uci_options: # Arbitrary UCI options passed to the engine. 89 | #Move Overhead: 100 # Increase if your bot flags games too often. 90 | #Threads: 4 # Max CPU threads the engine can use. 91 | #Hash: 512 # Max memory (in megabytes) the engine can allocate. 92 | #SyzygyPath: "./syzygy/" # Paths to Syzygy endgame tablebases that the engine reads. 93 | #UCI_ShowWDL: true # Show the chance of the engine winning. 94 | # go_commands: # Additional options to pass to the UCI go command. 95 | # nodes: 1 # Search so many nodes only. 96 | # depth: 5 # Search depth ply only. 97 | # movetime: 1000 # Integer. Search exactly movetime milliseconds. 98 | 99 | # xboard_options: # Arbitrary XBoard options passed to the engine. 100 | # cores: "4" 101 | # memory: "4096" 102 | # egtpath: # Directory containing egtb (endgame tablabases), relative to this project. For 'xboard' engines. 103 | # gaviota: "Gaviota path" 104 | # nalimov: "Nalimov Path" 105 | # scorpio: "Scorpio Path" 106 | # syzygy: "Syzygy Path" 107 | # go_commands: # Additional options to pass to the XBoard go command. 108 | # depth: 5 # Search depth ply only. 109 | # Do note that the go commands 'movetime' and 'nodes' are invalid and may cause bad time management for XBoard engines. 110 | 111 | silence_stderr: false # Some engines (yes you, Leela) are very noisy. 112 | 113 | abort_time: 30 # Time to abort a game in seconds when there is no activity. 114 | fake_think_time: false # Artificially slow down the bot to pretend like it's thinking. 115 | rate_limiting_delay: 0 # Time (in ms) to delay after sending a move to prevent "Too Many Requests" errors. 116 | move_overhead: 2000 # Increase if your bot flags games too often. 117 | 118 | correspondence: 119 | move_time: 60 # Time in seconds to search in correspondence games. 120 | checkin_period: 300 # How often to check for opponent moves in correspondence games after disconnecting. 121 | disconnect_time: 150 # Time before disconnecting from a correspondence game. 122 | ponder: false # Ponder in correspondence games the bot is connected to. 123 | 124 | challenge: # Incoming challenges. 125 | concurrency: 10 # Number of games to play simultaneously. 126 | sort_by: "first" # Possible values: "best" and "first". 127 | accept_bot: false # Accepts challenges coming from other bots. 128 | only_bot: false # Accept challenges by bots only. 129 | max_increment: 10 # Maximum amount of increment to accept a challenge in seconds. The max is 180. Set to 0 for no increment. 130 | min_increment: 0 # Minimum amount of increment to accept a challenge in seconds. 131 | max_base: 600 # Maximum amount of base time to accept a challenge in seconds. The max is 10800 (3 hours). 132 | min_base: 0 # Minimum amount of base time to accept a challenge in seconds. 133 | max_days: 0 # Maximum number of days per move to accept a challenge for a correspondence game. 134 | # Unlimited games can be accepted by removing this field or specifying .inf 135 | min_days: 1 # Minimum number of days per move to accept a challenge for a correspondence game. 136 | variants: # Chess variants to accept (https://lichess.org/variant). 137 | - standard 138 | # - fromPosition 139 | # - antichess 140 | # - atomic 141 | # - chess960 142 | # - crazyhouse 143 | # - horde 144 | # - kingOfTheHill 145 | # - racingKings 146 | # - threeCheck 147 | time_controls: # Time controls to accept. 148 | - bullet 149 | - blitz 150 | # - rapid 151 | # - classical 152 | # - correspondence 153 | modes: # Game modes to accept. 154 | - casual # Unrated games. 155 | - rated # Rated games - must comment if the engine doesn't try to win. 156 | # block_list: # List of users from which the challenges are always declined. 157 | # - user1 158 | # - user2 159 | # allow_list: # List of users from which challenges are exclusively accepted, all others being declined. If empty, challenges from all users may be accepted. 160 | # - user3 161 | # - user4 162 | # recent_bot_challenge_age: 60 # Maximum age of a bot challenge to be considered recent in seconds 163 | # max_recent_bot_challenges: 2 # Maximum number of recent challenges that can be accepted from the same bot 164 | bullet_requires_increment: False # Require that bullet game challenges from bots have a non-zero increment 165 | 166 | greeting: 167 | # Optional substitution keywords (include curly braces): 168 | # {opponent} to insert opponent's name 169 | # {me} to insert bot's name 170 | # Any other words in curly braces will be removed. 171 | hello: "Hi! I'm {me}. I query GPT-3.5-turbo-instruct and play whatever move the language model says comes next given the PGN game." # Message to send to opponent chat at the start of a game For more information see https://nicholas.carlini.com/writing/2023/chess-llm.html 172 | goodbye: "Good game!" # Message to send to opponent chat at the end of a game 173 | hello_spectators: "Hi! I'm {me}. I query GPT-3.5-turbo-instruct and play whatever move the language model says comes next given the PGN game." # Message to send to spectator chat at the start of a game 174 | goodbye_spectators: "Thanks for watching!" # Message to send to spectator chat at the end of a game 175 | 176 | # pgn_directory: "game_records" # A directory where PGN-format records of the bot's games are kept 177 | # pgn_file_grouping: "game" # How to group games into files. Options are "game", "opponent", and "all" 178 | # "game" (default) - every game is written to a different file named "{White name} vs. {Black name} - {lichess game ID}.pgn" 179 | # "opponent" - every game with a given opponent is written to a file named "{Bot name} games vs. {Opponent name}.pgn" 180 | # "all" - every game is written to a single file named "{Bot name} games.pgn" 181 | 182 | matchmaking: 183 | allow_matchmaking: false # Set it to 'true' to challenge other bots. 184 | challenge_variant: "random" # If set to 'random', the bot will choose one variant from the variants enabled in 'challenge.variants'. 185 | challenge_timeout: 30 # Create a challenge after being idle for 'challenge_timeout' minutes. The minimum is 1 minute. 186 | challenge_initial_time: # Initial time in seconds of the challenge (to be chosen at random). 187 | - 60 188 | - 180 189 | challenge_increment: # Increment in seconds of the challenge (to be chosen at random). 190 | - 1 191 | - 2 192 | # challenge_days: # Days for correspondence challenge (to be chosen at random). 193 | # - 1 194 | # - 2 195 | # opponent_min_rating: 600 # Opponents rating should be above this value (600 is the minimum rating in lichess). 196 | # opponent_max_rating: 4000 # Opponents rating should be below this value (4000 is the maximum rating in lichess). 197 | opponent_rating_difference: 300 # The maximum difference in rating between the bot's rating and opponent's rating. 198 | opponent_allow_tos_violation: false # Set to 'true' to allow challenging bots that violated the Lichess Terms of Service. 199 | challenge_mode: "random" # Set it to the mode in which challenges are sent. Possible options are 'casual', 'rated' and 'random'. 200 | challenge_filter: none # If a bot declines a challenge, do not issue a similar challenge to that bot. Possible options are 'none', 'coarse', and 'fine'. 201 | # block_list: # The list of bots that will not be challenged 202 | # - user1 203 | # - user2 204 | 205 | # overrides: # List of overrides for the matchmaking specifications above. When a challenge is created, either the default specification above or one of the overrides will be randomly chosen. 206 | # bullet_only_horde: # Name of the override. Can be anything as long as each override has a unique name ("bullet_only_horde" and "easy_chess960" in these examples). 207 | # challenge_variant: "horde" # List of options to override. Only the options mentioned will change when making the challenge. The rest will follow the default matchmaking options above. 208 | # challenge_initial_time: 209 | # - 1 210 | # - 2 211 | # challenge_increment: 212 | # - 0 213 | # - 1 214 | # 215 | # easy_chess960: 216 | # challenge_variant: "chess960" 217 | # opponent_min_rating: 400 218 | # opponent_max_rating: 1200 219 | # opponent_rating_difference: 220 | # challenge_mode: casual 221 | # 222 | # no_pressure_correspondence: 223 | # challenge_initial_time: 224 | # challenge_increment: 225 | # challenge_days: 226 | # - 2 227 | # - 3 228 | # challenge_mode: casual 229 | # 230 | # The following configurations cannot be overridden: allow_matchmaking, challenge_timeout, challenge_filter and block_list. 231 | -------------------------------------------------------------------------------- /lichess-bot/matchmaking.py: -------------------------------------------------------------------------------- 1 | """Challenge other bots.""" 2 | import random 3 | import logging 4 | import model 5 | from timer import Timer, seconds, minutes, days 6 | from collections import defaultdict 7 | from collections.abc import Sequence 8 | import lichess 9 | import datetime 10 | from config import Configuration, FilterType 11 | from typing import Any, Optional 12 | USER_PROFILE_TYPE = dict[str, Any] 13 | EVENT_TYPE = dict[str, Any] 14 | MULTIPROCESSING_LIST_TYPE = Sequence[model.Challenge] 15 | DAILY_TIMERS_TYPE = list[Timer] 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | daily_challenges_file_name = "daily_challenge_times.txt" 20 | timestamp_format = "%Y-%m-%d %H:%M:%S\n" 21 | 22 | 23 | def read_daily_challenges() -> DAILY_TIMERS_TYPE: 24 | """Read the challenges we have created in the past 24 hours from a text file.""" 25 | timers: DAILY_TIMERS_TYPE = [] 26 | try: 27 | with open(daily_challenges_file_name) as file: 28 | for line in file: 29 | timers.append(Timer(days(1), datetime.datetime.strptime(line, timestamp_format))) 30 | except FileNotFoundError: 31 | pass 32 | 33 | return [timer for timer in timers if not timer.is_expired()] 34 | 35 | 36 | def write_daily_challenges(daily_challenges: DAILY_TIMERS_TYPE) -> None: 37 | """Write the challenges we have created in the past 24 hours to a text file.""" 38 | with open(daily_challenges_file_name, "w") as file: 39 | for timer in daily_challenges: 40 | file.write(timer.starting_timestamp(timestamp_format)) 41 | 42 | 43 | class Matchmaking: 44 | """Challenge other bots.""" 45 | 46 | def __init__(self, li: lichess.Lichess, config: Configuration, user_profile: USER_PROFILE_TYPE) -> None: 47 | """Initialize values needed for matchmaking.""" 48 | self.li = li 49 | self.variants = list(filter(lambda variant: variant != "fromPosition", config.challenge.variants)) 50 | self.matchmaking_cfg = config.matchmaking 51 | self.user_profile = user_profile 52 | self.last_challenge_created_delay = Timer(seconds(25)) # Challenges expire after 20 seconds. 53 | self.last_game_ended_delay = Timer(minutes(self.matchmaking_cfg.challenge_timeout)) 54 | self.last_user_profile_update_time = Timer(minutes(5)) 55 | self.min_wait_time = seconds(60) # Wait before new challenge to avoid api rate limits. 56 | self.challenge_id: str = "" 57 | self.daily_challenges: DAILY_TIMERS_TYPE = read_daily_challenges() 58 | 59 | # (opponent name, game aspect) --> other bot is likely to accept challenge 60 | # game aspect is the one the challenged bot objects to and is one of: 61 | # - game speed (bullet, blitz, etc.) 62 | # - variant (standard, horde, etc.) 63 | # - casual/rated 64 | # - empty string (if no other reason is given or self.filter_type is COARSE) 65 | self.challenge_type_acceptable: defaultdict[tuple[str, str], bool] = defaultdict(lambda: True) 66 | self.challenge_filter = self.matchmaking_cfg.challenge_filter 67 | 68 | for name in self.matchmaking_cfg.block_list: 69 | self.add_to_block_list(name) 70 | 71 | def should_create_challenge(self) -> bool: 72 | """Whether we should create a challenge.""" 73 | matchmaking_enabled = self.matchmaking_cfg.allow_matchmaking 74 | time_has_passed = self.last_game_ended_delay.is_expired() 75 | challenge_expired = self.last_challenge_created_delay.is_expired() and self.challenge_id 76 | min_wait_time_passed = self.last_challenge_created_delay.time_since_reset() > self.min_wait_time 77 | if challenge_expired: 78 | self.li.cancel(self.challenge_id) 79 | logger.info(f"Challenge id {self.challenge_id} cancelled.") 80 | self.challenge_id = "" 81 | self.show_earliest_challenge_time() 82 | return bool(matchmaking_enabled and (time_has_passed or challenge_expired) and min_wait_time_passed) 83 | 84 | def create_challenge(self, username: str, base_time: int, increment: int, days: int, variant: str, 85 | mode: str) -> str: 86 | """Create a challenge.""" 87 | params = {"rated": mode == "rated", "variant": variant} 88 | 89 | if days: 90 | params["days"] = days 91 | elif base_time or increment: 92 | params["clock.limit"] = base_time 93 | params["clock.increment"] = increment 94 | else: 95 | logger.error("At least one of challenge_days, challenge_initial_time, or challenge_increment " 96 | "must be greater than zero in the matchmaking section of your config file.") 97 | return "" 98 | 99 | try: 100 | self.update_daily_challenge_record() 101 | self.last_challenge_created_delay.reset() 102 | response = self.li.challenge(username, params) 103 | challenge_id: str = response.get("challenge", {}).get("id", "") 104 | if not challenge_id: 105 | logger.error(response) 106 | self.add_to_block_list(username) 107 | self.show_earliest_challenge_time() 108 | return challenge_id 109 | except Exception as e: 110 | logger.warning("Could not create challenge") 111 | logger.debug(e, exc_info=e) 112 | self.show_earliest_challenge_time() 113 | return "" 114 | 115 | def update_daily_challenge_record(self) -> None: 116 | """ 117 | Record timestamp of latest challenge and update minimum wait time. 118 | 119 | As the number of challenges in a day increase, the minimum wait time between challenges increases. 120 | 0 - 49 challenges --> 1 minute 121 | 50 - 99 challenges --> 2 minutes 122 | 100 - 149 challenges --> 3 minutes 123 | etc. 124 | """ 125 | self.daily_challenges = [timer for timer in self.daily_challenges if not timer.is_expired()] 126 | self.daily_challenges.append(Timer(days(1))) 127 | self.min_wait_time = seconds(60) * ((len(self.daily_challenges) // 50) + 1) 128 | write_daily_challenges(self.daily_challenges) 129 | 130 | def perf(self) -> dict[str, dict[str, Any]]: 131 | """Get the bot's rating in every variant. Bullet, blitz, rapid etc. are considered different variants.""" 132 | user_perf: dict[str, dict[str, Any]] = self.user_profile["perfs"] 133 | return user_perf 134 | 135 | def username(self) -> str: 136 | """Our username.""" 137 | username: str = self.user_profile["username"] 138 | return username 139 | 140 | def update_user_profile(self) -> None: 141 | """Update our user profile data, to get our latest rating.""" 142 | if self.last_user_profile_update_time.is_expired(): 143 | self.last_user_profile_update_time.reset() 144 | try: 145 | self.user_profile = self.li.get_profile() 146 | except Exception: 147 | pass 148 | 149 | def choose_opponent(self) -> tuple[Optional[str], int, int, int, str, str]: 150 | """Choose an opponent.""" 151 | override_choice = random.choice(self.matchmaking_cfg.overrides.keys() + [None]) 152 | logger.info(f"Using the {override_choice or 'default'} matchmaking configuration.") 153 | override = {} if override_choice is None else self.matchmaking_cfg.overrides.lookup(override_choice) 154 | match_config = self.matchmaking_cfg | override 155 | 156 | variant = self.get_random_config_value(match_config, "challenge_variant", self.variants) 157 | mode = self.get_random_config_value(match_config, "challenge_mode", ["casual", "rated"]) 158 | 159 | base_time = random.choice(match_config.challenge_initial_time) 160 | increment = random.choice(match_config.challenge_increment) 161 | days = random.choice(match_config.challenge_days) 162 | 163 | play_correspondence = [bool(days), not bool(base_time or increment)] 164 | if random.choice(play_correspondence): 165 | base_time = 0 166 | increment = 0 167 | else: 168 | days = 0 169 | 170 | game_type = game_category(variant, base_time, increment, days) 171 | 172 | min_rating = match_config.opponent_min_rating 173 | max_rating = match_config.opponent_max_rating 174 | rating_diff = match_config.opponent_rating_difference 175 | bot_rating = self.perf().get(game_type, {}).get("rating", 0) 176 | if rating_diff is not None and bot_rating > 0: 177 | min_rating = bot_rating - rating_diff 178 | max_rating = bot_rating + rating_diff 179 | logger.info(f"Seeking {game_type} game with opponent rating in [{min_rating}, {max_rating}] ...") 180 | allow_tos_violation = match_config.opponent_allow_tos_violation 181 | 182 | def is_suitable_opponent(bot: USER_PROFILE_TYPE) -> bool: 183 | perf = bot.get("perfs", {}).get(game_type, {}) 184 | return (bot["username"] != self.username() 185 | and not self.in_block_list(bot["username"]) 186 | and not bot.get("disabled") 187 | and (allow_tos_violation or not bot.get("tosViolation")) # Terms of Service violation. 188 | and perf.get("games", 0) > 0 189 | and min_rating <= perf.get("rating", 0) <= max_rating) 190 | 191 | online_bots = self.li.get_online_bots() 192 | online_bots = list(filter(is_suitable_opponent, online_bots)) 193 | 194 | def ready_for_challenge(bot: USER_PROFILE_TYPE) -> bool: 195 | aspects = [variant, game_type, mode] if self.challenge_filter == FilterType.FINE else [] 196 | return all(self.should_accept_challenge(bot["username"], aspect) for aspect in aspects) 197 | 198 | ready_bots = list(filter(ready_for_challenge, online_bots)) 199 | online_bots = ready_bots or online_bots 200 | bot_username = None 201 | 202 | try: 203 | bot = random.choice(online_bots) 204 | bot_profile = self.li.get_public_data(bot["username"]) 205 | if bot_profile.get("blocking"): 206 | self.add_to_block_list(bot["username"]) 207 | else: 208 | bot_username = bot["username"] 209 | except Exception: 210 | if online_bots: 211 | logger.exception("Error:") 212 | else: 213 | logger.error("No suitable bots found to challenge.") 214 | 215 | return bot_username, base_time, increment, days, variant, mode 216 | 217 | def get_random_config_value(self, config: Configuration, parameter: str, choices: list[str]) -> str: 218 | """Choose a random value from `choices` if the parameter value in the config is `random`.""" 219 | value: str = config.lookup(parameter) 220 | return value if value != "random" else random.choice(choices) 221 | 222 | def challenge(self, active_games: set[str], challenge_queue: MULTIPROCESSING_LIST_TYPE) -> None: 223 | """ 224 | Challenge an opponent. 225 | 226 | :param active_games: The games that the bot is playing. 227 | :param challenge_queue: The queue containing the challenges. 228 | """ 229 | if active_games or challenge_queue or not self.should_create_challenge(): 230 | return 231 | 232 | logger.info("Challenging a random bot") 233 | self.update_user_profile() 234 | bot_username, base_time, increment, days, variant, mode = self.choose_opponent() 235 | logger.info(f"Will challenge {bot_username} for a {variant} game.") 236 | challenge_id = self.create_challenge(bot_username, base_time, increment, days, variant, mode) if bot_username else "" 237 | logger.info(f"Challenge id is {challenge_id if challenge_id else 'None'}.") 238 | self.challenge_id = challenge_id 239 | 240 | def game_done(self) -> None: 241 | """Reset the timer for when the last game ended, and prints the earliest that the next challenge will be created.""" 242 | self.last_game_ended_delay.reset() 243 | self.show_earliest_challenge_time() 244 | 245 | def show_earliest_challenge_time(self) -> None: 246 | """Show the earliest that the next challenge will be created.""" 247 | if self.matchmaking_cfg.allow_matchmaking: 248 | postgame_timeout = self.last_game_ended_delay.time_until_expiration() 249 | time_to_next_challenge = self.min_wait_time - self.last_challenge_created_delay.time_since_reset() 250 | time_left = max(postgame_timeout, time_to_next_challenge) 251 | earliest_challenge_time = datetime.datetime.now() + time_left 252 | challenges = "challenge" + ("" if len(self.daily_challenges) == 1 else "s") 253 | logger.info(f"Next challenge will be created after {earliest_challenge_time.strftime('%X')} " 254 | f"({len(self.daily_challenges)} {challenges} in last 24 hours)") 255 | 256 | def add_to_block_list(self, username: str) -> None: 257 | """Add a bot to the blocklist.""" 258 | self.add_challenge_filter(username, "") 259 | 260 | def in_block_list(self, username: str) -> bool: 261 | """Check if an opponent is in the block list to prevent future challenges.""" 262 | return not self.should_accept_challenge(username, "") 263 | 264 | def add_challenge_filter(self, username: str, game_aspect: str) -> None: 265 | """ 266 | Prevent creating another challenge when an opponent has decline a challenge. 267 | 268 | :param username: The name of the opponent. 269 | :param game_aspect: The aspect of a game (time control, chess variant, etc.) 270 | that caused the opponent to decline a challenge. If the parameter is empty, 271 | that is equivalent to adding the opponent to the block list. 272 | """ 273 | self.challenge_type_acceptable[(username, game_aspect)] = False 274 | 275 | def should_accept_challenge(self, username: str, game_aspect: str) -> bool: 276 | """ 277 | Whether a bot is likely to accept a challenge to a game. 278 | 279 | :param username: The name of the opponent. 280 | :param game_aspect: A category of the challenge type (time control, chess variant, etc.) to test for acceptance. 281 | If game_aspect is empty, this is equivalent to checking if the opponent is in the block list. 282 | """ 283 | return self.challenge_type_acceptable[(username, game_aspect)] 284 | 285 | def accepted_challenge(self, event: EVENT_TYPE) -> None: 286 | """ 287 | Set the challenge id to an empty string, if the challenge was accepted. 288 | 289 | Otherwise, we would attempt to cancel the challenge later. 290 | """ 291 | if self.challenge_id == event["game"]["id"]: 292 | self.challenge_id = "" 293 | 294 | def declined_challenge(self, event: EVENT_TYPE) -> None: 295 | """ 296 | Handle a challenge that was declined by the opponent. 297 | 298 | Depends on whether `FilterType` is `NONE`, `COARSE`, or `FINE`. 299 | """ 300 | challenge = model.Challenge(event["challenge"], self.user_profile) 301 | opponent = challenge.opponent 302 | reason = event["challenge"]["declineReason"] 303 | logger.info(f"{opponent} declined {challenge}: {reason}") 304 | if self.challenge_id == challenge.id: 305 | self.challenge_id = "" 306 | if not challenge.from_self or self.challenge_filter == FilterType.NONE: 307 | return 308 | 309 | mode = "rated" if challenge.rated else "casual" 310 | decline_details: dict[str, str] = {"generic": "", 311 | "later": "", 312 | "nobot": "", 313 | "toofast": challenge.speed, 314 | "tooslow": challenge.speed, 315 | "timecontrol": challenge.speed, 316 | "rated": mode, 317 | "casual": mode, 318 | "standard": challenge.variant, 319 | "variant": challenge.variant} 320 | 321 | reason_key = event["challenge"]["declineReasonKey"].lower() 322 | if reason_key not in decline_details: 323 | logger.warning(f"Unknown decline reason received: {reason_key}") 324 | game_problem = decline_details.get(reason_key, "") if self.challenge_filter == FilterType.FINE else "" 325 | self.add_challenge_filter(opponent.name, game_problem) 326 | logger.info(f"Will not challenge {opponent} to another {game_problem}".strip() + " game.") 327 | 328 | self.show_earliest_challenge_time() 329 | 330 | 331 | def game_category(variant: str, base_time: int, increment: int, days: int) -> str: 332 | """ 333 | Get the game type (e.g. bullet, atomic, classical). Lichess has one rating for every variant regardless of time control. 334 | 335 | :param variant: The game's variant. 336 | :param base_time: The base time in seconds. 337 | :param increment: The increment in seconds. 338 | :param days: If the game is correspondence, we have some days to play the move. 339 | :return: The game category. 340 | """ 341 | game_duration = base_time + increment * 40 342 | if variant != "standard": 343 | return variant 344 | elif days: 345 | return "correspondence" 346 | elif game_duration < 179: 347 | return "bullet" 348 | elif game_duration < 479: 349 | return "blitz" 350 | elif game_duration < 1499: 351 | return "rapid" 352 | else: 353 | return "classical" 354 | -------------------------------------------------------------------------------- /lichess-bot/lichess.py: -------------------------------------------------------------------------------- 1 | """Communication with APIs.""" 2 | import json 3 | import requests 4 | from urllib.parse import urljoin 5 | from requests.exceptions import ConnectionError, HTTPError, ReadTimeout 6 | from http.client import RemoteDisconnected 7 | import backoff 8 | import logging 9 | import traceback 10 | from collections import defaultdict 11 | import datetime 12 | from timer import Timer, seconds, sec_str 13 | from typing import Optional, Union, Any 14 | import chess.engine 15 | JSON_REPLY_TYPE = dict[str, Any] 16 | REQUESTS_PAYLOAD_TYPE = dict[str, Any] 17 | 18 | ENDPOINTS = { 19 | "profile": "/api/account", 20 | "playing": "/api/account/playing", 21 | "stream": "/api/bot/game/stream/{}", 22 | "stream_event": "/api/stream/event", 23 | "move": "/api/bot/game/{}/move/{}", 24 | "chat": "/api/bot/game/{}/chat", 25 | "abort": "/api/bot/game/{}/abort", 26 | "accept": "/api/challenge/{}/accept", 27 | "decline": "/api/challenge/{}/decline", 28 | "upgrade": "/api/bot/account/upgrade", 29 | "resign": "/api/bot/game/{}/resign", 30 | "export": "/game/export/{}", 31 | "online_bots": "/api/bot/online", 32 | "challenge": "/api/challenge/{}", 33 | "cancel": "/api/challenge/{}/cancel", 34 | "status": "/api/users/status", 35 | "public_data": "/api/user/{}", 36 | "token_test": "/api/token/test" 37 | } 38 | 39 | 40 | logger = logging.getLogger(__name__) 41 | 42 | MAX_CHAT_MESSAGE_LEN = 140 # The maximum characters in a chat message. 43 | 44 | 45 | class RateLimited(RuntimeError): 46 | """Exception raised when we are rate limited (status code 429).""" 47 | 48 | pass 49 | 50 | 51 | def is_new_rate_limit(response: requests.models.Response) -> bool: 52 | """Check if the status code is 429, which means that we are rate limited.""" 53 | return response.status_code == 429 54 | 55 | 56 | def is_final(exception: Exception) -> bool: 57 | """If `is_final` returns True then we won't retry.""" 58 | return isinstance(exception, HTTPError) and exception.response.status_code < 500 59 | 60 | 61 | def backoff_handler(details: Any) -> None: 62 | """Log exceptions inside functions with the backoff decorator.""" 63 | logger.debug("Backing off {wait:0.1f} seconds after {tries} tries " 64 | "calling function {target} with args {args} and kwargs {kwargs}".format(**details)) 65 | logger.debug(f"Exception: {traceback.format_exc()}") 66 | 67 | 68 | # Docs: https://lichess.org/api. 69 | class Lichess: 70 | """Communication with lichess.org (and chessdb.cn for getting moves).""" 71 | 72 | def __init__(self, token: str, url: str, version: str, logging_level: int, max_retries: int) -> None: 73 | """ 74 | Communication with lichess.org (and chessdb.cn for getting moves). 75 | 76 | :param token: The bot's token. 77 | :param url: The base url (lichess.org). 78 | :param version: The lichess-bot version running. 79 | :param logging_level: The logging level (logging.INFO or logging.DEBUG). 80 | :param max_retries: The maximum amount of retries for online moves (e.g. chessdb's opening book). 81 | """ 82 | self.version = version 83 | self.header = { 84 | "Authorization": f"Bearer {token}" 85 | } 86 | self.baseUrl = url 87 | self.session = requests.Session() 88 | self.session.headers.update(self.header) 89 | self.other_session = requests.Session() 90 | self.set_user_agent("?") 91 | self.logging_level = logging_level 92 | self.max_retries = max_retries 93 | self.rate_limit_timers: defaultdict[str, Timer] = defaultdict(Timer) 94 | 95 | # Confirm that the OAuth token has the proper permission to play on lichess 96 | token_info = self.api_post("token_test", data=token)[token] 97 | 98 | if not token_info: 99 | raise RuntimeError("Token in config file is not recognized by lichess. " 100 | "Please check that it was copied correctly into your configuration file.") 101 | 102 | scopes = token_info["scopes"] 103 | if "bot:play" not in scopes.split(","): 104 | raise RuntimeError("Please use an API access token for your bot that " 105 | 'has the scope "Play games with the bot API (bot:play)". ' 106 | f"The current token has: {scopes}.") 107 | 108 | @backoff.on_exception(backoff.constant, 109 | (RemoteDisconnected, ConnectionError, HTTPError, ReadTimeout), 110 | max_time=60, 111 | interval=0.1, 112 | giveup=is_final, 113 | on_backoff=backoff_handler, 114 | backoff_log_level=logging.DEBUG, 115 | giveup_log_level=logging.DEBUG) 116 | def api_get(self, endpoint_name: str, *template_args: str, 117 | params: Optional[dict[str, str]] = None, 118 | stream: bool = False, timeout: int = 2) -> requests.Response: 119 | """ 120 | Send a GET to lichess.org. 121 | 122 | :param endpoint_name: The name of the endpoint. 123 | :param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`). 124 | :param params: Parameters sent to lichess.org. 125 | :param stream: Whether the data returned from lichess.org should be streamed. 126 | :param timeout: The amount of time in seconds to wait for a response. 127 | :return: lichess.org's response. 128 | """ 129 | logging.getLogger("backoff").setLevel(self.logging_level) 130 | path_template = self.get_path_template(endpoint_name) 131 | url = urljoin(self.baseUrl, path_template.format(*template_args)) 132 | response = self.session.get(url, params=params, timeout=timeout, stream=stream) 133 | 134 | if is_new_rate_limit(response): 135 | delay = seconds(1 if endpoint_name == "move" else 60) 136 | self.set_rate_limit_delay(path_template, delay) 137 | 138 | response.raise_for_status() 139 | response.encoding = "utf-8" 140 | return response 141 | 142 | def api_get_json(self, endpoint_name: str, *template_args: str, 143 | params: Optional[dict[str, str]] = None) -> JSON_REPLY_TYPE: 144 | """ 145 | Send a GET to the lichess.org endpoints that return a JSON. 146 | 147 | :param endpoint_name: The name of the endpoint. 148 | :param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`). 149 | :param params: Parameters sent to lichess.org. 150 | :return: lichess.org's response in a dict. 151 | """ 152 | response = self.api_get(endpoint_name, *template_args, params=params) 153 | json_response: JSON_REPLY_TYPE = response.json() 154 | return json_response 155 | 156 | def api_get_list(self, endpoint_name: str, *template_args: str, 157 | params: Optional[dict[str, str]] = None) -> list[JSON_REPLY_TYPE]: 158 | """ 159 | Send a GET to the lichess.org endpoints that return a list containing JSON. 160 | 161 | :param endpoint_name: The name of the endpoint. 162 | :param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`). 163 | :param params: Parameters sent to lichess.org. 164 | :return: lichess.org's response in a list of dicts. 165 | """ 166 | response = self.api_get(endpoint_name, *template_args, params=params) 167 | json_response: list[JSON_REPLY_TYPE] = response.json() 168 | return json_response 169 | 170 | def api_get_raw(self, endpoint_name: str, *template_args: str, 171 | params: Optional[dict[str, str]] = None, ) -> str: 172 | """ 173 | Send a GET to lichess.org that returns plain text (UTF-8). 174 | 175 | :param endpoint_name: The name of the endpoint. 176 | :param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`). 177 | :param params: Parameters sent to lichess.org. 178 | :return: The text of lichess.org's response. 179 | """ 180 | response = self.api_get(endpoint_name, *template_args, params=params) 181 | return response.text 182 | 183 | @backoff.on_exception(backoff.constant, 184 | (RemoteDisconnected, ConnectionError, HTTPError, ReadTimeout), 185 | max_time=60, 186 | interval=0.1, 187 | giveup=is_final, 188 | on_backoff=backoff_handler, 189 | backoff_log_level=logging.DEBUG, 190 | giveup_log_level=logging.DEBUG) 191 | def api_post(self, 192 | endpoint_name: str, 193 | *template_args: Any, 194 | data: Union[str, dict[str, str], None] = None, 195 | headers: Optional[dict[str, str]] = None, 196 | params: Optional[dict[str, str]] = None, 197 | payload: Optional[REQUESTS_PAYLOAD_TYPE] = None, 198 | raise_for_status: bool = True) -> JSON_REPLY_TYPE: 199 | """ 200 | Send a POST to lichess.org. 201 | 202 | :param endpoint_name: The name of the endpoint. 203 | :param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`). 204 | :param data: Data sent to lichess.org. 205 | :param headers: The headers for the request. 206 | :param params: Parameters sent to lichess.org. 207 | :param payload: Payload sent to lichess.org. 208 | :param raise_for_status: Whether to raise an exception if the response contains an error code. 209 | :return: lichess.org's response in a dict. 210 | """ 211 | logging.getLogger("backoff").setLevel(self.logging_level) 212 | path_template = self.get_path_template(endpoint_name) 213 | url = urljoin(self.baseUrl, path_template.format(*template_args)) 214 | response = self.session.post(url, data=data, headers=headers, params=params, json=payload, timeout=2) 215 | 216 | if is_new_rate_limit(response): 217 | self.set_rate_limit_delay(path_template, seconds(60)) 218 | 219 | if raise_for_status: 220 | response.raise_for_status() 221 | 222 | json_response: JSON_REPLY_TYPE = response.json() 223 | return json_response 224 | 225 | def get_path_template(self, endpoint_name: str) -> str: 226 | """ 227 | Get the path template given the endpoint name. Will raise an exception if the path template is rate limited. 228 | 229 | :param endpoint_name: The name of the endpoint. 230 | :return: The path template. 231 | """ 232 | path_template = ENDPOINTS[endpoint_name] 233 | if self.is_rate_limited(path_template): 234 | raise RateLimited(f"{path_template} is rate-limited. " 235 | f"Will retry in {sec_str(self.rate_limit_time_left(path_template))} seconds.") 236 | return path_template 237 | 238 | def set_rate_limit_delay(self, path_template: str, delay_time: datetime.timedelta) -> None: 239 | """ 240 | Set a delay to a path template if it was rate limited. 241 | 242 | :param path_template: The path template. 243 | :param delay_time: How long we won't call this endpoint. 244 | """ 245 | logger.warning(f"Endpoint {path_template} is rate limited. Waiting {delay_time} seconds until next request.") 246 | self.rate_limit_timers[path_template] = Timer(delay_time) 247 | 248 | def is_rate_limited(self, path_template: str) -> bool: 249 | """Check if a path template is rate limited.""" 250 | return not self.rate_limit_timers[path_template].is_expired() 251 | 252 | def rate_limit_time_left(self, path_template: str) -> datetime.timedelta: 253 | """How much time is left until we can use the path template normally.""" 254 | return self.rate_limit_timers[path_template].time_until_expiration() 255 | 256 | def upgrade_to_bot_account(self) -> JSON_REPLY_TYPE: 257 | """Upgrade the account to a BOT account.""" 258 | return self.api_post("upgrade") 259 | 260 | def make_move(self, game_id: str, move: chess.engine.PlayResult) -> JSON_REPLY_TYPE: 261 | """ 262 | Make a move. 263 | 264 | :param game_id: The id of the game. 265 | :param move: The move to make. 266 | """ 267 | return self.api_post("move", game_id, move.move, 268 | params={"offeringDraw": str(move.draw_offered).lower()}) 269 | 270 | def chat(self, game_id: str, room: str, text: str) -> JSON_REPLY_TYPE: 271 | """ 272 | Send a message to the chat. 273 | 274 | :param game_id: The id of the game. 275 | :param room: The room (either chat or spectator room). 276 | :param text: The text to send. 277 | """ 278 | if len(text) > MAX_CHAT_MESSAGE_LEN: 279 | logger.warning(f"This chat message is {len(text)} characters, which is longer " 280 | f"than the maximum of {MAX_CHAT_MESSAGE_LEN}. It will not be sent.") 281 | logger.warning(f"Message: {text}") 282 | return {} 283 | 284 | payload = {"room": room, "text": text} 285 | return self.api_post("chat", game_id, data=payload) 286 | 287 | def abort(self, game_id: str) -> JSON_REPLY_TYPE: 288 | """Aborts a game.""" 289 | return self.api_post("abort", game_id) 290 | 291 | def get_event_stream(self) -> requests.models.Response: 292 | """Get a stream of the events (e.g. challenge, gameStart).""" 293 | return self.api_get("stream_event", stream=True, timeout=15) 294 | 295 | def get_game_stream(self, game_id: str) -> requests.models.Response: 296 | """Get stream of the in-game events (e.g. moves by the opponent).""" 297 | return self.api_get("stream", game_id, stream=True, timeout=15) 298 | 299 | def accept_challenge(self, challenge_id: str) -> JSON_REPLY_TYPE: 300 | """Accept a challenge.""" 301 | return self.api_post("accept", challenge_id) 302 | 303 | def decline_challenge(self, challenge_id: str, reason: str = "generic") -> JSON_REPLY_TYPE: 304 | """Decline a challenge.""" 305 | try: 306 | return self.api_post("decline", challenge_id, 307 | data=f"reason={reason}", 308 | headers={"Content-Type": 309 | "application/x-www-form-urlencoded"}, 310 | raise_for_status=False) 311 | except Exception: 312 | return {} 313 | 314 | def get_profile(self) -> JSON_REPLY_TYPE: 315 | """Get the bot's profile (e.g. username).""" 316 | profile = self.api_get_json("profile") 317 | self.set_user_agent(profile["username"]) 318 | return profile 319 | 320 | def get_ongoing_games(self) -> list[dict[str, Any]]: 321 | """Get the bot's ongoing games.""" 322 | ongoing_games: list[dict[str, Any]] = [] 323 | try: 324 | ongoing_games = self.api_get_json("playing")["nowPlaying"] 325 | except Exception: 326 | pass 327 | return ongoing_games 328 | 329 | def resign(self, game_id: str) -> None: 330 | """Resign a game.""" 331 | self.api_post("resign", game_id) 332 | 333 | def set_user_agent(self, username: str) -> None: 334 | """Set the user agent for communication with lichess.org.""" 335 | self.header.update({"User-Agent": f"lichess-bot/{self.version} user:{username}"}) 336 | self.session.headers.update(self.header) 337 | 338 | def get_game_pgn(self, game_id: str) -> str: 339 | """Get the PGN (Portable Game Notation) record of a game.""" 340 | try: 341 | return self.api_get_raw("export", game_id) 342 | except Exception: 343 | return "" 344 | 345 | def get_online_bots(self) -> list[dict[str, Any]]: 346 | """Get a list of bots that are online.""" 347 | try: 348 | online_bots_str = self.api_get_raw("online_bots") 349 | online_bots = list(filter(bool, online_bots_str.split("\n"))) 350 | return list(map(json.loads, online_bots)) 351 | except Exception: 352 | return [] 353 | 354 | def challenge(self, username: str, payload: REQUESTS_PAYLOAD_TYPE) -> JSON_REPLY_TYPE: 355 | """Create a challenge.""" 356 | return self.api_post("challenge", username, payload=payload, raise_for_status=False) 357 | 358 | def cancel(self, challenge_id: str) -> JSON_REPLY_TYPE: 359 | """Cancel a challenge.""" 360 | return self.api_post("cancel", challenge_id, raise_for_status=False) 361 | 362 | def online_book_get(self, path: str, params: Optional[dict[str, Any]] = None, stream: bool = False) -> JSON_REPLY_TYPE: 363 | """Get an external move from online sources (chessdb or lichess.org).""" 364 | @backoff.on_exception(backoff.constant, 365 | (RemoteDisconnected, ConnectionError, HTTPError, ReadTimeout), 366 | max_time=60, 367 | max_tries=self.max_retries, 368 | interval=0.1, 369 | giveup=is_final, 370 | on_backoff=backoff_handler, 371 | backoff_log_level=logging.DEBUG, 372 | giveup_log_level=logging.DEBUG) 373 | def online_book_get() -> JSON_REPLY_TYPE: 374 | json_response: JSON_REPLY_TYPE = self.other_session.get(path, timeout=2, params=params, stream=stream).json() 375 | return json_response 376 | return online_book_get() 377 | 378 | def is_online(self, user_id: str) -> bool: 379 | """Check if lichess.org thinks the bot is online or not.""" 380 | user = self.api_get_list("status", params={"ids": user_id}) 381 | return bool(user and user[0].get("online")) 382 | 383 | def get_public_data(self, user_name: str) -> JSON_REPLY_TYPE: 384 | """Get the public data of a bot.""" 385 | return self.api_get_json("public_data", user_name) 386 | -------------------------------------------------------------------------------- /lichess-bot/config.py: -------------------------------------------------------------------------------- 1 | """Code related to the config that lichess-bot uses.""" 2 | from __future__ import annotations 3 | import yaml 4 | import os 5 | import os.path 6 | import logging 7 | import math 8 | from abc import ABCMeta 9 | from enum import Enum 10 | from typing import Any, Union 11 | CONFIG_DICT_TYPE = dict[str, Any] 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class FilterType(str, Enum): 17 | """What to do if the opponent declines our challenge.""" 18 | 19 | NONE = "none" 20 | """Will still challenge the opponent.""" 21 | COARSE = "coarse" 22 | """Won't challenge the opponent again.""" 23 | FINE = "fine" 24 | """ 25 | Won't challenge the opponent to a game of the same mode, speed, and variant 26 | based on the reason for the opponent declining the challenge. 27 | """ 28 | 29 | 30 | class Configuration: 31 | """The config or a sub-config that the bot uses.""" 32 | 33 | def __init__(self, parameters: CONFIG_DICT_TYPE) -> None: 34 | """:param parameters: A `dict` containing the config for the bot.""" 35 | self.config = parameters 36 | 37 | def __getattr__(self, name: str) -> Any: 38 | """ 39 | Enable the use of `config.key1.key2`. 40 | 41 | :param name: The key to get its value. 42 | :return: The value of the key. 43 | """ 44 | return self.lookup(name) 45 | 46 | def lookup(self, name: str) -> Any: 47 | """ 48 | Get the value of a key. 49 | 50 | :param name: The key to get its value. 51 | :return: `Configuration` if the value is a `dict` else returns the value. 52 | """ 53 | data = self.config.get(name) 54 | return Configuration(data) if isinstance(data, dict) else data 55 | 56 | def items(self) -> Any: 57 | """:return: All the key-value pairs in this config.""" 58 | return self.config.items() 59 | 60 | def keys(self) -> list[str]: 61 | """:return: All of the keys in this config.""" 62 | return list(self.config.keys()) 63 | 64 | def __or__(self, other: Union[Configuration, CONFIG_DICT_TYPE]) -> Configuration: 65 | """Create a copy of this configuration that is updated with values from the parameter.""" 66 | other_dict = other.config if isinstance(other, Configuration) else other 67 | return Configuration(self.config | other_dict) 68 | 69 | def __bool__(self) -> bool: 70 | """Whether `self.config` is empty.""" 71 | return bool(self.config) 72 | 73 | def __getstate__(self) -> CONFIG_DICT_TYPE: 74 | """Get `self.config`.""" 75 | return self.config 76 | 77 | def __setstate__(self, d: CONFIG_DICT_TYPE) -> None: 78 | """Set `self.config`.""" 79 | self.config = d 80 | 81 | 82 | def config_assert(assertion: bool, error_message: str) -> None: 83 | """Raise an exception if an assertion is false.""" 84 | if not assertion: 85 | raise Exception(error_message) 86 | 87 | 88 | def check_config_section(config: CONFIG_DICT_TYPE, data_name: str, data_type: ABCMeta, subsection: str = "") -> None: 89 | """ 90 | Check the validity of a config section. 91 | 92 | :param config: The config section. 93 | :param data_name: The key to check its value. 94 | :param data_type: The expected data type. 95 | :param subsection: The subsection of the key. 96 | """ 97 | config_part = config[subsection] if subsection else config 98 | sub = f"`{subsection}` sub" if subsection else "" 99 | data_location = f"`{data_name}` subsection in `{subsection}`" if subsection else f"Section `{data_name}`" 100 | type_error_message = {str: f"{data_location} must be a string wrapped in quotes.", 101 | dict: f"{data_location} must be a dictionary with indented keys followed by colons."} 102 | config_assert(data_name in config_part, f"Your config.yml does not have required {sub}section `{data_name}`.") 103 | config_assert(isinstance(config_part[data_name], data_type), type_error_message[data_type]) 104 | 105 | 106 | def set_config_default(config: CONFIG_DICT_TYPE, *sections: str, key: str, default: Any, 107 | force_empty_values: bool = False) -> CONFIG_DICT_TYPE: 108 | """ 109 | Fill a specific config key with the default value if it is missing. 110 | 111 | :param config: The bot's config. 112 | :param sections: The sections that the key is in. 113 | :param key: The key to set. 114 | :param default: The default value. 115 | :param force_empty_values: Whether an empty value should be replaced with the default value. 116 | :return: The new config with the default value inserted if needed. 117 | """ 118 | subconfig = config 119 | for section in sections: 120 | subconfig = subconfig.setdefault(section, {}) 121 | if not isinstance(subconfig, dict): 122 | raise Exception(f"The {section} section in {sections} should hold a set of key-value pairs, not a value.") 123 | if force_empty_values: 124 | if subconfig.get(key) in [None, ""]: 125 | subconfig[key] = default 126 | else: 127 | subconfig.setdefault(key, default) 128 | return subconfig 129 | 130 | 131 | def change_value_to_list(config: CONFIG_DICT_TYPE, *sections: str, key: str) -> None: 132 | """ 133 | Change a single value to a list. e.g. 60 becomes [60]. Used to maintain backwards compatibility. 134 | 135 | :param config: The bot's config. 136 | :param sections: The sections that the key is in. 137 | :param key: The key to set. 138 | """ 139 | subconfig = set_config_default(config, *sections, key=key, default=[]) 140 | 141 | if subconfig[key] is None: 142 | subconfig[key] = [] 143 | 144 | if not isinstance(subconfig[key], list): 145 | subconfig[key] = [subconfig[key]] 146 | 147 | 148 | def insert_default_values(CONFIG: CONFIG_DICT_TYPE) -> None: 149 | """ 150 | Insert the default values of most keys to the config if they are missing. 151 | 152 | :param CONFIG: The bot's config. 153 | """ 154 | set_config_default(CONFIG, key="abort_time", default=20) 155 | set_config_default(CONFIG, key="move_overhead", default=1000) 156 | set_config_default(CONFIG, key="rate_limiting_delay", default=0) 157 | set_config_default(CONFIG, key="pgn_file_grouping", default="game", force_empty_values=True) 158 | set_config_default(CONFIG, "engine", key="working_dir", default=os.getcwd(), force_empty_values=True) 159 | set_config_default(CONFIG, "engine", key="silence_stderr", default=False) 160 | set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_enabled", default=False) 161 | set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_for_egtb_zero", default=True) 162 | set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_enabled", default=False) 163 | set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_for_egtb_minus_two", default=True) 164 | set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_moves", default=3) 165 | set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_score", default=-1000) 166 | set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_moves", default=5) 167 | set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_score", default=0) 168 | set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_pieces", default=10) 169 | set_config_default(CONFIG, "engine", "online_moves", key="max_out_of_book_moves", default=10) 170 | set_config_default(CONFIG, "engine", "online_moves", key="max_retries", default=2, force_empty_values=True) 171 | set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="enabled", default=False) 172 | set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="source", default="lichess") 173 | set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="min_time", default=20) 174 | set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="max_pieces", default=7) 175 | set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="move_quality", default="best") 176 | set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="enabled", default=False) 177 | set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="min_time", default=20) 178 | set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="move_quality", default="good") 179 | set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="min_depth", default=20) 180 | set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="enabled", default=False) 181 | set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="min_time", default=20) 182 | set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="move_quality", default="best") 183 | set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="min_depth", default=20) 184 | set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="min_knodes", default=0) 185 | set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="max_score_difference", default=50) 186 | set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="enabled", default=False) 187 | set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="min_time", default=20) 188 | set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="source", default="masters") 189 | set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="player_name", default="") 190 | set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="sort", default="winrate") 191 | set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="min_games", default=10) 192 | set_config_default(CONFIG, "engine", "lichess_bot_tbs", "syzygy", key="enabled", default=False) 193 | set_config_default(CONFIG, "engine", "lichess_bot_tbs", "syzygy", key="max_pieces", default=7) 194 | set_config_default(CONFIG, "engine", "lichess_bot_tbs", "syzygy", key="move_quality", default="best") 195 | set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="enabled", default=False) 196 | set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="max_pieces", default=5) 197 | set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="move_quality", default="best") 198 | set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="min_dtm_to_consider_as_wdl_1", default=120) 199 | set_config_default(CONFIG, "engine", "polyglot", key="enabled", default=False) 200 | set_config_default(CONFIG, "engine", "polyglot", key="max_depth", default=8) 201 | set_config_default(CONFIG, "engine", "polyglot", key="selection", default="weighted_random") 202 | set_config_default(CONFIG, "engine", "polyglot", key="min_weight", default=1) 203 | set_config_default(CONFIG, "challenge", key="concurrency", default=1) 204 | set_config_default(CONFIG, "challenge", key="sort_by", default="best") 205 | set_config_default(CONFIG, "challenge", key="accept_bot", default=False) 206 | set_config_default(CONFIG, "challenge", key="only_bot", default=False) 207 | set_config_default(CONFIG, "challenge", key="max_increment", default=180) 208 | set_config_default(CONFIG, "challenge", key="min_increment", default=0) 209 | set_config_default(CONFIG, "challenge", key="max_base", default=math.inf) 210 | set_config_default(CONFIG, "challenge", key="min_base", default=0) 211 | set_config_default(CONFIG, "challenge", key="max_days", default=math.inf) 212 | set_config_default(CONFIG, "challenge", key="min_days", default=1) 213 | set_config_default(CONFIG, "challenge", key="block_list", default=[], force_empty_values=True) 214 | set_config_default(CONFIG, "challenge", key="allow_list", default=[], force_empty_values=True) 215 | set_config_default(CONFIG, "correspondence", key="checkin_period", default=600) 216 | set_config_default(CONFIG, "correspondence", key="move_time", default=60, force_empty_values=True) 217 | set_config_default(CONFIG, "correspondence", key="disconnect_time", default=300) 218 | set_config_default(CONFIG, "matchmaking", key="challenge_timeout", default=30, force_empty_values=True) 219 | CONFIG["matchmaking"]["challenge_timeout"] = max(CONFIG["matchmaking"]["challenge_timeout"], 1) 220 | set_config_default(CONFIG, "matchmaking", key="block_list", default=[], force_empty_values=True) 221 | default_filter = (CONFIG.get("matchmaking") or {}).get("delay_after_decline") or FilterType.NONE.value 222 | set_config_default(CONFIG, "matchmaking", key="challenge_filter", default=default_filter, force_empty_values=True) 223 | set_config_default(CONFIG, "matchmaking", key="allow_matchmaking", default=False) 224 | set_config_default(CONFIG, "matchmaking", key="challenge_initial_time", default=[None], force_empty_values=True) 225 | change_value_to_list(CONFIG, "matchmaking", key="challenge_initial_time") 226 | set_config_default(CONFIG, "matchmaking", key="challenge_increment", default=[None], force_empty_values=True) 227 | change_value_to_list(CONFIG, "matchmaking", key="challenge_increment") 228 | set_config_default(CONFIG, "matchmaking", key="challenge_days", default=[None], force_empty_values=True) 229 | change_value_to_list(CONFIG, "matchmaking", key="challenge_days") 230 | set_config_default(CONFIG, "matchmaking", key="opponent_min_rating", default=600, force_empty_values=True) 231 | set_config_default(CONFIG, "matchmaking", key="opponent_max_rating", default=4000, force_empty_values=True) 232 | set_config_default(CONFIG, "matchmaking", key="opponent_allow_tos_violation", default=True) 233 | set_config_default(CONFIG, "matchmaking", key="challenge_variant", default="random") 234 | set_config_default(CONFIG, "matchmaking", key="challenge_mode", default="random") 235 | set_config_default(CONFIG, "matchmaking", key="overrides", default={}, force_empty_values=True) 236 | for override_config in CONFIG["matchmaking"]["overrides"].values(): 237 | for parameter in ["challenge_initial_time", "challenge_increment", "challenge_days"]: 238 | if parameter in override_config: 239 | set_config_default(override_config, key=parameter, default=[None], force_empty_values=True) 240 | change_value_to_list(override_config, key=parameter) 241 | 242 | for section in ["engine", "correspondence"]: 243 | for ponder in ["ponder", "uci_ponder"]: 244 | set_config_default(CONFIG, section, key=ponder, default=False) 245 | 246 | for type in ["hello", "goodbye"]: 247 | for target in ["", "_spectators"]: 248 | set_config_default(CONFIG, "greeting", key=type + target, default="", force_empty_values=True) 249 | 250 | 251 | def log_config(CONFIG: CONFIG_DICT_TYPE) -> None: 252 | """ 253 | Log the config to make debugging easier. 254 | 255 | :param CONFIG: The bot's config. 256 | """ 257 | logger_config = CONFIG.copy() 258 | logger_config["token"] = "logger" 259 | logger.debug(f"Config:\n{yaml.dump(logger_config, sort_keys=False)}") 260 | logger.debug("====================") 261 | 262 | 263 | def validate_config(CONFIG: CONFIG_DICT_TYPE) -> None: 264 | """Check if the config is valid.""" 265 | check_config_section(CONFIG, "token", str) 266 | check_config_section(CONFIG, "url", str) 267 | check_config_section(CONFIG, "engine", dict) 268 | check_config_section(CONFIG, "challenge", dict) 269 | check_config_section(CONFIG, "dir", str, "engine") 270 | check_config_section(CONFIG, "name", str, "engine") 271 | 272 | config_assert(os.path.isdir(CONFIG["engine"]["dir"]), 273 | f'Your engine directory `{CONFIG["engine"]["dir"]}` is not a directory.') 274 | 275 | working_dir = CONFIG["engine"].get("working_dir") 276 | config_assert(not working_dir or os.path.isdir(working_dir), 277 | f"Your engine's working directory `{working_dir}` is not a directory.") 278 | 279 | engine = os.path.join(CONFIG["engine"]["dir"], CONFIG["engine"]["name"]) 280 | config_assert(os.path.isfile(engine) or CONFIG["engine"]["protocol"] == "homemade", 281 | f"The engine {engine} file does not exist.") 282 | config_assert(os.access(engine, os.X_OK) or CONFIG["engine"]["protocol"] == "homemade", 283 | f"The engine {engine} doesn't have execute (x) permission. Try: chmod +x {engine}") 284 | 285 | if CONFIG["engine"]["protocol"] == "xboard": 286 | for section, subsection in (("online_moves", "online_egtb"), 287 | ("lichess_bot_tbs", "syzygy"), 288 | ("lichess_bot_tbs", "gaviota")): 289 | online_section = (CONFIG["engine"].get(section) or {}).get(subsection) or {} 290 | config_assert(online_section.get("move_quality") != "suggest" or not online_section.get("enabled"), 291 | f"XBoard engines can't be used with `move_quality` set to `suggest` in {subsection}.") 292 | 293 | valid_pgn_grouping_options = ["game", "opponent", "all"] 294 | config_pgn_choice = CONFIG["pgn_file_grouping"] 295 | config_assert(config_pgn_choice in valid_pgn_grouping_options, 296 | f"The `pgn_file_grouping` choice of `{config_pgn_choice}` is not valid. " 297 | f"Please choose from {valid_pgn_grouping_options}.") 298 | 299 | matchmaking = CONFIG.get("matchmaking") or {} 300 | matchmaking_enabled = matchmaking.get("allow_matchmaking") or False 301 | # `, []` is there only for mypy. It isn't used. 302 | matchmaking_has_values = (matchmaking.get("challenge_initial_time", [])[0] is not None 303 | and matchmaking.get("challenge_increment", [])[0] is not None 304 | or matchmaking.get("challenge_days", [])[0] is not None) 305 | config_assert(not matchmaking_enabled or matchmaking_has_values, 306 | "The time control to challenge other bots is not set. Either lists of challenge_initial_time and " 307 | "challenge_increment is required, or a list of challenge_days, or both.") 308 | 309 | filter_option = "challenge_filter" 310 | filter_type = (CONFIG.get("matchmaking") or {}).get(filter_option) 311 | config_assert(filter_type is None or filter_type in FilterType.__members__.values(), 312 | f"{filter_type} is not a valid value for {filter_option} (formerly delay_after_decline) parameter. " 313 | f"Choices are: {', '.join(FilterType)}.") 314 | 315 | selection_choices = {"polyglot": ["weighted_random", "uniform_random", "best_move"], 316 | "chessdb_book": ["all", "good", "best"], 317 | "lichess_cloud_analysis": ["good", "best"], 318 | "online_egtb": ["best", "suggest"]} 319 | for db_name, valid_selections in selection_choices.items(): 320 | is_online = db_name != "polyglot" 321 | db_section = (CONFIG["engine"].get("online_moves") or {}) if is_online else CONFIG["engine"] 322 | db_config = db_section.get(db_name) 323 | select_key = "selection" if db_name == "polyglot" else "move_quality" 324 | selection = db_config.get(select_key) 325 | select = f"{'online_moves:' if is_online else ''}{db_name}:{select_key}" 326 | config_assert(selection in valid_selections, 327 | f"`{selection}` is not a valid `engine:{select}` value. " 328 | f"Please choose from {valid_selections}.") 329 | 330 | lichess_tbs_config = CONFIG["engine"].get("lichess_bot_tbs") or {} 331 | quality_selections = ["best", "suggest"] 332 | for tb in ["syzygy", "gaviota"]: 333 | selection = (lichess_tbs_config.get(tb) or {}).get("move_quality") 334 | config_assert(selection in quality_selections, 335 | f"`{selection}` is not a valid choice for `engine:lichess_bot_tbs:{tb}:move_quality`. " 336 | f"Please choose from {quality_selections}.") 337 | 338 | explorer_choices = {"source": ["lichess", "masters", "player"], 339 | "sort": ["winrate", "games_played"]} 340 | explorer_config = (CONFIG["engine"].get("online_moves") or {}).get("lichess_opening_explorer") 341 | if explorer_config: 342 | for parameter, choice_list in explorer_choices.items(): 343 | explorer_choice = explorer_config.get(parameter) 344 | config_assert(explorer_choice in choice_list, 345 | f"`{explorer_choice}` is not a valid" 346 | f" `engine:online_moves:lichess_opening_explorer:{parameter}`" 347 | f" value. Please choose from {choice_list}.") 348 | 349 | 350 | def load_config(config_file: str) -> Configuration: 351 | """ 352 | Read the config. 353 | 354 | :param config_file: The filename of the config (usually `config.yml`). 355 | :return: A `Configuration` object containing the config. 356 | """ 357 | with open(config_file) as stream: 358 | try: 359 | CONFIG = yaml.safe_load(stream) 360 | except Exception: 361 | logger.exception("There appears to be a syntax problem with your config.yml") 362 | raise 363 | 364 | log_config(CONFIG) 365 | 366 | if "LICHESS_BOT_TOKEN" in os.environ: 367 | CONFIG["token"] = os.environ["LICHESS_BOT_TOKEN"] 368 | 369 | insert_default_values(CONFIG) 370 | log_config(CONFIG) 371 | validate_config(CONFIG) 372 | 373 | return Configuration(CONFIG) 374 | -------------------------------------------------------------------------------- /lichess-bot/wiki/Configure-lichess-bot.md: -------------------------------------------------------------------------------- 1 | # Configuring lichess-bot 2 | There are many possible options within `config.yml` for configuring lichess-bot. 3 | 4 | ## Engine options 5 | - `protocol`: Specify which protocol your engine uses. Choices are 6 | 1. `"uci"` for the [Universal Chess Interface](http://wbec-ridderkerk.nl/html/UCIProtocol.html) 7 | 2. `"xboard"` for the XBoard/WinBoard/[Chess Engine Communication Protocol](https://www.gnu.org/software/xboard/engine-intf.html) 8 | 3. `"homemade"` if you want to write your own engine in Python within lichess-bot. See [**Create a custom engine**](https://github.com/lichess-bot-devs/lichess-bot/wiki/Create-a-custom-engine). 9 | - `ponder`: Specify whether your bot will ponder--i.e., think while the bot's opponent is choosing a move. 10 | - `engine_options`: Command line options to pass to the engine on startup. For example, the `config.yml.default` has the configuration 11 | ```yml 12 | engine_options: 13 | cpuct: 3.1 14 | ``` 15 | This would create the command-line option `--cpuct=3.1` to be used when starting the engine, like this for the engine lc0: `lc0 --cpuct=3.1`. Any number of options can be listed here, each getting their own command-line option. 16 | - `uci_options`: A list of options to pass to a UCI engine after startup. Different engines have different options, so treat the options in `config.yml.default` as templates and not suggestions. When UCI engines start, they print a list of configurations that can modify their behavior after receiving the string "uci". For example, to find out what options Stockfish 13 supports, run the executable in a terminal, type `uci`, and press Enter. The engine will print the following when run at the command line: 17 | ``` 18 | id name Stockfish 13 19 | id author the Stockfish developers (see AUTHORS file) 20 | 21 | option name Debug Log File type string default 22 | option name Contempt type spin default 24 min -100 max 100 23 | option name Analysis Contempt type combo default Both var Off var White var Black var Both 24 | option name Threads type spin default 1 min 1 max 512 25 | option name Hash type spin default 16 min 1 max 33554432 26 | option name Clear Hash type button 27 | option name Ponder type check default false 28 | option name MultiPV type spin default 1 min 1 max 500 29 | option name Skill Level type spin default 20 min 0 max 20 30 | option name Move Overhead type spin default 10 min 0 max 5000 31 | option name Slow Mover type spin default 100 min 10 max 1000 32 | option name nodestime type spin default 0 min 0 max 10000 33 | option name UCI_Chess960 type check default false 34 | option name UCI_AnalyseMode type check default false 35 | option name UCI_LimitStrength type check default false 36 | option name UCI_Elo type spin default 1350 min 1350 max 2850 37 | option name UCI_ShowWDL type check default false 38 | option name SyzygyPath type string default 39 | option name SyzygyProbeDepth type spin default 1 min 1 max 100 40 | option name Syzygy50MoveRule type check default true 41 | option name SyzygyProbeLimit type spin default 7 min 0 max 7 42 | option name Use NNUE type check default true 43 | option name EvalFile type string default nn-62ef826d1a6d.nnue 44 | uciok 45 | ``` 46 | Any of the names following `option name` can be listed in `uci_options` in order to configure the Stockfish engine. 47 | ```yml 48 | uci_options: 49 | Move Overhead: 100 50 | Skill Level: 10 51 | ``` 52 | The exceptions to this are the options `uci_chess960`, `uci_variant`, `multipv`, and `ponder`. These will be handled by lichess-bot after a game starts and should not be listed in `config.yml`. Also, if an option is listed under `uci_options` that is not in the list printed by the engine, it will cause an error when the engine starts because the engine won't understand the option. The word after `type` indicates the expected type of the options: `string` for a text string, `spin` for a numeric value, `check` for a boolean True/False value. 53 | 54 | One last option is `go_commands`. Beneath this option, arguments to the UCI `go` command can be passed. For example, 55 | ```yml 56 | go_commands: 57 | nodes: 1 58 | depth: 5 59 | movetime: 1000 60 | ``` 61 | will append `nodes 1 depth 5 movetime 1000` to the command to start thinking of a move: `go startpos e2e4 e7e5 ...`. 62 | 63 | - `xboard_options`: A list of options to pass to an XBoard engine after startup. Different engines have different options, so treat the options in `config.yml.default` as templates and not suggestions. When XBoard engines start, they print a list of configurations that can modify their behavior. To see these configurations, run the engine in a terminal, type `xboard`, press Enter, type `protover 2`, and press Enter. The configurable options will be prefixed with `feature option`. Some examples may include 64 | ``` 65 | feature option="Add Noise -check VALUE" 66 | feature option="PGN File -string VALUE" 67 | feature option="CPU Count -spin VALUE MIN MAX" 68 | ``` 69 | Any of the options can be listed under `xboard_options` in order to configure the XBoard engine. 70 | ```yml 71 | xboard_options: 72 | Add Noise: False 73 | PGN File: lichess_games.pgn 74 | CPU Count: 1 75 | ``` 76 | The exceptions to this are the options `multipv`, and `ponder`. These will be handled by lichess-bot after a game starts and should not be listed in `config.yml`. Also, if an option is listed under `xboard_options` that is not in the list printed by the engine, it will cause an error when the engine starts because the engine won't know how to handle the option. The word prefixed with a hyphen indicates the expected type of the options: `-string` for a text string, `-spin` for a numeric value, `-check` for a boolean True/False value. 77 | 78 | One last option is `go_commands`. Beneath this option, commands prior to the `go` command can be passed. For example, 79 | ```yml 80 | go_commands: 81 | depth: 5 82 | ``` 83 | will precede the `go` command to start thinking with `sd 5`. The other `go_commands` list above for UCI engines (`nodes` and `movetime`) are not valid for XBoard engines and will detrimentally affect their time control. 84 | 85 | ## External moves 86 | - `polyglot`: Tell lichess-bot whether your bot should use an opening book. Multiple books can be specified for each chess variant. 87 | - `enabled`: Whether to use the book at all. 88 | - `book`: A nested list of books. The next indented line should list a chess variant (`standard`, `3check`, `horde`, etc.) followed on succeeding indented lines with paths to the book files. See `config.yml.default` for examples. 89 | - `min_weight`: The minimum weight or quality a move must have if it is to have a chance of being selected. If a move cannot be found that has at least this weight, no move will be selected. 90 | - `selection`: The method for selecting a move. The choices are: `"weighted_random"` where moves with a higher weight/quality have a higher probability of being chosen, `"uniform_random"` where all moves of sufficient quality have an equal chance of being chosen, and `"best_move"` where the move with the highest weight is always chosen. 91 | - `max_depth`: The maximum number of moves a bot plays before it stops consulting the book. If `max_depth` is 3, then the bot will stop consulting the book after its third move. 92 | - `online_moves`: This section gives your bot access to various online resources for choosing moves like opening books and endgame tablebases. This can be a supplement or a replacement for chess databases stored on your computer. There are four sections that correspond to four different online databases: 93 | 1. `chessdb_book`: Consults a [Chinese chess position database](https://www.chessdb.cn/), which also hosts a xiangqi database. 94 | 2. `lichess_cloud_analysis`: Consults [Lichess's own position analysis database](https://lichess.org/api#operation/apiCloudEval). 95 | 3. `lichess_opening_explorer`: Consults [Lichess's opening explorer](https://lichess.org/api#tag/Opening-Explorer). 96 | 4. `online_egtb`: Consults either the online Syzygy 7-piece endgame tablebase [hosted by Lichess](https://lichess.org/blog/W3WeMyQAACQAdfAL/7-piece-syzygy-tablebases-are-complete) or the chessdb listed above. 97 | - `max_out_of_book_moves`: Stop using online opening books after they don't have a move for `max_out_of_book_moves` positions. Doesn't apply to the online endgame tablebases. 98 | - `max_retries`: The maximum amount of retries when getting an online move. 99 | - Configurations common to all: 100 | - `enabled`: Whether to use the database at all. 101 | - `min_time`: The minimum time in seconds on the game clock necessary to allow the online database to be consulted. 102 | - `move_quality`: Choice of `"all"` (`chessdb_book` only), `"good"` (all except `online_egtb`), `"best"`, or `"suggest"` (`online_egtb` only). 103 | - `all`: Choose a random move from all legal moves. 104 | - `best`: Choose only the highest scoring move. 105 | - `good`: Choose randomly from the top moves. In `lichess_cloud_analysis`, the top moves list is controlled by `max_score_difference`. In `chessdb_book`, the top list is controlled by the online source. 106 | - `suggest`: Let the engine choose between the top moves. The top moves are the all the moves that have the best WDL. Can't be used with XBoard engines. 107 | - Configurations only in `chessdb_book` and `lichess_cloud_analysis`: 108 | - `min_depth`: The minimum search depth for a move evaluation for a database move to be accepted. 109 | - Configurations only in `lichess_cloud_analysis`: 110 | - `max_score_difference`: When `move_quality` is set to `"good"`, this option specifies the maximum difference between the top scoring move and any other move that will make up the set from which a move will be chosen randomly. If this option is set to 25 and the top move in a position has a score of 100, no move with a score of less than 75 will be returned. 111 | - `min_knodes`: The minimum number of kilonodes to search. The minimum number of nodes to search is this value times 1000. 112 | - Configurations only in `lichess_opening_explorer`: 113 | - `source`: One of `lichess`, `masters`, or `player`. Whether to use move statistics from masters, lichess players, or a specific player. 114 | - `player_name`: Used only when `source` is `player`. The username of the player to use for move statistics. 115 | - `sort`: One of `winrate` or `games_played`. Whether to choose the best move according to the winrate or the games played. 116 | - `min_games`: The minimum number of times a move must have been played to be considered. 117 | - Configurations only in `online_egtb`: 118 | - `max_pieces`: The maximum number of pieces in the current board for which the tablebase will be consulted. 119 | - `source`: One of `chessdb` or `lichess`. Lichess also has tablebases for atomic and antichess while chessdb only has those for standard. 120 | - `lichess_bot_tbs`: This section gives your bot access to various resources for choosing moves like syzygy and gaviota endgame tablebases. There are two sections that correspond to two different endgame tablebases: 121 | 1. `syzygy`: Get moves from syzygy tablebases. `.*tbw` have to be always provided. Syzygy TBs are generally smaller that gaviota TBs. 122 | 2. `gaviota`: Get moves from gaviota tablebases. 123 | - Configurations common to all: 124 | - `enabled`: Whether to use the tablebases at all. 125 | - `paths`: The paths to the tablebases. 126 | - `max_pieces`: The maximum number of pieces in the current board for which the tablebase will be consulted. 127 | - `move_quality`: Choice of `best` or `suggest`. 128 | - `best`: Choose only the highest scoring move. When using `syzygy`, if `.*tbz` files are not provided, the bot will attempt to get a move using `move_quality` = `suggest`. 129 | - `suggest`: Let the engine choose between the top moves. The top moves are the all the moves that have the best WDL. Can't be used with XBoard engines. 130 | - Configurations only in `gaviota`: 131 | - `min_dtm_to_consider_as_wdl_1`: The minimum DTM to consider as syzygy WDL=1/-1. Setting it to 100 will disable it. 132 | 133 | ## Offering draw and resigning 134 | - `draw_or_resign`: This section allows your bot to resign or offer/accept draw based on the evaluation by the engine. XBoard engines can resign and offer/accept draw without this feature enabled. 135 | - `resign_enabled`: Whether the bot is allowed to resign based on the evaluation. 136 | - `resign_score`: The engine evaluation has to be less than or equal to `resign_score` for the bot to resign. 137 | - `resign_for_egtb_minus_two`: If true the bot will resign in positions where the online_egtb returns a wdl of -2. 138 | - `resign_moves`: The evaluation has to be less than or equal to `resign_score` for `resign_moves` amount of moves for the bot to resign. 139 | - `offer_draw_enabled`: Whether the bot is allowed to offer/accept draw based on the evaluation. 140 | - `offer_draw_score`: The absolute value of the engine evaluation has to be less than or equal to `offer_draw_score` for the bot to offer/accept draw. 141 | - `offer_draw_for_egtb_zero`: If true the bot will offer/accept draw in positions where the online_egtb returns a wdl of 0. 142 | - `offer_draw_moves`: The absolute value of the evaluation has to be less than or equal to `offer_draw_score` for `offer_draw_moves` amount of moves for the bot to offer/accept draw. 143 | - `offer_draw_pieces`: The bot only offers/accepts draws if the position has less than or equal to `offer_draw_pieces` pieces. 144 | 145 | ## Options for correspondence games 146 | - `correspondence` These options control how the engine behaves during correspondence games. 147 | - `move_time`: How many seconds to think for each move. 148 | - `checkin_period`: How often (in seconds) to reconnect to games to check for new moves after disconnecting. 149 | - `disconnect_time`: How many seconds to wait after the bot makes a move for an opponent to make a move. If no move is made during the wait, disconnect from the game. 150 | - `ponder`: Whether the bot should ponder during the above waiting period. 151 | 152 | ## Challenges the BOT should accept 153 | - `challenge`: Control what kind of games for which the bot should accept challenges. All of the following options must be satisfied by a challenge to be accepted. 154 | - `concurrency`: The maximum number of games to play simultaneously. 155 | - `sort_by`: Whether to start games by the best rated/titled opponent `"best"` or by first-come-first-serve `"first"`. 156 | - `accept_bot`: Whether to accept challenges from other bots. 157 | - `only_bot`: Whether to only accept challenges from other bots. 158 | - `max_increment`: The maximum value of time increment. 159 | - `min_increment`: The minimum value of time increment. 160 | - `bullet_requires_increment`: Require that bullet game challenges from bots have a non-zero increment. This can be useful if a bot often loses on time in short games due to spotty network connections or other sources of delay. 161 | - `max_base`: The maximum base time for a game. 162 | - `min_base`: The minimum base time for a game. 163 | - `max_days`: The maximum number of days for a correspondence game. 164 | - `min_days`: The minimum number of days for a correspondence game. 165 | - `variants`: An indented list of chess variants that the bot can handle. 166 | ```yml 167 | variants: 168 | - standard 169 | - horde 170 | - antichess 171 | # etc. 172 | ``` 173 | - `time_controls`: An indented list of acceptable time control types from `bullet` to `correspondence`. 174 | ```yml 175 | time_controls: 176 | - bullet 177 | - blitz 178 | - rapid 179 | - classical 180 | - correspondence 181 | ``` 182 | - `modes`: An indented list of acceptable game modes (`rated` and/or `casual`). 183 | ```yml 184 | modes: 185 | -rated 186 | -casual 187 | ``` 188 | - `block_list`: An indented list of usernames from which the challenges are always declined. If this option is not present, then the list is considered empty. 189 | - `allow_list`: An indented list of usernames from which challenges are exclusively accepted. A challenge from a user not on this list is declined. If this option is not present or empty, any user's challenge may be accepted. 190 | - `recent_bot_challenge_age`: Maximum age of a bot challenge to be considered recent in seconds 191 | - `max_recent_bot_challenges`: Maximum number of recent challenges that can be accepted from the same bot 192 | 193 | ## Greeting 194 | - `greeting`: Send messages via chat to the bot's opponent. The string `{me}` will be replaced by the bot's lichess account name. The string `{opponent}` will be replaced by the opponent's lichess account name. Any other word between curly brackets will be removed. If you want to put a curly bracket in the message, use two: `{{` or `}}`. 195 | - `hello`: Message to send to the opponent when the bot makes its first move. 196 | - `goodbye`: Message to send to the opponent once the game is over. 197 | - `hello_spectators`: Message to send to the spectators when the bot makes its first move. 198 | - `goodbye_spectators`: Message to send to the spectators once the game is over. 199 | ```yml 200 | greeting: 201 | hello: Hi, {opponent}! I'm {me}. Good luck! 202 | goodbye: Good game! 203 | hello_spectators: "Hi! I'm {me}. Type !help for a list of commands I can respond to." # Message to send to spectator chat at the start of a game 204 | goodbye_spectators: "Thanks for watching!" # Message to send to spectator chat at the end of a game 205 | ``` 206 | 207 | ## Other options 208 | - `abort_time`: How many seconds to wait before aborting a game due to opponent inaction. This only applies during the first six moves of the game. 209 | - `fake_think_time`: Artificially slow down the engine to simulate a person thinking about a move. The amount of thinking time decreases as the game goes on. 210 | - `rate_limiting_delay`: For extremely fast games, the lichess.org servers may respond with an error if too many moves are played too quickly. This option avoids this problem by pausing for a specified number of milliseconds after submitting a move before making the next move. 211 | - `move_overhead`: To prevent losing on time due to network lag, subtract this many milliseconds from the time to think on each move. 212 | - `pgn_directory`: Write a record of every game played in PGN format to files in this directory. Each bot move will be annotated with the bot's calculated score and principal variation. The score is written with a tag of the form `[%eval s,d]`, where `s` is the score in pawns (positive means white has the advantage), and `d` is the depth of the search. 213 | - `pgn_file_grouping`: Determine how games are written to files. There are three options: 214 | - `game`: Every game record is written to a different file in the `pgn_directory`. The file name is `{White name} vs. {Black name} - {lichess game ID}.pgn`. 215 | - `opponent`: Game records are written to files named according to the bot's opponent. The file name is `{Bot name} games vs. {Opponent name}.pgn`. 216 | - `all`: All games are written to the same file. The file name is `{Bot name} games.pgn`. 217 | ```yml 218 | pgn_directory: "game_records" 219 | pgn_file_grouping: "all" 220 | ``` 221 | 222 | ## Challenging other bots 223 | - `matchmaking`: Challenge a random bot. 224 | - `allow_matchmaking`: Whether to challenge other bots. 225 | - `challenge_variant`: The variant for the challenges. If set to `random` a variant from the ones enabled in `challenge.variants` will be chosen at random. 226 | - `challenge_timeout`: The time (in minutes) the bot has to be idle before it creates a challenge. 227 | - `challenge_initial_time`: A list of initial times (in seconds and to be chosen at random) for the challenges. 228 | - `challenge_increment`: A list of increments (in seconds and to be chosen at random) for the challenges. 229 | - `challenge_days`: A list of number of days for a correspondence challenge (to be chosen at random). 230 | - `opponent_min_rating`: The minimum rating of the opponent bot. The minimum rating in lichess is 600. 231 | - `opponent_max_rating`: The maximum rating of the opponent bot. The maximum rating in lichess is 4000. 232 | - `opponent_rating_difference`: The maximum difference between the bot's rating and the opponent bot's rating. 233 | - `opponent_allow_tos_violation`: Whether to challenge bots that violated Lichess Terms of Service. Note that even rated games against them will not affect ratings. 234 | - `challenge_mode`: Possible options are `casual`, `rated` and `random`. 235 | - `challenge_filter`: Whether and how to prevent challenging a bot after that bot declines a challenge. Options are `none`, `coarse`, and `fine`. 236 | - `none` does not prevent challenging a bot that declined a challenge. 237 | - `coarse` will prevent challenging a bot to any type of game after it declines one challenge. 238 | - `fine` will prevent challenging a bot to the same kind of game that was declined. 239 | 240 | The `challenge_filter` option can be useful if your matchmaking settings result in a lot of declined challenges. The bots that accept challenges will be challenged more often than those that have declined. The filter will remain until lichess-bot quits or the connection with lichess.org is reset. 241 | - `block_list`: An indented list of usernames of bots that will not be challenged. If this option is not present, then the list is considered empty. 242 | - `overrides`: Create variations on the matchmaking settings above for more specific circumstances. If there are any subsections under `overrides`, the settings below that will override the settings in the matchmaking section. Any settings that do not appear will be taken from the settings above.

243 | The overrides section must have the following: 244 | - Name: A unique name must be given for each override. In the example configuration below, `easy_chess960` and `no_pressure_correspondence` are arbitrary strings to name the subsections and they are unique. 245 | - List of options: A list of options to override. Only the options mentioned will change when making the challenge. The rest will follow the default matchmaking options. In the example settings below, the blank settings for `challenge_initial_time` and `challenge_increment` under `no_pressure_correspondence` have the effect of deleting these settings, meaning that only correspondence games are possible. 246 | 247 | For each matchmaking challenge, the default settings and each override have equal probability of being chosen to create the challenge. For example, in the example configuration below, the default settings, `easy_chess960`, and `no_pressure_correspondence` all have a 1/3 chance of being used to create the next challenge. 248 | 249 | The following configurations cannot be overridden: `allow_matchmaking`, `challenge_timeout`, `challenge_filter` and `block_list`. 250 | - Additional Points: 251 | - If there are entries for both real-time (`challenge_initial_time` and/or `challenge_increment`) and correspondence games (`challenge_days`), the challenge will be a random choice between the two. 252 | - If there are entries for both absolute ratings (`opponent_min_rating` and `opponent_max_rating`) and rating difference (`opponent_rating_difference`), the rating difference takes precedence. 253 | 254 | ```yml 255 | matchmaking: 256 | allow_matchmaking: false 257 | challenge_variant: "random" 258 | challenge_timeout: 30 259 | challenge_initial_time: 260 | - 60 261 | - 120 262 | challenge_increment: 263 | - 1 264 | - 2 265 | challenge_days: 266 | - 1 267 | - 2 268 | # opponent_min_rating: 600 269 | # opponent_max_rating: 4000 270 | opponent_rating_difference: 100 271 | opponent_allow_tos_violation: true 272 | challenge_mode: "random" 273 | challenge_filter: none 274 | overrides: 275 | easy_chess960: 276 | challenge_variant: "chess960" 277 | opponent_min_rating: 400 278 | opponent_max_rating: 1200 279 | opponent_rating_difference: 280 | challenge_mode: casual 281 | no_pressure_correspondence: 282 | challenge_initial_time: 283 | challenge_increment: 284 | challenge_days: 285 | - 2 286 | - 3 287 | challenge_mode: casual 288 | ``` 289 | 290 | **Next step**: [Run lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Run-lichess%E2%80%90bot) 291 | 292 | **Previous step**: [Setup the engine](https://github.com/lichess-bot-devs/lichess-bot/wiki/Setup-the-engine) 293 | --------------------------------------------------------------------------------