first prototype

This commit is contained in:
2026-09-27 09:52:00 +02:00
parent 4635199a41
commit 6d011d474f
109 changed files with 6354 additions and 507 deletions
+1
View File
@@ -0,0 +1 @@
"""Wizard game server: manages players and lobbies and runs games."""
+141
View File
@@ -0,0 +1,141 @@
"""Bridge between the game server and the AI service.
The AI service (``wizard_ai``) connects to ``/internal/ai`` via websocket.
Whenever an AI seat has to act, the server sends it the seat's observation
and waits for an action:
server -> ai: {"type": "decide", "request_id": "...", "observation": {...}}
ai -> server: {"type": "action", "request_id": "...", "action": {...}}
ai -> server: {"type": "error", "request_id": "...", "message": "..."}
Several AI service instances may connect; requests are distributed round
robin. If no instance is connected, it does not answer in time, or it answers
with an illegal action, a random legal action is played instead so a game
never gets stuck.
"""
from __future__ import annotations
import asyncio
import itertools
import logging
import random
import uuid
from collections.abc import Callable
from typing import Any
from starlette.websockets import WebSocket, WebSocketDisconnect
from wizard_game import Action, Observation, action_from_dict
logger = logging.getLogger(__name__)
class AIConnection:
def __init__(self, websocket: WebSocket, name: str) -> None:
self.websocket = websocket
self.name = name
self.pending: dict[str, asyncio.Future[dict[str, Any]]] = {}
self._send_lock = asyncio.Lock()
async def send(self, message: dict[str, Any]) -> None:
async with self._send_lock:
await self.websocket.send_json(message)
def fail_pending(self) -> None:
for future in self.pending.values():
if not future.done():
future.set_exception(ConnectionError("AI service disconnected"))
self.pending.clear()
class AIBridge:
def __init__(
self,
timeout: float,
rng: random.Random | None = None,
on_change: Callable[[], None] | None = None,
) -> None:
self.timeout = timeout
# Called whenever an AI service connects or disconnects.
self._on_change = on_change
self._connections: list[AIConnection] = []
self._round_robin = itertools.count()
self._rng = rng or random.Random()
@property
def available(self) -> bool:
return bool(self._connections)
async def decide(self, observation: Observation) -> Action:
"""Ask the AI service for an action; falls back to a random legal one."""
legal = observation.legal_actions
if not legal:
raise ValueError("Observation has no legal actions")
connection = self._pick_connection()
if connection is None:
return self._fallback(observation, "no AI service connected", logging.DEBUG)
request_id = uuid.uuid4().hex
future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
connection.pending[request_id] = future
try:
await connection.send({
"type": "decide",
"request_id": request_id,
"observation": observation.to_dict(),
})
reply = await asyncio.wait_for(future, self.timeout)
except (TimeoutError, ConnectionError, RuntimeError, WebSocketDisconnect) as exc:
return self._fallback(observation, f"AI request failed: {exc!r}")
finally:
connection.pending.pop(request_id, None)
if reply.get("type") != "action":
return self._fallback(observation, f"AI error: {reply.get('message')}")
try:
action = action_from_dict(reply.get("action", {}))
except ValueError as exc:
return self._fallback(observation, f"malformed AI action: {exc}")
if action not in legal:
return self._fallback(observation, f"illegal AI action {action}")
return action
async def serve(self, websocket: WebSocket) -> None:
"""Handle one AI service connection until it disconnects."""
connection = AIConnection(websocket, name=websocket.client.host if websocket.client else "?")
self._connections.append(connection)
logger.info("AI service connected from %s (%d connected)", connection.name, len(self._connections))
self._notify_change()
try:
while True:
message = await websocket.receive_json()
if not isinstance(message, dict):
continue
if message.get("type") == "hello":
logger.info("AI service %s: %s", connection.name, message.get("agent"))
continue
future = connection.pending.get(str(message.get("request_id")))
if future and not future.done():
future.set_result(message)
except (WebSocketDisconnect, RuntimeError, ValueError):
pass
finally:
self._connections.remove(connection)
connection.fail_pending()
logger.info("AI service %s disconnected (%d connected)", connection.name, len(self._connections))
self._notify_change()
def _notify_change(self) -> None:
if self._on_change is not None:
self._on_change()
def _pick_connection(self) -> AIConnection | None:
if not self._connections:
return None
return self._connections[next(self._round_robin) % len(self._connections)]
def _fallback(self, observation: Observation, reason: str, level: int = logging.WARNING) -> Action:
logger.log(level, "Playing random move for player %d: %s", observation.player, reason)
return self._rng.choice(observation.legal_actions)
+39
View File
@@ -0,0 +1,39 @@
"""Server configuration, read from environment variables."""
from __future__ import annotations
import os
from dataclasses import dataclass
def _float(name: str, default: float) -> float:
return float(os.environ.get(name, default))
def _int(name: str, default: int) -> int:
return int(os.environ.get(name, default))
@dataclass(frozen=True)
class Settings:
# Shared secret the AI service has to present on /internal/ai.
# Empty disables authentication (development only).
ai_shared_secret: str = os.environ.get("AI_SHARED_SECRET", "")
# Seconds to wait for the AI service before falling back to a random move.
ai_decision_timeout: float = _float("AI_DECISION_TIMEOUT", 5.0)
# Minimum "thinking" time of AI players so humans can follow the game.
ai_move_delay: float = _float("AI_MOVE_DELAY", 0.8)
# Pause after a completed trick before AI players continue.
trick_pause: float = _float("TRICK_PAUSE", 1.8)
# Sessions without a connection are dropped after this many seconds.
session_ttl: float = _float("SESSION_TTL", 1800.0)
# Maximum number of simultaneously existing lobbies.
max_lobbies: int = _int("MAX_LOBBIES", 1000)
# Comma separated list of allowed websocket origins; empty allows all.
allowed_origins: tuple[str, ...] = tuple(
origin.strip() for origin in os.environ.get("ALLOWED_ORIGINS", "").split(",") if origin.strip()
)
log_level: str = os.environ.get("LOG_LEVEL", "INFO")
settings = Settings()
+329
View File
@@ -0,0 +1,329 @@
"""Lobbies: a group of seats (humans and AI players) that play one game."""
from __future__ import annotations
import asyncio
import logging
import secrets
from collections.abc import Iterable
from dataclasses import dataclass
from enum import Enum
from typing import Any
from wizard_game import (
MAX_PLAYERS,
MIN_PLAYERS,
Event,
GameFinished,
GameOptions,
IllegalActionError,
TrickCompleted,
WizardGame,
action_from_dict,
)
from .ai_bridge import AIBridge
from .config import Settings
from .sessions import Session
logger = logging.getLogger(__name__)
LOBBY_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O, 1/I
LOBBY_CODE_LENGTH = 6
AI_NAMES = (
"Merlin", "Morgana", "Gandalf", "Radagast", "Rincewind", "Circe",
"Prospero", "Elminster", "Saruman", "Medea", "Zatanna", "Raistlin",
)
class LobbyError(Exception):
"""An error that is reported to the client that caused it."""
class LobbyStatus(str, Enum):
WAITING = "waiting"
PLAYING = "playing"
FINISHED = "finished"
@dataclass
class Seat:
player_id: str
name: str
is_ai: bool = False
session: Session | None = None
@property
def connected(self) -> bool:
return self.is_ai or (self.session is not None and self.session.connected)
class Lobby:
def __init__(self, code: str, bridge: AIBridge, settings: Settings) -> None:
self.code = code
self.seats: list[Seat] = []
self.host_id: str | None = None
self.options = GameOptions()
self.status = LobbyStatus.WAITING
self.game: WizardGame | None = None
self._bridge = bridge
self._settings = settings
self._ai_task: asyncio.Task | None = None
self._pause_until = 0.0
self._closed = False
# --- Queries ------------------------------------------------------------
@property
def has_humans(self) -> bool:
return any(not seat.is_ai for seat in self.seats)
def seat_index(self, player_id: str) -> int | None:
for index, seat in enumerate(self.seats):
if seat.player_id == player_id:
return index
return None
def view(self) -> dict[str, Any]:
return {
"code": self.code,
"status": self.status.value,
"host_id": self.host_id,
"options": self.options.to_dict(),
"players": [
{
"id": seat.player_id,
"name": seat.name,
"is_ai": seat.is_ai,
"connected": seat.connected,
}
for seat in self.seats
],
"min_players": MIN_PLAYERS,
"max_players": MAX_PLAYERS,
"ai_available": self._bridge.available,
}
def state_for(self, session: Session, events: Iterable[Event] = ()) -> dict[str, Any]:
game_view = None
if self.game is not None:
you = self.seat_index(session.player_id)
observation = self.game.observation(you if you is not None else 0)
data = observation.to_dict()
if you is None: # not seated (should not happen): hide private info
data["hand"], data["legal_actions"] = [], []
game_view = {"you": you, "observation": data}
return {
"type": "state",
"lobby": self.view(),
"game": game_view,
"events": [event.to_dict() for event in events],
}
# --- Membership ---------------------------------------------------------
def add_human(self, session: Session) -> None:
index = self.seat_index(session.player_id)
if index is not None: # already seated, e.g. after a reconnect
self.seats[index].session = session
return
if self.status is not LobbyStatus.WAITING:
raise LobbyError("The game in this lobby has already started")
if len(self.seats) >= MAX_PLAYERS:
raise LobbyError("This lobby is full")
assert session.name is not None
self.seats.append(Seat(session.player_id, self._unique_name(session.name), session=session))
if self.host_id is None:
self.host_id = session.player_id
def add_ai(self, by: Session) -> None:
self._require_host(by)
self._require_waiting()
if len(self.seats) >= MAX_PLAYERS:
raise LobbyError("This lobby is full")
taken = {seat.name for seat in self.seats}
name = next((n for n in AI_NAMES if n not in taken), f"Bot {len(self.seats) + 1}")
self.seats.append(Seat(f"ai-{secrets.token_hex(4)}", name, is_ai=True))
def remove_player(self, by: Session, player_id: str) -> Seat:
self._require_host(by)
self._require_waiting()
if player_id == by.player_id:
raise LobbyError("Use 'leave' to leave the lobby")
index = self.seat_index(player_id)
if index is None:
raise LobbyError("No such player")
return self.seats.pop(index)
def leave(self, session: Session) -> None:
index = self.seat_index(session.player_id)
if index is None:
return
seat = self.seats[index]
if self.status is LobbyStatus.WAITING:
self.seats.pop(index)
else:
# Keep the seat so the game can go on: an AI player takes over.
seat.is_ai = True
seat.session = None
seat.player_id = f"ai-{secrets.token_hex(4)}"
self._schedule_ai()
if self.host_id == session.player_id:
self.host_id = next((s.player_id for s in self.seats if not s.is_ai), None)
# --- Host actions -------------------------------------------------------
def set_options(self, by: Session, options: GameOptions) -> None:
self._require_host(by)
self._require_waiting()
self.options = options
def start(self, by: Session) -> None:
self._require_host(by)
self._require_waiting()
if not MIN_PLAYERS <= len(self.seats) <= MAX_PLAYERS:
raise LobbyError(f"Wizard needs {MIN_PLAYERS} to {MAX_PLAYERS} players")
self.game = WizardGame(len(self.seats), self.options)
self.status = LobbyStatus.PLAYING
self._pause_until = 0.0
logger.info("Lobby %s: game started with %d players", self.code, len(self.seats))
self._schedule_ai()
def return_to_lobby(self, by: Session) -> None:
self._require_host(by)
if self.status is not LobbyStatus.FINISHED:
raise LobbyError("The game is not finished yet")
self.status = LobbyStatus.WAITING
self.game = None
# --- Game actions -------------------------------------------------------
def apply_action(self, session: Session, action_data: dict[str, Any]) -> None:
if self.status is not LobbyStatus.PLAYING or self.game is None:
raise LobbyError("No game is running")
index = self.seat_index(session.player_id)
if index is None:
raise LobbyError("You are not playing in this game")
try:
action = action_from_dict(action_data)
events = self.game.apply(index, action)
except (ValueError, IllegalActionError) as exc:
raise LobbyError(str(exc)) from exc
self._after_action(events)
# --- Broadcasting -------------------------------------------------------
def broadcast(self, events: Iterable[Event] = ()) -> None:
events = list(events)
for seat in self.seats:
if seat.session is not None:
seat.session.send(self.state_for(seat.session, events))
def close(self) -> None:
self._closed = True
if self._ai_task and not self._ai_task.done():
self._ai_task.cancel()
# --- Internals ----------------------------------------------------------
def _require_host(self, session: Session) -> None:
if session.player_id != self.host_id:
raise LobbyError("Only the host can do that")
def _require_waiting(self) -> None:
if self.status is not LobbyStatus.WAITING:
raise LobbyError("The game has already started")
def _unique_name(self, name: str) -> str:
taken = {seat.name for seat in self.seats}
candidate, n = name, 2
while candidate in taken:
candidate = f"{name} ({n})"
n += 1
return candidate
def _after_action(self, events: list[Event]) -> None:
loop = asyncio.get_running_loop()
if any(isinstance(e, TrickCompleted) for e in events):
self._pause_until = loop.time() + self._settings.trick_pause
if any(isinstance(e, GameFinished) for e in events):
self.status = LobbyStatus.FINISHED
logger.info("Lobby %s: game finished", self.code)
self.broadcast(events)
self._schedule_ai()
def _current_ai_seat(self) -> int | None:
game = self.game
if self._closed or self.status is not LobbyStatus.PLAYING or game is None:
return None
index = game.current_player
if index is None or not self.seats[index].is_ai:
return None
return index
def _schedule_ai(self) -> None:
if self._ai_task and not self._ai_task.done():
return # the running loop picks up the new state
if self._current_ai_seat() is not None:
self._ai_task = asyncio.create_task(self._ai_loop())
async def _ai_loop(self) -> None:
loop = asyncio.get_running_loop()
try:
while (index := self._current_ai_seat()) is not None:
game = self.game
assert game is not None
started = loop.time()
wait_until = max(started + self._settings.ai_move_delay, self._pause_until)
action = await self._bridge.decide(game.observation(index))
if (remaining := wait_until - loop.time()) > 0:
await asyncio.sleep(remaining)
# The state may have changed while waiting (e.g. game restarted).
if self.game is not game or self._current_ai_seat() != index:
continue
if not game.is_legal(index, action):
continue
self._after_action(game.apply(index, action))
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Lobby %s: AI loop crashed", self.code)
class LobbyManager:
def __init__(self, bridge: AIBridge, settings: Settings) -> None:
self._bridge = bridge
self._settings = settings
self._lobbies: dict[str, Lobby] = {}
def __len__(self) -> int:
return len(self._lobbies)
def __iter__(self):
return iter(list(self._lobbies.values()))
def create(self) -> Lobby:
if len(self._lobbies) >= self._settings.max_lobbies:
raise LobbyError("Too many open lobbies, please try again later")
while True:
code = "".join(secrets.choice(LOBBY_CODE_ALPHABET) for _ in range(LOBBY_CODE_LENGTH))
if code not in self._lobbies:
break
lobby = Lobby(code, self._bridge, self._settings)
self._lobbies[code] = lobby
logger.info("Lobby %s created (%d open)", code, len(self._lobbies))
return lobby
def get(self, code: str | None) -> Lobby | None:
if not code:
return None
return self._lobbies.get(code.upper())
def discard_if_abandoned(self, lobby: Lobby) -> bool:
if lobby.has_humans:
return False
lobby.close()
self._lobbies.pop(lobby.code, None)
logger.info("Lobby %s closed (%d open)", lobby.code, len(self._lobbies))
return True
+77
View File
@@ -0,0 +1,77 @@
"""FastAPI application of the Wizard game server.
Routes:
- ``GET /api/health`` health check
- ``WS /api/ws`` web clients (see ``app.protocol``)
- ``WS /internal/ai`` AI service (see ``app.ai_bridge``); not meant to be
exposed by the reverse proxy and protected by ``AI_SHARED_SECRET``
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import secrets
from collections.abc import AsyncIterator
from fastapi import FastAPI, WebSocket
from .config import Settings, settings
from .service import WizardService
logger = logging.getLogger(__name__)
def create_app(config: Settings = settings) -> FastAPI:
service = WizardService(config)
@contextlib.asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
if not config.ai_shared_secret:
logger.warning("AI_SHARED_SECRET is not set, /internal/ai is unauthenticated")
cleanup = asyncio.create_task(service.cleanup_loop())
try:
yield
finally:
cleanup.cancel()
app = FastAPI(title="Wizard game server", lifespan=lifespan, docs_url=None, redoc_url=None)
app.state.service = service
@app.get("/api/health")
async def health() -> dict:
return {
"status": "ok",
"lobbies": len(service.lobbies),
"sessions": len(service.sessions),
"ai_available": service.bridge.available,
}
@app.websocket("/api/ws")
async def client_socket(websocket: WebSocket) -> None:
await service.handle_client(websocket)
@app.websocket("/internal/ai")
async def ai_socket(websocket: WebSocket) -> None:
if not _ai_authorized(websocket, config.ai_shared_secret):
await websocket.close(code=1008)
return
await service.handle_ai(websocket)
return app
def _ai_authorized(websocket: WebSocket, secret: str) -> bool:
if not secret:
return True
header = websocket.headers.get("authorization", "")
token = header.removeprefix("Bearer ").strip() if header.startswith("Bearer ") else ""
return secrets.compare_digest(token.encode(), secret.encode())
logging.basicConfig(
level=settings.log_level.upper(),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
app = create_app()
+80
View File
@@ -0,0 +1,80 @@
"""Messages exchanged with web clients over the ``/api/ws`` websocket.
Client -> server messages are validated with pydantic. Server -> client
messages are plain dicts:
- ``{"type": "welcome", "player_id", "token", "name"}``
- ``{"type": "state", "lobby": LobbyView | null, "game": GameView | null, "events": [...]}``
- ``{"type": "error", "message"}``
"""
from __future__ import annotations
from typing import Annotated, Any, Literal
from pydantic import BaseModel, Field, StringConstraints, TypeAdapter
PlayerName = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=20)]
LobbyCode = Annotated[str, StringConstraints(strip_whitespace=True, to_upper=True, min_length=4, max_length=12)]
class Hello(BaseModel):
type: Literal["hello"]
# Token of a previous session, used to reconnect after a page reload.
token: str | None = None
class CreateLobby(BaseModel):
type: Literal["create_lobby"]
name: PlayerName
class JoinLobby(BaseModel):
type: Literal["join_lobby"]
code: LobbyCode
name: PlayerName
class LeaveLobby(BaseModel):
type: Literal["leave_lobby"]
class AddAI(BaseModel):
type: Literal["add_ai"]
class RemovePlayer(BaseModel):
type: Literal["remove_player"]
player_id: str
class SetOptions(BaseModel):
type: Literal["set_options"]
not_equal: bool
hidden_bids: bool
class StartGame(BaseModel):
type: Literal["start_game"]
class GameAction(BaseModel):
type: Literal["action"]
action: dict[str, Any]
class ReturnToLobby(BaseModel):
type: Literal["return_to_lobby"]
ClientMessage = Annotated[
Hello | CreateLobby | JoinLobby | LeaveLobby | AddAI | RemovePlayer
| SetOptions | StartGame | GameAction | ReturnToLobby,
Field(discriminator="type"),
]
client_message_adapter: TypeAdapter[ClientMessage] = TypeAdapter(ClientMessage)
def error(message: str) -> dict:
return {"type": "error", "message": message}
+211
View File
@@ -0,0 +1,211 @@
"""Connects web clients, sessions and lobbies."""
from __future__ import annotations
import asyncio
import contextlib
import logging
import time
from pydantic import ValidationError
from starlette.websockets import WebSocket, WebSocketDisconnect
from wizard_game import GameOptions
from . import protocol
from .ai_bridge import AIBridge
from .config import Settings
from .lobby import Lobby, LobbyError, LobbyManager
from .sessions import Connection, Session, SessionRegistry
logger = logging.getLogger(__name__)
CLEANUP_INTERVAL = 30.0
class WizardService:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.sessions = SessionRegistry()
self.bridge = AIBridge(settings.ai_decision_timeout, on_change=self._on_ai_availability_changed)
self.lobbies = LobbyManager(self.bridge, settings)
# --- Client connections -------------------------------------------------
async def handle_client(self, websocket: WebSocket) -> None:
origin = websocket.headers.get("origin")
if self.settings.allowed_origins and origin not in self.settings.allowed_origins:
await websocket.close(code=1008)
return
await websocket.accept()
connection = Connection(websocket)
connection.start()
session: Session | None = None
try:
while True:
raw = await websocket.receive_text()
try:
message = protocol.client_message_adapter.validate_json(raw)
except ValidationError as exc:
connection.send(protocol.error(_validation_message(exc)))
continue
if isinstance(message, protocol.Hello):
session = self._hello(connection, message.token)
continue
if session is None or session.connection is not connection:
connection.send(protocol.error("Send 'hello' first"))
continue
try:
self._dispatch(session, message)
except LobbyError as exc:
session.send(protocol.error(str(exc)))
except WebSocketDisconnect:
pass
finally:
if session is not None and session.connection is connection:
session.connection = None
session.last_seen = time.monotonic()
if lobby := self.lobbies.get(session.lobby_code):
lobby.broadcast()
await connection.close()
def _hello(self, connection: Connection, token: str | None) -> Session:
session = self.sessions.get(token)
if session is None:
session = self.sessions.create()
elif session.connection is not None and session.connection is not connection:
old = session.connection
old.send(protocol.error("This session was opened in another window"))
asyncio.create_task(old.close(code=4000))
session.connection = connection
session.last_seen = time.monotonic()
session.send({
"type": "welcome",
"player_id": session.player_id,
"token": session.token,
"name": session.name,
})
lobby = self.lobbies.get(session.lobby_code)
if lobby is not None and lobby.seat_index(session.player_id) is not None:
lobby.add_human(session) # re-binds the seat to this session
lobby.broadcast()
else:
session.lobby_code = None
self._send_no_lobby(session)
return session
def _dispatch(self, session: Session, message: object) -> None:
match message:
case protocol.CreateLobby(name=name):
self._leave_current_lobby(session)
session.name = name
lobby = self.lobbies.create()
self._join(session, lobby)
case protocol.JoinLobby(code=code, name=name):
lobby = self.lobbies.get(code)
if lobby is None:
raise LobbyError(f"Lobby {code} does not exist")
if session.lobby_code != lobby.code:
self._leave_current_lobby(session)
session.name = name
self._join(session, lobby)
case protocol.LeaveLobby():
self._leave_current_lobby(session)
self._send_no_lobby(session)
case protocol.AddAI():
lobby = self._lobby_of(session)
lobby.add_ai(session)
lobby.broadcast()
case protocol.RemovePlayer(player_id=player_id):
lobby = self._lobby_of(session)
seat = lobby.remove_player(session, player_id)
if seat.session is not None:
seat.session.lobby_code = None
seat.session.send(protocol.error("You were removed from the lobby"))
self._send_no_lobby(seat.session)
lobby.broadcast()
case protocol.SetOptions(not_equal=not_equal, hidden_bids=hidden_bids):
lobby = self._lobby_of(session)
lobby.set_options(session, GameOptions(not_equal=not_equal, hidden_bids=hidden_bids))
lobby.broadcast()
case protocol.StartGame():
lobby = self._lobby_of(session)
lobby.start(session)
lobby.broadcast()
case protocol.ReturnToLobby():
lobby = self._lobby_of(session)
lobby.return_to_lobby(session)
lobby.broadcast()
case protocol.GameAction(action=action):
self._lobby_of(session).apply_action(session, action)
case _:
raise LobbyError("Unsupported message")
def _join(self, session: Session, lobby: Lobby) -> None:
try:
lobby.add_human(session)
except LobbyError:
self.lobbies.discard_if_abandoned(lobby)
raise
session.lobby_code = lobby.code
lobby.broadcast()
def _leave_current_lobby(self, session: Session) -> None:
lobby = self.lobbies.get(session.lobby_code)
session.lobby_code = None
if lobby is None:
return
lobby.leave(session)
if not self.lobbies.discard_if_abandoned(lobby):
lobby.broadcast()
def _lobby_of(self, session: Session) -> Lobby:
lobby = self.lobbies.get(session.lobby_code)
if lobby is None:
raise LobbyError("You are not in a lobby")
return lobby
@staticmethod
def _send_no_lobby(session: Session) -> None:
session.send({"type": "state", "lobby": None, "game": None, "events": []})
# --- AI service ---------------------------------------------------------
async def handle_ai(self, websocket: WebSocket) -> None:
await websocket.accept()
try:
await self.bridge.serve(websocket)
finally:
with contextlib.suppress(Exception):
await websocket.close()
def _on_ai_availability_changed(self) -> None:
# Lobbies show whether the AI service is available.
for lobby in self.lobbies:
lobby.broadcast()
# --- Housekeeping -------------------------------------------------------
async def cleanup_loop(self) -> None:
while True:
await asyncio.sleep(CLEANUP_INTERVAL)
try:
self.cleanup()
except Exception:
logger.exception("Cleanup failed")
def cleanup(self) -> None:
for session in self.sessions.expired(self.settings.session_ttl):
self._leave_current_lobby(session)
self.sessions.remove(session)
def _validation_message(exc: ValidationError) -> str:
errors = exc.errors()
if not errors:
return "Invalid message"
first = errors[0]
location = ".".join(str(part) for part in first.get("loc", ()) if part)
return f"Invalid message: {location} {first.get('msg', '')}".strip()
+93
View File
@@ -0,0 +1,93 @@
"""Player sessions and their websocket connections.
A ``Session`` identifies a human player across reconnects (e.g. a page
reload) via a secret token. A ``Connection`` wraps one websocket and owns an
outgoing message queue so that messages are sent in order and never
concurrently.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import secrets
import time
import uuid
from typing import Any
from starlette.websockets import WebSocket
logger = logging.getLogger(__name__)
class Connection:
def __init__(self, websocket: WebSocket) -> None:
self.websocket = websocket
self._queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue()
self._writer: asyncio.Task | None = None
def start(self) -> None:
self._writer = asyncio.create_task(self._write_loop())
def send(self, message: dict[str, Any]) -> None:
self._queue.put_nowait(message)
async def close(self, code: int = 1000) -> None:
self._queue.put_nowait(None)
if self._writer:
with contextlib.suppress(Exception):
await self._writer
with contextlib.suppress(Exception):
await self.websocket.close(code)
async def _write_loop(self) -> None:
while (message := await self._queue.get()) is not None:
try:
await self.websocket.send_json(message)
except Exception: # connection is gone; the reader loop cleans up
logger.debug("Failed to send message", exc_info=True)
return
class Session:
def __init__(self) -> None:
self.player_id = uuid.uuid4().hex[:12]
self.token = secrets.token_urlsafe(24)
self.name: str | None = None
self.lobby_code: str | None = None
self.connection: Connection | None = None
self.last_seen = time.monotonic()
@property
def connected(self) -> bool:
return self.connection is not None
def send(self, message: dict[str, Any]) -> None:
if self.connection:
self.connection.send(message)
class SessionRegistry:
def __init__(self) -> None:
self._by_token: dict[str, Session] = {}
def create(self) -> Session:
session = Session()
self._by_token[session.token] = session
return session
def get(self, token: str | None) -> Session | None:
if not token:
return None
return self._by_token.get(token)
def remove(self, session: Session) -> None:
self._by_token.pop(session.token, None)
def expired(self, ttl: float) -> list[Session]:
now = time.monotonic()
return [s for s in self._by_token.values() if not s.connected and now - s.last_seen > ttl]
def __len__(self) -> int:
return len(self._by_token)