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.
|
React + TypeScript frontend (Vite). All component styles are CSS modules
|
||||||
|
(`*.module.css`); the design tokens live on the `.app` root in
|
||||||
Currently, two official plugins are available:
|
`src/App.module.css`.
|
||||||
|
|
||||||
- [@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...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
|
```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
|
## Structure
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
- `src/api/` — protocol types and the `useWizard` hook (websocket connection,
|
||||||
globalIgnores(['dist']),
|
reconnect with a per-tab session token, game state)
|
||||||
{
|
- `src/components/` — reusable UI (button, playing card, modal, toasts)
|
||||||
files: ['**/*.{ts,tsx}'],
|
- `src/screens/HomeScreen` — enter a name, create or join a lobby
|
||||||
extends: [
|
- `src/screens/LobbyScreen` — lobby code, players, AI players, rule options, start
|
||||||
// Other configs...
|
- `src/screens/GameScreen` — the table: seats, trick, hand, bidding, trump choice,
|
||||||
// Enable lint rules for React
|
scoreboard, round summary, game over
|
||||||
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...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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;
|
||||||
|
}
|
||||||
+48
-114
@@ -1,121 +1,55 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect } from 'react'
|
||||||
import heroImg from './assets/hero.png'
|
import styles from './App.module.css'
|
||||||
import reactLogo from './assets/react.svg'
|
import { useWizard } from './api/useWizard'
|
||||||
import viteLogo from './assets/vite.svg'
|
import { Toasts } from './components/Toasts/Toasts'
|
||||||
import './App.css'
|
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() {
|
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 (
|
return (
|
||||||
<>
|
<div className={styles.app}>
|
||||||
<section id="center">
|
{status !== 'open' && <div className={styles.banner}>{STATUS_MESSAGES[status]}</div>}
|
||||||
<div className="hero">
|
{screen}
|
||||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
<Toasts toasts={toasts} onDismiss={dismissToast} />
|
||||||
<img src={reactLogo} className="framework" alt="React logo" />
|
</div>
|
||||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
|
||||||
</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 { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
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