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