island-battles-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- island_battles_client/__init__.py +89 -0
- island_battles_client/_entities.py +107 -0
- island_battles_client/_generated.py +870 -0
- island_battles_client/bot.py +189 -0
- island_battles_client/cli.py +539 -0
- island_battles_client/client.py +763 -0
- island_battles_client/errors.py +92 -0
- island_battles_client/fixture.py +88 -0
- island_battles_client/game_log.py +346 -0
- island_battles_client/models.py +33 -0
- island_battles_python-0.1.0.dist-info/METADATA +8 -0
- island_battles_python-0.1.0.dist-info/RECORD +14 -0
- island_battles_python-0.1.0.dist-info/WHEEL +4 -0
- island_battles_python-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
"""Authenticated HTTP turns and recoverable player-channel notifications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
10
|
+
from contextlib import suppress
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from os import PathLike
|
|
13
|
+
from typing import Any, Self
|
|
14
|
+
from uuid import UUID, uuid4
|
|
15
|
+
|
|
16
|
+
import centrifuge
|
|
17
|
+
import httpx
|
|
18
|
+
from pydantic import ValidationError
|
|
19
|
+
|
|
20
|
+
from ._generated import (
|
|
21
|
+
BotTurn,
|
|
22
|
+
ConnectionToken,
|
|
23
|
+
GameEndedEvent,
|
|
24
|
+
GameOutcome,
|
|
25
|
+
GenerationEvent,
|
|
26
|
+
GenerationLogLine,
|
|
27
|
+
GenerationState,
|
|
28
|
+
PublicGenerationCommand,
|
|
29
|
+
RegisterGenerationCommands,
|
|
30
|
+
Upgrade,
|
|
31
|
+
)
|
|
32
|
+
from .bot import Play, add_result_lines, call_play
|
|
33
|
+
from .errors import (
|
|
34
|
+
PROBLEM_TYPE_ERRORS,
|
|
35
|
+
GameAuthenticationError,
|
|
36
|
+
GameClientClosedError,
|
|
37
|
+
GameEndedError,
|
|
38
|
+
GameError,
|
|
39
|
+
GameLogLimitError,
|
|
40
|
+
GameProtocolError,
|
|
41
|
+
GameTransportError,
|
|
42
|
+
)
|
|
43
|
+
from .game_log import SDK_LOGGER, ClientLogging, GameLog, report_server_limit
|
|
44
|
+
from .models import Generation
|
|
45
|
+
|
|
46
|
+
_sdk_logger = logging.getLogger(SDK_LOGGER)
|
|
47
|
+
|
|
48
|
+
# The server runs no timers, so a client waiting for other players asks it to
|
|
49
|
+
# apply the turn's deadline this many seconds after the deadline.
|
|
50
|
+
DEADLINE_SLACK_SECONDS = 1.0
|
|
51
|
+
# If the turn is still open after that, for example because the server's clock
|
|
52
|
+
# is behind, the client asks again after these many seconds, doubling up to
|
|
53
|
+
# the maximum.
|
|
54
|
+
DEADLINE_RETRY_SECONDS = 2.0
|
|
55
|
+
DEADLINE_RETRY_MAX_SECONDS = 30.0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _parse_state(data: object) -> GenerationState:
|
|
59
|
+
try:
|
|
60
|
+
return GenerationState.model_validate(data)
|
|
61
|
+
except ValidationError as exc:
|
|
62
|
+
raise GameProtocolError("invalid generation state") from exc
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _ConnectionEvents(centrifuge.ClientEventHandler):
|
|
66
|
+
def __init__(self, owner: IslandBattlesClient):
|
|
67
|
+
self.owner = owner
|
|
68
|
+
|
|
69
|
+
async def on_connected(self, ctx):
|
|
70
|
+
self.owner._wakeup.set()
|
|
71
|
+
|
|
72
|
+
async def on_disconnected(self, ctx):
|
|
73
|
+
self.owner._wakeup.set()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _SubscriptionEvents(centrifuge.SubscriptionEventHandler):
|
|
77
|
+
def __init__(self, owner: IslandBattlesClient):
|
|
78
|
+
self.owner = owner
|
|
79
|
+
|
|
80
|
+
async def on_subscribed(self, ctx):
|
|
81
|
+
self.owner._acknowledged.set()
|
|
82
|
+
self.owner._wakeup.set()
|
|
83
|
+
|
|
84
|
+
async def on_subscribing(self, ctx):
|
|
85
|
+
self.owner._acknowledged.clear()
|
|
86
|
+
|
|
87
|
+
async def on_unsubscribed(self, ctx):
|
|
88
|
+
self.owner._acknowledged.clear()
|
|
89
|
+
self.owner._wakeup.set()
|
|
90
|
+
|
|
91
|
+
async def on_publication(self, ctx):
|
|
92
|
+
# Never await HTTP or a user callback from the SDK read loop.
|
|
93
|
+
if self.owner._closed:
|
|
94
|
+
return
|
|
95
|
+
try:
|
|
96
|
+
data = ctx.pub.data
|
|
97
|
+
if isinstance(data, (str, bytes)):
|
|
98
|
+
data = json.loads(data)
|
|
99
|
+
# The turn that ends the game pushes game.ended instead.
|
|
100
|
+
ended = isinstance(data, dict) and data.get("type") == "game.ended"
|
|
101
|
+
event = (GameEndedEvent if ended else GenerationEvent).model_validate(data)
|
|
102
|
+
if ended and event.state.outcome is None:
|
|
103
|
+
raise GameProtocolError("game.ended without an outcome")
|
|
104
|
+
self.owner._validate_identity(event.state)
|
|
105
|
+
self.owner._install(event.state)
|
|
106
|
+
except TypeError, ValueError, GameProtocolError:
|
|
107
|
+
# Malformed or foreign publications are ignored; HTTP is authoritative.
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class IslandBattlesClient:
|
|
112
|
+
"""One authenticated player's generation-turn client.
|
|
113
|
+
|
|
114
|
+
The client owns an injected ``http_client`` and closes it. ``play`` is the
|
|
115
|
+
bot, a function (sync or async) from ``BotTurn`` to ``BotTurnResult``. The
|
|
116
|
+
client calls it once per generation, serially, and sends its result as the
|
|
117
|
+
turn's one completing batch: the commands, the game log lines and the
|
|
118
|
+
memory, which the server stores for the next ``BotTurn.memory``. When the
|
|
119
|
+
game ends, the optional ``on_game_ended(outcome, generation)`` runs once and
|
|
120
|
+
``wait()`` and ``run()`` return. While the player waits for others to complete a turn
|
|
121
|
+
that has a deadline, the client asks the server to apply the deadline a
|
|
122
|
+
second after it, which ends the game when someone didn't answer.
|
|
123
|
+
|
|
124
|
+
From ``start()`` to ``close()`` the client attaches its log handlers to the
|
|
125
|
+
``island_battles`` logger, which the bot's ``island_battles.player`` logger
|
|
126
|
+
propagates to: stdout unless ``log_to_stdout`` is false, ``log_file``
|
|
127
|
+
when it is set, and always the game log, which sends the lines logged
|
|
128
|
+
while ``play`` runs with that generation's completing request. ``log_level``
|
|
129
|
+
applies to all three.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
base_url: str,
|
|
135
|
+
credential: str,
|
|
136
|
+
lobby_id: str,
|
|
137
|
+
*,
|
|
138
|
+
play: Play,
|
|
139
|
+
on_game_ended: Callable[[GameOutcome, Generation], Awaitable[None] | None]
|
|
140
|
+
| None = None,
|
|
141
|
+
http_client: httpx.AsyncClient | None = None,
|
|
142
|
+
sdk_factory=centrifuge.Client,
|
|
143
|
+
refresh_interval: float | None = None,
|
|
144
|
+
retry_attempts: int = 3,
|
|
145
|
+
subscription_timeout: float = 10.0,
|
|
146
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
147
|
+
clock: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
148
|
+
log_level: int = logging.INFO,
|
|
149
|
+
log_to_stdout: bool = True,
|
|
150
|
+
log_file: str | PathLike[str] | None = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
self.base_url = base_url.rstrip("/")
|
|
153
|
+
self.credential = credential
|
|
154
|
+
self.lobby_id = str(UUID(str(lobby_id)))
|
|
155
|
+
self._play = play
|
|
156
|
+
self._on_game_ended = on_game_ended
|
|
157
|
+
if (
|
|
158
|
+
(refresh_interval is not None and refresh_interval <= 0)
|
|
159
|
+
or retry_attempts < 1
|
|
160
|
+
or subscription_timeout <= 0
|
|
161
|
+
):
|
|
162
|
+
raise ValueError(
|
|
163
|
+
"interval, attempts, and subscription timeout must be positive"
|
|
164
|
+
)
|
|
165
|
+
self._http = http_client or httpx.AsyncClient(
|
|
166
|
+
base_url=self.base_url, timeout=10
|
|
167
|
+
)
|
|
168
|
+
self._sdk_factory = sdk_factory
|
|
169
|
+
self._sleep = sleep
|
|
170
|
+
self._clock = clock
|
|
171
|
+
self._refresh_interval = refresh_interval
|
|
172
|
+
self._retry_attempts = retry_attempts
|
|
173
|
+
self._subscription_timeout = subscription_timeout
|
|
174
|
+
self._sdk = None
|
|
175
|
+
self._subscription = None
|
|
176
|
+
self._state: GenerationState | None = None
|
|
177
|
+
# The upgrade cost table, read once for the first turn and reused.
|
|
178
|
+
self._upgrades: list[Upgrade] | None = None
|
|
179
|
+
self._expected_identity: tuple[int, UUID, str, int] | None = None
|
|
180
|
+
self._last_queued_generation = 0
|
|
181
|
+
# Whether the state with the outcome was queued for on_game_ended. A
|
|
182
|
+
# turn deadline ends the game on a generation already queued.
|
|
183
|
+
self._ended_queued = False
|
|
184
|
+
self._state_changed = asyncio.Event()
|
|
185
|
+
self._start_lock = asyncio.Lock()
|
|
186
|
+
self._command_lock = asyncio.Lock()
|
|
187
|
+
self._reconcile_lock = asyncio.Lock()
|
|
188
|
+
self._acknowledged = asyncio.Event()
|
|
189
|
+
self._wakeup = asyncio.Event()
|
|
190
|
+
self._stopped = asyncio.Event()
|
|
191
|
+
self._failed = asyncio.Event()
|
|
192
|
+
self._callbacks: asyncio.Queue[GenerationState] = asyncio.Queue()
|
|
193
|
+
self._tasks: set[asyncio.Task] = set()
|
|
194
|
+
self._failure: BaseException | None = None
|
|
195
|
+
self._close_task: asyncio.Task | None = None
|
|
196
|
+
self._started = False
|
|
197
|
+
self._closed = False
|
|
198
|
+
self._logging = ClientLogging(self, log_level, log_to_stdout, log_file)
|
|
199
|
+
# The game log of the generation whose callback runs.
|
|
200
|
+
self._game_log: GameLog | None = None
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def generation(self) -> Generation | None:
|
|
204
|
+
"""The latest board snapshot, without transport metadata."""
|
|
205
|
+
return self._state.generation if self._state is not None else None
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def team_color(self) -> int | None:
|
|
209
|
+
"""The player's frozen team color, known after the first state read."""
|
|
210
|
+
return self._expected_identity[3] if self._expected_identity else None
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def outcome(self) -> GameOutcome | None:
|
|
214
|
+
"""How the game ended, or None while it runs."""
|
|
215
|
+
return self._state.outcome if self._state is not None else None
|
|
216
|
+
|
|
217
|
+
async def __aenter__(self) -> Self:
|
|
218
|
+
await self.start()
|
|
219
|
+
return self
|
|
220
|
+
|
|
221
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
222
|
+
await self.close()
|
|
223
|
+
|
|
224
|
+
def _check_open(self) -> None:
|
|
225
|
+
if self._closed:
|
|
226
|
+
raise GameClientClosedError()
|
|
227
|
+
if self._failure is not None:
|
|
228
|
+
raise self._failure
|
|
229
|
+
if not self.credential:
|
|
230
|
+
raise GameAuthenticationError("missing credential", 401)
|
|
231
|
+
|
|
232
|
+
def _spawn(self, coroutine) -> None:
|
|
233
|
+
task = asyncio.create_task(coroutine)
|
|
234
|
+
self._tasks.add(task)
|
|
235
|
+
|
|
236
|
+
def done(completed: asyncio.Task) -> None:
|
|
237
|
+
self._tasks.discard(completed)
|
|
238
|
+
if not completed.cancelled() and not self._closed:
|
|
239
|
+
error = completed.exception()
|
|
240
|
+
if error is not None:
|
|
241
|
+
self._fail(error)
|
|
242
|
+
|
|
243
|
+
task.add_done_callback(done)
|
|
244
|
+
|
|
245
|
+
def _fail(self, error: BaseException) -> None:
|
|
246
|
+
if self._failure is None:
|
|
247
|
+
self._failure = error
|
|
248
|
+
self._failed.set()
|
|
249
|
+
|
|
250
|
+
async def _request(
|
|
251
|
+
self,
|
|
252
|
+
method: str,
|
|
253
|
+
path: str,
|
|
254
|
+
*,
|
|
255
|
+
payload: dict[str, Any] | None = None,
|
|
256
|
+
retry_transport: bool = False,
|
|
257
|
+
) -> Any:
|
|
258
|
+
self._check_open()
|
|
259
|
+
attempts = self._retry_attempts if retry_transport else 1
|
|
260
|
+
for attempt in range(attempts):
|
|
261
|
+
try:
|
|
262
|
+
response = await self._http.request(
|
|
263
|
+
method,
|
|
264
|
+
f"{self.base_url}{path}",
|
|
265
|
+
headers={"Authorization": f"Bearer {self.credential}"},
|
|
266
|
+
json=payload,
|
|
267
|
+
)
|
|
268
|
+
except httpx.TransportError as exc:
|
|
269
|
+
if attempt + 1 == attempts:
|
|
270
|
+
raise GameTransportError("network error") from exc
|
|
271
|
+
await self._sleep(min(0.1 * 2**attempt, 1.0))
|
|
272
|
+
continue
|
|
273
|
+
if response.status_code >= 400:
|
|
274
|
+
try:
|
|
275
|
+
problem = response.json()
|
|
276
|
+
except ValueError, TypeError:
|
|
277
|
+
problem = {}
|
|
278
|
+
if not isinstance(problem, dict):
|
|
279
|
+
problem = {}
|
|
280
|
+
detail = problem.get("detail")
|
|
281
|
+
if not isinstance(detail, str) or not detail:
|
|
282
|
+
detail = f"request failed ({response.status_code})"
|
|
283
|
+
problem_type = problem.get("type")
|
|
284
|
+
error_class = (
|
|
285
|
+
GameAuthenticationError
|
|
286
|
+
if response.status_code == 401
|
|
287
|
+
else GameTransportError
|
|
288
|
+
if response.status_code == 429 or response.status_code >= 500
|
|
289
|
+
else PROBLEM_TYPE_ERRORS.get(problem_type, GameError)
|
|
290
|
+
if isinstance(problem_type, str)
|
|
291
|
+
else GameError
|
|
292
|
+
)
|
|
293
|
+
error = error_class(detail, response.status_code)
|
|
294
|
+
if isinstance(error, GameAuthenticationError):
|
|
295
|
+
self._fail(error)
|
|
296
|
+
raise error
|
|
297
|
+
try:
|
|
298
|
+
return response.json()
|
|
299
|
+
except (ValueError, TypeError) as exc:
|
|
300
|
+
raise GameProtocolError("invalid JSON response") from exc
|
|
301
|
+
raise AssertionError("unreachable retry state")
|
|
302
|
+
|
|
303
|
+
async def _connection_token(self) -> tuple[str, str]:
|
|
304
|
+
data = await self._request("POST", "/lobby/v1/connection-token", payload={})
|
|
305
|
+
try:
|
|
306
|
+
token = ConnectionToken.model_validate(data)
|
|
307
|
+
except ValidationError as exc:
|
|
308
|
+
raise GameProtocolError("invalid connection-token response") from exc
|
|
309
|
+
return token.token, str(token.websocket_url)
|
|
310
|
+
|
|
311
|
+
async def _get_token(self) -> str:
|
|
312
|
+
try:
|
|
313
|
+
token, _ = await self._connection_token()
|
|
314
|
+
return token
|
|
315
|
+
except GameAuthenticationError as exc:
|
|
316
|
+
self._fail(exc)
|
|
317
|
+
raise centrifuge.UnauthorizedError() from exc
|
|
318
|
+
|
|
319
|
+
def _validate_identity(self, state: GenerationState) -> None:
|
|
320
|
+
if (
|
|
321
|
+
str(state.lobby_id) != self.lobby_id
|
|
322
|
+
or state.channel != f"player:{state.player_id}"
|
|
323
|
+
):
|
|
324
|
+
raise GameProtocolError(
|
|
325
|
+
"generation state has wrong lobby or player channel"
|
|
326
|
+
)
|
|
327
|
+
if (
|
|
328
|
+
self._expected_identity is not None
|
|
329
|
+
and (state.game_id, state.player_id, state.channel, state.team_color)
|
|
330
|
+
!= self._expected_identity
|
|
331
|
+
):
|
|
332
|
+
raise GameProtocolError(
|
|
333
|
+
"generation state has wrong game, player, or team color"
|
|
334
|
+
)
|
|
335
|
+
if self._state is not None and (
|
|
336
|
+
state.game_id != self._state.game_id
|
|
337
|
+
or state.player_id != self._state.player_id
|
|
338
|
+
):
|
|
339
|
+
raise GameProtocolError("generation state has wrong game or player")
|
|
340
|
+
|
|
341
|
+
def _install(self, state: GenerationState) -> GenerationState:
|
|
342
|
+
self._validate_identity(state)
|
|
343
|
+
current = self._state
|
|
344
|
+
if current is not None and state.generation_id < current.generation_id:
|
|
345
|
+
return current
|
|
346
|
+
if current is not None and state.generation_id == current.generation_id:
|
|
347
|
+
if current.outcome is not None and state.outcome is None:
|
|
348
|
+
# A GET begun before a turn deadline ended the game can finish
|
|
349
|
+
# afterward. The outcome is final.
|
|
350
|
+
return current
|
|
351
|
+
if current.submitted and not state.submitted:
|
|
352
|
+
# A GET begun before a successful command can finish afterward.
|
|
353
|
+
# Completion is monotone within one generation. A deadline that
|
|
354
|
+
# ends the game on this generation still brings its outcome.
|
|
355
|
+
if state.outcome is None or current.outcome is not None:
|
|
356
|
+
return current
|
|
357
|
+
state = state.model_copy(update={"submitted": True})
|
|
358
|
+
if state.log_lines_stored < current.log_lines_stored:
|
|
359
|
+
# So is the stored line count. A pushed event says 0 for its
|
|
360
|
+
# generation, which a restarted client may receive after its
|
|
361
|
+
# read.
|
|
362
|
+
state = state.model_copy(
|
|
363
|
+
update={"log_lines_stored": current.log_lines_stored}
|
|
364
|
+
)
|
|
365
|
+
self._state = state
|
|
366
|
+
self._state_changed.set()
|
|
367
|
+
ends = state.outcome is not None and not self._ended_queued
|
|
368
|
+
if state.generation_id > self._last_queued_generation or ends:
|
|
369
|
+
self._last_queued_generation = state.generation_id
|
|
370
|
+
self._ended_queued = state.outcome is not None
|
|
371
|
+
self._callbacks.put_nowait(state)
|
|
372
|
+
return state
|
|
373
|
+
|
|
374
|
+
async def _fetch_state(self) -> GenerationState:
|
|
375
|
+
data = await self._request(
|
|
376
|
+
"GET", f"/game/from-lobby/{self.lobby_id}/generation"
|
|
377
|
+
)
|
|
378
|
+
return self._install(_parse_state(data))
|
|
379
|
+
|
|
380
|
+
async def _fetch_upgrades(self) -> list[Upgrade]:
|
|
381
|
+
"""Read the upgrade cost table, the same for the whole game."""
|
|
382
|
+
data = await self._request("GET", "/game/upgrades", retry_transport=True)
|
|
383
|
+
try:
|
|
384
|
+
return [Upgrade.model_validate(entry) for entry in data["upgrades"]]
|
|
385
|
+
except (KeyError, TypeError, ValidationError) as error:
|
|
386
|
+
raise GameProtocolError("invalid upgrade table") from error
|
|
387
|
+
|
|
388
|
+
async def refresh(self) -> Generation:
|
|
389
|
+
"""Fetch the authorized state and schedule any new generation callback."""
|
|
390
|
+
self._check_open()
|
|
391
|
+
if not self._started:
|
|
392
|
+
raise GameError("client has not synchronized")
|
|
393
|
+
async with self._reconcile_lock:
|
|
394
|
+
state = await self._fetch_state()
|
|
395
|
+
return state.generation
|
|
396
|
+
|
|
397
|
+
async def start(self) -> Generation:
|
|
398
|
+
"""Subscribe, await acknowledgment, then fetch authoritative state."""
|
|
399
|
+
try:
|
|
400
|
+
async with self._start_lock:
|
|
401
|
+
self._check_open()
|
|
402
|
+
if self._started:
|
|
403
|
+
assert self._state is not None
|
|
404
|
+
return self._state.generation
|
|
405
|
+
self._logging.attach()
|
|
406
|
+
token, address = await self._connection_token()
|
|
407
|
+
self._sdk = self._sdk_factory(
|
|
408
|
+
address,
|
|
409
|
+
token=token,
|
|
410
|
+
get_token=self._get_token,
|
|
411
|
+
events=_ConnectionEvents(self),
|
|
412
|
+
)
|
|
413
|
+
await self._sdk.connect()
|
|
414
|
+
# The first authorized HTTP read provides the player channel. A
|
|
415
|
+
# personal channel is derivable from the verified session only
|
|
416
|
+
# after this read, so fetch once to discover it, then reconcile
|
|
417
|
+
# again after the subscription acknowledgment.
|
|
418
|
+
discovery = await self._request(
|
|
419
|
+
"GET", f"/game/from-lobby/{self.lobby_id}/generation"
|
|
420
|
+
)
|
|
421
|
+
initial = _parse_state(discovery)
|
|
422
|
+
self._validate_identity(initial)
|
|
423
|
+
if initial.bot.kind != "localhost":
|
|
424
|
+
# The backend plays this seat and completes its turns itself.
|
|
425
|
+
raise GameError(
|
|
426
|
+
f"the backend plays this seat with its level {initial.bot.level} "
|
|
427
|
+
"default bot; to play it with your own bot, choose your "
|
|
428
|
+
"localhost bot in the lobby before getting ready"
|
|
429
|
+
)
|
|
430
|
+
self._expected_identity = (
|
|
431
|
+
initial.game_id,
|
|
432
|
+
initial.player_id,
|
|
433
|
+
initial.channel,
|
|
434
|
+
initial.team_color,
|
|
435
|
+
)
|
|
436
|
+
self._subscription = self._sdk.new_subscription(
|
|
437
|
+
initial.channel,
|
|
438
|
+
events=_SubscriptionEvents(self),
|
|
439
|
+
recoverable=True,
|
|
440
|
+
positioned=True,
|
|
441
|
+
)
|
|
442
|
+
await self._subscription.subscribe()
|
|
443
|
+
try:
|
|
444
|
+
await asyncio.wait_for(
|
|
445
|
+
self._subscription.ready(), self._subscription_timeout
|
|
446
|
+
)
|
|
447
|
+
await asyncio.wait_for(
|
|
448
|
+
self._acknowledged.wait(), self._subscription_timeout
|
|
449
|
+
)
|
|
450
|
+
except TimeoutError as exc:
|
|
451
|
+
# An SDK denial may resolve ready() without an acknowledgment.
|
|
452
|
+
await self._fetch_state()
|
|
453
|
+
raise GameError(
|
|
454
|
+
"player-channel subscription was not acknowledged"
|
|
455
|
+
) from exc
|
|
456
|
+
state = await self._fetch_state()
|
|
457
|
+
self._started = True
|
|
458
|
+
self._spawn(self._reconcile_worker())
|
|
459
|
+
if self._refresh_interval is not None:
|
|
460
|
+
self._spawn(self._periodic_worker())
|
|
461
|
+
self._spawn(self._callback_worker())
|
|
462
|
+
self._spawn(self._deadline_worker())
|
|
463
|
+
return state.generation
|
|
464
|
+
except BaseException:
|
|
465
|
+
await self.close()
|
|
466
|
+
raise
|
|
467
|
+
|
|
468
|
+
async def _reconcile_worker(self) -> None:
|
|
469
|
+
failures = 0
|
|
470
|
+
while not self._closed:
|
|
471
|
+
await self._wakeup.wait()
|
|
472
|
+
self._wakeup.clear()
|
|
473
|
+
try:
|
|
474
|
+
async with self._reconcile_lock:
|
|
475
|
+
await self._fetch_state()
|
|
476
|
+
failures = 0
|
|
477
|
+
except GameTransportError:
|
|
478
|
+
# Retry failed subscription reconciliation without idle polling.
|
|
479
|
+
failures += 1
|
|
480
|
+
await self._sleep(min(0.1 * 2 ** min(failures - 1, 7), 10.0))
|
|
481
|
+
self._wakeup.set()
|
|
482
|
+
|
|
483
|
+
async def _periodic_worker(self) -> None:
|
|
484
|
+
if self._refresh_interval is None:
|
|
485
|
+
return
|
|
486
|
+
while not self._closed:
|
|
487
|
+
await self._sleep(self._refresh_interval)
|
|
488
|
+
self._wakeup.set()
|
|
489
|
+
|
|
490
|
+
async def _callback_worker(self) -> None:
|
|
491
|
+
while not self._closed:
|
|
492
|
+
state = await self._callbacks.get()
|
|
493
|
+
if state.outcome is not None:
|
|
494
|
+
# The final generation has no turn: report the end, then stop.
|
|
495
|
+
try:
|
|
496
|
+
if self._on_game_ended is not None:
|
|
497
|
+
with self._logging.scope():
|
|
498
|
+
ended = self._on_game_ended(state.outcome, state.generation)
|
|
499
|
+
if inspect.isawaitable(ended):
|
|
500
|
+
await ended
|
|
501
|
+
finally:
|
|
502
|
+
self._callbacks.task_done()
|
|
503
|
+
self._stopped.set()
|
|
504
|
+
return
|
|
505
|
+
try:
|
|
506
|
+
if self._state is not None and (
|
|
507
|
+
self._state.generation_id != state.generation_id
|
|
508
|
+
or self._state.submitted
|
|
509
|
+
or self._state.outcome is not None
|
|
510
|
+
):
|
|
511
|
+
continue
|
|
512
|
+
# Count on from the lines an earlier run stored for this turn,
|
|
513
|
+
# as the latest state of this generation says.
|
|
514
|
+
latest = self._state or state
|
|
515
|
+
self._game_log = GameLog(state.generation_id, latest.log_lines_stored)
|
|
516
|
+
if self._upgrades is None:
|
|
517
|
+
self._upgrades = await self._fetch_upgrades()
|
|
518
|
+
turn = BotTurn(
|
|
519
|
+
game_id=state.game_id,
|
|
520
|
+
generation=state.generation,
|
|
521
|
+
upgrades=self._upgrades,
|
|
522
|
+
memory=state.memory,
|
|
523
|
+
team_color=state.team_color,
|
|
524
|
+
turn_deadline_at=state.turn_deadline_at,
|
|
525
|
+
)
|
|
526
|
+
with self._logging.scope(self._game_log):
|
|
527
|
+
try:
|
|
528
|
+
result = await call_play(self._play, turn)
|
|
529
|
+
add_result_lines(self._game_log, result)
|
|
530
|
+
if not self._closed:
|
|
531
|
+
# The one completing batch: the commands, the lines
|
|
532
|
+
# and the memory, which the server stores.
|
|
533
|
+
await self._register(
|
|
534
|
+
list(result.commands),
|
|
535
|
+
True,
|
|
536
|
+
generation_id=state.generation_id,
|
|
537
|
+
memory=result.memory,
|
|
538
|
+
)
|
|
539
|
+
except (
|
|
540
|
+
GameEndedError,
|
|
541
|
+
GameTransportError,
|
|
542
|
+
GameAuthenticationError,
|
|
543
|
+
GameClientClosedError,
|
|
544
|
+
):
|
|
545
|
+
raise
|
|
546
|
+
except Exception as error:
|
|
547
|
+
# The bot raised, returned an invalid result, or the
|
|
548
|
+
# server rejected the result's commands or memory. The
|
|
549
|
+
# turn stays open and the client stops after the
|
|
550
|
+
# error and the remaining lines are stored.
|
|
551
|
+
await self._send_failure_logs(state.generation_id, error)
|
|
552
|
+
raise
|
|
553
|
+
except GameEndedError:
|
|
554
|
+
# The turn's deadline passed during the callback, and the game
|
|
555
|
+
# ended. Reconciling reads the final state, which queues
|
|
556
|
+
# on_game_ended, instead of failing the client.
|
|
557
|
+
self._wakeup.set()
|
|
558
|
+
finally:
|
|
559
|
+
self._game_log = None
|
|
560
|
+
self._callbacks.task_done()
|
|
561
|
+
|
|
562
|
+
async def _deadline_worker(self) -> None:
|
|
563
|
+
"""Apply the turn's deadline while this player waits for the others.
|
|
564
|
+
|
|
565
|
+
The server runs no timers: a turn times out when a request applies its
|
|
566
|
+
deadline. Once this player has completed a turn that has a deadline,
|
|
567
|
+
the client posts to the deadline endpoint a second after it, unless a
|
|
568
|
+
new state arrives first. The server answers with this player's state,
|
|
569
|
+
which carries the outcome when someone didn't answer in time.
|
|
570
|
+
"""
|
|
571
|
+
attempts: tuple[int, int] = (0, 0)
|
|
572
|
+
while not self._closed:
|
|
573
|
+
self._state_changed.clear()
|
|
574
|
+
state = self._state
|
|
575
|
+
if (
|
|
576
|
+
state is None
|
|
577
|
+
or state.outcome is not None
|
|
578
|
+
or state.turn_deadline_at is None
|
|
579
|
+
or not state.submitted
|
|
580
|
+
):
|
|
581
|
+
await self._state_changed.wait()
|
|
582
|
+
continue
|
|
583
|
+
tries = attempts[1] if attempts[0] == state.generation_id else 0
|
|
584
|
+
delay = (
|
|
585
|
+
state.turn_deadline_at - self._clock()
|
|
586
|
+
).total_seconds() + DEADLINE_SLACK_SECONDS
|
|
587
|
+
if tries:
|
|
588
|
+
# The turn was still open after an earlier attempt.
|
|
589
|
+
delay = max(
|
|
590
|
+
delay,
|
|
591
|
+
min(
|
|
592
|
+
DEADLINE_RETRY_SECONDS * 2 ** (tries - 1),
|
|
593
|
+
DEADLINE_RETRY_MAX_SECONDS,
|
|
594
|
+
),
|
|
595
|
+
)
|
|
596
|
+
if delay > 0 and await self._state_changes_within(delay):
|
|
597
|
+
continue
|
|
598
|
+
attempts = (state.generation_id, tries + 1)
|
|
599
|
+
try:
|
|
600
|
+
async with self._reconcile_lock:
|
|
601
|
+
data = await self._request(
|
|
602
|
+
"POST",
|
|
603
|
+
f"/game/from-lobby/{self.lobby_id}/deadline",
|
|
604
|
+
retry_transport=True,
|
|
605
|
+
)
|
|
606
|
+
self._install(_parse_state(data))
|
|
607
|
+
except GameAuthenticationError:
|
|
608
|
+
raise
|
|
609
|
+
except GameError as error:
|
|
610
|
+
# The next attempt waits longer; HTTP reads still recover the state.
|
|
611
|
+
_sdk_logger.warning("could not apply the turn deadline: %s", error)
|
|
612
|
+
|
|
613
|
+
async def _state_changes_within(self, delay: float) -> bool:
|
|
614
|
+
"""Wait up to ``delay`` seconds; return whether the state changed meanwhile."""
|
|
615
|
+
changed = asyncio.create_task(self._state_changed.wait())
|
|
616
|
+
timer = asyncio.ensure_future(self._sleep(delay))
|
|
617
|
+
try:
|
|
618
|
+
done, _ = await asyncio.wait(
|
|
619
|
+
(changed, timer), return_when=asyncio.FIRST_COMPLETED
|
|
620
|
+
)
|
|
621
|
+
finally:
|
|
622
|
+
for task in (changed, timer):
|
|
623
|
+
task.cancel()
|
|
624
|
+
await asyncio.gather(changed, timer, return_exceptions=True)
|
|
625
|
+
return changed in done
|
|
626
|
+
|
|
627
|
+
async def _send_failure_logs(self, generation_id: int, error: Exception) -> None:
|
|
628
|
+
"""Log the failure and send the generation's remaining lines.
|
|
629
|
+
|
|
630
|
+
The batch leaves the turn open, and the client stops after it, so a
|
|
631
|
+
crashing bot's last lines and traceback are kept.
|
|
632
|
+
"""
|
|
633
|
+
# Logged inside the turn's scope, so the game log stores it too.
|
|
634
|
+
_sdk_logger.error(
|
|
635
|
+
"the bot failed on generation %d", generation_id, exc_info=error
|
|
636
|
+
)
|
|
637
|
+
try:
|
|
638
|
+
await self._register([], False, generation_id=generation_id, last=True)
|
|
639
|
+
except GameError as send_error:
|
|
640
|
+
_sdk_logger.warning(
|
|
641
|
+
"could not send the failed generation's logs: %s", send_error
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
async def _register(
|
|
645
|
+
self,
|
|
646
|
+
commands: list[PublicGenerationCommand],
|
|
647
|
+
complete: bool,
|
|
648
|
+
*,
|
|
649
|
+
generation_id: int,
|
|
650
|
+
last: bool | None = None,
|
|
651
|
+
memory: Mapping[str, Any] | None = None,
|
|
652
|
+
) -> GenerationState:
|
|
653
|
+
"""Send one batch; ``last`` (default: ``complete``) marks the turn's last request."""
|
|
654
|
+
self._check_open()
|
|
655
|
+
last = complete if last is None else last
|
|
656
|
+
async with self._command_lock:
|
|
657
|
+
if self._state is None:
|
|
658
|
+
raise GameError("client has not synchronized")
|
|
659
|
+
if complete and (
|
|
660
|
+
generation_id < self._state.generation_id
|
|
661
|
+
or (
|
|
662
|
+
generation_id == self._state.generation_id and self._state.submitted
|
|
663
|
+
)
|
|
664
|
+
):
|
|
665
|
+
# A callback or concurrent request may have already sealed this
|
|
666
|
+
# turn. Check under the command lock to avoid a second completion.
|
|
667
|
+
return self._state
|
|
668
|
+
# The lines logged since the previous request for this generation.
|
|
669
|
+
game_log = self._game_log
|
|
670
|
+
if game_log is not None and game_log.generation_id != generation_id:
|
|
671
|
+
game_log = None
|
|
672
|
+
logs, sent = game_log.batch(last) if game_log is not None else ([], 0)
|
|
673
|
+
game_id = self._state.game_id
|
|
674
|
+
|
|
675
|
+
async def post(lines: list[GenerationLogLine]) -> Any:
|
|
676
|
+
payload = RegisterGenerationCommands(
|
|
677
|
+
game_id=game_id,
|
|
678
|
+
generation_id=generation_id,
|
|
679
|
+
request_id=uuid4(),
|
|
680
|
+
complete=complete,
|
|
681
|
+
commands=commands,
|
|
682
|
+
memory=memory,
|
|
683
|
+
logs=lines or None,
|
|
684
|
+
).model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
685
|
+
# The transport retries this exact payload, including its UUID.
|
|
686
|
+
return await self._request(
|
|
687
|
+
"POST",
|
|
688
|
+
"/generation/commands",
|
|
689
|
+
payload=payload,
|
|
690
|
+
retry_transport=True,
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
try:
|
|
694
|
+
result = await post(logs)
|
|
695
|
+
except GameLogLimitError as error:
|
|
696
|
+
if game_log is None or not logs:
|
|
697
|
+
raise
|
|
698
|
+
# The server holds more lines than this client counted, for
|
|
699
|
+
# example from another client of the same player. Drop the
|
|
700
|
+
# lines, and send the commands and completion without them.
|
|
701
|
+
game_log.seal()
|
|
702
|
+
report_server_limit(generation_id, error.detail)
|
|
703
|
+
result = await post([])
|
|
704
|
+
except GameTransportError:
|
|
705
|
+
# The server may have stored the lines; sending them again could
|
|
706
|
+
# pass the line limit, so they are not resent.
|
|
707
|
+
if game_log is not None:
|
|
708
|
+
game_log.commit(sent, last)
|
|
709
|
+
raise
|
|
710
|
+
if game_log is not None:
|
|
711
|
+
game_log.commit(sent, last)
|
|
712
|
+
return self._install(_parse_state(result))
|
|
713
|
+
|
|
714
|
+
async def wait(self) -> None:
|
|
715
|
+
"""Wait until closed or a background callback/reconciliation fails."""
|
|
716
|
+
tasks = (
|
|
717
|
+
asyncio.create_task(self._stopped.wait()),
|
|
718
|
+
asyncio.create_task(self._failed.wait()),
|
|
719
|
+
)
|
|
720
|
+
try:
|
|
721
|
+
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
722
|
+
finally:
|
|
723
|
+
for task in tasks:
|
|
724
|
+
task.cancel()
|
|
725
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
726
|
+
if self._failure is not None:
|
|
727
|
+
raise self._failure
|
|
728
|
+
|
|
729
|
+
async def run(self) -> None:
|
|
730
|
+
try:
|
|
731
|
+
await self.start()
|
|
732
|
+
await self.wait()
|
|
733
|
+
finally:
|
|
734
|
+
await self.close()
|
|
735
|
+
|
|
736
|
+
async def close(self) -> None:
|
|
737
|
+
if self._close_task is None:
|
|
738
|
+
self._closed = True
|
|
739
|
+
self._stopped.set()
|
|
740
|
+
self._close_task = asyncio.create_task(
|
|
741
|
+
self._shutdown(asyncio.current_task())
|
|
742
|
+
)
|
|
743
|
+
await asyncio.shield(self._close_task)
|
|
744
|
+
|
|
745
|
+
async def _shutdown(self, caller: asyncio.Task | None) -> None:
|
|
746
|
+
tasks = [task for task in self._tasks if task is not caller]
|
|
747
|
+
for task in tasks:
|
|
748
|
+
task.cancel()
|
|
749
|
+
if tasks:
|
|
750
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
751
|
+
if self._subscription is not None:
|
|
752
|
+
with suppress(Exception):
|
|
753
|
+
await asyncio.wait_for(self._subscription.unsubscribe(), 2)
|
|
754
|
+
if self._sdk is not None:
|
|
755
|
+
with suppress(Exception):
|
|
756
|
+
self._sdk.remove_subscription(self._subscription)
|
|
757
|
+
if self._sdk is not None:
|
|
758
|
+
with suppress(Exception):
|
|
759
|
+
await asyncio.wait_for(self._sdk.disconnect(), 2)
|
|
760
|
+
try:
|
|
761
|
+
await asyncio.wait_for(self._http.aclose(), 2)
|
|
762
|
+
finally:
|
|
763
|
+
self._logging.detach()
|