added lichess bot

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

View File

@@ -0,0 +1,29 @@
---
name: Bug report
about: Create a bug report
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Run lichess-bot with '...' commands [e.g. `python lichess-bot.py -v`]
2. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Logs**
Upload `lichess_bot_auto_logs\old.log`, `lichess_bot_auto_logs\recent.log`, and other logs/screenshots of the error.
**Desktop (please complete the following information):**
- OS: [e.g. Windows]
- Python Version [e.g. 3.11]
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

16
lichess_bot/.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,16 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "daily"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"

View File

@@ -0,0 +1,20 @@
## Type of pull request:
- [ ] Bug fix
- [ ] Feature
- [ ] Other
## Description:
[Provide a brief description of the changes introduced by this pull request.]
## Related Issues:
[Reference any related issues that this pull request addresses or closes. Use the syntax `Closes #issue_number` to automatically close the linked issue upon merging.]
## Checklist:
- [ ] I have read and followed the [contribution guidelines](/CONTRIBUTING.md).
- [ ] I have added necessary documentation (if applicable).
- [ ] The changes pass all existing tests.
## Screenshots/logs (if applicable):

32
lichess_bot/.github/workflows/mypy.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
# This workflow will install Python dependencies and run mypy
name: Mypy
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
mypy:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [windows-latest]
python: [3.9, "3.11"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r test_bot/test-requirements.txt
- name: Run mypy
run: |
mypy --strict .

View File

@@ -0,0 +1,44 @@
# This workflow will install Python dependencies and lint
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
name: Python Build
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python: [3.9, "3.10", "3.11"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r test_bot/test-requirements.txt
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide.
# W503 and W504 are mutually exclusive. W504 is considered the best practice now.
flake8 . --count --max-complexity=10 --max-line-length=127 --statistics --ignore=D,W503
- name: Lint with flake8-markdown
run: |
flake8-markdown "*.md"
flake8-markdown "wiki/*.md"
- name: Lint with flake8-docstrings
run: |
flake8 . --count --max-line-length=127 --statistics --select=D

View File

@@ -0,0 +1,48 @@
# This workflow will install Python dependencies and run tests
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
name: Python Test
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
python: [3.9, "3.11"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r test_bot/test-requirements.txt
- name: Restore engines
id: cache-temp-restore
uses: actions/cache/restore@v4
with:
path: |
TEMP
key: ${{ matrix.os }}-engines
- name: Test with pytest
run: |
pytest --log-cli-level=10
- name: Save engines
id: cache-temp-save
uses: actions/cache/save@v4
with:
path: |
TEMP
key: ${{ steps.cache-temp-restore.outputs.cache-primary-key }}

View File

@@ -0,0 +1,65 @@
name: Sync wiki
on:
push:
branches:
- master
paths:
- 'wiki/**'
- 'README.md'
jobs:
sync:
if: github.repository == 'lichess_bot-devs/lichess_bot'
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Checkout wiki
uses: actions/checkout@v4
with:
repository: "${{ github.repository }}.wiki"
path: "lichess_bot.wiki"
- name: Set path
uses: dorny/paths-filter@v2
id: changes
with:
filters: |
wiki:
- 'wiki/**'
home:
- 'wiki/Home.md'
readme:
- 'README.md'
- name: Prevent Conflicts
if: |
steps.changes.outputs.home == 'true' &&
steps.changes.outputs.readme == 'true'
run: |
echo "Error: Conflicting changes. Edit either the README.md file or the wiki/Home.md file, not both."
exit 1
- name: Set github bot global config
run: |
git config --global user.email "actions@github.com"
git config --global user.name "actions-user"
- name: Sync README.md to wiki/Home.md
if: steps.changes.outputs.readme == 'true'
run: |
cp -r $GITHUB_WORKSPACE/README.md $GITHUB_WORKSPACE/wiki/Home.md
git add wiki/Home.md
git commit -m "Auto update wiki/Home.md"
git push
- name: Sync wiki/Home.md to README.md
if: steps.changes.outputs.home == 'true'
run: |
cp -r $GITHUB_WORKSPACE/wiki/Home.md $GITHUB_WORKSPACE/README.md
git add README.md
git commit -m "Auto update README.md"
git push
- name: Sync all files to wiki
run: |
cp -r $GITHUB_WORKSPACE/wiki/* $GITHUB_WORKSPACE/lichess-bot.wiki
cd $GITHUB_WORKSPACE/lichess-bot.wiki
git add .
git commit -m "Auto update wiki"
git push

View File

@@ -0,0 +1,26 @@
"""Automatically updates the lichess_bot version."""
import yaml
import datetime
import os
with open("lib/versioning.yml") as version_file:
versioning_info = yaml.safe_load(version_file)
current_version = versioning_info["lichess_bot_version"]
utc_datetime = datetime.datetime.utcnow()
new_version = f"{utc_datetime.year}.{utc_datetime.month}.{utc_datetime.day}."
if current_version.startswith(new_version):
current_version_list = current_version.split(".")
current_version_list[-1] = str(int(current_version_list[-1]) + 1)
new_version = ".".join(current_version_list)
else:
new_version += "1"
versioning_info["lichess_bot_version"] = new_version
with open("lib/versioning.yml", "w") as version_file:
yaml.dump(versioning_info, version_file, sort_keys=False)
with open(os.environ['GITHUB_OUTPUT'], 'a') as fh:
print(f"new_version={new_version}", file=fh)

View File

@@ -0,0 +1,30 @@
name: Versioning
on:
push:
branches: [ master ]
jobs:
versioning:
if: github.repository == 'lichess_bot-devs/lichess_bot'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install PyYAML
- name: Update version
id: new-version
run: python .github/workflows/update_version.py
- name: Auto update version
run: |
git config --global user.email "actions@github.com"
git config --global user.name "actions-user"
git add lib/versioning.yml
git commit -m "Auto update version to ${{ steps.new-version.outputs.new_version }}"
git push origin HEAD:master

4
lichess_bot/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
config.yml
**/__pycache__
/engines/*
!/engines/README.md

35
lichess_bot/README.md Normal file
View File

@@ -0,0 +1,35 @@
# lichess-bot
[![Python Build](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-build.yml/badge.svg)](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-build.yml)
[![Python Test](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-test.yml/badge.svg)](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-test.yml)
[![Mypy](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/mypy.yml/badge.svg)](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/mypy.yml)
A bridge between [Lichess Bot API](https://lichess.org/api#tag/Bot) and bots.
## Features
Supports:
- Every variant and time control
- UCI, XBoard, and Homemade engines
- Matchmaking
- Offering Draw / Resigning
- Saving games as PGN
- Local & Online Opening Books
- Local & Online Endgame Tablebases
## Steps
1. [Install lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Install)
2. [Create a lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token)
3. [Upgrade to a BOT account](https://github.com/lichess-bot-devs/lichess-bot/wiki/Upgrade-to-a-BOT-account)
4. [Setup the engine](https://github.com/lichess-bot-devs/lichess-bot/wiki/Setup-the-engine)
5. [Configure lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/Configure-lichess-bot)
6. [Run lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Run-lichess%E2%80%90bot)
## Advanced options
- [Create a custom engine](https://github.com/lichess-bot-devs/lichess-bot/wiki/Create-a-custom-engine)
<br />
## Acknowledgements
Thanks to the Lichess team, especially T. Alexander Lystad and Thibault Duplessis for working with the LeelaChessZero team to get this API up. Thanks to the [Niklas Fiekas](https://github.com/niklasf) and his [python-chess](https://github.com/niklasf/python-chess) code which allows engine communication seamlessly.
## License
lichess-bot is licensed under the AGPLv3 (or any later version at your option). Check out the [LICENSE file](https://github.com/lichess-bot-devs/lichess-bot/blob/master/LICENSE) for the full text.

View File

@@ -0,0 +1,64 @@
# Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq

View File

@@ -0,0 +1,57 @@
# Contributing to lichess-bot
We welcome your contributions! There are multiple ways to contribute.
## Table of Contents
1. [Code of Conduct](#code-of-conduct)
2. [How to Contribute](#how-to-contribute)
3. [Reporting Bugs](#reporting-bugs)
4. [Requesting Features](#requesting-features)
5. [Submitting Pull Requests](#submitting-pull-requests)
6. [Testing](#testing)
7. [Documentation](#documentation)
## Code of Conduct
Please review our [Code of Conduct](/docs/CODE_OF_CONDUCT.md) before participating in our community. We want all contributors to feel welcome and to foster an open and inclusive environment.
## How to Contribute
We welcome contributions in the form of bug reports, feature requests, code changes, and documentation improvements. Here's how you can contribute:
- Fork the repository to your GitHub account.
- Create a new branch for your feature or bug fix.
- Make your changes and commit them with a clear and concise commit message.
- Push your changes to your branch.
- Submit a pull request to the main repository.
Please follow our [Pull Request Template](/.github/pull_request_template.md) when submitting a pull request.
## Reporting Bugs
If you find a bug, please open an issue with a detailed description of the problem. Include information about your environment and steps to reproduce the issue.
When filing a bug remember that the better written the bug is, the more likely it is to be fixed.
Please follow our [Bug Report Template](/.github/ISSUE_TEMPLATE/bug_report.md) when submitting a pull request.
## Requesting Features
We encourage you to open an issue to propose new features or improvements. Please provide as much detail as possible about your suggestion.
Please follow our [Feature Request Template](/.github/ISSUE_TEMPLATE/feature_request.md) when submitting a pull request.
## Submitting Pull Requests
When submitting a pull request, please ensure the following:
- You have added or updated relevant documentation.
- Tests (if applicable) have been added or updated.
- Your branch is up-to-date with the main repository.
- The pull request title and description are clear and concise.
## Testing
Ensure that your changes pass all existing tests and consider adding new tests if applicable.
## Documentation
Improvements to the documentation are always welcome. If you find areas that need clarification or additional information, please submit a pull request.

661
lichess_bot/docs/LICENSE Normal file
View File

@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<http://www.gnu.org/licenses/>.

View File

@@ -0,0 +1 @@
Put your engines and opening books here.

View File

@@ -0,0 +1 @@
"""This lib folder contains the library code necessary for running lichess_bot."""

382
lichess_bot/lib/config.py Normal file
View File

@@ -0,0 +1,382 @@
"""Code related to the config that lichess_bot uses."""
from __future__ import annotations
import yaml
import os
import os.path
import logging
import math
from abc import ABCMeta
from enum import Enum
from typing import Any, Union
CONFIG_DICT_TYPE = dict[str, Any]
logger = logging.getLogger(__name__)
class FilterType(str, Enum):
"""What to do if the opponent declines our challenge."""
NONE = "none"
"""Will still challenge the opponent."""
COARSE = "coarse"
"""Won't challenge the opponent again."""
FINE = "fine"
"""
Won't challenge the opponent to a game of the same mode, speed, and variant
based on the reason for the opponent declining the challenge.
"""
class Configuration:
"""The config or a sub-config that the bot uses."""
def __init__(self, parameters: CONFIG_DICT_TYPE) -> None:
""":param parameters: A `dict` containing the config for the bot."""
self.config = parameters
def __getattr__(self, name: str) -> Any:
"""
Enable the use of `config.key1.key2`.
:param name: The key to get its value.
:return: The value of the key.
"""
return self.lookup(name)
def lookup(self, name: str) -> Any:
"""
Get the value of a key.
:param name: The key to get its value.
:return: `Configuration` if the value is a `dict` else returns the value.
"""
data = self.config.get(name)
return Configuration(data) if isinstance(data, dict) else data
def items(self) -> Any:
""":return: All the key-value pairs in this config."""
return self.config.items()
def keys(self) -> list[str]:
""":return: All of the keys in this config."""
return list(self.config.keys())
def __or__(self, other: Union[Configuration, CONFIG_DICT_TYPE]) -> Configuration:
"""Create a copy of this configuration that is updated with values from the parameter."""
other_dict = other.config if isinstance(other, Configuration) else other
return Configuration(self.config | other_dict)
def __bool__(self) -> bool:
"""Whether `self.config` is empty."""
return bool(self.config)
def __getstate__(self) -> CONFIG_DICT_TYPE:
"""Get `self.config`."""
return self.config
def __setstate__(self, d: CONFIG_DICT_TYPE) -> None:
"""Set `self.config`."""
self.config = d
def config_assert(assertion: bool, error_message: str) -> None:
"""Raise an exception if an assertion is false."""
if not assertion:
raise Exception(error_message)
def check_config_section(config: CONFIG_DICT_TYPE, data_name: str, data_type: ABCMeta, subsection: str = "") -> None:
"""
Check the validity of a config section.
:param config: The config section.
:param data_name: The key to check its value.
:param data_type: The expected data type.
:param subsection: The subsection of the key.
"""
config_part = config[subsection] if subsection else config
sub = f"`{subsection}` sub" if subsection else ""
data_location = f"`{data_name}` subsection in `{subsection}`" if subsection else f"Section `{data_name}`"
type_error_message = {str: f"{data_location} must be a string wrapped in quotes.",
dict: f"{data_location} must be a dictionary with indented keys followed by colons."}
config_assert(data_name in config_part, f"Your config.yml does not have required {sub}section `{data_name}`.")
config_assert(isinstance(config_part[data_name], data_type), type_error_message[data_type])
def set_config_default(config: CONFIG_DICT_TYPE, *sections: str, key: str, default: Any,
force_empty_values: bool = False) -> CONFIG_DICT_TYPE:
"""
Fill a specific config key with the default value if it is missing.
:param config: The bot's config.
:param sections: The sections that the key is in.
:param key: The key to set.
:param default: The default value.
:param force_empty_values: Whether an empty value should be replaced with the default value.
:return: The new config with the default value inserted if needed.
"""
subconfig = config
for section in sections:
subconfig = subconfig.setdefault(section, {})
if not isinstance(subconfig, dict):
raise Exception(f"The {section} section in {sections} should hold a set of key-value pairs, not a value.")
if force_empty_values:
if subconfig.get(key) in [None, ""]:
subconfig[key] = default
else:
subconfig.setdefault(key, default)
return subconfig
def change_value_to_list(config: CONFIG_DICT_TYPE, *sections: str, key: str) -> None:
"""
Change a single value to a list. e.g. 60 becomes [60]. Used to maintain backwards compatibility.
:param config: The bot's config.
:param sections: The sections that the key is in.
:param key: The key to set.
"""
subconfig = set_config_default(config, *sections, key=key, default=[])
if subconfig[key] is None:
subconfig[key] = []
if not isinstance(subconfig[key], list):
subconfig[key] = [subconfig[key]]
def insert_default_values(CONFIG: CONFIG_DICT_TYPE) -> None:
"""
Insert the default values of most keys to the config if they are missing.
:param CONFIG: The bot's config.
"""
set_config_default(CONFIG, key="abort_time", default=20)
set_config_default(CONFIG, key="move_overhead", default=1000)
set_config_default(CONFIG, key="rate_limiting_delay", default=0)
set_config_default(CONFIG, key="pgn_file_grouping", default="game", force_empty_values=True)
set_config_default(CONFIG, "engine", key="working_dir", default=os.getcwd(), force_empty_values=True)
set_config_default(CONFIG, "engine", key="silence_stderr", default=False)
set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_enabled", default=False)
set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_for_egtb_zero", default=True)
set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_enabled", default=False)
set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_for_egtb_minus_two", default=True)
set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_moves", default=3)
set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_score", default=-1000)
set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_moves", default=5)
set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_score", default=0)
set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_pieces", default=10)
set_config_default(CONFIG, "engine", "online_moves", key="max_out_of_book_moves", default=10)
set_config_default(CONFIG, "engine", "online_moves", key="max_retries", default=2, force_empty_values=True)
set_config_default(CONFIG, "engine", "online_moves", key="max_depth", default=math.inf, force_empty_values=True)
set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="enabled", default=False)
set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="source", default="lichess")
set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="min_time", default=20)
set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="max_pieces", default=7)
set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="move_quality", default="best")
set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="enabled", default=False)
set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="min_time", default=20)
set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="move_quality", default="good")
set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="min_depth", default=20)
set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="enabled", default=False)
set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="min_time", default=20)
set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="move_quality", default="best")
set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="min_depth", default=20)
set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="min_knodes", default=0)
set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="max_score_difference", default=50)
set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="enabled", default=False)
set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="min_time", default=20)
set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="source", default="masters")
set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="player_name", default="")
set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="sort", default="winrate")
set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="min_games", default=10)
set_config_default(CONFIG, "engine", "lichess_bot_tbs", "syzygy", key="enabled", default=False)
set_config_default(CONFIG, "engine", "lichess_bot_tbs", "syzygy", key="max_pieces", default=7)
set_config_default(CONFIG, "engine", "lichess_bot_tbs", "syzygy", key="move_quality", default="best")
set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="enabled", default=False)
set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="max_pieces", default=5)
set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="move_quality", default="best")
set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="min_dtm_to_consider_as_wdl_1", default=120)
set_config_default(CONFIG, "engine", "polyglot", key="enabled", default=False)
set_config_default(CONFIG, "engine", "polyglot", key="max_depth", default=8)
set_config_default(CONFIG, "engine", "polyglot", key="selection", default="weighted_random")
set_config_default(CONFIG, "engine", "polyglot", key="min_weight", default=1)
set_config_default(CONFIG, "challenge", key="concurrency", default=1)
set_config_default(CONFIG, "challenge", key="sort_by", default="best")
set_config_default(CONFIG, "challenge", key="accept_bot", default=False)
set_config_default(CONFIG, "challenge", key="only_bot", default=False)
set_config_default(CONFIG, "challenge", key="max_increment", default=180)
set_config_default(CONFIG, "challenge", key="min_increment", default=0)
set_config_default(CONFIG, "challenge", key="max_base", default=math.inf)
set_config_default(CONFIG, "challenge", key="min_base", default=0)
set_config_default(CONFIG, "challenge", key="max_days", default=math.inf)
set_config_default(CONFIG, "challenge", key="min_days", default=1)
set_config_default(CONFIG, "challenge", key="block_list", default=[], force_empty_values=True)
set_config_default(CONFIG, "challenge", key="allow_list", default=[], force_empty_values=True)
set_config_default(CONFIG, "correspondence", key="checkin_period", default=600)
set_config_default(CONFIG, "correspondence", key="move_time", default=60, force_empty_values=True)
set_config_default(CONFIG, "correspondence", key="disconnect_time", default=300)
set_config_default(CONFIG, "matchmaking", key="challenge_timeout", default=30, force_empty_values=True)
CONFIG["matchmaking"]["challenge_timeout"] = max(CONFIG["matchmaking"]["challenge_timeout"], 1)
set_config_default(CONFIG, "matchmaking", key="block_list", default=[], force_empty_values=True)
default_filter = (CONFIG.get("matchmaking") or {}).get("delay_after_decline") or FilterType.NONE.value
set_config_default(CONFIG, "matchmaking", key="challenge_filter", default=default_filter, force_empty_values=True)
set_config_default(CONFIG, "matchmaking", key="allow_matchmaking", default=False)
set_config_default(CONFIG, "matchmaking", key="challenge_initial_time", default=[None], force_empty_values=True)
change_value_to_list(CONFIG, "matchmaking", key="challenge_initial_time")
set_config_default(CONFIG, "matchmaking", key="challenge_increment", default=[None], force_empty_values=True)
change_value_to_list(CONFIG, "matchmaking", key="challenge_increment")
set_config_default(CONFIG, "matchmaking", key="challenge_days", default=[None], force_empty_values=True)
change_value_to_list(CONFIG, "matchmaking", key="challenge_days")
set_config_default(CONFIG, "matchmaking", key="opponent_min_rating", default=600, force_empty_values=True)
set_config_default(CONFIG, "matchmaking", key="opponent_max_rating", default=4000, force_empty_values=True)
set_config_default(CONFIG, "matchmaking", key="rating_preference", default="none")
set_config_default(CONFIG, "matchmaking", key="opponent_allow_tos_violation", default=True)
set_config_default(CONFIG, "matchmaking", key="challenge_variant", default="random")
set_config_default(CONFIG, "matchmaking", key="challenge_mode", default="random")
set_config_default(CONFIG, "matchmaking", key="overrides", default={}, force_empty_values=True)
for override_config in CONFIG["matchmaking"]["overrides"].values():
for parameter in ["challenge_initial_time", "challenge_increment", "challenge_days"]:
if parameter in override_config:
set_config_default(override_config, key=parameter, default=[None], force_empty_values=True)
change_value_to_list(override_config, key=parameter)
for section in ["engine", "correspondence"]:
for ponder in ["ponder", "uci_ponder"]:
set_config_default(CONFIG, section, key=ponder, default=False)
for type in ["hello", "goodbye"]:
for target in ["", "_spectators"]:
set_config_default(CONFIG, "greeting", key=type + target, default="", force_empty_values=True)
def log_config(CONFIG: CONFIG_DICT_TYPE) -> None:
"""
Log the config to make debugging easier.
:param CONFIG: The bot's config.
"""
logger_config = CONFIG.copy()
logger_config["token"] = "logger"
logger.debug(f"Config:\n{yaml.dump(logger_config, sort_keys=False)}")
logger.debug("====================")
def validate_config(CONFIG: CONFIG_DICT_TYPE) -> None:
"""Check if the config is valid."""
check_config_section(CONFIG, "token", str)
check_config_section(CONFIG, "url", str)
check_config_section(CONFIG, "engine", dict)
check_config_section(CONFIG, "challenge", dict)
check_config_section(CONFIG, "dir", str, "engine")
check_config_section(CONFIG, "name", str, "engine")
config_assert(os.path.isdir(CONFIG["engine"]["dir"]),
f'Your engine directory `{CONFIG["engine"]["dir"]}` is not a directory.')
working_dir = CONFIG["engine"].get("working_dir")
config_assert(not working_dir or os.path.isdir(working_dir),
f"Your engine's working directory `{working_dir}` is not a directory.")
engine = os.path.join(CONFIG["engine"]["dir"], CONFIG["engine"]["name"])
config_assert(os.path.isfile(engine) or CONFIG["engine"]["protocol"] == "homemade",
f"The engine {engine} file does not exist.")
config_assert(os.access(engine, os.X_OK) or CONFIG["engine"]["protocol"] == "homemade",
f"The engine {engine} doesn't have execute (x) permission. Try: chmod +x {engine}")
if CONFIG["engine"]["protocol"] == "xboard":
for section, subsection in (("online_moves", "online_egtb"),
("lichess_bot_tbs", "syzygy"),
("lichess_bot_tbs", "gaviota")):
online_section = (CONFIG["engine"].get(section) or {}).get(subsection) or {}
config_assert(online_section.get("move_quality") != "suggest" or not online_section.get("enabled"),
f"XBoard engines can't be used with `move_quality` set to `suggest` in {subsection}.")
valid_pgn_grouping_options = ["game", "opponent", "all"]
config_pgn_choice = CONFIG["pgn_file_grouping"]
config_assert(config_pgn_choice in valid_pgn_grouping_options,
f"The `pgn_file_grouping` choice of `{config_pgn_choice}` is not valid. "
f"Please choose from {valid_pgn_grouping_options}.")
matchmaking = CONFIG.get("matchmaking") or {}
matchmaking_enabled = matchmaking.get("allow_matchmaking") or False
def has_valid_list(name: str) -> bool:
entries = matchmaking.get(name)
return isinstance(entries, list) and entries[0] is not None
matchmaking_has_values = (has_valid_list("challenge_initial_time")
and has_valid_list("challenge_increment")
or has_valid_list("challenge_days"))
config_assert(not matchmaking_enabled or matchmaking_has_values,
"The time control to challenge other bots is not set. Either lists of challenge_initial_time and "
"challenge_increment is required, or a list of challenge_days, or both.")
filter_option = "challenge_filter"
filter_type = matchmaking.get(filter_option)
config_assert(filter_type is None or filter_type in FilterType.__members__.values(),
f"{filter_type} is not a valid value for {filter_option} (formerly delay_after_decline) parameter. "
f"Choices are: {', '.join(FilterType)}.")
config_assert(matchmaking.get("rating_preference") in ["none", "high", "low"],
f"{matchmaking.get('rating_preference')} is not a valid `matchmaking:rating_preference` option. "
f"Valid options are 'none', 'high', or 'low'.")
selection_choices = {"polyglot": ["weighted_random", "uniform_random", "best_move"],
"chessdb_book": ["all", "good", "best"],
"lichess_cloud_analysis": ["good", "best"],
"online_egtb": ["best", "suggest"]}
for db_name, valid_selections in selection_choices.items():
is_online = db_name != "polyglot"
db_section = (CONFIG["engine"].get("online_moves") or {}) if is_online else CONFIG["engine"]
db_config = db_section.get(db_name)
select_key = "selection" if db_name == "polyglot" else "move_quality"
selection = db_config.get(select_key)
select = f"{'online_moves:' if is_online else ''}{db_name}:{select_key}"
config_assert(selection in valid_selections,
f"`{selection}` is not a valid `engine:{select}` value. "
f"Please choose from {valid_selections}.")
lichess_tbs_config = CONFIG["engine"].get("lichess_bot_tbs") or {}
quality_selections = ["best", "suggest"]
for tb in ["syzygy", "gaviota"]:
selection = (lichess_tbs_config.get(tb) or {}).get("move_quality")
config_assert(selection in quality_selections,
f"`{selection}` is not a valid choice for `engine:lichess_bot_tbs:{tb}:move_quality`. "
f"Please choose from {quality_selections}.")
explorer_choices = {"source": ["lichess", "masters", "player"],
"sort": ["winrate", "games_played"]}
explorer_config = (CONFIG["engine"].get("online_moves") or {}).get("lichess_opening_explorer")
if explorer_config:
for parameter, choice_list in explorer_choices.items():
explorer_choice = explorer_config.get(parameter)
config_assert(explorer_choice in choice_list,
f"`{explorer_choice}` is not a valid"
f" `engine:online_moves:lichess_opening_explorer:{parameter}`"
f" value. Please choose from {choice_list}.")
def load_config(config_file: str) -> Configuration:
"""
Read the config.
:param config_file: The filename of the config (usually `config.yml`).
:return: A `Configuration` object containing the config.
"""
with open(config_file) as stream:
try:
CONFIG = yaml.safe_load(stream)
except Exception:
logger.exception("There appears to be a syntax problem with your config.yml")
raise
log_config(CONFIG)
if "LICHESS_BOT_TOKEN" in os.environ:
CONFIG["token"] = os.environ["LICHESS_BOT_TOKEN"]
insert_default_values(CONFIG)
log_config(CONFIG)
validate_config(CONFIG)
return Configuration(CONFIG)

View File

@@ -0,0 +1,102 @@
"""Allows lichess_bot to send messages to the chat."""
from __future__ import annotations
import logging
from lib import model
from lib.engine_wrapper import EngineWrapper
from lib.lichess import Lichess
from collections.abc import Sequence
from lib.timer import seconds
MULTIPROCESSING_LIST_TYPE = Sequence[model.Challenge]
logger = logging.getLogger(__name__)
class Conversation:
"""Enables the bot to communicate with its opponent and the spectators."""
def __init__(self, game: model.Game, engine: EngineWrapper, li: Lichess, version: str,
challenge_queue: MULTIPROCESSING_LIST_TYPE) -> None:
"""
Communication between lichess_bot and the game chats.
:param game: The game that the bot will send messages to.
:param engine: The engine playing the game.
:param li: A class that is used for communication with lichess.
:param version: The lichess_bot version.
:param challenge_queue: The active challenges the bot has.
"""
self.game = game
self.engine = engine
self.li = li
self.version = version
self.challengers = challenge_queue
command_prefix = "!"
def react(self, line: ChatLine) -> None:
"""
React to a received message.
:param line: Information about the message.
"""
logger.info(f'*** {self.game.url()} [{line.room}] {line.username}: {line.text}')
if line.text[0] == self.command_prefix:
self.command(line, line.text[1:].lower())
def command(self, line: ChatLine, cmd: str) -> None:
"""
Reacts to the specific commands in the chat.
:param line: Information about the message.
:param cmd: The command to react to.
"""
from_self = line.username == self.game.username
if cmd == "commands" or cmd == "help":
self.send_reply(line, "Supported commands: !wait (wait a minute for my first move), !name, !howto, !eval, !queue")
elif cmd == "wait" and self.game.is_abortable():
self.game.ping(seconds(60), seconds(120), seconds(120))
self.send_reply(line, "Waiting 60 seconds...")
elif cmd == "name":
name = self.game.me.name
self.send_reply(line, f"{name} running {self.engine.name()} (lichess_bot v{self.version})")
elif cmd == "howto":
self.send_reply(line, "How to run: Check out 'Lichess Bot API'")
elif cmd == "eval" and (from_self or line.room == "spectator"):
stats = self.engine.get_stats(for_chat=True)
self.send_reply(line, ", ".join(stats))
elif cmd == "eval":
self.send_reply(line, "I don't tell that to my opponent, sorry.")
elif cmd == "queue":
if self.challengers:
challengers = ", ".join([f"@{challenger.challenger.name}" for challenger in reversed(self.challengers)])
self.send_reply(line, f"Challenge queue: {challengers}")
else:
self.send_reply(line, "No challenges queued.")
def send_reply(self, line: ChatLine, reply: str) -> None:
"""
Send the reply to the chat.
:param line: Information about the original message that we reply to.
:param reply: The reply to send.
"""
logger.info(f'*** {self.game.url()} [{line.room}] {self.game.username}: {reply}')
self.li.chat(self.game.id, line.room, reply)
def send_message(self, room: str, message: str) -> None:
"""Send the message to the chat."""
if message:
self.send_reply(ChatLine({"room": room, "username": "", "text": ""}), message)
class ChatLine:
"""Information about the message."""
def __init__(self, message_info: dict[str, str]) -> None:
"""Information about the message."""
self.room = message_info["room"]
"""Whether the message was sent in the chat room or in the spectator room."""
self.username = message_info["username"]
"""The username of the account that sent the message."""
self.text = message_info["text"]
"""The message sent."""

File diff suppressed because it is too large Load Diff

385
lichess_bot/lib/lichess.py Normal file
View File

@@ -0,0 +1,385 @@
"""Communication with APIs."""
import json
import requests
from urllib.parse import urljoin
from requests.exceptions import ConnectionError, HTTPError, ReadTimeout
from http.client import RemoteDisconnected
import backoff
import logging
import traceback
from collections import defaultdict
import datetime
from lib.timer import Timer, seconds, sec_str
from typing import Optional, Union, Any
import chess.engine
JSON_REPLY_TYPE = dict[str, Any]
REQUESTS_PAYLOAD_TYPE = dict[str, Any]
ENDPOINTS = {
"profile": "/api/account",
"playing": "/api/account/playing",
"stream": "/api/bot/game/stream/{}",
"stream_event": "/api/stream/event",
"move": "/api/bot/game/{}/move/{}",
"chat": "/api/bot/game/{}/chat",
"abort": "/api/bot/game/{}/abort",
"accept": "/api/challenge/{}/accept",
"decline": "/api/challenge/{}/decline",
"upgrade": "/api/bot/account/upgrade",
"resign": "/api/bot/game/{}/resign",
"export": "/game/export/{}",
"online_bots": "/api/bot/online",
"challenge": "/api/challenge/{}",
"cancel": "/api/challenge/{}/cancel",
"status": "/api/users/status",
"public_data": "/api/user/{}",
"token_test": "/api/token/test"
}
logger = logging.getLogger(__name__)
MAX_CHAT_MESSAGE_LEN = 140 # The maximum characters in a chat message.
class RateLimited(RuntimeError):
"""Exception raised when we are rate limited (status code 429)."""
pass
def is_new_rate_limit(response: requests.models.Response) -> bool:
"""Check if the status code is 429, which means that we are rate limited."""
return response.status_code == 429
def is_final(exception: Exception) -> bool:
"""If `is_final` returns True then we won't retry."""
return isinstance(exception, HTTPError) and exception.response is not None and exception.response.status_code < 500
def backoff_handler(details: Any) -> None:
"""Log exceptions inside functions with the backoff decorator."""
logger.debug("Backing off {wait:0.1f} seconds after {tries} tries "
"calling function {target} with args {args} and kwargs {kwargs}".format(**details))
logger.debug(f"Exception: {traceback.format_exc()}")
# Docs: https://lichess.org/api.
class Lichess:
"""Communication with lichess.org (and chessdb.cn for getting moves)."""
def __init__(self, token: str, url: str, version: str, logging_level: int, max_retries: int) -> None:
"""
Communication with lichess.org (and chessdb.cn for getting moves).
:param token: The bot's token.
:param url: The base url (lichess.org).
:param version: The lichess_bot version running.
:param logging_level: The logging level (logging.INFO or logging.DEBUG).
:param max_retries: The maximum amount of retries for online moves (e.g. chessdb's opening book).
"""
self.version = version
self.header = {
"Authorization": f"Bearer {token}"
}
self.baseUrl = url
self.session = requests.Session()
self.session.headers.update(self.header)
self.other_session = requests.Session()
self.set_user_agent("?")
self.logging_level = logging_level
self.max_retries = max_retries
self.rate_limit_timers: defaultdict[str, Timer] = defaultdict(Timer)
# Confirm that the OAuth token has the proper permission to play on lichess
token_info = self.api_post("token_test", data=token)[token]
if not token_info:
raise RuntimeError("Token in config file is not recognized by lichess. "
"Please check that it was copied correctly into your configuration file.")
scopes = token_info["scopes"]
if "bot:play" not in scopes.split(","):
raise RuntimeError("Please use an API access token for your bot that "
'has the scope "Play games with the bot API (bot:play)". '
f"The current token has: {scopes}.")
@backoff.on_exception(backoff.constant,
(RemoteDisconnected, ConnectionError, HTTPError, ReadTimeout),
max_time=60,
interval=0.1,
giveup=is_final,
on_backoff=backoff_handler,
backoff_log_level=logging.DEBUG,
giveup_log_level=logging.DEBUG)
def api_get(self, endpoint_name: str, *template_args: str,
params: Optional[dict[str, str]] = None,
stream: bool = False, timeout: int = 2) -> requests.Response:
"""
Send a GET to lichess.org.
:param endpoint_name: The name of the endpoint.
:param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`).
:param params: Parameters sent to lichess.org.
:param stream: Whether the data returned from lichess.org should be streamed.
:param timeout: The amount of time in seconds to wait for a response.
:return: lichess.org's response.
"""
logging.getLogger("backoff").setLevel(self.logging_level)
path_template = self.get_path_template(endpoint_name)
url = urljoin(self.baseUrl, path_template.format(*template_args))
response = self.session.get(url, params=params, timeout=timeout, stream=stream)
if is_new_rate_limit(response):
delay = seconds(1 if endpoint_name == "move" else 60)
self.set_rate_limit_delay(path_template, delay)
response.raise_for_status()
response.encoding = "utf-8"
return response
def api_get_json(self, endpoint_name: str, *template_args: str,
params: Optional[dict[str, str]] = None) -> JSON_REPLY_TYPE:
"""
Send a GET to the lichess.org endpoints that return a JSON.
:param endpoint_name: The name of the endpoint.
:param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`).
:param params: Parameters sent to lichess.org.
:return: lichess.org's response in a dict.
"""
response = self.api_get(endpoint_name, *template_args, params=params)
json_response: JSON_REPLY_TYPE = response.json()
return json_response
def api_get_list(self, endpoint_name: str, *template_args: str,
params: Optional[dict[str, str]] = None) -> list[JSON_REPLY_TYPE]:
"""
Send a GET to the lichess.org endpoints that return a list containing JSON.
:param endpoint_name: The name of the endpoint.
:param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`).
:param params: Parameters sent to lichess.org.
:return: lichess.org's response in a list of dicts.
"""
response = self.api_get(endpoint_name, *template_args, params=params)
json_response: list[JSON_REPLY_TYPE] = response.json()
return json_response
def api_get_raw(self, endpoint_name: str, *template_args: str,
params: Optional[dict[str, str]] = None, ) -> str:
"""
Send a GET to lichess.org that returns plain text (UTF-8).
:param endpoint_name: The name of the endpoint.
:param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`).
:param params: Parameters sent to lichess.org.
:return: The text of lichess.org's response.
"""
response = self.api_get(endpoint_name, *template_args, params=params)
return response.text
@backoff.on_exception(backoff.constant,
(RemoteDisconnected, ConnectionError, HTTPError, ReadTimeout),
max_time=60,
interval=0.1,
giveup=is_final,
on_backoff=backoff_handler,
backoff_log_level=logging.DEBUG,
giveup_log_level=logging.DEBUG)
def api_post(self,
endpoint_name: str,
*template_args: Any,
data: Union[str, dict[str, str], None] = None,
headers: Optional[dict[str, str]] = None,
params: Optional[dict[str, str]] = None,
payload: Optional[REQUESTS_PAYLOAD_TYPE] = None,
raise_for_status: bool = True) -> JSON_REPLY_TYPE:
"""
Send a POST to lichess.org.
:param endpoint_name: The name of the endpoint.
:param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`).
:param data: Data sent to lichess.org.
:param headers: The headers for the request.
:param params: Parameters sent to lichess.org.
:param payload: Payload sent to lichess.org.
:param raise_for_status: Whether to raise an exception if the response contains an error code.
:return: lichess.org's response in a dict.
"""
logging.getLogger("backoff").setLevel(self.logging_level)
path_template = self.get_path_template(endpoint_name)
url = urljoin(self.baseUrl, path_template.format(*template_args))
response = self.session.post(url, data=data, headers=headers, params=params, json=payload, timeout=2)
if is_new_rate_limit(response):
self.set_rate_limit_delay(path_template, seconds(60))
if raise_for_status:
response.raise_for_status()
json_response: JSON_REPLY_TYPE = response.json()
return json_response
def get_path_template(self, endpoint_name: str) -> str:
"""
Get the path template given the endpoint name. Will raise an exception if the path template is rate limited.
:param endpoint_name: The name of the endpoint.
:return: The path template.
"""
path_template = ENDPOINTS[endpoint_name]
if self.is_rate_limited(path_template):
raise RateLimited(f"{path_template} is rate-limited. "
f"Will retry in {sec_str(self.rate_limit_time_left(path_template))} seconds.")
return path_template
def set_rate_limit_delay(self, path_template: str, delay_time: datetime.timedelta) -> None:
"""
Set a delay to a path template if it was rate limited.
:param path_template: The path template.
:param delay_time: How long we won't call this endpoint.
"""
logger.warning(f"Endpoint {path_template} is rate limited. Waiting {delay_time} seconds until next request.")
self.rate_limit_timers[path_template] = Timer(delay_time)
def is_rate_limited(self, path_template: str) -> bool:
"""Check if a path template is rate limited."""
return not self.rate_limit_timers[path_template].is_expired()
def rate_limit_time_left(self, path_template: str) -> datetime.timedelta:
"""How much time is left until we can use the path template normally."""
return self.rate_limit_timers[path_template].time_until_expiration()
def upgrade_to_bot_account(self) -> JSON_REPLY_TYPE:
"""Upgrade the account to a BOT account."""
return self.api_post("upgrade")
def make_move(self, game_id: str, move: chess.engine.PlayResult) -> JSON_REPLY_TYPE:
"""
Make a move.
:param game_id: The id of the game.
:param move: The move to make.
"""
return self.api_post("move", game_id, move.move,
params={"offeringDraw": str(move.draw_offered).lower()})
def chat(self, game_id: str, room: str, text: str) -> JSON_REPLY_TYPE:
"""
Send a message to the chat.
:param game_id: The id of the game.
:param room: The room (either chat or spectator room).
:param text: The text to send.
"""
if len(text) > MAX_CHAT_MESSAGE_LEN:
logger.warning(f"This chat message is {len(text)} characters, which is longer "
f"than the maximum of {MAX_CHAT_MESSAGE_LEN}. It will not be sent.")
logger.warning(f"Message: {text}")
return {}
payload = {"room": room, "text": text}
return self.api_post("chat", game_id, data=payload)
def abort(self, game_id: str) -> JSON_REPLY_TYPE:
"""Aborts a game."""
return self.api_post("abort", game_id)
def get_event_stream(self) -> requests.models.Response:
"""Get a stream of the events (e.g. challenge, gameStart)."""
return self.api_get("stream_event", stream=True, timeout=15)
def get_game_stream(self, game_id: str) -> requests.models.Response:
"""Get stream of the in-game events (e.g. moves by the opponent)."""
return self.api_get("stream", game_id, stream=True, timeout=15)
def accept_challenge(self, challenge_id: str) -> JSON_REPLY_TYPE:
"""Accept a challenge."""
return self.api_post("accept", challenge_id)
def decline_challenge(self, challenge_id: str, reason: str = "generic") -> JSON_REPLY_TYPE:
"""Decline a challenge."""
try:
return self.api_post("decline", challenge_id,
data=f"reason={reason}",
headers={"Content-Type":
"application/x-www-form-urlencoded"},
raise_for_status=False)
except Exception:
return {}
def get_profile(self) -> JSON_REPLY_TYPE:
"""Get the bot's profile (e.g. username)."""
profile = self.api_get_json("profile")
self.set_user_agent(profile["username"])
return profile
def get_ongoing_games(self) -> list[dict[str, Any]]:
"""Get the bot's ongoing games."""
ongoing_games: list[dict[str, Any]] = []
try:
ongoing_games = self.api_get_json("playing")["nowPlaying"]
except Exception:
pass
return ongoing_games
def resign(self, game_id: str) -> None:
"""Resign a game."""
self.api_post("resign", game_id)
def set_user_agent(self, username: str) -> None:
"""Set the user agent for communication with lichess.org."""
self.header.update({"User-Agent": f"lichess_bot/{self.version} user:{username}"})
self.session.headers.update(self.header)
def get_game_pgn(self, game_id: str) -> str:
"""Get the PGN (Portable Game Notation) record of a game."""
try:
return self.api_get_raw("export", game_id)
except Exception:
return ""
def get_online_bots(self) -> list[dict[str, Any]]:
"""Get a list of bots that are online."""
try:
online_bots_str = self.api_get_raw("online_bots")
online_bots = list(filter(bool, online_bots_str.split("\n")))
return list(map(json.loads, online_bots))
except Exception:
return []
def challenge(self, username: str, payload: REQUESTS_PAYLOAD_TYPE) -> JSON_REPLY_TYPE:
"""Create a challenge."""
return self.api_post("challenge", username, payload=payload, raise_for_status=False)
def cancel(self, challenge_id: str) -> JSON_REPLY_TYPE:
"""Cancel a challenge."""
return self.api_post("cancel", challenge_id, raise_for_status=False)
def online_book_get(self, path: str, params: Optional[dict[str, Any]] = None, stream: bool = False) -> JSON_REPLY_TYPE:
"""Get an external move from online sources (chessdb or lichess.org)."""
@backoff.on_exception(backoff.constant,
(RemoteDisconnected, ConnectionError, HTTPError, ReadTimeout),
max_time=60,
max_tries=self.max_retries,
interval=0.1,
giveup=is_final,
on_backoff=backoff_handler,
backoff_log_level=logging.DEBUG,
giveup_log_level=logging.DEBUG)
def online_book_get() -> JSON_REPLY_TYPE:
json_response: JSON_REPLY_TYPE = self.other_session.get(path, timeout=2, params=params, stream=stream).json()
return json_response
return online_book_get()
def is_online(self, user_id: str) -> bool:
"""Check if lichess.org thinks the bot is online or not."""
user = self.api_get_list("status", params={"ids": user_id})
return bool(user and user[0].get("online"))
def get_public_data(self, user_name: str) -> JSON_REPLY_TYPE:
"""Get the public data of a bot."""
return self.api_get_json("public_data", user_name)

View File

@@ -0,0 +1,373 @@
"""Challenge other bots."""
import random
import logging
from lib import model
from lib.timer import Timer, seconds, minutes, days
from collections import defaultdict
from collections.abc import Sequence
from lib import lichess
import datetime
from lib.config import Configuration, FilterType
from typing import Any, Optional
USER_PROFILE_TYPE = dict[str, Any]
EVENT_TYPE = dict[str, Any]
MULTIPROCESSING_LIST_TYPE = Sequence[model.Challenge]
DAILY_TIMERS_TYPE = list[Timer]
logger = logging.getLogger(__name__)
daily_challenges_file_name = "daily_challenge_times.txt"
timestamp_format = "%Y-%m-%d %H:%M:%S\n"
def read_daily_challenges() -> DAILY_TIMERS_TYPE:
"""Read the challenges we have created in the past 24 hours from a text file."""
timers: DAILY_TIMERS_TYPE = []
try:
with open(daily_challenges_file_name) as file:
for line in file:
timers.append(Timer(days(1), datetime.datetime.strptime(line, timestamp_format)))
except FileNotFoundError:
pass
return [timer for timer in timers if not timer.is_expired()]
def write_daily_challenges(daily_challenges: DAILY_TIMERS_TYPE) -> None:
"""Write the challenges we have created in the past 24 hours to a text file."""
with open(daily_challenges_file_name, "w") as file:
for timer in daily_challenges:
file.write(timer.starting_timestamp(timestamp_format))
class Matchmaking:
"""Challenge other bots."""
def __init__(self, li: lichess.Lichess, config: Configuration, user_profile: USER_PROFILE_TYPE) -> None:
"""Initialize values needed for matchmaking."""
self.li = li
self.variants = list(filter(lambda variant: variant != "fromPosition", config.challenge.variants))
self.matchmaking_cfg = config.matchmaking
self.user_profile = user_profile
self.last_challenge_created_delay = Timer(seconds(25)) # Challenges expire after 20 seconds.
self.last_game_ended_delay = Timer(minutes(self.matchmaking_cfg.challenge_timeout))
self.last_user_profile_update_time = Timer(minutes(5))
self.min_wait_time = seconds(60) # Wait before new challenge to avoid api rate limits.
self.challenge_id: str = ""
self.daily_challenges: DAILY_TIMERS_TYPE = read_daily_challenges()
# (opponent name, game aspect) --> other bot is likely to accept challenge
# game aspect is the one the challenged bot objects to and is one of:
# - game speed (bullet, blitz, etc.)
# - variant (standard, horde, etc.)
# - casual/rated
# - empty string (if no other reason is given or self.filter_type is COARSE)
self.challenge_type_acceptable: defaultdict[tuple[str, str], bool] = defaultdict(lambda: True)
self.challenge_filter = self.matchmaking_cfg.challenge_filter
for name in self.matchmaking_cfg.block_list:
self.add_to_block_list(name)
def should_create_challenge(self) -> bool:
"""Whether we should create a challenge."""
matchmaking_enabled = self.matchmaking_cfg.allow_matchmaking
time_has_passed = self.last_game_ended_delay.is_expired()
challenge_expired = self.last_challenge_created_delay.is_expired() and self.challenge_id
min_wait_time_passed = self.last_challenge_created_delay.time_since_reset() > self.min_wait_time
if challenge_expired:
self.li.cancel(self.challenge_id)
logger.info(f"Challenge id {self.challenge_id} cancelled.")
self.challenge_id = ""
self.show_earliest_challenge_time()
return bool(matchmaking_enabled and (time_has_passed or challenge_expired) and min_wait_time_passed)
def create_challenge(self, username: str, base_time: int, increment: int, days: int, variant: str,
mode: str) -> str:
"""Create a challenge."""
params = {"rated": mode == "rated", "variant": variant}
if days:
params["days"] = days
elif base_time or increment:
params["clock.limit"] = base_time
params["clock.increment"] = increment
else:
logger.error("At least one of challenge_days, challenge_initial_time, or challenge_increment "
"must be greater than zero in the matchmaking section of your config file.")
return ""
try:
self.update_daily_challenge_record()
self.last_challenge_created_delay.reset()
response = self.li.challenge(username, params)
challenge_id: str = response.get("challenge", {}).get("id", "")
if not challenge_id:
logger.error(response)
self.add_to_block_list(username)
self.show_earliest_challenge_time()
return challenge_id
except Exception as e:
logger.warning("Could not create challenge")
logger.debug(e, exc_info=e)
self.show_earliest_challenge_time()
return ""
def update_daily_challenge_record(self) -> None:
"""
Record timestamp of latest challenge and update minimum wait time.
As the number of challenges in a day increase, the minimum wait time between challenges increases.
0 - 49 challenges --> 1 minute
50 - 99 challenges --> 2 minutes
100 - 149 challenges --> 3 minutes
etc.
"""
self.daily_challenges = [timer for timer in self.daily_challenges if not timer.is_expired()]
self.daily_challenges.append(Timer(days(1)))
self.min_wait_time = seconds(60) * ((len(self.daily_challenges) // 50) + 1)
write_daily_challenges(self.daily_challenges)
def perf(self) -> dict[str, dict[str, Any]]:
"""Get the bot's rating in every variant. Bullet, blitz, rapid etc. are considered different variants."""
user_perf: dict[str, dict[str, Any]] = self.user_profile["perfs"]
return user_perf
def username(self) -> str:
"""Our username."""
username: str = self.user_profile["username"]
return username
def update_user_profile(self) -> None:
"""Update our user profile data, to get our latest rating."""
if self.last_user_profile_update_time.is_expired():
self.last_user_profile_update_time.reset()
try:
self.user_profile = self.li.get_profile()
except Exception:
pass
def get_weights(self, online_bots: list[USER_PROFILE_TYPE], rating_preference: str, min_rating: int, max_rating: int,
game_type: str) -> list[int]:
"""Get the weight for each bot. A higher weights means the bot is more likely to get challenged."""
def rating(bot: USER_PROFILE_TYPE) -> int:
return int(bot.get("perfs", {}).get(game_type, {}).get("rating", 0))
if rating_preference == "high":
# A bot with max_rating rating will be twice as likely to get picked than a bot with min_rating rating.
reduce_ratings_by = min(min_rating - (max_rating - min_rating), min_rating - 1)
weights = [rating(bot) - reduce_ratings_by for bot in online_bots]
elif rating_preference == "low":
# A bot with min_rating rating will be twice as likely to get picked than a bot with max_rating rating.
reduce_ratings_by = max(max_rating - (min_rating - max_rating), max_rating + 1)
weights = [reduce_ratings_by - rating(bot) for bot in online_bots]
else:
weights = [1] * len(online_bots)
return weights
def choose_opponent(self) -> tuple[Optional[str], int, int, int, str, str]:
"""Choose an opponent."""
override_choice = random.choice(self.matchmaking_cfg.overrides.keys() + [None])
logger.info(f"Using the {override_choice or 'default'} matchmaking configuration.")
override = {} if override_choice is None else self.matchmaking_cfg.overrides.lookup(override_choice)
match_config = self.matchmaking_cfg | override
variant = self.get_random_config_value(match_config, "challenge_variant", self.variants)
mode = self.get_random_config_value(match_config, "challenge_mode", ["casual", "rated"])
rating_preference = match_config.rating_preference
base_time = random.choice(match_config.challenge_initial_time)
increment = random.choice(match_config.challenge_increment)
days = random.choice(match_config.challenge_days)
play_correspondence = [bool(days), not bool(base_time or increment)]
if random.choice(play_correspondence):
base_time = 0
increment = 0
else:
days = 0
game_type = game_category(variant, base_time, increment, days)
min_rating = match_config.opponent_min_rating
max_rating = match_config.opponent_max_rating
rating_diff = match_config.opponent_rating_difference
bot_rating = self.perf().get(game_type, {}).get("rating", 0)
if rating_diff is not None and bot_rating > 0:
min_rating = bot_rating - rating_diff
max_rating = bot_rating + rating_diff
logger.info(f"Seeking {game_type} game with opponent rating in [{min_rating}, {max_rating}] ...")
allow_tos_violation = match_config.opponent_allow_tos_violation
def is_suitable_opponent(bot: USER_PROFILE_TYPE) -> bool:
perf = bot.get("perfs", {}).get(game_type, {})
return (bot["username"] != self.username()
and not self.in_block_list(bot["username"])
and not bot.get("disabled")
and (allow_tos_violation or not bot.get("tosViolation")) # Terms of Service violation.
and perf.get("games", 0) > 0
and min_rating <= perf.get("rating", 0) <= max_rating)
online_bots = self.li.get_online_bots()
online_bots = list(filter(is_suitable_opponent, online_bots))
def ready_for_challenge(bot: USER_PROFILE_TYPE) -> bool:
aspects = [variant, game_type, mode] if self.challenge_filter == FilterType.FINE else []
return all(self.should_accept_challenge(bot["username"], aspect) for aspect in aspects)
ready_bots = list(filter(ready_for_challenge, online_bots))
online_bots = ready_bots or online_bots
bot_username = None
weights = self.get_weights(online_bots, rating_preference, min_rating, max_rating, game_type)
try:
bot = random.choices(online_bots, weights=weights)[0]
bot_profile = self.li.get_public_data(bot["username"])
if bot_profile.get("blocking"):
self.add_to_block_list(bot["username"])
else:
bot_username = bot["username"]
except Exception:
if online_bots:
logger.exception("Error:")
else:
logger.error("No suitable bots found to challenge.")
return bot_username, base_time, increment, days, variant, mode
def get_random_config_value(self, config: Configuration, parameter: str, choices: list[str]) -> str:
"""Choose a random value from `choices` if the parameter value in the config is `random`."""
value: str = config.lookup(parameter)
return value if value != "random" else random.choice(choices)
def challenge(self, active_games: set[str], challenge_queue: MULTIPROCESSING_LIST_TYPE) -> None:
"""
Challenge an opponent.
:param active_games: The games that the bot is playing.
:param challenge_queue: The queue containing the challenges.
"""
if active_games or challenge_queue or not self.should_create_challenge():
return
logger.info("Challenging a random bot")
self.update_user_profile()
bot_username, base_time, increment, days, variant, mode = self.choose_opponent()
logger.info(f"Will challenge {bot_username} for a {variant} game.")
challenge_id = self.create_challenge(bot_username, base_time, increment, days, variant, mode) if bot_username else ""
logger.info(f"Challenge id is {challenge_id if challenge_id else 'None'}.")
self.challenge_id = challenge_id
def game_done(self) -> None:
"""Reset the timer for when the last game ended, and prints the earliest that the next challenge will be created."""
self.last_game_ended_delay.reset()
self.show_earliest_challenge_time()
def show_earliest_challenge_time(self) -> None:
"""Show the earliest that the next challenge will be created."""
if self.matchmaking_cfg.allow_matchmaking:
postgame_timeout = self.last_game_ended_delay.time_until_expiration()
time_to_next_challenge = self.min_wait_time - self.last_challenge_created_delay.time_since_reset()
time_left = max(postgame_timeout, time_to_next_challenge)
earliest_challenge_time = datetime.datetime.now() + time_left
challenges = "challenge" + ("" if len(self.daily_challenges) == 1 else "s")
logger.info(f"Next challenge will be created after {earliest_challenge_time.strftime('%X')} "
f"({len(self.daily_challenges)} {challenges} in last 24 hours)")
def add_to_block_list(self, username: str) -> None:
"""Add a bot to the blocklist."""
self.add_challenge_filter(username, "")
def in_block_list(self, username: str) -> bool:
"""Check if an opponent is in the block list to prevent future challenges."""
return not self.should_accept_challenge(username, "")
def add_challenge_filter(self, username: str, game_aspect: str) -> None:
"""
Prevent creating another challenge when an opponent has decline a challenge.
:param username: The name of the opponent.
:param game_aspect: The aspect of a game (time control, chess variant, etc.)
that caused the opponent to decline a challenge. If the parameter is empty,
that is equivalent to adding the opponent to the block list.
"""
self.challenge_type_acceptable[(username, game_aspect)] = False
def should_accept_challenge(self, username: str, game_aspect: str) -> bool:
"""
Whether a bot is likely to accept a challenge to a game.
:param username: The name of the opponent.
:param game_aspect: A category of the challenge type (time control, chess variant, etc.) to test for acceptance.
If game_aspect is empty, this is equivalent to checking if the opponent is in the block list.
"""
return self.challenge_type_acceptable[(username, game_aspect)]
def accepted_challenge(self, event: EVENT_TYPE) -> None:
"""
Set the challenge id to an empty string, if the challenge was accepted.
Otherwise, we would attempt to cancel the challenge later.
"""
if self.challenge_id == event["game"]["id"]:
self.challenge_id = ""
def declined_challenge(self, event: EVENT_TYPE) -> None:
"""
Handle a challenge that was declined by the opponent.
Depends on whether `FilterType` is `NONE`, `COARSE`, or `FINE`.
"""
challenge = model.Challenge(event["challenge"], self.user_profile)
opponent = challenge.opponent
reason = event["challenge"]["declineReason"]
logger.info(f"{opponent} declined {challenge}: {reason}")
if self.challenge_id == challenge.id:
self.challenge_id = ""
if not challenge.from_self or self.challenge_filter == FilterType.NONE:
return
mode = "rated" if challenge.rated else "casual"
decline_details: dict[str, str] = {"generic": "",
"later": "",
"nobot": "",
"toofast": challenge.speed,
"tooslow": challenge.speed,
"timecontrol": challenge.speed,
"rated": mode,
"casual": mode,
"standard": challenge.variant,
"variant": challenge.variant}
reason_key = event["challenge"]["declineReasonKey"].lower()
if reason_key not in decline_details:
logger.warning(f"Unknown decline reason received: {reason_key}")
game_problem = decline_details.get(reason_key, "") if self.challenge_filter == FilterType.FINE else ""
self.add_challenge_filter(opponent.name, game_problem)
logger.info(f"Will not challenge {opponent} to another {game_problem}".strip() + " game.")
self.show_earliest_challenge_time()
def game_category(variant: str, base_time: int, increment: int, days: int) -> str:
"""
Get the game type (e.g. bullet, atomic, classical). Lichess has one rating for every variant regardless of time control.
:param variant: The game's variant.
:param base_time: The base time in seconds.
:param increment: The increment in seconds.
:param days: If the game is correspondence, we have some days to play the move.
:return: The game category.
"""
game_duration = base_time + increment * 40
if variant != "standard":
return variant
elif days:
return "correspondence"
elif game_duration < 179:
return "bullet"
elif game_duration < 479:
return "blitz"
elif game_duration < 1499:
return "rapid"
else:
return "classical"

284
lichess_bot/lib/model.py Normal file
View File

@@ -0,0 +1,284 @@
"""Store information about a challenge, game or player in a class."""
import math
from urllib.parse import urljoin
import logging
import datetime
from enum import Enum
from lib.timer import Timer, msec, seconds, sec_str, to_msec, to_seconds, years
from lib.config import Configuration
from typing import Any
from collections import defaultdict
logger = logging.getLogger(__name__)
class Challenge:
"""Store information about a challenge."""
def __init__(self, challenge_info: dict[str, Any], user_profile: dict[str, Any]) -> None:
""":param user_profile: Information about our bot."""
self.id = challenge_info["id"]
self.rated = challenge_info["rated"]
self.variant = challenge_info["variant"]["key"]
self.perf_name = challenge_info["perf"]["name"]
self.speed = challenge_info["speed"]
self.increment: int = challenge_info.get("timeControl", {}).get("increment")
self.base: int = challenge_info.get("timeControl", {}).get("limit")
self.days: int = challenge_info.get("timeControl", {}).get("daysPerTurn")
self.challenger = Player(challenge_info.get("challenger") or {})
self.opponent = Player(challenge_info.get("destUser") or {})
self.from_self = self.challenger.name == user_profile["username"]
def is_supported_variant(self, challenge_cfg: Configuration) -> bool:
"""Check whether the variant is supported."""
return self.variant in challenge_cfg.variants
def is_supported_time_control(self, challenge_cfg: Configuration) -> bool:
"""Check whether the time control is supported."""
speeds = challenge_cfg.time_controls
increment_max: int = challenge_cfg.max_increment
increment_min: int = challenge_cfg.min_increment
base_max: int = challenge_cfg.max_base
base_min: int = challenge_cfg.min_base
days_max: int = challenge_cfg.max_days
days_min: int = challenge_cfg.min_days
if self.speed not in speeds:
return False
require_non_zero_increment = (self.challenger.is_bot
and self.speed == "bullet"
and challenge_cfg.bullet_requires_increment)
increment_min = max(increment_min, 1 if require_non_zero_increment else 0)
if self.base is not None and self.increment is not None:
# Normal clock game
return (increment_min <= self.increment <= increment_max
and base_min <= self.base <= base_max)
elif self.days is not None:
# Correspondence game
return days_min <= self.days <= days_max
else:
# Unlimited game
return days_max == math.inf
def is_supported_mode(self, challenge_cfg: Configuration) -> bool:
"""Check whether the mode is supported."""
return ("rated" if self.rated else "casual") in challenge_cfg.modes
def is_supported_recent(self, config: Configuration, recent_bot_challenges: defaultdict[str, list[Timer]]) -> bool:
"""Check whether we have played a lot of games with this opponent recently. Only used when the oppoennt is a BOT."""
# Filter out old challenges
recent_bot_challenges[self.challenger.name] = [timer for timer
in recent_bot_challenges[self.challenger.name]
if not timer.is_expired()]
max_recent_challenges = config.max_recent_bot_challenges
return (not self.challenger.is_bot
or max_recent_challenges is None
or len(recent_bot_challenges[self.challenger.name]) < max_recent_challenges)
def decline_due_to(self, requirement_met: bool, decline_reason: str) -> str:
"""
Get the reason lichess_bot declined an incoming challenge.
:param requirement_met: Whether a requirement is met.
:param decline_reason: The reason we declined the challenge if the requirement wasn't met.
:return: `decline_reason` if `requirement_met` is false else returns an empty string.
"""
return "" if requirement_met else decline_reason
def is_supported(self, config: Configuration,
recent_bot_challenges: defaultdict[str, list[Timer]]) -> tuple[bool, str]:
"""Whether the challenge is supported."""
try:
if self.from_self:
return True, ""
allowed_opponents: list[str] = list(filter(None, config.allow_list)) or [self.challenger.name]
decline_reason = (self.decline_due_to(config.accept_bot or not self.challenger.is_bot, "noBot")
or self.decline_due_to(not config.only_bot or self.challenger.is_bot, "onlyBot")
or self.decline_due_to(self.is_supported_time_control(config), "timeControl")
or self.decline_due_to(self.is_supported_variant(config), "variant")
or self.decline_due_to(self.is_supported_mode(config), "casual" if self.rated else "rated")
or self.decline_due_to(self.challenger.name not in config.block_list, "generic")
or self.decline_due_to(self.challenger.name in allowed_opponents, "generic")
or self.decline_due_to(self.is_supported_recent(config, recent_bot_challenges), "later"))
return not decline_reason, decline_reason
except Exception:
logger.exception(f"Error while checking challenge {self.id}:")
return False, "generic"
def score(self) -> int:
"""Give a rating estimate to the opponent."""
rated_bonus = 200 if self.rated else 0
challenger_master_title = self.challenger.title if not self.challenger.is_bot else None
titled_bonus = 200 if challenger_master_title else 0
challenger_rating_int = self.challenger.rating or 0
return challenger_rating_int + rated_bonus + titled_bonus
def mode(self) -> str:
"""Get the mode of the challenge (rated or casual)."""
return "rated" if self.rated else "casual"
def __str__(self) -> str:
"""Get a string representation of `Challenge`."""
return f"{self.perf_name} {self.mode()} challenge from {self.challenger} ({self.id})"
def __repr__(self) -> str:
"""Get a string representation of `Challenge`."""
return self.__str__()
class Termination(str, Enum):
"""The possible game terminations."""
MATE = "mate"
TIMEOUT = "outoftime"
RESIGN = "resign"
ABORT = "aborted"
DRAW = "draw"
class Game:
"""Store information about a game."""
def __init__(self, game_info: dict[str, Any], username: str, base_url: str, abort_time: datetime.timedelta) -> None:
""":param abort_time: How long to wait before aborting the game."""
self.username = username
self.id: str = game_info["id"]
self.speed = game_info.get("speed")
clock = game_info.get("clock") or {}
ten_years_in_ms = to_msec(years(10))
self.clock_initial = msec(clock.get("initial", ten_years_in_ms))
self.clock_increment = msec(clock.get("increment", 0))
self.perf_name = (game_info.get("perf") or {}).get("name", "{perf?}")
self.variant_name = game_info["variant"]["name"]
self.mode = "rated" if game_info.get("rated") else "casual"
self.white = Player(game_info["white"])
self.black = Player(game_info["black"])
self.initial_fen = game_info.get("initialFen")
self.state: dict[str, Any] = game_info["state"]
self.is_white = (self.white.name or "").lower() == username.lower()
self.my_color = "white" if self.is_white else "black"
self.opponent_color = "black" if self.is_white else "white"
self.me = self.white if self.is_white else self.black
self.opponent = self.black if self.is_white else self.white
self.base_url = base_url
self.game_start = datetime.datetime.fromtimestamp(to_seconds(msec(game_info["createdAt"])),
tz=datetime.timezone.utc)
self.abort_time = Timer(abort_time)
self.terminate_time = Timer(self.clock_initial + self.clock_increment + abort_time + seconds(60))
self.disconnect_time = Timer(seconds(0))
def url(self) -> str:
"""Get the url of the game."""
return f"{self.short_url()}/{self.my_color}"
def short_url(self) -> str:
"""Get the short url of the game."""
return urljoin(self.base_url, self.id)
def pgn_event(self) -> str:
"""Get the event to write in the PGN file."""
if self.variant_name in ["Standard", "From Position"]:
return f"{self.mode.title()} {self.perf_name.title()} game"
else:
return f"{self.mode.title()} {self.variant_name} game"
def time_control(self) -> str:
"""Get the time control of the game."""
return f"{sec_str(self.clock_initial)}+{sec_str(self.clock_increment)}"
def is_abortable(self) -> bool:
"""Whether the game can be aborted."""
# Moves are separated by spaces. A game is abortable when less
# than two moves (one from each player) have been played.
return " " not in self.state["moves"]
def ping(self, abort_in: datetime.timedelta, terminate_in: datetime.timedelta, disconnect_in: datetime.timedelta) -> None:
"""
Tell the bot when to abort, terminate, and disconnect from a game.
:param abort_in: How many seconds to wait before aborting.
:param terminate_in: How many seconds to wait before terminating.
:param disconnect_in: How many seconds to wait before disconnecting.
"""
if self.is_abortable():
self.abort_time = Timer(abort_in)
self.terminate_time = Timer(terminate_in)
self.disconnect_time = Timer(disconnect_in)
def should_abort_now(self) -> bool:
"""Whether we should abort the game."""
return self.is_abortable() and self.abort_time.is_expired()
def should_terminate_now(self) -> bool:
"""Whether we should terminate the game."""
return self.terminate_time.is_expired()
def should_disconnect_now(self) -> bool:
"""Whether we should disconnect form the game."""
return self.disconnect_time.is_expired()
def my_remaining_time(self) -> datetime.timedelta:
"""How many seconds we have left."""
wtime = msec(self.state["wtime"])
btime = msec(self.state["btime"])
return wtime if self.is_white else btime
def result(self) -> str:
"""Get the result of the game."""
class GameEnding(str, Enum):
WHITE_WINS = "1-0"
BLACK_WINS = "0-1"
DRAW = "1/2-1/2"
INCOMPLETE = "*"
winner = self.state.get("winner")
termination = self.state.get("status")
if winner == "white":
result = GameEnding.WHITE_WINS
elif winner == "black":
result = GameEnding.BLACK_WINS
elif termination in [Termination.DRAW, Termination.TIMEOUT]:
result = GameEnding.DRAW
else:
result = GameEnding.INCOMPLETE
return result.value
def __str__(self) -> str:
"""Get a string representation of `Game`."""
return f"{self.url()} {self.perf_name} vs {self.opponent} ({self.id})"
def __repr__(self) -> str:
"""Get a string representation of `Game`."""
return self.__str__()
class Player:
"""Store information about a player."""
def __init__(self, player_info: dict[str, Any]) -> None:
""":param player_info: Contains information about a player."""
self.title = player_info.get("title")
self.rating = player_info.get("rating")
self.provisional = player_info.get("provisional")
self.aiLevel = player_info.get("aiLevel")
self.is_bot = self.title == "BOT" or self.aiLevel is not None
self.name: str = f"AI level {self.aiLevel}" if self.aiLevel else player_info.get("name", "")
def __str__(self) -> str:
"""Get a string representation of `Player`."""
if self.aiLevel:
return self.name
else:
rating = f'{self.rating}{"?" if self.provisional else ""}'
return f'{self.title or ""} {self.name} ({rating})'.strip()
def __repr__(self) -> str:
"""Get a string representation of `Player`."""
return self.__str__()

View File

@@ -0,0 +1,95 @@
"""
Some example strategies for people who want to create a custom, homemade bot.
With these classes, bot makers will not have to implement the UCI or XBoard interfaces themselves.
"""
from __future__ import annotations
import chess
from chess.engine import PlayResult, Limit
import random
from lib.engine_wrapper import MinimalEngine, MOVE
from typing import Any
import logging
# Use this logger variable to print messages to the console or log files.
# logger.info("message") will always print "message" to the console or log file.
# logger.debug("message") will only print "message" if verbose logging is enabled.
logger = logging.getLogger(__name__)
class ExampleEngine(MinimalEngine):
"""An example engine that all homemade engines inherit."""
pass
# Strategy names and ideas from tom7's excellent eloWorld video
class RandomMove(ExampleEngine):
"""Get a random move."""
def search(self, board: chess.Board, *args: Any) -> PlayResult:
"""Choose a random move."""
return PlayResult(random.choice(list(board.legal_moves)), None)
class Alphabetical(ExampleEngine):
"""Get the first move when sorted by san representation."""
def search(self, board: chess.Board, *args: Any) -> PlayResult:
"""Choose the first move alphabetically."""
moves = list(board.legal_moves)
moves.sort(key=board.san)
return PlayResult(moves[0], None)
class FirstMove(ExampleEngine):
"""Get the first move when sorted by uci representation."""
def search(self, board: chess.Board, *args: Any) -> PlayResult:
"""Choose the first move alphabetically in uci representation."""
moves = list(board.legal_moves)
moves.sort(key=str)
return PlayResult(moves[0], None)
class ComboEngine(ExampleEngine):
"""
Get a move using multiple different methods.
This engine demonstrates how one can use `time_limit`, `draw_offered`, and `root_moves`.
"""
def search(self, board: chess.Board, time_limit: Limit, ponder: bool, draw_offered: bool, root_moves: MOVE) -> PlayResult:
"""
Choose a move using multiple different methods.
:param board: The current position.
:param time_limit: Conditions for how long the engine can search (e.g. we have 10 seconds and search up to depth 10).
:param ponder: Whether the engine can ponder after playing a move.
:param draw_offered: Whether the bot was offered a draw.
:param root_moves: If it is a list, the engine should only play a move that is in `root_moves`.
:return: The move to play.
"""
if isinstance(time_limit.time, int):
my_time = time_limit.time
my_inc = 0
elif board.turn == chess.WHITE:
my_time = time_limit.white_clock if isinstance(time_limit.white_clock, int) else 0
my_inc = time_limit.white_inc if isinstance(time_limit.white_inc, int) else 0
else:
my_time = time_limit.black_clock if isinstance(time_limit.black_clock, int) else 0
my_inc = time_limit.black_inc if isinstance(time_limit.black_inc, int) else 0
possible_moves = root_moves if isinstance(root_moves, list) else list(board.legal_moves)
if my_time / 60 + my_inc > 10:
# Choose a random move.
move = random.choice(possible_moves)
else:
# Choose the first move alphabetically in uci representation.
possible_moves.sort(key=str)
move = possible_moves[0]
return PlayResult(move, None, draw_offered=draw_offered)

103
lichess_bot/lib/timer.py Normal file
View File

@@ -0,0 +1,103 @@
"""A timer for use in lichess_bot."""
import time
import datetime
from typing import Optional
def msec(time_in_msec: float) -> datetime.timedelta:
"""Create a timedelta duration in milliseconds."""
return datetime.timedelta(milliseconds=time_in_msec)
def to_msec(duration: datetime.timedelta) -> float:
"""Return a bare number representing the length of the duration in milliseconds."""
return duration / msec(1)
def msec_str(duration: datetime.timedelta) -> str:
"""Return a string with the duration value in whole number milliseconds."""
return str(round(to_msec(duration)))
def seconds(time_in_sec: float) -> datetime.timedelta:
"""Create a timedelta duration in seconds."""
return datetime.timedelta(seconds=time_in_sec)
def to_seconds(duration: datetime.timedelta) -> float:
"""Return a bare number representing the length of the duration in seconds."""
return duration.total_seconds()
def sec_str(duration: datetime.timedelta) -> str:
"""Return a string with the duration value in whole number seconds."""
return str(round(to_seconds(duration)))
def minutes(time_in_minutes: float) -> datetime.timedelta:
"""Create a timedelta duration in minutes."""
return datetime.timedelta(minutes=time_in_minutes)
def hours(time_in_hours: float) -> datetime.timedelta:
"""Create a timedelta duration in hours."""
return datetime.timedelta(hours=time_in_hours)
def days(time_in_days: float) -> datetime.timedelta:
"""Create a timedelta duration in minutes."""
return datetime.timedelta(days=time_in_days)
def years(time_in_years: float) -> datetime.timedelta:
"""Create a timedelta duration in median years--i.e., 365 days."""
return days(365) * time_in_years
class Timer:
"""
A timer for use in lichess_bot. An instance of timer can be used both as a countdown timer and a stopwatch.
If the duration argument in the __init__() method is greater than zero, then
the method is_expired() indicates when the intial duration has passed. The
method time_until_expiration() gives the amount of time left until the timer
expires.
Regardless of the initial duration (even if it's zero), a timer can be used
as a stopwatch by calling time_since_reset() to get the amount of time since
the timer was created or since it was last reset.
"""
def __init__(self, duration: datetime.timedelta = seconds(0),
backdated_timestamp: Optional[datetime.datetime] = None) -> None:
"""
Start the timer.
:param duration: The duration of time before Timer.is_expired() returns True.
:param backdated_timestamp: When the timer should have started. Used to keep the timers between sessions.
"""
self.duration = duration
self.reset()
if backdated_timestamp is not None:
time_already_used = datetime.datetime.now() - backdated_timestamp
self.starting_time -= to_seconds(time_already_used)
def is_expired(self) -> bool:
"""Check if a timer is expired."""
return self.time_since_reset() >= self.duration
def reset(self) -> None:
"""Reset the timer."""
self.starting_time = time.perf_counter()
def time_since_reset(self) -> datetime.timedelta:
"""How much time has passed."""
return seconds(time.perf_counter() - self.starting_time)
def time_until_expiration(self) -> datetime.timedelta:
"""How much time is left until it expires."""
return max(seconds(0), self.duration - self.time_since_reset())
def starting_timestamp(self, format: str) -> str:
"""When the timer started."""
return (datetime.datetime.now() - self.time_since_reset()).strftime(format)

View File

@@ -0,0 +1,4 @@
lichess_bot_version: 2024.1.21.1
minimum_python_version: '3.9'
deprecated_python_version: '3.8'
deprecation_date: 2023-05-01

1091
lichess_bot/lichess-bot.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,299 @@
2024-01-25 17:43:00,988 __main__ (lichess-bot.py:1023) INFO
. _/|
. // o\
. || ._) lichess_bot 2024.1.21.1
. //__\
. )___( Play on Lichess with a bot
2024-01-25 17:43:01,008 lib.config (config.py:261) DEBUG Config:
token: logger
url: https://lichess.org/
engine:
dir: ../engines/
name: ProbStockfish
working_dir: ./
protocol: homemade
ponder: true
polyglot:
enabled: false
book:
standard:
- engines/book1.bin
- engines/book2.bin
min_weight: 1
selection: weighted_random
max_depth: 20
draw_or_resign:
resign_enabled: false
resign_score: -1000
resign_for_egtb_minus_two: true
resign_moves: 3
offer_draw_enabled: true
offer_draw_score: 0
offer_draw_for_egtb_zero: true
offer_draw_moves: 10
offer_draw_pieces: 10
online_moves:
max_out_of_book_moves: 10
max_retries: 2
chessdb_book:
enabled: false
min_time: 20
move_quality: good
min_depth: 20
lichess_cloud_analysis:
enabled: false
min_time: 20
move_quality: best
max_score_difference: 50
min_depth: 20
min_knodes: 0
lichess_opening_explorer:
enabled: false
min_time: 20
source: masters
player_name: ''
sort: winrate
min_games: 10
online_egtb:
enabled: false
min_time: 20
max_pieces: 7
source: lichess
move_quality: best
lichess_bot_tbs:
syzygy:
enabled: false
paths:
- engines/syzygy
max_pieces: 7
move_quality: best
gaviota:
enabled: false
paths:
- engines/gaviota
max_pieces: 5
min_dtm_to_consider_as_wdl_1: 120
move_quality: best
homemade_options: null
uci_options:
Move Overhead: 100
Threads: 4
Hash: 512
SyzygyPath: ./syzygy/
UCI_ShowWDL: true
silence_stderr: false
abort_time: 30
fake_think_time: false
rate_limiting_delay: 0
move_overhead: 2000
correspondence:
move_time: 60
checkin_period: 300
disconnect_time: 150
ponder: false
challenge:
concurrency: 1
sort_by: best
accept_bot: true
only_bot: false
max_increment: 20
min_increment: 0
max_base: 1800
min_base: 0
max_days: 14
min_days: 1
variants:
- standard
time_controls:
- bullet
- blitz
- rapid
- classical
modes:
- casual
- rated
bullet_requires_increment: false
greeting:
hello: Hi! I'm {me}. Good luck! Type !help for a list of commands I can respond
to.
goodbye: Good game!
hello_spectators: Hi! I'm {me}. Type !help for a list of commands I can respond
to.
goodbye_spectators: Thanks for watching!
matchmaking:
allow_matchmaking: false
challenge_variant: random
challenge_timeout: 30
challenge_initial_time:
- 60
- 180
challenge_increment:
- 1
- 2
opponent_rating_difference: 300
rating_preference: none
opponent_allow_tos_violation: false
challenge_mode: random
challenge_filter: none
2024-01-25 17:43:01,008 lib.config (config.py:262) DEBUG ====================
2024-01-25 17:43:01,012 lib.config (config.py:261) DEBUG Config:
token: logger
url: https://lichess.org/
engine:
dir: ../engines/
name: ProbStockfish
working_dir: ./
protocol: homemade
ponder: true
polyglot:
enabled: false
book:
standard:
- engines/book1.bin
- engines/book2.bin
min_weight: 1
selection: weighted_random
max_depth: 20
draw_or_resign:
resign_enabled: false
resign_score: -1000
resign_for_egtb_minus_two: true
resign_moves: 3
offer_draw_enabled: true
offer_draw_score: 0
offer_draw_for_egtb_zero: true
offer_draw_moves: 10
offer_draw_pieces: 10
online_moves:
max_out_of_book_moves: 10
max_retries: 2
chessdb_book:
enabled: false
min_time: 20
move_quality: good
min_depth: 20
lichess_cloud_analysis:
enabled: false
min_time: 20
move_quality: best
max_score_difference: 50
min_depth: 20
min_knodes: 0
lichess_opening_explorer:
enabled: false
min_time: 20
source: masters
player_name: ''
sort: winrate
min_games: 10
online_egtb:
enabled: false
min_time: 20
max_pieces: 7
source: lichess
move_quality: best
max_depth: .inf
lichess_bot_tbs:
syzygy:
enabled: false
paths:
- engines/syzygy
max_pieces: 7
move_quality: best
gaviota:
enabled: false
paths:
- engines/gaviota
max_pieces: 5
min_dtm_to_consider_as_wdl_1: 120
move_quality: best
homemade_options: null
uci_options:
Move Overhead: 100
Threads: 4
Hash: 512
SyzygyPath: ./syzygy/
UCI_ShowWDL: true
silence_stderr: false
uci_ponder: false
abort_time: 30
fake_think_time: false
rate_limiting_delay: 0
move_overhead: 2000
correspondence:
move_time: 60
checkin_period: 300
disconnect_time: 150
ponder: false
uci_ponder: false
challenge:
concurrency: 1
sort_by: best
accept_bot: true
only_bot: false
max_increment: 20
min_increment: 0
max_base: 1800
min_base: 0
max_days: 14
min_days: 1
variants:
- standard
time_controls:
- bullet
- blitz
- rapid
- classical
modes:
- casual
- rated
bullet_requires_increment: false
block_list: []
allow_list: []
greeting:
hello: Hi! I'm {me}. Good luck! Type !help for a list of commands I can respond
to.
goodbye: Good game!
hello_spectators: Hi! I'm {me}. Type !help for a list of commands I can respond
to.
goodbye_spectators: Thanks for watching!
matchmaking:
allow_matchmaking: false
challenge_variant: random
challenge_timeout: 30
challenge_initial_time:
- 60
- 180
challenge_increment:
- 1
- 2
opponent_rating_difference: 300
rating_preference: none
opponent_allow_tos_violation: false
challenge_mode: random
challenge_filter: none
block_list: []
challenge_days:
- null
opponent_min_rating: 600
opponent_max_rating: 4000
overrides: {}
pgn_file_grouping: game
2024-01-25 17:43:01,012 lib.config (config.py:262) DEBUG ====================
2024-01-25 17:43:01,012 __main__ (lichess-bot.py:1026) INFO Checking engine configuration ...
2024-01-25 17:43:01,015 __main__ (lichess-bot.py:1091) ERROR Quitting lichess_bot due to an error:
Traceback (most recent call last):
File "/home/luke/projects/pp-project/chess-engine-pp/lichess_bot/lichess-bot.py", line 1088, in <module>
start_lichess_bot()
File "/home/luke/projects/pp-project/chess-engine-pp/lichess_bot/lichess-bot.py", line 1027, in start_lichess_bot
with engine_wrapper.create_engine(CONFIG):
File "/usr/lib/python3.10/contextlib.py", line 135, in __enter__
return next(self.gen)
File "/home/luke/projects/pp-project/chess-engine-pp/lichess_bot/lib/engine_wrapper.py", line 60, in create_engine
Engine = getHomemadeEngine(cfg.name)
File "/home/luke/projects/pp-project/chess-engine-pp/lichess_bot/lib/engine_wrapper.py", line 589, in getHomemadeEngine
engine: type[MinimalEngine] = getattr(strategies, name)
AttributeError: module 'lib.strategies' has no attribute 'ProbStockfish'

View File

@@ -0,0 +1,299 @@
2024-01-25 17:53:40,978 __main__ (lichess-bot.py:1023) INFO
. _/|
. // o\
. || ._) lichess_bot 2024.1.21.1
. //__\
. )___( Play on Lichess with a bot
2024-01-25 17:53:40,998 lib.config (config.py:261) DEBUG Config:
token: logger
url: https://lichess.org/
engine:
dir: ../
name: ProbStockfish
working_dir: ../
protocol: homemade
ponder: true
polyglot:
enabled: false
book:
standard:
- engines/book1.bin
- engines/book2.bin
min_weight: 1
selection: weighted_random
max_depth: 20
draw_or_resign:
resign_enabled: false
resign_score: -1000
resign_for_egtb_minus_two: true
resign_moves: 3
offer_draw_enabled: true
offer_draw_score: 0
offer_draw_for_egtb_zero: true
offer_draw_moves: 10
offer_draw_pieces: 10
online_moves:
max_out_of_book_moves: 10
max_retries: 2
chessdb_book:
enabled: false
min_time: 20
move_quality: good
min_depth: 20
lichess_cloud_analysis:
enabled: false
min_time: 20
move_quality: best
max_score_difference: 50
min_depth: 20
min_knodes: 0
lichess_opening_explorer:
enabled: false
min_time: 20
source: masters
player_name: ''
sort: winrate
min_games: 10
online_egtb:
enabled: false
min_time: 20
max_pieces: 7
source: lichess
move_quality: best
lichess_bot_tbs:
syzygy:
enabled: false
paths:
- engines/syzygy
max_pieces: 7
move_quality: best
gaviota:
enabled: false
paths:
- engines/gaviota
max_pieces: 5
min_dtm_to_consider_as_wdl_1: 120
move_quality: best
homemade_options: null
uci_options:
Move Overhead: 100
Threads: 4
Hash: 512
SyzygyPath: ./syzygy/
UCI_ShowWDL: true
silence_stderr: false
abort_time: 30
fake_think_time: false
rate_limiting_delay: 0
move_overhead: 2000
correspondence:
move_time: 60
checkin_period: 300
disconnect_time: 150
ponder: false
challenge:
concurrency: 1
sort_by: best
accept_bot: true
only_bot: false
max_increment: 20
min_increment: 0
max_base: 1800
min_base: 0
max_days: 14
min_days: 1
variants:
- standard
time_controls:
- bullet
- blitz
- rapid
- classical
modes:
- casual
- rated
bullet_requires_increment: false
greeting:
hello: Hi! I'm {me}. Good luck! Type !help for a list of commands I can respond
to.
goodbye: Good game!
hello_spectators: Hi! I'm {me}. Type !help for a list of commands I can respond
to.
goodbye_spectators: Thanks for watching!
matchmaking:
allow_matchmaking: false
challenge_variant: random
challenge_timeout: 30
challenge_initial_time:
- 60
- 180
challenge_increment:
- 1
- 2
opponent_rating_difference: 300
rating_preference: none
opponent_allow_tos_violation: false
challenge_mode: random
challenge_filter: none
2024-01-25 17:53:40,998 lib.config (config.py:262) DEBUG ====================
2024-01-25 17:53:41,003 lib.config (config.py:261) DEBUG Config:
token: logger
url: https://lichess.org/
engine:
dir: ../
name: ProbStockfish
working_dir: ../
protocol: homemade
ponder: true
polyglot:
enabled: false
book:
standard:
- engines/book1.bin
- engines/book2.bin
min_weight: 1
selection: weighted_random
max_depth: 20
draw_or_resign:
resign_enabled: false
resign_score: -1000
resign_for_egtb_minus_two: true
resign_moves: 3
offer_draw_enabled: true
offer_draw_score: 0
offer_draw_for_egtb_zero: true
offer_draw_moves: 10
offer_draw_pieces: 10
online_moves:
max_out_of_book_moves: 10
max_retries: 2
chessdb_book:
enabled: false
min_time: 20
move_quality: good
min_depth: 20
lichess_cloud_analysis:
enabled: false
min_time: 20
move_quality: best
max_score_difference: 50
min_depth: 20
min_knodes: 0
lichess_opening_explorer:
enabled: false
min_time: 20
source: masters
player_name: ''
sort: winrate
min_games: 10
online_egtb:
enabled: false
min_time: 20
max_pieces: 7
source: lichess
move_quality: best
max_depth: .inf
lichess_bot_tbs:
syzygy:
enabled: false
paths:
- engines/syzygy
max_pieces: 7
move_quality: best
gaviota:
enabled: false
paths:
- engines/gaviota
max_pieces: 5
min_dtm_to_consider_as_wdl_1: 120
move_quality: best
homemade_options: null
uci_options:
Move Overhead: 100
Threads: 4
Hash: 512
SyzygyPath: ./syzygy/
UCI_ShowWDL: true
silence_stderr: false
uci_ponder: false
abort_time: 30
fake_think_time: false
rate_limiting_delay: 0
move_overhead: 2000
correspondence:
move_time: 60
checkin_period: 300
disconnect_time: 150
ponder: false
uci_ponder: false
challenge:
concurrency: 1
sort_by: best
accept_bot: true
only_bot: false
max_increment: 20
min_increment: 0
max_base: 1800
min_base: 0
max_days: 14
min_days: 1
variants:
- standard
time_controls:
- bullet
- blitz
- rapid
- classical
modes:
- casual
- rated
bullet_requires_increment: false
block_list: []
allow_list: []
greeting:
hello: Hi! I'm {me}. Good luck! Type !help for a list of commands I can respond
to.
goodbye: Good game!
hello_spectators: Hi! I'm {me}. Type !help for a list of commands I can respond
to.
goodbye_spectators: Thanks for watching!
matchmaking:
allow_matchmaking: false
challenge_variant: random
challenge_timeout: 30
challenge_initial_time:
- 60
- 180
challenge_increment:
- 1
- 2
opponent_rating_difference: 300
rating_preference: none
opponent_allow_tos_violation: false
challenge_mode: random
challenge_filter: none
block_list: []
challenge_days:
- null
opponent_min_rating: 600
opponent_max_rating: 4000
overrides: {}
pgn_file_grouping: game
2024-01-25 17:53:41,003 lib.config (config.py:262) DEBUG ====================
2024-01-25 17:53:41,003 __main__ (lichess-bot.py:1026) INFO Checking engine configuration ...
2024-01-25 17:53:41,006 __main__ (lichess-bot.py:1091) ERROR Quitting lichess_bot due to an error:
Traceback (most recent call last):
File "/home/luke/projects/pp-project/chess-engine-pp/lichess_bot/lichess-bot.py", line 1088, in <module>
start_lichess_bot()
File "/home/luke/projects/pp-project/chess-engine-pp/lichess_bot/lichess-bot.py", line 1027, in start_lichess_bot
with engine_wrapper.create_engine(CONFIG):
File "/usr/lib/python3.10/contextlib.py", line 135, in __enter__
return next(self.gen)
File "/home/luke/projects/pp-project/chess-engine-pp/lichess_bot/lib/engine_wrapper.py", line 60, in create_engine
Engine = getHomemadeEngine(cfg.name)
File "/home/luke/projects/pp-project/chess-engine-pp/lichess_bot/lib/engine_wrapper.py", line 589, in getHomemadeEngine
engine: type[MinimalEngine] = getattr(strategies, name)
AttributeError: module 'lib.strategies' has no attribute 'ProbStockfish'

View File

@@ -0,0 +1,5 @@
chess==1.10.0
PyYAML==6.0.1
requests==2.31.0
backoff==2.2.1
rich==13.7.0

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,294 @@
# Configuring lichess-bot
There are many possible options within `config.yml` for configuring lichess-bot.
## Engine options
- `protocol`: Specify which protocol your engine uses. Choices are
1. `"uci"` for the [Universal Chess Interface](http://wbec-ridderkerk.nl/html/UCIProtocol.html)
2. `"xboard"` for the XBoard/WinBoard/[Chess Engine Communication Protocol](https://www.gnu.org/software/xboard/engine-intf.html)
3. `"homemade"` if you want to write your own engine in Python within lichess-bot. See [**Create a custom engine**](https://github.com/lichess-bot-devs/lichess-bot/wiki/Create-a-custom-engine).
- `ponder`: Specify whether your bot will ponder--i.e., think while the bot's opponent is choosing a move.
- `engine_options`: Command line options to pass to the engine on startup. For example, the `config.yml.default` has the configuration
```yml
engine_options:
cpuct: 3.1
```
This would create the command-line option `--cpuct=3.1` to be used when starting the engine, like this for the engine lc0: `lc0 --cpuct=3.1`. Any number of options can be listed here, each getting their own command-line option.
- `uci_options`: A list of options to pass to a UCI engine after startup. Different engines have different options, so treat the options in `config.yml.default` as templates and not suggestions. When UCI engines start, they print a list of configurations that can modify their behavior after receiving the string "uci". For example, to find out what options Stockfish 13 supports, run the executable in a terminal, type `uci`, and press Enter. The engine will print the following when run at the command line:
```
id name Stockfish 13
id author the Stockfish developers (see AUTHORS file)
option name Debug Log File type string default
option name Contempt type spin default 24 min -100 max 100
option name Analysis Contempt type combo default Both var Off var White var Black var Both
option name Threads type spin default 1 min 1 max 512
option name Hash type spin default 16 min 1 max 33554432
option name Clear Hash type button
option name Ponder type check default false
option name MultiPV type spin default 1 min 1 max 500
option name Skill Level type spin default 20 min 0 max 20
option name Move Overhead type spin default 10 min 0 max 5000
option name Slow Mover type spin default 100 min 10 max 1000
option name nodestime type spin default 0 min 0 max 10000
option name UCI_Chess960 type check default false
option name UCI_AnalyseMode type check default false
option name UCI_LimitStrength type check default false
option name UCI_Elo type spin default 1350 min 1350 max 2850
option name UCI_ShowWDL type check default false
option name SyzygyPath type string default <empty>
option name SyzygyProbeDepth type spin default 1 min 1 max 100
option name Syzygy50MoveRule type check default true
option name SyzygyProbeLimit type spin default 7 min 0 max 7
option name Use NNUE type check default true
option name EvalFile type string default nn-62ef826d1a6d.nnue
uciok
```
Any of the names following `option name` can be listed in `uci_options` in order to configure the Stockfish engine.
```yml
uci_options:
Move Overhead: 100
Skill Level: 10
```
The exceptions to this are the options `uci_chess960`, `uci_variant`, `multipv`, and `ponder`. These will be handled by lichess-bot after a game starts and should not be listed in `config.yml`. Also, if an option is listed under `uci_options` that is not in the list printed by the engine, it will cause an error when the engine starts because the engine won't understand the option. The word after `type` indicates the expected type of the options: `string` for a text string, `spin` for a numeric value, `check` for a boolean True/False value.
One last option is `go_commands`. Beneath this option, arguments to the UCI `go` command can be passed. For example,
```yml
go_commands:
nodes: 1
depth: 5
movetime: 1000
```
will append `nodes 1 depth 5 movetime 1000` to the command to start thinking of a move: `go startpos e2e4 e7e5 ...`.
- `xboard_options`: A list of options to pass to an XBoard engine after startup. Different engines have different options, so treat the options in `config.yml.default` as templates and not suggestions. When XBoard engines start, they print a list of configurations that can modify their behavior. To see these configurations, run the engine in a terminal, type `xboard`, press Enter, type `protover 2`, and press Enter. The configurable options will be prefixed with `feature option`. Some examples may include
```
feature option="Add Noise -check VALUE"
feature option="PGN File -string VALUE"
feature option="CPU Count -spin VALUE MIN MAX"
```
Any of the options can be listed under `xboard_options` in order to configure the XBoard engine.
```yml
xboard_options:
Add Noise: False
PGN File: lichess_games.pgn
CPU Count: 1
```
The exceptions to this are the options `multipv`, and `ponder`. These will be handled by lichess-bot after a game starts and should not be listed in `config.yml`. Also, if an option is listed under `xboard_options` that is not in the list printed by the engine, it will cause an error when the engine starts because the engine won't know how to handle the option. The word prefixed with a hyphen indicates the expected type of the options: `-string` for a text string, `-spin` for a numeric value, `-check` for a boolean True/False value.
One last option is `go_commands`. Beneath this option, commands prior to the `go` command can be passed. For example,
```yml
go_commands:
depth: 5
```
will precede the `go` command to start thinking with `sd 5`. The other `go_commands` list above for UCI engines (`nodes` and `movetime`) are not valid for XBoard engines and will detrimentally affect their time control.
## External moves
- `polyglot`: Tell lichess-bot whether your bot should use an opening book. Multiple books can be specified for each chess variant.
- `enabled`: Whether to use the book at all.
- `book`: A nested list of books. The next indented line should list a chess variant (`standard`, `3check`, `horde`, etc.) followed on succeeding indented lines with paths to the book files. See `config.yml.default` for examples.
- `min_weight`: The minimum weight or quality a move must have if it is to have a chance of being selected. If a move cannot be found that has at least this weight, no move will be selected.
- `selection`: The method for selecting a move. The choices are: `"weighted_random"` where moves with a higher weight/quality have a higher probability of being chosen, `"uniform_random"` where all moves of sufficient quality have an equal chance of being chosen, and `"best_move"` where the move with the highest weight is always chosen.
- `max_depth`: The maximum number of moves a bot plays before it stops consulting the book. If `max_depth` is 3, then the bot will stop consulting the book after its third move.
- `online_moves`: This section gives your bot access to various online resources for choosing moves like opening books and endgame tablebases. This can be a supplement or a replacement for chess databases stored on your computer. There are four sections that correspond to four different online databases:
1. `chessdb_book`: Consults a [Chinese chess position database](https://www.chessdb.cn/), which also hosts a xiangqi database.
2. `lichess_cloud_analysis`: Consults [Lichess's own position analysis database](https://lichess.org/api#operation/apiCloudEval).
3. `lichess_opening_explorer`: Consults [Lichess's opening explorer](https://lichess.org/api#tag/Opening-Explorer).
4. `online_egtb`: Consults either the online Syzygy 7-piece endgame tablebase [hosted by Lichess](https://lichess.org/blog/W3WeMyQAACQAdfAL/7-piece-syzygy-tablebases-are-complete) or the chessdb listed above.
- `max_out_of_book_moves`: Stop using online opening books after they don't have a move for `max_out_of_book_moves` positions. Doesn't apply to the online endgame tablebases.
- `max_retries`: The maximum amount of retries when getting an online move.
- `max_depth`: The maximum number of moves a bot can make in the opening before it stops consulting the online opening books. If `max_depth` is 5, then the bot will stop consulting the online books after its fifth move.
- Configurations common to all:
- `enabled`: Whether to use the database at all.
- `min_time`: The minimum time in seconds on the game clock necessary to allow the online database to be consulted.
- `move_quality`: Choice of `"all"` (`chessdb_book` only), `"good"` (all except `online_egtb`), `"best"`, or `"suggest"` (`online_egtb` only).
- `all`: Choose a random move from all legal moves.
- `best`: Choose only the highest scoring move.
- `good`: Choose randomly from the top moves. In `lichess_cloud_analysis`, the top moves list is controlled by `max_score_difference`. In `chessdb_book`, the top list is controlled by the online source.
- `suggest`: Let the engine choose between the top moves. The top moves are the all the moves that have the best WDL. Can't be used with XBoard engines.
- Configurations only in `chessdb_book` and `lichess_cloud_analysis`:
- `min_depth`: The minimum search depth for a move evaluation for a database move to be accepted.
- Configurations only in `lichess_cloud_analysis`:
- `max_score_difference`: When `move_quality` is set to `"good"`, this option specifies the maximum difference between the top scoring move and any other move that will make up the set from which a move will be chosen randomly. If this option is set to 25 and the top move in a position has a score of 100, no move with a score of less than 75 will be returned.
- `min_knodes`: The minimum number of kilonodes to search. The minimum number of nodes to search is this value times 1000.
- Configurations only in `lichess_opening_explorer`:
- `source`: One of `lichess`, `masters`, or `player`. Whether to use move statistics from masters, lichess players, or a specific player.
- `player_name`: Used only when `source` is `player`. The username of the player to use for move statistics.
- `sort`: One of `winrate` or `games_played`. Whether to choose the best move according to the winrate or the games played.
- `min_games`: The minimum number of times a move must have been played to be considered.
- Configurations only in `online_egtb`:
- `max_pieces`: The maximum number of pieces in the current board for which the tablebase will be consulted.
- `source`: One of `chessdb` or `lichess`. Lichess also has tablebases for atomic and antichess while chessdb only has those for standard.
- `lichess_bot_tbs`: This section gives your bot access to various resources for choosing moves like syzygy and gaviota endgame tablebases. There are two sections that correspond to two different endgame tablebases:
1. `syzygy`: Get moves from syzygy tablebases. `.*tbw` have to be always provided. Syzygy TBs are generally smaller that gaviota TBs.
2. `gaviota`: Get moves from gaviota tablebases.
- Configurations common to all:
- `enabled`: Whether to use the tablebases at all.
- `paths`: The paths to the tablebases.
- `max_pieces`: The maximum number of pieces in the current board for which the tablebase will be consulted.
- `move_quality`: Choice of `best` or `suggest`.
- `best`: Choose only the highest scoring move. When using `syzygy`, if `.*tbz` files are not provided, the bot will attempt to get a move using `move_quality` = `suggest`.
- `suggest`: Let the engine choose between the top moves. The top moves are the all the moves that have the best WDL. Can't be used with XBoard engines.
- Configurations only in `gaviota`:
- `min_dtm_to_consider_as_wdl_1`: The minimum DTM to consider as syzygy WDL=1/-1. Setting it to 100 will disable it.
## Offering draw and resigning
- `draw_or_resign`: This section allows your bot to resign or offer/accept draw based on the evaluation by the engine. XBoard engines can resign and offer/accept draw without this feature enabled.
- `resign_enabled`: Whether the bot is allowed to resign based on the evaluation.
- `resign_score`: The engine evaluation has to be less than or equal to `resign_score` for the bot to resign.
- `resign_for_egtb_minus_two`: If true the bot will resign in positions where the online_egtb returns a wdl of -2.
- `resign_moves`: The evaluation has to be less than or equal to `resign_score` for `resign_moves` amount of moves for the bot to resign.
- `offer_draw_enabled`: Whether the bot is allowed to offer/accept draw based on the evaluation.
- `offer_draw_score`: The absolute value of the engine evaluation has to be less than or equal to `offer_draw_score` for the bot to offer/accept draw.
- `offer_draw_for_egtb_zero`: If true the bot will offer/accept draw in positions where the online_egtb returns a wdl of 0.
- `offer_draw_moves`: The absolute value of the evaluation has to be less than or equal to `offer_draw_score` for `offer_draw_moves` amount of moves for the bot to offer/accept draw.
- `offer_draw_pieces`: The bot only offers/accepts draws if the position has less than or equal to `offer_draw_pieces` pieces.
## Options for correspondence games
- `correspondence` These options control how the engine behaves during correspondence games.
- `move_time`: How many seconds to think for each move.
- `checkin_period`: How often (in seconds) to reconnect to games to check for new moves after disconnecting.
- `disconnect_time`: How many seconds to wait after the bot makes a move for an opponent to make a move. If no move is made during the wait, disconnect from the game.
- `ponder`: Whether the bot should ponder during the above waiting period.
## Challenges the BOT should accept
- `challenge`: Control what kind of games for which the bot should accept challenges. All of the following options must be satisfied by a challenge to be accepted.
- `concurrency`: The maximum number of games to play simultaneously.
- `sort_by`: Whether to start games by the best rated/titled opponent `"best"` or by first-come-first-serve `"first"`.
- `accept_bot`: Whether to accept challenges from other bots.
- `only_bot`: Whether to only accept challenges from other bots.
- `max_increment`: The maximum value of time increment.
- `min_increment`: The minimum value of time increment.
- `bullet_requires_increment`: Require that bullet game challenges from bots have a non-zero increment. This can be useful if a bot often loses on time in short games due to spotty network connections or other sources of delay.
- `max_base`: The maximum base time for a game.
- `min_base`: The minimum base time for a game.
- `max_days`: The maximum number of days for a correspondence game.
- `min_days`: The minimum number of days for a correspondence game.
- `variants`: An indented list of chess variants that the bot can handle.
```yml
variants:
- standard
- horde
- antichess
# etc.
```
- `time_controls`: An indented list of acceptable time control types from `bullet` to `correspondence`.
```yml
time_controls:
- bullet
- blitz
- rapid
- classical
- correspondence
```
- `modes`: An indented list of acceptable game modes (`rated` and/or `casual`).
```yml
modes:
-rated
-casual
```
- `block_list`: An indented list of usernames from which the challenges are always declined. If this option is not present, then the list is considered empty.
- `allow_list`: An indented list of usernames from which challenges are exclusively accepted. A challenge from a user not on this list is declined. If this option is not present or empty, any user's challenge may be accepted.
- `recent_bot_challenge_age`: Maximum age of a bot challenge to be considered recent in seconds
- `max_recent_bot_challenges`: Maximum number of recent challenges that can be accepted from the same bot
## Greeting
- `greeting`: Send messages via chat to the bot's opponent. The string `{me}` will be replaced by the bot's lichess account name. The string `{opponent}` will be replaced by the opponent's lichess account name. Any other word between curly brackets will be removed. If you want to put a curly bracket in the message, use two: `{{` or `}}`.
- `hello`: Message to send to the opponent when the bot makes its first move.
- `goodbye`: Message to send to the opponent once the game is over.
- `hello_spectators`: Message to send to the spectators when the bot makes its first move.
- `goodbye_spectators`: Message to send to the spectators once the game is over.
```yml
greeting:
hello: Hi, {opponent}! I'm {me}. Good luck!
goodbye: Good game!
hello_spectators: "Hi! I'm {me}. Type !help for a list of commands I can respond to." # Message to send to spectator chat at the start of a game
goodbye_spectators: "Thanks for watching!" # Message to send to spectator chat at the end of a game
```
## Other options
- `abort_time`: How many seconds to wait before aborting a game due to opponent inaction. This only applies during the first six moves of the game.
- `fake_think_time`: Artificially slow down the engine to simulate a person thinking about a move. The amount of thinking time decreases as the game goes on.
- `rate_limiting_delay`: For extremely fast games, the lichess.org servers may respond with an error if too many moves are played too quickly. This option avoids this problem by pausing for a specified number of milliseconds after submitting a move before making the next move.
- `move_overhead`: To prevent losing on time due to network lag, subtract this many milliseconds from the time to think on each move.
- `pgn_directory`: Write a record of every game played in PGN format to files in this directory. Each bot move will be annotated with the bot's calculated score and principal variation. The score is written with a tag of the form `[%eval s,d]`, where `s` is the score in pawns (positive means white has the advantage), and `d` is the depth of the search.
- `pgn_file_grouping`: Determine how games are written to files. There are three options:
- `game`: Every game record is written to a different file in the `pgn_directory`. The file name is `{White name} vs. {Black name} - {lichess game ID}.pgn`.
- `opponent`: Game records are written to files named according to the bot's opponent. The file name is `{Bot name} games vs. {Opponent name}.pgn`.
- `all`: All games are written to the same file. The file name is `{Bot name} games.pgn`.
```yml
pgn_directory: "game_records"
pgn_file_grouping: "all"
```
## Challenging other bots
- `matchmaking`: Challenge a random bot.
- `allow_matchmaking`: Whether to challenge other bots.
- `challenge_variant`: The variant for the challenges. If set to `random` a variant from the ones enabled in `challenge.variants` will be chosen at random.
- `challenge_timeout`: The time (in minutes) the bot has to be idle before it creates a challenge.
- `challenge_initial_time`: A list of initial times (in seconds and to be chosen at random) for the challenges.
- `challenge_increment`: A list of increments (in seconds and to be chosen at random) for the challenges.
- `challenge_days`: A list of number of days for a correspondence challenge (to be chosen at random).
- `opponent_min_rating`: The minimum rating of the opponent bot. The minimum rating in lichess is 600.
- `opponent_max_rating`: The maximum rating of the opponent bot. The maximum rating in lichess is 4000.
- `opponent_rating_difference`: The maximum difference between the bot's rating and the opponent bot's rating.
- `rating_preference`: Whether the bot should prefer challenging high or low rated players, or have no preference.
- `opponent_allow_tos_violation`: Whether to challenge bots that violated Lichess Terms of Service. Note that even rated games against them will not affect ratings.
- `challenge_mode`: Possible options are `casual`, `rated` and `random`.
- `challenge_filter`: Whether and how to prevent challenging a bot after that bot declines a challenge. Options are `none`, `coarse`, and `fine`.
- `none` does not prevent challenging a bot that declined a challenge.
- `coarse` will prevent challenging a bot to any type of game after it declines one challenge.
- `fine` will prevent challenging a bot to the same kind of game that was declined.
The `challenge_filter` option can be useful if your matchmaking settings result in a lot of declined challenges. The bots that accept challenges will be challenged more often than those that have declined. The filter will remain until lichess-bot quits or the connection with lichess.org is reset.
- `block_list`: An indented list of usernames of bots that will not be challenged. If this option is not present, then the list is considered empty.
- `overrides`: Create variations on the matchmaking settings above for more specific circumstances. If there are any subsections under `overrides`, the settings below that will override the settings in the matchmaking section. Any settings that do not appear will be taken from the settings above. <br/> <br/>
The overrides section must have the following:
- Name: A unique name must be given for each override. In the example configuration below, `easy_chess960` and `no_pressure_correspondence` are arbitrary strings to name the subsections and they are unique.
- List of options: A list of options to override. Only the options mentioned will change when making the challenge. The rest will follow the default matchmaking options. In the example settings below, the blank settings for `challenge_initial_time` and `challenge_increment` under `no_pressure_correspondence` have the effect of deleting these settings, meaning that only correspondence games are possible.
For each matchmaking challenge, the default settings and each override have equal probability of being chosen to create the challenge. For example, in the example configuration below, the default settings, `easy_chess960`, and `no_pressure_correspondence` all have a 1/3 chance of being used to create the next challenge.
The following configurations cannot be overridden: `allow_matchmaking`, `challenge_timeout`, `challenge_filter` and `block_list`.
- Additional Points:
- If there are entries for both real-time (`challenge_initial_time` and/or `challenge_increment`) and correspondence games (`challenge_days`), the challenge will be a random choice between the two.
- If there are entries for both absolute ratings (`opponent_min_rating` and `opponent_max_rating`) and rating difference (`opponent_rating_difference`), the rating difference takes precedence.
```yml
matchmaking:
allow_matchmaking: false
challenge_variant: "random"
challenge_timeout: 30
challenge_initial_time:
- 60
- 120
challenge_increment:
- 1
- 2
challenge_days:
- 1
- 2
# opponent_min_rating: 600
# opponent_max_rating: 4000
opponent_rating_difference: 100
opponent_allow_tos_violation: true
challenge_mode: "random"
challenge_filter: none
overrides:
easy_chess960:
challenge_variant: "chess960"
opponent_min_rating: 400
opponent_max_rating: 1200
opponent_rating_difference:
challenge_mode: casual
no_pressure_correspondence:
challenge_initial_time:
challenge_increment:
challenge_days:
- 2
- 3
challenge_mode: casual
```
**Next step**: [Run lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Run-lichess%E2%80%90bot)
**Previous step**: [Setup the engine](https://github.com/lichess-bot-devs/lichess-bot/wiki/Setup-the-engine)

View File

@@ -0,0 +1,16 @@
## Creating a custom engine
As an alternative to creating an entire chess engine and implementing one of the communication protocols (`UCI` or `XBoard`), a bot can also be created by writing a single class with a single method. The `search()` method in this new class takes the current board and the game clock as arguments and should return a move based on whatever criteria the coder desires.
Steps to create a homemade bot:
1. Do all the steps in the [How to Install](#how-to-install)
2. In the `config.yml`, change the engine protocol to `homemade`
3. Create a class in some file that extends `MinimalEngine` (in `strategies.py`).
- Look at the `strategies.py` file to see some examples.
- If you don't know what to implement, look at the `EngineWrapper` or `UCIEngine` class.
- You don't have to create your own engine, even though it's an "EngineWrapper" class.<br>
The examples just implement `search`.
4. In the `config.yml`, change the name from `engine_name` to the name of your class
- In this case, you could change it to:
`name: "RandomMove"`

35
lichess_bot/wiki/Home.md Normal file
View File

@@ -0,0 +1,35 @@
# lichess-bot
[![Python Build](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-build.yml/badge.svg)](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-build.yml)
[![Python Test](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-test.yml/badge.svg)](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-test.yml)
[![Mypy](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/mypy.yml/badge.svg)](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/mypy.yml)
A bridge between [Lichess Bot API](https://lichess.org/api#tag/Bot) and bots.
## Features
Supports:
- Every variant and time control
- UCI, XBoard, and Homemade engines
- Matchmaking
- Offering Draw / Resigning
- Saving games as PGN
- Local & Online Opening Books
- Local & Online Endgame Tablebases
## Steps
1. [Install lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Install)
2. [Create a lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token)
3. [Upgrade to a BOT account](https://github.com/lichess-bot-devs/lichess-bot/wiki/Upgrade-to-a-BOT-account)
4. [Setup the engine](https://github.com/lichess-bot-devs/lichess-bot/wiki/Setup-the-engine)
5. [Configure lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/Configure-lichess-bot)
6. [Run lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Run-lichess%E2%80%90bot)
## Advanced options
- [Create a custom engine](https://github.com/lichess-bot-devs/lichess-bot/wiki/Create-a-custom-engine)
<br />
## Acknowledgements
Thanks to the Lichess team, especially T. Alexander Lystad and Thibault Duplessis for working with the LeelaChessZero team to get this API up. Thanks to the [Niklas Fiekas](https://github.com/niklasf) and his [python-chess](https://github.com/niklasf/python-chess) code which allows engine communication seamlessly.
## License
lichess-bot is licensed under the AGPLv3 (or any later version at your option). Check out the [LICENSE file](https://github.com/lichess-bot-devs/lichess-bot/blob/master/LICENSE) for the full text.

View File

@@ -0,0 +1,40 @@
### Mac/Linux
- **NOTE: Only Python 3.9 or later is supported!**
- Download the repo into lichess-bot directory.
- Navigate to the directory in cmd/Terminal: `cd lichess-bot`.
- Install pip: `apt install python3-pip`.
- In non-Ubuntu distros, replace `apt` with the correct package manager (`pacman` in Arch, `dnf` in Fedora, `brew` in Mac, etc.), package name, and installation command.
- Install virtualenv: `apt install python3-virtualenv`.
- Setup virtualenv: `apt install python3-venv`.
```
python3 -m venv venv # If this fails you probably need to add Python3 to your PATH.
virtualenv venv -p python3
source ./venv/bin/activate
python3 -m pip install -r requirements.txt
```
- Copy `config.yml.default` to `config.yml`.
**Next step**: [Create a Lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token)
### Windows
- **NOTE: Only Python 3.9 or later is supported!**
- If needed, install Python:
- [Download Python here](https://www.python.org/downloads/).
- When installing, enable "add Python to PATH".
- If the Python version is at least 3.10, a default local install works.
- If the Python version is 3.9, choose "Custom installation", keep the defaults on the Optional Features page, and choose "Install for all users" in the Advanced Options page.
- Start Terminal, PowerShell, cmd, or your preferred command prompt.
- Upgrade pip: `py -m pip install --upgrade pip`.
- Download the repo into lichess-bot directory.
- Navigate to the directory: `cd [folder's address]` (for example, `cd C:\Users\username\repos\lichess-bot`).
- Install virtualenv: `py -m pip install virtualenv`.
- Setup virtualenv:
```
py -m venv venv # If this fails you probably need to add Python3 to your PATH.
venv\Scripts\activate
pip install -r requirements.txt
```
PowerShell note: If the `activate` command does not work in PowerShell, execute `Set-ExecutionPolicy RemoteSigned` first and choose `Y` there (you may need to run Powershell as administrator). After you execute the script, change execution policy back with `Set-ExecutionPolicy Restricted` and pressing `Y`.
- Copy `config.yml.default` to `config.yml`.
**Next step**: [Create a Lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token)

View File

@@ -0,0 +1,52 @@
## Running lichess-bot
After activating the virtual environment created in the installation steps (the `source` line for Linux and Macs or the `activate` script for Windows), run
```
python3 lichess-bot.py
```
The working directory for the engine execution will be the lichess-bot directory. If your engine requires files located elsewhere, make sure they are specified by absolute path or copy the files to an appropriate location inside the lichess-bot directory.
To output more information (including your engine's thinking output and debugging information), the `-v` option can be passed to lichess-bot:
```
python3 lichess-bot.py -v
```
If you want to disable automatic logging:
```
python3 lichess-bot.py --disable_auto_logging
```
If you want to record the output to a log file, add the `-l` or `--logfile` along with a file name:
```
python3 lichess-bot.py --logfile log.txt
```
If you want to specify a different config file, add the `--config` along with a file name:
```
python3 lichess-bot.py --config config2.yml
```
### Running as a service
- Here's an example systemd service definition:
```ini
[Unit]
Description=lichess-bot
After=network-online.target
Wants=network-online.target
[Service]
Environment="PYTHONUNBUFFERED=1"
ExecStart=/usr/bin/python3 /home/thibault/lichess-bot/lichess-bot.py
WorkingDirectory=/home/thibault/lichess-bot/
User=thibault
Group=thibault
Restart=always
[Install]
WantedBy=multi-user.target
```
## Quitting
- Press `CTRL+C`.
- It may take some time to quit.
**Previous step**: [Configure lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/Configure-lichess-bot)

View File

@@ -0,0 +1,10 @@
# Creating a lichess OAuth token
- Create an account for your bot on [Lichess.org](https://lichess.org/signup).
- **NOTE: If you have previously played games on an existing account, you will not be able to use it as a bot account.**
- Once your account has been created and you are logged in, [create a personal OAuth2 token with the "Play games with the bot API" (`bot:play`) scope](https://lichess.org/account/oauth/token/create?scopes[]=bot:play&description=lichess-bot) selected and a description added.
- A `token` (e.g. `xxxxxxxxxxxxxxxx`) will be displayed. Store this in the `config.yml` file as the `token` field. You can also set the token in the environment variable `$LICHESS_BOT_TOKEN`.
- **NOTE: You won't see this token again on Lichess, so do save it.**
**Next step**: [Upgrade to a BOT account](https://github.com/lichess-bot-devs/lichess-bot/wiki/Upgrade-to-a-BOT-account)
**Previous step**: [Install lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Install)

View File

@@ -0,0 +1,32 @@
# Setup the engine
## For all engines
Within the file `config.yml`:
- Enter the directory containing the engine executable in the `engine: dir` field.
- Enter the executable name in the `engine: name` field (In Windows you may need to type a name with ".exe", like "lczero.exe")
- If you want the engine to run in a different directory (e.g., if the engine needs to read or write files at a certain location), enter that directory in the `engine: working_dir` field.
- If this field is blank or missing, the current directory will be used.
- IMPORTANT NOTE: If this field is used, the running engine will look for files and directories (Syzygy tablebases, for example) relative to this path, not the directory where lichess-bot was launched. Files and folders specified with absolute paths are unaffected.
As an optional convenience, there is a folder named `engines` within the lichess-bot folder where you can copy your engine and all the files it needs. This is the default executable location in the `config.yml.default` file.
## For Leela Chess Zero
### LeelaChessZero: Mac/Linux
- Download the weights for the id you want to play from [here](https://lczero.org/play/networks/bestnets/).
- Extract the weights from the zip archive and rename it to `latest.txt`.
- For Mac/Linux, build the lczero binary yourself following [LeelaChessZero/lc0/README](https://github.com/LeelaChessZero/lc0/blob/master/README.md).
- Copy both the files into the `engine.dir` directory.
- Change the `engine.name` and `engine.engine_options.weights` keys in `config.yml` file to `lczero` and `weights.pb.gz`.
- You can specify the number of `engine.uci_options.threads` in the `config.yml` file as well.
- To start: `python3 lichess-bot.py`.
### LeelaChessZero: Windows CPU 2021
- For Windows modern CPUs, download the lczero binary from the [latest Lc0 release](https://github.com/LeelaChessZero/lc0/releases) (e.g. `lc0-v0.27.0-windows-cpu-dnnl.zip`).
- Unzip the file, it comes with `lc0.exe` , `dnnl.dll`, and a weights file example, `703810.pb.gz` (amongst other files).
- All three main files need to be copied to the `engines` directory.
- The `lc0.exe` should be doubleclicked and the windows safesearch warning about it being unsigned should be cleared (be careful and be sure you have the genuine file).
- Change the `engine.name` key in the `config.yml` file to `lc0.exe`, no need to edit the `config.yml` file concerning the weights file as the `lc0.exe` will use whatever `*.pb.gz` is in the same folder (have only one `*pb.gz` file in the `engines` directory).
- To start: `python3 lichess-bot.py`.
**Next step**: [Configure lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/Configure-lichess-bot)
**Previous step**: [Upgrade to a BOT accout](https://github.com/lichess-bot-devs/lichess-bot/wiki/Upgrade-to-a-BOT-account)

View File

@@ -0,0 +1,7 @@
# Upgrading to a BOT account
**WARNING: This is irreversible. [Read more about upgrading to bot account](https://lichess.org/api#operation/botAccountUpgrade).**
- run `python3 lichess-bot.py -u`.
**Next step**: [Setup the engine](https://github.com/lichess-bot-devs/lichess-bot/wiki/Setup-the-engine)
**Previous step**: [Create a Lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token)