"""Ordered, application-managed game bookkeeping outside HTTP response budgets.""" import asyncio from collections.abc import Awaitable, Callable import os from quart_common.web.logger import await_log from server.database import StorageLoader class GameLifecycleService: def __init__(self, server): self.server = server self.pending: dict[str, asyncio.Future] = {} def submit(self, game_id:str, operation:Callable[[], Awaitable[None]]) -> None: previous = self.pending.get(game_id) completed = asyncio.get_running_loop().create_future() self.pending[game_id] = completed async def run(): try: if previous is not None: await asyncio.shield(previous) await operation() except Exception: self.server.app.logger.exception('Game bookkeeping failed for %s', game_id) finally: if not completed.done(): completed.set_result(None) if self.pending.get(game_id) is completed: self.pending.pop(game_id, None) self.server.app.add_background_task(run) async def drain(self) -> None: while self.pending: await asyncio.gather(*(asyncio.shield(task) for task in list(self.pending.values()))) async def started(self, game_state:dict, game_board, active_count:int) -> None: server = self.server await server.metrics_collector.record_game_started(active_count) await server.gameplay_tracking.record_gameplay_start(game_state, game_board) await server.dashboard_query.push_dashboard_games_update(game_state, trigger='game_started') await await_log(server.logger.info(f'GAME START: {game_state["game"]}')) await server.game_runtime.prune_stale_games() async def ended(self, game_state:dict, game_board) -> None: server = self.server try: if server.store_game_state and game_board is not None: kwargs = { 'file_path': os.path.join(server.data_path, 'data'), 'database': os.getenv('EDGEDB_DATABASE'), } if server.check_tls_security: kwargs['tls_security'] = None # Legacy exporters include synchronous serialization and database calls. # Keep those off the request event loop as well as off the response path. def export(): asyncio.run(game_board.save(StorageLoader.build(server.storage_type), **kwargs)) await asyncio.to_thread(export) finally: # A history export failure must not suppress the game result or metrics. await server.gameplay_tracking.record_gameplay_end(game_state) await server.metrics_collector.record_game_end(game_state) await server.dashboard_query.push_dashboard_games_update(game_state) await await_log(server.logger.info( f'GAME ENDED: Winner is {[snake["name"] for snake in game_state["board"]["snakes"]]}' )) async def turn(self, game_state:dict, move:str|None, thinking:dict|None) -> None: server = self.server await server.gameplay_tracking.record_gameplay_turn_snapshot(game_state, move, thinking) await server.dashboard_query.push_dashboard_game_replay_update(game_state['game']['id'])