first prototype

This commit is contained in:
2026-09-27 09:52:00 +02:00
parent 4635199a41
commit 6d011d474f
109 changed files with 6354 additions and 507 deletions
+11
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
.env
+15
View File
@@ -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
}
}
+64
View File
@@ -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)
```
+51
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
**/__pycache__
**/*.pyc
.venv
.pytest_cache
tests
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
.venv/
.pytest_cache/
models/*
!models/.gitkeep
+27
View File
@@ -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"]
+27
View File
@@ -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
```
View File
+2
View File
@@ -0,0 +1,2 @@
[pytest]
testpaths = tests
+3
View File
@@ -0,0 +1,3 @@
-r requirements.txt
-e ../wizard_server/game_logic
pytest>=8
+1
View File
@@ -0,0 +1 @@
websockets>=14,<18
+80
View File
@@ -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:])
+7
View File
@@ -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
"""
+83
View File
@@ -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()
+36
View File
@@ -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"]
+26
View File
@@ -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)."""
@@ -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
+33
View File
@@ -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)
@@ -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)
+19
View File
@@ -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")
+14
View File
@@ -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",
]
+158
View File
@@ -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
)
+126
View File
@@ -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}
+3
View File
@@ -0,0 +1,3 @@
from .client import AIService
__all__ = ["AIService"]
+75
View File
@@ -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
+5
View File
@@ -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"]
+72
View File
@@ -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)
+61
View File
@@ -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],
)
+102
View File
@@ -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}")
+7
View File
@@ -0,0 +1,7 @@
**/__pycache__
**/*.pyc
**/*.egg-info
**/.pytest_cache
.venv
tests
game_logic/tests
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
*.egg-info/
.venv/
.pytest_cache/
build/
+26
View File
@@ -0,0 +1,26 @@
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /srv
COPY requirements.txt .
RUN pip install -r requirements.txt
# The game logic is a separate package so the AI player can use it as well.
COPY game_logic ./game_logic
RUN pip install ./game_logic
COPY app ./app
RUN useradd --system --no-create-home wizard
USER wizard
EXPOSE 8083
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8083/api/health', timeout=2)"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8083", "--proxy-headers", "--forwarded-allow-ips", "*"]
+20
View File
@@ -0,0 +1,20 @@
# wizard_server
FastAPI game server. It manages sessions, lobbies and games, and talks to the AI service.
- `game_logic/` — the standalone rules engine (`pip install ./game_logic`, see its README).
- `app/main.py` — routes: `GET /api/health`, `WS /api/ws` (browsers), `WS /internal/ai` (AI service)
- `app/protocol.py` — client message schema
- `app/service.py` — dispatches client messages, reconnects sessions, cleanup
- `app/lobby.py` — lobbies, seats, host actions, running games and AI turns
- `app/ai_bridge.py` — request/response protocol with the AI service, random-move fallback
- `app/sessions.py` — sessions (token based, survive reloads) and ordered websocket sending
Configuration (environment): `AI_SHARED_SECRET`, `AI_DECISION_TIMEOUT` (5s), `AI_MOVE_DELAY` (0.8s),
`TRICK_PAUSE` (1.8s), `SESSION_TTL` (1800s), `MAX_LOBBIES` (1000), `ALLOWED_ORIGINS`, `LOG_LEVEL`.
```bash
pip install -r requirements-dev.txt
uvicorn app.main:app --reload
pytest
```
+1
View File
@@ -0,0 +1 @@
"""Wizard game server: manages players and lobbies and runs games."""
+141
View File
@@ -0,0 +1,141 @@
"""Bridge between the game server and the AI service.
The AI service (``wizard_ai``) connects to ``/internal/ai`` via websocket.
Whenever an AI seat has to act, the server sends it the seat's observation
and waits for an action:
server -> ai: {"type": "decide", "request_id": "...", "observation": {...}}
ai -> server: {"type": "action", "request_id": "...", "action": {...}}
ai -> server: {"type": "error", "request_id": "...", "message": "..."}
Several AI service instances may connect; requests are distributed round
robin. If no instance is connected, it does not answer in time, or it answers
with an illegal action, a random legal action is played instead so a game
never gets stuck.
"""
from __future__ import annotations
import asyncio
import itertools
import logging
import random
import uuid
from collections.abc import Callable
from typing import Any
from starlette.websockets import WebSocket, WebSocketDisconnect
from wizard_game import Action, Observation, action_from_dict
logger = logging.getLogger(__name__)
class AIConnection:
def __init__(self, websocket: WebSocket, name: str) -> None:
self.websocket = websocket
self.name = name
self.pending: dict[str, asyncio.Future[dict[str, Any]]] = {}
self._send_lock = asyncio.Lock()
async def send(self, message: dict[str, Any]) -> None:
async with self._send_lock:
await self.websocket.send_json(message)
def fail_pending(self) -> None:
for future in self.pending.values():
if not future.done():
future.set_exception(ConnectionError("AI service disconnected"))
self.pending.clear()
class AIBridge:
def __init__(
self,
timeout: float,
rng: random.Random | None = None,
on_change: Callable[[], None] | None = None,
) -> None:
self.timeout = timeout
# Called whenever an AI service connects or disconnects.
self._on_change = on_change
self._connections: list[AIConnection] = []
self._round_robin = itertools.count()
self._rng = rng or random.Random()
@property
def available(self) -> bool:
return bool(self._connections)
async def decide(self, observation: Observation) -> Action:
"""Ask the AI service for an action; falls back to a random legal one."""
legal = observation.legal_actions
if not legal:
raise ValueError("Observation has no legal actions")
connection = self._pick_connection()
if connection is None:
return self._fallback(observation, "no AI service connected", logging.DEBUG)
request_id = uuid.uuid4().hex
future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
connection.pending[request_id] = future
try:
await connection.send({
"type": "decide",
"request_id": request_id,
"observation": observation.to_dict(),
})
reply = await asyncio.wait_for(future, self.timeout)
except (TimeoutError, ConnectionError, RuntimeError, WebSocketDisconnect) as exc:
return self._fallback(observation, f"AI request failed: {exc!r}")
finally:
connection.pending.pop(request_id, None)
if reply.get("type") != "action":
return self._fallback(observation, f"AI error: {reply.get('message')}")
try:
action = action_from_dict(reply.get("action", {}))
except ValueError as exc:
return self._fallback(observation, f"malformed AI action: {exc}")
if action not in legal:
return self._fallback(observation, f"illegal AI action {action}")
return action
async def serve(self, websocket: WebSocket) -> None:
"""Handle one AI service connection until it disconnects."""
connection = AIConnection(websocket, name=websocket.client.host if websocket.client else "?")
self._connections.append(connection)
logger.info("AI service connected from %s (%d connected)", connection.name, len(self._connections))
self._notify_change()
try:
while True:
message = await websocket.receive_json()
if not isinstance(message, dict):
continue
if message.get("type") == "hello":
logger.info("AI service %s: %s", connection.name, message.get("agent"))
continue
future = connection.pending.get(str(message.get("request_id")))
if future and not future.done():
future.set_result(message)
except (WebSocketDisconnect, RuntimeError, ValueError):
pass
finally:
self._connections.remove(connection)
connection.fail_pending()
logger.info("AI service %s disconnected (%d connected)", connection.name, len(self._connections))
self._notify_change()
def _notify_change(self) -> None:
if self._on_change is not None:
self._on_change()
def _pick_connection(self) -> AIConnection | None:
if not self._connections:
return None
return self._connections[next(self._round_robin) % len(self._connections)]
def _fallback(self, observation: Observation, reason: str, level: int = logging.WARNING) -> Action:
logger.log(level, "Playing random move for player %d: %s", observation.player, reason)
return self._rng.choice(observation.legal_actions)
+39
View File
@@ -0,0 +1,39 @@
"""Server configuration, read from environment variables."""
from __future__ import annotations
import os
from dataclasses import dataclass
def _float(name: str, default: float) -> float:
return float(os.environ.get(name, default))
def _int(name: str, default: int) -> int:
return int(os.environ.get(name, default))
@dataclass(frozen=True)
class Settings:
# Shared secret the AI service has to present on /internal/ai.
# Empty disables authentication (development only).
ai_shared_secret: str = os.environ.get("AI_SHARED_SECRET", "")
# Seconds to wait for the AI service before falling back to a random move.
ai_decision_timeout: float = _float("AI_DECISION_TIMEOUT", 5.0)
# Minimum "thinking" time of AI players so humans can follow the game.
ai_move_delay: float = _float("AI_MOVE_DELAY", 0.8)
# Pause after a completed trick before AI players continue.
trick_pause: float = _float("TRICK_PAUSE", 1.8)
# Sessions without a connection are dropped after this many seconds.
session_ttl: float = _float("SESSION_TTL", 1800.0)
# Maximum number of simultaneously existing lobbies.
max_lobbies: int = _int("MAX_LOBBIES", 1000)
# Comma separated list of allowed websocket origins; empty allows all.
allowed_origins: tuple[str, ...] = tuple(
origin.strip() for origin in os.environ.get("ALLOWED_ORIGINS", "").split(",") if origin.strip()
)
log_level: str = os.environ.get("LOG_LEVEL", "INFO")
settings = Settings()
+329
View File
@@ -0,0 +1,329 @@
"""Lobbies: a group of seats (humans and AI players) that play one game."""
from __future__ import annotations
import asyncio
import logging
import secrets
from collections.abc import Iterable
from dataclasses import dataclass
from enum import Enum
from typing import Any
from wizard_game import (
MAX_PLAYERS,
MIN_PLAYERS,
Event,
GameFinished,
GameOptions,
IllegalActionError,
TrickCompleted,
WizardGame,
action_from_dict,
)
from .ai_bridge import AIBridge
from .config import Settings
from .sessions import Session
logger = logging.getLogger(__name__)
LOBBY_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O, 1/I
LOBBY_CODE_LENGTH = 6
AI_NAMES = (
"Merlin", "Morgana", "Gandalf", "Radagast", "Rincewind", "Circe",
"Prospero", "Elminster", "Saruman", "Medea", "Zatanna", "Raistlin",
)
class LobbyError(Exception):
"""An error that is reported to the client that caused it."""
class LobbyStatus(str, Enum):
WAITING = "waiting"
PLAYING = "playing"
FINISHED = "finished"
@dataclass
class Seat:
player_id: str
name: str
is_ai: bool = False
session: Session | None = None
@property
def connected(self) -> bool:
return self.is_ai or (self.session is not None and self.session.connected)
class Lobby:
def __init__(self, code: str, bridge: AIBridge, settings: Settings) -> None:
self.code = code
self.seats: list[Seat] = []
self.host_id: str | None = None
self.options = GameOptions()
self.status = LobbyStatus.WAITING
self.game: WizardGame | None = None
self._bridge = bridge
self._settings = settings
self._ai_task: asyncio.Task | None = None
self._pause_until = 0.0
self._closed = False
# --- Queries ------------------------------------------------------------
@property
def has_humans(self) -> bool:
return any(not seat.is_ai for seat in self.seats)
def seat_index(self, player_id: str) -> int | None:
for index, seat in enumerate(self.seats):
if seat.player_id == player_id:
return index
return None
def view(self) -> dict[str, Any]:
return {
"code": self.code,
"status": self.status.value,
"host_id": self.host_id,
"options": self.options.to_dict(),
"players": [
{
"id": seat.player_id,
"name": seat.name,
"is_ai": seat.is_ai,
"connected": seat.connected,
}
for seat in self.seats
],
"min_players": MIN_PLAYERS,
"max_players": MAX_PLAYERS,
"ai_available": self._bridge.available,
}
def state_for(self, session: Session, events: Iterable[Event] = ()) -> dict[str, Any]:
game_view = None
if self.game is not None:
you = self.seat_index(session.player_id)
observation = self.game.observation(you if you is not None else 0)
data = observation.to_dict()
if you is None: # not seated (should not happen): hide private info
data["hand"], data["legal_actions"] = [], []
game_view = {"you": you, "observation": data}
return {
"type": "state",
"lobby": self.view(),
"game": game_view,
"events": [event.to_dict() for event in events],
}
# --- Membership ---------------------------------------------------------
def add_human(self, session: Session) -> None:
index = self.seat_index(session.player_id)
if index is not None: # already seated, e.g. after a reconnect
self.seats[index].session = session
return
if self.status is not LobbyStatus.WAITING:
raise LobbyError("The game in this lobby has already started")
if len(self.seats) >= MAX_PLAYERS:
raise LobbyError("This lobby is full")
assert session.name is not None
self.seats.append(Seat(session.player_id, self._unique_name(session.name), session=session))
if self.host_id is None:
self.host_id = session.player_id
def add_ai(self, by: Session) -> None:
self._require_host(by)
self._require_waiting()
if len(self.seats) >= MAX_PLAYERS:
raise LobbyError("This lobby is full")
taken = {seat.name for seat in self.seats}
name = next((n for n in AI_NAMES if n not in taken), f"Bot {len(self.seats) + 1}")
self.seats.append(Seat(f"ai-{secrets.token_hex(4)}", name, is_ai=True))
def remove_player(self, by: Session, player_id: str) -> Seat:
self._require_host(by)
self._require_waiting()
if player_id == by.player_id:
raise LobbyError("Use 'leave' to leave the lobby")
index = self.seat_index(player_id)
if index is None:
raise LobbyError("No such player")
return self.seats.pop(index)
def leave(self, session: Session) -> None:
index = self.seat_index(session.player_id)
if index is None:
return
seat = self.seats[index]
if self.status is LobbyStatus.WAITING:
self.seats.pop(index)
else:
# Keep the seat so the game can go on: an AI player takes over.
seat.is_ai = True
seat.session = None
seat.player_id = f"ai-{secrets.token_hex(4)}"
self._schedule_ai()
if self.host_id == session.player_id:
self.host_id = next((s.player_id for s in self.seats if not s.is_ai), None)
# --- Host actions -------------------------------------------------------
def set_options(self, by: Session, options: GameOptions) -> None:
self._require_host(by)
self._require_waiting()
self.options = options
def start(self, by: Session) -> None:
self._require_host(by)
self._require_waiting()
if not MIN_PLAYERS <= len(self.seats) <= MAX_PLAYERS:
raise LobbyError(f"Wizard needs {MIN_PLAYERS} to {MAX_PLAYERS} players")
self.game = WizardGame(len(self.seats), self.options)
self.status = LobbyStatus.PLAYING
self._pause_until = 0.0
logger.info("Lobby %s: game started with %d players", self.code, len(self.seats))
self._schedule_ai()
def return_to_lobby(self, by: Session) -> None:
self._require_host(by)
if self.status is not LobbyStatus.FINISHED:
raise LobbyError("The game is not finished yet")
self.status = LobbyStatus.WAITING
self.game = None
# --- Game actions -------------------------------------------------------
def apply_action(self, session: Session, action_data: dict[str, Any]) -> None:
if self.status is not LobbyStatus.PLAYING or self.game is None:
raise LobbyError("No game is running")
index = self.seat_index(session.player_id)
if index is None:
raise LobbyError("You are not playing in this game")
try:
action = action_from_dict(action_data)
events = self.game.apply(index, action)
except (ValueError, IllegalActionError) as exc:
raise LobbyError(str(exc)) from exc
self._after_action(events)
# --- Broadcasting -------------------------------------------------------
def broadcast(self, events: Iterable[Event] = ()) -> None:
events = list(events)
for seat in self.seats:
if seat.session is not None:
seat.session.send(self.state_for(seat.session, events))
def close(self) -> None:
self._closed = True
if self._ai_task and not self._ai_task.done():
self._ai_task.cancel()
# --- Internals ----------------------------------------------------------
def _require_host(self, session: Session) -> None:
if session.player_id != self.host_id:
raise LobbyError("Only the host can do that")
def _require_waiting(self) -> None:
if self.status is not LobbyStatus.WAITING:
raise LobbyError("The game has already started")
def _unique_name(self, name: str) -> str:
taken = {seat.name for seat in self.seats}
candidate, n = name, 2
while candidate in taken:
candidate = f"{name} ({n})"
n += 1
return candidate
def _after_action(self, events: list[Event]) -> None:
loop = asyncio.get_running_loop()
if any(isinstance(e, TrickCompleted) for e in events):
self._pause_until = loop.time() + self._settings.trick_pause
if any(isinstance(e, GameFinished) for e in events):
self.status = LobbyStatus.FINISHED
logger.info("Lobby %s: game finished", self.code)
self.broadcast(events)
self._schedule_ai()
def _current_ai_seat(self) -> int | None:
game = self.game
if self._closed or self.status is not LobbyStatus.PLAYING or game is None:
return None
index = game.current_player
if index is None or not self.seats[index].is_ai:
return None
return index
def _schedule_ai(self) -> None:
if self._ai_task and not self._ai_task.done():
return # the running loop picks up the new state
if self._current_ai_seat() is not None:
self._ai_task = asyncio.create_task(self._ai_loop())
async def _ai_loop(self) -> None:
loop = asyncio.get_running_loop()
try:
while (index := self._current_ai_seat()) is not None:
game = self.game
assert game is not None
started = loop.time()
wait_until = max(started + self._settings.ai_move_delay, self._pause_until)
action = await self._bridge.decide(game.observation(index))
if (remaining := wait_until - loop.time()) > 0:
await asyncio.sleep(remaining)
# The state may have changed while waiting (e.g. game restarted).
if self.game is not game or self._current_ai_seat() != index:
continue
if not game.is_legal(index, action):
continue
self._after_action(game.apply(index, action))
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Lobby %s: AI loop crashed", self.code)
class LobbyManager:
def __init__(self, bridge: AIBridge, settings: Settings) -> None:
self._bridge = bridge
self._settings = settings
self._lobbies: dict[str, Lobby] = {}
def __len__(self) -> int:
return len(self._lobbies)
def __iter__(self):
return iter(list(self._lobbies.values()))
def create(self) -> Lobby:
if len(self._lobbies) >= self._settings.max_lobbies:
raise LobbyError("Too many open lobbies, please try again later")
while True:
code = "".join(secrets.choice(LOBBY_CODE_ALPHABET) for _ in range(LOBBY_CODE_LENGTH))
if code not in self._lobbies:
break
lobby = Lobby(code, self._bridge, self._settings)
self._lobbies[code] = lobby
logger.info("Lobby %s created (%d open)", code, len(self._lobbies))
return lobby
def get(self, code: str | None) -> Lobby | None:
if not code:
return None
return self._lobbies.get(code.upper())
def discard_if_abandoned(self, lobby: Lobby) -> bool:
if lobby.has_humans:
return False
lobby.close()
self._lobbies.pop(lobby.code, None)
logger.info("Lobby %s closed (%d open)", lobby.code, len(self._lobbies))
return True
+77
View File
@@ -0,0 +1,77 @@
"""FastAPI application of the Wizard game server.
Routes:
- ``GET /api/health`` health check
- ``WS /api/ws`` web clients (see ``app.protocol``)
- ``WS /internal/ai`` AI service (see ``app.ai_bridge``); not meant to be
exposed by the reverse proxy and protected by ``AI_SHARED_SECRET``
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import secrets
from collections.abc import AsyncIterator
from fastapi import FastAPI, WebSocket
from .config import Settings, settings
from .service import WizardService
logger = logging.getLogger(__name__)
def create_app(config: Settings = settings) -> FastAPI:
service = WizardService(config)
@contextlib.asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
if not config.ai_shared_secret:
logger.warning("AI_SHARED_SECRET is not set, /internal/ai is unauthenticated")
cleanup = asyncio.create_task(service.cleanup_loop())
try:
yield
finally:
cleanup.cancel()
app = FastAPI(title="Wizard game server", lifespan=lifespan, docs_url=None, redoc_url=None)
app.state.service = service
@app.get("/api/health")
async def health() -> dict:
return {
"status": "ok",
"lobbies": len(service.lobbies),
"sessions": len(service.sessions),
"ai_available": service.bridge.available,
}
@app.websocket("/api/ws")
async def client_socket(websocket: WebSocket) -> None:
await service.handle_client(websocket)
@app.websocket("/internal/ai")
async def ai_socket(websocket: WebSocket) -> None:
if not _ai_authorized(websocket, config.ai_shared_secret):
await websocket.close(code=1008)
return
await service.handle_ai(websocket)
return app
def _ai_authorized(websocket: WebSocket, secret: str) -> bool:
if not secret:
return True
header = websocket.headers.get("authorization", "")
token = header.removeprefix("Bearer ").strip() if header.startswith("Bearer ") else ""
return secrets.compare_digest(token.encode(), secret.encode())
logging.basicConfig(
level=settings.log_level.upper(),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
app = create_app()
+80
View File
@@ -0,0 +1,80 @@
"""Messages exchanged with web clients over the ``/api/ws`` websocket.
Client -> server messages are validated with pydantic. Server -> client
messages are plain dicts:
- ``{"type": "welcome", "player_id", "token", "name"}``
- ``{"type": "state", "lobby": LobbyView | null, "game": GameView | null, "events": [...]}``
- ``{"type": "error", "message"}``
"""
from __future__ import annotations
from typing import Annotated, Any, Literal
from pydantic import BaseModel, Field, StringConstraints, TypeAdapter
PlayerName = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=20)]
LobbyCode = Annotated[str, StringConstraints(strip_whitespace=True, to_upper=True, min_length=4, max_length=12)]
class Hello(BaseModel):
type: Literal["hello"]
# Token of a previous session, used to reconnect after a page reload.
token: str | None = None
class CreateLobby(BaseModel):
type: Literal["create_lobby"]
name: PlayerName
class JoinLobby(BaseModel):
type: Literal["join_lobby"]
code: LobbyCode
name: PlayerName
class LeaveLobby(BaseModel):
type: Literal["leave_lobby"]
class AddAI(BaseModel):
type: Literal["add_ai"]
class RemovePlayer(BaseModel):
type: Literal["remove_player"]
player_id: str
class SetOptions(BaseModel):
type: Literal["set_options"]
not_equal: bool
hidden_bids: bool
class StartGame(BaseModel):
type: Literal["start_game"]
class GameAction(BaseModel):
type: Literal["action"]
action: dict[str, Any]
class ReturnToLobby(BaseModel):
type: Literal["return_to_lobby"]
ClientMessage = Annotated[
Hello | CreateLobby | JoinLobby | LeaveLobby | AddAI | RemovePlayer
| SetOptions | StartGame | GameAction | ReturnToLobby,
Field(discriminator="type"),
]
client_message_adapter: TypeAdapter[ClientMessage] = TypeAdapter(ClientMessage)
def error(message: str) -> dict:
return {"type": "error", "message": message}
+211
View File
@@ -0,0 +1,211 @@
"""Connects web clients, sessions and lobbies."""
from __future__ import annotations
import asyncio
import contextlib
import logging
import time
from pydantic import ValidationError
from starlette.websockets import WebSocket, WebSocketDisconnect
from wizard_game import GameOptions
from . import protocol
from .ai_bridge import AIBridge
from .config import Settings
from .lobby import Lobby, LobbyError, LobbyManager
from .sessions import Connection, Session, SessionRegistry
logger = logging.getLogger(__name__)
CLEANUP_INTERVAL = 30.0
class WizardService:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.sessions = SessionRegistry()
self.bridge = AIBridge(settings.ai_decision_timeout, on_change=self._on_ai_availability_changed)
self.lobbies = LobbyManager(self.bridge, settings)
# --- Client connections -------------------------------------------------
async def handle_client(self, websocket: WebSocket) -> None:
origin = websocket.headers.get("origin")
if self.settings.allowed_origins and origin not in self.settings.allowed_origins:
await websocket.close(code=1008)
return
await websocket.accept()
connection = Connection(websocket)
connection.start()
session: Session | None = None
try:
while True:
raw = await websocket.receive_text()
try:
message = protocol.client_message_adapter.validate_json(raw)
except ValidationError as exc:
connection.send(protocol.error(_validation_message(exc)))
continue
if isinstance(message, protocol.Hello):
session = self._hello(connection, message.token)
continue
if session is None or session.connection is not connection:
connection.send(protocol.error("Send 'hello' first"))
continue
try:
self._dispatch(session, message)
except LobbyError as exc:
session.send(protocol.error(str(exc)))
except WebSocketDisconnect:
pass
finally:
if session is not None and session.connection is connection:
session.connection = None
session.last_seen = time.monotonic()
if lobby := self.lobbies.get(session.lobby_code):
lobby.broadcast()
await connection.close()
def _hello(self, connection: Connection, token: str | None) -> Session:
session = self.sessions.get(token)
if session is None:
session = self.sessions.create()
elif session.connection is not None and session.connection is not connection:
old = session.connection
old.send(protocol.error("This session was opened in another window"))
asyncio.create_task(old.close(code=4000))
session.connection = connection
session.last_seen = time.monotonic()
session.send({
"type": "welcome",
"player_id": session.player_id,
"token": session.token,
"name": session.name,
})
lobby = self.lobbies.get(session.lobby_code)
if lobby is not None and lobby.seat_index(session.player_id) is not None:
lobby.add_human(session) # re-binds the seat to this session
lobby.broadcast()
else:
session.lobby_code = None
self._send_no_lobby(session)
return session
def _dispatch(self, session: Session, message: object) -> None:
match message:
case protocol.CreateLobby(name=name):
self._leave_current_lobby(session)
session.name = name
lobby = self.lobbies.create()
self._join(session, lobby)
case protocol.JoinLobby(code=code, name=name):
lobby = self.lobbies.get(code)
if lobby is None:
raise LobbyError(f"Lobby {code} does not exist")
if session.lobby_code != lobby.code:
self._leave_current_lobby(session)
session.name = name
self._join(session, lobby)
case protocol.LeaveLobby():
self._leave_current_lobby(session)
self._send_no_lobby(session)
case protocol.AddAI():
lobby = self._lobby_of(session)
lobby.add_ai(session)
lobby.broadcast()
case protocol.RemovePlayer(player_id=player_id):
lobby = self._lobby_of(session)
seat = lobby.remove_player(session, player_id)
if seat.session is not None:
seat.session.lobby_code = None
seat.session.send(protocol.error("You were removed from the lobby"))
self._send_no_lobby(seat.session)
lobby.broadcast()
case protocol.SetOptions(not_equal=not_equal, hidden_bids=hidden_bids):
lobby = self._lobby_of(session)
lobby.set_options(session, GameOptions(not_equal=not_equal, hidden_bids=hidden_bids))
lobby.broadcast()
case protocol.StartGame():
lobby = self._lobby_of(session)
lobby.start(session)
lobby.broadcast()
case protocol.ReturnToLobby():
lobby = self._lobby_of(session)
lobby.return_to_lobby(session)
lobby.broadcast()
case protocol.GameAction(action=action):
self._lobby_of(session).apply_action(session, action)
case _:
raise LobbyError("Unsupported message")
def _join(self, session: Session, lobby: Lobby) -> None:
try:
lobby.add_human(session)
except LobbyError:
self.lobbies.discard_if_abandoned(lobby)
raise
session.lobby_code = lobby.code
lobby.broadcast()
def _leave_current_lobby(self, session: Session) -> None:
lobby = self.lobbies.get(session.lobby_code)
session.lobby_code = None
if lobby is None:
return
lobby.leave(session)
if not self.lobbies.discard_if_abandoned(lobby):
lobby.broadcast()
def _lobby_of(self, session: Session) -> Lobby:
lobby = self.lobbies.get(session.lobby_code)
if lobby is None:
raise LobbyError("You are not in a lobby")
return lobby
@staticmethod
def _send_no_lobby(session: Session) -> None:
session.send({"type": "state", "lobby": None, "game": None, "events": []})
# --- AI service ---------------------------------------------------------
async def handle_ai(self, websocket: WebSocket) -> None:
await websocket.accept()
try:
await self.bridge.serve(websocket)
finally:
with contextlib.suppress(Exception):
await websocket.close()
def _on_ai_availability_changed(self) -> None:
# Lobbies show whether the AI service is available.
for lobby in self.lobbies:
lobby.broadcast()
# --- Housekeeping -------------------------------------------------------
async def cleanup_loop(self) -> None:
while True:
await asyncio.sleep(CLEANUP_INTERVAL)
try:
self.cleanup()
except Exception:
logger.exception("Cleanup failed")
def cleanup(self) -> None:
for session in self.sessions.expired(self.settings.session_ttl):
self._leave_current_lobby(session)
self.sessions.remove(session)
def _validation_message(exc: ValidationError) -> str:
errors = exc.errors()
if not errors:
return "Invalid message"
first = errors[0]
location = ".".join(str(part) for part in first.get("loc", ()) if part)
return f"Invalid message: {location} {first.get('msg', '')}".strip()
+93
View File
@@ -0,0 +1,93 @@
"""Player sessions and their websocket connections.
A ``Session`` identifies a human player across reconnects (e.g. a page
reload) via a secret token. A ``Connection`` wraps one websocket and owns an
outgoing message queue so that messages are sent in order and never
concurrently.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import secrets
import time
import uuid
from typing import Any
from starlette.websockets import WebSocket
logger = logging.getLogger(__name__)
class Connection:
def __init__(self, websocket: WebSocket) -> None:
self.websocket = websocket
self._queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue()
self._writer: asyncio.Task | None = None
def start(self) -> None:
self._writer = asyncio.create_task(self._write_loop())
def send(self, message: dict[str, Any]) -> None:
self._queue.put_nowait(message)
async def close(self, code: int = 1000) -> None:
self._queue.put_nowait(None)
if self._writer:
with contextlib.suppress(Exception):
await self._writer
with contextlib.suppress(Exception):
await self.websocket.close(code)
async def _write_loop(self) -> None:
while (message := await self._queue.get()) is not None:
try:
await self.websocket.send_json(message)
except Exception: # connection is gone; the reader loop cleans up
logger.debug("Failed to send message", exc_info=True)
return
class Session:
def __init__(self) -> None:
self.player_id = uuid.uuid4().hex[:12]
self.token = secrets.token_urlsafe(24)
self.name: str | None = None
self.lobby_code: str | None = None
self.connection: Connection | None = None
self.last_seen = time.monotonic()
@property
def connected(self) -> bool:
return self.connection is not None
def send(self, message: dict[str, Any]) -> None:
if self.connection:
self.connection.send(message)
class SessionRegistry:
def __init__(self) -> None:
self._by_token: dict[str, Session] = {}
def create(self) -> Session:
session = Session()
self._by_token[session.token] = session
return session
def get(self, token: str | None) -> Session | None:
if not token:
return None
return self._by_token.get(token)
def remove(self, session: Session) -> None:
self._by_token.pop(session.token, None)
def expired(self, ttl: float) -> list[Session]:
now = time.monotonic()
return [s for s in self._by_token.values() if not s.connected and now - s.last_seen > ttl]
def __len__(self) -> int:
return len(self._by_token)
+5
View File
@@ -0,0 +1,5 @@
**/__pycache__
**/*.pyc
**/*.egg-info
.pytest_cache
build
+26
View File
@@ -0,0 +1,26 @@
# wizard_game
Pure-Python rules engine of Wizard (no dependencies). It is used by the game
server and can be installed on its own to train AI players:
```bash
pip install ./wizard_server/game_logic
```
```python
import random
from wizard_game import WizardGame, GameOptions
game = WizardGame(num_players=4, options=GameOptions(not_equal=True), seed=1)
rng = random.Random(1)
while not game.is_finished:
player = game.current_player
obs = game.observation(player) # what this player may see
action = rng.choice(obs.legal_actions) # Bid / PlayCard / ChooseTrump
events = game.apply(player, action) # TrickCompleted, RoundCompleted, ...
print(game.scores, game.winners())
```
`Observation.to_dict()` / `Observation.from_dict()` and
`action_to_dict()` / `action_from_dict()` define the JSON format that the
server sends to web clients and to the AI service.
+16
View File
@@ -0,0 +1,16 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "wizard-game"
version = "0.1.0"
description = "Rules engine of the Wizard card game, shared by the game server and the AI player."
requires-python = ">=3.11"
dependencies = []
[tool.setuptools]
packages = ["wizard_game"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+158
View File
@@ -0,0 +1,158 @@
import json
import random
import pytest
from wizard_game import (
Bid,
ChooseTrump,
GameFinished,
GameOptions,
IllegalActionError,
Observation,
Phase,
PlayCard,
RoundCompleted,
Suit,
WizardGame,
action_from_dict,
card_by_id,
)
from wizard_game.cards import WIZARD_ID_OFFSET
def play_random_game(num_players: int, seed: int, options: GameOptions | None = None) -> WizardGame:
game = WizardGame(num_players, options, seed=seed)
rng = random.Random(seed)
rounds_completed = 0
while not game.is_finished:
player = game.current_player
actions = game.legal_actions()
assert actions, "current player must always have a legal action"
events = game.apply(player, rng.choice(actions))
rounds_completed += sum(isinstance(e, RoundCompleted) for e in events)
assert rounds_completed == game.total_rounds
return game
@pytest.mark.parametrize("num_players", [3, 4, 5, 6])
@pytest.mark.parametrize("seed", range(5))
def test_random_games_complete(num_players, seed):
options = GameOptions(not_equal=seed % 2 == 0, hidden_bids=seed % 3 == 0)
game = play_random_game(num_players, seed, options)
assert game.phase is Phase.FINISHED
assert len(game.round_history) == game.total_rounds
assert game.scores == list(game.round_history[-1].scores)
for result in game.round_history:
assert sum(result.tricks) == result.round_number
if options.not_equal:
assert sum(result.bids) != result.round_number
def test_invalid_player_count():
with pytest.raises(ValueError):
WizardGame(2)
with pytest.raises(ValueError):
WizardGame(7)
def test_deal_sizes_and_starter():
game = WizardGame(4, seed=3, first_dealer=2)
assert game.round_number == 1
assert all(len(game.hand(p)) == 1 for p in range(4))
if game.phase is Phase.BIDDING:
assert game.current_player == 3
def test_wrong_player_rejected():
game = WizardGame(3, seed=1, first_dealer=0)
other = (game.current_player + 1) % 3
with pytest.raises(IllegalActionError):
game.apply(other, game.legal_actions()[0])
def test_illegal_bid_rejected():
game = WizardGame(3, seed=1, first_dealer=0)
if game.phase is Phase.TRUMP_SELECTION:
game.apply(game.current_player, ChooseTrump(Suit.RED))
with pytest.raises(IllegalActionError):
game.apply(game.current_player, Bid(5))
def test_wizard_trump_card_lets_dealer_choose():
for seed in range(200):
game = WizardGame(4, seed=seed, first_dealer=1)
if game.round.trump_card and game.round.trump_card.is_wizard:
break
else:
pytest.fail("no seed produced a wizard as trump card")
assert game.phase is Phase.TRUMP_SELECTION
assert game.current_player == 1
game.apply(1, ChooseTrump(Suit.GREEN))
assert game.round.trump_suit is Suit.GREEN
assert game.phase is Phase.BIDDING
assert game.current_player == 2
def test_not_equal_restricts_last_bidder():
game = WizardGame(3, GameOptions(not_equal=True), seed=11, first_dealer=0)
if game.phase is Phase.TRUMP_SELECTION:
game.apply(game.current_player, ChooseTrump(Suit.RED))
game.apply(1, Bid(0))
game.apply(2, Bid(0))
assert game.legal_actions(0) == [Bid(0)] # bidding 1 would make the sum equal 1
def test_hidden_bids():
game = WizardGame(3, GameOptions(hidden_bids=True), seed=5, first_dealer=0)
if game.phase is Phase.TRUMP_SELECTION:
game.apply(game.current_player, ChooseTrump(Suit.RED))
game.apply(1, Bid(1))
assert game.observation(1).bids[1] == 1
assert game.observation(2).bids[1] is None
assert game.observation(2).has_bid[1] is True
game.apply(2, Bid(0))
game.apply(0, Bid(0))
assert game.observation(2).bids == (0, 1, 0)
def test_last_round_has_no_trump():
game = play_random_game(3, seed=7)
assert game.round.round_number == 20
assert game.round.trump_card is None
def test_observation_hides_other_hands_and_round_trips():
game = WizardGame(5, seed=9)
obs = game.observation(0)
assert obs.hand == tuple(sorted(game.hand(0), key=lambda c: c.id))
data = json.loads(json.dumps(obs.to_dict()))
assert Observation.from_dict(data) == obs
def test_action_round_trip():
for action in (Bid(3), PlayCard(card_by_id(WIZARD_ID_OFFSET)), ChooseTrump(Suit.BLUE)):
assert action_from_dict(action.to_dict()) == action
with pytest.raises(ValueError):
action_from_dict({"type": "bid", "value": True})
with pytest.raises(ValueError):
action_from_dict({"type": "play", "card": 99})
def test_game_finished_event_and_winners():
game = WizardGame(3, seed=2)
rng = random.Random(2)
last_events = []
while not game.is_finished:
last_events = game.apply(game.current_player, rng.choice(game.legal_actions()))
finished = [e for e in last_events if isinstance(e, GameFinished)]
assert finished and list(finished[0].winners) == game.winners()
assert game.observation(0).winners == tuple(game.winners())
assert game.legal_actions() == []
def test_clone_is_independent():
game = WizardGame(4, seed=4)
clone = game.clone()
clone.apply(clone.current_player, clone.legal_actions()[0])
assert game.observation(0) != clone.observation(0) or game.current_player != clone.current_player
@@ -0,0 +1,79 @@
from wizard_game import Suit, card_by_id
from wizard_game.cards import JESTER_ID_OFFSET, WIZARD_ID_OFFSET
from wizard_game.rules import lead_suit, legal_cards, round_score, total_rounds, trick_winner
def num(suit: Suit, rank: int):
return card_by_id(suit.index * 13 + rank - 1)
WIZ1 = card_by_id(WIZARD_ID_OFFSET)
WIZ2 = card_by_id(WIZARD_ID_OFFSET + 1)
JES1 = card_by_id(JESTER_ID_OFFSET)
JES2 = card_by_id(JESTER_ID_OFFSET + 1)
def test_total_rounds():
assert [total_rounds(n) for n in (3, 4, 5, 6)] == [20, 15, 12, 10]
def test_card_ids_are_consistent():
card = num(Suit.GREEN, 7)
assert card.suit is Suit.GREEN and card.rank == 7
assert WIZ1.is_wizard and JES1.is_jester
def test_lead_suit():
assert lead_suit([]) is None
assert lead_suit([num(Suit.RED, 3), num(Suit.BLUE, 5)]) is Suit.RED
assert lead_suit([JES1, num(Suit.BLUE, 5)]) is Suit.BLUE
assert lead_suit([JES1, JES2]) is None
assert lead_suit([WIZ1, num(Suit.BLUE, 5)]) is None
assert lead_suit([JES1, WIZ1, num(Suit.BLUE, 5)]) is None
def test_must_follow_suit():
hand = [num(Suit.RED, 1), num(Suit.BLUE, 2), WIZ1, JES1]
assert set(legal_cards(hand, [num(Suit.RED, 9)])) == {num(Suit.RED, 1), WIZ1, JES1}
def test_free_play_without_lead_suit_cards():
hand = [num(Suit.GREEN, 1), num(Suit.BLUE, 2), JES1]
assert set(legal_cards(hand, [num(Suit.RED, 9)])) == set(hand)
def test_free_play_after_wizard_lead():
hand = [num(Suit.RED, 1), num(Suit.BLUE, 2)]
assert set(legal_cards(hand, [WIZ1])) == set(hand)
def test_first_wizard_wins():
trick = [num(Suit.RED, 13), WIZ1, WIZ2]
assert trick_winner(trick, Suit.RED) == 1
def test_trump_beats_lead_suit():
trick = [num(Suit.RED, 13), num(Suit.BLUE, 2), num(Suit.BLUE, 5)]
assert trick_winner(trick, Suit.BLUE) == 2
def test_highest_lead_suit_wins_without_trump():
trick = [num(Suit.RED, 3), num(Suit.GREEN, 13), num(Suit.RED, 10)]
assert trick_winner(trick, None) == 2
assert trick_winner(trick, Suit.YELLOW) == 2
def test_jester_lead_then_suit():
trick = [JES1, num(Suit.GREEN, 2), num(Suit.RED, 13)]
assert trick_winner(trick, None) == 1
def test_only_jesters_first_wins():
assert trick_winner([JES1, JES2], Suit.RED) == 0
def test_round_score():
assert round_score(0, 0) == 20
assert round_score(3, 3) == 50
assert round_score(2, 0) == -20
assert round_score(1, 4) == -30
@@ -0,0 +1,55 @@
"""Rules engine of the Wizard card game.
This package is independent of the game server so that it can also be used
to train AI players.
"""
from .actions import Action, Bid, ChooseTrump, PlayCard, action_from_dict, action_to_dict
from .cards import ALL_CARDS, DECK_SIZE, SUITS, Card, CardKind, Suit, card_by_id
from .game import IllegalActionError, WizardGame
from .observation import Observation
from .rules import MAX_PLAYERS, MIN_PLAYERS, total_rounds
from .state import (
Event,
GameFinished,
GameOptions,
Phase,
PlayedCard,
RoundCompleted,
RoundResult,
RoundStarted,
TrickCompleted,
TrickResult,
)
__all__ = [
"ALL_CARDS",
"DECK_SIZE",
"MAX_PLAYERS",
"MIN_PLAYERS",
"SUITS",
"Action",
"Bid",
"Card",
"CardKind",
"ChooseTrump",
"Event",
"GameFinished",
"GameOptions",
"IllegalActionError",
"Observation",
"Phase",
"PlayCard",
"PlayedCard",
"RoundCompleted",
"RoundResult",
"RoundStarted",
"Suit",
"TrickCompleted",
"TrickResult",
"WizardGame",
"action_from_dict",
"action_to_dict",
"card_by_id",
"total_rounds",
]
@@ -0,0 +1,73 @@
"""Actions a player can take and their (de)serialization.
Actions are serialized as small JSON objects which are shared by the web
frontend, the game server and the AI player:
- ``{"type": "bid", "value": 2}``
- ``{"type": "play", "card": 17}``
- ``{"type": "trump", "suit": "red"}``
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .cards import Card, Suit, card_by_id
@dataclass(frozen=True, slots=True)
class Bid:
value: int
def to_dict(self) -> dict:
return {"type": "bid", "value": self.value}
@dataclass(frozen=True, slots=True)
class PlayCard:
card: Card
def to_dict(self) -> dict:
return {"type": "play", "card": self.card.id}
@dataclass(frozen=True, slots=True)
class ChooseTrump:
suit: Suit
def to_dict(self) -> dict:
return {"type": "trump", "suit": self.suit.value}
Action = Bid | PlayCard | ChooseTrump
def action_to_dict(action: Action) -> dict:
return action.to_dict()
def action_from_dict(data: dict[str, Any]) -> Action:
"""Parse an action from its JSON representation.
Raises ``ValueError`` if the data is malformed.
"""
if not isinstance(data, dict):
raise ValueError("Action must be an object")
kind = data.get("type")
try:
if kind == "bid":
value = data["value"]
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError("Bid value must be an integer")
return Bid(value)
if kind == "play":
card_id = data["card"]
if not isinstance(card_id, int) or isinstance(card_id, bool):
raise ValueError("Card must be an integer id")
return PlayCard(card_by_id(card_id))
if kind == "trump":
return ChooseTrump(Suit(data["suit"]))
except KeyError as exc:
raise ValueError(f"Missing field {exc} in action") from exc
raise ValueError(f"Unknown action type: {kind!r}")
@@ -0,0 +1,115 @@
"""Cards and deck of the Wizard card game.
The deck consists of 60 cards:
- 52 number cards: four suits (red, yellow, green, blue) with ranks 1-13
- 4 wizards (highest cards)
- 4 jesters (lowest cards)
Every card has a stable integer id in the range 0-59 which is used for
serialization and as an index for machine learning encodings:
- 0-51: number cards, ``suit_index * 13 + (rank - 1)``
- 52-55: wizards
- 56-59: jesters
"""
from __future__ import annotations
import random
from dataclasses import dataclass
from enum import Enum
class Suit(str, Enum):
RED = "red"
YELLOW = "yellow"
GREEN = "green"
BLUE = "blue"
@property
def index(self) -> int:
return SUITS.index(self)
SUITS: tuple[Suit, ...] = (Suit.RED, Suit.YELLOW, Suit.GREEN, Suit.BLUE)
class CardKind(str, Enum):
NUMBER = "number"
WIZARD = "wizard"
JESTER = "jester"
NUM_RANKS = 13
NUM_NUMBER_CARDS = len(SUITS) * NUM_RANKS
NUM_WIZARDS = 4
NUM_JESTERS = 4
DECK_SIZE = NUM_NUMBER_CARDS + NUM_WIZARDS + NUM_JESTERS
WIZARD_ID_OFFSET = NUM_NUMBER_CARDS
JESTER_ID_OFFSET = NUM_NUMBER_CARDS + NUM_WIZARDS
@dataclass(frozen=True, slots=True)
class Card:
id: int
kind: CardKind
suit: Suit | None = None
rank: int = 0
@property
def is_wizard(self) -> bool:
return self.kind is CardKind.WIZARD
@property
def is_jester(self) -> bool:
return self.kind is CardKind.JESTER
@property
def is_number(self) -> bool:
return self.kind is CardKind.NUMBER
def to_dict(self) -> dict:
return {
"id": self.id,
"kind": self.kind.value,
"suit": self.suit.value if self.suit else None,
"rank": self.rank,
}
def __str__(self) -> str:
if self.is_wizard:
return f"Wizard{self.id - WIZARD_ID_OFFSET + 1}"
if self.is_jester:
return f"Jester{self.id - JESTER_ID_OFFSET + 1}"
assert self.suit is not None
return f"{self.suit.value.capitalize()}{self.rank}"
def __repr__(self) -> str:
return f"Card({self})"
def _build_deck() -> tuple[Card, ...]:
cards: list[Card] = []
for suit in SUITS:
for rank in range(1, NUM_RANKS + 1):
cards.append(Card(len(cards), CardKind.NUMBER, suit, rank))
for _ in range(NUM_WIZARDS):
cards.append(Card(len(cards), CardKind.WIZARD))
for _ in range(NUM_JESTERS):
cards.append(Card(len(cards), CardKind.JESTER))
return tuple(cards)
ALL_CARDS: tuple[Card, ...] = _build_deck()
def card_by_id(card_id: int) -> Card:
if not 0 <= card_id < DECK_SIZE:
raise ValueError(f"Invalid card id: {card_id}")
return ALL_CARDS[card_id]
def new_shuffled_deck(rng: random.Random) -> list[Card]:
deck = list(ALL_CARDS)
rng.shuffle(deck)
return deck
@@ -0,0 +1,300 @@
"""The Wizard game state machine.
``WizardGame`` implements the complete rules of Wizard and has no
dependencies besides the Python standard library, so it can be used by the
game server as well as for (reinforcement learning) training of AI players.
Typical usage::
game = WizardGame(num_players=4, seed=42)
while not game.is_finished:
player = game.current_player
action = choose(game.observation(player)) # any Action from legal_actions
events = game.apply(player, action)
"""
from __future__ import annotations
import copy
import random
from . import rules
from .actions import Action, Bid, ChooseTrump, PlayCard
from .cards import SUITS, Card, new_shuffled_deck
from .observation import Observation
from .state import (
Event,
GameFinished,
GameOptions,
Phase,
PlayedCard,
RoundCompleted,
RoundResult,
RoundStarted,
RoundState,
TrickCompleted,
TrickResult,
)
class IllegalActionError(ValueError):
"""Raised when a player attempts an action the rules do not allow."""
class WizardGame:
def __init__(
self,
num_players: int,
options: GameOptions | None = None,
*,
seed: int | None = None,
rng: random.Random | None = None,
first_dealer: int | None = None,
) -> None:
rules.validate_player_count(num_players)
self.num_players = num_players
self.options = options or GameOptions()
self.total_rounds = rules.total_rounds(num_players)
self._rng = rng or random.Random(seed)
self.scores: list[int] = [0] * num_players
self.round_history: list[RoundResult] = []
self.last_trick: TrickResult | None = None
self.phase: Phase = Phase.BIDDING
self.current_player: int | None = None
if first_dealer is None:
first_dealer = self._rng.randrange(num_players)
elif not 0 <= first_dealer < num_players:
raise ValueError(f"Invalid first dealer: {first_dealer}")
self.round: RoundState = self._deal(1, first_dealer)
# --- Public API ---------------------------------------------------------
@property
def is_finished(self) -> bool:
return self.phase is Phase.FINISHED
@property
def round_number(self) -> int:
return self.round.round_number
@property
def dealer(self) -> int:
return self.round.dealer
def hand(self, player: int) -> list[Card]:
return list(self.round.hands[player])
def winners(self) -> list[int]:
"""Players with the highest score (only meaningful once finished)."""
best = max(self.scores)
return [p for p, score in enumerate(self.scores) if score == best]
def legal_actions(self, player: int | None = None) -> list[Action]:
"""Legal actions of ``player`` (defaults to the current player)."""
if player is None:
player = self.current_player
if player is None or player != self.current_player:
return []
if self.phase is Phase.TRUMP_SELECTION:
return [ChooseTrump(suit) for suit in SUITS]
if self.phase is Phase.BIDDING:
return [Bid(value) for value in self._allowed_bids(player)]
if self.phase is Phase.PLAYING:
trick = [played.card for played in self.round.current_trick]
return [PlayCard(card) for card in rules.legal_cards(self.round.hands[player], trick)]
return []
def is_legal(self, player: int, action: Action) -> bool:
return action in self.legal_actions(player)
def apply(self, player: int, action: Action) -> list[Event]:
"""Perform ``action`` for ``player`` and advance the game.
Returns the events caused by the action. Raises
``IllegalActionError`` if the action is not allowed.
"""
if self.phase is Phase.FINISHED:
raise IllegalActionError("The game is already finished")
if player != self.current_player:
raise IllegalActionError(f"It is not player {player}'s turn")
if not self.is_legal(player, action):
raise IllegalActionError(f"Illegal action {action} for player {player}")
if isinstance(action, ChooseTrump):
return self._choose_trump(action)
if isinstance(action, Bid):
return self._bid(player, action)
if isinstance(action, PlayCard):
return self._play(player, action)
raise IllegalActionError(f"Unknown action {action!r}") # pragma: no cover
def observation(self, player: int) -> Observation:
"""Everything ``player`` is allowed to know about the game."""
rnd = self.round
all_bids_placed = all(bid is not None for bid in rnd.bids)
reveal_bids = not self.options.hidden_bids or all_bids_placed
bids = tuple(
bid if reveal_bids or p == player else None
for p, bid in enumerate(rnd.bids)
)
trick = [played.card for played in rnd.current_trick]
return Observation(
player=player,
num_players=self.num_players,
options=self.options,
phase=self.phase,
round_number=rnd.round_number,
total_rounds=self.total_rounds,
dealer=rnd.dealer,
current_player=self.current_player,
hand=tuple(sorted(rnd.hands[player], key=lambda card: card.id)),
hand_sizes=tuple(len(hand) for hand in rnd.hands),
trump_card=rnd.trump_card,
trump_suit=rnd.trump_suit,
bids=bids,
has_bid=tuple(bid is not None for bid in rnd.bids),
tricks_won=tuple(rnd.tricks_won),
scores=tuple(self.scores),
trick_number=rnd.trick_number,
trick_leader=rnd.trick_leader,
current_trick=tuple(rnd.current_trick),
lead_suit=rules.lead_suit(trick),
played_cards=tuple(rnd.played_cards),
last_trick=self.last_trick,
round_history=tuple(self.round_history),
legal_actions=tuple(self.legal_actions(player)),
winners=tuple(self.winners()) if self.is_finished else (),
)
def clone(self) -> WizardGame:
"""Deep copy of the game, e.g. for search based AI players."""
return copy.deepcopy(self)
# --- Internals ----------------------------------------------------------
def _next(self, player: int) -> int:
return (player + 1) % self.num_players
def _deal(self, round_number: int, dealer: int) -> RoundState:
deck = new_shuffled_deck(self._rng)
hands: list[list[Card]] = [[] for _ in range(self.num_players)]
# Deal one card at a time starting left of the dealer.
for i in range(round_number * self.num_players):
hands[(dealer + 1 + i) % self.num_players].append(deck[i])
remaining = deck[round_number * self.num_players:]
# The next card defines the trump suit. In the last round no cards
# are left, so there is no trump.
trump_card = remaining[0] if remaining else None
starter = self._next(dealer)
rnd = RoundState(
round_number=round_number,
dealer=dealer,
hands=hands,
trump_card=trump_card,
trump_suit=trump_card.suit if trump_card and trump_card.is_number else None,
bids=[None] * self.num_players,
tricks_won=[0] * self.num_players,
trick_leader=starter,
)
if trump_card is not None and trump_card.is_wizard:
# A wizard as trump card: the dealer chooses the trump suit.
self.phase = Phase.TRUMP_SELECTION
self.current_player = dealer
else:
# No trump card or a jester: the round is played without trump.
self.phase = Phase.BIDDING
self.current_player = starter
return rnd
def _allowed_bids(self, player: int) -> list[int]:
rnd = self.round
allowed = list(range(rnd.round_number + 1))
placed = [bid for bid in rnd.bids if bid is not None]
is_last_bidder = len(placed) == self.num_players - 1 and rnd.bids[player] is None
if self.options.not_equal and is_last_bidder:
forbidden = rnd.round_number - sum(placed)
allowed = [bid for bid in allowed if bid != forbidden]
return allowed
def _choose_trump(self, action: ChooseTrump) -> list[Event]:
self.round.trump_suit = action.suit
self.phase = Phase.BIDDING
self.current_player = self._next(self.round.dealer)
return []
def _bid(self, player: int, action: Bid) -> list[Event]:
rnd = self.round
rnd.bids[player] = action.value
if all(bid is not None for bid in rnd.bids):
self.phase = Phase.PLAYING
self.current_player = rnd.trick_leader
else:
self.current_player = self._next(player)
return []
def _play(self, player: int, action: PlayCard) -> list[Event]:
rnd = self.round
rnd.hands[player].remove(action.card)
rnd.current_trick.append(PlayedCard(player, action.card))
rnd.played_cards.append(action.card)
if len(rnd.current_trick) < self.num_players:
self.current_player = self._next(player)
return []
return self._complete_trick()
def _complete_trick(self) -> list[Event]:
rnd = self.round
cards = [played.card for played in rnd.current_trick]
winner = rnd.current_trick[rules.trick_winner(cards, rnd.trump_suit)].player
rnd.tricks_won[winner] += 1
result = TrickResult(rnd.round_number, rnd.trick_number, tuple(rnd.current_trick), winner)
self.last_trick = result
events: list[Event] = [TrickCompleted(result)]
rnd.current_trick = []
if rnd.trick_number < rnd.round_number:
rnd.trick_number += 1
rnd.trick_leader = winner
self.current_player = winner
return events
events.extend(self._complete_round())
return events
def _complete_round(self) -> list[Event]:
rnd = self.round
bids = [bid if bid is not None else 0 for bid in rnd.bids]
changes = [rules.round_score(bid, tricks) for bid, tricks in zip(bids, rnd.tricks_won)]
self.scores = [score + change for score, change in zip(self.scores, changes)]
result = RoundResult(
round_number=rnd.round_number,
bids=tuple(bids),
tricks=tuple(rnd.tricks_won),
score_changes=tuple(changes),
scores=tuple(self.scores),
)
self.round_history.append(result)
events: list[Event] = [RoundCompleted(result)]
if rnd.round_number >= self.total_rounds:
self.phase = Phase.FINISHED
self.current_player = None
events.append(GameFinished(tuple(self.scores), tuple(self.winners())))
return events
self.round = self._deal(rnd.round_number + 1, self._next(rnd.dealer))
events.append(RoundStarted(self.round.round_number, self.round.dealer, self.round.trump_card))
return events
@@ -0,0 +1,120 @@
"""The view of the game a single player is allowed to see.
An ``Observation`` contains everything a player knows at a point in time and
nothing more (no opponent hands, hidden bids stay hidden). It is used
- by the server to send the game state to a web client, and
- as the input of AI players, both in training and in production.
``to_dict`` / ``from_dict`` provide a lossless JSON representation.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .actions import Action, action_from_dict
from .cards import Card, Suit, card_by_id
from .state import GameOptions, Phase, PlayedCard, RoundResult, TrickResult
@dataclass(frozen=True, slots=True)
class Observation:
player: int
num_players: int
options: GameOptions
phase: Phase
round_number: int
total_rounds: int
dealer: int
current_player: int | None
hand: tuple[Card, ...]
hand_sizes: tuple[int, ...]
trump_card: Card | None
trump_suit: Suit | None
# ``None`` for players who have not bid yet, or whose bid is still hidden.
bids: tuple[int | None, ...]
has_bid: tuple[bool, ...]
tricks_won: tuple[int, ...]
scores: tuple[int, ...]
trick_number: int
trick_leader: int
current_trick: tuple[PlayedCard, ...]
lead_suit: Suit | None
# All cards played in the current round, including the current trick.
played_cards: tuple[Card, ...]
last_trick: TrickResult | None
round_history: tuple[RoundResult, ...]
# Empty unless it is this player's turn.
legal_actions: tuple[Action, ...]
winners: tuple[int, ...]
@property
def is_my_turn(self) -> bool:
return self.current_player == self.player
def to_dict(self) -> dict:
return {
"player": self.player,
"num_players": self.num_players,
"options": self.options.to_dict(),
"phase": self.phase.value,
"round_number": self.round_number,
"total_rounds": self.total_rounds,
"dealer": self.dealer,
"current_player": self.current_player,
"hand": [card.to_dict() for card in self.hand],
"hand_sizes": list(self.hand_sizes),
"trump_card": self.trump_card.to_dict() if self.trump_card else None,
"trump_suit": self.trump_suit.value if self.trump_suit else None,
"bids": list(self.bids),
"has_bid": list(self.has_bid),
"tricks_won": list(self.tricks_won),
"scores": list(self.scores),
"trick_number": self.trick_number,
"trick_leader": self.trick_leader,
"current_trick": [played.to_dict() for played in self.current_trick],
"lead_suit": self.lead_suit.value if self.lead_suit else None,
"played_cards": [card.to_dict() for card in self.played_cards],
"last_trick": self.last_trick.to_dict() if self.last_trick else None,
"round_history": [result.to_dict() for result in self.round_history],
"legal_actions": [action.to_dict() for action in self.legal_actions],
"winners": list(self.winners),
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Observation:
def card(value: dict | None) -> Card | None:
return card_by_id(value["id"]) if value else None
def suit(value: str | None) -> Suit | None:
return Suit(value) if value else None
return cls(
player=data["player"],
num_players=data["num_players"],
options=GameOptions.from_dict(data["options"]),
phase=Phase(data["phase"]),
round_number=data["round_number"],
total_rounds=data["total_rounds"],
dealer=data["dealer"],
current_player=data["current_player"],
hand=tuple(card_by_id(c["id"]) for c in data["hand"]),
hand_sizes=tuple(data["hand_sizes"]),
trump_card=card(data["trump_card"]),
trump_suit=suit(data["trump_suit"]),
bids=tuple(data["bids"]),
has_bid=tuple(data["has_bid"]),
tricks_won=tuple(data["tricks_won"]),
scores=tuple(data["scores"]),
trick_number=data["trick_number"],
trick_leader=data["trick_leader"],
current_trick=tuple(PlayedCard.from_dict(p) for p in data["current_trick"]),
lead_suit=suit(data["lead_suit"]),
played_cards=tuple(card_by_id(c["id"]) for c in data["played_cards"]),
last_trick=TrickResult.from_dict(data["last_trick"]) if data["last_trick"] else None,
round_history=tuple(RoundResult.from_dict(r) for r in data["round_history"]),
legal_actions=tuple(action_from_dict(a) for a in data["legal_actions"]),
winners=tuple(data["winners"]),
)
@@ -0,0 +1,95 @@
"""Stateless rule helpers of the Wizard card game."""
from __future__ import annotations
from collections.abc import Sequence
from .cards import DECK_SIZE, Card, Suit
MIN_PLAYERS = 3
MAX_PLAYERS = 6
# Points for a correct prediction and per trick (correct) / per trick off (wrong).
CORRECT_BID_BONUS = 20
POINTS_PER_TRICK = 10
PENALTY_PER_TRICK_OFF = 10
def total_rounds(num_players: int) -> int:
"""Number of rounds played: 3/4/5/6 players play 20/15/12/10 rounds."""
validate_player_count(num_players)
return DECK_SIZE // num_players
def validate_player_count(num_players: int) -> None:
if not MIN_PLAYERS <= num_players <= MAX_PLAYERS:
raise ValueError(
f"Wizard is played with {MIN_PLAYERS}-{MAX_PLAYERS} players, got {num_players}"
)
def lead_suit(trick: Sequence[Card]) -> Suit | None:
"""The suit that has to be followed in a trick.
The first number card determines the suit. Jesters played before it are
skipped. If a wizard is played before any number card, the trick has no
suit to follow.
"""
for card in trick:
if card.is_jester:
continue
if card.is_wizard:
return None
return card.suit
return None
def legal_cards(hand: Sequence[Card], trick: Sequence[Card]) -> list[Card]:
"""Cards of ``hand`` that may be played onto ``trick``.
Wizards and jesters may always be played. Number cards must follow the
lead suit if the player holds a number card of that suit.
"""
suit = lead_suit(trick)
if suit is None:
return list(hand)
if not any(card.is_number and card.suit is suit for card in hand):
return list(hand)
return [card for card in hand if not card.is_number or card.suit is suit]
def trick_winner(trick: Sequence[Card], trump: Suit | None) -> int:
"""Index (within ``trick``) of the card that wins the trick.
- the first wizard wins
- otherwise the highest trump wins
- otherwise the highest card of the lead suit wins
- if only jesters were played, the first jester wins
"""
if not trick:
raise ValueError("Cannot determine the winner of an empty trick")
for index, card in enumerate(trick):
if card.is_wizard:
return index
if trump is not None:
trumps = [(card.rank, index) for index, card in enumerate(trick)
if card.is_number and card.suit is trump]
if trumps:
return max(trumps)[1]
suit = lead_suit(trick)
if suit is not None:
followers = [(card.rank, index) for index, card in enumerate(trick)
if card.is_number and card.suit is suit]
return max(followers)[1]
return 0
def round_score(bid: int, tricks: int) -> int:
"""Score change of a player at the end of a round."""
if bid == tricks:
return CORRECT_BID_BONUS + POINTS_PER_TRICK * tricks
return -PENALTY_PER_TRICK_OFF * abs(bid - tricks)
@@ -0,0 +1,166 @@
"""Value types describing game state, options, results and events."""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from .cards import Card, Suit, card_by_id
class Phase(str, Enum):
TRUMP_SELECTION = "trump_selection"
BIDDING = "bidding"
PLAYING = "playing"
FINISHED = "finished"
@dataclass(frozen=True, slots=True)
class GameOptions:
# "notequal": the sum of all bids must not equal the number of tricks
# of the round. The last bidder has to adjust their bid.
not_equal: bool = False
# "hiddentip": bids are revealed only once every player has bid.
hidden_bids: bool = False
def to_dict(self) -> dict:
return {"not_equal": self.not_equal, "hidden_bids": self.hidden_bids}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> GameOptions:
return cls(
not_equal=bool(data.get("not_equal", False)),
hidden_bids=bool(data.get("hidden_bids", False)),
)
@dataclass(frozen=True, slots=True)
class PlayedCard:
player: int
card: Card
def to_dict(self) -> dict:
return {"player": self.player, "card": self.card.to_dict()}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> PlayedCard:
return cls(player=data["player"], card=card_by_id(data["card"]["id"]))
@dataclass(frozen=True, slots=True)
class TrickResult:
round_number: int
trick_number: int
cards: tuple[PlayedCard, ...]
winner: int
def to_dict(self) -> dict:
return {
"round_number": self.round_number,
"trick_number": self.trick_number,
"cards": [played.to_dict() for played in self.cards],
"winner": self.winner,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> TrickResult:
return cls(
round_number=data["round_number"],
trick_number=data["trick_number"],
cards=tuple(PlayedCard.from_dict(c) for c in data["cards"]),
winner=data["winner"],
)
@dataclass(frozen=True, slots=True)
class RoundResult:
round_number: int
bids: tuple[int, ...]
tricks: tuple[int, ...]
score_changes: tuple[int, ...]
scores: tuple[int, ...]
def to_dict(self) -> dict:
return {
"round_number": self.round_number,
"bids": list(self.bids),
"tricks": list(self.tricks),
"score_changes": list(self.score_changes),
"scores": list(self.scores),
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> RoundResult:
return cls(
round_number=data["round_number"],
bids=tuple(data["bids"]),
tricks=tuple(data["tricks"]),
score_changes=tuple(data["score_changes"]),
scores=tuple(data["scores"]),
)
# --- Events -----------------------------------------------------------------
# ``WizardGame.apply`` returns the events caused by an action. They are handy
# for clients that want to animate what happened (e.g. pause after a trick).
@dataclass(frozen=True, slots=True)
class RoundStarted:
round_number: int
dealer: int
trump_card: Card | None
def to_dict(self) -> dict:
return {
"type": "round_started",
"round_number": self.round_number,
"dealer": self.dealer,
"trump_card": self.trump_card.to_dict() if self.trump_card else None,
}
@dataclass(frozen=True, slots=True)
class TrickCompleted:
trick: TrickResult
def to_dict(self) -> dict:
return {"type": "trick_completed", "trick": self.trick.to_dict()}
@dataclass(frozen=True, slots=True)
class RoundCompleted:
result: RoundResult
def to_dict(self) -> dict:
return {"type": "round_completed", "result": self.result.to_dict()}
@dataclass(frozen=True, slots=True)
class GameFinished:
scores: tuple[int, ...]
winners: tuple[int, ...]
def to_dict(self) -> dict:
return {"type": "game_finished", "scores": list(self.scores), "winners": list(self.winners)}
Event = RoundStarted | TrickCompleted | RoundCompleted | GameFinished
@dataclass(slots=True)
class RoundState:
"""Mutable per-round state, owned by ``WizardGame``."""
round_number: int
dealer: int
hands: list[list[Card]]
trump_card: Card | None
trump_suit: Suit | None
bids: list[int | None]
tricks_won: list[int]
current_trick: list[PlayedCard] = field(default_factory=list)
trick_leader: int = 0
trick_number: int = 1
played_cards: list[Card] = field(default_factory=list)
+2
View File
@@ -0,0 +1,2 @@
[pytest]
testpaths = tests game_logic/tests
+4
View File
@@ -0,0 +1,4 @@
-r requirements.txt
-e ./game_logic
pytest>=8
httpx>=0.27
+2
View File
@@ -0,0 +1,2 @@
fastapi>=0.115,<1
uvicorn[standard]>=0.30,<1
+193
View File
@@ -0,0 +1,193 @@
import contextlib
import random
import threading
import pytest
from fastapi.testclient import TestClient
from app.config import Settings
from app.main import create_app
SECRET = "test-secret"
@pytest.fixture
def client():
settings = Settings(ai_shared_secret=SECRET, ai_move_delay=0, trick_pause=0, ai_decision_timeout=2)
with TestClient(create_app(settings)) as test_client, contextlib.ExitStack() as sockets:
test_client.sockets = sockets # websocket sessions are closed before the app stops
yield test_client
def receive_until(ws, predicate, limit=5000):
for _ in range(limit):
message = ws.receive_json()
if predicate(message):
return message
raise AssertionError("expected message not received")
def is_state(message):
return message["type"] == "state"
def connect(client, token=None):
ws = client.sockets.enter_context(client.websocket_connect("/api/ws"))
ws.send_json({"type": "hello", "token": token})
welcome = receive_until(ws, lambda m: m["type"] == "welcome")
receive_until(ws, is_state)
return ws, welcome
def test_health(client):
response = client.get("/api/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_requires_hello(client):
with client.websocket_connect("/api/ws") as ws:
ws.send_json({"type": "create_lobby", "name": "Ann"})
assert ws.receive_json()["type"] == "error"
def test_invalid_message(client):
ws, _ = connect(client)
ws.send_json({"type": "join_lobby", "code": "ABCDEF", "name": ""})
assert ws.receive_json()["type"] == "error"
def test_lobby_flow_and_full_game(client):
host, host_welcome = connect(client)
host.send_json({"type": "create_lobby", "name": "Ann"})
state = receive_until(host, is_state)
lobby = state["lobby"]
code = lobby["code"]
assert lobby["host_id"] == host_welcome["player_id"]
assert [p["name"] for p in lobby["players"]] == ["Ann"]
guest, _ = connect(client)
guest.send_json({"type": "join_lobby", "code": code.lower(), "name": "Ann"})
state = receive_until(guest, lambda m: is_state(m) and m["lobby"])
assert [p["name"] for p in state["lobby"]["players"]] == ["Ann", "Ann (2)"]
# Only the host may start / add AI players.
guest.send_json({"type": "add_ai"})
assert receive_until(guest, lambda m: m["type"] == "error")
host.send_json({"type": "start_game"})
assert "needs" in receive_until(host, lambda m: m["type"] == "error")["message"]
host.send_json({"type": "add_ai"})
host.send_json({"type": "set_options", "not_equal": True, "hidden_bids": False})
host.send_json({"type": "start_game"})
rng = random.Random(0)
finished = {}
def play(ws, key):
while True:
message = ws.receive_json()
if not is_state(message) or not message["game"]:
continue
obs = message["game"]["observation"]
if obs["phase"] == "finished":
finished[key] = obs
return
if obs["current_player"] == message["game"]["you"] and obs["legal_actions"]:
ws.send_json({"type": "action", "action": rng.choice(obs["legal_actions"])})
threads = [threading.Thread(target=play, args=(ws, key)) for ws, key in ((host, "host"), (guest, "guest"))]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=60)
assert set(finished) == {"host", "guest"}
result = finished["host"]
assert len(result["round_history"]) == 20
assert result["winners"]
host.send_json({"type": "return_to_lobby"})
state = receive_until(host, lambda m: is_state(m) and m["lobby"]["status"] == "waiting")
assert state["game"] is None
def test_reconnect_with_token(client):
ws, welcome = connect(client)
ws.send_json({"type": "create_lobby", "name": "Bob"})
code = receive_until(ws, is_state)["lobby"]["code"]
ws.close()
ws2 = client.sockets.enter_context(client.websocket_connect("/api/ws"))
ws2.send_json({"type": "hello", "token": welcome["token"]})
again = receive_until(ws2, lambda m: m["type"] == "welcome")
assert again["player_id"] == welcome["player_id"]
state = receive_until(ws2, is_state)
assert state["lobby"]["code"] == code
def test_leaving_mid_game_hands_seat_to_ai(client):
host, _ = connect(client)
host.send_json({"type": "create_lobby", "name": "Cy"})
code = receive_until(host, is_state)["lobby"]["code"]
guest, _ = connect(client)
guest.send_json({"type": "join_lobby", "code": code, "name": "Di"})
receive_until(guest, lambda m: is_state(m) and m["lobby"])
host.send_json({"type": "add_ai"})
host.send_json({"type": "start_game"})
receive_until(host, lambda m: is_state(m) and m["game"])
host.send_json({"type": "leave_lobby"})
receive_until(host, lambda m: is_state(m) and m["lobby"] is None)
state = receive_until(guest, lambda m: is_state(m) and m["lobby"]["host_id"] != m["lobby"]["players"][0]["id"])
players = state["lobby"]["players"]
assert players[0]["is_ai"] and players[0]["name"] == "Cy"
assert state["lobby"]["host_id"] == players[1]["id"]
def test_ai_endpoint_requires_secret(client):
from starlette.websockets import WebSocketDisconnect
with pytest.raises(WebSocketDisconnect):
with client.websocket_connect("/internal/ai") as ws:
ws.receive_json()
def test_ai_service_decides(client):
"""A fake AI service answers decide requests with the first legal action."""
requests = []
stop = threading.Event()
def fake_ai():
headers = {"Authorization": f"Bearer {SECRET}"}
with client.websocket_connect("/internal/ai", headers=headers) as ai:
ai.send_json({"type": "hello", "agent": "fake"})
while not stop.is_set():
message = ai.receive_json()
if message["type"] == "decide":
requests.append(message)
action = message["observation"]["legal_actions"][0]
ai.send_json({"type": "action", "request_id": message["request_id"], "action": action})
if len(requests) >= 3:
return
thread = threading.Thread(target=fake_ai, daemon=True)
thread.start()
host, _ = connect(client)
host.send_json({"type": "create_lobby", "name": "Eve"})
receive_until(host, lambda m: is_state(m) and m["lobby"] and m["lobby"]["ai_available"])
host.send_json({"type": "add_ai"})
host.send_json({"type": "add_ai"})
host.send_json({"type": "start_game"})
rng = random.Random(1)
while thread.is_alive():
message = host.receive_json()
if is_state(message) and message["game"]:
obs = message["game"]["observation"]
if obs["current_player"] == message["game"]["you"] and obs["legal_actions"]:
host.send_json({"type": "action", "action": rng.choice(obs["legal_actions"])})
stop.set()
assert len(requests) >= 3
assert all(r["observation"]["player"] in (1, 2) for r in requests)
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.vite
*.log
Dockerfile
.dockerignore
+3
View File
@@ -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=
+19
View File
@@ -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
+19 -69
View File
@@ -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
+2 -1
View File
@@ -4,7 +4,8 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>wizard_website</title>
<meta name="description" content="Play the card game Wizard online with friends and AI players." />
<title>Wizard</title>
</head>
<body>
<div id="root"></div>
+17
View File
@@ -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;
}
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 308 B

-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

-184
View File
@@ -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);
}
}
+54
View File
@@ -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;
}
+47 -113
View File
@@ -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 = <HomeScreen connected={status === 'open'} actions={actions} />
} else if (lobby.status === 'waiting' || !game) {
screen = <LobbyScreen lobby={lobby} playerId={playerId} actions={actions} />
} else {
screen = (
<GameScreen
lobby={lobby}
game={game}
playerId={playerId}
completedTrick={completedTrick}
completedRound={completedRound}
actions={actions}
/>
)
}
return (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
<div className={styles.app}>
{status !== 'open' && <div className={styles.banner}>{STATUS_MESSAGES[status]}</div>}
{screen}
<Toasts toasts={toasts} onDismiss={dismissToast} />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
type="button"
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
)
}
+121
View File
@@ -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' }
+190
View File
@@ -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<ConnectionStatus>('connecting')
const [playerId, setPlayerId] = useState<string | null>(null)
const [lobby, setLobby] = useState<Lobby | null>(null)
const [game, setGame] = useState<GameView | null>(null)
// The most recently completed trick, shown for a moment before it is cleared.
const [completedTrick, setCompletedTrick] = useState<TrickResult | null>(null)
// Result of the round that just ended, shown as a short summary.
const [completedRound, setCompletedRound] = useState<RoundResult | null>(null)
const [toasts, setToasts] = useState<Toast[]>([])
const socketRef = useRef<WebSocket | null>(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<typeof setTimeout> | undefined
let trickTimer: ReturnType<typeof setTimeout> | undefined
let roundTimer: ReturnType<typeof setTimeout> | 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<typeof useWizard>['actions']
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

@@ -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%);
}
@@ -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<HTMLButtonElement> {
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 <button type={type} className={classes} {...props} />
}
@@ -0,0 +1,55 @@
.backdrop {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
padding: 16px;
background: rgb(5 4 15 / 70%);
backdrop-filter: blur(3px);
}
.dialog {
width: min(460px, 100%);
max-height: calc(100dvh - 32px);
display: flex;
flex-direction: column;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: var(--shadow);
}
.wide {
width: min(900px, 100%);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 0;
}
.title {
margin: 0;
font-size: 20px;
}
.close {
border: 0;
background: none;
color: var(--text-muted);
font-size: 28px;
line-height: 1;
cursor: pointer;
}
.close:hover {
color: var(--text);
}
.body {
padding: 16px 20px 20px;
overflow: auto;
}
@@ -0,0 +1,40 @@
import { useEffect, type ReactNode } from 'react'
import styles from './Modal.module.css'
interface ModalProps {
title: string
onClose?: () => void
children: ReactNode
wide?: boolean
}
export function Modal({ title, onClose, children, wide }: ModalProps) {
useEffect(() => {
if (!onClose) return
const onKey = (event: KeyboardEvent) => event.key === 'Escape' && onClose()
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div className={styles.backdrop} onClick={onClose}>
<div
className={`${styles.dialog} ${wide ? styles.wide : ''}`}
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(event) => event.stopPropagation()}
>
<header className={styles.header}>
<h2 className={styles.title}>{title}</h2>
{onClose && (
<button type="button" className={styles.close} onClick={onClose} aria-label="Close">
×
</button>
)}
</header>
<div className={styles.body}>{children}</div>
</div>
</div>
)
}
@@ -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%);
}
@@ -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 = (
<>
<span className={styles.corner}>{symbol}</span>
<span className={styles.center}>
{card.kind === 'wizard' && <span className={styles.glyph}>✦</span>}
{card.kind === 'jester' && <span className={styles.glyph}>☾</span>}
{card.kind === 'number' && symbol}
</span>
<span className={`${styles.corner} ${styles.cornerBottom}`}>{symbol}</span>
</>
)
if (onClick) {
return (
<button type="button" className={classes} onClick={onClick} disabled={dimmed} aria-label={cardLabel(card)} title={cardLabel(card)}>
{content}
</button>
)
}
return (
<div className={classes} role="img" aria-label={cardLabel(card)} title={cardLabel(card)}>
{content}
</div>
)
}
export function CardBack({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
return <div className={`${styles.card} ${styles[size]} ${styles.back}`} aria-hidden="true" />
}
@@ -0,0 +1,31 @@
.container {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 60;
display: flex;
flex-direction: column;
gap: 8px;
max-width: min(360px, calc(100vw - 32px));
}
.toast {
padding: 10px 14px;
border: 1px solid rgb(255 107 122 / 50%);
border-radius: 10px;
background: #2a1426;
color: #ffd9de;
font: inherit;
font-size: 14px;
text-align: left;
box-shadow: var(--shadow);
cursor: pointer;
animation: slide-in 0.2s ease-out;
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateY(8px);
}
}
@@ -0,0 +1,19 @@
import type { Toast } from '../../api/useWizard'
import styles from './Toasts.module.css'
interface ToastsProps {
toasts: Toast[]
onDismiss: (id: number) => void
}
export function Toasts({ toasts, onDismiss }: ToastsProps) {
return (
<div className={styles.container} aria-live="polite">
{toasts.map((toast) => (
<button key={toast.id} type="button" className={styles.toast} onClick={() => onDismiss(toast.id)}>
{toast.message}
</button>
))}
</div>
)
}
-111
View File
@@ -1,111 +0,0 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
-1
View File
@@ -1,6 +1,5 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
@@ -0,0 +1,36 @@
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 14px 18px;
border: 1px solid var(--gold);
border-radius: var(--radius);
background: rgb(26 22 64 / 95%);
box-shadow: 0 0 24px rgb(255 209 102 / 20%);
}
.question {
margin: 0;
font-weight: 700;
}
.options {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px;
max-width: 560px;
}
.option {
min-width: 44px;
}
.hint {
min-height: 1.2em;
margin: 0;
color: var(--text-muted);
font-size: 13px;
text-align: center;
}
@@ -0,0 +1,41 @@
import type { Observation } from '../../api/types'
import { Button } from '../../components/Button/Button'
import { legalBids } from '../../util/cards'
import styles from './BidPanel.module.css'
interface BidPanelProps {
observation: Observation
onBid: (value: number) => void
}
export function BidPanel({ observation, onBid }: BidPanelProps) {
const allowed = legalBids(observation.legal_actions)
const options = Array.from({ length: observation.round_number + 1 }, (_, value) => value)
const forbidden = options.filter((value) => !allowed.has(value))
const knownBids = observation.bids.filter((bid): bid is number => bid !== null)
const sum = knownBids.reduce((total, bid) => total + bid, 0)
return (
<div className={styles.panel}>
<p className={styles.question}>How many tricks will you take?</p>
<div className={styles.options}>
{options.map((value) => (
<Button
key={value}
variant={allowed.has(value) ? 'secondary' : 'ghost'}
className={styles.option}
disabled={!allowed.has(value)}
onClick={() => onBid(value)}
>
{value}
</Button>
))}
</div>
<p className={styles.hint}>
{!observation.options.hidden_bids &&
`Bids so far: ${sum} for ${observation.round_number} trick${observation.round_number > 1 ? 's' : ''}. `}
{forbidden.length > 0 && `Not-equal rule: you may not bid ${forbidden.join(', ')}.`}
</p>
</div>
)
}
@@ -0,0 +1,66 @@
.content {
display: flex;
flex-direction: column;
gap: 16px;
}
.headline {
margin: 0;
color: var(--gold);
font-size: 26px;
font-weight: 800;
text-align: center;
}
.ranking {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 10px;
background: var(--surface);
}
.winner {
border: 1px solid var(--gold);
background: rgb(255 209 102 / 10%);
}
.place {
width: 24px;
color: var(--text-muted);
}
.name {
flex: 1;
font-weight: 600;
}
.score {
font-size: 18px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.muted {
color: var(--text-muted);
font-size: 14px;
font-weight: 400;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
@@ -0,0 +1,51 @@
import type { LobbyPlayer, Observation } from '../../api/types'
import { Button } from '../../components/Button/Button'
import styles from './GameOver.module.css'
interface GameOverProps {
observation: Observation
players: LobbyPlayer[]
isHost: boolean
onShowScores: () => void
onBackToLobby: () => void
onLeave: () => void
}
export function GameOver({ observation, players, isHost, onShowScores, onBackToLobby, onLeave }: GameOverProps) {
const ranking = observation.scores
.map((score, seat) => ({ seat, score }))
.sort((a, b) => b.score - a.score)
const winners = new Set(observation.winners)
const youWon = winners.has(observation.player)
return (
<div className={styles.content}>
<p className={styles.headline}>{youWon ? 'You win! ✦' : `${players[ranking[0].seat]?.name ?? 'Someone'} wins!`}</p>
<ol className={styles.ranking}>
{ranking.map(({ seat, score }, index) => (
<li key={seat} className={`${styles.row} ${winners.has(seat) ? styles.winner : ''}`}>
<span className={styles.place}>{index + 1}.</span>
<span className={styles.name}>
{players[seat]?.name ?? `Player ${seat + 1}`}
{seat === observation.player && <span className={styles.muted}> (you)</span>}
</span>
<span className={styles.score}>{score}</span>
</li>
))}
</ol>
<div className={styles.actions}>
<Button variant="ghost" onClick={onShowScores}>
Scoreboard
</Button>
<Button variant="danger" onClick={onLeave}>
Leave
</Button>
{isHost ? (
<Button onClick={onBackToLobby}>Back to lobby</Button>
) : (
<span className={styles.muted}>The host can start a new game.</span>
)}
</div>
</div>
)
}
@@ -0,0 +1,167 @@
.page {
display: flex;
flex-direction: column;
gap: 16px;
width: min(1200px, 100%);
min-height: 100dvh;
margin: 0 auto;
padding: 16px;
}
.topBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.roundInfo {
display: flex;
align-items: baseline;
gap: 14px;
}
.round {
font-size: 20px;
font-weight: 800;
}
.muted {
color: var(--text-muted);
font-weight: 500;
}
.code {
color: var(--text-muted);
font-family: ui-monospace, monospace;
font-size: 13px;
}
.topActions {
display: flex;
gap: 8px;
}
.opponents {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
}
.table {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 32px;
min-height: 250px;
padding: 24px;
border: 1px solid rgb(255 255 255 / 6%);
border-radius: 28px;
background:
radial-gradient(ellipse at center, rgb(47 164 108 / 18%), transparent 70%),
rgb(10 8 26 / 55%);
box-shadow: inset 0 0 60px rgb(0 0 0 / 40%);
}
.trump {
position: absolute;
top: 16px;
left: 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
max-width: 120px;
text-align: center;
}
.noTrump {
display: grid;
place-items: center;
width: 72px;
aspect-ratio: 5 / 7.4;
border: 2px dashed rgb(255 255 255 / 15%);
border-radius: 9px;
color: var(--text-muted);
}
.trumpLabel {
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
}
.red {
color: var(--suit-red);
}
.yellow {
color: var(--suit-yellow);
}
.green {
color: var(--suit-green);
}
.blue {
color: var(--suit-blue);
}
.status {
margin: 0;
color: var(--text-muted);
font-weight: 600;
text-align: center;
}
.statusActive {
color: var(--gold);
}
.me {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
margin-top: auto;
}
.myRow {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 20px;
width: 100%;
}
.confirmText {
margin: 0 0 16px;
color: var(--text-muted);
}
.confirmActions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
@media (max-width: 720px) {
.table {
flex-direction: column;
gap: 12px;
padding: 16px 12px;
}
.trump {
position: static;
flex-direction: row;
max-width: none;
}
.myRow {
flex-direction: column-reverse;
align-items: center;
}
}
@@ -0,0 +1,148 @@
import { useState } from 'react'
import type { GameView, Lobby, RoundResult, TrickResult } from '../../api/types'
import type { WizardActions } from '../../api/useWizard'
import { Button } from '../../components/Button/Button'
import { Modal } from '../../components/Modal/Modal'
import { PlayingCard } from '../../components/PlayingCard/PlayingCard'
import { SUIT_NAMES } from '../../util/cards'
import { BidPanel } from './BidPanel'
import { GameOver } from './GameOver'
import styles from './GameScreen.module.css'
import { Hand } from './Hand'
import { PlayerSeat } from './PlayerSeat'
import { RoundSummary } from './RoundSummary'
import { Scoreboard } from './Scoreboard'
import { TrickArea } from './TrickArea'
import { TrumpPicker } from './TrumpPicker'
interface GameScreenProps {
lobby: Lobby
game: GameView
playerId: string
completedTrick: TrickResult | null
completedRound: RoundResult | null
actions: WizardActions
}
export function GameScreen({ lobby, game, playerId, completedTrick, completedRound, actions }: GameScreenProps) {
const [showScores, setShowScores] = useState(false)
const [confirmLeave, setConfirmLeave] = useState(false)
const obs = game.observation
const you = game.you ?? obs.player
const players = lobby.players
const isHost = lobby.host_id === playerId
const myTurn = obs.current_player === you && obs.phase !== 'finished'
const name = (seat: number | null) => (seat === null ? '' : (players[seat]?.name ?? `Player ${seat + 1}`))
// Opponents in play order, starting with the player after you.
const opponents = Array.from({ length: obs.num_players - 1 }, (_, index) => (you + 1 + index) % obs.num_players)
let status: string
switch (obs.phase) {
case 'trump_selection':
status = myTurn ? 'Choose the trump suit' : `${name(obs.current_player)} is choosing the trump suit…`
break
case 'bidding':
status = myTurn ? 'Your turn to bid' : `Waiting for ${name(obs.current_player)} to bid…`
break
case 'playing':
status = myTurn ? 'Your turn — play a card' : `${name(obs.current_player)} is playing…`
break
default:
status = 'Game over'
}
let trumpText = 'No trump this round'
if (obs.trump_suit) trumpText = `Trump: ${SUIT_NAMES[obs.trump_suit]}`
else if (obs.phase === 'trump_selection') trumpText = 'Wizard turned up — dealer chooses'
return (
<main className={styles.page}>
<header className={styles.topBar}>
<div className={styles.roundInfo}>
<span className={styles.round}>
Round {obs.round_number}
<span className={styles.muted}> / {obs.total_rounds}</span>
</span>
<span className={styles.code}>Lobby {lobby.code}</span>
</div>
<div className={styles.topActions}>
<Button size="sm" variant="secondary" onClick={() => setShowScores(true)}>
Scoreboard
</Button>
<Button size="sm" variant="danger" onClick={() => setConfirmLeave(true)}>
Leave
</Button>
</div>
</header>
<section className={styles.opponents}>
{opponents.map((seat) => (
<PlayerSeat key={seat} observation={obs} seat={seat} player={players[seat]} />
))}
</section>
<section className={styles.table}>
<div className={styles.trump}>
{obs.trump_card ? <PlayingCard card={obs.trump_card} size="md" /> : <div className={styles.noTrump}>—</div>}
<span className={`${styles.trumpLabel} ${obs.trump_suit ? styles[obs.trump_suit] : ''}`}>{trumpText}</span>
</div>
<TrickArea observation={obs} players={players} completedTrick={completedTrick} />
</section>
<p className={`${styles.status} ${myTurn ? styles.statusActive : ''}`} aria-live="polite">
{status}
</p>
<section className={styles.me}>
{myTurn && obs.phase === 'bidding' && <BidPanel observation={obs} onBid={(value) => actions.play({ type: 'bid', value })} />}
{myTurn && obs.phase === 'trump_selection' && (
<TrumpPicker onChoose={(suit) => actions.play({ type: 'trump', suit })} />
)}
<div className={styles.myRow}>
<PlayerSeat observation={obs} seat={you} player={players[you]} isYou showHand={false} />
<Hand observation={obs} onPlay={(card) => actions.play({ type: 'play', card })} />
</div>
</section>
{completedRound && obs.phase !== 'finished' && <RoundSummary result={completedRound} players={players} />}
{showScores && (
<Modal title="Scoreboard" wide onClose={() => setShowScores(false)}>
<Scoreboard observation={obs} players={players} />
</Modal>
)}
{obs.phase === 'finished' && !showScores && (
<Modal title="Game over">
<GameOver
observation={obs}
players={players}
isHost={isHost}
onShowScores={() => setShowScores(true)}
onBackToLobby={actions.returnToLobby}
onLeave={actions.leaveLobby}
/>
</Modal>
)}
{confirmLeave && (
<Modal title="Leave the game?" onClose={() => setConfirmLeave(false)}>
<p className={styles.confirmText}>
{obs.phase === 'finished'
? 'You will return to the start page.'
: 'An AI player will take over your seat for the rest of the game.'}
</p>
<div className={styles.confirmActions}>
<Button variant="secondary" onClick={() => setConfirmLeave(false)}>
Stay
</Button>
<Button variant="danger" onClick={actions.leaveLobby}>
Leave game
</Button>
</div>
</Modal>
)}
</main>
)
}
@@ -0,0 +1,26 @@
.hand {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
padding-top: 14px;
}
.slot {
display: flex;
}
/* Overlap the cards on narrow screens so larger hands still fit. */
@media (max-width: 720px) {
.hand {
gap: 4px 0;
}
.slot + .slot {
margin-left: -26px;
}
.slot:hover {
z-index: 1;
}
}
@@ -0,0 +1,30 @@
import type { Observation } from '../../api/types'
import { PlayingCard } from '../../components/PlayingCard/PlayingCard'
import { legalCardIds, sortHand } from '../../util/cards'
import styles from './Hand.module.css'
interface HandProps {
observation: Observation
onPlay: (cardId: number) => void
}
export function Hand({ observation, onPlay }: HandProps) {
const canPlay = observation.phase === 'playing' && observation.current_player === observation.player
const legal = legalCardIds(observation.legal_actions)
const cards = sortHand(observation.hand, observation.trump_suit)
return (
<div className={styles.hand} data-count={cards.length}>
{cards.map((card) => (
<div key={card.id} className={styles.slot}>
<PlayingCard
card={card}
size="lg"
onClick={canPlay ? () => onPlay(card.id) : undefined}
dimmed={canPlay && !legal.has(card.id)}
/>
</div>
))}
</div>
)
}
@@ -0,0 +1,114 @@
.seat {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 150px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgb(26 22 64 / 85%);
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.active {
border-color: var(--gold);
box-shadow: 0 0 0 1px var(--gold), 0 0 22px rgb(255 209 102 / 35%);
}
.you {
background: rgb(35 29 82 / 90%);
}
.header {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 700;
}
.muted {
color: var(--text-muted);
font-weight: 400;
}
.chip {
flex: none;
padding: 1px 7px;
border-radius: 999px;
background: rgb(255 209 102 / 18%);
color: var(--gold);
font-size: 11px;
font-weight: 700;
}
.ai {
background: rgb(139 108 255 / 22%);
color: var(--accent-strong);
}
.offline {
background: rgb(255 107 122 / 15%);
color: var(--danger);
}
.stats {
display: flex;
gap: 16px;
}
.stat {
display: flex;
flex-direction: column;
}
.label {
color: var(--text-muted);
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.score {
font-size: 20px;
font-weight: 800;
}
.tricks {
font-size: 20px;
font-weight: 800;
}
.onTarget {
color: var(--success);
}
.over {
color: var(--danger);
}
.hand {
display: flex;
align-items: center;
min-height: 24px;
}
.hand > * + * {
margin-left: -30px;
}
.more {
margin-left: 6px !important;
color: var(--text-muted);
font-size: 12px;
}
@@ -0,0 +1,63 @@
import type { LobbyPlayer, Observation } from '../../api/types'
import { CardBack } from '../../components/PlayingCard/PlayingCard'
import styles from './PlayerSeat.module.css'
const MAX_BACKS = 8
interface PlayerSeatProps {
observation: Observation
seat: number
player: LobbyPlayer | undefined
isYou?: boolean
showHand?: boolean
}
export function PlayerSeat({ observation, seat, player, isYou, showHand = true }: PlayerSeatProps) {
const isTurn = observation.current_player === seat && observation.phase !== 'finished'
const bid = observation.bids[seat]
const tricks = observation.tricks_won[seat]
const handSize = observation.hand_sizes[seat]
let bidText = '–'
if (bid !== null) bidText = String(bid)
else if (observation.has_bid[seat]) bidText = '?'
const onTarget = bid !== null && observation.phase === 'playing' && tricks === bid
const over = bid !== null && tricks > bid
return (
<div className={`${styles.seat} ${isTurn ? styles.active : ''} ${isYou ? styles.you : ''}`}>
<div className={styles.header}>
<span className={styles.name} title={player?.name}>
{player?.name ?? `Player ${seat + 1}`}
{isYou && <span className={styles.muted}> (you)</span>}
</span>
{player?.is_ai && <span className={`${styles.chip} ${styles.ai}`}>AI</span>}
{observation.dealer === seat && <span className={styles.chip} title="Dealer">D</span>}
{player && !player.connected && <span className={`${styles.chip} ${styles.offline}`}>offline</span>}
</div>
<div className={styles.stats}>
<div className={styles.stat}>
<span className={styles.label}>Score</span>
<span className={styles.score}>{observation.scores[seat]}</span>
</div>
<div className={styles.stat}>
<span className={styles.label}>Tricks / Bid</span>
<span className={`${styles.tricks} ${onTarget ? styles.onTarget : ''} ${over ? styles.over : ''}`}>
{tricks} / {bidText}
</span>
</div>
</div>
{showHand && (
<div className={styles.hand} aria-label={`${handSize} cards in hand`}>
{Array.from({ length: Math.min(handSize, MAX_BACKS) }, (_, index) => (
<CardBack key={index} size="sm" />
))}
{handSize > MAX_BACKS && <span className={styles.more}>+{handSize - MAX_BACKS}</span>}
</div>
)}
</div>
)
}
@@ -0,0 +1,77 @@
.summary {
position: fixed;
top: 72px;
right: 16px;
z-index: 30;
width: 240px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgb(26 22 64 / 96%);
box-shadow: var(--shadow);
animation: pop 0.25s ease-out;
}
.title {
margin: 0 0 8px;
font-weight: 700;
}
.list {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.row {
display: flex;
gap: 8px;
}
.name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail {
color: var(--text-muted);
}
.plus {
width: 44px;
color: var(--success);
font-weight: 700;
text-align: right;
}
.minus {
width: 44px;
color: var(--danger);
font-weight: 700;
text-align: right;
}
@keyframes pop {
from {
opacity: 0;
transform: translateY(-8px);
}
}
@media (max-width: 720px) {
.summary {
top: auto;
right: 50%;
bottom: 16px;
transform: translateX(50%);
animation: none;
}
}
@@ -0,0 +1,26 @@
import type { LobbyPlayer, RoundResult } from '../../api/types'
import styles from './RoundSummary.module.css'
interface RoundSummaryProps {
result: RoundResult
players: LobbyPlayer[]
}
export function RoundSummary({ result, players }: RoundSummaryProps) {
return (
<div className={styles.summary} role="status">
<p className={styles.title}>Round {result.round_number} complete</p>
<ul className={styles.list}>
{result.score_changes.map((change, seat) => (
<li key={seat} className={styles.row}>
<span className={styles.name}>{players[seat]?.name ?? `Player ${seat + 1}`}</span>
<span className={styles.detail}>
{result.tricks[seat]}/{result.bids[seat]}
</span>
<span className={change >= 0 ? styles.plus : styles.minus}>{change >= 0 ? `+${change}` : change}</span>
</li>
))}
</ul>
</div>
)
}
@@ -0,0 +1,73 @@
.wrapper {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.table th,
.table td {
padding: 6px 10px;
border-bottom: 1px solid var(--border);
text-align: center;
white-space: nowrap;
}
.table th {
position: sticky;
top: 0;
background: var(--bg-elevated);
color: var(--text-muted);
font-weight: 600;
}
.round {
width: 60px;
color: var(--text-muted);
}
.me {
background: rgb(139 108 255 / 10%);
}
.table th.me {
color: var(--text);
background: #262057;
}
.total {
font-weight: 700;
}
.change {
margin-left: 6px;
font-size: 12px;
}
.plus {
color: var(--success);
}
.minus {
color: var(--danger);
}
.detail {
margin-left: 6px;
color: var(--text-muted);
font-size: 12px;
}
.current td {
font-style: italic;
}
.legend {
margin: 10px 0 0;
color: var(--text-muted);
font-size: 12px;
}
@@ -0,0 +1,65 @@
import type { LobbyPlayer, Observation } from '../../api/types'
import styles from './Scoreboard.module.css'
interface ScoreboardProps {
observation: Observation
players: LobbyPlayer[]
}
/** The "block of truth": bids, tricks and scores of every round. */
export function Scoreboard({ observation, players }: ScoreboardProps) {
const seats = Array.from({ length: observation.num_players }, (_, seat) => seat)
const inProgress = observation.phase !== 'finished'
return (
<div className={styles.wrapper}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.round}>Round</th>
{seats.map((seat) => (
<th key={seat} className={seat === observation.player ? styles.me : ''}>
{players[seat]?.name ?? `Player ${seat + 1}`}
</th>
))}
</tr>
</thead>
<tbody>
{observation.round_history.map((result) => (
<tr key={result.round_number}>
<td className={styles.round}>{result.round_number}</td>
{seats.map((seat) => {
const change = result.score_changes[seat]
return (
<td key={seat} className={seat === observation.player ? styles.me : ''}>
<span className={styles.total}>{result.scores[seat]}</span>
<span className={`${styles.change} ${change >= 0 ? styles.plus : styles.minus}`}>
{change >= 0 ? `+${change}` : change}
</span>
<span className={styles.detail}>
{result.tricks[seat]}/{result.bids[seat]}
</span>
</td>
)
})}
</tr>
))}
{inProgress && (
<tr className={styles.current}>
<td className={styles.round}>{observation.round_number}</td>
{seats.map((seat) => (
<td key={seat} className={seat === observation.player ? styles.me : ''}>
<span className={styles.detail}>
{observation.tricks_won[seat]}/
{observation.bids[seat] ?? (observation.has_bid[seat] ? '?' : '–')}
</span>
</td>
))}
</tr>
)}
</tbody>
</table>
<p className={styles.legend}>Cells show total score, change and tricks/bid.</p>
</div>
)
}
@@ -0,0 +1,61 @@
.area {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.cards {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
min-height: 160px;
align-items: flex-start;
}
.slot {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.enter {
animation: deal-in 0.25s ease-out;
}
.owner {
max-width: 96px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-muted);
font-size: 13px;
}
.winner {
color: var(--gold);
font-weight: 700;
}
.placeholder {
width: 88px;
aspect-ratio: 5 / 7.4;
border: 2px dashed rgb(255 255 255 / 12%);
border-radius: 11px;
}
.caption {
min-height: 1.4em;
margin: 0;
color: var(--text-muted);
font-size: 14px;
}
@keyframes deal-in {
from {
opacity: 0;
transform: translateY(24px) scale(0.9);
}
}

Some files were not shown because too many files have changed in this diff Show More