Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add matchmaking #473

Merged
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c250d06
Add matchmaking
AttackingOrDefending May 6, 2022
41ff494
Delete some whitespace
AttackingOrDefending May 6, 2022
028323b
Remove new line
AttackingOrDefending May 6, 2022
9968264
Remove useless code
AttackingOrDefending May 6, 2022
e598e56
Fix a small bug
AttackingOrDefending May 7, 2022
82b7512
Do not challenge ourselves and move some code
AttackingOrDefending May 7, 2022
6866999
Move some code
AttackingOrDefending May 7, 2022
fb66668
Remove unnecessary debugging output
AttackingOrDefending May 8, 2022
978d042
Add unit
AttackingOrDefending May 8, 2022
750394b
Add extra space
AttackingOrDefending May 8, 2022
9d06571
Simplify code
AttackingOrDefending May 9, 2022
a45b149
Fix bug
AttackingOrDefending May 10, 2022
e6f6bb2
Use logger.info instead of logger.debug
AttackingOrDefending May 10, 2022
7ad719b
Fix bug
AttackingOrDefending May 11, 2022
2094600
Use line breaks
AttackingOrDefending May 11, 2022
d569de4
Change minimum and maximum rating for matchmaking
AttackingOrDefending May 13, 2022
e198064
Change treatment of 0 in max rating
AttackingOrDefending May 13, 2022
9614894
Change treatment of 0 in min rating
AttackingOrDefending May 13, 2022
49e400d
Cancel challenge after 25 seconds
AttackingOrDefending May 15, 2022
50b4d8d
Wait 10 seconds to not hit api rate limits
AttackingOrDefending May 15, 2022
c3f58cb
Minor changes
AttackingOrDefending May 15, 2022
0a4069b
Add 'challenge_variant'
AttackingOrDefending May 15, 2022
a730a22
Add 'challenge_variant'
AttackingOrDefending May 15, 2022
0e32b1c
Remove if to avoid hitting the api rate limits
AttackingOrDefending May 19, 2022
b807575
Increase time between challenges from 10 to 20
AttackingOrDefending May 22, 2022
af6ea5b
Use challenge timeout instead of challenge interval
AttackingOrDefending May 27, 2022
6d629ea
Change matchmaking to matchmaker
AttackingOrDefending Jun 4, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,18 +219,38 @@ will precede the `go` command to start thinking with `sd 5`. The other `go_comma
-rated
-casual
```
- `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 opponent before the bot makes its first move.
- `goodbye`: Message to send to opponent once the game is over.
- `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 opponent before the bot makes its first move.
- `goodbye`: Message to send to opponent once the game is over.
```yml
greeting:
hello: Hi, {opponent}! I'm {me}. Good luck!
goodbye: Good game!
```
- `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. Each game will be written to a uniquely named file.
- `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. Each game will be written to a uniquely named file.
```yml
pgn_directory: "game_records"
```
- `matchmaking`: Challenge a random bot.
- `allow_matchmaking`: Whether to challenge other bots.
- `challenge_interval`: How often to challenge a bot (in minutes).
- `challenge_initial_time`: The initial time (in seconds) for the challenges.
- `challenge_increment`: The increment (in seconds) for the challenges.
- `challenge_days`: The days for a correspondence challenge. If this option is enabled, a correspondence challenge will be created even if `challenge_initial_time` is enabled.
- `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.
- `challenge_mode`: Possible options are `casual`, `rated` and `random`.
```yml
matchmaking:
allow_matchmaking: false
challenge_interval: 30
challenge_initial_time: 60
challenge_increment: 3
# challenge_days: 2
opponent_min_rating: 600
opponent_max_rating: 4000
challenge_mode: "random"
```

## Lichess Upgrade to Bot Account
**WARNING: This is irreversible. [Read more about upgrading to bot account](https://lichess.org/api#operation/botAccountUpgrade).**
Expand Down
10 changes: 10 additions & 0 deletions config.yml.default
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,13 @@ greeting:
goodbye: "Good game!" # Message to send to chat at the end of a game

# pgn_directory: "game_records" # A directory where PGN-format records of the bot's games are kept

matchmaking:
allow_matchmaking: false # Set it to 'true' to challenge other bot in a set interval, time control and range.
challenge_interval: 30 # Interval in minutes between two challenges.
challenge_initial_time: 60 # Initial time in seconds of the challenge.
challenge_increment: 3 # Increment in seconds of the challenge.
# challenge_days: 2 # Days for correspondence challenge. If this option is enabled, a correspondence challenge will be created even if 'challenge_initial_time' is enabled.
opponent_min_rating: 600 # Opponents rating should be above this value (600 is the minimum rating in lichess).
opponent_max_rating: 4000 # Opponents rating should be below this value (4000 is the maximum rating in lichess).
challenge_mode: "random" # Set it to the mode in which challenges are sent. Possible options are 'casual', 'rated' and 'random'.
13 changes: 11 additions & 2 deletions lichess-bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import chess.polyglot
import engine_wrapper
import model
import matchmaking
import json
import lichess
import logging
Expand Down Expand Up @@ -117,6 +118,7 @@ def start(li, user_profile, config, logging_level, log_filename, one_game=False)
correspondence_queue.put("")
startup_correspondence_games = [game["gameId"] for game in li.get_ongoing_games() if game["perf"] == "correspondence"]
wait_for_correspondence_ping = False
matchmaker = matchmaking.Matchmaking(li, config, user_profile["username"])

busy_processes = 0
queued_processes = 0
Expand Down Expand Up @@ -156,7 +158,7 @@ def start(li, user_profile, config, logging_level, log_filename, one_game=False)
list_c = list(challenge_queue)
list_c.sort(key=lambda c: -c.score())
challenge_queue = list_c
else:
elif chlng.id != matchmaker.challenge_id:
try:
reason = "generic"
challenge = config["challenge"]
Expand All @@ -176,6 +178,8 @@ def start(li, user_profile, config, logging_level, log_filename, one_game=False)
pass
elif event["type"] == "gameStart":
game_id = event["game"]["id"]
if matchmaker.challenge_id == game_id:
matchmaker.challenge_id = None
if game_id in startup_correspondence_games:
logger.info(f'--- Enqueue {config["url"] + game_id}')
correspondence_queue.put(game_id)
Expand Down Expand Up @@ -220,6 +224,10 @@ def start(li, user_profile, config, logging_level, log_filename, one_game=False)
logger.info(f"Skip missing {chlng}")
queued_processes -= 1

if queued_processes + busy_processes < max_games and not challenge_queue and matchmaker.should_create_challenge():
logger.info("Challenging a random bot")
matchmaker.challenge()

control_queue.task_done()

logger.info("Terminated")
Expand Down Expand Up @@ -280,9 +288,10 @@ def play_game(li, game_id, control_queue, user_profile, config, challenge_queue,
else:
binary_chunk = next(lines)
upd = json.loads(binary_chunk.decode("utf-8")) if binary_chunk else None
logger.debug(f"Game state: {upd}")

u_type = upd["type"] if upd else "ping"
if u_type != "ping":
logger.debug(f"Game state: {upd}")
if u_type == "chatLine":
conversation.react(ChatLine(upd), game)
elif u_type == "gameState":
Expand Down
25 changes: 21 additions & 4 deletions lichess.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import requests
from urllib.parse import urljoin
from requests.exceptions import ConnectionError, HTTPError, ReadTimeout
Expand All @@ -19,7 +20,10 @@
"decline": "/api/challenge/{}/decline",
"upgrade": "/api/bot/account/upgrade",
"resign": "/api/bot/game/{}/resign",
"export": "/game/export/{}"
"export": "/game/export/{}",
"online_bots": "/api/bot/online",
"challenge": "/api/challenge/{}",
"cancel": "/api/challenge/{}/cancel"
}


Expand Down Expand Up @@ -52,6 +56,7 @@ def api_get(self, path, raise_for_status=True, get_raw_text=False, params=None):
response = self.session.get(url, timeout=2, params=params)
if raise_for_status:
response.raise_for_status()
response.encoding = "utf-8"
return response.text if get_raw_text else response.json()

@backoff.on_exception(backoff.constant,
Expand All @@ -61,11 +66,12 @@ def api_get(self, path, raise_for_status=True, get_raw_text=False, params=None):
giveup=is_final,
backoff_log_level=logging.DEBUG,
giveup_log_level=logging.DEBUG)
def api_post(self, path, data=None, headers=None, params=None):
def api_post(self, path, data=None, headers=None, params=None, payload=None, raise_for_status=True):
logging.getLogger("backoff").setLevel(self.logging_level)
url = urljoin(self.baseUrl, path)
response = self.session.post(url, data=data, headers=headers, params=params, timeout=2)
response.raise_for_status()
response = self.session.post(url, data=data, headers=headers, params=params, json=payload, timeout=2)
if raise_for_status:
response.raise_for_status()
return response.json()

def get_game(self, game_id):
Expand Down Expand Up @@ -119,3 +125,14 @@ def get_game_pgn(self, game_id):
return self.api_get(ENDPOINTS["export"].format(game_id),
get_raw_text=True,
params={"literate": "true"})

def get_online_bots(self):
AttackingOrDefending marked this conversation as resolved.
Show resolved Hide resolved
online_bots = self.api_get(ENDPOINTS["online_bots"], get_raw_text=True)
online_bots = list(filter(bool, online_bots.split("\n")))
return list(map(lambda bot: json.loads(bot), online_bots))

def challenge(self, username, params):
return self.api_post(ENDPOINTS["challenge"].format(username), payload=params, raise_for_status=False)

def cancel(self, challenge_id):
return self.api_post(ENDPOINTS["cancel"].format(challenge_id), raise_for_status=False)
78 changes: 78 additions & 0 deletions matchmaking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import random
import time
import logging

logger = logging.getLogger(__name__)


class Matchmaking:
def __init__(self, li, config, username):
self.li = li
self.variants = list(filter(lambda variant: variant != "fromPosition", config["challenge"]["variants"]))
self.matchmaking_cfg = config.get("matchmaking") or {}
self.username = username
self.last_challenge_created = time.time()
self.challenge_expire_time = 25 # The challenge expires 20 seconds after creating it.
self.challenge_id = None

def should_create_challenge(self):
matchmaking_enabled = self.matchmaking_cfg.get("allow_matchmaking")
time_has_passed = self.last_challenge_created + ((self.matchmaking_cfg.get("challenge_interval") or 30) * 60) < time.time()
challenge_expired = self.last_challenge_created + self.challenge_expire_time < time.time() and self.challenge_id
# Wait 10 seconds before creating a new challenge to avoid hitting the api rate limits.
ten_seconds_passed = self.last_challenge_created + 10 < time.time()
if challenge_expired:
self.li.cancel(self.challenge_id)
AttackingOrDefending marked this conversation as resolved.
Show resolved Hide resolved
return matchmaking_enabled and (time_has_passed or challenge_expired) and ten_seconds_passed

def create_challenge(self, username, base_time, increment, days, variant):
mode = self.matchmaking_cfg.get("challenge_mode") or "random"
if mode == "random":
mode = random.choice(["casual", "rated"])
rated = mode == "rated"
params = {"rated": rated, "variant": variant}
if days:
params["days"] = days
else:
params["clock.limit"] = base_time
params["clock.increment"] = increment
challenge_id = self.li.challenge(username, params).get("challenge", {}).get("id")
return challenge_id

def choose_opponent(self):
variant = random.choice(self.variants)
AttackingOrDefending marked this conversation as resolved.
Show resolved Hide resolved
base_time = self.matchmaking_cfg.get("challenge_initial_time", 60)
increment = self.matchmaking_cfg.get("challenge_increment", 2)
days = self.matchmaking_cfg.get("challenge_days")
game_duration = base_time + increment * 40
if variant != "standard":
game_type = variant
elif days:
game_type = "correspondence"
elif game_duration < 179:
game_type = "bullet"
elif game_duration < 479:
game_type = "blitz"
elif game_duration < 1499:
game_type = "rapid"
else:
game_type = "classical"

min_rating = self.matchmaking_cfg.get("opponent_min_rating") or 600
max_rating = self.matchmaking_cfg.get("opponent_max_rating") or 4000

online_bots = self.li.get_online_bots()
online_bots = list(filter(lambda bot: bot["username"] != self.username and not bot.get("disabled") and
min_rating <= ((bot["perfs"].get(game_type) or {}).get("rating") or 0) <= max_rating,
online_bots))
bot_username = random.choice(online_bots)["username"] if online_bots else None
return bot_username, base_time, increment, days, variant

def challenge(self):
bot_username, base_time, increment, days, variant = self.choose_opponent()
logger.debug(f"Will challenge {bot_username} for a {variant} game.")
AttackingOrDefending marked this conversation as resolved.
Show resolved Hide resolved
challenge_id = self.create_challenge(bot_username, base_time, increment, days, variant) if bot_username else None
logger.debug(f"Challenge id is {challenge_id}.")
AttackingOrDefending marked this conversation as resolved.
Show resolved Hide resolved
if challenge_id:
self.last_challenge_created = time.time()
self.challenge_id = challenge_id