Files
marvinandClaude Opus 5.5 87dfcd55b7 Add resource limits, abuse protection and overload handling
Running games always continue; new work is refused when there is no
capacity; abuse is rejected immediately.

- Docker: CPU/memory/pids caps per container (overridable in .env);
  uvicorn message size, queue and concurrency limits
- Server abuse limits: connections total/per IP (close 1013), message
  rate per connection (token bucket, close 1008 on floods), lobby
  creation rate per IP
- Load monitor: event loop lag, CPU of the own cgroup quota and
  throttling, with hysteresis (normal/busy/overloaded, severe lag
  overloads immediately)
- Admission control: create_lobby and start_game refused while
  overloaded or MAX_ACTIVE_GAMES are running; server_status message
  broadcast on changes; /health reports load but stays ok
- AI bridge: timeout sent with requests, in-flight cap, circuit breaker
- AI service: bounded workers and queue ("overloaded"), drops expired
  requests, degrades to the heuristic agent when busy or too slow
- Website: busy notices, Create/Start disabled when not accepting
  games, slow retry when the server is full
- Tests for limiters, load levels, admission control and AI service
  limits; docs updated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:51:46 +02:00

364 lines
13 KiB
Python

"""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 Callable, 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 .protocol import ERROR_BUSY
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."""
def __init__(self, message: str, code: str | None = None) -> None:
super().__init__(message)
# Optional machine readable code, see ``protocol.ERROR_*``.
self.code = code
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,
on_status_change: Callable[[], None] | None = None,
) -> None:
self.code = code
# Called when a game starts or ends (the number of running games changes).
self._on_status_change = on_status_change
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._notify_status_change()
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()
if self.status is LobbyStatus.PLAYING:
self.status = LobbyStatus.FINISHED
self._notify_status_change()
# --- 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
finished = any(isinstance(e, GameFinished) for e in events)
if finished:
self.status = LobbyStatus.FINISHED
logger.info("Lobby %s: game finished", self.code)
self.broadcast(events)
if finished:
self._notify_status_change()
self._schedule_ai()
def _notify_status_change(self) -> None:
if self._on_status_change is not None:
self._on_status_change()
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,
on_games_changed: Callable[[], None] | None = None,
) -> None:
self._bridge = bridge
self._settings = settings
self._on_games_changed = on_games_changed
self._lobbies: dict[str, Lobby] = {}
def __len__(self) -> int:
return len(self._lobbies)
def __iter__(self):
return iter(list(self._lobbies.values()))
def active_games(self) -> int:
return sum(lobby.status is LobbyStatus.PLAYING for lobby in 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", ERROR_BUSY)
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._on_games_changed)
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