From 6d011d474fc833a5e333c8870f42a7b3710a151d Mon Sep 17 00:00:00 2001 From: Marvin Date: Sun, 27 Sep 2026 09:52:00 +0200 Subject: [PATCH] first prototype --- .env.example | 11 + .gitignore | 1 + Caddyfile.example | 15 + README.md | 64 ++++ compose.yaml | 51 +++ wizard_ai/.dockerignore | 5 + wizard_ai/.gitignore | 6 + wizard_ai/Dockerfile | 27 ++ wizard_ai/README.md | 27 ++ wizard_ai/models/.gitkeep | 0 wizard_ai/pytest.ini | 2 + wizard_ai/requirements-dev.txt | 3 + wizard_ai/requirements.txt | 1 + wizard_ai/tests/test_ai.py | 80 +++++ wizard_ai/wizard_ai/__init__.py | 7 + wizard_ai/wizard_ai/__main__.py | 83 +++++ wizard_ai/wizard_ai/agents/__init__.py | 36 ++ wizard_ai/wizard_ai/agents/base.py | 26 ++ wizard_ai/wizard_ai/agents/heuristic_agent.py | 96 +++++ wizard_ai/wizard_ai/agents/model_agent.py | 33 ++ wizard_ai/wizard_ai/agents/random_agent.py | 19 + wizard_ai/wizard_ai/config.py | 19 + wizard_ai/wizard_ai/model/__init__.py | 14 + wizard_ai/wizard_ai/model/encoding.py | 158 +++++++++ wizard_ai/wizard_ai/model/policy.py | 126 +++++++ wizard_ai/wizard_ai/service/__init__.py | 3 + wizard_ai/wizard_ai/service/client.py | 75 ++++ wizard_ai/wizard_ai/training/__init__.py | 5 + wizard_ai/wizard_ai/training/env.py | 72 ++++ wizard_ai/wizard_ai/training/evaluate.py | 61 ++++ wizard_ai/wizard_ai/training/self_play.py | 102 ++++++ wizard_server/.dockerignore | 7 + wizard_server/.gitignore | 6 + wizard_server/Dockerfile | 26 ++ wizard_server/README.md | 20 ++ wizard_server/app/__init__.py | 1 + wizard_server/app/ai_bridge.py | 141 ++++++++ wizard_server/app/config.py | 39 +++ wizard_server/app/lobby.py | 329 ++++++++++++++++++ wizard_server/app/main.py | 77 ++++ wizard_server/app/protocol.py | 80 +++++ wizard_server/app/service.py | 211 +++++++++++ wizard_server/app/sessions.py | 93 +++++ wizard_server/game_logic/.dockerignore | 5 + wizard_server/game_logic/README.md | 26 ++ wizard_server/game_logic/pyproject.toml | 16 + wizard_server/game_logic/tests/test_game.py | 158 +++++++++ wizard_server/game_logic/tests/test_rules.py | 79 +++++ .../game_logic/wizard_game/__init__.py | 55 +++ .../game_logic/wizard_game/actions.py | 73 ++++ wizard_server/game_logic/wizard_game/cards.py | 115 ++++++ wizard_server/game_logic/wizard_game/game.py | 300 ++++++++++++++++ .../game_logic/wizard_game/observation.py | 120 +++++++ wizard_server/game_logic/wizard_game/rules.py | 95 +++++ wizard_server/game_logic/wizard_game/state.py | 166 +++++++++ wizard_server/pytest.ini | 2 + wizard_server/requirements-dev.txt | 4 + wizard_server/requirements.txt | 2 + wizard_server/tests/test_server.py | 193 ++++++++++ wizard_website/.dockerignore | 6 + wizard_website/.env.example | 3 + wizard_website/Dockerfile | 19 + wizard_website/README.md | 88 +---- wizard_website/index.html | 3 +- wizard_website/nginx.conf | 17 + wizard_website/public/favicon.svg | 2 +- wizard_website/public/icons.svg | 24 -- wizard_website/src/App.css | 184 ---------- wizard_website/src/App.module.css | 54 +++ wizard_website/src/App.tsx | 162 +++------ wizard_website/src/api/types.ts | 121 +++++++ wizard_website/src/api/useWizard.ts | 190 ++++++++++ wizard_website/src/assets/hero.png | Bin 13057 -> 0 bytes wizard_website/src/assets/react.svg | 1 - wizard_website/src/assets/vite.svg | 1 - .../src/components/Button/Button.module.css | 85 +++++ .../src/components/Button/Button.tsx | 14 + .../src/components/Modal/Modal.module.css | 55 +++ wizard_website/src/components/Modal/Modal.tsx | 40 +++ .../PlayingCard/PlayingCard.module.css | 138 ++++++++ .../components/PlayingCard/PlayingCard.tsx | 57 +++ .../src/components/Toasts/Toasts.module.css | 31 ++ .../src/components/Toasts/Toasts.tsx | 19 + wizard_website/src/index.css | 111 ------ wizard_website/src/main.tsx | 1 - .../screens/GameScreen/BidPanel.module.css | 36 ++ .../src/screens/GameScreen/BidPanel.tsx | 41 +++ .../screens/GameScreen/GameOver.module.css | 66 ++++ .../src/screens/GameScreen/GameOver.tsx | 51 +++ .../screens/GameScreen/GameScreen.module.css | 167 +++++++++ .../src/screens/GameScreen/GameScreen.tsx | 148 ++++++++ .../src/screens/GameScreen/Hand.module.css | 26 ++ .../src/screens/GameScreen/Hand.tsx | 30 ++ .../screens/GameScreen/PlayerSeat.module.css | 114 ++++++ .../src/screens/GameScreen/PlayerSeat.tsx | 63 ++++ .../GameScreen/RoundSummary.module.css | 77 ++++ .../src/screens/GameScreen/RoundSummary.tsx | 26 ++ .../screens/GameScreen/Scoreboard.module.css | 73 ++++ .../src/screens/GameScreen/Scoreboard.tsx | 65 ++++ .../screens/GameScreen/TrickArea.module.css | 61 ++++ .../src/screens/GameScreen/TrickArea.tsx | 45 +++ .../screens/GameScreen/TrumpPicker.module.css | 57 +++ .../src/screens/GameScreen/TrumpPicker.tsx | 22 ++ .../screens/HomeScreen/HomeScreen.module.css | 106 ++++++ .../src/screens/HomeScreen/HomeScreen.tsx | 102 ++++++ .../LobbyScreen/LobbyScreen.module.css | 207 +++++++++++ .../src/screens/LobbyScreen/LobbyScreen.tsx | 141 ++++++++ wizard_website/src/util/cards.ts | 35 ++ wizard_website/vite.config.ts | 9 + 109 files changed, 6354 insertions(+), 507 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Caddyfile.example create mode 100644 README.md create mode 100644 compose.yaml create mode 100644 wizard_ai/.dockerignore create mode 100644 wizard_ai/.gitignore create mode 100644 wizard_ai/Dockerfile create mode 100644 wizard_ai/README.md create mode 100644 wizard_ai/models/.gitkeep create mode 100644 wizard_ai/pytest.ini create mode 100644 wizard_ai/requirements-dev.txt create mode 100644 wizard_ai/requirements.txt create mode 100644 wizard_ai/tests/test_ai.py create mode 100644 wizard_ai/wizard_ai/__init__.py create mode 100644 wizard_ai/wizard_ai/__main__.py create mode 100644 wizard_ai/wizard_ai/agents/__init__.py create mode 100644 wizard_ai/wizard_ai/agents/base.py create mode 100644 wizard_ai/wizard_ai/agents/heuristic_agent.py create mode 100644 wizard_ai/wizard_ai/agents/model_agent.py create mode 100644 wizard_ai/wizard_ai/agents/random_agent.py create mode 100644 wizard_ai/wizard_ai/config.py create mode 100644 wizard_ai/wizard_ai/model/__init__.py create mode 100644 wizard_ai/wizard_ai/model/encoding.py create mode 100644 wizard_ai/wizard_ai/model/policy.py create mode 100644 wizard_ai/wizard_ai/service/__init__.py create mode 100644 wizard_ai/wizard_ai/service/client.py create mode 100644 wizard_ai/wizard_ai/training/__init__.py create mode 100644 wizard_ai/wizard_ai/training/env.py create mode 100644 wizard_ai/wizard_ai/training/evaluate.py create mode 100644 wizard_ai/wizard_ai/training/self_play.py create mode 100644 wizard_server/.dockerignore create mode 100644 wizard_server/.gitignore create mode 100644 wizard_server/Dockerfile create mode 100644 wizard_server/README.md create mode 100644 wizard_server/app/__init__.py create mode 100644 wizard_server/app/ai_bridge.py create mode 100644 wizard_server/app/config.py create mode 100644 wizard_server/app/lobby.py create mode 100644 wizard_server/app/main.py create mode 100644 wizard_server/app/protocol.py create mode 100644 wizard_server/app/service.py create mode 100644 wizard_server/app/sessions.py create mode 100644 wizard_server/game_logic/.dockerignore create mode 100644 wizard_server/game_logic/README.md create mode 100644 wizard_server/game_logic/pyproject.toml create mode 100644 wizard_server/game_logic/tests/test_game.py create mode 100644 wizard_server/game_logic/tests/test_rules.py create mode 100644 wizard_server/game_logic/wizard_game/__init__.py create mode 100644 wizard_server/game_logic/wizard_game/actions.py create mode 100644 wizard_server/game_logic/wizard_game/cards.py create mode 100644 wizard_server/game_logic/wizard_game/game.py create mode 100644 wizard_server/game_logic/wizard_game/observation.py create mode 100644 wizard_server/game_logic/wizard_game/rules.py create mode 100644 wizard_server/game_logic/wizard_game/state.py create mode 100644 wizard_server/pytest.ini create mode 100644 wizard_server/requirements-dev.txt create mode 100644 wizard_server/requirements.txt create mode 100644 wizard_server/tests/test_server.py create mode 100644 wizard_website/.dockerignore create mode 100644 wizard_website/.env.example create mode 100644 wizard_website/Dockerfile create mode 100644 wizard_website/nginx.conf delete mode 100644 wizard_website/public/icons.svg delete mode 100644 wizard_website/src/App.css create mode 100644 wizard_website/src/App.module.css create mode 100644 wizard_website/src/api/types.ts create mode 100644 wizard_website/src/api/useWizard.ts delete mode 100644 wizard_website/src/assets/hero.png delete mode 100644 wizard_website/src/assets/react.svg delete mode 100644 wizard_website/src/assets/vite.svg create mode 100644 wizard_website/src/components/Button/Button.module.css create mode 100644 wizard_website/src/components/Button/Button.tsx create mode 100644 wizard_website/src/components/Modal/Modal.module.css create mode 100644 wizard_website/src/components/Modal/Modal.tsx create mode 100644 wizard_website/src/components/PlayingCard/PlayingCard.module.css create mode 100644 wizard_website/src/components/PlayingCard/PlayingCard.tsx create mode 100644 wizard_website/src/components/Toasts/Toasts.module.css create mode 100644 wizard_website/src/components/Toasts/Toasts.tsx delete mode 100644 wizard_website/src/index.css create mode 100644 wizard_website/src/screens/GameScreen/BidPanel.module.css create mode 100644 wizard_website/src/screens/GameScreen/BidPanel.tsx create mode 100644 wizard_website/src/screens/GameScreen/GameOver.module.css create mode 100644 wizard_website/src/screens/GameScreen/GameOver.tsx create mode 100644 wizard_website/src/screens/GameScreen/GameScreen.module.css create mode 100644 wizard_website/src/screens/GameScreen/GameScreen.tsx create mode 100644 wizard_website/src/screens/GameScreen/Hand.module.css create mode 100644 wizard_website/src/screens/GameScreen/Hand.tsx create mode 100644 wizard_website/src/screens/GameScreen/PlayerSeat.module.css create mode 100644 wizard_website/src/screens/GameScreen/PlayerSeat.tsx create mode 100644 wizard_website/src/screens/GameScreen/RoundSummary.module.css create mode 100644 wizard_website/src/screens/GameScreen/RoundSummary.tsx create mode 100644 wizard_website/src/screens/GameScreen/Scoreboard.module.css create mode 100644 wizard_website/src/screens/GameScreen/Scoreboard.tsx create mode 100644 wizard_website/src/screens/GameScreen/TrickArea.module.css create mode 100644 wizard_website/src/screens/GameScreen/TrickArea.tsx create mode 100644 wizard_website/src/screens/GameScreen/TrumpPicker.module.css create mode 100644 wizard_website/src/screens/GameScreen/TrumpPicker.tsx create mode 100644 wizard_website/src/screens/HomeScreen/HomeScreen.module.css create mode 100644 wizard_website/src/screens/HomeScreen/HomeScreen.tsx create mode 100644 wizard_website/src/screens/LobbyScreen/LobbyScreen.module.css create mode 100644 wizard_website/src/screens/LobbyScreen/LobbyScreen.tsx create mode 100644 wizard_website/src/util/cards.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9bf05b9 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Shared secret between the game server and the AI service. Generate one with: +# python3 -c "import secrets; print(secrets.token_urlsafe(32))" +AI_SHARED_SECRET=change-me + +# Optional: comma separated list of allowed websocket origins, e.g. https://wizard.example.com +ALLOWED_ORIGINS= + +# Agent of the AI service: model (falls back to heuristic without checkpoint), heuristic or random +AI_AGENT=model + +LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/Caddyfile.example b/Caddyfile.example new file mode 100644 index 0000000..740bf8b --- /dev/null +++ b/Caddyfile.example @@ -0,0 +1,15 @@ +# Example site block for the existing Caddy container. Caddy has to be attached +# to the external networks "wizard_website" and "wizard_api". +wizard.example.com { + encode zstd gzip + + # Game server: REST + websocket (Caddy proxies websockets automatically). + # /internal/* (AI endpoint) is intentionally not exposed. + handle /api/* { + reverse_proxy wizard_server:8083 + } + + handle { + reverse_proxy wizard_website:8082 + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..02068e1 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# Wizard online + +An online version of the card game Wizard (rules: `wizard_instructions/`). + +| Component | Path | Tech | +|-----------|------|------| +| Frontend | `wizard_website/` | React + TypeScript + Vite, CSS modules, served by nginx | +| Game server | `wizard_server/` | Python, FastAPI, websockets | +| Game logic | `wizard_server/game_logic/` | Pure Python package `wizard_game` (no dependencies) | +| AI player | `wizard_ai/` | Python; RL training skeleton + websocket service | + +``` +browser ──wss /api/ws──▶ Caddy ──▶ wizard_server ◀──ws /internal/ai── wizard_ai + ──https /──────▶ Caddy ──▶ wizard_website (network wizard_internal) +``` + +## How it works + +1. A player opens the website, enters a name and creates a lobby (and joins it). +2. The lobby shows a code (and an invite link, `?lobby=CODE`) others use to join. +3. The host can add AI players, toggle the *not equal* / *hidden bids* rules, and start the game (3–6 players). +4. The server runs the game with `wizard_game` and sends every player a personal view + (`Observation`) after each move. Reloading the page reconnects to the same seat. + A player who leaves a running game is replaced by an AI player. +5. When an AI seat has to act, the server sends its `Observation` to the AI service over the + internal websocket and applies the answered action. If the AI service is unavailable or + too slow, a random legal move is played so games never get stuck. + +The `Observation` / action JSON format is defined once in `wizard_game` and shared by the +frontend, the server and the AI, so AI models see exactly the same data in training and in +production. + +## Deployment + +```bash +cp .env.example .env # set AI_SHARED_SECRET +docker network create wizard_website # if they don't exist yet +docker network create wizard_api +docker compose up -d --build +``` + +- `wizard_website` joins the external network `wizard_website` (port 8082). +- `wizard_server` joins the external network `wizard_api` (port 8083) and `wizard_internal`. +- `wizard_ai` joins only `wizard_internal`, created by compose as an internal network. + +Add a site block like `Caddyfile.example` to the existing Caddy container: `/api/*` goes to +`wizard_server:8083`, everything else to `wizard_website:8082`. + +Trained AI checkpoints go into `wizard_ai/models/latest/` (mounted read-only). Until one exists +the AI service uses the built-in heuristic player. + +## Development + +```bash +python -m venv .venv && . .venv/bin/activate +pip install -r wizard_server/requirements-dev.txt -r wizard_ai/requirements-dev.txt + +(cd wizard_server && uvicorn app.main:app --reload) # :8000 +(cd wizard_ai && python -m wizard_ai serve) # connects to :8000 +(cd wizard_website && npm install && npm run dev) # :5173 + +(cd wizard_server && pytest) # game logic + server tests +(cd wizard_ai && pytest) +``` diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..dd9d582 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,51 @@ +name: wizard + +services: + wizard_website: + build: ./wizard_website + restart: unless-stopped + networks: + - wizard_website + + wizard_server: + build: ./wizard_server + restart: unless-stopped + environment: + AI_SHARED_SECRET: ${AI_SHARED_SECRET:?set AI_SHARED_SECRET in .env} + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + networks: + - wizard_api + - wizard_internal + + wizard_ai: + build: + context: ./wizard_ai + # The AI uses the game logic package of the server (requires docker compose >= 2.17). + additional_contexts: + game_logic: ./wizard_server/game_logic + restart: unless-stopped + environment: + SERVER_URL: ws://wizard_server:8083/internal/ai + AI_SHARED_SECRET: ${AI_SHARED_SECRET:?set AI_SHARED_SECRET in .env} + AGENT: ${AI_AGENT:-model} + MODEL_PATH: /srv/models/latest + LOG_LEVEL: ${LOG_LEVEL:-INFO} + volumes: + # Trained checkpoints; replace without rebuilding the image. + - ./wizard_ai/models:/srv/models:ro + depends_on: + - wizard_server + networks: + - wizard_internal + +networks: + # Shared with the existing Caddy reverse proxy. + wizard_website: + external: true + wizard_api: + external: true + # Only the game server and the AI player; no route to the outside. + wizard_internal: + name: wizard_internal + internal: true diff --git a/wizard_ai/.dockerignore b/wizard_ai/.dockerignore new file mode 100644 index 0000000..d0e3b7b --- /dev/null +++ b/wizard_ai/.dockerignore @@ -0,0 +1,5 @@ +**/__pycache__ +**/*.pyc +.venv +.pytest_cache +tests diff --git a/wizard_ai/.gitignore b/wizard_ai/.gitignore new file mode 100644 index 0000000..1f34a07 --- /dev/null +++ b/wizard_ai/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +.venv/ +.pytest_cache/ +models/* +!models/.gitkeep diff --git a/wizard_ai/Dockerfile b/wizard_ai/Dockerfile new file mode 100644 index 0000000..5eda44e --- /dev/null +++ b/wizard_ai/Dockerfile @@ -0,0 +1,27 @@ +# Build with the game logic as additional build context, e.g. +# docker build --build-context game_logic=../wizard_server/game_logic . +# (docker compose does this automatically, see compose.yaml) +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 + +# Shared rules engine from the game server. +COPY --from=game_logic . /tmp/game_logic +RUN pip install /tmp/game_logic && rm -rf /tmp/game_logic + +COPY wizard_ai ./wizard_ai +COPY models ./models + +RUN useradd --system --no-create-home wizard +USER wizard + +ENV MODEL_PATH=/srv/models/latest +CMD ["python", "-m", "wizard_ai", "serve"] diff --git a/wizard_ai/README.md b/wizard_ai/README.md new file mode 100644 index 0000000..263fe40 --- /dev/null +++ b/wizard_ai/README.md @@ -0,0 +1,27 @@ +# wizard_ai + +AI player for the Wizard server. The actual RL model is still to be implemented; this package +provides everything around it. + +- `agents/` — `Agent` interface (`act(observation) -> action`) plus `RandomAgent`, + `HeuristicAgent` (baseline / fallback) and `ModelAgent` (wraps a `Policy`) +- `model/encoding.py` — observation → fixed-size feature vector (`FEATURE_SIZE`), and the fixed + action space (21 bids + 60 cards + 4 trump suits) with legal-action masks; works for 3–6 players +- `model/policy.py` — **the place to plug in the model**: subclass `Policy`, implement + `select`, `update`, `save_weights`/`load`, register with `@register_policy`. + `UniformPolicy` is a placeholder so the pipeline runs end to end. +- `training/env.py` — turn-based multi-agent environment around `wizard_game` + (reward = score change per round / 100) +- `training/self_play.py` — self-play loop collecting per-player trajectories and calling + `Policy.update`, with checkpointing +- `training/evaluate.py` — play agents against each other (mean score, win rate) +- `service/client.py` — production: connects to `ws://wizard_server:8083/internal/ai`, + answers `decide` requests, reconnects automatically + +```bash +pip install -r requirements-dev.txt +python -m wizard_ai train --episodes 5000 --out models/latest +python -m wizard_ai evaluate --agents model,heuristic,random,random --games 200 +python -m wizard_ai serve # env: SERVER_URL, AI_SHARED_SECRET, AGENT, MODEL_PATH +pytest +``` diff --git a/wizard_ai/models/.gitkeep b/wizard_ai/models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/wizard_ai/pytest.ini b/wizard_ai/pytest.ini new file mode 100644 index 0000000..5ee6477 --- /dev/null +++ b/wizard_ai/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/wizard_ai/requirements-dev.txt b/wizard_ai/requirements-dev.txt new file mode 100644 index 0000000..c462d91 --- /dev/null +++ b/wizard_ai/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +-e ../wizard_server/game_logic +pytest>=8 diff --git a/wizard_ai/requirements.txt b/wizard_ai/requirements.txt new file mode 100644 index 0000000..69d2bca --- /dev/null +++ b/wizard_ai/requirements.txt @@ -0,0 +1 @@ +websockets>=14,<18 diff --git a/wizard_ai/tests/test_ai.py b/wizard_ai/tests/test_ai.py new file mode 100644 index 0000000..fd07b81 --- /dev/null +++ b/wizard_ai/tests/test_ai.py @@ -0,0 +1,80 @@ +import json +import random + +import pytest + +from wizard_game import GameOptions, WizardGame + +from wizard_ai.agents import HeuristicAgent, ModelAgent, RandomAgent, make_agent +from wizard_ai.model import FEATURE_SIZE, ActionSpace, UniformPolicy, encode_observation, load_policy +from wizard_ai.training import SelfPlayTrainer, TrainingConfig, WizardEnv, evaluate + + +@pytest.mark.parametrize("num_players", [3, 4, 5, 6]) +def test_encoding_has_fixed_size_and_masks_match(num_players): + game = WizardGame(num_players, GameOptions(hidden_bids=True), seed=num_players) + rng = random.Random(0) + while not game.is_finished: + obs = game.observation(game.current_player) + assert len(encode_observation(obs)) == FEATURE_SIZE + mask = ActionSpace.mask(obs) + legal = {ActionSpace.index(a) for a in obs.legal_actions} + assert {i for i, allowed in enumerate(mask) if allowed} == legal + game.apply(obs.player, rng.choice(obs.legal_actions)) + + +def test_action_space_round_trip(): + for index in range(ActionSpace.SIZE): + assert ActionSpace.index(ActionSpace.action(index)) == index + + +@pytest.mark.parametrize("agent", [HeuristicAgent(), RandomAgent(1), ModelAgent(UniformPolicy(1))]) +def test_agents_play_legal_games(agent): + game = WizardGame(4, GameOptions(not_equal=True), seed=3) + while not game.is_finished: + obs = game.observation(game.current_player) + action = agent.act(obs) + assert action in obs.legal_actions + game.apply(obs.player, action) + + +def test_env_rewards_add_up_to_scores(): + env = WizardEnv(3, seed=5) + obs = env.reset() + totals = [0.0] * env.num_players + rng = random.Random(5) + while obs is not None: + result = env.step(rng.choice(obs.legal_actions)) + totals = [t + r for t, r in zip(totals, result.rewards)] + obs = result.observation + assert [round(t * 100) for t in totals] == env.game.scores + + +def test_trainer_runs_and_checkpoints(tmp_path): + config = TrainingConfig(episodes=4, player_counts=(3, 4), episodes_per_update=2, + checkpoint_dir=tmp_path / "ckpt", seed=1) + trainer = SelfPlayTrainer(UniformPolicy(1), config) + trajectories, scores = trainer.play_episode() + assert len(trajectories) == len(scores) + assert all(t[-1].done for t in trajectories) + assert sum(sum(tr.reward for tr in t) for t in trajectories) == pytest.approx(sum(scores) / 100) + trainer.train() + assert json.loads((tmp_path / "ckpt" / "policy.json").read_text())["type"] == "uniform" + assert isinstance(load_policy(tmp_path / "ckpt"), UniformPolicy) + + +def test_trainer_against_heuristic_opponents(): + config = TrainingConfig(episodes=2, player_counts=(5,), opponents="heuristic", seed=2) + trajectories, _ = SelfPlayTrainer(UniformPolicy(2), config).play_episode() + assert len(trajectories) == 1 + + +def test_make_agent_falls_back_without_checkpoint(tmp_path): + assert isinstance(make_agent("model", tmp_path / "missing"), HeuristicAgent) + UniformPolicy().save(tmp_path / "ckpt") + assert isinstance(make_agent("model", tmp_path / "ckpt"), ModelAgent) + + +def test_heuristic_beats_random(): + result = evaluate([HeuristicAgent(), RandomAgent(1), RandomAgent(2), RandomAgent(3)], games=40, seed=0) + assert result.mean_scores[0] > max(result.mean_scores[1:]) diff --git a/wizard_ai/wizard_ai/__init__.py b/wizard_ai/wizard_ai/__init__.py new file mode 100644 index 0000000..772a9e0 --- /dev/null +++ b/wizard_ai/wizard_ai/__init__.py @@ -0,0 +1,7 @@ +"""AI player for the Wizard game server. + +- ``agents``: the ``Agent`` interface and implementations +- ``model``: observation/action encoding and the (RL) policy interface +- ``training``: self-play environment, training loop and evaluation +- ``service``: websocket client that serves decisions to the game server +""" diff --git a/wizard_ai/wizard_ai/__main__.py b/wizard_ai/wizard_ai/__main__.py new file mode 100644 index 0000000..4c3f519 --- /dev/null +++ b/wizard_ai/wizard_ai/__main__.py @@ -0,0 +1,83 @@ +"""Command line entry point. + + python -m wizard_ai serve # production: connect to the game server + python -m wizard_ai train --episodes 5000 # self-play training + python -m wizard_ai evaluate --agents model,heuristic,random,random +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +from pathlib import Path + +from wizard_game import GameOptions + +from .agents import AGENT_NAMES, make_agent +from .config import Config + + +def main() -> None: + config = Config() + parser = argparse.ArgumentParser(prog="wizard_ai") + parser.add_argument("--log-level", default=config.log_level) + commands = parser.add_subparsers(dest="command", required=True) + + serve = commands.add_parser("serve", help="serve AI decisions to the game server") + serve.add_argument("--server-url", default=config.server_url) + serve.add_argument("--agent", default=config.agent, choices=AGENT_NAMES) + serve.add_argument("--model-path", default=config.model_path) + + train = commands.add_parser("train", help="train the policy with self-play") + train.add_argument("--episodes", type=int, default=1000) + train.add_argument("--players", default="3,4,5,6", help="comma separated player counts") + train.add_argument("--opponents", default="self", choices=("self", "heuristic", "random")) + train.add_argument("--not-equal", action="store_true") + train.add_argument("--hidden-bids", action="store_true") + train.add_argument("--resume", action="store_true", help="continue from --out if it exists") + train.add_argument("--out", default=config.model_path, help="checkpoint directory") + train.add_argument("--seed", type=int) + + evaluation = commands.add_parser("evaluate", help="let agents play against each other") + evaluation.add_argument("--agents", default="model,heuristic,random,random", + help=f"comma separated, 3-6 of {AGENT_NAMES}") + evaluation.add_argument("--games", type=int, default=200) + evaluation.add_argument("--model-path", default=config.model_path) + evaluation.add_argument("--seed", type=int) + + args = parser.parse_args() + logging.basicConfig(level=args.log_level.upper(), format="%(asctime)s %(levelname)s %(name)s: %(message)s") + + if args.command == "serve": + from .service import AIService + + agent = make_agent(args.agent, args.model_path) + asyncio.run(AIService(args.server_url, agent, config.secret).run_forever()) + + elif args.command == "train": + from .model import UniformPolicy, checkpoint_exists, load_policy + from .training import SelfPlayTrainer, TrainingConfig + + out = Path(args.out) + # TODO: replace UniformPolicy with the real model once it is implemented. + policy = load_policy(out) if args.resume and checkpoint_exists(out) else UniformPolicy(args.seed) + training_config = TrainingConfig( + episodes=args.episodes, + player_counts=tuple(int(n) for n in args.players.split(",")), + options=GameOptions(not_equal=args.not_equal, hidden_bids=args.hidden_bids), + opponents=args.opponents, + checkpoint_dir=out, + seed=args.seed, + ) + SelfPlayTrainer(policy, training_config).train() + + elif args.command == "evaluate": + from .training import evaluate + + agents = [make_agent(name.strip(), args.model_path) for name in args.agents.split(",")] + print(evaluate(agents, args.games, seed=args.seed)) + + +if __name__ == "__main__": + main() diff --git a/wizard_ai/wizard_ai/agents/__init__.py b/wizard_ai/wizard_ai/agents/__init__.py new file mode 100644 index 0000000..97bbfcf --- /dev/null +++ b/wizard_ai/wizard_ai/agents/__init__.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +from ..model.policy import checkpoint_exists, load_policy +from .base import Agent +from .heuristic_agent import HeuristicAgent +from .model_agent import ModelAgent +from .random_agent import RandomAgent + +logger = logging.getLogger(__name__) + +AGENT_NAMES = ("model", "heuristic", "random") + + +def make_agent(name: str, model_path: str | Path | None = None) -> Agent: + """Create an agent by name. + + ``model`` loads the policy checkpoint at ``model_path``. As long as no + trained model exists, it falls back to the heuristic agent. + """ + if name == "random": + return RandomAgent() + if name == "heuristic": + return HeuristicAgent() + if name == "model": + if checkpoint_exists(model_path): + logger.info("Loading policy from %s", model_path) + return ModelAgent(load_policy(model_path)) + logger.warning("No model checkpoint at %s, using the heuristic agent", model_path) + return HeuristicAgent() + raise ValueError(f"Unknown agent {name!r}, expected one of {AGENT_NAMES}") + + +__all__ = ["AGENT_NAMES", "Agent", "HeuristicAgent", "ModelAgent", "RandomAgent", "make_agent"] diff --git a/wizard_ai/wizard_ai/agents/base.py b/wizard_ai/wizard_ai/agents/base.py new file mode 100644 index 0000000..577fdbd --- /dev/null +++ b/wizard_ai/wizard_ai/agents/base.py @@ -0,0 +1,26 @@ +"""The interface every AI player implements.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from wizard_game import Action, Observation + + +class Agent(ABC): + """Chooses actions from observations. + + The same interface is used during training (observations come straight + from ``wizard_game.WizardGame``) and in production (observations arrive + from the game server via websocket), so a trained agent can be deployed + without changes. + """ + + name: str = "agent" + + @abstractmethod + def act(self, observation: Observation) -> Action: + """Return one of ``observation.legal_actions``.""" + + def reset(self) -> None: # noqa: B027 - optional hook + """Called before a new game starts (training only).""" diff --git a/wizard_ai/wizard_ai/agents/heuristic_agent.py b/wizard_ai/wizard_ai/agents/heuristic_agent.py new file mode 100644 index 0000000..385d76e --- /dev/null +++ b/wizard_ai/wizard_ai/agents/heuristic_agent.py @@ -0,0 +1,96 @@ +"""A simple rule based player. + +It serves as a sparring partner / benchmark for reinforcement learning and +as the fallback in production while no trained model is available. +""" + +from __future__ import annotations + +from collections import Counter + +from wizard_game import SUITS, Action, Bid, Card, ChooseTrump, Observation, Phase, PlayCard, Suit +from wizard_game.rules import trick_winner + +from .base import Agent + + +class HeuristicAgent(Agent): + name = "heuristic" + + def act(self, observation: Observation) -> Action: + if observation.phase is Phase.TRUMP_SELECTION: + return ChooseTrump(self._best_trump(observation.hand)) + if observation.phase is Phase.BIDDING: + return self._bid(observation) + return self._play(observation) + + # --- Trump & bidding ---------------------------------------------------- + + @staticmethod + def _best_trump(hand: tuple[Card, ...]) -> Suit: + strength = Counter({suit: 0.0 for suit in SUITS}) + for card in hand: + if card.is_number and card.suit is not None: + strength[card.suit] += 1 + card.rank / 13 + return strength.most_common(1)[0][0] + + @staticmethod + def _expected_tricks(observation: Observation) -> float: + trump = observation.trump_suit + opponents = observation.num_players - 1 + expected = 0.0 + for card in observation.hand: + if card.is_wizard: + expected += 0.95 + elif card.is_number and card.suit is trump: + expected += 0.35 + 0.05 * card.rank + elif card.is_number and card.rank >= 12: + # High side suit cards are good but get trumped more often + # with more opponents. + expected += max(0.1, 0.75 - 0.1 * opponents) * (card.rank - 10) / 3 + return expected + + def _bid(self, observation: Observation) -> Action: + target = round(self._expected_tricks(observation)) + bids = [action for action in observation.legal_actions if isinstance(action, Bid)] + return min(bids, key=lambda bid: (abs(bid.value - target), bid.value)) + + # --- Playing ------------------------------------------------------------ + + def _play(self, observation: Observation) -> Action: + candidates = [a for a in observation.legal_actions if isinstance(a, PlayCard)] + me = observation.player + bid = observation.bids[me] or 0 + wants_tricks = observation.tricks_won[me] < bid + trick = [played.card for played in observation.current_trick] + is_last = len(trick) == observation.num_players - 1 + + winning = [a for a in candidates if self._wins(trick, a.card, observation.trump_suit)] + losing = [a for a in candidates if a not in winning] + + if wants_tricks: + if winning: + # Last to play: win as cheaply as possible, otherwise go strong. + chooser = min if is_last else max + return chooser(winning, key=lambda a: self._strength(a.card, observation.trump_suit)) + return min(candidates, key=lambda a: self._strength(a.card, observation.trump_suit)) + + if losing: + # Get rid of the strongest card that still loses. + return max(losing, key=lambda a: self._strength(a.card, observation.trump_suit)) + return min(candidates, key=lambda a: self._strength(a.card, observation.trump_suit)) + + @staticmethod + def _wins(trick: list[Card], card: Card, trump: Suit | None) -> bool: + """Whether ``card`` would currently win the trick (ignores later players).""" + return trick_winner([*trick, card], trump) == len(trick) + + @staticmethod + def _strength(card: Card, trump: Suit | None) -> float: + if card.is_wizard: + return 100 + if card.is_jester: + return -1 + if card.suit is trump: + return 50 + card.rank + return card.rank diff --git a/wizard_ai/wizard_ai/agents/model_agent.py b/wizard_ai/wizard_ai/agents/model_agent.py new file mode 100644 index 0000000..38d5199 --- /dev/null +++ b/wizard_ai/wizard_ai/agents/model_agent.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from collections.abc import Callable + +from wizard_game import Action, Observation + +from ..model.encoding import ActionSpace, encode_observation +from ..model.policy import Policy +from .base import Agent + +# Called with (observation, features, mask, action_index) for every decision. +DecisionRecorder = Callable[[Observation, list[float], list[bool], int], None] + + +class ModelAgent(Agent): + """Agent backed by a learned ``Policy``.""" + + name = "model" + + def __init__(self, policy: Policy, *, explore: bool = False, recorder: DecisionRecorder | None = None) -> None: + self.policy = policy + self.explore = explore + self.recorder = recorder + + def act(self, observation: Observation) -> Action: + features = encode_observation(observation) + mask = ActionSpace.mask(observation) + index = self.policy.select(features, mask, explore=self.explore) + if not mask[index]: + raise ValueError(f"Policy selected illegal action index {index}") + if self.recorder is not None: + self.recorder(observation, features, mask, index) + return ActionSpace.action(index) diff --git a/wizard_ai/wizard_ai/agents/random_agent.py b/wizard_ai/wizard_ai/agents/random_agent.py new file mode 100644 index 0000000..daa84ed --- /dev/null +++ b/wizard_ai/wizard_ai/agents/random_agent.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import random + +from wizard_game import Action, Observation + +from .base import Agent + + +class RandomAgent(Agent): + """Plays a uniformly random legal action. Useful as a training baseline.""" + + name = "random" + + def __init__(self, seed: int | None = None) -> None: + self._rng = random.Random(seed) + + def act(self, observation: Observation) -> Action: + return self._rng.choice(observation.legal_actions) diff --git a/wizard_ai/wizard_ai/config.py b/wizard_ai/wizard_ai/config.py new file mode 100644 index 0000000..68f6a12 --- /dev/null +++ b/wizard_ai/wizard_ai/config.py @@ -0,0 +1,19 @@ +"""Configuration of the AI service, read from environment variables.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Config: + # Websocket endpoint of the game server for AI players. + server_url: str = os.environ.get("SERVER_URL", "ws://localhost:8000/internal/ai") + # Must match AI_SHARED_SECRET of the game server. + secret: str = os.environ.get("AI_SHARED_SECRET", "") + # "model", "heuristic" or "random". + agent: str = os.environ.get("AGENT", "model") + # Checkpoint directory of the trained policy. + model_path: str = os.environ.get("MODEL_PATH", "models/latest") + log_level: str = os.environ.get("LOG_LEVEL", "INFO") diff --git a/wizard_ai/wizard_ai/model/__init__.py b/wizard_ai/wizard_ai/model/__init__.py new file mode 100644 index 0000000..6563292 --- /dev/null +++ b/wizard_ai/wizard_ai/model/__init__.py @@ -0,0 +1,14 @@ +from .encoding import FEATURE_SIZE, ActionSpace, encode_observation +from .policy import Policy, Transition, UniformPolicy, checkpoint_exists, load_policy, register_policy + +__all__ = [ + "FEATURE_SIZE", + "ActionSpace", + "Policy", + "Transition", + "UniformPolicy", + "checkpoint_exists", + "encode_observation", + "load_policy", + "register_policy", +] diff --git a/wizard_ai/wizard_ai/model/encoding.py b/wizard_ai/wizard_ai/model/encoding.py new file mode 100644 index 0000000..09a792f --- /dev/null +++ b/wizard_ai/wizard_ai/model/encoding.py @@ -0,0 +1,158 @@ +"""Conversion between game objects and model inputs / outputs. + +``encode_observation`` turns an ``Observation`` into a fixed size feature +vector and ``ActionSpace`` maps actions to indices of a fixed size output +layer (plus a mask of the currently legal actions). + +Everything is plain Python lists so the module has no dependencies; convert +to numpy / torch tensors in the model implementation. +""" + +from __future__ import annotations + +from wizard_game import ( + DECK_SIZE, + MAX_PLAYERS, + MIN_PLAYERS, + SUITS, + Action, + Bid, + ChooseTrump, + Observation, + Phase, + PlayCard, + Suit, + card_by_id, + total_rounds, +) + +MAX_BID = total_rounds(MIN_PLAYERS) # 20 cards in the last round with 3 players +MAX_OPPONENTS = MAX_PLAYERS - 1 +SCORE_SCALE = 100.0 + + +class ActionSpace: + """Fixed action indexing: bids 0-20, then the 60 cards, then 4 trump suits.""" + + BID_OFFSET = 0 + CARD_OFFSET = BID_OFFSET + MAX_BID + 1 + TRUMP_OFFSET = CARD_OFFSET + DECK_SIZE + SIZE = TRUMP_OFFSET + len(SUITS) + + @classmethod + def index(cls, action: Action) -> int: + if isinstance(action, Bid): + return cls.BID_OFFSET + action.value + if isinstance(action, PlayCard): + return cls.CARD_OFFSET + action.card.id + if isinstance(action, ChooseTrump): + return cls.TRUMP_OFFSET + action.suit.index + raise TypeError(f"Unknown action {action!r}") + + @classmethod + def action(cls, index: int) -> Action: + if not 0 <= index < cls.SIZE: + raise ValueError(f"Action index out of range: {index}") + if index >= cls.TRUMP_OFFSET: + return ChooseTrump(SUITS[index - cls.TRUMP_OFFSET]) + if index >= cls.CARD_OFFSET: + return PlayCard(card_by_id(index - cls.CARD_OFFSET)) + return Bid(index - cls.BID_OFFSET) + + @classmethod + def mask(cls, observation: Observation) -> list[bool]: + mask = [False] * cls.SIZE + for action in observation.legal_actions: + mask[cls.index(action)] = True + return mask + + +def _one_hot(size: int, index: int | None) -> list[float]: + vector = [0.0] * size + if index is not None: + vector[index] = 1.0 + return vector + + +def _suit_one_hot(suit: Suit | None) -> list[float]: + # Last slot means "no suit". + return _one_hot(len(SUITS) + 1, suit.index if suit is not None else len(SUITS)) + + +def _cards(cards) -> list[float]: + vector = [0.0] * DECK_SIZE + for card in cards: + vector[card.id] = 1.0 + return vector + + +PHASES = (Phase.TRUMP_SELECTION, Phase.BIDDING, Phase.PLAYING) + + +def encode_observation(obs: Observation) -> list[float]: + """Encode an observation as a vector of length ``FEATURE_SIZE``. + + Opponent features are ordered relative to the observing player (next + player first) and zero padded to ``MAX_OPPONENTS``, so the same model + can play with 3 to 6 players. + """ + me = obs.player + n = obs.num_players + features: list[float] = [] + + # Game context + features += _one_hot(MAX_PLAYERS - MIN_PLAYERS + 1, n - MIN_PLAYERS) + features += _one_hot(len(PHASES), PHASES.index(obs.phase) if obs.phase in PHASES else None) + features += [obs.round_number / MAX_BID, obs.round_number / obs.total_rounds] + features += [float(obs.options.not_equal), float(obs.options.hidden_bids)] + features += _one_hot(MAX_PLAYERS, (me - obs.dealer) % n) # seat relative to dealer + features += _one_hot(MAX_PLAYERS, len(obs.current_trick)) # position in the trick + + # Cards + features += _cards(obs.hand) + features += _cards(obs.played_cards) + features += _cards(played.card for played in obs.current_trick) + features += _suit_one_hot(obs.trump_suit) + features += _suit_one_hot(obs.lead_suit) + features += _one_hot(DECK_SIZE, obs.trump_card.id if obs.trump_card else None) + + # Own bid and tricks + own_bid = obs.bids[me] + features += [ + float(own_bid is not None), + (own_bid or 0) / MAX_BID, + obs.tricks_won[me] / MAX_BID, + ((own_bid or 0) - obs.tricks_won[me]) / MAX_BID, + obs.scores[me] / SCORE_SCALE, + ] + + # Opponents, relative to me + for offset in range(1, MAX_OPPONENTS + 1): + if offset < n: + p = (me + offset) % n + bid = obs.bids[p] + features += [ + 1.0, + float(obs.has_bid[p]), + float(bid is not None), + (bid or 0) / MAX_BID, + obs.tricks_won[p] / MAX_BID, + (obs.scores[p] - obs.scores[me]) / SCORE_SCALE, + float(any(played.player == p for played in obs.current_trick)), + ] + else: + features += [0.0] * 7 + + # Sum of known bids vs. tricks available + known_bids = sum(bid for bid in obs.bids if bid is not None) + features += [(known_bids - obs.round_number) / MAX_BID] + return features + + +FEATURE_SIZE = ( + (MAX_PLAYERS - MIN_PLAYERS + 1) + len(PHASES) + 2 + 2 + MAX_PLAYERS + MAX_PLAYERS + + DECK_SIZE * 3 + (len(SUITS) + 1) * 2 + DECK_SIZE + + 5 + + MAX_OPPONENTS * 7 + + 1 +) diff --git a/wizard_ai/wizard_ai/model/policy.py b/wizard_ai/wizard_ai/model/policy.py new file mode 100644 index 0000000..ec59e98 --- /dev/null +++ b/wizard_ai/wizard_ai/model/policy.py @@ -0,0 +1,126 @@ +"""Policy (model) interface and checkpoint handling. + +This is the place where the actual reinforcement learning model will be +plugged in. A policy maps an encoded observation plus a legal action mask +(see ``encoding``) to an action index, and can be updated from experience +collected in self-play (see ``training.self_play``). + +To add a model, subclass ``Policy``, implement ``select``, ``update``, +``save`` and ``load`` and decorate the class with ``@register_policy``. A +checkpoint is a directory containing ``policy.json`` (with the registered +``type``) plus whatever files the policy writes (e.g. ``weights.pt``). +""" + +from __future__ import annotations + +import json +import random +from abc import ABC, abstractmethod +from dataclasses import dataclass +from pathlib import Path +from typing import Any, ClassVar + +from .encoding import ActionSpace, FEATURE_SIZE + +METADATA_FILE = "policy.json" + + +@dataclass(slots=True) +class Transition: + """One decision of one player, collected during self-play.""" + + features: list[float] + mask: list[bool] + action: int + # Reward observed after this decision (score change of a finished round, + # scaled). Rewards are attached to the player's most recent decision. + reward: float = 0.0 + # True for the player's last decision of the game. + done: bool = False + + +class Policy(ABC): + type_name: ClassVar[str] + + feature_size: int = FEATURE_SIZE + action_size: int = ActionSpace.SIZE + + @abstractmethod + def select(self, features: list[float], mask: list[bool], *, explore: bool = False) -> int: + """Return the index of a legal action (``mask[index]`` is True).""" + + @abstractmethod + def update(self, episodes: list[list[Transition]]) -> dict[str, float]: + """Improve the policy from trajectories (one list per player and game). + + Returns metrics for logging. + """ + + def save(self, directory: str | Path) -> None: + directory = Path(directory) + directory.mkdir(parents=True, exist_ok=True) + metadata = {"type": self.type_name, "config": self.config()} + (directory / METADATA_FILE).write_text(json.dumps(metadata, indent=2)) + self.save_weights(directory) + + def config(self) -> dict[str, Any]: + """Constructor arguments stored in the checkpoint metadata.""" + return {} + + def save_weights(self, directory: Path) -> None: # noqa: B027 - optional hook + """Write model parameters into ``directory``.""" + + @classmethod + def load(cls, directory: Path, config: dict[str, Any]) -> Policy: + return cls(**config) + + +_REGISTRY: dict[str, type[Policy]] = {} + + +def register_policy(cls: type[Policy]) -> type[Policy]: + _REGISTRY[cls.type_name] = cls + return cls + + +def load_policy(directory: str | Path) -> Policy: + directory = Path(directory) + metadata = json.loads((directory / METADATA_FILE).read_text()) + policy_type = metadata.get("type") + if policy_type not in _REGISTRY: + raise ValueError(f"Unknown policy type {policy_type!r} in {directory}") + return _REGISTRY[policy_type].load(directory, metadata.get("config", {})) + + +def checkpoint_exists(directory: str | Path | None) -> bool: + return directory is not None and (Path(directory) / METADATA_FILE).is_file() + + +@register_policy +class UniformPolicy(Policy): + """Placeholder model: picks a uniformly random legal action. + + Replace with the real (neural network) policy; it only exists so that + the training and serving pipeline can be run end to end. + """ + + type_name = "uniform" + + def __init__(self, seed: int | None = None) -> None: + self._seed = seed + self._rng = random.Random(seed) + + def select(self, features: list[float], mask: list[bool], *, explore: bool = False) -> int: + legal = [index for index, allowed in enumerate(mask) if allowed] + return self._rng.choice(legal) + + def update(self, episodes: list[list[Transition]]) -> dict[str, float]: + transitions = sum(len(episode) for episode in episodes) + total_reward = sum(t.reward for episode in episodes for t in episode) + return { + "transitions": float(transitions), + "mean_episode_reward": total_reward / max(1, len(episodes)), + } + + def config(self) -> dict[str, Any]: + return {"seed": self._seed} diff --git a/wizard_ai/wizard_ai/service/__init__.py b/wizard_ai/wizard_ai/service/__init__.py new file mode 100644 index 0000000..810fd5c --- /dev/null +++ b/wizard_ai/wizard_ai/service/__init__.py @@ -0,0 +1,3 @@ +from .client import AIService + +__all__ = ["AIService"] diff --git a/wizard_ai/wizard_ai/service/client.py b/wizard_ai/wizard_ai/service/client.py new file mode 100644 index 0000000..f5e12d9 --- /dev/null +++ b/wizard_ai/wizard_ai/service/client.py @@ -0,0 +1,75 @@ +"""Production mode: serve decisions to the game server over a websocket. + +The service connects to the server's ``/internal/ai`` endpoint and answers +``decide`` requests (see ``wizard_server/app/ai_bridge.py``). It reconnects +automatically if the connection drops. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Any + +from websockets.asyncio.client import ClientConnection, connect +from websockets.exceptions import ConnectionClosed, InvalidHandshake, InvalidStatus + +from wizard_game import Observation + +from ..agents import Agent + +logger = logging.getLogger(__name__) + +MAX_BACKOFF = 30.0 + + +class AIService: + def __init__(self, server_url: str, agent: Agent, secret: str = "") -> None: + self.server_url = server_url + self.agent = agent + self.secret = secret + # Inference runs in worker threads; the lock keeps agents that are not + # thread safe (e.g. stateful models) consistent. + self._agent_lock = asyncio.Lock() + + async def run_forever(self) -> None: + backoff = 1.0 + while True: + try: + await self._run_once() + backoff = 1.0 + except (OSError, ConnectionClosed, InvalidHandshake, TimeoutError) as exc: + if isinstance(exc, InvalidStatus) and exc.response.status_code in (401, 403): + logger.error("Server rejected the connection, check AI_SHARED_SECRET") + logger.warning("Connection to %s lost (%s), retrying in %.0fs", self.server_url, exc, backoff) + await asyncio.sleep(backoff) + backoff = min(backoff * 2, MAX_BACKOFF) + + async def _run_once(self) -> None: + headers = {"Authorization": f"Bearer {self.secret}"} if self.secret else {} + async with connect(self.server_url, additional_headers=headers, open_timeout=10) as websocket: + logger.info("Connected to %s as '%s' agent", self.server_url, self.agent.name) + await websocket.send(json.dumps({"type": "hello", "agent": self.agent.name})) + tasks: set[asyncio.Task] = set() + async for raw in websocket: + message = json.loads(raw) + if message.get("type") == "decide": + task = asyncio.create_task(self._decide(websocket, message)) + tasks.add(task) + task.add_done_callback(tasks.discard) + + async def _decide(self, websocket: ClientConnection, message: dict[str, Any]) -> None: + request_id = message.get("request_id") + try: + observation = Observation.from_dict(message["observation"]) + async with self._agent_lock: + action = await asyncio.to_thread(self.agent.act, observation) + reply = {"type": "action", "request_id": request_id, "action": action.to_dict()} + except Exception as exc: # report instead of crashing; the server falls back + logger.exception("Failed to decide request %s", request_id) + reply = {"type": "error", "request_id": request_id, "message": str(exc)} + try: + await websocket.send(json.dumps(reply)) + except ConnectionClosed: + pass diff --git a/wizard_ai/wizard_ai/training/__init__.py b/wizard_ai/wizard_ai/training/__init__.py new file mode 100644 index 0000000..1b84f7c --- /dev/null +++ b/wizard_ai/wizard_ai/training/__init__.py @@ -0,0 +1,5 @@ +from .env import StepResult, WizardEnv +from .evaluate import EvaluationResult, evaluate +from .self_play import SelfPlayTrainer, TrainingConfig + +__all__ = ["EvaluationResult", "SelfPlayTrainer", "StepResult", "TrainingConfig", "WizardEnv", "evaluate"] diff --git a/wizard_ai/wizard_ai/training/env.py b/wizard_ai/wizard_ai/training/env.py new file mode 100644 index 0000000..140cffc --- /dev/null +++ b/wizard_ai/wizard_ai/training/env.py @@ -0,0 +1,72 @@ +"""Multi-agent training environment around ``wizard_game.WizardGame``.""" + +from __future__ import annotations + +import random +from dataclasses import dataclass + +from wizard_game import Action, Event, GameOptions, Observation, RoundCompleted, WizardGame + +from ..model.encoding import SCORE_SCALE + + +@dataclass(slots=True) +class StepResult: + # Observation of the player who has to act next (None once the game is over). + observation: Observation | None + # Reward per player for this step: the scaled score change at the end of + # a round, 0 otherwise. + rewards: list[float] + done: bool + events: list[Event] + + +class WizardEnv: + """Turn based environment: each step is one decision of the current player. + + ``num_players`` may be a fixed number or a tuple of player counts to + sample from on every reset, so a single model learns all table sizes. + """ + + def __init__( + self, + num_players: int | tuple[int, ...] = 4, + options: GameOptions | None = None, + seed: int | None = None, + ) -> None: + self._player_counts = (num_players,) if isinstance(num_players, int) else tuple(num_players) + self._options = options or GameOptions() + self._rng = random.Random(seed) + self.game: WizardGame | None = None + + @property + def current_player(self) -> int | None: + return self.game.current_player if self.game else None + + @property + def num_players(self) -> int: + assert self.game is not None, "call reset() first" + return self.game.num_players + + def reset(self) -> Observation: + num_players = self._rng.choice(self._player_counts) + self.game = WizardGame(num_players, self._options, rng=random.Random(self._rng.getrandbits(64))) + return self.observe(self.game.current_player) + + def observe(self, player: int) -> Observation: + assert self.game is not None, "call reset() first" + return self.game.observation(player) + + def step(self, action: Action) -> StepResult: + game = self.game + assert game is not None and game.current_player is not None, "game is over, call reset()" + events = game.apply(game.current_player, action) + + rewards = [0.0] * game.num_players + for event in events: + if isinstance(event, RoundCompleted): + rewards = [change / SCORE_SCALE for change in event.result.score_changes] + + done = game.is_finished + observation = None if done else self.observe(game.current_player) + return StepResult(observation, rewards, done, events) diff --git a/wizard_ai/wizard_ai/training/evaluate.py b/wizard_ai/wizard_ai/training/evaluate.py new file mode 100644 index 0000000..7dcf3cb --- /dev/null +++ b/wizard_ai/wizard_ai/training/evaluate.py @@ -0,0 +1,61 @@ +"""Let agents play against each other to measure their strength.""" + +from __future__ import annotations + +import random +from dataclasses import dataclass + +from wizard_game import GameOptions, WizardGame + +from ..agents import Agent + + +@dataclass +class EvaluationResult: + names: list[str] + games: int + mean_scores: list[float] + win_rates: list[float] + + def __str__(self) -> str: + rows = [f"{'agent':<14}{'mean score':>12}{'win rate':>10}"] + for name, score, win_rate in zip(self.names, self.mean_scores, self.win_rates): + rows.append(f"{name:<14}{score:>12.1f}{win_rate:>10.1%}") + return f"{self.games} games\n" + "\n".join(rows) + + +def evaluate( + agents: list[Agent], + games: int, + options: GameOptions | None = None, + seed: int | None = None, +) -> EvaluationResult: + """Play ``games`` games; seats are rotated so every agent starts everywhere. + + Ties count as a win for every tied agent. + """ + rng = random.Random(seed) + n = len(agents) + totals = [0.0] * n + wins = [0.0] * n + for game_index in range(games): + shift = game_index % n + seating = [agents[(seat + shift) % n] for seat in range(n)] + for agent in seating: + agent.reset() + game = WizardGame(n, options, rng=random.Random(rng.getrandbits(64))) + while not game.is_finished: + player = game.current_player + assert player is not None + game.apply(player, seating[player].act(game.observation(player))) + winners = set(game.winners()) + for seat in range(n): + agent_index = (seat + shift) % n + totals[agent_index] += game.scores[seat] + wins[agent_index] += seat in winners + return EvaluationResult( + names=[agent.name for agent in agents], + games=games, + mean_scores=[total / games for total in totals], + win_rates=[win / games for win in wins], + ) diff --git a/wizard_ai/wizard_ai/training/self_play.py b/wizard_ai/wizard_ai/training/self_play.py new file mode 100644 index 0000000..8c8ace4 --- /dev/null +++ b/wizard_ai/wizard_ai/training/self_play.py @@ -0,0 +1,102 @@ +"""Self-play training loop. + +The trainer lets the learning policy play against copies of itself (or +against baseline agents), collects one trajectory of ``Transition`` objects +per learning seat and game, and hands batches of trajectories to +``Policy.update``. The learning algorithm itself (PPO, DQN, ...) lives in the +policy implementation. +""" + +from __future__ import annotations + +import logging +import random +from dataclasses import dataclass, field +from pathlib import Path + +from wizard_game import GameOptions, Observation + +from ..agents import Agent, HeuristicAgent, ModelAgent, RandomAgent +from ..model.policy import Policy, Transition +from .env import WizardEnv + +logger = logging.getLogger(__name__) + + +@dataclass +class TrainingConfig: + episodes: int = 1000 + player_counts: tuple[int, ...] = (3, 4, 5, 6) + options: GameOptions = field(default_factory=GameOptions) + # Opponents of the learning seat: "self" (all seats learn), "heuristic" or "random". + opponents: str = "self" + episodes_per_update: int = 16 + checkpoint_dir: Path | None = None + checkpoint_every: int = 500 + log_every: int = 100 + seed: int | None = None + + +class SelfPlayTrainer: + def __init__(self, policy: Policy, config: TrainingConfig) -> None: + self.policy = policy + self.config = config + self.env = WizardEnv(config.player_counts, config.options, seed=config.seed) + self._rng = random.Random(config.seed) + + def play_episode(self) -> tuple[list[list[Transition]], list[int]]: + """Play one game. Returns the learners' trajectories and the final scores.""" + observation: Observation | None = self.env.reset() + num_players = self.env.num_players + learners = self._learning_seats(num_players) + trajectories: dict[int, list[Transition]] = {seat: [] for seat in learners} + + def record(obs: Observation, features: list[float], mask: list[bool], index: int) -> None: + trajectories[obs.player].append(Transition(features, mask, index)) + + learner = ModelAgent(self.policy, explore=True, recorder=record) + agents: list[Agent] = [learner if seat in learners else self._opponent() for seat in range(num_players)] + + while observation is not None: + action = agents[observation.player].act(observation) + result = self.env.step(action) + for seat, reward in enumerate(result.rewards): + if reward and seat in trajectories and trajectories[seat]: + trajectories[seat][-1].reward += reward + observation = result.observation + + for trajectory in trajectories.values(): + if trajectory: + trajectory[-1].done = True + assert self.env.game is not None + return list(trajectories.values()), list(self.env.game.scores) + + def train(self) -> None: + config = self.config + batch: list[list[Transition]] = [] + for episode in range(1, config.episodes + 1): + trajectories, _ = self.play_episode() + batch.extend(trajectories) + + if episode % config.episodes_per_update == 0 or episode == config.episodes: + metrics = self.policy.update(batch) + batch = [] + if episode % config.log_every < config.episodes_per_update or episode == config.episodes: + summary = ", ".join(f"{key}={value:.3f}" for key, value in metrics.items()) + logger.info("episode %d/%d: %s", episode, config.episodes, summary) + + if config.checkpoint_dir and (episode % config.checkpoint_every == 0 or episode == config.episodes): + self.policy.save(config.checkpoint_dir) + logger.info("Saved checkpoint to %s", config.checkpoint_dir) + + def _learning_seats(self, num_players: int) -> set[int]: + if self.config.opponents == "self": + return set(range(num_players)) + return {self._rng.randrange(num_players)} + + def _opponent(self) -> Agent: + if self.config.opponents == "heuristic": + return HeuristicAgent() + if self.config.opponents == "random": + return RandomAgent(self._rng.getrandbits(32)) + raise ValueError(f"Unknown opponents {self.config.opponents!r}") diff --git a/wizard_server/.dockerignore b/wizard_server/.dockerignore new file mode 100644 index 0000000..93997f4 --- /dev/null +++ b/wizard_server/.dockerignore @@ -0,0 +1,7 @@ +**/__pycache__ +**/*.pyc +**/*.egg-info +**/.pytest_cache +.venv +tests +game_logic/tests diff --git a/wizard_server/.gitignore b/wizard_server/.gitignore new file mode 100644 index 0000000..1f7327e --- /dev/null +++ b/wizard_server/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +*.egg-info/ +.venv/ +.pytest_cache/ +build/ diff --git a/wizard_server/Dockerfile b/wizard_server/Dockerfile new file mode 100644 index 0000000..15b5649 --- /dev/null +++ b/wizard_server/Dockerfile @@ -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", "*"] diff --git a/wizard_server/README.md b/wizard_server/README.md new file mode 100644 index 0000000..3c82a1b --- /dev/null +++ b/wizard_server/README.md @@ -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 +``` diff --git a/wizard_server/app/__init__.py b/wizard_server/app/__init__.py new file mode 100644 index 0000000..0d95753 --- /dev/null +++ b/wizard_server/app/__init__.py @@ -0,0 +1 @@ +"""Wizard game server: manages players and lobbies and runs games.""" diff --git a/wizard_server/app/ai_bridge.py b/wizard_server/app/ai_bridge.py new file mode 100644 index 0000000..e81cac6 --- /dev/null +++ b/wizard_server/app/ai_bridge.py @@ -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) diff --git a/wizard_server/app/config.py b/wizard_server/app/config.py new file mode 100644 index 0000000..2426c28 --- /dev/null +++ b/wizard_server/app/config.py @@ -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() diff --git a/wizard_server/app/lobby.py b/wizard_server/app/lobby.py new file mode 100644 index 0000000..4498337 --- /dev/null +++ b/wizard_server/app/lobby.py @@ -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 diff --git a/wizard_server/app/main.py b/wizard_server/app/main.py new file mode 100644 index 0000000..cf29ce4 --- /dev/null +++ b/wizard_server/app/main.py @@ -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() diff --git a/wizard_server/app/protocol.py b/wizard_server/app/protocol.py new file mode 100644 index 0000000..e6f0b88 --- /dev/null +++ b/wizard_server/app/protocol.py @@ -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} diff --git a/wizard_server/app/service.py b/wizard_server/app/service.py new file mode 100644 index 0000000..1738c12 --- /dev/null +++ b/wizard_server/app/service.py @@ -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() diff --git a/wizard_server/app/sessions.py b/wizard_server/app/sessions.py new file mode 100644 index 0000000..2756096 --- /dev/null +++ b/wizard_server/app/sessions.py @@ -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) diff --git a/wizard_server/game_logic/.dockerignore b/wizard_server/game_logic/.dockerignore new file mode 100644 index 0000000..43d6e30 --- /dev/null +++ b/wizard_server/game_logic/.dockerignore @@ -0,0 +1,5 @@ +**/__pycache__ +**/*.pyc +**/*.egg-info +.pytest_cache +build diff --git a/wizard_server/game_logic/README.md b/wizard_server/game_logic/README.md new file mode 100644 index 0000000..f26f660 --- /dev/null +++ b/wizard_server/game_logic/README.md @@ -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. diff --git a/wizard_server/game_logic/pyproject.toml b/wizard_server/game_logic/pyproject.toml new file mode 100644 index 0000000..e040d8b --- /dev/null +++ b/wizard_server/game_logic/pyproject.toml @@ -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"] diff --git a/wizard_server/game_logic/tests/test_game.py b/wizard_server/game_logic/tests/test_game.py new file mode 100644 index 0000000..3bd6ae1 --- /dev/null +++ b/wizard_server/game_logic/tests/test_game.py @@ -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 diff --git a/wizard_server/game_logic/tests/test_rules.py b/wizard_server/game_logic/tests/test_rules.py new file mode 100644 index 0000000..eccd5e4 --- /dev/null +++ b/wizard_server/game_logic/tests/test_rules.py @@ -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 diff --git a/wizard_server/game_logic/wizard_game/__init__.py b/wizard_server/game_logic/wizard_game/__init__.py new file mode 100644 index 0000000..d40dd38 --- /dev/null +++ b/wizard_server/game_logic/wizard_game/__init__.py @@ -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", +] diff --git a/wizard_server/game_logic/wizard_game/actions.py b/wizard_server/game_logic/wizard_game/actions.py new file mode 100644 index 0000000..bf7afa8 --- /dev/null +++ b/wizard_server/game_logic/wizard_game/actions.py @@ -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}") diff --git a/wizard_server/game_logic/wizard_game/cards.py b/wizard_server/game_logic/wizard_game/cards.py new file mode 100644 index 0000000..f7c0196 --- /dev/null +++ b/wizard_server/game_logic/wizard_game/cards.py @@ -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 diff --git a/wizard_server/game_logic/wizard_game/game.py b/wizard_server/game_logic/wizard_game/game.py new file mode 100644 index 0000000..51e8038 --- /dev/null +++ b/wizard_server/game_logic/wizard_game/game.py @@ -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 diff --git a/wizard_server/game_logic/wizard_game/observation.py b/wizard_server/game_logic/wizard_game/observation.py new file mode 100644 index 0000000..8cdf96c --- /dev/null +++ b/wizard_server/game_logic/wizard_game/observation.py @@ -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"]), + ) diff --git a/wizard_server/game_logic/wizard_game/rules.py b/wizard_server/game_logic/wizard_game/rules.py new file mode 100644 index 0000000..247839b --- /dev/null +++ b/wizard_server/game_logic/wizard_game/rules.py @@ -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) diff --git a/wizard_server/game_logic/wizard_game/state.py b/wizard_server/game_logic/wizard_game/state.py new file mode 100644 index 0000000..b601b73 --- /dev/null +++ b/wizard_server/game_logic/wizard_game/state.py @@ -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) diff --git a/wizard_server/pytest.ini b/wizard_server/pytest.ini new file mode 100644 index 0000000..adc6f2b --- /dev/null +++ b/wizard_server/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests game_logic/tests diff --git a/wizard_server/requirements-dev.txt b/wizard_server/requirements-dev.txt new file mode 100644 index 0000000..251ae25 --- /dev/null +++ b/wizard_server/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +-e ./game_logic +pytest>=8 +httpx>=0.27 diff --git a/wizard_server/requirements.txt b/wizard_server/requirements.txt new file mode 100644 index 0000000..a4c245d --- /dev/null +++ b/wizard_server/requirements.txt @@ -0,0 +1,2 @@ +fastapi>=0.115,<1 +uvicorn[standard]>=0.30,<1 diff --git a/wizard_server/tests/test_server.py b/wizard_server/tests/test_server.py new file mode 100644 index 0000000..97267b0 --- /dev/null +++ b/wizard_server/tests/test_server.py @@ -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) diff --git a/wizard_website/.dockerignore b/wizard_website/.dockerignore new file mode 100644 index 0000000..8b4f11e --- /dev/null +++ b/wizard_website/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.vite +*.log +Dockerfile +.dockerignore diff --git a/wizard_website/.env.example b/wizard_website/.env.example new file mode 100644 index 0000000..64d3595 --- /dev/null +++ b/wizard_website/.env.example @@ -0,0 +1,3 @@ +# Absolute websocket URL of the game server. Leave empty to use /api/ws on the +# same host the website is served from (recommended behind the Caddy proxy). +VITE_WS_URL= diff --git a/wizard_website/Dockerfile b/wizard_website/Dockerfile new file mode 100644 index 0000000..a202a87 --- /dev/null +++ b/wizard_website/Dockerfile @@ -0,0 +1,19 @@ +# --- Build the static React app --------------------------------------------- +FROM node:22-alpine AS build +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . +# Optional absolute websocket URL of the game server, e.g. wss://api.example.com/api/ws. +# By default the app connects to /api/ws on the host it was loaded from. +ARG VITE_WS_URL= +ENV VITE_WS_URL=${VITE_WS_URL} +RUN npm run build + +# --- Serve it --------------------------------------------------------------- +FROM nginx:1.27-alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 8082 diff --git a/wizard_website/README.md b/wizard_website/README.md index 2d31cb6..aab2f5f 100644 --- a/wizard_website/README.md +++ b/wizard_website/README.md @@ -1,75 +1,25 @@ -# React + TypeScript + Vite +# wizard_website -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) - -## React Compiler - -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +React + TypeScript frontend (Vite). All component styles are CSS modules +(`*.module.css`); the design tokens live on the `.app` root in +`src/App.module.css`. +```bash +npm install +npm run dev # http://localhost:5173, proxies /api (incl. websocket) to localhost:8000 +npm run build +npm run lint ``` -You can also install [eslint-plugin-react-x](https://npmx.dev/package/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://npmx.dev/package/eslint-plugin-react-dom) for React-specific lint rules: +Set `WIZARD_SERVER_URL` to proxy to another server in development, or +`VITE_WS_URL` at build time to connect to an absolute websocket URL. -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' +## Structure -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) - -``` +- `src/api/` — protocol types and the `useWizard` hook (websocket connection, + reconnect with a per-tab session token, game state) +- `src/components/` — reusable UI (button, playing card, modal, toasts) +- `src/screens/HomeScreen` — enter a name, create or join a lobby +- `src/screens/LobbyScreen` — lobby code, players, AI players, rule options, start +- `src/screens/GameScreen` — the table: seats, trick, hand, bidding, trump choice, + scoreboard, round summary, game over diff --git a/wizard_website/index.html b/wizard_website/index.html index 2a5e174..3a50363 100644 --- a/wizard_website/index.html +++ b/wizard_website/index.html @@ -4,7 +4,8 @@ - wizard_website + + Wizard
diff --git a/wizard_website/nginx.conf b/wizard_website/nginx.conf new file mode 100644 index 0000000..1e075cb --- /dev/null +++ b/wizard_website/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 8082; + server_name _; + root /usr/share/nginx/html; + + # Hashed build assets can be cached forever. + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + # Single page app: always serve index.html. + location / { + add_header Cache-Control "no-cache"; + try_files $uri /index.html; + } +} diff --git a/wizard_website/public/favicon.svg b/wizard_website/public/favicon.svg index 6893eb1..f1ba415 100644 --- a/wizard_website/public/favicon.svg +++ b/wizard_website/public/favicon.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/wizard_website/public/icons.svg b/wizard_website/public/icons.svg deleted file mode 100644 index e952219..0000000 --- a/wizard_website/public/icons.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/wizard_website/src/App.css b/wizard_website/src/App.css deleted file mode 100644 index f90339d..0000000 --- a/wizard_website/src/App.css +++ /dev/null @@ -1,184 +0,0 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} diff --git a/wizard_website/src/App.module.css b/wizard_website/src/App.module.css new file mode 100644 index 0000000..df0df48 --- /dev/null +++ b/wizard_website/src/App.module.css @@ -0,0 +1,54 @@ +/* Global element defaults; everything else is scoped to modules. */ +:global(*), +:global(*::before), +:global(*::after) { + box-sizing: border-box; +} + +:global(body) { + margin: 0; +} + +.app { + /* Design tokens, inherited by all components. */ + --bg: #0f0c24; + --bg-elevated: #1a1640; + --bg-sunken: #0a081a; + --surface: #231d52; + --border: #3a3278; + --text: #ece9ff; + --text-muted: #a49fcc; + --accent: #8b6cff; + --accent-strong: #a58cff; + --gold: #ffd166; + --danger: #ff6b7a; + --success: #4fd18b; + --suit-red: #e5484d; + --suit-yellow: #f2b90d; + --suit-green: #2fa46c; + --suit-blue: #3e7bfa; + --radius: 12px; + --shadow: 0 10px 30px rgb(0 0 0 / 35%); + --font: system-ui, 'Segoe UI', Roboto, sans-serif; + + min-height: 100vh; + min-height: 100dvh; + background: + radial-gradient(ellipse at top, rgb(109 74 255 / 25%), transparent 60%), + radial-gradient(ellipse at bottom, rgb(255 209 102 / 8%), transparent 60%), + var(--bg); + color: var(--text); + font: 16px/1.45 var(--font); + -webkit-font-smoothing: antialiased; +} + +.banner { + position: fixed; + inset: 0 0 auto; + z-index: 50; + padding: 8px 16px; + background: #5b2330; + color: #ffe1e6; + text-align: center; + font-size: 14px; +} diff --git a/wizard_website/src/App.tsx b/wizard_website/src/App.tsx index 9ca8e8d..3fb4343 100644 --- a/wizard_website/src/App.tsx +++ b/wizard_website/src/App.tsx @@ -1,121 +1,55 @@ -import { useState } from 'react' -import heroImg from './assets/hero.png' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import './App.css' +import { useEffect } from 'react' +import styles from './App.module.css' +import { useWizard } from './api/useWizard' +import { Toasts } from './components/Toasts/Toasts' +import { GameScreen } from './screens/GameScreen/GameScreen' +import { HomeScreen } from './screens/HomeScreen/HomeScreen' +import { LobbyScreen } from './screens/LobbyScreen/LobbyScreen' + +const STATUS_MESSAGES = { + connecting: 'Connecting to the server…', + closed: 'Connection lost. Reconnecting…', + replaced: 'This game was opened in another window. Reload the page to continue here.', +} as const function App() { - const [count, setCount] = useState(0) + const { status, playerId, lobby, game, completedTrick, completedRound, toasts, dismissToast, actions } = + useWizard() + const lobbyCode = lobby?.code + + // Keep the lobby code in the address bar so the URL can be shared as an invite link. + useEffect(() => { + if (lobbyCode) { + window.history.replaceState(null, '', `?lobby=${lobbyCode}`) + } else if (status === 'open' && window.location.search) { + window.history.replaceState(null, '', window.location.pathname) + } + }, [lobbyCode, status]) + + let screen + if (!lobby || !playerId) { + screen = + } else if (lobby.status === 'waiting' || !game) { + screen = + } else { + screen = ( + + ) + } return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- +
+ {status !== 'open' &&
{STATUS_MESSAGES[status]}
} + {screen} + +
) } diff --git a/wizard_website/src/api/types.ts b/wizard_website/src/api/types.ts new file mode 100644 index 0000000..a28fa17 --- /dev/null +++ b/wizard_website/src/api/types.ts @@ -0,0 +1,121 @@ +// Types of the messages exchanged with the game server (see wizard_server/app/protocol.py +// and wizard_server/game_logic/wizard_game/observation.py). + +export type Suit = 'red' | 'yellow' | 'green' | 'blue' +export type CardKind = 'number' | 'wizard' | 'jester' + +export interface Card { + id: number + kind: CardKind + suit: Suit | null + rank: number +} + +export type Action = + | { type: 'bid'; value: number } + | { type: 'play'; card: number } + | { type: 'trump'; suit: Suit } + +export type Phase = 'trump_selection' | 'bidding' | 'playing' | 'finished' + +export interface GameOptions { + not_equal: boolean + hidden_bids: boolean +} + +export interface PlayedCard { + player: number + card: Card +} + +export interface TrickResult { + round_number: number + trick_number: number + cards: PlayedCard[] + winner: number +} + +export interface RoundResult { + round_number: number + bids: number[] + tricks: number[] + score_changes: number[] + scores: number[] +} + +export interface Observation { + player: number + num_players: number + options: GameOptions + phase: Phase + round_number: number + total_rounds: number + dealer: number + current_player: number | null + hand: Card[] + hand_sizes: number[] + trump_card: Card | null + trump_suit: Suit | null + bids: (number | null)[] + has_bid: boolean[] + tricks_won: number[] + scores: number[] + trick_number: number + trick_leader: number + current_trick: PlayedCard[] + lead_suit: Suit | null + played_cards: Card[] + last_trick: TrickResult | null + round_history: RoundResult[] + legal_actions: Action[] + winners: number[] +} + +export interface LobbyPlayer { + id: string + name: string + is_ai: boolean + connected: boolean +} + +export type LobbyStatus = 'waiting' | 'playing' | 'finished' + +export interface Lobby { + code: string + status: LobbyStatus + host_id: string | null + options: GameOptions + players: LobbyPlayer[] + min_players: number + max_players: number + ai_available: boolean +} + +export interface GameView { + // Seat index of the receiving player. + you: number | null + observation: Observation +} + +export type GameEvent = + | { type: 'trick_completed'; trick: TrickResult } + | { type: 'round_completed'; result: RoundResult } + | { type: 'round_started'; round_number: number; dealer: number; trump_card: Card | null } + | { type: 'game_finished'; scores: number[]; winners: number[] } + +export type ServerMessage = + | { type: 'welcome'; player_id: string; token: string; name: string | null } + | { type: 'state'; lobby: Lobby | null; game: GameView | null; events: GameEvent[] } + | { type: 'error'; message: string } + +export type ClientMessage = + | { type: 'hello'; token: string | null } + | { type: 'create_lobby'; name: string } + | { type: 'join_lobby'; code: string; name: string } + | { type: 'leave_lobby' } + | { type: 'add_ai' } + | { type: 'remove_player'; player_id: string } + | { type: 'set_options'; not_equal: boolean; hidden_bids: boolean } + | { type: 'start_game' } + | { type: 'action'; action: Action } + | { type: 'return_to_lobby' } diff --git a/wizard_website/src/api/useWizard.ts b/wizard_website/src/api/useWizard.ts new file mode 100644 index 0000000..3202f8f --- /dev/null +++ b/wizard_website/src/api/useWizard.ts @@ -0,0 +1,190 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { + Action, + ClientMessage, + GameOptions, + GameView, + Lobby, + RoundResult, + ServerMessage, + TrickResult, +} from './types' + +export type ConnectionStatus = 'connecting' | 'open' | 'closed' | 'replaced' + +export interface Toast { + id: number + message: string +} + +// Per tab, so several tabs can act as different players; survives reloads. +const TOKEN_KEY = 'wizard.token' +const TRICK_DISPLAY_MS = 2200 +const ROUND_DISPLAY_MS = 5000 +const TOAST_MS = 4000 +const MAX_RETRY_DELAY_MS = 10000 +// Close code the server uses when the session was opened in another tab. +const CLOSE_REPLACED = 4000 + +function websocketUrl(): string { + const configured = import.meta.env.VITE_WS_URL as string | undefined + if (configured) return configured + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + return `${protocol}//${window.location.host}/api/ws` +} + +function readToken(): string | null { + try { + return sessionStorage.getItem(TOKEN_KEY) + } catch { + return null + } +} + +function storeToken(token: string) { + try { + sessionStorage.setItem(TOKEN_KEY, token) + } catch { + // Storage unavailable: reconnecting after a reload will start a new session. + } +} + +export function useWizard() { + const [status, setStatus] = useState('connecting') + const [playerId, setPlayerId] = useState(null) + const [lobby, setLobby] = useState(null) + const [game, setGame] = useState(null) + // The most recently completed trick, shown for a moment before it is cleared. + const [completedTrick, setCompletedTrick] = useState(null) + // Result of the round that just ended, shown as a short summary. + const [completedRound, setCompletedRound] = useState(null) + const [toasts, setToasts] = useState([]) + + const socketRef = useRef(null) + const toastId = useRef(0) + + const showError = useCallback((message: string) => { + const id = ++toastId.current + setToasts((current) => [...current, { id, message }]) + setTimeout(() => setToasts((current) => current.filter((toast) => toast.id !== id)), TOAST_MS) + }, []) + + const dismissToast = useCallback((id: number) => { + setToasts((current) => current.filter((toast) => toast.id !== id)) + }, []) + + useEffect(() => { + let disposed = false + let attempt = 0 + let retryTimer: ReturnType | undefined + let trickTimer: ReturnType | undefined + let roundTimer: ReturnType | undefined + + const handleMessage = (message: ServerMessage) => { + switch (message.type) { + case 'welcome': + storeToken(message.token) + setPlayerId(message.player_id) + break + case 'state': { + setLobby(message.lobby) + setGame(message.game) + const trick = message.events.find((event) => event.type === 'trick_completed') + if (trick) { + setCompletedTrick(trick.trick) + clearTimeout(trickTimer) + trickTimer = setTimeout(() => setCompletedTrick(null), TRICK_DISPLAY_MS) + } + const round = message.events.find((event) => event.type === 'round_completed') + if (round) { + setCompletedRound(round.result) + clearTimeout(roundTimer) + roundTimer = setTimeout(() => setCompletedRound(null), ROUND_DISPLAY_MS) + } + if (!message.game) { + setCompletedTrick(null) + setCompletedRound(null) + } + break + } + case 'error': + showError(message.message) + break + } + } + + const connect = () => { + const socket = new WebSocket(websocketUrl()) + socketRef.current = socket + + socket.onopen = () => { + attempt = 0 + setStatus('open') + socket.send(JSON.stringify({ type: 'hello', token: readToken() } satisfies ClientMessage)) + } + socket.onmessage = (event) => { + try { + handleMessage(JSON.parse(event.data as string) as ServerMessage) + } catch (error) { + console.error('Invalid message from server', error) + } + } + socket.onclose = (event) => { + if (disposed) return + socketRef.current = null + if (event.code === CLOSE_REPLACED) { + setStatus('replaced') + return + } + setStatus('closed') + const delay = Math.min(1000 * 2 ** attempt, MAX_RETRY_DELAY_MS) + attempt += 1 + retryTimer = setTimeout(() => { + setStatus('connecting') + connect() + }, delay) + } + } + + connect() + return () => { + disposed = true + clearTimeout(retryTimer) + clearTimeout(trickTimer) + clearTimeout(roundTimer) + socketRef.current?.close() + socketRef.current = null + } + }, [showError]) + + const send = useCallback( + (message: ClientMessage) => { + const socket = socketRef.current + if (socket?.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(message)) + } else { + showError('Not connected to the server') + } + }, + [showError], + ) + + const actions = useMemo( + () => ({ + createLobby: (name: string) => send({ type: 'create_lobby', name }), + joinLobby: (code: string, name: string) => send({ type: 'join_lobby', code, name }), + leaveLobby: () => send({ type: 'leave_lobby' }), + addAI: () => send({ type: 'add_ai' }), + removePlayer: (playerId: string) => send({ type: 'remove_player', player_id: playerId }), + setOptions: (options: GameOptions) => send({ type: 'set_options', ...options }), + startGame: () => send({ type: 'start_game' }), + play: (action: Action) => send({ type: 'action', action }), + returnToLobby: () => send({ type: 'return_to_lobby' }), + }), + [send], + ) + + return { status, playerId, lobby, game, completedTrick, completedRound, toasts, dismissToast, actions } +} + +export type WizardActions = ReturnType['actions'] diff --git a/wizard_website/src/assets/hero.png b/wizard_website/src/assets/hero.png deleted file mode 100644 index 02251f4b956c55af2d76fd0788124d7eee2b45eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13057 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 P00000NkvXXu0mjft=yBf diff --git a/wizard_website/src/assets/react.svg b/wizard_website/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/wizard_website/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/wizard_website/src/assets/vite.svg b/wizard_website/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/wizard_website/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/wizard_website/src/components/Button/Button.module.css b/wizard_website/src/components/Button/Button.module.css new file mode 100644 index 0000000..d70be32 --- /dev/null +++ b/wizard_website/src/components/Button/Button.module.css @@ -0,0 +1,85 @@ +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid transparent; + border-radius: 10px; + font: inherit; + font-weight: 600; + cursor: pointer; + transition: + background-color 0.15s, + border-color 0.15s, + transform 0.1s, + opacity 0.15s; +} + +.button:active:not(:disabled) { + transform: translateY(1px); +} + +.button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.button:focus-visible { + outline: 2px solid var(--gold); + outline-offset: 2px; +} + +.sm { + padding: 4px 10px; + font-size: 13px; +} + +.md { + padding: 9px 16px; + font-size: 15px; +} + +.lg { + padding: 13px 24px; + font-size: 17px; +} + +.primary { + background: linear-gradient(180deg, var(--accent-strong), var(--accent)); + color: #fff; + box-shadow: 0 4px 14px rgb(139 108 255 / 35%); +} + +.primary:hover:not(:disabled) { + background: var(--accent-strong); +} + +.secondary { + background: var(--surface); + border-color: var(--border); + color: var(--text); +} + +.secondary:hover:not(:disabled) { + border-color: var(--accent); +} + +.ghost { + background: transparent; + color: var(--text-muted); +} + +.ghost:hover:not(:disabled) { + color: var(--text); + background: rgb(255 255 255 / 6%); +} + +.danger { + background: transparent; + border-color: rgb(255 107 122 / 50%); + color: var(--danger); +} + +.danger:hover:not(:disabled) { + background: rgb(255 107 122 / 12%); +} diff --git a/wizard_website/src/components/Button/Button.tsx b/wizard_website/src/components/Button/Button.tsx new file mode 100644 index 0000000..2b57c8d --- /dev/null +++ b/wizard_website/src/components/Button/Button.tsx @@ -0,0 +1,14 @@ +import type { ButtonHTMLAttributes } from 'react' +import styles from './Button.module.css' + +type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: Variant + size?: 'sm' | 'md' | 'lg' +} + +export function Button({ variant = 'primary', size = 'md', className, type = 'button', ...props }: ButtonProps) { + const classes = [styles.button, styles[variant], styles[size], className].filter(Boolean).join(' ') + return + )} + +
{children}
+ + + ) +} diff --git a/wizard_website/src/components/PlayingCard/PlayingCard.module.css b/wizard_website/src/components/PlayingCard/PlayingCard.module.css new file mode 100644 index 0000000..3d1e2e5 --- /dev/null +++ b/wizard_website/src/components/PlayingCard/PlayingCard.module.css @@ -0,0 +1,138 @@ +.card { + --card-color: #666; + --w: 72px; + + position: relative; + flex: none; + width: var(--w); + aspect-ratio: 5 / 7.4; + padding: 0; + border: 2px solid var(--card-color); + border-radius: calc(var(--w) * 0.12); + background: + linear-gradient(160deg, rgb(255 255 255 / 100%), rgb(240 238 250 / 100%)); + color: var(--card-color); + font-family: var(--font); + font-weight: 800; + box-shadow: 0 3px 8px rgb(0 0 0 / 35%); + user-select: none; + transition: + transform 0.15s ease, + box-shadow 0.15s ease, + opacity 0.15s ease, + filter 0.15s ease; +} + +.sm { + --w: 44px; +} + +.md { + --w: 72px; +} + +.lg { + --w: 88px; +} + +.red { + --card-color: var(--suit-red); +} + +.yellow { + --card-color: var(--suit-yellow); +} + +.green { + --card-color: var(--suit-green); +} + +.blue { + --card-color: var(--suit-blue); +} + +.wizard { + --card-color: #fff; + + border-color: #c9b8ff; + background: radial-gradient(circle at 50% 35%, #8b6cff, #3b1f9e 75%); +} + +.jester { + --card-color: #3d3a52; + + border-color: #9b98b3; + background: linear-gradient(160deg, #e9e7f2, #c9c6d8); +} + +.back { + border-color: #564aa8; + background: + repeating-linear-gradient(45deg, rgb(255 255 255 / 7%) 0 4px, transparent 4px 9px), + linear-gradient(160deg, #3b2f8f, #211a58); +} + +.corner { + position: absolute; + top: 4%; + left: 8%; + font-size: calc(var(--w) * 0.2); + line-height: 1; +} + +.cornerBottom { + top: auto; + right: 8%; + bottom: 4%; + left: auto; + transform: rotate(180deg); +} + +.center { + position: absolute; + inset: 0; + display: grid; + place-items: center; + font-size: calc(var(--w) * 0.46); + line-height: 1; +} + +.glyph { + font-size: calc(var(--w) * 0.5); + text-shadow: 0 0 12px rgb(255 209 102 / 60%); +} + +.wizard .glyph { + color: var(--gold); +} + +.sm .corner { + display: none; +} + +.playable { + cursor: pointer; + box-shadow: + 0 0 0 2px rgb(255 209 102 / 55%), + 0 3px 8px rgb(0 0 0 / 35%); +} + +.playable:hover, +.playable:focus-visible { + transform: translateY(-12px); + box-shadow: + 0 0 0 3px var(--gold), + 0 10px 20px rgb(0 0 0 / 45%); + outline: none; +} + +.dimmed { + cursor: not-allowed; + filter: grayscale(0.6) brightness(0.6); +} + +.highlighted { + box-shadow: + 0 0 0 3px var(--gold), + 0 0 24px rgb(255 209 102 / 55%); +} diff --git a/wizard_website/src/components/PlayingCard/PlayingCard.tsx b/wizard_website/src/components/PlayingCard/PlayingCard.tsx new file mode 100644 index 0000000..53a4f7c --- /dev/null +++ b/wizard_website/src/components/PlayingCard/PlayingCard.tsx @@ -0,0 +1,57 @@ +import type { Card } from '../../api/types' +import { cardLabel } from '../../util/cards' +import styles from './PlayingCard.module.css' + +interface PlayingCardProps { + card: Card + size?: 'sm' | 'md' | 'lg' + // Clickable card in the hand. + onClick?: () => void + // Rendered dimmed, e.g. a card that must not be played now. + dimmed?: boolean + highlighted?: boolean +} + +export function PlayingCard({ card, size = 'md', onClick, dimmed, highlighted }: PlayingCardProps) { + const kindClass = card.kind === 'number' ? styles[card.suit!] : styles[card.kind] + const classes = [ + styles.card, + styles[size], + kindClass, + onClick && !dimmed ? styles.playable : '', + dimmed ? styles.dimmed : '', + highlighted ? styles.highlighted : '', + ] + .filter(Boolean) + .join(' ') + + const symbol = card.kind === 'wizard' ? 'W' : card.kind === 'jester' ? 'J' : String(card.rank) + const content = ( + <> + {symbol} + + {card.kind === 'wizard' && ✦} + {card.kind === 'jester' && ☾} + {card.kind === 'number' && symbol} + + {symbol} + + ) + + if (onClick) { + return ( + + ) + } + return ( +
+ {content} +
+ ) +} + +export function CardBack({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) { + return