from __future__ import annotations from random import Random from threading import RLock from typing import Any from uuid import uuid4 from texas_holdem.agents import build_agent, http_agent_endpoint_from_spec from texas_holdem.engine import TableGame class GameManager: def __init__(self) -> None: self._games: dict[str, TableGame] = {} self._http_endpoint_owners: dict[str, str] = {} self._lock = RLock() def create_game(self, payload: dict[str, Any]) -> TableGame: players = payload.get("players") if not isinstance(players, list): raise ValueError("players must be a list") if not 2 <= len(players) <= 12: raise ValueError("players must contain 2-12 entries") seed = payload.get("seed") rng = Random(seed) game_id = str(payload.get("game_id") or uuid4()) starting_stack = int(payload.get("starting_stack", 1000)) small_blind = int(payload.get("small_blind", 5)) big_blind = int(payload.get("big_blind", 10)) specs = [] http_endpoints: set[str] = set() for seat, raw_spec in enumerate(players): if not isinstance(raw_spec, dict): raise ValueError("each player must be an object") player_id = str(raw_spec.get("id") or raw_spec.get("player_id") or f"p{seat + 1}") name = str(raw_spec.get("name") or player_id) agent_spec = raw_spec.get("agent", raw_spec) if not isinstance(agent_spec, dict): raise ValueError("agent spec must be an object") endpoint = http_agent_endpoint_from_spec(agent_spec) if endpoint is not None: http_endpoints.add(endpoint) agent = build_agent(agent_spec, rng, player_id=player_id) specs.append((player_id, name, agent)) game = TableGame( game_id=game_id, player_specs=specs, starting_stack=starting_stack, small_blind=small_blind, big_blind=big_blind, rng=rng, ) with self._lock: self._release_completed_http_endpoints_locked() if game_id in self._games: raise ValueError(f"game already exists: {game_id}") for endpoint in http_endpoints: owner = self._http_endpoint_owners.get(endpoint) if owner is not None and owner != game_id: raise ValueError( f"http agent endpoint already belongs to game {owner}: {endpoint}" ) self._games[game_id] = game for endpoint in http_endpoints: self._http_endpoint_owners[endpoint] = game_id return game def get_game(self, game_id: str) -> TableGame: with self._lock: try: return self._games[game_id] except KeyError as exc: raise KeyError(f"game not found: {game_id}") from exc def get_game_state(self, game_id: str) -> dict[str, object]: return self.get_game(game_id).snapshot_completed() def list_games(self) -> list[dict[str, object]]: with self._lock: games = list(self._games.values()) return [game.snapshot_completed() for game in games] def run_hands( self, game_id: str, count: int = 1, until_one_left: bool = False, small_blind: int | None = None, big_blind: int | None = None, ) -> list[dict[str, object]]: """Run ``count`` hands, optionally raising the blinds first. ``small_blind`` / ``big_blind`` are forwarded to the engine so the blinds can change between batches. Leaving them as ``None`` keeps the previously configured level, which preserves the original no-argument behaviour. """ game = self.get_game(game_id) summaries = [ summary.to_dict() for summary in game.run_hands( count, until_one_left=until_one_left, small_blind=small_blind, big_blind=big_blind, ) ] if game.is_complete: with self._lock: self._release_http_endpoints_for_game_locked(game_id) return summaries def _release_completed_http_endpoints_locked(self) -> None: for game_id, game in list(self._games.items()): if game.lock.acquire(blocking=False): try: if game.is_complete: self._release_http_endpoints_for_game_locked(game_id) finally: game.lock.release() def _release_http_endpoints_for_game_locked(self, game_id: str) -> None: for endpoint, owner in list(self._http_endpoint_owners.items()): if owner == game_id: del self._http_endpoint_owners[endpoint]