Spaces:
Running
Running
Upload 67 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- lichess-bot-master/.gitattributes +2 -0
- lichess-bot-master/.github/ISSUE_TEMPLATE/bug_report.md +30 -0
- lichess-bot-master/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
- lichess-bot-master/.github/dependabot.yml +17 -0
- lichess-bot-master/.github/pull_request_template.md +20 -0
- lichess-bot-master/.github/workflows/docker.yml +85 -0
- lichess-bot-master/.github/workflows/mypy.yml +32 -0
- lichess-bot-master/.github/workflows/python-build.yml +34 -0
- lichess-bot-master/.github/workflows/python-test.yml +50 -0
- lichess-bot-master/.github/workflows/sync-wiki.yml +34 -0
- lichess-bot-master/.github/workflows/update_version.py +29 -0
- lichess-bot-master/.github/workflows/versioning.yml +30 -0
- lichess-bot-master/.gitignore +11 -0
- lichess-bot-master/CITATION.cff +36 -0
- lichess-bot-master/LICENSE +661 -0
- lichess-bot-master/README.md +65 -0
- lichess-bot-master/config.yml.default +253 -0
- lichess-bot-master/docker/Dockerfile +22 -0
- lichess-bot-master/docker/Dockerfile.dockerignore +6 -0
- lichess-bot-master/docker/copy_files.sh +9 -0
- lichess-bot-master/docs/CODE_OF_CONDUCT.md +65 -0
- lichess-bot-master/docs/CONTRIBUTING.md +57 -0
- lichess-bot-master/docs/SECURITY.md +9 -0
- lichess-bot-master/engines/README.md +1 -0
- lichess-bot-master/extra_game_handlers.py +21 -0
- lichess-bot-master/homemade.py +96 -0
- lichess-bot-master/lib/__init__.py +1 -0
- lichess-bot-master/lib/blocklist.py +51 -0
- lichess-bot-master/lib/config.py +449 -0
- lichess-bot-master/lib/conversation.py +106 -0
- lichess-bot-master/lib/engine_wrapper.py +1427 -0
- lichess-bot-master/lib/lichess.py +474 -0
- lichess-bot-master/lib/lichess_bot.py +1277 -0
- lichess-bot-master/lib/lichess_types.py +468 -0
- lichess-bot-master/lib/matchmaking.py +361 -0
- lichess-bot-master/lib/model.py +329 -0
- lichess-bot-master/lib/timer.py +98 -0
- lichess-bot-master/lib/versioning.yml +4 -0
- lichess-bot-master/lichess-bot.py +5 -0
- lichess-bot-master/requirements.txt +5 -0
- lichess-bot-master/test_bot/__init__.py +1 -0
- lichess-bot-master/test_bot/buggy_engine.py +52 -0
- lichess-bot-master/test_bot/conftest.py +15 -0
- lichess-bot-master/test_bot/homemade.py +25 -0
- lichess-bot-master/test_bot/lichess.py +262 -0
- lichess-bot-master/test_bot/ruff.toml +46 -0
- lichess-bot-master/test_bot/test-requirements.txt +6 -0
- lichess-bot-master/test_bot/test_blocklist.py +227 -0
- lichess-bot-master/test_bot/test_bot.py +197 -0
- lichess-bot-master/test_bot/test_config.py +33 -0
lichess-bot-master/.gitattributes
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Normalize line endings
|
| 2 |
+
* text=auto
|
lichess-bot-master/.github/ISSUE_TEMPLATE/bug_report.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: Bug report
|
| 3 |
+
about: Create a bug report
|
| 4 |
+
title: ''
|
| 5 |
+
labels: ''
|
| 6 |
+
assignees: ''
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
**Describe the bug**
|
| 11 |
+
A clear and concise description of what the bug is.
|
| 12 |
+
|
| 13 |
+
**To Reproduce**
|
| 14 |
+
Steps to reproduce the behavior:
|
| 15 |
+
1. Run lichess-bot with [...] commands [e.g. `python lichess-bot.py -v`]
|
| 16 |
+
2. See error
|
| 17 |
+
|
| 18 |
+
**Expected behavior**
|
| 19 |
+
A clear and concise description of what you expected to happen.
|
| 20 |
+
|
| 21 |
+
**Logs**
|
| 22 |
+
Upload `lichess_bot_auto_logs\lichess-bot.log`, older `lichess-bot.log.<date>` 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.
|
| 23 |
+
|
| 24 |
+
**Desktop (please complete the following information):**
|
| 25 |
+
- OS: [e.g. Windows]
|
| 26 |
+
- Python Version: [e.g. 3.11]
|
| 27 |
+
- Terminal: [e.g. Command Line, PowerShell]
|
| 28 |
+
|
| 29 |
+
**Additional context**
|
| 30 |
+
Add any other context about the problem here.
|
lichess-bot-master/.github/ISSUE_TEMPLATE/feature_request.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: Feature request
|
| 3 |
+
about: Suggest an idea for this project
|
| 4 |
+
title: ''
|
| 5 |
+
labels: ''
|
| 6 |
+
assignees: ''
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
**Is your feature request related to a problem? Please describe.**
|
| 11 |
+
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
| 12 |
+
|
| 13 |
+
**Describe the solution you'd like**
|
| 14 |
+
A clear and concise description of what you want to happen.
|
| 15 |
+
|
| 16 |
+
**Describe alternatives you've considered**
|
| 17 |
+
A clear and concise description of any alternative solutions or features you've considered.
|
| 18 |
+
|
| 19 |
+
**Additional context**
|
| 20 |
+
Add any other context or screenshots about the feature request here.
|
lichess-bot-master/.github/dependabot.yml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# To get started with Dependabot version updates, you'll need to specify which
|
| 2 |
+
# package ecosystems to update and where the package manifests are located.
|
| 3 |
+
# Please see the documentation for all configuration options:
|
| 4 |
+
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
| 5 |
+
|
| 6 |
+
version: 2
|
| 7 |
+
updates:
|
| 8 |
+
- package-ecosystem: "pip" # See documentation for possible values
|
| 9 |
+
directory: "/" # Location of package manifests
|
| 10 |
+
schedule:
|
| 11 |
+
interval: "daily"
|
| 12 |
+
versioning-strategy: increase
|
| 13 |
+
|
| 14 |
+
- package-ecosystem: "github-actions"
|
| 15 |
+
directory: "/"
|
| 16 |
+
schedule:
|
| 17 |
+
interval: "daily"
|
lichess-bot-master/.github/pull_request_template.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Type of pull request:
|
| 2 |
+
- [ ] Bug fix
|
| 3 |
+
- [ ] Feature
|
| 4 |
+
- [ ] Other
|
| 5 |
+
|
| 6 |
+
## Description:
|
| 7 |
+
|
| 8 |
+
[Provide a brief description of the changes introduced by this pull request.]
|
| 9 |
+
|
| 10 |
+
## Related Issues:
|
| 11 |
+
|
| 12 |
+
[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.]
|
| 13 |
+
|
| 14 |
+
## Checklist:
|
| 15 |
+
|
| 16 |
+
- [ ] I have read and followed the [contribution guidelines](/docs/CONTRIBUTING.md).
|
| 17 |
+
- [ ] I have added necessary documentation (if applicable).
|
| 18 |
+
- [ ] The changes pass all existing tests.
|
| 19 |
+
|
| 20 |
+
## Screenshots/logs (if applicable):
|
lichess-bot-master/.github/workflows/docker.yml
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Docker CI
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
workflow_run:
|
| 5 |
+
workflows: [Versioning]
|
| 6 |
+
types:
|
| 7 |
+
- completed
|
| 8 |
+
|
| 9 |
+
env:
|
| 10 |
+
GB_REGISTRY: ghcr.io
|
| 11 |
+
|
| 12 |
+
jobs:
|
| 13 |
+
build:
|
| 14 |
+
runs-on: ubuntu-latest
|
| 15 |
+
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
| 16 |
+
steps:
|
| 17 |
+
- uses: actions/checkout@v6
|
| 18 |
+
|
| 19 |
+
- name: Assign defaultValue
|
| 20 |
+
run: |
|
| 21 |
+
version=$(grep -m1 '^lichess_bot_version: ' lib/versioning.yml | sed -n -e 's/^.*://p' | tr -d '[:space:]')
|
| 22 |
+
echo version="$version" >> "$GITHUB_ENV"
|
| 23 |
+
|
| 24 |
+
- name: show me docker tags.
|
| 25 |
+
run: |
|
| 26 |
+
echo "version:'"$version"'"
|
| 27 |
+
|
| 28 |
+
- name: Login to Docker Hub
|
| 29 |
+
uses: docker/login-action@v4
|
| 30 |
+
with:
|
| 31 |
+
username: ${{ secrets.DOCKER_USER }}
|
| 32 |
+
password: ${{ secrets.DOCKER_TOKEN }}
|
| 33 |
+
|
| 34 |
+
- name: Login to Github container registry
|
| 35 |
+
uses: docker/login-action@v4
|
| 36 |
+
with:
|
| 37 |
+
registry: ${{ env.GB_REGISTRY }}
|
| 38 |
+
username: ${{ secrets.GB_REPO_USER }}
|
| 39 |
+
password: ${{ secrets.GITHUB_TOKEN }}
|
| 40 |
+
|
| 41 |
+
- name: Set up QEMU
|
| 42 |
+
uses: docker/setup-qemu-action@v4
|
| 43 |
+
|
| 44 |
+
- name: Set up Docker Buildx
|
| 45 |
+
uses: docker/setup-buildx-action@v4
|
| 46 |
+
|
| 47 |
+
- name: build and push alpine image
|
| 48 |
+
uses: docker/build-push-action@v7
|
| 49 |
+
with:
|
| 50 |
+
context: .
|
| 51 |
+
file: ./docker/Dockerfile
|
| 52 |
+
platforms: |
|
| 53 |
+
linux/amd64
|
| 54 |
+
linux/arm64
|
| 55 |
+
build-args: VARIANT=-alpine
|
| 56 |
+
push: true
|
| 57 |
+
tags: |
|
| 58 |
+
${{ vars.DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:alpine
|
| 59 |
+
${{ vars.DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:${{ env.version }}-alpine
|
| 60 |
+
${{ env.GB_REGISTRY }}/${{ vars.GHCR_DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:alpine
|
| 61 |
+
${{ env.GB_REGISTRY }}/${{ vars.GHCR_DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:${{ env.version }}-alpine
|
| 62 |
+
|
| 63 |
+
- name: build and push fat image
|
| 64 |
+
uses: docker/build-push-action@v7
|
| 65 |
+
with:
|
| 66 |
+
context: .
|
| 67 |
+
file: ./docker/Dockerfile
|
| 68 |
+
platforms: |
|
| 69 |
+
linux/amd64
|
| 70 |
+
linux/arm64
|
| 71 |
+
push: true
|
| 72 |
+
tags: |
|
| 73 |
+
${{ vars.DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:latest
|
| 74 |
+
${{ vars.DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:${{ env.version }}
|
| 75 |
+
${{ env.GB_REGISTRY }}/${{ vars.GHCR_DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:latest
|
| 76 |
+
${{ env.GB_REGISTRY }}/${{ vars.GHCR_DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:${{ env.version }}
|
| 77 |
+
|
| 78 |
+
- name: push README to Dockerhub
|
| 79 |
+
uses: peter-evans/dockerhub-description@v5
|
| 80 |
+
with:
|
| 81 |
+
username: ${{ secrets.DOCKER_USER }}
|
| 82 |
+
password: ${{ secrets.DOCKER_TOKEN }}
|
| 83 |
+
repository: lichessbotdevs/lichess-bot
|
| 84 |
+
short-description: 'A bridge between Lichess API and chess engines'
|
| 85 |
+
readme-filepath: './wiki/How-to-use-the-Docker-image.md'
|
lichess-bot-master/.github/workflows/mypy.yml
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This workflow will install Python dependencies and run mypy
|
| 2 |
+
|
| 3 |
+
name: Mypy
|
| 4 |
+
|
| 5 |
+
on:
|
| 6 |
+
push:
|
| 7 |
+
branches: [ master ]
|
| 8 |
+
pull_request:
|
| 9 |
+
branches: [ master ]
|
| 10 |
+
|
| 11 |
+
jobs:
|
| 12 |
+
mypy:
|
| 13 |
+
runs-on: ${{ matrix.os }}
|
| 14 |
+
strategy:
|
| 15 |
+
matrix:
|
| 16 |
+
os: [windows-latest]
|
| 17 |
+
python: ["3.10", "3.14"]
|
| 18 |
+
|
| 19 |
+
steps:
|
| 20 |
+
- uses: actions/checkout@v6
|
| 21 |
+
- name: Set up Python ${{ matrix.python }}
|
| 22 |
+
uses: actions/setup-python@v6
|
| 23 |
+
with:
|
| 24 |
+
python-version: ${{ matrix.python }}
|
| 25 |
+
- name: Install dependencies
|
| 26 |
+
run: |
|
| 27 |
+
python -m pip install --upgrade pip
|
| 28 |
+
pip install -r requirements.txt
|
| 29 |
+
pip install -r test_bot/test-requirements.txt
|
| 30 |
+
- name: Run mypy
|
| 31 |
+
run: |
|
| 32 |
+
mypy --strict .
|
lichess-bot-master/.github/workflows/python-build.yml
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This workflow will install Python dependencies and lint
|
| 2 |
+
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
|
| 3 |
+
|
| 4 |
+
name: Python Build
|
| 5 |
+
|
| 6 |
+
on:
|
| 7 |
+
push:
|
| 8 |
+
branches: [ master ]
|
| 9 |
+
pull_request:
|
| 10 |
+
branches: [ master ]
|
| 11 |
+
|
| 12 |
+
jobs:
|
| 13 |
+
build:
|
| 14 |
+
runs-on: ${{ matrix.os }}
|
| 15 |
+
strategy:
|
| 16 |
+
matrix:
|
| 17 |
+
os: [ubuntu-latest, macos-latest, windows-latest]
|
| 18 |
+
python: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
| 19 |
+
|
| 20 |
+
steps:
|
| 21 |
+
- uses: actions/checkout@v6
|
| 22 |
+
- name: Set up Python ${{ matrix.python }}
|
| 23 |
+
uses: actions/setup-python@v6
|
| 24 |
+
with:
|
| 25 |
+
python-version: ${{ matrix.python }}
|
| 26 |
+
- name: Install dependencies
|
| 27 |
+
run: |
|
| 28 |
+
python -m pip install --upgrade pip
|
| 29 |
+
pip install -r requirements.txt
|
| 30 |
+
pip install -r test_bot/test-requirements.txt
|
| 31 |
+
- name: Lint with ruff
|
| 32 |
+
run: |
|
| 33 |
+
# Check for python syntax errors and inconsistent code style.
|
| 34 |
+
ruff check --config test_bot/ruff.toml
|
lichess-bot-master/.github/workflows/python-test.yml
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This workflow will install Python dependencies and run tests
|
| 2 |
+
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
|
| 3 |
+
|
| 4 |
+
name: Python Test
|
| 5 |
+
|
| 6 |
+
on:
|
| 7 |
+
push:
|
| 8 |
+
branches: [ master ]
|
| 9 |
+
pull_request:
|
| 10 |
+
branches: [ master ]
|
| 11 |
+
|
| 12 |
+
jobs:
|
| 13 |
+
test:
|
| 14 |
+
runs-on: ${{ matrix.os }}
|
| 15 |
+
strategy:
|
| 16 |
+
matrix:
|
| 17 |
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
| 18 |
+
python: ["3.10", "3.14"]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
steps:
|
| 22 |
+
- uses: actions/checkout@v6
|
| 23 |
+
- name: Set up Python ${{ matrix.python }}
|
| 24 |
+
uses: actions/setup-python@v6
|
| 25 |
+
with:
|
| 26 |
+
python-version: ${{ matrix.python }}
|
| 27 |
+
- name: Install dependencies
|
| 28 |
+
run: |
|
| 29 |
+
python -m pip install --upgrade pip
|
| 30 |
+
pip install -r requirements.txt
|
| 31 |
+
pip install -r test_bot/test-requirements.txt
|
| 32 |
+
- name: Restore engines
|
| 33 |
+
id: cache-temp-restore
|
| 34 |
+
uses: actions/cache/restore@v5
|
| 35 |
+
with:
|
| 36 |
+
path: |
|
| 37 |
+
TEMP
|
| 38 |
+
key: ${{ matrix.os }}-engines
|
| 39 |
+
- name: Test with pytest
|
| 40 |
+
env:
|
| 41 |
+
LICHESS_BOT_TEST_TOKEN: ${{secrets.LICHESS_BOT_TEST_TOKEN}}
|
| 42 |
+
run: |
|
| 43 |
+
pytest --log-cli-level=10
|
| 44 |
+
- name: Save engines
|
| 45 |
+
id: cache-temp-save
|
| 46 |
+
uses: actions/cache/save@v5
|
| 47 |
+
with:
|
| 48 |
+
path: |
|
| 49 |
+
TEMP
|
| 50 |
+
key: ${{ steps.cache-temp-restore.outputs.cache-primary-key }}
|
lichess-bot-master/.github/workflows/sync-wiki.yml
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Sync wiki
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches:
|
| 6 |
+
- master
|
| 7 |
+
paths:
|
| 8 |
+
- 'wiki/**'
|
| 9 |
+
- 'README.md'
|
| 10 |
+
|
| 11 |
+
jobs:
|
| 12 |
+
sync:
|
| 13 |
+
if: github.repository == 'lichess-bot-devs/lichess-bot'
|
| 14 |
+
runs-on: ubuntu-latest
|
| 15 |
+
steps:
|
| 16 |
+
- name: Checkout Repository
|
| 17 |
+
uses: actions/checkout@v6
|
| 18 |
+
- name: Checkout wiki
|
| 19 |
+
uses: actions/checkout@v6
|
| 20 |
+
with:
|
| 21 |
+
repository: "${{ github.repository }}.wiki"
|
| 22 |
+
path: "lichess-bot.wiki"
|
| 23 |
+
- name: Set github bot global config
|
| 24 |
+
run: |
|
| 25 |
+
git config --global user.email "actions@github.com"
|
| 26 |
+
git config --global user.name "actions-user"
|
| 27 |
+
- name: Sync all files to wiki
|
| 28 |
+
run: |
|
| 29 |
+
cp $GITHUB_WORKSPACE/README.md $GITHUB_WORKSPACE/lichess-bot.wiki/Home.md
|
| 30 |
+
cp -r $GITHUB_WORKSPACE/wiki/* $GITHUB_WORKSPACE/lichess-bot.wiki
|
| 31 |
+
cd $GITHUB_WORKSPACE/lichess-bot.wiki
|
| 32 |
+
git add .
|
| 33 |
+
git commit -m "Auto update wiki"
|
| 34 |
+
git push
|
lichess-bot-master/.github/workflows/update_version.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Automatically updates the lichess-bot version."""
|
| 2 |
+
import yaml
|
| 3 |
+
import datetime
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
# File is part of an implicit namespace package. Add an `__init__.py`.
|
| 7 |
+
# ruff: noqa: INP001
|
| 8 |
+
|
| 9 |
+
with open("lib/versioning.yml") as version_file:
|
| 10 |
+
versioning_info = yaml.safe_load(version_file)
|
| 11 |
+
|
| 12 |
+
current_version = versioning_info["lichess_bot_version"]
|
| 13 |
+
|
| 14 |
+
utc_datetime = datetime.datetime.now(datetime.UTC)
|
| 15 |
+
new_version = f"{utc_datetime.year}.{utc_datetime.month}.{utc_datetime.day}."
|
| 16 |
+
if current_version.startswith(new_version):
|
| 17 |
+
current_version_list = current_version.split(".")
|
| 18 |
+
current_version_list[-1] = str(int(current_version_list[-1]) + 1)
|
| 19 |
+
new_version = ".".join(current_version_list)
|
| 20 |
+
else:
|
| 21 |
+
new_version += "1"
|
| 22 |
+
|
| 23 |
+
versioning_info["lichess_bot_version"] = new_version
|
| 24 |
+
|
| 25 |
+
with open("lib/versioning.yml", "w") as version_file:
|
| 26 |
+
yaml.dump(versioning_info, version_file, sort_keys=False)
|
| 27 |
+
|
| 28 |
+
with open(os.environ["GITHUB_OUTPUT"], "a") as fh:
|
| 29 |
+
print(f"new_version={new_version}", file=fh)
|
lichess-bot-master/.github/workflows/versioning.yml
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Versioning
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [ master ]
|
| 6 |
+
|
| 7 |
+
jobs:
|
| 8 |
+
versioning:
|
| 9 |
+
if: github.repository == 'lichess-bot-devs/lichess-bot'
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
steps:
|
| 12 |
+
- uses: actions/checkout@v6
|
| 13 |
+
- name: Set up Python 3.11
|
| 14 |
+
uses: actions/setup-python@v6
|
| 15 |
+
with:
|
| 16 |
+
python-version: "3.11"
|
| 17 |
+
- name: Install dependencies
|
| 18 |
+
run: |
|
| 19 |
+
python -m pip install --upgrade pip
|
| 20 |
+
pip install PyYAML
|
| 21 |
+
- name: Update version
|
| 22 |
+
id: new-version
|
| 23 |
+
run: python .github/workflows/update_version.py
|
| 24 |
+
- name: Auto update version
|
| 25 |
+
run: |
|
| 26 |
+
git config --global user.email "actions@github.com"
|
| 27 |
+
git config --global user.name "actions-user"
|
| 28 |
+
git add lib/versioning.yml
|
| 29 |
+
git commit -m "Auto update version to ${{ steps.new-version.outputs.new_version }}"
|
| 30 |
+
git push origin HEAD:master
|
lichess-bot-master/.gitignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.yml
|
| 2 |
+
**/__pycache__
|
| 3 |
+
/engines/*
|
| 4 |
+
!/engines/README.md
|
| 5 |
+
lichess_bot_auto_logs/*
|
| 6 |
+
.vscode/*
|
| 7 |
+
.vs/*
|
| 8 |
+
*.pgn
|
| 9 |
+
*.log
|
| 10 |
+
*.txt
|
| 11 |
+
TEMP/*
|
lichess-bot-master/CITATION.cff
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This CITATION.cff file was generated with cffinit.
|
| 2 |
+
# Visit https://bit.ly/cffinit to generate yours today!
|
| 3 |
+
|
| 4 |
+
cff-version: 1.2.0
|
| 5 |
+
title: lichess-bot
|
| 6 |
+
message: >-
|
| 7 |
+
If you use this software, please cite it using the
|
| 8 |
+
metadata from this file.
|
| 9 |
+
type: software
|
| 10 |
+
authors:
|
| 11 |
+
- given-names: Ioannis
|
| 12 |
+
family-names: Pantidis
|
| 13 |
+
- given-names: Mark
|
| 14 |
+
family-names: Harrison
|
| 15 |
+
orcid: 'https://orcid.org/0000-0001-7212-8810'
|
| 16 |
+
- given-names: Shail
|
| 17 |
+
family-names: Choksi
|
| 18 |
+
- given-names: Thibault
|
| 19 |
+
family-names: Duplessis
|
| 20 |
+
affiliation: lichess.org
|
| 21 |
+
repository-code: 'https://github.com/lichess-bot-devs/lichess-bot'
|
| 22 |
+
abstract: >-
|
| 23 |
+
lichess-bot is a free bridge between the Lichess Bot API
|
| 24 |
+
and chess engines.
|
| 25 |
+
|
| 26 |
+
With lichess-bot, you can create and operate a bot on
|
| 27 |
+
lichess. Your bot will be able to play against humans and
|
| 28 |
+
bots alike, and you will be able to view these games live
|
| 29 |
+
on lichess.
|
| 30 |
+
keywords:
|
| 31 |
+
- chess
|
| 32 |
+
- chess engine
|
| 33 |
+
- lichess
|
| 34 |
+
- API
|
| 35 |
+
- game
|
| 36 |
+
license: AGPL-3.0
|
lichess-bot-master/LICENSE
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
GNU AFFERO GENERAL PUBLIC LICENSE
|
| 2 |
+
Version 3, 19 November 2007
|
| 3 |
+
|
| 4 |
+
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
| 5 |
+
Everyone is permitted to copy and distribute verbatim copies
|
| 6 |
+
of this license document, but changing it is not allowed.
|
| 7 |
+
|
| 8 |
+
Preamble
|
| 9 |
+
|
| 10 |
+
The GNU Affero General Public License is a free, copyleft license for
|
| 11 |
+
software and other kinds of works, specifically designed to ensure
|
| 12 |
+
cooperation with the community in the case of network server software.
|
| 13 |
+
|
| 14 |
+
The licenses for most software and other practical works are designed
|
| 15 |
+
to take away your freedom to share and change the works. By contrast,
|
| 16 |
+
our General Public Licenses are intended to guarantee your freedom to
|
| 17 |
+
share and change all versions of a program--to make sure it remains free
|
| 18 |
+
software for all its users.
|
| 19 |
+
|
| 20 |
+
When we speak of free software, we are referring to freedom, not
|
| 21 |
+
price. Our General Public Licenses are designed to make sure that you
|
| 22 |
+
have the freedom to distribute copies of free software (and charge for
|
| 23 |
+
them if you wish), that you receive source code or can get it if you
|
| 24 |
+
want it, that you can change the software or use pieces of it in new
|
| 25 |
+
free programs, and that you know you can do these things.
|
| 26 |
+
|
| 27 |
+
Developers that use our General Public Licenses protect your rights
|
| 28 |
+
with two steps: (1) assert copyright on the software, and (2) offer
|
| 29 |
+
you this License which gives you legal permission to copy, distribute
|
| 30 |
+
and/or modify the software.
|
| 31 |
+
|
| 32 |
+
A secondary benefit of defending all users' freedom is that
|
| 33 |
+
improvements made in alternate versions of the program, if they
|
| 34 |
+
receive widespread use, become available for other developers to
|
| 35 |
+
incorporate. Many developers of free software are heartened and
|
| 36 |
+
encouraged by the resulting cooperation. However, in the case of
|
| 37 |
+
software used on network servers, this result may fail to come about.
|
| 38 |
+
The GNU General Public License permits making a modified version and
|
| 39 |
+
letting the public access it on a server without ever releasing its
|
| 40 |
+
source code to the public.
|
| 41 |
+
|
| 42 |
+
The GNU Affero General Public License is designed specifically to
|
| 43 |
+
ensure that, in such cases, the modified source code becomes available
|
| 44 |
+
to the community. It requires the operator of a network server to
|
| 45 |
+
provide the source code of the modified version running there to the
|
| 46 |
+
users of that server. Therefore, public use of a modified version, on
|
| 47 |
+
a publicly accessible server, gives the public access to the source
|
| 48 |
+
code of the modified version.
|
| 49 |
+
|
| 50 |
+
An older license, called the Affero General Public License and
|
| 51 |
+
published by Affero, was designed to accomplish similar goals. This is
|
| 52 |
+
a different license, not a version of the Affero GPL, but Affero has
|
| 53 |
+
released a new version of the Affero GPL which permits relicensing under
|
| 54 |
+
this license.
|
| 55 |
+
|
| 56 |
+
The precise terms and conditions for copying, distribution and
|
| 57 |
+
modification follow.
|
| 58 |
+
|
| 59 |
+
TERMS AND CONDITIONS
|
| 60 |
+
|
| 61 |
+
0. Definitions.
|
| 62 |
+
|
| 63 |
+
"This License" refers to version 3 of the GNU Affero General Public License.
|
| 64 |
+
|
| 65 |
+
"Copyright" also means copyright-like laws that apply to other kinds of
|
| 66 |
+
works, such as semiconductor masks.
|
| 67 |
+
|
| 68 |
+
"The Program" refers to any copyrightable work licensed under this
|
| 69 |
+
License. Each licensee is addressed as "you". "Licensees" and
|
| 70 |
+
"recipients" may be individuals or organizations.
|
| 71 |
+
|
| 72 |
+
To "modify" a work means to copy from or adapt all or part of the work
|
| 73 |
+
in a fashion requiring copyright permission, other than the making of an
|
| 74 |
+
exact copy. The resulting work is called a "modified version" of the
|
| 75 |
+
earlier work or a work "based on" the earlier work.
|
| 76 |
+
|
| 77 |
+
A "covered work" means either the unmodified Program or a work based
|
| 78 |
+
on the Program.
|
| 79 |
+
|
| 80 |
+
To "propagate" a work means to do anything with it that, without
|
| 81 |
+
permission, would make you directly or secondarily liable for
|
| 82 |
+
infringement under applicable copyright law, except executing it on a
|
| 83 |
+
computer or modifying a private copy. Propagation includes copying,
|
| 84 |
+
distribution (with or without modification), making available to the
|
| 85 |
+
public, and in some countries other activities as well.
|
| 86 |
+
|
| 87 |
+
To "convey" a work means any kind of propagation that enables other
|
| 88 |
+
parties to make or receive copies. Mere interaction with a user through
|
| 89 |
+
a computer network, with no transfer of a copy, is not conveying.
|
| 90 |
+
|
| 91 |
+
An interactive user interface displays "Appropriate Legal Notices"
|
| 92 |
+
to the extent that it includes a convenient and prominently visible
|
| 93 |
+
feature that (1) displays an appropriate copyright notice, and (2)
|
| 94 |
+
tells the user that there is no warranty for the work (except to the
|
| 95 |
+
extent that warranties are provided), that licensees may convey the
|
| 96 |
+
work under this License, and how to view a copy of this License. If
|
| 97 |
+
the interface presents a list of user commands or options, such as a
|
| 98 |
+
menu, a prominent item in the list meets this criterion.
|
| 99 |
+
|
| 100 |
+
1. Source Code.
|
| 101 |
+
|
| 102 |
+
The "source code" for a work means the preferred form of the work
|
| 103 |
+
for making modifications to it. "Object code" means any non-source
|
| 104 |
+
form of a work.
|
| 105 |
+
|
| 106 |
+
A "Standard Interface" means an interface that either is an official
|
| 107 |
+
standard defined by a recognized standards body, or, in the case of
|
| 108 |
+
interfaces specified for a particular programming language, one that
|
| 109 |
+
is widely used among developers working in that language.
|
| 110 |
+
|
| 111 |
+
The "System Libraries" of an executable work include anything, other
|
| 112 |
+
than the work as a whole, that (a) is included in the normal form of
|
| 113 |
+
packaging a Major Component, but which is not part of that Major
|
| 114 |
+
Component, and (b) serves only to enable use of the work with that
|
| 115 |
+
Major Component, or to implement a Standard Interface for which an
|
| 116 |
+
implementation is available to the public in source code form. A
|
| 117 |
+
"Major Component", in this context, means a major essential component
|
| 118 |
+
(kernel, window system, and so on) of the specific operating system
|
| 119 |
+
(if any) on which the executable work runs, or a compiler used to
|
| 120 |
+
produce the work, or an object code interpreter used to run it.
|
| 121 |
+
|
| 122 |
+
The "Corresponding Source" for a work in object code form means all
|
| 123 |
+
the source code needed to generate, install, and (for an executable
|
| 124 |
+
work) run the object code and to modify the work, including scripts to
|
| 125 |
+
control those activities. However, it does not include the work's
|
| 126 |
+
System Libraries, or general-purpose tools or generally available free
|
| 127 |
+
programs which are used unmodified in performing those activities but
|
| 128 |
+
which are not part of the work. For example, Corresponding Source
|
| 129 |
+
includes interface definition files associated with source files for
|
| 130 |
+
the work, and the source code for shared libraries and dynamically
|
| 131 |
+
linked subprograms that the work is specifically designed to require,
|
| 132 |
+
such as by intimate data communication or control flow between those
|
| 133 |
+
subprograms and other parts of the work.
|
| 134 |
+
|
| 135 |
+
The Corresponding Source need not include anything that users
|
| 136 |
+
can regenerate automatically from other parts of the Corresponding
|
| 137 |
+
Source.
|
| 138 |
+
|
| 139 |
+
The Corresponding Source for a work in source code form is that
|
| 140 |
+
same work.
|
| 141 |
+
|
| 142 |
+
2. Basic Permissions.
|
| 143 |
+
|
| 144 |
+
All rights granted under this License are granted for the term of
|
| 145 |
+
copyright on the Program, and are irrevocable provided the stated
|
| 146 |
+
conditions are met. This License explicitly affirms your unlimited
|
| 147 |
+
permission to run the unmodified Program. The output from running a
|
| 148 |
+
covered work is covered by this License only if the output, given its
|
| 149 |
+
content, constitutes a covered work. This License acknowledges your
|
| 150 |
+
rights of fair use or other equivalent, as provided by copyright law.
|
| 151 |
+
|
| 152 |
+
You may make, run and propagate covered works that you do not
|
| 153 |
+
convey, without conditions so long as your license otherwise remains
|
| 154 |
+
in force. You may convey covered works to others for the sole purpose
|
| 155 |
+
of having them make modifications exclusively for you, or provide you
|
| 156 |
+
with facilities for running those works, provided that you comply with
|
| 157 |
+
the terms of this License in conveying all material for which you do
|
| 158 |
+
not control copyright. Those thus making or running the covered works
|
| 159 |
+
for you must do so exclusively on your behalf, under your direction
|
| 160 |
+
and control, on terms that prohibit them from making any copies of
|
| 161 |
+
your copyrighted material outside their relationship with you.
|
| 162 |
+
|
| 163 |
+
Conveying under any other circumstances is permitted solely under
|
| 164 |
+
the conditions stated below. Sublicensing is not allowed; section 10
|
| 165 |
+
makes it unnecessary.
|
| 166 |
+
|
| 167 |
+
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
| 168 |
+
|
| 169 |
+
No covered work shall be deemed part of an effective technological
|
| 170 |
+
measure under any applicable law fulfilling obligations under article
|
| 171 |
+
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
| 172 |
+
similar laws prohibiting or restricting circumvention of such
|
| 173 |
+
measures.
|
| 174 |
+
|
| 175 |
+
When you convey a covered work, you waive any legal power to forbid
|
| 176 |
+
circumvention of technological measures to the extent such circumvention
|
| 177 |
+
is effected by exercising rights under this License with respect to
|
| 178 |
+
the covered work, and you disclaim any intention to limit operation or
|
| 179 |
+
modification of the work as a means of enforcing, against the work's
|
| 180 |
+
users, your or third parties' legal rights to forbid circumvention of
|
| 181 |
+
technological measures.
|
| 182 |
+
|
| 183 |
+
4. Conveying Verbatim Copies.
|
| 184 |
+
|
| 185 |
+
You may convey verbatim copies of the Program's source code as you
|
| 186 |
+
receive it, in any medium, provided that you conspicuously and
|
| 187 |
+
appropriately publish on each copy an appropriate copyright notice;
|
| 188 |
+
keep intact all notices stating that this License and any
|
| 189 |
+
non-permissive terms added in accord with section 7 apply to the code;
|
| 190 |
+
keep intact all notices of the absence of any warranty; and give all
|
| 191 |
+
recipients a copy of this License along with the Program.
|
| 192 |
+
|
| 193 |
+
You may charge any price or no price for each copy that you convey,
|
| 194 |
+
and you may offer support or warranty protection for a fee.
|
| 195 |
+
|
| 196 |
+
5. Conveying Modified Source Versions.
|
| 197 |
+
|
| 198 |
+
You may convey a work based on the Program, or the modifications to
|
| 199 |
+
produce it from the Program, in the form of source code under the
|
| 200 |
+
terms of section 4, provided that you also meet all of these conditions:
|
| 201 |
+
|
| 202 |
+
a) The work must carry prominent notices stating that you modified
|
| 203 |
+
it, and giving a relevant date.
|
| 204 |
+
|
| 205 |
+
b) The work must carry prominent notices stating that it is
|
| 206 |
+
released under this License and any conditions added under section
|
| 207 |
+
7. This requirement modifies the requirement in section 4 to
|
| 208 |
+
"keep intact all notices".
|
| 209 |
+
|
| 210 |
+
c) You must license the entire work, as a whole, under this
|
| 211 |
+
License to anyone who comes into possession of a copy. This
|
| 212 |
+
License will therefore apply, along with any applicable section 7
|
| 213 |
+
additional terms, to the whole of the work, and all its parts,
|
| 214 |
+
regardless of how they are packaged. This License gives no
|
| 215 |
+
permission to license the work in any other way, but it does not
|
| 216 |
+
invalidate such permission if you have separately received it.
|
| 217 |
+
|
| 218 |
+
d) If the work has interactive user interfaces, each must display
|
| 219 |
+
Appropriate Legal Notices; however, if the Program has interactive
|
| 220 |
+
interfaces that do not display Appropriate Legal Notices, your
|
| 221 |
+
work need not make them do so.
|
| 222 |
+
|
| 223 |
+
A compilation of a covered work with other separate and independent
|
| 224 |
+
works, which are not by their nature extensions of the covered work,
|
| 225 |
+
and which are not combined with it such as to form a larger program,
|
| 226 |
+
in or on a volume of a storage or distribution medium, is called an
|
| 227 |
+
"aggregate" if the compilation and its resulting copyright are not
|
| 228 |
+
used to limit the access or legal rights of the compilation's users
|
| 229 |
+
beyond what the individual works permit. Inclusion of a covered work
|
| 230 |
+
in an aggregate does not cause this License to apply to the other
|
| 231 |
+
parts of the aggregate.
|
| 232 |
+
|
| 233 |
+
6. Conveying Non-Source Forms.
|
| 234 |
+
|
| 235 |
+
You may convey a covered work in object code form under the terms
|
| 236 |
+
of sections 4 and 5, provided that you also convey the
|
| 237 |
+
machine-readable Corresponding Source under the terms of this License,
|
| 238 |
+
in one of these ways:
|
| 239 |
+
|
| 240 |
+
a) Convey the object code in, or embodied in, a physical product
|
| 241 |
+
(including a physical distribution medium), accompanied by the
|
| 242 |
+
Corresponding Source fixed on a durable physical medium
|
| 243 |
+
customarily used for software interchange.
|
| 244 |
+
|
| 245 |
+
b) Convey the object code in, or embodied in, a physical product
|
| 246 |
+
(including a physical distribution medium), accompanied by a
|
| 247 |
+
written offer, valid for at least three years and valid for as
|
| 248 |
+
long as you offer spare parts or customer support for that product
|
| 249 |
+
model, to give anyone who possesses the object code either (1) a
|
| 250 |
+
copy of the Corresponding Source for all the software in the
|
| 251 |
+
product that is covered by this License, on a durable physical
|
| 252 |
+
medium customarily used for software interchange, for a price no
|
| 253 |
+
more than your reasonable cost of physically performing this
|
| 254 |
+
conveying of source, or (2) access to copy the
|
| 255 |
+
Corresponding Source from a network server at no charge.
|
| 256 |
+
|
| 257 |
+
c) Convey individual copies of the object code with a copy of the
|
| 258 |
+
written offer to provide the Corresponding Source. This
|
| 259 |
+
alternative is allowed only occasionally and noncommercially, and
|
| 260 |
+
only if you received the object code with such an offer, in accord
|
| 261 |
+
with subsection 6b.
|
| 262 |
+
|
| 263 |
+
d) Convey the object code by offering access from a designated
|
| 264 |
+
place (gratis or for a charge), and offer equivalent access to the
|
| 265 |
+
Corresponding Source in the same way through the same place at no
|
| 266 |
+
further charge. You need not require recipients to copy the
|
| 267 |
+
Corresponding Source along with the object code. If the place to
|
| 268 |
+
copy the object code is a network server, the Corresponding Source
|
| 269 |
+
may be on a different server (operated by you or a third party)
|
| 270 |
+
that supports equivalent copying facilities, provided you maintain
|
| 271 |
+
clear directions next to the object code saying where to find the
|
| 272 |
+
Corresponding Source. Regardless of what server hosts the
|
| 273 |
+
Corresponding Source, you remain obligated to ensure that it is
|
| 274 |
+
available for as long as needed to satisfy these requirements.
|
| 275 |
+
|
| 276 |
+
e) Convey the object code using peer-to-peer transmission, provided
|
| 277 |
+
you inform other peers where the object code and Corresponding
|
| 278 |
+
Source of the work are being offered to the general public at no
|
| 279 |
+
charge under subsection 6d.
|
| 280 |
+
|
| 281 |
+
A separable portion of the object code, whose source code is excluded
|
| 282 |
+
from the Corresponding Source as a System Library, need not be
|
| 283 |
+
included in conveying the object code work.
|
| 284 |
+
|
| 285 |
+
A "User Product" is either (1) a "consumer product", which means any
|
| 286 |
+
tangible personal property which is normally used for personal, family,
|
| 287 |
+
or household purposes, or (2) anything designed or sold for incorporation
|
| 288 |
+
into a dwelling. In determining whether a product is a consumer product,
|
| 289 |
+
doubtful cases shall be resolved in favor of coverage. For a particular
|
| 290 |
+
product received by a particular user, "normally used" refers to a
|
| 291 |
+
typical or common use of that class of product, regardless of the status
|
| 292 |
+
of the particular user or of the way in which the particular user
|
| 293 |
+
actually uses, or expects or is expected to use, the product. A product
|
| 294 |
+
is a consumer product regardless of whether the product has substantial
|
| 295 |
+
commercial, industrial or non-consumer uses, unless such uses represent
|
| 296 |
+
the only significant mode of use of the product.
|
| 297 |
+
|
| 298 |
+
"Installation Information" for a User Product means any methods,
|
| 299 |
+
procedures, authorization keys, or other information required to install
|
| 300 |
+
and execute modified versions of a covered work in that User Product from
|
| 301 |
+
a modified version of its Corresponding Source. The information must
|
| 302 |
+
suffice to ensure that the continued functioning of the modified object
|
| 303 |
+
code is in no case prevented or interfered with solely because
|
| 304 |
+
modification has been made.
|
| 305 |
+
|
| 306 |
+
If you convey an object code work under this section in, or with, or
|
| 307 |
+
specifically for use in, a User Product, and the conveying occurs as
|
| 308 |
+
part of a transaction in which the right of possession and use of the
|
| 309 |
+
User Product is transferred to the recipient in perpetuity or for a
|
| 310 |
+
fixed term (regardless of how the transaction is characterized), the
|
| 311 |
+
Corresponding Source conveyed under this section must be accompanied
|
| 312 |
+
by the Installation Information. But this requirement does not apply
|
| 313 |
+
if neither you nor any third party retains the ability to install
|
| 314 |
+
modified object code on the User Product (for example, the work has
|
| 315 |
+
been installed in ROM).
|
| 316 |
+
|
| 317 |
+
The requirement to provide Installation Information does not include a
|
| 318 |
+
requirement to continue to provide support service, warranty, or updates
|
| 319 |
+
for a work that has been modified or installed by the recipient, or for
|
| 320 |
+
the User Product in which it has been modified or installed. Access to a
|
| 321 |
+
network may be denied when the modification itself materially and
|
| 322 |
+
adversely affects the operation of the network or violates the rules and
|
| 323 |
+
protocols for communication across the network.
|
| 324 |
+
|
| 325 |
+
Corresponding Source conveyed, and Installation Information provided,
|
| 326 |
+
in accord with this section must be in a format that is publicly
|
| 327 |
+
documented (and with an implementation available to the public in
|
| 328 |
+
source code form), and must require no special password or key for
|
| 329 |
+
unpacking, reading or copying.
|
| 330 |
+
|
| 331 |
+
7. Additional Terms.
|
| 332 |
+
|
| 333 |
+
"Additional permissions" are terms that supplement the terms of this
|
| 334 |
+
License by making exceptions from one or more of its conditions.
|
| 335 |
+
Additional permissions that are applicable to the entire Program shall
|
| 336 |
+
be treated as though they were included in this License, to the extent
|
| 337 |
+
that they are valid under applicable law. If additional permissions
|
| 338 |
+
apply only to part of the Program, that part may be used separately
|
| 339 |
+
under those permissions, but the entire Program remains governed by
|
| 340 |
+
this License without regard to the additional permissions.
|
| 341 |
+
|
| 342 |
+
When you convey a copy of a covered work, you may at your option
|
| 343 |
+
remove any additional permissions from that copy, or from any part of
|
| 344 |
+
it. (Additional permissions may be written to require their own
|
| 345 |
+
removal in certain cases when you modify the work.) You may place
|
| 346 |
+
additional permissions on material, added by you to a covered work,
|
| 347 |
+
for which you have or can give appropriate copyright permission.
|
| 348 |
+
|
| 349 |
+
Notwithstanding any other provision of this License, for material you
|
| 350 |
+
add to a covered work, you may (if authorized by the copyright holders of
|
| 351 |
+
that material) supplement the terms of this License with terms:
|
| 352 |
+
|
| 353 |
+
a) Disclaiming warranty or limiting liability differently from the
|
| 354 |
+
terms of sections 15 and 16 of this License; or
|
| 355 |
+
|
| 356 |
+
b) Requiring preservation of specified reasonable legal notices or
|
| 357 |
+
author attributions in that material or in the Appropriate Legal
|
| 358 |
+
Notices displayed by works containing it; or
|
| 359 |
+
|
| 360 |
+
c) Prohibiting misrepresentation of the origin of that material, or
|
| 361 |
+
requiring that modified versions of such material be marked in
|
| 362 |
+
reasonable ways as different from the original version; or
|
| 363 |
+
|
| 364 |
+
d) Limiting the use for publicity purposes of names of licensors or
|
| 365 |
+
authors of the material; or
|
| 366 |
+
|
| 367 |
+
e) Declining to grant rights under trademark law for use of some
|
| 368 |
+
trade names, trademarks, or service marks; or
|
| 369 |
+
|
| 370 |
+
f) Requiring indemnification of licensors and authors of that
|
| 371 |
+
material by anyone who conveys the material (or modified versions of
|
| 372 |
+
it) with contractual assumptions of liability to the recipient, for
|
| 373 |
+
any liability that these contractual assumptions directly impose on
|
| 374 |
+
those licensors and authors.
|
| 375 |
+
|
| 376 |
+
All other non-permissive additional terms are considered "further
|
| 377 |
+
restrictions" within the meaning of section 10. If the Program as you
|
| 378 |
+
received it, or any part of it, contains a notice stating that it is
|
| 379 |
+
governed by this License along with a term that is a further
|
| 380 |
+
restriction, you may remove that term. If a license document contains
|
| 381 |
+
a further restriction but permits relicensing or conveying under this
|
| 382 |
+
License, you may add to a covered work material governed by the terms
|
| 383 |
+
of that license document, provided that the further restriction does
|
| 384 |
+
not survive such relicensing or conveying.
|
| 385 |
+
|
| 386 |
+
If you add terms to a covered work in accord with this section, you
|
| 387 |
+
must place, in the relevant source files, a statement of the
|
| 388 |
+
additional terms that apply to those files, or a notice indicating
|
| 389 |
+
where to find the applicable terms.
|
| 390 |
+
|
| 391 |
+
Additional terms, permissive or non-permissive, may be stated in the
|
| 392 |
+
form of a separately written license, or stated as exceptions;
|
| 393 |
+
the above requirements apply either way.
|
| 394 |
+
|
| 395 |
+
8. Termination.
|
| 396 |
+
|
| 397 |
+
You may not propagate or modify a covered work except as expressly
|
| 398 |
+
provided under this License. Any attempt otherwise to propagate or
|
| 399 |
+
modify it is void, and will automatically terminate your rights under
|
| 400 |
+
this License (including any patent licenses granted under the third
|
| 401 |
+
paragraph of section 11).
|
| 402 |
+
|
| 403 |
+
However, if you cease all violation of this License, then your
|
| 404 |
+
license from a particular copyright holder is reinstated (a)
|
| 405 |
+
provisionally, unless and until the copyright holder explicitly and
|
| 406 |
+
finally terminates your license, and (b) permanently, if the copyright
|
| 407 |
+
holder fails to notify you of the violation by some reasonable means
|
| 408 |
+
prior to 60 days after the cessation.
|
| 409 |
+
|
| 410 |
+
Moreover, your license from a particular copyright holder is
|
| 411 |
+
reinstated permanently if the copyright holder notifies you of the
|
| 412 |
+
violation by some reasonable means, this is the first time you have
|
| 413 |
+
received notice of violation of this License (for any work) from that
|
| 414 |
+
copyright holder, and you cure the violation prior to 30 days after
|
| 415 |
+
your receipt of the notice.
|
| 416 |
+
|
| 417 |
+
Termination of your rights under this section does not terminate the
|
| 418 |
+
licenses of parties who have received copies or rights from you under
|
| 419 |
+
this License. If your rights have been terminated and not permanently
|
| 420 |
+
reinstated, you do not qualify to receive new licenses for the same
|
| 421 |
+
material under section 10.
|
| 422 |
+
|
| 423 |
+
9. Acceptance Not Required for Having Copies.
|
| 424 |
+
|
| 425 |
+
You are not required to accept this License in order to receive or
|
| 426 |
+
run a copy of the Program. Ancillary propagation of a covered work
|
| 427 |
+
occurring solely as a consequence of using peer-to-peer transmission
|
| 428 |
+
to receive a copy likewise does not require acceptance. However,
|
| 429 |
+
nothing other than this License grants you permission to propagate or
|
| 430 |
+
modify any covered work. These actions infringe copyright if you do
|
| 431 |
+
not accept this License. Therefore, by modifying or propagating a
|
| 432 |
+
covered work, you indicate your acceptance of this License to do so.
|
| 433 |
+
|
| 434 |
+
10. Automatic Licensing of Downstream Recipients.
|
| 435 |
+
|
| 436 |
+
Each time you convey a covered work, the recipient automatically
|
| 437 |
+
receives a license from the original licensors, to run, modify and
|
| 438 |
+
propagate that work, subject to this License. You are not responsible
|
| 439 |
+
for enforcing compliance by third parties with this License.
|
| 440 |
+
|
| 441 |
+
An "entity transaction" is a transaction transferring control of an
|
| 442 |
+
organization, or substantially all assets of one, or subdividing an
|
| 443 |
+
organization, or merging organizations. If propagation of a covered
|
| 444 |
+
work results from an entity transaction, each party to that
|
| 445 |
+
transaction who receives a copy of the work also receives whatever
|
| 446 |
+
licenses to the work the party's predecessor in interest had or could
|
| 447 |
+
give under the previous paragraph, plus a right to possession of the
|
| 448 |
+
Corresponding Source of the work from the predecessor in interest, if
|
| 449 |
+
the predecessor has it or can get it with reasonable efforts.
|
| 450 |
+
|
| 451 |
+
You may not impose any further restrictions on the exercise of the
|
| 452 |
+
rights granted or affirmed under this License. For example, you may
|
| 453 |
+
not impose a license fee, royalty, or other charge for exercise of
|
| 454 |
+
rights granted under this License, and you may not initiate litigation
|
| 455 |
+
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
| 456 |
+
any patent claim is infringed by making, using, selling, offering for
|
| 457 |
+
sale, or importing the Program or any portion of it.
|
| 458 |
+
|
| 459 |
+
11. Patents.
|
| 460 |
+
|
| 461 |
+
A "contributor" is a copyright holder who authorizes use under this
|
| 462 |
+
License of the Program or a work on which the Program is based. The
|
| 463 |
+
work thus licensed is called the contributor's "contributor version".
|
| 464 |
+
|
| 465 |
+
A contributor's "essential patent claims" are all patent claims
|
| 466 |
+
owned or controlled by the contributor, whether already acquired or
|
| 467 |
+
hereafter acquired, that would be infringed by some manner, permitted
|
| 468 |
+
by this License, of making, using, or selling its contributor version,
|
| 469 |
+
but do not include claims that would be infringed only as a
|
| 470 |
+
consequence of further modification of the contributor version. For
|
| 471 |
+
purposes of this definition, "control" includes the right to grant
|
| 472 |
+
patent sublicenses in a manner consistent with the requirements of
|
| 473 |
+
this License.
|
| 474 |
+
|
| 475 |
+
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
| 476 |
+
patent license under the contributor's essential patent claims, to
|
| 477 |
+
make, use, sell, offer for sale, import and otherwise run, modify and
|
| 478 |
+
propagate the contents of its contributor version.
|
| 479 |
+
|
| 480 |
+
In the following three paragraphs, a "patent license" is any express
|
| 481 |
+
agreement or commitment, however denominated, not to enforce a patent
|
| 482 |
+
(such as an express permission to practice a patent or covenant not to
|
| 483 |
+
sue for patent infringement). To "grant" such a patent license to a
|
| 484 |
+
party means to make such an agreement or commitment not to enforce a
|
| 485 |
+
patent against the party.
|
| 486 |
+
|
| 487 |
+
If you convey a covered work, knowingly relying on a patent license,
|
| 488 |
+
and the Corresponding Source of the work is not available for anyone
|
| 489 |
+
to copy, free of charge and under the terms of this License, through a
|
| 490 |
+
publicly available network server or other readily accessible means,
|
| 491 |
+
then you must either (1) cause the Corresponding Source to be so
|
| 492 |
+
available, or (2) arrange to deprive yourself of the benefit of the
|
| 493 |
+
patent license for this particular work, or (3) arrange, in a manner
|
| 494 |
+
consistent with the requirements of this License, to extend the patent
|
| 495 |
+
license to downstream recipients. "Knowingly relying" means you have
|
| 496 |
+
actual knowledge that, but for the patent license, your conveying the
|
| 497 |
+
covered work in a country, or your recipient's use of the covered work
|
| 498 |
+
in a country, would infringe one or more identifiable patents in that
|
| 499 |
+
country that you have reason to believe are valid.
|
| 500 |
+
|
| 501 |
+
If, pursuant to or in connection with a single transaction or
|
| 502 |
+
arrangement, you convey, or propagate by procuring conveyance of, a
|
| 503 |
+
covered work, and grant a patent license to some of the parties
|
| 504 |
+
receiving the covered work authorizing them to use, propagate, modify
|
| 505 |
+
or convey a specific copy of the covered work, then the patent license
|
| 506 |
+
you grant is automatically extended to all recipients of the covered
|
| 507 |
+
work and works based on it.
|
| 508 |
+
|
| 509 |
+
A patent license is "discriminatory" if it does not include within
|
| 510 |
+
the scope of its coverage, prohibits the exercise of, or is
|
| 511 |
+
conditioned on the non-exercise of one or more of the rights that are
|
| 512 |
+
specifically granted under this License. You may not convey a covered
|
| 513 |
+
work if you are a party to an arrangement with a third party that is
|
| 514 |
+
in the business of distributing software, under which you make payment
|
| 515 |
+
to the third party based on the extent of your activity of conveying
|
| 516 |
+
the work, and under which the third party grants, to any of the
|
| 517 |
+
parties who would receive the covered work from you, a discriminatory
|
| 518 |
+
patent license (a) in connection with copies of the covered work
|
| 519 |
+
conveyed by you (or copies made from those copies), or (b) primarily
|
| 520 |
+
for and in connection with specific products or compilations that
|
| 521 |
+
contain the covered work, unless you entered into that arrangement,
|
| 522 |
+
or that patent license was granted, prior to 28 March 2007.
|
| 523 |
+
|
| 524 |
+
Nothing in this License shall be construed as excluding or limiting
|
| 525 |
+
any implied license or other defenses to infringement that may
|
| 526 |
+
otherwise be available to you under applicable patent law.
|
| 527 |
+
|
| 528 |
+
12. No Surrender of Others' Freedom.
|
| 529 |
+
|
| 530 |
+
If conditions are imposed on you (whether by court order, agreement or
|
| 531 |
+
otherwise) that contradict the conditions of this License, they do not
|
| 532 |
+
excuse you from the conditions of this License. If you cannot convey a
|
| 533 |
+
covered work so as to satisfy simultaneously your obligations under this
|
| 534 |
+
License and any other pertinent obligations, then as a consequence you may
|
| 535 |
+
not convey it at all. For example, if you agree to terms that obligate you
|
| 536 |
+
to collect a royalty for further conveying from those to whom you convey
|
| 537 |
+
the Program, the only way you could satisfy both those terms and this
|
| 538 |
+
License would be to refrain entirely from conveying the Program.
|
| 539 |
+
|
| 540 |
+
13. Remote Network Interaction; Use with the GNU General Public License.
|
| 541 |
+
|
| 542 |
+
Notwithstanding any other provision of this License, if you modify the
|
| 543 |
+
Program, your modified version must prominently offer all users
|
| 544 |
+
interacting with it remotely through a computer network (if your version
|
| 545 |
+
supports such interaction) an opportunity to receive the Corresponding
|
| 546 |
+
Source of your version by providing access to the Corresponding Source
|
| 547 |
+
from a network server at no charge, through some standard or customary
|
| 548 |
+
means of facilitating copying of software. This Corresponding Source
|
| 549 |
+
shall include the Corresponding Source for any work covered by version 3
|
| 550 |
+
of the GNU General Public License that is incorporated pursuant to the
|
| 551 |
+
following paragraph.
|
| 552 |
+
|
| 553 |
+
Notwithstanding any other provision of this License, you have
|
| 554 |
+
permission to link or combine any covered work with a work licensed
|
| 555 |
+
under version 3 of the GNU General Public License into a single
|
| 556 |
+
combined work, and to convey the resulting work. The terms of this
|
| 557 |
+
License will continue to apply to the part which is the covered work,
|
| 558 |
+
but the work with which it is combined will remain governed by version
|
| 559 |
+
3 of the GNU General Public License.
|
| 560 |
+
|
| 561 |
+
14. Revised Versions of this License.
|
| 562 |
+
|
| 563 |
+
The Free Software Foundation may publish revised and/or new versions of
|
| 564 |
+
the GNU Affero General Public License from time to time. Such new versions
|
| 565 |
+
will be similar in spirit to the present version, but may differ in detail to
|
| 566 |
+
address new problems or concerns.
|
| 567 |
+
|
| 568 |
+
Each version is given a distinguishing version number. If the
|
| 569 |
+
Program specifies that a certain numbered version of the GNU Affero General
|
| 570 |
+
Public License "or any later version" applies to it, you have the
|
| 571 |
+
option of following the terms and conditions either of that numbered
|
| 572 |
+
version or of any later version published by the Free Software
|
| 573 |
+
Foundation. If the Program does not specify a version number of the
|
| 574 |
+
GNU Affero General Public License, you may choose any version ever published
|
| 575 |
+
by the Free Software Foundation.
|
| 576 |
+
|
| 577 |
+
If the Program specifies that a proxy can decide which future
|
| 578 |
+
versions of the GNU Affero General Public License can be used, that proxy's
|
| 579 |
+
public statement of acceptance of a version permanently authorizes you
|
| 580 |
+
to choose that version for the Program.
|
| 581 |
+
|
| 582 |
+
Later license versions may give you additional or different
|
| 583 |
+
permissions. However, no additional obligations are imposed on any
|
| 584 |
+
author or copyright holder as a result of your choosing to follow a
|
| 585 |
+
later version.
|
| 586 |
+
|
| 587 |
+
15. Disclaimer of Warranty.
|
| 588 |
+
|
| 589 |
+
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
| 590 |
+
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
| 591 |
+
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
| 592 |
+
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
| 593 |
+
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
| 594 |
+
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
| 595 |
+
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
| 596 |
+
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
| 597 |
+
|
| 598 |
+
16. Limitation of Liability.
|
| 599 |
+
|
| 600 |
+
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
| 601 |
+
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
| 602 |
+
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
| 603 |
+
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
| 604 |
+
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
| 605 |
+
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
| 606 |
+
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
| 607 |
+
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
| 608 |
+
SUCH DAMAGES.
|
| 609 |
+
|
| 610 |
+
17. Interpretation of Sections 15 and 16.
|
| 611 |
+
|
| 612 |
+
If the disclaimer of warranty and limitation of liability provided
|
| 613 |
+
above cannot be given local legal effect according to their terms,
|
| 614 |
+
reviewing courts shall apply local law that most closely approximates
|
| 615 |
+
an absolute waiver of all civil liability in connection with the
|
| 616 |
+
Program, unless a warranty or assumption of liability accompanies a
|
| 617 |
+
copy of the Program in return for a fee.
|
| 618 |
+
|
| 619 |
+
END OF TERMS AND CONDITIONS
|
| 620 |
+
|
| 621 |
+
How to Apply These Terms to Your New Programs
|
| 622 |
+
|
| 623 |
+
If you develop a new program, and you want it to be of the greatest
|
| 624 |
+
possible use to the public, the best way to achieve this is to make it
|
| 625 |
+
free software which everyone can redistribute and change under these terms.
|
| 626 |
+
|
| 627 |
+
To do so, attach the following notices to the program. It is safest
|
| 628 |
+
to attach them to the start of each source file to most effectively
|
| 629 |
+
state the exclusion of warranty; and each file should have at least
|
| 630 |
+
the "copyright" line and a pointer to where the full notice is found.
|
| 631 |
+
|
| 632 |
+
<one line to give the program's name and a brief idea of what it does.>
|
| 633 |
+
Copyright (C) <year> <name of author>
|
| 634 |
+
|
| 635 |
+
This program is free software: you can redistribute it and/or modify
|
| 636 |
+
it under the terms of the GNU Affero General Public License as published by
|
| 637 |
+
the Free Software Foundation, either version 3 of the License, or
|
| 638 |
+
(at your option) any later version.
|
| 639 |
+
|
| 640 |
+
This program is distributed in the hope that it will be useful,
|
| 641 |
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 642 |
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 643 |
+
GNU Affero General Public License for more details.
|
| 644 |
+
|
| 645 |
+
You should have received a copy of the GNU Affero General Public License
|
| 646 |
+
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 647 |
+
|
| 648 |
+
Also add information on how to contact you by electronic and paper mail.
|
| 649 |
+
|
| 650 |
+
If your software can interact with users remotely through a computer
|
| 651 |
+
network, you should also make sure that it provides a way for users to
|
| 652 |
+
get its source. For example, if your program is a web application, its
|
| 653 |
+
interface could display a "Source" link that leads users to an archive
|
| 654 |
+
of the code. There are many ways you could offer source, and different
|
| 655 |
+
solutions will be better for different programs; see section 13 for the
|
| 656 |
+
specific requirements.
|
| 657 |
+
|
| 658 |
+
You should also get your employer (if you work as a programmer) or school,
|
| 659 |
+
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
| 660 |
+
For more information on this, and how to apply and follow the GNU AGPL, see
|
| 661 |
+
<http://www.gnu.org/licenses/>.
|
lichess-bot-master/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div align="center">
|
| 2 |
+
|
| 3 |
+

|
| 4 |
+
|
| 5 |
+
<h1>lichess-bot</h1>
|
| 6 |
+
|
| 7 |
+
A bridge between [lichess.org](https://lichess.org) and bots.
|
| 8 |
+
<br>
|
| 9 |
+
<strong>[Explore lichess-bot docs »](https://github.com/lichess-bot-devs/lichess-bot/wiki)</strong>
|
| 10 |
+
<br>
|
| 11 |
+
<br>
|
| 12 |
+
[](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-build.yml)
|
| 13 |
+
[](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-test.yml)
|
| 14 |
+
[](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/mypy.yml)
|
| 15 |
+
|
| 16 |
+
</div>
|
| 17 |
+
|
| 18 |
+
## Overview
|
| 19 |
+
|
| 20 |
+
[lichess-bot](https://github.com/lichess-bot-devs/lichess-bot) is a free bridge
|
| 21 |
+
between the [Lichess Bot API](https://lichess.org/api#tag/Bot) and chess engines.
|
| 22 |
+
|
| 23 |
+
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.
|
| 24 |
+
|
| 25 |
+
See also the lichess-bot [documentation](https://github.com/lichess-bot-devs/lichess-bot/wiki) for further usage help.
|
| 26 |
+
|
| 27 |
+
## Features
|
| 28 |
+
Supports:
|
| 29 |
+
- Every variant and time control
|
| 30 |
+
- UCI, XBoard, and Homemade engines
|
| 31 |
+
- Matchmaking (challenging other bots)
|
| 32 |
+
- Offering Draws and Resigning
|
| 33 |
+
- Participating in tournaments
|
| 34 |
+
- Accepting move takeback requests from opponents
|
| 35 |
+
- Saving games as PGN
|
| 36 |
+
- Local & Online Opening Books
|
| 37 |
+
- Local & Online Endgame Tablebases
|
| 38 |
+
|
| 39 |
+
Can run on:
|
| 40 |
+
- Python 3.10 and later
|
| 41 |
+
- Windows, Linux and MacOS
|
| 42 |
+
- Docker
|
| 43 |
+
|
| 44 |
+
## Steps
|
| 45 |
+
1. [Install lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Install)
|
| 46 |
+
2. [Create a lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token)
|
| 47 |
+
3. [Setup the engine](https://github.com/lichess-bot-devs/lichess-bot/wiki/Setup-the-engine)
|
| 48 |
+
4. [Configure lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/Configure-lichess-bot)
|
| 49 |
+
5. [Upgrade to a BOT account](https://github.com/lichess-bot-devs/lichess-bot/wiki/Upgrade-to-a-BOT-account)
|
| 50 |
+
6. [Run lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Run-lichess%E2%80%90bot)
|
| 51 |
+
|
| 52 |
+
## Advanced options
|
| 53 |
+
- [Create a homemade engine](https://github.com/lichess-bot-devs/lichess-bot/wiki/Create-a-homemade-engine)
|
| 54 |
+
- [Add extra customizations](https://github.com/lichess-bot-devs/lichess-bot/wiki/Extra-customizations)
|
| 55 |
+
|
| 56 |
+
<br />
|
| 57 |
+
|
| 58 |
+
## Acknowledgements
|
| 59 |
+
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.
|
| 60 |
+
|
| 61 |
+
## License
|
| 62 |
+
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.
|
| 63 |
+
|
| 64 |
+
## Citation
|
| 65 |
+
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).
|
lichess-bot-master/config.yml.default
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
token: "lip_kKCkBvIIxTKygbXxNv8Z" # Lichess OAuth2 Token.
|
| 2 |
+
url: "https://lichess.org/" # Lichess base URL.
|
| 3 |
+
|
| 4 |
+
engine: # Engine settings.
|
| 5 |
+
dir: "./engines/stockfish" # Directory containing the engine. This can be an absolute path or one relative to lichess-bot/.
|
| 6 |
+
name: "stockfish-ubuntu-x86-64-avx2fd" # Binary name of the engine to use.
|
| 7 |
+
debug: False # When enabled, log all startup communication with the engine.
|
| 8 |
+
# interpreter: "java"
|
| 9 |
+
# interpreter_options:
|
| 10 |
+
# - "-jar"
|
| 11 |
+
working_dir: "" # Directory where the chess engine will read and write files. If blank or missing, the current directory is used.
|
| 12 |
+
# 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.
|
| 13 |
+
protocol: "uci" # "uci", "xboard" or "homemade"
|
| 14 |
+
ponder: true # Think on opponent's time.
|
| 15 |
+
|
| 16 |
+
polyglot:
|
| 17 |
+
enabled: false # Activate polyglot book.
|
| 18 |
+
book:
|
| 19 |
+
standard: # List of book file paths for variant standard.
|
| 20 |
+
- engines/book1.bin
|
| 21 |
+
- engines/book2.bin
|
| 22 |
+
# atomic: # List of book file paths for variant atomic.
|
| 23 |
+
# - engines/atomicbook1.bin
|
| 24 |
+
# - engines/atomicbook2.bin
|
| 25 |
+
# etc.
|
| 26 |
+
# Use the same pattern for 'chess960', 'giveaway' (antichess), 'crazyhouse', 'horde', 'kingofthehill', 'racingkings' and '3check' as well.
|
| 27 |
+
min_weight: 1 # Does not select moves with weight below min_weight (min 0, max: 100 if normalization isn't "none" else 65535).
|
| 28 |
+
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).
|
| 29 |
+
max_depth: 20 # How many moves from the start to take from the book.
|
| 30 |
+
normalization: "none" # Normalization method for the book weights. One of "none", "sum", or "max".
|
| 31 |
+
|
| 32 |
+
draw_or_resign:
|
| 33 |
+
resign_enabled: false # Whether or not the bot should resign.
|
| 34 |
+
resign_score: -1000 # If the score is less than or equal to this value, the bot resigns (in cp).
|
| 35 |
+
resign_for_egtb_minus_two: true # If true the bot will resign in positions where the online_egtb returns a wdl of -2.
|
| 36 |
+
resign_moves: 3 # How many moves in a row the score has to be below the resign value.
|
| 37 |
+
offer_draw_enabled: true # Whether or not the bot should offer/accept draw.
|
| 38 |
+
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).
|
| 39 |
+
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.
|
| 40 |
+
offer_draw_moves: 10 # How many moves in a row the absolute value of the score has to be below the draw value.
|
| 41 |
+
offer_draw_pieces: 10 # Only if the pieces on board are less than or equal to this value, the bot offers/accepts draw.
|
| 42 |
+
|
| 43 |
+
online_moves:
|
| 44 |
+
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.
|
| 45 |
+
max_retries: 2 # The maximum amount of retries when getting an online move.
|
| 46 |
+
# max_depth: 10 # How many moves from the start to take from online books. Default is no limit.
|
| 47 |
+
chessdb_book:
|
| 48 |
+
enabled: false # Whether or not to use chessdb book.
|
| 49 |
+
min_time: 20 # Minimum time (in seconds) to use chessdb book.
|
| 50 |
+
max_time: 10800 # Maximum starting game time (in seconds) to use chessdb book.
|
| 51 |
+
move_quality: "good" # One of "all", "good", "best".
|
| 52 |
+
min_depth: 20 # Only for move_quality: "best".
|
| 53 |
+
lichess_cloud_analysis:
|
| 54 |
+
enabled: false # Whether or not to use lichess cloud analysis.
|
| 55 |
+
min_time: 20 # Minimum time (in seconds) the bot must have to use cloud analysis.
|
| 56 |
+
max_time: 10800 # Maximum starting game time (in seconds) the bot must have to use cloud analysis.
|
| 57 |
+
move_quality: "best" # One of "good", "best".
|
| 58 |
+
max_score_difference: 50 # Only for move_quality: "good". The maximum score difference (in cp) between the best move and the other moves.
|
| 59 |
+
min_depth: 20
|
| 60 |
+
min_knodes: 0
|
| 61 |
+
lichess_opening_explorer:
|
| 62 |
+
enabled: false
|
| 63 |
+
min_time: 20
|
| 64 |
+
max_time: 10800 # Maximum starting game time (in seconds) the bot must have to use the lichess opening explorer.
|
| 65 |
+
source: "masters" # One of "lichess", "masters", "player"
|
| 66 |
+
player_name: "" # The lichess username. Leave empty for the bot's username to be used. Used only when source is "player".
|
| 67 |
+
sort: "winrate" # One of "winrate", "games_played"
|
| 68 |
+
min_games: 10 # Minimum number of times a move must have been played to be chosen.
|
| 69 |
+
online_egtb:
|
| 70 |
+
enabled: false # Whether or not to enable online endgame tablebases.
|
| 71 |
+
min_time: 20 # Minimum time (in seconds) the bot must have to use online EGTBs.
|
| 72 |
+
max_time: 10800 # Maximum starting game time (in seconds) the bot must have to use online EGTBs.
|
| 73 |
+
max_pieces: 8 # Maximum number of pieces on the board to use endgame tablebases.
|
| 74 |
+
source: "lichess" # One of "lichess", "chessdb".
|
| 75 |
+
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).
|
| 76 |
+
|
| 77 |
+
lichess_bot_tbs: # The tablebases list here will be read by lichess-bot, not the engine.
|
| 78 |
+
syzygy:
|
| 79 |
+
enabled: false # Whether or not to use local syzygy endgame tablebases.
|
| 80 |
+
paths: # Paths to Syzygy endgame tablebases.
|
| 81 |
+
- "engines/syzygy"
|
| 82 |
+
max_pieces: 7 # Maximum number of pieces in the endgame tablebase.
|
| 83 |
+
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).
|
| 84 |
+
gaviota:
|
| 85 |
+
enabled: false # Whether or not to use local gaviota endgame tablebases.
|
| 86 |
+
paths:
|
| 87 |
+
- "engines/gaviota"
|
| 88 |
+
max_pieces: 5
|
| 89 |
+
min_dtm_to_consider_as_wdl_1: 120 # The minimum DTM to consider as syzygy WDL=1/-1. Set to 100 to disable.
|
| 90 |
+
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).
|
| 91 |
+
|
| 92 |
+
# engine_options: # Any custom command line params to pass to the engine.
|
| 93 |
+
# cpuct: 3.1
|
| 94 |
+
|
| 95 |
+
homemade_options:
|
| 96 |
+
# Hash: 256
|
| 97 |
+
|
| 98 |
+
uci_options: # Arbitrary UCI options passed to the engine.
|
| 99 |
+
Move Overhead: 100 # Increase if your bot flags games too often.
|
| 100 |
+
Threads: 4 # Max CPU threads the engine can use.
|
| 101 |
+
Hash: 512 # Max memory (in megabytes) the engine can allocate.
|
| 102 |
+
SyzygyPath: "./syzygy/" # Paths to Syzygy endgame tablebases that the engine reads.
|
| 103 |
+
UCI_ShowWDL: true # Show the chance of the engine winning.
|
| 104 |
+
# go_commands: # Additional options to pass to the UCI go command.
|
| 105 |
+
# nodes: 1 # Search so many nodes only.
|
| 106 |
+
# depth: 5 # Search depth ply only.
|
| 107 |
+
# movetime: 1000 # Integer. Search exactly movetime milliseconds.
|
| 108 |
+
|
| 109 |
+
# xboard_options: # Arbitrary XBoard options passed to the engine.
|
| 110 |
+
# cores: "4"
|
| 111 |
+
# memory: "4096"
|
| 112 |
+
# egtpath: # Directory containing egtb (endgame tablabases), relative to this project. For 'xboard' engines.
|
| 113 |
+
# gaviota: "Gaviota path"
|
| 114 |
+
# nalimov: "Nalimov Path"
|
| 115 |
+
# scorpio: "Scorpio Path"
|
| 116 |
+
# syzygy: "Syzygy Path"
|
| 117 |
+
# go_commands: # Additional options to pass to the XBoard go command.
|
| 118 |
+
# depth: 5 # Search depth ply only.
|
| 119 |
+
# Do note that the go commands 'movetime' and 'nodes' are invalid and may cause bad time management for XBoard engines.
|
| 120 |
+
|
| 121 |
+
silence_stderr: false # Some engines (yes you, Leela) are very noisy.
|
| 122 |
+
|
| 123 |
+
abort_time: 30 # Time to abort a game in seconds when there is no activity.
|
| 124 |
+
fake_think_time: false # Artificially slow down the bot to pretend like it's thinking.
|
| 125 |
+
rate_limiting_delay: 0 # Time (in ms) to delay after sending a move to prevent "Too Many Requests" errors.
|
| 126 |
+
move_overhead: 2000 # Increase if your bot flags games too often.
|
| 127 |
+
max_takebacks_accepted: 0 # The number of times to allow an opponent to take back a move in a game.
|
| 128 |
+
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.
|
| 129 |
+
|
| 130 |
+
correspondence:
|
| 131 |
+
move_time: 60 # Time in seconds to search in correspondence games.
|
| 132 |
+
checkin_period: 300 # How often to check for opponent moves in correspondence games after disconnecting.
|
| 133 |
+
disconnect_time: 150 # Time before disconnecting from a correspondence game.
|
| 134 |
+
ponder: false # Ponder in correspondence games the bot is connected to.
|
| 135 |
+
|
| 136 |
+
challenge: # Incoming challenges.
|
| 137 |
+
concurrency: 1 # Number of games to play simultaneously.
|
| 138 |
+
sort_by: "best" # Possible values: "best" and "first".
|
| 139 |
+
preference: "none" # Possible values: "none", "human", "bot".
|
| 140 |
+
accept_bot: true # Accepts challenges coming from other bots.
|
| 141 |
+
only_bot: false # Accept challenges by bots only.
|
| 142 |
+
max_increment: 20 # Maximum amount of increment to accept a challenge in seconds. The max is 180. Set to 0 for no increment.
|
| 143 |
+
min_increment: 0 # Minimum amount of increment to accept a challenge in seconds.
|
| 144 |
+
max_base: 1800 # Maximum amount of base time to accept a challenge in seconds. The max is 10800 (3 hours).
|
| 145 |
+
min_base: 0 # Minimum amount of base time to accept a challenge in seconds.
|
| 146 |
+
max_days: 14 # Maximum number of days per move to accept a challenge for a correspondence game.
|
| 147 |
+
# Unlimited games can be accepted by removing this field or specifying .inf
|
| 148 |
+
min_days: 1 # Minimum number of days per move to accept a challenge for a correspondence game.
|
| 149 |
+
variants: # Chess variants to accept (https://lichess.org/variant).
|
| 150 |
+
- standard
|
| 151 |
+
# - fromPosition
|
| 152 |
+
# - antichess
|
| 153 |
+
# - atomic
|
| 154 |
+
# - chess960
|
| 155 |
+
# - crazyhouse
|
| 156 |
+
# - horde
|
| 157 |
+
# - kingOfTheHill
|
| 158 |
+
# - racingKings
|
| 159 |
+
# - threeCheck
|
| 160 |
+
time_controls: # Time controls to accept (bots are not allowed to play ultraBullet).
|
| 161 |
+
- bullet
|
| 162 |
+
- blitz
|
| 163 |
+
- rapid
|
| 164 |
+
- classical
|
| 165 |
+
# - correspondence
|
| 166 |
+
modes: # Game modes to accept.
|
| 167 |
+
- casual # Unrated games.
|
| 168 |
+
- rated # Rated games - must comment if the engine doesn't try to win.
|
| 169 |
+
# block_list: # List of users from which the challenges are always declined.
|
| 170 |
+
# - user1
|
| 171 |
+
# - user2
|
| 172 |
+
# 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
|
| 173 |
+
# - example.com/blocklist
|
| 174 |
+
# allow_list: # List of users from which challenges are exclusively accepted, all others being declined. If empty, challenges from all users may be accepted.
|
| 175 |
+
# - user3
|
| 176 |
+
# - user4
|
| 177 |
+
# recent_bot_challenge_age: 60 # Maximum age of a bot challenge to be considered recent in seconds
|
| 178 |
+
# max_recent_bot_challenges: 2 # Maximum number of recent challenges that can be accepted from the same bot
|
| 179 |
+
# min_rating: 0 # Minimum rating of the challenger to accept a challenge.
|
| 180 |
+
# max_rating: 4000 # Maximum rating of the challenger to accept a challenge. The maximum rating in lichess is 4000.
|
| 181 |
+
# rating_difference: 300 # Maximum rating difference between the bot and the challenger. Overrides min_rating and max_rating if set.
|
| 182 |
+
bullet_requires_increment: false # Require that bullet game challenges from bots have a non-zero increment
|
| 183 |
+
max_simultaneous_games_per_user: 5 # Maximum number of simultaneous games with the same user
|
| 184 |
+
|
| 185 |
+
greeting:
|
| 186 |
+
# Optional substitution keywords (include curly braces):
|
| 187 |
+
# {opponent} to insert opponent's name
|
| 188 |
+
# {me} to insert bot's name
|
| 189 |
+
# Any other words in curly braces will be removed.
|
| 190 |
+
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
|
| 191 |
+
goodbye: "Good game!" # Message to send to opponent chat at the end of a game
|
| 192 |
+
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
|
| 193 |
+
goodbye_spectators: "Thanks for watching!" # Message to send to spectator chat at the end of a game
|
| 194 |
+
|
| 195 |
+
# pgn_directory: "game_records" # A directory where PGN-format records of the bot's games are kept
|
| 196 |
+
# pgn_file_grouping: "game" # How to group games into files. Options are "game", "opponent", and "all"
|
| 197 |
+
# "game" (default) - every game is written to a different file named "{White name} vs. {Black name} - {lichess game ID}.pgn"
|
| 198 |
+
# "opponent" - every game with a given opponent is written to a file named "{Bot name} games vs. {Opponent name}.pgn"
|
| 199 |
+
# "all" - every game is written to a single file named "{Bot name} games.pgn"
|
| 200 |
+
|
| 201 |
+
matchmaking:
|
| 202 |
+
allow_matchmaking: false # Set it to 'true' to challenge other bots.
|
| 203 |
+
allow_during_games: false # Set it to 'true' to create challenges during long games.
|
| 204 |
+
challenge_variant: "random" # If set to 'random', the bot will choose one variant from the variants enabled in 'challenge.variants'.
|
| 205 |
+
challenge_timeout: 30 # Create a challenge after being idle for 'challenge_timeout' minutes. The minimum is 1 minute.
|
| 206 |
+
challenge_initial_time: # Initial time in seconds of the challenge (to be chosen at random).
|
| 207 |
+
- 60
|
| 208 |
+
- 180
|
| 209 |
+
challenge_increment: # Increment in seconds of the challenge (to be chosen at random).
|
| 210 |
+
- 1
|
| 211 |
+
- 2
|
| 212 |
+
# challenge_days: # Days for correspondence challenge (to be chosen at random).
|
| 213 |
+
# - 1
|
| 214 |
+
# - 2
|
| 215 |
+
# opponent_min_rating: 600 # Opponents rating should be above this value (600 is the minimum rating in lichess).
|
| 216 |
+
# opponent_max_rating: 4000 # Opponents rating should be below this value (4000 is the maximum rating in lichess).
|
| 217 |
+
opponent_rating_difference: 300 # The maximum difference in rating between the bot's rating and opponent's rating.
|
| 218 |
+
rating_preference: "none" # One of "none", "high", "low".
|
| 219 |
+
challenge_mode: "random" # Set it to the mode in which challenges are sent. Possible options are 'casual', 'rated' and 'random'.
|
| 220 |
+
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'.
|
| 221 |
+
# block_list: # The list of bots that will not be challenged
|
| 222 |
+
# - user1
|
| 223 |
+
# - user2
|
| 224 |
+
# 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
|
| 225 |
+
# - example.com/blocklist
|
| 226 |
+
include_challenge_block_list: false # Do not challenge bots in the challenge: block_list in addition to the matchmaking block list.
|
| 227 |
+
|
| 228 |
+
# 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.
|
| 229 |
+
# 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).
|
| 230 |
+
# 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.
|
| 231 |
+
# challenge_initial_time:
|
| 232 |
+
# - 1
|
| 233 |
+
# - 2
|
| 234 |
+
# challenge_increment:
|
| 235 |
+
# - 0
|
| 236 |
+
# - 1
|
| 237 |
+
#
|
| 238 |
+
# easy_chess960:
|
| 239 |
+
# challenge_variant: "chess960"
|
| 240 |
+
# opponent_min_rating: 400
|
| 241 |
+
# opponent_max_rating: 1200
|
| 242 |
+
# opponent_rating_difference:
|
| 243 |
+
# challenge_mode: casual
|
| 244 |
+
#
|
| 245 |
+
# no_pressure_correspondence:
|
| 246 |
+
# challenge_initial_time:
|
| 247 |
+
# challenge_increment:
|
| 248 |
+
# challenge_days:
|
| 249 |
+
# - 2
|
| 250 |
+
# - 3
|
| 251 |
+
# challenge_mode: casual
|
| 252 |
+
#
|
| 253 |
+
# The following configurations cannot be overridden: allow_matchmaking, challenge_timeout, challenge_filter and block_list.
|
lichess-bot-master/docker/Dockerfile
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ARG VARIANT
|
| 2 |
+
FROM python:3$VARIANT
|
| 3 |
+
LABEL org.opencontainers.image.authors="Lichess Bot Devs <lichess.bot.devs@gmail.com>"
|
| 4 |
+
LABEL org.opencontainers.image.description="A bridge between Lichess API and chess engines"
|
| 5 |
+
LABEL org.opencontainers.image.source="https://github.com/lichess-bot-devs/lichess-bot"
|
| 6 |
+
LABEL org.opencontainers.image.documentation="https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-use-the-Docker-image"
|
| 7 |
+
LABEL org.opencontainers.image.title="lichess-bot"
|
| 8 |
+
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
|
| 9 |
+
|
| 10 |
+
ENV LICHESS_BOT_DOCKER="true"
|
| 11 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 12 |
+
|
| 13 |
+
ARG LICHESS_DIR=/lichess-bot
|
| 14 |
+
WORKDIR $LICHESS_DIR
|
| 15 |
+
|
| 16 |
+
COPY . .
|
| 17 |
+
|
| 18 |
+
RUN python3 -m pip install --no-cache-dir -r requirements.txt
|
| 19 |
+
|
| 20 |
+
RUN chmod +x docker/copy_files.sh && ln -s $LICHESS_DIR/config/config.yml config.yml
|
| 21 |
+
|
| 22 |
+
CMD docker/copy_files.sh && python3 lichess-bot.py ${OPTIONS} --disable_auto_logging
|
lichess-bot-master/docker/Dockerfile.dockerignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
docker/*
|
| 2 |
+
!docker/copy_files.sh
|
| 3 |
+
docs/
|
| 4 |
+
wiki/
|
| 5 |
+
.git*
|
| 6 |
+
**/__pycache__
|
lichess-bot-master/docker/copy_files.sh
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/sh
|
| 2 |
+
|
| 3 |
+
if [ -e /lichess-bot/config/homemade.py ]; then
|
| 4 |
+
ln -sf /lichess-bot/config/homemade.py /lichess-bot/
|
| 5 |
+
fi
|
| 6 |
+
|
| 7 |
+
if [ -e /lichess-bot/config/extra_game_handlers.py ]; then
|
| 8 |
+
ln -sf /lichess-bot/config/extra_game_handlers.py /lichess-bot/
|
| 9 |
+
fi
|
lichess-bot-master/docs/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Code of Conduct
|
| 2 |
+
|
| 3 |
+
## Our Pledge
|
| 4 |
+
|
| 5 |
+
We as members, contributors, and leaders pledge to make participation in our
|
| 6 |
+
community a harassment-free experience for everyone, regardless of age, body
|
| 7 |
+
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
| 8 |
+
identity and expression, level of experience, education, socio-economic status,
|
| 9 |
+
nationality, personal appearance, race, religion, or sexual identity
|
| 10 |
+
and orientation.
|
| 11 |
+
|
| 12 |
+
We pledge to act and interact in ways that contribute to an open, welcoming,
|
| 13 |
+
diverse, inclusive, and healthy community.
|
| 14 |
+
|
| 15 |
+
## Our Standards
|
| 16 |
+
|
| 17 |
+
Examples of behavior that contributes to a positive environment for our
|
| 18 |
+
community include:
|
| 19 |
+
|
| 20 |
+
* Demonstrating empathy and kindness toward other people
|
| 21 |
+
* Being respectful of differing opinions, viewpoints, and experiences
|
| 22 |
+
* Giving and gracefully accepting constructive feedback
|
| 23 |
+
* Accepting responsibility and apologizing to those affected by our mistakes,
|
| 24 |
+
and learning from the experience
|
| 25 |
+
* Focusing on what is best not just for us as individuals, but for the
|
| 26 |
+
overall community
|
| 27 |
+
|
| 28 |
+
Examples of unacceptable behavior include:
|
| 29 |
+
|
| 30 |
+
* The use of sexualized language or imagery, and sexual attention or
|
| 31 |
+
advances of any kind
|
| 32 |
+
* Trolling, insulting or derogatory comments, and personal or political attacks
|
| 33 |
+
* Public or private harassment
|
| 34 |
+
* Publishing others' private information, such as a physical or email
|
| 35 |
+
address, without their explicit permission
|
| 36 |
+
* Other conduct which could reasonably be considered inappropriate in a
|
| 37 |
+
professional setting
|
| 38 |
+
|
| 39 |
+
## Our Responsibilities
|
| 40 |
+
|
| 41 |
+
Community leaders are responsible for clarifying and enforcing our standards of
|
| 42 |
+
acceptable behavior and will take appropriate and fair corrective action in
|
| 43 |
+
response to any behavior that they deem inappropriate, threatening, offensive,
|
| 44 |
+
or harmful.
|
| 45 |
+
|
| 46 |
+
Community leaders have the right and responsibility to remove, edit, or reject
|
| 47 |
+
comments, commits, code, wiki edits, issues, and other contributions that are
|
| 48 |
+
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
| 49 |
+
decisions when appropriate.
|
| 50 |
+
|
| 51 |
+
## Enforcement
|
| 52 |
+
|
| 53 |
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
| 54 |
+
reported to the community leaders responsible for enforcement
|
| 55 |
+
at lichess.bot.devs@gmail.com.
|
| 56 |
+
All complaints will be reviewed and investigated promptly and fairly.
|
| 57 |
+
|
| 58 |
+
All community leaders are obligated to respect the privacy and security of the
|
| 59 |
+
reporter of any incident.
|
| 60 |
+
|
| 61 |
+
## Attribution
|
| 62 |
+
|
| 63 |
+
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
|
| 64 |
+
|
| 65 |
+
For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq
|
lichess-bot-master/docs/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributing to lichess-bot
|
| 2 |
+
|
| 3 |
+
We welcome your contributions! There are multiple ways to contribute.
|
| 4 |
+
|
| 5 |
+
## Table of Contents
|
| 6 |
+
|
| 7 |
+
1. [Code of Conduct](#code-of-conduct)
|
| 8 |
+
2. [How to Contribute](#how-to-contribute)
|
| 9 |
+
3. [Reporting Bugs](#reporting-bugs)
|
| 10 |
+
4. [Requesting Features](#requesting-features)
|
| 11 |
+
5. [Submitting Pull Requests](#submitting-pull-requests)
|
| 12 |
+
6. [Testing](#testing)
|
| 13 |
+
7. [Documentation](#documentation)
|
| 14 |
+
|
| 15 |
+
## Code of Conduct
|
| 16 |
+
|
| 17 |
+
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.
|
| 18 |
+
|
| 19 |
+
## How to Contribute
|
| 20 |
+
|
| 21 |
+
We welcome contributions in the form of bug reports, feature requests, code changes, and documentation improvements. Here's how you can contribute:
|
| 22 |
+
|
| 23 |
+
- Fork the repository to your GitHub account.
|
| 24 |
+
- Create a new branch for your feature or bug fix.
|
| 25 |
+
- Make your changes and commit them with a clear and concise commit message.
|
| 26 |
+
- Push your changes to your branch.
|
| 27 |
+
- Submit a pull request to the main repository.
|
| 28 |
+
|
| 29 |
+
Please follow our [Pull Request Template](/.github/pull_request_template.md) when submitting a pull request.
|
| 30 |
+
|
| 31 |
+
## Reporting Bugs
|
| 32 |
+
|
| 33 |
+
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.
|
| 34 |
+
When filing a bug remember that the better written the bug is, the more likely it is to be fixed.
|
| 35 |
+
Please follow our [Bug Report Template](/.github/ISSUE_TEMPLATE/bug_report.md) when submitting a pull request.
|
| 36 |
+
|
| 37 |
+
## Requesting Features
|
| 38 |
+
|
| 39 |
+
We encourage you to open an issue to propose new features or improvements. Please provide as much detail as possible about your suggestion.
|
| 40 |
+
Please follow our [Feature Request Template](/.github/ISSUE_TEMPLATE/feature_request.md) when submitting a pull request.
|
| 41 |
+
|
| 42 |
+
## Submitting Pull Requests
|
| 43 |
+
|
| 44 |
+
When submitting a pull request, please ensure the following:
|
| 45 |
+
|
| 46 |
+
- You have added or updated relevant documentation.
|
| 47 |
+
- Tests (if applicable) have been added or updated.
|
| 48 |
+
- Your branch is up-to-date with the main repository.
|
| 49 |
+
- The pull request title and description are clear and concise.
|
| 50 |
+
|
| 51 |
+
## Testing
|
| 52 |
+
|
| 53 |
+
Ensure that your changes pass all existing tests and consider adding new tests if applicable.
|
| 54 |
+
|
| 55 |
+
## Documentation
|
| 56 |
+
|
| 57 |
+
Improvements to the documentation are always welcome. If you find areas that need clarification or additional information, please submit a pull request.
|
lichess-bot-master/docs/SECURITY.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Reporting Security Issues
|
| 2 |
+
|
| 3 |
+
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.
|
| 4 |
+
|
| 5 |
+
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.
|
| 6 |
+
|
| 7 |
+
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.
|
| 8 |
+
|
| 9 |
+
Report security bugs in third-party modules to the person or team maintaining the module.
|
lichess-bot-master/engines/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Put your engines and opening books here.
|
lichess-bot-master/extra_game_handlers.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Functions for the user to implement when the config file is not adequate to express bot requirements."""
|
| 2 |
+
from lib import model
|
| 3 |
+
from lib.lichess_types import OPTIONS_TYPE
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def game_specific_options(game: model.Game) -> OPTIONS_TYPE: # noqa: ARG001
|
| 7 |
+
"""
|
| 8 |
+
Return a dictionary of engine options based on game aspects.
|
| 9 |
+
|
| 10 |
+
By default, an empty dict is returned so that the options in the configuration file are used.
|
| 11 |
+
"""
|
| 12 |
+
return {}
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def is_supported_extra(challenge: model.Challenge) -> bool: # noqa: ARG001
|
| 16 |
+
"""
|
| 17 |
+
Determine whether to accept a challenge.
|
| 18 |
+
|
| 19 |
+
By default, True is always returned so that there are no extra restrictions beyond those in the config file.
|
| 20 |
+
"""
|
| 21 |
+
return True
|
lichess-bot-master/homemade.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Some example classes for people who want to create a homemade bot.
|
| 3 |
+
|
| 4 |
+
With these classes, bot makers will not have to implement the UCI or XBoard interfaces themselves.
|
| 5 |
+
"""
|
| 6 |
+
import chess
|
| 7 |
+
from chess.engine import PlayResult, Limit
|
| 8 |
+
import random
|
| 9 |
+
from lib.engine_wrapper import MinimalEngine
|
| 10 |
+
from lib.lichess_types import MOVE, HOMEMADE_ARGS_TYPE
|
| 11 |
+
import logging
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# Use this logger variable to print messages to the console or log files.
|
| 15 |
+
# logger.info("message") will always print "message" to the console or log file.
|
| 16 |
+
# logger.debug("message") will only print "message" if verbose logging is enabled.
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ExampleEngine(MinimalEngine):
|
| 21 |
+
"""An example engine that all homemade engines inherit."""
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# Bot names and ideas from tom7's excellent eloWorld video
|
| 25 |
+
|
| 26 |
+
class RandomMove(ExampleEngine):
|
| 27 |
+
"""Get a random move."""
|
| 28 |
+
|
| 29 |
+
def search(self, board: chess.Board, *args: HOMEMADE_ARGS_TYPE) -> PlayResult: # noqa: ARG002
|
| 30 |
+
"""Choose a random move."""
|
| 31 |
+
return PlayResult(random.choice(list(board.legal_moves)), None)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class Alphabetical(ExampleEngine):
|
| 35 |
+
"""Get the first move when sorted by san representation."""
|
| 36 |
+
|
| 37 |
+
def search(self, board: chess.Board, *args: HOMEMADE_ARGS_TYPE) -> PlayResult: # noqa: ARG002
|
| 38 |
+
"""Choose the first move alphabetically."""
|
| 39 |
+
moves = list(board.legal_moves)
|
| 40 |
+
moves.sort(key=board.san)
|
| 41 |
+
return PlayResult(moves[0], None)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class FirstMove(ExampleEngine):
|
| 45 |
+
"""Get the first move when sorted by uci representation."""
|
| 46 |
+
|
| 47 |
+
def search(self, board: chess.Board, *args: HOMEMADE_ARGS_TYPE) -> PlayResult: # noqa: ARG002
|
| 48 |
+
"""Choose the first move alphabetically in uci representation."""
|
| 49 |
+
moves = list(board.legal_moves)
|
| 50 |
+
moves.sort(key=str)
|
| 51 |
+
return PlayResult(moves[0], None)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class ComboEngine(ExampleEngine):
|
| 55 |
+
"""
|
| 56 |
+
Get a move using multiple different methods.
|
| 57 |
+
|
| 58 |
+
This engine demonstrates how one can use `time_limit`, `draw_offered`, and `root_moves`.
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
def search(self,
|
| 62 |
+
board: chess.Board,
|
| 63 |
+
time_limit: Limit,
|
| 64 |
+
ponder: bool, # noqa: ARG002
|
| 65 |
+
draw_offered: bool,
|
| 66 |
+
root_moves: MOVE) -> PlayResult:
|
| 67 |
+
"""
|
| 68 |
+
Choose a move using multiple different methods.
|
| 69 |
+
|
| 70 |
+
:param board: The current position.
|
| 71 |
+
:param time_limit: Conditions for how long the engine can search (e.g. we have 10 seconds and search up to depth 10).
|
| 72 |
+
:param ponder: Whether the engine can ponder after playing a move.
|
| 73 |
+
:param draw_offered: Whether the bot was offered a draw.
|
| 74 |
+
:param root_moves: If it is a list, the engine should only play a move that is in `root_moves`.
|
| 75 |
+
:return: The move to play.
|
| 76 |
+
"""
|
| 77 |
+
if isinstance(time_limit.time, int):
|
| 78 |
+
my_time = time_limit.time
|
| 79 |
+
my_inc = 0
|
| 80 |
+
elif board.turn == chess.WHITE:
|
| 81 |
+
my_time = time_limit.white_clock if isinstance(time_limit.white_clock, int) else 0
|
| 82 |
+
my_inc = time_limit.white_inc if isinstance(time_limit.white_inc, int) else 0
|
| 83 |
+
else:
|
| 84 |
+
my_time = time_limit.black_clock if isinstance(time_limit.black_clock, int) else 0
|
| 85 |
+
my_inc = time_limit.black_inc if isinstance(time_limit.black_inc, int) else 0
|
| 86 |
+
|
| 87 |
+
possible_moves = root_moves if isinstance(root_moves, list) else list(board.legal_moves)
|
| 88 |
+
|
| 89 |
+
if my_time / 60 + my_inc > 10:
|
| 90 |
+
# Choose a random move.
|
| 91 |
+
move = random.choice(possible_moves)
|
| 92 |
+
else:
|
| 93 |
+
# Choose the first move alphabetically in uci representation.
|
| 94 |
+
possible_moves.sort(key=str)
|
| 95 |
+
move = possible_moves[0]
|
| 96 |
+
return PlayResult(move, None, draw_offered=draw_offered)
|
lichess-bot-master/lib/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""The lib folder contains the library code necessary for running lichess-bot."""
|
lichess-bot-master/lib/blocklist.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Code related to blocklists."""
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
import requests
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class BlocklistData:
|
| 11 |
+
"""Dataclass for online blocklist requests, including usernames and etag."""
|
| 12 |
+
|
| 13 |
+
users: list[str]
|
| 14 |
+
etag: str | None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _parse_block_list_from_url(url: str, old_data: BlocklistData) -> BlocklistData:
|
| 18 |
+
headers = {"If-None-Match": old_data.etag} if old_data.etag else {}
|
| 19 |
+
response = requests.get(url, headers=headers, timeout=15)
|
| 20 |
+
|
| 21 |
+
response.raise_for_status()
|
| 22 |
+
|
| 23 |
+
if response.status_code == 304:
|
| 24 |
+
return old_data
|
| 25 |
+
|
| 26 |
+
block_list = [username for line in response.text.strip().splitlines() if (username := line.strip())]
|
| 27 |
+
|
| 28 |
+
return BlocklistData(block_list, response.headers.get("ETag"))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class OnlineBlocklist:
|
| 32 |
+
"""Manage online blocklists."""
|
| 33 |
+
|
| 34 |
+
def __init__(self, urls: list[str]) -> None:
|
| 35 |
+
"""Initialize the OnlineBlockList class."""
|
| 36 |
+
self.blocklist: dict[str, BlocklistData] = {url : BlocklistData([], None) for url in urls}
|
| 37 |
+
self.refresh()
|
| 38 |
+
|
| 39 |
+
def refresh(self) -> None:
|
| 40 |
+
"""Pull updated blocklists from the list of blocklist urls."""
|
| 41 |
+
logger.info(f"Refreshing {len(self.blocklist)} online blocklists")
|
| 42 |
+
|
| 43 |
+
for url, data in self.blocklist.items():
|
| 44 |
+
try:
|
| 45 |
+
self.blocklist[url] = _parse_block_list_from_url(url, data)
|
| 46 |
+
except Exception:
|
| 47 |
+
logger.warning(f"Failed to refresh online blocklist {url}")
|
| 48 |
+
|
| 49 |
+
def __contains__(self, item: str) -> bool:
|
| 50 |
+
"""Check if an username is in the blocklist."""
|
| 51 |
+
return any(item in blocklist.users for blocklist in self.blocklist.values())
|
lichess-bot-master/lib/config.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Code related to the config that lichess-bot uses."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import yaml
|
| 4 |
+
import os
|
| 5 |
+
import logging
|
| 6 |
+
import math
|
| 7 |
+
from typing import Any
|
| 8 |
+
from collections.abc import ItemsView, Callable
|
| 9 |
+
from lib.lichess_types import CONFIG_DICT_TYPE, FilterType
|
| 10 |
+
from lib.timer import minutes, days
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class Configuration:
|
| 16 |
+
"""The config or a sub-config that the bot uses."""
|
| 17 |
+
|
| 18 |
+
def __init__(self, parameters: CONFIG_DICT_TYPE) -> None:
|
| 19 |
+
""":param parameters: A `dict` containing the config for the bot."""
|
| 20 |
+
self.config = parameters
|
| 21 |
+
|
| 22 |
+
def __getattr__(self, name: str) -> Any:
|
| 23 |
+
"""
|
| 24 |
+
Enable the use of `config.key1.key2`.
|
| 25 |
+
|
| 26 |
+
:param name: The key to get its value.
|
| 27 |
+
:return: The value of the key.
|
| 28 |
+
"""
|
| 29 |
+
return self.lookup(name)
|
| 30 |
+
|
| 31 |
+
def lookup(self, name: str) -> Any:
|
| 32 |
+
"""
|
| 33 |
+
Get the value of a key.
|
| 34 |
+
|
| 35 |
+
:param name: The key to get its value.
|
| 36 |
+
:return: `Configuration` if the value is a `dict` else returns the value.
|
| 37 |
+
"""
|
| 38 |
+
data = self.config.get(name)
|
| 39 |
+
return Configuration(data) if isinstance(data, dict) else data
|
| 40 |
+
|
| 41 |
+
def items(self) -> ItemsView[str, Any]:
|
| 42 |
+
""":return: All the key-value pairs in this config."""
|
| 43 |
+
return self.config.items()
|
| 44 |
+
|
| 45 |
+
def keys(self) -> list[str]:
|
| 46 |
+
""":return: All of the keys in this config."""
|
| 47 |
+
return list(self.config.keys())
|
| 48 |
+
|
| 49 |
+
def __or__(self, other: Configuration | CONFIG_DICT_TYPE) -> Configuration:
|
| 50 |
+
"""Create a copy of this configuration that is updated with values from the parameter."""
|
| 51 |
+
other_dict = other.config if isinstance(other, Configuration) else other
|
| 52 |
+
return Configuration(self.config | other_dict)
|
| 53 |
+
|
| 54 |
+
def __bool__(self) -> bool:
|
| 55 |
+
"""Whether `self.config` is empty."""
|
| 56 |
+
return bool(self.config)
|
| 57 |
+
|
| 58 |
+
def __getstate__(self) -> CONFIG_DICT_TYPE:
|
| 59 |
+
"""Get `self.config`."""
|
| 60 |
+
return self.config
|
| 61 |
+
|
| 62 |
+
def __setstate__(self, d: CONFIG_DICT_TYPE) -> None:
|
| 63 |
+
"""Set `self.config`."""
|
| 64 |
+
self.config = d
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def config_assert(assertion: bool, error_message: str) -> None:
|
| 68 |
+
"""Raise an exception if an assertion is false."""
|
| 69 |
+
if not assertion:
|
| 70 |
+
raise Exception(error_message)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def config_warn(assertion: bool, warning_message: str) -> None:
|
| 74 |
+
"""Print a warning message if an assertion is false."""
|
| 75 |
+
if not assertion:
|
| 76 |
+
logger.warning(warning_message)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def check_config_section(config: CONFIG_DICT_TYPE, data_name: str, data_type: type, subsection: str = "") -> None:
|
| 80 |
+
"""
|
| 81 |
+
Check the validity of a config section.
|
| 82 |
+
|
| 83 |
+
:param config: The config section.
|
| 84 |
+
:param data_name: The key to check its value.
|
| 85 |
+
:param data_type: The expected data type.
|
| 86 |
+
:param subsection: The subsection of the key.
|
| 87 |
+
"""
|
| 88 |
+
config_part = config[subsection] if subsection else config
|
| 89 |
+
sub = f"`{subsection}` sub" if subsection else ""
|
| 90 |
+
data_location = f"`{data_name}` subsection in `{subsection}`" if subsection else f"Section `{data_name}`"
|
| 91 |
+
type_error_message: dict[type, str] = {
|
| 92 |
+
str: f"{data_location} must be a string wrapped in quotes.",
|
| 93 |
+
dict: f"{data_location} must be a dictionary with indented keys followed by colons."}
|
| 94 |
+
config_assert(data_name in config_part, f"Your config.yml does not have required {sub}section `{data_name}`.")
|
| 95 |
+
config_assert(isinstance(config_part[data_name], data_type), type_error_message[data_type])
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def set_config_default(config: CONFIG_DICT_TYPE, *sections: str, key: str, default: Any,
|
| 99 |
+
force_empty_values: bool = False) -> CONFIG_DICT_TYPE:
|
| 100 |
+
"""
|
| 101 |
+
Fill a specific config key with the default value if it is missing.
|
| 102 |
+
|
| 103 |
+
:param config: The bot's config.
|
| 104 |
+
:param sections: The sections that the key is in.
|
| 105 |
+
:param key: The key to set.
|
| 106 |
+
:param default: The default value.
|
| 107 |
+
:param force_empty_values: Whether an empty value should be replaced with the default value.
|
| 108 |
+
:return: The new config with the default value inserted if needed.
|
| 109 |
+
"""
|
| 110 |
+
subconfig = config
|
| 111 |
+
for section in sections:
|
| 112 |
+
subconfig = subconfig.setdefault(section, {})
|
| 113 |
+
if not isinstance(subconfig, dict):
|
| 114 |
+
raise Exception(f"The {section} section in {sections} should hold a set of key-value pairs, not a value.")
|
| 115 |
+
if force_empty_values:
|
| 116 |
+
if subconfig.get(key) in [None, ""]:
|
| 117 |
+
subconfig[key] = default
|
| 118 |
+
else:
|
| 119 |
+
subconfig.setdefault(key, default)
|
| 120 |
+
return subconfig
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def change_value_to_list(config: CONFIG_DICT_TYPE, *sections: str, key: str) -> None:
|
| 124 |
+
"""
|
| 125 |
+
Change a single value to a list. e.g. 60 becomes [60]. Used to maintain backwards compatibility.
|
| 126 |
+
|
| 127 |
+
:param config: The bot's config.
|
| 128 |
+
:param sections: The sections that the key is in.
|
| 129 |
+
:param key: The key to set.
|
| 130 |
+
"""
|
| 131 |
+
subconfig = set_config_default(config, *sections, key=key, default=[])
|
| 132 |
+
|
| 133 |
+
if subconfig[key] is None:
|
| 134 |
+
subconfig[key] = []
|
| 135 |
+
|
| 136 |
+
if not isinstance(subconfig[key], list):
|
| 137 |
+
subconfig[key] = [subconfig[key]]
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def insert_default_values(CONFIG: CONFIG_DICT_TYPE) -> None:
|
| 141 |
+
"""
|
| 142 |
+
Insert the default values of most keys to the config if they are missing.
|
| 143 |
+
|
| 144 |
+
:param CONFIG: The bot's config.
|
| 145 |
+
"""
|
| 146 |
+
set_config_default(CONFIG, key="abort_time", default=20)
|
| 147 |
+
set_config_default(CONFIG, key="move_overhead", default=1000)
|
| 148 |
+
set_config_default(CONFIG, key="quit_after_all_games_finish", default=False)
|
| 149 |
+
set_config_default(CONFIG, key="rate_limiting_delay", default=0)
|
| 150 |
+
set_config_default(CONFIG, key="pgn_directory", default=None)
|
| 151 |
+
set_config_default(CONFIG, key="pgn_file_grouping", default="game", force_empty_values=True)
|
| 152 |
+
set_config_default(CONFIG, key="max_takebacks_accepted", default=0, force_empty_values=True)
|
| 153 |
+
set_config_default(CONFIG, "engine", key="interpreter", default=None)
|
| 154 |
+
set_config_default(CONFIG, "engine", key="interpreter_options", default=[], force_empty_values=True)
|
| 155 |
+
change_value_to_list(CONFIG, "engine", key="interpreter_options")
|
| 156 |
+
set_config_default(CONFIG, "engine", key="working_dir", default=os.getcwd(), force_empty_values=True)
|
| 157 |
+
set_config_default(CONFIG, "engine", key="silence_stderr", default=False)
|
| 158 |
+
set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_enabled", default=False)
|
| 159 |
+
set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_for_egtb_zero", default=True)
|
| 160 |
+
set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_enabled", default=False)
|
| 161 |
+
set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_for_egtb_minus_two", default=True)
|
| 162 |
+
set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_moves", default=3)
|
| 163 |
+
set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_score", default=-1000)
|
| 164 |
+
set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_moves", default=5)
|
| 165 |
+
set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_score", default=0)
|
| 166 |
+
set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_pieces", default=10)
|
| 167 |
+
set_config_default(CONFIG, "engine", "online_moves", key="max_out_of_book_moves", default=10)
|
| 168 |
+
set_config_default(CONFIG, "engine", "online_moves", key="max_retries", default=2, force_empty_values=True)
|
| 169 |
+
set_config_default(CONFIG, "engine", "online_moves", key="max_depth", default=math.inf, force_empty_values=True)
|
| 170 |
+
set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="enabled", default=False)
|
| 171 |
+
set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="source", default="lichess")
|
| 172 |
+
set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="min_time", default=20)
|
| 173 |
+
set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="max_time", default=10800)
|
| 174 |
+
set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="max_pieces", default=7)
|
| 175 |
+
set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="move_quality", default="best")
|
| 176 |
+
set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="enabled", default=False)
|
| 177 |
+
set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="min_time", default=20)
|
| 178 |
+
set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="max_time", default=10800)
|
| 179 |
+
set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="move_quality", default="good")
|
| 180 |
+
set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="min_depth", default=20)
|
| 181 |
+
set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="enabled", default=False)
|
| 182 |
+
set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="min_time", default=20)
|
| 183 |
+
set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="max_time", default=10800)
|
| 184 |
+
set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="move_quality", default="best")
|
| 185 |
+
set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="min_depth", default=20)
|
| 186 |
+
set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="min_knodes", default=0)
|
| 187 |
+
set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="max_score_difference", default=50)
|
| 188 |
+
set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="enabled", default=False)
|
| 189 |
+
set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="min_time", default=20)
|
| 190 |
+
set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="max_time", default=10800)
|
| 191 |
+
set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="source", default="masters")
|
| 192 |
+
set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="player_name", default="")
|
| 193 |
+
set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="sort", default="winrate")
|
| 194 |
+
set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="min_games", default=10)
|
| 195 |
+
set_config_default(CONFIG, "engine", "lichess_bot_tbs", "syzygy", key="enabled", default=False)
|
| 196 |
+
set_config_default(CONFIG, "engine", "lichess_bot_tbs", "syzygy", key="max_pieces", default=7)
|
| 197 |
+
set_config_default(CONFIG, "engine", "lichess_bot_tbs", "syzygy", key="move_quality", default="best")
|
| 198 |
+
set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="enabled", default=False)
|
| 199 |
+
set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="max_pieces", default=5)
|
| 200 |
+
set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="move_quality", default="best")
|
| 201 |
+
set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="min_dtm_to_consider_as_wdl_1", default=120)
|
| 202 |
+
set_config_default(CONFIG, "engine", "polyglot", key="enabled", default=False)
|
| 203 |
+
set_config_default(CONFIG, "engine", "polyglot", key="max_depth", default=8)
|
| 204 |
+
set_config_default(CONFIG, "engine", "polyglot", key="selection", default="weighted_random")
|
| 205 |
+
set_config_default(CONFIG, "engine", "polyglot", key="min_weight", default=1)
|
| 206 |
+
set_config_default(CONFIG, "engine", "polyglot", key="normalization", default="none")
|
| 207 |
+
set_config_default(CONFIG, "challenge", key="concurrency", default=1)
|
| 208 |
+
set_config_default(CONFIG, "challenge", key="sort_by", default="best")
|
| 209 |
+
set_config_default(CONFIG, "challenge", key="preference", default="none")
|
| 210 |
+
set_config_default(CONFIG, "challenge", key="accept_bot", default=False)
|
| 211 |
+
set_config_default(CONFIG, "challenge", key="only_bot", default=False)
|
| 212 |
+
set_config_default(CONFIG, "challenge", key="max_increment", default=180)
|
| 213 |
+
set_config_default(CONFIG, "challenge", key="min_increment", default=0)
|
| 214 |
+
set_config_default(CONFIG, "challenge", key="max_base", default=math.inf)
|
| 215 |
+
set_config_default(CONFIG, "challenge", key="min_base", default=0)
|
| 216 |
+
set_config_default(CONFIG, "challenge", key="max_days", default=math.inf)
|
| 217 |
+
set_config_default(CONFIG, "challenge", key="min_days", default=1)
|
| 218 |
+
set_config_default(CONFIG, "challenge", key="block_list", default=[], force_empty_values=True)
|
| 219 |
+
set_config_default(CONFIG, "challenge", key="online_block_list", default=[], force_empty_values=True)
|
| 220 |
+
set_config_default(CONFIG, "challenge", key="allow_list", default=[], force_empty_values=True)
|
| 221 |
+
set_config_default(CONFIG, "challenge", key="min_rating", default=0, force_empty_values=True)
|
| 222 |
+
set_config_default(CONFIG, "challenge", key="max_rating", default=4000, force_empty_values=True)
|
| 223 |
+
set_config_default(CONFIG, "challenge", key="rating_difference", default=None)
|
| 224 |
+
set_config_default(CONFIG, "challenge", key="max_simultaneous_games_per_user", default=5)
|
| 225 |
+
set_config_default(CONFIG, "correspondence", key="checkin_period", default=600)
|
| 226 |
+
set_config_default(CONFIG, "correspondence", key="move_time", default=60, force_empty_values=True)
|
| 227 |
+
set_config_default(CONFIG, "correspondence", key="disconnect_time", default=300)
|
| 228 |
+
set_config_default(CONFIG, "matchmaking", key="challenge_timeout", default=30, force_empty_values=True)
|
| 229 |
+
CONFIG["matchmaking"]["challenge_timeout"] = max(CONFIG["matchmaking"]["challenge_timeout"], 1)
|
| 230 |
+
set_config_default(CONFIG, "matchmaking", key="block_list", default=[], force_empty_values=True)
|
| 231 |
+
set_config_default(CONFIG, "matchmaking", key="online_block_list", default=[], force_empty_values=True)
|
| 232 |
+
set_config_default(CONFIG, "matchmaking", key="include_challenge_block_list", default=False, force_empty_values=True)
|
| 233 |
+
default_filter = (CONFIG.get("matchmaking") or {}).get("delay_after_decline") or FilterType.NONE.value
|
| 234 |
+
set_config_default(CONFIG, "matchmaking", key="challenge_filter", default=default_filter, force_empty_values=True)
|
| 235 |
+
set_config_default(CONFIG, "matchmaking", key="allow_matchmaking", default=False)
|
| 236 |
+
set_config_default(CONFIG, "matchmaking", key="challenge_initial_time", default=[None], force_empty_values=True)
|
| 237 |
+
change_value_to_list(CONFIG, "matchmaking", key="challenge_initial_time")
|
| 238 |
+
set_config_default(CONFIG, "matchmaking", key="challenge_increment", default=[None], force_empty_values=True)
|
| 239 |
+
change_value_to_list(CONFIG, "matchmaking", key="challenge_increment")
|
| 240 |
+
set_config_default(CONFIG, "matchmaking", key="challenge_days", default=[None], force_empty_values=True)
|
| 241 |
+
change_value_to_list(CONFIG, "matchmaking", key="challenge_days")
|
| 242 |
+
set_config_default(CONFIG, "matchmaking", key="opponent_min_rating", default=600, force_empty_values=True)
|
| 243 |
+
set_config_default(CONFIG, "matchmaking", key="opponent_max_rating", default=4000, force_empty_values=True)
|
| 244 |
+
set_config_default(CONFIG, "matchmaking", key="rating_preference", default="none")
|
| 245 |
+
set_config_default(CONFIG, "matchmaking", key="challenge_variant", default="random")
|
| 246 |
+
set_config_default(CONFIG, "matchmaking", key="challenge_mode", default="random")
|
| 247 |
+
set_config_default(CONFIG, "matchmaking", key="overrides", default={}, force_empty_values=True)
|
| 248 |
+
for override_config in CONFIG["matchmaking"]["overrides"].values():
|
| 249 |
+
for parameter in ["challenge_initial_time", "challenge_increment", "challenge_days"]:
|
| 250 |
+
if parameter in override_config:
|
| 251 |
+
set_config_default(override_config, key=parameter, default=[None], force_empty_values=True)
|
| 252 |
+
change_value_to_list(override_config, key=parameter)
|
| 253 |
+
|
| 254 |
+
for section in ["engine", "correspondence"]:
|
| 255 |
+
for ponder in ["ponder", "uci_ponder"]:
|
| 256 |
+
set_config_default(CONFIG, section, key=ponder, default=False)
|
| 257 |
+
|
| 258 |
+
for greeting in ["hello", "goodbye"]:
|
| 259 |
+
for target in ["", "_spectators"]:
|
| 260 |
+
set_config_default(CONFIG, "greeting", key=greeting + target, default="", force_empty_values=True)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def process_block_list(CONFIG: CONFIG_DICT_TYPE) -> None:
|
| 264 |
+
"""
|
| 265 |
+
Retrieve online block lists and copy over challenge blocklist if necessary.
|
| 266 |
+
|
| 267 |
+
:param CONFIG: The bot's config.
|
| 268 |
+
"""
|
| 269 |
+
if CONFIG["matchmaking"]["include_challenge_block_list"]:
|
| 270 |
+
CONFIG["matchmaking"]["block_list"].extend(CONFIG["challenge"]["block_list"])
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def log_config(CONFIG: CONFIG_DICT_TYPE, alternate_log_function: Callable[[str], Any] | None = None) -> None:
|
| 274 |
+
"""
|
| 275 |
+
Log the config to make debugging easier.
|
| 276 |
+
|
| 277 |
+
:param CONFIG: The bot's config.
|
| 278 |
+
"""
|
| 279 |
+
logger_config = CONFIG.copy()
|
| 280 |
+
logger_config["token"] = "logger" # noqa: S105 (Possible hardcoded password)
|
| 281 |
+
destination = alternate_log_function or logger.debug
|
| 282 |
+
destination(f"Config:\n{yaml.dump(logger_config, sort_keys=False)}")
|
| 283 |
+
destination("====================")
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def validate_config(CONFIG: CONFIG_DICT_TYPE) -> None:
|
| 287 |
+
"""Check if the config is valid."""
|
| 288 |
+
check_config_section(CONFIG, "token", str)
|
| 289 |
+
check_config_section(CONFIG, "url", str)
|
| 290 |
+
check_config_section(CONFIG, "engine", dict)
|
| 291 |
+
check_config_section(CONFIG, "challenge", dict)
|
| 292 |
+
check_config_section(CONFIG, "dir", str, "engine")
|
| 293 |
+
check_config_section(CONFIG, "name", str, "engine")
|
| 294 |
+
|
| 295 |
+
config_assert(os.path.isdir(CONFIG["engine"]["dir"]),
|
| 296 |
+
f'Your engine directory `{CONFIG["engine"]["dir"]}` is not a directory.')
|
| 297 |
+
|
| 298 |
+
working_dir = CONFIG["engine"].get("working_dir")
|
| 299 |
+
config_assert(not working_dir or os.path.isdir(working_dir),
|
| 300 |
+
f"Your engine's working directory `{working_dir}` is not a directory.")
|
| 301 |
+
|
| 302 |
+
engine = os.path.join(CONFIG["engine"]["dir"], CONFIG["engine"]["name"])
|
| 303 |
+
config_assert(os.path.isfile(engine) or CONFIG["engine"]["protocol"] == "homemade",
|
| 304 |
+
f"The engine {engine} file does not exist.")
|
| 305 |
+
config_assert(os.access(engine, os.X_OK) or CONFIG["engine"]["protocol"] == "homemade",
|
| 306 |
+
f"The engine {engine} doesn't have execute (x) permission. Try: chmod +x {engine}")
|
| 307 |
+
|
| 308 |
+
if CONFIG["engine"]["protocol"] == "xboard":
|
| 309 |
+
for section, subsection in (("online_moves", "online_egtb"),
|
| 310 |
+
("lichess_bot_tbs", "syzygy"),
|
| 311 |
+
("lichess_bot_tbs", "gaviota")):
|
| 312 |
+
online_section = (CONFIG["engine"].get(section) or {}).get(subsection) or {}
|
| 313 |
+
config_assert(online_section.get("move_quality") != "suggest" or not online_section.get("enabled"),
|
| 314 |
+
f"XBoard engines can't be used with `move_quality` set to `suggest` in {subsection}.")
|
| 315 |
+
|
| 316 |
+
config_warn(CONFIG["challenge"]["concurrency"] > 0, "With challenge.concurrency set to 0, the bot won't accept or create "
|
| 317 |
+
"any challenges.")
|
| 318 |
+
|
| 319 |
+
config_assert(CONFIG["challenge"]["sort_by"] in ["best", "first"], "challenge.sort_by can be either `first` or `best`.")
|
| 320 |
+
config_assert(CONFIG["challenge"]["preference"] in ["none", "human", "bot"],
|
| 321 |
+
"challenge.preference should be `none`, `human`, or `bot`.")
|
| 322 |
+
|
| 323 |
+
min_max_template = ("challenge.max_{setting} < challenge.min_{setting} will result "
|
| 324 |
+
"in no {game_type} challenges being accepted.")
|
| 325 |
+
for setting in ["increment", "base", "days"]:
|
| 326 |
+
game_type = "correspondence" if setting == "days" else "real-time"
|
| 327 |
+
config_warn(CONFIG["challenge"][f"min_{setting}"] <= CONFIG["challenge"][f"max_{setting}"],
|
| 328 |
+
min_max_template.format(setting=setting, game_type=game_type))
|
| 329 |
+
|
| 330 |
+
config_warn(CONFIG["challenge"]["min_rating"] <= CONFIG["challenge"]["max_rating"],
|
| 331 |
+
"challenge.max_rating < challenge.min_rating will result in no challenges being accepted.")
|
| 332 |
+
config_warn(CONFIG["challenge"].get("rating_difference") is None
|
| 333 |
+
or CONFIG["challenge"].get("rating_difference", 0) >= 0,
|
| 334 |
+
"challenge.rating_difference < 0 will result in no challenges being accepted.")
|
| 335 |
+
|
| 336 |
+
matchmaking = CONFIG["matchmaking"]
|
| 337 |
+
matchmaking_enabled = matchmaking["allow_matchmaking"]
|
| 338 |
+
|
| 339 |
+
if matchmaking_enabled:
|
| 340 |
+
config_warn(matchmaking["opponent_min_rating"] <= matchmaking["opponent_max_rating"],
|
| 341 |
+
"matchmaking.opponent_max_rating < matchmaking.opponent_min_rating will result in "
|
| 342 |
+
"no challenges being created.")
|
| 343 |
+
config_warn(matchmaking.get("opponent_rating_difference", 0) >= 0,
|
| 344 |
+
"matchmaking.opponent_rating_difference < 0 will result in no challenges being created.")
|
| 345 |
+
max_games_per_day = 100
|
| 346 |
+
game_timeout = minutes(matchmaking["challenge_timeout"])
|
| 347 |
+
config_warn(game_timeout*max_games_per_day >= days(1),
|
| 348 |
+
f"A bot is only allowed to play {max_games_per_day} games per day against other bots. Please check your "
|
| 349 |
+
"config file to make sure your bot won't use up all its allotted games quickly.")
|
| 350 |
+
|
| 351 |
+
pgn_directory = CONFIG["pgn_directory"]
|
| 352 |
+
in_docker = os.environ.get("LICHESS_BOT_DOCKER")
|
| 353 |
+
config_warn(not pgn_directory or not in_docker,
|
| 354 |
+
f"Games will be saved to '{pgn_directory}', please ensure this folder is in a mounted "
|
| 355 |
+
"volume; Using the Docker's container internal file system will prevent "
|
| 356 |
+
"you accessing the saved files and can lead to disk "
|
| 357 |
+
"saturation.")
|
| 358 |
+
|
| 359 |
+
valid_pgn_grouping_options = ["game", "opponent", "all"]
|
| 360 |
+
config_pgn_choice = CONFIG["pgn_file_grouping"]
|
| 361 |
+
config_assert(config_pgn_choice in valid_pgn_grouping_options,
|
| 362 |
+
f"The `pgn_file_grouping` choice of `{config_pgn_choice}` is not valid. "
|
| 363 |
+
f"Please choose from {valid_pgn_grouping_options}.")
|
| 364 |
+
|
| 365 |
+
def has_valid_list(name: str) -> bool:
|
| 366 |
+
entries = matchmaking.get(name)
|
| 367 |
+
return isinstance(entries, list) and entries[0] is not None
|
| 368 |
+
matchmaking_has_values = (has_valid_list("challenge_initial_time")
|
| 369 |
+
and has_valid_list("challenge_increment")
|
| 370 |
+
or has_valid_list("challenge_days"))
|
| 371 |
+
config_assert(not matchmaking_enabled or matchmaking_has_values,
|
| 372 |
+
"The time control to challenge other bots is not set. Either lists of challenge_initial_time and "
|
| 373 |
+
"challenge_increment is required, or a list of challenge_days, or both.")
|
| 374 |
+
|
| 375 |
+
filter_option = "challenge_filter"
|
| 376 |
+
filter_type = matchmaking.get(filter_option)
|
| 377 |
+
config_assert(filter_type is None or filter_type in FilterType.__members__.values(),
|
| 378 |
+
f"{filter_type} is not a valid value for {filter_option} (formerly delay_after_decline) parameter. "
|
| 379 |
+
f"Choices are: {', '.join(FilterType)}.")
|
| 380 |
+
|
| 381 |
+
config_assert(matchmaking.get("rating_preference") in ["none", "high", "low"],
|
| 382 |
+
f"{matchmaking.get('rating_preference')} is not a valid `matchmaking:rating_preference` option. "
|
| 383 |
+
f"Valid options are 'none', 'high', or 'low'.")
|
| 384 |
+
|
| 385 |
+
selection_choices = {"polyglot": ["weighted_random", "uniform_random", "best_move"],
|
| 386 |
+
"chessdb_book": ["all", "good", "best"],
|
| 387 |
+
"lichess_cloud_analysis": ["good", "best"],
|
| 388 |
+
"online_egtb": ["best", "suggest"]}
|
| 389 |
+
for db_name, valid_selections in selection_choices.items():
|
| 390 |
+
is_online = db_name != "polyglot"
|
| 391 |
+
db_section = (CONFIG["engine"].get("online_moves") or {}) if is_online else CONFIG["engine"]
|
| 392 |
+
db_config = db_section.get(db_name) or {}
|
| 393 |
+
select_key = "selection" if db_name == "polyglot" else "move_quality"
|
| 394 |
+
selection = db_config.get(select_key)
|
| 395 |
+
select = f"{'online_moves:' if is_online else ''}{db_name}:{select_key}"
|
| 396 |
+
config_assert(selection in valid_selections,
|
| 397 |
+
f"`{selection}` is not a valid `engine:{select}` value. "
|
| 398 |
+
f"Please choose from {valid_selections}.")
|
| 399 |
+
|
| 400 |
+
polyglot_section = CONFIG["engine"].get("polyglot") or {}
|
| 401 |
+
config_assert(polyglot_section.get("normalization") in ["none", "max", "sum"],
|
| 402 |
+
f"`{polyglot_section.get('normalization')}` is not a valid choice for "
|
| 403 |
+
f"`engine:polyglot:normalization`. Please choose from ['none', 'max', 'sum'].")
|
| 404 |
+
|
| 405 |
+
lichess_tbs_config = CONFIG["engine"].get("lichess_bot_tbs") or {}
|
| 406 |
+
quality_selections = ["best", "suggest"]
|
| 407 |
+
for tb in ["syzygy", "gaviota"]:
|
| 408 |
+
selection = (lichess_tbs_config.get(tb) or {}).get("move_quality")
|
| 409 |
+
config_assert(selection in quality_selections,
|
| 410 |
+
f"`{selection}` is not a valid choice for `engine:lichess_bot_tbs:{tb}:move_quality`. "
|
| 411 |
+
f"Please choose from {quality_selections}.")
|
| 412 |
+
|
| 413 |
+
explorer_choices = {"source": ["lichess", "masters", "player"],
|
| 414 |
+
"sort": ["winrate", "games_played"]}
|
| 415 |
+
explorer_config = (CONFIG["engine"].get("online_moves") or {}).get("lichess_opening_explorer")
|
| 416 |
+
if explorer_config:
|
| 417 |
+
for parameter, choice_list in explorer_choices.items():
|
| 418 |
+
explorer_choice = explorer_config.get(parameter)
|
| 419 |
+
config_assert(explorer_choice in choice_list,
|
| 420 |
+
f"`{explorer_choice}` is not a valid"
|
| 421 |
+
f" `engine:online_moves:lichess_opening_explorer:{parameter}`"
|
| 422 |
+
f" value. Please choose from {choice_list}.")
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
def load_config(config_file: str) -> Configuration:
|
| 426 |
+
"""
|
| 427 |
+
Read the config.
|
| 428 |
+
|
| 429 |
+
:param config_file: The filename of the config (usually `config.yml`).
|
| 430 |
+
:return: A `Configuration` object containing the config.
|
| 431 |
+
"""
|
| 432 |
+
with open(config_file) as stream:
|
| 433 |
+
try:
|
| 434 |
+
CONFIG = yaml.safe_load(stream)
|
| 435 |
+
except Exception:
|
| 436 |
+
logger.exception("There appears to be a syntax problem with your config.yml")
|
| 437 |
+
raise
|
| 438 |
+
|
| 439 |
+
log_config(CONFIG)
|
| 440 |
+
|
| 441 |
+
if "LICHESS_BOT_TOKEN" in os.environ:
|
| 442 |
+
CONFIG["token"] = os.environ["LICHESS_BOT_TOKEN"]
|
| 443 |
+
|
| 444 |
+
insert_default_values(CONFIG)
|
| 445 |
+
process_block_list(CONFIG)
|
| 446 |
+
log_config(CONFIG)
|
| 447 |
+
validate_config(CONFIG)
|
| 448 |
+
|
| 449 |
+
return Configuration(CONFIG)
|
lichess-bot-master/lib/conversation.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Allows lichess-bot to send messages to the chat."""
|
| 2 |
+
import logging
|
| 3 |
+
from lib import model
|
| 4 |
+
from lib.engine_wrapper import EngineWrapper
|
| 5 |
+
from lib.lichess import Lichess
|
| 6 |
+
from lib.lichess_types import GameEventType
|
| 7 |
+
from collections.abc import Sequence
|
| 8 |
+
from lib.timer import seconds
|
| 9 |
+
from typing import TypeAlias
|
| 10 |
+
MULTIPROCESSING_LIST_TYPE: TypeAlias = Sequence[model.Challenge]
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ChatLine:
|
| 16 |
+
"""Information about the message."""
|
| 17 |
+
|
| 18 |
+
def __init__(self, message_info: GameEventType) -> None:
|
| 19 |
+
"""Information about the message."""
|
| 20 |
+
self.room = message_info["room"]
|
| 21 |
+
"""Whether the message was sent in the chat room or in the spectator room."""
|
| 22 |
+
self.username = message_info["username"]
|
| 23 |
+
"""The username of the account that sent the message."""
|
| 24 |
+
self.text = message_info["text"]
|
| 25 |
+
"""The message sent."""
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class Conversation:
|
| 29 |
+
"""Enables the bot to communicate with its opponent and the spectators."""
|
| 30 |
+
|
| 31 |
+
def __init__(self, game: model.Game, engine: EngineWrapper, li: Lichess, version: str,
|
| 32 |
+
challenge_queue: MULTIPROCESSING_LIST_TYPE) -> None:
|
| 33 |
+
"""
|
| 34 |
+
Communication between lichess-bot and the game chats.
|
| 35 |
+
|
| 36 |
+
:param game: The game that the bot will send messages to.
|
| 37 |
+
:param engine: The engine playing the game.
|
| 38 |
+
:param li: A class that is used for communication with lichess.
|
| 39 |
+
:param version: The lichess-bot version.
|
| 40 |
+
:param challenge_queue: The active challenges the bot has.
|
| 41 |
+
"""
|
| 42 |
+
self.game = game
|
| 43 |
+
self.engine = engine
|
| 44 |
+
self.li = li
|
| 45 |
+
self.version = version
|
| 46 |
+
self.challengers = challenge_queue
|
| 47 |
+
self.messages: list[ChatLine] = []
|
| 48 |
+
|
| 49 |
+
command_prefix = "!"
|
| 50 |
+
|
| 51 |
+
def react(self, line: ChatLine) -> None:
|
| 52 |
+
"""
|
| 53 |
+
React to a received message.
|
| 54 |
+
|
| 55 |
+
:param line: Information about the message.
|
| 56 |
+
"""
|
| 57 |
+
self.messages.append(line)
|
| 58 |
+
logger.info(f"*** {self.game.url()} [{line.room}] {line.username}: {line.text}")
|
| 59 |
+
if line.text[0] == self.command_prefix:
|
| 60 |
+
self.command(line, line.text[1:].lower())
|
| 61 |
+
|
| 62 |
+
def command(self, line: ChatLine, cmd: str) -> None:
|
| 63 |
+
"""
|
| 64 |
+
Reacts to the specific commands in the chat.
|
| 65 |
+
|
| 66 |
+
:param line: Information about the message.
|
| 67 |
+
:param cmd: The command to react to.
|
| 68 |
+
"""
|
| 69 |
+
from_self = line.username == self.game.username
|
| 70 |
+
is_eval = cmd.startswith("eval")
|
| 71 |
+
if cmd in ("commands", "help"):
|
| 72 |
+
self.send_reply(line,
|
| 73 |
+
"Supported commands: !wait (wait a minute for my first move), !name, "
|
| 74 |
+
"!eval (or any text starting with !eval), !queue")
|
| 75 |
+
elif cmd == "wait" and self.game.is_abortable():
|
| 76 |
+
self.game.ping(seconds(60), seconds(120), seconds(120))
|
| 77 |
+
self.send_reply(line, "Waiting 60 seconds...")
|
| 78 |
+
elif cmd == "name":
|
| 79 |
+
name = self.game.me.name
|
| 80 |
+
self.send_reply(line, f"{name} running {self.engine.name()} (lichess-bot v{self.version})")
|
| 81 |
+
elif is_eval and (from_self or line.room == "spectator"):
|
| 82 |
+
stats = self.engine.get_stats(for_chat=True)
|
| 83 |
+
self.send_reply(line, ", ".join(stats))
|
| 84 |
+
elif is_eval:
|
| 85 |
+
self.send_reply(line, "I don't tell that to my opponent, sorry.")
|
| 86 |
+
elif cmd == "queue":
|
| 87 |
+
if self.challengers:
|
| 88 |
+
challengers = ", ".join([f"@{challenger.challenger.name}" for challenger in reversed(self.challengers)])
|
| 89 |
+
self.send_reply(line, f"Challenge queue: {challengers}")
|
| 90 |
+
else:
|
| 91 |
+
self.send_reply(line, "No challenges queued.")
|
| 92 |
+
|
| 93 |
+
def send_reply(self, line: ChatLine, reply: str) -> None:
|
| 94 |
+
"""
|
| 95 |
+
Send the reply to the chat.
|
| 96 |
+
|
| 97 |
+
:param line: Information about the original message that we reply to.
|
| 98 |
+
:param reply: The reply to send.
|
| 99 |
+
"""
|
| 100 |
+
logger.info(f"*** {self.game.url()} [{line.room}] {self.game.username}: {reply}")
|
| 101 |
+
self.li.chat(self.game.id, line.room, reply)
|
| 102 |
+
|
| 103 |
+
def send_message(self, room: str, message: str) -> None:
|
| 104 |
+
"""Send the message to the chat."""
|
| 105 |
+
if message:
|
| 106 |
+
self.send_reply(ChatLine({"room": room, "username": "", "text": ""}), message)
|
lichess-bot-master/lib/engine_wrapper.py
ADDED
|
@@ -0,0 +1,1427 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Provides communication with the engine."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import os
|
| 4 |
+
import chess.engine
|
| 5 |
+
import chess.polyglot
|
| 6 |
+
import chess.syzygy
|
| 7 |
+
import chess.gaviota
|
| 8 |
+
import chess
|
| 9 |
+
import subprocess
|
| 10 |
+
import logging
|
| 11 |
+
import datetime
|
| 12 |
+
import time
|
| 13 |
+
import random
|
| 14 |
+
import math
|
| 15 |
+
import contextlib
|
| 16 |
+
from collections import Counter
|
| 17 |
+
from collections.abc import Callable
|
| 18 |
+
from lib import model, lichess
|
| 19 |
+
from lib.config import Configuration, change_value_to_list
|
| 20 |
+
from lib.timer import Timer, msec, seconds, msec_str, sec_str, to_seconds
|
| 21 |
+
from lib.lichess_types import (ReadableType, ChessDBMoveType, LichessEGTBMoveType, OPTIONS_GO_EGTB_TYPE, OPTIONS_TYPE,
|
| 22 |
+
COMMANDS_TYPE, MOVE, InfoStrDict, InfoDictKeys, InfoDictValue, GO_COMMANDS_TYPE, EGTPATH_TYPE,
|
| 23 |
+
ENGINE_INPUT_ARGS_TYPE, ENGINE_INPUT_KWARGS_TYPE)
|
| 24 |
+
from extra_game_handlers import game_specific_options
|
| 25 |
+
from operator import itemgetter
|
| 26 |
+
from typing import Any, Literal, cast
|
| 27 |
+
from types import TracebackType
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
logger = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
out_of_online_opening_book_moves: Counter[str] = Counter()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def create_engine(engine_config: Configuration, game: model.Game | None = None) -> EngineWrapper:
|
| 36 |
+
"""
|
| 37 |
+
Create the engine.
|
| 38 |
+
|
| 39 |
+
Use in a with-block to automatically close the engine when exiting the game.
|
| 40 |
+
|
| 41 |
+
:param engine_config: The options for the engine.
|
| 42 |
+
:param game: The game to create the engine from.
|
| 43 |
+
:return: An engine. Either UCI, XBoard, or Homemade.
|
| 44 |
+
"""
|
| 45 |
+
cfg = engine_config.engine
|
| 46 |
+
engine_path = os.path.abspath(os.path.join(cfg.dir, cfg.name))
|
| 47 |
+
engine_type = cfg.protocol
|
| 48 |
+
commands = []
|
| 49 |
+
if cfg.interpreter:
|
| 50 |
+
commands.append(cfg.interpreter)
|
| 51 |
+
commands.extend(cfg.interpreter_options)
|
| 52 |
+
commands.append(engine_path)
|
| 53 |
+
if cfg.engine_options:
|
| 54 |
+
for k, v in cfg.engine_options.items():
|
| 55 |
+
commands.append(f"--{k}={v}" if v is not None else f"--{k}")
|
| 56 |
+
|
| 57 |
+
stderr = subprocess.DEVNULL if cfg.silence_stderr else None
|
| 58 |
+
|
| 59 |
+
Engine: type[UCIEngine | XBoardEngine | MinimalEngine]
|
| 60 |
+
if engine_type == "xboard":
|
| 61 |
+
Engine = XBoardEngine
|
| 62 |
+
elif engine_type == "uci":
|
| 63 |
+
Engine = UCIEngine
|
| 64 |
+
elif engine_type == "homemade":
|
| 65 |
+
Engine = get_homemade_engine(cfg.name)
|
| 66 |
+
else:
|
| 67 |
+
raise ValueError(
|
| 68 |
+
f" Invalid engine type: {engine_type}. Expected xboard, uci, or homemade.")
|
| 69 |
+
options = remove_managed_options(cfg.lookup(f"{engine_type}_options") or Configuration({}))
|
| 70 |
+
logger.debug(f"Starting engine: {commands}")
|
| 71 |
+
return Engine(commands, options, stderr, cfg.draw_or_resign, game, cfg.debug,
|
| 72 |
+
cwd=cfg.working_dir)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def remove_managed_options(config: Configuration) -> OPTIONS_GO_EGTB_TYPE:
|
| 76 |
+
"""Remove the options managed by python-chess."""
|
| 77 |
+
def is_managed(key: str) -> bool:
|
| 78 |
+
return chess.engine.Option(key, "", None, None, None, None).is_managed()
|
| 79 |
+
|
| 80 |
+
return {name: value for (name, value) in config.items() if not is_managed(name)}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
PONDERPV_CHARACTERS = 6 # The length of ", Pv: ".
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class EngineWrapper:
|
| 87 |
+
"""A wrapper used by all engines (UCI, XBoard, Homemade)."""
|
| 88 |
+
|
| 89 |
+
def __init__(self, options: OPTIONS_GO_EGTB_TYPE, draw_or_resign: Configuration) -> None:
|
| 90 |
+
"""
|
| 91 |
+
Initialize the values of the wrapper used by all engines (UCI, XBoard, Homemade).
|
| 92 |
+
|
| 93 |
+
:param options: The options to send to the engine.
|
| 94 |
+
:param draw_or_resign: Options on whether the bot should resign or offer draws.
|
| 95 |
+
"""
|
| 96 |
+
self.engine: chess.engine.SimpleEngine | FillerEngine
|
| 97 |
+
self.scores: list[chess.engine.PovScore] = []
|
| 98 |
+
self.draw_or_resign = draw_or_resign
|
| 99 |
+
self.go_commands = Configuration(cast(GO_COMMANDS_TYPE, options.pop("go_commands", {})) or {})
|
| 100 |
+
self.move_commentary: list[InfoStrDict] = []
|
| 101 |
+
self.comment_start_index = -1
|
| 102 |
+
|
| 103 |
+
def configure(self, options: OPTIONS_GO_EGTB_TYPE, game: model.Game | None) -> None:
|
| 104 |
+
"""
|
| 105 |
+
Send configurations to the engine.
|
| 106 |
+
|
| 107 |
+
:param options: A dictionary of strings to option values.
|
| 108 |
+
|
| 109 |
+
Raises chess.engine.EngineError if an option is sent that the engine does not support.
|
| 110 |
+
"""
|
| 111 |
+
try:
|
| 112 |
+
extra_options = {} if game is None else game_specific_options(game)
|
| 113 |
+
self.engine.configure(cast(OPTIONS_TYPE, options | extra_options))
|
| 114 |
+
except Exception:
|
| 115 |
+
self.engine.close()
|
| 116 |
+
raise
|
| 117 |
+
|
| 118 |
+
def __enter__(self) -> EngineWrapper: # noqa: PYI034 (return Self not available until 3.11)
|
| 119 |
+
"""Enter context so engine communication will be properly shutdown."""
|
| 120 |
+
self.engine.__enter__()
|
| 121 |
+
return self
|
| 122 |
+
|
| 123 |
+
def __exit__(self, exc_type: type[BaseException] | None,
|
| 124 |
+
exc_value: BaseException | None,
|
| 125 |
+
traceback: TracebackType | None) -> None:
|
| 126 |
+
"""Exit context and allow engine to shutdown nicely if there was no exception."""
|
| 127 |
+
if exc_type is None:
|
| 128 |
+
self.ping()
|
| 129 |
+
self.quit()
|
| 130 |
+
self.engine.__exit__(exc_type, exc_value, traceback)
|
| 131 |
+
|
| 132 |
+
def play_move(self,
|
| 133 |
+
board: chess.Board,
|
| 134 |
+
game: model.Game,
|
| 135 |
+
li: lichess.Lichess,
|
| 136 |
+
setup_timer: Timer,
|
| 137 |
+
move_overhead: datetime.timedelta,
|
| 138 |
+
can_ponder: bool,
|
| 139 |
+
is_correspondence: bool,
|
| 140 |
+
correspondence_move_time: datetime.timedelta,
|
| 141 |
+
engine_cfg: Configuration,
|
| 142 |
+
min_time: datetime.timedelta) -> None:
|
| 143 |
+
"""
|
| 144 |
+
Play a move.
|
| 145 |
+
|
| 146 |
+
:param board: The current position.
|
| 147 |
+
:param game: The game that the bot is playing.
|
| 148 |
+
:param li: Provides communication with lichess.org.
|
| 149 |
+
:param start_time: The time that the bot received the move.
|
| 150 |
+
:param move_overhead: The time it takes to communicate between the engine and lichess.org.
|
| 151 |
+
:param can_ponder: Whether the engine is allowed to ponder.
|
| 152 |
+
:param is_correspondence: Whether this is a correspondence or unlimited game.
|
| 153 |
+
:param correspondence_move_time: The time the engine will think if `is_correspondence` is true.
|
| 154 |
+
:param engine_cfg: Options for external moves (e.g. from an opening book), and for engine resignation and draw offers.
|
| 155 |
+
:param min_time: Minimum time to spend, in seconds.
|
| 156 |
+
:return: The move to play.
|
| 157 |
+
"""
|
| 158 |
+
polyglot_cfg = engine_cfg.polyglot
|
| 159 |
+
online_moves_cfg = engine_cfg.online_moves
|
| 160 |
+
draw_or_resign_cfg = engine_cfg.draw_or_resign
|
| 161 |
+
lichess_bot_tbs = engine_cfg.lichess_bot_tbs
|
| 162 |
+
|
| 163 |
+
best_move: MOVE
|
| 164 |
+
best_move = get_book_move(board, game, polyglot_cfg)
|
| 165 |
+
|
| 166 |
+
if best_move.move is None:
|
| 167 |
+
best_move = get_egtb_move(board,
|
| 168 |
+
game,
|
| 169 |
+
lichess_bot_tbs,
|
| 170 |
+
draw_or_resign_cfg)
|
| 171 |
+
|
| 172 |
+
if not isinstance(best_move, list) and best_move.move is None:
|
| 173 |
+
best_move = get_online_move(li,
|
| 174 |
+
board,
|
| 175 |
+
game,
|
| 176 |
+
online_moves_cfg,
|
| 177 |
+
draw_or_resign_cfg)
|
| 178 |
+
|
| 179 |
+
if isinstance(best_move, list) or best_move.move is None:
|
| 180 |
+
draw_offered = check_for_draw_offer(game)
|
| 181 |
+
|
| 182 |
+
time_limit, can_ponder = move_time(board, game, can_ponder,
|
| 183 |
+
setup_timer, move_overhead,
|
| 184 |
+
is_correspondence, correspondence_move_time)
|
| 185 |
+
|
| 186 |
+
try:
|
| 187 |
+
best_move = self.search(board, time_limit, can_ponder, draw_offered, best_move)
|
| 188 |
+
except chess.engine.EngineError as error:
|
| 189 |
+
BadMove = (chess.IllegalMoveError, chess.InvalidMoveError)
|
| 190 |
+
if not any(isinstance(e, BadMove) for e in error.args):
|
| 191 |
+
raise
|
| 192 |
+
logger.error("Ending game due to bot attempting an illegal move.")
|
| 193 |
+
logger.error(error)
|
| 194 |
+
game_ender = li.abort if game.is_abortable() else li.resign
|
| 195 |
+
game_ender(game.id)
|
| 196 |
+
return
|
| 197 |
+
|
| 198 |
+
# Heed min_time
|
| 199 |
+
elapsed = setup_timer.time_since_reset()
|
| 200 |
+
if elapsed < min_time:
|
| 201 |
+
time.sleep(to_seconds(min_time - elapsed))
|
| 202 |
+
|
| 203 |
+
self.add_comment(best_move, board)
|
| 204 |
+
self.print_stats()
|
| 205 |
+
if best_move.resigned and len(board.move_stack) >= 2:
|
| 206 |
+
li.resign(game.id)
|
| 207 |
+
else:
|
| 208 |
+
li.make_move(game.id, best_move)
|
| 209 |
+
|
| 210 |
+
def add_go_commands(self, time_limit: chess.engine.Limit) -> chess.engine.Limit:
|
| 211 |
+
"""Add extra commands to send to the engine. For example, to search for 1000 nodes or up to depth 10."""
|
| 212 |
+
movetime_cfg = self.go_commands.movetime
|
| 213 |
+
if movetime_cfg is not None:
|
| 214 |
+
movetime = msec(movetime_cfg)
|
| 215 |
+
if time_limit.time is None or seconds(time_limit.time) > movetime:
|
| 216 |
+
time_limit.time = to_seconds(movetime)
|
| 217 |
+
time_limit.depth = self.go_commands.depth
|
| 218 |
+
time_limit.nodes = self.go_commands.nodes
|
| 219 |
+
return time_limit
|
| 220 |
+
|
| 221 |
+
def offer_draw_or_resign(self, result: chess.engine.PlayResult, board: chess.Board) -> chess.engine.PlayResult:
|
| 222 |
+
"""Offer draw or resign depending on the score of the engine."""
|
| 223 |
+
def actual(score: chess.engine.PovScore) -> int:
|
| 224 |
+
return score.relative.score(mate_score=40000)
|
| 225 |
+
|
| 226 |
+
can_offer_draw = self.draw_or_resign.offer_draw_enabled
|
| 227 |
+
draw_offer_moves = self.draw_or_resign.offer_draw_moves
|
| 228 |
+
draw_score_range: int = self.draw_or_resign.offer_draw_score
|
| 229 |
+
draw_max_piece_count = self.draw_or_resign.offer_draw_pieces
|
| 230 |
+
pieces_on_board = chess.popcount(board.occupied)
|
| 231 |
+
enough_pieces_captured = pieces_on_board <= draw_max_piece_count
|
| 232 |
+
if can_offer_draw and len(self.scores) >= draw_offer_moves and enough_pieces_captured:
|
| 233 |
+
scores = self.scores[-draw_offer_moves:]
|
| 234 |
+
|
| 235 |
+
def score_near_draw(score: chess.engine.PovScore) -> bool:
|
| 236 |
+
return abs(actual(score)) <= draw_score_range
|
| 237 |
+
if len(scores) == len(list(filter(score_near_draw, scores))):
|
| 238 |
+
result.draw_offered = True
|
| 239 |
+
|
| 240 |
+
resign_enabled = self.draw_or_resign.resign_enabled
|
| 241 |
+
min_moves_for_resign = self.draw_or_resign.resign_moves
|
| 242 |
+
resign_score: int = self.draw_or_resign.resign_score
|
| 243 |
+
if resign_enabled and len(self.scores) >= min_moves_for_resign:
|
| 244 |
+
scores = self.scores[-min_moves_for_resign:]
|
| 245 |
+
|
| 246 |
+
def score_near_loss(score: chess.engine.PovScore) -> bool:
|
| 247 |
+
return actual(score) <= resign_score
|
| 248 |
+
if len(scores) == len(list(filter(score_near_loss, scores))):
|
| 249 |
+
result.resigned = True
|
| 250 |
+
return result
|
| 251 |
+
|
| 252 |
+
def search(self, board: chess.Board, time_limit: chess.engine.Limit, ponder: bool, draw_offered: bool,
|
| 253 |
+
root_moves: MOVE) -> chess.engine.PlayResult:
|
| 254 |
+
"""
|
| 255 |
+
Tell the engine to search.
|
| 256 |
+
|
| 257 |
+
:param board: The current position.
|
| 258 |
+
:param time_limit: Conditions for how long the engine can search (e.g. we have 10 seconds and search up to depth 10).
|
| 259 |
+
:param ponder: Whether the engine can ponder.
|
| 260 |
+
:param draw_offered: Whether the bot was offered a draw.
|
| 261 |
+
:param root_moves: If it is a list, the engine will only play a move that is in `root_moves`.
|
| 262 |
+
:return: The move to play.
|
| 263 |
+
"""
|
| 264 |
+
time_limit = self.add_go_commands(time_limit)
|
| 265 |
+
result = self.engine.play(board,
|
| 266 |
+
time_limit,
|
| 267 |
+
info=chess.engine.INFO_ALL,
|
| 268 |
+
ponder=ponder,
|
| 269 |
+
draw_offered=draw_offered,
|
| 270 |
+
root_moves=root_moves if isinstance(root_moves, list) else None)
|
| 271 |
+
# Use null_score to have no effect on draw/resign decisions
|
| 272 |
+
null_score = chess.engine.PovScore(chess.engine.Mate(1), board.turn)
|
| 273 |
+
self.scores.append(result.info.get("score", null_score))
|
| 274 |
+
return self.offer_draw_or_resign(result, board)
|
| 275 |
+
|
| 276 |
+
def comment_index(self, move_stack_index: int) -> int:
|
| 277 |
+
"""
|
| 278 |
+
Get the index of a move for use in `comment_for_board_index`.
|
| 279 |
+
|
| 280 |
+
:param move_stack_index: The move number.
|
| 281 |
+
:return: The index of the move in `self.move_commentary`.
|
| 282 |
+
"""
|
| 283 |
+
if self.comment_start_index < 0:
|
| 284 |
+
return -1
|
| 285 |
+
return move_stack_index - self.comment_start_index
|
| 286 |
+
|
| 287 |
+
def comment_for_board_index(self, index: int) -> InfoStrDict:
|
| 288 |
+
"""
|
| 289 |
+
Get the engine comments for a specific move.
|
| 290 |
+
|
| 291 |
+
:param index: The move number.
|
| 292 |
+
:return: The move comments.
|
| 293 |
+
"""
|
| 294 |
+
no_info: InfoStrDict = {}
|
| 295 |
+
comment_index = self.comment_index(index)
|
| 296 |
+
if comment_index < 0 or comment_index % 2 != 0:
|
| 297 |
+
return no_info
|
| 298 |
+
|
| 299 |
+
try:
|
| 300 |
+
return self.move_commentary[comment_index // 2]
|
| 301 |
+
except IndexError:
|
| 302 |
+
return no_info
|
| 303 |
+
|
| 304 |
+
def add_comment(self, move: chess.engine.PlayResult, board: chess.Board) -> None:
|
| 305 |
+
"""
|
| 306 |
+
Store the move's comments.
|
| 307 |
+
|
| 308 |
+
:param move: The move. Contains the comments in `move.info`.
|
| 309 |
+
:param board: The current position.
|
| 310 |
+
"""
|
| 311 |
+
if self.comment_start_index < 0:
|
| 312 |
+
self.comment_start_index = len(board.move_stack)
|
| 313 |
+
move_info = cast(InfoStrDict, dict(move.info.copy() if move.info else {}))
|
| 314 |
+
if "pv" in move_info:
|
| 315 |
+
move_info["ponderpv"] = board.variation_san(move.info["pv"])
|
| 316 |
+
if "refutation" in move_info:
|
| 317 |
+
move_info["refutation"] = board.variation_san(move.info["refutation"])
|
| 318 |
+
if "currmove" in move_info:
|
| 319 |
+
move_info["currmove"] = board.san(move.info["currmove"])
|
| 320 |
+
self.move_commentary.append(move_info)
|
| 321 |
+
|
| 322 |
+
def discard_last_move_commentary(self) -> None:
|
| 323 |
+
"""
|
| 324 |
+
Remove the commentary for the last move, if any.
|
| 325 |
+
|
| 326 |
+
Used after allowing an opponent to take back a move.
|
| 327 |
+
"""
|
| 328 |
+
with contextlib.suppress(IndexError):
|
| 329 |
+
self.move_commentary.pop()
|
| 330 |
+
|
| 331 |
+
def print_stats(self) -> None:
|
| 332 |
+
"""Print the engine stats."""
|
| 333 |
+
for line in self.get_stats():
|
| 334 |
+
logger.info(line)
|
| 335 |
+
|
| 336 |
+
def readable_score(self, relative_score: chess.engine.PovScore) -> str:
|
| 337 |
+
"""Convert the score to a more human-readable format."""
|
| 338 |
+
score = relative_score.relative
|
| 339 |
+
cp_score = score.score()
|
| 340 |
+
if cp_score is None:
|
| 341 |
+
str_score = f"#{score.mate()}"
|
| 342 |
+
else:
|
| 343 |
+
str_score = str(round(cp_score / 100, 2))
|
| 344 |
+
return str_score
|
| 345 |
+
|
| 346 |
+
def readable_wdl(self, wdl: chess.engine.PovWdl) -> str:
|
| 347 |
+
"""Convert the WDL score to a percentage, so it is more human-readable."""
|
| 348 |
+
wdl_percentage = round(wdl.relative.expectation() * 100, 1)
|
| 349 |
+
return f"{wdl_percentage}%"
|
| 350 |
+
|
| 351 |
+
def readable_time(self, number: int) -> str:
|
| 352 |
+
"""Convert time given as a number into minutes and seconds, so it is more human-readable. e.g. 123 -> 2m 3s."""
|
| 353 |
+
minutes, seconds = divmod(number, 60)
|
| 354 |
+
if minutes >= 1:
|
| 355 |
+
return f"{minutes:0.0f}m {seconds:0.1f}s"
|
| 356 |
+
else:
|
| 357 |
+
return f"{seconds:0.1f}s"
|
| 358 |
+
|
| 359 |
+
def readable_number(self, number: int) -> str:
|
| 360 |
+
"""Convert number to a more human-readable format. e.g. 123456789 -> 123M."""
|
| 361 |
+
if number >= 1e9:
|
| 362 |
+
return f"{round(number / 1e9, 1)}B"
|
| 363 |
+
if number >= 1e6:
|
| 364 |
+
return f"{round(number / 1e6, 1)}M"
|
| 365 |
+
if number >= 1e3:
|
| 366 |
+
return f"{round(number / 1e3, 1)}K"
|
| 367 |
+
return str(number)
|
| 368 |
+
|
| 369 |
+
def to_readable_value(self, stat: InfoDictKeys, info: InfoStrDict) -> str:
|
| 370 |
+
"""Change a value to a more human-readable format."""
|
| 371 |
+
readable: ReadableType = {"Evaluation": self.readable_score, "Winrate": self.readable_wdl,
|
| 372 |
+
"Hashfull": lambda x: f"{round(x / 10, 1)}%", "Nodes": self.readable_number,
|
| 373 |
+
"Speed": lambda x: f"{self.readable_number(x)}nps", "Tbhits": self.readable_number,
|
| 374 |
+
"Cpuload": lambda x: f"{round(x / 10, 1)}%", "Movetime": self.readable_time}
|
| 375 |
+
|
| 376 |
+
def identity(x: InfoDictValue) -> str:
|
| 377 |
+
return str(x)
|
| 378 |
+
|
| 379 |
+
func = cast(Callable[[InfoDictValue], str], readable.get(stat, identity))
|
| 380 |
+
return str(func(info[stat]))
|
| 381 |
+
|
| 382 |
+
def get_stats(self, for_chat: bool = False) -> list[str]:
|
| 383 |
+
"""
|
| 384 |
+
Get the stats returned by the engine.
|
| 385 |
+
|
| 386 |
+
:param for_chat: Whether the stats will be sent to the game chat, which has a 140 character limit.
|
| 387 |
+
"""
|
| 388 |
+
can_index = self.move_commentary and self.move_commentary[-1]
|
| 389 |
+
info: InfoStrDict = self.move_commentary[-1].copy() if can_index else {}
|
| 390 |
+
|
| 391 |
+
def to_readable_item(stat: InfoDictKeys, value: InfoDictValue) -> tuple[InfoDictKeys, InfoDictValue]:
|
| 392 |
+
readable = {"wdl": "winrate", "ponderpv": "PV", "nps": "speed", "score": "evaluation", "time": "movetime"}
|
| 393 |
+
stat = cast(InfoDictKeys, readable.get(stat, stat))
|
| 394 |
+
if stat == "string" and isinstance(value, str) and value.startswith("lichess-bot-source:"):
|
| 395 |
+
stat = "Source"
|
| 396 |
+
value = value.split(":", 1)[1]
|
| 397 |
+
return cast(InfoDictKeys, stat.title()), value
|
| 398 |
+
|
| 399 |
+
info = cast(InfoStrDict, dict(to_readable_item(cast(InfoDictKeys, key), cast(InfoDictValue, value))
|
| 400 |
+
for (key, value) in info.items()))
|
| 401 |
+
if "Source" not in info:
|
| 402 |
+
info["Source"] = "Engine"
|
| 403 |
+
|
| 404 |
+
stats = ["Source", "Evaluation", "Winrate", "Depth", "Nodes", "Speed", "Pv"]
|
| 405 |
+
if for_chat and "Pv" in info:
|
| 406 |
+
bot_stats = [f"{stat}: {self.to_readable_value(cast(InfoDictKeys, stat), info)}"
|
| 407 |
+
for stat in stats if stat in info and stat != "Pv"]
|
| 408 |
+
len_bot_stats = len(", ".join(bot_stats)) + PONDERPV_CHARACTERS
|
| 409 |
+
ponder_pv = info["Pv"].split()
|
| 410 |
+
try:
|
| 411 |
+
while len(" ".join(ponder_pv)) + len_bot_stats > lichess.MAX_CHAT_MESSAGE_LEN:
|
| 412 |
+
ponder_pv.pop()
|
| 413 |
+
if ponder_pv[-1].endswith("."):
|
| 414 |
+
ponder_pv.pop()
|
| 415 |
+
info["Pv"] = " ".join(ponder_pv)
|
| 416 |
+
except IndexError:
|
| 417 |
+
pass
|
| 418 |
+
if not info["Pv"]:
|
| 419 |
+
info.pop("Pv")
|
| 420 |
+
return [f"{stat}: {self.to_readable_value(cast(InfoDictKeys, stat), info)}" for stat in stats if stat in info]
|
| 421 |
+
|
| 422 |
+
def get_opponent_info(self, game: model.Game) -> None:
|
| 423 |
+
"""Get the opponent's information and sends it to the engine."""
|
| 424 |
+
opponent = chess.engine.Opponent(name=game.opponent.name,
|
| 425 |
+
title=game.opponent.title,
|
| 426 |
+
rating=game.opponent.rating,
|
| 427 |
+
is_engine=game.opponent.is_bot)
|
| 428 |
+
self.engine.send_opponent_information(opponent=opponent, engine_rating=game.me.rating)
|
| 429 |
+
|
| 430 |
+
def name(self) -> str:
|
| 431 |
+
"""Get the name of the engine."""
|
| 432 |
+
return self.engine.id["name"]
|
| 433 |
+
|
| 434 |
+
def get_pid(self) -> str:
|
| 435 |
+
"""Get the pid of the engine."""
|
| 436 |
+
pid = "?"
|
| 437 |
+
if self.engine.transport is not None:
|
| 438 |
+
pid = str(self.engine.transport.get_pid())
|
| 439 |
+
return pid
|
| 440 |
+
|
| 441 |
+
def ping(self) -> None:
|
| 442 |
+
"""Ping the engine."""
|
| 443 |
+
self.engine.ping()
|
| 444 |
+
|
| 445 |
+
def send_game_result(self, game: model.Game, board: chess.Board) -> None:
|
| 446 |
+
"""
|
| 447 |
+
Inform engine of the game ending.
|
| 448 |
+
|
| 449 |
+
:param game: The final game state from lichess.
|
| 450 |
+
:param board: The final board state.
|
| 451 |
+
"""
|
| 452 |
+
termination = game.state.get("status")
|
| 453 |
+
winner = game.state.get("winner")
|
| 454 |
+
winning_color = chess.WHITE if winner == "white" else chess.BLACK
|
| 455 |
+
|
| 456 |
+
if termination == model.Termination.MATE:
|
| 457 |
+
self.engine.send_game_result(board)
|
| 458 |
+
elif termination == model.Termination.RESIGN:
|
| 459 |
+
resigner = "White" if winner == "black" else "Black"
|
| 460 |
+
self.engine.send_game_result(board, winning_color, f"{resigner} resigned")
|
| 461 |
+
elif termination == model.Termination.ABORT:
|
| 462 |
+
self.engine.send_game_result(board, None, "Game aborted", False)
|
| 463 |
+
elif termination == model.Termination.DRAW:
|
| 464 |
+
draw_reason = None if board.is_game_over(claim_draw=True) else "Draw by agreement"
|
| 465 |
+
self.engine.send_game_result(board, None, draw_reason)
|
| 466 |
+
elif termination == model.Termination.TIMEOUT:
|
| 467 |
+
if winner:
|
| 468 |
+
self.engine.send_game_result(board, winning_color, "Time forfeiture")
|
| 469 |
+
else:
|
| 470 |
+
self.engine.send_game_result(board, None, "Time out with insufficient material")
|
| 471 |
+
else:
|
| 472 |
+
self.engine.send_game_result(board, None, termination)
|
| 473 |
+
|
| 474 |
+
def quit(self) -> None:
|
| 475 |
+
"""Tell the engine to shut down."""
|
| 476 |
+
self.engine.quit()
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
class UCIEngine(EngineWrapper):
|
| 480 |
+
"""The class used to communicate with UCI engines."""
|
| 481 |
+
|
| 482 |
+
def __init__(self, commands: COMMANDS_TYPE, options: OPTIONS_GO_EGTB_TYPE, stderr: int | None,
|
| 483 |
+
draw_or_resign: Configuration, game: model.Game | None, debug: bool,
|
| 484 |
+
**popen_args: str) -> None:
|
| 485 |
+
"""
|
| 486 |
+
Communicate with UCI engines.
|
| 487 |
+
|
| 488 |
+
:param commands: The engine path and commands to send to the engine. e.g. ["engines/engine.exe", "--option1=value1"]
|
| 489 |
+
:param options: The options to send to the engine.
|
| 490 |
+
:param stderr: Whether we should silence the stderr.
|
| 491 |
+
:param draw_or_resign: Options on whether the bot should resign or offer draws.
|
| 492 |
+
:param game: The first Game message from the game stream.
|
| 493 |
+
:param debug: Whether to debug or not.
|
| 494 |
+
:param popen_args: The cwd of the engine.
|
| 495 |
+
"""
|
| 496 |
+
super().__init__(options, draw_or_resign)
|
| 497 |
+
self.engine = chess.engine.SimpleEngine.popen_uci(commands, timeout=60., debug=debug, setpgrp=True, stderr=stderr,
|
| 498 |
+
**popen_args)
|
| 499 |
+
self.configure(options, game)
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
class XBoardEngine(EngineWrapper):
|
| 503 |
+
"""The class used to communicate with XBoard engines."""
|
| 504 |
+
|
| 505 |
+
def __init__(self, commands: COMMANDS_TYPE, options: OPTIONS_GO_EGTB_TYPE, stderr: int | None,
|
| 506 |
+
draw_or_resign: Configuration, game: model.Game | None, debug: bool,
|
| 507 |
+
**popen_args: str) -> None:
|
| 508 |
+
"""
|
| 509 |
+
Communicate with XBoard engines.
|
| 510 |
+
|
| 511 |
+
:param commands: The engine path and commands to send to the engine. e.g. ["engines/engine.exe", "--option1=value1"]
|
| 512 |
+
:param options: The options to send to the engine.
|
| 513 |
+
:param stderr: Whether we should silence the stderr.
|
| 514 |
+
:param draw_or_resign: Options on whether the bot should resign or offer draws.
|
| 515 |
+
:param game: The first Game message from the game stream.
|
| 516 |
+
:param debug: Whether to debug or not.
|
| 517 |
+
:param popen_args: The cwd of the engine.
|
| 518 |
+
"""
|
| 519 |
+
super().__init__(options, draw_or_resign)
|
| 520 |
+
self.engine = chess.engine.SimpleEngine.popen_xboard(commands, timeout=60., debug=debug, setpgrp=True,
|
| 521 |
+
stderr=stderr, **popen_args)
|
| 522 |
+
egt_paths = cast(EGTPATH_TYPE, options.pop("egtpath", {}) or {})
|
| 523 |
+
protocol = cast(chess.engine.XBoardProtocol, self.engine.protocol)
|
| 524 |
+
egt_features = protocol.features.get("egt", "")
|
| 525 |
+
if isinstance(egt_features, str):
|
| 526 |
+
egt_types_from_engine = egt_features.split(",")
|
| 527 |
+
for egt_type in filter(None, egt_types_from_engine):
|
| 528 |
+
if egt_type in egt_paths:
|
| 529 |
+
options[f"egtpath {egt_type}"] = egt_paths[egt_type]
|
| 530 |
+
else:
|
| 531 |
+
logger.debug(f"No paths found for egt type: {egt_type}.")
|
| 532 |
+
self.configure(options, game)
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
class MinimalEngine(EngineWrapper):
|
| 536 |
+
"""
|
| 537 |
+
Subclass this to prevent a few random errors.
|
| 538 |
+
|
| 539 |
+
Even though MinimalEngine extends EngineWrapper,
|
| 540 |
+
you don't have to actually wrap an engine.
|
| 541 |
+
|
| 542 |
+
At minimum, just implement `search`,
|
| 543 |
+
however you can also change other methods like
|
| 544 |
+
`notify`, etc.
|
| 545 |
+
"""
|
| 546 |
+
|
| 547 |
+
def __init__(self, commands: COMMANDS_TYPE, options: OPTIONS_GO_EGTB_TYPE, stderr: int | None, # noqa: ARG002
|
| 548 |
+
draw_or_resign: Configuration, game: model.Game | None, debug: bool, # noqa: ARG002
|
| 549 |
+
**popen_args: str) -> None: # noqa: ARG002 Unused argument popen_args
|
| 550 |
+
"""
|
| 551 |
+
Initialize the values of the engine that all homemade engines inherit.
|
| 552 |
+
|
| 553 |
+
:param options: The options to send to the engine.
|
| 554 |
+
:param draw_or_resign: Options on whether the bot should resign or offer draws.
|
| 555 |
+
"""
|
| 556 |
+
super().__init__(options, draw_or_resign)
|
| 557 |
+
self.engine_name = self.__class__.__name__
|
| 558 |
+
self.engine = FillerEngine(self, name=self.engine_name)
|
| 559 |
+
|
| 560 |
+
def get_pid(self) -> str:
|
| 561 |
+
"""Homemade engines don't have a pid, so we return a question mark."""
|
| 562 |
+
return "?"
|
| 563 |
+
|
| 564 |
+
def search(self, board: chess.Board, time_limit: chess.engine.Limit, ponder: bool, draw_offered: bool,
|
| 565 |
+
root_moves: MOVE) -> chess.engine.PlayResult:
|
| 566 |
+
"""
|
| 567 |
+
Choose a move.
|
| 568 |
+
|
| 569 |
+
The method to be implemented in your homemade engine.
|
| 570 |
+
NOTE: This method must return an instance of "chess.engine.PlayResult"
|
| 571 |
+
"""
|
| 572 |
+
raise NotImplementedError("The search method is not implemented")
|
| 573 |
+
|
| 574 |
+
def notify(self, method_name: str, *args: ENGINE_INPUT_ARGS_TYPE, **kwargs: ENGINE_INPUT_KWARGS_TYPE
|
| 575 |
+
) -> Any:
|
| 576 |
+
"""
|
| 577 |
+
Enable the use of `self.engine.option1`.
|
| 578 |
+
|
| 579 |
+
The EngineWrapper class sometimes calls methods on "self.engine".
|
| 580 |
+
|
| 581 |
+
"self.engine" is a filler property that notifies <self>
|
| 582 |
+
whenever an attribute is called.
|
| 583 |
+
|
| 584 |
+
Nothing happens unless the main engine does something.
|
| 585 |
+
|
| 586 |
+
Simply put, the following code is equivalent
|
| 587 |
+
self.engine.<method_name>(<*args>, <**kwargs>)
|
| 588 |
+
self.notify(<method_name>, <*args>, <**kwargs>)
|
| 589 |
+
"""
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
class FillerEngine:
|
| 593 |
+
"""
|
| 594 |
+
Not meant to be an actual engine.
|
| 595 |
+
|
| 596 |
+
This is only used to provide the property "self.engine"
|
| 597 |
+
in "MinimalEngine" which extends "EngineWrapper"
|
| 598 |
+
"""
|
| 599 |
+
|
| 600 |
+
def __init__(self, main_engine: MinimalEngine, name: str = "") -> None:
|
| 601 |
+
""":param name: The name to send to the chat."""
|
| 602 |
+
self.id = {"name": name}
|
| 603 |
+
self.name = name
|
| 604 |
+
self.main_engine = main_engine
|
| 605 |
+
|
| 606 |
+
def __getattr__(self, method_name: str) -> Any:
|
| 607 |
+
"""Provide the property `self.engine`."""
|
| 608 |
+
main_engine = self.main_engine
|
| 609 |
+
|
| 610 |
+
# These types aren't tested by mypy.
|
| 611 |
+
def method(*args: ENGINE_INPUT_ARGS_TYPE, **kwargs: ENGINE_INPUT_KWARGS_TYPE) -> Any:
|
| 612 |
+
nonlocal main_engine
|
| 613 |
+
nonlocal method_name
|
| 614 |
+
return main_engine.notify(method_name, *args, **kwargs)
|
| 615 |
+
|
| 616 |
+
return method
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
test_suffix = "-for-lichess-bot-testing-only"
|
| 620 |
+
|
| 621 |
+
|
| 622 |
+
def get_homemade_engine(name: str) -> type[MinimalEngine]:
|
| 623 |
+
"""
|
| 624 |
+
Get the homemade engine with name `name`. e.g. If `name` is `RandomMove` then we will return `homemade.RandomMove`.
|
| 625 |
+
|
| 626 |
+
:param name: The name of the homemade engine.
|
| 627 |
+
:return: The engine with this name.
|
| 628 |
+
"""
|
| 629 |
+
import homemade
|
| 630 |
+
from test_bot import homemade as test_homemade
|
| 631 |
+
engine: type[MinimalEngine]
|
| 632 |
+
if name.endswith(test_suffix): # Test only.
|
| 633 |
+
engine = getattr(test_homemade, name.removesuffix(test_suffix))
|
| 634 |
+
else:
|
| 635 |
+
engine = getattr(homemade, name)
|
| 636 |
+
return engine
|
| 637 |
+
|
| 638 |
+
|
| 639 |
+
def move_time(board: chess.Board,
|
| 640 |
+
game: model.Game,
|
| 641 |
+
can_ponder: bool,
|
| 642 |
+
setup_timer: Timer,
|
| 643 |
+
move_overhead: datetime.timedelta,
|
| 644 |
+
is_correspondence: bool,
|
| 645 |
+
correspondence_move_time: datetime.timedelta) -> tuple[chess.engine.Limit, bool]:
|
| 646 |
+
"""
|
| 647 |
+
Determine the game clock settings for the current move.
|
| 648 |
+
|
| 649 |
+
:param Board: The current position.
|
| 650 |
+
:param game: Information about the current game.
|
| 651 |
+
:param setup_timer: How much time has passed since receiving the opponent's move.
|
| 652 |
+
:param move_overhead: How much time it takes to communicate with lichess.
|
| 653 |
+
:param can_ponder: Whether the bot is allowed to ponder after choosing a move.
|
| 654 |
+
:param is_correspondence: Whether the current game is a correspondence game.
|
| 655 |
+
:param correspondence_move_time: How much time to use for this move it it is a correspondence game.
|
| 656 |
+
:return: The time to choose a move and whether the bot can ponder after the move.
|
| 657 |
+
"""
|
| 658 |
+
if len(board.move_stack) < 2:
|
| 659 |
+
return first_move_time(game), False # No pondering after the first move since a new clock starts afterwards.
|
| 660 |
+
if is_correspondence:
|
| 661 |
+
return single_move_time(board, game, correspondence_move_time, setup_timer, move_overhead), can_ponder
|
| 662 |
+
return game_clock_time(board, game, setup_timer, move_overhead), can_ponder
|
| 663 |
+
|
| 664 |
+
|
| 665 |
+
def wbtime(board: chess.Board) -> Literal["wtime", "btime"]:
|
| 666 |
+
"""Return `wtime` if it is white's turn to move else `btime`."""
|
| 667 |
+
return "wtime" if board.turn == chess.WHITE else "btime"
|
| 668 |
+
|
| 669 |
+
|
| 670 |
+
def wbinc(board: chess.Board) -> Literal["winc", "binc"]:
|
| 671 |
+
"""Return `winc` if it is white's turn to move else `binc`."""
|
| 672 |
+
return "winc" if board.turn == chess.WHITE else "binc"
|
| 673 |
+
|
| 674 |
+
|
| 675 |
+
def single_move_time(board: chess.Board, game: model.Game, search_time: datetime.timedelta,
|
| 676 |
+
setup_timer: Timer, move_overhead: datetime.timedelta) -> chess.engine.Limit:
|
| 677 |
+
"""
|
| 678 |
+
Calculate time to search in correspondence games.
|
| 679 |
+
|
| 680 |
+
:param board: The current positions.
|
| 681 |
+
:param game: The game that the bot is playing.
|
| 682 |
+
:param search_time: How long the engine should search.
|
| 683 |
+
:param setup_timer: How much time has passed since receiving the opponent's move.
|
| 684 |
+
:param move_overhead: The time it takes to communicate between the engine and lichess-bot.
|
| 685 |
+
:return: The time to choose a move.
|
| 686 |
+
"""
|
| 687 |
+
pre_move_time = setup_timer.time_since_reset()
|
| 688 |
+
overhead = pre_move_time + move_overhead
|
| 689 |
+
clock_time = max(msec(1), msec(game.state[wbtime(board)]) - overhead)
|
| 690 |
+
search_time = min(search_time, clock_time)
|
| 691 |
+
logger.info(f"Searching for time {sec_str(search_time)} seconds for game {game.id}")
|
| 692 |
+
return chess.engine.Limit(time=to_seconds(search_time), clock_id="correspondence")
|
| 693 |
+
|
| 694 |
+
|
| 695 |
+
def first_move_time(game: model.Game) -> chess.engine.Limit:
|
| 696 |
+
"""
|
| 697 |
+
Determine time limit for the first move in the game.
|
| 698 |
+
|
| 699 |
+
:param game: The game that the bot is playing.
|
| 700 |
+
:return: The time to choose the first move.
|
| 701 |
+
"""
|
| 702 |
+
# Need to hardcode first movetime since Lichess has 30 sec limit.
|
| 703 |
+
search_time = seconds(10)
|
| 704 |
+
logger.info(f"Searching for time {sec_str(search_time)} seconds for game {game.id}")
|
| 705 |
+
return chess.engine.Limit(time=to_seconds(search_time), clock_id="first move")
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
def game_clock_time(board: chess.Board,
|
| 709 |
+
game: model.Game,
|
| 710 |
+
setup_timer: Timer,
|
| 711 |
+
move_overhead: datetime.timedelta) -> chess.engine.Limit:
|
| 712 |
+
"""
|
| 713 |
+
Get the time to play by the engine in realtime games.
|
| 714 |
+
|
| 715 |
+
:param board: The current positions.
|
| 716 |
+
:param game: The game that the bot is playing.
|
| 717 |
+
:param setup_timer: How much time has passed since receiving the opponent's move.
|
| 718 |
+
:param move_overhead: The time it takes to communicate between the engine and lichess-bot.
|
| 719 |
+
:return: The time to play a move.
|
| 720 |
+
"""
|
| 721 |
+
pre_move_time = setup_timer.time_since_reset()
|
| 722 |
+
overhead = pre_move_time + move_overhead
|
| 723 |
+
times = {"wtime": msec(game.state["wtime"]), "btime": msec(game.state["btime"])}
|
| 724 |
+
side = wbtime(board)
|
| 725 |
+
times[side] = max(msec(1), times[side] - overhead)
|
| 726 |
+
logger.info(f"Searching for wtime {msec_str(times['wtime'])} btime {msec_str(times['btime'])} for game {game.id}")
|
| 727 |
+
return chess.engine.Limit(white_clock=to_seconds(times["wtime"]),
|
| 728 |
+
black_clock=to_seconds(times["btime"]),
|
| 729 |
+
white_inc=to_seconds(msec(game.state["winc"])),
|
| 730 |
+
black_inc=to_seconds(msec(game.state["binc"])),
|
| 731 |
+
clock_id="real time")
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
def check_for_draw_offer(game: model.Game) -> bool:
|
| 735 |
+
"""Check if the bot was offered a draw."""
|
| 736 |
+
return bool(game.state.get(f"{game.opponent_color[0]}draw"))
|
| 737 |
+
|
| 738 |
+
|
| 739 |
+
def get_book_move(board: chess.Board, game: model.Game,
|
| 740 |
+
polyglot_cfg: Configuration) -> chess.engine.PlayResult:
|
| 741 |
+
"""Get a move from an opening book."""
|
| 742 |
+
no_book_move = chess.engine.PlayResult(None, None)
|
| 743 |
+
use_book = polyglot_cfg.enabled
|
| 744 |
+
max_game_length = polyglot_cfg.max_depth * 2 - 1
|
| 745 |
+
if not use_book or len(board.move_stack) > max_game_length:
|
| 746 |
+
return no_book_move
|
| 747 |
+
|
| 748 |
+
if board.chess960:
|
| 749 |
+
variant = "chess960"
|
| 750 |
+
else:
|
| 751 |
+
variant = "standard" if board.uci_variant == "chess" else str(board.uci_variant)
|
| 752 |
+
|
| 753 |
+
change_value_to_list(polyglot_cfg.config, "book", key=variant)
|
| 754 |
+
books = polyglot_cfg.book.lookup(variant)
|
| 755 |
+
|
| 756 |
+
for book in books:
|
| 757 |
+
with chess.polyglot.open_reader(book) as reader:
|
| 758 |
+
try:
|
| 759 |
+
selection = polyglot_cfg.selection
|
| 760 |
+
min_weight = polyglot_cfg.min_weight
|
| 761 |
+
normalization = polyglot_cfg.normalization
|
| 762 |
+
weights = [entry.weight for entry in reader.find_all(board)]
|
| 763 |
+
scalar = (sum(weights) if normalization == "sum" and weights else
|
| 764 |
+
max(weights) if normalization == "max" and weights else 100)
|
| 765 |
+
min_weight = min_weight * scalar / 100
|
| 766 |
+
|
| 767 |
+
if selection == "weighted_random":
|
| 768 |
+
move = reader.weighted_choice(board).move
|
| 769 |
+
elif selection == "uniform_random":
|
| 770 |
+
move = reader.choice(board, minimum_weight=min_weight).move
|
| 771 |
+
elif selection == "best_move":
|
| 772 |
+
move = reader.find(board, minimum_weight=min_weight).move
|
| 773 |
+
except IndexError:
|
| 774 |
+
# python-chess raises "IndexError" if no entries found.
|
| 775 |
+
move = None
|
| 776 |
+
|
| 777 |
+
if move is not None:
|
| 778 |
+
logger.info(f"Got move {move} from book {book} for game {game.id}")
|
| 779 |
+
return chess.engine.PlayResult(move, None, {"string": "lichess-bot-source:Opening Book"})
|
| 780 |
+
|
| 781 |
+
return no_book_move
|
| 782 |
+
|
| 783 |
+
|
| 784 |
+
def get_online_move(li: lichess.Lichess, board: chess.Board, game: model.Game, online_moves_cfg: Configuration,
|
| 785 |
+
draw_or_resign_cfg: Configuration) -> chess.engine.PlayResult | list[chess.Move]:
|
| 786 |
+
"""
|
| 787 |
+
Get a move from an online source.
|
| 788 |
+
|
| 789 |
+
If `move_quality` is `suggest`, then it will return a list of moves for the engine to choose from.
|
| 790 |
+
"""
|
| 791 |
+
online_egtb_cfg = online_moves_cfg.online_egtb
|
| 792 |
+
best_move, wdl, comment = get_online_egtb_move(li, board, game, online_egtb_cfg)
|
| 793 |
+
if best_move is not None:
|
| 794 |
+
can_offer_draw = draw_or_resign_cfg.offer_draw_enabled
|
| 795 |
+
offer_draw_for_zero = draw_or_resign_cfg.offer_draw_for_egtb_zero
|
| 796 |
+
offer_draw = can_offer_draw and offer_draw_for_zero and wdl == 0
|
| 797 |
+
|
| 798 |
+
can_resign = draw_or_resign_cfg.resign_enabled
|
| 799 |
+
resign_on_egtb_loss = draw_or_resign_cfg.resign_for_egtb_minus_two
|
| 800 |
+
resign = can_resign and resign_on_egtb_loss and wdl == -2
|
| 801 |
+
|
| 802 |
+
wdl_to_score = {2: 9900, 1: 500, 0: 0, -1: -500, -2: -9900}
|
| 803 |
+
comment["score"] = chess.engine.PovScore(chess.engine.Cp(wdl_to_score[wdl]), board.turn)
|
| 804 |
+
if isinstance(best_move, str):
|
| 805 |
+
return chess.engine.PlayResult(chess.Move.from_uci(best_move),
|
| 806 |
+
None,
|
| 807 |
+
comment,
|
| 808 |
+
draw_offered=offer_draw,
|
| 809 |
+
resigned=resign)
|
| 810 |
+
return [chess.Move.from_uci(move) for move in best_move]
|
| 811 |
+
|
| 812 |
+
max_out_of_book_moves = online_moves_cfg.max_out_of_book_moves
|
| 813 |
+
max_opening_moves = online_moves_cfg.max_depth * 2 - 1
|
| 814 |
+
game_moves = len(board.move_stack)
|
| 815 |
+
if game_moves > max_opening_moves or out_of_online_opening_book_moves[game.id] >= max_out_of_book_moves:
|
| 816 |
+
return chess.engine.PlayResult(None, None)
|
| 817 |
+
|
| 818 |
+
chessdb_cfg = online_moves_cfg.chessdb_book
|
| 819 |
+
lichess_cloud_cfg = online_moves_cfg.lichess_cloud_analysis
|
| 820 |
+
opening_explorer_cfg = online_moves_cfg.lichess_opening_explorer
|
| 821 |
+
|
| 822 |
+
for online_source, cfg in ((get_chessdb_move, chessdb_cfg),
|
| 823 |
+
(get_lichess_cloud_move, lichess_cloud_cfg),
|
| 824 |
+
(get_opening_explorer_move, opening_explorer_cfg)):
|
| 825 |
+
best_move, comment = online_source(li, board, game, cfg)
|
| 826 |
+
if best_move:
|
| 827 |
+
return chess.engine.PlayResult(chess.Move.from_uci(best_move), None, comment)
|
| 828 |
+
|
| 829 |
+
out_of_online_opening_book_moves[game.id] += 1
|
| 830 |
+
used_opening_books = chessdb_cfg.enabled or lichess_cloud_cfg.enabled or opening_explorer_cfg.enabled
|
| 831 |
+
if out_of_online_opening_book_moves[game.id] == max_out_of_book_moves and used_opening_books:
|
| 832 |
+
logger.info(f"Will stop using online opening books for game {game.id}.")
|
| 833 |
+
return chess.engine.PlayResult(None, None)
|
| 834 |
+
|
| 835 |
+
|
| 836 |
+
def get_chessdb_move(li: lichess.Lichess, board: chess.Board, game: model.Game,
|
| 837 |
+
chessdb_cfg: Configuration) -> tuple[str | None, chess.engine.InfoDict]:
|
| 838 |
+
"""Get a move from chessdb.cn's opening book."""
|
| 839 |
+
use_chessdb = chessdb_cfg.enabled
|
| 840 |
+
time_left = msec(game.state[wbtime(board)])
|
| 841 |
+
min_time = seconds(chessdb_cfg.min_time)
|
| 842 |
+
max_time = seconds(chessdb_cfg.max_time)
|
| 843 |
+
if not use_chessdb or time_left < min_time or game.clock_initial > max_time or board.uci_variant != "chess":
|
| 844 |
+
return None, {}
|
| 845 |
+
|
| 846 |
+
move = None
|
| 847 |
+
comment: chess.engine.InfoDict = {}
|
| 848 |
+
site = "https://www.chessdb.cn/cdb.php"
|
| 849 |
+
quality = chessdb_cfg.move_quality
|
| 850 |
+
action = {"best": "querypv",
|
| 851 |
+
"good": "querybest",
|
| 852 |
+
"all": "query"}
|
| 853 |
+
with contextlib.suppress(Exception):
|
| 854 |
+
params: dict[str, str | int] = {"action": action[quality], "board": board.fen(), "json": 1}
|
| 855 |
+
data = li.online_book_get(site, params=params)
|
| 856 |
+
if data["status"] == "ok":
|
| 857 |
+
if quality == "best":
|
| 858 |
+
depth = data["depth"]
|
| 859 |
+
if depth >= chessdb_cfg.min_depth:
|
| 860 |
+
score = data["score"]
|
| 861 |
+
move = data["pv"][0]
|
| 862 |
+
comment["score"] = chess.engine.PovScore(chess.engine.Cp(score), board.turn)
|
| 863 |
+
comment["depth"] = data["depth"]
|
| 864 |
+
comment["pv"] = list(map(chess.Move.from_uci, data["pv"]))
|
| 865 |
+
comment["string"] = "lichess-bot-source:ChessDB"
|
| 866 |
+
logger.info(f"Got move {move} from chessdb.cn (depth: {depth}, score: {score}) for game {game.id}")
|
| 867 |
+
else:
|
| 868 |
+
move = data["move"]
|
| 869 |
+
logger.info(f"Got move {move} from chessdb.cn for game {game.id}")
|
| 870 |
+
|
| 871 |
+
return move, comment
|
| 872 |
+
|
| 873 |
+
|
| 874 |
+
def get_lichess_cloud_move(li: lichess.Lichess, board: chess.Board, game: model.Game,
|
| 875 |
+
lichess_cloud_cfg: Configuration) -> tuple[str | None, chess.engine.InfoDict]:
|
| 876 |
+
"""Get a move from the lichess's cloud analysis."""
|
| 877 |
+
side = wbtime(board)
|
| 878 |
+
time_left = msec(game.state[side])
|
| 879 |
+
min_time = seconds(lichess_cloud_cfg.min_time)
|
| 880 |
+
max_time = seconds(lichess_cloud_cfg.max_time)
|
| 881 |
+
use_lichess_cloud = lichess_cloud_cfg.enabled
|
| 882 |
+
if not use_lichess_cloud or time_left < min_time or game.clock_initial > max_time:
|
| 883 |
+
return None, {}
|
| 884 |
+
|
| 885 |
+
move = None
|
| 886 |
+
comment: chess.engine.InfoDict = {}
|
| 887 |
+
|
| 888 |
+
quality = lichess_cloud_cfg.move_quality
|
| 889 |
+
multipv = 1 if quality == "best" else 5
|
| 890 |
+
variant = "standard" if board.uci_variant == "chess" else str(board.uci_variant) # `str` is there only for mypy.
|
| 891 |
+
|
| 892 |
+
with contextlib.suppress(Exception):
|
| 893 |
+
data = li.online_book_get("https://lichess.org/api/cloud-eval",
|
| 894 |
+
params={"fen": board.fen(), "multiPv": multipv, "variant": variant},
|
| 895 |
+
authenticated=True)
|
| 896 |
+
if "error" not in data:
|
| 897 |
+
depth = data["depth"]
|
| 898 |
+
knodes = data["knodes"]
|
| 899 |
+
min_depth = lichess_cloud_cfg.min_depth
|
| 900 |
+
min_knodes = lichess_cloud_cfg.min_knodes
|
| 901 |
+
if depth >= min_depth and knodes >= min_knodes:
|
| 902 |
+
if quality == "best":
|
| 903 |
+
pv = data["pvs"][0]
|
| 904 |
+
else:
|
| 905 |
+
best_eval = data["pvs"][0]["cp"]
|
| 906 |
+
pvs = data["pvs"]
|
| 907 |
+
max_difference = lichess_cloud_cfg.max_score_difference
|
| 908 |
+
if side == "wtime":
|
| 909 |
+
pvs = list(filter(lambda pv: pv["cp"] >= best_eval - max_difference, pvs))
|
| 910 |
+
else:
|
| 911 |
+
pvs = list(filter(lambda pv: pv["cp"] <= best_eval + max_difference, pvs))
|
| 912 |
+
pv = random.choice(pvs)
|
| 913 |
+
move = pv["moves"].split()[0]
|
| 914 |
+
score = pv["cp"] if side == "wtime" else -pv["cp"]
|
| 915 |
+
comment["score"] = chess.engine.PovScore(chess.engine.Cp(score), board.turn)
|
| 916 |
+
comment["depth"] = data["depth"]
|
| 917 |
+
comment["nodes"] = data["knodes"] * 1000
|
| 918 |
+
comment["pv"] = list(map(chess.Move.from_uci, pv["moves"].split()))
|
| 919 |
+
comment["string"] = "lichess-bot-source:Lichess Cloud Analysis"
|
| 920 |
+
logger.info(f"Got move {move} from lichess cloud analysis (depth: {depth}, score: {score}, knodes: {knodes})"
|
| 921 |
+
f" for game {game.id}")
|
| 922 |
+
|
| 923 |
+
return move, comment
|
| 924 |
+
|
| 925 |
+
|
| 926 |
+
def get_opening_explorer_move(li: lichess.Lichess, board: chess.Board, game: model.Game,
|
| 927 |
+
opening_explorer_cfg: Configuration
|
| 928 |
+
) -> tuple[str | None, chess.engine.InfoDict]:
|
| 929 |
+
"""Get a move from lichess's opening explorer."""
|
| 930 |
+
side = wbtime(board)
|
| 931 |
+
time_left = msec(game.state[side])
|
| 932 |
+
min_time = seconds(opening_explorer_cfg.min_time)
|
| 933 |
+
max_time = seconds(opening_explorer_cfg.max_time)
|
| 934 |
+
source = opening_explorer_cfg.source
|
| 935 |
+
if (not opening_explorer_cfg.enabled or time_left < min_time or game.clock_initial > max_time or source == "master"
|
| 936 |
+
and board.uci_variant != "chess"):
|
| 937 |
+
return None, {}
|
| 938 |
+
|
| 939 |
+
move = None
|
| 940 |
+
comment: chess.engine.InfoDict = {}
|
| 941 |
+
variant = "standard" if board.uci_variant == "chess" else str(board.uci_variant) # `str` is there only for mypy
|
| 942 |
+
with contextlib.suppress(Exception):
|
| 943 |
+
params: dict[str, str | int]
|
| 944 |
+
if source == "masters":
|
| 945 |
+
params = {"fen": board.fen(), "moves": 100}
|
| 946 |
+
response = li.online_book_get("https://explorer.lichess.ovh/masters", params, authenticated=True)
|
| 947 |
+
comment = {"string": "lichess-bot-source:Lichess Opening Explorer (Masters)"}
|
| 948 |
+
elif source == "player":
|
| 949 |
+
player = opening_explorer_cfg.player_name
|
| 950 |
+
if not player:
|
| 951 |
+
player = game.username
|
| 952 |
+
params = {"player": player, "fen": board.fen(), "moves": 100, "variant": variant,
|
| 953 |
+
"recentGames": 0, "color": "white" if side == "wtime" else "black"}
|
| 954 |
+
response = li.online_book_get("https://explorer.lichess.ovh/player", params, stream=True, authenticated=True)
|
| 955 |
+
comment = {"string": "lichess-bot-source:Lichess Opening Explorer (Player)"}
|
| 956 |
+
else:
|
| 957 |
+
params = {"fen": board.fen(), "moves": 100, "variant": variant, "topGames": 0, "recentGames": 0}
|
| 958 |
+
response = li.online_book_get("https://explorer.lichess.ovh/lichess", params, authenticated=True)
|
| 959 |
+
comment = {"string": "lichess-bot-source:Lichess Opening Explorer (Lichess)"}
|
| 960 |
+
moves = []
|
| 961 |
+
for possible_move in response["moves"]:
|
| 962 |
+
games_played = possible_move["white"] + possible_move["black"] + possible_move["draws"]
|
| 963 |
+
winrate = (possible_move["white"] + possible_move["draws"] * .5) / games_played
|
| 964 |
+
if side == "btime":
|
| 965 |
+
winrate = 1 - winrate
|
| 966 |
+
if games_played >= opening_explorer_cfg.min_games:
|
| 967 |
+
# We add both winrate and games_played to the tuple, so that if 2 moves are tied on the first metric,
|
| 968 |
+
# the second one will be used.
|
| 969 |
+
moves.append((winrate if opening_explorer_cfg.sort == "winrate" else games_played,
|
| 970 |
+
games_played if opening_explorer_cfg.sort == "winrate" else winrate, possible_move["uci"]))
|
| 971 |
+
moves.sort(reverse=True)
|
| 972 |
+
move = moves[0][2]
|
| 973 |
+
logger.info(f"Got move {move} from lichess opening explorer ({opening_explorer_cfg.sort}: {moves[0][0]})"
|
| 974 |
+
f" for game {game.id}")
|
| 975 |
+
|
| 976 |
+
return move, comment
|
| 977 |
+
|
| 978 |
+
|
| 979 |
+
def get_online_egtb_move(li: lichess.Lichess, board: chess.Board, game: model.Game, online_egtb_cfg: Configuration
|
| 980 |
+
) -> tuple[str | list[str] | None, int, chess.engine.InfoDict]:
|
| 981 |
+
"""
|
| 982 |
+
Get a move from an online egtb (either by lichess or chessdb).
|
| 983 |
+
|
| 984 |
+
If `move_quality` is `suggest`, then it will return a list of moves for the engine to choose from.
|
| 985 |
+
"""
|
| 986 |
+
use_online_egtb = online_egtb_cfg.enabled
|
| 987 |
+
pieces = chess.popcount(board.occupied)
|
| 988 |
+
source = online_egtb_cfg.source
|
| 989 |
+
minimum_time = seconds(online_egtb_cfg.min_time)
|
| 990 |
+
maximum_time = seconds(online_egtb_cfg.max_time)
|
| 991 |
+
time_left = game.state[wbtime(board)]
|
| 992 |
+
if (not use_online_egtb
|
| 993 |
+
or msec(time_left) < minimum_time
|
| 994 |
+
or game.clock_initial > maximum_time
|
| 995 |
+
or board.uci_variant not in ["chess", "antichess", "atomic"]
|
| 996 |
+
and source == "lichess"
|
| 997 |
+
or board.uci_variant != "chess"
|
| 998 |
+
and source == "chessdb"
|
| 999 |
+
or pieces > online_egtb_cfg.max_pieces
|
| 1000 |
+
or board.castling_rights):
|
| 1001 |
+
|
| 1002 |
+
return None, -3, {}
|
| 1003 |
+
|
| 1004 |
+
quality = online_egtb_cfg.move_quality
|
| 1005 |
+
variant = "standard" if board.uci_variant == "chess" else str(board.uci_variant)
|
| 1006 |
+
|
| 1007 |
+
with contextlib.suppress(Exception):
|
| 1008 |
+
if source == "lichess":
|
| 1009 |
+
return get_lichess_egtb_move(li, game, board, quality, variant)
|
| 1010 |
+
if source == "chessdb":
|
| 1011 |
+
return get_chessdb_egtb_move(li, game, board, quality)
|
| 1012 |
+
|
| 1013 |
+
return None, -3, {}
|
| 1014 |
+
|
| 1015 |
+
|
| 1016 |
+
def get_egtb_move(board: chess.Board, game: model.Game, lichess_bot_tbs: Configuration,
|
| 1017 |
+
draw_or_resign_cfg: Configuration) -> chess.engine.PlayResult | list[chess.Move]:
|
| 1018 |
+
"""
|
| 1019 |
+
Get a move from a local egtb.
|
| 1020 |
+
|
| 1021 |
+
If `move_quality` is `suggest`, then it will return a list of moves for the engine to choose from.
|
| 1022 |
+
"""
|
| 1023 |
+
best_move, wdl = get_syzygy(board, game, lichess_bot_tbs.syzygy)
|
| 1024 |
+
source = "lichess-bot-source:Syzygy EGTB"
|
| 1025 |
+
if best_move is None:
|
| 1026 |
+
best_move, wdl = get_gaviota(board, game, lichess_bot_tbs.gaviota)
|
| 1027 |
+
source = "lichess-bot-source:Gaviota EGTB"
|
| 1028 |
+
if best_move:
|
| 1029 |
+
can_offer_draw = draw_or_resign_cfg.offer_draw_enabled
|
| 1030 |
+
offer_draw_for_zero = draw_or_resign_cfg.offer_draw_for_egtb_zero
|
| 1031 |
+
offer_draw = bool(can_offer_draw and offer_draw_for_zero and wdl == 0)
|
| 1032 |
+
|
| 1033 |
+
can_resign = draw_or_resign_cfg.resign_enabled
|
| 1034 |
+
resign_on_egtb_loss = draw_or_resign_cfg.resign_for_egtb_minus_two
|
| 1035 |
+
resign = bool(can_resign and resign_on_egtb_loss and wdl == -2)
|
| 1036 |
+
wdl_to_score = {2: 9900, 1: 500, 0: 0, -1: -500, -2: -9900}
|
| 1037 |
+
comment: chess.engine.InfoDict = {"score": chess.engine.PovScore(chess.engine.Cp(wdl_to_score[wdl]), board.turn),
|
| 1038 |
+
"string": source}
|
| 1039 |
+
if isinstance(best_move, chess.Move):
|
| 1040 |
+
return chess.engine.PlayResult(best_move, None, comment, draw_offered=offer_draw, resigned=resign)
|
| 1041 |
+
return best_move
|
| 1042 |
+
return chess.engine.PlayResult(None, None)
|
| 1043 |
+
|
| 1044 |
+
|
| 1045 |
+
def is_op1_position(board: chess.Board) -> bool:
|
| 1046 |
+
"""Determine if a given python-chess board represents an 'op1' 8-piece tablebase position."""
|
| 1047 |
+
if len(board.piece_map()) != 8 or board.castling_rights:
|
| 1048 |
+
return False
|
| 1049 |
+
|
| 1050 |
+
white_piece_count = chess.popcount(board.occupied_co[chess.WHITE])
|
| 1051 |
+
black_piece_count = chess.popcount(board.occupied_co[chess.BLACK])
|
| 1052 |
+
|
| 1053 |
+
if white_piece_count <= 2 or black_piece_count <= 2:
|
| 1054 |
+
return False
|
| 1055 |
+
|
| 1056 |
+
white_pawns = board.pieces(chess.PAWN, chess.WHITE)
|
| 1057 |
+
black_pawns = board.pieces(chess.PAWN, chess.BLACK)
|
| 1058 |
+
|
| 1059 |
+
if not white_pawns or not black_pawns:
|
| 1060 |
+
return False
|
| 1061 |
+
|
| 1062 |
+
for wp in white_pawns:
|
| 1063 |
+
for bp in black_pawns:
|
| 1064 |
+
if chess.square_file(wp) == chess.square_file(bp) and chess.square_rank(wp) < chess.square_rank(bp):
|
| 1065 |
+
return True
|
| 1066 |
+
|
| 1067 |
+
return False
|
| 1068 |
+
|
| 1069 |
+
|
| 1070 |
+
def get_lichess_egtb_move(li: lichess.Lichess, game: model.Game, board: chess.Board, quality: str,
|
| 1071 |
+
variant: str) -> tuple[str | list[str] | None, int, chess.engine.InfoDict]:
|
| 1072 |
+
"""
|
| 1073 |
+
Get a move from lichess's egtb.
|
| 1074 |
+
|
| 1075 |
+
If `move_quality` is `suggest`, then it will return a list of moves for the engine to choose from.
|
| 1076 |
+
"""
|
| 1077 |
+
name_to_wld = {"loss": -2,
|
| 1078 |
+
"maybe-loss": -1,
|
| 1079 |
+
"blessed-loss": -1,
|
| 1080 |
+
"draw": 0,
|
| 1081 |
+
"cursed-win": 1,
|
| 1082 |
+
"maybe-win": 1,
|
| 1083 |
+
"win": 2}
|
| 1084 |
+
pieces = chess.popcount(board.occupied)
|
| 1085 |
+
max_pieces = 8 if board.uci_variant == "chess" else 6
|
| 1086 |
+
metric: Literal["dtz", "dtc"] = "dtz" if pieces < 8 else "dtc"
|
| 1087 |
+
is_op1 = is_op1_position(board)
|
| 1088 |
+
if pieces <= max_pieces and (pieces < 8 or is_op1):
|
| 1089 |
+
data = li.online_book_get(f"https://tablebase.lichess.ovh/{variant}",
|
| 1090 |
+
params={"fen": board.fen()}, authenticated=True)
|
| 1091 |
+
if quality == "best":
|
| 1092 |
+
move = data["moves"][0]["uci"]
|
| 1093 |
+
wdl = name_to_wld[data["moves"][0]["category"]] * -1
|
| 1094 |
+
dtz = data["moves"][0][metric] * -1
|
| 1095 |
+
dtm = data["moves"][0]["dtm"]
|
| 1096 |
+
if dtm:
|
| 1097 |
+
dtm *= -1
|
| 1098 |
+
logger.info(f"Got move {move} from tablebase.lichess.ovh (wdl: {wdl}, {metric}: {dtz}, dtm: {dtm})"
|
| 1099 |
+
f" for game {game.id}")
|
| 1100 |
+
else: # quality == "suggest":
|
| 1101 |
+
best_wdl = name_to_wld[data["moves"][0]["category"]] * -1
|
| 1102 |
+
|
| 1103 |
+
def good_enough(possible_move: LichessEGTBMoveType) -> bool:
|
| 1104 |
+
return name_to_wld[possible_move["category"]] * -1 == best_wdl
|
| 1105 |
+
|
| 1106 |
+
possible_moves = list(filter(good_enough, data["moves"]))
|
| 1107 |
+
if len(possible_moves) > 1:
|
| 1108 |
+
move_list = [move["uci"] for move in possible_moves]
|
| 1109 |
+
wdl = best_wdl
|
| 1110 |
+
logger.info(f"Suggesting moves from tablebase.lichess.ovh (wdl: {wdl}) for game {game.id}")
|
| 1111 |
+
return move_list, wdl, {"string": "lichess-bot-source:Lichess EGTB"}
|
| 1112 |
+
else:
|
| 1113 |
+
best_move = possible_moves[0]
|
| 1114 |
+
move = best_move["uci"]
|
| 1115 |
+
wdl = name_to_wld[best_move["category"]] * -1
|
| 1116 |
+
dtz = best_move[metric] * -1
|
| 1117 |
+
dtm = best_move["dtm"]
|
| 1118 |
+
if dtm:
|
| 1119 |
+
dtm *= -1
|
| 1120 |
+
logger.info(f"Got move {move} from tablebase.lichess.ovh (wdl: {wdl}, {metric}: {dtz}, dtm: {dtm})"
|
| 1121 |
+
f" for game {game.id}")
|
| 1122 |
+
|
| 1123 |
+
return move, wdl, {"string": "lichess-bot-source:Lichess EGTB"}
|
| 1124 |
+
return None, -3, {}
|
| 1125 |
+
|
| 1126 |
+
|
| 1127 |
+
def get_chessdb_egtb_move(li: lichess.Lichess, game: model.Game, board: chess.Board,
|
| 1128 |
+
quality: str) -> tuple[str | list[str] | None, int, chess.engine.InfoDict]:
|
| 1129 |
+
"""
|
| 1130 |
+
Get a move from chessdb's egtb.
|
| 1131 |
+
|
| 1132 |
+
If `move_quality` is `suggest`, then it will return a list of moves for the engine to choose from.
|
| 1133 |
+
"""
|
| 1134 |
+
def score_to_wdl(score: int) -> int:
|
| 1135 |
+
return piecewise_function([(-20000, "e", -2),
|
| 1136 |
+
(0, "e", -1),
|
| 1137 |
+
(0, "i", 0),
|
| 1138 |
+
(20000, "i", 1)], 2, score)
|
| 1139 |
+
|
| 1140 |
+
def score_to_dtz(score: int) -> int:
|
| 1141 |
+
return piecewise_function([(-20000, "e", -30000 - score),
|
| 1142 |
+
(0, "e", -20000 - score),
|
| 1143 |
+
(0, "i", 0),
|
| 1144 |
+
(20000, "i", 20000 - score)], 30000 - score, score)
|
| 1145 |
+
|
| 1146 |
+
action = "querypv" if quality == "best" else "queryall"
|
| 1147 |
+
data = li.online_book_get("https://www.chessdb.cn/cdb.php",
|
| 1148 |
+
params={"action": action, "board": board.fen(), "json": 1})
|
| 1149 |
+
if data["status"] == "ok":
|
| 1150 |
+
if quality == "best":
|
| 1151 |
+
score = data["score"]
|
| 1152 |
+
move = data["pv"][0]
|
| 1153 |
+
wdl = score_to_wdl(score)
|
| 1154 |
+
dtz = score_to_dtz(score)
|
| 1155 |
+
logger.info(f"Got move {move} from chessdb.cn (wdl: {wdl}, dtz: {dtz}) for game {game.id}")
|
| 1156 |
+
else: # quality == "suggest"
|
| 1157 |
+
best_wdl = score_to_wdl(data["moves"][0]["score"])
|
| 1158 |
+
|
| 1159 |
+
def good_enough(move: ChessDBMoveType) -> bool:
|
| 1160 |
+
return score_to_wdl(move["score"]) == best_wdl
|
| 1161 |
+
|
| 1162 |
+
possible_moves = list(filter(good_enough, cast(list[ChessDBMoveType], data["moves"])))
|
| 1163 |
+
if len(possible_moves) > 1:
|
| 1164 |
+
wdl = score_to_wdl(possible_moves[0]["score"])
|
| 1165 |
+
move_list = [move["uci"] for move in possible_moves]
|
| 1166 |
+
logger.info(f"Suggesting moves from from chessdb.cn (wdl: {wdl}) for game {game.id}")
|
| 1167 |
+
return move_list, wdl, {"string": "lichess-bot-source:ChessDB EGTB"}
|
| 1168 |
+
else:
|
| 1169 |
+
best_move = possible_moves[0]
|
| 1170 |
+
score = best_move["score"]
|
| 1171 |
+
move = best_move["uci"]
|
| 1172 |
+
wdl = score_to_wdl(score)
|
| 1173 |
+
dtz = score_to_dtz(score)
|
| 1174 |
+
logger.info(f"Got move {move} from chessdb.cn (wdl: {wdl}, dtz: {dtz}) for game {game.id}")
|
| 1175 |
+
|
| 1176 |
+
return move, wdl, {"string": "lichess-bot-source:ChessDB EGTB"}
|
| 1177 |
+
return None, -3, {}
|
| 1178 |
+
|
| 1179 |
+
|
| 1180 |
+
def get_syzygy(board: chess.Board, game: model.Game,
|
| 1181 |
+
syzygy_cfg: Configuration) -> tuple[chess.Move | list[chess.Move] | None, int]:
|
| 1182 |
+
"""
|
| 1183 |
+
Get a move from local syzygy egtbs.
|
| 1184 |
+
|
| 1185 |
+
If `move_quality` is `suggest`, then it will return a list of moves for the engine to choose from.
|
| 1186 |
+
"""
|
| 1187 |
+
if (not syzygy_cfg.enabled
|
| 1188 |
+
or chess.popcount(board.occupied) > syzygy_cfg.max_pieces
|
| 1189 |
+
or board.uci_variant not in ["chess", "antichess", "atomic"]):
|
| 1190 |
+
return None, -3
|
| 1191 |
+
|
| 1192 |
+
move: chess.Move | list[chess.Move]
|
| 1193 |
+
move_quality = syzygy_cfg.move_quality
|
| 1194 |
+
|
| 1195 |
+
with chess.syzygy.open_tablebase(syzygy_cfg.paths[0]) as tablebase:
|
| 1196 |
+
for path in syzygy_cfg.paths[1:]:
|
| 1197 |
+
tablebase.add_directory(path)
|
| 1198 |
+
|
| 1199 |
+
try:
|
| 1200 |
+
moves = score_syzygy_moves(board, dtz_scorer, tablebase)
|
| 1201 |
+
|
| 1202 |
+
best_wdl = max(map(dtz_to_wdl, moves.values()))
|
| 1203 |
+
good_moves = [(move, dtz) for move, dtz in moves.items() if dtz_to_wdl(dtz) == best_wdl]
|
| 1204 |
+
if move_quality == "suggest" and len(good_moves) > 1:
|
| 1205 |
+
move = [chess_move for chess_move, dtz in good_moves]
|
| 1206 |
+
logger.info(f"Suggesting moves from syzygy (wdl: {best_wdl}) for game {game.id}")
|
| 1207 |
+
return move, best_wdl
|
| 1208 |
+
# There can be multiple moves with the same dtz.
|
| 1209 |
+
best_dtz = min(good_moves, key=itemgetter(1))[1]
|
| 1210 |
+
best_moves = [chess_move for chess_move, dtz in good_moves if dtz == best_dtz]
|
| 1211 |
+
move = random.choice(best_moves)
|
| 1212 |
+
logger.info(f"Got move {move.uci()} from syzygy (wdl: {best_wdl}, dtz: {best_dtz}) for game {game.id}")
|
| 1213 |
+
return move, best_wdl
|
| 1214 |
+
except KeyError:
|
| 1215 |
+
# Attempt to only get the WDL score. It returns moves of quality="suggest", even if quality is set to "best".
|
| 1216 |
+
try:
|
| 1217 |
+
moves = score_syzygy_moves(board, lambda tablebase, b: -tablebase.probe_wdl(b), tablebase)
|
| 1218 |
+
best_wdl = int(max(moves.values())) # int is there only for mypy.
|
| 1219 |
+
good_chess_moves = [chess_move for chess_move, wdl in moves.items() if wdl == best_wdl]
|
| 1220 |
+
logger.debug("Found moves using 'move_quality'='suggest'. We didn't find an '.rtbz' file for this endgame."
|
| 1221 |
+
if move_quality == "best" else "")
|
| 1222 |
+
if len(good_chess_moves) > 1:
|
| 1223 |
+
move = good_chess_moves
|
| 1224 |
+
logger.info(f"Suggesting moves from syzygy (wdl: {best_wdl}) for game {game.id}")
|
| 1225 |
+
else:
|
| 1226 |
+
move = good_chess_moves[0]
|
| 1227 |
+
logger.info(f"Got move {move.uci()} from syzygy (wdl: {best_wdl}) for game {game.id}")
|
| 1228 |
+
return move, best_wdl
|
| 1229 |
+
except KeyError:
|
| 1230 |
+
return None, -3
|
| 1231 |
+
|
| 1232 |
+
|
| 1233 |
+
def dtz_scorer(tablebase: chess.syzygy.Tablebase, board: chess.Board) -> int | float:
|
| 1234 |
+
"""
|
| 1235 |
+
Score a position based on a syzygy DTZ egtb.
|
| 1236 |
+
|
| 1237 |
+
For a zeroing move (capture or pawn move), a DTZ of +/-0.5 is returned.
|
| 1238 |
+
"""
|
| 1239 |
+
dtz: int | float = -tablebase.probe_dtz(board)
|
| 1240 |
+
dtz = dtz if board.halfmove_clock or not dtz else math.copysign(.5, dtz)
|
| 1241 |
+
return dtz + (math.copysign(board.halfmove_clock, dtz) if dtz else 0)
|
| 1242 |
+
|
| 1243 |
+
|
| 1244 |
+
def dtz_to_wdl(dtz: float) -> int:
|
| 1245 |
+
"""
|
| 1246 |
+
Convert DTZ scores to syzygy WDL scores.
|
| 1247 |
+
|
| 1248 |
+
A DTZ of +/-100 returns a draw score of +/-1 instead of a win/loss score of +/-2 because
|
| 1249 |
+
a 50-move draw can be forced before checkmate can be forced.
|
| 1250 |
+
"""
|
| 1251 |
+
return piecewise_function([(-100, "i", -1), (0, "e", -2), (0, "i", 0), (100, "e", 2)], 1, dtz)
|
| 1252 |
+
|
| 1253 |
+
|
| 1254 |
+
def get_gaviota(board: chess.Board, game: model.Game,
|
| 1255 |
+
gaviota_cfg: Configuration) -> tuple[chess.Move | list[chess.Move] | None, int]:
|
| 1256 |
+
"""
|
| 1257 |
+
Get a move from local gaviota egtbs.
|
| 1258 |
+
|
| 1259 |
+
If `move_quality` is `suggest`, then it will return a list of moves for the engine to choose from.
|
| 1260 |
+
"""
|
| 1261 |
+
if (not gaviota_cfg.enabled
|
| 1262 |
+
or chess.popcount(board.occupied) > gaviota_cfg.max_pieces
|
| 1263 |
+
or board.uci_variant != "chess"):
|
| 1264 |
+
return None, -3
|
| 1265 |
+
|
| 1266 |
+
move: chess.Move | list[chess.Move]
|
| 1267 |
+
move_quality = gaviota_cfg.move_quality
|
| 1268 |
+
|
| 1269 |
+
# Since gaviota TBs use dtm and not dtz, we have to put a limit where after it the position are considered to have
|
| 1270 |
+
# 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
|
| 1271 |
+
# 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
|
| 1272 |
+
# another 12 to mate, so this position has a syzygy wdl=2/-2. To be safe, the first limit is 100 moves, which
|
| 1273 |
+
# guarantees that all moves have a syzygy wdl=2/-2. Setting min_dtm_to_consider_as_wdl_1 to 100 will disable it
|
| 1274 |
+
# because dtm >= dtz, so if abs(dtm) < 100 => abs(dtz) < 100, so wdl=2/-2.
|
| 1275 |
+
min_dtm_to_consider_as_wdl_1 = gaviota_cfg.min_dtm_to_consider_as_wdl_1
|
| 1276 |
+
|
| 1277 |
+
with chess.gaviota.open_tablebase(gaviota_cfg.paths[0]) as tablebase:
|
| 1278 |
+
for path in gaviota_cfg.paths[1:]:
|
| 1279 |
+
tablebase.add_directory(path)
|
| 1280 |
+
|
| 1281 |
+
try:
|
| 1282 |
+
moves = score_gaviota_moves(board, dtm_scorer, tablebase)
|
| 1283 |
+
|
| 1284 |
+
best_wdl = max(map(dtm_to_gaviota_wdl, moves.values()))
|
| 1285 |
+
good_moves = [(move, dtm) for move, dtm in moves.items() if dtm_to_gaviota_wdl(dtm) == best_wdl]
|
| 1286 |
+
best_dtm = min(good_moves, key=itemgetter(1))[1]
|
| 1287 |
+
|
| 1288 |
+
pseudo_wdl = dtm_to_wdl(best_dtm, min_dtm_to_consider_as_wdl_1)
|
| 1289 |
+
if move_quality == "suggest":
|
| 1290 |
+
best_moves = good_enough_gaviota_moves(good_moves, best_dtm, min_dtm_to_consider_as_wdl_1)
|
| 1291 |
+
if len(best_moves) > 1:
|
| 1292 |
+
move = [chess_move for chess_move, dtm in best_moves]
|
| 1293 |
+
logger.info(f"Suggesting moves from gaviota (pseudo wdl: {pseudo_wdl}) for game {game.id}")
|
| 1294 |
+
else:
|
| 1295 |
+
move, dtm = best_moves[0]
|
| 1296 |
+
logger.info(f"Got move {move.uci()} from gaviota (pseudo wdl: {pseudo_wdl}, dtm: {dtm})"
|
| 1297 |
+
f" for game {game.id}")
|
| 1298 |
+
else:
|
| 1299 |
+
# There can be multiple moves with the same dtm.
|
| 1300 |
+
best_moves = [(move, dtm) for move, dtm in good_moves if dtm == best_dtm]
|
| 1301 |
+
move, dtm = random.choice(best_moves)
|
| 1302 |
+
logger.info(f"Got move {move.uci()} from gaviota (pseudo wdl: {pseudo_wdl}, dtm: {dtm}) for game {game.id}")
|
| 1303 |
+
return move, pseudo_wdl
|
| 1304 |
+
except KeyError:
|
| 1305 |
+
return None, -3
|
| 1306 |
+
|
| 1307 |
+
|
| 1308 |
+
def dtm_scorer(tablebase: chess.gaviota.NativeTablebase | chess.gaviota.PythonTablebase, board: chess.Board) -> int:
|
| 1309 |
+
"""Score a position based on a gaviota DTM egtb."""
|
| 1310 |
+
dtm = -tablebase.probe_dtm(board)
|
| 1311 |
+
return dtm + int(math.copysign(board.halfmove_clock, dtm) if dtm else 0)
|
| 1312 |
+
|
| 1313 |
+
|
| 1314 |
+
def dtm_to_gaviota_wdl(dtm: int) -> int:
|
| 1315 |
+
"""Convert DTM scores to gaviota WDL scores."""
|
| 1316 |
+
return piecewise_function([(-1, "i", -1), (0, "i", 0)], 1, dtm)
|
| 1317 |
+
|
| 1318 |
+
|
| 1319 |
+
def dtm_to_wdl(dtm: int, min_dtm_to_consider_as_wdl_1: int) -> int:
|
| 1320 |
+
"""Convert DTM scores to syzygy WDL scores."""
|
| 1321 |
+
# We use 100 and not min_dtm_to_consider_as_wdl_1, because we want to play it safe and not resign in a
|
| 1322 |
+
# position where dtz=-102 (only if resign_for_egtb_minus_two is enabled).
|
| 1323 |
+
return piecewise_function([(-100, "i", -1), (-1, "i", -2), (0, "i", 0), (min_dtm_to_consider_as_wdl_1, "e", 2)], 1, dtm)
|
| 1324 |
+
|
| 1325 |
+
|
| 1326 |
+
def good_enough_gaviota_moves(good_moves: list[tuple[chess.Move, int]], best_dtm: int,
|
| 1327 |
+
min_dtm_to_consider_as_wdl_1: int) -> list[tuple[chess.Move, int]]:
|
| 1328 |
+
"""
|
| 1329 |
+
Get the moves that are good enough to consider.
|
| 1330 |
+
|
| 1331 |
+
:param good_moves: All the moves to choose from.
|
| 1332 |
+
:param best_dtm: The best DTM score of a move.
|
| 1333 |
+
:param min_dtm_to_consider_as_wdl_1: The minimum DTM score to consider as WDL=1.
|
| 1334 |
+
:return: A list of the moves that are good enough to consider.
|
| 1335 |
+
"""
|
| 1336 |
+
if best_dtm < 100:
|
| 1337 |
+
# If a move had wdl=2 and dtz=98, but halfmove_clock is 4 then the real wdl=1 and dtz=102, so we
|
| 1338 |
+
# want to avoid these positions, if there is a move where even when we add the halfmove_clock the
|
| 1339 |
+
# dtz is still <100.
|
| 1340 |
+
return [(move, dtm) for move, dtm in good_moves if dtm < 100]
|
| 1341 |
+
if best_dtm < min_dtm_to_consider_as_wdl_1:
|
| 1342 |
+
# If a move had wdl=2 and dtz=98, but halfmove_clock is 4 then the real wdl=1 and dtz=102, so we
|
| 1343 |
+
# want to avoid these positions, if there is a move where even when we add the halfmove_clock the
|
| 1344 |
+
# dtz is still <100.
|
| 1345 |
+
return [(move, dtm) for move, dtm in good_moves if dtm < min_dtm_to_consider_as_wdl_1]
|
| 1346 |
+
if best_dtm <= -min_dtm_to_consider_as_wdl_1:
|
| 1347 |
+
# If a move had wdl=-2 and dtz=-98, but halfmove_clock is 4 then the real wdl=-1 and dtz=-102, so we
|
| 1348 |
+
# want to only choose between the moves where the real wdl=-1.
|
| 1349 |
+
return [(move, dtm) for move, dtm in good_moves if dtm <= -min_dtm_to_consider_as_wdl_1]
|
| 1350 |
+
if best_dtm <= -100:
|
| 1351 |
+
# If a move had wdl=-2 and dtz=-98, but halfmove_clock is 4 then the real wdl=-1 and dtz=-102, so we
|
| 1352 |
+
# want to only choose between the moves where the real wdl=-1.
|
| 1353 |
+
return [(move, dtm) for move, dtm in good_moves if dtm <= -100]
|
| 1354 |
+
return good_moves
|
| 1355 |
+
|
| 1356 |
+
|
| 1357 |
+
def piecewise_function(range_definitions: list[tuple[float, Literal["e", "i"], int]], last_value: int,
|
| 1358 |
+
position: float) -> int:
|
| 1359 |
+
"""
|
| 1360 |
+
Return a value according to a position argument.
|
| 1361 |
+
|
| 1362 |
+
This function is meant to replace if-elif-else blocks that turn ranges into discrete values.
|
| 1363 |
+
Each tuple in the list has three parts: an upper limit, and inclusive/exclusive indicator, and
|
| 1364 |
+
a value. For example,
|
| 1365 |
+
`piecewise_function([(-20000, "e", 2), (0, "e" -1), (0, "i", 0), (20000, "i", 1)], 2, score)` is equivalent to:
|
| 1366 |
+
|
| 1367 |
+
if score < -20000:
|
| 1368 |
+
return -2
|
| 1369 |
+
elif score < 0:
|
| 1370 |
+
return -1
|
| 1371 |
+
elif score <= 0:
|
| 1372 |
+
return 0
|
| 1373 |
+
elif score <= 20000:
|
| 1374 |
+
return 1
|
| 1375 |
+
else:
|
| 1376 |
+
return 2
|
| 1377 |
+
|
| 1378 |
+
Arguments:
|
| 1379 |
+
range_definitions:
|
| 1380 |
+
A list of tuples with the first element being the inclusive right border of region and the second
|
| 1381 |
+
element being the associated value. An element of this list (a, "i", b) corresponds to an
|
| 1382 |
+
inclusive limit and is equivalent to
|
| 1383 |
+
if x <= a:
|
| 1384 |
+
return b
|
| 1385 |
+
where x is the value of the position argument. An element of the form (a, "e", b) corresponds to
|
| 1386 |
+
an exclusive limit and is equivalent to
|
| 1387 |
+
if x < a:
|
| 1388 |
+
return b
|
| 1389 |
+
For correct operation, this argument should be sorted by the first element. If two ranges have the
|
| 1390 |
+
same border, one with "e" and the other with "i", the "e" element should be first.
|
| 1391 |
+
last_value:
|
| 1392 |
+
If the position argument does not fall in any of the ranges in the range_definition argument,
|
| 1393 |
+
return this value.
|
| 1394 |
+
position:
|
| 1395 |
+
The value that will be compared to the first element of the range_definitions tuples.
|
| 1396 |
+
|
| 1397 |
+
"""
|
| 1398 |
+
for border, inc_exc, value in range_definitions:
|
| 1399 |
+
if position < border or (inc_exc == "i" and position == border):
|
| 1400 |
+
return value
|
| 1401 |
+
return last_value
|
| 1402 |
+
|
| 1403 |
+
|
| 1404 |
+
def score_syzygy_moves(board: chess.Board,
|
| 1405 |
+
scorer: Callable[[chess.syzygy.Tablebase, chess.Board], int] |
|
| 1406 |
+
Callable[[chess.syzygy.Tablebase, chess.Board], int | float],
|
| 1407 |
+
tablebase: chess.syzygy.Tablebase) -> dict[chess.Move, int | float]:
|
| 1408 |
+
"""Score all the moves using syzygy egtbs."""
|
| 1409 |
+
moves = {}
|
| 1410 |
+
for move in board.legal_moves:
|
| 1411 |
+
board.push(move)
|
| 1412 |
+
moves[move] = scorer(tablebase, board)
|
| 1413 |
+
board.pop()
|
| 1414 |
+
return moves
|
| 1415 |
+
|
| 1416 |
+
|
| 1417 |
+
def score_gaviota_moves(board: chess.Board,
|
| 1418 |
+
scorer: Callable[[chess.gaviota.NativeTablebase | chess.gaviota.PythonTablebase, chess.Board], int],
|
| 1419 |
+
tablebase: chess.gaviota.NativeTablebase | chess.gaviota.PythonTablebase
|
| 1420 |
+
) -> dict[chess.Move, int]:
|
| 1421 |
+
"""Score all the moves using gaviota egtbs."""
|
| 1422 |
+
moves = {}
|
| 1423 |
+
for move in board.legal_moves:
|
| 1424 |
+
board.push(move)
|
| 1425 |
+
moves[move] = scorer(tablebase, board)
|
| 1426 |
+
board.pop()
|
| 1427 |
+
return moves
|
lichess-bot-master/lib/lichess.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Communication with APIs."""
|
| 2 |
+
import json
|
| 3 |
+
import requests
|
| 4 |
+
from urllib.parse import urljoin
|
| 5 |
+
from requests.exceptions import ConnectionError as RequestsConnectionError, HTTPError, ReadTimeout
|
| 6 |
+
from http.client import RemoteDisconnected
|
| 7 |
+
import backoff
|
| 8 |
+
import logging
|
| 9 |
+
import traceback
|
| 10 |
+
from collections import defaultdict
|
| 11 |
+
import datetime
|
| 12 |
+
import contextlib
|
| 13 |
+
from lib.timer import Timer, seconds, sec_str
|
| 14 |
+
from typing import cast
|
| 15 |
+
import chess.engine
|
| 16 |
+
from lib.lichess_types import (UserProfileType, REQUESTS_PAYLOAD_TYPE, GameType, PublicDataType, OnlineType,
|
| 17 |
+
ChallengeType, TOKEN_TESTS_TYPE, BackoffDetails)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
ENDPOINTS = {
|
| 21 |
+
"profile": "/api/account",
|
| 22 |
+
"playing": "/api/account/playing",
|
| 23 |
+
"stream": "/api/bot/game/stream/{}",
|
| 24 |
+
"stream_event": "/api/stream/event",
|
| 25 |
+
"move": "/api/bot/game/{}/move/{}",
|
| 26 |
+
"takeback": "/api/bot/game/{}/takeback/{}",
|
| 27 |
+
"chat": "/api/bot/game/{}/chat",
|
| 28 |
+
"abort": "/api/bot/game/{}/abort",
|
| 29 |
+
"accept": "/api/challenge/{}/accept",
|
| 30 |
+
"decline": "/api/challenge/{}/decline",
|
| 31 |
+
"upgrade": "/api/bot/account/upgrade",
|
| 32 |
+
"resign": "/api/bot/game/{}/resign",
|
| 33 |
+
"export": "/game/export/{}",
|
| 34 |
+
"online_bots": "/api/bot/online",
|
| 35 |
+
"challenge": "/api/challenge/{}",
|
| 36 |
+
"cancel": "/api/challenge/{}/cancel",
|
| 37 |
+
"status": "/api/users/status",
|
| 38 |
+
"public_data": "/api/user/{}",
|
| 39 |
+
"token_test": "/api/token/test"
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
logger = logging.getLogger(__name__)
|
| 44 |
+
|
| 45 |
+
MAX_CHAT_MESSAGE_LEN = 140 # The maximum characters in a chat message.
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class Stop:
|
| 49 |
+
"""Class to stop the bot."""
|
| 50 |
+
|
| 51 |
+
def __init__(self) -> None:
|
| 52 |
+
"""Initialize the Stop class."""
|
| 53 |
+
self.terminated = False
|
| 54 |
+
self.force_quit = False
|
| 55 |
+
self.restart = True
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
stop = Stop()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class RateLimitedError(RuntimeError):
|
| 62 |
+
"""Exception raised when we are rate limited (status code 429)."""
|
| 63 |
+
|
| 64 |
+
def __init__(self, message: str, timeout: datetime.timedelta) -> None:
|
| 65 |
+
"""Create a rate-limited error with the time left until the rate limit expires."""
|
| 66 |
+
super().__init__(message)
|
| 67 |
+
self.timeout = timeout
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def is_new_rate_limit(response: requests.models.Response) -> bool:
|
| 71 |
+
"""Check if the status code is 429, which means that we are rate limited."""
|
| 72 |
+
return response.status_code == 429
|
| 73 |
+
|
| 74 |
+
def is_daily_game_rate_limit(response: requests.models.Response, rate_limit_status_code: int) -> bool:
|
| 75 |
+
"""Check if response to challenge is a rate limit, either of the bot or the opponent."""
|
| 76 |
+
if response.status_code != rate_limit_status_code:
|
| 77 |
+
return False
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
body = response.json()
|
| 81 |
+
return "error" in body and body.get("ratelimit", {}).get("key", "") == "bot.vsBot.day"
|
| 82 |
+
except requests.exceptions.JSONDecodeError:
|
| 83 |
+
return False
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def is_opponent_rate_limit(response: requests.models.Response) -> bool:
|
| 87 |
+
"""Check if response to a challenge is 400, which means opponent is rate limited."""
|
| 88 |
+
return is_daily_game_rate_limit(response, 400)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def is_bot_rate_limit(response: requests.models.Response) -> bool:
|
| 92 |
+
"""Check if response to a challenge is 429, which means the bot is rate limited."""
|
| 93 |
+
return is_daily_game_rate_limit(response, 429)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def get_challenge_timeout(challenge_response: ChallengeType) -> datetime.timedelta | None:
|
| 97 |
+
"""Return the timeout in a challenge response if the bot or the opponent cannot play another game."""
|
| 98 |
+
rate_limit = challenge_response.get("ratelimit", {})
|
| 99 |
+
key = rate_limit.get("key", "")
|
| 100 |
+
if key == "bot.vsBot.day":
|
| 101 |
+
return seconds(float(rate_limit["seconds"]))
|
| 102 |
+
return None
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def is_final(exception: Exception) -> bool:
|
| 106 |
+
"""If `is_final` returns True then we won't retry."""
|
| 107 |
+
return (isinstance(exception, HTTPError) and exception.response is not None and exception.response.status_code < 500
|
| 108 |
+
or stop.force_quit)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def backoff_handler(details: BackoffDetails) -> None:
|
| 112 |
+
"""Log exceptions inside functions with the backoff decorator."""
|
| 113 |
+
args = details["args"]
|
| 114 |
+
kwargs = details["kwargs"]
|
| 115 |
+
if "token_test" in args:
|
| 116 |
+
kwargs["data"] = "<token redacted>"
|
| 117 |
+
logger.debug("Backing off {wait:0.1f} seconds after {tries} tries "
|
| 118 |
+
"calling function {target} with args {args} and kwargs {kwargs}".format(**details))
|
| 119 |
+
logger.debug(f"Exception: {traceback.format_exc()}")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# Docs: https://lichess.org/api.
|
| 123 |
+
class Lichess:
|
| 124 |
+
"""Communication with lichess.org (and chessdb.cn for getting moves)."""
|
| 125 |
+
|
| 126 |
+
def __init__(self, token: str, url: str, version: str, logging_level: int, max_retries: int) -> None:
|
| 127 |
+
"""
|
| 128 |
+
Communication with lichess.org (and chessdb.cn for getting moves).
|
| 129 |
+
|
| 130 |
+
:param token: The bot's token.
|
| 131 |
+
:param url: The base url (lichess.org).
|
| 132 |
+
:param version: The lichess-bot version running.
|
| 133 |
+
:param logging_level: The logging level (logging.INFO or logging.DEBUG).
|
| 134 |
+
:param max_retries: The maximum amount of retries for online moves (e.g. chessdb's opening book).
|
| 135 |
+
"""
|
| 136 |
+
self.version = version
|
| 137 |
+
self.header = {
|
| 138 |
+
"Authorization": f"Bearer {token}"
|
| 139 |
+
}
|
| 140 |
+
self.baseUrl = url
|
| 141 |
+
self.session = requests.Session()
|
| 142 |
+
self.session.headers.update(self.header)
|
| 143 |
+
self.other_session = requests.Session()
|
| 144 |
+
self.set_user_agent("?")
|
| 145 |
+
self.logging_level = logging_level
|
| 146 |
+
self.max_retries = max_retries
|
| 147 |
+
self.rate_limit_timers: defaultdict[str, Timer] = defaultdict(Timer)
|
| 148 |
+
|
| 149 |
+
# Confirm that the OAuth token has the proper permission to play on lichess
|
| 150 |
+
token_response = cast(TOKEN_TESTS_TYPE, self.api_post("token_test", data=token))
|
| 151 |
+
token_info = token_response.get(token)
|
| 152 |
+
|
| 153 |
+
if not token_info:
|
| 154 |
+
raise RuntimeError("There was an error in retrieving information about the bot's token. "
|
| 155 |
+
"Please check that it was copied correctly into your configuration file "
|
| 156 |
+
"and try again.")
|
| 157 |
+
|
| 158 |
+
scopes = token_info.get("scopes", "")
|
| 159 |
+
if "bot:play" not in scopes.split(","):
|
| 160 |
+
raise RuntimeError("Please use an API access token for your bot that "
|
| 161 |
+
'has the scope "Play games with the bot API (bot:play)". '
|
| 162 |
+
f"The current token has: {scopes}.")
|
| 163 |
+
|
| 164 |
+
@backoff.on_exception(backoff.constant,
|
| 165 |
+
(RemoteDisconnected, RequestsConnectionError, HTTPError, ReadTimeout),
|
| 166 |
+
max_time=60,
|
| 167 |
+
interval=0.1,
|
| 168 |
+
giveup=is_final,
|
| 169 |
+
on_backoff=backoff_handler,
|
| 170 |
+
backoff_log_level=logging.DEBUG,
|
| 171 |
+
giveup_log_level=logging.DEBUG)
|
| 172 |
+
def api_get(self, endpoint_name: str, *template_args: str,
|
| 173 |
+
params: dict[str, str] | None = None,
|
| 174 |
+
stream: bool = False, timeout: int = 2) -> requests.Response:
|
| 175 |
+
"""
|
| 176 |
+
Send a GET to lichess.org.
|
| 177 |
+
|
| 178 |
+
:param endpoint_name: The name of the endpoint.
|
| 179 |
+
:param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`).
|
| 180 |
+
:param params: Parameters sent to lichess.org.
|
| 181 |
+
:param stream: Whether the data returned from lichess.org should be streamed.
|
| 182 |
+
:param timeout: The amount of time in seconds to wait for a response.
|
| 183 |
+
:return: lichess.org's response.
|
| 184 |
+
"""
|
| 185 |
+
logging.getLogger("backoff").setLevel(self.logging_level)
|
| 186 |
+
path_template = self.get_path_template(endpoint_name)
|
| 187 |
+
url = urljoin(self.baseUrl, path_template.format(*template_args))
|
| 188 |
+
response = self.session.get(url, params=params, timeout=timeout, stream=stream)
|
| 189 |
+
|
| 190 |
+
if is_new_rate_limit(response):
|
| 191 |
+
delay = seconds(1 if endpoint_name == "move" else 60)
|
| 192 |
+
self.set_rate_limit_delay(path_template, delay)
|
| 193 |
+
|
| 194 |
+
response.raise_for_status()
|
| 195 |
+
response.encoding = "utf-8"
|
| 196 |
+
return response
|
| 197 |
+
|
| 198 |
+
def api_get_json(self, endpoint_name: str, *template_args: str,
|
| 199 |
+
params: dict[str, str] | None = None
|
| 200 |
+
) -> PublicDataType | UserProfileType | dict[str, list[GameType]]:
|
| 201 |
+
"""
|
| 202 |
+
Send a GET to the lichess.org endpoints that return a JSON.
|
| 203 |
+
|
| 204 |
+
:param endpoint_name: The name of the endpoint.
|
| 205 |
+
:param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`).
|
| 206 |
+
:param params: Parameters sent to lichess.org.
|
| 207 |
+
:return: lichess.org's response in a dict.
|
| 208 |
+
"""
|
| 209 |
+
response = self.api_get(endpoint_name, *template_args, params=params)
|
| 210 |
+
json_response: PublicDataType | UserProfileType | dict[str, list[GameType]] = response.json()
|
| 211 |
+
return json_response
|
| 212 |
+
|
| 213 |
+
def api_get_list(self, endpoint_name: str, *template_args: str,
|
| 214 |
+
params: dict[str, str] | None = None) -> list[UserProfileType]:
|
| 215 |
+
"""
|
| 216 |
+
Send a GET to the lichess.org endpoints that return a list containing JSON.
|
| 217 |
+
|
| 218 |
+
:param endpoint_name: The name of the endpoint.
|
| 219 |
+
:param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`).
|
| 220 |
+
:param params: Parameters sent to lichess.org.
|
| 221 |
+
:return: lichess.org's response in a list of dicts.
|
| 222 |
+
"""
|
| 223 |
+
response = self.api_get(endpoint_name, *template_args, params=params)
|
| 224 |
+
json_response: list[UserProfileType] = response.json()
|
| 225 |
+
return json_response
|
| 226 |
+
|
| 227 |
+
def api_get_raw(self, endpoint_name: str, *template_args: str,
|
| 228 |
+
params: dict[str, str] | None = None) -> str:
|
| 229 |
+
"""
|
| 230 |
+
Send a GET to lichess.org that returns plain text (UTF-8).
|
| 231 |
+
|
| 232 |
+
:param endpoint_name: The name of the endpoint.
|
| 233 |
+
:param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`).
|
| 234 |
+
:param params: Parameters sent to lichess.org.
|
| 235 |
+
:return: The text of lichess.org's response.
|
| 236 |
+
"""
|
| 237 |
+
response = self.api_get(endpoint_name, *template_args, params=params)
|
| 238 |
+
return response.text
|
| 239 |
+
|
| 240 |
+
@backoff.on_exception(backoff.constant,
|
| 241 |
+
(RemoteDisconnected, RequestsConnectionError, HTTPError, ReadTimeout),
|
| 242 |
+
max_time=60,
|
| 243 |
+
interval=0.1,
|
| 244 |
+
giveup=is_final,
|
| 245 |
+
on_backoff=backoff_handler,
|
| 246 |
+
backoff_log_level=logging.DEBUG,
|
| 247 |
+
giveup_log_level=logging.DEBUG)
|
| 248 |
+
def api_post(self,
|
| 249 |
+
endpoint_name: str,
|
| 250 |
+
*template_args: str,
|
| 251 |
+
data: str | dict[str, str] | None = None,
|
| 252 |
+
headers: dict[str, str] | None = None,
|
| 253 |
+
params: dict[str, str] | None = None,
|
| 254 |
+
payload: REQUESTS_PAYLOAD_TYPE | None = None,
|
| 255 |
+
raise_for_status: bool = True) -> ChallengeType | TOKEN_TESTS_TYPE | None:
|
| 256 |
+
"""
|
| 257 |
+
Send a POST to lichess.org.
|
| 258 |
+
|
| 259 |
+
:param endpoint_name: The name of the endpoint.
|
| 260 |
+
:param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`).
|
| 261 |
+
:param data: Data sent to lichess.org.
|
| 262 |
+
:param headers: The headers for the request.
|
| 263 |
+
:param params: Parameters sent to lichess.org.
|
| 264 |
+
:param payload: Payload sent to lichess.org.
|
| 265 |
+
:param raise_for_status: Whether to raise an exception if the response contains an error code.
|
| 266 |
+
:return: lichess.org's response in a dict.
|
| 267 |
+
"""
|
| 268 |
+
logging.getLogger("backoff").setLevel(self.logging_level)
|
| 269 |
+
path_template = self.get_path_template(endpoint_name)
|
| 270 |
+
url = urljoin(self.baseUrl, path_template.format(*template_args))
|
| 271 |
+
response = self.session.post(url, data=data, headers=headers, params=params, json=payload, timeout=2)
|
| 272 |
+
|
| 273 |
+
if endpoint_name == "challenge":
|
| 274 |
+
return self.handle_challenge(response)
|
| 275 |
+
|
| 276 |
+
if is_new_rate_limit(response):
|
| 277 |
+
self.set_rate_limit_delay(path_template, seconds(60))
|
| 278 |
+
|
| 279 |
+
if raise_for_status:
|
| 280 |
+
response.raise_for_status()
|
| 281 |
+
|
| 282 |
+
json_response: ChallengeType | TOKEN_TESTS_TYPE | None = response.json()
|
| 283 |
+
return json_response
|
| 284 |
+
|
| 285 |
+
def get_path_template(self, endpoint_name: str) -> str:
|
| 286 |
+
"""
|
| 287 |
+
Get the path template given the endpoint name. Will raise an exception if the path template is rate limited.
|
| 288 |
+
|
| 289 |
+
:param endpoint_name: The name of the endpoint.
|
| 290 |
+
:return: The path template.
|
| 291 |
+
"""
|
| 292 |
+
path_template = ENDPOINTS[endpoint_name]
|
| 293 |
+
if self.is_rate_limited(path_template):
|
| 294 |
+
time_left = self.rate_limit_time_left(path_template)
|
| 295 |
+
raise RateLimitedError(
|
| 296 |
+
f"{path_template} is rate-limited. Will retry in {sec_str(time_left)} seconds.", time_left)
|
| 297 |
+
return path_template
|
| 298 |
+
|
| 299 |
+
def handle_challenge(self, response: requests.models.Response) -> ChallengeType:
|
| 300 |
+
"""Handle the response to a challenge and, if necessary, the daily game timeout."""
|
| 301 |
+
bot_is_rate_limited = is_bot_rate_limit(response)
|
| 302 |
+
opponent_is_rate_limited = is_opponent_rate_limit(response)
|
| 303 |
+
challenge_response: ChallengeType = response.json()
|
| 304 |
+
if bot_is_rate_limited or opponent_is_rate_limited:
|
| 305 |
+
delay = cast(datetime.timedelta, get_challenge_timeout(challenge_response))
|
| 306 |
+
if bot_is_rate_limited:
|
| 307 |
+
self.set_rate_limit_delay(ENDPOINTS["challenge"], delay)
|
| 308 |
+
challenge_response["bot_is_rate_limited"] = bot_is_rate_limited
|
| 309 |
+
challenge_response["opponent_is_rate_limited"] = opponent_is_rate_limited
|
| 310 |
+
challenge_response["rate_limit_timeout"] = delay
|
| 311 |
+
|
| 312 |
+
return challenge_response
|
| 313 |
+
|
| 314 |
+
def set_rate_limit_delay(self, path_template: str, delay_time: datetime.timedelta) -> None:
|
| 315 |
+
"""
|
| 316 |
+
Set a delay to a path template if it was rate limited.
|
| 317 |
+
|
| 318 |
+
:param path_template: The path template.
|
| 319 |
+
:param delay_time: How long we won't call this endpoint.
|
| 320 |
+
"""
|
| 321 |
+
logger.warning(f"Endpoint {path_template} is rate limited. Waiting {sec_str(delay_time)} seconds until next request.")
|
| 322 |
+
self.rate_limit_timers[path_template] = Timer(delay_time)
|
| 323 |
+
|
| 324 |
+
def is_rate_limited(self, path_template: str) -> bool:
|
| 325 |
+
"""Check if a path template is rate limited."""
|
| 326 |
+
return not self.rate_limit_timers[path_template].is_expired()
|
| 327 |
+
|
| 328 |
+
def rate_limit_time_left(self, path_template: str) -> datetime.timedelta:
|
| 329 |
+
"""How much time is left until we can use the path template normally."""
|
| 330 |
+
return self.rate_limit_timers[path_template].time_until_expiration()
|
| 331 |
+
|
| 332 |
+
def upgrade_to_bot_account(self) -> None:
|
| 333 |
+
"""Upgrade the account to a BOT account."""
|
| 334 |
+
self.api_post("upgrade")
|
| 335 |
+
|
| 336 |
+
def make_move(self, game_id: str, move: chess.engine.PlayResult) -> None:
|
| 337 |
+
"""
|
| 338 |
+
Make a move.
|
| 339 |
+
|
| 340 |
+
:param game_id: The id of the game.
|
| 341 |
+
:param move: The move to make.
|
| 342 |
+
"""
|
| 343 |
+
self.api_post("move", game_id, str(move.move),
|
| 344 |
+
params={"offeringDraw": str(move.draw_offered).lower()})
|
| 345 |
+
|
| 346 |
+
def accept_takeback(self, game_id: str, accept: bool) -> bool:
|
| 347 |
+
"""Answer an opponent's move takeback request."""
|
| 348 |
+
try:
|
| 349 |
+
self.api_post("takeback", game_id, "yes" if accept else "no")
|
| 350 |
+
if accept:
|
| 351 |
+
logger.info("Opponent took back previous move.")
|
| 352 |
+
else:
|
| 353 |
+
logger.info("Refused opponent's take back request.")
|
| 354 |
+
return accept
|
| 355 |
+
except Exception:
|
| 356 |
+
return False
|
| 357 |
+
|
| 358 |
+
def chat(self, game_id: str, room: str, text: str) -> None:
|
| 359 |
+
"""
|
| 360 |
+
Send a message to the chat.
|
| 361 |
+
|
| 362 |
+
:param game_id: The id of the game.
|
| 363 |
+
:param room: The room (either chat or spectator room).
|
| 364 |
+
:param text: The text to send.
|
| 365 |
+
"""
|
| 366 |
+
if len(text) > MAX_CHAT_MESSAGE_LEN:
|
| 367 |
+
logger.warning(f"This chat message is {len(text)} characters, which is longer "
|
| 368 |
+
f"than the maximum of {MAX_CHAT_MESSAGE_LEN}. It will not be sent.")
|
| 369 |
+
logger.warning(f"Message: {text}")
|
| 370 |
+
|
| 371 |
+
data = {"room": room, "text": text}
|
| 372 |
+
self.api_post("chat", game_id, data=data)
|
| 373 |
+
|
| 374 |
+
def abort(self, game_id: str) -> None:
|
| 375 |
+
"""Aborts a game."""
|
| 376 |
+
self.api_post("abort", game_id)
|
| 377 |
+
|
| 378 |
+
def get_event_stream(self) -> requests.models.Response:
|
| 379 |
+
"""Get a stream of the events (e.g. challenge, gameStart)."""
|
| 380 |
+
return self.api_get("stream_event", stream=True, timeout=15)
|
| 381 |
+
|
| 382 |
+
def get_game_stream(self, game_id: str) -> requests.models.Response:
|
| 383 |
+
"""Get stream of the in-game events (e.g. moves by the opponent)."""
|
| 384 |
+
return self.api_get("stream", game_id, stream=True, timeout=15)
|
| 385 |
+
|
| 386 |
+
def accept_challenge(self, challenge_id: str) -> None:
|
| 387 |
+
"""Accept a challenge."""
|
| 388 |
+
self.api_post("accept", challenge_id)
|
| 389 |
+
|
| 390 |
+
def decline_challenge(self, challenge_id: str, reason: str = "generic") -> None:
|
| 391 |
+
"""Decline a challenge."""
|
| 392 |
+
with contextlib.suppress(Exception):
|
| 393 |
+
self.api_post("decline", challenge_id,
|
| 394 |
+
data=f"reason={reason}",
|
| 395 |
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
| 396 |
+
raise_for_status=False)
|
| 397 |
+
|
| 398 |
+
def get_profile(self) -> UserProfileType:
|
| 399 |
+
"""Get the bot's profile (e.g. username)."""
|
| 400 |
+
profile = cast(UserProfileType, self.api_get_json("profile"))
|
| 401 |
+
self.set_user_agent(profile["username"])
|
| 402 |
+
return profile
|
| 403 |
+
|
| 404 |
+
def get_ongoing_games(self) -> list[GameType] | None:
|
| 405 |
+
"""
|
| 406 |
+
Get the bot's ongoing games.
|
| 407 |
+
|
| 408 |
+
If an error occurs when retrieving the games, None is returned.
|
| 409 |
+
"""
|
| 410 |
+
with contextlib.suppress(Exception):
|
| 411 |
+
response = cast(dict[str, list[GameType]], self.api_get_json("playing"))
|
| 412 |
+
return response["nowPlaying"]
|
| 413 |
+
return None
|
| 414 |
+
|
| 415 |
+
def resign(self, game_id: str) -> None:
|
| 416 |
+
"""Resign a game."""
|
| 417 |
+
self.api_post("resign", game_id)
|
| 418 |
+
|
| 419 |
+
def set_user_agent(self, username: str) -> None:
|
| 420 |
+
"""Set the user agent for communication with lichess.org."""
|
| 421 |
+
self.header.update({"User-Agent": f"lichess-bot/{self.version} user:{username}"})
|
| 422 |
+
self.session.headers.update(self.header)
|
| 423 |
+
|
| 424 |
+
def get_game_pgn(self, game_id: str) -> str:
|
| 425 |
+
"""Get the PGN (Portable Game Notation) record of a game."""
|
| 426 |
+
try:
|
| 427 |
+
return self.api_get_raw("export", game_id)
|
| 428 |
+
except Exception:
|
| 429 |
+
return ""
|
| 430 |
+
|
| 431 |
+
def get_online_bots(self) -> list[UserProfileType]:
|
| 432 |
+
"""Get a list of bots that are online."""
|
| 433 |
+
try:
|
| 434 |
+
online_bots_str = self.api_get_raw("online_bots")
|
| 435 |
+
online_bots = list(filter(bool, online_bots_str.split("\n")))
|
| 436 |
+
return list(map(json.loads, online_bots))
|
| 437 |
+
except Exception:
|
| 438 |
+
return []
|
| 439 |
+
|
| 440 |
+
def challenge(self, username: str, payload: REQUESTS_PAYLOAD_TYPE) -> ChallengeType:
|
| 441 |
+
"""Create a challenge."""
|
| 442 |
+
return cast(ChallengeType,
|
| 443 |
+
self.api_post("challenge", username, payload=payload, raise_for_status=False))
|
| 444 |
+
|
| 445 |
+
def cancel(self, challenge_id: str) -> None:
|
| 446 |
+
"""Cancel a challenge."""
|
| 447 |
+
self.api_post("cancel", challenge_id, raise_for_status=False)
|
| 448 |
+
|
| 449 |
+
def online_book_get(self, path: str, params: dict[str, str | int] | None = None, *,
|
| 450 |
+
stream: bool = False, authenticated: bool = False) -> OnlineType:
|
| 451 |
+
"""Get an external move from online sources (chessdb or lichess.org)."""
|
| 452 |
+
@backoff.on_exception(backoff.constant,
|
| 453 |
+
(RemoteDisconnected, RequestsConnectionError, HTTPError, ReadTimeout),
|
| 454 |
+
max_time=60,
|
| 455 |
+
max_tries=self.max_retries,
|
| 456 |
+
interval=0.1,
|
| 457 |
+
giveup=is_final,
|
| 458 |
+
on_backoff=backoff_handler,
|
| 459 |
+
backoff_log_level=logging.DEBUG,
|
| 460 |
+
giveup_log_level=logging.DEBUG)
|
| 461 |
+
def online_book_get() -> OnlineType:
|
| 462 |
+
session = self.session if authenticated else self.other_session # Choose session based on authentication need
|
| 463 |
+
json_response: OnlineType = session.get(path, timeout=2, params=params, stream=stream).json()
|
| 464 |
+
return json_response
|
| 465 |
+
return online_book_get()
|
| 466 |
+
|
| 467 |
+
def is_online(self, user_id: str) -> bool:
|
| 468 |
+
"""Check if lichess.org thinks the bot is online or not."""
|
| 469 |
+
user = self.api_get_list("status", params={"ids": user_id})
|
| 470 |
+
return bool(user and user[0].get("online"))
|
| 471 |
+
|
| 472 |
+
def get_public_data(self, user_name: str) -> PublicDataType:
|
| 473 |
+
"""Get the public data of a bot."""
|
| 474 |
+
return cast(PublicDataType, self.api_get_json("public_data", user_name))
|
lichess-bot-master/lib/lichess_bot.py
ADDED
|
@@ -0,0 +1,1277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The main module that controls lichess-bot."""
|
| 2 |
+
import argparse
|
| 3 |
+
import chess
|
| 4 |
+
import chess.pgn
|
| 5 |
+
from chess.variant import find_variant
|
| 6 |
+
from lib import engine_wrapper, model, lichess, matchmaking
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
import logging.handlers
|
| 10 |
+
import multiprocessing
|
| 11 |
+
import signal
|
| 12 |
+
import time
|
| 13 |
+
import datetime
|
| 14 |
+
import backoff
|
| 15 |
+
import os
|
| 16 |
+
import io
|
| 17 |
+
import copy
|
| 18 |
+
import math
|
| 19 |
+
import sys
|
| 20 |
+
import yaml
|
| 21 |
+
import traceback
|
| 22 |
+
import itertools
|
| 23 |
+
import glob
|
| 24 |
+
import platform
|
| 25 |
+
import importlib.metadata
|
| 26 |
+
import contextlib
|
| 27 |
+
from lib.blocklist import OnlineBlocklist
|
| 28 |
+
from lib.config import load_config, Configuration, log_config
|
| 29 |
+
from lib.conversation import Conversation, ChatLine
|
| 30 |
+
from lib.timer import Timer, seconds, msec, hours, to_seconds
|
| 31 |
+
from lib.lichess import stop
|
| 32 |
+
from lib.lichess_types import (UserProfileType, EventType, GameType, GameEventType, CONTROL_QUEUE_TYPE,
|
| 33 |
+
CORRESPONDENCE_QUEUE_TYPE, LOGGING_QUEUE_TYPE, PGN_QUEUE_TYPE)
|
| 34 |
+
from requests.exceptions import (ChunkedEncodingError, ConnectionError as RequestsConnectionError, HTTPError, ReadTimeout,
|
| 35 |
+
RequestException)
|
| 36 |
+
from rich.logging import RichHandler
|
| 37 |
+
from collections import defaultdict
|
| 38 |
+
from collections.abc import Iterator, MutableSequence
|
| 39 |
+
from http.client import RemoteDisconnected
|
| 40 |
+
from queue import Empty
|
| 41 |
+
from multiprocessing.pool import Pool
|
| 42 |
+
from collections import Counter
|
| 43 |
+
from typing import TypedDict, cast, TypeAlias
|
| 44 |
+
from types import FrameType
|
| 45 |
+
MULTIPROCESSING_LIST_TYPE: TypeAlias = MutableSequence[model.Challenge]
|
| 46 |
+
POOL_TYPE: TypeAlias = Pool
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class PlayGameArgsType(TypedDict, total=False):
|
| 50 |
+
"""Type hint for `play_game_args`."""
|
| 51 |
+
|
| 52 |
+
li: lichess.Lichess
|
| 53 |
+
control_queue: CONTROL_QUEUE_TYPE
|
| 54 |
+
user_profile: UserProfileType
|
| 55 |
+
config: Configuration
|
| 56 |
+
challenge_queue: MULTIPROCESSING_LIST_TYPE
|
| 57 |
+
correspondence_queue: CORRESPONDENCE_QUEUE_TYPE
|
| 58 |
+
logging_queue: LOGGING_QUEUE_TYPE
|
| 59 |
+
pgn_queue: PGN_QUEUE_TYPE
|
| 60 |
+
game_id: str
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class VersioningType(TypedDict):
|
| 64 |
+
"""Type hint for the versioning information from lib/versioning.yml."""
|
| 65 |
+
|
| 66 |
+
lichess_bot_version: str
|
| 67 |
+
minimum_python_version: str
|
| 68 |
+
deprecated_python_version: str
|
| 69 |
+
deprecation_date: datetime.date
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
logger = logging.getLogger(__name__)
|
| 73 |
+
|
| 74 |
+
with open(os.path.join(os.path.dirname(__file__), "versioning.yml")) as version_file:
|
| 75 |
+
versioning_info: VersioningType = yaml.safe_load(version_file)
|
| 76 |
+
|
| 77 |
+
__version__ = versioning_info["lichess_bot_version"]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def should_restart() -> bool:
|
| 81 |
+
"""Decide whether to restart lichess-bot when exiting main program."""
|
| 82 |
+
return stop.restart
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def disable_restart() -> None:
|
| 86 |
+
"""Disable restarting lichess-bot when errors occur. Used during testing."""
|
| 87 |
+
stop.restart = False
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def signal_handler(signal: int, frame: FrameType | None) -> None: # noqa: ARG001
|
| 91 |
+
"""Terminate lichess-bot."""
|
| 92 |
+
if not stop.terminated:
|
| 93 |
+
logger.debug("Received SIGINT. Terminating client.")
|
| 94 |
+
stop.terminated = True
|
| 95 |
+
else:
|
| 96 |
+
logger.debug("Received second SIGINT. Quitting now.")
|
| 97 |
+
stop.force_quit = True
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
signal.signal(signal.SIGINT, signal_handler)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def upgrade_account(li: lichess.Lichess) -> bool:
|
| 104 |
+
"""Upgrade the account to a BOT account."""
|
| 105 |
+
try:
|
| 106 |
+
li.upgrade_to_bot_account()
|
| 107 |
+
except HTTPError:
|
| 108 |
+
logger.exception("Failed to upgrade to Bot Account.")
|
| 109 |
+
return False
|
| 110 |
+
|
| 111 |
+
logger.info("Successfully upgraded to Bot Account!")
|
| 112 |
+
return True
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def watch_control_stream(control_queue: CONTROL_QUEUE_TYPE, li: lichess.Lichess) -> None:
|
| 116 |
+
"""Put the events in a queue."""
|
| 117 |
+
while not stop.terminated:
|
| 118 |
+
try:
|
| 119 |
+
with li.get_event_stream() as response:
|
| 120 |
+
lines = response.iter_lines()
|
| 121 |
+
for line in lines:
|
| 122 |
+
if line:
|
| 123 |
+
event = json.loads(line.decode("utf-8"))
|
| 124 |
+
control_queue.put_nowait(event)
|
| 125 |
+
else:
|
| 126 |
+
control_queue.put_nowait({"type": "ping"})
|
| 127 |
+
except Exception:
|
| 128 |
+
logger.warning(f"Control stream error, reconnecting:\n{traceback.format_exc()}")
|
| 129 |
+
time.sleep(1)
|
| 130 |
+
|
| 131 |
+
control_queue.put_nowait({"type": "terminated"})
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def do_correspondence_ping(control_queue: CONTROL_QUEUE_TYPE, period: datetime.timedelta) -> None:
|
| 135 |
+
"""
|
| 136 |
+
Tell the engine to check the correspondence games.
|
| 137 |
+
|
| 138 |
+
:param control_queue: Queue to put events in.
|
| 139 |
+
:param period: How many seconds to wait before sending a correspondence ping.
|
| 140 |
+
"""
|
| 141 |
+
while not stop.terminated:
|
| 142 |
+
time.sleep(to_seconds(period))
|
| 143 |
+
control_queue.put_nowait({"type": "correspondence_ping"})
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def write_pgn_records(pgn_queue: PGN_QUEUE_TYPE, config: Configuration, username: str) -> None:
|
| 147 |
+
"""Write PGN records to files as games finish."""
|
| 148 |
+
while True:
|
| 149 |
+
mark_task_done = False
|
| 150 |
+
try:
|
| 151 |
+
event = pgn_queue.get()
|
| 152 |
+
mark_task_done = True
|
| 153 |
+
if event:
|
| 154 |
+
save_pgn_record(event, config, username)
|
| 155 |
+
except InterruptedError:
|
| 156 |
+
pass
|
| 157 |
+
except Exception:
|
| 158 |
+
logger.exception("Could not write PGN to file")
|
| 159 |
+
|
| 160 |
+
if mark_task_done:
|
| 161 |
+
pgn_queue.task_done()
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def logging_configurer(level: int, filename: str | None, disable_auto_logs: bool) -> None:
|
| 165 |
+
"""
|
| 166 |
+
Configure the logger.
|
| 167 |
+
|
| 168 |
+
:param level: The logging level. Either `logging.INFO` or `logging.DEBUG`.
|
| 169 |
+
:param filename: The filename to write the logs to. If it is `None` then the logs aren't written to a file.
|
| 170 |
+
:param disable_auto_logs: Whether to disable automatic logging.
|
| 171 |
+
"""
|
| 172 |
+
console_handler = RichHandler()
|
| 173 |
+
console_formatter = logging.Formatter("%(message)s")
|
| 174 |
+
console_handler.setFormatter(console_formatter)
|
| 175 |
+
console_handler.setLevel(level)
|
| 176 |
+
all_handlers: list[logging.Handler] = [console_handler]
|
| 177 |
+
|
| 178 |
+
if filename:
|
| 179 |
+
file_handler = logging.FileHandler(filename, delay=True, encoding="utf-8")
|
| 180 |
+
FORMAT = "%(asctime)s %(name)s (%(filename)s:%(lineno)d) %(levelname)s %(message)s"
|
| 181 |
+
file_formatter = logging.Formatter(FORMAT)
|
| 182 |
+
file_handler.setFormatter(file_formatter)
|
| 183 |
+
file_handler.setLevel(level)
|
| 184 |
+
all_handlers.append(file_handler)
|
| 185 |
+
|
| 186 |
+
if not disable_auto_logs:
|
| 187 |
+
os.makedirs(auto_log_directory, exist_ok=True)
|
| 188 |
+
|
| 189 |
+
# Set up automatic logging.
|
| 190 |
+
auto_log_filename = os.path.join(auto_log_directory, "lichess-bot.log")
|
| 191 |
+
auto_file_handler = logging.handlers.TimedRotatingFileHandler(auto_log_filename,
|
| 192 |
+
delay=True,
|
| 193 |
+
encoding="utf-8",
|
| 194 |
+
when="midnight",
|
| 195 |
+
backupCount=7)
|
| 196 |
+
auto_file_handler.setLevel(logging.DEBUG)
|
| 197 |
+
|
| 198 |
+
FORMAT = "%(asctime)s %(name)s (%(filename)s:%(lineno)d) %(levelname)s %(message)s"
|
| 199 |
+
file_formatter = logging.Formatter(FORMAT)
|
| 200 |
+
auto_file_handler.setFormatter(file_formatter)
|
| 201 |
+
all_handlers.append(auto_file_handler)
|
| 202 |
+
|
| 203 |
+
logging.basicConfig(level=logging.DEBUG,
|
| 204 |
+
handlers=all_handlers,
|
| 205 |
+
force=True)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def logging_listener_proc(queue: LOGGING_QUEUE_TYPE, level: int, log_filename: str | None,
|
| 209 |
+
disable_auto_logging: bool) -> None:
|
| 210 |
+
"""
|
| 211 |
+
Handle events from the logging queue.
|
| 212 |
+
|
| 213 |
+
This allows the logs from inside a thread to be printed.
|
| 214 |
+
They are added to the queue, so they are printed outside the thread.
|
| 215 |
+
"""
|
| 216 |
+
logging_configurer(level, log_filename, disable_auto_logging)
|
| 217 |
+
logger = logging.getLogger()
|
| 218 |
+
while True:
|
| 219 |
+
task: logging.LogRecord | None = None
|
| 220 |
+
try:
|
| 221 |
+
task = queue.get(block=False)
|
| 222 |
+
except Empty:
|
| 223 |
+
time.sleep(0.1)
|
| 224 |
+
except InterruptedError:
|
| 225 |
+
pass
|
| 226 |
+
except Exception: # noqa: S110
|
| 227 |
+
pass
|
| 228 |
+
|
| 229 |
+
if task is None:
|
| 230 |
+
continue
|
| 231 |
+
|
| 232 |
+
logger.handle(task)
|
| 233 |
+
queue.task_done()
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def thread_logging_configurer(queue: LOGGING_QUEUE_TYPE) -> None:
|
| 237 |
+
"""Configure the game logger."""
|
| 238 |
+
h = logging.handlers.QueueHandler(queue)
|
| 239 |
+
root = logging.getLogger()
|
| 240 |
+
root.handlers.clear()
|
| 241 |
+
root.addHandler(h)
|
| 242 |
+
root.setLevel(logging.DEBUG)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def start(li: lichess.Lichess, user_profile: UserProfileType, config: Configuration, logging_level: int,
|
| 246 |
+
log_filename: str | None, disable_auto_logging: bool, one_game: bool = False) -> None:
|
| 247 |
+
"""
|
| 248 |
+
Start lichess-bot.
|
| 249 |
+
|
| 250 |
+
:param li: Provides communication with lichess.org.
|
| 251 |
+
:param user_profile: Information on our bot.
|
| 252 |
+
:param config: The config that the bot will use.
|
| 253 |
+
:param logging_level: The logging level. Either `logging.INFO` or `logging.DEBUG`.
|
| 254 |
+
:param log_filename: The filename to write the logs to. If it is `None` then the logs aren't written to a file.
|
| 255 |
+
:param disable_auto_logging: Whether to disable automatic logging.
|
| 256 |
+
:param one_game: Whether the bot should play only one game. Only used in `test_bot/test_bot.py` to test lichess-bot.
|
| 257 |
+
"""
|
| 258 |
+
logger.info(f"You're now connected to {config.url} and awaiting challenges.")
|
| 259 |
+
manager = multiprocessing.Manager()
|
| 260 |
+
challenge_queue: MULTIPROCESSING_LIST_TYPE = manager.list()
|
| 261 |
+
control_queue: CONTROL_QUEUE_TYPE = manager.Queue()
|
| 262 |
+
control_stream = multiprocessing.Process(target=watch_control_stream, args=(control_queue, li))
|
| 263 |
+
control_stream.start()
|
| 264 |
+
correspondence_pinger = multiprocessing.Process(target=do_correspondence_ping,
|
| 265 |
+
args=(control_queue,
|
| 266 |
+
seconds(config.correspondence.checkin_period)))
|
| 267 |
+
correspondence_pinger.start()
|
| 268 |
+
correspondence_queue: CORRESPONDENCE_QUEUE_TYPE = manager.Queue()
|
| 269 |
+
|
| 270 |
+
logging_queue = manager.Queue()
|
| 271 |
+
logging_listener = multiprocessing.Process(target=logging_listener_proc,
|
| 272 |
+
args=(logging_queue,
|
| 273 |
+
logging_level,
|
| 274 |
+
log_filename,
|
| 275 |
+
disable_auto_logging))
|
| 276 |
+
logging_listener.start()
|
| 277 |
+
|
| 278 |
+
pgn_queue = manager.Queue()
|
| 279 |
+
pgn_listener = multiprocessing.Process(target=write_pgn_records,
|
| 280 |
+
args=(pgn_queue,
|
| 281 |
+
config,
|
| 282 |
+
user_profile["username"]))
|
| 283 |
+
pgn_listener.start()
|
| 284 |
+
|
| 285 |
+
thread_logging_configurer(logging_queue)
|
| 286 |
+
|
| 287 |
+
try:
|
| 288 |
+
lichess_bot_main(li,
|
| 289 |
+
user_profile,
|
| 290 |
+
config,
|
| 291 |
+
challenge_queue,
|
| 292 |
+
control_queue,
|
| 293 |
+
correspondence_queue,
|
| 294 |
+
logging_queue,
|
| 295 |
+
pgn_queue,
|
| 296 |
+
one_game)
|
| 297 |
+
finally:
|
| 298 |
+
control_stream.terminate()
|
| 299 |
+
control_stream.join()
|
| 300 |
+
correspondence_pinger.terminate()
|
| 301 |
+
correspondence_pinger.join()
|
| 302 |
+
time.sleep(1.0) # Allow final messages in logging_queue to be handled.
|
| 303 |
+
logging_configurer(logging_level, log_filename, disable_auto_logging)
|
| 304 |
+
logging_listener.terminate()
|
| 305 |
+
logging_listener.join()
|
| 306 |
+
pgn_listener.terminate()
|
| 307 |
+
pgn_listener.join()
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def log_proc_count(change: str, active_games: set[str]) -> None:
|
| 311 |
+
"""
|
| 312 |
+
Log the number of active games and their IDs.
|
| 313 |
+
|
| 314 |
+
:param change: Either "Freed", "Used", or "Queued".
|
| 315 |
+
:param active_games: A set containing the IDs of the active games.
|
| 316 |
+
"""
|
| 317 |
+
symbol = "+++" if change == "Freed" else "---"
|
| 318 |
+
logger.info(f"{symbol} Process {change}. Count: {len(active_games)}. IDs: {active_games or None}")
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def lichess_bot_main(li: lichess.Lichess,
|
| 322 |
+
user_profile: UserProfileType,
|
| 323 |
+
config: Configuration,
|
| 324 |
+
challenge_queue: MULTIPROCESSING_LIST_TYPE,
|
| 325 |
+
control_queue: CONTROL_QUEUE_TYPE,
|
| 326 |
+
correspondence_queue: CORRESPONDENCE_QUEUE_TYPE,
|
| 327 |
+
logging_queue: LOGGING_QUEUE_TYPE,
|
| 328 |
+
pgn_queue: PGN_QUEUE_TYPE,
|
| 329 |
+
one_game: bool) -> None:
|
| 330 |
+
"""
|
| 331 |
+
Handle all the games and challenges.
|
| 332 |
+
|
| 333 |
+
:param li: Provides communication with lichess.org.
|
| 334 |
+
:param user_profile: Information on our bot.
|
| 335 |
+
:param config: The config that the bot will use.
|
| 336 |
+
:param challenge_queue: The queue containing the challenges.
|
| 337 |
+
:param control_queue: The queue containing all the events.
|
| 338 |
+
:param correspondence_queue: The queue containing the correspondence games.
|
| 339 |
+
:param logging_queue: The logging queue. Used by `logging_listener_proc`.
|
| 340 |
+
:param pgn_queue: The queue containing the PGN games.
|
| 341 |
+
:param one_game: Whether the bot should play only one game. Only used in `test_bot/test_bot.py` to test lichess-bot.
|
| 342 |
+
"""
|
| 343 |
+
max_games = config.challenge.concurrency
|
| 344 |
+
|
| 345 |
+
one_game_completed = False
|
| 346 |
+
|
| 347 |
+
all_games = li.get_ongoing_games() or []
|
| 348 |
+
prune_takeback_records(all_games)
|
| 349 |
+
startup_correspondence_games = [game["gameId"]
|
| 350 |
+
for game in all_games
|
| 351 |
+
if game["speed"] == "correspondence"]
|
| 352 |
+
active_games = {game["gameId"]
|
| 353 |
+
for game in all_games
|
| 354 |
+
if game["gameId"] not in startup_correspondence_games}
|
| 355 |
+
low_time_games: list[GameType] = []
|
| 356 |
+
|
| 357 |
+
last_check_online_time = Timer(hours(1))
|
| 358 |
+
matchmaker = matchmaking.Matchmaking(li, config, user_profile)
|
| 359 |
+
matchmaker.show_earliest_challenge_time()
|
| 360 |
+
|
| 361 |
+
play_game_args = PlayGameArgsType(li=li, control_queue=control_queue, user_profile=user_profile,
|
| 362 |
+
config=config, challenge_queue=challenge_queue,
|
| 363 |
+
correspondence_queue=correspondence_queue, logging_queue=logging_queue,
|
| 364 |
+
pgn_queue=pgn_queue)
|
| 365 |
+
|
| 366 |
+
recent_bot_challenges: defaultdict[str, list[Timer]] = defaultdict(list)
|
| 367 |
+
|
| 368 |
+
online_block_list = OnlineBlocklist(config.challenge.online_block_list)
|
| 369 |
+
|
| 370 |
+
if config.quit_after_all_games_finish:
|
| 371 |
+
logger.info("When quitting, lichess-bot will first wait for all running games to finish.")
|
| 372 |
+
logger.info("Press Ctrl-C twice to quit immediately.")
|
| 373 |
+
|
| 374 |
+
with multiprocessing.pool.Pool(max_games + 1) as pool:
|
| 375 |
+
while not (stop.terminated or (one_game and one_game_completed) or stop.restart):
|
| 376 |
+
event = next_event(control_queue)
|
| 377 |
+
if not event:
|
| 378 |
+
continue
|
| 379 |
+
|
| 380 |
+
if event["type"] == "terminated":
|
| 381 |
+
stop.restart = True
|
| 382 |
+
control_queue.task_done()
|
| 383 |
+
break
|
| 384 |
+
|
| 385 |
+
if event["type"] == "local_game_done":
|
| 386 |
+
active_games.discard(event["game"]["id"])
|
| 387 |
+
matchmaker.game_done()
|
| 388 |
+
log_proc_count("Freed", active_games)
|
| 389 |
+
one_game_completed = True
|
| 390 |
+
elif event["type"] == "challenge":
|
| 391 |
+
handle_challenge(event,
|
| 392 |
+
li,
|
| 393 |
+
challenge_queue,
|
| 394 |
+
config.challenge,
|
| 395 |
+
user_profile,
|
| 396 |
+
recent_bot_challenges,
|
| 397 |
+
online_block_list)
|
| 398 |
+
elif event["type"] == "challengeDeclined":
|
| 399 |
+
matchmaker.declined_challenge(event)
|
| 400 |
+
elif event["type"] == "challengeCanceled":
|
| 401 |
+
active_games.discard(event["challenge"]["id"])
|
| 402 |
+
log_proc_count("Freed", active_games)
|
| 403 |
+
elif event["type"] == "gameStart":
|
| 404 |
+
matchmaker.accepted_challenge(event)
|
| 405 |
+
start_game(event,
|
| 406 |
+
pool,
|
| 407 |
+
play_game_args,
|
| 408 |
+
config,
|
| 409 |
+
startup_correspondence_games,
|
| 410 |
+
correspondence_queue,
|
| 411 |
+
active_games,
|
| 412 |
+
low_time_games)
|
| 413 |
+
|
| 414 |
+
start_low_time_games(low_time_games, active_games, max_games, pool, play_game_args)
|
| 415 |
+
check_in_on_correspondence_games(pool,
|
| 416 |
+
event,
|
| 417 |
+
correspondence_queue,
|
| 418 |
+
challenge_queue,
|
| 419 |
+
play_game_args,
|
| 420 |
+
active_games,
|
| 421 |
+
max_games)
|
| 422 |
+
accept_challenges(li, challenge_queue, active_games, max_games)
|
| 423 |
+
matchmaker.challenge(active_games, challenge_queue, max_games)
|
| 424 |
+
check_online_status(li, user_profile, last_check_online_time)
|
| 425 |
+
|
| 426 |
+
control_queue.task_done()
|
| 427 |
+
|
| 428 |
+
close_pool(pool, active_games, config)
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def close_pool(pool: POOL_TYPE, active_games: set[str], config: Configuration) -> None:
|
| 432 |
+
"""Shut down pool after possibly waiting on games to finish depending on the configuration."""
|
| 433 |
+
if config.quit_after_all_games_finish:
|
| 434 |
+
if active_games:
|
| 435 |
+
logger.info("Waiting for games to finish before quitting.")
|
| 436 |
+
pool.close()
|
| 437 |
+
pool.join()
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def next_event(control_queue: CONTROL_QUEUE_TYPE) -> EventType:
|
| 441 |
+
"""Get the next event from the control queue."""
|
| 442 |
+
try:
|
| 443 |
+
event = control_queue.get()
|
| 444 |
+
if event is None:
|
| 445 |
+
return {}
|
| 446 |
+
except InterruptedError:
|
| 447 |
+
return {}
|
| 448 |
+
|
| 449 |
+
if "type" not in event:
|
| 450 |
+
logger.warning("Unable to handle response from lichess.org:")
|
| 451 |
+
logger.warning(event)
|
| 452 |
+
control_queue.task_done()
|
| 453 |
+
return {}
|
| 454 |
+
|
| 455 |
+
if event.get("type") != "ping":
|
| 456 |
+
logger.debug(f"Event: {event}")
|
| 457 |
+
|
| 458 |
+
return event
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
correspondence_games_to_start = 0
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
def check_in_on_correspondence_games(pool: POOL_TYPE,
|
| 465 |
+
event: EventType,
|
| 466 |
+
correspondence_queue: CORRESPONDENCE_QUEUE_TYPE,
|
| 467 |
+
challenge_queue: MULTIPROCESSING_LIST_TYPE,
|
| 468 |
+
play_game_args: PlayGameArgsType,
|
| 469 |
+
active_games: set[str],
|
| 470 |
+
max_games: int) -> None:
|
| 471 |
+
"""Start correspondence games."""
|
| 472 |
+
global correspondence_games_to_start
|
| 473 |
+
|
| 474 |
+
if event["type"] == "correspondence_ping":
|
| 475 |
+
correspondence_games_to_start = correspondence_queue.qsize()
|
| 476 |
+
elif event["type"] != "local_game_done":
|
| 477 |
+
return
|
| 478 |
+
|
| 479 |
+
if challenge_queue:
|
| 480 |
+
return
|
| 481 |
+
|
| 482 |
+
while len(active_games) < max_games and correspondence_games_to_start > 0:
|
| 483 |
+
game_id = correspondence_queue.get_nowait()
|
| 484 |
+
correspondence_games_to_start -= 1
|
| 485 |
+
correspondence_queue.task_done()
|
| 486 |
+
start_game_thread(active_games, game_id, play_game_args, pool)
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
def start_low_time_games(low_time_games: list[GameType], active_games: set[str], max_games: int,
|
| 490 |
+
pool: POOL_TYPE, play_game_args: PlayGameArgsType) -> None:
|
| 491 |
+
"""Start the games based on how much time we have left."""
|
| 492 |
+
low_time_games.sort(key=lambda g: g.get("secondsLeft", math.inf))
|
| 493 |
+
while low_time_games and len(active_games) < max_games:
|
| 494 |
+
game_id = low_time_games.pop(0)["id"]
|
| 495 |
+
start_game_thread(active_games, game_id, play_game_args, pool)
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
def accept_challenges(li: lichess.Lichess, challenge_queue: MULTIPROCESSING_LIST_TYPE, active_games: set[str],
|
| 499 |
+
max_games: int) -> None:
|
| 500 |
+
"""Accept a challenge."""
|
| 501 |
+
while len(active_games) < max_games and challenge_queue:
|
| 502 |
+
chlng = challenge_queue.pop(0)
|
| 503 |
+
if chlng.from_self:
|
| 504 |
+
continue
|
| 505 |
+
|
| 506 |
+
try:
|
| 507 |
+
logger.info(f"Accept {chlng}")
|
| 508 |
+
li.accept_challenge(chlng.id)
|
| 509 |
+
active_games.add(chlng.id)
|
| 510 |
+
log_proc_count("Queued", active_games)
|
| 511 |
+
except (HTTPError, ReadTimeout) as exception:
|
| 512 |
+
if isinstance(exception, HTTPError) and exception.response is not None and exception.response.status_code == 404:
|
| 513 |
+
logger.info(f"Skip missing {chlng}")
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
def check_online_status(li: lichess.Lichess, user_profile: UserProfileType, last_check_online_time: Timer) -> None:
|
| 517 |
+
"""Check if lichess.org thinks the bot is online or not. If it isn't, we restart it."""
|
| 518 |
+
if last_check_online_time.is_expired():
|
| 519 |
+
try:
|
| 520 |
+
if not li.is_online(user_profile["id"]):
|
| 521 |
+
logger.info("Will restart lichess-bot")
|
| 522 |
+
stop.restart = True
|
| 523 |
+
last_check_online_time.reset()
|
| 524 |
+
except (HTTPError, ReadTimeout):
|
| 525 |
+
pass
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
def sort_challenges(challenge_queue: MULTIPROCESSING_LIST_TYPE, challenge_config: Configuration) -> None:
|
| 529 |
+
"""
|
| 530 |
+
Sort the challenges.
|
| 531 |
+
|
| 532 |
+
They can be sorted either by rating (the best challenger is accepted first),
|
| 533 |
+
or by time (the first challenger is accepted first). The bot can also
|
| 534 |
+
prioritize playing against humans or bots.
|
| 535 |
+
"""
|
| 536 |
+
challenge_list = list(challenge_queue)
|
| 537 |
+
if challenge_config.sort_by == "best":
|
| 538 |
+
challenge_list.sort(key=lambda challenger: challenger.score(), reverse=True)
|
| 539 |
+
if challenge_config.preference != "none":
|
| 540 |
+
challenge_list.sort(key=lambda challenger: challenger.challenger.is_bot, reverse=challenge_config.preference == "bot")
|
| 541 |
+
challenge_queue[:] = challenge_list
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
def game_is_active(li: lichess.Lichess, game_id: str) -> bool:
|
| 545 |
+
"""Determine if a game is still being played."""
|
| 546 |
+
active_games = li.get_ongoing_games()
|
| 547 |
+
if active_games is None:
|
| 548 |
+
return True
|
| 549 |
+
return game_id in (ongoing_game["gameId"] for ongoing_game in active_games)
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
def start_game_thread(active_games: set[str], game_id: str, play_game_args: PlayGameArgsType, pool: POOL_TYPE) -> None:
|
| 553 |
+
"""Start a game thread."""
|
| 554 |
+
active_games.add(game_id)
|
| 555 |
+
log_proc_count("Used", active_games)
|
| 556 |
+
play_game_args["game_id"] = game_id
|
| 557 |
+
|
| 558 |
+
def game_error_handler(error: BaseException) -> None:
|
| 559 |
+
logger.exception("Game ended due to error:", exc_info=error)
|
| 560 |
+
control_queue = play_game_args["control_queue"]
|
| 561 |
+
pgn_queue = play_game_args["pgn_queue"]
|
| 562 |
+
li = play_game_args["li"]
|
| 563 |
+
control_queue.put_nowait({"type": "local_game_done", "game": {"id": game_id}})
|
| 564 |
+
pgn_queue.put_nowait({"game": {"id": game_id,
|
| 565 |
+
"pgn": li.get_game_pgn(game_id),
|
| 566 |
+
"complete": not game_is_active(li, game_id)}})
|
| 567 |
+
|
| 568 |
+
pool.apply_async(play_game,
|
| 569 |
+
kwds=play_game_args,
|
| 570 |
+
error_callback=game_error_handler)
|
| 571 |
+
|
| 572 |
+
|
| 573 |
+
def start_game(event: EventType,
|
| 574 |
+
pool: POOL_TYPE,
|
| 575 |
+
play_game_args: PlayGameArgsType,
|
| 576 |
+
config: Configuration,
|
| 577 |
+
startup_correspondence_games: list[str],
|
| 578 |
+
correspondence_queue: CORRESPONDENCE_QUEUE_TYPE,
|
| 579 |
+
active_games: set[str],
|
| 580 |
+
low_time_games: list[GameType]) -> None:
|
| 581 |
+
"""
|
| 582 |
+
Start a game.
|
| 583 |
+
|
| 584 |
+
:param event: The gameStart event.
|
| 585 |
+
:param pool: The thread pool that the game is added to, so they can be run asynchronously.
|
| 586 |
+
:param play_game_args: The args passed to `play_game`.
|
| 587 |
+
:param config: The config the bot will use.
|
| 588 |
+
:param startup_correspondence_games: A list of correspondence games that have to be started.
|
| 589 |
+
:param correspondence_queue: The queue that correspondence games are added to, to be started.
|
| 590 |
+
:param active_games: A set of all the games that aren't correspondence games.
|
| 591 |
+
:param low_time_games: A list of games, in which we don't have much time remaining.
|
| 592 |
+
"""
|
| 593 |
+
game_id = event["game"]["id"]
|
| 594 |
+
if game_id in startup_correspondence_games:
|
| 595 |
+
if enough_time_to_queue(event, config):
|
| 596 |
+
logger.info(f"--- Enqueue {config.url + game_id}")
|
| 597 |
+
correspondence_queue.put_nowait(game_id)
|
| 598 |
+
else:
|
| 599 |
+
logger.info(f"--- Will start {config.url + game_id} as soon as possible")
|
| 600 |
+
low_time_games.append(event["game"])
|
| 601 |
+
startup_correspondence_games.remove(game_id)
|
| 602 |
+
else:
|
| 603 |
+
start_game_thread(active_games, game_id, play_game_args, pool)
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
def enough_time_to_queue(event: EventType, config: Configuration) -> bool:
|
| 607 |
+
"""Check whether the correspondence must be started now or if it can wait."""
|
| 608 |
+
corr_cfg = config.correspondence
|
| 609 |
+
minimum_time = (corr_cfg.checkin_period + corr_cfg.move_time) * 10
|
| 610 |
+
game = event["game"]
|
| 611 |
+
return not game["isMyTurn"] or game.get("secondsLeft", math.inf) > minimum_time
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
def handle_challenge(event: EventType, li: lichess.Lichess, challenge_queue: MULTIPROCESSING_LIST_TYPE,
|
| 615 |
+
challenge_config: Configuration, user_profile: UserProfileType,
|
| 616 |
+
recent_bot_challenges: defaultdict[str, list[Timer]], online_block_list: OnlineBlocklist) -> None:
|
| 617 |
+
"""Handle incoming challenges. It either accepts, declines, or queues them to accept later."""
|
| 618 |
+
chlng = model.Challenge(event["challenge"], user_profile)
|
| 619 |
+
if chlng.from_self:
|
| 620 |
+
return
|
| 621 |
+
|
| 622 |
+
active_games = li.get_ongoing_games() or []
|
| 623 |
+
opponent_engagements = Counter(game["opponent"]["username"] for game in active_games)
|
| 624 |
+
opponent_engagements.update(challenge.challenger.name for challenge in challenge_queue)
|
| 625 |
+
|
| 626 |
+
online_block_list.refresh()
|
| 627 |
+
|
| 628 |
+
is_supported, decline_reason = chlng.is_supported(challenge_config,
|
| 629 |
+
recent_bot_challenges,
|
| 630 |
+
opponent_engagements,
|
| 631 |
+
online_block_list,
|
| 632 |
+
user_profile)
|
| 633 |
+
if is_supported:
|
| 634 |
+
challenge_queue.append(chlng)
|
| 635 |
+
sort_challenges(challenge_queue, challenge_config)
|
| 636 |
+
time_window = challenge_config.recent_bot_challenge_age
|
| 637 |
+
if time_window is not None:
|
| 638 |
+
recent_bot_challenges[chlng.challenger.name].append(Timer(seconds(time_window)))
|
| 639 |
+
else:
|
| 640 |
+
li.decline_challenge(chlng.id, reason=decline_reason)
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
@backoff.on_exception(backoff.expo, BaseException, max_time=600, giveup=lichess.is_final, # type: ignore[arg-type]
|
| 644 |
+
on_backoff=lichess.backoff_handler)
|
| 645 |
+
def play_game(li: lichess.Lichess,
|
| 646 |
+
game_id: str,
|
| 647 |
+
control_queue: CONTROL_QUEUE_TYPE,
|
| 648 |
+
user_profile: UserProfileType,
|
| 649 |
+
config: Configuration,
|
| 650 |
+
challenge_queue: MULTIPROCESSING_LIST_TYPE,
|
| 651 |
+
correspondence_queue: CORRESPONDENCE_QUEUE_TYPE,
|
| 652 |
+
logging_queue: LOGGING_QUEUE_TYPE,
|
| 653 |
+
pgn_queue: PGN_QUEUE_TYPE) -> None:
|
| 654 |
+
"""
|
| 655 |
+
Play a game.
|
| 656 |
+
|
| 657 |
+
:param li: Provides communication with lichess.org.
|
| 658 |
+
:param game_id: The id of the game.
|
| 659 |
+
:param control_queue: The control queue that contains events (adds `local_game_done` to the queue).
|
| 660 |
+
:param user_profile: Information on our bot.
|
| 661 |
+
:param config: The config that the bot will use.
|
| 662 |
+
:param challenge_queue: The queue containing the challenges.
|
| 663 |
+
:param correspondence_queue: The queue containing the correspondence games.
|
| 664 |
+
:param logging_queue: The logging queue. Used by `logging_listener_proc`.
|
| 665 |
+
:param pgn_queue: The queue containing the PGN games.
|
| 666 |
+
"""
|
| 667 |
+
thread_logging_configurer(logging_queue)
|
| 668 |
+
logger = logging.getLogger(__name__)
|
| 669 |
+
|
| 670 |
+
with li.get_game_stream(game_id) as response:
|
| 671 |
+
lines = response.iter_lines()
|
| 672 |
+
|
| 673 |
+
# Initial response of stream will be the full game info. Store it.
|
| 674 |
+
initial_state = json.loads(next(lines).decode("utf-8"))
|
| 675 |
+
logger.debug(f"Initial state: {initial_state}")
|
| 676 |
+
abort_time = seconds(config.abort_time)
|
| 677 |
+
game = model.Game(initial_state, user_profile["username"], li.baseUrl, abort_time)
|
| 678 |
+
|
| 679 |
+
with engine_wrapper.create_engine(config, game) as engine:
|
| 680 |
+
engine.get_opponent_info(game)
|
| 681 |
+
logger.debug(f"The engine for game {game_id} has pid={engine.get_pid()}")
|
| 682 |
+
conversation = Conversation(game, engine, li, __version__, challenge_queue)
|
| 683 |
+
|
| 684 |
+
logger.info(f"+++ {game}")
|
| 685 |
+
|
| 686 |
+
is_correspondence = game.speed == "correspondence"
|
| 687 |
+
correspondence_cfg = config.correspondence
|
| 688 |
+
correspondence_move_time = seconds(correspondence_cfg.move_time)
|
| 689 |
+
correspondence_disconnect_time = seconds(correspondence_cfg.disconnect_time)
|
| 690 |
+
|
| 691 |
+
engine_cfg = config.engine
|
| 692 |
+
ponder_cfg = correspondence_cfg if is_correspondence else engine_cfg
|
| 693 |
+
can_ponder = ponder_cfg.uci_ponder or ponder_cfg.ponder
|
| 694 |
+
move_overhead = msec(config.move_overhead)
|
| 695 |
+
delay = msec(config.rate_limiting_delay)
|
| 696 |
+
|
| 697 |
+
takebacks_accepted = read_takeback_record(game)
|
| 698 |
+
max_takebacks_accepted = config.max_takebacks_accepted
|
| 699 |
+
|
| 700 |
+
keyword_map: defaultdict[str, str] = defaultdict(str, me=game.me.name, opponent=game.opponent.name)
|
| 701 |
+
hello = get_greeting("hello", config.greeting, keyword_map)
|
| 702 |
+
goodbye = get_greeting("goodbye", config.greeting, keyword_map)
|
| 703 |
+
hello_spectators = get_greeting("hello_spectators", config.greeting, keyword_map)
|
| 704 |
+
goodbye_spectators = get_greeting("goodbye_spectators", config.greeting, keyword_map)
|
| 705 |
+
|
| 706 |
+
disconnect_time = correspondence_disconnect_time if not game.state.get("moves") else seconds(0)
|
| 707 |
+
prior_game = None
|
| 708 |
+
board = chess.Board()
|
| 709 |
+
game_stream = itertools.chain([json.dumps(game.state).encode("utf-8")], lines)
|
| 710 |
+
quit_after_all_games_finish = config.quit_after_all_games_finish
|
| 711 |
+
stay_in_game = True
|
| 712 |
+
while stay_in_game and (not stop.terminated or quit_after_all_games_finish) and not stop.force_quit:
|
| 713 |
+
move_attempted = False
|
| 714 |
+
try:
|
| 715 |
+
upd = next_update(game_stream)
|
| 716 |
+
u_type = upd["type"] if upd else "ping"
|
| 717 |
+
if u_type == "chatLine":
|
| 718 |
+
conversation.react(ChatLine(upd))
|
| 719 |
+
elif u_type == "gameState":
|
| 720 |
+
game.state = upd
|
| 721 |
+
board = setup_board(game)
|
| 722 |
+
takeback_field = game.state.get("btakeback") if game.is_white else game.state.get("wtakeback")
|
| 723 |
+
|
| 724 |
+
if not is_game_over(game) and is_engine_move(game, prior_game, board):
|
| 725 |
+
disconnect_time = correspondence_disconnect_time
|
| 726 |
+
say_hello(conversation, hello, hello_spectators, board)
|
| 727 |
+
setup_timer = Timer()
|
| 728 |
+
print_move_number(board)
|
| 729 |
+
move_attempted = True
|
| 730 |
+
engine.play_move(board,
|
| 731 |
+
game,
|
| 732 |
+
li,
|
| 733 |
+
setup_timer,
|
| 734 |
+
move_overhead,
|
| 735 |
+
can_ponder,
|
| 736 |
+
is_correspondence,
|
| 737 |
+
correspondence_move_time,
|
| 738 |
+
engine_cfg,
|
| 739 |
+
fake_think_time(config, board, game))
|
| 740 |
+
time.sleep(to_seconds(delay))
|
| 741 |
+
elif is_game_over(game):
|
| 742 |
+
tell_user_game_result(game, board)
|
| 743 |
+
engine.send_game_result(game, board)
|
| 744 |
+
conversation.send_message("player", goodbye)
|
| 745 |
+
conversation.send_message("spectator", goodbye_spectators)
|
| 746 |
+
elif (takeback_field
|
| 747 |
+
and not bot_to_move(game, board)
|
| 748 |
+
and li.accept_takeback(game.id, takebacks_accepted < max_takebacks_accepted)):
|
| 749 |
+
takebacks_accepted += 1
|
| 750 |
+
record_takeback(game, takebacks_accepted)
|
| 751 |
+
engine.discard_last_move_commentary()
|
| 752 |
+
|
| 753 |
+
wbtime = upd[engine_wrapper.wbtime(board)]
|
| 754 |
+
wbinc = upd[engine_wrapper.wbinc(board)]
|
| 755 |
+
terminate_time = msec(wbtime) + msec(wbinc) + seconds(60)
|
| 756 |
+
game.ping(abort_time, terminate_time, disconnect_time)
|
| 757 |
+
prior_game = copy.deepcopy(game)
|
| 758 |
+
elif u_type == "ping" and should_exit_game(board, game, prior_game, li, is_correspondence):
|
| 759 |
+
stay_in_game = False
|
| 760 |
+
except (HTTPError, ReadTimeout, RemoteDisconnected, ChunkedEncodingError, RequestsConnectionError,
|
| 761 |
+
StopIteration) as e:
|
| 762 |
+
stopped = isinstance(e, StopIteration)
|
| 763 |
+
stay_in_game = not stopped and (move_attempted or game_is_active(li, game.id))
|
| 764 |
+
|
| 765 |
+
pgn_record = try_get_pgn_game_record(li, config, game, board, engine)
|
| 766 |
+
final_queue_entries(control_queue, correspondence_queue, game, is_correspondence, pgn_record, pgn_queue)
|
| 767 |
+
delete_takeback_record(game)
|
| 768 |
+
|
| 769 |
+
|
| 770 |
+
def read_takeback_record(game: model.Game) -> int:
|
| 771 |
+
"""Read the number of move takeback requests accepted in a game."""
|
| 772 |
+
try:
|
| 773 |
+
with open(takeback_record_file_name(game.id)) as takeback_file:
|
| 774 |
+
return int(takeback_file.read())
|
| 775 |
+
except Exception:
|
| 776 |
+
return 0
|
| 777 |
+
|
| 778 |
+
|
| 779 |
+
def record_takeback(game: model.Game, accepted_count: int) -> None:
|
| 780 |
+
"""Record the number of move takeback requests accepted in a game."""
|
| 781 |
+
with open(takeback_record_file_name(game.id), "w") as takeback_file:
|
| 782 |
+
takeback_file.write(str(accepted_count))
|
| 783 |
+
|
| 784 |
+
|
| 785 |
+
def delete_takeback_record(game: model.Game) -> None:
|
| 786 |
+
"""Delete the takeback record from a game if it has finished."""
|
| 787 |
+
if is_game_over(game):
|
| 788 |
+
with contextlib.suppress(Exception):
|
| 789 |
+
os.remove(takeback_record_file_name(game.id))
|
| 790 |
+
|
| 791 |
+
|
| 792 |
+
def prune_takeback_records(all_games: list[GameType]) -> None:
|
| 793 |
+
"""Delete takeback records from games that have ended."""
|
| 794 |
+
active_game_ids = {game["gameId"] for game in all_games}
|
| 795 |
+
takeback_file_template = takeback_record_file_name("*")
|
| 796 |
+
prefix, suffix = takeback_file_template.split("*")
|
| 797 |
+
for takeback_file_name in glob.glob(takeback_file_template):
|
| 798 |
+
game_id = takeback_file_name.removeprefix(prefix).removesuffix(suffix)
|
| 799 |
+
if game_id not in active_game_ids:
|
| 800 |
+
with contextlib.suppress(Exception):
|
| 801 |
+
os.remove(takeback_file_name)
|
| 802 |
+
|
| 803 |
+
|
| 804 |
+
def takeback_record_file_name(game_id: str) -> str:
|
| 805 |
+
"""Get the file name for recording the number of move takebacks accepted."""
|
| 806 |
+
return os.path.join(auto_log_directory, f"takeback-count-{game_id}.txt")
|
| 807 |
+
|
| 808 |
+
|
| 809 |
+
def get_greeting(greeting: str, greeting_cfg: Configuration, keyword_map: defaultdict[str, str]) -> str:
|
| 810 |
+
"""Get the greeting to send to the chat."""
|
| 811 |
+
greeting_text: str = greeting_cfg.lookup(greeting)
|
| 812 |
+
return greeting_text.format_map(keyword_map)
|
| 813 |
+
|
| 814 |
+
|
| 815 |
+
def say_hello(conversation: Conversation, hello: str, hello_spectators: str, board: chess.Board) -> None:
|
| 816 |
+
"""Send the greetings to the chat rooms."""
|
| 817 |
+
if len(board.move_stack) < 2:
|
| 818 |
+
conversation.send_message("player", hello)
|
| 819 |
+
conversation.send_message("spectator", hello_spectators)
|
| 820 |
+
|
| 821 |
+
|
| 822 |
+
def fake_think_time(config: Configuration, board: chess.Board, game: model.Game) -> datetime.timedelta:
|
| 823 |
+
"""Calculate how much time we should wait for fake_think_time."""
|
| 824 |
+
sleep = seconds(0.0)
|
| 825 |
+
|
| 826 |
+
if config.fake_think_time and len(board.move_stack) > 9:
|
| 827 |
+
remaining = max(seconds(0), game.my_remaining_time() - msec(config.move_overhead))
|
| 828 |
+
delay = remaining * 0.025
|
| 829 |
+
accel = 0.99 ** (len(board.move_stack) - 10)
|
| 830 |
+
sleep = delay * accel
|
| 831 |
+
|
| 832 |
+
return sleep
|
| 833 |
+
|
| 834 |
+
|
| 835 |
+
def print_move_number(board: chess.Board) -> None:
|
| 836 |
+
"""Log the move number."""
|
| 837 |
+
logger.info("")
|
| 838 |
+
logger.info(f"move: {len(board.move_stack) // 2 + 1}")
|
| 839 |
+
|
| 840 |
+
|
| 841 |
+
def next_update(lines: Iterator[bytes]) -> GameEventType:
|
| 842 |
+
"""Get the next game state."""
|
| 843 |
+
binary_chunk = next(lines)
|
| 844 |
+
upd = cast(GameEventType, json.loads(binary_chunk.decode("utf-8"))) if binary_chunk else {}
|
| 845 |
+
if upd:
|
| 846 |
+
logger.debug(f"Game state: {upd}")
|
| 847 |
+
return upd
|
| 848 |
+
|
| 849 |
+
|
| 850 |
+
def setup_board(game: model.Game) -> chess.Board:
|
| 851 |
+
"""Set up the board."""
|
| 852 |
+
if game.variant_name.lower() == "chess960":
|
| 853 |
+
board = chess.Board(game.initial_fen, chess960=True)
|
| 854 |
+
elif game.variant_name == "From Position":
|
| 855 |
+
fen = cast(str, game.initial_fen)
|
| 856 |
+
board = chess.Board(fen, chess960=model.is_chess_960(fen))
|
| 857 |
+
|
| 858 |
+
else:
|
| 859 |
+
VariantBoard = find_variant(game.variant_name)
|
| 860 |
+
board = VariantBoard()
|
| 861 |
+
|
| 862 |
+
for move in game.state["moves"].split():
|
| 863 |
+
try:
|
| 864 |
+
board.push_uci(move)
|
| 865 |
+
except ValueError:
|
| 866 |
+
logger.exception(f"Ignoring illegal move {move} on board {board.fen()}")
|
| 867 |
+
|
| 868 |
+
return board
|
| 869 |
+
|
| 870 |
+
|
| 871 |
+
def is_engine_move(game: model.Game, prior_game: model.Game | None, board: chess.Board) -> bool:
|
| 872 |
+
"""Check whether it is the engine's turn."""
|
| 873 |
+
return game_changed(game, prior_game) and bot_to_move(game, board)
|
| 874 |
+
|
| 875 |
+
|
| 876 |
+
def bot_to_move(game: model.Game, board: chess.Board) -> bool:
|
| 877 |
+
"""
|
| 878 |
+
Determine whether it is the bot's move on the given board.
|
| 879 |
+
|
| 880 |
+
This only determines if the board state shows the bot is on move. It does not check if the board state has changed.
|
| 881 |
+
Messages from lichess can contain repeat board states if another game aspect has changed (draw offer, takeback offer,
|
| 882 |
+
etc.). Use is_engine_move() to determine if the engine should play a move.
|
| 883 |
+
"""
|
| 884 |
+
return game.is_white == (board.turn == chess.WHITE)
|
| 885 |
+
|
| 886 |
+
|
| 887 |
+
def is_game_over(game: model.Game) -> bool:
|
| 888 |
+
"""Check whether the game is over."""
|
| 889 |
+
status = game.state["status"]
|
| 890 |
+
return status != "started"
|
| 891 |
+
|
| 892 |
+
|
| 893 |
+
def should_exit_game(board: chess.Board, game: model.Game, prior_game: model.Game | None, li: lichess.Lichess,
|
| 894 |
+
is_correspondence: bool) -> bool:
|
| 895 |
+
"""Whether we should exit a game."""
|
| 896 |
+
if (is_correspondence
|
| 897 |
+
and not is_engine_move(game, prior_game, board)
|
| 898 |
+
and game.should_disconnect_now()):
|
| 899 |
+
return True
|
| 900 |
+
if game.should_abort_now():
|
| 901 |
+
logger.info(f"Aborting {game.url()} by lack of activity")
|
| 902 |
+
li.abort(game.id)
|
| 903 |
+
return True
|
| 904 |
+
if game.should_terminate_now():
|
| 905 |
+
logger.info(f"Terminating {game.url()} by lack of activity")
|
| 906 |
+
if game.is_abortable():
|
| 907 |
+
li.abort(game.id)
|
| 908 |
+
return True
|
| 909 |
+
return False
|
| 910 |
+
|
| 911 |
+
|
| 912 |
+
def final_queue_entries(control_queue: CONTROL_QUEUE_TYPE, correspondence_queue: CORRESPONDENCE_QUEUE_TYPE,
|
| 913 |
+
game: model.Game, is_correspondence: bool, pgn_record: str, pgn_queue: PGN_QUEUE_TYPE) -> None:
|
| 914 |
+
"""
|
| 915 |
+
Log the game that ended or we disconnected from, and sends a `local_game_done` for the game.
|
| 916 |
+
|
| 917 |
+
If this is an unfinished correspondence game, put it in a queue to resume later.
|
| 918 |
+
"""
|
| 919 |
+
if is_correspondence and not is_game_over(game):
|
| 920 |
+
logger.info(f"--- Disconnecting from {game.url()}")
|
| 921 |
+
correspondence_queue.put_nowait(game.id)
|
| 922 |
+
else:
|
| 923 |
+
logger.info(f"--- {game.url()} Game over")
|
| 924 |
+
|
| 925 |
+
control_queue.put_nowait({"type": "local_game_done", "game": {"id": game.id}})
|
| 926 |
+
pgn_queue.put_nowait({"game": {"id": game.id,
|
| 927 |
+
"pgn": pgn_record,
|
| 928 |
+
"complete": is_game_over(game)}})
|
| 929 |
+
|
| 930 |
+
|
| 931 |
+
def game_changed(current_game: model.Game, prior_game: model.Game | None) -> bool:
|
| 932 |
+
"""Check whether the current game state is different from the previous game state."""
|
| 933 |
+
if prior_game is None:
|
| 934 |
+
return True
|
| 935 |
+
|
| 936 |
+
current_game_moves_str = current_game.state["moves"]
|
| 937 |
+
prior_game_moves_str = prior_game.state["moves"]
|
| 938 |
+
return current_game_moves_str != prior_game_moves_str
|
| 939 |
+
|
| 940 |
+
|
| 941 |
+
def tell_user_game_result(game: model.Game, board: chess.Board) -> None:
|
| 942 |
+
"""Log the game result."""
|
| 943 |
+
winner = game.state.get("winner")
|
| 944 |
+
termination = game.state.get("status")
|
| 945 |
+
|
| 946 |
+
winning_name = game.white.name if winner == "white" else game.black.name
|
| 947 |
+
losing_name = game.white.name if winner == "black" else game.black.name
|
| 948 |
+
|
| 949 |
+
if winner is not None:
|
| 950 |
+
logger.info(f"{winning_name} won!")
|
| 951 |
+
elif termination in [model.Termination.DRAW, model.Termination.TIMEOUT]:
|
| 952 |
+
logger.info("Game ended in a draw.")
|
| 953 |
+
else:
|
| 954 |
+
logger.info("Game adjourned.")
|
| 955 |
+
|
| 956 |
+
simple_endings: dict[str, str] = {model.Termination.MATE: "Game won by checkmate.",
|
| 957 |
+
model.Termination.RESIGN: f"{losing_name} resigned.",
|
| 958 |
+
model.Termination.ABORT: "Game aborted."}
|
| 959 |
+
|
| 960 |
+
if termination in simple_endings:
|
| 961 |
+
logger.info(simple_endings[termination])
|
| 962 |
+
elif termination == model.Termination.DRAW:
|
| 963 |
+
draw_results = [(board.is_fifty_moves(), "Game drawn by 50-move rule."),
|
| 964 |
+
(board.is_repetition(), "Game drawn by threefold repetition."),
|
| 965 |
+
(board.is_insufficient_material(), "Game drawn from insufficient material."),
|
| 966 |
+
(board.is_stalemate(), "Game drawn by stalemate."),
|
| 967 |
+
(True, "Game drawn by agreement.")]
|
| 968 |
+
messages = [draw_message for is_result, draw_message in draw_results if is_result]
|
| 969 |
+
logger.info(messages[0])
|
| 970 |
+
elif termination == model.Termination.TIMEOUT:
|
| 971 |
+
if winner:
|
| 972 |
+
logger.info(f"{losing_name} forfeited on time.")
|
| 973 |
+
else:
|
| 974 |
+
timeout_name = game.white.name if game.state.get("wtime") == 0 else game.black.name
|
| 975 |
+
other_name = game.white.name if timeout_name == game.black.name else game.black.name
|
| 976 |
+
logger.info(f"{timeout_name} ran out of time, but {other_name} did not have enough material to mate.")
|
| 977 |
+
elif termination:
|
| 978 |
+
logger.info(f"Game ended by {termination}")
|
| 979 |
+
|
| 980 |
+
|
| 981 |
+
def try_get_pgn_game_record(li: lichess.Lichess, config: Configuration, game: model.Game, board: chess.Board,
|
| 982 |
+
engine: engine_wrapper.EngineWrapper) -> str:
|
| 983 |
+
"""
|
| 984 |
+
Call `print_pgn_game_record` to write the game to a PGN file and handle errors raised by it.
|
| 985 |
+
|
| 986 |
+
:param li: Provides communication with lichess.org.
|
| 987 |
+
:param config: The config that the bot will use.
|
| 988 |
+
:param game: Contains information about the game (e.g. the players' names).
|
| 989 |
+
:param board: The board. Contains the moves.
|
| 990 |
+
:param engine: The engine. Contains information about the moves (e.g. eval, PV, depth).
|
| 991 |
+
"""
|
| 992 |
+
try:
|
| 993 |
+
return pgn_game_record(li, config, game, board, engine)
|
| 994 |
+
except Exception:
|
| 995 |
+
logger.exception("Error writing game record:")
|
| 996 |
+
return ""
|
| 997 |
+
|
| 998 |
+
|
| 999 |
+
def pgn_game_record(li: lichess.Lichess, config: Configuration, game: model.Game, board: chess.Board,
|
| 1000 |
+
engine: engine_wrapper.EngineWrapper) -> str:
|
| 1001 |
+
"""
|
| 1002 |
+
Return the text of the game's PGN.
|
| 1003 |
+
|
| 1004 |
+
:param li: Provides communication with lichess.org.
|
| 1005 |
+
:param config: The config that the bot will use.
|
| 1006 |
+
:param game: Contains information about the game (e.g. the players' names).
|
| 1007 |
+
:param board: The board. Contains the moves.
|
| 1008 |
+
:param engine: The engine. Contains information about the moves (e.g. eval, PV, depth).
|
| 1009 |
+
"""
|
| 1010 |
+
if not config.pgn_directory:
|
| 1011 |
+
return ""
|
| 1012 |
+
|
| 1013 |
+
lichess_game_record = chess.pgn.read_game(io.StringIO(li.get_game_pgn(game.id))) or chess.pgn.Game()
|
| 1014 |
+
try:
|
| 1015 |
+
# Recall previously written PGN file to retain engine evaluations.
|
| 1016 |
+
previous_game_path = get_game_file_path(config,
|
| 1017 |
+
game.id,
|
| 1018 |
+
game.white.name,
|
| 1019 |
+
game.black.name,
|
| 1020 |
+
game.me.name,
|
| 1021 |
+
is_game_over(game),
|
| 1022 |
+
force_single=True)
|
| 1023 |
+
with open(previous_game_path) as game_data:
|
| 1024 |
+
game_record = chess.pgn.read_game(game_data) or lichess_game_record
|
| 1025 |
+
game_record.headers.update(lichess_game_record.headers)
|
| 1026 |
+
except FileNotFoundError:
|
| 1027 |
+
game_record = lichess_game_record
|
| 1028 |
+
|
| 1029 |
+
fill_missing_pgn_headers(game_record, game)
|
| 1030 |
+
|
| 1031 |
+
current_node: chess.pgn.Game | chess.pgn.ChildNode = game_record.game()
|
| 1032 |
+
lichess_node: chess.pgn.Game | chess.pgn.ChildNode = lichess_game_record.game()
|
| 1033 |
+
for index, move in enumerate(board.move_stack):
|
| 1034 |
+
next_node = current_node.next()
|
| 1035 |
+
if next_node is None or next_node.move != move:
|
| 1036 |
+
current_node = current_node.add_main_variation(move)
|
| 1037 |
+
else:
|
| 1038 |
+
current_node = next_node
|
| 1039 |
+
|
| 1040 |
+
next_lichess_node = lichess_node.next()
|
| 1041 |
+
if next_lichess_node:
|
| 1042 |
+
lichess_node = next_lichess_node
|
| 1043 |
+
current_node.set_clock(lichess_node.clock())
|
| 1044 |
+
if current_node.comment != lichess_node.comment:
|
| 1045 |
+
current_node.comment = f"{current_node.comment} {lichess_node.comment}".strip()
|
| 1046 |
+
|
| 1047 |
+
commentary = engine.comment_for_board_index(index)
|
| 1048 |
+
pv_node = current_node.parent.add_line(commentary["pv"]) if "pv" in commentary else current_node
|
| 1049 |
+
pv_node.set_eval(commentary.get("score"), commentary.get("depth"))
|
| 1050 |
+
|
| 1051 |
+
pgn_writer = chess.pgn.StringExporter()
|
| 1052 |
+
return game_record.accept(pgn_writer)
|
| 1053 |
+
|
| 1054 |
+
|
| 1055 |
+
def get_game_file_path(config: Configuration,
|
| 1056 |
+
game_id: str,
|
| 1057 |
+
white_name: str,
|
| 1058 |
+
black_name: str,
|
| 1059 |
+
user_name: str,
|
| 1060 |
+
game_is_over: bool,
|
| 1061 |
+
*, force_single: bool = False) -> str:
|
| 1062 |
+
"""Return the path of the file where the game record will be written."""
|
| 1063 |
+
def create_valid_path(s: str) -> str:
|
| 1064 |
+
illegal = '<>:"/\\|?*'
|
| 1065 |
+
return os.path.join(config.pgn_directory, "".join(c for c in s if c not in illegal))
|
| 1066 |
+
|
| 1067 |
+
if config.pgn_file_grouping == "game" or not game_is_over or force_single:
|
| 1068 |
+
return create_valid_path(f"{white_name} vs {black_name} - {game_id}.pgn")
|
| 1069 |
+
elif config.pgn_file_grouping == "opponent":
|
| 1070 |
+
opponent_name = white_name if user_name == black_name else black_name
|
| 1071 |
+
return create_valid_path(f"{user_name} games vs. {opponent_name}.pgn")
|
| 1072 |
+
else: # config.pgn_file_grouping == "all"
|
| 1073 |
+
return create_valid_path(f"{user_name} games.pgn")
|
| 1074 |
+
|
| 1075 |
+
|
| 1076 |
+
def fill_missing_pgn_headers(game_record: chess.pgn.Game, game: model.Game) -> None:
|
| 1077 |
+
"""
|
| 1078 |
+
Fill in any missing headers in the PGN record provided by lichess.org with information from `game`.
|
| 1079 |
+
|
| 1080 |
+
:param game_record: A `chess.pgn.Game` object containing information about the game lichess.org's PGN file.
|
| 1081 |
+
:param game: Contains information about the game (e.g. the players' names), which is used to get the local headers.
|
| 1082 |
+
"""
|
| 1083 |
+
local_headers = get_headers(game)
|
| 1084 |
+
for header, game_value in local_headers.items():
|
| 1085 |
+
record_value = game_record.headers.get(header)
|
| 1086 |
+
if not record_value or record_value.startswith("?") or (header == "Result" and record_value == "*"):
|
| 1087 |
+
game_record.headers[header] = str(game_value)
|
| 1088 |
+
|
| 1089 |
+
|
| 1090 |
+
def get_headers(game: model.Game) -> dict[str, str | int]:
|
| 1091 |
+
"""
|
| 1092 |
+
Create local headers to be written in the PGN file.
|
| 1093 |
+
|
| 1094 |
+
:param game: Contains information about the game (e.g. the players' names).
|
| 1095 |
+
:return: The headers in a dict.
|
| 1096 |
+
"""
|
| 1097 |
+
headers: dict[str, str | int] = {}
|
| 1098 |
+
headers["Event"] = game.pgn_event()
|
| 1099 |
+
headers["Site"] = game.short_url()
|
| 1100 |
+
headers["Date"] = game.game_start.strftime("%Y.%m.%d")
|
| 1101 |
+
headers["White"] = game.white.name or str(game.white)
|
| 1102 |
+
headers["Black"] = game.black.name or str(game.black)
|
| 1103 |
+
headers["Result"] = game.result()
|
| 1104 |
+
|
| 1105 |
+
if game.black.rating:
|
| 1106 |
+
headers["BlackElo"] = game.black.rating
|
| 1107 |
+
if game.black.title:
|
| 1108 |
+
headers["BlackTitle"] = game.black.title
|
| 1109 |
+
|
| 1110 |
+
if game.perf_name != "correspondence":
|
| 1111 |
+
headers["TimeControl"] = game.time_control()
|
| 1112 |
+
|
| 1113 |
+
headers["UTCDate"] = headers["Date"]
|
| 1114 |
+
headers["UTCTime"] = game.game_start.strftime("%H:%M:%S")
|
| 1115 |
+
headers["Variant"] = game.variant_name
|
| 1116 |
+
|
| 1117 |
+
if game.initial_fen and game.initial_fen != "startpos":
|
| 1118 |
+
headers["Setup"] = 1
|
| 1119 |
+
headers["FEN"] = game.initial_fen
|
| 1120 |
+
|
| 1121 |
+
if game.white.rating:
|
| 1122 |
+
headers["WhiteElo"] = game.white.rating
|
| 1123 |
+
if game.white.title:
|
| 1124 |
+
headers["WhiteTitle"] = game.white.title
|
| 1125 |
+
|
| 1126 |
+
return headers
|
| 1127 |
+
|
| 1128 |
+
|
| 1129 |
+
def save_pgn_record(event: EventType, config: Configuration, user_name: str) -> None:
|
| 1130 |
+
"""
|
| 1131 |
+
Write the game PGN record to a file.
|
| 1132 |
+
|
| 1133 |
+
:param event: A local_game_done event from the control queue.
|
| 1134 |
+
:param config: The user's bot configuration.
|
| 1135 |
+
:param user_name: The bot's name.
|
| 1136 |
+
"""
|
| 1137 |
+
pgn = event["game"]["pgn"]
|
| 1138 |
+
pgn_headers = chess.pgn.read_headers(io.StringIO(pgn))
|
| 1139 |
+
if not config.pgn_directory or pgn_headers is None:
|
| 1140 |
+
return
|
| 1141 |
+
|
| 1142 |
+
game_id = event["game"]["id"]
|
| 1143 |
+
white_name = pgn_headers["White"]
|
| 1144 |
+
black_name = pgn_headers["Black"]
|
| 1145 |
+
game_is_over = event["game"]["complete"]
|
| 1146 |
+
|
| 1147 |
+
os.makedirs(config.pgn_directory, exist_ok=True)
|
| 1148 |
+
game_path = get_game_file_path(config, game_id, white_name, black_name, user_name, game_is_over)
|
| 1149 |
+
single_game_path = get_game_file_path(config, game_id, white_name, black_name, user_name, game_is_over, force_single=True)
|
| 1150 |
+
write_mode = "w" if game_path == single_game_path else "a"
|
| 1151 |
+
logger.debug(f"Writing PGN game record to: {game_path}")
|
| 1152 |
+
with open(game_path, write_mode) as game_file:
|
| 1153 |
+
game_file.write(pgn + "\n\n")
|
| 1154 |
+
|
| 1155 |
+
if os.path.exists(single_game_path) and game_path != single_game_path:
|
| 1156 |
+
os.remove(single_game_path)
|
| 1157 |
+
|
| 1158 |
+
|
| 1159 |
+
def intro() -> str:
|
| 1160 |
+
"""Return the intro string."""
|
| 1161 |
+
return fr"""
|
| 1162 |
+
. _/|
|
| 1163 |
+
. // o\
|
| 1164 |
+
. || ._) lichess-bot {__version__} on {platform.system()} {platform.release()}
|
| 1165 |
+
. //__\
|
| 1166 |
+
. )___( Play on Lichess with a bot
|
| 1167 |
+
"""
|
| 1168 |
+
|
| 1169 |
+
|
| 1170 |
+
auto_log_directory = "lichess_bot_auto_logs"
|
| 1171 |
+
|
| 1172 |
+
|
| 1173 |
+
def log_python_and_libraries() -> None:
|
| 1174 |
+
"""Log the installed libraries and the python version."""
|
| 1175 |
+
logger.debug(f"Python version: {'.'.join(map(str, sys.version_info))}")
|
| 1176 |
+
text = "Installed libraries:\n"
|
| 1177 |
+
distributions = importlib.metadata.distributions()
|
| 1178 |
+
for distribution in distributions:
|
| 1179 |
+
text += f"{distribution.metadata['Name']}=={distribution.version}\n"
|
| 1180 |
+
text += "\n"
|
| 1181 |
+
logger.debug(text)
|
| 1182 |
+
|
| 1183 |
+
|
| 1184 |
+
def start_lichess_bot() -> None:
|
| 1185 |
+
"""Parse arguments passed to lichess-bot.py and starts lichess-bot."""
|
| 1186 |
+
parser = argparse.ArgumentParser(description="Play on Lichess with a bot")
|
| 1187 |
+
parser.add_argument("-u", action="store_true", help="Upgrade your account to a bot account.")
|
| 1188 |
+
parser.add_argument("-v", action="store_true", help="Make output more verbose. Include all communication with lichess.")
|
| 1189 |
+
parser.add_argument("--config", help="Specify a configuration file (defaults to ./config.yml).")
|
| 1190 |
+
parser.add_argument("-l", "--logfile", help="Record all console output to a log file.", default=None)
|
| 1191 |
+
parser.add_argument("--disable_auto_logging", action="store_true", help="Disable automatic logging.")
|
| 1192 |
+
args = parser.parse_args()
|
| 1193 |
+
|
| 1194 |
+
logging_level = logging.DEBUG if args.v else logging.INFO
|
| 1195 |
+
logging_configurer(logging_level, args.logfile, args.disable_auto_logging)
|
| 1196 |
+
logger.info(intro(), extra={"highlighter": None})
|
| 1197 |
+
|
| 1198 |
+
CONFIG = load_config(args.config or "./config.yml")
|
| 1199 |
+
if not args.disable_auto_logging:
|
| 1200 |
+
with open(os.path.join(auto_log_directory, "config.log"), "w") as config_log:
|
| 1201 |
+
log_config(CONFIG.config, config_log.write)
|
| 1202 |
+
logger.info("Checking engine configuration ...")
|
| 1203 |
+
with engine_wrapper.create_engine(CONFIG):
|
| 1204 |
+
pass
|
| 1205 |
+
logger.info("Engine configuration OK")
|
| 1206 |
+
|
| 1207 |
+
max_retries = CONFIG.engine.online_moves.max_retries
|
| 1208 |
+
check_python_version()
|
| 1209 |
+
log_python_and_libraries()
|
| 1210 |
+
li = lichess.Lichess(CONFIG.token, CONFIG.url, __version__, logging_level, max_retries)
|
| 1211 |
+
|
| 1212 |
+
user_profile = li.get_profile()
|
| 1213 |
+
username = user_profile["username"]
|
| 1214 |
+
is_bot = user_profile.get("title") == "BOT"
|
| 1215 |
+
logger.info(f"Welcome {username}!")
|
| 1216 |
+
|
| 1217 |
+
if args.u and not is_bot:
|
| 1218 |
+
is_bot = upgrade_account(li)
|
| 1219 |
+
|
| 1220 |
+
if is_bot:
|
| 1221 |
+
start(li, user_profile, CONFIG, logging_level, args.logfile, args.disable_auto_logging)
|
| 1222 |
+
else:
|
| 1223 |
+
logger.error(f"{username} is not a bot account. Please upgrade it to a bot account!")
|
| 1224 |
+
logging.shutdown()
|
| 1225 |
+
|
| 1226 |
+
|
| 1227 |
+
def check_python_version() -> None:
|
| 1228 |
+
"""Raise a warning or an exception if the version isn't supported or is deprecated."""
|
| 1229 |
+
def version_numeric(version_str: str) -> list[int]:
|
| 1230 |
+
return [int(n) for n in version_str.split(".")]
|
| 1231 |
+
|
| 1232 |
+
python_deprecated_version = version_numeric(versioning_info["deprecated_python_version"])
|
| 1233 |
+
python_good_version = version_numeric(versioning_info["minimum_python_version"])
|
| 1234 |
+
version_change_date = versioning_info["deprecation_date"]
|
| 1235 |
+
this_python_version = list(sys.version_info[0:2])
|
| 1236 |
+
|
| 1237 |
+
def version_str(version: list[int]) -> str:
|
| 1238 |
+
return f"Python {'.'.join(str(n) for n in version)}"
|
| 1239 |
+
|
| 1240 |
+
upgrade_request = (f"You are currently running {version_str(this_python_version)}. "
|
| 1241 |
+
f"Please upgrade to {version_str(python_good_version)} or newer")
|
| 1242 |
+
out_of_date_error = RuntimeError("A newer version of Python is required "
|
| 1243 |
+
f"to run this version of lichess-bot. {upgrade_request}.")
|
| 1244 |
+
out_of_date_warning = ("A newer version of Python will be required "
|
| 1245 |
+
f"on {version_change_date} to run lichess-bot. {upgrade_request} before then.")
|
| 1246 |
+
|
| 1247 |
+
this_lichess_bot_version = version_numeric(__version__)
|
| 1248 |
+
lichess_bot_breaking_version = list(version_change_date.timetuple()[0:3])
|
| 1249 |
+
|
| 1250 |
+
if this_python_version < python_deprecated_version:
|
| 1251 |
+
raise out_of_date_error
|
| 1252 |
+
|
| 1253 |
+
if this_python_version == python_deprecated_version:
|
| 1254 |
+
if this_lichess_bot_version < lichess_bot_breaking_version:
|
| 1255 |
+
logger.warning(out_of_date_warning)
|
| 1256 |
+
else:
|
| 1257 |
+
raise out_of_date_error
|
| 1258 |
+
|
| 1259 |
+
|
| 1260 |
+
def start_program() -> None:
|
| 1261 |
+
"""Start lichess-bot and restart when needed."""
|
| 1262 |
+
multiprocessing.set_start_method("spawn")
|
| 1263 |
+
try:
|
| 1264 |
+
while should_restart():
|
| 1265 |
+
disable_restart()
|
| 1266 |
+
# Restart on network errors.
|
| 1267 |
+
try:
|
| 1268 |
+
start_lichess_bot()
|
| 1269 |
+
except RequestException:
|
| 1270 |
+
stop.restart = True
|
| 1271 |
+
logger.exception("Restarting lichess-bot due to a network error:")
|
| 1272 |
+
if stop.terminated or stop.force_quit:
|
| 1273 |
+
logger.info("Termination requested - stopping restart cycle")
|
| 1274 |
+
break
|
| 1275 |
+
time.sleep(10 if should_restart() else 0)
|
| 1276 |
+
except Exception:
|
| 1277 |
+
logger.exception("Quitting lichess-bot due to an error:")
|
lichess-bot-master/lib/lichess_types.py
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Some type hints that can be accessed by all other python files."""
|
| 2 |
+
from typing import Any, TypedDict, Literal, TypeAlias
|
| 3 |
+
from collections.abc import Callable
|
| 4 |
+
from chess.engine import PovWdl, PovScore, PlayResult, Limit, Opponent
|
| 5 |
+
from chess import Move, Board
|
| 6 |
+
from queue import Queue
|
| 7 |
+
import logging
|
| 8 |
+
import datetime
|
| 9 |
+
from enum import Enum
|
| 10 |
+
from types import TracebackType
|
| 11 |
+
|
| 12 |
+
COMMANDS_TYPE: TypeAlias = list[str]
|
| 13 |
+
MOVE: TypeAlias = PlayResult | list[Move]
|
| 14 |
+
CORRESPONDENCE_QUEUE_TYPE: TypeAlias = Queue[str]
|
| 15 |
+
LOGGING_QUEUE_TYPE: TypeAlias = Queue[logging.LogRecord]
|
| 16 |
+
REQUESTS_PAYLOAD_TYPE: TypeAlias = dict[str, str | int | bool]
|
| 17 |
+
GO_COMMANDS_TYPE: TypeAlias = dict[str, str]
|
| 18 |
+
EGTPATH_TYPE: TypeAlias = dict[str, str]
|
| 19 |
+
OPTIONS_GO_EGTB_TYPE: TypeAlias = dict[str, str | int | bool | None | EGTPATH_TYPE | GO_COMMANDS_TYPE]
|
| 20 |
+
OPTIONS_TYPE: TypeAlias = dict[str, str | int | bool | None]
|
| 21 |
+
HOMEMADE_ARGS_TYPE: TypeAlias = Limit | bool | MOVE
|
| 22 |
+
|
| 23 |
+
# Types that still use `Any`.
|
| 24 |
+
CONFIG_DICT_TYPE: TypeAlias = dict[str, Any]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class PerfType(TypedDict, total=False):
|
| 28 |
+
"""Type hint for `perf`."""
|
| 29 |
+
|
| 30 |
+
games: int
|
| 31 |
+
rating: int
|
| 32 |
+
rd: int
|
| 33 |
+
sd: int
|
| 34 |
+
prov: bool
|
| 35 |
+
prog: int
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class ProfileType(TypedDict, total=False):
|
| 39 |
+
"""Type hint for `profile`."""
|
| 40 |
+
|
| 41 |
+
country: str
|
| 42 |
+
location: str
|
| 43 |
+
bio: str
|
| 44 |
+
firstName: str
|
| 45 |
+
lastName: str
|
| 46 |
+
fideRating: int
|
| 47 |
+
uscfRating: int
|
| 48 |
+
ecfRating: int
|
| 49 |
+
cfcRating: int
|
| 50 |
+
dsbRating: int
|
| 51 |
+
links: str
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class UserProfileType(TypedDict, total=False):
|
| 55 |
+
"""Type hint for `user_profile`."""
|
| 56 |
+
|
| 57 |
+
id: str
|
| 58 |
+
username: str
|
| 59 |
+
perfs: dict[str, PerfType]
|
| 60 |
+
createdAt: int
|
| 61 |
+
profile: ProfileType
|
| 62 |
+
seenAt: int
|
| 63 |
+
patron: int
|
| 64 |
+
verified: int
|
| 65 |
+
playTime: dict[str, int]
|
| 66 |
+
title: str
|
| 67 |
+
online: bool
|
| 68 |
+
url: str
|
| 69 |
+
followable: bool
|
| 70 |
+
following: bool
|
| 71 |
+
blocking: bool
|
| 72 |
+
followsYou: bool
|
| 73 |
+
count: dict[str, int]
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class ReadableType(TypedDict):
|
| 77 |
+
"""Type hint for `readable`."""
|
| 78 |
+
|
| 79 |
+
Evaluation: Callable[[PovScore], str]
|
| 80 |
+
Winrate: Callable[[PovWdl], str]
|
| 81 |
+
Hashfull: Callable[[int], str]
|
| 82 |
+
Nodes: Callable[[int], str]
|
| 83 |
+
Speed: Callable[[int], str]
|
| 84 |
+
Tbhits: Callable[[int], str]
|
| 85 |
+
Cpuload: Callable[[int], str]
|
| 86 |
+
Movetime: Callable[[int], str]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class InfoStrDict(TypedDict, total=False):
|
| 90 |
+
"""Type hints for the readable version of the information returned by chess engines."""
|
| 91 |
+
|
| 92 |
+
score: PovScore
|
| 93 |
+
pv: list[Move]
|
| 94 |
+
depth: int
|
| 95 |
+
seldepth: int
|
| 96 |
+
time: float
|
| 97 |
+
nodes: int
|
| 98 |
+
nps: int
|
| 99 |
+
tbhits: int
|
| 100 |
+
multipv: int
|
| 101 |
+
currmove: str | Move
|
| 102 |
+
currmovenumber: int
|
| 103 |
+
hashfull: int
|
| 104 |
+
cpuload: int
|
| 105 |
+
refutation: str | dict[Move, list[Move]]
|
| 106 |
+
currline: dict[int, list[Move]]
|
| 107 |
+
ebf: float
|
| 108 |
+
wdl: PovWdl
|
| 109 |
+
string: str
|
| 110 |
+
ponderpv: str
|
| 111 |
+
Source: str
|
| 112 |
+
Pv: str
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
InfoDictKeys: TypeAlias = Literal["score", "pv", "depth", "seldepth", "time", "nodes", "nps", "tbhits", "multipv", "currmove",
|
| 116 |
+
"currmovenumber", "hashfull", "cpuload", "refutation", "currline", "ebf", "wdl", "string",
|
| 117 |
+
"ponderpv", "Source", "Pv"]
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
InfoDictValue: TypeAlias = (PovScore | list[Move] | int | float | str | Move | dict[Move, list[Move]] |
|
| 121 |
+
dict[int, list[Move]] | PovWdl)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class PlayerType(TypedDict, total=False):
|
| 125 |
+
"""Type hint for information on a player."""
|
| 126 |
+
|
| 127 |
+
title: str | None
|
| 128 |
+
rating: int
|
| 129 |
+
provisional: bool
|
| 130 |
+
aiLevel: int
|
| 131 |
+
id: str
|
| 132 |
+
username: str
|
| 133 |
+
name: str
|
| 134 |
+
online: bool
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class GameType(TypedDict, total=False):
|
| 138 |
+
"""Type hint for game."""
|
| 139 |
+
|
| 140 |
+
gameId: str
|
| 141 |
+
fullId: str
|
| 142 |
+
color: str
|
| 143 |
+
fen: str
|
| 144 |
+
hasMoved: bool
|
| 145 |
+
isMyTurn: bool
|
| 146 |
+
lastMove: str
|
| 147 |
+
opponent: PlayerType
|
| 148 |
+
perf: str
|
| 149 |
+
rated: bool
|
| 150 |
+
secondsLeft: int
|
| 151 |
+
source: str
|
| 152 |
+
status: dict[str, str | int]
|
| 153 |
+
speed: str
|
| 154 |
+
variant: dict[str, str]
|
| 155 |
+
compat: dict[str, bool]
|
| 156 |
+
id: str
|
| 157 |
+
winner: str
|
| 158 |
+
ratingDiff: int
|
| 159 |
+
pgn: str
|
| 160 |
+
complete: bool
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
class TimeControlType(TypedDict, total=False):
|
| 164 |
+
"""Type hint for time control."""
|
| 165 |
+
|
| 166 |
+
increment: int
|
| 167 |
+
limit: int
|
| 168 |
+
show: str
|
| 169 |
+
type: str
|
| 170 |
+
daysPerTurn: int
|
| 171 |
+
initial: int
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
class ChallengeType(TypedDict, total=False):
|
| 175 |
+
"""Type hint for challenge."""
|
| 176 |
+
|
| 177 |
+
id: str
|
| 178 |
+
url: str
|
| 179 |
+
color: str
|
| 180 |
+
direction: str
|
| 181 |
+
rated: bool
|
| 182 |
+
speed: str
|
| 183 |
+
status: str
|
| 184 |
+
timeControl: TimeControlType
|
| 185 |
+
variant: dict[str, str]
|
| 186 |
+
challenger: PlayerType
|
| 187 |
+
destUser: PlayerType
|
| 188 |
+
perf: dict[str, str]
|
| 189 |
+
compat: dict[str, bool]
|
| 190 |
+
finalColor: str
|
| 191 |
+
declineReason: str
|
| 192 |
+
declineReasonKey: str
|
| 193 |
+
initialFen: str
|
| 194 |
+
error: str
|
| 195 |
+
ratelimit: dict[str, str | int]
|
| 196 |
+
bot_is_rate_limited: bool
|
| 197 |
+
opponent_is_rate_limited: bool
|
| 198 |
+
rate_limit_timeout: datetime.timedelta
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
class EventType(TypedDict, total=False):
|
| 202 |
+
"""Type hint for event."""
|
| 203 |
+
|
| 204 |
+
type: str
|
| 205 |
+
game: GameType
|
| 206 |
+
challenge: ChallengeType
|
| 207 |
+
error: str | None
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
class GameStateType(TypedDict, total=False):
|
| 211 |
+
"""Type hint for game state."""
|
| 212 |
+
|
| 213 |
+
type: str
|
| 214 |
+
moves: str
|
| 215 |
+
wtime: int
|
| 216 |
+
btime: int
|
| 217 |
+
winc: int
|
| 218 |
+
binc: int
|
| 219 |
+
wdraw: bool
|
| 220 |
+
bdraw: bool
|
| 221 |
+
status: str
|
| 222 |
+
winner: str
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
class GameEventType(TypedDict, total=False):
|
| 226 |
+
"""Type hint for game event."""
|
| 227 |
+
|
| 228 |
+
type: str
|
| 229 |
+
id: str
|
| 230 |
+
rated: bool
|
| 231 |
+
variant: dict[str, str]
|
| 232 |
+
cloak: dict[str, int]
|
| 233 |
+
speed: str
|
| 234 |
+
perf: dict[str, str]
|
| 235 |
+
createdAt: int
|
| 236 |
+
white: PlayerType
|
| 237 |
+
black: PlayerType
|
| 238 |
+
initialFen: str
|
| 239 |
+
state: GameStateType
|
| 240 |
+
username: str
|
| 241 |
+
text: str
|
| 242 |
+
room: str
|
| 243 |
+
gone: bool
|
| 244 |
+
claimWinInSeconds: int
|
| 245 |
+
moves: str
|
| 246 |
+
wtime: int
|
| 247 |
+
btime: int
|
| 248 |
+
winc: int
|
| 249 |
+
binc: int
|
| 250 |
+
wdraw: bool
|
| 251 |
+
bdraw: bool
|
| 252 |
+
status: str
|
| 253 |
+
winner: str
|
| 254 |
+
clock: TimeControlType
|
| 255 |
+
wtakeback: bool
|
| 256 |
+
btakeback: bool
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
CONTROL_QUEUE_TYPE: TypeAlias = Queue[EventType]
|
| 260 |
+
PGN_QUEUE_TYPE: TypeAlias = Queue[EventType | None]
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
class PublicDataType(TypedDict, total=False):
|
| 264 |
+
"""Type hint for public data."""
|
| 265 |
+
|
| 266 |
+
id: str
|
| 267 |
+
username: str
|
| 268 |
+
perfs: dict[str, PerfType]
|
| 269 |
+
flair: str
|
| 270 |
+
createdAt: int
|
| 271 |
+
disabled: bool
|
| 272 |
+
tosViolation: bool
|
| 273 |
+
profile: ProfileType
|
| 274 |
+
seenAt: int
|
| 275 |
+
patron: bool
|
| 276 |
+
verified: bool
|
| 277 |
+
playTime: dict[str, int]
|
| 278 |
+
title: str
|
| 279 |
+
url: str
|
| 280 |
+
playing: str
|
| 281 |
+
count: dict[str, int]
|
| 282 |
+
streaming: bool
|
| 283 |
+
streamer: dict[str, dict[str, str]]
|
| 284 |
+
followable: bool
|
| 285 |
+
following: bool
|
| 286 |
+
blocking: bool
|
| 287 |
+
followsYou: bool
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
class FilterType(str, Enum):
|
| 291 |
+
"""What to do if the opponent declines our challenge."""
|
| 292 |
+
|
| 293 |
+
NONE = "none"
|
| 294 |
+
"""Will still challenge the opponent."""
|
| 295 |
+
COARSE = "coarse"
|
| 296 |
+
"""Won't challenge the opponent again."""
|
| 297 |
+
FINE = "fine"
|
| 298 |
+
"""
|
| 299 |
+
Won't challenge the opponent to a game of the same mode, speed, and variant
|
| 300 |
+
based on the reason for the opponent declining the challenge.
|
| 301 |
+
"""
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
class ChessDBMoveType(TypedDict, total=False):
|
| 305 |
+
"""Type hint for a move returned by chessdb (opening & egtb)."""
|
| 306 |
+
|
| 307 |
+
uci: str
|
| 308 |
+
san: str
|
| 309 |
+
score: int
|
| 310 |
+
rank: int
|
| 311 |
+
note: str
|
| 312 |
+
winrate: str
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
class LichessPvType(TypedDict, total=False):
|
| 316 |
+
"""Type hint for a move returned by lichess cloud analysis."""
|
| 317 |
+
|
| 318 |
+
moves: str
|
| 319 |
+
cp: int
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
class LichessExplorerGameType(TypedDict, total=False):
|
| 323 |
+
"""Type hint for a game returned by lichess explorer."""
|
| 324 |
+
|
| 325 |
+
id: str
|
| 326 |
+
winner: str | None
|
| 327 |
+
speed: str
|
| 328 |
+
mode: str
|
| 329 |
+
black: dict[str, str | int]
|
| 330 |
+
white: dict[str, str | int]
|
| 331 |
+
year: int
|
| 332 |
+
month: str
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
class LichessEGTBMoveType(TypedDict):
|
| 336 |
+
"""Type hint for the moves returned by the lichess egtb."""
|
| 337 |
+
|
| 338 |
+
uci: str
|
| 339 |
+
san: str
|
| 340 |
+
zeroing: bool
|
| 341 |
+
checkmate: bool
|
| 342 |
+
stalemate: bool
|
| 343 |
+
variant_win: bool
|
| 344 |
+
variant_loss: bool
|
| 345 |
+
insufficient_material: bool
|
| 346 |
+
dtz: int
|
| 347 |
+
precise_dtz: int | None
|
| 348 |
+
dtm: int | None
|
| 349 |
+
category: str
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
class OnlineMoveType(TypedDict):
|
| 353 |
+
"""Type hint for a move returned by an online source."""
|
| 354 |
+
|
| 355 |
+
# chessdb
|
| 356 |
+
uci: str
|
| 357 |
+
san: str
|
| 358 |
+
score: int
|
| 359 |
+
rank: int
|
| 360 |
+
note: str
|
| 361 |
+
winrate: str
|
| 362 |
+
|
| 363 |
+
# lichess explorer
|
| 364 |
+
# uci: str (duplicate)
|
| 365 |
+
# san: str (duplicate)
|
| 366 |
+
averageRating: int
|
| 367 |
+
performance: int
|
| 368 |
+
white: int
|
| 369 |
+
black: int
|
| 370 |
+
draws: int
|
| 371 |
+
game: LichessExplorerGameType | None
|
| 372 |
+
|
| 373 |
+
# lichess egtb
|
| 374 |
+
# uci: str (duplicate)
|
| 375 |
+
# san: str (duplicate)
|
| 376 |
+
zeroing: bool
|
| 377 |
+
checkmate: bool
|
| 378 |
+
stalemate: bool
|
| 379 |
+
variant_win: bool
|
| 380 |
+
variant_loss: bool
|
| 381 |
+
insufficient_material: bool
|
| 382 |
+
dtz: int
|
| 383 |
+
precise_dtz: int | None
|
| 384 |
+
dtm: int | None
|
| 385 |
+
dtc: int
|
| 386 |
+
category: str
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
class OnlineType(TypedDict, total=False):
|
| 390 |
+
"""Type hint for moves returned by an online source."""
|
| 391 |
+
|
| 392 |
+
# lichess egtb
|
| 393 |
+
checkmate: bool
|
| 394 |
+
stalemate: bool
|
| 395 |
+
variant_win: bool
|
| 396 |
+
variant_loss: bool
|
| 397 |
+
insufficient_material: bool
|
| 398 |
+
dtz: int
|
| 399 |
+
precise_dtz: int | None
|
| 400 |
+
dtm: int | None
|
| 401 |
+
dtc: int
|
| 402 |
+
category: str
|
| 403 |
+
# moves: list[LichessEGTBMoveType]
|
| 404 |
+
|
| 405 |
+
# lichess explorer
|
| 406 |
+
white: int
|
| 407 |
+
black: int
|
| 408 |
+
draws: int
|
| 409 |
+
# moves: list[LichessExplorerMoveType]
|
| 410 |
+
topGames: list[LichessExplorerGameType]
|
| 411 |
+
recentGames: list[LichessExplorerGameType]
|
| 412 |
+
opening: dict[str, str] | None
|
| 413 |
+
queuePosition: int
|
| 414 |
+
|
| 415 |
+
# lichess cloud
|
| 416 |
+
fen: str
|
| 417 |
+
knodes: int
|
| 418 |
+
ply: str
|
| 419 |
+
depth: int
|
| 420 |
+
pvs: list[LichessPvType]
|
| 421 |
+
error: str
|
| 422 |
+
|
| 423 |
+
# chessdb (opening & egtb)
|
| 424 |
+
status: str
|
| 425 |
+
# moves: list[ChessDBMoveType]
|
| 426 |
+
# ply: str (duplicate)
|
| 427 |
+
score: int
|
| 428 |
+
# depth: int (duplicate)
|
| 429 |
+
pv: list[str]
|
| 430 |
+
pvSAN: list[str]
|
| 431 |
+
move: str
|
| 432 |
+
egtb: str
|
| 433 |
+
|
| 434 |
+
# all
|
| 435 |
+
moves: list[OnlineMoveType]
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
class TokenTestType(TypedDict, total=False):
|
| 439 |
+
"""Type hint for token test."""
|
| 440 |
+
|
| 441 |
+
scopes: str
|
| 442 |
+
userId: str
|
| 443 |
+
expires: int
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
TOKEN_TESTS_TYPE: TypeAlias = dict[str, TokenTestType]
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
class _BackoffDetails(TypedDict):
|
| 450 |
+
"""`_Details` from `backoff._typing`."""
|
| 451 |
+
|
| 452 |
+
target: Callable[..., Any]
|
| 453 |
+
args: tuple[Any, ...]
|
| 454 |
+
kwargs: dict[str, Any]
|
| 455 |
+
tries: int
|
| 456 |
+
elapsed: float
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
class BackoffDetails(_BackoffDetails, total=False):
|
| 460 |
+
"""`Details` from `backoff._typing`."""
|
| 461 |
+
|
| 462 |
+
wait: float # present in the on_backoff handler case for either decorator
|
| 463 |
+
value: Any # present in the on_predicate decorator case
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
ENGINE_INPUT_ARGS_TYPE: TypeAlias = (OPTIONS_TYPE | type[BaseException] | BaseException | TracebackType | Board | Limit |
|
| 467 |
+
str | bool | None)
|
| 468 |
+
ENGINE_INPUT_KWARGS_TYPE: TypeAlias = int | bool | list[Move] | Opponent | None
|
lichess-bot-master/lib/matchmaking.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Challenge other bots."""
|
| 2 |
+
import random
|
| 3 |
+
import logging
|
| 4 |
+
import datetime
|
| 5 |
+
import contextlib
|
| 6 |
+
from lib import model
|
| 7 |
+
from lib.timer import Timer, days, seconds, minutes, years
|
| 8 |
+
from collections import defaultdict
|
| 9 |
+
from collections.abc import Sequence
|
| 10 |
+
from lib.lichess import Lichess, RateLimitedError
|
| 11 |
+
from lib.config import Configuration
|
| 12 |
+
from typing import cast, TypeAlias
|
| 13 |
+
from lib.blocklist import OnlineBlocklist
|
| 14 |
+
from lib.lichess_types import UserProfileType, PerfType, EventType, FilterType, ChallengeType
|
| 15 |
+
MULTIPROCESSING_LIST_TYPE: TypeAlias = Sequence[model.Challenge]
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class Matchmaking:
|
| 21 |
+
"""Challenge other bots."""
|
| 22 |
+
|
| 23 |
+
def __init__(self, li: Lichess, config: Configuration, user_profile: UserProfileType) -> None:
|
| 24 |
+
"""Initialize values needed for matchmaking."""
|
| 25 |
+
self.li = li
|
| 26 |
+
self.variants = list(filter(lambda variant: variant != "fromPosition", config.challenge.variants))
|
| 27 |
+
self.matchmaking_cfg = config.matchmaking
|
| 28 |
+
self.user_profile = user_profile
|
| 29 |
+
self.last_challenge_created_delay = Timer(seconds(25)) # Challenges expire after 20 seconds.
|
| 30 |
+
self.last_game_ended_delay = Timer(minutes(self.matchmaking_cfg.challenge_timeout))
|
| 31 |
+
self.last_user_profile_update_time = Timer(minutes(5))
|
| 32 |
+
self.min_wait_time = seconds(60) # Wait before new challenge to avoid api rate limits.
|
| 33 |
+
self.rate_limit_timer = Timer()
|
| 34 |
+
|
| 35 |
+
# Maximum time between challenges, even if there are active games
|
| 36 |
+
self.max_wait_time = minutes(10) if self.matchmaking_cfg.allow_during_games else years(10)
|
| 37 |
+
self.challenge_id = ""
|
| 38 |
+
|
| 39 |
+
# (opponent name, game aspect) --> other bot is likely to accept challenge
|
| 40 |
+
# game aspect is the one the challenged bot objects to and is one of:
|
| 41 |
+
# - game speed (bullet, blitz, etc.)
|
| 42 |
+
# - variant (standard, horde, etc.)
|
| 43 |
+
# - casual/rated
|
| 44 |
+
# - empty string (if no other reason is given or self.filter_type is COARSE)
|
| 45 |
+
self.challenge_type_acceptable: defaultdict[tuple[str, str], Timer] = defaultdict(Timer)
|
| 46 |
+
self.challenge_filter = self.matchmaking_cfg.challenge_filter
|
| 47 |
+
|
| 48 |
+
for name in self.matchmaking_cfg.block_list:
|
| 49 |
+
self.add_to_block_list(name)
|
| 50 |
+
|
| 51 |
+
self.online_block_list = OnlineBlocklist(self.matchmaking_cfg.online_block_list)
|
| 52 |
+
|
| 53 |
+
def should_create_challenge(self) -> bool:
|
| 54 |
+
"""Whether we should create a challenge."""
|
| 55 |
+
matchmaking_enabled = self.matchmaking_cfg.allow_matchmaking
|
| 56 |
+
time_has_passed = self.last_game_ended_delay.is_expired() and self.rate_limit_timer.is_expired()
|
| 57 |
+
challenge_expired = self.last_challenge_created_delay.is_expired() and self.challenge_id
|
| 58 |
+
min_wait_time_passed = self.last_challenge_created_delay.time_since_reset() > self.min_wait_time
|
| 59 |
+
if challenge_expired:
|
| 60 |
+
self.li.cancel(self.challenge_id)
|
| 61 |
+
logger.info(f"Challenge id {self.challenge_id} cancelled.")
|
| 62 |
+
self.discard_challenge(self.challenge_id)
|
| 63 |
+
self.show_earliest_challenge_time()
|
| 64 |
+
return bool(matchmaking_enabled and (time_has_passed or challenge_expired) and min_wait_time_passed)
|
| 65 |
+
|
| 66 |
+
def create_challenge(self, username: str, base_time: int, increment: int, days: int, variant: str,
|
| 67 |
+
mode: str) -> str:
|
| 68 |
+
"""Create a challenge."""
|
| 69 |
+
params: dict[str, str | int | bool] = {"rated": mode == "rated", "variant": variant}
|
| 70 |
+
|
| 71 |
+
if days:
|
| 72 |
+
params["days"] = days
|
| 73 |
+
elif base_time or increment:
|
| 74 |
+
params["clock.limit"] = base_time
|
| 75 |
+
params["clock.increment"] = increment
|
| 76 |
+
else:
|
| 77 |
+
logger.error("At least one of challenge_days, challenge_initial_time, or challenge_increment "
|
| 78 |
+
"must be greater than zero in the matchmaking section of your config file.")
|
| 79 |
+
return ""
|
| 80 |
+
|
| 81 |
+
try:
|
| 82 |
+
self.last_challenge_created_delay.reset()
|
| 83 |
+
response = self.li.challenge(username, params)
|
| 84 |
+
challenge_id = response.get("id", "")
|
| 85 |
+
if not challenge_id:
|
| 86 |
+
self.handle_challenge_error_response(response, username)
|
| 87 |
+
return challenge_id
|
| 88 |
+
except RateLimitedError as e:
|
| 89 |
+
logger.warning(e)
|
| 90 |
+
self.rate_limit_timer = Timer(e.timeout)
|
| 91 |
+
except Exception as e:
|
| 92 |
+
logger.debug(e, exc_info=e)
|
| 93 |
+
|
| 94 |
+
logger.warning("Could not create challenge")
|
| 95 |
+
self.show_earliest_challenge_time()
|
| 96 |
+
return ""
|
| 97 |
+
|
| 98 |
+
def handle_challenge_error_response(self, response: ChallengeType, username: str) -> None:
|
| 99 |
+
"""If a challenge fails, print the error and adjust the challenge requirements in response."""
|
| 100 |
+
logger.error(response)
|
| 101 |
+
if response.get("bot_is_rate_limited"):
|
| 102 |
+
timeout = cast(datetime.timedelta, response.get("rate_limit_timeout"))
|
| 103 |
+
self.rate_limit_timer = Timer(timeout)
|
| 104 |
+
elif response.get("opponent_is_rate_limited"):
|
| 105 |
+
self.add_challenge_filter(username, "", response.get("rate_limit_timeout"))
|
| 106 |
+
else:
|
| 107 |
+
self.add_challenge_filter(username, "")
|
| 108 |
+
self.show_earliest_challenge_time()
|
| 109 |
+
|
| 110 |
+
def perf(self) -> dict[str, PerfType]:
|
| 111 |
+
"""Get the bot's rating in every variant. Bullet, blitz, rapid etc. are considered different variants."""
|
| 112 |
+
user_perf: dict[str, PerfType] = self.user_profile["perfs"]
|
| 113 |
+
return user_perf
|
| 114 |
+
|
| 115 |
+
def username(self) -> str:
|
| 116 |
+
"""Our username."""
|
| 117 |
+
username: str = self.user_profile["username"]
|
| 118 |
+
return username
|
| 119 |
+
|
| 120 |
+
def update_user_profile(self) -> None:
|
| 121 |
+
"""Update our user profile data, to get our latest rating."""
|
| 122 |
+
if self.last_user_profile_update_time.is_expired():
|
| 123 |
+
self.last_user_profile_update_time.reset()
|
| 124 |
+
with contextlib.suppress(Exception):
|
| 125 |
+
self.user_profile = self.li.get_profile()
|
| 126 |
+
|
| 127 |
+
def get_weights(self, online_bots: list[UserProfileType], rating_preference: str, min_rating: int, max_rating: int,
|
| 128 |
+
game_type: str) -> list[int]:
|
| 129 |
+
"""Get the weight for each bot. A higher weights means the bot is more likely to get challenged."""
|
| 130 |
+
def rating(bot: UserProfileType) -> int:
|
| 131 |
+
perfs: dict[str, PerfType] = bot.get("perfs", {})
|
| 132 |
+
perf: PerfType = perfs.get(game_type, {})
|
| 133 |
+
return perf.get("rating", 0)
|
| 134 |
+
|
| 135 |
+
if rating_preference == "high":
|
| 136 |
+
# A bot with max_rating rating will be twice as likely to get picked than a bot with min_rating rating.
|
| 137 |
+
reduce_ratings_by = min(min_rating - (max_rating - min_rating), min_rating - 1)
|
| 138 |
+
weights = [rating(bot) - reduce_ratings_by for bot in online_bots]
|
| 139 |
+
elif rating_preference == "low":
|
| 140 |
+
# A bot with min_rating rating will be twice as likely to get picked than a bot with max_rating rating.
|
| 141 |
+
reduce_ratings_by = max(max_rating - (min_rating - max_rating), max_rating + 1)
|
| 142 |
+
weights = [reduce_ratings_by - rating(bot) for bot in online_bots]
|
| 143 |
+
else:
|
| 144 |
+
weights = [1] * len(online_bots)
|
| 145 |
+
return weights
|
| 146 |
+
|
| 147 |
+
def choose_opponent(self) -> tuple[str | None, int, int, int, str, str]:
|
| 148 |
+
"""Choose an opponent."""
|
| 149 |
+
override_choice = random.choice(self.matchmaking_cfg.overrides.keys() + [None])
|
| 150 |
+
logger.info(f"Using the {override_choice or 'default'} matchmaking configuration.")
|
| 151 |
+
override = {} if override_choice is None else self.matchmaking_cfg.overrides.lookup(override_choice)
|
| 152 |
+
match_config = self.matchmaking_cfg | override
|
| 153 |
+
|
| 154 |
+
variant = self.get_random_config_value(match_config, "challenge_variant", self.variants)
|
| 155 |
+
mode = self.get_random_config_value(match_config, "challenge_mode", ["casual", "rated"])
|
| 156 |
+
rating_preference = match_config.rating_preference
|
| 157 |
+
|
| 158 |
+
base_time = random.choice(match_config.challenge_initial_time)
|
| 159 |
+
increment = random.choice(match_config.challenge_increment)
|
| 160 |
+
num_days = random.choice(match_config.challenge_days)
|
| 161 |
+
|
| 162 |
+
play_correspondence = [bool(num_days), not bool(base_time or increment)]
|
| 163 |
+
if random.choice(play_correspondence):
|
| 164 |
+
base_time = 0
|
| 165 |
+
increment = 0
|
| 166 |
+
else:
|
| 167 |
+
num_days = 0
|
| 168 |
+
|
| 169 |
+
game_type = game_category(variant, base_time, increment, num_days)
|
| 170 |
+
|
| 171 |
+
min_rating = match_config.opponent_min_rating
|
| 172 |
+
max_rating = match_config.opponent_max_rating
|
| 173 |
+
rating_diff = match_config.opponent_rating_difference
|
| 174 |
+
bot_rating = self.perf().get(game_type, {}).get("rating", 0)
|
| 175 |
+
if rating_diff is not None and bot_rating > 0:
|
| 176 |
+
min_rating = bot_rating - rating_diff
|
| 177 |
+
max_rating = bot_rating + rating_diff
|
| 178 |
+
logger.info(f"Seeking {game_type} game with opponent rating in [{min_rating}, {max_rating}] ...")
|
| 179 |
+
|
| 180 |
+
def is_suitable_opponent(bot: UserProfileType) -> bool:
|
| 181 |
+
perf = bot.get("perfs", {}).get(game_type, {})
|
| 182 |
+
return (bot["username"] != self.username()
|
| 183 |
+
and not self.in_block_list(bot["username"])
|
| 184 |
+
and perf.get("games", 0) > 0
|
| 185 |
+
and min_rating <= perf.get("rating", 0) <= max_rating)
|
| 186 |
+
|
| 187 |
+
self.online_block_list.refresh()
|
| 188 |
+
online_bots = self.li.get_online_bots()
|
| 189 |
+
online_bots = list(filter(is_suitable_opponent, online_bots))
|
| 190 |
+
|
| 191 |
+
def ready_for_challenge(bot: UserProfileType) -> bool:
|
| 192 |
+
aspects = [variant, game_type, mode] if self.challenge_filter == FilterType.FINE else []
|
| 193 |
+
return all(self.should_accept_challenge(bot["username"], aspect) for aspect in aspects)
|
| 194 |
+
|
| 195 |
+
ready_bots = list(filter(ready_for_challenge, online_bots))
|
| 196 |
+
online_bots = ready_bots or online_bots
|
| 197 |
+
bot_username = None
|
| 198 |
+
weights = self.get_weights(online_bots, rating_preference, min_rating, max_rating, game_type)
|
| 199 |
+
|
| 200 |
+
try:
|
| 201 |
+
bot = random.choices(online_bots, weights=weights)[0]
|
| 202 |
+
bot_profile = self.li.get_public_data(bot["username"])
|
| 203 |
+
if bot_profile.get("blocking"):
|
| 204 |
+
self.add_to_block_list(bot["username"])
|
| 205 |
+
else:
|
| 206 |
+
bot_username = bot["username"]
|
| 207 |
+
except Exception:
|
| 208 |
+
if online_bots:
|
| 209 |
+
logger.exception("Error:")
|
| 210 |
+
else:
|
| 211 |
+
logger.error("No suitable bots found to challenge.")
|
| 212 |
+
|
| 213 |
+
return bot_username, base_time, increment, num_days, variant, mode
|
| 214 |
+
|
| 215 |
+
def get_random_config_value(self, config: Configuration, parameter: str, choices: list[str]) -> str:
|
| 216 |
+
"""Choose a random value from `choices` if the parameter value in the config is `random`."""
|
| 217 |
+
value: str = config.lookup(parameter)
|
| 218 |
+
return value if value != "random" else random.choice(choices)
|
| 219 |
+
|
| 220 |
+
def challenge(self, active_games: set[str], challenge_queue: MULTIPROCESSING_LIST_TYPE, max_games: int) -> None:
|
| 221 |
+
"""
|
| 222 |
+
Challenge an opponent.
|
| 223 |
+
|
| 224 |
+
:param active_games: The games that the bot is playing.
|
| 225 |
+
:param challenge_queue: The queue containing the challenges.
|
| 226 |
+
:param max_games: The maximum allowed number of simultaneous games.
|
| 227 |
+
"""
|
| 228 |
+
max_games_for_matchmaking = max_games if self.matchmaking_cfg.allow_during_games else min(1, max_games)
|
| 229 |
+
game_count = len(active_games) + len(challenge_queue)
|
| 230 |
+
if (game_count >= max_games_for_matchmaking
|
| 231 |
+
or (game_count > 0 and self.last_challenge_created_delay.time_since_reset() < self.max_wait_time)
|
| 232 |
+
or not self.should_create_challenge()):
|
| 233 |
+
return
|
| 234 |
+
|
| 235 |
+
logger.info("Challenging a random bot")
|
| 236 |
+
self.update_user_profile()
|
| 237 |
+
bot_username, base_time, increment, days, variant, mode = self.choose_opponent()
|
| 238 |
+
logger.info(f"Will challenge {bot_username} for a {variant} game.")
|
| 239 |
+
challenge_id = self.create_challenge(bot_username, base_time, increment, days, variant, mode) if bot_username else ""
|
| 240 |
+
logger.info(f"Challenge id is {challenge_id or 'None'}.")
|
| 241 |
+
self.challenge_id = challenge_id
|
| 242 |
+
|
| 243 |
+
def discard_challenge(self, challenge_id: str) -> None:
|
| 244 |
+
"""
|
| 245 |
+
Clear the ID of the most recent challenge if it is no longer needed.
|
| 246 |
+
|
| 247 |
+
:param challenge_id: The ID of the challenge that is expired, accepted, or declined.
|
| 248 |
+
"""
|
| 249 |
+
if self.challenge_id == challenge_id:
|
| 250 |
+
self.challenge_id = ""
|
| 251 |
+
|
| 252 |
+
def game_done(self) -> None:
|
| 253 |
+
"""Reset the timer for when the last game ended, and prints the earliest that the next challenge will be created."""
|
| 254 |
+
self.last_game_ended_delay.reset()
|
| 255 |
+
self.show_earliest_challenge_time()
|
| 256 |
+
|
| 257 |
+
def show_earliest_challenge_time(self) -> None:
|
| 258 |
+
"""Show the earliest that the next challenge will be created."""
|
| 259 |
+
if self.matchmaking_cfg.allow_matchmaking:
|
| 260 |
+
postgame_timeout = self.last_game_ended_delay.time_until_expiration()
|
| 261 |
+
time_to_next_challenge = self.min_wait_time - self.last_challenge_created_delay.time_since_reset()
|
| 262 |
+
rate_limit_delay = self.rate_limit_timer.time_until_expiration()
|
| 263 |
+
time_left = max(postgame_timeout, time_to_next_challenge, rate_limit_delay)
|
| 264 |
+
earliest_challenge_time = datetime.datetime.now() + time_left
|
| 265 |
+
logger.info(f"Next challenge will be created after {earliest_challenge_time.strftime('%c')}")
|
| 266 |
+
|
| 267 |
+
def add_to_block_list(self, username: str) -> None:
|
| 268 |
+
"""Add a bot to the blocklist."""
|
| 269 |
+
self.add_challenge_filter(username, "", years(10))
|
| 270 |
+
|
| 271 |
+
def in_block_list(self, username: str) -> bool:
|
| 272 |
+
"""Check if an opponent is in the block list to prevent future challenges."""
|
| 273 |
+
return (not self.should_accept_challenge(username, "")) or username in self.online_block_list
|
| 274 |
+
|
| 275 |
+
def add_challenge_filter(self, username: str, game_aspect: str, timeout: datetime.timedelta | None = None) -> None:
|
| 276 |
+
"""
|
| 277 |
+
Prevent creating another challenge for a timeout when an opponent has declined a challenge.
|
| 278 |
+
|
| 279 |
+
:param username: The name of the opponent.
|
| 280 |
+
:param game_aspect: The aspect of a game (time control, chess variant, etc.) that caused the opponent to decline a
|
| 281 |
+
challenge. If the parameter is empty, that is equivalent to adding the opponent to the block list.
|
| 282 |
+
:param timeout: The amount of time to not challenge an opponent. If None, the default is a day.
|
| 283 |
+
"""
|
| 284 |
+
self.challenge_type_acceptable[(username, game_aspect)] = Timer(timeout or days(1))
|
| 285 |
+
|
| 286 |
+
def should_accept_challenge(self, username: str, game_aspect: str) -> bool:
|
| 287 |
+
"""
|
| 288 |
+
Whether a bot is likely to accept a challenge to a game.
|
| 289 |
+
|
| 290 |
+
:param username: The name of the opponent.
|
| 291 |
+
:param game_aspect: A category of the challenge type (time control, chess variant, etc.) to test for acceptance.
|
| 292 |
+
If game_aspect is empty, this is equivalent to checking if the opponent is in the block list.
|
| 293 |
+
"""
|
| 294 |
+
return self.challenge_type_acceptable[(username, game_aspect)].is_expired()
|
| 295 |
+
|
| 296 |
+
def accepted_challenge(self, event: EventType) -> None:
|
| 297 |
+
"""
|
| 298 |
+
Set the challenge id to an empty string, if the challenge was accepted.
|
| 299 |
+
|
| 300 |
+
Otherwise, we would attempt to cancel the challenge later.
|
| 301 |
+
"""
|
| 302 |
+
self.discard_challenge(event["game"]["id"])
|
| 303 |
+
|
| 304 |
+
def declined_challenge(self, event: EventType) -> None:
|
| 305 |
+
"""
|
| 306 |
+
Handle a challenge that was declined by the opponent.
|
| 307 |
+
|
| 308 |
+
Depends on whether `FilterType` is `NONE`, `COARSE`, or `FINE`.
|
| 309 |
+
"""
|
| 310 |
+
challenge = model.Challenge(event["challenge"], self.user_profile)
|
| 311 |
+
opponent = challenge.challenge_target
|
| 312 |
+
reason = event["challenge"]["declineReason"]
|
| 313 |
+
logger.info(f"{opponent} declined {challenge}: {reason}")
|
| 314 |
+
self.discard_challenge(challenge.id)
|
| 315 |
+
if not challenge.from_self or self.challenge_filter == FilterType.NONE:
|
| 316 |
+
return
|
| 317 |
+
|
| 318 |
+
mode = "rated" if challenge.rated else "casual"
|
| 319 |
+
decline_details: dict[str, str] = {"generic": "",
|
| 320 |
+
"later": "",
|
| 321 |
+
"nobot": "",
|
| 322 |
+
"toofast": challenge.speed,
|
| 323 |
+
"tooslow": challenge.speed,
|
| 324 |
+
"timecontrol": challenge.speed,
|
| 325 |
+
"rated": mode,
|
| 326 |
+
"casual": mode,
|
| 327 |
+
"standard": challenge.variant,
|
| 328 |
+
"variant": challenge.variant}
|
| 329 |
+
|
| 330 |
+
reason_key = event["challenge"]["declineReasonKey"].lower()
|
| 331 |
+
if reason_key not in decline_details:
|
| 332 |
+
logger.warning(f"Unknown decline reason received: {reason_key}")
|
| 333 |
+
game_problem = decline_details.get(reason_key, "") if self.challenge_filter == FilterType.FINE else ""
|
| 334 |
+
self.add_challenge_filter(opponent.name, game_problem)
|
| 335 |
+
logger.info(f"Will not challenge {opponent} to another {game_problem}".strip() + " game today.")
|
| 336 |
+
|
| 337 |
+
self.show_earliest_challenge_time()
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def game_category(variant: str, base_time: int, increment: int, num_days: int) -> str:
|
| 341 |
+
"""
|
| 342 |
+
Get the game type (e.g. bullet, atomic, classical). Lichess has one rating for every variant regardless of time control.
|
| 343 |
+
|
| 344 |
+
:param variant: The game's variant.
|
| 345 |
+
:param base_time: The base time in seconds.
|
| 346 |
+
:param increment: The increment in seconds.
|
| 347 |
+
:param num_days: If the game is correspondence, we have some days to play the move.
|
| 348 |
+
:return: The game category.
|
| 349 |
+
"""
|
| 350 |
+
game_duration = base_time + increment * 40
|
| 351 |
+
if variant != "standard":
|
| 352 |
+
return variant
|
| 353 |
+
if num_days:
|
| 354 |
+
return "correspondence"
|
| 355 |
+
if game_duration < 179:
|
| 356 |
+
return "bullet"
|
| 357 |
+
if game_duration < 479:
|
| 358 |
+
return "blitz"
|
| 359 |
+
if game_duration < 1499:
|
| 360 |
+
return "rapid"
|
| 361 |
+
return "classical"
|
lichess-bot-master/lib/model.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Store information about a challenge, game or player in a class."""
|
| 2 |
+
import math
|
| 3 |
+
from urllib.parse import urljoin
|
| 4 |
+
import logging
|
| 5 |
+
import datetime
|
| 6 |
+
import chess
|
| 7 |
+
from enum import Enum
|
| 8 |
+
from lib.blocklist import OnlineBlocklist
|
| 9 |
+
from lib.timer import Timer, msec, seconds, sec_str, to_msec, to_seconds, years
|
| 10 |
+
from lib.config import Configuration
|
| 11 |
+
from collections import defaultdict, Counter
|
| 12 |
+
from lib.lichess_types import UserProfileType, ChallengeType, GameEventType, PlayerType
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def is_chess_960(fen: str) -> bool:
|
| 18 |
+
"""Determine whether an FEN string represents a chess960 game."""
|
| 19 |
+
return chess.Board(fen) != chess.Board(fen, chess960=True)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class Challenge:
|
| 23 |
+
"""Store information about a challenge."""
|
| 24 |
+
|
| 25 |
+
def __init__(self, challenge_info: ChallengeType, user_profile: UserProfileType) -> None:
|
| 26 |
+
""":param user_profile: Information about our bot."""
|
| 27 |
+
self.id = challenge_info["id"]
|
| 28 |
+
self.rated = challenge_info["rated"]
|
| 29 |
+
self.variant = challenge_info["variant"]["key"]
|
| 30 |
+
self.perf_name = challenge_info["perf"]["name"]
|
| 31 |
+
self.speed = challenge_info["speed"]
|
| 32 |
+
self.increment = challenge_info.get("timeControl", {}).get("increment")
|
| 33 |
+
self.base = challenge_info.get("timeControl", {}).get("limit")
|
| 34 |
+
self.days = challenge_info.get("timeControl", {}).get("daysPerTurn")
|
| 35 |
+
self.challenger = Player(challenge_info.get("challenger") or {})
|
| 36 |
+
self.challenge_target = Player(challenge_info.get("destUser") or {})
|
| 37 |
+
self.from_self = self.challenger.name == user_profile["username"]
|
| 38 |
+
self.initial_fen = challenge_info.get("initialFen", "startpos")
|
| 39 |
+
color = challenge_info["color"]
|
| 40 |
+
self.color = color if color != "random" else challenge_info["finalColor"]
|
| 41 |
+
self.time_control = challenge_info["timeControl"]
|
| 42 |
+
|
| 43 |
+
def is_supported_variant(self, challenge_cfg: Configuration) -> bool:
|
| 44 |
+
"""Check whether the variant is supported."""
|
| 45 |
+
if self.variant not in challenge_cfg.variants:
|
| 46 |
+
return False
|
| 47 |
+
|
| 48 |
+
if self.initial_fen == "startpos":
|
| 49 |
+
return True
|
| 50 |
+
|
| 51 |
+
if is_chess_960(self.initial_fen):
|
| 52 |
+
return "chess960" in challenge_cfg.variants
|
| 53 |
+
|
| 54 |
+
return True
|
| 55 |
+
|
| 56 |
+
def is_supported_time_control(self, challenge_cfg: Configuration) -> bool:
|
| 57 |
+
"""Check whether the time control is supported."""
|
| 58 |
+
speeds = challenge_cfg.time_controls
|
| 59 |
+
increment_max: int = challenge_cfg.max_increment
|
| 60 |
+
increment_min: int = challenge_cfg.min_increment
|
| 61 |
+
base_max: int = challenge_cfg.max_base
|
| 62 |
+
base_min: int = challenge_cfg.min_base
|
| 63 |
+
days_max: float = challenge_cfg.max_days
|
| 64 |
+
days_min: float = challenge_cfg.min_days
|
| 65 |
+
|
| 66 |
+
if self.speed not in speeds:
|
| 67 |
+
return False
|
| 68 |
+
|
| 69 |
+
require_non_zero_increment = (self.challenger.is_bot
|
| 70 |
+
and self.speed == "bullet"
|
| 71 |
+
and challenge_cfg.bullet_requires_increment)
|
| 72 |
+
increment_min = max(increment_min, 1 if require_non_zero_increment else 0)
|
| 73 |
+
|
| 74 |
+
if self.base is not None and self.increment is not None:
|
| 75 |
+
# Normal clock game
|
| 76 |
+
return (increment_min <= self.increment <= increment_max
|
| 77 |
+
and base_min <= self.base <= base_max)
|
| 78 |
+
elif self.days is not None:
|
| 79 |
+
# Correspondence game
|
| 80 |
+
return days_min <= self.days <= days_max
|
| 81 |
+
else:
|
| 82 |
+
# Unlimited game
|
| 83 |
+
return days_max == math.inf
|
| 84 |
+
|
| 85 |
+
def is_supported_mode(self, challenge_cfg: Configuration) -> bool:
|
| 86 |
+
"""Check whether the mode is supported."""
|
| 87 |
+
return ("rated" if self.rated else "casual") in challenge_cfg.modes
|
| 88 |
+
|
| 89 |
+
def is_supported_rating(self, challenge_cfg: Configuration, user_profile: UserProfileType) -> bool:
|
| 90 |
+
"""Check whether the challenger's rating is within the acceptable range."""
|
| 91 |
+
challenger_rating = self.challenger.rating
|
| 92 |
+
if challenger_rating is None:
|
| 93 |
+
return True
|
| 94 |
+
|
| 95 |
+
min_rating: int = challenge_cfg.min_rating
|
| 96 |
+
max_rating: int = challenge_cfg.max_rating
|
| 97 |
+
rating_diff: int | None = challenge_cfg.rating_difference
|
| 98 |
+
|
| 99 |
+
if rating_diff is not None:
|
| 100 |
+
bot_rating = user_profile.get("perfs", {}).get(self.perf_name.lower(), {}).get("rating")
|
| 101 |
+
if bot_rating:
|
| 102 |
+
min_rating = max(min_rating, bot_rating - rating_diff)
|
| 103 |
+
max_rating = min(max_rating, bot_rating + rating_diff)
|
| 104 |
+
|
| 105 |
+
return min_rating <= challenger_rating <= max_rating
|
| 106 |
+
|
| 107 |
+
def is_supported_recent(self, config: Configuration, recent_bot_challenges: defaultdict[str, list[Timer]]) -> bool:
|
| 108 |
+
"""Check whether we have played a lot of games with this opponent recently. Only used when the opponent is a BOT."""
|
| 109 |
+
# Filter out old challenges
|
| 110 |
+
recent_bot_challenges[self.challenger.name] = [timer for timer
|
| 111 |
+
in recent_bot_challenges[self.challenger.name]
|
| 112 |
+
if not timer.is_expired()]
|
| 113 |
+
max_recent_challenges = config.max_recent_bot_challenges
|
| 114 |
+
return (not self.challenger.is_bot
|
| 115 |
+
or max_recent_challenges is None
|
| 116 |
+
or len(recent_bot_challenges[self.challenger.name]) < max_recent_challenges)
|
| 117 |
+
|
| 118 |
+
def decline_due_to(self, requirement_met: bool, decline_reason: str) -> str:
|
| 119 |
+
"""
|
| 120 |
+
Get the reason lichess-bot declined an incoming challenge.
|
| 121 |
+
|
| 122 |
+
:param requirement_met: Whether a requirement is met.
|
| 123 |
+
:param decline_reason: The reason we declined the challenge if the requirement wasn't met.
|
| 124 |
+
:return: `decline_reason` if `requirement_met` is false else returns an empty string.
|
| 125 |
+
"""
|
| 126 |
+
return "" if requirement_met else decline_reason
|
| 127 |
+
|
| 128 |
+
def is_supported(self, config: Configuration, recent_bot_challenges: defaultdict[str, list[Timer]],
|
| 129 |
+
opponent_engagements: Counter[str], online_block_list: OnlineBlocklist,
|
| 130 |
+
user_profile: UserProfileType) -> tuple[bool, str]:
|
| 131 |
+
"""Whether the challenge is supported."""
|
| 132 |
+
try:
|
| 133 |
+
if self.from_self:
|
| 134 |
+
return True, ""
|
| 135 |
+
|
| 136 |
+
from extra_game_handlers import is_supported_extra
|
| 137 |
+
|
| 138 |
+
allowed_opponents: list[str] = list(filter(None, config.allow_list)) or [self.challenger.name]
|
| 139 |
+
decline_reason = (self.decline_due_to(config.accept_bot or not self.challenger.is_bot, "noBot")
|
| 140 |
+
or self.decline_due_to(not config.only_bot or self.challenger.is_bot, "onlyBot")
|
| 141 |
+
or self.decline_due_to(self.is_supported_time_control(config), "timeControl")
|
| 142 |
+
or self.decline_due_to(self.is_supported_variant(config), "variant")
|
| 143 |
+
or self.decline_due_to(self.is_supported_mode(config), "casual" if self.rated else "rated")
|
| 144 |
+
or self.decline_due_to(self.is_supported_rating(config, user_profile), "generic")
|
| 145 |
+
or self.decline_due_to(self.challenger.name not in config.block_list, "generic")
|
| 146 |
+
or self.decline_due_to(self.challenger.name not in online_block_list, "generic")
|
| 147 |
+
or self.decline_due_to(self.challenger.name in allowed_opponents, "generic")
|
| 148 |
+
or self.decline_due_to(self.is_supported_recent(config, recent_bot_challenges), "later")
|
| 149 |
+
or self.decline_due_to(opponent_engagements[self.challenger.name]
|
| 150 |
+
< config.max_simultaneous_games_per_user, "later")
|
| 151 |
+
or self.decline_due_to(is_supported_extra(self), "generic"))
|
| 152 |
+
|
| 153 |
+
return not decline_reason, decline_reason
|
| 154 |
+
|
| 155 |
+
except Exception:
|
| 156 |
+
logger.exception(f"Error while checking challenge {self.id}:")
|
| 157 |
+
return False, "generic"
|
| 158 |
+
|
| 159 |
+
def score(self) -> int:
|
| 160 |
+
"""Give a rating estimate to the opponent."""
|
| 161 |
+
rated_bonus = 200 if self.rated else 0
|
| 162 |
+
challenger_master_title = self.challenger.title if not self.challenger.is_bot else None
|
| 163 |
+
titled_bonus = 200 if challenger_master_title else 0
|
| 164 |
+
challenger_rating_int = self.challenger.rating or 0
|
| 165 |
+
return challenger_rating_int + rated_bonus + titled_bonus
|
| 166 |
+
|
| 167 |
+
def mode(self) -> str:
|
| 168 |
+
"""Get the mode of the challenge (rated or casual)."""
|
| 169 |
+
return "rated" if self.rated else "casual"
|
| 170 |
+
|
| 171 |
+
def __str__(self) -> str:
|
| 172 |
+
"""Get a string representation of `Challenge`."""
|
| 173 |
+
return f"{self.perf_name} {self.mode()} challenge from {self.challenger} ({self.id})"
|
| 174 |
+
|
| 175 |
+
def __repr__(self) -> str:
|
| 176 |
+
"""Get a string representation of `Challenge`."""
|
| 177 |
+
return self.__str__()
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
class Termination(str, Enum):
|
| 181 |
+
"""The possible game terminations."""
|
| 182 |
+
|
| 183 |
+
MATE = "mate"
|
| 184 |
+
TIMEOUT = "outoftime"
|
| 185 |
+
RESIGN = "resign"
|
| 186 |
+
ABORT = "aborted"
|
| 187 |
+
DRAW = "draw"
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
class Game:
|
| 191 |
+
"""Store information about a game."""
|
| 192 |
+
|
| 193 |
+
def __init__(self, game_info: GameEventType, username: str, base_url: str, abort_time: datetime.timedelta) -> None:
|
| 194 |
+
""":param abort_time: How long to wait before aborting the game."""
|
| 195 |
+
self.username = username
|
| 196 |
+
self.id = game_info["id"]
|
| 197 |
+
self.speed = game_info.get("speed")
|
| 198 |
+
clock = game_info.get("clock") or {}
|
| 199 |
+
ten_years_in_ms = to_msec(years(10))
|
| 200 |
+
self.clock_initial = msec(clock.get("initial", ten_years_in_ms))
|
| 201 |
+
self.clock_increment = msec(clock.get("increment", 0))
|
| 202 |
+
self.perf_name = (game_info.get("perf") or {}).get("name", "{perf?}")
|
| 203 |
+
self.variant_name = game_info["variant"]["name"]
|
| 204 |
+
self.mode = "rated" if game_info.get("rated") else "casual"
|
| 205 |
+
self.white = Player(game_info["white"])
|
| 206 |
+
self.black = Player(game_info["black"])
|
| 207 |
+
self.initial_fen = game_info.get("initialFen")
|
| 208 |
+
self.state = game_info["state"]
|
| 209 |
+
self.is_white = (self.white.name or "").lower() == username.lower()
|
| 210 |
+
self.my_color = "white" if self.is_white else "black"
|
| 211 |
+
self.opponent_color = "black" if self.is_white else "white"
|
| 212 |
+
self.me = self.white if self.is_white else self.black
|
| 213 |
+
self.opponent = self.black if self.is_white else self.white
|
| 214 |
+
self.base_url = base_url
|
| 215 |
+
self.game_start = datetime.datetime.fromtimestamp(to_seconds(msec(game_info["createdAt"])),
|
| 216 |
+
tz=datetime.timezone.utc)
|
| 217 |
+
self.abort_time = Timer(abort_time)
|
| 218 |
+
self.terminate_time = Timer(self.clock_initial + self.clock_increment + abort_time + seconds(60))
|
| 219 |
+
self.disconnect_time = Timer(seconds(0))
|
| 220 |
+
|
| 221 |
+
def url(self) -> str:
|
| 222 |
+
"""Get the url of the game."""
|
| 223 |
+
return f"{self.short_url()}/{self.my_color}"
|
| 224 |
+
|
| 225 |
+
def short_url(self) -> str:
|
| 226 |
+
"""Get the short url of the game."""
|
| 227 |
+
return urljoin(self.base_url, self.id)
|
| 228 |
+
|
| 229 |
+
def pgn_event(self) -> str:
|
| 230 |
+
"""Get the event to write in the PGN file."""
|
| 231 |
+
if self.variant_name in ["Standard", "From Position"]:
|
| 232 |
+
return f"{self.mode.title()} {self.perf_name.title()} game"
|
| 233 |
+
else:
|
| 234 |
+
return f"{self.mode.title()} {self.variant_name} game"
|
| 235 |
+
|
| 236 |
+
def time_control(self) -> str:
|
| 237 |
+
"""Get the time control of the game."""
|
| 238 |
+
return f"{sec_str(self.clock_initial)}+{sec_str(self.clock_increment)}"
|
| 239 |
+
|
| 240 |
+
def is_abortable(self) -> bool:
|
| 241 |
+
"""Whether the game can be aborted."""
|
| 242 |
+
# Moves are separated by spaces. A game is abortable when less
|
| 243 |
+
# than two moves (one from each player) have been played.
|
| 244 |
+
return " " not in self.state["moves"]
|
| 245 |
+
|
| 246 |
+
def ping(self, abort_in: datetime.timedelta, terminate_in: datetime.timedelta, disconnect_in: datetime.timedelta) -> None:
|
| 247 |
+
"""
|
| 248 |
+
Tell the bot when to abort, terminate, and disconnect from a game.
|
| 249 |
+
|
| 250 |
+
:param abort_in: How many seconds to wait before aborting.
|
| 251 |
+
:param terminate_in: How many seconds to wait before terminating.
|
| 252 |
+
:param disconnect_in: How many seconds to wait before disconnecting.
|
| 253 |
+
"""
|
| 254 |
+
if self.is_abortable():
|
| 255 |
+
self.abort_time = Timer(abort_in)
|
| 256 |
+
self.terminate_time = Timer(terminate_in)
|
| 257 |
+
self.disconnect_time = Timer(disconnect_in)
|
| 258 |
+
|
| 259 |
+
def should_abort_now(self) -> bool:
|
| 260 |
+
"""Whether we should abort the game."""
|
| 261 |
+
return self.is_abortable() and self.abort_time.is_expired()
|
| 262 |
+
|
| 263 |
+
def should_terminate_now(self) -> bool:
|
| 264 |
+
"""Whether we should terminate the game."""
|
| 265 |
+
return self.terminate_time.is_expired()
|
| 266 |
+
|
| 267 |
+
def should_disconnect_now(self) -> bool:
|
| 268 |
+
"""Whether we should disconnect form the game."""
|
| 269 |
+
return self.disconnect_time.is_expired()
|
| 270 |
+
|
| 271 |
+
def my_remaining_time(self) -> datetime.timedelta:
|
| 272 |
+
"""How many seconds we have left."""
|
| 273 |
+
wtime = msec(self.state["wtime"])
|
| 274 |
+
btime = msec(self.state["btime"])
|
| 275 |
+
return wtime if self.is_white else btime
|
| 276 |
+
|
| 277 |
+
def result(self) -> str:
|
| 278 |
+
"""Get the result of the game."""
|
| 279 |
+
class GameEnding(str, Enum):
|
| 280 |
+
WHITE_WINS = "1-0"
|
| 281 |
+
BLACK_WINS = "0-1"
|
| 282 |
+
DRAW = "1/2-1/2"
|
| 283 |
+
INCOMPLETE = "*"
|
| 284 |
+
|
| 285 |
+
winner = self.state.get("winner")
|
| 286 |
+
termination = self.state.get("status")
|
| 287 |
+
|
| 288 |
+
if winner == "white":
|
| 289 |
+
result = GameEnding.WHITE_WINS
|
| 290 |
+
elif winner == "black":
|
| 291 |
+
result = GameEnding.BLACK_WINS
|
| 292 |
+
elif termination in [Termination.DRAW, Termination.TIMEOUT]:
|
| 293 |
+
result = GameEnding.DRAW
|
| 294 |
+
else:
|
| 295 |
+
result = GameEnding.INCOMPLETE
|
| 296 |
+
|
| 297 |
+
return result.value
|
| 298 |
+
|
| 299 |
+
def __str__(self) -> str:
|
| 300 |
+
"""Get a string representation of `Game`."""
|
| 301 |
+
return f"{self.url()} {self.perf_name} vs {self.opponent} ({self.id})"
|
| 302 |
+
|
| 303 |
+
def __repr__(self) -> str:
|
| 304 |
+
"""Get a string representation of `Game`."""
|
| 305 |
+
return self.__str__()
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
class Player:
|
| 309 |
+
"""Store information about a player."""
|
| 310 |
+
|
| 311 |
+
def __init__(self, player_info: PlayerType) -> None:
|
| 312 |
+
""":param player_info: Contains information about a player."""
|
| 313 |
+
self.title = player_info.get("title")
|
| 314 |
+
self.rating = player_info.get("rating")
|
| 315 |
+
self.provisional = player_info.get("provisional")
|
| 316 |
+
self.aiLevel = player_info.get("aiLevel")
|
| 317 |
+
self.is_bot = self.title == "BOT" or self.aiLevel is not None
|
| 318 |
+
self.name = f"AI level {self.aiLevel}" if self.aiLevel else player_info.get("name", "")
|
| 319 |
+
|
| 320 |
+
def __str__(self) -> str:
|
| 321 |
+
"""Get a string representation of `Player`."""
|
| 322 |
+
if self.aiLevel:
|
| 323 |
+
return self.name
|
| 324 |
+
rating = f'{self.rating}{"?" if self.provisional else ""}'
|
| 325 |
+
return f'{self.title or ""} {self.name} ({rating})'.strip()
|
| 326 |
+
|
| 327 |
+
def __repr__(self) -> str:
|
| 328 |
+
"""Get a string representation of `Player`."""
|
| 329 |
+
return self.__str__()
|
lichess-bot-master/lib/timer.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A timer for use in lichess-bot."""
|
| 2 |
+
|
| 3 |
+
from datetime import timedelta
|
| 4 |
+
from time import perf_counter
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def msec(time_in_msec: float) -> timedelta:
|
| 8 |
+
"""Create a timedelta duration in milliseconds."""
|
| 9 |
+
return timedelta(milliseconds=time_in_msec)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def to_msec(duration: timedelta) -> float:
|
| 13 |
+
"""Return a bare number representing the length of the duration in milliseconds."""
|
| 14 |
+
return duration / msec(1)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def msec_str(duration: timedelta) -> str:
|
| 18 |
+
"""Return a string with the duration value in whole number milliseconds."""
|
| 19 |
+
return str(round(to_msec(duration)))
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def seconds(time_in_sec: float) -> timedelta:
|
| 23 |
+
"""Create a timedelta duration in seconds."""
|
| 24 |
+
return timedelta(seconds=time_in_sec)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def to_seconds(duration: timedelta) -> float:
|
| 28 |
+
"""Return a bare number representing the length of the duration in seconds."""
|
| 29 |
+
return duration.total_seconds()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def sec_str(duration: timedelta) -> str:
|
| 33 |
+
"""Return a string with the duration value in whole number seconds."""
|
| 34 |
+
return str(round(to_seconds(duration)))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def minutes(time_in_minutes: float) -> timedelta:
|
| 38 |
+
"""Create a timedelta duration in minutes."""
|
| 39 |
+
return timedelta(minutes=time_in_minutes)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def hours(time_in_hours: float) -> timedelta:
|
| 43 |
+
"""Create a timedelta duration in hours."""
|
| 44 |
+
return timedelta(hours=time_in_hours)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def days(time_in_days: float) -> timedelta:
|
| 48 |
+
"""Create a timedelta duration in days."""
|
| 49 |
+
return timedelta(days=time_in_days)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def years(time_in_years: float) -> timedelta:
|
| 53 |
+
"""Create a timedelta duration in median years--i.e., 365 days."""
|
| 54 |
+
return days(365) * time_in_years
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
zero_seconds = seconds(0)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class Timer:
|
| 61 |
+
"""
|
| 62 |
+
A timer for use in lichess-bot. An instance of timer can be used both as a countdown timer and a stopwatch.
|
| 63 |
+
|
| 64 |
+
If the duration argument in the __init__() method is greater than zero, then
|
| 65 |
+
the method is_expired() indicates when the initial duration has passed. The
|
| 66 |
+
method time_until_expiration() gives the amount of time left until the timer
|
| 67 |
+
expires.
|
| 68 |
+
|
| 69 |
+
Regardless of the initial duration (even if it's zero), a timer can be used
|
| 70 |
+
as a stopwatch by calling time_since_reset() to get the amount of time since
|
| 71 |
+
the timer was created or since it was last reset.
|
| 72 |
+
"""
|
| 73 |
+
|
| 74 |
+
def __init__(self, duration: timedelta = zero_seconds) -> None:
|
| 75 |
+
"""
|
| 76 |
+
Start the timer.
|
| 77 |
+
|
| 78 |
+
:param duration: The duration of time before Timer.is_expired() returns True.
|
| 79 |
+
:param backdated_timestamp: When the timer should have started. Used to keep the timers between sessions.
|
| 80 |
+
"""
|
| 81 |
+
self.duration = duration
|
| 82 |
+
self.starting_time = perf_counter()
|
| 83 |
+
|
| 84 |
+
def is_expired(self) -> bool:
|
| 85 |
+
"""Check if a timer is expired."""
|
| 86 |
+
return self.time_since_reset() >= self.duration
|
| 87 |
+
|
| 88 |
+
def reset(self) -> None:
|
| 89 |
+
"""Reset the timer."""
|
| 90 |
+
self.starting_time = perf_counter()
|
| 91 |
+
|
| 92 |
+
def time_since_reset(self) -> timedelta:
|
| 93 |
+
"""How much time has passed."""
|
| 94 |
+
return seconds(perf_counter() - self.starting_time)
|
| 95 |
+
|
| 96 |
+
def time_until_expiration(self) -> timedelta:
|
| 97 |
+
"""How much time is left until it expires."""
|
| 98 |
+
return max(seconds(0), self.duration - self.time_since_reset())
|
lichess-bot-master/lib/versioning.yml
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
lichess_bot_version: 2026.5.19.1
|
| 2 |
+
minimum_python_version: '3.10'
|
| 3 |
+
deprecated_python_version: '3.9'
|
| 4 |
+
deprecation_date: 2025-10-05
|
lichess-bot-master/lichess-bot.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Starting point for lichess-bot."""
|
| 2 |
+
from lib.lichess_bot import start_program
|
| 3 |
+
|
| 4 |
+
if __name__ == "__main__":
|
| 5 |
+
start_program()
|
lichess-bot-master/requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
chess~=1.11
|
| 2 |
+
PyYAML~=6.0
|
| 3 |
+
requests~=2.34
|
| 4 |
+
backoff~=2.2
|
| 5 |
+
rich~=15.0
|
lichess-bot-master/test_bot/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""pytest won't search `test_bot/` if there is no `__init__.py` file."""
|
lichess-bot-master/test_bot/buggy_engine.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""An engine that takes too much time to make a move during tests."""
|
| 2 |
+
|
| 3 |
+
import chess
|
| 4 |
+
import time
|
| 5 |
+
import typing
|
| 6 |
+
|
| 7 |
+
if typing.TYPE_CHECKING:
|
| 8 |
+
from test_bot.test_games import scholars_mate
|
| 9 |
+
else:
|
| 10 |
+
from test_games import scholars_mate
|
| 11 |
+
|
| 12 |
+
assert input() == "uci"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def send_command(command: str) -> None:
|
| 16 |
+
"""Send UCI commands to lichess-bot without output buffering."""
|
| 17 |
+
print(command, flush=True) # noqa: T201 (print() found)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
send_command("id name Procrastinator")
|
| 21 |
+
send_command("id author lichess-bot-devs")
|
| 22 |
+
send_command("uciok")
|
| 23 |
+
|
| 24 |
+
delay_performed = False
|
| 25 |
+
just_started = True
|
| 26 |
+
board = chess.Board()
|
| 27 |
+
while True:
|
| 28 |
+
command, *remaining = input().split()
|
| 29 |
+
if command == "quit":
|
| 30 |
+
break
|
| 31 |
+
elif command == "isready":
|
| 32 |
+
send_command("readyok")
|
| 33 |
+
elif command == "position":
|
| 34 |
+
spec_type, *remaining = remaining
|
| 35 |
+
assert spec_type == "startpos"
|
| 36 |
+
board = chess.Board()
|
| 37 |
+
if remaining:
|
| 38 |
+
moves_label, *move_list = remaining
|
| 39 |
+
assert moves_label == "moves"
|
| 40 |
+
for move in move_list:
|
| 41 |
+
board.push_uci(move)
|
| 42 |
+
if just_started and len(board.move_stack) > 1:
|
| 43 |
+
delay_performed = True
|
| 44 |
+
elif command == "go":
|
| 45 |
+
move_count = len(board.move_stack)
|
| 46 |
+
if move_count == 3 and not delay_performed:
|
| 47 |
+
send_command("info string delaying move")
|
| 48 |
+
delay_performed = True
|
| 49 |
+
time.sleep(11)
|
| 50 |
+
move = scholars_mate[move_count]
|
| 51 |
+
send_command(f"bestmove {move}")
|
| 52 |
+
just_started = False
|
lichess-bot-master/test_bot/conftest.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Remove files created when testing lichess-bot."""
|
| 2 |
+
import shutil
|
| 3 |
+
import os
|
| 4 |
+
from _pytest.config import ExitCode
|
| 5 |
+
from _pytest.main import Session
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def pytest_sessionfinish(session: Session, exitstatus: int | ExitCode) -> None: # noqa: ARG001
|
| 9 |
+
"""
|
| 10 |
+
Remove files created when testing lichess-bot.
|
| 11 |
+
|
| 12 |
+
The only exception is if running in a GitHub action, in which case we save the engines to the cache.
|
| 13 |
+
"""
|
| 14 |
+
if os.path.exists("TEMP") and not os.getenv("GITHUB_ACTIONS"):
|
| 15 |
+
shutil.rmtree("TEMP")
|
lichess-bot-master/test_bot/homemade.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Homemade engine playing scholar's mate."""
|
| 2 |
+
from homemade import ExampleEngine
|
| 3 |
+
import chess
|
| 4 |
+
import chess.engine
|
| 5 |
+
from lib.config import Configuration
|
| 6 |
+
from lib import model
|
| 7 |
+
from lib.lichess_types import OPTIONS_GO_EGTB_TYPE, COMMANDS_TYPE, MOVE
|
| 8 |
+
from test_bot.test_games import scholars_mate
|
| 9 |
+
|
| 10 |
+
# ruff: noqa: ARG002
|
| 11 |
+
|
| 12 |
+
class ScholarsMate(ExampleEngine):
|
| 13 |
+
"""A homemade engine that plays the scholar's mate."""
|
| 14 |
+
|
| 15 |
+
def __init__(self, commands: COMMANDS_TYPE, options: OPTIONS_GO_EGTB_TYPE, stderr: int | None,
|
| 16 |
+
draw_or_resign: Configuration, game: model.Game | None, debug: bool, **popen_args: str) -> None:
|
| 17 |
+
"""Set up engine."""
|
| 18 |
+
super().__init__(commands, options, stderr, draw_or_resign, game, debug, **popen_args)
|
| 19 |
+
|
| 20 |
+
def search(self, board: chess.Board, time_limit: chess.engine.Limit, ponder: bool, draw_offered: bool,
|
| 21 |
+
root_moves: MOVE) -> chess.engine.PlayResult:
|
| 22 |
+
"""Get the next scholar's mate move."""
|
| 23 |
+
move_number = len(board.move_stack)
|
| 24 |
+
move = board.parse_uci(scholars_mate[move_number])
|
| 25 |
+
return chess.engine.PlayResult(move, None)
|
lichess-bot-master/test_bot/lichess.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Imitate `lichess.py`. Used in tests."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import time
|
| 4 |
+
import chess.engine
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
import traceback
|
| 8 |
+
import datetime
|
| 9 |
+
from queue import Queue
|
| 10 |
+
from requests.models import Response
|
| 11 |
+
from collections.abc import Generator
|
| 12 |
+
from lib.lichess import Lichess as OriginalLichess
|
| 13 |
+
from lib.timer import to_msec
|
| 14 |
+
from lib.lichess_types import (UserProfileType, ChallengeType, REQUESTS_PAYLOAD_TYPE, GameType, OnlineType, PublicDataType,
|
| 15 |
+
BackoffDetails)
|
| 16 |
+
|
| 17 |
+
# ruff: noqa: ARG002
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def backoff_handler(details: BackoffDetails) -> None:
|
| 23 |
+
"""Log exceptions inside functions with the backoff decorator."""
|
| 24 |
+
logger.debug("Backing off {wait:0.1f} seconds after {tries} tries "
|
| 25 |
+
"calling function {target} with args {args} and kwargs {kwargs}".format(**details))
|
| 26 |
+
logger.debug(f"Exception: {traceback.format_exc()}")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def is_final(error: Exception) -> bool:
|
| 30 |
+
"""Mock error handler for tests when a function has a backup decorator."""
|
| 31 |
+
logger.debug(error)
|
| 32 |
+
return False
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class GameStream(Response):
|
| 36 |
+
"""Imitate lichess.org's GameStream. Used in tests."""
|
| 37 |
+
|
| 38 |
+
def __init__(self,
|
| 39 |
+
board_queue: Queue[chess.Board],
|
| 40 |
+
clock_queue: Queue[tuple[datetime.timedelta, datetime.timedelta, datetime.timedelta]]) -> None:
|
| 41 |
+
"""
|
| 42 |
+
Capture the interprocess queues that will feed the gameStream with game information.
|
| 43 |
+
|
| 44 |
+
:param board_queue: Updated board positions from the lichess_org_simulator() function.
|
| 45 |
+
:param clock_queue: Updated game clock timings (white time, black time, and increment) from the
|
| 46 |
+
lichess_org_simulator() function.
|
| 47 |
+
"""
|
| 48 |
+
self.board_queue = board_queue
|
| 49 |
+
self.clock_queue = clock_queue
|
| 50 |
+
|
| 51 |
+
def iter_lines(self, chunk_size: int | None = 512, decode_unicode: bool = False,
|
| 52 |
+
delimiter: str | bytes | None = None) -> Generator[bytes, None, None]:
|
| 53 |
+
"""Send the game events to lichess-bot."""
|
| 54 |
+
yield json.dumps(
|
| 55 |
+
{"id": "zzzzzzzz",
|
| 56 |
+
"variant": {"key": "standard",
|
| 57 |
+
"name": "Standard",
|
| 58 |
+
"short": "Std"},
|
| 59 |
+
"clock": {"initial": 60000,
|
| 60 |
+
"increment": 2000},
|
| 61 |
+
"speed": "bullet",
|
| 62 |
+
"perf": {"name": "Bullet"},
|
| 63 |
+
"rated": True,
|
| 64 |
+
"createdAt": 1600000000000,
|
| 65 |
+
"white": {"id": "bo",
|
| 66 |
+
"name": "bo",
|
| 67 |
+
"title": "BOT",
|
| 68 |
+
"rating": 3000},
|
| 69 |
+
"black": {"id": "b",
|
| 70 |
+
"name": "b",
|
| 71 |
+
"title": "BOT",
|
| 72 |
+
"rating": 3000,
|
| 73 |
+
"provisional": True},
|
| 74 |
+
"initialFen": "startpos",
|
| 75 |
+
"type": "gameFull",
|
| 76 |
+
"state": {"type": "gameState",
|
| 77 |
+
"moves": "",
|
| 78 |
+
"wtime": 10000,
|
| 79 |
+
"btime": 10000,
|
| 80 |
+
"winc": 100,
|
| 81 |
+
"binc": 100,
|
| 82 |
+
"status": "started"}}).encode("utf-8")
|
| 83 |
+
while True:
|
| 84 |
+
board = self.board_queue.get()
|
| 85 |
+
self.board_queue.task_done()
|
| 86 |
+
|
| 87 |
+
wtime, btime, increment = self.clock_queue.get()
|
| 88 |
+
self.clock_queue.task_done()
|
| 89 |
+
|
| 90 |
+
new_game_state = {"type": "gameState",
|
| 91 |
+
"moves": " ".join(move.uci() for move in board.move_stack),
|
| 92 |
+
"wtime": int(to_msec(wtime)),
|
| 93 |
+
"btime": int(to_msec(btime)),
|
| 94 |
+
"winc": int(to_msec(increment)),
|
| 95 |
+
"binc": int(to_msec(increment))}
|
| 96 |
+
|
| 97 |
+
if board.is_game_over():
|
| 98 |
+
new_game_state["status"] = "outoftime"
|
| 99 |
+
new_game_state["winner"] = "black"
|
| 100 |
+
yield json.dumps(new_game_state).encode("utf-8")
|
| 101 |
+
break
|
| 102 |
+
|
| 103 |
+
if board.move_stack:
|
| 104 |
+
new_game_state["status"] = "started"
|
| 105 |
+
yield json.dumps(new_game_state).encode("utf-8")
|
| 106 |
+
|
| 107 |
+
def __enter__(self) -> GameStream: # noqa: PYI034
|
| 108 |
+
"""Enter game stream context."""
|
| 109 |
+
return self
|
| 110 |
+
|
| 111 |
+
def __exit__(self, *args: object) -> None:
|
| 112 |
+
"""Exit game stream context."""
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class EventStream(Response):
|
| 116 |
+
"""Imitate lichess.org's EventStream. Used in tests."""
|
| 117 |
+
|
| 118 |
+
def __init__(self, sent_game: bool = False) -> None:
|
| 119 |
+
"""
|
| 120 |
+
Start the event stream for the lichess_bot_main() loop.
|
| 121 |
+
|
| 122 |
+
:param sent_game: If we have already sent the `gameStart` event, so we don't send it again.
|
| 123 |
+
"""
|
| 124 |
+
self.sent_game = sent_game
|
| 125 |
+
|
| 126 |
+
def iter_lines(self, chunk_size: int | None = 512, decode_unicode: bool = False,
|
| 127 |
+
delimiter: str | bytes | None = None) -> Generator[bytes, None, None]:
|
| 128 |
+
"""Send the events to lichess-bot."""
|
| 129 |
+
if self.sent_game:
|
| 130 |
+
yield b""
|
| 131 |
+
time.sleep(1)
|
| 132 |
+
else:
|
| 133 |
+
yield json.dumps(
|
| 134 |
+
{"type": "gameStart",
|
| 135 |
+
"game": {"id": "zzzzzzzz",
|
| 136 |
+
"source": "friend",
|
| 137 |
+
"compat": {"bot": True,
|
| 138 |
+
"board": True}}}).encode("utf-8")
|
| 139 |
+
|
| 140 |
+
def __enter__(self) -> EventStream: # noqa: PYI034
|
| 141 |
+
"""Enter context block."""
|
| 142 |
+
return self
|
| 143 |
+
|
| 144 |
+
def __exit__(self, *args: object) -> None:
|
| 145 |
+
"""Exit context block."""
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# Docs: https://lichess.org/api.
|
| 149 |
+
class Lichess(OriginalLichess):
|
| 150 |
+
"""Imitate communication with lichess.org."""
|
| 151 |
+
|
| 152 |
+
def __init__(self,
|
| 153 |
+
move_queue: Queue[chess.Move | None],
|
| 154 |
+
board_queue: Queue[chess.Board],
|
| 155 |
+
clock_queue: Queue[tuple[datetime.timedelta, datetime.timedelta, datetime.timedelta]]) -> None:
|
| 156 |
+
"""
|
| 157 |
+
Capture the interprocess queues to distribute them to the eventStream and gameStream instances.
|
| 158 |
+
|
| 159 |
+
:param move_queue: An interprocess queue to send moves chosen by the bot under test to the mock lichess function.
|
| 160 |
+
:param board_queue: An interprocess queue to send board positions to the mock game stream.
|
| 161 |
+
:param clock_queue: An interprocess queue to send game clock information to the mock game stream.
|
| 162 |
+
"""
|
| 163 |
+
self.baseUrl = "testing"
|
| 164 |
+
self.move_queue = move_queue
|
| 165 |
+
self.board_queue = board_queue
|
| 166 |
+
self.clock_queue = clock_queue
|
| 167 |
+
self.sent_game = False
|
| 168 |
+
self.started_game_stream = False
|
| 169 |
+
|
| 170 |
+
def upgrade_to_bot_account(self) -> None:
|
| 171 |
+
"""Isn't used in tests."""
|
| 172 |
+
|
| 173 |
+
def make_move(self, game_id: str, move: chess.engine.PlayResult) -> None:
|
| 174 |
+
"""Send a move to the opponent engine thread."""
|
| 175 |
+
self.move_queue.put(move.move)
|
| 176 |
+
|
| 177 |
+
def accept_takeback(self, game_id: str, accept: bool) -> bool:
|
| 178 |
+
"""Isn't used in tests."""
|
| 179 |
+
return False
|
| 180 |
+
|
| 181 |
+
def chat(self, game_id: str, room: str, text: str) -> None:
|
| 182 |
+
"""Isn't used in tests."""
|
| 183 |
+
|
| 184 |
+
def abort(self, game_id: str) -> None:
|
| 185 |
+
"""Isn't used in tests."""
|
| 186 |
+
|
| 187 |
+
def get_event_stream(self) -> EventStream:
|
| 188 |
+
"""Send the `EventStream`."""
|
| 189 |
+
events = EventStream(self.sent_game)
|
| 190 |
+
self.sent_game = True
|
| 191 |
+
return events
|
| 192 |
+
|
| 193 |
+
def get_game_stream(self, game_id: str) -> GameStream:
|
| 194 |
+
"""Send the `GameStream`."""
|
| 195 |
+
if self.started_game_stream:
|
| 196 |
+
self.move_queue.put(None)
|
| 197 |
+
self.started_game_stream = True
|
| 198 |
+
return GameStream(self.board_queue, self.clock_queue)
|
| 199 |
+
|
| 200 |
+
def accept_challenge(self, challenge_id: str) -> None:
|
| 201 |
+
"""Isn't used in tests."""
|
| 202 |
+
|
| 203 |
+
def decline_challenge(self, challenge_id: str, reason: str = "generic") -> None:
|
| 204 |
+
"""Isn't used in tests."""
|
| 205 |
+
|
| 206 |
+
def get_profile(self) -> UserProfileType:
|
| 207 |
+
"""Return a simple profile for the bot that lichess-bot uses when testing."""
|
| 208 |
+
return {"id": "b",
|
| 209 |
+
"username": "b",
|
| 210 |
+
"online": True,
|
| 211 |
+
"title": "BOT",
|
| 212 |
+
"url": "https://lichess.org/@/b",
|
| 213 |
+
"followable": True,
|
| 214 |
+
"following": False,
|
| 215 |
+
"blocking": False,
|
| 216 |
+
"followsYou": False,
|
| 217 |
+
"perfs": {}}
|
| 218 |
+
|
| 219 |
+
def get_ongoing_games(self) -> list[GameType]:
|
| 220 |
+
"""Return that the bot isn't playing a game."""
|
| 221 |
+
return []
|
| 222 |
+
|
| 223 |
+
def resign(self, game_id: str) -> None:
|
| 224 |
+
"""Isn't used in tests."""
|
| 225 |
+
|
| 226 |
+
def get_game_pgn(self, game_id: str) -> str:
|
| 227 |
+
"""Return a simple PGN."""
|
| 228 |
+
return """
|
| 229 |
+
[Event "Test game"]
|
| 230 |
+
[Site "pytest"]
|
| 231 |
+
[Date "2022.03.11"]
|
| 232 |
+
[Round "1"]
|
| 233 |
+
[White "bo"]
|
| 234 |
+
[Black "b"]
|
| 235 |
+
[Result "0-1"]
|
| 236 |
+
|
| 237 |
+
*
|
| 238 |
+
"""
|
| 239 |
+
|
| 240 |
+
def get_online_bots(self) -> list[UserProfileType]:
|
| 241 |
+
"""Return that the only bot online is us."""
|
| 242 |
+
return [{"username": "b", "online": True}]
|
| 243 |
+
|
| 244 |
+
def challenge(self, username: str, payload: REQUESTS_PAYLOAD_TYPE) -> ChallengeType:
|
| 245 |
+
"""Isn't used in tests."""
|
| 246 |
+
return {}
|
| 247 |
+
|
| 248 |
+
def cancel(self, challenge_id: str) -> None:
|
| 249 |
+
"""Isn't used in tests."""
|
| 250 |
+
|
| 251 |
+
def online_book_get(self, path: str, params: dict[str, str | int] | None = None, *,
|
| 252 |
+
stream: bool = False, authenticated: bool = False) -> OnlineType:
|
| 253 |
+
"""Isn't used in tests."""
|
| 254 |
+
return {}
|
| 255 |
+
|
| 256 |
+
def is_online(self, user_id: str) -> bool:
|
| 257 |
+
"""Return that a bot is online."""
|
| 258 |
+
return True
|
| 259 |
+
|
| 260 |
+
def get_public_data(self, user_name: str) -> PublicDataType:
|
| 261 |
+
"""Isn't used in tests."""
|
| 262 |
+
return {}
|
lichess-bot-master/test_bot/ruff.toml
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
target-version = "py310"
|
| 2 |
+
|
| 3 |
+
# The GitHub editor is 127 chars wide.
|
| 4 |
+
line-length = 127
|
| 5 |
+
|
| 6 |
+
[lint]
|
| 7 |
+
select = ["ALL"]
|
| 8 |
+
|
| 9 |
+
ignore = [
|
| 10 |
+
"ANN401", # Dynamically typed expressions (typing.Any) are disallowed
|
| 11 |
+
"BLE001", # Do not catch blind exception: Exception
|
| 12 |
+
"COM812", # Trailing comma missing
|
| 13 |
+
"D203", # Require blank line after class declaration before docstring
|
| 14 |
+
"D212", # Start multiline docstring on same line as triple-quote
|
| 15 |
+
"DTZ005", # datetime without timezone
|
| 16 |
+
"EM101", # Exception must not use a string literal, assign to variable first
|
| 17 |
+
"EM102", # Exception must not use an f-string literal, assign to variable first
|
| 18 |
+
"ERA001", # Found commented-out code
|
| 19 |
+
"FBT", # Boolean argument in function definition
|
| 20 |
+
"G", # Logging
|
| 21 |
+
"I001", # Import block is un-sorted or un-formatted
|
| 22 |
+
"N803", # Argument name should be lowercase
|
| 23 |
+
"N806", # Variable in function should be lowercase
|
| 24 |
+
"PERF203", # try-except within a loop incurs performance overhead
|
| 25 |
+
"PLC0415", # `import` should be at the top-level of a file
|
| 26 |
+
"PLR0913", # Too many arguments in function definition
|
| 27 |
+
"PLR0915", # Too many statements
|
| 28 |
+
"PLR2004", # Magic value used in comparison, consider replacing `20` with a constant variable
|
| 29 |
+
"PLW0603", # Using the global statement to update variable is discouraged
|
| 30 |
+
"PT018", # Assertion should be broken down into multiple parts
|
| 31 |
+
"PTH", # Replace builtin functions with Path methods
|
| 32 |
+
"RET505", # Unnecessary else after return statement
|
| 33 |
+
"RUF005", # Consider [*list1, None] instead of concatenation (list1 + [None])
|
| 34 |
+
"RUF021", # Parenthesize `a and b` expressions when chaining `and` and `or` together, to make the precedence clear
|
| 35 |
+
"S101", # Use of assert detected
|
| 36 |
+
"S311", # Standard pseudo-random generators are not suitable for cryptographic purposes
|
| 37 |
+
"SIM108", # Use ternary operator instead of `if`-`else`-block
|
| 38 |
+
"TC001", # Move application import into a type-checking block
|
| 39 |
+
"TC002", # Move third-party import into a type-checking block
|
| 40 |
+
"TC003", # Move standard library import into a type-checking block
|
| 41 |
+
"TC006", # Add quotes to type expression in `typing.cast()`
|
| 42 |
+
"TRY", # Try-except suggestions
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
[lint.mccabe]
|
| 46 |
+
max-complexity = 10
|
lichess-bot-master/test_bot/test-requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
pytest~=9.0
|
| 2 |
+
pytest-timeout~=2.4
|
| 3 |
+
ruff~=0.15
|
| 4 |
+
mypy~=2.1
|
| 5 |
+
types-requests~=2.33
|
| 6 |
+
types-PyYAML~=6.0
|
lichess-bot-master/test_bot/test_blocklist.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the blocklist module."""
|
| 2 |
+
from unittest.mock import Mock, patch
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
from urllib3.exceptions import HTTPError
|
| 6 |
+
|
| 7 |
+
from lib.blocklist import BlocklistData, _parse_block_list_from_url, OnlineBlocklist
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_parse_block_list_from_url_success() -> None:
|
| 11 |
+
"""Test parsing blocklist from URL with successful response."""
|
| 12 |
+
mock_response = Mock()
|
| 13 |
+
mock_response.status_code = 200
|
| 14 |
+
mock_response.text = "user1\nuser2\nuser3"
|
| 15 |
+
mock_response.headers = {"ETag": "test-etag-123"}
|
| 16 |
+
mock_response.raise_for_status = Mock()
|
| 17 |
+
|
| 18 |
+
old_data = BlocklistData([], None)
|
| 19 |
+
|
| 20 |
+
with patch("lib.blocklist.requests.get", return_value=mock_response):
|
| 21 |
+
result = _parse_block_list_from_url("http://example.com/blocklist", old_data)
|
| 22 |
+
|
| 23 |
+
assert result.users == ["user1", "user2", "user3"]
|
| 24 |
+
assert result.etag == "test-etag-123"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_parse_block_list_from_url__several_line_breaks() -> None:
|
| 28 |
+
"""Test parsing blocklist from URL with several line-breaks."""
|
| 29 |
+
mock_response = Mock()
|
| 30 |
+
mock_response.status_code = 200
|
| 31 |
+
mock_response.headers = {"ETag": "test-etag-123"}
|
| 32 |
+
mock_response.raise_for_status = Mock()
|
| 33 |
+
|
| 34 |
+
old_data = BlocklistData([], None)
|
| 35 |
+
|
| 36 |
+
texts = [
|
| 37 |
+
"user1\nuser2\nuser3",
|
| 38 |
+
"user1\r\nuser2\r\n\r\nuser3",
|
| 39 |
+
"user1\ruser2\r\ruser3",
|
| 40 |
+
"user1\nuser2\nuser3",
|
| 41 |
+
]
|
| 42 |
+
for text in texts:
|
| 43 |
+
mock_response.text = text
|
| 44 |
+
|
| 45 |
+
with patch("lib.blocklist.requests.get", return_value=mock_response):
|
| 46 |
+
result = _parse_block_list_from_url("http://example.com/blocklist", old_data)
|
| 47 |
+
|
| 48 |
+
assert result.users == ["user1", "user2", "user3"]
|
| 49 |
+
assert result.etag == "test-etag-123"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_parse_block_list_from_url_not_modified() -> None:
|
| 53 |
+
"""Test parsing blocklist returns cached data when not modified (304)."""
|
| 54 |
+
mock_response = Mock()
|
| 55 |
+
mock_response.status_code = 304
|
| 56 |
+
mock_response.raise_for_status = Mock()
|
| 57 |
+
|
| 58 |
+
old_data = BlocklistData(["cached_user1", "cached_user2"], "old-etag")
|
| 59 |
+
|
| 60 |
+
with patch("lib.blocklist.requests.get", return_value=mock_response):
|
| 61 |
+
result = _parse_block_list_from_url("http://example.com/blocklist", old_data)
|
| 62 |
+
|
| 63 |
+
assert result.users == ["cached_user1", "cached_user2"]
|
| 64 |
+
assert result.etag == "old-etag"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_parse_block_list_from_url_with_etag_header() -> None:
|
| 68 |
+
"""Test that ETag is sent in request headers when available."""
|
| 69 |
+
mock_response = Mock()
|
| 70 |
+
mock_response.status_code = 200
|
| 71 |
+
mock_response.text = "user1"
|
| 72 |
+
mock_response.headers = {}
|
| 73 |
+
mock_response.raise_for_status = Mock()
|
| 74 |
+
|
| 75 |
+
old_data = BlocklistData([], "existing-etag")
|
| 76 |
+
|
| 77 |
+
with patch("lib.blocklist.requests.get", return_value=mock_response) as mock_get:
|
| 78 |
+
_parse_block_list_from_url("http://example.com/blocklist", old_data)
|
| 79 |
+
mock_get.assert_called_once_with(
|
| 80 |
+
"http://example.com/blocklist",
|
| 81 |
+
headers={"If-None-Match": "existing-etag"},
|
| 82 |
+
timeout=15,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_parse_block_list_from_url_without_etag_header() -> None:
|
| 87 |
+
"""Test that no ETag header is sent when old_data has no ETag."""
|
| 88 |
+
mock_response = Mock()
|
| 89 |
+
mock_response.status_code = 200
|
| 90 |
+
mock_response.text = "user1"
|
| 91 |
+
mock_response.headers = {}
|
| 92 |
+
mock_response.raise_for_status = Mock()
|
| 93 |
+
|
| 94 |
+
old_data = BlocklistData([], None)
|
| 95 |
+
|
| 96 |
+
with patch("lib.blocklist.requests.get", return_value=mock_response) as mock_get:
|
| 97 |
+
_parse_block_list_from_url("http://example.com/blocklist", old_data)
|
| 98 |
+
mock_get.assert_called_once_with(
|
| 99 |
+
"http://example.com/blocklist",
|
| 100 |
+
headers={},
|
| 101 |
+
timeout=15
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_parse_block_list_from_url_strips_whitespace() -> None:
|
| 106 |
+
"""Test that usernames are stripped of leading/trailing whitespace."""
|
| 107 |
+
mock_response = Mock()
|
| 108 |
+
mock_response.status_code = 200
|
| 109 |
+
mock_response.text = " user1 \n user2\t\nuser3 "
|
| 110 |
+
mock_response.headers = {}
|
| 111 |
+
mock_response.raise_for_status = Mock()
|
| 112 |
+
|
| 113 |
+
old_data = BlocklistData([], None)
|
| 114 |
+
|
| 115 |
+
with patch("lib.blocklist.requests.get", return_value=mock_response):
|
| 116 |
+
result = _parse_block_list_from_url("http://example.com/blocklist", old_data)
|
| 117 |
+
|
| 118 |
+
assert result.users == ["user1", "user2", "user3"]
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_parse_block_list_from_url_empty_response() -> None:
|
| 122 |
+
"""Test parsing blocklist with empty response."""
|
| 123 |
+
mock_response = Mock()
|
| 124 |
+
mock_response.status_code = 200
|
| 125 |
+
mock_response.text = ""
|
| 126 |
+
mock_response.headers = {}
|
| 127 |
+
mock_response.raise_for_status = Mock()
|
| 128 |
+
|
| 129 |
+
old_data = BlocklistData([], None)
|
| 130 |
+
|
| 131 |
+
with patch("lib.blocklist.requests.get", return_value=mock_response):
|
| 132 |
+
result = _parse_block_list_from_url("http://example.com/blocklist", old_data)
|
| 133 |
+
|
| 134 |
+
assert result.users == []
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_parse_block_list_from_url_no_etag_in_response() -> None:
|
| 138 |
+
"""Test parsing blocklist when response has no ETag header."""
|
| 139 |
+
mock_response = Mock()
|
| 140 |
+
mock_response.status_code = 200
|
| 141 |
+
mock_response.text = "user1\nuser2"
|
| 142 |
+
mock_response.headers = {}
|
| 143 |
+
mock_response.raise_for_status = Mock()
|
| 144 |
+
|
| 145 |
+
old_data = BlocklistData([], None)
|
| 146 |
+
|
| 147 |
+
with patch("lib.blocklist.requests.get", return_value=mock_response):
|
| 148 |
+
result = _parse_block_list_from_url("http://example.com/blocklist", old_data)
|
| 149 |
+
|
| 150 |
+
assert result.users == ["user1", "user2"]
|
| 151 |
+
assert result.etag is None
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def test_parse_block_list_from_url_multiline_with_blanks() -> None:
|
| 155 |
+
"""Test parsing blocklist that contains blank lines."""
|
| 156 |
+
mock_response = Mock()
|
| 157 |
+
mock_response.status_code = 200
|
| 158 |
+
mock_response.text = "user1\n\nuser2\n\n\nuser3"
|
| 159 |
+
mock_response.headers = {}
|
| 160 |
+
mock_response.raise_for_status = Mock()
|
| 161 |
+
|
| 162 |
+
old_data = BlocklistData([], None)
|
| 163 |
+
|
| 164 |
+
with patch("lib.blocklist.requests.get", return_value=mock_response):
|
| 165 |
+
result = _parse_block_list_from_url("http://example.com/blocklist", old_data)
|
| 166 |
+
|
| 167 |
+
assert result.users == ["user1", "user2", "user3"]
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def test_parse_block_list_from_url__exception() -> None:
|
| 171 |
+
"""Test parsing blocklist from URL with exception."""
|
| 172 |
+
mock_response = Mock()
|
| 173 |
+
mock_response.status_code = 500
|
| 174 |
+
mock_response.raise_for_status.side_effect = HTTPError("500 Internal Server Error")
|
| 175 |
+
|
| 176 |
+
old_data = BlocklistData([], None)
|
| 177 |
+
|
| 178 |
+
with patch("lib.blocklist.requests.get", return_value=mock_response), pytest.raises(HTTPError):
|
| 179 |
+
_parse_block_list_from_url("http://example.com/blocklist", old_data)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def test_online_blocklist_refresh_success() -> None:
|
| 183 |
+
"""Test successful refresh of all blocklists."""
|
| 184 |
+
mock_response1 = Mock()
|
| 185 |
+
mock_response1.status_code = 200
|
| 186 |
+
mock_response1.text = "user1\nuser2"
|
| 187 |
+
mock_response1.headers = {"ETag": "etag1"}
|
| 188 |
+
mock_response1.raise_for_status = Mock()
|
| 189 |
+
|
| 190 |
+
mock_response2 = Mock()
|
| 191 |
+
mock_response2.status_code = 200
|
| 192 |
+
mock_response2.text = "user3\nuser4\nuser5"
|
| 193 |
+
mock_response2.headers = {"ETag": "etag2"}
|
| 194 |
+
mock_response2.raise_for_status = Mock()
|
| 195 |
+
|
| 196 |
+
with patch("lib.blocklist.requests.get") as mock_get:
|
| 197 |
+
mock_get.side_effect = [mock_response1, mock_response2]
|
| 198 |
+
blocklist = OnlineBlocklist(["http://example.com/list1", "http://example.com/list2"])
|
| 199 |
+
|
| 200 |
+
assert blocklist.blocklist["http://example.com/list1"].users == ["user1", "user2"]
|
| 201 |
+
assert blocklist.blocklist["http://example.com/list1"].etag == "etag1"
|
| 202 |
+
assert blocklist.blocklist["http://example.com/list2"].users == ["user3", "user4", "user5"]
|
| 203 |
+
assert blocklist.blocklist["http://example.com/list2"].etag == "etag2"
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def test_online_blocklist_refresh_partial_failure() -> None:
|
| 207 |
+
"""Test refresh when some blocklists fail to load."""
|
| 208 |
+
mock_response_success = Mock()
|
| 209 |
+
mock_response_success.status_code = 200
|
| 210 |
+
mock_response_success.text = "user1\nuser2"
|
| 211 |
+
mock_response_success.headers = {"ETag": "etag1"}
|
| 212 |
+
mock_response_success.raise_for_status = Mock()
|
| 213 |
+
|
| 214 |
+
mock_response_fail = Mock()
|
| 215 |
+
mock_response_fail.raise_for_status.side_effect = HTTPError("500 Server Error")
|
| 216 |
+
|
| 217 |
+
with patch("lib.blocklist.requests.get") as mock_get:
|
| 218 |
+
# First call succeeds, second call fails
|
| 219 |
+
mock_get.side_effect = [mock_response_success, mock_response_fail]
|
| 220 |
+
blocklist = OnlineBlocklist(["http://example.com/list1", "http://example.com/list2"])
|
| 221 |
+
|
| 222 |
+
# First blocklist should be loaded
|
| 223 |
+
assert blocklist.blocklist["http://example.com/list1"].users == ["user1", "user2"]
|
| 224 |
+
|
| 225 |
+
# Second blocklist should remain empty (failed to load)
|
| 226 |
+
assert blocklist.blocklist["http://example.com/list2"].users == []
|
| 227 |
+
assert blocklist.blocklist["http://example.com/list2"].etag is None
|
lichess-bot-master/test_bot/test_bot.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Test lichess-bot."""
|
| 2 |
+
import yaml
|
| 3 |
+
import chess
|
| 4 |
+
import chess.engine
|
| 5 |
+
import threading
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import datetime
|
| 9 |
+
import time
|
| 10 |
+
import logging
|
| 11 |
+
import tempfile
|
| 12 |
+
from multiprocessing import Manager
|
| 13 |
+
from queue import Queue
|
| 14 |
+
import test_bot.lichess
|
| 15 |
+
from lib import config
|
| 16 |
+
from lib.timer import Timer, seconds
|
| 17 |
+
from lib.engine_wrapper import test_suffix
|
| 18 |
+
from lib.lichess_types import CONFIG_DICT_TYPE
|
| 19 |
+
if "pytest" not in sys.modules:
|
| 20 |
+
sys.exit(f"The script {os.path.basename(__file__)} should only be run by pytest.")
|
| 21 |
+
from lib import lichess_bot
|
| 22 |
+
from test_bot.test_games import scholars_mate
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
logging_level = logging.DEBUG
|
| 26 |
+
testing_log_file_name = None
|
| 27 |
+
lichess_bot.logging_configurer(logging_level, testing_log_file_name, True)
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def lichess_org_simulator(move_queue: Queue[chess.Move | None],
|
| 32 |
+
board_queue: Queue[chess.Board],
|
| 33 |
+
clock_queue: Queue[tuple[datetime.timedelta, datetime.timedelta, datetime.timedelta]],
|
| 34 |
+
results: Queue[bool]) -> None:
|
| 35 |
+
"""
|
| 36 |
+
Run a mocked version of the lichess.org server to provide an opponent for a test. This opponent always plays white.
|
| 37 |
+
|
| 38 |
+
:param opponent_path: The path to the executable of the opponent. Usually Stockfish.
|
| 39 |
+
:param move_queue: An interprocess queue that supplies the moves chosen by the bot being tested.
|
| 40 |
+
:param board_queue: An interprocess queue where this function sends the updated board after choosing a move.
|
| 41 |
+
:param clock_queue: An interprocess queue where this function sends the updated game clock after choosing a move.
|
| 42 |
+
:param results: An interprocess queue where this function sends the result of the game to the testing function.
|
| 43 |
+
"""
|
| 44 |
+
start_time = seconds(10)
|
| 45 |
+
increment = seconds(0.1)
|
| 46 |
+
|
| 47 |
+
board = chess.Board()
|
| 48 |
+
wtime = start_time
|
| 49 |
+
btime = start_time
|
| 50 |
+
|
| 51 |
+
while not board.is_game_over():
|
| 52 |
+
move_timer = Timer()
|
| 53 |
+
if board.turn == chess.WHITE:
|
| 54 |
+
move_count = len(board.move_stack)
|
| 55 |
+
engine_move = board.parse_uci(scholars_mate[move_count])
|
| 56 |
+
board.push(engine_move)
|
| 57 |
+
board_queue.put(board)
|
| 58 |
+
clock_queue.put((wtime, btime, increment))
|
| 59 |
+
if len(board.move_stack) > 1:
|
| 60 |
+
wtime -= move_timer.time_since_reset()
|
| 61 |
+
wtime += increment
|
| 62 |
+
else:
|
| 63 |
+
move_timer = Timer()
|
| 64 |
+
while (bot_move := move_queue.get()) is None:
|
| 65 |
+
board_queue.put(board)
|
| 66 |
+
clock_queue.put((wtime, btime, increment))
|
| 67 |
+
move_queue.task_done()
|
| 68 |
+
board.push(bot_move)
|
| 69 |
+
move_queue.task_done()
|
| 70 |
+
if len(board.move_stack) > 2:
|
| 71 |
+
btime -= move_timer.time_since_reset()
|
| 72 |
+
btime += increment
|
| 73 |
+
|
| 74 |
+
board_queue.put(board)
|
| 75 |
+
clock_queue.put((wtime, btime, increment))
|
| 76 |
+
outcome = board.outcome()
|
| 77 |
+
results.put(outcome is not None and outcome.winner == chess.BLACK)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def run_bot(raw_config: CONFIG_DICT_TYPE, logging_level: int) -> bool:
|
| 81 |
+
"""
|
| 82 |
+
Start lichess-bot test with a mocked version of the lichess.org site.
|
| 83 |
+
|
| 84 |
+
:param raw_config: A dictionary of values to specify the engine to test. This engine will play as white.
|
| 85 |
+
:param logging_level: The level of logging to use during the test. Usually logging.DEBUG.
|
| 86 |
+
:param opponent_path: The path to the executable that will play the opponent. The opponent plays as black.
|
| 87 |
+
"""
|
| 88 |
+
config.insert_default_values(raw_config)
|
| 89 |
+
CONFIG = config.Configuration(raw_config)
|
| 90 |
+
logger.info(lichess_bot.intro())
|
| 91 |
+
manager = Manager()
|
| 92 |
+
board_queue: Queue[chess.Board] = manager.Queue()
|
| 93 |
+
clock_queue: Queue[tuple[datetime.timedelta, datetime.timedelta, datetime.timedelta]] = manager.Queue()
|
| 94 |
+
move_queue: Queue[chess.Move | None] = manager.Queue()
|
| 95 |
+
li = test_bot.lichess.Lichess(move_queue, board_queue, clock_queue)
|
| 96 |
+
|
| 97 |
+
user_profile = li.get_profile()
|
| 98 |
+
username = user_profile["username"]
|
| 99 |
+
if user_profile.get("title") != "BOT":
|
| 100 |
+
return False
|
| 101 |
+
logger.info(f"Welcome {username}!")
|
| 102 |
+
lichess_bot.disable_restart()
|
| 103 |
+
|
| 104 |
+
results: Queue[bool] = manager.Queue()
|
| 105 |
+
thr = threading.Thread(target=lichess_org_simulator, args=[move_queue, board_queue, clock_queue, results])
|
| 106 |
+
thr.start()
|
| 107 |
+
lichess_bot.start(li, user_profile, CONFIG, logging_level, testing_log_file_name, True, one_game=True)
|
| 108 |
+
|
| 109 |
+
result = results.get()
|
| 110 |
+
results.task_done()
|
| 111 |
+
|
| 112 |
+
results.join()
|
| 113 |
+
board_queue.join()
|
| 114 |
+
clock_queue.join()
|
| 115 |
+
move_queue.join()
|
| 116 |
+
|
| 117 |
+
thr.join()
|
| 118 |
+
|
| 119 |
+
return result
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_uci() -> None:
|
| 123 |
+
"""Test lichess-bot with Stockfish (UCI)."""
|
| 124 |
+
with open("./config.yml.default") as file:
|
| 125 |
+
CONFIG = yaml.safe_load(file)
|
| 126 |
+
|
| 127 |
+
with tempfile.TemporaryDirectory() as temp:
|
| 128 |
+
CONFIG["token"] = ""
|
| 129 |
+
CONFIG["engine"]["dir"] = "test_bot"
|
| 130 |
+
CONFIG["engine"]["name"] = "uci_engine.py"
|
| 131 |
+
CONFIG["engine"]["interpreter"] = sys.executable
|
| 132 |
+
CONFIG["pgn_directory"] = os.path.join(temp, "uci_game_record")
|
| 133 |
+
CONFIG["engine"]["uci_options"] = {}
|
| 134 |
+
win = run_bot(CONFIG, logging_level)
|
| 135 |
+
logger.info("Finished Testing UCI")
|
| 136 |
+
assert win
|
| 137 |
+
time.sleep(0.1) # Wait for file to be written.
|
| 138 |
+
assert os.path.isfile(os.path.join(CONFIG["pgn_directory"],
|
| 139 |
+
"bo vs b - zzzzzzzz.pgn"))
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def test_xboard() -> None:
|
| 143 |
+
"""Test lichess-bot with an XBoard engine."""
|
| 144 |
+
with open("./config.yml.default") as file:
|
| 145 |
+
CONFIG = yaml.safe_load(file)
|
| 146 |
+
|
| 147 |
+
with tempfile.TemporaryDirectory() as temp:
|
| 148 |
+
CONFIG["token"] = ""
|
| 149 |
+
CONFIG["engine"]["dir"] = "test_bot"
|
| 150 |
+
CONFIG["engine"]["name"] = "xboard_engine.py"
|
| 151 |
+
CONFIG["engine"]["protocol"] = "xboard"
|
| 152 |
+
CONFIG["engine"]["interpreter"] = sys.executable
|
| 153 |
+
CONFIG["pgn_directory"] = os.path.join(temp, "lc0_game_record")
|
| 154 |
+
win = run_bot(CONFIG, logging_level)
|
| 155 |
+
logger.info("Finished Testing XBoard")
|
| 156 |
+
assert win
|
| 157 |
+
time.sleep(0.1) # Wait for file to be written.
|
| 158 |
+
assert os.path.isfile(os.path.join(CONFIG["pgn_directory"],
|
| 159 |
+
"bo vs b - zzzzzzzz.pgn"))
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def test_homemade() -> None:
|
| 163 |
+
"""Test lichess-bot with a homemade engine."""
|
| 164 |
+
with open("./config.yml.default") as file:
|
| 165 |
+
CONFIG = yaml.safe_load(file)
|
| 166 |
+
|
| 167 |
+
with tempfile.TemporaryDirectory() as temp:
|
| 168 |
+
CONFIG["token"] = ""
|
| 169 |
+
CONFIG["engine"]["name"] = f"ScholarsMate{test_suffix}"
|
| 170 |
+
CONFIG["engine"]["protocol"] = "homemade"
|
| 171 |
+
CONFIG["pgn_directory"] = os.path.join(temp, "homemade_game_record")
|
| 172 |
+
win = run_bot(CONFIG, logging_level)
|
| 173 |
+
logger.info("Finished Testing Homemade")
|
| 174 |
+
assert win
|
| 175 |
+
time.sleep(0.1) # Wait for file to be written.
|
| 176 |
+
assert os.path.isfile(os.path.join(CONFIG["pgn_directory"],
|
| 177 |
+
"bo vs b - zzzzzzzz.pgn"))
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def test_buggy_engine() -> None:
|
| 181 |
+
"""Test lichess-bot with an engine that causes a timeout error within python-chess."""
|
| 182 |
+
with open("./config.yml.default") as file:
|
| 183 |
+
CONFIG = yaml.safe_load(file)
|
| 184 |
+
|
| 185 |
+
with tempfile.TemporaryDirectory() as temp:
|
| 186 |
+
CONFIG["token"] = ""
|
| 187 |
+
CONFIG["engine"]["dir"] = "test_bot"
|
| 188 |
+
CONFIG["engine"]["name"] = "buggy_engine.py"
|
| 189 |
+
CONFIG["engine"]["interpreter"] = sys.executable
|
| 190 |
+
CONFIG["engine"]["uci_options"] = {"go_commands": {"movetime": 100}}
|
| 191 |
+
CONFIG["pgn_directory"] = os.path.join(temp, "bug_game_record")
|
| 192 |
+
win = run_bot(CONFIG, logging_level)
|
| 193 |
+
logger.info("Finished Testing Buggy Engine")
|
| 194 |
+
assert win
|
| 195 |
+
time.sleep(0.1) # Wait for file to be written.
|
| 196 |
+
assert os.path.isfile(os.path.join(CONFIG["pgn_directory"],
|
| 197 |
+
"bo vs b - zzzzzzzz.pgn"))
|
lichess-bot-master/test_bot/test_config.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Test functions for config module."""
|
| 2 |
+
import logging
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
from lib import config
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def test_config_assert__false() -> None:
|
| 10 |
+
"""Test that config_assert raises an exception with the provided error message."""
|
| 11 |
+
with pytest.raises(Exception, match="some error"):
|
| 12 |
+
config.config_assert(False, "some error")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_config_assert__true() -> None:
|
| 16 |
+
"""Test that config_assert does not raise when assertion is True."""
|
| 17 |
+
config.config_assert(True, "no error")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_config_warn__true(caplog: pytest.LogCaptureFixture) -> None:
|
| 21 |
+
"""Test that config_warn does not log a warning when assertion is True."""
|
| 22 |
+
with caplog.at_level(logging.WARNING):
|
| 23 |
+
config.config_warn(True, "this should not appear")
|
| 24 |
+
assert len(caplog.records) == 0 # No warning should be logged
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_config_warn__false(caplog: pytest.LogCaptureFixture) -> None:
|
| 28 |
+
"""Test that config_warn logs a warning when assertion is False."""
|
| 29 |
+
with caplog.at_level(logging.WARNING):
|
| 30 |
+
config.config_warn(False, "test warning message")
|
| 31 |
+
assert "test warning message" in caplog.text
|
| 32 |
+
assert len(caplog.records) == 1
|
| 33 |
+
assert caplog.records[0].levelname == "WARNING"
|