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
+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()