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
+7
View File
@@ -0,0 +1,7 @@
**/__pycache__
**/*.pyc
**/*.egg-info
**/.pytest_cache
.venv
tests
game_logic/tests
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
*.egg-info/
.venv/
.pytest_cache/
build/
+26
View File
@@ -0,0 +1,26 @@
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /srv
COPY requirements.txt .
RUN pip install -r requirements.txt
# The game logic is a separate package so the AI player can use it as well.
COPY game_logic ./game_logic
RUN pip install ./game_logic
COPY app ./app
RUN useradd --system --no-create-home wizard
USER wizard
EXPOSE 8083
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8083/api/health', timeout=2)"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8083", "--proxy-headers", "--forwarded-allow-ips", "*"]
+20
View File
@@ -0,0 +1,20 @@
# wizard_server
FastAPI game server. It manages sessions, lobbies and games, and talks to the AI service.
- `game_logic/` — the standalone rules engine (`pip install ./game_logic`, see its README).
- `app/main.py` — routes: `GET /api/health`, `WS /api/ws` (browsers), `WS /internal/ai` (AI service)
- `app/protocol.py` — client message schema
- `app/service.py` — dispatches client messages, reconnects sessions, cleanup
- `app/lobby.py` — lobbies, seats, host actions, running games and AI turns
- `app/ai_bridge.py` — request/response protocol with the AI service, random-move fallback
- `app/sessions.py` — sessions (token based, survive reloads) and ordered websocket sending
Configuration (environment): `AI_SHARED_SECRET`, `AI_DECISION_TIMEOUT` (5s), `AI_MOVE_DELAY` (0.8s),
`TRICK_PAUSE` (1.8s), `SESSION_TTL` (1800s), `MAX_LOBBIES` (1000), `ALLOWED_ORIGINS`, `LOG_LEVEL`.
```bash
pip install -r requirements-dev.txt
uvicorn app.main:app --reload
pytest
```
+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)
+5
View File
@@ -0,0 +1,5 @@
**/__pycache__
**/*.pyc
**/*.egg-info
.pytest_cache
build
+26
View File
@@ -0,0 +1,26 @@
# wizard_game
Pure-Python rules engine of Wizard (no dependencies). It is used by the game
server and can be installed on its own to train AI players:
```bash
pip install ./wizard_server/game_logic
```
```python
import random
from wizard_game import WizardGame, GameOptions
game = WizardGame(num_players=4, options=GameOptions(not_equal=True), seed=1)
rng = random.Random(1)
while not game.is_finished:
player = game.current_player
obs = game.observation(player) # what this player may see
action = rng.choice(obs.legal_actions) # Bid / PlayCard / ChooseTrump
events = game.apply(player, action) # TrickCompleted, RoundCompleted, ...
print(game.scores, game.winners())
```
`Observation.to_dict()` / `Observation.from_dict()` and
`action_to_dict()` / `action_from_dict()` define the JSON format that the
server sends to web clients and to the AI service.
+16
View File
@@ -0,0 +1,16 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "wizard-game"
version = "0.1.0"
description = "Rules engine of the Wizard card game, shared by the game server and the AI player."
requires-python = ">=3.11"
dependencies = []
[tool.setuptools]
packages = ["wizard_game"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+158
View File
@@ -0,0 +1,158 @@
import json
import random
import pytest
from wizard_game import (
Bid,
ChooseTrump,
GameFinished,
GameOptions,
IllegalActionError,
Observation,
Phase,
PlayCard,
RoundCompleted,
Suit,
WizardGame,
action_from_dict,
card_by_id,
)
from wizard_game.cards import WIZARD_ID_OFFSET
def play_random_game(num_players: int, seed: int, options: GameOptions | None = None) -> WizardGame:
game = WizardGame(num_players, options, seed=seed)
rng = random.Random(seed)
rounds_completed = 0
while not game.is_finished:
player = game.current_player
actions = game.legal_actions()
assert actions, "current player must always have a legal action"
events = game.apply(player, rng.choice(actions))
rounds_completed += sum(isinstance(e, RoundCompleted) for e in events)
assert rounds_completed == game.total_rounds
return game
@pytest.mark.parametrize("num_players", [3, 4, 5, 6])
@pytest.mark.parametrize("seed", range(5))
def test_random_games_complete(num_players, seed):
options = GameOptions(not_equal=seed % 2 == 0, hidden_bids=seed % 3 == 0)
game = play_random_game(num_players, seed, options)
assert game.phase is Phase.FINISHED
assert len(game.round_history) == game.total_rounds
assert game.scores == list(game.round_history[-1].scores)
for result in game.round_history:
assert sum(result.tricks) == result.round_number
if options.not_equal:
assert sum(result.bids) != result.round_number
def test_invalid_player_count():
with pytest.raises(ValueError):
WizardGame(2)
with pytest.raises(ValueError):
WizardGame(7)
def test_deal_sizes_and_starter():
game = WizardGame(4, seed=3, first_dealer=2)
assert game.round_number == 1
assert all(len(game.hand(p)) == 1 for p in range(4))
if game.phase is Phase.BIDDING:
assert game.current_player == 3
def test_wrong_player_rejected():
game = WizardGame(3, seed=1, first_dealer=0)
other = (game.current_player + 1) % 3
with pytest.raises(IllegalActionError):
game.apply(other, game.legal_actions()[0])
def test_illegal_bid_rejected():
game = WizardGame(3, seed=1, first_dealer=0)
if game.phase is Phase.TRUMP_SELECTION:
game.apply(game.current_player, ChooseTrump(Suit.RED))
with pytest.raises(IllegalActionError):
game.apply(game.current_player, Bid(5))
def test_wizard_trump_card_lets_dealer_choose():
for seed in range(200):
game = WizardGame(4, seed=seed, first_dealer=1)
if game.round.trump_card and game.round.trump_card.is_wizard:
break
else:
pytest.fail("no seed produced a wizard as trump card")
assert game.phase is Phase.TRUMP_SELECTION
assert game.current_player == 1
game.apply(1, ChooseTrump(Suit.GREEN))
assert game.round.trump_suit is Suit.GREEN
assert game.phase is Phase.BIDDING
assert game.current_player == 2
def test_not_equal_restricts_last_bidder():
game = WizardGame(3, GameOptions(not_equal=True), seed=11, first_dealer=0)
if game.phase is Phase.TRUMP_SELECTION:
game.apply(game.current_player, ChooseTrump(Suit.RED))
game.apply(1, Bid(0))
game.apply(2, Bid(0))
assert game.legal_actions(0) == [Bid(0)] # bidding 1 would make the sum equal 1
def test_hidden_bids():
game = WizardGame(3, GameOptions(hidden_bids=True), seed=5, first_dealer=0)
if game.phase is Phase.TRUMP_SELECTION:
game.apply(game.current_player, ChooseTrump(Suit.RED))
game.apply(1, Bid(1))
assert game.observation(1).bids[1] == 1
assert game.observation(2).bids[1] is None
assert game.observation(2).has_bid[1] is True
game.apply(2, Bid(0))
game.apply(0, Bid(0))
assert game.observation(2).bids == (0, 1, 0)
def test_last_round_has_no_trump():
game = play_random_game(3, seed=7)
assert game.round.round_number == 20
assert game.round.trump_card is None
def test_observation_hides_other_hands_and_round_trips():
game = WizardGame(5, seed=9)
obs = game.observation(0)
assert obs.hand == tuple(sorted(game.hand(0), key=lambda c: c.id))
data = json.loads(json.dumps(obs.to_dict()))
assert Observation.from_dict(data) == obs
def test_action_round_trip():
for action in (Bid(3), PlayCard(card_by_id(WIZARD_ID_OFFSET)), ChooseTrump(Suit.BLUE)):
assert action_from_dict(action.to_dict()) == action
with pytest.raises(ValueError):
action_from_dict({"type": "bid", "value": True})
with pytest.raises(ValueError):
action_from_dict({"type": "play", "card": 99})
def test_game_finished_event_and_winners():
game = WizardGame(3, seed=2)
rng = random.Random(2)
last_events = []
while not game.is_finished:
last_events = game.apply(game.current_player, rng.choice(game.legal_actions()))
finished = [e for e in last_events if isinstance(e, GameFinished)]
assert finished and list(finished[0].winners) == game.winners()
assert game.observation(0).winners == tuple(game.winners())
assert game.legal_actions() == []
def test_clone_is_independent():
game = WizardGame(4, seed=4)
clone = game.clone()
clone.apply(clone.current_player, clone.legal_actions()[0])
assert game.observation(0) != clone.observation(0) or game.current_player != clone.current_player
@@ -0,0 +1,79 @@
from wizard_game import Suit, card_by_id
from wizard_game.cards import JESTER_ID_OFFSET, WIZARD_ID_OFFSET
from wizard_game.rules import lead_suit, legal_cards, round_score, total_rounds, trick_winner
def num(suit: Suit, rank: int):
return card_by_id(suit.index * 13 + rank - 1)
WIZ1 = card_by_id(WIZARD_ID_OFFSET)
WIZ2 = card_by_id(WIZARD_ID_OFFSET + 1)
JES1 = card_by_id(JESTER_ID_OFFSET)
JES2 = card_by_id(JESTER_ID_OFFSET + 1)
def test_total_rounds():
assert [total_rounds(n) for n in (3, 4, 5, 6)] == [20, 15, 12, 10]
def test_card_ids_are_consistent():
card = num(Suit.GREEN, 7)
assert card.suit is Suit.GREEN and card.rank == 7
assert WIZ1.is_wizard and JES1.is_jester
def test_lead_suit():
assert lead_suit([]) is None
assert lead_suit([num(Suit.RED, 3), num(Suit.BLUE, 5)]) is Suit.RED
assert lead_suit([JES1, num(Suit.BLUE, 5)]) is Suit.BLUE
assert lead_suit([JES1, JES2]) is None
assert lead_suit([WIZ1, num(Suit.BLUE, 5)]) is None
assert lead_suit([JES1, WIZ1, num(Suit.BLUE, 5)]) is None
def test_must_follow_suit():
hand = [num(Suit.RED, 1), num(Suit.BLUE, 2), WIZ1, JES1]
assert set(legal_cards(hand, [num(Suit.RED, 9)])) == {num(Suit.RED, 1), WIZ1, JES1}
def test_free_play_without_lead_suit_cards():
hand = [num(Suit.GREEN, 1), num(Suit.BLUE, 2), JES1]
assert set(legal_cards(hand, [num(Suit.RED, 9)])) == set(hand)
def test_free_play_after_wizard_lead():
hand = [num(Suit.RED, 1), num(Suit.BLUE, 2)]
assert set(legal_cards(hand, [WIZ1])) == set(hand)
def test_first_wizard_wins():
trick = [num(Suit.RED, 13), WIZ1, WIZ2]
assert trick_winner(trick, Suit.RED) == 1
def test_trump_beats_lead_suit():
trick = [num(Suit.RED, 13), num(Suit.BLUE, 2), num(Suit.BLUE, 5)]
assert trick_winner(trick, Suit.BLUE) == 2
def test_highest_lead_suit_wins_without_trump():
trick = [num(Suit.RED, 3), num(Suit.GREEN, 13), num(Suit.RED, 10)]
assert trick_winner(trick, None) == 2
assert trick_winner(trick, Suit.YELLOW) == 2
def test_jester_lead_then_suit():
trick = [JES1, num(Suit.GREEN, 2), num(Suit.RED, 13)]
assert trick_winner(trick, None) == 1
def test_only_jesters_first_wins():
assert trick_winner([JES1, JES2], Suit.RED) == 0
def test_round_score():
assert round_score(0, 0) == 20
assert round_score(3, 3) == 50
assert round_score(2, 0) == -20
assert round_score(1, 4) == -30
@@ -0,0 +1,55 @@
"""Rules engine of the Wizard card game.
This package is independent of the game server so that it can also be used
to train AI players.
"""
from .actions import Action, Bid, ChooseTrump, PlayCard, action_from_dict, action_to_dict
from .cards import ALL_CARDS, DECK_SIZE, SUITS, Card, CardKind, Suit, card_by_id
from .game import IllegalActionError, WizardGame
from .observation import Observation
from .rules import MAX_PLAYERS, MIN_PLAYERS, total_rounds
from .state import (
Event,
GameFinished,
GameOptions,
Phase,
PlayedCard,
RoundCompleted,
RoundResult,
RoundStarted,
TrickCompleted,
TrickResult,
)
__all__ = [
"ALL_CARDS",
"DECK_SIZE",
"MAX_PLAYERS",
"MIN_PLAYERS",
"SUITS",
"Action",
"Bid",
"Card",
"CardKind",
"ChooseTrump",
"Event",
"GameFinished",
"GameOptions",
"IllegalActionError",
"Observation",
"Phase",
"PlayCard",
"PlayedCard",
"RoundCompleted",
"RoundResult",
"RoundStarted",
"Suit",
"TrickCompleted",
"TrickResult",
"WizardGame",
"action_from_dict",
"action_to_dict",
"card_by_id",
"total_rounds",
]
@@ -0,0 +1,73 @@
"""Actions a player can take and their (de)serialization.
Actions are serialized as small JSON objects which are shared by the web
frontend, the game server and the AI player:
- ``{"type": "bid", "value": 2}``
- ``{"type": "play", "card": 17}``
- ``{"type": "trump", "suit": "red"}``
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .cards import Card, Suit, card_by_id
@dataclass(frozen=True, slots=True)
class Bid:
value: int
def to_dict(self) -> dict:
return {"type": "bid", "value": self.value}
@dataclass(frozen=True, slots=True)
class PlayCard:
card: Card
def to_dict(self) -> dict:
return {"type": "play", "card": self.card.id}
@dataclass(frozen=True, slots=True)
class ChooseTrump:
suit: Suit
def to_dict(self) -> dict:
return {"type": "trump", "suit": self.suit.value}
Action = Bid | PlayCard | ChooseTrump
def action_to_dict(action: Action) -> dict:
return action.to_dict()
def action_from_dict(data: dict[str, Any]) -> Action:
"""Parse an action from its JSON representation.
Raises ``ValueError`` if the data is malformed.
"""
if not isinstance(data, dict):
raise ValueError("Action must be an object")
kind = data.get("type")
try:
if kind == "bid":
value = data["value"]
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError("Bid value must be an integer")
return Bid(value)
if kind == "play":
card_id = data["card"]
if not isinstance(card_id, int) or isinstance(card_id, bool):
raise ValueError("Card must be an integer id")
return PlayCard(card_by_id(card_id))
if kind == "trump":
return ChooseTrump(Suit(data["suit"]))
except KeyError as exc:
raise ValueError(f"Missing field {exc} in action") from exc
raise ValueError(f"Unknown action type: {kind!r}")
@@ -0,0 +1,115 @@
"""Cards and deck of the Wizard card game.
The deck consists of 60 cards:
- 52 number cards: four suits (red, yellow, green, blue) with ranks 1-13
- 4 wizards (highest cards)
- 4 jesters (lowest cards)
Every card has a stable integer id in the range 0-59 which is used for
serialization and as an index for machine learning encodings:
- 0-51: number cards, ``suit_index * 13 + (rank - 1)``
- 52-55: wizards
- 56-59: jesters
"""
from __future__ import annotations
import random
from dataclasses import dataclass
from enum import Enum
class Suit(str, Enum):
RED = "red"
YELLOW = "yellow"
GREEN = "green"
BLUE = "blue"
@property
def index(self) -> int:
return SUITS.index(self)
SUITS: tuple[Suit, ...] = (Suit.RED, Suit.YELLOW, Suit.GREEN, Suit.BLUE)
class CardKind(str, Enum):
NUMBER = "number"
WIZARD = "wizard"
JESTER = "jester"
NUM_RANKS = 13
NUM_NUMBER_CARDS = len(SUITS) * NUM_RANKS
NUM_WIZARDS = 4
NUM_JESTERS = 4
DECK_SIZE = NUM_NUMBER_CARDS + NUM_WIZARDS + NUM_JESTERS
WIZARD_ID_OFFSET = NUM_NUMBER_CARDS
JESTER_ID_OFFSET = NUM_NUMBER_CARDS + NUM_WIZARDS
@dataclass(frozen=True, slots=True)
class Card:
id: int
kind: CardKind
suit: Suit | None = None
rank: int = 0
@property
def is_wizard(self) -> bool:
return self.kind is CardKind.WIZARD
@property
def is_jester(self) -> bool:
return self.kind is CardKind.JESTER
@property
def is_number(self) -> bool:
return self.kind is CardKind.NUMBER
def to_dict(self) -> dict:
return {
"id": self.id,
"kind": self.kind.value,
"suit": self.suit.value if self.suit else None,
"rank": self.rank,
}
def __str__(self) -> str:
if self.is_wizard:
return f"Wizard{self.id - WIZARD_ID_OFFSET + 1}"
if self.is_jester:
return f"Jester{self.id - JESTER_ID_OFFSET + 1}"
assert self.suit is not None
return f"{self.suit.value.capitalize()}{self.rank}"
def __repr__(self) -> str:
return f"Card({self})"
def _build_deck() -> tuple[Card, ...]:
cards: list[Card] = []
for suit in SUITS:
for rank in range(1, NUM_RANKS + 1):
cards.append(Card(len(cards), CardKind.NUMBER, suit, rank))
for _ in range(NUM_WIZARDS):
cards.append(Card(len(cards), CardKind.WIZARD))
for _ in range(NUM_JESTERS):
cards.append(Card(len(cards), CardKind.JESTER))
return tuple(cards)
ALL_CARDS: tuple[Card, ...] = _build_deck()
def card_by_id(card_id: int) -> Card:
if not 0 <= card_id < DECK_SIZE:
raise ValueError(f"Invalid card id: {card_id}")
return ALL_CARDS[card_id]
def new_shuffled_deck(rng: random.Random) -> list[Card]:
deck = list(ALL_CARDS)
rng.shuffle(deck)
return deck
@@ -0,0 +1,300 @@
"""The Wizard game state machine.
``WizardGame`` implements the complete rules of Wizard and has no
dependencies besides the Python standard library, so it can be used by the
game server as well as for (reinforcement learning) training of AI players.
Typical usage::
game = WizardGame(num_players=4, seed=42)
while not game.is_finished:
player = game.current_player
action = choose(game.observation(player)) # any Action from legal_actions
events = game.apply(player, action)
"""
from __future__ import annotations
import copy
import random
from . import rules
from .actions import Action, Bid, ChooseTrump, PlayCard
from .cards import SUITS, Card, new_shuffled_deck
from .observation import Observation
from .state import (
Event,
GameFinished,
GameOptions,
Phase,
PlayedCard,
RoundCompleted,
RoundResult,
RoundStarted,
RoundState,
TrickCompleted,
TrickResult,
)
class IllegalActionError(ValueError):
"""Raised when a player attempts an action the rules do not allow."""
class WizardGame:
def __init__(
self,
num_players: int,
options: GameOptions | None = None,
*,
seed: int | None = None,
rng: random.Random | None = None,
first_dealer: int | None = None,
) -> None:
rules.validate_player_count(num_players)
self.num_players = num_players
self.options = options or GameOptions()
self.total_rounds = rules.total_rounds(num_players)
self._rng = rng or random.Random(seed)
self.scores: list[int] = [0] * num_players
self.round_history: list[RoundResult] = []
self.last_trick: TrickResult | None = None
self.phase: Phase = Phase.BIDDING
self.current_player: int | None = None
if first_dealer is None:
first_dealer = self._rng.randrange(num_players)
elif not 0 <= first_dealer < num_players:
raise ValueError(f"Invalid first dealer: {first_dealer}")
self.round: RoundState = self._deal(1, first_dealer)
# --- Public API ---------------------------------------------------------
@property
def is_finished(self) -> bool:
return self.phase is Phase.FINISHED
@property
def round_number(self) -> int:
return self.round.round_number
@property
def dealer(self) -> int:
return self.round.dealer
def hand(self, player: int) -> list[Card]:
return list(self.round.hands[player])
def winners(self) -> list[int]:
"""Players with the highest score (only meaningful once finished)."""
best = max(self.scores)
return [p for p, score in enumerate(self.scores) if score == best]
def legal_actions(self, player: int | None = None) -> list[Action]:
"""Legal actions of ``player`` (defaults to the current player)."""
if player is None:
player = self.current_player
if player is None or player != self.current_player:
return []
if self.phase is Phase.TRUMP_SELECTION:
return [ChooseTrump(suit) for suit in SUITS]
if self.phase is Phase.BIDDING:
return [Bid(value) for value in self._allowed_bids(player)]
if self.phase is Phase.PLAYING:
trick = [played.card for played in self.round.current_trick]
return [PlayCard(card) for card in rules.legal_cards(self.round.hands[player], trick)]
return []
def is_legal(self, player: int, action: Action) -> bool:
return action in self.legal_actions(player)
def apply(self, player: int, action: Action) -> list[Event]:
"""Perform ``action`` for ``player`` and advance the game.
Returns the events caused by the action. Raises
``IllegalActionError`` if the action is not allowed.
"""
if self.phase is Phase.FINISHED:
raise IllegalActionError("The game is already finished")
if player != self.current_player:
raise IllegalActionError(f"It is not player {player}'s turn")
if not self.is_legal(player, action):
raise IllegalActionError(f"Illegal action {action} for player {player}")
if isinstance(action, ChooseTrump):
return self._choose_trump(action)
if isinstance(action, Bid):
return self._bid(player, action)
if isinstance(action, PlayCard):
return self._play(player, action)
raise IllegalActionError(f"Unknown action {action!r}") # pragma: no cover
def observation(self, player: int) -> Observation:
"""Everything ``player`` is allowed to know about the game."""
rnd = self.round
all_bids_placed = all(bid is not None for bid in rnd.bids)
reveal_bids = not self.options.hidden_bids or all_bids_placed
bids = tuple(
bid if reveal_bids or p == player else None
for p, bid in enumerate(rnd.bids)
)
trick = [played.card for played in rnd.current_trick]
return Observation(
player=player,
num_players=self.num_players,
options=self.options,
phase=self.phase,
round_number=rnd.round_number,
total_rounds=self.total_rounds,
dealer=rnd.dealer,
current_player=self.current_player,
hand=tuple(sorted(rnd.hands[player], key=lambda card: card.id)),
hand_sizes=tuple(len(hand) for hand in rnd.hands),
trump_card=rnd.trump_card,
trump_suit=rnd.trump_suit,
bids=bids,
has_bid=tuple(bid is not None for bid in rnd.bids),
tricks_won=tuple(rnd.tricks_won),
scores=tuple(self.scores),
trick_number=rnd.trick_number,
trick_leader=rnd.trick_leader,
current_trick=tuple(rnd.current_trick),
lead_suit=rules.lead_suit(trick),
played_cards=tuple(rnd.played_cards),
last_trick=self.last_trick,
round_history=tuple(self.round_history),
legal_actions=tuple(self.legal_actions(player)),
winners=tuple(self.winners()) if self.is_finished else (),
)
def clone(self) -> WizardGame:
"""Deep copy of the game, e.g. for search based AI players."""
return copy.deepcopy(self)
# --- Internals ----------------------------------------------------------
def _next(self, player: int) -> int:
return (player + 1) % self.num_players
def _deal(self, round_number: int, dealer: int) -> RoundState:
deck = new_shuffled_deck(self._rng)
hands: list[list[Card]] = [[] for _ in range(self.num_players)]
# Deal one card at a time starting left of the dealer.
for i in range(round_number * self.num_players):
hands[(dealer + 1 + i) % self.num_players].append(deck[i])
remaining = deck[round_number * self.num_players:]
# The next card defines the trump suit. In the last round no cards
# are left, so there is no trump.
trump_card = remaining[0] if remaining else None
starter = self._next(dealer)
rnd = RoundState(
round_number=round_number,
dealer=dealer,
hands=hands,
trump_card=trump_card,
trump_suit=trump_card.suit if trump_card and trump_card.is_number else None,
bids=[None] * self.num_players,
tricks_won=[0] * self.num_players,
trick_leader=starter,
)
if trump_card is not None and trump_card.is_wizard:
# A wizard as trump card: the dealer chooses the trump suit.
self.phase = Phase.TRUMP_SELECTION
self.current_player = dealer
else:
# No trump card or a jester: the round is played without trump.
self.phase = Phase.BIDDING
self.current_player = starter
return rnd
def _allowed_bids(self, player: int) -> list[int]:
rnd = self.round
allowed = list(range(rnd.round_number + 1))
placed = [bid for bid in rnd.bids if bid is not None]
is_last_bidder = len(placed) == self.num_players - 1 and rnd.bids[player] is None
if self.options.not_equal and is_last_bidder:
forbidden = rnd.round_number - sum(placed)
allowed = [bid for bid in allowed if bid != forbidden]
return allowed
def _choose_trump(self, action: ChooseTrump) -> list[Event]:
self.round.trump_suit = action.suit
self.phase = Phase.BIDDING
self.current_player = self._next(self.round.dealer)
return []
def _bid(self, player: int, action: Bid) -> list[Event]:
rnd = self.round
rnd.bids[player] = action.value
if all(bid is not None for bid in rnd.bids):
self.phase = Phase.PLAYING
self.current_player = rnd.trick_leader
else:
self.current_player = self._next(player)
return []
def _play(self, player: int, action: PlayCard) -> list[Event]:
rnd = self.round
rnd.hands[player].remove(action.card)
rnd.current_trick.append(PlayedCard(player, action.card))
rnd.played_cards.append(action.card)
if len(rnd.current_trick) < self.num_players:
self.current_player = self._next(player)
return []
return self._complete_trick()
def _complete_trick(self) -> list[Event]:
rnd = self.round
cards = [played.card for played in rnd.current_trick]
winner = rnd.current_trick[rules.trick_winner(cards, rnd.trump_suit)].player
rnd.tricks_won[winner] += 1
result = TrickResult(rnd.round_number, rnd.trick_number, tuple(rnd.current_trick), winner)
self.last_trick = result
events: list[Event] = [TrickCompleted(result)]
rnd.current_trick = []
if rnd.trick_number < rnd.round_number:
rnd.trick_number += 1
rnd.trick_leader = winner
self.current_player = winner
return events
events.extend(self._complete_round())
return events
def _complete_round(self) -> list[Event]:
rnd = self.round
bids = [bid if bid is not None else 0 for bid in rnd.bids]
changes = [rules.round_score(bid, tricks) for bid, tricks in zip(bids, rnd.tricks_won)]
self.scores = [score + change for score, change in zip(self.scores, changes)]
result = RoundResult(
round_number=rnd.round_number,
bids=tuple(bids),
tricks=tuple(rnd.tricks_won),
score_changes=tuple(changes),
scores=tuple(self.scores),
)
self.round_history.append(result)
events: list[Event] = [RoundCompleted(result)]
if rnd.round_number >= self.total_rounds:
self.phase = Phase.FINISHED
self.current_player = None
events.append(GameFinished(tuple(self.scores), tuple(self.winners())))
return events
self.round = self._deal(rnd.round_number + 1, self._next(rnd.dealer))
events.append(RoundStarted(self.round.round_number, self.round.dealer, self.round.trump_card))
return events
@@ -0,0 +1,120 @@
"""The view of the game a single player is allowed to see.
An ``Observation`` contains everything a player knows at a point in time and
nothing more (no opponent hands, hidden bids stay hidden). It is used
- by the server to send the game state to a web client, and
- as the input of AI players, both in training and in production.
``to_dict`` / ``from_dict`` provide a lossless JSON representation.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .actions import Action, action_from_dict
from .cards import Card, Suit, card_by_id
from .state import GameOptions, Phase, PlayedCard, RoundResult, TrickResult
@dataclass(frozen=True, slots=True)
class Observation:
player: int
num_players: int
options: GameOptions
phase: Phase
round_number: int
total_rounds: int
dealer: int
current_player: int | None
hand: tuple[Card, ...]
hand_sizes: tuple[int, ...]
trump_card: Card | None
trump_suit: Suit | None
# ``None`` for players who have not bid yet, or whose bid is still hidden.
bids: tuple[int | None, ...]
has_bid: tuple[bool, ...]
tricks_won: tuple[int, ...]
scores: tuple[int, ...]
trick_number: int
trick_leader: int
current_trick: tuple[PlayedCard, ...]
lead_suit: Suit | None
# All cards played in the current round, including the current trick.
played_cards: tuple[Card, ...]
last_trick: TrickResult | None
round_history: tuple[RoundResult, ...]
# Empty unless it is this player's turn.
legal_actions: tuple[Action, ...]
winners: tuple[int, ...]
@property
def is_my_turn(self) -> bool:
return self.current_player == self.player
def to_dict(self) -> dict:
return {
"player": self.player,
"num_players": self.num_players,
"options": self.options.to_dict(),
"phase": self.phase.value,
"round_number": self.round_number,
"total_rounds": self.total_rounds,
"dealer": self.dealer,
"current_player": self.current_player,
"hand": [card.to_dict() for card in self.hand],
"hand_sizes": list(self.hand_sizes),
"trump_card": self.trump_card.to_dict() if self.trump_card else None,
"trump_suit": self.trump_suit.value if self.trump_suit else None,
"bids": list(self.bids),
"has_bid": list(self.has_bid),
"tricks_won": list(self.tricks_won),
"scores": list(self.scores),
"trick_number": self.trick_number,
"trick_leader": self.trick_leader,
"current_trick": [played.to_dict() for played in self.current_trick],
"lead_suit": self.lead_suit.value if self.lead_suit else None,
"played_cards": [card.to_dict() for card in self.played_cards],
"last_trick": self.last_trick.to_dict() if self.last_trick else None,
"round_history": [result.to_dict() for result in self.round_history],
"legal_actions": [action.to_dict() for action in self.legal_actions],
"winners": list(self.winners),
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Observation:
def card(value: dict | None) -> Card | None:
return card_by_id(value["id"]) if value else None
def suit(value: str | None) -> Suit | None:
return Suit(value) if value else None
return cls(
player=data["player"],
num_players=data["num_players"],
options=GameOptions.from_dict(data["options"]),
phase=Phase(data["phase"]),
round_number=data["round_number"],
total_rounds=data["total_rounds"],
dealer=data["dealer"],
current_player=data["current_player"],
hand=tuple(card_by_id(c["id"]) for c in data["hand"]),
hand_sizes=tuple(data["hand_sizes"]),
trump_card=card(data["trump_card"]),
trump_suit=suit(data["trump_suit"]),
bids=tuple(data["bids"]),
has_bid=tuple(data["has_bid"]),
tricks_won=tuple(data["tricks_won"]),
scores=tuple(data["scores"]),
trick_number=data["trick_number"],
trick_leader=data["trick_leader"],
current_trick=tuple(PlayedCard.from_dict(p) for p in data["current_trick"]),
lead_suit=suit(data["lead_suit"]),
played_cards=tuple(card_by_id(c["id"]) for c in data["played_cards"]),
last_trick=TrickResult.from_dict(data["last_trick"]) if data["last_trick"] else None,
round_history=tuple(RoundResult.from_dict(r) for r in data["round_history"]),
legal_actions=tuple(action_from_dict(a) for a in data["legal_actions"]),
winners=tuple(data["winners"]),
)
@@ -0,0 +1,95 @@
"""Stateless rule helpers of the Wizard card game."""
from __future__ import annotations
from collections.abc import Sequence
from .cards import DECK_SIZE, Card, Suit
MIN_PLAYERS = 3
MAX_PLAYERS = 6
# Points for a correct prediction and per trick (correct) / per trick off (wrong).
CORRECT_BID_BONUS = 20
POINTS_PER_TRICK = 10
PENALTY_PER_TRICK_OFF = 10
def total_rounds(num_players: int) -> int:
"""Number of rounds played: 3/4/5/6 players play 20/15/12/10 rounds."""
validate_player_count(num_players)
return DECK_SIZE // num_players
def validate_player_count(num_players: int) -> None:
if not MIN_PLAYERS <= num_players <= MAX_PLAYERS:
raise ValueError(
f"Wizard is played with {MIN_PLAYERS}-{MAX_PLAYERS} players, got {num_players}"
)
def lead_suit(trick: Sequence[Card]) -> Suit | None:
"""The suit that has to be followed in a trick.
The first number card determines the suit. Jesters played before it are
skipped. If a wizard is played before any number card, the trick has no
suit to follow.
"""
for card in trick:
if card.is_jester:
continue
if card.is_wizard:
return None
return card.suit
return None
def legal_cards(hand: Sequence[Card], trick: Sequence[Card]) -> list[Card]:
"""Cards of ``hand`` that may be played onto ``trick``.
Wizards and jesters may always be played. Number cards must follow the
lead suit if the player holds a number card of that suit.
"""
suit = lead_suit(trick)
if suit is None:
return list(hand)
if not any(card.is_number and card.suit is suit for card in hand):
return list(hand)
return [card for card in hand if not card.is_number or card.suit is suit]
def trick_winner(trick: Sequence[Card], trump: Suit | None) -> int:
"""Index (within ``trick``) of the card that wins the trick.
- the first wizard wins
- otherwise the highest trump wins
- otherwise the highest card of the lead suit wins
- if only jesters were played, the first jester wins
"""
if not trick:
raise ValueError("Cannot determine the winner of an empty trick")
for index, card in enumerate(trick):
if card.is_wizard:
return index
if trump is not None:
trumps = [(card.rank, index) for index, card in enumerate(trick)
if card.is_number and card.suit is trump]
if trumps:
return max(trumps)[1]
suit = lead_suit(trick)
if suit is not None:
followers = [(card.rank, index) for index, card in enumerate(trick)
if card.is_number and card.suit is suit]
return max(followers)[1]
return 0
def round_score(bid: int, tricks: int) -> int:
"""Score change of a player at the end of a round."""
if bid == tricks:
return CORRECT_BID_BONUS + POINTS_PER_TRICK * tricks
return -PENALTY_PER_TRICK_OFF * abs(bid - tricks)
@@ -0,0 +1,166 @@
"""Value types describing game state, options, results and events."""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from .cards import Card, Suit, card_by_id
class Phase(str, Enum):
TRUMP_SELECTION = "trump_selection"
BIDDING = "bidding"
PLAYING = "playing"
FINISHED = "finished"
@dataclass(frozen=True, slots=True)
class GameOptions:
# "notequal": the sum of all bids must not equal the number of tricks
# of the round. The last bidder has to adjust their bid.
not_equal: bool = False
# "hiddentip": bids are revealed only once every player has bid.
hidden_bids: bool = False
def to_dict(self) -> dict:
return {"not_equal": self.not_equal, "hidden_bids": self.hidden_bids}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> GameOptions:
return cls(
not_equal=bool(data.get("not_equal", False)),
hidden_bids=bool(data.get("hidden_bids", False)),
)
@dataclass(frozen=True, slots=True)
class PlayedCard:
player: int
card: Card
def to_dict(self) -> dict:
return {"player": self.player, "card": self.card.to_dict()}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> PlayedCard:
return cls(player=data["player"], card=card_by_id(data["card"]["id"]))
@dataclass(frozen=True, slots=True)
class TrickResult:
round_number: int
trick_number: int
cards: tuple[PlayedCard, ...]
winner: int
def to_dict(self) -> dict:
return {
"round_number": self.round_number,
"trick_number": self.trick_number,
"cards": [played.to_dict() for played in self.cards],
"winner": self.winner,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> TrickResult:
return cls(
round_number=data["round_number"],
trick_number=data["trick_number"],
cards=tuple(PlayedCard.from_dict(c) for c in data["cards"]),
winner=data["winner"],
)
@dataclass(frozen=True, slots=True)
class RoundResult:
round_number: int
bids: tuple[int, ...]
tricks: tuple[int, ...]
score_changes: tuple[int, ...]
scores: tuple[int, ...]
def to_dict(self) -> dict:
return {
"round_number": self.round_number,
"bids": list(self.bids),
"tricks": list(self.tricks),
"score_changes": list(self.score_changes),
"scores": list(self.scores),
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> RoundResult:
return cls(
round_number=data["round_number"],
bids=tuple(data["bids"]),
tricks=tuple(data["tricks"]),
score_changes=tuple(data["score_changes"]),
scores=tuple(data["scores"]),
)
# --- Events -----------------------------------------------------------------
# ``WizardGame.apply`` returns the events caused by an action. They are handy
# for clients that want to animate what happened (e.g. pause after a trick).
@dataclass(frozen=True, slots=True)
class RoundStarted:
round_number: int
dealer: int
trump_card: Card | None
def to_dict(self) -> dict:
return {
"type": "round_started",
"round_number": self.round_number,
"dealer": self.dealer,
"trump_card": self.trump_card.to_dict() if self.trump_card else None,
}
@dataclass(frozen=True, slots=True)
class TrickCompleted:
trick: TrickResult
def to_dict(self) -> dict:
return {"type": "trick_completed", "trick": self.trick.to_dict()}
@dataclass(frozen=True, slots=True)
class RoundCompleted:
result: RoundResult
def to_dict(self) -> dict:
return {"type": "round_completed", "result": self.result.to_dict()}
@dataclass(frozen=True, slots=True)
class GameFinished:
scores: tuple[int, ...]
winners: tuple[int, ...]
def to_dict(self) -> dict:
return {"type": "game_finished", "scores": list(self.scores), "winners": list(self.winners)}
Event = RoundStarted | TrickCompleted | RoundCompleted | GameFinished
@dataclass(slots=True)
class RoundState:
"""Mutable per-round state, owned by ``WizardGame``."""
round_number: int
dealer: int
hands: list[list[Card]]
trump_card: Card | None
trump_suit: Suit | None
bids: list[int | None]
tricks_won: list[int]
current_trick: list[PlayedCard] = field(default_factory=list)
trick_leader: int = 0
trick_number: int = 1
played_cards: list[Card] = field(default_factory=list)
+2
View File
@@ -0,0 +1,2 @@
[pytest]
testpaths = tests game_logic/tests
+4
View File
@@ -0,0 +1,4 @@
-r requirements.txt
-e ./game_logic
pytest>=8
httpx>=0.27
+2
View File
@@ -0,0 +1,2 @@
fastapi>=0.115,<1
uvicorn[standard]>=0.30,<1
+193
View File
@@ -0,0 +1,193 @@
import contextlib
import random
import threading
import pytest
from fastapi.testclient import TestClient
from app.config import Settings
from app.main import create_app
SECRET = "test-secret"
@pytest.fixture
def client():
settings = Settings(ai_shared_secret=SECRET, ai_move_delay=0, trick_pause=0, ai_decision_timeout=2)
with TestClient(create_app(settings)) as test_client, contextlib.ExitStack() as sockets:
test_client.sockets = sockets # websocket sessions are closed before the app stops
yield test_client
def receive_until(ws, predicate, limit=5000):
for _ in range(limit):
message = ws.receive_json()
if predicate(message):
return message
raise AssertionError("expected message not received")
def is_state(message):
return message["type"] == "state"
def connect(client, token=None):
ws = client.sockets.enter_context(client.websocket_connect("/api/ws"))
ws.send_json({"type": "hello", "token": token})
welcome = receive_until(ws, lambda m: m["type"] == "welcome")
receive_until(ws, is_state)
return ws, welcome
def test_health(client):
response = client.get("/api/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_requires_hello(client):
with client.websocket_connect("/api/ws") as ws:
ws.send_json({"type": "create_lobby", "name": "Ann"})
assert ws.receive_json()["type"] == "error"
def test_invalid_message(client):
ws, _ = connect(client)
ws.send_json({"type": "join_lobby", "code": "ABCDEF", "name": ""})
assert ws.receive_json()["type"] == "error"
def test_lobby_flow_and_full_game(client):
host, host_welcome = connect(client)
host.send_json({"type": "create_lobby", "name": "Ann"})
state = receive_until(host, is_state)
lobby = state["lobby"]
code = lobby["code"]
assert lobby["host_id"] == host_welcome["player_id"]
assert [p["name"] for p in lobby["players"]] == ["Ann"]
guest, _ = connect(client)
guest.send_json({"type": "join_lobby", "code": code.lower(), "name": "Ann"})
state = receive_until(guest, lambda m: is_state(m) and m["lobby"])
assert [p["name"] for p in state["lobby"]["players"]] == ["Ann", "Ann (2)"]
# Only the host may start / add AI players.
guest.send_json({"type": "add_ai"})
assert receive_until(guest, lambda m: m["type"] == "error")
host.send_json({"type": "start_game"})
assert "needs" in receive_until(host, lambda m: m["type"] == "error")["message"]
host.send_json({"type": "add_ai"})
host.send_json({"type": "set_options", "not_equal": True, "hidden_bids": False})
host.send_json({"type": "start_game"})
rng = random.Random(0)
finished = {}
def play(ws, key):
while True:
message = ws.receive_json()
if not is_state(message) or not message["game"]:
continue
obs = message["game"]["observation"]
if obs["phase"] == "finished":
finished[key] = obs
return
if obs["current_player"] == message["game"]["you"] and obs["legal_actions"]:
ws.send_json({"type": "action", "action": rng.choice(obs["legal_actions"])})
threads = [threading.Thread(target=play, args=(ws, key)) for ws, key in ((host, "host"), (guest, "guest"))]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=60)
assert set(finished) == {"host", "guest"}
result = finished["host"]
assert len(result["round_history"]) == 20
assert result["winners"]
host.send_json({"type": "return_to_lobby"})
state = receive_until(host, lambda m: is_state(m) and m["lobby"]["status"] == "waiting")
assert state["game"] is None
def test_reconnect_with_token(client):
ws, welcome = connect(client)
ws.send_json({"type": "create_lobby", "name": "Bob"})
code = receive_until(ws, is_state)["lobby"]["code"]
ws.close()
ws2 = client.sockets.enter_context(client.websocket_connect("/api/ws"))
ws2.send_json({"type": "hello", "token": welcome["token"]})
again = receive_until(ws2, lambda m: m["type"] == "welcome")
assert again["player_id"] == welcome["player_id"]
state = receive_until(ws2, is_state)
assert state["lobby"]["code"] == code
def test_leaving_mid_game_hands_seat_to_ai(client):
host, _ = connect(client)
host.send_json({"type": "create_lobby", "name": "Cy"})
code = receive_until(host, is_state)["lobby"]["code"]
guest, _ = connect(client)
guest.send_json({"type": "join_lobby", "code": code, "name": "Di"})
receive_until(guest, lambda m: is_state(m) and m["lobby"])
host.send_json({"type": "add_ai"})
host.send_json({"type": "start_game"})
receive_until(host, lambda m: is_state(m) and m["game"])
host.send_json({"type": "leave_lobby"})
receive_until(host, lambda m: is_state(m) and m["lobby"] is None)
state = receive_until(guest, lambda m: is_state(m) and m["lobby"]["host_id"] != m["lobby"]["players"][0]["id"])
players = state["lobby"]["players"]
assert players[0]["is_ai"] and players[0]["name"] == "Cy"
assert state["lobby"]["host_id"] == players[1]["id"]
def test_ai_endpoint_requires_secret(client):
from starlette.websockets import WebSocketDisconnect
with pytest.raises(WebSocketDisconnect):
with client.websocket_connect("/internal/ai") as ws:
ws.receive_json()
def test_ai_service_decides(client):
"""A fake AI service answers decide requests with the first legal action."""
requests = []
stop = threading.Event()
def fake_ai():
headers = {"Authorization": f"Bearer {SECRET}"}
with client.websocket_connect("/internal/ai", headers=headers) as ai:
ai.send_json({"type": "hello", "agent": "fake"})
while not stop.is_set():
message = ai.receive_json()
if message["type"] == "decide":
requests.append(message)
action = message["observation"]["legal_actions"][0]
ai.send_json({"type": "action", "request_id": message["request_id"], "action": action})
if len(requests) >= 3:
return
thread = threading.Thread(target=fake_ai, daemon=True)
thread.start()
host, _ = connect(client)
host.send_json({"type": "create_lobby", "name": "Eve"})
receive_until(host, lambda m: is_state(m) and m["lobby"] and m["lobby"]["ai_available"])
host.send_json({"type": "add_ai"})
host.send_json({"type": "add_ai"})
host.send_json({"type": "start_game"})
rng = random.Random(1)
while thread.is_alive():
message = host.receive_json()
if is_state(message) and message["game"]:
obs = message["game"]["observation"]
if obs["current_player"] == message["game"]["you"] and obs["legal_actions"]:
host.send_json({"type": "action", "action": rng.choice(obs["legal_actions"])})
stop.set()
assert len(requests) >= 3
assert all(r["observation"]["player"] in (1, 2) for r in requests)