-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathgame_manager.py
More file actions
303 lines (249 loc) · 10.7 KB
/
Copy pathgame_manager.py
File metadata and controls
303 lines (249 loc) · 10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
import asyncio
import logging
import os
import threading
import time
from collections import deque
from typing import NoReturn
from config import CONFIG
from enums import ChallengeMode, ChallengeOpponent, DeclineReason, Variant
from game import Game
from lichess import Lichess
from matchmaker import Matchmaker
from models import (
ChallengeCanceledEvent,
ChallengeEvent,
GameFinishEvent,
GameStartEvent,
PingEvent,
)
logger = logging.getLogger(__name__)
WATCHDOG_INTERVAL_SECONDS = 5
HEALTH_TIMEOUT_SECONDS = 120
class GameManager:
def __init__(self, li: Lichess) -> None:
self.li: Lichess = li
self.matchmaker: Matchmaker = Matchmaker(li)
self.current_games: dict[str, Game] = {}
self.challenge_queue: deque[str] = deque()
self.last_event_time: float = time.monotonic()
self.blocklist: set[str] = set()
self.last_blocklist_refresh: float = 0.0
self.last_ping: float = time.monotonic()
self.watchdog_thread: threading.Thread | None = None
async def watch_event_stream(self) -> NoReturn:
self.blocklist = await self.li.fetch_blocklist()
self.last_blocklist_refresh = time.monotonic()
if self.blocklist:
logger.info("Blocklist contains %d users", len(self.blocklist))
# The restart policy can only recover the process if the watchdog brings
# it down, so only run it inside Docker where one exists.
if self.in_docker():
self.watchdog_thread = threading.Thread(target=self.watchdog, daemon=True)
self.watchdog_thread.start()
async for event in self.li.event_stream():
match event:
case PingEvent():
await self.on_ping()
case GameStartEvent():
await self.on_game_start(event)
case GameFinishEvent():
await self.on_game_finish(event)
case ChallengeEvent():
await self.on_challenge(event)
case ChallengeCanceledEvent():
self.on_challenge_canceled(event)
@staticmethod
def in_docker() -> bool:
# Docker creates this marker file in every container; it's the simplest
# reliable signal that a restart policy is around to recover us.
return os.path.exists("/.dockerenv")
def is_healthy(self) -> bool:
return time.monotonic() - self.last_ping < HEALTH_TIMEOUT_SECONDS
def watchdog(self) -> NoReturn:
# Runs in its own OS thread: an asyncio watchdog is starved by the very
# condition it must detect (a blocked event loop), whereas a thread only
# needs a CPU timeslice to keep ticking.
# Nothing restarts an unhealthy-but-running container under
# `restart: unless-stopped`, so the watchdog exits the process to let the
# restart policy recover it. os._exit rather than raising: an exception
# outside the main thread can't stop the process and would leave it
# wedged, so we bring it down directly. A crashed watchdog protects
# nothing, so any escaping exception also exits (fail closed); os._exit
# skips finally clauses, making the healthy-exit path unambiguous.
try:
while True:
time.sleep(WATCHDOG_INTERVAL_SECONDS)
if not self.is_healthy():
idle = time.monotonic() - self.last_ping
logger.critical(
"Unhealthy: no ping for %.0fs; exiting to trigger restart",
idle,
)
os._exit(1)
except BaseException:
logger.exception("Watchdog crashed; exiting to trigger restart")
finally:
os._exit(1)
async def on_ping(self) -> None:
self.last_ping = time.monotonic()
# Sometimes the lichess game loop seems to close without the event loop sending a "gameFinish" event
# but still having closed the game stream. This will take care of those cases.
self.current_games = {
game_id: game
for game_id, game in self.current_games.items()
if not game.loop_task.done()
}
logger.debug("Active tasks: %d", len(asyncio.all_tasks()))
if self.should_refresh_blocklist():
self.last_blocklist_refresh = time.monotonic()
self.blocklist = await self.li.fetch_blocklist()
logger.debug("Refreshed blocklist: %d users", len(self.blocklist))
if self.is_under_concurrency_limit() and self.challenge_queue:
await self.li.accept_challenge(self.challenge_queue.popleft())
return
if self.should_create_challenge():
self.last_event_time = time.monotonic()
await self.matchmaker.challenge(self.blocklist)
async def on_game_start(self, event: GameStartEvent) -> None:
self.last_event_time = time.monotonic()
game_id = event.game.id
if game_id in self.current_games:
return
# If this is an extremely late acceptance of a challenge we issued earlier
# that would bring us over our concurrency limit, abort it.
if not self.is_under_concurrency_limit():
await self.li.abort_game(game_id)
return
game = Game(self.li, event)
game.start() # non-blocking task creation
self.current_games[game_id] = game
logger.info(
"Games: %d, Challenges: %d",
len(self.current_games),
len(self.challenge_queue),
)
logger.info("%s starting", game)
async def on_game_finish(self, event: GameFinishEvent) -> None:
self.last_event_time = time.monotonic()
if (game_id := event.game.id) in self.current_games:
game = self.current_games.pop(game_id)
logger.info("%s finished", game)
await game.loop_task
logger.info(
"Games: %d, Challenges: %d",
len(self.current_games),
len(self.challenge_queue),
)
if self.is_under_concurrency_limit() and self.challenge_queue:
await self.li.accept_challenge(self.challenge_queue.popleft())
async def on_challenge(self, event: ChallengeEvent) -> None:
challenge = event.challenge
if challenge.id in self.challenge_queue:
return
challenger_name = (
challenge.challenger.name if challenge.challenger else "Anonymous"
)
if challenger_name == self.li.username:
return
if challenger_name.lower() in self.blocklist:
logger.info(
"%s -- Declining challenge from blocked user %s",
challenge.id,
challenger_name,
)
await self.li.decline_challenge(challenge.id, reason=DeclineReason.GENERIC)
return
logger.info("%s -- Challenger: %s", challenge.id, challenger_name)
if decline_reason := self.check_decline_reason(event):
logger.info(
"%s -- Declining challenge from %s for reason: %s",
challenge.id,
challenger_name,
decline_reason,
)
await self.li.decline_challenge(challenge.id, reason=decline_reason)
return
if self.is_under_concurrency_limit():
await self.li.accept_challenge(challenge.id)
return
self.challenge_queue.append(challenge.id)
logger.info(
"Games: %d, Challenges: %d",
len(self.current_games),
len(self.challenge_queue),
)
def on_challenge_canceled(self, event: ChallengeCanceledEvent) -> None:
self.last_event_time = time.monotonic()
challenge_id = event.challenge.id
logger.info("%s -- Challenge canceled.", challenge_id)
if challenge_id in self.challenge_queue:
self.challenge_queue.remove(challenge_id)
logger.info(
"Games: %d, Challenges: %d",
len(self.current_games),
len(self.challenge_queue),
)
def is_under_concurrency_limit(self) -> bool:
return len(self.current_games) < CONFIG.concurrency
def should_refresh_blocklist(self) -> bool:
if CONFIG.blocklist.refresh <= 0:
return False
return (
time.monotonic() - self.last_blocklist_refresh
>= CONFIG.blocklist.refresh * 60
)
def should_create_challenge(self) -> bool:
if not CONFIG.matchmaking.enabled:
return False
if time.monotonic() < self.li.challenge_timeout:
return False
if len(self.current_games) > 0:
return False
return (
time.monotonic() - self.last_event_time
>= max(1, CONFIG.matchmaking.timeout) * 60
)
@staticmethod
def check_decline_reason(event: ChallengeEvent) -> DeclineReason | None:
cfg = CONFIG.challenge
if not cfg.enabled:
return DeclineReason.GENERIC
challenge = event.challenge
if challenge.rated and ChallengeMode.RATED not in cfg.modes:
return DeclineReason.CASUAL
if not challenge.rated and ChallengeMode.CASUAL not in cfg.modes:
return DeclineReason.RATED
if challenge.variant not in cfg.variants:
return (
DeclineReason.STANDARD
if cfg.variants == [Variant.STANDARD]
else DeclineReason.VARIANT
)
if challenger := challenge.challenger:
is_bot = challenger.title == "BOT"
their_rating = challenger.rating
else:
is_bot = False
their_rating = None
my_rating = challenge.dest_user.rating if challenge.dest_user else None
if is_bot and ChallengeOpponent.BOT not in cfg.opponents:
return DeclineReason.NO_BOT
if not is_bot and ChallengeOpponent.HUMAN not in cfg.opponents:
return DeclineReason.ONLY_BOT
if challenge.speed not in cfg.time_controls:
return DeclineReason.TIME_CONTROL
initial = challenge.time_control.limit
increment = challenge.time_control.increment
if initial < cfg.min_initial or increment < cfg.min_increment:
return DeclineReason.TOO_FAST
if initial > cfg.max_initial or increment > cfg.max_increment:
return DeclineReason.TOO_SLOW
if challenge.rated and my_rating is not None and their_rating is not None:
rating_diff = abs(my_rating - their_rating)
max_rating_diff = (
cfg.max_rating_diffs.bot if is_bot else cfg.max_rating_diffs.human
)
if rating_diff > max_rating_diff:
return DeclineReason.GENERIC
return None