diff --git a/lichess-bot-master/.gitattributes b/lichess-bot-master/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..7f8cd8296f7d2d45cb8fb0253352e4b1147e7383 --- /dev/null +++ b/lichess-bot-master/.gitattributes @@ -0,0 +1,2 @@ +# Normalize line endings +* text=auto diff --git a/lichess-bot-master/.github/ISSUE_TEMPLATE/bug_report.md b/lichess-bot-master/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000000000000000000000000000000000..1e46b727b504899c977125c2a0ae94db28935e65 --- /dev/null +++ b/lichess-bot-master/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Create a bug report +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Run lichess-bot with [...] commands [e.g. `python lichess-bot.py -v`] +2. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Logs** +Upload `lichess_bot_auto_logs\lichess-bot.log`, older `lichess-bot.log.` files as needed, `config.log` for your bot's configuration, and other logs/screenshots of the error. If you upload `config.yml` or other configuration file, be sure to delete the token before uploading. + +**Desktop (please complete the following information):** + - OS: [e.g. Windows] + - Python Version: [e.g. 3.11] + - Terminal: [e.g. Command Line, PowerShell] + +**Additional context** +Add any other context about the problem here. diff --git a/lichess-bot-master/.github/ISSUE_TEMPLATE/feature_request.md b/lichess-bot-master/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000000000000000000000000000000000000..bbcbbe7d61558adde3cbfd0c7a63a67c27ed6d30 --- /dev/null +++ b/lichess-bot-master/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/lichess-bot-master/.github/dependabot.yml b/lichess-bot-master/.github/dependabot.yml new file mode 100644 index 0000000000000000000000000000000000000000..4cc8b836a57c9772a01f15057e78aef92012b9f8 --- /dev/null +++ b/lichess-bot-master/.github/dependabot.yml @@ -0,0 +1,17 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "daily" + versioning-strategy: increase + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/lichess-bot-master/.github/pull_request_template.md b/lichess-bot-master/.github/pull_request_template.md new file mode 100644 index 0000000000000000000000000000000000000000..a8b6225745e0cd7fb281fcdc4b84b5773b271769 --- /dev/null +++ b/lichess-bot-master/.github/pull_request_template.md @@ -0,0 +1,20 @@ +## Type of pull request: +- [ ] Bug fix +- [ ] Feature +- [ ] Other + +## Description: + +[Provide a brief description of the changes introduced by this pull request.] + +## Related Issues: + +[Reference any related issues that this pull request addresses or closes. Use the syntax `Closes #issue_number` to automatically close the linked issue upon merging.] + +## Checklist: + +- [ ] I have read and followed the [contribution guidelines](/docs/CONTRIBUTING.md). +- [ ] I have added necessary documentation (if applicable). +- [ ] The changes pass all existing tests. + +## Screenshots/logs (if applicable): diff --git a/lichess-bot-master/.github/workflows/docker.yml b/lichess-bot-master/.github/workflows/docker.yml new file mode 100644 index 0000000000000000000000000000000000000000..5d67222669524cf788646e93dfeec01c6ba34ca5 --- /dev/null +++ b/lichess-bot-master/.github/workflows/docker.yml @@ -0,0 +1,85 @@ +name: Docker CI + +on: + workflow_run: + workflows: [Versioning] + types: + - completed + +env: + GB_REGISTRY: ghcr.io + +jobs: + build: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + steps: + - uses: actions/checkout@v6 + + - name: Assign defaultValue + run: | + version=$(grep -m1 '^lichess_bot_version: ' lib/versioning.yml | sed -n -e 's/^.*://p' | tr -d '[:space:]') + echo version="$version" >> "$GITHUB_ENV" + + - name: show me docker tags. + run: | + echo "version:'"$version"'" + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Login to Github container registry + uses: docker/login-action@v4 + with: + registry: ${{ env.GB_REGISTRY }} + username: ${{ secrets.GB_REPO_USER }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: build and push alpine image + uses: docker/build-push-action@v7 + with: + context: . + file: ./docker/Dockerfile + platforms: | + linux/amd64 + linux/arm64 + build-args: VARIANT=-alpine + push: true + tags: | + ${{ vars.DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:alpine + ${{ vars.DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:${{ env.version }}-alpine + ${{ env.GB_REGISTRY }}/${{ vars.GHCR_DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:alpine + ${{ env.GB_REGISTRY }}/${{ vars.GHCR_DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:${{ env.version }}-alpine + + - name: build and push fat image + uses: docker/build-push-action@v7 + with: + context: . + file: ./docker/Dockerfile + platforms: | + linux/amd64 + linux/arm64 + push: true + tags: | + ${{ vars.DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:latest + ${{ vars.DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:${{ env.version }} + ${{ env.GB_REGISTRY }}/${{ vars.GHCR_DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:latest + ${{ env.GB_REGISTRY }}/${{ vars.GHCR_DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:${{ env.version }} + + - name: push README to Dockerhub + uses: peter-evans/dockerhub-description@v5 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_TOKEN }} + repository: lichessbotdevs/lichess-bot + short-description: 'A bridge between Lichess API and chess engines' + readme-filepath: './wiki/How-to-use-the-Docker-image.md' diff --git a/lichess-bot-master/.github/workflows/mypy.yml b/lichess-bot-master/.github/workflows/mypy.yml new file mode 100644 index 0000000000000000000000000000000000000000..f8a5c82cdcdda4d9ea48ef51c64ba175798372ce --- /dev/null +++ b/lichess-bot-master/.github/workflows/mypy.yml @@ -0,0 +1,32 @@ +# This workflow will install Python dependencies and run mypy + +name: Mypy + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + mypy: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [windows-latest] + python: ["3.10", "3.14"] + + steps: + - uses: actions/checkout@v6 + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r test_bot/test-requirements.txt + - name: Run mypy + run: | + mypy --strict . diff --git a/lichess-bot-master/.github/workflows/python-build.yml b/lichess-bot-master/.github/workflows/python-build.yml new file mode 100644 index 0000000000000000000000000000000000000000..d98454631baf8b7ece49cef6fd4985d9d7d34abf --- /dev/null +++ b/lichess-bot-master/.github/workflows/python-build.yml @@ -0,0 +1,34 @@ +# This workflow will install Python dependencies and lint +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Python Build + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python: ["3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - uses: actions/checkout@v6 + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r test_bot/test-requirements.txt + - name: Lint with ruff + run: | + # Check for python syntax errors and inconsistent code style. + ruff check --config test_bot/ruff.toml diff --git a/lichess-bot-master/.github/workflows/python-test.yml b/lichess-bot-master/.github/workflows/python-test.yml new file mode 100644 index 0000000000000000000000000000000000000000..f6265616505b79a27d0fa7c7bab45d55c60ad222 --- /dev/null +++ b/lichess-bot-master/.github/workflows/python-test.yml @@ -0,0 +1,50 @@ +# This workflow will install Python dependencies and run tests +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Python Test + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python: ["3.10", "3.14"] + + + steps: + - uses: actions/checkout@v6 + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r test_bot/test-requirements.txt + - name: Restore engines + id: cache-temp-restore + uses: actions/cache/restore@v5 + with: + path: | + TEMP + key: ${{ matrix.os }}-engines + - name: Test with pytest + env: + LICHESS_BOT_TEST_TOKEN: ${{secrets.LICHESS_BOT_TEST_TOKEN}} + run: | + pytest --log-cli-level=10 + - name: Save engines + id: cache-temp-save + uses: actions/cache/save@v5 + with: + path: | + TEMP + key: ${{ steps.cache-temp-restore.outputs.cache-primary-key }} diff --git a/lichess-bot-master/.github/workflows/sync-wiki.yml b/lichess-bot-master/.github/workflows/sync-wiki.yml new file mode 100644 index 0000000000000000000000000000000000000000..61c70a8322009900f3bd4b65d5f9b758706c3fef --- /dev/null +++ b/lichess-bot-master/.github/workflows/sync-wiki.yml @@ -0,0 +1,34 @@ +name: Sync wiki + +on: + push: + branches: + - master + paths: + - 'wiki/**' + - 'README.md' + +jobs: + sync: + if: github.repository == 'lichess-bot-devs/lichess-bot' + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + - name: Checkout wiki + uses: actions/checkout@v6 + with: + repository: "${{ github.repository }}.wiki" + path: "lichess-bot.wiki" + - name: Set github bot global config + run: | + git config --global user.email "actions@github.com" + git config --global user.name "actions-user" + - name: Sync all files to wiki + run: | + cp $GITHUB_WORKSPACE/README.md $GITHUB_WORKSPACE/lichess-bot.wiki/Home.md + cp -r $GITHUB_WORKSPACE/wiki/* $GITHUB_WORKSPACE/lichess-bot.wiki + cd $GITHUB_WORKSPACE/lichess-bot.wiki + git add . + git commit -m "Auto update wiki" + git push diff --git a/lichess-bot-master/.github/workflows/update_version.py b/lichess-bot-master/.github/workflows/update_version.py new file mode 100644 index 0000000000000000000000000000000000000000..92b1c6e59600017ec2f2c98ba8e4997437e328c7 --- /dev/null +++ b/lichess-bot-master/.github/workflows/update_version.py @@ -0,0 +1,29 @@ +"""Automatically updates the lichess-bot version.""" +import yaml +import datetime +import os + +# File is part of an implicit namespace package. Add an `__init__.py`. +# ruff: noqa: INP001 + +with open("lib/versioning.yml") as version_file: + versioning_info = yaml.safe_load(version_file) + +current_version = versioning_info["lichess_bot_version"] + +utc_datetime = datetime.datetime.now(datetime.UTC) +new_version = f"{utc_datetime.year}.{utc_datetime.month}.{utc_datetime.day}." +if current_version.startswith(new_version): + current_version_list = current_version.split(".") + current_version_list[-1] = str(int(current_version_list[-1]) + 1) + new_version = ".".join(current_version_list) +else: + new_version += "1" + +versioning_info["lichess_bot_version"] = new_version + +with open("lib/versioning.yml", "w") as version_file: + yaml.dump(versioning_info, version_file, sort_keys=False) + +with open(os.environ["GITHUB_OUTPUT"], "a") as fh: + print(f"new_version={new_version}", file=fh) diff --git a/lichess-bot-master/.github/workflows/versioning.yml b/lichess-bot-master/.github/workflows/versioning.yml new file mode 100644 index 0000000000000000000000000000000000000000..b2b6d83ac2b3de8a95d5a0866cbbf3fc9dd16055 --- /dev/null +++ b/lichess-bot-master/.github/workflows/versioning.yml @@ -0,0 +1,30 @@ +name: Versioning + +on: + push: + branches: [ master ] + +jobs: + versioning: + if: github.repository == 'lichess-bot-devs/lichess-bot' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Set up Python 3.11 + uses: actions/setup-python@v6 + with: + python-version: "3.11" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install PyYAML + - name: Update version + id: new-version + run: python .github/workflows/update_version.py + - name: Auto update version + run: | + git config --global user.email "actions@github.com" + git config --global user.name "actions-user" + git add lib/versioning.yml + git commit -m "Auto update version to ${{ steps.new-version.outputs.new_version }}" + git push origin HEAD:master diff --git a/lichess-bot-master/.gitignore b/lichess-bot-master/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..48bc0828c00614d94466e8daa22f56d55120eec7 --- /dev/null +++ b/lichess-bot-master/.gitignore @@ -0,0 +1,11 @@ +*.yml +**/__pycache__ +/engines/* +!/engines/README.md +lichess_bot_auto_logs/* +.vscode/* +.vs/* +*.pgn +*.log +*.txt +TEMP/* diff --git a/lichess-bot-master/CITATION.cff b/lichess-bot-master/CITATION.cff new file mode 100644 index 0000000000000000000000000000000000000000..4725d22f446e91f1d34fb4e64d86796721c745dc --- /dev/null +++ b/lichess-bot-master/CITATION.cff @@ -0,0 +1,36 @@ +# This CITATION.cff file was generated with cffinit. +# Visit https://bit.ly/cffinit to generate yours today! + +cff-version: 1.2.0 +title: lichess-bot +message: >- + If you use this software, please cite it using the + metadata from this file. +type: software +authors: + - given-names: Ioannis + family-names: Pantidis + - given-names: Mark + family-names: Harrison + orcid: 'https://orcid.org/0000-0001-7212-8810' + - given-names: Shail + family-names: Choksi + - given-names: Thibault + family-names: Duplessis + affiliation: lichess.org +repository-code: 'https://github.com/lichess-bot-devs/lichess-bot' +abstract: >- + lichess-bot is a free bridge between the Lichess Bot API + and chess engines. + + With lichess-bot, you can create and operate a bot on + lichess. Your bot will be able to play against humans and + bots alike, and you will be able to view these games live + on lichess. +keywords: + - chess + - chess engine + - lichess + - API + - game +license: AGPL-3.0 diff --git a/lichess-bot-master/LICENSE b/lichess-bot-master/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..dba13ed2ddf783ee8118c6a581dbf75305f816a3 --- /dev/null +++ b/lichess-bot-master/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/lichess-bot-master/README.md b/lichess-bot-master/README.md new file mode 100644 index 0000000000000000000000000000000000000000..694c4bbfffa322b3ea825035bf671f01ec01241d --- /dev/null +++ b/lichess-bot-master/README.md @@ -0,0 +1,65 @@ +
+ + ![lichess-bot](https://github.com/lichess-bot-devs/lichess-bot-images/blob/main/lichess-bot-icon-400.png) + +

lichess-bot

+ + A bridge between [lichess.org](https://lichess.org) and bots. +
+ [Explore lichess-bot docs ยป](https://github.com/lichess-bot-devs/lichess-bot/wiki) +
+
+ [![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) + [![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) + [![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) + +
+ +## Overview + +[lichess-bot](https://github.com/lichess-bot-devs/lichess-bot) is a free bridge +between the [Lichess Bot API](https://lichess.org/api#tag/Bot) and chess engines. + +With lichess-bot, you can create and operate a bot on lichess. Your bot will be able to play against humans and bots alike, and you will be able to view these games live on lichess. + +See also the lichess-bot [documentation](https://github.com/lichess-bot-devs/lichess-bot/wiki) for further usage help. + +## Features +Supports: +- Every variant and time control +- UCI, XBoard, and Homemade engines +- Matchmaking (challenging other bots) +- Offering Draws and Resigning +- Participating in tournaments +- Accepting move takeback requests from opponents +- Saving games as PGN +- Local & Online Opening Books +- Local & Online Endgame Tablebases + +Can run on: +- Python 3.10 and later +- Windows, Linux and MacOS +- Docker + +## Steps +1. [Install lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Install) +2. [Create a lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token) +3. [Setup the engine](https://github.com/lichess-bot-devs/lichess-bot/wiki/Setup-the-engine) +4. [Configure lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/Configure-lichess-bot) +5. [Upgrade to a BOT account](https://github.com/lichess-bot-devs/lichess-bot/wiki/Upgrade-to-a-BOT-account) +6. [Run lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Run-lichess%E2%80%90bot) + +## Advanced options +- [Create a homemade engine](https://github.com/lichess-bot-devs/lichess-bot/wiki/Create-a-homemade-engine) +- [Add extra customizations](https://github.com/lichess-bot-devs/lichess-bot/wiki/Extra-customizations) + +
+ +## Acknowledgements +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 [Niklas Fiekas](https://github.com/niklasf) and his [python-chess](https://github.com/niklasf/python-chess) code which allows engine communication seamlessly. + +## License +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. + +## Citation +If this software has been used for research purposes, please cite it using the "Cite this repository" menu on the right sidebar. For more information, check the [CITATION file](https://github.com/lichess-bot-devs/lichess-bot/blob/master/CITATION.cff). diff --git a/lichess-bot-master/config.yml.default b/lichess-bot-master/config.yml.default new file mode 100644 index 0000000000000000000000000000000000000000..2c4877fa844271d3863a2de478442b91a42fa4a2 --- /dev/null +++ b/lichess-bot-master/config.yml.default @@ -0,0 +1,253 @@ +token: "lip_kKCkBvIIxTKygbXxNv8Z" # Lichess OAuth2 Token. +url: "https://lichess.org/" # Lichess base URL. + +engine: # Engine settings. + dir: "./engines/stockfish" # Directory containing the engine. This can be an absolute path or one relative to lichess-bot/. + name: "stockfish-ubuntu-x86-64-avx2fd" # Binary name of the engine to use. + debug: False # When enabled, log all startup communication with the engine. +# interpreter: "java" +# interpreter_options: +# - "-jar" + working_dir: "" # Directory where the chess engine will read and write files. If blank or missing, the current directory is used. + # 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. + protocol: "uci" # "uci", "xboard" or "homemade" + ponder: true # Think on opponent's time. + + polyglot: + enabled: false # Activate polyglot book. + book: + standard: # List of book file paths for variant standard. + - engines/book1.bin + - engines/book2.bin +# atomic: # List of book file paths for variant atomic. +# - engines/atomicbook1.bin +# - engines/atomicbook2.bin +# etc. +# Use the same pattern for 'chess960', 'giveaway' (antichess), 'crazyhouse', 'horde', 'kingofthehill', 'racingkings' and '3check' as well. + min_weight: 1 # Does not select moves with weight below min_weight (min 0, max: 100 if normalization isn't "none" else 65535). + 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). + max_depth: 20 # How many moves from the start to take from the book. + normalization: "none" # Normalization method for the book weights. One of "none", "sum", or "max". + + draw_or_resign: + resign_enabled: false # Whether or not the bot should resign. + resign_score: -1000 # If the score is less than or equal to this value, the bot resigns (in cp). + resign_for_egtb_minus_two: true # If true the bot will resign in positions where the online_egtb returns a wdl of -2. + resign_moves: 3 # How many moves in a row the score has to be below the resign value. + offer_draw_enabled: true # Whether or not the bot should offer/accept draw. + 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). + 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. + offer_draw_moves: 10 # How many moves in a row the absolute value of the score has to be below the draw value. + offer_draw_pieces: 10 # Only if the pieces on board are less than or equal to this value, the bot offers/accepts draw. + + online_moves: + 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. + max_retries: 2 # The maximum amount of retries when getting an online move. + # max_depth: 10 # How many moves from the start to take from online books. Default is no limit. + chessdb_book: + enabled: false # Whether or not to use chessdb book. + min_time: 20 # Minimum time (in seconds) to use chessdb book. + max_time: 10800 # Maximum starting game time (in seconds) to use chessdb book. + move_quality: "good" # One of "all", "good", "best". + min_depth: 20 # Only for move_quality: "best". + lichess_cloud_analysis: + enabled: false # Whether or not to use lichess cloud analysis. + min_time: 20 # Minimum time (in seconds) the bot must have to use cloud analysis. + max_time: 10800 # Maximum starting game time (in seconds) the bot must have to use cloud analysis. + move_quality: "best" # One of "good", "best". + max_score_difference: 50 # Only for move_quality: "good". The maximum score difference (in cp) between the best move and the other moves. + min_depth: 20 + min_knodes: 0 + lichess_opening_explorer: + enabled: false + min_time: 20 + max_time: 10800 # Maximum starting game time (in seconds) the bot must have to use the lichess opening explorer. + source: "masters" # One of "lichess", "masters", "player" + player_name: "" # The lichess username. Leave empty for the bot's username to be used. Used only when source is "player". + sort: "winrate" # One of "winrate", "games_played" + min_games: 10 # Minimum number of times a move must have been played to be chosen. + online_egtb: + enabled: false # Whether or not to enable online endgame tablebases. + min_time: 20 # Minimum time (in seconds) the bot must have to use online EGTBs. + max_time: 10800 # Maximum starting game time (in seconds) the bot must have to use online EGTBs. + max_pieces: 8 # Maximum number of pieces on the board to use endgame tablebases. + source: "lichess" # One of "lichess", "chessdb". + 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). + + lichess_bot_tbs: # The tablebases list here will be read by lichess-bot, not the engine. + syzygy: + enabled: false # Whether or not to use local syzygy endgame tablebases. + paths: # Paths to Syzygy endgame tablebases. + - "engines/syzygy" + max_pieces: 7 # Maximum number of pieces in the endgame tablebase. + 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). + gaviota: + enabled: false # Whether or not to use local gaviota endgame tablebases. + paths: + - "engines/gaviota" + max_pieces: 5 + min_dtm_to_consider_as_wdl_1: 120 # The minimum DTM to consider as syzygy WDL=1/-1. Set to 100 to disable. + 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). + +# engine_options: # Any custom command line params to pass to the engine. +# cpuct: 3.1 + + homemade_options: +# Hash: 256 + + uci_options: # Arbitrary UCI options passed to the engine. + Move Overhead: 100 # Increase if your bot flags games too often. + Threads: 4 # Max CPU threads the engine can use. + Hash: 512 # Max memory (in megabytes) the engine can allocate. + SyzygyPath: "./syzygy/" # Paths to Syzygy endgame tablebases that the engine reads. + UCI_ShowWDL: true # Show the chance of the engine winning. +# go_commands: # Additional options to pass to the UCI go command. +# nodes: 1 # Search so many nodes only. +# depth: 5 # Search depth ply only. +# movetime: 1000 # Integer. Search exactly movetime milliseconds. + +# xboard_options: # Arbitrary XBoard options passed to the engine. +# cores: "4" +# memory: "4096" +# egtpath: # Directory containing egtb (endgame tablabases), relative to this project. For 'xboard' engines. +# gaviota: "Gaviota path" +# nalimov: "Nalimov Path" +# scorpio: "Scorpio Path" +# syzygy: "Syzygy Path" +# go_commands: # Additional options to pass to the XBoard go command. +# depth: 5 # Search depth ply only. +# Do note that the go commands 'movetime' and 'nodes' are invalid and may cause bad time management for XBoard engines. + + silence_stderr: false # Some engines (yes you, Leela) are very noisy. + +abort_time: 30 # Time to abort a game in seconds when there is no activity. +fake_think_time: false # Artificially slow down the bot to pretend like it's thinking. +rate_limiting_delay: 0 # Time (in ms) to delay after sending a move to prevent "Too Many Requests" errors. +move_overhead: 2000 # Increase if your bot flags games too often. +max_takebacks_accepted: 0 # The number of times to allow an opponent to take back a move in a game. +quit_after_all_games_finish: false # If set to true, then pressing Ctrl-C to quit will only stop lichess-bot after all current games have finished. + +correspondence: + move_time: 60 # Time in seconds to search in correspondence games. + checkin_period: 300 # How often to check for opponent moves in correspondence games after disconnecting. + disconnect_time: 150 # Time before disconnecting from a correspondence game. + ponder: false # Ponder in correspondence games the bot is connected to. + +challenge: # Incoming challenges. + concurrency: 1 # Number of games to play simultaneously. + sort_by: "best" # Possible values: "best" and "first". + preference: "none" # Possible values: "none", "human", "bot". + accept_bot: true # Accepts challenges coming from other bots. + only_bot: false # Accept challenges by bots only. + max_increment: 20 # Maximum amount of increment to accept a challenge in seconds. The max is 180. Set to 0 for no increment. + min_increment: 0 # Minimum amount of increment to accept a challenge in seconds. + max_base: 1800 # Maximum amount of base time to accept a challenge in seconds. The max is 10800 (3 hours). + min_base: 0 # Minimum amount of base time to accept a challenge in seconds. + max_days: 14 # Maximum number of days per move to accept a challenge for a correspondence game. + # Unlimited games can be accepted by removing this field or specifying .inf + min_days: 1 # Minimum number of days per move to accept a challenge for a correspondence game. + variants: # Chess variants to accept (https://lichess.org/variant). + - standard +# - fromPosition +# - antichess +# - atomic +# - chess960 +# - crazyhouse +# - horde +# - kingOfTheHill +# - racingKings +# - threeCheck + time_controls: # Time controls to accept (bots are not allowed to play ultraBullet). + - bullet + - blitz + - rapid + - classical +# - correspondence + modes: # Game modes to accept. + - casual # Unrated games. + - rated # Rated games - must comment if the engine doesn't try to win. +# block_list: # List of users from which the challenges are always declined. +# - user1 +# - user2 +# online_block_list: # The urls from which to retrieve a list of bot names that will not be challenged. The list should be a text file where each line contains the name of a blocked bot +# - example.com/blocklist +# allow_list: # List of users from which challenges are exclusively accepted, all others being declined. If empty, challenges from all users may be accepted. +# - user3 +# - user4 +# recent_bot_challenge_age: 60 # Maximum age of a bot challenge to be considered recent in seconds +# max_recent_bot_challenges: 2 # Maximum number of recent challenges that can be accepted from the same bot +# min_rating: 0 # Minimum rating of the challenger to accept a challenge. +# max_rating: 4000 # Maximum rating of the challenger to accept a challenge. The maximum rating in lichess is 4000. +# rating_difference: 300 # Maximum rating difference between the bot and the challenger. Overrides min_rating and max_rating if set. + bullet_requires_increment: false # Require that bullet game challenges from bots have a non-zero increment + max_simultaneous_games_per_user: 5 # Maximum number of simultaneous games with the same user + +greeting: + # Optional substitution keywords (include curly braces): + # {opponent} to insert opponent's name + # {me} to insert bot's name + # Any other words in curly braces will be removed. + 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 + goodbye: "Good game!" # Message to send to opponent chat at the end of a game + 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 + goodbye_spectators: "Thanks for watching!" # Message to send to spectator chat at the end of a game + +# pgn_directory: "game_records" # A directory where PGN-format records of the bot's games are kept +# pgn_file_grouping: "game" # How to group games into files. Options are "game", "opponent", and "all" + # "game" (default) - every game is written to a different file named "{White name} vs. {Black name} - {lichess game ID}.pgn" + # "opponent" - every game with a given opponent is written to a file named "{Bot name} games vs. {Opponent name}.pgn" + # "all" - every game is written to a single file named "{Bot name} games.pgn" + +matchmaking: + allow_matchmaking: false # Set it to 'true' to challenge other bots. + allow_during_games: false # Set it to 'true' to create challenges during long games. + challenge_variant: "random" # If set to 'random', the bot will choose one variant from the variants enabled in 'challenge.variants'. + challenge_timeout: 30 # Create a challenge after being idle for 'challenge_timeout' minutes. The minimum is 1 minute. + challenge_initial_time: # Initial time in seconds of the challenge (to be chosen at random). + - 60 + - 180 + challenge_increment: # Increment in seconds of the challenge (to be chosen at random). + - 1 + - 2 +# challenge_days: # Days for correspondence challenge (to be chosen at random). +# - 1 +# - 2 +# opponent_min_rating: 600 # Opponents rating should be above this value (600 is the minimum rating in lichess). +# opponent_max_rating: 4000 # Opponents rating should be below this value (4000 is the maximum rating in lichess). + opponent_rating_difference: 300 # The maximum difference in rating between the bot's rating and opponent's rating. + rating_preference: "none" # One of "none", "high", "low". + challenge_mode: "random" # Set it to the mode in which challenges are sent. Possible options are 'casual', 'rated' and 'random'. + 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'. +# block_list: # The list of bots that will not be challenged +# - user1 +# - user2 +# online_block_list: # The urls from which to retrieve a list of bot names that will not be challenged. The list should be a text file where each line contains the name of a blocked bot +# - example.com/blocklist + include_challenge_block_list: false # Do not challenge bots in the challenge: block_list in addition to the matchmaking block list. + +# 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. +# 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). +# 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. +# challenge_initial_time: +# - 1 +# - 2 +# challenge_increment: +# - 0 +# - 1 +# +# easy_chess960: +# challenge_variant: "chess960" +# opponent_min_rating: 400 +# opponent_max_rating: 1200 +# opponent_rating_difference: +# challenge_mode: casual +# +# no_pressure_correspondence: +# challenge_initial_time: +# challenge_increment: +# challenge_days: +# - 2 +# - 3 +# challenge_mode: casual +# +# The following configurations cannot be overridden: allow_matchmaking, challenge_timeout, challenge_filter and block_list. diff --git a/lichess-bot-master/docker/Dockerfile b/lichess-bot-master/docker/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..0ff7e1d7e8802e87f9f776e3ca52ac2484becbf4 --- /dev/null +++ b/lichess-bot-master/docker/Dockerfile @@ -0,0 +1,22 @@ +ARG VARIANT +FROM python:3$VARIANT +LABEL org.opencontainers.image.authors="Lichess Bot Devs " +LABEL org.opencontainers.image.description="A bridge between Lichess API and chess engines" +LABEL org.opencontainers.image.source="https://github.com/lichess-bot-devs/lichess-bot" +LABEL org.opencontainers.image.documentation="https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-use-the-Docker-image" +LABEL org.opencontainers.image.title="lichess-bot" +LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later" + +ENV LICHESS_BOT_DOCKER="true" +ENV PYTHONDONTWRITEBYTECODE=1 + +ARG LICHESS_DIR=/lichess-bot +WORKDIR $LICHESS_DIR + +COPY . . + +RUN python3 -m pip install --no-cache-dir -r requirements.txt + +RUN chmod +x docker/copy_files.sh && ln -s $LICHESS_DIR/config/config.yml config.yml + +CMD docker/copy_files.sh && python3 lichess-bot.py ${OPTIONS} --disable_auto_logging diff --git a/lichess-bot-master/docker/Dockerfile.dockerignore b/lichess-bot-master/docker/Dockerfile.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..332436192756ecc01efeb0cd17bbd8e177942fc7 --- /dev/null +++ b/lichess-bot-master/docker/Dockerfile.dockerignore @@ -0,0 +1,6 @@ +docker/* +!docker/copy_files.sh +docs/ +wiki/ +.git* +**/__pycache__ diff --git a/lichess-bot-master/docker/copy_files.sh b/lichess-bot-master/docker/copy_files.sh new file mode 100644 index 0000000000000000000000000000000000000000..c7f3bcf3bd14643aec27fe47fea1317dc611f2e8 --- /dev/null +++ b/lichess-bot-master/docker/copy_files.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +if [ -e /lichess-bot/config/homemade.py ]; then + ln -sf /lichess-bot/config/homemade.py /lichess-bot/ +fi + +if [ -e /lichess-bot/config/extra_game_handlers.py ]; then + ln -sf /lichess-bot/config/extra_game_handlers.py /lichess-bot/ +fi diff --git a/lichess-bot-master/docs/CODE_OF_CONDUCT.md b/lichess-bot-master/docs/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000000000000000000000000000000000..427ca4318a5531ba95dbc7bd13d545943a29cbf9 --- /dev/null +++ b/lichess-bot-master/docs/CODE_OF_CONDUCT.md @@ -0,0 +1,65 @@ +# Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement +at lichess.bot.devs@gmail.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html + +For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq diff --git a/lichess-bot-master/docs/CONTRIBUTING.md b/lichess-bot-master/docs/CONTRIBUTING.md new file mode 100644 index 0000000000000000000000000000000000000000..6a93ea9f80dd60f7f13b5a180735d59e4f752ecf --- /dev/null +++ b/lichess-bot-master/docs/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Contributing to lichess-bot + +We welcome your contributions! There are multiple ways to contribute. + +## Table of Contents + +1. [Code of Conduct](#code-of-conduct) +2. [How to Contribute](#how-to-contribute) +3. [Reporting Bugs](#reporting-bugs) +4. [Requesting Features](#requesting-features) +5. [Submitting Pull Requests](#submitting-pull-requests) +6. [Testing](#testing) +7. [Documentation](#documentation) + +## Code of Conduct + +Please review our [Code of Conduct](/docs/CODE_OF_CONDUCT.md) before participating in our community. We want all contributors to feel welcome and to foster an open and inclusive environment. + +## How to Contribute + +We welcome contributions in the form of bug reports, feature requests, code changes, and documentation improvements. Here's how you can contribute: + +- Fork the repository to your GitHub account. +- Create a new branch for your feature or bug fix. +- Make your changes and commit them with a clear and concise commit message. +- Push your changes to your branch. +- Submit a pull request to the main repository. + +Please follow our [Pull Request Template](/.github/pull_request_template.md) when submitting a pull request. + +## Reporting Bugs + +If you find a bug, please open an issue with a detailed description of the problem. Include information about your environment and steps to reproduce the issue. +When filing a bug remember that the better written the bug is, the more likely it is to be fixed. +Please follow our [Bug Report Template](/.github/ISSUE_TEMPLATE/bug_report.md) when submitting a pull request. + +## Requesting Features + +We encourage you to open an issue to propose new features or improvements. Please provide as much detail as possible about your suggestion. +Please follow our [Feature Request Template](/.github/ISSUE_TEMPLATE/feature_request.md) when submitting a pull request. + +## Submitting Pull Requests + +When submitting a pull request, please ensure the following: + +- You have added or updated relevant documentation. +- Tests (if applicable) have been added or updated. +- Your branch is up-to-date with the main repository. +- The pull request title and description are clear and concise. + +## Testing + +Ensure that your changes pass all existing tests and consider adding new tests if applicable. + +## Documentation + +Improvements to the documentation are always welcome. If you find areas that need clarification or additional information, please submit a pull request. diff --git a/lichess-bot-master/docs/SECURITY.md b/lichess-bot-master/docs/SECURITY.md new file mode 100644 index 0000000000000000000000000000000000000000..7df28eceea4ce0796150cb11df10e82c398f54d8 --- /dev/null +++ b/lichess-bot-master/docs/SECURITY.md @@ -0,0 +1,9 @@ +# Reporting Security Issues + +The lichess-bot team and community take security bugs in lichess-bot seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. + +To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/lichess-bot-devs/lichess-bot/security/advisories/new) tab. + +The lichess-bot team will send a response indicating the next steps in handling your report. After the initial reply to your report, the team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. + +Report security bugs in third-party modules to the person or team maintaining the module. diff --git a/lichess-bot-master/engines/README.md b/lichess-bot-master/engines/README.md new file mode 100644 index 0000000000000000000000000000000000000000..f6e52713838eefcdf421bc93f7448ffb83da54d4 --- /dev/null +++ b/lichess-bot-master/engines/README.md @@ -0,0 +1 @@ +Put your engines and opening books here. \ No newline at end of file diff --git a/lichess-bot-master/extra_game_handlers.py b/lichess-bot-master/extra_game_handlers.py new file mode 100644 index 0000000000000000000000000000000000000000..f7d51de62d7e05004eec9a9408d3aefd75476445 --- /dev/null +++ b/lichess-bot-master/extra_game_handlers.py @@ -0,0 +1,21 @@ +"""Functions for the user to implement when the config file is not adequate to express bot requirements.""" +from lib import model +from lib.lichess_types import OPTIONS_TYPE + + +def game_specific_options(game: model.Game) -> OPTIONS_TYPE: # noqa: ARG001 + """ + Return a dictionary of engine options based on game aspects. + + By default, an empty dict is returned so that the options in the configuration file are used. + """ + return {} + + +def is_supported_extra(challenge: model.Challenge) -> bool: # noqa: ARG001 + """ + Determine whether to accept a challenge. + + By default, True is always returned so that there are no extra restrictions beyond those in the config file. + """ + return True diff --git a/lichess-bot-master/homemade.py b/lichess-bot-master/homemade.py new file mode 100644 index 0000000000000000000000000000000000000000..39870eaac115179abe01a82de864294fae78bdd3 --- /dev/null +++ b/lichess-bot-master/homemade.py @@ -0,0 +1,96 @@ +""" +Some example classes for people who want to create a homemade bot. + +With these classes, bot makers will not have to implement the UCI or XBoard interfaces themselves. +""" +import chess +from chess.engine import PlayResult, Limit +import random +from lib.engine_wrapper import MinimalEngine +from lib.lichess_types import MOVE, HOMEMADE_ARGS_TYPE +import logging + + +# Use this logger variable to print messages to the console or log files. +# logger.info("message") will always print "message" to the console or log file. +# logger.debug("message") will only print "message" if verbose logging is enabled. +logger = logging.getLogger(__name__) + + +class ExampleEngine(MinimalEngine): + """An example engine that all homemade engines inherit.""" + + +# Bot names and ideas from tom7's excellent eloWorld video + +class RandomMove(ExampleEngine): + """Get a random move.""" + + def search(self, board: chess.Board, *args: HOMEMADE_ARGS_TYPE) -> PlayResult: # noqa: ARG002 + """Choose a random move.""" + return PlayResult(random.choice(list(board.legal_moves)), None) + + +class Alphabetical(ExampleEngine): + """Get the first move when sorted by san representation.""" + + def search(self, board: chess.Board, *args: HOMEMADE_ARGS_TYPE) -> PlayResult: # noqa: ARG002 + """Choose the first move alphabetically.""" + moves = list(board.legal_moves) + moves.sort(key=board.san) + return PlayResult(moves[0], None) + + +class FirstMove(ExampleEngine): + """Get the first move when sorted by uci representation.""" + + def search(self, board: chess.Board, *args: HOMEMADE_ARGS_TYPE) -> PlayResult: # noqa: ARG002 + """Choose the first move alphabetically in uci representation.""" + moves = list(board.legal_moves) + moves.sort(key=str) + return PlayResult(moves[0], None) + + +class ComboEngine(ExampleEngine): + """ + Get a move using multiple different methods. + + This engine demonstrates how one can use `time_limit`, `draw_offered`, and `root_moves`. + """ + + def search(self, + board: chess.Board, + time_limit: Limit, + ponder: bool, # noqa: ARG002 + draw_offered: bool, + root_moves: MOVE) -> PlayResult: + """ + Choose a move using multiple different methods. + + :param board: The current position. + :param time_limit: Conditions for how long the engine can search (e.g. we have 10 seconds and search up to depth 10). + :param ponder: Whether the engine can ponder after playing a move. + :param draw_offered: Whether the bot was offered a draw. + :param root_moves: If it is a list, the engine should only play a move that is in `root_moves`. + :return: The move to play. + """ + if isinstance(time_limit.time, int): + my_time = time_limit.time + my_inc = 0 + elif board.turn == chess.WHITE: + my_time = time_limit.white_clock if isinstance(time_limit.white_clock, int) else 0 + my_inc = time_limit.white_inc if isinstance(time_limit.white_inc, int) else 0 + else: + my_time = time_limit.black_clock if isinstance(time_limit.black_clock, int) else 0 + my_inc = time_limit.black_inc if isinstance(time_limit.black_inc, int) else 0 + + possible_moves = root_moves if isinstance(root_moves, list) else list(board.legal_moves) + + if my_time / 60 + my_inc > 10: + # Choose a random move. + move = random.choice(possible_moves) + else: + # Choose the first move alphabetically in uci representation. + possible_moves.sort(key=str) + move = possible_moves[0] + return PlayResult(move, None, draw_offered=draw_offered) diff --git a/lichess-bot-master/lib/__init__.py b/lichess-bot-master/lib/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..073371fc0183465e9d2fac4c2d75d6ba8fb1bac1 --- /dev/null +++ b/lichess-bot-master/lib/__init__.py @@ -0,0 +1 @@ +"""The lib folder contains the library code necessary for running lichess-bot.""" diff --git a/lichess-bot-master/lib/blocklist.py b/lichess-bot-master/lib/blocklist.py new file mode 100644 index 0000000000000000000000000000000000000000..30faf25ce92a2b4b70897a89abf15e5c7e52d6f0 --- /dev/null +++ b/lichess-bot-master/lib/blocklist.py @@ -0,0 +1,51 @@ +"""Code related to blocklists.""" +from dataclasses import dataclass +import requests +import logging + +logger = logging.getLogger(__name__) + + +@dataclass +class BlocklistData: + """Dataclass for online blocklist requests, including usernames and etag.""" + + users: list[str] + etag: str | None + + +def _parse_block_list_from_url(url: str, old_data: BlocklistData) -> BlocklistData: + headers = {"If-None-Match": old_data.etag} if old_data.etag else {} + response = requests.get(url, headers=headers, timeout=15) + + response.raise_for_status() + + if response.status_code == 304: + return old_data + + block_list = [username for line in response.text.strip().splitlines() if (username := line.strip())] + + return BlocklistData(block_list, response.headers.get("ETag")) + + +class OnlineBlocklist: + """Manage online blocklists.""" + + def __init__(self, urls: list[str]) -> None: + """Initialize the OnlineBlockList class.""" + self.blocklist: dict[str, BlocklistData] = {url : BlocklistData([], None) for url in urls} + self.refresh() + + def refresh(self) -> None: + """Pull updated blocklists from the list of blocklist urls.""" + logger.info(f"Refreshing {len(self.blocklist)} online blocklists") + + for url, data in self.blocklist.items(): + try: + self.blocklist[url] = _parse_block_list_from_url(url, data) + except Exception: + logger.warning(f"Failed to refresh online blocklist {url}") + + def __contains__(self, item: str) -> bool: + """Check if an username is in the blocklist.""" + return any(item in blocklist.users for blocklist in self.blocklist.values()) diff --git a/lichess-bot-master/lib/config.py b/lichess-bot-master/lib/config.py new file mode 100644 index 0000000000000000000000000000000000000000..e370bce46126aa49d52d5af2def32d8751e4bc5c --- /dev/null +++ b/lichess-bot-master/lib/config.py @@ -0,0 +1,449 @@ +"""Code related to the config that lichess-bot uses.""" +from __future__ import annotations +import yaml +import os +import logging +import math +from typing import Any +from collections.abc import ItemsView, Callable +from lib.lichess_types import CONFIG_DICT_TYPE, FilterType +from lib.timer import minutes, days + +logger = logging.getLogger(__name__) + + +class Configuration: + """The config or a sub-config that the bot uses.""" + + def __init__(self, parameters: CONFIG_DICT_TYPE) -> None: + """:param parameters: A `dict` containing the config for the bot.""" + self.config = parameters + + def __getattr__(self, name: str) -> Any: + """ + Enable the use of `config.key1.key2`. + + :param name: The key to get its value. + :return: The value of the key. + """ + return self.lookup(name) + + def lookup(self, name: str) -> Any: + """ + Get the value of a key. + + :param name: The key to get its value. + :return: `Configuration` if the value is a `dict` else returns the value. + """ + data = self.config.get(name) + return Configuration(data) if isinstance(data, dict) else data + + def items(self) -> ItemsView[str, Any]: + """:return: All the key-value pairs in this config.""" + return self.config.items() + + def keys(self) -> list[str]: + """:return: All of the keys in this config.""" + return list(self.config.keys()) + + def __or__(self, other: Configuration | CONFIG_DICT_TYPE) -> Configuration: + """Create a copy of this configuration that is updated with values from the parameter.""" + other_dict = other.config if isinstance(other, Configuration) else other + return Configuration(self.config | other_dict) + + def __bool__(self) -> bool: + """Whether `self.config` is empty.""" + return bool(self.config) + + def __getstate__(self) -> CONFIG_DICT_TYPE: + """Get `self.config`.""" + return self.config + + def __setstate__(self, d: CONFIG_DICT_TYPE) -> None: + """Set `self.config`.""" + self.config = d + + +def config_assert(assertion: bool, error_message: str) -> None: + """Raise an exception if an assertion is false.""" + if not assertion: + raise Exception(error_message) + + +def config_warn(assertion: bool, warning_message: str) -> None: + """Print a warning message if an assertion is false.""" + if not assertion: + logger.warning(warning_message) + + +def check_config_section(config: CONFIG_DICT_TYPE, data_name: str, data_type: type, subsection: str = "") -> None: + """ + Check the validity of a config section. + + :param config: The config section. + :param data_name: The key to check its value. + :param data_type: The expected data type. + :param subsection: The subsection of the key. + """ + config_part = config[subsection] if subsection else config + sub = f"`{subsection}` sub" if subsection else "" + data_location = f"`{data_name}` subsection in `{subsection}`" if subsection else f"Section `{data_name}`" + type_error_message: dict[type, str] = { + str: f"{data_location} must be a string wrapped in quotes.", + dict: f"{data_location} must be a dictionary with indented keys followed by colons."} + config_assert(data_name in config_part, f"Your config.yml does not have required {sub}section `{data_name}`.") + config_assert(isinstance(config_part[data_name], data_type), type_error_message[data_type]) + + +def set_config_default(config: CONFIG_DICT_TYPE, *sections: str, key: str, default: Any, + force_empty_values: bool = False) -> CONFIG_DICT_TYPE: + """ + Fill a specific config key with the default value if it is missing. + + :param config: The bot's config. + :param sections: The sections that the key is in. + :param key: The key to set. + :param default: The default value. + :param force_empty_values: Whether an empty value should be replaced with the default value. + :return: The new config with the default value inserted if needed. + """ + subconfig = config + for section in sections: + subconfig = subconfig.setdefault(section, {}) + if not isinstance(subconfig, dict): + raise Exception(f"The {section} section in {sections} should hold a set of key-value pairs, not a value.") + if force_empty_values: + if subconfig.get(key) in [None, ""]: + subconfig[key] = default + else: + subconfig.setdefault(key, default) + return subconfig + + +def change_value_to_list(config: CONFIG_DICT_TYPE, *sections: str, key: str) -> None: + """ + Change a single value to a list. e.g. 60 becomes [60]. Used to maintain backwards compatibility. + + :param config: The bot's config. + :param sections: The sections that the key is in. + :param key: The key to set. + """ + subconfig = set_config_default(config, *sections, key=key, default=[]) + + if subconfig[key] is None: + subconfig[key] = [] + + if not isinstance(subconfig[key], list): + subconfig[key] = [subconfig[key]] + + +def insert_default_values(CONFIG: CONFIG_DICT_TYPE) -> None: + """ + Insert the default values of most keys to the config if they are missing. + + :param CONFIG: The bot's config. + """ + set_config_default(CONFIG, key="abort_time", default=20) + set_config_default(CONFIG, key="move_overhead", default=1000) + set_config_default(CONFIG, key="quit_after_all_games_finish", default=False) + set_config_default(CONFIG, key="rate_limiting_delay", default=0) + set_config_default(CONFIG, key="pgn_directory", default=None) + set_config_default(CONFIG, key="pgn_file_grouping", default="game", force_empty_values=True) + set_config_default(CONFIG, key="max_takebacks_accepted", default=0, force_empty_values=True) + set_config_default(CONFIG, "engine", key="interpreter", default=None) + set_config_default(CONFIG, "engine", key="interpreter_options", default=[], force_empty_values=True) + change_value_to_list(CONFIG, "engine", key="interpreter_options") + set_config_default(CONFIG, "engine", key="working_dir", default=os.getcwd(), force_empty_values=True) + set_config_default(CONFIG, "engine", key="silence_stderr", default=False) + set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_enabled", default=False) + set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_for_egtb_zero", default=True) + set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_enabled", default=False) + set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_for_egtb_minus_two", default=True) + set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_moves", default=3) + set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_score", default=-1000) + set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_moves", default=5) + set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_score", default=0) + set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_pieces", default=10) + set_config_default(CONFIG, "engine", "online_moves", key="max_out_of_book_moves", default=10) + set_config_default(CONFIG, "engine", "online_moves", key="max_retries", default=2, force_empty_values=True) + set_config_default(CONFIG, "engine", "online_moves", key="max_depth", default=math.inf, force_empty_values=True) + set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="enabled", default=False) + set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="source", default="lichess") + set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="min_time", default=20) + set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="max_time", default=10800) + set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="max_pieces", default=7) + set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="move_quality", default="best") + set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="enabled", default=False) + set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="min_time", default=20) + set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="max_time", default=10800) + set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="move_quality", default="good") + set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="min_depth", default=20) + set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="enabled", default=False) + set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="min_time", default=20) + set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="max_time", default=10800) + set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="move_quality", default="best") + set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="min_depth", default=20) + set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="min_knodes", default=0) + set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="max_score_difference", default=50) + set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="enabled", default=False) + set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="min_time", default=20) + set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="max_time", default=10800) + set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="source", default="masters") + set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="player_name", default="") + set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="sort", default="winrate") + set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="min_games", default=10) + set_config_default(CONFIG, "engine", "lichess_bot_tbs", "syzygy", key="enabled", default=False) + set_config_default(CONFIG, "engine", "lichess_bot_tbs", "syzygy", key="max_pieces", default=7) + set_config_default(CONFIG, "engine", "lichess_bot_tbs", "syzygy", key="move_quality", default="best") + set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="enabled", default=False) + set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="max_pieces", default=5) + set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="move_quality", default="best") + set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="min_dtm_to_consider_as_wdl_1", default=120) + set_config_default(CONFIG, "engine", "polyglot", key="enabled", default=False) + set_config_default(CONFIG, "engine", "polyglot", key="max_depth", default=8) + set_config_default(CONFIG, "engine", "polyglot", key="selection", default="weighted_random") + set_config_default(CONFIG, "engine", "polyglot", key="min_weight", default=1) + set_config_default(CONFIG, "engine", "polyglot", key="normalization", default="none") + set_config_default(CONFIG, "challenge", key="concurrency", default=1) + set_config_default(CONFIG, "challenge", key="sort_by", default="best") + set_config_default(CONFIG, "challenge", key="preference", default="none") + set_config_default(CONFIG, "challenge", key="accept_bot", default=False) + set_config_default(CONFIG, "challenge", key="only_bot", default=False) + set_config_default(CONFIG, "challenge", key="max_increment", default=180) + set_config_default(CONFIG, "challenge", key="min_increment", default=0) + set_config_default(CONFIG, "challenge", key="max_base", default=math.inf) + set_config_default(CONFIG, "challenge", key="min_base", default=0) + set_config_default(CONFIG, "challenge", key="max_days", default=math.inf) + set_config_default(CONFIG, "challenge", key="min_days", default=1) + set_config_default(CONFIG, "challenge", key="block_list", default=[], force_empty_values=True) + set_config_default(CONFIG, "challenge", key="online_block_list", default=[], force_empty_values=True) + set_config_default(CONFIG, "challenge", key="allow_list", default=[], force_empty_values=True) + set_config_default(CONFIG, "challenge", key="min_rating", default=0, force_empty_values=True) + set_config_default(CONFIG, "challenge", key="max_rating", default=4000, force_empty_values=True) + set_config_default(CONFIG, "challenge", key="rating_difference", default=None) + set_config_default(CONFIG, "challenge", key="max_simultaneous_games_per_user", default=5) + set_config_default(CONFIG, "correspondence", key="checkin_period", default=600) + set_config_default(CONFIG, "correspondence", key="move_time", default=60, force_empty_values=True) + set_config_default(CONFIG, "correspondence", key="disconnect_time", default=300) + set_config_default(CONFIG, "matchmaking", key="challenge_timeout", default=30, force_empty_values=True) + CONFIG["matchmaking"]["challenge_timeout"] = max(CONFIG["matchmaking"]["challenge_timeout"], 1) + set_config_default(CONFIG, "matchmaking", key="block_list", default=[], force_empty_values=True) + set_config_default(CONFIG, "matchmaking", key="online_block_list", default=[], force_empty_values=True) + set_config_default(CONFIG, "matchmaking", key="include_challenge_block_list", default=False, force_empty_values=True) + default_filter = (CONFIG.get("matchmaking") or {}).get("delay_after_decline") or FilterType.NONE.value + set_config_default(CONFIG, "matchmaking", key="challenge_filter", default=default_filter, force_empty_values=True) + set_config_default(CONFIG, "matchmaking", key="allow_matchmaking", default=False) + set_config_default(CONFIG, "matchmaking", key="challenge_initial_time", default=[None], force_empty_values=True) + change_value_to_list(CONFIG, "matchmaking", key="challenge_initial_time") + set_config_default(CONFIG, "matchmaking", key="challenge_increment", default=[None], force_empty_values=True) + change_value_to_list(CONFIG, "matchmaking", key="challenge_increment") + set_config_default(CONFIG, "matchmaking", key="challenge_days", default=[None], force_empty_values=True) + change_value_to_list(CONFIG, "matchmaking", key="challenge_days") + set_config_default(CONFIG, "matchmaking", key="opponent_min_rating", default=600, force_empty_values=True) + set_config_default(CONFIG, "matchmaking", key="opponent_max_rating", default=4000, force_empty_values=True) + set_config_default(CONFIG, "matchmaking", key="rating_preference", default="none") + set_config_default(CONFIG, "matchmaking", key="challenge_variant", default="random") + set_config_default(CONFIG, "matchmaking", key="challenge_mode", default="random") + set_config_default(CONFIG, "matchmaking", key="overrides", default={}, force_empty_values=True) + for override_config in CONFIG["matchmaking"]["overrides"].values(): + for parameter in ["challenge_initial_time", "challenge_increment", "challenge_days"]: + if parameter in override_config: + set_config_default(override_config, key=parameter, default=[None], force_empty_values=True) + change_value_to_list(override_config, key=parameter) + + for section in ["engine", "correspondence"]: + for ponder in ["ponder", "uci_ponder"]: + set_config_default(CONFIG, section, key=ponder, default=False) + + for greeting in ["hello", "goodbye"]: + for target in ["", "_spectators"]: + set_config_default(CONFIG, "greeting", key=greeting + target, default="", force_empty_values=True) + + +def process_block_list(CONFIG: CONFIG_DICT_TYPE) -> None: + """ + Retrieve online block lists and copy over challenge blocklist if necessary. + + :param CONFIG: The bot's config. + """ + if CONFIG["matchmaking"]["include_challenge_block_list"]: + CONFIG["matchmaking"]["block_list"].extend(CONFIG["challenge"]["block_list"]) + + +def log_config(CONFIG: CONFIG_DICT_TYPE, alternate_log_function: Callable[[str], Any] | None = None) -> None: + """ + Log the config to make debugging easier. + + :param CONFIG: The bot's config. + """ + logger_config = CONFIG.copy() + logger_config["token"] = "logger" # noqa: S105 (Possible hardcoded password) + destination = alternate_log_function or logger.debug + destination(f"Config:\n{yaml.dump(logger_config, sort_keys=False)}") + destination("====================") + + +def validate_config(CONFIG: CONFIG_DICT_TYPE) -> None: + """Check if the config is valid.""" + check_config_section(CONFIG, "token", str) + check_config_section(CONFIG, "url", str) + check_config_section(CONFIG, "engine", dict) + check_config_section(CONFIG, "challenge", dict) + check_config_section(CONFIG, "dir", str, "engine") + check_config_section(CONFIG, "name", str, "engine") + + config_assert(os.path.isdir(CONFIG["engine"]["dir"]), + f'Your engine directory `{CONFIG["engine"]["dir"]}` is not a directory.') + + working_dir = CONFIG["engine"].get("working_dir") + config_assert(not working_dir or os.path.isdir(working_dir), + f"Your engine's working directory `{working_dir}` is not a directory.") + + engine = os.path.join(CONFIG["engine"]["dir"], CONFIG["engine"]["name"]) + config_assert(os.path.isfile(engine) or CONFIG["engine"]["protocol"] == "homemade", + f"The engine {engine} file does not exist.") + config_assert(os.access(engine, os.X_OK) or CONFIG["engine"]["protocol"] == "homemade", + f"The engine {engine} doesn't have execute (x) permission. Try: chmod +x {engine}") + + if CONFIG["engine"]["protocol"] == "xboard": + for section, subsection in (("online_moves", "online_egtb"), + ("lichess_bot_tbs", "syzygy"), + ("lichess_bot_tbs", "gaviota")): + online_section = (CONFIG["engine"].get(section) or {}).get(subsection) or {} + config_assert(online_section.get("move_quality") != "suggest" or not online_section.get("enabled"), + f"XBoard engines can't be used with `move_quality` set to `suggest` in {subsection}.") + + config_warn(CONFIG["challenge"]["concurrency"] > 0, "With challenge.concurrency set to 0, the bot won't accept or create " + "any challenges.") + + config_assert(CONFIG["challenge"]["sort_by"] in ["best", "first"], "challenge.sort_by can be either `first` or `best`.") + config_assert(CONFIG["challenge"]["preference"] in ["none", "human", "bot"], + "challenge.preference should be `none`, `human`, or `bot`.") + + min_max_template = ("challenge.max_{setting} < challenge.min_{setting} will result " + "in no {game_type} challenges being accepted.") + for setting in ["increment", "base", "days"]: + game_type = "correspondence" if setting == "days" else "real-time" + config_warn(CONFIG["challenge"][f"min_{setting}"] <= CONFIG["challenge"][f"max_{setting}"], + min_max_template.format(setting=setting, game_type=game_type)) + + config_warn(CONFIG["challenge"]["min_rating"] <= CONFIG["challenge"]["max_rating"], + "challenge.max_rating < challenge.min_rating will result in no challenges being accepted.") + config_warn(CONFIG["challenge"].get("rating_difference") is None + or CONFIG["challenge"].get("rating_difference", 0) >= 0, + "challenge.rating_difference < 0 will result in no challenges being accepted.") + + matchmaking = CONFIG["matchmaking"] + matchmaking_enabled = matchmaking["allow_matchmaking"] + + if matchmaking_enabled: + config_warn(matchmaking["opponent_min_rating"] <= matchmaking["opponent_max_rating"], + "matchmaking.opponent_max_rating < matchmaking.opponent_min_rating will result in " + "no challenges being created.") + config_warn(matchmaking.get("opponent_rating_difference", 0) >= 0, + "matchmaking.opponent_rating_difference < 0 will result in no challenges being created.") + max_games_per_day = 100 + game_timeout = minutes(matchmaking["challenge_timeout"]) + config_warn(game_timeout*max_games_per_day >= days(1), + f"A bot is only allowed to play {max_games_per_day} games per day against other bots. Please check your " + "config file to make sure your bot won't use up all its allotted games quickly.") + + pgn_directory = CONFIG["pgn_directory"] + in_docker = os.environ.get("LICHESS_BOT_DOCKER") + config_warn(not pgn_directory or not in_docker, + f"Games will be saved to '{pgn_directory}', please ensure this folder is in a mounted " + "volume; Using the Docker's container internal file system will prevent " + "you accessing the saved files and can lead to disk " + "saturation.") + + valid_pgn_grouping_options = ["game", "opponent", "all"] + config_pgn_choice = CONFIG["pgn_file_grouping"] + config_assert(config_pgn_choice in valid_pgn_grouping_options, + f"The `pgn_file_grouping` choice of `{config_pgn_choice}` is not valid. " + f"Please choose from {valid_pgn_grouping_options}.") + + def has_valid_list(name: str) -> bool: + entries = matchmaking.get(name) + return isinstance(entries, list) and entries[0] is not None + matchmaking_has_values = (has_valid_list("challenge_initial_time") + and has_valid_list("challenge_increment") + or has_valid_list("challenge_days")) + config_assert(not matchmaking_enabled or matchmaking_has_values, + "The time control to challenge other bots is not set. Either lists of challenge_initial_time and " + "challenge_increment is required, or a list of challenge_days, or both.") + + filter_option = "challenge_filter" + filter_type = matchmaking.get(filter_option) + config_assert(filter_type is None or filter_type in FilterType.__members__.values(), + f"{filter_type} is not a valid value for {filter_option} (formerly delay_after_decline) parameter. " + f"Choices are: {', '.join(FilterType)}.") + + config_assert(matchmaking.get("rating_preference") in ["none", "high", "low"], + f"{matchmaking.get('rating_preference')} is not a valid `matchmaking:rating_preference` option. " + f"Valid options are 'none', 'high', or 'low'.") + + selection_choices = {"polyglot": ["weighted_random", "uniform_random", "best_move"], + "chessdb_book": ["all", "good", "best"], + "lichess_cloud_analysis": ["good", "best"], + "online_egtb": ["best", "suggest"]} + for db_name, valid_selections in selection_choices.items(): + is_online = db_name != "polyglot" + db_section = (CONFIG["engine"].get("online_moves") or {}) if is_online else CONFIG["engine"] + db_config = db_section.get(db_name) or {} + select_key = "selection" if db_name == "polyglot" else "move_quality" + selection = db_config.get(select_key) + select = f"{'online_moves:' if is_online else ''}{db_name}:{select_key}" + config_assert(selection in valid_selections, + f"`{selection}` is not a valid `engine:{select}` value. " + f"Please choose from {valid_selections}.") + + polyglot_section = CONFIG["engine"].get("polyglot") or {} + config_assert(polyglot_section.get("normalization") in ["none", "max", "sum"], + f"`{polyglot_section.get('normalization')}` is not a valid choice for " + f"`engine:polyglot:normalization`. Please choose from ['none', 'max', 'sum'].") + + lichess_tbs_config = CONFIG["engine"].get("lichess_bot_tbs") or {} + quality_selections = ["best", "suggest"] + for tb in ["syzygy", "gaviota"]: + selection = (lichess_tbs_config.get(tb) or {}).get("move_quality") + config_assert(selection in quality_selections, + f"`{selection}` is not a valid choice for `engine:lichess_bot_tbs:{tb}:move_quality`. " + f"Please choose from {quality_selections}.") + + explorer_choices = {"source": ["lichess", "masters", "player"], + "sort": ["winrate", "games_played"]} + explorer_config = (CONFIG["engine"].get("online_moves") or {}).get("lichess_opening_explorer") + if explorer_config: + for parameter, choice_list in explorer_choices.items(): + explorer_choice = explorer_config.get(parameter) + config_assert(explorer_choice in choice_list, + f"`{explorer_choice}` is not a valid" + f" `engine:online_moves:lichess_opening_explorer:{parameter}`" + f" value. Please choose from {choice_list}.") + + +def load_config(config_file: str) -> Configuration: + """ + Read the config. + + :param config_file: The filename of the config (usually `config.yml`). + :return: A `Configuration` object containing the config. + """ + with open(config_file) as stream: + try: + CONFIG = yaml.safe_load(stream) + except Exception: + logger.exception("There appears to be a syntax problem with your config.yml") + raise + + log_config(CONFIG) + + if "LICHESS_BOT_TOKEN" in os.environ: + CONFIG["token"] = os.environ["LICHESS_BOT_TOKEN"] + + insert_default_values(CONFIG) + process_block_list(CONFIG) + log_config(CONFIG) + validate_config(CONFIG) + + return Configuration(CONFIG) diff --git a/lichess-bot-master/lib/conversation.py b/lichess-bot-master/lib/conversation.py new file mode 100644 index 0000000000000000000000000000000000000000..3341dc2a8c6968234df158cc42927c8e35b12a4b --- /dev/null +++ b/lichess-bot-master/lib/conversation.py @@ -0,0 +1,106 @@ +"""Allows lichess-bot to send messages to the chat.""" +import logging +from lib import model +from lib.engine_wrapper import EngineWrapper +from lib.lichess import Lichess +from lib.lichess_types import GameEventType +from collections.abc import Sequence +from lib.timer import seconds +from typing import TypeAlias +MULTIPROCESSING_LIST_TYPE: TypeAlias = Sequence[model.Challenge] + +logger = logging.getLogger(__name__) + + +class ChatLine: + """Information about the message.""" + + def __init__(self, message_info: GameEventType) -> None: + """Information about the message.""" + self.room = message_info["room"] + """Whether the message was sent in the chat room or in the spectator room.""" + self.username = message_info["username"] + """The username of the account that sent the message.""" + self.text = message_info["text"] + """The message sent.""" + + +class Conversation: + """Enables the bot to communicate with its opponent and the spectators.""" + + def __init__(self, game: model.Game, engine: EngineWrapper, li: Lichess, version: str, + challenge_queue: MULTIPROCESSING_LIST_TYPE) -> None: + """ + Communication between lichess-bot and the game chats. + + :param game: The game that the bot will send messages to. + :param engine: The engine playing the game. + :param li: A class that is used for communication with lichess. + :param version: The lichess-bot version. + :param challenge_queue: The active challenges the bot has. + """ + self.game = game + self.engine = engine + self.li = li + self.version = version + self.challengers = challenge_queue + self.messages: list[ChatLine] = [] + + command_prefix = "!" + + def react(self, line: ChatLine) -> None: + """ + React to a received message. + + :param line: Information about the message. + """ + self.messages.append(line) + logger.info(f"*** {self.game.url()} [{line.room}] {line.username}: {line.text}") + if line.text[0] == self.command_prefix: + self.command(line, line.text[1:].lower()) + + def command(self, line: ChatLine, cmd: str) -> None: + """ + Reacts to the specific commands in the chat. + + :param line: Information about the message. + :param cmd: The command to react to. + """ + from_self = line.username == self.game.username + is_eval = cmd.startswith("eval") + if cmd in ("commands", "help"): + self.send_reply(line, + "Supported commands: !wait (wait a minute for my first move), !name, " + "!eval (or any text starting with !eval), !queue") + elif cmd == "wait" and self.game.is_abortable(): + self.game.ping(seconds(60), seconds(120), seconds(120)) + self.send_reply(line, "Waiting 60 seconds...") + elif cmd == "name": + name = self.game.me.name + self.send_reply(line, f"{name} running {self.engine.name()} (lichess-bot v{self.version})") + elif is_eval and (from_self or line.room == "spectator"): + stats = self.engine.get_stats(for_chat=True) + self.send_reply(line, ", ".join(stats)) + elif is_eval: + self.send_reply(line, "I don't tell that to my opponent, sorry.") + elif cmd == "queue": + if self.challengers: + challengers = ", ".join([f"@{challenger.challenger.name}" for challenger in reversed(self.challengers)]) + self.send_reply(line, f"Challenge queue: {challengers}") + else: + self.send_reply(line, "No challenges queued.") + + def send_reply(self, line: ChatLine, reply: str) -> None: + """ + Send the reply to the chat. + + :param line: Information about the original message that we reply to. + :param reply: The reply to send. + """ + logger.info(f"*** {self.game.url()} [{line.room}] {self.game.username}: {reply}") + self.li.chat(self.game.id, line.room, reply) + + def send_message(self, room: str, message: str) -> None: + """Send the message to the chat.""" + if message: + self.send_reply(ChatLine({"room": room, "username": "", "text": ""}), message) diff --git a/lichess-bot-master/lib/engine_wrapper.py b/lichess-bot-master/lib/engine_wrapper.py new file mode 100644 index 0000000000000000000000000000000000000000..38c13f993bb4e4bf9d052f2f58e019f97030b2b0 --- /dev/null +++ b/lichess-bot-master/lib/engine_wrapper.py @@ -0,0 +1,1427 @@ +"""Provides communication with the engine.""" +from __future__ import annotations +import os +import chess.engine +import chess.polyglot +import chess.syzygy +import chess.gaviota +import chess +import subprocess +import logging +import datetime +import time +import random +import math +import contextlib +from collections import Counter +from collections.abc import Callable +from lib import model, lichess +from lib.config import Configuration, change_value_to_list +from lib.timer import Timer, msec, seconds, msec_str, sec_str, to_seconds +from lib.lichess_types import (ReadableType, ChessDBMoveType, LichessEGTBMoveType, OPTIONS_GO_EGTB_TYPE, OPTIONS_TYPE, + COMMANDS_TYPE, MOVE, InfoStrDict, InfoDictKeys, InfoDictValue, GO_COMMANDS_TYPE, EGTPATH_TYPE, + ENGINE_INPUT_ARGS_TYPE, ENGINE_INPUT_KWARGS_TYPE) +from extra_game_handlers import game_specific_options +from operator import itemgetter +from typing import Any, Literal, cast +from types import TracebackType + + +logger = logging.getLogger(__name__) + +out_of_online_opening_book_moves: Counter[str] = Counter() + + +def create_engine(engine_config: Configuration, game: model.Game | None = None) -> EngineWrapper: + """ + Create the engine. + + Use in a with-block to automatically close the engine when exiting the game. + + :param engine_config: The options for the engine. + :param game: The game to create the engine from. + :return: An engine. Either UCI, XBoard, or Homemade. + """ + cfg = engine_config.engine + engine_path = os.path.abspath(os.path.join(cfg.dir, cfg.name)) + engine_type = cfg.protocol + commands = [] + if cfg.interpreter: + commands.append(cfg.interpreter) + commands.extend(cfg.interpreter_options) + commands.append(engine_path) + if cfg.engine_options: + for k, v in cfg.engine_options.items(): + commands.append(f"--{k}={v}" if v is not None else f"--{k}") + + stderr = subprocess.DEVNULL if cfg.silence_stderr else None + + Engine: type[UCIEngine | XBoardEngine | MinimalEngine] + if engine_type == "xboard": + Engine = XBoardEngine + elif engine_type == "uci": + Engine = UCIEngine + elif engine_type == "homemade": + Engine = get_homemade_engine(cfg.name) + else: + raise ValueError( + f" Invalid engine type: {engine_type}. Expected xboard, uci, or homemade.") + options = remove_managed_options(cfg.lookup(f"{engine_type}_options") or Configuration({})) + logger.debug(f"Starting engine: {commands}") + return Engine(commands, options, stderr, cfg.draw_or_resign, game, cfg.debug, + cwd=cfg.working_dir) + + +def remove_managed_options(config: Configuration) -> OPTIONS_GO_EGTB_TYPE: + """Remove the options managed by python-chess.""" + def is_managed(key: str) -> bool: + return chess.engine.Option(key, "", None, None, None, None).is_managed() + + return {name: value for (name, value) in config.items() if not is_managed(name)} + + +PONDERPV_CHARACTERS = 6 # The length of ", Pv: ". + + +class EngineWrapper: + """A wrapper used by all engines (UCI, XBoard, Homemade).""" + + def __init__(self, options: OPTIONS_GO_EGTB_TYPE, draw_or_resign: Configuration) -> None: + """ + Initialize the values of the wrapper used by all engines (UCI, XBoard, Homemade). + + :param options: The options to send to the engine. + :param draw_or_resign: Options on whether the bot should resign or offer draws. + """ + self.engine: chess.engine.SimpleEngine | FillerEngine + self.scores: list[chess.engine.PovScore] = [] + self.draw_or_resign = draw_or_resign + self.go_commands = Configuration(cast(GO_COMMANDS_TYPE, options.pop("go_commands", {})) or {}) + self.move_commentary: list[InfoStrDict] = [] + self.comment_start_index = -1 + + def configure(self, options: OPTIONS_GO_EGTB_TYPE, game: model.Game | None) -> None: + """ + Send configurations to the engine. + + :param options: A dictionary of strings to option values. + + Raises chess.engine.EngineError if an option is sent that the engine does not support. + """ + try: + extra_options = {} if game is None else game_specific_options(game) + self.engine.configure(cast(OPTIONS_TYPE, options | extra_options)) + except Exception: + self.engine.close() + raise + + def __enter__(self) -> EngineWrapper: # noqa: PYI034 (return Self not available until 3.11) + """Enter context so engine communication will be properly shutdown.""" + self.engine.__enter__() + return self + + def __exit__(self, exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None) -> None: + """Exit context and allow engine to shutdown nicely if there was no exception.""" + if exc_type is None: + self.ping() + self.quit() + self.engine.__exit__(exc_type, exc_value, traceback) + + def play_move(self, + board: chess.Board, + game: model.Game, + li: lichess.Lichess, + setup_timer: Timer, + move_overhead: datetime.timedelta, + can_ponder: bool, + is_correspondence: bool, + correspondence_move_time: datetime.timedelta, + engine_cfg: Configuration, + min_time: datetime.timedelta) -> None: + """ + Play a move. + + :param board: The current position. + :param game: The game that the bot is playing. + :param li: Provides communication with lichess.org. + :param start_time: The time that the bot received the move. + :param move_overhead: The time it takes to communicate between the engine and lichess.org. + :param can_ponder: Whether the engine is allowed to ponder. + :param is_correspondence: Whether this is a correspondence or unlimited game. + :param correspondence_move_time: The time the engine will think if `is_correspondence` is true. + :param engine_cfg: Options for external moves (e.g. from an opening book), and for engine resignation and draw offers. + :param min_time: Minimum time to spend, in seconds. + :return: The move to play. + """ + polyglot_cfg = engine_cfg.polyglot + online_moves_cfg = engine_cfg.online_moves + draw_or_resign_cfg = engine_cfg.draw_or_resign + lichess_bot_tbs = engine_cfg.lichess_bot_tbs + + best_move: MOVE + best_move = get_book_move(board, game, polyglot_cfg) + + if best_move.move is None: + best_move = get_egtb_move(board, + game, + lichess_bot_tbs, + draw_or_resign_cfg) + + if not isinstance(best_move, list) and best_move.move is None: + best_move = get_online_move(li, + board, + game, + online_moves_cfg, + draw_or_resign_cfg) + + if isinstance(best_move, list) or best_move.move is None: + draw_offered = check_for_draw_offer(game) + + time_limit, can_ponder = move_time(board, game, can_ponder, + setup_timer, move_overhead, + is_correspondence, correspondence_move_time) + + try: + best_move = self.search(board, time_limit, can_ponder, draw_offered, best_move) + except chess.engine.EngineError as error: + BadMove = (chess.IllegalMoveError, chess.InvalidMoveError) + if not any(isinstance(e, BadMove) for e in error.args): + raise + logger.error("Ending game due to bot attempting an illegal move.") + logger.error(error) + game_ender = li.abort if game.is_abortable() else li.resign + game_ender(game.id) + return + + # Heed min_time + elapsed = setup_timer.time_since_reset() + if elapsed < min_time: + time.sleep(to_seconds(min_time - elapsed)) + + self.add_comment(best_move, board) + self.print_stats() + if best_move.resigned and len(board.move_stack) >= 2: + li.resign(game.id) + else: + li.make_move(game.id, best_move) + + def add_go_commands(self, time_limit: chess.engine.Limit) -> chess.engine.Limit: + """Add extra commands to send to the engine. For example, to search for 1000 nodes or up to depth 10.""" + movetime_cfg = self.go_commands.movetime + if movetime_cfg is not None: + movetime = msec(movetime_cfg) + if time_limit.time is None or seconds(time_limit.time) > movetime: + time_limit.time = to_seconds(movetime) + time_limit.depth = self.go_commands.depth + time_limit.nodes = self.go_commands.nodes + return time_limit + + def offer_draw_or_resign(self, result: chess.engine.PlayResult, board: chess.Board) -> chess.engine.PlayResult: + """Offer draw or resign depending on the score of the engine.""" + def actual(score: chess.engine.PovScore) -> int: + return score.relative.score(mate_score=40000) + + can_offer_draw = self.draw_or_resign.offer_draw_enabled + draw_offer_moves = self.draw_or_resign.offer_draw_moves + draw_score_range: int = self.draw_or_resign.offer_draw_score + draw_max_piece_count = self.draw_or_resign.offer_draw_pieces + pieces_on_board = chess.popcount(board.occupied) + enough_pieces_captured = pieces_on_board <= draw_max_piece_count + if can_offer_draw and len(self.scores) >= draw_offer_moves and enough_pieces_captured: + scores = self.scores[-draw_offer_moves:] + + def score_near_draw(score: chess.engine.PovScore) -> bool: + return abs(actual(score)) <= draw_score_range + if len(scores) == len(list(filter(score_near_draw, scores))): + result.draw_offered = True + + resign_enabled = self.draw_or_resign.resign_enabled + min_moves_for_resign = self.draw_or_resign.resign_moves + resign_score: int = self.draw_or_resign.resign_score + if resign_enabled and len(self.scores) >= min_moves_for_resign: + scores = self.scores[-min_moves_for_resign:] + + def score_near_loss(score: chess.engine.PovScore) -> bool: + return actual(score) <= resign_score + if len(scores) == len(list(filter(score_near_loss, scores))): + result.resigned = True + return result + + def search(self, board: chess.Board, time_limit: chess.engine.Limit, ponder: bool, draw_offered: bool, + root_moves: MOVE) -> chess.engine.PlayResult: + """ + Tell the engine to search. + + :param board: The current position. + :param time_limit: Conditions for how long the engine can search (e.g. we have 10 seconds and search up to depth 10). + :param ponder: Whether the engine can ponder. + :param draw_offered: Whether the bot was offered a draw. + :param root_moves: If it is a list, the engine will only play a move that is in `root_moves`. + :return: The move to play. + """ + time_limit = self.add_go_commands(time_limit) + result = self.engine.play(board, + time_limit, + info=chess.engine.INFO_ALL, + ponder=ponder, + draw_offered=draw_offered, + root_moves=root_moves if isinstance(root_moves, list) else None) + # Use null_score to have no effect on draw/resign decisions + null_score = chess.engine.PovScore(chess.engine.Mate(1), board.turn) + self.scores.append(result.info.get("score", null_score)) + return self.offer_draw_or_resign(result, board) + + def comment_index(self, move_stack_index: int) -> int: + """ + Get the index of a move for use in `comment_for_board_index`. + + :param move_stack_index: The move number. + :return: The index of the move in `self.move_commentary`. + """ + if self.comment_start_index < 0: + return -1 + return move_stack_index - self.comment_start_index + + def comment_for_board_index(self, index: int) -> InfoStrDict: + """ + Get the engine comments for a specific move. + + :param index: The move number. + :return: The move comments. + """ + no_info: InfoStrDict = {} + comment_index = self.comment_index(index) + if comment_index < 0 or comment_index % 2 != 0: + return no_info + + try: + return self.move_commentary[comment_index // 2] + except IndexError: + return no_info + + def add_comment(self, move: chess.engine.PlayResult, board: chess.Board) -> None: + """ + Store the move's comments. + + :param move: The move. Contains the comments in `move.info`. + :param board: The current position. + """ + if self.comment_start_index < 0: + self.comment_start_index = len(board.move_stack) + move_info = cast(InfoStrDict, dict(move.info.copy() if move.info else {})) + if "pv" in move_info: + move_info["ponderpv"] = board.variation_san(move.info["pv"]) + if "refutation" in move_info: + move_info["refutation"] = board.variation_san(move.info["refutation"]) + if "currmove" in move_info: + move_info["currmove"] = board.san(move.info["currmove"]) + self.move_commentary.append(move_info) + + def discard_last_move_commentary(self) -> None: + """ + Remove the commentary for the last move, if any. + + Used after allowing an opponent to take back a move. + """ + with contextlib.suppress(IndexError): + self.move_commentary.pop() + + def print_stats(self) -> None: + """Print the engine stats.""" + for line in self.get_stats(): + logger.info(line) + + def readable_score(self, relative_score: chess.engine.PovScore) -> str: + """Convert the score to a more human-readable format.""" + score = relative_score.relative + cp_score = score.score() + if cp_score is None: + str_score = f"#{score.mate()}" + else: + str_score = str(round(cp_score / 100, 2)) + return str_score + + def readable_wdl(self, wdl: chess.engine.PovWdl) -> str: + """Convert the WDL score to a percentage, so it is more human-readable.""" + wdl_percentage = round(wdl.relative.expectation() * 100, 1) + return f"{wdl_percentage}%" + + def readable_time(self, number: int) -> str: + """Convert time given as a number into minutes and seconds, so it is more human-readable. e.g. 123 -> 2m 3s.""" + minutes, seconds = divmod(number, 60) + if minutes >= 1: + return f"{minutes:0.0f}m {seconds:0.1f}s" + else: + return f"{seconds:0.1f}s" + + def readable_number(self, number: int) -> str: + """Convert number to a more human-readable format. e.g. 123456789 -> 123M.""" + if number >= 1e9: + return f"{round(number / 1e9, 1)}B" + if number >= 1e6: + return f"{round(number / 1e6, 1)}M" + if number >= 1e3: + return f"{round(number / 1e3, 1)}K" + return str(number) + + def to_readable_value(self, stat: InfoDictKeys, info: InfoStrDict) -> str: + """Change a value to a more human-readable format.""" + readable: ReadableType = {"Evaluation": self.readable_score, "Winrate": self.readable_wdl, + "Hashfull": lambda x: f"{round(x / 10, 1)}%", "Nodes": self.readable_number, + "Speed": lambda x: f"{self.readable_number(x)}nps", "Tbhits": self.readable_number, + "Cpuload": lambda x: f"{round(x / 10, 1)}%", "Movetime": self.readable_time} + + def identity(x: InfoDictValue) -> str: + return str(x) + + func = cast(Callable[[InfoDictValue], str], readable.get(stat, identity)) + return str(func(info[stat])) + + def get_stats(self, for_chat: bool = False) -> list[str]: + """ + Get the stats returned by the engine. + + :param for_chat: Whether the stats will be sent to the game chat, which has a 140 character limit. + """ + can_index = self.move_commentary and self.move_commentary[-1] + info: InfoStrDict = self.move_commentary[-1].copy() if can_index else {} + + def to_readable_item(stat: InfoDictKeys, value: InfoDictValue) -> tuple[InfoDictKeys, InfoDictValue]: + readable = {"wdl": "winrate", "ponderpv": "PV", "nps": "speed", "score": "evaluation", "time": "movetime"} + stat = cast(InfoDictKeys, readable.get(stat, stat)) + if stat == "string" and isinstance(value, str) and value.startswith("lichess-bot-source:"): + stat = "Source" + value = value.split(":", 1)[1] + return cast(InfoDictKeys, stat.title()), value + + info = cast(InfoStrDict, dict(to_readable_item(cast(InfoDictKeys, key), cast(InfoDictValue, value)) + for (key, value) in info.items())) + if "Source" not in info: + info["Source"] = "Engine" + + stats = ["Source", "Evaluation", "Winrate", "Depth", "Nodes", "Speed", "Pv"] + if for_chat and "Pv" in info: + bot_stats = [f"{stat}: {self.to_readable_value(cast(InfoDictKeys, stat), info)}" + for stat in stats if stat in info and stat != "Pv"] + len_bot_stats = len(", ".join(bot_stats)) + PONDERPV_CHARACTERS + ponder_pv = info["Pv"].split() + try: + while len(" ".join(ponder_pv)) + len_bot_stats > lichess.MAX_CHAT_MESSAGE_LEN: + ponder_pv.pop() + if ponder_pv[-1].endswith("."): + ponder_pv.pop() + info["Pv"] = " ".join(ponder_pv) + except IndexError: + pass + if not info["Pv"]: + info.pop("Pv") + return [f"{stat}: {self.to_readable_value(cast(InfoDictKeys, stat), info)}" for stat in stats if stat in info] + + def get_opponent_info(self, game: model.Game) -> None: + """Get the opponent's information and sends it to the engine.""" + opponent = chess.engine.Opponent(name=game.opponent.name, + title=game.opponent.title, + rating=game.opponent.rating, + is_engine=game.opponent.is_bot) + self.engine.send_opponent_information(opponent=opponent, engine_rating=game.me.rating) + + def name(self) -> str: + """Get the name of the engine.""" + return self.engine.id["name"] + + def get_pid(self) -> str: + """Get the pid of the engine.""" + pid = "?" + if self.engine.transport is not None: + pid = str(self.engine.transport.get_pid()) + return pid + + def ping(self) -> None: + """Ping the engine.""" + self.engine.ping() + + def send_game_result(self, game: model.Game, board: chess.Board) -> None: + """ + Inform engine of the game ending. + + :param game: The final game state from lichess. + :param board: The final board state. + """ + termination = game.state.get("status") + winner = game.state.get("winner") + winning_color = chess.WHITE if winner == "white" else chess.BLACK + + if termination == model.Termination.MATE: + self.engine.send_game_result(board) + elif termination == model.Termination.RESIGN: + resigner = "White" if winner == "black" else "Black" + self.engine.send_game_result(board, winning_color, f"{resigner} resigned") + elif termination == model.Termination.ABORT: + self.engine.send_game_result(board, None, "Game aborted", False) + elif termination == model.Termination.DRAW: + draw_reason = None if board.is_game_over(claim_draw=True) else "Draw by agreement" + self.engine.send_game_result(board, None, draw_reason) + elif termination == model.Termination.TIMEOUT: + if winner: + self.engine.send_game_result(board, winning_color, "Time forfeiture") + else: + self.engine.send_game_result(board, None, "Time out with insufficient material") + else: + self.engine.send_game_result(board, None, termination) + + def quit(self) -> None: + """Tell the engine to shut down.""" + self.engine.quit() + + +class UCIEngine(EngineWrapper): + """The class used to communicate with UCI engines.""" + + def __init__(self, commands: COMMANDS_TYPE, options: OPTIONS_GO_EGTB_TYPE, stderr: int | None, + draw_or_resign: Configuration, game: model.Game | None, debug: bool, + **popen_args: str) -> None: + """ + Communicate with UCI engines. + + :param commands: The engine path and commands to send to the engine. e.g. ["engines/engine.exe", "--option1=value1"] + :param options: The options to send to the engine. + :param stderr: Whether we should silence the stderr. + :param draw_or_resign: Options on whether the bot should resign or offer draws. + :param game: The first Game message from the game stream. + :param debug: Whether to debug or not. + :param popen_args: The cwd of the engine. + """ + super().__init__(options, draw_or_resign) + self.engine = chess.engine.SimpleEngine.popen_uci(commands, timeout=60., debug=debug, setpgrp=True, stderr=stderr, + **popen_args) + self.configure(options, game) + + +class XBoardEngine(EngineWrapper): + """The class used to communicate with XBoard engines.""" + + def __init__(self, commands: COMMANDS_TYPE, options: OPTIONS_GO_EGTB_TYPE, stderr: int | None, + draw_or_resign: Configuration, game: model.Game | None, debug: bool, + **popen_args: str) -> None: + """ + Communicate with XBoard engines. + + :param commands: The engine path and commands to send to the engine. e.g. ["engines/engine.exe", "--option1=value1"] + :param options: The options to send to the engine. + :param stderr: Whether we should silence the stderr. + :param draw_or_resign: Options on whether the bot should resign or offer draws. + :param game: The first Game message from the game stream. + :param debug: Whether to debug or not. + :param popen_args: The cwd of the engine. + """ + super().__init__(options, draw_or_resign) + self.engine = chess.engine.SimpleEngine.popen_xboard(commands, timeout=60., debug=debug, setpgrp=True, + stderr=stderr, **popen_args) + egt_paths = cast(EGTPATH_TYPE, options.pop("egtpath", {}) or {}) + protocol = cast(chess.engine.XBoardProtocol, self.engine.protocol) + egt_features = protocol.features.get("egt", "") + if isinstance(egt_features, str): + egt_types_from_engine = egt_features.split(",") + for egt_type in filter(None, egt_types_from_engine): + if egt_type in egt_paths: + options[f"egtpath {egt_type}"] = egt_paths[egt_type] + else: + logger.debug(f"No paths found for egt type: {egt_type}.") + self.configure(options, game) + + +class MinimalEngine(EngineWrapper): + """ + Subclass this to prevent a few random errors. + + Even though MinimalEngine extends EngineWrapper, + you don't have to actually wrap an engine. + + At minimum, just implement `search`, + however you can also change other methods like + `notify`, etc. + """ + + def __init__(self, commands: COMMANDS_TYPE, options: OPTIONS_GO_EGTB_TYPE, stderr: int | None, # noqa: ARG002 + draw_or_resign: Configuration, game: model.Game | None, debug: bool, # noqa: ARG002 + **popen_args: str) -> None: # noqa: ARG002 Unused argument popen_args + """ + Initialize the values of the engine that all homemade engines inherit. + + :param options: The options to send to the engine. + :param draw_or_resign: Options on whether the bot should resign or offer draws. + """ + super().__init__(options, draw_or_resign) + self.engine_name = self.__class__.__name__ + self.engine = FillerEngine(self, name=self.engine_name) + + def get_pid(self) -> str: + """Homemade engines don't have a pid, so we return a question mark.""" + return "?" + + def search(self, board: chess.Board, time_limit: chess.engine.Limit, ponder: bool, draw_offered: bool, + root_moves: MOVE) -> chess.engine.PlayResult: + """ + Choose a move. + + The method to be implemented in your homemade engine. + NOTE: This method must return an instance of "chess.engine.PlayResult" + """ + raise NotImplementedError("The search method is not implemented") + + def notify(self, method_name: str, *args: ENGINE_INPUT_ARGS_TYPE, **kwargs: ENGINE_INPUT_KWARGS_TYPE + ) -> Any: + """ + Enable the use of `self.engine.option1`. + + The EngineWrapper class sometimes calls methods on "self.engine". + + "self.engine" is a filler property that notifies + whenever an attribute is called. + + Nothing happens unless the main engine does something. + + Simply put, the following code is equivalent + self.engine.(<*args>, <**kwargs>) + self.notify(, <*args>, <**kwargs>) + """ + + +class FillerEngine: + """ + Not meant to be an actual engine. + + This is only used to provide the property "self.engine" + in "MinimalEngine" which extends "EngineWrapper" + """ + + def __init__(self, main_engine: MinimalEngine, name: str = "") -> None: + """:param name: The name to send to the chat.""" + self.id = {"name": name} + self.name = name + self.main_engine = main_engine + + def __getattr__(self, method_name: str) -> Any: + """Provide the property `self.engine`.""" + main_engine = self.main_engine + + # These types aren't tested by mypy. + def method(*args: ENGINE_INPUT_ARGS_TYPE, **kwargs: ENGINE_INPUT_KWARGS_TYPE) -> Any: + nonlocal main_engine + nonlocal method_name + return main_engine.notify(method_name, *args, **kwargs) + + return method + + +test_suffix = "-for-lichess-bot-testing-only" + + +def get_homemade_engine(name: str) -> type[MinimalEngine]: + """ + Get the homemade engine with name `name`. e.g. If `name` is `RandomMove` then we will return `homemade.RandomMove`. + + :param name: The name of the homemade engine. + :return: The engine with this name. + """ + import homemade + from test_bot import homemade as test_homemade + engine: type[MinimalEngine] + if name.endswith(test_suffix): # Test only. + engine = getattr(test_homemade, name.removesuffix(test_suffix)) + else: + engine = getattr(homemade, name) + return engine + + +def move_time(board: chess.Board, + game: model.Game, + can_ponder: bool, + setup_timer: Timer, + move_overhead: datetime.timedelta, + is_correspondence: bool, + correspondence_move_time: datetime.timedelta) -> tuple[chess.engine.Limit, bool]: + """ + Determine the game clock settings for the current move. + + :param Board: The current position. + :param game: Information about the current game. + :param setup_timer: How much time has passed since receiving the opponent's move. + :param move_overhead: How much time it takes to communicate with lichess. + :param can_ponder: Whether the bot is allowed to ponder after choosing a move. + :param is_correspondence: Whether the current game is a correspondence game. + :param correspondence_move_time: How much time to use for this move it it is a correspondence game. + :return: The time to choose a move and whether the bot can ponder after the move. + """ + if len(board.move_stack) < 2: + return first_move_time(game), False # No pondering after the first move since a new clock starts afterwards. + if is_correspondence: + return single_move_time(board, game, correspondence_move_time, setup_timer, move_overhead), can_ponder + return game_clock_time(board, game, setup_timer, move_overhead), can_ponder + + +def wbtime(board: chess.Board) -> Literal["wtime", "btime"]: + """Return `wtime` if it is white's turn to move else `btime`.""" + return "wtime" if board.turn == chess.WHITE else "btime" + + +def wbinc(board: chess.Board) -> Literal["winc", "binc"]: + """Return `winc` if it is white's turn to move else `binc`.""" + return "winc" if board.turn == chess.WHITE else "binc" + + +def single_move_time(board: chess.Board, game: model.Game, search_time: datetime.timedelta, + setup_timer: Timer, move_overhead: datetime.timedelta) -> chess.engine.Limit: + """ + Calculate time to search in correspondence games. + + :param board: The current positions. + :param game: The game that the bot is playing. + :param search_time: How long the engine should search. + :param setup_timer: How much time has passed since receiving the opponent's move. + :param move_overhead: The time it takes to communicate between the engine and lichess-bot. + :return: The time to choose a move. + """ + pre_move_time = setup_timer.time_since_reset() + overhead = pre_move_time + move_overhead + clock_time = max(msec(1), msec(game.state[wbtime(board)]) - overhead) + search_time = min(search_time, clock_time) + logger.info(f"Searching for time {sec_str(search_time)} seconds for game {game.id}") + return chess.engine.Limit(time=to_seconds(search_time), clock_id="correspondence") + + +def first_move_time(game: model.Game) -> chess.engine.Limit: + """ + Determine time limit for the first move in the game. + + :param game: The game that the bot is playing. + :return: The time to choose the first move. + """ + # Need to hardcode first movetime since Lichess has 30 sec limit. + search_time = seconds(10) + logger.info(f"Searching for time {sec_str(search_time)} seconds for game {game.id}") + return chess.engine.Limit(time=to_seconds(search_time), clock_id="first move") + + +def game_clock_time(board: chess.Board, + game: model.Game, + setup_timer: Timer, + move_overhead: datetime.timedelta) -> chess.engine.Limit: + """ + Get the time to play by the engine in realtime games. + + :param board: The current positions. + :param game: The game that the bot is playing. + :param setup_timer: How much time has passed since receiving the opponent's move. + :param move_overhead: The time it takes to communicate between the engine and lichess-bot. + :return: The time to play a move. + """ + pre_move_time = setup_timer.time_since_reset() + overhead = pre_move_time + move_overhead + times = {"wtime": msec(game.state["wtime"]), "btime": msec(game.state["btime"])} + side = wbtime(board) + times[side] = max(msec(1), times[side] - overhead) + logger.info(f"Searching for wtime {msec_str(times['wtime'])} btime {msec_str(times['btime'])} for game {game.id}") + return chess.engine.Limit(white_clock=to_seconds(times["wtime"]), + black_clock=to_seconds(times["btime"]), + white_inc=to_seconds(msec(game.state["winc"])), + black_inc=to_seconds(msec(game.state["binc"])), + clock_id="real time") + + +def check_for_draw_offer(game: model.Game) -> bool: + """Check if the bot was offered a draw.""" + return bool(game.state.get(f"{game.opponent_color[0]}draw")) + + +def get_book_move(board: chess.Board, game: model.Game, + polyglot_cfg: Configuration) -> chess.engine.PlayResult: + """Get a move from an opening book.""" + no_book_move = chess.engine.PlayResult(None, None) + use_book = polyglot_cfg.enabled + max_game_length = polyglot_cfg.max_depth * 2 - 1 + if not use_book or len(board.move_stack) > max_game_length: + return no_book_move + + if board.chess960: + variant = "chess960" + else: + variant = "standard" if board.uci_variant == "chess" else str(board.uci_variant) + + change_value_to_list(polyglot_cfg.config, "book", key=variant) + books = polyglot_cfg.book.lookup(variant) + + for book in books: + with chess.polyglot.open_reader(book) as reader: + try: + selection = polyglot_cfg.selection + min_weight = polyglot_cfg.min_weight + normalization = polyglot_cfg.normalization + weights = [entry.weight for entry in reader.find_all(board)] + scalar = (sum(weights) if normalization == "sum" and weights else + max(weights) if normalization == "max" and weights else 100) + min_weight = min_weight * scalar / 100 + + if selection == "weighted_random": + move = reader.weighted_choice(board).move + elif selection == "uniform_random": + move = reader.choice(board, minimum_weight=min_weight).move + elif selection == "best_move": + move = reader.find(board, minimum_weight=min_weight).move + except IndexError: + # python-chess raises "IndexError" if no entries found. + move = None + + if move is not None: + logger.info(f"Got move {move} from book {book} for game {game.id}") + return chess.engine.PlayResult(move, None, {"string": "lichess-bot-source:Opening Book"}) + + return no_book_move + + +def get_online_move(li: lichess.Lichess, board: chess.Board, game: model.Game, online_moves_cfg: Configuration, + draw_or_resign_cfg: Configuration) -> chess.engine.PlayResult | list[chess.Move]: + """ + Get a move from an online source. + + If `move_quality` is `suggest`, then it will return a list of moves for the engine to choose from. + """ + online_egtb_cfg = online_moves_cfg.online_egtb + best_move, wdl, comment = get_online_egtb_move(li, board, game, online_egtb_cfg) + if best_move is not None: + can_offer_draw = draw_or_resign_cfg.offer_draw_enabled + offer_draw_for_zero = draw_or_resign_cfg.offer_draw_for_egtb_zero + offer_draw = can_offer_draw and offer_draw_for_zero and wdl == 0 + + can_resign = draw_or_resign_cfg.resign_enabled + resign_on_egtb_loss = draw_or_resign_cfg.resign_for_egtb_minus_two + resign = can_resign and resign_on_egtb_loss and wdl == -2 + + wdl_to_score = {2: 9900, 1: 500, 0: 0, -1: -500, -2: -9900} + comment["score"] = chess.engine.PovScore(chess.engine.Cp(wdl_to_score[wdl]), board.turn) + if isinstance(best_move, str): + return chess.engine.PlayResult(chess.Move.from_uci(best_move), + None, + comment, + draw_offered=offer_draw, + resigned=resign) + return [chess.Move.from_uci(move) for move in best_move] + + max_out_of_book_moves = online_moves_cfg.max_out_of_book_moves + max_opening_moves = online_moves_cfg.max_depth * 2 - 1 + game_moves = len(board.move_stack) + if game_moves > max_opening_moves or out_of_online_opening_book_moves[game.id] >= max_out_of_book_moves: + return chess.engine.PlayResult(None, None) + + chessdb_cfg = online_moves_cfg.chessdb_book + lichess_cloud_cfg = online_moves_cfg.lichess_cloud_analysis + opening_explorer_cfg = online_moves_cfg.lichess_opening_explorer + + for online_source, cfg in ((get_chessdb_move, chessdb_cfg), + (get_lichess_cloud_move, lichess_cloud_cfg), + (get_opening_explorer_move, opening_explorer_cfg)): + best_move, comment = online_source(li, board, game, cfg) + if best_move: + return chess.engine.PlayResult(chess.Move.from_uci(best_move), None, comment) + + out_of_online_opening_book_moves[game.id] += 1 + used_opening_books = chessdb_cfg.enabled or lichess_cloud_cfg.enabled or opening_explorer_cfg.enabled + if out_of_online_opening_book_moves[game.id] == max_out_of_book_moves and used_opening_books: + logger.info(f"Will stop using online opening books for game {game.id}.") + return chess.engine.PlayResult(None, None) + + +def get_chessdb_move(li: lichess.Lichess, board: chess.Board, game: model.Game, + chessdb_cfg: Configuration) -> tuple[str | None, chess.engine.InfoDict]: + """Get a move from chessdb.cn's opening book.""" + use_chessdb = chessdb_cfg.enabled + time_left = msec(game.state[wbtime(board)]) + min_time = seconds(chessdb_cfg.min_time) + max_time = seconds(chessdb_cfg.max_time) + if not use_chessdb or time_left < min_time or game.clock_initial > max_time or board.uci_variant != "chess": + return None, {} + + move = None + comment: chess.engine.InfoDict = {} + site = "https://www.chessdb.cn/cdb.php" + quality = chessdb_cfg.move_quality + action = {"best": "querypv", + "good": "querybest", + "all": "query"} + with contextlib.suppress(Exception): + params: dict[str, str | int] = {"action": action[quality], "board": board.fen(), "json": 1} + data = li.online_book_get(site, params=params) + if data["status"] == "ok": + if quality == "best": + depth = data["depth"] + if depth >= chessdb_cfg.min_depth: + score = data["score"] + move = data["pv"][0] + comment["score"] = chess.engine.PovScore(chess.engine.Cp(score), board.turn) + comment["depth"] = data["depth"] + comment["pv"] = list(map(chess.Move.from_uci, data["pv"])) + comment["string"] = "lichess-bot-source:ChessDB" + logger.info(f"Got move {move} from chessdb.cn (depth: {depth}, score: {score}) for game {game.id}") + else: + move = data["move"] + logger.info(f"Got move {move} from chessdb.cn for game {game.id}") + + return move, comment + + +def get_lichess_cloud_move(li: lichess.Lichess, board: chess.Board, game: model.Game, + lichess_cloud_cfg: Configuration) -> tuple[str | None, chess.engine.InfoDict]: + """Get a move from the lichess's cloud analysis.""" + side = wbtime(board) + time_left = msec(game.state[side]) + min_time = seconds(lichess_cloud_cfg.min_time) + max_time = seconds(lichess_cloud_cfg.max_time) + use_lichess_cloud = lichess_cloud_cfg.enabled + if not use_lichess_cloud or time_left < min_time or game.clock_initial > max_time: + return None, {} + + move = None + comment: chess.engine.InfoDict = {} + + quality = lichess_cloud_cfg.move_quality + multipv = 1 if quality == "best" else 5 + variant = "standard" if board.uci_variant == "chess" else str(board.uci_variant) # `str` is there only for mypy. + + with contextlib.suppress(Exception): + data = li.online_book_get("https://lichess.org/api/cloud-eval", + params={"fen": board.fen(), "multiPv": multipv, "variant": variant}, + authenticated=True) + if "error" not in data: + depth = data["depth"] + knodes = data["knodes"] + min_depth = lichess_cloud_cfg.min_depth + min_knodes = lichess_cloud_cfg.min_knodes + if depth >= min_depth and knodes >= min_knodes: + if quality == "best": + pv = data["pvs"][0] + else: + best_eval = data["pvs"][0]["cp"] + pvs = data["pvs"] + max_difference = lichess_cloud_cfg.max_score_difference + if side == "wtime": + pvs = list(filter(lambda pv: pv["cp"] >= best_eval - max_difference, pvs)) + else: + pvs = list(filter(lambda pv: pv["cp"] <= best_eval + max_difference, pvs)) + pv = random.choice(pvs) + move = pv["moves"].split()[0] + score = pv["cp"] if side == "wtime" else -pv["cp"] + comment["score"] = chess.engine.PovScore(chess.engine.Cp(score), board.turn) + comment["depth"] = data["depth"] + comment["nodes"] = data["knodes"] * 1000 + comment["pv"] = list(map(chess.Move.from_uci, pv["moves"].split())) + comment["string"] = "lichess-bot-source:Lichess Cloud Analysis" + logger.info(f"Got move {move} from lichess cloud analysis (depth: {depth}, score: {score}, knodes: {knodes})" + f" for game {game.id}") + + return move, comment + + +def get_opening_explorer_move(li: lichess.Lichess, board: chess.Board, game: model.Game, + opening_explorer_cfg: Configuration + ) -> tuple[str | None, chess.engine.InfoDict]: + """Get a move from lichess's opening explorer.""" + side = wbtime(board) + time_left = msec(game.state[side]) + min_time = seconds(opening_explorer_cfg.min_time) + max_time = seconds(opening_explorer_cfg.max_time) + source = opening_explorer_cfg.source + if (not opening_explorer_cfg.enabled or time_left < min_time or game.clock_initial > max_time or source == "master" + and board.uci_variant != "chess"): + return None, {} + + move = None + comment: chess.engine.InfoDict = {} + variant = "standard" if board.uci_variant == "chess" else str(board.uci_variant) # `str` is there only for mypy + with contextlib.suppress(Exception): + params: dict[str, str | int] + if source == "masters": + params = {"fen": board.fen(), "moves": 100} + response = li.online_book_get("https://explorer.lichess.ovh/masters", params, authenticated=True) + comment = {"string": "lichess-bot-source:Lichess Opening Explorer (Masters)"} + elif source == "player": + player = opening_explorer_cfg.player_name + if not player: + player = game.username + params = {"player": player, "fen": board.fen(), "moves": 100, "variant": variant, + "recentGames": 0, "color": "white" if side == "wtime" else "black"} + response = li.online_book_get("https://explorer.lichess.ovh/player", params, stream=True, authenticated=True) + comment = {"string": "lichess-bot-source:Lichess Opening Explorer (Player)"} + else: + params = {"fen": board.fen(), "moves": 100, "variant": variant, "topGames": 0, "recentGames": 0} + response = li.online_book_get("https://explorer.lichess.ovh/lichess", params, authenticated=True) + comment = {"string": "lichess-bot-source:Lichess Opening Explorer (Lichess)"} + moves = [] + for possible_move in response["moves"]: + games_played = possible_move["white"] + possible_move["black"] + possible_move["draws"] + winrate = (possible_move["white"] + possible_move["draws"] * .5) / games_played + if side == "btime": + winrate = 1 - winrate + if games_played >= opening_explorer_cfg.min_games: + # We add both winrate and games_played to the tuple, so that if 2 moves are tied on the first metric, + # the second one will be used. + moves.append((winrate if opening_explorer_cfg.sort == "winrate" else games_played, + games_played if opening_explorer_cfg.sort == "winrate" else winrate, possible_move["uci"])) + moves.sort(reverse=True) + move = moves[0][2] + logger.info(f"Got move {move} from lichess opening explorer ({opening_explorer_cfg.sort}: {moves[0][0]})" + f" for game {game.id}") + + return move, comment + + +def get_online_egtb_move(li: lichess.Lichess, board: chess.Board, game: model.Game, online_egtb_cfg: Configuration + ) -> tuple[str | list[str] | None, int, chess.engine.InfoDict]: + """ + Get a move from an online egtb (either by lichess or chessdb). + + If `move_quality` is `suggest`, then it will return a list of moves for the engine to choose from. + """ + use_online_egtb = online_egtb_cfg.enabled + pieces = chess.popcount(board.occupied) + source = online_egtb_cfg.source + minimum_time = seconds(online_egtb_cfg.min_time) + maximum_time = seconds(online_egtb_cfg.max_time) + time_left = game.state[wbtime(board)] + if (not use_online_egtb + or msec(time_left) < minimum_time + or game.clock_initial > maximum_time + or board.uci_variant not in ["chess", "antichess", "atomic"] + and source == "lichess" + or board.uci_variant != "chess" + and source == "chessdb" + or pieces > online_egtb_cfg.max_pieces + or board.castling_rights): + + return None, -3, {} + + quality = online_egtb_cfg.move_quality + variant = "standard" if board.uci_variant == "chess" else str(board.uci_variant) + + with contextlib.suppress(Exception): + if source == "lichess": + return get_lichess_egtb_move(li, game, board, quality, variant) + if source == "chessdb": + return get_chessdb_egtb_move(li, game, board, quality) + + return None, -3, {} + + +def get_egtb_move(board: chess.Board, game: model.Game, lichess_bot_tbs: Configuration, + draw_or_resign_cfg: Configuration) -> chess.engine.PlayResult | list[chess.Move]: + """ + Get a move from a local egtb. + + If `move_quality` is `suggest`, then it will return a list of moves for the engine to choose from. + """ + best_move, wdl = get_syzygy(board, game, lichess_bot_tbs.syzygy) + source = "lichess-bot-source:Syzygy EGTB" + if best_move is None: + best_move, wdl = get_gaviota(board, game, lichess_bot_tbs.gaviota) + source = "lichess-bot-source:Gaviota EGTB" + if best_move: + can_offer_draw = draw_or_resign_cfg.offer_draw_enabled + offer_draw_for_zero = draw_or_resign_cfg.offer_draw_for_egtb_zero + offer_draw = bool(can_offer_draw and offer_draw_for_zero and wdl == 0) + + can_resign = draw_or_resign_cfg.resign_enabled + resign_on_egtb_loss = draw_or_resign_cfg.resign_for_egtb_minus_two + resign = bool(can_resign and resign_on_egtb_loss and wdl == -2) + wdl_to_score = {2: 9900, 1: 500, 0: 0, -1: -500, -2: -9900} + comment: chess.engine.InfoDict = {"score": chess.engine.PovScore(chess.engine.Cp(wdl_to_score[wdl]), board.turn), + "string": source} + if isinstance(best_move, chess.Move): + return chess.engine.PlayResult(best_move, None, comment, draw_offered=offer_draw, resigned=resign) + return best_move + return chess.engine.PlayResult(None, None) + + +def is_op1_position(board: chess.Board) -> bool: + """Determine if a given python-chess board represents an 'op1' 8-piece tablebase position.""" + if len(board.piece_map()) != 8 or board.castling_rights: + return False + + white_piece_count = chess.popcount(board.occupied_co[chess.WHITE]) + black_piece_count = chess.popcount(board.occupied_co[chess.BLACK]) + + if white_piece_count <= 2 or black_piece_count <= 2: + return False + + white_pawns = board.pieces(chess.PAWN, chess.WHITE) + black_pawns = board.pieces(chess.PAWN, chess.BLACK) + + if not white_pawns or not black_pawns: + return False + + for wp in white_pawns: + for bp in black_pawns: + if chess.square_file(wp) == chess.square_file(bp) and chess.square_rank(wp) < chess.square_rank(bp): + return True + + return False + + +def get_lichess_egtb_move(li: lichess.Lichess, game: model.Game, board: chess.Board, quality: str, + variant: str) -> tuple[str | list[str] | None, int, chess.engine.InfoDict]: + """ + Get a move from lichess's egtb. + + If `move_quality` is `suggest`, then it will return a list of moves for the engine to choose from. + """ + name_to_wld = {"loss": -2, + "maybe-loss": -1, + "blessed-loss": -1, + "draw": 0, + "cursed-win": 1, + "maybe-win": 1, + "win": 2} + pieces = chess.popcount(board.occupied) + max_pieces = 8 if board.uci_variant == "chess" else 6 + metric: Literal["dtz", "dtc"] = "dtz" if pieces < 8 else "dtc" + is_op1 = is_op1_position(board) + if pieces <= max_pieces and (pieces < 8 or is_op1): + data = li.online_book_get(f"https://tablebase.lichess.ovh/{variant}", + params={"fen": board.fen()}, authenticated=True) + if quality == "best": + move = data["moves"][0]["uci"] + wdl = name_to_wld[data["moves"][0]["category"]] * -1 + dtz = data["moves"][0][metric] * -1 + dtm = data["moves"][0]["dtm"] + if dtm: + dtm *= -1 + logger.info(f"Got move {move} from tablebase.lichess.ovh (wdl: {wdl}, {metric}: {dtz}, dtm: {dtm})" + f" for game {game.id}") + else: # quality == "suggest": + best_wdl = name_to_wld[data["moves"][0]["category"]] * -1 + + def good_enough(possible_move: LichessEGTBMoveType) -> bool: + return name_to_wld[possible_move["category"]] * -1 == best_wdl + + possible_moves = list(filter(good_enough, data["moves"])) + if len(possible_moves) > 1: + move_list = [move["uci"] for move in possible_moves] + wdl = best_wdl + logger.info(f"Suggesting moves from tablebase.lichess.ovh (wdl: {wdl}) for game {game.id}") + return move_list, wdl, {"string": "lichess-bot-source:Lichess EGTB"} + else: + best_move = possible_moves[0] + move = best_move["uci"] + wdl = name_to_wld[best_move["category"]] * -1 + dtz = best_move[metric] * -1 + dtm = best_move["dtm"] + if dtm: + dtm *= -1 + logger.info(f"Got move {move} from tablebase.lichess.ovh (wdl: {wdl}, {metric}: {dtz}, dtm: {dtm})" + f" for game {game.id}") + + return move, wdl, {"string": "lichess-bot-source:Lichess EGTB"} + return None, -3, {} + + +def get_chessdb_egtb_move(li: lichess.Lichess, game: model.Game, board: chess.Board, + quality: str) -> tuple[str | list[str] | None, int, chess.engine.InfoDict]: + """ + Get a move from chessdb's egtb. + + If `move_quality` is `suggest`, then it will return a list of moves for the engine to choose from. + """ + def score_to_wdl(score: int) -> int: + return piecewise_function([(-20000, "e", -2), + (0, "e", -1), + (0, "i", 0), + (20000, "i", 1)], 2, score) + + def score_to_dtz(score: int) -> int: + return piecewise_function([(-20000, "e", -30000 - score), + (0, "e", -20000 - score), + (0, "i", 0), + (20000, "i", 20000 - score)], 30000 - score, score) + + action = "querypv" if quality == "best" else "queryall" + data = li.online_book_get("https://www.chessdb.cn/cdb.php", + params={"action": action, "board": board.fen(), "json": 1}) + if data["status"] == "ok": + if quality == "best": + score = data["score"] + move = data["pv"][0] + wdl = score_to_wdl(score) + dtz = score_to_dtz(score) + logger.info(f"Got move {move} from chessdb.cn (wdl: {wdl}, dtz: {dtz}) for game {game.id}") + else: # quality == "suggest" + best_wdl = score_to_wdl(data["moves"][0]["score"]) + + def good_enough(move: ChessDBMoveType) -> bool: + return score_to_wdl(move["score"]) == best_wdl + + possible_moves = list(filter(good_enough, cast(list[ChessDBMoveType], data["moves"]))) + if len(possible_moves) > 1: + wdl = score_to_wdl(possible_moves[0]["score"]) + move_list = [move["uci"] for move in possible_moves] + logger.info(f"Suggesting moves from from chessdb.cn (wdl: {wdl}) for game {game.id}") + return move_list, wdl, {"string": "lichess-bot-source:ChessDB EGTB"} + else: + best_move = possible_moves[0] + score = best_move["score"] + move = best_move["uci"] + wdl = score_to_wdl(score) + dtz = score_to_dtz(score) + logger.info(f"Got move {move} from chessdb.cn (wdl: {wdl}, dtz: {dtz}) for game {game.id}") + + return move, wdl, {"string": "lichess-bot-source:ChessDB EGTB"} + return None, -3, {} + + +def get_syzygy(board: chess.Board, game: model.Game, + syzygy_cfg: Configuration) -> tuple[chess.Move | list[chess.Move] | None, int]: + """ + Get a move from local syzygy egtbs. + + If `move_quality` is `suggest`, then it will return a list of moves for the engine to choose from. + """ + if (not syzygy_cfg.enabled + or chess.popcount(board.occupied) > syzygy_cfg.max_pieces + or board.uci_variant not in ["chess", "antichess", "atomic"]): + return None, -3 + + move: chess.Move | list[chess.Move] + move_quality = syzygy_cfg.move_quality + + with chess.syzygy.open_tablebase(syzygy_cfg.paths[0]) as tablebase: + for path in syzygy_cfg.paths[1:]: + tablebase.add_directory(path) + + try: + moves = score_syzygy_moves(board, dtz_scorer, tablebase) + + best_wdl = max(map(dtz_to_wdl, moves.values())) + good_moves = [(move, dtz) for move, dtz in moves.items() if dtz_to_wdl(dtz) == best_wdl] + if move_quality == "suggest" and len(good_moves) > 1: + move = [chess_move for chess_move, dtz in good_moves] + logger.info(f"Suggesting moves from syzygy (wdl: {best_wdl}) for game {game.id}") + return move, best_wdl + # There can be multiple moves with the same dtz. + best_dtz = min(good_moves, key=itemgetter(1))[1] + best_moves = [chess_move for chess_move, dtz in good_moves if dtz == best_dtz] + move = random.choice(best_moves) + logger.info(f"Got move {move.uci()} from syzygy (wdl: {best_wdl}, dtz: {best_dtz}) for game {game.id}") + return move, best_wdl + except KeyError: + # Attempt to only get the WDL score. It returns moves of quality="suggest", even if quality is set to "best". + try: + moves = score_syzygy_moves(board, lambda tablebase, b: -tablebase.probe_wdl(b), tablebase) + best_wdl = int(max(moves.values())) # int is there only for mypy. + good_chess_moves = [chess_move for chess_move, wdl in moves.items() if wdl == best_wdl] + logger.debug("Found moves using 'move_quality'='suggest'. We didn't find an '.rtbz' file for this endgame." + if move_quality == "best" else "") + if len(good_chess_moves) > 1: + move = good_chess_moves + logger.info(f"Suggesting moves from syzygy (wdl: {best_wdl}) for game {game.id}") + else: + move = good_chess_moves[0] + logger.info(f"Got move {move.uci()} from syzygy (wdl: {best_wdl}) for game {game.id}") + return move, best_wdl + except KeyError: + return None, -3 + + +def dtz_scorer(tablebase: chess.syzygy.Tablebase, board: chess.Board) -> int | float: + """ + Score a position based on a syzygy DTZ egtb. + + For a zeroing move (capture or pawn move), a DTZ of +/-0.5 is returned. + """ + dtz: int | float = -tablebase.probe_dtz(board) + dtz = dtz if board.halfmove_clock or not dtz else math.copysign(.5, dtz) + return dtz + (math.copysign(board.halfmove_clock, dtz) if dtz else 0) + + +def dtz_to_wdl(dtz: float) -> int: + """ + Convert DTZ scores to syzygy WDL scores. + + A DTZ of +/-100 returns a draw score of +/-1 instead of a win/loss score of +/-2 because + a 50-move draw can be forced before checkmate can be forced. + """ + return piecewise_function([(-100, "i", -1), (0, "e", -2), (0, "i", 0), (100, "e", 2)], 1, dtz) + + +def get_gaviota(board: chess.Board, game: model.Game, + gaviota_cfg: Configuration) -> tuple[chess.Move | list[chess.Move] | None, int]: + """ + Get a move from local gaviota egtbs. + + If `move_quality` is `suggest`, then it will return a list of moves for the engine to choose from. + """ + if (not gaviota_cfg.enabled + or chess.popcount(board.occupied) > gaviota_cfg.max_pieces + or board.uci_variant != "chess"): + return None, -3 + + move: chess.Move | list[chess.Move] + move_quality = gaviota_cfg.move_quality + + # Since gaviota TBs use dtm and not dtz, we have to put a limit where after it the position are considered to have + # a syzygy wdl=1/-1, so the positions are draws under the 50 move rule. We use min_dtm_to_consider_as_wdl_1 as a + # second limit, because if a position has 5 pieces and dtm=110 it may take 98 half-moves, to go down to 4 pieces and + # another 12 to mate, so this position has a syzygy wdl=2/-2. To be safe, the first limit is 100 moves, which + # guarantees that all moves have a syzygy wdl=2/-2. Setting min_dtm_to_consider_as_wdl_1 to 100 will disable it + # because dtm >= dtz, so if abs(dtm) < 100 => abs(dtz) < 100, so wdl=2/-2. + min_dtm_to_consider_as_wdl_1 = gaviota_cfg.min_dtm_to_consider_as_wdl_1 + + with chess.gaviota.open_tablebase(gaviota_cfg.paths[0]) as tablebase: + for path in gaviota_cfg.paths[1:]: + tablebase.add_directory(path) + + try: + moves = score_gaviota_moves(board, dtm_scorer, tablebase) + + best_wdl = max(map(dtm_to_gaviota_wdl, moves.values())) + good_moves = [(move, dtm) for move, dtm in moves.items() if dtm_to_gaviota_wdl(dtm) == best_wdl] + best_dtm = min(good_moves, key=itemgetter(1))[1] + + pseudo_wdl = dtm_to_wdl(best_dtm, min_dtm_to_consider_as_wdl_1) + if move_quality == "suggest": + best_moves = good_enough_gaviota_moves(good_moves, best_dtm, min_dtm_to_consider_as_wdl_1) + if len(best_moves) > 1: + move = [chess_move for chess_move, dtm in best_moves] + logger.info(f"Suggesting moves from gaviota (pseudo wdl: {pseudo_wdl}) for game {game.id}") + else: + move, dtm = best_moves[0] + logger.info(f"Got move {move.uci()} from gaviota (pseudo wdl: {pseudo_wdl}, dtm: {dtm})" + f" for game {game.id}") + else: + # There can be multiple moves with the same dtm. + best_moves = [(move, dtm) for move, dtm in good_moves if dtm == best_dtm] + move, dtm = random.choice(best_moves) + logger.info(f"Got move {move.uci()} from gaviota (pseudo wdl: {pseudo_wdl}, dtm: {dtm}) for game {game.id}") + return move, pseudo_wdl + except KeyError: + return None, -3 + + +def dtm_scorer(tablebase: chess.gaviota.NativeTablebase | chess.gaviota.PythonTablebase, board: chess.Board) -> int: + """Score a position based on a gaviota DTM egtb.""" + dtm = -tablebase.probe_dtm(board) + return dtm + int(math.copysign(board.halfmove_clock, dtm) if dtm else 0) + + +def dtm_to_gaviota_wdl(dtm: int) -> int: + """Convert DTM scores to gaviota WDL scores.""" + return piecewise_function([(-1, "i", -1), (0, "i", 0)], 1, dtm) + + +def dtm_to_wdl(dtm: int, min_dtm_to_consider_as_wdl_1: int) -> int: + """Convert DTM scores to syzygy WDL scores.""" + # We use 100 and not min_dtm_to_consider_as_wdl_1, because we want to play it safe and not resign in a + # position where dtz=-102 (only if resign_for_egtb_minus_two is enabled). + return piecewise_function([(-100, "i", -1), (-1, "i", -2), (0, "i", 0), (min_dtm_to_consider_as_wdl_1, "e", 2)], 1, dtm) + + +def good_enough_gaviota_moves(good_moves: list[tuple[chess.Move, int]], best_dtm: int, + min_dtm_to_consider_as_wdl_1: int) -> list[tuple[chess.Move, int]]: + """ + Get the moves that are good enough to consider. + + :param good_moves: All the moves to choose from. + :param best_dtm: The best DTM score of a move. + :param min_dtm_to_consider_as_wdl_1: The minimum DTM score to consider as WDL=1. + :return: A list of the moves that are good enough to consider. + """ + if best_dtm < 100: + # If a move had wdl=2 and dtz=98, but halfmove_clock is 4 then the real wdl=1 and dtz=102, so we + # want to avoid these positions, if there is a move where even when we add the halfmove_clock the + # dtz is still <100. + return [(move, dtm) for move, dtm in good_moves if dtm < 100] + if best_dtm < min_dtm_to_consider_as_wdl_1: + # If a move had wdl=2 and dtz=98, but halfmove_clock is 4 then the real wdl=1 and dtz=102, so we + # want to avoid these positions, if there is a move where even when we add the halfmove_clock the + # dtz is still <100. + return [(move, dtm) for move, dtm in good_moves if dtm < min_dtm_to_consider_as_wdl_1] + if best_dtm <= -min_dtm_to_consider_as_wdl_1: + # If a move had wdl=-2 and dtz=-98, but halfmove_clock is 4 then the real wdl=-1 and dtz=-102, so we + # want to only choose between the moves where the real wdl=-1. + return [(move, dtm) for move, dtm in good_moves if dtm <= -min_dtm_to_consider_as_wdl_1] + if best_dtm <= -100: + # If a move had wdl=-2 and dtz=-98, but halfmove_clock is 4 then the real wdl=-1 and dtz=-102, so we + # want to only choose between the moves where the real wdl=-1. + return [(move, dtm) for move, dtm in good_moves if dtm <= -100] + return good_moves + + +def piecewise_function(range_definitions: list[tuple[float, Literal["e", "i"], int]], last_value: int, + position: float) -> int: + """ + Return a value according to a position argument. + + This function is meant to replace if-elif-else blocks that turn ranges into discrete values. + Each tuple in the list has three parts: an upper limit, and inclusive/exclusive indicator, and + a value. For example, + `piecewise_function([(-20000, "e", 2), (0, "e" -1), (0, "i", 0), (20000, "i", 1)], 2, score)` is equivalent to: + + if score < -20000: + return -2 + elif score < 0: + return -1 + elif score <= 0: + return 0 + elif score <= 20000: + return 1 + else: + return 2 + + Arguments: + range_definitions: + A list of tuples with the first element being the inclusive right border of region and the second + element being the associated value. An element of this list (a, "i", b) corresponds to an + inclusive limit and is equivalent to + if x <= a: + return b + where x is the value of the position argument. An element of the form (a, "e", b) corresponds to + an exclusive limit and is equivalent to + if x < a: + return b + For correct operation, this argument should be sorted by the first element. If two ranges have the + same border, one with "e" and the other with "i", the "e" element should be first. + last_value: + If the position argument does not fall in any of the ranges in the range_definition argument, + return this value. + position: + The value that will be compared to the first element of the range_definitions tuples. + + """ + for border, inc_exc, value in range_definitions: + if position < border or (inc_exc == "i" and position == border): + return value + return last_value + + +def score_syzygy_moves(board: chess.Board, + scorer: Callable[[chess.syzygy.Tablebase, chess.Board], int] | + Callable[[chess.syzygy.Tablebase, chess.Board], int | float], + tablebase: chess.syzygy.Tablebase) -> dict[chess.Move, int | float]: + """Score all the moves using syzygy egtbs.""" + moves = {} + for move in board.legal_moves: + board.push(move) + moves[move] = scorer(tablebase, board) + board.pop() + return moves + + +def score_gaviota_moves(board: chess.Board, + scorer: Callable[[chess.gaviota.NativeTablebase | chess.gaviota.PythonTablebase, chess.Board], int], + tablebase: chess.gaviota.NativeTablebase | chess.gaviota.PythonTablebase + ) -> dict[chess.Move, int]: + """Score all the moves using gaviota egtbs.""" + moves = {} + for move in board.legal_moves: + board.push(move) + moves[move] = scorer(tablebase, board) + board.pop() + return moves diff --git a/lichess-bot-master/lib/lichess.py b/lichess-bot-master/lib/lichess.py new file mode 100644 index 0000000000000000000000000000000000000000..59037f12c8eb690aa419a878aae187b3eba245e6 --- /dev/null +++ b/lichess-bot-master/lib/lichess.py @@ -0,0 +1,474 @@ +"""Communication with APIs.""" +import json +import requests +from urllib.parse import urljoin +from requests.exceptions import ConnectionError as RequestsConnectionError, HTTPError, ReadTimeout +from http.client import RemoteDisconnected +import backoff +import logging +import traceback +from collections import defaultdict +import datetime +import contextlib +from lib.timer import Timer, seconds, sec_str +from typing import cast +import chess.engine +from lib.lichess_types import (UserProfileType, REQUESTS_PAYLOAD_TYPE, GameType, PublicDataType, OnlineType, + ChallengeType, TOKEN_TESTS_TYPE, BackoffDetails) + + +ENDPOINTS = { + "profile": "/api/account", + "playing": "/api/account/playing", + "stream": "/api/bot/game/stream/{}", + "stream_event": "/api/stream/event", + "move": "/api/bot/game/{}/move/{}", + "takeback": "/api/bot/game/{}/takeback/{}", + "chat": "/api/bot/game/{}/chat", + "abort": "/api/bot/game/{}/abort", + "accept": "/api/challenge/{}/accept", + "decline": "/api/challenge/{}/decline", + "upgrade": "/api/bot/account/upgrade", + "resign": "/api/bot/game/{}/resign", + "export": "/game/export/{}", + "online_bots": "/api/bot/online", + "challenge": "/api/challenge/{}", + "cancel": "/api/challenge/{}/cancel", + "status": "/api/users/status", + "public_data": "/api/user/{}", + "token_test": "/api/token/test" +} + + +logger = logging.getLogger(__name__) + +MAX_CHAT_MESSAGE_LEN = 140 # The maximum characters in a chat message. + + +class Stop: + """Class to stop the bot.""" + + def __init__(self) -> None: + """Initialize the Stop class.""" + self.terminated = False + self.force_quit = False + self.restart = True + + +stop = Stop() + + +class RateLimitedError(RuntimeError): + """Exception raised when we are rate limited (status code 429).""" + + def __init__(self, message: str, timeout: datetime.timedelta) -> None: + """Create a rate-limited error with the time left until the rate limit expires.""" + super().__init__(message) + self.timeout = timeout + + +def is_new_rate_limit(response: requests.models.Response) -> bool: + """Check if the status code is 429, which means that we are rate limited.""" + return response.status_code == 429 + +def is_daily_game_rate_limit(response: requests.models.Response, rate_limit_status_code: int) -> bool: + """Check if response to challenge is a rate limit, either of the bot or the opponent.""" + if response.status_code != rate_limit_status_code: + return False + + try: + body = response.json() + return "error" in body and body.get("ratelimit", {}).get("key", "") == "bot.vsBot.day" + except requests.exceptions.JSONDecodeError: + return False + + +def is_opponent_rate_limit(response: requests.models.Response) -> bool: + """Check if response to a challenge is 400, which means opponent is rate limited.""" + return is_daily_game_rate_limit(response, 400) + + +def is_bot_rate_limit(response: requests.models.Response) -> bool: + """Check if response to a challenge is 429, which means the bot is rate limited.""" + return is_daily_game_rate_limit(response, 429) + + +def get_challenge_timeout(challenge_response: ChallengeType) -> datetime.timedelta | None: + """Return the timeout in a challenge response if the bot or the opponent cannot play another game.""" + rate_limit = challenge_response.get("ratelimit", {}) + key = rate_limit.get("key", "") + if key == "bot.vsBot.day": + return seconds(float(rate_limit["seconds"])) + return None + + +def is_final(exception: Exception) -> bool: + """If `is_final` returns True then we won't retry.""" + return (isinstance(exception, HTTPError) and exception.response is not None and exception.response.status_code < 500 + or stop.force_quit) + + +def backoff_handler(details: BackoffDetails) -> None: + """Log exceptions inside functions with the backoff decorator.""" + args = details["args"] + kwargs = details["kwargs"] + if "token_test" in args: + kwargs["data"] = "" + logger.debug("Backing off {wait:0.1f} seconds after {tries} tries " + "calling function {target} with args {args} and kwargs {kwargs}".format(**details)) + logger.debug(f"Exception: {traceback.format_exc()}") + + +# Docs: https://lichess.org/api. +class Lichess: + """Communication with lichess.org (and chessdb.cn for getting moves).""" + + def __init__(self, token: str, url: str, version: str, logging_level: int, max_retries: int) -> None: + """ + Communication with lichess.org (and chessdb.cn for getting moves). + + :param token: The bot's token. + :param url: The base url (lichess.org). + :param version: The lichess-bot version running. + :param logging_level: The logging level (logging.INFO or logging.DEBUG). + :param max_retries: The maximum amount of retries for online moves (e.g. chessdb's opening book). + """ + self.version = version + self.header = { + "Authorization": f"Bearer {token}" + } + self.baseUrl = url + self.session = requests.Session() + self.session.headers.update(self.header) + self.other_session = requests.Session() + self.set_user_agent("?") + self.logging_level = logging_level + self.max_retries = max_retries + self.rate_limit_timers: defaultdict[str, Timer] = defaultdict(Timer) + + # Confirm that the OAuth token has the proper permission to play on lichess + token_response = cast(TOKEN_TESTS_TYPE, self.api_post("token_test", data=token)) + token_info = token_response.get(token) + + if not token_info: + raise RuntimeError("There was an error in retrieving information about the bot's token. " + "Please check that it was copied correctly into your configuration file " + "and try again.") + + scopes = token_info.get("scopes", "") + if "bot:play" not in scopes.split(","): + raise RuntimeError("Please use an API access token for your bot that " + 'has the scope "Play games with the bot API (bot:play)". ' + f"The current token has: {scopes}.") + + @backoff.on_exception(backoff.constant, + (RemoteDisconnected, RequestsConnectionError, HTTPError, ReadTimeout), + max_time=60, + interval=0.1, + giveup=is_final, + on_backoff=backoff_handler, + backoff_log_level=logging.DEBUG, + giveup_log_level=logging.DEBUG) + def api_get(self, endpoint_name: str, *template_args: str, + params: dict[str, str] | None = None, + stream: bool = False, timeout: int = 2) -> requests.Response: + """ + Send a GET to lichess.org. + + :param endpoint_name: The name of the endpoint. + :param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`). + :param params: Parameters sent to lichess.org. + :param stream: Whether the data returned from lichess.org should be streamed. + :param timeout: The amount of time in seconds to wait for a response. + :return: lichess.org's response. + """ + logging.getLogger("backoff").setLevel(self.logging_level) + path_template = self.get_path_template(endpoint_name) + url = urljoin(self.baseUrl, path_template.format(*template_args)) + response = self.session.get(url, params=params, timeout=timeout, stream=stream) + + if is_new_rate_limit(response): + delay = seconds(1 if endpoint_name == "move" else 60) + self.set_rate_limit_delay(path_template, delay) + + response.raise_for_status() + response.encoding = "utf-8" + return response + + def api_get_json(self, endpoint_name: str, *template_args: str, + params: dict[str, str] | None = None + ) -> PublicDataType | UserProfileType | dict[str, list[GameType]]: + """ + Send a GET to the lichess.org endpoints that return a JSON. + + :param endpoint_name: The name of the endpoint. + :param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`). + :param params: Parameters sent to lichess.org. + :return: lichess.org's response in a dict. + """ + response = self.api_get(endpoint_name, *template_args, params=params) + json_response: PublicDataType | UserProfileType | dict[str, list[GameType]] = response.json() + return json_response + + def api_get_list(self, endpoint_name: str, *template_args: str, + params: dict[str, str] | None = None) -> list[UserProfileType]: + """ + Send a GET to the lichess.org endpoints that return a list containing JSON. + + :param endpoint_name: The name of the endpoint. + :param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`). + :param params: Parameters sent to lichess.org. + :return: lichess.org's response in a list of dicts. + """ + response = self.api_get(endpoint_name, *template_args, params=params) + json_response: list[UserProfileType] = response.json() + return json_response + + def api_get_raw(self, endpoint_name: str, *template_args: str, + params: dict[str, str] | None = None) -> str: + """ + Send a GET to lichess.org that returns plain text (UTF-8). + + :param endpoint_name: The name of the endpoint. + :param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`). + :param params: Parameters sent to lichess.org. + :return: The text of lichess.org's response. + """ + response = self.api_get(endpoint_name, *template_args, params=params) + return response.text + + @backoff.on_exception(backoff.constant, + (RemoteDisconnected, RequestsConnectionError, HTTPError, ReadTimeout), + max_time=60, + interval=0.1, + giveup=is_final, + on_backoff=backoff_handler, + backoff_log_level=logging.DEBUG, + giveup_log_level=logging.DEBUG) + def api_post(self, + endpoint_name: str, + *template_args: str, + data: str | dict[str, str] | None = None, + headers: dict[str, str] | None = None, + params: dict[str, str] | None = None, + payload: REQUESTS_PAYLOAD_TYPE | None = None, + raise_for_status: bool = True) -> ChallengeType | TOKEN_TESTS_TYPE | None: + """ + Send a POST to lichess.org. + + :param endpoint_name: The name of the endpoint. + :param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`). + :param data: Data sent to lichess.org. + :param headers: The headers for the request. + :param params: Parameters sent to lichess.org. + :param payload: Payload sent to lichess.org. + :param raise_for_status: Whether to raise an exception if the response contains an error code. + :return: lichess.org's response in a dict. + """ + logging.getLogger("backoff").setLevel(self.logging_level) + path_template = self.get_path_template(endpoint_name) + url = urljoin(self.baseUrl, path_template.format(*template_args)) + response = self.session.post(url, data=data, headers=headers, params=params, json=payload, timeout=2) + + if endpoint_name == "challenge": + return self.handle_challenge(response) + + if is_new_rate_limit(response): + self.set_rate_limit_delay(path_template, seconds(60)) + + if raise_for_status: + response.raise_for_status() + + json_response: ChallengeType | TOKEN_TESTS_TYPE | None = response.json() + return json_response + + def get_path_template(self, endpoint_name: str) -> str: + """ + Get the path template given the endpoint name. Will raise an exception if the path template is rate limited. + + :param endpoint_name: The name of the endpoint. + :return: The path template. + """ + path_template = ENDPOINTS[endpoint_name] + if self.is_rate_limited(path_template): + time_left = self.rate_limit_time_left(path_template) + raise RateLimitedError( + f"{path_template} is rate-limited. Will retry in {sec_str(time_left)} seconds.", time_left) + return path_template + + def handle_challenge(self, response: requests.models.Response) -> ChallengeType: + """Handle the response to a challenge and, if necessary, the daily game timeout.""" + bot_is_rate_limited = is_bot_rate_limit(response) + opponent_is_rate_limited = is_opponent_rate_limit(response) + challenge_response: ChallengeType = response.json() + if bot_is_rate_limited or opponent_is_rate_limited: + delay = cast(datetime.timedelta, get_challenge_timeout(challenge_response)) + if bot_is_rate_limited: + self.set_rate_limit_delay(ENDPOINTS["challenge"], delay) + challenge_response["bot_is_rate_limited"] = bot_is_rate_limited + challenge_response["opponent_is_rate_limited"] = opponent_is_rate_limited + challenge_response["rate_limit_timeout"] = delay + + return challenge_response + + def set_rate_limit_delay(self, path_template: str, delay_time: datetime.timedelta) -> None: + """ + Set a delay to a path template if it was rate limited. + + :param path_template: The path template. + :param delay_time: How long we won't call this endpoint. + """ + logger.warning(f"Endpoint {path_template} is rate limited. Waiting {sec_str(delay_time)} seconds until next request.") + self.rate_limit_timers[path_template] = Timer(delay_time) + + def is_rate_limited(self, path_template: str) -> bool: + """Check if a path template is rate limited.""" + return not self.rate_limit_timers[path_template].is_expired() + + def rate_limit_time_left(self, path_template: str) -> datetime.timedelta: + """How much time is left until we can use the path template normally.""" + return self.rate_limit_timers[path_template].time_until_expiration() + + def upgrade_to_bot_account(self) -> None: + """Upgrade the account to a BOT account.""" + self.api_post("upgrade") + + def make_move(self, game_id: str, move: chess.engine.PlayResult) -> None: + """ + Make a move. + + :param game_id: The id of the game. + :param move: The move to make. + """ + self.api_post("move", game_id, str(move.move), + params={"offeringDraw": str(move.draw_offered).lower()}) + + def accept_takeback(self, game_id: str, accept: bool) -> bool: + """Answer an opponent's move takeback request.""" + try: + self.api_post("takeback", game_id, "yes" if accept else "no") + if accept: + logger.info("Opponent took back previous move.") + else: + logger.info("Refused opponent's take back request.") + return accept + except Exception: + return False + + def chat(self, game_id: str, room: str, text: str) -> None: + """ + Send a message to the chat. + + :param game_id: The id of the game. + :param room: The room (either chat or spectator room). + :param text: The text to send. + """ + if len(text) > MAX_CHAT_MESSAGE_LEN: + logger.warning(f"This chat message is {len(text)} characters, which is longer " + f"than the maximum of {MAX_CHAT_MESSAGE_LEN}. It will not be sent.") + logger.warning(f"Message: {text}") + + data = {"room": room, "text": text} + self.api_post("chat", game_id, data=data) + + def abort(self, game_id: str) -> None: + """Aborts a game.""" + self.api_post("abort", game_id) + + def get_event_stream(self) -> requests.models.Response: + """Get a stream of the events (e.g. challenge, gameStart).""" + return self.api_get("stream_event", stream=True, timeout=15) + + def get_game_stream(self, game_id: str) -> requests.models.Response: + """Get stream of the in-game events (e.g. moves by the opponent).""" + return self.api_get("stream", game_id, stream=True, timeout=15) + + def accept_challenge(self, challenge_id: str) -> None: + """Accept a challenge.""" + self.api_post("accept", challenge_id) + + def decline_challenge(self, challenge_id: str, reason: str = "generic") -> None: + """Decline a challenge.""" + with contextlib.suppress(Exception): + self.api_post("decline", challenge_id, + data=f"reason={reason}", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + raise_for_status=False) + + def get_profile(self) -> UserProfileType: + """Get the bot's profile (e.g. username).""" + profile = cast(UserProfileType, self.api_get_json("profile")) + self.set_user_agent(profile["username"]) + return profile + + def get_ongoing_games(self) -> list[GameType] | None: + """ + Get the bot's ongoing games. + + If an error occurs when retrieving the games, None is returned. + """ + with contextlib.suppress(Exception): + response = cast(dict[str, list[GameType]], self.api_get_json("playing")) + return response["nowPlaying"] + return None + + def resign(self, game_id: str) -> None: + """Resign a game.""" + self.api_post("resign", game_id) + + def set_user_agent(self, username: str) -> None: + """Set the user agent for communication with lichess.org.""" + self.header.update({"User-Agent": f"lichess-bot/{self.version} user:{username}"}) + self.session.headers.update(self.header) + + def get_game_pgn(self, game_id: str) -> str: + """Get the PGN (Portable Game Notation) record of a game.""" + try: + return self.api_get_raw("export", game_id) + except Exception: + return "" + + def get_online_bots(self) -> list[UserProfileType]: + """Get a list of bots that are online.""" + try: + online_bots_str = self.api_get_raw("online_bots") + online_bots = list(filter(bool, online_bots_str.split("\n"))) + return list(map(json.loads, online_bots)) + except Exception: + return [] + + def challenge(self, username: str, payload: REQUESTS_PAYLOAD_TYPE) -> ChallengeType: + """Create a challenge.""" + return cast(ChallengeType, + self.api_post("challenge", username, payload=payload, raise_for_status=False)) + + def cancel(self, challenge_id: str) -> None: + """Cancel a challenge.""" + self.api_post("cancel", challenge_id, raise_for_status=False) + + def online_book_get(self, path: str, params: dict[str, str | int] | None = None, *, + stream: bool = False, authenticated: bool = False) -> OnlineType: + """Get an external move from online sources (chessdb or lichess.org).""" + @backoff.on_exception(backoff.constant, + (RemoteDisconnected, RequestsConnectionError, HTTPError, ReadTimeout), + max_time=60, + max_tries=self.max_retries, + interval=0.1, + giveup=is_final, + on_backoff=backoff_handler, + backoff_log_level=logging.DEBUG, + giveup_log_level=logging.DEBUG) + def online_book_get() -> OnlineType: + session = self.session if authenticated else self.other_session # Choose session based on authentication need + json_response: OnlineType = session.get(path, timeout=2, params=params, stream=stream).json() + return json_response + return online_book_get() + + def is_online(self, user_id: str) -> bool: + """Check if lichess.org thinks the bot is online or not.""" + user = self.api_get_list("status", params={"ids": user_id}) + return bool(user and user[0].get("online")) + + def get_public_data(self, user_name: str) -> PublicDataType: + """Get the public data of a bot.""" + return cast(PublicDataType, self.api_get_json("public_data", user_name)) diff --git a/lichess-bot-master/lib/lichess_bot.py b/lichess-bot-master/lib/lichess_bot.py new file mode 100644 index 0000000000000000000000000000000000000000..1962036d1d3b14ae8c5e93e053886cd6436a01c5 --- /dev/null +++ b/lichess-bot-master/lib/lichess_bot.py @@ -0,0 +1,1277 @@ +"""The main module that controls lichess-bot.""" +import argparse +import chess +import chess.pgn +from chess.variant import find_variant +from lib import engine_wrapper, model, lichess, matchmaking +import json +import logging +import logging.handlers +import multiprocessing +import signal +import time +import datetime +import backoff +import os +import io +import copy +import math +import sys +import yaml +import traceback +import itertools +import glob +import platform +import importlib.metadata +import contextlib +from lib.blocklist import OnlineBlocklist +from lib.config import load_config, Configuration, log_config +from lib.conversation import Conversation, ChatLine +from lib.timer import Timer, seconds, msec, hours, to_seconds +from lib.lichess import stop +from lib.lichess_types import (UserProfileType, EventType, GameType, GameEventType, CONTROL_QUEUE_TYPE, + CORRESPONDENCE_QUEUE_TYPE, LOGGING_QUEUE_TYPE, PGN_QUEUE_TYPE) +from requests.exceptions import (ChunkedEncodingError, ConnectionError as RequestsConnectionError, HTTPError, ReadTimeout, + RequestException) +from rich.logging import RichHandler +from collections import defaultdict +from collections.abc import Iterator, MutableSequence +from http.client import RemoteDisconnected +from queue import Empty +from multiprocessing.pool import Pool +from collections import Counter +from typing import TypedDict, cast, TypeAlias +from types import FrameType +MULTIPROCESSING_LIST_TYPE: TypeAlias = MutableSequence[model.Challenge] +POOL_TYPE: TypeAlias = Pool + + +class PlayGameArgsType(TypedDict, total=False): + """Type hint for `play_game_args`.""" + + li: lichess.Lichess + control_queue: CONTROL_QUEUE_TYPE + user_profile: UserProfileType + config: Configuration + challenge_queue: MULTIPROCESSING_LIST_TYPE + correspondence_queue: CORRESPONDENCE_QUEUE_TYPE + logging_queue: LOGGING_QUEUE_TYPE + pgn_queue: PGN_QUEUE_TYPE + game_id: str + + +class VersioningType(TypedDict): + """Type hint for the versioning information from lib/versioning.yml.""" + + lichess_bot_version: str + minimum_python_version: str + deprecated_python_version: str + deprecation_date: datetime.date + + +logger = logging.getLogger(__name__) + +with open(os.path.join(os.path.dirname(__file__), "versioning.yml")) as version_file: + versioning_info: VersioningType = yaml.safe_load(version_file) + +__version__ = versioning_info["lichess_bot_version"] + + +def should_restart() -> bool: + """Decide whether to restart lichess-bot when exiting main program.""" + return stop.restart + + +def disable_restart() -> None: + """Disable restarting lichess-bot when errors occur. Used during testing.""" + stop.restart = False + + +def signal_handler(signal: int, frame: FrameType | None) -> None: # noqa: ARG001 + """Terminate lichess-bot.""" + if not stop.terminated: + logger.debug("Received SIGINT. Terminating client.") + stop.terminated = True + else: + logger.debug("Received second SIGINT. Quitting now.") + stop.force_quit = True + + +signal.signal(signal.SIGINT, signal_handler) + + +def upgrade_account(li: lichess.Lichess) -> bool: + """Upgrade the account to a BOT account.""" + try: + li.upgrade_to_bot_account() + except HTTPError: + logger.exception("Failed to upgrade to Bot Account.") + return False + + logger.info("Successfully upgraded to Bot Account!") + return True + + +def watch_control_stream(control_queue: CONTROL_QUEUE_TYPE, li: lichess.Lichess) -> None: + """Put the events in a queue.""" + while not stop.terminated: + try: + with li.get_event_stream() as response: + lines = response.iter_lines() + for line in lines: + if line: + event = json.loads(line.decode("utf-8")) + control_queue.put_nowait(event) + else: + control_queue.put_nowait({"type": "ping"}) + except Exception: + logger.warning(f"Control stream error, reconnecting:\n{traceback.format_exc()}") + time.sleep(1) + + control_queue.put_nowait({"type": "terminated"}) + + +def do_correspondence_ping(control_queue: CONTROL_QUEUE_TYPE, period: datetime.timedelta) -> None: + """ + Tell the engine to check the correspondence games. + + :param control_queue: Queue to put events in. + :param period: How many seconds to wait before sending a correspondence ping. + """ + while not stop.terminated: + time.sleep(to_seconds(period)) + control_queue.put_nowait({"type": "correspondence_ping"}) + + +def write_pgn_records(pgn_queue: PGN_QUEUE_TYPE, config: Configuration, username: str) -> None: + """Write PGN records to files as games finish.""" + while True: + mark_task_done = False + try: + event = pgn_queue.get() + mark_task_done = True + if event: + save_pgn_record(event, config, username) + except InterruptedError: + pass + except Exception: + logger.exception("Could not write PGN to file") + + if mark_task_done: + pgn_queue.task_done() + + +def logging_configurer(level: int, filename: str | None, disable_auto_logs: bool) -> None: + """ + Configure the logger. + + :param level: The logging level. Either `logging.INFO` or `logging.DEBUG`. + :param filename: The filename to write the logs to. If it is `None` then the logs aren't written to a file. + :param disable_auto_logs: Whether to disable automatic logging. + """ + console_handler = RichHandler() + console_formatter = logging.Formatter("%(message)s") + console_handler.setFormatter(console_formatter) + console_handler.setLevel(level) + all_handlers: list[logging.Handler] = [console_handler] + + if filename: + file_handler = logging.FileHandler(filename, delay=True, encoding="utf-8") + FORMAT = "%(asctime)s %(name)s (%(filename)s:%(lineno)d) %(levelname)s %(message)s" + file_formatter = logging.Formatter(FORMAT) + file_handler.setFormatter(file_formatter) + file_handler.setLevel(level) + all_handlers.append(file_handler) + + if not disable_auto_logs: + os.makedirs(auto_log_directory, exist_ok=True) + + # Set up automatic logging. + auto_log_filename = os.path.join(auto_log_directory, "lichess-bot.log") + auto_file_handler = logging.handlers.TimedRotatingFileHandler(auto_log_filename, + delay=True, + encoding="utf-8", + when="midnight", + backupCount=7) + auto_file_handler.setLevel(logging.DEBUG) + + FORMAT = "%(asctime)s %(name)s (%(filename)s:%(lineno)d) %(levelname)s %(message)s" + file_formatter = logging.Formatter(FORMAT) + auto_file_handler.setFormatter(file_formatter) + all_handlers.append(auto_file_handler) + + logging.basicConfig(level=logging.DEBUG, + handlers=all_handlers, + force=True) + + +def logging_listener_proc(queue: LOGGING_QUEUE_TYPE, level: int, log_filename: str | None, + disable_auto_logging: bool) -> None: + """ + Handle events from the logging queue. + + This allows the logs from inside a thread to be printed. + They are added to the queue, so they are printed outside the thread. + """ + logging_configurer(level, log_filename, disable_auto_logging) + logger = logging.getLogger() + while True: + task: logging.LogRecord | None = None + try: + task = queue.get(block=False) + except Empty: + time.sleep(0.1) + except InterruptedError: + pass + except Exception: # noqa: S110 + pass + + if task is None: + continue + + logger.handle(task) + queue.task_done() + + +def thread_logging_configurer(queue: LOGGING_QUEUE_TYPE) -> None: + """Configure the game logger.""" + h = logging.handlers.QueueHandler(queue) + root = logging.getLogger() + root.handlers.clear() + root.addHandler(h) + root.setLevel(logging.DEBUG) + + +def start(li: lichess.Lichess, user_profile: UserProfileType, config: Configuration, logging_level: int, + log_filename: str | None, disable_auto_logging: bool, one_game: bool = False) -> None: + """ + Start lichess-bot. + + :param li: Provides communication with lichess.org. + :param user_profile: Information on our bot. + :param config: The config that the bot will use. + :param logging_level: The logging level. Either `logging.INFO` or `logging.DEBUG`. + :param log_filename: The filename to write the logs to. If it is `None` then the logs aren't written to a file. + :param disable_auto_logging: Whether to disable automatic logging. + :param one_game: Whether the bot should play only one game. Only used in `test_bot/test_bot.py` to test lichess-bot. + """ + logger.info(f"You're now connected to {config.url} and awaiting challenges.") + manager = multiprocessing.Manager() + challenge_queue: MULTIPROCESSING_LIST_TYPE = manager.list() + control_queue: CONTROL_QUEUE_TYPE = manager.Queue() + control_stream = multiprocessing.Process(target=watch_control_stream, args=(control_queue, li)) + control_stream.start() + correspondence_pinger = multiprocessing.Process(target=do_correspondence_ping, + args=(control_queue, + seconds(config.correspondence.checkin_period))) + correspondence_pinger.start() + correspondence_queue: CORRESPONDENCE_QUEUE_TYPE = manager.Queue() + + logging_queue = manager.Queue() + logging_listener = multiprocessing.Process(target=logging_listener_proc, + args=(logging_queue, + logging_level, + log_filename, + disable_auto_logging)) + logging_listener.start() + + pgn_queue = manager.Queue() + pgn_listener = multiprocessing.Process(target=write_pgn_records, + args=(pgn_queue, + config, + user_profile["username"])) + pgn_listener.start() + + thread_logging_configurer(logging_queue) + + try: + lichess_bot_main(li, + user_profile, + config, + challenge_queue, + control_queue, + correspondence_queue, + logging_queue, + pgn_queue, + one_game) + finally: + control_stream.terminate() + control_stream.join() + correspondence_pinger.terminate() + correspondence_pinger.join() + time.sleep(1.0) # Allow final messages in logging_queue to be handled. + logging_configurer(logging_level, log_filename, disable_auto_logging) + logging_listener.terminate() + logging_listener.join() + pgn_listener.terminate() + pgn_listener.join() + + +def log_proc_count(change: str, active_games: set[str]) -> None: + """ + Log the number of active games and their IDs. + + :param change: Either "Freed", "Used", or "Queued". + :param active_games: A set containing the IDs of the active games. + """ + symbol = "+++" if change == "Freed" else "---" + logger.info(f"{symbol} Process {change}. Count: {len(active_games)}. IDs: {active_games or None}") + + +def lichess_bot_main(li: lichess.Lichess, + user_profile: UserProfileType, + config: Configuration, + challenge_queue: MULTIPROCESSING_LIST_TYPE, + control_queue: CONTROL_QUEUE_TYPE, + correspondence_queue: CORRESPONDENCE_QUEUE_TYPE, + logging_queue: LOGGING_QUEUE_TYPE, + pgn_queue: PGN_QUEUE_TYPE, + one_game: bool) -> None: + """ + Handle all the games and challenges. + + :param li: Provides communication with lichess.org. + :param user_profile: Information on our bot. + :param config: The config that the bot will use. + :param challenge_queue: The queue containing the challenges. + :param control_queue: The queue containing all the events. + :param correspondence_queue: The queue containing the correspondence games. + :param logging_queue: The logging queue. Used by `logging_listener_proc`. + :param pgn_queue: The queue containing the PGN games. + :param one_game: Whether the bot should play only one game. Only used in `test_bot/test_bot.py` to test lichess-bot. + """ + max_games = config.challenge.concurrency + + one_game_completed = False + + all_games = li.get_ongoing_games() or [] + prune_takeback_records(all_games) + startup_correspondence_games = [game["gameId"] + for game in all_games + if game["speed"] == "correspondence"] + active_games = {game["gameId"] + for game in all_games + if game["gameId"] not in startup_correspondence_games} + low_time_games: list[GameType] = [] + + last_check_online_time = Timer(hours(1)) + matchmaker = matchmaking.Matchmaking(li, config, user_profile) + matchmaker.show_earliest_challenge_time() + + play_game_args = PlayGameArgsType(li=li, control_queue=control_queue, user_profile=user_profile, + config=config, challenge_queue=challenge_queue, + correspondence_queue=correspondence_queue, logging_queue=logging_queue, + pgn_queue=pgn_queue) + + recent_bot_challenges: defaultdict[str, list[Timer]] = defaultdict(list) + + online_block_list = OnlineBlocklist(config.challenge.online_block_list) + + if config.quit_after_all_games_finish: + logger.info("When quitting, lichess-bot will first wait for all running games to finish.") + logger.info("Press Ctrl-C twice to quit immediately.") + + with multiprocessing.pool.Pool(max_games + 1) as pool: + while not (stop.terminated or (one_game and one_game_completed) or stop.restart): + event = next_event(control_queue) + if not event: + continue + + if event["type"] == "terminated": + stop.restart = True + control_queue.task_done() + break + + if event["type"] == "local_game_done": + active_games.discard(event["game"]["id"]) + matchmaker.game_done() + log_proc_count("Freed", active_games) + one_game_completed = True + elif event["type"] == "challenge": + handle_challenge(event, + li, + challenge_queue, + config.challenge, + user_profile, + recent_bot_challenges, + online_block_list) + elif event["type"] == "challengeDeclined": + matchmaker.declined_challenge(event) + elif event["type"] == "challengeCanceled": + active_games.discard(event["challenge"]["id"]) + log_proc_count("Freed", active_games) + elif event["type"] == "gameStart": + matchmaker.accepted_challenge(event) + start_game(event, + pool, + play_game_args, + config, + startup_correspondence_games, + correspondence_queue, + active_games, + low_time_games) + + start_low_time_games(low_time_games, active_games, max_games, pool, play_game_args) + check_in_on_correspondence_games(pool, + event, + correspondence_queue, + challenge_queue, + play_game_args, + active_games, + max_games) + accept_challenges(li, challenge_queue, active_games, max_games) + matchmaker.challenge(active_games, challenge_queue, max_games) + check_online_status(li, user_profile, last_check_online_time) + + control_queue.task_done() + + close_pool(pool, active_games, config) + + +def close_pool(pool: POOL_TYPE, active_games: set[str], config: Configuration) -> None: + """Shut down pool after possibly waiting on games to finish depending on the configuration.""" + if config.quit_after_all_games_finish: + if active_games: + logger.info("Waiting for games to finish before quitting.") + pool.close() + pool.join() + + +def next_event(control_queue: CONTROL_QUEUE_TYPE) -> EventType: + """Get the next event from the control queue.""" + try: + event = control_queue.get() + if event is None: + return {} + except InterruptedError: + return {} + + if "type" not in event: + logger.warning("Unable to handle response from lichess.org:") + logger.warning(event) + control_queue.task_done() + return {} + + if event.get("type") != "ping": + logger.debug(f"Event: {event}") + + return event + + +correspondence_games_to_start = 0 + + +def check_in_on_correspondence_games(pool: POOL_TYPE, + event: EventType, + correspondence_queue: CORRESPONDENCE_QUEUE_TYPE, + challenge_queue: MULTIPROCESSING_LIST_TYPE, + play_game_args: PlayGameArgsType, + active_games: set[str], + max_games: int) -> None: + """Start correspondence games.""" + global correspondence_games_to_start + + if event["type"] == "correspondence_ping": + correspondence_games_to_start = correspondence_queue.qsize() + elif event["type"] != "local_game_done": + return + + if challenge_queue: + return + + while len(active_games) < max_games and correspondence_games_to_start > 0: + game_id = correspondence_queue.get_nowait() + correspondence_games_to_start -= 1 + correspondence_queue.task_done() + start_game_thread(active_games, game_id, play_game_args, pool) + + +def start_low_time_games(low_time_games: list[GameType], active_games: set[str], max_games: int, + pool: POOL_TYPE, play_game_args: PlayGameArgsType) -> None: + """Start the games based on how much time we have left.""" + low_time_games.sort(key=lambda g: g.get("secondsLeft", math.inf)) + while low_time_games and len(active_games) < max_games: + game_id = low_time_games.pop(0)["id"] + start_game_thread(active_games, game_id, play_game_args, pool) + + +def accept_challenges(li: lichess.Lichess, challenge_queue: MULTIPROCESSING_LIST_TYPE, active_games: set[str], + max_games: int) -> None: + """Accept a challenge.""" + while len(active_games) < max_games and challenge_queue: + chlng = challenge_queue.pop(0) + if chlng.from_self: + continue + + try: + logger.info(f"Accept {chlng}") + li.accept_challenge(chlng.id) + active_games.add(chlng.id) + log_proc_count("Queued", active_games) + except (HTTPError, ReadTimeout) as exception: + if isinstance(exception, HTTPError) and exception.response is not None and exception.response.status_code == 404: + logger.info(f"Skip missing {chlng}") + + +def check_online_status(li: lichess.Lichess, user_profile: UserProfileType, last_check_online_time: Timer) -> None: + """Check if lichess.org thinks the bot is online or not. If it isn't, we restart it.""" + if last_check_online_time.is_expired(): + try: + if not li.is_online(user_profile["id"]): + logger.info("Will restart lichess-bot") + stop.restart = True + last_check_online_time.reset() + except (HTTPError, ReadTimeout): + pass + + +def sort_challenges(challenge_queue: MULTIPROCESSING_LIST_TYPE, challenge_config: Configuration) -> None: + """ + Sort the challenges. + + They can be sorted either by rating (the best challenger is accepted first), + or by time (the first challenger is accepted first). The bot can also + prioritize playing against humans or bots. + """ + challenge_list = list(challenge_queue) + if challenge_config.sort_by == "best": + challenge_list.sort(key=lambda challenger: challenger.score(), reverse=True) + if challenge_config.preference != "none": + challenge_list.sort(key=lambda challenger: challenger.challenger.is_bot, reverse=challenge_config.preference == "bot") + challenge_queue[:] = challenge_list + + +def game_is_active(li: lichess.Lichess, game_id: str) -> bool: + """Determine if a game is still being played.""" + active_games = li.get_ongoing_games() + if active_games is None: + return True + return game_id in (ongoing_game["gameId"] for ongoing_game in active_games) + + +def start_game_thread(active_games: set[str], game_id: str, play_game_args: PlayGameArgsType, pool: POOL_TYPE) -> None: + """Start a game thread.""" + active_games.add(game_id) + log_proc_count("Used", active_games) + play_game_args["game_id"] = game_id + + def game_error_handler(error: BaseException) -> None: + logger.exception("Game ended due to error:", exc_info=error) + control_queue = play_game_args["control_queue"] + pgn_queue = play_game_args["pgn_queue"] + li = play_game_args["li"] + control_queue.put_nowait({"type": "local_game_done", "game": {"id": game_id}}) + pgn_queue.put_nowait({"game": {"id": game_id, + "pgn": li.get_game_pgn(game_id), + "complete": not game_is_active(li, game_id)}}) + + pool.apply_async(play_game, + kwds=play_game_args, + error_callback=game_error_handler) + + +def start_game(event: EventType, + pool: POOL_TYPE, + play_game_args: PlayGameArgsType, + config: Configuration, + startup_correspondence_games: list[str], + correspondence_queue: CORRESPONDENCE_QUEUE_TYPE, + active_games: set[str], + low_time_games: list[GameType]) -> None: + """ + Start a game. + + :param event: The gameStart event. + :param pool: The thread pool that the game is added to, so they can be run asynchronously. + :param play_game_args: The args passed to `play_game`. + :param config: The config the bot will use. + :param startup_correspondence_games: A list of correspondence games that have to be started. + :param correspondence_queue: The queue that correspondence games are added to, to be started. + :param active_games: A set of all the games that aren't correspondence games. + :param low_time_games: A list of games, in which we don't have much time remaining. + """ + game_id = event["game"]["id"] + if game_id in startup_correspondence_games: + if enough_time_to_queue(event, config): + logger.info(f"--- Enqueue {config.url + game_id}") + correspondence_queue.put_nowait(game_id) + else: + logger.info(f"--- Will start {config.url + game_id} as soon as possible") + low_time_games.append(event["game"]) + startup_correspondence_games.remove(game_id) + else: + start_game_thread(active_games, game_id, play_game_args, pool) + + +def enough_time_to_queue(event: EventType, config: Configuration) -> bool: + """Check whether the correspondence must be started now or if it can wait.""" + corr_cfg = config.correspondence + minimum_time = (corr_cfg.checkin_period + corr_cfg.move_time) * 10 + game = event["game"] + return not game["isMyTurn"] or game.get("secondsLeft", math.inf) > minimum_time + + +def handle_challenge(event: EventType, li: lichess.Lichess, challenge_queue: MULTIPROCESSING_LIST_TYPE, + challenge_config: Configuration, user_profile: UserProfileType, + recent_bot_challenges: defaultdict[str, list[Timer]], online_block_list: OnlineBlocklist) -> None: + """Handle incoming challenges. It either accepts, declines, or queues them to accept later.""" + chlng = model.Challenge(event["challenge"], user_profile) + if chlng.from_self: + return + + active_games = li.get_ongoing_games() or [] + opponent_engagements = Counter(game["opponent"]["username"] for game in active_games) + opponent_engagements.update(challenge.challenger.name for challenge in challenge_queue) + + online_block_list.refresh() + + is_supported, decline_reason = chlng.is_supported(challenge_config, + recent_bot_challenges, + opponent_engagements, + online_block_list, + user_profile) + if is_supported: + challenge_queue.append(chlng) + sort_challenges(challenge_queue, challenge_config) + time_window = challenge_config.recent_bot_challenge_age + if time_window is not None: + recent_bot_challenges[chlng.challenger.name].append(Timer(seconds(time_window))) + else: + li.decline_challenge(chlng.id, reason=decline_reason) + + +@backoff.on_exception(backoff.expo, BaseException, max_time=600, giveup=lichess.is_final, # type: ignore[arg-type] + on_backoff=lichess.backoff_handler) +def play_game(li: lichess.Lichess, + game_id: str, + control_queue: CONTROL_QUEUE_TYPE, + user_profile: UserProfileType, + config: Configuration, + challenge_queue: MULTIPROCESSING_LIST_TYPE, + correspondence_queue: CORRESPONDENCE_QUEUE_TYPE, + logging_queue: LOGGING_QUEUE_TYPE, + pgn_queue: PGN_QUEUE_TYPE) -> None: + """ + Play a game. + + :param li: Provides communication with lichess.org. + :param game_id: The id of the game. + :param control_queue: The control queue that contains events (adds `local_game_done` to the queue). + :param user_profile: Information on our bot. + :param config: The config that the bot will use. + :param challenge_queue: The queue containing the challenges. + :param correspondence_queue: The queue containing the correspondence games. + :param logging_queue: The logging queue. Used by `logging_listener_proc`. + :param pgn_queue: The queue containing the PGN games. + """ + thread_logging_configurer(logging_queue) + logger = logging.getLogger(__name__) + + with li.get_game_stream(game_id) as response: + lines = response.iter_lines() + + # Initial response of stream will be the full game info. Store it. + initial_state = json.loads(next(lines).decode("utf-8")) + logger.debug(f"Initial state: {initial_state}") + abort_time = seconds(config.abort_time) + game = model.Game(initial_state, user_profile["username"], li.baseUrl, abort_time) + + with engine_wrapper.create_engine(config, game) as engine: + engine.get_opponent_info(game) + logger.debug(f"The engine for game {game_id} has pid={engine.get_pid()}") + conversation = Conversation(game, engine, li, __version__, challenge_queue) + + logger.info(f"+++ {game}") + + is_correspondence = game.speed == "correspondence" + correspondence_cfg = config.correspondence + correspondence_move_time = seconds(correspondence_cfg.move_time) + correspondence_disconnect_time = seconds(correspondence_cfg.disconnect_time) + + engine_cfg = config.engine + ponder_cfg = correspondence_cfg if is_correspondence else engine_cfg + can_ponder = ponder_cfg.uci_ponder or ponder_cfg.ponder + move_overhead = msec(config.move_overhead) + delay = msec(config.rate_limiting_delay) + + takebacks_accepted = read_takeback_record(game) + max_takebacks_accepted = config.max_takebacks_accepted + + keyword_map: defaultdict[str, str] = defaultdict(str, me=game.me.name, opponent=game.opponent.name) + hello = get_greeting("hello", config.greeting, keyword_map) + goodbye = get_greeting("goodbye", config.greeting, keyword_map) + hello_spectators = get_greeting("hello_spectators", config.greeting, keyword_map) + goodbye_spectators = get_greeting("goodbye_spectators", config.greeting, keyword_map) + + disconnect_time = correspondence_disconnect_time if not game.state.get("moves") else seconds(0) + prior_game = None + board = chess.Board() + game_stream = itertools.chain([json.dumps(game.state).encode("utf-8")], lines) + quit_after_all_games_finish = config.quit_after_all_games_finish + stay_in_game = True + while stay_in_game and (not stop.terminated or quit_after_all_games_finish) and not stop.force_quit: + move_attempted = False + try: + upd = next_update(game_stream) + u_type = upd["type"] if upd else "ping" + if u_type == "chatLine": + conversation.react(ChatLine(upd)) + elif u_type == "gameState": + game.state = upd + board = setup_board(game) + takeback_field = game.state.get("btakeback") if game.is_white else game.state.get("wtakeback") + + if not is_game_over(game) and is_engine_move(game, prior_game, board): + disconnect_time = correspondence_disconnect_time + say_hello(conversation, hello, hello_spectators, board) + setup_timer = Timer() + print_move_number(board) + move_attempted = True + engine.play_move(board, + game, + li, + setup_timer, + move_overhead, + can_ponder, + is_correspondence, + correspondence_move_time, + engine_cfg, + fake_think_time(config, board, game)) + time.sleep(to_seconds(delay)) + elif is_game_over(game): + tell_user_game_result(game, board) + engine.send_game_result(game, board) + conversation.send_message("player", goodbye) + conversation.send_message("spectator", goodbye_spectators) + elif (takeback_field + and not bot_to_move(game, board) + and li.accept_takeback(game.id, takebacks_accepted < max_takebacks_accepted)): + takebacks_accepted += 1 + record_takeback(game, takebacks_accepted) + engine.discard_last_move_commentary() + + wbtime = upd[engine_wrapper.wbtime(board)] + wbinc = upd[engine_wrapper.wbinc(board)] + terminate_time = msec(wbtime) + msec(wbinc) + seconds(60) + game.ping(abort_time, terminate_time, disconnect_time) + prior_game = copy.deepcopy(game) + elif u_type == "ping" and should_exit_game(board, game, prior_game, li, is_correspondence): + stay_in_game = False + except (HTTPError, ReadTimeout, RemoteDisconnected, ChunkedEncodingError, RequestsConnectionError, + StopIteration) as e: + stopped = isinstance(e, StopIteration) + stay_in_game = not stopped and (move_attempted or game_is_active(li, game.id)) + + pgn_record = try_get_pgn_game_record(li, config, game, board, engine) + final_queue_entries(control_queue, correspondence_queue, game, is_correspondence, pgn_record, pgn_queue) + delete_takeback_record(game) + + +def read_takeback_record(game: model.Game) -> int: + """Read the number of move takeback requests accepted in a game.""" + try: + with open(takeback_record_file_name(game.id)) as takeback_file: + return int(takeback_file.read()) + except Exception: + return 0 + + +def record_takeback(game: model.Game, accepted_count: int) -> None: + """Record the number of move takeback requests accepted in a game.""" + with open(takeback_record_file_name(game.id), "w") as takeback_file: + takeback_file.write(str(accepted_count)) + + +def delete_takeback_record(game: model.Game) -> None: + """Delete the takeback record from a game if it has finished.""" + if is_game_over(game): + with contextlib.suppress(Exception): + os.remove(takeback_record_file_name(game.id)) + + +def prune_takeback_records(all_games: list[GameType]) -> None: + """Delete takeback records from games that have ended.""" + active_game_ids = {game["gameId"] for game in all_games} + takeback_file_template = takeback_record_file_name("*") + prefix, suffix = takeback_file_template.split("*") + for takeback_file_name in glob.glob(takeback_file_template): + game_id = takeback_file_name.removeprefix(prefix).removesuffix(suffix) + if game_id not in active_game_ids: + with contextlib.suppress(Exception): + os.remove(takeback_file_name) + + +def takeback_record_file_name(game_id: str) -> str: + """Get the file name for recording the number of move takebacks accepted.""" + return os.path.join(auto_log_directory, f"takeback-count-{game_id}.txt") + + +def get_greeting(greeting: str, greeting_cfg: Configuration, keyword_map: defaultdict[str, str]) -> str: + """Get the greeting to send to the chat.""" + greeting_text: str = greeting_cfg.lookup(greeting) + return greeting_text.format_map(keyword_map) + + +def say_hello(conversation: Conversation, hello: str, hello_spectators: str, board: chess.Board) -> None: + """Send the greetings to the chat rooms.""" + if len(board.move_stack) < 2: + conversation.send_message("player", hello) + conversation.send_message("spectator", hello_spectators) + + +def fake_think_time(config: Configuration, board: chess.Board, game: model.Game) -> datetime.timedelta: + """Calculate how much time we should wait for fake_think_time.""" + sleep = seconds(0.0) + + if config.fake_think_time and len(board.move_stack) > 9: + remaining = max(seconds(0), game.my_remaining_time() - msec(config.move_overhead)) + delay = remaining * 0.025 + accel = 0.99 ** (len(board.move_stack) - 10) + sleep = delay * accel + + return sleep + + +def print_move_number(board: chess.Board) -> None: + """Log the move number.""" + logger.info("") + logger.info(f"move: {len(board.move_stack) // 2 + 1}") + + +def next_update(lines: Iterator[bytes]) -> GameEventType: + """Get the next game state.""" + binary_chunk = next(lines) + upd = cast(GameEventType, json.loads(binary_chunk.decode("utf-8"))) if binary_chunk else {} + if upd: + logger.debug(f"Game state: {upd}") + return upd + + +def setup_board(game: model.Game) -> chess.Board: + """Set up the board.""" + if game.variant_name.lower() == "chess960": + board = chess.Board(game.initial_fen, chess960=True) + elif game.variant_name == "From Position": + fen = cast(str, game.initial_fen) + board = chess.Board(fen, chess960=model.is_chess_960(fen)) + + else: + VariantBoard = find_variant(game.variant_name) + board = VariantBoard() + + for move in game.state["moves"].split(): + try: + board.push_uci(move) + except ValueError: + logger.exception(f"Ignoring illegal move {move} on board {board.fen()}") + + return board + + +def is_engine_move(game: model.Game, prior_game: model.Game | None, board: chess.Board) -> bool: + """Check whether it is the engine's turn.""" + return game_changed(game, prior_game) and bot_to_move(game, board) + + +def bot_to_move(game: model.Game, board: chess.Board) -> bool: + """ + Determine whether it is the bot's move on the given board. + + This only determines if the board state shows the bot is on move. It does not check if the board state has changed. + Messages from lichess can contain repeat board states if another game aspect has changed (draw offer, takeback offer, + etc.). Use is_engine_move() to determine if the engine should play a move. + """ + return game.is_white == (board.turn == chess.WHITE) + + +def is_game_over(game: model.Game) -> bool: + """Check whether the game is over.""" + status = game.state["status"] + return status != "started" + + +def should_exit_game(board: chess.Board, game: model.Game, prior_game: model.Game | None, li: lichess.Lichess, + is_correspondence: bool) -> bool: + """Whether we should exit a game.""" + if (is_correspondence + and not is_engine_move(game, prior_game, board) + and game.should_disconnect_now()): + return True + if game.should_abort_now(): + logger.info(f"Aborting {game.url()} by lack of activity") + li.abort(game.id) + return True + if game.should_terminate_now(): + logger.info(f"Terminating {game.url()} by lack of activity") + if game.is_abortable(): + li.abort(game.id) + return True + return False + + +def final_queue_entries(control_queue: CONTROL_QUEUE_TYPE, correspondence_queue: CORRESPONDENCE_QUEUE_TYPE, + game: model.Game, is_correspondence: bool, pgn_record: str, pgn_queue: PGN_QUEUE_TYPE) -> None: + """ + Log the game that ended or we disconnected from, and sends a `local_game_done` for the game. + + If this is an unfinished correspondence game, put it in a queue to resume later. + """ + if is_correspondence and not is_game_over(game): + logger.info(f"--- Disconnecting from {game.url()}") + correspondence_queue.put_nowait(game.id) + else: + logger.info(f"--- {game.url()} Game over") + + control_queue.put_nowait({"type": "local_game_done", "game": {"id": game.id}}) + pgn_queue.put_nowait({"game": {"id": game.id, + "pgn": pgn_record, + "complete": is_game_over(game)}}) + + +def game_changed(current_game: model.Game, prior_game: model.Game | None) -> bool: + """Check whether the current game state is different from the previous game state.""" + if prior_game is None: + return True + + current_game_moves_str = current_game.state["moves"] + prior_game_moves_str = prior_game.state["moves"] + return current_game_moves_str != prior_game_moves_str + + +def tell_user_game_result(game: model.Game, board: chess.Board) -> None: + """Log the game result.""" + winner = game.state.get("winner") + termination = game.state.get("status") + + winning_name = game.white.name if winner == "white" else game.black.name + losing_name = game.white.name if winner == "black" else game.black.name + + if winner is not None: + logger.info(f"{winning_name} won!") + elif termination in [model.Termination.DRAW, model.Termination.TIMEOUT]: + logger.info("Game ended in a draw.") + else: + logger.info("Game adjourned.") + + simple_endings: dict[str, str] = {model.Termination.MATE: "Game won by checkmate.", + model.Termination.RESIGN: f"{losing_name} resigned.", + model.Termination.ABORT: "Game aborted."} + + if termination in simple_endings: + logger.info(simple_endings[termination]) + elif termination == model.Termination.DRAW: + draw_results = [(board.is_fifty_moves(), "Game drawn by 50-move rule."), + (board.is_repetition(), "Game drawn by threefold repetition."), + (board.is_insufficient_material(), "Game drawn from insufficient material."), + (board.is_stalemate(), "Game drawn by stalemate."), + (True, "Game drawn by agreement.")] + messages = [draw_message for is_result, draw_message in draw_results if is_result] + logger.info(messages[0]) + elif termination == model.Termination.TIMEOUT: + if winner: + logger.info(f"{losing_name} forfeited on time.") + else: + timeout_name = game.white.name if game.state.get("wtime") == 0 else game.black.name + other_name = game.white.name if timeout_name == game.black.name else game.black.name + logger.info(f"{timeout_name} ran out of time, but {other_name} did not have enough material to mate.") + elif termination: + logger.info(f"Game ended by {termination}") + + +def try_get_pgn_game_record(li: lichess.Lichess, config: Configuration, game: model.Game, board: chess.Board, + engine: engine_wrapper.EngineWrapper) -> str: + """ + Call `print_pgn_game_record` to write the game to a PGN file and handle errors raised by it. + + :param li: Provides communication with lichess.org. + :param config: The config that the bot will use. + :param game: Contains information about the game (e.g. the players' names). + :param board: The board. Contains the moves. + :param engine: The engine. Contains information about the moves (e.g. eval, PV, depth). + """ + try: + return pgn_game_record(li, config, game, board, engine) + except Exception: + logger.exception("Error writing game record:") + return "" + + +def pgn_game_record(li: lichess.Lichess, config: Configuration, game: model.Game, board: chess.Board, + engine: engine_wrapper.EngineWrapper) -> str: + """ + Return the text of the game's PGN. + + :param li: Provides communication with lichess.org. + :param config: The config that the bot will use. + :param game: Contains information about the game (e.g. the players' names). + :param board: The board. Contains the moves. + :param engine: The engine. Contains information about the moves (e.g. eval, PV, depth). + """ + if not config.pgn_directory: + return "" + + lichess_game_record = chess.pgn.read_game(io.StringIO(li.get_game_pgn(game.id))) or chess.pgn.Game() + try: + # Recall previously written PGN file to retain engine evaluations. + previous_game_path = get_game_file_path(config, + game.id, + game.white.name, + game.black.name, + game.me.name, + is_game_over(game), + force_single=True) + with open(previous_game_path) as game_data: + game_record = chess.pgn.read_game(game_data) or lichess_game_record + game_record.headers.update(lichess_game_record.headers) + except FileNotFoundError: + game_record = lichess_game_record + + fill_missing_pgn_headers(game_record, game) + + current_node: chess.pgn.Game | chess.pgn.ChildNode = game_record.game() + lichess_node: chess.pgn.Game | chess.pgn.ChildNode = lichess_game_record.game() + for index, move in enumerate(board.move_stack): + next_node = current_node.next() + if next_node is None or next_node.move != move: + current_node = current_node.add_main_variation(move) + else: + current_node = next_node + + next_lichess_node = lichess_node.next() + if next_lichess_node: + lichess_node = next_lichess_node + current_node.set_clock(lichess_node.clock()) + if current_node.comment != lichess_node.comment: + current_node.comment = f"{current_node.comment} {lichess_node.comment}".strip() + + commentary = engine.comment_for_board_index(index) + pv_node = current_node.parent.add_line(commentary["pv"]) if "pv" in commentary else current_node + pv_node.set_eval(commentary.get("score"), commentary.get("depth")) + + pgn_writer = chess.pgn.StringExporter() + return game_record.accept(pgn_writer) + + +def get_game_file_path(config: Configuration, + game_id: str, + white_name: str, + black_name: str, + user_name: str, + game_is_over: bool, + *, force_single: bool = False) -> str: + """Return the path of the file where the game record will be written.""" + def create_valid_path(s: str) -> str: + illegal = '<>:"/\\|?*' + return os.path.join(config.pgn_directory, "".join(c for c in s if c not in illegal)) + + if config.pgn_file_grouping == "game" or not game_is_over or force_single: + return create_valid_path(f"{white_name} vs {black_name} - {game_id}.pgn") + elif config.pgn_file_grouping == "opponent": + opponent_name = white_name if user_name == black_name else black_name + return create_valid_path(f"{user_name} games vs. {opponent_name}.pgn") + else: # config.pgn_file_grouping == "all" + return create_valid_path(f"{user_name} games.pgn") + + +def fill_missing_pgn_headers(game_record: chess.pgn.Game, game: model.Game) -> None: + """ + Fill in any missing headers in the PGN record provided by lichess.org with information from `game`. + + :param game_record: A `chess.pgn.Game` object containing information about the game lichess.org's PGN file. + :param game: Contains information about the game (e.g. the players' names), which is used to get the local headers. + """ + local_headers = get_headers(game) + for header, game_value in local_headers.items(): + record_value = game_record.headers.get(header) + if not record_value or record_value.startswith("?") or (header == "Result" and record_value == "*"): + game_record.headers[header] = str(game_value) + + +def get_headers(game: model.Game) -> dict[str, str | int]: + """ + Create local headers to be written in the PGN file. + + :param game: Contains information about the game (e.g. the players' names). + :return: The headers in a dict. + """ + headers: dict[str, str | int] = {} + headers["Event"] = game.pgn_event() + headers["Site"] = game.short_url() + headers["Date"] = game.game_start.strftime("%Y.%m.%d") + headers["White"] = game.white.name or str(game.white) + headers["Black"] = game.black.name or str(game.black) + headers["Result"] = game.result() + + if game.black.rating: + headers["BlackElo"] = game.black.rating + if game.black.title: + headers["BlackTitle"] = game.black.title + + if game.perf_name != "correspondence": + headers["TimeControl"] = game.time_control() + + headers["UTCDate"] = headers["Date"] + headers["UTCTime"] = game.game_start.strftime("%H:%M:%S") + headers["Variant"] = game.variant_name + + if game.initial_fen and game.initial_fen != "startpos": + headers["Setup"] = 1 + headers["FEN"] = game.initial_fen + + if game.white.rating: + headers["WhiteElo"] = game.white.rating + if game.white.title: + headers["WhiteTitle"] = game.white.title + + return headers + + +def save_pgn_record(event: EventType, config: Configuration, user_name: str) -> None: + """ + Write the game PGN record to a file. + + :param event: A local_game_done event from the control queue. + :param config: The user's bot configuration. + :param user_name: The bot's name. + """ + pgn = event["game"]["pgn"] + pgn_headers = chess.pgn.read_headers(io.StringIO(pgn)) + if not config.pgn_directory or pgn_headers is None: + return + + game_id = event["game"]["id"] + white_name = pgn_headers["White"] + black_name = pgn_headers["Black"] + game_is_over = event["game"]["complete"] + + os.makedirs(config.pgn_directory, exist_ok=True) + game_path = get_game_file_path(config, game_id, white_name, black_name, user_name, game_is_over) + single_game_path = get_game_file_path(config, game_id, white_name, black_name, user_name, game_is_over, force_single=True) + write_mode = "w" if game_path == single_game_path else "a" + logger.debug(f"Writing PGN game record to: {game_path}") + with open(game_path, write_mode) as game_file: + game_file.write(pgn + "\n\n") + + if os.path.exists(single_game_path) and game_path != single_game_path: + os.remove(single_game_path) + + +def intro() -> str: + """Return the intro string.""" + return fr""" + . _/| + . // o\ + . || ._) lichess-bot {__version__} on {platform.system()} {platform.release()} + . //__\ + . )___( Play on Lichess with a bot + """ + + +auto_log_directory = "lichess_bot_auto_logs" + + +def log_python_and_libraries() -> None: + """Log the installed libraries and the python version.""" + logger.debug(f"Python version: {'.'.join(map(str, sys.version_info))}") + text = "Installed libraries:\n" + distributions = importlib.metadata.distributions() + for distribution in distributions: + text += f"{distribution.metadata['Name']}=={distribution.version}\n" + text += "\n" + logger.debug(text) + + +def start_lichess_bot() -> None: + """Parse arguments passed to lichess-bot.py and starts lichess-bot.""" + parser = argparse.ArgumentParser(description="Play on Lichess with a bot") + parser.add_argument("-u", action="store_true", help="Upgrade your account to a bot account.") + parser.add_argument("-v", action="store_true", help="Make output more verbose. Include all communication with lichess.") + parser.add_argument("--config", help="Specify a configuration file (defaults to ./config.yml).") + parser.add_argument("-l", "--logfile", help="Record all console output to a log file.", default=None) + parser.add_argument("--disable_auto_logging", action="store_true", help="Disable automatic logging.") + args = parser.parse_args() + + logging_level = logging.DEBUG if args.v else logging.INFO + logging_configurer(logging_level, args.logfile, args.disable_auto_logging) + logger.info(intro(), extra={"highlighter": None}) + + CONFIG = load_config(args.config or "./config.yml") + if not args.disable_auto_logging: + with open(os.path.join(auto_log_directory, "config.log"), "w") as config_log: + log_config(CONFIG.config, config_log.write) + logger.info("Checking engine configuration ...") + with engine_wrapper.create_engine(CONFIG): + pass + logger.info("Engine configuration OK") + + max_retries = CONFIG.engine.online_moves.max_retries + check_python_version() + log_python_and_libraries() + li = lichess.Lichess(CONFIG.token, CONFIG.url, __version__, logging_level, max_retries) + + user_profile = li.get_profile() + username = user_profile["username"] + is_bot = user_profile.get("title") == "BOT" + logger.info(f"Welcome {username}!") + + if args.u and not is_bot: + is_bot = upgrade_account(li) + + if is_bot: + start(li, user_profile, CONFIG, logging_level, args.logfile, args.disable_auto_logging) + else: + logger.error(f"{username} is not a bot account. Please upgrade it to a bot account!") + logging.shutdown() + + +def check_python_version() -> None: + """Raise a warning or an exception if the version isn't supported or is deprecated.""" + def version_numeric(version_str: str) -> list[int]: + return [int(n) for n in version_str.split(".")] + + python_deprecated_version = version_numeric(versioning_info["deprecated_python_version"]) + python_good_version = version_numeric(versioning_info["minimum_python_version"]) + version_change_date = versioning_info["deprecation_date"] + this_python_version = list(sys.version_info[0:2]) + + def version_str(version: list[int]) -> str: + return f"Python {'.'.join(str(n) for n in version)}" + + upgrade_request = (f"You are currently running {version_str(this_python_version)}. " + f"Please upgrade to {version_str(python_good_version)} or newer") + out_of_date_error = RuntimeError("A newer version of Python is required " + f"to run this version of lichess-bot. {upgrade_request}.") + out_of_date_warning = ("A newer version of Python will be required " + f"on {version_change_date} to run lichess-bot. {upgrade_request} before then.") + + this_lichess_bot_version = version_numeric(__version__) + lichess_bot_breaking_version = list(version_change_date.timetuple()[0:3]) + + if this_python_version < python_deprecated_version: + raise out_of_date_error + + if this_python_version == python_deprecated_version: + if this_lichess_bot_version < lichess_bot_breaking_version: + logger.warning(out_of_date_warning) + else: + raise out_of_date_error + + +def start_program() -> None: + """Start lichess-bot and restart when needed.""" + multiprocessing.set_start_method("spawn") + try: + while should_restart(): + disable_restart() + # Restart on network errors. + try: + start_lichess_bot() + except RequestException: + stop.restart = True + logger.exception("Restarting lichess-bot due to a network error:") + if stop.terminated or stop.force_quit: + logger.info("Termination requested - stopping restart cycle") + break + time.sleep(10 if should_restart() else 0) + except Exception: + logger.exception("Quitting lichess-bot due to an error:") diff --git a/lichess-bot-master/lib/lichess_types.py b/lichess-bot-master/lib/lichess_types.py new file mode 100644 index 0000000000000000000000000000000000000000..5bb7fd9aa1adb596c9506845cc6f0ebdffcf008c --- /dev/null +++ b/lichess-bot-master/lib/lichess_types.py @@ -0,0 +1,468 @@ +"""Some type hints that can be accessed by all other python files.""" +from typing import Any, TypedDict, Literal, TypeAlias +from collections.abc import Callable +from chess.engine import PovWdl, PovScore, PlayResult, Limit, Opponent +from chess import Move, Board +from queue import Queue +import logging +import datetime +from enum import Enum +from types import TracebackType + +COMMANDS_TYPE: TypeAlias = list[str] +MOVE: TypeAlias = PlayResult | list[Move] +CORRESPONDENCE_QUEUE_TYPE: TypeAlias = Queue[str] +LOGGING_QUEUE_TYPE: TypeAlias = Queue[logging.LogRecord] +REQUESTS_PAYLOAD_TYPE: TypeAlias = dict[str, str | int | bool] +GO_COMMANDS_TYPE: TypeAlias = dict[str, str] +EGTPATH_TYPE: TypeAlias = dict[str, str] +OPTIONS_GO_EGTB_TYPE: TypeAlias = dict[str, str | int | bool | None | EGTPATH_TYPE | GO_COMMANDS_TYPE] +OPTIONS_TYPE: TypeAlias = dict[str, str | int | bool | None] +HOMEMADE_ARGS_TYPE: TypeAlias = Limit | bool | MOVE + +# Types that still use `Any`. +CONFIG_DICT_TYPE: TypeAlias = dict[str, Any] + + +class PerfType(TypedDict, total=False): + """Type hint for `perf`.""" + + games: int + rating: int + rd: int + sd: int + prov: bool + prog: int + + +class ProfileType(TypedDict, total=False): + """Type hint for `profile`.""" + + country: str + location: str + bio: str + firstName: str + lastName: str + fideRating: int + uscfRating: int + ecfRating: int + cfcRating: int + dsbRating: int + links: str + + +class UserProfileType(TypedDict, total=False): + """Type hint for `user_profile`.""" + + id: str + username: str + perfs: dict[str, PerfType] + createdAt: int + profile: ProfileType + seenAt: int + patron: int + verified: int + playTime: dict[str, int] + title: str + online: bool + url: str + followable: bool + following: bool + blocking: bool + followsYou: bool + count: dict[str, int] + + +class ReadableType(TypedDict): + """Type hint for `readable`.""" + + Evaluation: Callable[[PovScore], str] + Winrate: Callable[[PovWdl], str] + Hashfull: Callable[[int], str] + Nodes: Callable[[int], str] + Speed: Callable[[int], str] + Tbhits: Callable[[int], str] + Cpuload: Callable[[int], str] + Movetime: Callable[[int], str] + + +class InfoStrDict(TypedDict, total=False): + """Type hints for the readable version of the information returned by chess engines.""" + + score: PovScore + pv: list[Move] + depth: int + seldepth: int + time: float + nodes: int + nps: int + tbhits: int + multipv: int + currmove: str | Move + currmovenumber: int + hashfull: int + cpuload: int + refutation: str | dict[Move, list[Move]] + currline: dict[int, list[Move]] + ebf: float + wdl: PovWdl + string: str + ponderpv: str + Source: str + Pv: str + + +InfoDictKeys: TypeAlias = Literal["score", "pv", "depth", "seldepth", "time", "nodes", "nps", "tbhits", "multipv", "currmove", + "currmovenumber", "hashfull", "cpuload", "refutation", "currline", "ebf", "wdl", "string", + "ponderpv", "Source", "Pv"] + + +InfoDictValue: TypeAlias = (PovScore | list[Move] | int | float | str | Move | dict[Move, list[Move]] | + dict[int, list[Move]] | PovWdl) + + +class PlayerType(TypedDict, total=False): + """Type hint for information on a player.""" + + title: str | None + rating: int + provisional: bool + aiLevel: int + id: str + username: str + name: str + online: bool + + +class GameType(TypedDict, total=False): + """Type hint for game.""" + + gameId: str + fullId: str + color: str + fen: str + hasMoved: bool + isMyTurn: bool + lastMove: str + opponent: PlayerType + perf: str + rated: bool + secondsLeft: int + source: str + status: dict[str, str | int] + speed: str + variant: dict[str, str] + compat: dict[str, bool] + id: str + winner: str + ratingDiff: int + pgn: str + complete: bool + + +class TimeControlType(TypedDict, total=False): + """Type hint for time control.""" + + increment: int + limit: int + show: str + type: str + daysPerTurn: int + initial: int + + +class ChallengeType(TypedDict, total=False): + """Type hint for challenge.""" + + id: str + url: str + color: str + direction: str + rated: bool + speed: str + status: str + timeControl: TimeControlType + variant: dict[str, str] + challenger: PlayerType + destUser: PlayerType + perf: dict[str, str] + compat: dict[str, bool] + finalColor: str + declineReason: str + declineReasonKey: str + initialFen: str + error: str + ratelimit: dict[str, str | int] + bot_is_rate_limited: bool + opponent_is_rate_limited: bool + rate_limit_timeout: datetime.timedelta + + +class EventType(TypedDict, total=False): + """Type hint for event.""" + + type: str + game: GameType + challenge: ChallengeType + error: str | None + + +class GameStateType(TypedDict, total=False): + """Type hint for game state.""" + + type: str + moves: str + wtime: int + btime: int + winc: int + binc: int + wdraw: bool + bdraw: bool + status: str + winner: str + + +class GameEventType(TypedDict, total=False): + """Type hint for game event.""" + + type: str + id: str + rated: bool + variant: dict[str, str] + cloak: dict[str, int] + speed: str + perf: dict[str, str] + createdAt: int + white: PlayerType + black: PlayerType + initialFen: str + state: GameStateType + username: str + text: str + room: str + gone: bool + claimWinInSeconds: int + moves: str + wtime: int + btime: int + winc: int + binc: int + wdraw: bool + bdraw: bool + status: str + winner: str + clock: TimeControlType + wtakeback: bool + btakeback: bool + + +CONTROL_QUEUE_TYPE: TypeAlias = Queue[EventType] +PGN_QUEUE_TYPE: TypeAlias = Queue[EventType | None] + + +class PublicDataType(TypedDict, total=False): + """Type hint for public data.""" + + id: str + username: str + perfs: dict[str, PerfType] + flair: str + createdAt: int + disabled: bool + tosViolation: bool + profile: ProfileType + seenAt: int + patron: bool + verified: bool + playTime: dict[str, int] + title: str + url: str + playing: str + count: dict[str, int] + streaming: bool + streamer: dict[str, dict[str, str]] + followable: bool + following: bool + blocking: bool + followsYou: bool + + +class FilterType(str, Enum): + """What to do if the opponent declines our challenge.""" + + NONE = "none" + """Will still challenge the opponent.""" + COARSE = "coarse" + """Won't challenge the opponent again.""" + FINE = "fine" + """ + Won't challenge the opponent to a game of the same mode, speed, and variant + based on the reason for the opponent declining the challenge. + """ + + +class ChessDBMoveType(TypedDict, total=False): + """Type hint for a move returned by chessdb (opening & egtb).""" + + uci: str + san: str + score: int + rank: int + note: str + winrate: str + + +class LichessPvType(TypedDict, total=False): + """Type hint for a move returned by lichess cloud analysis.""" + + moves: str + cp: int + + +class LichessExplorerGameType(TypedDict, total=False): + """Type hint for a game returned by lichess explorer.""" + + id: str + winner: str | None + speed: str + mode: str + black: dict[str, str | int] + white: dict[str, str | int] + year: int + month: str + + +class LichessEGTBMoveType(TypedDict): + """Type hint for the moves returned by the lichess egtb.""" + + uci: str + san: str + zeroing: bool + checkmate: bool + stalemate: bool + variant_win: bool + variant_loss: bool + insufficient_material: bool + dtz: int + precise_dtz: int | None + dtm: int | None + category: str + + +class OnlineMoveType(TypedDict): + """Type hint for a move returned by an online source.""" + + # chessdb + uci: str + san: str + score: int + rank: int + note: str + winrate: str + + # lichess explorer + # uci: str (duplicate) + # san: str (duplicate) + averageRating: int + performance: int + white: int + black: int + draws: int + game: LichessExplorerGameType | None + + # lichess egtb + # uci: str (duplicate) + # san: str (duplicate) + zeroing: bool + checkmate: bool + stalemate: bool + variant_win: bool + variant_loss: bool + insufficient_material: bool + dtz: int + precise_dtz: int | None + dtm: int | None + dtc: int + category: str + + +class OnlineType(TypedDict, total=False): + """Type hint for moves returned by an online source.""" + + # lichess egtb + checkmate: bool + stalemate: bool + variant_win: bool + variant_loss: bool + insufficient_material: bool + dtz: int + precise_dtz: int | None + dtm: int | None + dtc: int + category: str + # moves: list[LichessEGTBMoveType] + + # lichess explorer + white: int + black: int + draws: int + # moves: list[LichessExplorerMoveType] + topGames: list[LichessExplorerGameType] + recentGames: list[LichessExplorerGameType] + opening: dict[str, str] | None + queuePosition: int + + # lichess cloud + fen: str + knodes: int + ply: str + depth: int + pvs: list[LichessPvType] + error: str + + # chessdb (opening & egtb) + status: str + # moves: list[ChessDBMoveType] + # ply: str (duplicate) + score: int + # depth: int (duplicate) + pv: list[str] + pvSAN: list[str] + move: str + egtb: str + + # all + moves: list[OnlineMoveType] + + +class TokenTestType(TypedDict, total=False): + """Type hint for token test.""" + + scopes: str + userId: str + expires: int + + +TOKEN_TESTS_TYPE: TypeAlias = dict[str, TokenTestType] + + +class _BackoffDetails(TypedDict): + """`_Details` from `backoff._typing`.""" + + target: Callable[..., Any] + args: tuple[Any, ...] + kwargs: dict[str, Any] + tries: int + elapsed: float + + +class BackoffDetails(_BackoffDetails, total=False): + """`Details` from `backoff._typing`.""" + + wait: float # present in the on_backoff handler case for either decorator + value: Any # present in the on_predicate decorator case + + +ENGINE_INPUT_ARGS_TYPE: TypeAlias = (OPTIONS_TYPE | type[BaseException] | BaseException | TracebackType | Board | Limit | + str | bool | None) +ENGINE_INPUT_KWARGS_TYPE: TypeAlias = int | bool | list[Move] | Opponent | None diff --git a/lichess-bot-master/lib/matchmaking.py b/lichess-bot-master/lib/matchmaking.py new file mode 100644 index 0000000000000000000000000000000000000000..3570a1cef2726d30f73a789812fdeb26edd36476 --- /dev/null +++ b/lichess-bot-master/lib/matchmaking.py @@ -0,0 +1,361 @@ +"""Challenge other bots.""" +import random +import logging +import datetime +import contextlib +from lib import model +from lib.timer import Timer, days, seconds, minutes, years +from collections import defaultdict +from collections.abc import Sequence +from lib.lichess import Lichess, RateLimitedError +from lib.config import Configuration +from typing import cast, TypeAlias +from lib.blocklist import OnlineBlocklist +from lib.lichess_types import UserProfileType, PerfType, EventType, FilterType, ChallengeType +MULTIPROCESSING_LIST_TYPE: TypeAlias = Sequence[model.Challenge] + +logger = logging.getLogger(__name__) + + +class Matchmaking: + """Challenge other bots.""" + + def __init__(self, li: Lichess, config: Configuration, user_profile: UserProfileType) -> None: + """Initialize values needed for matchmaking.""" + self.li = li + self.variants = list(filter(lambda variant: variant != "fromPosition", config.challenge.variants)) + self.matchmaking_cfg = config.matchmaking + self.user_profile = user_profile + self.last_challenge_created_delay = Timer(seconds(25)) # Challenges expire after 20 seconds. + self.last_game_ended_delay = Timer(minutes(self.matchmaking_cfg.challenge_timeout)) + self.last_user_profile_update_time = Timer(minutes(5)) + self.min_wait_time = seconds(60) # Wait before new challenge to avoid api rate limits. + self.rate_limit_timer = Timer() + + # Maximum time between challenges, even if there are active games + self.max_wait_time = minutes(10) if self.matchmaking_cfg.allow_during_games else years(10) + self.challenge_id = "" + + # (opponent name, game aspect) --> other bot is likely to accept challenge + # game aspect is the one the challenged bot objects to and is one of: + # - game speed (bullet, blitz, etc.) + # - variant (standard, horde, etc.) + # - casual/rated + # - empty string (if no other reason is given or self.filter_type is COARSE) + self.challenge_type_acceptable: defaultdict[tuple[str, str], Timer] = defaultdict(Timer) + self.challenge_filter = self.matchmaking_cfg.challenge_filter + + for name in self.matchmaking_cfg.block_list: + self.add_to_block_list(name) + + self.online_block_list = OnlineBlocklist(self.matchmaking_cfg.online_block_list) + + def should_create_challenge(self) -> bool: + """Whether we should create a challenge.""" + matchmaking_enabled = self.matchmaking_cfg.allow_matchmaking + time_has_passed = self.last_game_ended_delay.is_expired() and self.rate_limit_timer.is_expired() + challenge_expired = self.last_challenge_created_delay.is_expired() and self.challenge_id + min_wait_time_passed = self.last_challenge_created_delay.time_since_reset() > self.min_wait_time + if challenge_expired: + self.li.cancel(self.challenge_id) + logger.info(f"Challenge id {self.challenge_id} cancelled.") + self.discard_challenge(self.challenge_id) + self.show_earliest_challenge_time() + return bool(matchmaking_enabled and (time_has_passed or challenge_expired) and min_wait_time_passed) + + def create_challenge(self, username: str, base_time: int, increment: int, days: int, variant: str, + mode: str) -> str: + """Create a challenge.""" + params: dict[str, str | int | bool] = {"rated": mode == "rated", "variant": variant} + + if days: + params["days"] = days + elif base_time or increment: + params["clock.limit"] = base_time + params["clock.increment"] = increment + else: + logger.error("At least one of challenge_days, challenge_initial_time, or challenge_increment " + "must be greater than zero in the matchmaking section of your config file.") + return "" + + try: + self.last_challenge_created_delay.reset() + response = self.li.challenge(username, params) + challenge_id = response.get("id", "") + if not challenge_id: + self.handle_challenge_error_response(response, username) + return challenge_id + except RateLimitedError as e: + logger.warning(e) + self.rate_limit_timer = Timer(e.timeout) + except Exception as e: + logger.debug(e, exc_info=e) + + logger.warning("Could not create challenge") + self.show_earliest_challenge_time() + return "" + + def handle_challenge_error_response(self, response: ChallengeType, username: str) -> None: + """If a challenge fails, print the error and adjust the challenge requirements in response.""" + logger.error(response) + if response.get("bot_is_rate_limited"): + timeout = cast(datetime.timedelta, response.get("rate_limit_timeout")) + self.rate_limit_timer = Timer(timeout) + elif response.get("opponent_is_rate_limited"): + self.add_challenge_filter(username, "", response.get("rate_limit_timeout")) + else: + self.add_challenge_filter(username, "") + self.show_earliest_challenge_time() + + def perf(self) -> dict[str, PerfType]: + """Get the bot's rating in every variant. Bullet, blitz, rapid etc. are considered different variants.""" + user_perf: dict[str, PerfType] = self.user_profile["perfs"] + return user_perf + + def username(self) -> str: + """Our username.""" + username: str = self.user_profile["username"] + return username + + def update_user_profile(self) -> None: + """Update our user profile data, to get our latest rating.""" + if self.last_user_profile_update_time.is_expired(): + self.last_user_profile_update_time.reset() + with contextlib.suppress(Exception): + self.user_profile = self.li.get_profile() + + def get_weights(self, online_bots: list[UserProfileType], rating_preference: str, min_rating: int, max_rating: int, + game_type: str) -> list[int]: + """Get the weight for each bot. A higher weights means the bot is more likely to get challenged.""" + def rating(bot: UserProfileType) -> int: + perfs: dict[str, PerfType] = bot.get("perfs", {}) + perf: PerfType = perfs.get(game_type, {}) + return perf.get("rating", 0) + + if rating_preference == "high": + # A bot with max_rating rating will be twice as likely to get picked than a bot with min_rating rating. + reduce_ratings_by = min(min_rating - (max_rating - min_rating), min_rating - 1) + weights = [rating(bot) - reduce_ratings_by for bot in online_bots] + elif rating_preference == "low": + # A bot with min_rating rating will be twice as likely to get picked than a bot with max_rating rating. + reduce_ratings_by = max(max_rating - (min_rating - max_rating), max_rating + 1) + weights = [reduce_ratings_by - rating(bot) for bot in online_bots] + else: + weights = [1] * len(online_bots) + return weights + + def choose_opponent(self) -> tuple[str | None, int, int, int, str, str]: + """Choose an opponent.""" + override_choice = random.choice(self.matchmaking_cfg.overrides.keys() + [None]) + logger.info(f"Using the {override_choice or 'default'} matchmaking configuration.") + override = {} if override_choice is None else self.matchmaking_cfg.overrides.lookup(override_choice) + match_config = self.matchmaking_cfg | override + + variant = self.get_random_config_value(match_config, "challenge_variant", self.variants) + mode = self.get_random_config_value(match_config, "challenge_mode", ["casual", "rated"]) + rating_preference = match_config.rating_preference + + base_time = random.choice(match_config.challenge_initial_time) + increment = random.choice(match_config.challenge_increment) + num_days = random.choice(match_config.challenge_days) + + play_correspondence = [bool(num_days), not bool(base_time or increment)] + if random.choice(play_correspondence): + base_time = 0 + increment = 0 + else: + num_days = 0 + + game_type = game_category(variant, base_time, increment, num_days) + + min_rating = match_config.opponent_min_rating + max_rating = match_config.opponent_max_rating + rating_diff = match_config.opponent_rating_difference + bot_rating = self.perf().get(game_type, {}).get("rating", 0) + if rating_diff is not None and bot_rating > 0: + min_rating = bot_rating - rating_diff + max_rating = bot_rating + rating_diff + logger.info(f"Seeking {game_type} game with opponent rating in [{min_rating}, {max_rating}] ...") + + def is_suitable_opponent(bot: UserProfileType) -> bool: + perf = bot.get("perfs", {}).get(game_type, {}) + return (bot["username"] != self.username() + and not self.in_block_list(bot["username"]) + and perf.get("games", 0) > 0 + and min_rating <= perf.get("rating", 0) <= max_rating) + + self.online_block_list.refresh() + online_bots = self.li.get_online_bots() + online_bots = list(filter(is_suitable_opponent, online_bots)) + + def ready_for_challenge(bot: UserProfileType) -> bool: + aspects = [variant, game_type, mode] if self.challenge_filter == FilterType.FINE else [] + return all(self.should_accept_challenge(bot["username"], aspect) for aspect in aspects) + + ready_bots = list(filter(ready_for_challenge, online_bots)) + online_bots = ready_bots or online_bots + bot_username = None + weights = self.get_weights(online_bots, rating_preference, min_rating, max_rating, game_type) + + try: + bot = random.choices(online_bots, weights=weights)[0] + bot_profile = self.li.get_public_data(bot["username"]) + if bot_profile.get("blocking"): + self.add_to_block_list(bot["username"]) + else: + bot_username = bot["username"] + except Exception: + if online_bots: + logger.exception("Error:") + else: + logger.error("No suitable bots found to challenge.") + + return bot_username, base_time, increment, num_days, variant, mode + + def get_random_config_value(self, config: Configuration, parameter: str, choices: list[str]) -> str: + """Choose a random value from `choices` if the parameter value in the config is `random`.""" + value: str = config.lookup(parameter) + return value if value != "random" else random.choice(choices) + + def challenge(self, active_games: set[str], challenge_queue: MULTIPROCESSING_LIST_TYPE, max_games: int) -> None: + """ + Challenge an opponent. + + :param active_games: The games that the bot is playing. + :param challenge_queue: The queue containing the challenges. + :param max_games: The maximum allowed number of simultaneous games. + """ + max_games_for_matchmaking = max_games if self.matchmaking_cfg.allow_during_games else min(1, max_games) + game_count = len(active_games) + len(challenge_queue) + if (game_count >= max_games_for_matchmaking + or (game_count > 0 and self.last_challenge_created_delay.time_since_reset() < self.max_wait_time) + or not self.should_create_challenge()): + return + + logger.info("Challenging a random bot") + self.update_user_profile() + bot_username, base_time, increment, days, variant, mode = self.choose_opponent() + logger.info(f"Will challenge {bot_username} for a {variant} game.") + challenge_id = self.create_challenge(bot_username, base_time, increment, days, variant, mode) if bot_username else "" + logger.info(f"Challenge id is {challenge_id or 'None'}.") + self.challenge_id = challenge_id + + def discard_challenge(self, challenge_id: str) -> None: + """ + Clear the ID of the most recent challenge if it is no longer needed. + + :param challenge_id: The ID of the challenge that is expired, accepted, or declined. + """ + if self.challenge_id == challenge_id: + self.challenge_id = "" + + def game_done(self) -> None: + """Reset the timer for when the last game ended, and prints the earliest that the next challenge will be created.""" + self.last_game_ended_delay.reset() + self.show_earliest_challenge_time() + + def show_earliest_challenge_time(self) -> None: + """Show the earliest that the next challenge will be created.""" + if self.matchmaking_cfg.allow_matchmaking: + postgame_timeout = self.last_game_ended_delay.time_until_expiration() + time_to_next_challenge = self.min_wait_time - self.last_challenge_created_delay.time_since_reset() + rate_limit_delay = self.rate_limit_timer.time_until_expiration() + time_left = max(postgame_timeout, time_to_next_challenge, rate_limit_delay) + earliest_challenge_time = datetime.datetime.now() + time_left + logger.info(f"Next challenge will be created after {earliest_challenge_time.strftime('%c')}") + + def add_to_block_list(self, username: str) -> None: + """Add a bot to the blocklist.""" + self.add_challenge_filter(username, "", years(10)) + + def in_block_list(self, username: str) -> bool: + """Check if an opponent is in the block list to prevent future challenges.""" + return (not self.should_accept_challenge(username, "")) or username in self.online_block_list + + def add_challenge_filter(self, username: str, game_aspect: str, timeout: datetime.timedelta | None = None) -> None: + """ + Prevent creating another challenge for a timeout when an opponent has declined a challenge. + + :param username: The name of the opponent. + :param game_aspect: The aspect of a game (time control, chess variant, etc.) that caused the opponent to decline a + challenge. If the parameter is empty, that is equivalent to adding the opponent to the block list. + :param timeout: The amount of time to not challenge an opponent. If None, the default is a day. + """ + self.challenge_type_acceptable[(username, game_aspect)] = Timer(timeout or days(1)) + + def should_accept_challenge(self, username: str, game_aspect: str) -> bool: + """ + Whether a bot is likely to accept a challenge to a game. + + :param username: The name of the opponent. + :param game_aspect: A category of the challenge type (time control, chess variant, etc.) to test for acceptance. + If game_aspect is empty, this is equivalent to checking if the opponent is in the block list. + """ + return self.challenge_type_acceptable[(username, game_aspect)].is_expired() + + def accepted_challenge(self, event: EventType) -> None: + """ + Set the challenge id to an empty string, if the challenge was accepted. + + Otherwise, we would attempt to cancel the challenge later. + """ + self.discard_challenge(event["game"]["id"]) + + def declined_challenge(self, event: EventType) -> None: + """ + Handle a challenge that was declined by the opponent. + + Depends on whether `FilterType` is `NONE`, `COARSE`, or `FINE`. + """ + challenge = model.Challenge(event["challenge"], self.user_profile) + opponent = challenge.challenge_target + reason = event["challenge"]["declineReason"] + logger.info(f"{opponent} declined {challenge}: {reason}") + self.discard_challenge(challenge.id) + if not challenge.from_self or self.challenge_filter == FilterType.NONE: + return + + mode = "rated" if challenge.rated else "casual" + decline_details: dict[str, str] = {"generic": "", + "later": "", + "nobot": "", + "toofast": challenge.speed, + "tooslow": challenge.speed, + "timecontrol": challenge.speed, + "rated": mode, + "casual": mode, + "standard": challenge.variant, + "variant": challenge.variant} + + reason_key = event["challenge"]["declineReasonKey"].lower() + if reason_key not in decline_details: + logger.warning(f"Unknown decline reason received: {reason_key}") + game_problem = decline_details.get(reason_key, "") if self.challenge_filter == FilterType.FINE else "" + self.add_challenge_filter(opponent.name, game_problem) + logger.info(f"Will not challenge {opponent} to another {game_problem}".strip() + " game today.") + + self.show_earliest_challenge_time() + + +def game_category(variant: str, base_time: int, increment: int, num_days: int) -> str: + """ + Get the game type (e.g. bullet, atomic, classical). Lichess has one rating for every variant regardless of time control. + + :param variant: The game's variant. + :param base_time: The base time in seconds. + :param increment: The increment in seconds. + :param num_days: If the game is correspondence, we have some days to play the move. + :return: The game category. + """ + game_duration = base_time + increment * 40 + if variant != "standard": + return variant + if num_days: + return "correspondence" + if game_duration < 179: + return "bullet" + if game_duration < 479: + return "blitz" + if game_duration < 1499: + return "rapid" + return "classical" diff --git a/lichess-bot-master/lib/model.py b/lichess-bot-master/lib/model.py new file mode 100644 index 0000000000000000000000000000000000000000..e58d7f3a83dc162c2f24e28830c1dfece457d828 --- /dev/null +++ b/lichess-bot-master/lib/model.py @@ -0,0 +1,329 @@ +"""Store information about a challenge, game or player in a class.""" +import math +from urllib.parse import urljoin +import logging +import datetime +import chess +from enum import Enum +from lib.blocklist import OnlineBlocklist +from lib.timer import Timer, msec, seconds, sec_str, to_msec, to_seconds, years +from lib.config import Configuration +from collections import defaultdict, Counter +from lib.lichess_types import UserProfileType, ChallengeType, GameEventType, PlayerType + +logger = logging.getLogger(__name__) + + +def is_chess_960(fen: str) -> bool: + """Determine whether an FEN string represents a chess960 game.""" + return chess.Board(fen) != chess.Board(fen, chess960=True) + + +class Challenge: + """Store information about a challenge.""" + + def __init__(self, challenge_info: ChallengeType, user_profile: UserProfileType) -> None: + """:param user_profile: Information about our bot.""" + self.id = challenge_info["id"] + self.rated = challenge_info["rated"] + self.variant = challenge_info["variant"]["key"] + self.perf_name = challenge_info["perf"]["name"] + self.speed = challenge_info["speed"] + self.increment = challenge_info.get("timeControl", {}).get("increment") + self.base = challenge_info.get("timeControl", {}).get("limit") + self.days = challenge_info.get("timeControl", {}).get("daysPerTurn") + self.challenger = Player(challenge_info.get("challenger") or {}) + self.challenge_target = Player(challenge_info.get("destUser") or {}) + self.from_self = self.challenger.name == user_profile["username"] + self.initial_fen = challenge_info.get("initialFen", "startpos") + color = challenge_info["color"] + self.color = color if color != "random" else challenge_info["finalColor"] + self.time_control = challenge_info["timeControl"] + + def is_supported_variant(self, challenge_cfg: Configuration) -> bool: + """Check whether the variant is supported.""" + if self.variant not in challenge_cfg.variants: + return False + + if self.initial_fen == "startpos": + return True + + if is_chess_960(self.initial_fen): + return "chess960" in challenge_cfg.variants + + return True + + def is_supported_time_control(self, challenge_cfg: Configuration) -> bool: + """Check whether the time control is supported.""" + speeds = challenge_cfg.time_controls + increment_max: int = challenge_cfg.max_increment + increment_min: int = challenge_cfg.min_increment + base_max: int = challenge_cfg.max_base + base_min: int = challenge_cfg.min_base + days_max: float = challenge_cfg.max_days + days_min: float = challenge_cfg.min_days + + if self.speed not in speeds: + return False + + require_non_zero_increment = (self.challenger.is_bot + and self.speed == "bullet" + and challenge_cfg.bullet_requires_increment) + increment_min = max(increment_min, 1 if require_non_zero_increment else 0) + + if self.base is not None and self.increment is not None: + # Normal clock game + return (increment_min <= self.increment <= increment_max + and base_min <= self.base <= base_max) + elif self.days is not None: + # Correspondence game + return days_min <= self.days <= days_max + else: + # Unlimited game + return days_max == math.inf + + def is_supported_mode(self, challenge_cfg: Configuration) -> bool: + """Check whether the mode is supported.""" + return ("rated" if self.rated else "casual") in challenge_cfg.modes + + def is_supported_rating(self, challenge_cfg: Configuration, user_profile: UserProfileType) -> bool: + """Check whether the challenger's rating is within the acceptable range.""" + challenger_rating = self.challenger.rating + if challenger_rating is None: + return True + + min_rating: int = challenge_cfg.min_rating + max_rating: int = challenge_cfg.max_rating + rating_diff: int | None = challenge_cfg.rating_difference + + if rating_diff is not None: + bot_rating = user_profile.get("perfs", {}).get(self.perf_name.lower(), {}).get("rating") + if bot_rating: + min_rating = max(min_rating, bot_rating - rating_diff) + max_rating = min(max_rating, bot_rating + rating_diff) + + return min_rating <= challenger_rating <= max_rating + + def is_supported_recent(self, config: Configuration, recent_bot_challenges: defaultdict[str, list[Timer]]) -> bool: + """Check whether we have played a lot of games with this opponent recently. Only used when the opponent is a BOT.""" + # Filter out old challenges + recent_bot_challenges[self.challenger.name] = [timer for timer + in recent_bot_challenges[self.challenger.name] + if not timer.is_expired()] + max_recent_challenges = config.max_recent_bot_challenges + return (not self.challenger.is_bot + or max_recent_challenges is None + or len(recent_bot_challenges[self.challenger.name]) < max_recent_challenges) + + def decline_due_to(self, requirement_met: bool, decline_reason: str) -> str: + """ + Get the reason lichess-bot declined an incoming challenge. + + :param requirement_met: Whether a requirement is met. + :param decline_reason: The reason we declined the challenge if the requirement wasn't met. + :return: `decline_reason` if `requirement_met` is false else returns an empty string. + """ + return "" if requirement_met else decline_reason + + def is_supported(self, config: Configuration, recent_bot_challenges: defaultdict[str, list[Timer]], + opponent_engagements: Counter[str], online_block_list: OnlineBlocklist, + user_profile: UserProfileType) -> tuple[bool, str]: + """Whether the challenge is supported.""" + try: + if self.from_self: + return True, "" + + from extra_game_handlers import is_supported_extra + + allowed_opponents: list[str] = list(filter(None, config.allow_list)) or [self.challenger.name] + decline_reason = (self.decline_due_to(config.accept_bot or not self.challenger.is_bot, "noBot") + or self.decline_due_to(not config.only_bot or self.challenger.is_bot, "onlyBot") + or self.decline_due_to(self.is_supported_time_control(config), "timeControl") + or self.decline_due_to(self.is_supported_variant(config), "variant") + or self.decline_due_to(self.is_supported_mode(config), "casual" if self.rated else "rated") + or self.decline_due_to(self.is_supported_rating(config, user_profile), "generic") + or self.decline_due_to(self.challenger.name not in config.block_list, "generic") + or self.decline_due_to(self.challenger.name not in online_block_list, "generic") + or self.decline_due_to(self.challenger.name in allowed_opponents, "generic") + or self.decline_due_to(self.is_supported_recent(config, recent_bot_challenges), "later") + or self.decline_due_to(opponent_engagements[self.challenger.name] + < config.max_simultaneous_games_per_user, "later") + or self.decline_due_to(is_supported_extra(self), "generic")) + + return not decline_reason, decline_reason + + except Exception: + logger.exception(f"Error while checking challenge {self.id}:") + return False, "generic" + + def score(self) -> int: + """Give a rating estimate to the opponent.""" + rated_bonus = 200 if self.rated else 0 + challenger_master_title = self.challenger.title if not self.challenger.is_bot else None + titled_bonus = 200 if challenger_master_title else 0 + challenger_rating_int = self.challenger.rating or 0 + return challenger_rating_int + rated_bonus + titled_bonus + + def mode(self) -> str: + """Get the mode of the challenge (rated or casual).""" + return "rated" if self.rated else "casual" + + def __str__(self) -> str: + """Get a string representation of `Challenge`.""" + return f"{self.perf_name} {self.mode()} challenge from {self.challenger} ({self.id})" + + def __repr__(self) -> str: + """Get a string representation of `Challenge`.""" + return self.__str__() + + +class Termination(str, Enum): + """The possible game terminations.""" + + MATE = "mate" + TIMEOUT = "outoftime" + RESIGN = "resign" + ABORT = "aborted" + DRAW = "draw" + + +class Game: + """Store information about a game.""" + + def __init__(self, game_info: GameEventType, username: str, base_url: str, abort_time: datetime.timedelta) -> None: + """:param abort_time: How long to wait before aborting the game.""" + self.username = username + self.id = game_info["id"] + self.speed = game_info.get("speed") + clock = game_info.get("clock") or {} + ten_years_in_ms = to_msec(years(10)) + self.clock_initial = msec(clock.get("initial", ten_years_in_ms)) + self.clock_increment = msec(clock.get("increment", 0)) + self.perf_name = (game_info.get("perf") or {}).get("name", "{perf?}") + self.variant_name = game_info["variant"]["name"] + self.mode = "rated" if game_info.get("rated") else "casual" + self.white = Player(game_info["white"]) + self.black = Player(game_info["black"]) + self.initial_fen = game_info.get("initialFen") + self.state = game_info["state"] + self.is_white = (self.white.name or "").lower() == username.lower() + self.my_color = "white" if self.is_white else "black" + self.opponent_color = "black" if self.is_white else "white" + self.me = self.white if self.is_white else self.black + self.opponent = self.black if self.is_white else self.white + self.base_url = base_url + self.game_start = datetime.datetime.fromtimestamp(to_seconds(msec(game_info["createdAt"])), + tz=datetime.timezone.utc) + self.abort_time = Timer(abort_time) + self.terminate_time = Timer(self.clock_initial + self.clock_increment + abort_time + seconds(60)) + self.disconnect_time = Timer(seconds(0)) + + def url(self) -> str: + """Get the url of the game.""" + return f"{self.short_url()}/{self.my_color}" + + def short_url(self) -> str: + """Get the short url of the game.""" + return urljoin(self.base_url, self.id) + + def pgn_event(self) -> str: + """Get the event to write in the PGN file.""" + if self.variant_name in ["Standard", "From Position"]: + return f"{self.mode.title()} {self.perf_name.title()} game" + else: + return f"{self.mode.title()} {self.variant_name} game" + + def time_control(self) -> str: + """Get the time control of the game.""" + return f"{sec_str(self.clock_initial)}+{sec_str(self.clock_increment)}" + + def is_abortable(self) -> bool: + """Whether the game can be aborted.""" + # Moves are separated by spaces. A game is abortable when less + # than two moves (one from each player) have been played. + return " " not in self.state["moves"] + + def ping(self, abort_in: datetime.timedelta, terminate_in: datetime.timedelta, disconnect_in: datetime.timedelta) -> None: + """ + Tell the bot when to abort, terminate, and disconnect from a game. + + :param abort_in: How many seconds to wait before aborting. + :param terminate_in: How many seconds to wait before terminating. + :param disconnect_in: How many seconds to wait before disconnecting. + """ + if self.is_abortable(): + self.abort_time = Timer(abort_in) + self.terminate_time = Timer(terminate_in) + self.disconnect_time = Timer(disconnect_in) + + def should_abort_now(self) -> bool: + """Whether we should abort the game.""" + return self.is_abortable() and self.abort_time.is_expired() + + def should_terminate_now(self) -> bool: + """Whether we should terminate the game.""" + return self.terminate_time.is_expired() + + def should_disconnect_now(self) -> bool: + """Whether we should disconnect form the game.""" + return self.disconnect_time.is_expired() + + def my_remaining_time(self) -> datetime.timedelta: + """How many seconds we have left.""" + wtime = msec(self.state["wtime"]) + btime = msec(self.state["btime"]) + return wtime if self.is_white else btime + + def result(self) -> str: + """Get the result of the game.""" + class GameEnding(str, Enum): + WHITE_WINS = "1-0" + BLACK_WINS = "0-1" + DRAW = "1/2-1/2" + INCOMPLETE = "*" + + winner = self.state.get("winner") + termination = self.state.get("status") + + if winner == "white": + result = GameEnding.WHITE_WINS + elif winner == "black": + result = GameEnding.BLACK_WINS + elif termination in [Termination.DRAW, Termination.TIMEOUT]: + result = GameEnding.DRAW + else: + result = GameEnding.INCOMPLETE + + return result.value + + def __str__(self) -> str: + """Get a string representation of `Game`.""" + return f"{self.url()} {self.perf_name} vs {self.opponent} ({self.id})" + + def __repr__(self) -> str: + """Get a string representation of `Game`.""" + return self.__str__() + + +class Player: + """Store information about a player.""" + + def __init__(self, player_info: PlayerType) -> None: + """:param player_info: Contains information about a player.""" + self.title = player_info.get("title") + self.rating = player_info.get("rating") + self.provisional = player_info.get("provisional") + self.aiLevel = player_info.get("aiLevel") + self.is_bot = self.title == "BOT" or self.aiLevel is not None + self.name = f"AI level {self.aiLevel}" if self.aiLevel else player_info.get("name", "") + + def __str__(self) -> str: + """Get a string representation of `Player`.""" + if self.aiLevel: + return self.name + rating = f'{self.rating}{"?" if self.provisional else ""}' + return f'{self.title or ""} {self.name} ({rating})'.strip() + + def __repr__(self) -> str: + """Get a string representation of `Player`.""" + return self.__str__() diff --git a/lichess-bot-master/lib/timer.py b/lichess-bot-master/lib/timer.py new file mode 100644 index 0000000000000000000000000000000000000000..28d0f6a1e54b9e36cdeb715f0aed4d9bbf3aa76e --- /dev/null +++ b/lichess-bot-master/lib/timer.py @@ -0,0 +1,98 @@ +"""A timer for use in lichess-bot.""" + +from datetime import timedelta +from time import perf_counter + + +def msec(time_in_msec: float) -> timedelta: + """Create a timedelta duration in milliseconds.""" + return timedelta(milliseconds=time_in_msec) + + +def to_msec(duration: timedelta) -> float: + """Return a bare number representing the length of the duration in milliseconds.""" + return duration / msec(1) + + +def msec_str(duration: timedelta) -> str: + """Return a string with the duration value in whole number milliseconds.""" + return str(round(to_msec(duration))) + + +def seconds(time_in_sec: float) -> timedelta: + """Create a timedelta duration in seconds.""" + return timedelta(seconds=time_in_sec) + + +def to_seconds(duration: timedelta) -> float: + """Return a bare number representing the length of the duration in seconds.""" + return duration.total_seconds() + + +def sec_str(duration: timedelta) -> str: + """Return a string with the duration value in whole number seconds.""" + return str(round(to_seconds(duration))) + + +def minutes(time_in_minutes: float) -> timedelta: + """Create a timedelta duration in minutes.""" + return timedelta(minutes=time_in_minutes) + + +def hours(time_in_hours: float) -> timedelta: + """Create a timedelta duration in hours.""" + return timedelta(hours=time_in_hours) + + +def days(time_in_days: float) -> timedelta: + """Create a timedelta duration in days.""" + return timedelta(days=time_in_days) + + +def years(time_in_years: float) -> timedelta: + """Create a timedelta duration in median years--i.e., 365 days.""" + return days(365) * time_in_years + + +zero_seconds = seconds(0) + + +class Timer: + """ + A timer for use in lichess-bot. An instance of timer can be used both as a countdown timer and a stopwatch. + + If the duration argument in the __init__() method is greater than zero, then + the method is_expired() indicates when the initial duration has passed. The + method time_until_expiration() gives the amount of time left until the timer + expires. + + Regardless of the initial duration (even if it's zero), a timer can be used + as a stopwatch by calling time_since_reset() to get the amount of time since + the timer was created or since it was last reset. + """ + + def __init__(self, duration: timedelta = zero_seconds) -> None: + """ + Start the timer. + + :param duration: The duration of time before Timer.is_expired() returns True. + :param backdated_timestamp: When the timer should have started. Used to keep the timers between sessions. + """ + self.duration = duration + self.starting_time = perf_counter() + + def is_expired(self) -> bool: + """Check if a timer is expired.""" + return self.time_since_reset() >= self.duration + + def reset(self) -> None: + """Reset the timer.""" + self.starting_time = perf_counter() + + def time_since_reset(self) -> timedelta: + """How much time has passed.""" + return seconds(perf_counter() - self.starting_time) + + def time_until_expiration(self) -> timedelta: + """How much time is left until it expires.""" + return max(seconds(0), self.duration - self.time_since_reset()) diff --git a/lichess-bot-master/lib/versioning.yml b/lichess-bot-master/lib/versioning.yml new file mode 100644 index 0000000000000000000000000000000000000000..9276dbe6625cdcf2f14864219a6d9fd361a2fa4d --- /dev/null +++ b/lichess-bot-master/lib/versioning.yml @@ -0,0 +1,4 @@ +lichess_bot_version: 2026.5.19.1 +minimum_python_version: '3.10' +deprecated_python_version: '3.9' +deprecation_date: 2025-10-05 diff --git a/lichess-bot-master/lichess-bot.py b/lichess-bot-master/lichess-bot.py new file mode 100644 index 0000000000000000000000000000000000000000..770ad25b1f2f0a739414baef3c766f2a717d5d75 --- /dev/null +++ b/lichess-bot-master/lichess-bot.py @@ -0,0 +1,5 @@ +"""Starting point for lichess-bot.""" +from lib.lichess_bot import start_program + +if __name__ == "__main__": + start_program() diff --git a/lichess-bot-master/requirements.txt b/lichess-bot-master/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..f3d16453b85f6fe19678d14c95844919e95f21dc --- /dev/null +++ b/lichess-bot-master/requirements.txt @@ -0,0 +1,5 @@ +chess~=1.11 +PyYAML~=6.0 +requests~=2.34 +backoff~=2.2 +rich~=15.0 diff --git a/lichess-bot-master/test_bot/__init__.py b/lichess-bot-master/test_bot/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7b5e963f5bd6ea0456cc4e5dc57c65fe13842829 --- /dev/null +++ b/lichess-bot-master/test_bot/__init__.py @@ -0,0 +1 @@ +"""pytest won't search `test_bot/` if there is no `__init__.py` file.""" diff --git a/lichess-bot-master/test_bot/buggy_engine.py b/lichess-bot-master/test_bot/buggy_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..e1dbf039749096285b328d268d0f8dbaa0b63256 --- /dev/null +++ b/lichess-bot-master/test_bot/buggy_engine.py @@ -0,0 +1,52 @@ +"""An engine that takes too much time to make a move during tests.""" + +import chess +import time +import typing + +if typing.TYPE_CHECKING: + from test_bot.test_games import scholars_mate +else: + from test_games import scholars_mate + +assert input() == "uci" + + +def send_command(command: str) -> None: + """Send UCI commands to lichess-bot without output buffering.""" + print(command, flush=True) # noqa: T201 (print() found) + + +send_command("id name Procrastinator") +send_command("id author lichess-bot-devs") +send_command("uciok") + +delay_performed = False +just_started = True +board = chess.Board() +while True: + command, *remaining = input().split() + if command == "quit": + break + elif command == "isready": + send_command("readyok") + elif command == "position": + spec_type, *remaining = remaining + assert spec_type == "startpos" + board = chess.Board() + if remaining: + moves_label, *move_list = remaining + assert moves_label == "moves" + for move in move_list: + board.push_uci(move) + if just_started and len(board.move_stack) > 1: + delay_performed = True + elif command == "go": + move_count = len(board.move_stack) + if move_count == 3 and not delay_performed: + send_command("info string delaying move") + delay_performed = True + time.sleep(11) + move = scholars_mate[move_count] + send_command(f"bestmove {move}") + just_started = False diff --git a/lichess-bot-master/test_bot/conftest.py b/lichess-bot-master/test_bot/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..ba74a1b7af8051cb7f79012bfb68dd8caaec7397 --- /dev/null +++ b/lichess-bot-master/test_bot/conftest.py @@ -0,0 +1,15 @@ +"""Remove files created when testing lichess-bot.""" +import shutil +import os +from _pytest.config import ExitCode +from _pytest.main import Session + + +def pytest_sessionfinish(session: Session, exitstatus: int | ExitCode) -> None: # noqa: ARG001 + """ + Remove files created when testing lichess-bot. + + The only exception is if running in a GitHub action, in which case we save the engines to the cache. + """ + if os.path.exists("TEMP") and not os.getenv("GITHUB_ACTIONS"): + shutil.rmtree("TEMP") diff --git a/lichess-bot-master/test_bot/homemade.py b/lichess-bot-master/test_bot/homemade.py new file mode 100644 index 0000000000000000000000000000000000000000..45a9176ebedbf1f0a3b169aa304e9af82fdc9d11 --- /dev/null +++ b/lichess-bot-master/test_bot/homemade.py @@ -0,0 +1,25 @@ +"""Homemade engine playing scholar's mate.""" +from homemade import ExampleEngine +import chess +import chess.engine +from lib.config import Configuration +from lib import model +from lib.lichess_types import OPTIONS_GO_EGTB_TYPE, COMMANDS_TYPE, MOVE +from test_bot.test_games import scholars_mate + +# ruff: noqa: ARG002 + +class ScholarsMate(ExampleEngine): + """A homemade engine that plays the scholar's mate.""" + + def __init__(self, commands: COMMANDS_TYPE, options: OPTIONS_GO_EGTB_TYPE, stderr: int | None, + draw_or_resign: Configuration, game: model.Game | None, debug: bool, **popen_args: str) -> None: + """Set up engine.""" + super().__init__(commands, options, stderr, draw_or_resign, game, debug, **popen_args) + + def search(self, board: chess.Board, time_limit: chess.engine.Limit, ponder: bool, draw_offered: bool, + root_moves: MOVE) -> chess.engine.PlayResult: + """Get the next scholar's mate move.""" + move_number = len(board.move_stack) + move = board.parse_uci(scholars_mate[move_number]) + return chess.engine.PlayResult(move, None) diff --git a/lichess-bot-master/test_bot/lichess.py b/lichess-bot-master/test_bot/lichess.py new file mode 100644 index 0000000000000000000000000000000000000000..17b7662963f640d0765273366e5288c537c77caf --- /dev/null +++ b/lichess-bot-master/test_bot/lichess.py @@ -0,0 +1,262 @@ +"""Imitate `lichess.py`. Used in tests.""" +from __future__ import annotations +import time +import chess.engine +import json +import logging +import traceback +import datetime +from queue import Queue +from requests.models import Response +from collections.abc import Generator +from lib.lichess import Lichess as OriginalLichess +from lib.timer import to_msec +from lib.lichess_types import (UserProfileType, ChallengeType, REQUESTS_PAYLOAD_TYPE, GameType, OnlineType, PublicDataType, + BackoffDetails) + +# ruff: noqa: ARG002 + +logger = logging.getLogger(__name__) + + +def backoff_handler(details: BackoffDetails) -> None: + """Log exceptions inside functions with the backoff decorator.""" + logger.debug("Backing off {wait:0.1f} seconds after {tries} tries " + "calling function {target} with args {args} and kwargs {kwargs}".format(**details)) + logger.debug(f"Exception: {traceback.format_exc()}") + + +def is_final(error: Exception) -> bool: + """Mock error handler for tests when a function has a backup decorator.""" + logger.debug(error) + return False + + +class GameStream(Response): + """Imitate lichess.org's GameStream. Used in tests.""" + + def __init__(self, + board_queue: Queue[chess.Board], + clock_queue: Queue[tuple[datetime.timedelta, datetime.timedelta, datetime.timedelta]]) -> None: + """ + Capture the interprocess queues that will feed the gameStream with game information. + + :param board_queue: Updated board positions from the lichess_org_simulator() function. + :param clock_queue: Updated game clock timings (white time, black time, and increment) from the + lichess_org_simulator() function. + """ + self.board_queue = board_queue + self.clock_queue = clock_queue + + def iter_lines(self, chunk_size: int | None = 512, decode_unicode: bool = False, + delimiter: str | bytes | None = None) -> Generator[bytes, None, None]: + """Send the game events to lichess-bot.""" + yield json.dumps( + {"id": "zzzzzzzz", + "variant": {"key": "standard", + "name": "Standard", + "short": "Std"}, + "clock": {"initial": 60000, + "increment": 2000}, + "speed": "bullet", + "perf": {"name": "Bullet"}, + "rated": True, + "createdAt": 1600000000000, + "white": {"id": "bo", + "name": "bo", + "title": "BOT", + "rating": 3000}, + "black": {"id": "b", + "name": "b", + "title": "BOT", + "rating": 3000, + "provisional": True}, + "initialFen": "startpos", + "type": "gameFull", + "state": {"type": "gameState", + "moves": "", + "wtime": 10000, + "btime": 10000, + "winc": 100, + "binc": 100, + "status": "started"}}).encode("utf-8") + while True: + board = self.board_queue.get() + self.board_queue.task_done() + + wtime, btime, increment = self.clock_queue.get() + self.clock_queue.task_done() + + new_game_state = {"type": "gameState", + "moves": " ".join(move.uci() for move in board.move_stack), + "wtime": int(to_msec(wtime)), + "btime": int(to_msec(btime)), + "winc": int(to_msec(increment)), + "binc": int(to_msec(increment))} + + if board.is_game_over(): + new_game_state["status"] = "outoftime" + new_game_state["winner"] = "black" + yield json.dumps(new_game_state).encode("utf-8") + break + + if board.move_stack: + new_game_state["status"] = "started" + yield json.dumps(new_game_state).encode("utf-8") + + def __enter__(self) -> GameStream: # noqa: PYI034 + """Enter game stream context.""" + return self + + def __exit__(self, *args: object) -> None: + """Exit game stream context.""" + + +class EventStream(Response): + """Imitate lichess.org's EventStream. Used in tests.""" + + def __init__(self, sent_game: bool = False) -> None: + """ + Start the event stream for the lichess_bot_main() loop. + + :param sent_game: If we have already sent the `gameStart` event, so we don't send it again. + """ + self.sent_game = sent_game + + def iter_lines(self, chunk_size: int | None = 512, decode_unicode: bool = False, + delimiter: str | bytes | None = None) -> Generator[bytes, None, None]: + """Send the events to lichess-bot.""" + if self.sent_game: + yield b"" + time.sleep(1) + else: + yield json.dumps( + {"type": "gameStart", + "game": {"id": "zzzzzzzz", + "source": "friend", + "compat": {"bot": True, + "board": True}}}).encode("utf-8") + + def __enter__(self) -> EventStream: # noqa: PYI034 + """Enter context block.""" + return self + + def __exit__(self, *args: object) -> None: + """Exit context block.""" + + +# Docs: https://lichess.org/api. +class Lichess(OriginalLichess): + """Imitate communication with lichess.org.""" + + def __init__(self, + move_queue: Queue[chess.Move | None], + board_queue: Queue[chess.Board], + clock_queue: Queue[tuple[datetime.timedelta, datetime.timedelta, datetime.timedelta]]) -> None: + """ + Capture the interprocess queues to distribute them to the eventStream and gameStream instances. + + :param move_queue: An interprocess queue to send moves chosen by the bot under test to the mock lichess function. + :param board_queue: An interprocess queue to send board positions to the mock game stream. + :param clock_queue: An interprocess queue to send game clock information to the mock game stream. + """ + self.baseUrl = "testing" + self.move_queue = move_queue + self.board_queue = board_queue + self.clock_queue = clock_queue + self.sent_game = False + self.started_game_stream = False + + def upgrade_to_bot_account(self) -> None: + """Isn't used in tests.""" + + def make_move(self, game_id: str, move: chess.engine.PlayResult) -> None: + """Send a move to the opponent engine thread.""" + self.move_queue.put(move.move) + + def accept_takeback(self, game_id: str, accept: bool) -> bool: + """Isn't used in tests.""" + return False + + def chat(self, game_id: str, room: str, text: str) -> None: + """Isn't used in tests.""" + + def abort(self, game_id: str) -> None: + """Isn't used in tests.""" + + def get_event_stream(self) -> EventStream: + """Send the `EventStream`.""" + events = EventStream(self.sent_game) + self.sent_game = True + return events + + def get_game_stream(self, game_id: str) -> GameStream: + """Send the `GameStream`.""" + if self.started_game_stream: + self.move_queue.put(None) + self.started_game_stream = True + return GameStream(self.board_queue, self.clock_queue) + + def accept_challenge(self, challenge_id: str) -> None: + """Isn't used in tests.""" + + def decline_challenge(self, challenge_id: str, reason: str = "generic") -> None: + """Isn't used in tests.""" + + def get_profile(self) -> UserProfileType: + """Return a simple profile for the bot that lichess-bot uses when testing.""" + return {"id": "b", + "username": "b", + "online": True, + "title": "BOT", + "url": "https://lichess.org/@/b", + "followable": True, + "following": False, + "blocking": False, + "followsYou": False, + "perfs": {}} + + def get_ongoing_games(self) -> list[GameType]: + """Return that the bot isn't playing a game.""" + return [] + + def resign(self, game_id: str) -> None: + """Isn't used in tests.""" + + def get_game_pgn(self, game_id: str) -> str: + """Return a simple PGN.""" + return """ +[Event "Test game"] +[Site "pytest"] +[Date "2022.03.11"] +[Round "1"] +[White "bo"] +[Black "b"] +[Result "0-1"] + +* +""" + + def get_online_bots(self) -> list[UserProfileType]: + """Return that the only bot online is us.""" + return [{"username": "b", "online": True}] + + def challenge(self, username: str, payload: REQUESTS_PAYLOAD_TYPE) -> ChallengeType: + """Isn't used in tests.""" + return {} + + def cancel(self, challenge_id: str) -> None: + """Isn't used in tests.""" + + def online_book_get(self, path: str, params: dict[str, str | int] | None = None, *, + stream: bool = False, authenticated: bool = False) -> OnlineType: + """Isn't used in tests.""" + return {} + + def is_online(self, user_id: str) -> bool: + """Return that a bot is online.""" + return True + + def get_public_data(self, user_name: str) -> PublicDataType: + """Isn't used in tests.""" + return {} diff --git a/lichess-bot-master/test_bot/ruff.toml b/lichess-bot-master/test_bot/ruff.toml new file mode 100644 index 0000000000000000000000000000000000000000..9059279cdc7f1f2612427cc1b3350472838f4e48 --- /dev/null +++ b/lichess-bot-master/test_bot/ruff.toml @@ -0,0 +1,46 @@ +target-version = "py310" + +# The GitHub editor is 127 chars wide. +line-length = 127 + +[lint] +select = ["ALL"] + +ignore = [ + "ANN401", # Dynamically typed expressions (typing.Any) are disallowed + "BLE001", # Do not catch blind exception: Exception + "COM812", # Trailing comma missing + "D203", # Require blank line after class declaration before docstring + "D212", # Start multiline docstring on same line as triple-quote + "DTZ005", # datetime without timezone + "EM101", # Exception must not use a string literal, assign to variable first + "EM102", # Exception must not use an f-string literal, assign to variable first + "ERA001", # Found commented-out code + "FBT", # Boolean argument in function definition + "G", # Logging + "I001", # Import block is un-sorted or un-formatted + "N803", # Argument name should be lowercase + "N806", # Variable in function should be lowercase + "PERF203", # try-except within a loop incurs performance overhead + "PLC0415", # `import` should be at the top-level of a file + "PLR0913", # Too many arguments in function definition + "PLR0915", # Too many statements + "PLR2004", # Magic value used in comparison, consider replacing `20` with a constant variable + "PLW0603", # Using the global statement to update variable is discouraged + "PT018", # Assertion should be broken down into multiple parts + "PTH", # Replace builtin functions with Path methods + "RET505", # Unnecessary else after return statement + "RUF005", # Consider [*list1, None] instead of concatenation (list1 + [None]) + "RUF021", # Parenthesize `a and b` expressions when chaining `and` and `or` together, to make the precedence clear + "S101", # Use of assert detected + "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes + "SIM108", # Use ternary operator instead of `if`-`else`-block + "TC001", # Move application import into a type-checking block + "TC002", # Move third-party import into a type-checking block + "TC003", # Move standard library import into a type-checking block + "TC006", # Add quotes to type expression in `typing.cast()` + "TRY", # Try-except suggestions +] + +[lint.mccabe] +max-complexity = 10 diff --git a/lichess-bot-master/test_bot/test-requirements.txt b/lichess-bot-master/test_bot/test-requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..454e144f4f671be6ed91c25cbce6a7b98a06583f --- /dev/null +++ b/lichess-bot-master/test_bot/test-requirements.txt @@ -0,0 +1,6 @@ +pytest~=9.0 +pytest-timeout~=2.4 +ruff~=0.15 +mypy~=2.1 +types-requests~=2.33 +types-PyYAML~=6.0 diff --git a/lichess-bot-master/test_bot/test_blocklist.py b/lichess-bot-master/test_bot/test_blocklist.py new file mode 100644 index 0000000000000000000000000000000000000000..1b2df8205d029d683b3b8452e21768e27937b663 --- /dev/null +++ b/lichess-bot-master/test_bot/test_blocklist.py @@ -0,0 +1,227 @@ +"""Tests for the blocklist module.""" +from unittest.mock import Mock, patch + +import pytest +from urllib3.exceptions import HTTPError + +from lib.blocklist import BlocklistData, _parse_block_list_from_url, OnlineBlocklist + + +def test_parse_block_list_from_url_success() -> None: + """Test parsing blocklist from URL with successful response.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.text = "user1\nuser2\nuser3" + mock_response.headers = {"ETag": "test-etag-123"} + mock_response.raise_for_status = Mock() + + old_data = BlocklistData([], None) + + with patch("lib.blocklist.requests.get", return_value=mock_response): + result = _parse_block_list_from_url("http://example.com/blocklist", old_data) + + assert result.users == ["user1", "user2", "user3"] + assert result.etag == "test-etag-123" + + +def test_parse_block_list_from_url__several_line_breaks() -> None: + """Test parsing blocklist from URL with several line-breaks.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {"ETag": "test-etag-123"} + mock_response.raise_for_status = Mock() + + old_data = BlocklistData([], None) + + texts = [ + "user1\nuser2\nuser3", + "user1\r\nuser2\r\n\r\nuser3", + "user1\ruser2\r\ruser3", + "user1\nuser2\nuser3", + ] + for text in texts: + mock_response.text = text + + with patch("lib.blocklist.requests.get", return_value=mock_response): + result = _parse_block_list_from_url("http://example.com/blocklist", old_data) + + assert result.users == ["user1", "user2", "user3"] + assert result.etag == "test-etag-123" + + +def test_parse_block_list_from_url_not_modified() -> None: + """Test parsing blocklist returns cached data when not modified (304).""" + mock_response = Mock() + mock_response.status_code = 304 + mock_response.raise_for_status = Mock() + + old_data = BlocklistData(["cached_user1", "cached_user2"], "old-etag") + + with patch("lib.blocklist.requests.get", return_value=mock_response): + result = _parse_block_list_from_url("http://example.com/blocklist", old_data) + + assert result.users == ["cached_user1", "cached_user2"] + assert result.etag == "old-etag" + + +def test_parse_block_list_from_url_with_etag_header() -> None: + """Test that ETag is sent in request headers when available.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.text = "user1" + mock_response.headers = {} + mock_response.raise_for_status = Mock() + + old_data = BlocklistData([], "existing-etag") + + with patch("lib.blocklist.requests.get", return_value=mock_response) as mock_get: + _parse_block_list_from_url("http://example.com/blocklist", old_data) + mock_get.assert_called_once_with( + "http://example.com/blocklist", + headers={"If-None-Match": "existing-etag"}, + timeout=15, + ) + + +def test_parse_block_list_from_url_without_etag_header() -> None: + """Test that no ETag header is sent when old_data has no ETag.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.text = "user1" + mock_response.headers = {} + mock_response.raise_for_status = Mock() + + old_data = BlocklistData([], None) + + with patch("lib.blocklist.requests.get", return_value=mock_response) as mock_get: + _parse_block_list_from_url("http://example.com/blocklist", old_data) + mock_get.assert_called_once_with( + "http://example.com/blocklist", + headers={}, + timeout=15 + ) + + +def test_parse_block_list_from_url_strips_whitespace() -> None: + """Test that usernames are stripped of leading/trailing whitespace.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.text = " user1 \n user2\t\nuser3 " + mock_response.headers = {} + mock_response.raise_for_status = Mock() + + old_data = BlocklistData([], None) + + with patch("lib.blocklist.requests.get", return_value=mock_response): + result = _parse_block_list_from_url("http://example.com/blocklist", old_data) + + assert result.users == ["user1", "user2", "user3"] + + +def test_parse_block_list_from_url_empty_response() -> None: + """Test parsing blocklist with empty response.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.text = "" + mock_response.headers = {} + mock_response.raise_for_status = Mock() + + old_data = BlocklistData([], None) + + with patch("lib.blocklist.requests.get", return_value=mock_response): + result = _parse_block_list_from_url("http://example.com/blocklist", old_data) + + assert result.users == [] + + +def test_parse_block_list_from_url_no_etag_in_response() -> None: + """Test parsing blocklist when response has no ETag header.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.text = "user1\nuser2" + mock_response.headers = {} + mock_response.raise_for_status = Mock() + + old_data = BlocklistData([], None) + + with patch("lib.blocklist.requests.get", return_value=mock_response): + result = _parse_block_list_from_url("http://example.com/blocklist", old_data) + + assert result.users == ["user1", "user2"] + assert result.etag is None + + +def test_parse_block_list_from_url_multiline_with_blanks() -> None: + """Test parsing blocklist that contains blank lines.""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.text = "user1\n\nuser2\n\n\nuser3" + mock_response.headers = {} + mock_response.raise_for_status = Mock() + + old_data = BlocklistData([], None) + + with patch("lib.blocklist.requests.get", return_value=mock_response): + result = _parse_block_list_from_url("http://example.com/blocklist", old_data) + + assert result.users == ["user1", "user2", "user3"] + + +def test_parse_block_list_from_url__exception() -> None: + """Test parsing blocklist from URL with exception.""" + mock_response = Mock() + mock_response.status_code = 500 + mock_response.raise_for_status.side_effect = HTTPError("500 Internal Server Error") + + old_data = BlocklistData([], None) + + with patch("lib.blocklist.requests.get", return_value=mock_response), pytest.raises(HTTPError): + _parse_block_list_from_url("http://example.com/blocklist", old_data) + + +def test_online_blocklist_refresh_success() -> None: + """Test successful refresh of all blocklists.""" + mock_response1 = Mock() + mock_response1.status_code = 200 + mock_response1.text = "user1\nuser2" + mock_response1.headers = {"ETag": "etag1"} + mock_response1.raise_for_status = Mock() + + mock_response2 = Mock() + mock_response2.status_code = 200 + mock_response2.text = "user3\nuser4\nuser5" + mock_response2.headers = {"ETag": "etag2"} + mock_response2.raise_for_status = Mock() + + with patch("lib.blocklist.requests.get") as mock_get: + mock_get.side_effect = [mock_response1, mock_response2] + blocklist = OnlineBlocklist(["http://example.com/list1", "http://example.com/list2"]) + + assert blocklist.blocklist["http://example.com/list1"].users == ["user1", "user2"] + assert blocklist.blocklist["http://example.com/list1"].etag == "etag1" + assert blocklist.blocklist["http://example.com/list2"].users == ["user3", "user4", "user5"] + assert blocklist.blocklist["http://example.com/list2"].etag == "etag2" + + +def test_online_blocklist_refresh_partial_failure() -> None: + """Test refresh when some blocklists fail to load.""" + mock_response_success = Mock() + mock_response_success.status_code = 200 + mock_response_success.text = "user1\nuser2" + mock_response_success.headers = {"ETag": "etag1"} + mock_response_success.raise_for_status = Mock() + + mock_response_fail = Mock() + mock_response_fail.raise_for_status.side_effect = HTTPError("500 Server Error") + + with patch("lib.blocklist.requests.get") as mock_get: + # First call succeeds, second call fails + mock_get.side_effect = [mock_response_success, mock_response_fail] + blocklist = OnlineBlocklist(["http://example.com/list1", "http://example.com/list2"]) + + # First blocklist should be loaded + assert blocklist.blocklist["http://example.com/list1"].users == ["user1", "user2"] + + # Second blocklist should remain empty (failed to load) + assert blocklist.blocklist["http://example.com/list2"].users == [] + assert blocklist.blocklist["http://example.com/list2"].etag is None diff --git a/lichess-bot-master/test_bot/test_bot.py b/lichess-bot-master/test_bot/test_bot.py new file mode 100644 index 0000000000000000000000000000000000000000..b3d4af2aa65a769be6b3f44f9606dfa3318f50a5 --- /dev/null +++ b/lichess-bot-master/test_bot/test_bot.py @@ -0,0 +1,197 @@ +"""Test lichess-bot.""" +import yaml +import chess +import chess.engine +import threading +import os +import sys +import datetime +import time +import logging +import tempfile +from multiprocessing import Manager +from queue import Queue +import test_bot.lichess +from lib import config +from lib.timer import Timer, seconds +from lib.engine_wrapper import test_suffix +from lib.lichess_types import CONFIG_DICT_TYPE +if "pytest" not in sys.modules: + sys.exit(f"The script {os.path.basename(__file__)} should only be run by pytest.") +from lib import lichess_bot +from test_bot.test_games import scholars_mate + + +logging_level = logging.DEBUG +testing_log_file_name = None +lichess_bot.logging_configurer(logging_level, testing_log_file_name, True) +logger = logging.getLogger(__name__) + + +def lichess_org_simulator(move_queue: Queue[chess.Move | None], + board_queue: Queue[chess.Board], + clock_queue: Queue[tuple[datetime.timedelta, datetime.timedelta, datetime.timedelta]], + results: Queue[bool]) -> None: + """ + Run a mocked version of the lichess.org server to provide an opponent for a test. This opponent always plays white. + + :param opponent_path: The path to the executable of the opponent. Usually Stockfish. + :param move_queue: An interprocess queue that supplies the moves chosen by the bot being tested. + :param board_queue: An interprocess queue where this function sends the updated board after choosing a move. + :param clock_queue: An interprocess queue where this function sends the updated game clock after choosing a move. + :param results: An interprocess queue where this function sends the result of the game to the testing function. + """ + start_time = seconds(10) + increment = seconds(0.1) + + board = chess.Board() + wtime = start_time + btime = start_time + + while not board.is_game_over(): + move_timer = Timer() + if board.turn == chess.WHITE: + move_count = len(board.move_stack) + engine_move = board.parse_uci(scholars_mate[move_count]) + board.push(engine_move) + board_queue.put(board) + clock_queue.put((wtime, btime, increment)) + if len(board.move_stack) > 1: + wtime -= move_timer.time_since_reset() + wtime += increment + else: + move_timer = Timer() + while (bot_move := move_queue.get()) is None: + board_queue.put(board) + clock_queue.put((wtime, btime, increment)) + move_queue.task_done() + board.push(bot_move) + move_queue.task_done() + if len(board.move_stack) > 2: + btime -= move_timer.time_since_reset() + btime += increment + + board_queue.put(board) + clock_queue.put((wtime, btime, increment)) + outcome = board.outcome() + results.put(outcome is not None and outcome.winner == chess.BLACK) + + +def run_bot(raw_config: CONFIG_DICT_TYPE, logging_level: int) -> bool: + """ + Start lichess-bot test with a mocked version of the lichess.org site. + + :param raw_config: A dictionary of values to specify the engine to test. This engine will play as white. + :param logging_level: The level of logging to use during the test. Usually logging.DEBUG. + :param opponent_path: The path to the executable that will play the opponent. The opponent plays as black. + """ + config.insert_default_values(raw_config) + CONFIG = config.Configuration(raw_config) + logger.info(lichess_bot.intro()) + manager = Manager() + board_queue: Queue[chess.Board] = manager.Queue() + clock_queue: Queue[tuple[datetime.timedelta, datetime.timedelta, datetime.timedelta]] = manager.Queue() + move_queue: Queue[chess.Move | None] = manager.Queue() + li = test_bot.lichess.Lichess(move_queue, board_queue, clock_queue) + + user_profile = li.get_profile() + username = user_profile["username"] + if user_profile.get("title") != "BOT": + return False + logger.info(f"Welcome {username}!") + lichess_bot.disable_restart() + + results: Queue[bool] = manager.Queue() + thr = threading.Thread(target=lichess_org_simulator, args=[move_queue, board_queue, clock_queue, results]) + thr.start() + lichess_bot.start(li, user_profile, CONFIG, logging_level, testing_log_file_name, True, one_game=True) + + result = results.get() + results.task_done() + + results.join() + board_queue.join() + clock_queue.join() + move_queue.join() + + thr.join() + + return result + + +def test_uci() -> None: + """Test lichess-bot with Stockfish (UCI).""" + with open("./config.yml.default") as file: + CONFIG = yaml.safe_load(file) + + with tempfile.TemporaryDirectory() as temp: + CONFIG["token"] = "" + CONFIG["engine"]["dir"] = "test_bot" + CONFIG["engine"]["name"] = "uci_engine.py" + CONFIG["engine"]["interpreter"] = sys.executable + CONFIG["pgn_directory"] = os.path.join(temp, "uci_game_record") + CONFIG["engine"]["uci_options"] = {} + win = run_bot(CONFIG, logging_level) + logger.info("Finished Testing UCI") + assert win + time.sleep(0.1) # Wait for file to be written. + assert os.path.isfile(os.path.join(CONFIG["pgn_directory"], + "bo vs b - zzzzzzzz.pgn")) + + +def test_xboard() -> None: + """Test lichess-bot with an XBoard engine.""" + with open("./config.yml.default") as file: + CONFIG = yaml.safe_load(file) + + with tempfile.TemporaryDirectory() as temp: + CONFIG["token"] = "" + CONFIG["engine"]["dir"] = "test_bot" + CONFIG["engine"]["name"] = "xboard_engine.py" + CONFIG["engine"]["protocol"] = "xboard" + CONFIG["engine"]["interpreter"] = sys.executable + CONFIG["pgn_directory"] = os.path.join(temp, "lc0_game_record") + win = run_bot(CONFIG, logging_level) + logger.info("Finished Testing XBoard") + assert win + time.sleep(0.1) # Wait for file to be written. + assert os.path.isfile(os.path.join(CONFIG["pgn_directory"], + "bo vs b - zzzzzzzz.pgn")) + + +def test_homemade() -> None: + """Test lichess-bot with a homemade engine.""" + with open("./config.yml.default") as file: + CONFIG = yaml.safe_load(file) + + with tempfile.TemporaryDirectory() as temp: + CONFIG["token"] = "" + CONFIG["engine"]["name"] = f"ScholarsMate{test_suffix}" + CONFIG["engine"]["protocol"] = "homemade" + CONFIG["pgn_directory"] = os.path.join(temp, "homemade_game_record") + win = run_bot(CONFIG, logging_level) + logger.info("Finished Testing Homemade") + assert win + time.sleep(0.1) # Wait for file to be written. + assert os.path.isfile(os.path.join(CONFIG["pgn_directory"], + "bo vs b - zzzzzzzz.pgn")) + + +def test_buggy_engine() -> None: + """Test lichess-bot with an engine that causes a timeout error within python-chess.""" + with open("./config.yml.default") as file: + CONFIG = yaml.safe_load(file) + + with tempfile.TemporaryDirectory() as temp: + CONFIG["token"] = "" + CONFIG["engine"]["dir"] = "test_bot" + CONFIG["engine"]["name"] = "buggy_engine.py" + CONFIG["engine"]["interpreter"] = sys.executable + CONFIG["engine"]["uci_options"] = {"go_commands": {"movetime": 100}} + CONFIG["pgn_directory"] = os.path.join(temp, "bug_game_record") + win = run_bot(CONFIG, logging_level) + logger.info("Finished Testing Buggy Engine") + assert win + time.sleep(0.1) # Wait for file to be written. + assert os.path.isfile(os.path.join(CONFIG["pgn_directory"], + "bo vs b - zzzzzzzz.pgn")) diff --git a/lichess-bot-master/test_bot/test_config.py b/lichess-bot-master/test_bot/test_config.py new file mode 100644 index 0000000000000000000000000000000000000000..99258f5a5af54ec77bd956e1125ffd0980b51e16 --- /dev/null +++ b/lichess-bot-master/test_bot/test_config.py @@ -0,0 +1,33 @@ +"""Test functions for config module.""" +import logging + +import pytest + +from lib import config + + +def test_config_assert__false() -> None: + """Test that config_assert raises an exception with the provided error message.""" + with pytest.raises(Exception, match="some error"): + config.config_assert(False, "some error") + + +def test_config_assert__true() -> None: + """Test that config_assert does not raise when assertion is True.""" + config.config_assert(True, "no error") + + +def test_config_warn__true(caplog: pytest.LogCaptureFixture) -> None: + """Test that config_warn does not log a warning when assertion is True.""" + with caplog.at_level(logging.WARNING): + config.config_warn(True, "this should not appear") + assert len(caplog.records) == 0 # No warning should be logged + + +def test_config_warn__false(caplog: pytest.LogCaptureFixture) -> None: + """Test that config_warn logs a warning when assertion is False.""" + with caplog.at_level(logging.WARNING): + config.config_warn(False, "test warning message") + assert "test warning message" in caplog.text + assert len(caplog.records) == 1 + assert caplog.records[0].levelname == "WARNING" diff --git a/lichess-bot-master/test_bot/test_external_moves.py b/lichess-bot-master/test_bot/test_external_moves.py new file mode 100644 index 0000000000000000000000000000000000000000..052e6b8bde336d4ed880a099f6ef8ab1bd3e3470 --- /dev/null +++ b/lichess-bot-master/test_bot/test_external_moves.py @@ -0,0 +1,299 @@ +"""Test the functions that get the external moves.""" +import pytest +import backoff +import requests +import yaml +import os +import chess +import logging +import chess.engine +from datetime import timedelta +from copy import deepcopy +from requests.exceptions import ConnectionError as RequestsConnectionError, HTTPError, ReadTimeout, RequestException +from http.client import RemoteDisconnected +from lib.lichess_types import OnlineType, GameEventType +from typing import cast +from lib.lichess import is_final, backoff_handler, Lichess +from lib.config import Configuration, insert_default_values +from lib.model import Game +from lib.engine_wrapper import get_online_move, get_book_move, is_op1_position + + +class MockLichess(Lichess): + """A modified Lichess class for communication with external move sources.""" + + def __init__(self) -> None: + """Initialize only self.other_session and not self.session.""" + self.max_retries = 3 + self.other_session = requests.Session() + + def online_book_get(self, path: str, params: dict[str, str | int] | None = None, *, + stream: bool = False, authenticated: bool = False) -> OnlineType: + """ + Get an external move from online sources (chessdb or lichess.org). + + Ignore authentication for tests. + """ + del authenticated + + @backoff.on_exception(backoff.constant, + (RemoteDisconnected, RequestsConnectionError, HTTPError, ReadTimeout), + max_time=60, + max_tries=self.max_retries, + interval=0.1, + giveup=is_final, + on_backoff=backoff_handler, + backoff_log_level=logging.DEBUG, + giveup_log_level=logging.DEBUG) + def online_book_get() -> OnlineType: + json_response: OnlineType = self.other_session.get(path, timeout=2, params=params, stream=stream).json() + return json_response + + return online_book_get() + + def is_website_up(self, url: str) -> bool: + """Check if a website is up.""" + try: + self.other_session.get(url, timeout=2) + return True + except RequestException: + return False + + +def get_configs() -> tuple[Configuration, Configuration, Configuration, Configuration]: + """Create the configs used for the tests.""" + with open("./config.yml.default") as file: + CONFIG = yaml.safe_load(file) + insert_default_values(CONFIG) + CONFIG["engine"]["online_moves"]["lichess_cloud_analysis"]["enabled"] = True + CONFIG["engine"]["online_moves"]["online_egtb"]["enabled"] = True + CONFIG["engine"]["draw_or_resign"]["resign_enabled"] = True + CONFIG["engine"]["polyglot"]["enabled"] = True + CONFIG["engine"]["polyglot"]["book"]["standard"] = ["TEMP/gm2001.bin"] + engine_cfg = Configuration(CONFIG).engine + CONFIG_2 = deepcopy(CONFIG) + CONFIG_2["engine"]["online_moves"]["chessdb_book"]["enabled"] = True + CONFIG_2["engine"]["online_moves"]["online_egtb"]["source"] = "chessdb" + engine_cfg_2 = Configuration(CONFIG_2).engine + return engine_cfg.online_moves, engine_cfg_2.online_moves, engine_cfg.draw_or_resign, engine_cfg.polyglot + + +def get_game() -> Game: + """Create a model.Game to be used in the tests.""" + game_event: GameEventType = {"id": "zzzzzzzz", + "variant": {"key": "standard", + "name": "Standard", + "short": "Std"}, + "clock": {"initial": 60000, + "increment": 2000}, + "speed": "bullet", + "perf": {"name": "Bullet"}, + "rated": True, + "createdAt": 1600000000000, + "white": {"id": "bo", + "name": "bo", + "title": "BOT", + "rating": 3000}, + "black": {"id": "b", + "name": "b", + "title": "BOT", + "rating": 3000, + "provisional": True}, + "initialFen": "startpos", + "type": "gameFull", + "state": {"type": "gameState", + "moves": "", + "wtime": 1000000, + "btime": 1000000, + "winc": 2000, + "binc": 2000, + "status": "started"}} + return Game(game_event, "b", "https://lichess.org", timedelta(seconds=60)) + + +def download_opening_book() -> None: + """Download gm2001.bin.""" + if os.path.exists("./TEMP/gm2001.bin"): + return + + os.makedirs("TEMP", exist_ok=True) + response = requests.get("https://github.com/gmcheems-org/free-opening-books/raw/main/books/bin/gm2001.bin", + allow_redirects=True, timeout=60) + if response.status_code != 200: + pytest.xfail("Could not download opening book.") + with open("./TEMP/gm2001.bin", "wb") as file: + file.write(response.content) + + +def get_online_move_wrapper(li: Lichess, board: chess.Board, game: Game, online_moves_cfg: Configuration, + draw_or_resign_cfg: Configuration, *, expect_none: bool = False) -> chess.engine.PlayResult: + """Wrap `lib.engine_wrapper.get_online_move` so that it only returns a PlayResult type.""" + online_move = get_online_move(li, board, game, online_moves_cfg, draw_or_resign_cfg) + online_move = cast(chess.engine.PlayResult, online_move) + if not expect_none and online_move.move is None: + pytest.xfail("Could not contact external move source.") + return online_move + + +class TestExternalMoves: + """Test that the code for external moves works properly.""" + + li = MockLichess() + game = get_game() + online_cfg, online_cfg_2, draw_or_resign_cfg, polyglot_cfg = get_configs() + + starting_fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + opening_fen = "rn1q1rk1/pbp1bpp1/1p2pn1p/3p4/2PP3B/2N1PN2/PP2BPPP/R2QK2R w KQ - 2 9" + middlegame_fen = "8/5p2/1n1p1nk1/1p1Pp1p1/1Pp1P1Pp/r1P2B1P/2RNKP2/8 w - - 0 31" + endgame_wdl2_fen = "2k5/4n2Q/5N2/8/8/8/1r6/2K5 b - - 0 123" + endgame_wdl1_fen = "6N1/3n4/3k1b2/8/8/7Q/1r6/5K2 b - - 6 9" + endgame_wdl0_fen = "6N1/3n4/3k1b2/8/8/7Q/5K2/1r6 b - - 8 10" + endgame_op1_fen = "r7/p1b1k3/8/8/P7/8/8/K2R1R2 w - - 1 2" + + def test_lichess_cloud_analysis(self) -> None: + """Test lichess_cloud_analysis.""" + if not self.li.is_website_up("https://lichess.org/api/cloud-eval"): + pytest.xfail("Lichess cloud eval is down.") + + assert get_online_move_wrapper(self.li, + chess.Board(self.starting_fen), + self.game, + self.online_cfg, + self.draw_or_resign_cfg).move is not None + assert get_online_move_wrapper(self.li, + chess.Board(self.opening_fen), + self.game, + self.online_cfg, + self.draw_or_resign_cfg).move is not None + assert get_online_move_wrapper(self.li, + chess.Board(self.middlegame_fen), + self.game, + self.online_cfg, + self.draw_or_resign_cfg, + expect_none=True).move is None + + def test_chessdb_book(self) -> None: + """Test chessdb_book.""" + if not self.li.is_website_up("https://www.chessdb.cn/cdb.php"): + pytest.xfail("ChessDB is down.") + + assert get_online_move_wrapper(self.li, + chess.Board(self.starting_fen), + self.game, + self.online_cfg_2, + self.draw_or_resign_cfg).move is not None + assert get_online_move_wrapper(self.li, + chess.Board(self.opening_fen), + self.game, + self.online_cfg_2, + self.draw_or_resign_cfg).move is not None + assert get_online_move_wrapper(self.li, + chess.Board(self.middlegame_fen), + self.game, + self.online_cfg_2, + self.draw_or_resign_cfg, + expect_none=True).move is None + + def test_online_egtb_with_lichess(self) -> None: + """Test online_egtb with lichess.""" + if not self.li.is_website_up("https://tablebase.lichess.ovh/standard"): + pytest.xfail("Lichess tablebase is down.") + + assert get_online_move_wrapper(self.li, + chess.Board(self.endgame_wdl2_fen), + self.game, + self.online_cfg, + self.draw_or_resign_cfg).resigned + assert get_online_move_wrapper(self.li, + chess.Board(self.endgame_wdl0_fen), + self.game, + self.online_cfg, + self.draw_or_resign_cfg).draw_offered + wdl1_move = get_online_move_wrapper(self.li, + chess.Board(self.endgame_wdl1_fen), + self.game, + self.online_cfg, + self.draw_or_resign_cfg) + assert not wdl1_move.resigned and not wdl1_move.draw_offered + op1_move = get_online_move_wrapper(self.li, + chess.Board(self.endgame_op1_fen), + self.game, + self.online_cfg, + self.draw_or_resign_cfg) + assert op1_move.move == chess.Move.from_uci("f1e1") + + # Test with reversed colors. + assert get_online_move_wrapper(self.li, + chess.Board(self.endgame_wdl2_fen).mirror(), + self.game, + self.online_cfg, + self.draw_or_resign_cfg).resigned + assert get_online_move_wrapper(self.li, + chess.Board(self.endgame_wdl0_fen).mirror(), + self.game, + self.online_cfg, + self.draw_or_resign_cfg).draw_offered + wdl1_move = get_online_move_wrapper(self.li, + chess.Board(self.endgame_wdl1_fen).mirror(), + self.game, + self.online_cfg, + self.draw_or_resign_cfg) + assert not wdl1_move.resigned and not wdl1_move.draw_offered + + def test_online_egtb_with_chessdb(self) -> None: + """Test online_egtb with chessdb.""" + if not self.li.is_website_up("https://www.chessdb.cn/cdb.php"): + pytest.xfail("ChessDB is down.") + + assert get_online_move_wrapper(self.li, + chess.Board(self.endgame_wdl2_fen), + self.game, + self.online_cfg_2, + self.draw_or_resign_cfg).resigned + assert get_online_move_wrapper(self.li, + chess.Board(self.endgame_wdl0_fen), + self.game, + self.online_cfg_2, + self.draw_or_resign_cfg).draw_offered + wdl1_move = get_online_move_wrapper(self.li, + chess.Board(self.endgame_wdl1_fen), + self.game, + self.online_cfg_2, + self.draw_or_resign_cfg) + assert not wdl1_move.resigned and not wdl1_move.draw_offered + + # Test with reversed colors. + assert get_online_move_wrapper(self.li, + chess.Board(self.endgame_wdl2_fen).mirror(), + self.game, + self.online_cfg_2, + self.draw_or_resign_cfg).resigned + assert get_online_move_wrapper(self.li, + chess.Board(self.endgame_wdl0_fen).mirror(), + self.game, + self.online_cfg_2, + self.draw_or_resign_cfg).draw_offered + wdl1_move = get_online_move_wrapper(self.li, + chess.Board(self.endgame_wdl1_fen).mirror(), + self.game, + self.online_cfg_2, + self.draw_or_resign_cfg) + assert not wdl1_move.resigned and not wdl1_move.draw_offered + + def test_opening_book(self) -> None: + """Test opening book.""" + download_opening_book() + assert get_book_move(chess.Board(self.opening_fen), self.game, self.polyglot_cfg).move == chess.Move.from_uci("h4f6") + + +class TestExternalMoveHelpers: + """Test the helper functions for external moves.""" + + def test_op1_positions(self) -> None: + """Test that a position is op1.""" + fens_op1 = ["r7/p1b1k3/8/8/P7/8/8/K2R1R2 w - - 1 2", "3qk3/1n1pp3/8/8/8/8/4P2P/4K3 w - - 0 1"] + fens_not_op1 = ["r3k3/2b5/8/p7/P7/8/8/K2R1R2 w q - 0 2", "3qk3/1n1pp1n1/8/8/8/8/4P3/4K3 w - - 0 1"] + for fen in fens_op1: + assert is_op1_position(chess.Board(fen)) + for fen in fens_not_op1: + assert not is_op1_position(chess.Board(fen)) diff --git a/lichess-bot-master/test_bot/test_games.py b/lichess-bot-master/test_bot/test_games.py new file mode 100644 index 0000000000000000000000000000000000000000..b8158336e1b1d4af781efd0af72684e888acdfd5 --- /dev/null +++ b/lichess-bot-master/test_bot/test_games.py @@ -0,0 +1,3 @@ +"""Games for testing.""" + +scholars_mate = ["a2a3", "e7e5", "a3a4", "f8c5", "a4a5", "d8h4", "a5a6", "h4f2"] diff --git a/lichess-bot-master/test_bot/test_lichess.py b/lichess-bot-master/test_bot/test_lichess.py new file mode 100644 index 0000000000000000000000000000000000000000..27b18d8a50ff8344af17d67e2aa986b1daeba0b8 --- /dev/null +++ b/lichess-bot-master/test_bot/test_lichess.py @@ -0,0 +1,45 @@ +"""Tests for the lichess communication.""" + +from lib import lichess +import logging +import os +import pytest + + +def test_lichess() -> None: + """Test the lichess communication.""" + token = os.environ.get("LICHESS_BOT_TEST_TOKEN") + if not token: + pytest.skip("Lichess-bot test token must be set.") + li = lichess.Lichess(token, "https://lichess.org/", "0.0.0", logging.DEBUG, 3) + assert len(li.get_online_bots()) > 20 + profile = li.get_profile() + profile["seenAt"] = 1700000000000 + assert profile == {"blocking": False, + "count": {"all": 12, "bookmark": 0, "draw": 1, "import": 0, + "loss": 8, "me": 0, "playing": 0, "rated": 0, "win": 3}, + "createdAt": 1627834995597, "followable": True, "following": False, "id": "badsunfish", + "perfs": {"blitz": {"games": 0, "prog": 0, "prov": True, "rating": 1500, "rd": 500}, + "bullet": {"games": 0, "prog": 0, "prov": True, "rating": 1500, "rd": 500}, + "classical": {"games": 0, "prog": 0, "prov": True, "rating": 1500, "rd": 500}, + "correspondence": {"games": 0, "prog": 0, "prov": True, "rating": 1500, "rd": 500}, + "rapid": {"games": 0, "prog": 0, "prov": True, "rating": 1500, "rd": 500}}, + "playTime": {"human": 1595, "total": 1873, "tv": 0}, "seenAt": 1700000000000, "title": "BOT", + "url": "https://lichess.org/@/BadSunfish", "username": "BadSunfish"} + assert li.get_ongoing_games() == [] + assert li.is_online("NNWithSF") is False + public_data = li.get_public_data("lichapibot") + for key in public_data["perfs"]: + public_data["perfs"][key]["rd"] = 0 + assert public_data == {"blocking": False, "count": {"all": 15774, "bookmark": 0, "draw": 3009, + "import": 0, "loss": 6423, + "me": 0, "playing": 0, "rated": 15121, "win": 6342}, + "createdAt": 1524037267522, "followable": True, "following": False, "id": "lichapibot", + "perfs": {"blitz": {"games": 2430, "prog": 3, "prov": True, "rating": 2388, "rd": 0}, + "bullet": {"games": 7293, "prog": 9, "prov": True, "rating": 2298, "rd": 0}, + "classical": {"games": 0, "prog": 0, "prov": True, "rating": 1500, "rd": 0}, + "correspondence": {"games": 0, "prog": 0, "prov": True, "rating": 1500, "rd": 0}, + "rapid": {"games": 993, "prog": -80, "prov": True, "rating": 2363, "rd": 0}}, + "playTime": {"total": 4111502, "tv": 1582068, "human": 534785}, "profile": {}, + "seenAt": 1669272254317, "title": "BOT", "tosViolation": True, + "url": "https://lichess.org/@/lichapibot", "username": "lichapibot"} diff --git a/lichess-bot-master/test_bot/test_matchmaking.py b/lichess-bot-master/test_bot/test_matchmaking.py new file mode 100644 index 0000000000000000000000000000000000000000..c04461aaa3e077c89d45a0045fdae1b2ce1dee24 --- /dev/null +++ b/lichess-bot-master/test_bot/test_matchmaking.py @@ -0,0 +1,226 @@ +"""Test functions for matchmaking module.""" +from unittest.mock import Mock +from lib.matchmaking import game_category, Matchmaking +from lib.config import Configuration +from lib.lichess_types import UserProfileType + + +def test_game_category_standard_bullet() -> None: + """Test bullet time control with config values.""" + # challenge_initial_time: 60 (1 min), challenge_increment: 1 + # 60 + 1*40 = 100 seconds < 179 = bullet + assert game_category("standard", 60, 1, 0) == "bullet" + + # challenge_initial_time: 60, challenge_increment: 2 + # 60 + 2*40 = 140 seconds < 179 = bullet + assert game_category("standard", 60, 2, 0) == "bullet" + + +def test_game_category_standard_blitz() -> None: + """Test blitz time control with config values.""" + # challenge_initial_time: 180 (3 min), challenge_increment: 1 + # 180 + 1*40 = 220 seconds, 179 <= 220 < 479 = blitz + assert game_category("standard", 180, 1, 0) == "blitz" + + # challenge_initial_time: 180, challenge_increment: 2 + # 180 + 2*40 = 260 seconds, 179 <= 260 < 479 = blitz + assert game_category("standard", 180, 2, 0) == "blitz" + + +def test_game_category_standard_rapid() -> None: + """Test rapid time control.""" + # 10 minutes + 5 seconds increment + # 600 + 5*40 = 800 seconds, 479 <= 800 < 1499 = rapid + assert game_category("standard", 600, 5, 0) == "rapid" + + # 15 minutes no increment + # 900 + 0*40 = 900 seconds, 479 <= 900 < 1499 = rapid + assert game_category("standard", 900, 0, 0) == "rapid" + + +def test_game_category_standard_classical() -> None: + """Test classical time control with max config values.""" + # max_base: 1800 (30 min), max_increment: 20 + # 1800 + 20*40 = 2600 seconds >= 1499 = classical + assert game_category("standard", 1800, 20, 0) == "classical" + + # 25 minutes no increment + # 1500 + 0*40 = 1500 seconds >= 1499 = classical + assert game_category("standard", 1500, 0, 0) == "classical" + + +def test_game_category_correspondence() -> None: + """Test correspondence games with config values.""" + # min_days: 1 + assert game_category("standard", 0, 0, 1) == "correspondence" + + # challenge_days: 2 + assert game_category("standard", 0, 0, 2) == "correspondence" + + # max_days: 14 + assert game_category("standard", 0, 0, 14) == "correspondence" + + +def test_game_category_variants() -> None: + """Test chess variants from config.""" + assert game_category("atomic", 60, 1, 0) == "atomic" + assert game_category("chess960", 180, 2, 0) == "chess960" + assert game_category("crazyhouse", 600, 5, 0) == "crazyhouse" + assert game_category("horde", 60, 0, 0) == "horde" + assert game_category("kingOfTheHill", 180, 1, 0) == "kingOfTheHill" + assert game_category("racingKings", 600, 0, 0) == "racingKings" + assert game_category("threeCheck", 60, 1, 0) == "threeCheck" + assert game_category("antichess", 180, 2, 0) == "antichess" + + +def test_game_category_time_boundaries() -> None: + """Test edge cases at time control boundaries.""" + # Exactly at bullet/blitz boundary + # 179 seconds should be blitz (179 < 179 is False) + assert game_category("standard", 179, 0, 0) == "blitz" + + # Just below boundary + assert game_category("standard", 178, 0, 0) == "bullet" + + # Exactly at blitz/rapid boundary + assert game_category("standard", 479, 0, 0) == "rapid" + + # Just below + assert game_category("standard", 478, 0, 0) == "blitz" + + # Exactly at rapid/classical boundary + assert game_category("standard", 1499, 0, 0) == "classical" + + # Just below + assert game_category("standard", 1498, 0, 0) == "rapid" + + +def test_game_category_min_config_values() -> None: + """Test minimum config values.""" + # min_base: 0, min_increment: 0 + # This is an edge case: 0 + 0*40 = 0 < 179 = bullet + assert game_category("standard", 0, 0, 0) == "bullet" + + # min_base: 0, min_increment: 0, min_days: 1 + assert game_category("standard", 0, 0, 1) == "correspondence" + + +def test_game_category_correspondence_overrides_time() -> None: + """Test that correspondence takes precedence over time controls.""" + # If both days and time controls are set, days takes precedence + assert game_category("standard", 1800, 20, 1) == "correspondence" + assert game_category("standard", 60, 1, 2) == "correspondence" + + +def test_game_category_variant_overrides_time() -> None: + """Test that variants override time control categorization.""" + # Variants are returned regardless of time control + # Even if time would be "classical", variant name is returned + assert game_category("atomic", 1800, 20, 0) == "atomic" + assert game_category("horde", 60, 1, 0) == "horde" + + # Variants override correspondence too + assert game_category("chess960", 0, 0, 14) == "chess960" + + +def test_game_category_negative_values() -> None: + """Test edge case with negative values (should not happen in practice).""" + # Negative base time + assert game_category("standard", -100, 5, 0) == "bullet" + + # Negative increment results in negative duration + result = game_category("standard", 100, -10, 0) + # 100 + (-10)*40 = -300, which is < 179, so bullet + assert result == "bullet" + + +def test_game_category_realistic_scenarios() -> None: + """Test realistic game scenarios from actual lichess games.""" + # 1+0 bullet + assert game_category("standard", 60, 0, 0) == "bullet" + + # 2+1 bullet + assert game_category("standard", 120, 1, 0) == "bullet" + + # 3+0 blitz + assert game_category("standard", 180, 0, 0) == "blitz" + + # 3+2 blitz + assert game_category("standard", 180, 2, 0) == "blitz" + + # 5+0 blitz + assert game_category("standard", 300, 0, 0) == "blitz" + + # 5+3 blitz + assert game_category("standard", 300, 3, 0) == "blitz" + + # 10+0 rapid + assert game_category("standard", 600, 0, 0) == "rapid" + + # 15+5 rapid + assert game_category("standard", 900, 5, 0) == "rapid" + + # 15+10 rapid + assert game_category("standard", 900, 10, 0) == "rapid" + + # 30+0 classical + assert game_category("standard", 1800, 0, 0) == "classical" + + # 30+20 classical + assert game_category("standard", 1800, 20, 0) == "classical" + + +def test_get_random_config_value__returns_specific_value() -> None: + """Test that get_random_config_value returns the config value when it's not 'random'.""" + # Create mock objects + mock_li = Mock() + mock_config = Configuration({ + "challenge": {"variants": ["standard"]}, + "matchmaking": { + "allow_matchmaking": False, + "block_list": [], + "online_block_list": [], + "challenge_timeout": 30 + } + }) + mock_user_profile: UserProfileType = {"username": "testbot", "perfs": {}} + + # Create matchmaking instance + matchmaking = Matchmaking(mock_li, mock_config, mock_user_profile) + + # Create config with a specific value + test_config = Configuration({"challenge_variant": "atomic"}) + + # Test that it returns the specific value, not a random choice + choices = ["standard", "chess960", "atomic", "horde"] + result = matchmaking.get_random_config_value(test_config, "challenge_variant", choices) + + assert result == "atomic", f"Expected 'atomic' but got '{result}'" + + +def test_get_random_config_value__returns_from_choices_when_random() -> None: + """Test that get_random_config_value returns a value from choices when config value is 'random'.""" + # Create mock objects + mock_li = Mock() + mock_config = Configuration({ + "challenge": {"variants": ["standard"]}, + "matchmaking": { + "allow_matchmaking": False, + "block_list": [], + "online_block_list": [], + "challenge_timeout": 30 + } + }) + mock_user_profile: UserProfileType = {"username": "testbot", "perfs": {}} + + # Create matchmaking instance + matchmaking = Matchmaking(mock_li, mock_config, mock_user_profile) + + # Create config with "random" value + test_config = Configuration({"challenge_mode": "random"}) + + # Test that it returns one of the choices + choices = ["casual", "rated"] + result = matchmaking.get_random_config_value(test_config, "challenge_mode", choices) + + assert result in choices, f"Expected result to be in {choices} but got '{result}'" diff --git a/lichess-bot-master/test_bot/test_model.py b/lichess-bot-master/test_bot/test_model.py new file mode 100644 index 0000000000000000000000000000000000000000..9c98bbbed3c9c2ab060ecd396e6fb76a0cfd5cdf --- /dev/null +++ b/lichess-bot-master/test_bot/test_model.py @@ -0,0 +1,185 @@ +"""Tests for the models.""" + +import datetime +from lib import model +import yaml +from lib import config +from collections import defaultdict, Counter +from lib.blocklist import OnlineBlocklist +from lib.timer import Timer +from lib.lichess_types import ChallengeType, UserProfileType, GameEventType, PlayerType + + +def test_challenge() -> None: + """Test the challenge model.""" + challenge: ChallengeType = {"id": "zzzzzzzz", "url": "https://lichess.org/zzzzzzzz", "status": "created", + "challenger": {"id": "c", "name": "c", "rating": 2000, "title": None, "online": True}, + "destUser": {"id": "b", "name": "b", "rating": 3000, "title": "BOT", "online": True}, + "variant": {"key": "standard", "name": "Standard", "short": "Std"}, "rated": False, + "speed": "bullet", + "timeControl": {"type": "clock", "limit": 90, "increment": 1, "show": "1.5+1"}, + "color": "random", "finalColor": "white", "perf": {"icon": "\ue032", "name": "Bullet"}} + user_profile: UserProfileType = {"id": "b", "username": "b", + "perfs": {"bullet": {"games": 100, "rating": 3000, "rd": 150, "prog": -10}, + "blitz": {"games": 100, "rating": 3000, "rd": 150, "prog": -10}, + "rapid": {"games": 100, "rating": 3000, "rd": 150, "prog": -10}, + "classical": {"games": 100, "rating": 3000, "rd": 150, "prog": -10}, + "correspondence": {"games": 100, "rating": 3000, "rd": 150, "prog": -10}, + "antichess": {"games": 100, "rating": 3000, "rd": 150, "prog": -10, + "prov": True}}, + "title": "BOT", "createdAt": 1500000000000, + "profile": {"bio": "This is my bio", + "links": "https://github.com/lichess-bot-devs/lichess-bot"}, + "seenAt": 1700000000000, "playTime": {"total": 1000000, "tv": 10000}, + "url": "https://lichess.org/@/b", + "count": {"all": 600, "rated": 500, "ai": 50, "draw": 200, "drawH": 50, "loss": 50, + "lossH": 50, "win": 250, "winH": 200, "bookmark": 0, "playing": 0, + "import": 0, "me": 0}, + "followable": True, "following": False, "blocking": False} + + with open("./config.yml.default") as file: + CONFIG = yaml.safe_load(file) + CONFIG["token"] = "" + CONFIG["challenge"]["allow_list"] = [] + CONFIG["challenge"]["block_list"] = [] + CONFIG["challenge"]["min_rating"] = 0 + CONFIG["challenge"]["max_rating"] = 4000 + CONFIG["challenge"]["rating_difference"] = None + configuration = config.Configuration(CONFIG).challenge + recent_challenges: defaultdict[str, list[Timer]] = defaultdict() + recent_challenges["c"] = [] + online_block_list = OnlineBlocklist([]) + + challenge_model = model.Challenge(challenge, user_profile) + assert challenge_model.id == "zzzzzzzz" + assert challenge_model.rated is False + assert challenge_model.variant == "standard" + assert challenge_model.speed == "bullet" + assert challenge_model.time_control["show"] == "1.5+1" + assert challenge_model.color == "white" + supported = challenge_model.is_supported(configuration, recent_challenges, Counter(), online_block_list, user_profile) + assert supported == (True, "") + + CONFIG["challenge"]["min_base"] = 120 + assert challenge_model.is_supported(configuration, recent_challenges, Counter(), online_block_list, user_profile) == ( + False, + "timeControl", + ) + + +def test_challenge_rating_filters() -> None: + """Test challenge rating filtering for incoming challenges.""" + challenge: ChallengeType = {"id": "zzzzzzzz", "url": "https://lichess.org/zzzzzzzz", "status": "created", + "challenger": {"id": "c", "name": "c", "rating": 2000, "title": None, "online": True}, + "destUser": {"id": "b", "name": "b", "rating": 3000, "title": "BOT", "online": True}, + "variant": {"key": "standard", "name": "Standard", "short": "Std"}, "rated": False, + "speed": "bullet", + "timeControl": {"type": "clock", "limit": 90, "increment": 1, "show": "1.5+1"}, + "color": "random", "finalColor": "white", "perf": {"icon": "\ue032", "name": "Bullet"}} + user_profile: UserProfileType = {"id": "b", "username": "b", + "perfs": {"bullet": {"games": 100, "rating": 3000, "rd": 150, "prog": -10}}, + "title": "BOT"} + + with open("./config.yml.default") as file: + CONFIG = yaml.safe_load(file) + CONFIG["token"] = "" + CONFIG["challenge"]["allow_list"] = [] + CONFIG["challenge"]["block_list"] = [] + CONFIG["challenge"]["min_rating"] = 0 + CONFIG["challenge"]["max_rating"] = 4000 + CONFIG["challenge"]["rating_difference"] = None + configuration = config.Configuration(CONFIG).challenge + recent_challenges: defaultdict[str, list[Timer]] = defaultdict() + recent_challenges["c"] = [] + online_block_list = OnlineBlocklist([]) + + challenge_model = model.Challenge(challenge, user_profile) + + # Default config should accept all ratings + supported = challenge_model.is_supported(configuration, recent_challenges, Counter(), online_block_list, user_profile) + assert supported == (True, "") + + # Test max_rating filter + CONFIG["challenge"]["max_rating"] = 1500 + assert challenge_model.is_supported(configuration, recent_challenges, Counter(), online_block_list, user_profile) == ( + False, "generic") + + # Test min_rating filter + CONFIG["challenge"]["max_rating"] = 4000 + CONFIG["challenge"]["min_rating"] = 2500 + assert challenge_model.is_supported(configuration, recent_challenges, Counter(), online_block_list, user_profile) == ( + False, "generic") + + # Test rating_difference filter (bot is 3000, challenger is 2000, diff is 1000) + CONFIG["challenge"]["min_rating"] = 0 + CONFIG["challenge"]["rating_difference"] = 500 + assert challenge_model.is_supported(configuration, recent_challenges, Counter(), online_block_list, user_profile) == ( + False, "generic") + + # Rating difference large enough to accept + CONFIG["challenge"]["rating_difference"] = 1500 + supported = challenge_model.is_supported(configuration, recent_challenges, Counter(), online_block_list, user_profile) + assert supported == (True, "") + + # Test that rating_difference narrows the range + # min_rating=0, max_rating=4000, but diff=500 from bot rating 3000 + CONFIG["challenge"]["rating_difference"] = 500 + CONFIG["challenge"]["min_rating"] = 0 + CONFIG["challenge"]["max_rating"] = 4000 + assert challenge_model.is_supported(configuration, recent_challenges, Counter(), online_block_list, user_profile) == ( + False, "generic") + + # Test with AI opponent (no rating) - should always accept + CONFIG["challenge"]["rating_difference"] = None + CONFIG["challenge"]["max_rating"] = 1000 + ai_challenge: ChallengeType = {**challenge, + "challenger": {"id": "ai", "name": "AI level 5", "aiLevel": 5}} + ai_challenge_model = model.Challenge(ai_challenge, user_profile) + assert ai_challenge_model.is_supported_rating(configuration, user_profile) is True + + +def test_game() -> None: + """Test the game model.""" + game: GameEventType = {"id": "zzzzzzzz", "variant": {"key": "standard", "name": "Standard", "short": "Std"}, + "speed": "bullet", "perf": {"name": "Bullet"}, "rated": False, "createdAt": 1700000000000, + "white": {"id": "c", "name": "c", "title": None, "rating": 2000}, + "black": {"id": "b", "name": "b", "title": "BOT", "rating": 3000}, + "initialFen": "startpos", "clock": {"initial": 90000, "increment": 1000}, "type": "gameFull", + "state": {"type": "gameState", "moves": "", "wtime": 90000, "btime": 90000, "winc": 1000, + "binc": 1000, "status": "started"}} + username = "b" + base_url = "https://lichess.org/" + abort_time = datetime.timedelta(seconds=30) + + game_model = model.Game(game, username, base_url, abort_time) + assert game_model.id == "zzzzzzzz" + assert game_model.mode == "casual" + assert game_model.is_white is False + assert game_model.my_color == "black" + assert game_model.url() == "https://lichess.org/zzzzzzzz/black" + assert game_model.short_url() == "https://lichess.org/zzzzzzzz" + assert game_model.pgn_event() == "Casual Bullet game" + assert game_model.time_control() == "90+1" + assert game_model.is_abortable() is True + + +def test_player() -> None: + """Test the player model.""" + player: PlayerType = {"id": "b", "name": "b", "rating": 3000, "title": "BOT", "online": True} + player_model = model.Player(player) + assert player_model.is_bot is True + assert str(player_model) == "BOT b (3000)" + + +def test_is_chess_960() -> None: + """Test the is_chess_960 function.""" + items = [ + {"fen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", "type": "standard", "is_960": False}, + {"fen": "brnkrqnb/pppppppp/8/8/8/8/PPPPPPPP/BRNKRQNB w KQkq - 0 1", "type": "960", "is_960": True}, # pos1 + {"fen": "nrbbnkqr/pppppppp/8/8/8/8/PPPPPPPP/NRBBNKQR w KQkq - 0 1", "type": "960", "is_960": True}, # pos2 + ] + for item in items: + fen = str(item["fen"]) + expected = item["is_960"] + result = model.is_chess_960(fen) + assert result == expected diff --git a/lichess-bot-master/test_bot/test_timer.py b/lichess-bot-master/test_bot/test_timer.py new file mode 100644 index 0000000000000000000000000000000000000000..62a87a5c6340a3c28eafa07f6a798290323a5434 --- /dev/null +++ b/lichess-bot-master/test_bot/test_timer.py @@ -0,0 +1,83 @@ +"""Test functions dedicated to time measurement and conversion.""" + +from datetime import timedelta +from lib import timer + + +def test_time_conversion() -> None: + """Test conversion of time units.""" + assert timer.msec(1000) == timedelta(milliseconds=1000) + assert timer.to_msec(timedelta(milliseconds=1000)) == 1000 + + assert timer.msec_str(timedelta(milliseconds=1000)) == "1000" + + assert timer.seconds(1) == timedelta(seconds=1) + assert timer.to_seconds(timedelta(seconds=1)) == 1 + + assert timer.sec_str(timedelta(seconds=1)) == "1" + + assert timer.minutes(1) == timedelta(minutes=1) + assert timer.hours(1) == timedelta(hours=1) + assert timer.days(1) == timedelta(days=1) + assert timer.years(1) == timedelta(days=365) + + assert timer.to_msec(timer.seconds(1)) == 1000 + assert timer.to_seconds(timer.minutes(1)) == 60 + assert timer.to_seconds(timer.hours(1)) == 60*60 + assert timer.to_seconds(timer.days(1)) == 24*60*60 + assert timer.to_seconds(timer.years(1)) == 365*24*60*60 + + +def test_init() -> None: + """Test Timer class init.""" + t = timer.Timer() + assert t.duration == timedelta(0) + assert t.starting_time is not None + + duration = timedelta(seconds=10) + t = timer.Timer(duration) + assert t.duration == duration + assert t.starting_time is not None + + +def test_is_expired() -> None: + """Test timer expiration.""" + t = timer.Timer(timedelta(seconds=10)) + assert not t.is_expired() + + t = timer.Timer(timedelta(seconds=0)) + assert t.is_expired() + + t = timer.Timer(timedelta(seconds=10)) + t.reset() + t.starting_time -= 10 + assert t.is_expired() + +def test_reset() -> None: + """Test timer reset.""" + t = timer.Timer(timedelta(seconds=10)) + t.reset() + assert t.starting_time is not None + assert timer.sec_str(t.time_since_reset()) == timer.sec_str(timedelta(0)) + +def test_time() -> None: + """Test time measurement, expiration, and time until expiration.""" + t = timer.Timer(timedelta(seconds=10)) + t.starting_time -= 5 + assert timer.sec_str(t.time_since_reset()) == timer.sec_str(timedelta(seconds=5)) + + t = timer.Timer(timedelta(seconds=10)) + t.starting_time -= 5 + assert timer.sec_str(t.time_until_expiration()) == timer.sec_str(timedelta(seconds=5)) + + t = timer.Timer(timedelta(seconds=10)) + t.starting_time -= 15 # Simulate time passing + assert t.time_until_expiration() == timedelta(0) + + t = timer.Timer(timedelta(seconds=10)) + t.starting_time -= 15 + assert t.time_until_expiration() == timedelta(0) + + t = timer.Timer(timedelta(seconds=10)) + t.starting_time -= 5 + assert timer.sec_str(t.time_until_expiration()) == timer.sec_str(timedelta(seconds=5)) diff --git a/lichess-bot-master/test_bot/uci_engine.py b/lichess-bot-master/test_bot/uci_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..cb7fbc67a2b3bd9eb459cef43efa43451b592f01 --- /dev/null +++ b/lichess-bot-master/test_bot/uci_engine.py @@ -0,0 +1,42 @@ +"""An engine mimics a UCI engine.""" + +import chess +import typing + +if typing.TYPE_CHECKING: + from test_bot.test_games import scholars_mate +else: + from test_games import scholars_mate + +assert input() == "uci" + + +def send_command(command: str) -> None: + """Send UCI commands to lichess-bot without output buffering.""" + print(command, flush=True) # noqa: T201 (print() found) + + +send_command("id name UCI_Test_Bot") +send_command("id author lichess-bot-devs") +send_command("uciok") + +board = chess.Board() +while True: + command, *remaining = input().split() + if command == "quit": + break + elif command == "isready": + send_command("readyok") + elif command == "position": + spec_type, *remaining = remaining + assert spec_type == "startpos" + board = chess.Board() + if remaining: + moves_label, *move_list = remaining + assert moves_label == "moves" + for move in move_list: + board.push_uci(move) + elif command == "go": + move_count = len(board.move_stack) + move = scholars_mate[move_count] + send_command(f"bestmove {move}") diff --git a/lichess-bot-master/test_bot/xboard_engine.py b/lichess-bot-master/test_bot/xboard_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..50a969aea3ffd2d36cdc2e2719fcac8130642c8b --- /dev/null +++ b/lichess-bot-master/test_bot/xboard_engine.py @@ -0,0 +1,36 @@ +"""An engine mimics an XBoard engine.""" + +import chess +import typing + +if typing.TYPE_CHECKING: + from test_bot.test_games import scholars_mate +else: + from test_games import scholars_mate + +assert input() == "xboard" +assert input() == "protover 2" + + +def send_command(command: str) -> None: + """Send XBoard commands to lichess-bot without output buffering.""" + print(command, flush=True) # noqa: T201 (print() found) + + +send_command('feature myname="XBoard Test Bot" ping=1 setboard=1 usermove=1 done=1') + +board = chess.Board() +while True: + command, *remaining = input().split() + if command == "quit": + break + elif command == "ping": + send_command(f"pong {''.join(remaining)}") + elif command == "new": + board = chess.Board() + elif command == "usermove": + board.push_xboard("".join(remaining)) + move_count = len(board.move_stack) + move = scholars_mate[move_count] + send_command(f"move {move}") + board.push_xboard(move) diff --git a/lichess-bot-master/wiki/Configure-lichess-bot.md b/lichess-bot-master/wiki/Configure-lichess-bot.md new file mode 100644 index 0000000000000000000000000000000000000000..75d232c2de3b3164ccd78c6574ee0090897ed7a1 --- /dev/null +++ b/lichess-bot-master/wiki/Configure-lichess-bot.md @@ -0,0 +1,331 @@ +# Configuring lichess-bot +There are many possible options within `config.yml` for configuring lichess-bot. + +## Note on daily game limits + +Lichess allows a bot to play 100 games against other bots in a single day (games against humans are unlimited). Several settings below can influence how quickly this game allowance is used up. These include the following: +- `challenge:` + - `concurrency`: Playing multiple simultaneous games will use up games faster. + - `accept_bot` and `only_bot`: Games against other bots use up the allotted games. + - `min_increment`, `min_base`, and `time_controls`: Shorter games use up the allotted games faster. + - `recent_bot_challenge_age`, `max_recent_bot_challenges`, `max_simultaneous_games_per_user`: To prevent a single bot from using up all your bot's games. +- `matchmaking:` + - `allow_during_games`: Starting simultaneous games uses up games faster. + - `challenge_timeout`: Longer timeouts between games will spread out games over a day. + - `challenge_initial_time` and `challenge_increment`: Shorter games use up the allotted games faster. + +## Engine options +- `interpreter`: Specify whether your engine requires an interpreter to run (e.g. `java`, `python`). +- `interpreter_options`: A list of options passed to the interpreter (e.g. `-jar` for `java`). +- `debug`: When enabled, log all startup communication with the engine. +- `protocol`: Specify which protocol your engine uses. Choices are: + 1. `"uci"` for the [Universal Chess Interface](https://wbec-ridderkerk.nl/html/UCIProtocol.html) + 2. `"xboard"` for the XBoard/WinBoard/[Chess Engine Communication Protocol](https://www.gnu.org/software/xboard/engine-intf.html) + 3. `"homemade"` if you want to write your own engine in Python within lichess-bot. See [**Create a homemade engine**](https://github.com/lichess-bot-devs/lichess-bot/wiki/Create-a-homemade-engine). +- `ponder`: Specify whether your bot will ponder--i.e., think while the bot's opponent is choosing a move. +- `engine_options`: Command line options to pass to the engine on startup. For example, the `config.yml.default` has the configuration +```yml + engine_options: + cpuct: 3.1 +``` +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. +- `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: +``` +id name Stockfish 13 +id author the Stockfish developers (see AUTHORS file) + +option name Debug Log File type string default +option name Contempt type spin default 24 min -100 max 100 +option name Analysis Contempt type combo default Both var Off var White var Black var Both +option name Threads type spin default 1 min 1 max 512 +option name Hash type spin default 16 min 1 max 33554432 +option name Clear Hash type button +option name Ponder type check default false +option name MultiPV type spin default 1 min 1 max 500 +option name Skill Level type spin default 20 min 0 max 20 +option name Move Overhead type spin default 10 min 0 max 5000 +option name Slow Mover type spin default 100 min 10 max 1000 +option name nodestime type spin default 0 min 0 max 10000 +option name UCI_Chess960 type check default false +option name UCI_AnalyseMode type check default false +option name UCI_LimitStrength type check default false +option name UCI_Elo type spin default 1350 min 1350 max 2850 +option name UCI_ShowWDL type check default false +option name SyzygyPath type string default +option name SyzygyProbeDepth type spin default 1 min 1 max 100 +option name Syzygy50MoveRule type check default true +option name SyzygyProbeLimit type spin default 7 min 0 max 7 +option name Use NNUE type check default true +option name EvalFile type string default nn-62ef826d1a6d.nnue +uciok +``` +Any of the names following `option name` can be listed in `uci_options` in order to configure the Stockfish engine. +```yml + uci_options: + Move Overhead: 100 + Skill Level: 10 +``` +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. + +One last option is `go_commands`. Beneath this option, arguments to the UCI `go` command can be passed. For example, +```yml + go_commands: + nodes: 1 + depth: 5 + movetime: 1000 +``` +will append `nodes 1 depth 5 movetime 1000` to the command to start thinking of a move: `go startpos e2e4 e7e5 ...`. + +- `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 +``` +feature option="Add Noise -check VALUE" +feature option="PGN File -string VALUE" +feature option="CPU Count -spin VALUE MIN MAX" +``` +Any of the options can be listed under `xboard_options` in order to configure the XBoard engine. +```yml + xboard_options: + Add Noise: False + PGN File: lichess_games.pgn + CPU Count: 1 +``` +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. + +One last option is `go_commands`. Beneath this option, commands prior to the `go` command can be passed. For example, +```yml + go_commands: + depth: 5 +``` +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. + +## External moves +- `polyglot`: Tell lichess-bot whether your bot should use an opening book. Multiple books can be specified for each chess variant. + - `enabled`: Whether to use the book at all. + - `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. + - `min_weight`: The minimum weight or quality a move must have if it is to have a chance of being selected (doesn't apply for `selection="weighted_random"`). If a move cannot be found that has at least this weight, no move will be selected. If `normalization` is `"none"` then `min_weight` takes values from 0 to 65535, but if `normalization` is `sum` or `max` then it takes values from 0 to 100. + - `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. + - `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. + - `normalization`: The normalization method applied to the weights of the moves. The choices are: `"none"` where no normalization is applied, `"sum"` where the weights are normalized to sum up to 100, and `"max"` where the weights are normalized so that the maximum weight is 100. +- `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: + 1. `chessdb_book`: Consults a [Chinese chess position database](https://www.chessdb.cn/), which also hosts a xiangqi database. + 2. `lichess_cloud_analysis`: Consults [Lichess's own position analysis database](https://lichess.org/api#operation/apiCloudEval). + 3. `lichess_opening_explorer`: Consults [Lichess's opening explorer](https://lichess.org/api#tag/Opening-Explorer). + 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. + - `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. + - `max_retries`: The maximum amount of retries when getting an online move. + - `max_depth`: The maximum number of moves a bot can make in the opening before it stops consulting the online opening books. If `max_depth` is 5, then the bot will stop consulting the online books after its fifth move. + - Configurations common to all: + - `enabled`: Whether to use the database at all. + - `min_time`: The minimum time in seconds on the game clock necessary to allow the online database to be consulted. + - `max_time`: The maximum starting game time in seconds on the game clock necessary to allow the online database to be consulted. + - `move_quality`: Choice of `"all"` (`chessdb_book` only), `"good"` (all except `online_egtb`), `"best"`, or `"suggest"` (`online_egtb` only). + - `all`: Choose a random move from all legal moves. + - `best`: Choose only the highest scoring move. + - `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. + - `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. + - Configurations only in `chessdb_book` and `lichess_cloud_analysis`: + - `min_depth`: The minimum search depth for a move evaluation for a database move to be accepted. + - Configurations only in `lichess_cloud_analysis`: + - `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. + - `min_knodes`: The minimum number of kilonodes to search. The minimum number of nodes to search is this value times 1000. + - Configurations only in `lichess_opening_explorer`: + - `source`: One of `lichess`, `masters`, or `player`. Whether to use move statistics from masters, lichess players, or a specific player. + - `player_name`: Used only when `source` is `player`. The username of the player to use for move statistics. + - `sort`: One of `winrate` or `games_played`. Whether to choose the best move according to the winrate or the games played. + - `min_games`: The minimum number of times a move must have been played to be considered. + - Configurations only in `online_egtb`: + - `max_pieces`: The maximum number of pieces in the current board for which the tablebase will be consulted. + - `source`: One of `chessdb` or `lichess`. Lichess also has tablebases for atomic and antichess while chessdb only has those for standard. +- `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: + 1. `syzygy`: Get moves from syzygy tablebases. `.*tbw` have to be always provided. Syzygy TBs are generally smaller that gaviota TBs. + 2. `gaviota`: Get moves from gaviota tablebases. + - Configurations common to all: + - `enabled`: Whether to use the tablebases at all. + - `paths`: The paths to the tablebases. + - `max_pieces`: The maximum number of pieces in the current board for which the tablebase will be consulted. + - `move_quality`: Choice of `best` or `suggest`. + - `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`. + - `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. + - Configurations only in `gaviota`: + - `min_dtm_to_consider_as_wdl_1`: The minimum DTM to consider as syzygy WDL=1/-1. Setting it to 100 will disable it. + +## Offering draw and resigning +- `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. + - `resign_enabled`: Whether the bot is allowed to resign based on the evaluation. + - `resign_score`: The engine evaluation has to be less than or equal to `resign_score` for the bot to resign. + - `resign_for_egtb_minus_two`: If true the bot will resign in positions where the online_egtb returns a wdl of -2. + - `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. + - `offer_draw_enabled`: Whether the bot is allowed to offer/accept draw based on the evaluation. + - `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. + - `offer_draw_for_egtb_zero`: If true the bot will offer/accept draw in positions where the online_egtb returns a wdl of 0. + - `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. + - `offer_draw_pieces`: The bot only offers/accepts draws if the position has less than or equal to `offer_draw_pieces` pieces. + + Note: If a game reaches 300 moves and no checkmate is delivered on move 300, it is adjudicated as a draw and shows up in the logs as _draw by agreement_ (see [this discussion](https://lichess.org/forum/general-chess-discussion/lichess-300-move-rule-forced-draw) and [this commit](https://github.com/lichess-org/lila/commit/f8921999115878a98431cd722b267281793b7f6f)). That's a lichess-specific behavior which doesn't depend on lichess-bot version or configuration. + +## Options for correspondence games +- `correspondence` These options control how the engine behaves during correspondence games. + - `move_time`: How many seconds to think for each move. + - `checkin_period`: How often (in seconds) to reconnect to games to check for new moves after disconnecting. + - `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. + - `ponder`: Whether the bot should ponder during the above waiting period. + +## Challenges the BOT should accept +- `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. + - `concurrency`: The maximum number of games to play simultaneously. + - `sort_by`: Whether to start games by the best rated/titled opponent `"best"` or by first-come-first-serve `"first"`. + - `preference`: Whether to prioritize human opponents, bot opponents, or treat them equally. + - `accept_bot`: Whether to accept challenges from other bots. + - `only_bot`: Whether to only accept challenges from other bots. + - `max_increment`: The maximum value of time increment. + - `min_increment`: The minimum value of time increment. + - `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. + - `max_base`: The maximum base time for a game. + - `min_base`: The minimum base time for a game. + - `max_days`: The maximum number of days for a correspondence game. + - `min_days`: The minimum number of days for a correspondence game. + - `variants`: An indented list of chess variants that the bot can handle. +```yml + variants: + - standard + - horde + - antichess + # etc. +``` + - `time_controls`: An indented list of acceptable time control types from `bullet` to `correspondence` (bots are not allowed to play `ultraBullet`). +```yml + time_controls: + - bullet + - blitz + - rapid + - classical + - correspondence +``` + - `modes`: An indented list of acceptable game modes (`rated` and/or `casual`). +```yml + modes: + -rated + -casual +``` + - `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. + - `online_block_list`: An indented list of urls from which additional block lists are retrieved. An online block list is a plain text file where each line contains a single username. If this option is not present, then the list is considered empty. + - `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. + - `recent_bot_challenge_age`: Maximum age of a bot challenge to be considered recent in seconds + - `max_recent_bot_challenges`: Maximum number of recent challenges that can be accepted from the same bot + - `max_simultaneous_games_per_user`: Maximum number of games that can be played simultaneously with the same user + - `min_rating`: The minimum rating of the challenger to accept a challenge. The default is 0. + - `max_rating`: The maximum rating of the challenger to accept a challenge. The maximum rating in lichess is 4000. The default is 4000. + - `rating_difference`: The maximum difference in rating between the bot's rating and the challenger's rating. If set, this further restricts the range defined by `min_rating` and `max_rating`. + +## Greeting +- `greeting`: Send messages via chat to the bot's opponent. The string `{me}` will be replaced by the bot's lichess account name. The string `{opponent}` will be replaced by the opponent's lichess account name. Any other word between curly brackets will be removed. If you want to put a curly bracket in the message, use two: `{{` or `}}`. + - `hello`: Message to send to the opponent when the bot makes its first move. + - `goodbye`: Message to send to the opponent once the game is over. + - `hello_spectators`: Message to send to the spectators when the bot makes its first move. + - `goodbye_spectators`: Message to send to the spectators once the game is over. +```yml + greeting: + hello: Hi, {opponent}! I'm {me}. Good luck! + goodbye: Good game! + 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 + goodbye_spectators: "Thanks for watching!" # Message to send to spectator chat at the end of a game +``` + +## Other options +- `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. +- `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. +- `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. +- `move_overhead`: To prevent losing on time due to network lag, subtract this many milliseconds from the time to think on each move. +- `max_takebacks_accepted`: Specify the number of times an opponent is allowed to take back their move in a single game. + - In order for an opponent to be able to request move takebacks, the bot's lichess preferences must be set to accept them. + 1. Sign into the bot's account on the lichess website. + 2. Go to the [`Game behavior`](https://lichess.org/account/preferences/game-behavior) section of the bot's preferences page. + 3. Under `Takebacks (with opponent approval)`, select `Always` or `In casual games only`. + - Note: bots requesting a move takeback (whether through lichess-bot or through the lichess website) is not supported. +- `quit_after_all_games_finish`: If this is set to `true`, then pressing Ctrl-c to quit will cause lichess-bot to terminate after all in-progress games are finished. No new challenges will be sent or accepted, nor will any correspondence games be checked on. If `false` (the default), lichess-bot will terminate immediately and not wait to finish games in progress. If this value is `true` and you find that you need to quit immediately, press Ctrl-c twice. +- `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. +- `pgn_file_grouping`: Determine how games are written to files. There are three options: + - `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`. + - `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`. + - `all`: All games are written to the same file. The file name is `{Bot name} games.pgn`. +```yml + pgn_directory: "game_records" + pgn_file_grouping: "all" +``` + +## Challenging other bots +- `matchmaking`: Challenge a random bot. + - `allow_matchmaking`: Whether to challenge other bots. + - `allow_during_games`: Whether to issue new challenges while the bot is already playing games. If true, no more than 10 minutes will pass between matchmaking challenges. + - `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. + - `challenge_timeout`: The time (in minutes) the bot has to be idle before it creates a challenge. + + Bots are limited to playing 100 games against other bots per day, so setting `challenge_timeout` to a small value will result in this limit being reached quickly. There is no limit to the number of games a bot can play against humans. + + - `challenge_initial_time`: A list of initial times (in seconds and to be chosen at random) for the challenges. + - `challenge_increment`: A list of increments (in seconds and to be chosen at random) for the challenges. + - `challenge_days`: A list of number of days for a correspondence challenge (to be chosen at random). + - `opponent_min_rating`: The minimum rating of the opponent bot. The minimum rating in lichess is 600. + - `opponent_max_rating`: The maximum rating of the opponent bot. The maximum rating in lichess is 4000. + - `opponent_rating_difference`: The maximum difference between the bot's rating and the opponent bot's rating. + - `rating_preference`: Whether the bot should prefer challenging high or low rated players, or have no preference. + - `challenge_mode`: Possible options are `casual`, `rated` and `random`. + - `challenge_filter`: Whether and how to prevent challenging a bot after that bot declines a challenge. Options are `none`, `coarse`, and `fine`. + - `none` does not prevent challenging a bot that declined a challenge. + - `coarse` will prevent challenging a bot to any type of game after it declines one challenge. + - `fine` will prevent challenging a bot to the same kind of game that was declined. + + 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. + - `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. + - `online_block_list`: An indented list of urls from which additional block lists are retrieved. An online block list is a plain text file where each line contains a single username. If this option is not present, then the list is considered empty. + - `include_challenge_block_list`: If `true`, do not send challenges to the bots listed in the `challenge: block_list`. Default is `false`. + - `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.

+ The overrides section must have the following: + - 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. + - 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. + + 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. + + The following configurations cannot be overridden: `allow_matchmaking`, `challenge_timeout`, `challenge_filter` and `block_list`. + - Additional Points: + - 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. + - 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. + +```yml +matchmaking: + allow_matchmaking: false + challenge_variant: "random" + challenge_timeout: 30 + challenge_initial_time: + - 60 + - 120 + challenge_increment: + - 1 + - 2 + challenge_days: + - 1 + - 2 +# opponent_min_rating: 600 +# opponent_max_rating: 4000 + opponent_rating_difference: 100 + challenge_mode: "random" + challenge_filter: none + overrides: + easy_chess960: + challenge_variant: "chess960" + opponent_min_rating: 400 + opponent_max_rating: 1200 + opponent_rating_difference: + challenge_mode: casual + no_pressure_correspondence: + challenge_initial_time: + challenge_increment: + challenge_days: + - 2 + - 3 + challenge_mode: casual +``` + +**Next step**: [Upgrade to a BOT account](https://github.com/lichess-bot-devs/lichess-bot/wiki/Upgrade-to-a-BOT-account) + +**Previous step**: [Setup the engine](https://github.com/lichess-bot-devs/lichess-bot/wiki/Setup-the-engine) diff --git a/lichess-bot-master/wiki/Create-a-homemade-engine.md b/lichess-bot-master/wiki/Create-a-homemade-engine.md new file mode 100644 index 0000000000000000000000000000000000000000..80c4fa9d1c0e4e77098b6436a400debffceaad5e --- /dev/null +++ b/lichess-bot-master/wiki/Create-a-homemade-engine.md @@ -0,0 +1,13 @@ +## Creating a homemade engine +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. + +Steps to create a homemade bot: + +1. Do all the steps in the [How to Install](./How-to-Install) +2. In the `config.yml`, change the engine protocol to `homemade` +3. Create a class in `homemade.py` that extends `MinimalEngine`. +4. Create a method called `search()` with an argument `board` that chooses a legal move from the board. + - There are examples in `homemade.py` to help you get started. These examples show different ways to implement the `search()` method and the proper way to return the chosen move. +5. In the `config.yml`, change the name from `engine_name` to the name of your class + - In this case, you could change it to: + `name: "RandomMove"` diff --git a/lichess-bot-master/wiki/Extra-customizations.md b/lichess-bot-master/wiki/Extra-customizations.md new file mode 100644 index 0000000000000000000000000000000000000000..f62f34dcc28b0851cc5f3653e2f62692f9ac86af --- /dev/null +++ b/lichess-bot-master/wiki/Extra-customizations.md @@ -0,0 +1,46 @@ +## Extra customizations + +If your bot has more complex requirements than can be expressed in the configuration file, edit the file named `extra_game_handlers.py` in the main lichess-bot directory. +Within this file, write whatever code is needed. + +Each section below describes a customization. +Only one function is needed to make that customization work. +However, if writing other functions makes implementing the customization easier, do so. +Only the named function will be used in lichess-bot. + + +### Filtering challenges + +The function `is_supported_extra()` allows for finer control over which challenges from other players are accepted. +It should use the data in the `Challenge` argument (see `lib/model.py`) and return `True` to accept the challenge or `False` to reject it. +As an example, here's a version that will only only accept games where the bot plays black: +``` python +def is_supported_extra(challenge): + return challenge.color == "white" +``` +For another example, this function will reject any board that contains queens: +``` python +def is_supported_extra(challenge): + # https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notation + starting_position = challenge.initial_fen + return starting_position != "startpos" and "Q" not in starting_position.upper() +``` +The body of the function can be as complex as needed and combine any conditions to suit the needs of the bot. +Information within the `Challenge` instance is detailed in the [Lichess API documentation](https://lichess.org/api#tag/Bot/operation/apiStreamEvent) (click on 200 under Responses, then select `ChallengeEvent` under Response Schema and expand the `challenge` heading). + +### Tailoring engine options + +The function `game_specific_options()` can modify the engine options for UCI and XBoard engines based on aspects of the game about to be played. +It use the data in the `Game` argument (see `lib/model.py`) and return a dictionary of `str` to values. +This dictionary will add or replace values in the `uci_options` or `xboard_options` section of the bot's configuration file. +For example, this version of the function will changes the move overhead value for longer games: +``` python +from datetime import timedelta + +def game_specific_options(game): + if game.clock_initial >= timedelta(minutes=5): + return {"Move Overhead": 5000} + else: + return {} +``` +Returning an empty dictionary leaves the engine options unchanged. diff --git a/lichess-bot-master/wiki/How-to-Install.md b/lichess-bot-master/wiki/How-to-Install.md new file mode 100644 index 0000000000000000000000000000000000000000..73622cd40a1c8c8259b99ecd5cda8a4bb9df48d3 --- /dev/null +++ b/lichess-bot-master/wiki/How-to-Install.md @@ -0,0 +1,62 @@ +### Linux +- **NOTE: Only Python 3.10 or later is supported!** +- Download the repo into lichess-bot directory. +- Navigate to the directory in cmd/Terminal: `cd lichess-bot`. +- Install dependencies: `apt install python3 python3-pip python3-virtualenv python3-venv`. + - In non-Ubuntu linux distros, replace `apt` with the correct package manager (`pacman` in Arch, `dnf` in Fedora, etc.), package name, and installation command. +- Run the following commands to set up a virtual environment: +``` +python3 -m venv venv # If this fails you probably need to add Python3 to your PATH. +virtualenv venv -p python3 +source ./venv/bin/activate +python3 -m pip install -r requirements.txt +``` +- Copy `config.yml.default` to `config.yml`. + +**Next step**: [Create a Lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token) + +### Mac/BSD +- **NOTE: Only Python 3.10 or later is supported!** +- Install Python and other dependencies using the [homebrew package manager](https://brew.sh/): + - ` brew install python3 virtualenv # Net-/FreeBSD users might want to install: git, python311, py311-pip and py311-virtualenv.` +- Download the repo into lichess-bot directory. +- Navigate to the directory in cmd/Terminal: `cd lichess-bot`. +``` +python3 -m venv venv # If this fails you probably need to add Python3 to your PATH. +virtualenv venv -p python3 +. venv/bin/activate +python3 -m pip install -r requirements.txt +``` +- Copy `config.yml.default` to `config.yml`. + +**Next step**: [Create a Lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token) + +### Windows +- **NOTE: Only Python 3.10 or later is supported!** +- If needed, install Python: + - [Download Python here](https://www.python.org/downloads/). + - When installing, enable "add Python to PATH". + - The default local install works. +- Start Terminal, PowerShell, cmd, or your preferred command prompt. +- Upgrade pip: `py -m pip install --upgrade pip`. +- Download the repo into lichess-bot directory. +- Navigate to the directory: `cd [folder's address]` (for example, `cd C:\Users\username\repos\lichess-bot`). +- Install virtualenv: `py -m pip install virtualenv`. +- Setup virtualenv: +``` +py -m venv venv # If this fails you probably need to add Python3 to your PATH. +venv\Scripts\activate +pip install -r requirements.txt +``` +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`. +- Copy `config.yml.default` to `config.yml`. + +**Next step**: [Create a Lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token) + +### Docker +If you have a [Docker](https://www.docker.com/) host, you can use the ```lichess-bot-devs/lichess-bot``` [image in DockerHub](https://hub.docker.com/r/lichessbotdevs/lichess-bot). +It requires a folder where you have to copy `config.yml.default` to `config.yml`. + +See [Running with Docker](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-use-the-Docker-image) once you've created the OAuth token and setup the engine. + +**Next step**: [Create a Lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token) diff --git "a/lichess-bot-master/wiki/How-to-Run-lichess\342\200\220bot.md" "b/lichess-bot-master/wiki/How-to-Run-lichess\342\200\220bot.md" new file mode 100644 index 0000000000000000000000000000000000000000..b38694a5962e51c9eb0daefe8fb57c804d4fa72c --- /dev/null +++ "b/lichess-bot-master/wiki/How-to-Run-lichess\342\200\220bot.md" @@ -0,0 +1,53 @@ +## Running lichess-bot +After activating the virtual environment created in the installation steps (the `source` line for Linux and Macs or the `activate` script for Windows), run +``` +python3 lichess-bot.py +``` +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. + +To output more information (including your engine's thinking output and debugging information), the `-v` option can be passed to lichess-bot: +``` +python3 lichess-bot.py -v +``` + +If you want to disable automatic logging: +``` +python3 lichess-bot.py --disable_auto_logging +``` + +If you want to record the output to a log file, add the `-l` or `--logfile` along with a file name: +``` +python3 lichess-bot.py --logfile log.txt +``` + +If you want to specify a different config file, add the `--config` along with a file name: +``` +python3 lichess-bot.py --config config2.yml +``` + +### Running as a service +- Here's an example systemd service definition: +```ini +[Unit] +Description=lichess-bot +After=network-online.target +Wants=network-online.target + +[Service] +Environment="PYTHONUNBUFFERED=1" +ExecStart=/usr/bin/python3 /home/thibault/lichess-bot/lichess-bot.py +WorkingDirectory=/home/thibault/lichess-bot/ +User=thibault +Group=thibault +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +## Quitting +- Press `CTRL+C`. +- If `quit_after_all_games_finish` is set to `true` in your config file, lichess-bot will wait for all games to exit. Otherwise, all games will exit immediately. +- It may take several seconds for lichess-bot to quit once all games have exited. + +**Previous step**: [Upgrade to a BOT account](https://github.com/lichess-bot-devs/lichess-bot/wiki/Upgrade-to-a-BOT-account) diff --git a/lichess-bot-master/wiki/How-to-create-a-Lichess-OAuth-token.md b/lichess-bot-master/wiki/How-to-create-a-Lichess-OAuth-token.md new file mode 100644 index 0000000000000000000000000000000000000000..d54744e4c71a6f40e7406eb9b1fa4d252d1b06cd --- /dev/null +++ b/lichess-bot-master/wiki/How-to-create-a-Lichess-OAuth-token.md @@ -0,0 +1,10 @@ +# Creating a lichess OAuth token +- Create an account for your bot on [Lichess.org](https://lichess.org/signup). +- **NOTE: If you have previously played games on an existing account, you will not be able to use it as a bot account.** +- 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. +- 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`. +- **NOTE: You won't see this token again on Lichess, so do save it.** + +**Next step**: [Setup the engine](https://github.com/lichess-bot-devs/lichess-bot/wiki/Setup-the-engine) + +**Previous step**: [Install lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Install) diff --git a/lichess-bot-master/wiki/How-to-use-the-Docker-image.md b/lichess-bot-master/wiki/How-to-use-the-Docker-image.md new file mode 100644 index 0000000000000000000000000000000000000000..6eb60c0f3d0044ea5e7f55eec52fa5908cbbb35e --- /dev/null +++ b/lichess-bot-master/wiki/How-to-use-the-Docker-image.md @@ -0,0 +1,75 @@ +# How to launch your bot +## Download the docker image +The images are available through [Docker hub](https://hub.docker.com/r/lichessbotdevs/lichess-bot) and [Github container registry](https://github.com/lichess-bot-devs/lichess-bot/pkgs/container/lichess-bot). +Run `docker pull lichessbotdevs/lichess-bot` to download from docker hub, or run `docker pull ghcr.io/lichess-bot-devs/lichess-bot` if you want to download it from the Github container registry. + +**NOTE**: On Docker hub, the organization name is `lichessbotdevs` (without hyphens) while on the Github container registry it is `lichess-bot-devs`. The package name is in both cases `lichess-bot` (with hyphens). + +In the steps below replace `lichessbotdevs` with `ghcr.io/lichess-bot-devs` if you downloaded the image through the Github container registry. + +## Prepare the deployment +Create a folder where you will put your configuration file, the UCI/XBoard program that runs your engine (be aware that this program will run inside the container ... in a Linux OS) or your own [`homemade.py`](https://github.com/lichess-bot-devs/lichess-bot/wiki/Create-a-homemade-engine) and, if required, your own [`extra_game_handlers.py`](https://github.com/lichess-bot-devs/lichess-bot/wiki/Extra-customizations). + +The configuration file **must** be named `config.yml`. + +You can see an example of this file using the following command: +```docker run --rm --entrypoint=cat lichessbotdevs/lichess-bot config.yml.default```. + +You can also find documentation [here](https://github.com/lichess-bot-devs/lichess-bot/wiki/Configure-lichess-bot). + +## Run the bot + +Once your configuration files are ready, let's say in `/home/me/myEngine` folder, run the following command: +```docker run -d -v /home/me/myEngine:/lichess-bot/config --name myBot lichessbotdevs/lichess-bot``` + +**NOTE**: Prefer using absolute paths over relative paths, as relative ones may cause problems. + +That's all! + +### Warning: +- Make sure you've set the `dir` and `working_dir` attributes of your `config.yml` to the right path (remember the engine runs in the container's OS, so the path should start with `/lichess-bot/config/`). +- **If you've configured a folder to save pgn files** using [`pgn_directory`](https://github.com/lichess-bot-devs/lichess-bot/wiki/Configure-lichess-bot#other-options) that is not in `/lichess-bot/config` directory, always mount a volume to that folder. Without that, your saved games will remain unreachable from the outside of the container, and storing a lot of games in the container's file system could result in disk saturation. +- The container uses the standard docker logging system and the bot is always launched with the `--disable_auto_logging` option. + Use the `docker logs myBot` [command](https://docs.docker.com/reference/cli/docker/container/logs/) to access to the bot logs. + +## Stop and delete the bot +Use the standard commands. +If you used the command from the [Run chapter](#run-the-bot) above: ```docker stop myBot``` and ```docker rm myBot``` + +# Image variants + +The `lichess-bot` images come in two flavors, each designed for a specific use case. + +## lichess-bot:\ +This is the defacto image. It is based on the [`python:3`](https://hub.docker.com/_/python) image. +If you are unsure about what your needs are, you probably want to use this one. + +To download the latest version use `lichess-bot:latest`. + +## lichess-bot:\-alpine +This image is based on the popular Alpine Linux project, available in the alpine official image. Alpine Linux is much smaller than most distribution base images, and thus leads to a much slimmer image than the default one (80MB instead of 1GB). + +This variant is useful when final image size being as small as possible is your primary concern. The main caveat to note is that it does use musl libc instead of glibc and friends, so software will often run into issues depending on the depth of their libc requirements/assumptions. For instance, running [Stockfish](https://stockfishchess.org/) on this image requires extra libraries installation. + +To download the latest version use `lichess-bot:alpine`. + +# Some tips + +## What if my engine requires some software installation? +You will have to create a new Docker image of your own and install the required software in your `Dockerfile`. +For example to install java 17, the docker file would look like: +``` +FROM lichessbotdevs/lichess-bot:alpine + +RUN apk add --no-cache openjdk17-jre +``` +Please note that, as `lichess-bot:alpine` image is based on [Alpine](https://www.alpinelinux.org/), you'll have to install new software using the ```apk``` command. + +## What if I want to add options to ```lichess-bot.py```? + +If you want to pass some options to the ```lichess-bot.py``` executed in the container, add them in the ```OPTIONS``` environment variable. +For instance, to launch the bot in verbose mode, run the command: +```docker run -d -v /home/me/myEngine:/lichess-bot/config --env OPTIONS=-v lichessbotdevs/lichess-bot``` + +## How to know which release of lichess-bot is running? +Use the following command: ```docker run --rm --entrypoint=cat lichessbotdevs/lichess-bot lib/versioning.yml``` diff --git a/lichess-bot-master/wiki/Setup-the-engine.md b/lichess-bot-master/wiki/Setup-the-engine.md new file mode 100644 index 0000000000000000000000000000000000000000..c8197eb915af339cd5191129de2081e7d008acc8 --- /dev/null +++ b/lichess-bot-master/wiki/Setup-the-engine.md @@ -0,0 +1,32 @@ +# Setup the engine +## For all engines +Within the file `config.yml`: +- Enter the directory containing the engine executable in the `engine: dir` field. +- Enter the executable name in the `engine: name` field (In Windows you may need to type a name with ".exe", like "lczero.exe") +- 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. + - If this field is blank or missing, the current directory will be used. + - 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. + +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. + +## For Leela Chess Zero +### LeelaChessZero: Mac/Linux +- Download the weights for the id you want to play from [here](https://lczero.org/play/networks/bestnets/). +- Extract the weights from the zip archive and rename it to `latest.txt`. +- For Mac/Linux, build the lczero binary yourself following [LeelaChessZero/lc0/README](https://github.com/LeelaChessZero/lc0/blob/master/README.md). +- Copy both the files into the `engine.dir` directory. +- Change the `engine.name` and `engine.engine_options.weights` keys in `config.yml` file to `lczero` and `weights.pb.gz`. +- You can specify the number of `engine.uci_options.threads` in the `config.yml` file as well. +- To start: `python3 lichess-bot.py`. + +### LeelaChessZero: Windows CPU 2021 +- 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`). +- Unzip the file, it comes with `lc0.exe` , `dnnl.dll`, and a weights file example, `703810.pb.gz` (amongst other files). +- All three main files need to be copied to the `engines` directory. +- 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). +- 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). +- To start: `python3 lichess-bot.py`. + +**Next step**: [Configure lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/Configure-lichess-bot) + +**Previous step**: [Create a lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token) diff --git a/lichess-bot-master/wiki/Upgrade-to-a-BOT-account.md b/lichess-bot-master/wiki/Upgrade-to-a-BOT-account.md new file mode 100644 index 0000000000000000000000000000000000000000..a7d967d4a2ad5be1374c52ecb32ed06b16c34199 --- /dev/null +++ b/lichess-bot-master/wiki/Upgrade-to-a-BOT-account.md @@ -0,0 +1,9 @@ +# Upgrading to a BOT account +**WARNING: This is irreversible. [Read more about upgrading to bot account](https://lichess.org/api#operation/botAccountUpgrade).** +- run `python3 lichess-bot.py -u`. + +If successful, this step will start a lichess session and the bot will start playing games. In the future, only the commands in the next step will have to be run. + +**Next step**: [Run lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Run-lichess%E2%80%90bot) + +**Previous step**: [Configure lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/Configure-lichess-bot)