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.
@@ -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()