"""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 # Client IP; behind the reverse proxy uvicorn takes it from X-Forwarded-For. self.ip = websocket.client.host if websocket.client else "unknown" 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 connected(self) -> list[Session]: return [s for s in self._by_token.values() if s.connected] 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)