c0bc5384f4
- HandSummary: add hole_cards, starting_stacks, ending_stacks, pot_contributions - Engine: capture all players' hole cards (not just showdown), pre/post hand stacks, per-level pot contributions - Server: new GET /game/<game_id>/hands/<hand_number> route - Service: add get_hand_state() method - Tests: add ServerTests for new endpoint, update existing tests - Existing GET /game/<game_id> auto-inherits new fields via shared to_dict()
136 lines
5.2 KiB
Python
136 lines
5.2 KiB
Python
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 get_hand_state(self, game_id: str, hand_number: int) -> dict[str, object]:
|
|
state = self.get_game_state(game_id)
|
|
for hand in state.get("hands", []):
|
|
if hand.get("hand_number") == hand_number:
|
|
return hand
|
|
raise KeyError(f"hand not found: {game_id} #{hand_number}")
|
|
|
|
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]
|