first prototype
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
**/*.egg-info
|
||||
**/.pytest_cache
|
||||
.venv
|
||||
tests
|
||||
game_logic/tests
|
||||
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
build/
|
||||
@@ -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", "*"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
"""Wizard game server: manages players and lobbies and runs games."""
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
**/*.egg-info
|
||||
.pytest_cache
|
||||
build
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
testpaths = tests game_logic/tests
|
||||
@@ -0,0 +1,4 @@
|
||||
-r requirements.txt
|
||||
-e ./game_logic
|
||||
pytest>=8
|
||||
httpx>=0.27
|
||||
@@ -0,0 +1,2 @@
|
||||
fastapi>=0.115,<1
|
||||
uvicorn[standard]>=0.30,<1
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user