From e9ddb82e9a4315d164373ad2fa877ae71b50ac98 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Wed, 16 Sep 2026 13:20:05 +0800 Subject: [PATCH] Initial scopone scientifico backend Multiplayer scopone scientifico backend on the kaya framework: - OIDC login (kaya-oidc), session-backed WebSocket auth - Pure rules engine (forced captures, scopa, primiera scoring) with full-match simulation tests - Live game state in Redis (JSON + TTL, join codes, per-game locks, pub/sub state push); in-memory fallback for tests - WebSocket /ws/games/{id} for real-time play; REST lobby endpoints (create/join/snapshot) with hidden-hand views - Finished matches persisted to Postgres (Tortoise + aerich) for match history and leaderboard endpoints - Docker Compose stack: postgres, redis, mock-oauth2-server, db-migrate, app - 45 tests passing; mypy clean --- .dockerignore | 21 ++ .env.example | 25 ++ .gitignore | 9 + Dockerfile | 60 ++++ README.md | 188 +++++++++++ conftest.py | 16 + dev/mockoauth/Dockerfile | 9 + dev/mockoauth/config.json | 55 +++ docker-compose.yml | 101 ++++++ migrations/models/0_20260915224544_init.py | 67 ++++ pyproject.toml | 61 ++++ requirements.txt | 99 ++++++ src/scopa/__init__.py | 1 + src/scopa/aerich_config.py | 23 ++ src/scopa/app.py | 75 +++++ src/scopa/auth.py | 59 ++++ src/scopa/config.py | 54 +++ src/scopa/game/__init__.py | 1 + src/scopa/game/engine.py | 372 +++++++++++++++++++++ src/scopa/game/errors.py | 42 +++ src/scopa/game/state.py | 187 +++++++++++ src/scopa/http.py | 73 ++++ src/scopa/models.py | 53 +++ src/scopa/openapi.py | 21 ++ src/scopa/pagination.py | 128 +++++++ src/scopa/routes/__init__.py | 1 + src/scopa/routes/games.py | 197 +++++++++++ src/scopa/routes/health.py | 19 ++ src/scopa/routes/stats.py | 110 ++++++ src/scopa/stats.py | 57 ++++ src/scopa/store.py | 187 +++++++++++ src/scopa/tortoise_mixin.py | 104 ++++++ src/scopa/ws.py | 175 ++++++++++ tests/__init__.py | 17 + tests/helpers/__init__.py | 4 + tests/helpers/oidc.py | 56 ++++ tests/test_engine.py | 282 ++++++++++++++++ tests/test_routes_games.py | 123 +++++++ tests/test_stats.py | 169 ++++++++++ tests/test_store.py | 95 ++++++ tests/test_websocket.py | 131 ++++++++ 41 files changed, 3527 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 conftest.py create mode 100644 dev/mockoauth/Dockerfile create mode 100644 dev/mockoauth/config.json create mode 100644 docker-compose.yml create mode 100644 migrations/models/0_20260915224544_init.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 src/scopa/__init__.py create mode 100644 src/scopa/aerich_config.py create mode 100644 src/scopa/app.py create mode 100644 src/scopa/auth.py create mode 100644 src/scopa/config.py create mode 100644 src/scopa/game/__init__.py create mode 100644 src/scopa/game/engine.py create mode 100644 src/scopa/game/errors.py create mode 100644 src/scopa/game/state.py create mode 100644 src/scopa/http.py create mode 100644 src/scopa/models.py create mode 100644 src/scopa/openapi.py create mode 100644 src/scopa/pagination.py create mode 100644 src/scopa/routes/__init__.py create mode 100644 src/scopa/routes/games.py create mode 100644 src/scopa/routes/health.py create mode 100644 src/scopa/routes/stats.py create mode 100644 src/scopa/stats.py create mode 100644 src/scopa/store.py create mode 100644 src/scopa/tortoise_mixin.py create mode 100644 src/scopa/ws.py create mode 100644 tests/__init__.py create mode 100644 tests/helpers/__init__.py create mode 100644 tests/helpers/oidc.py create mode 100644 tests/test_engine.py create mode 100644 tests/test_routes_games.py create mode 100644 tests/test_stats.py create mode 100644 tests/test_store.py create mode 100644 tests/test_websocket.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d9e16ee --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +# Keep the build context lean: exclude the local venv, caches, tests, +# and anything not needed to build the scopa wheel. +.git/ +.gitignore +.env +.env.example +.venv/ +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +.mypy_cache/ +.pytest_cache/ +dist/ +build/ +conf/ +tests/ +Dockerfile +.dockerignore +opencode.json +docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bfdb4b5 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Postgres +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=scopa +POSTGRES_USER=scopa +POSTGRES_PASSWORD=scopa +DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa + +# OIDC (mock-oauth2-server in dev; it does not validate clients, so any +# client id/secret works. For a real IdP like Keycloak, use its values here.) +OIDC_ISSUER=http://localhost:8180/scopa +OIDC_CLIENT_ID=scopa +OIDC_CLIENT_SECRET=dev-secret +OIDC_REDIRECT_URI=http://localhost:8080/auth/callback + +# Redis (session and live-game storage). Unset to fall back to in-memory +# stores (only sensible for local development with a single worker). +REDIS_URL=redis://localhost:6379/0 + +# How long a live game survives in Redis without activity. +GAME_TTL_SECONDS=86400 + +# App server +APP_HOST=0.0.0.0 +APP_PORT=8080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aee049f --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +.venv/ +.env +*.egg-info/ +dist/ +build/ +.mypy_cache/ +.pytest_cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f81d9c2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,60 @@ +# syntax=docker/dockerfile:1 +# Multi-stage production build for the scopa app on the kaya framework. +# Base: alpine:3.24 (python3 = 3.14). Deps come from the kaya Gitea registry +# (primary) with PyPI as fallback. All binary deps (asyncpg, cryptography, +# granian) ship musllinux wheels, so no compiler is strictly required; +# build-base + python3-dev are kept in the builder only as a safety net and +# are discarded in the runtime image. +# +# apk and pip both use BuildKit cache mounts (type=cache): the package +# caches persist in the builder's cache across builds instead of being +# re-downloaded, and never land in the image layers. Requires BuildKit +# (default for `docker build` / `docker buildx` on modern daemons). + +# --- Builder --------------------------------------------------------------- +FROM alpine:3.24 AS builder + +RUN --mount=type=cache,target=/var/cache/apk \ + apk add python3 py3-pip build-base python3-dev + +WORKDIR /build + +COPY pyproject.toml README.md requirements.txt ./ +COPY src/ ./src/ +# aerich migration files are a release artifact: the db-migrate compose +# service runs `aerich upgrade` from this image before the app starts. +COPY migrations/ ./migrations/ + +RUN --mount=type=cache,target=/root/.cache/pip \ + python3 -m venv /opt/venv \ + && /opt/venv/bin/pip install --upgrade pip \ + && /opt/venv/bin/pip install -r requirements.txt . + +# --- Runtime --------------------------------------------------------------- +FROM alpine:3.24 + +RUN --mount=type=cache,target=/var/cache/apk \ + apk add python3 ca-certificates tzdata \ + && addgroup -S app && adduser -S -G app app + +COPY --from=builder /opt/venv /opt/venv +COPY --from=builder /build/migrations /app/migrations +# aerich reads [tool.aerich] from pyproject.toml (its default config file); +# the db-migrate compose service runs `aerich upgrade` with working_dir=/app. +COPY --from=builder /build/pyproject.toml /app/pyproject.toml + +ENV PATH="/opt/venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + GRANIAN_HOST=0.0.0.0 \ + GRANIAN_PORT=8080 \ + GRANIAN_INTERFACE=rsgi + +USER app + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \ + CMD wget -q -O- http://127.0.0.1:8080/api/health || exit 1 + +CMD ["granian", "scopa.app:app"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff8d971 --- /dev/null +++ b/README.md @@ -0,0 +1,188 @@ +# scopa + +A multiplayer backend for **scopone scientifico** (the four-player, +fixed-partnership variant of the classic Italian card game), built on the +[kaya](../kaya) framework. + +Players authenticate with the configured **OIDC** provider. Live game state +is kept in **Redis** (with real-time play over WebSocket), and completed +matches — every participant and the final score — are persisted to +**Postgres** for history and leaderboard statistics. + +## Quick start (Docker Compose) + +```sh +docker compose up --build +``` + +The stack starts Postgres, Redis, a mock OIDC provider +(navikt/mock-oauth2-server) with four ready-made players +(`alice`, `bob`, `carol`, `dave`), a one-shot database migration service, +and the app itself on `http://127.0.0.1:8080`. + +Because the browser and the app both talk to the OIDC issuer at +`http://mockoauth:8180/scopa`, add a host entry once: + +```sh +echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts +``` + +Then log in: `GET http://localhost:8080/auth/login`, type any username +(e.g. `alice`) in the interactive login, and you are redirected back with a +session cookie. That cookie authenticates both the REST API and the +WebSocket endpoint. + +OpenAPI documentation is served at `/api/docs` (`/api/openapi.json`). + +## Configuration + +All configuration comes from environment variables (see `.env.example`): + +| Variable | Default | Description | +|---|---|---| +| `DATABASE_URL` | `postgres://scopa:scopa@localhost:5432/scopa` | Postgres DSN for match statistics | +| `REDIS_URL` | unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores | +| `OIDC_ISSUER` | `http://localhost:8180/scopa` | OIDC issuer URL | +| `OIDC_CLIENT_ID` | `scopa` | OIDC client id | +| `OIDC_CLIENT_SECRET` | unset | OIDC client secret | +| `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL | +| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis | +| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address | + +## Data model + +### Redis (live games) + +- `scopa:game:` — the whole match as JSON: players (seat 0/2 = team A, + 1/3 = team B), hands, table, captured piles, scope, current turn, dealer, + scores, phase (`lobby` → `playing` → `finished`). Sliding TTL + (`GAME_TTL_SECONDS`). +- `scopa:code:` — the 6-character join code → game id index. +- `scopa:game::lock` — a short-lived lock serializing every mutation. +- `scopa:game::events` — a pub/sub channel carrying "state changed" + signals; every open WebSocket reloads the state and pushes the + personalized view to its player. + +### Postgres (statistics, via Tortoise ORM + aerich migrations) + +- `match` — one row per finished match: both teams' final scores, winner, + target score, hands played, start/finish timestamps. +- `match_player` — one row per participant: the OIDC `sub`, display name, + seat, team and whether they won. Unique per `(match, user_sub)`. + +When a match ends, the result is written transactionally to Postgres +(once, guarded by a flag on the Redis state); the finished state stays in +Redis until its TTL expires so clients can still fetch the final board. + +## REST API + +All endpoints except `/api/health`, `/api/docs` and `/api/openapi.json` +require authentication. + +| Method | Path | Description | +|---|---|---| +| `POST` | `/api/games` | Create a lobby game. Optional body `{"target_score": 11}`. Returns `{id, join_code}` | +| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The fourth player triggers the deal | +| `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) | +| `GET` | `/api/me/matches` | Cursor-paginated match history with final scores (`?limit=&cursor=`) | +| `GET` | `/api/leaderboard` | Aggregated wins / matches / team points per player | + +## WebSocket protocol + +Connect to `/ws/games/{game_id}` with your session cookie. Only seated +players are accepted; unauthenticated, unknown-game and non-player +connections are closed with codes `4401`, `4404` and `4403` respectively. + +Server → client messages are JSON objects with a `type`: + +- `{"type": "state", "game": {...}}` — personalized game view. Your own + hand is included; other players expose only their card counts. + `your_turn: true` is present when it is your move. +- `{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}` +- `{"type": "error", "code": "illegal_move", "message": "..."}` + +Client → server messages: + +```json +{"action": "play", "card": "07D", "capture": ["02D", "05C"]} +{"action": "play", "card": "07D"} +{"action": "state"} +``` + +- `card` is the card you play, rendered as `RRSUIT` (`01A`..`10D`; suits + `D` denari, `C` coppe, `S` spade, `B` bastoni — e.g. `07D` is the + settebello). +- `capture` lists the table cards to take. When a capture is legal it is + mandatory to provide one; when no capture exists it must be omitted. +- `state` asks for a fresh snapshot. + +After every accepted move the new state is broadcast to all four players. + +## Rules implemented + +- 40-card Italian deck, ten cards per player, empty table at hand start. +- A card captures a **single card of equal rank** (mandatory when present) + or a **combination of table cards whose ranks sum to its own**. +- Emptying the table scores a *scopa* (+1), except on the last play of a + hand. Remaining table cards go to the last player who captured. +- Hand points: `carte` (most captured cards), `denara` (most diamonds), + `settebello` (7♦), `primiera` (best 7/6/5/4 per suit, all four suits + required), plus one point per scopa. Ties award nothing. +- The match ends when a team reaches the target score (default 11, + configurable per game) with a clear lead; a tie at or above the target is + broken by another hand. + +## Development + +```sh +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +.venv/bin/pip install -e '.[dev]' +.venv/bin/python -m unittest discover -s tests -t . +.venv/bin/mypy src +``` + +Tests run fully in-process: in-memory sqlite for Postgres, in-memory stores +for Redis, a fake OIDC user patched onto the mixins, and `httpx` / +`httpx-ws` ASGI transports for HTTP and WebSocket coverage. + +### Migrations + +The Postgres schema is owned by aerich migrations in `migrations/`. The +`db-migrate` compose service runs `aerich upgrade` before the app starts. +To add a migration after changing `src/scopa/models.py`: + +```sh +DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa .venv/bin/aerich migrate +DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa .venv/bin/aerich upgrade +``` + +(`aerich init-db` produces sqlite-flavored DDL when pointed at sqlite; +adjust `UUID`/`TIMESTAMPTZ`/`BOOL`/`JSONB` for Postgres like the existing +baseline.) + +## Layout + +``` +src/scopa/ +├── app.py # composition root: session/OIDC/Tortoise/OpenAPI mixins +├── config.py # env -> frozen Settings +├── auth.py # auth helpers (HTTP + WebSocket) +├── http.py # JSON request/response helpers +├── pagination.py # keyset (cursor) pagination +├── openapi.py # shared OpenAPI parameter fragments +├── tortoise_mixin.py # TortoiseORM lifecycle (HTTP + WebSocket) +├── aerich_config.py # aerich CLI configuration +├── models.py # Match, MatchPlayer (Postgres) +├── stats.py # finished match -> Postgres persistence +├── store.py # Redis / in-memory live-game store +├── ws.py # WebSocket live-play endpoint +├── game/ +│ ├── state.py # GameState / PlayerState / Card, JSON (de)serialization +│ ├── engine.py # pure scopone scientifico rules +│ └── errors.py # typed rule violations +└── routes/ + ├── health.py # GET /api/health + ├── games.py # lobby: create / join / snapshot + └── stats.py # match history + leaderboard +``` diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..7a064ff --- /dev/null +++ b/conftest.py @@ -0,0 +1,16 @@ +"""Shared test fixtures. + +Tests run against an in-memory sqlite database (overriding ``DATABASE_URL``) +so they need no running Postgres, and with ``REDIS_URL`` unset so sessions +and live games use their in-memory stores. The environment is set before +:mod:`scopa.app` is imported by the test modules. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "sqlite://:memory:") +os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/scopa") +os.environ.setdefault("OIDC_CLIENT_ID", "scopa") +os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback") +os.environ.pop("REDIS_URL", None) diff --git a/dev/mockoauth/Dockerfile b/dev/mockoauth/Dockerfile new file mode 100644 index 0000000..3b8b9be --- /dev/null +++ b/dev/mockoauth/Dockerfile @@ -0,0 +1,9 @@ +# Dev OIDC provider: navikt/mock-oauth2-server with the scopa test +# configuration (four ready-made players) baked in. The config is +# COPYed instead of bind-mounted so this also works against containerized +# (e.g. rootless/DinD) docker daemons that cannot see the host workspace. +FROM ghcr.io/navikt/mock-oauth2-server:5.0.2 + +COPY config.json /config.json + +ENV JSON_CONFIG_PATH=/config.json diff --git a/dev/mockoauth/config.json b/dev/mockoauth/config.json new file mode 100644 index 0000000..c0141b4 --- /dev/null +++ b/dev/mockoauth/config.json @@ -0,0 +1,55 @@ +{ + "interactiveLogin": true, + "tokenCallbacks": [ + { + "issuerId": "scopa", + "tokenExpiry": 3600, + "requestMappings": [ + { + "requestParam": "subject", + "match": "alice", + "claims": { + "sub": "alice", + "preferred_username": "alice", + "name": "Alice" + } + }, + { + "requestParam": "subject", + "match": "bob", + "claims": { + "sub": "bob", + "preferred_username": "bob", + "name": "Bob" + } + }, + { + "requestParam": "subject", + "match": "carol", + "claims": { + "sub": "carol", + "preferred_username": "carol", + "name": "Carol" + } + }, + { + "requestParam": "subject", + "match": "dave", + "claims": { + "sub": "dave", + "preferred_username": "dave", + "name": "Dave" + } + }, + { + "requestParam": "subject", + "match": ".*", + "claims": { + "sub": "${subject}", + "preferred_username": "${subject}" + } + } + ] + } + ] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e418704 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,101 @@ +services: + postgres: + image: postgres:18-alpine + environment: + POSTGRES_DB: scopa + POSTGRES_USER: scopa + POSTGRES_PASSWORD: scopa + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U scopa"] + interval: 5s + timeout: 3s + retries: 10 + + # Mock OIDC provider (navikt/mock-oauth2-server). Zero manual setup: + # interactive login accepts any username (no password). Pre-configured + # players: alice, bob, carol, dave; any other username works too. + # The mock does not validate clients, so any client id/secret works. + # + # It listens on 8180 both inside and outside the network so the OIDC + # issuer URL (http://mockoauth:8180/scopa) is identical for + # container-to-container calls and for browser redirects (via the + # /etc/hosts entry documented in the README). + mockoauth: + # Derived image baking dev/mockoauth/config.json in (see + # dev/mockoauth/Dockerfile) — a bind mount would not work against + # containerized docker daemons. + build: ./dev/mockoauth + environment: + SERVER_PORT: "8180" + LOG_LEVEL: INFO + ports: + - "8180:8180" + + # The JRE image has no usable healthcheck tooling; gate the app on the + # discovery endpoint being served instead. + mockoauth-init: + image: curlimages/curl + depends_on: + - mockoauth + entrypoint: > + /bin/sh -c " + until curl -sf http://mockoauth:8180/scopa/.well-known/openid-configuration > /dev/null; do + echo 'waiting for mockoauth...'; sleep 2; + done + " + + # Sessions + live game state. No volume: sessions are disposable (worst + # case, users log in again) and games in progress have a TTL. + redis: + image: redis:8-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + # One-shot: applies pending aerich migrations before the app starts + # (build cache makes the second build of the same Dockerfile instant). + db-migrate: + build: . + # aerich reads [tool.aerich] from /app/pyproject.toml (default config + # file) and finds migrations in ./migrations relative to working_dir. + working_dir: /app + command: ["aerich", "upgrade"] + environment: + DATABASE_URL: postgres://scopa:scopa@postgres:5432/scopa + depends_on: + postgres: + condition: service_healthy + + scopa: + build: . + depends_on: + postgres: + condition: service_healthy + db-migrate: + condition: service_completed_successfully + mockoauth-init: + condition: service_completed_successfully + redis: + condition: service_healthy + environment: + DATABASE_URL: postgres://scopa:scopa@postgres:5432/scopa + OIDC_ISSUER: http://mockoauth:8180/scopa + # The mock OIDC server does not validate clients: any id/secret works. + OIDC_CLIENT_ID: scopa + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dev-secret} + # Browser-facing callback: the app is published on host port 8080. + OIDC_REDIRECT_URI: http://localhost:8080/auth/callback + REDIS_URL: redis://redis:6379/0 + ports: + - "127.0.0.1:8080:8080" + +volumes: + pgdata: diff --git a/migrations/models/0_20260915224544_init.py b/migrations/models/0_20260915224544_init.py new file mode 100644 index 0000000..3c686bf --- /dev/null +++ b/migrations/models/0_20260915224544_init.py @@ -0,0 +1,67 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "match" ( + "id" UUID NOT NULL PRIMARY KEY, + "team_a_score" SMALLINT NOT NULL, + "team_b_score" SMALLINT NOT NULL, + "winner_team" VARCHAR(1) NOT NULL, + "target_score" SMALLINT NOT NULL, + "hands_played" SMALLINT NOT NULL, + "started_at" TIMESTAMPTZ NOT NULL, + "finished_at" TIMESTAMPTZ NOT NULL +); +COMMENT ON TABLE "match" IS 'A completed scopone scientifico match.'; +CREATE TABLE IF NOT EXISTS "match_player" ( + "id" UUID NOT NULL PRIMARY KEY, + "user_sub" VARCHAR(255) NOT NULL, + "display_name" VARCHAR(200) NOT NULL, + "seat" SMALLINT NOT NULL, + "team" VARCHAR(1) NOT NULL, + "won" BOOL NOT NULL, + "match_id" UUID NOT NULL REFERENCES "match" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_match_playe_match_i_f85e3e" UNIQUE ("match_id", "user_sub") +); +COMMENT ON TABLE "match_player" IS 'Participation of one user in one match.'; +CREATE INDEX IF NOT EXISTS "idx_match_playe_user_su_914c0d" ON "match_player" ("user_sub"); +CREATE TABLE IF NOT EXISTS "aerich" ( + "id" SERIAL NOT NULL PRIMARY KEY, + "version" VARCHAR(255) NOT NULL, + "app" VARCHAR(100) NOT NULL, + "content" JSONB NOT NULL +);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + """ + + +MODELS_STATE = ( + "eJztmW1v2joUx79KlFedtFWFdu10NU0KlGpsBaqS7l5tmiyTGLCa2FnsXIZ6+92v7Tw7Dy" + "3dmErFG0iOfZyTnx2f80/uTJ+6yGOHI8idpfmXcWcS6CNxUG54bZgwCHKzNHA48+KeWZcZ" + "4yF0uDDOoceQMLmIOSEOOKZEdrUMh/qBhzhyDebQgBIk/jEiHM+xQw011KEcy6WOGAyTxW" + "ZuEcE/IgQ4XSC+RKFw/vZdmDFx0U/E0tPgFswx8tzSDWNXDqDsgK8DZbu5GZ5fqJ4ypBlw" + "qBf5JO8drPmSkqx7FGH3UPrItgUiKIQi5AIOEnlegi01xRELAw8jlIXq5gYXzWHkSajm+3" + "lEHMnSUFeSPycfktAK3QAYT2wwHdgAmJU5kCFofBOTQ4mcP0y4BHV3H4+bA1FWU16g/9G6" + "Pjg+faUQUMYXoWpUuMx75Qg5jF0V9JwyR9AHEIhZDFGV99SHnjckvJ657qvRF4E/hXtqyM" + "HnizdFmkLbAmYRr/h7c9w9O30nWlWM8uSshf90ZF1eDse2Yq2xnf0C29mebRPbFSbi1oHE" + "VEXbX8KwHqvmplEVse4iVR/+BB4iCy4zRqeF5RfrWm0VHbVTUJEb4owxThq6skVbwzAUW/" + "cT17Dmu1/DZbZLSFwGAg+uUU2ua2er++7ZltkysfjEfQPIq2TPBRGOfVRPtuypcXUT18P0" + "YAcpt8C0h6PB1LZGV3J4n7EfnuJl2QPZ0lXWtWY9ONV2k2wQ4++h/dGQp8bXyXigVydZP/" + "urKWOCEaeA0BWAbpFJak5NpWmeY4LZ8knzrLnuJ/rZTbSUBvPbQtkqDTPo3K5g6IJSS74i" + "1JYYsupq6CWOF5+vkQcVy+q0F1XWlRppd+c9t5qxeFFAaZc2Ea02+V1ft0ACF+qW5LXllW" + "qQNenWnOgD6jVObOHjROyV2K+xgwM1pwadG1KORgyFBibquEHFbuBXI2O/5Tpb+gAWzczv" + "e237nLRtNi8bCISiz7bUQQX2HxMH3bdvHyEPRK9GgaDayqWWi5l8XIE63wC17vcCxVj36O" + "gxvI+OmnnLNq20RXXFTrtcSH32MqH6imaTNbt/cdCwTisvDlZxfaXVYJR6CJKGdzO1FdlM" + "uOwg2xaYvcnkslR294a2hvVm1BuktEUnzJW5unzjYmmzaqLo8ztriucL/IESoiIzNL5VuB" + "c0RHhBPqO1QjwUgUDi1CUx/avNbkKtKAlhDuEqK2xLa0rcvbhnFC/ZvjXtW+cD875Zum1T" + "lFgoxPXf0ZKW121SBOZ9HhIhzUR/8xewxgxf+0DX5Pdk+n5NHWw/uXc7J2cn745PT7IMn1" + "na0ny6RTaLgn9RyHBdbmpO+gWXF5j3t6IJ5EO1AeGk+wuk23mUAui0KIBOVQGIK3JEakTA" + "p+lkXE+44KK/6cQON/4zPMx2UQu0wJUwSnVWyvRgZP2j4+5fTnp6fSAH6NUVCH8ymd3/D7" + "yE7wc=" +) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..998a1b1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,61 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "scopa" +version = "0.1.0" +description = "Scopone scientifico multiplayer backend built on the kaya framework" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "kaya-core", + "kaya-session", + "kaya-session-redis", + "kaya-oidc", + "kaya-openapi", + "kaya-rsgi", + "granian>=2.0", + "tortoise-orm", + "aerich", + "asyncpg", + "httpx", + "PyJWT[crypto]", + "pwo", + "redis", +] + +[project.optional-dependencies] +dev = [ + "mypy", + "httpx-ws", +] + +[tool.setuptools.packages.find] +where = ["src"] +namespaces = false + +# Database migrations (aerich). See the Migrations section in README.md. +[tool.aerich] +tortoise_orm = "scopa.aerich_config.TORTOISE_ORM" +location = "./migrations" + +[tool.mypy] +python_version = "3.12" +ignore_missing_imports = true +plugins = [] + +# TortoiseORM auto-generates `_id` attributes on ForeignKeyField at +# runtime; without the (unavailable here) tortoise mypy plugin the stubs +# only declare the relation field. These are real attributes, not bugs. +[[tool.mypy.overrides]] +module = "scopa.models" +disable_error_code = ["attr-defined"] + +[[tool.mypy.overrides]] +module = "scopa.routes.*" +disable_error_code = ["attr-defined"] + +[[tool.mypy.overrides]] +module = "scopa.game.*" +disable_error_code = ["attr-defined"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4094efd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,99 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --allow-unsafe --extra-index-url=https://pypi.org/simple --index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --no-index --output-file=requirements.txt pyproject.toml +# +--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple +--extra-index-url https://pypi.org/simple + +aerich==0.10.1 + # via scopa (pyproject.toml) +aiosqlite==0.22.1 + # via tortoise-orm +anyio==4.15.1 + # via + # aerich + # httpx + # tortoise-orm +asyncclick==8.4.2.1 + # via aerich +asyncpg==0.31.0 + # via scopa (pyproject.toml) +certifi==2026.7.22 + # via + # httpcore + # httpx +cffi==2.1.1 + # via cryptography +click==8.5.0 + # via granian +cryptography==50.0.1 + # via pyjwt +dictdiffer==0.10.0 + # via aerich +granian==2.8.3 + # via + # kaya-rsgi + # scopa (pyproject.toml) +h11==0.16.0 + # via httpcore +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via + # kaya-oidc + # scopa (pyproject.toml) +idna==3.19 + # via + # anyio + # httpx +iso8601==2.1.0 + # via tortoise-orm +kaya-core==0.0.3 + # via + # kaya-oidc + # kaya-openapi + # kaya-rsgi + # kaya-session + # scopa (pyproject.toml) +kaya-oidc==0.0.3 + # via scopa (pyproject.toml) +kaya-openapi==0.0.3 + # via scopa (pyproject.toml) +kaya-rsgi==0.0.3 + # via scopa (pyproject.toml) +kaya-session==0.0.3 + # via + # kaya-oidc + # kaya-session-redis + # scopa (pyproject.toml) +kaya-session-redis==0.0.3 + # via scopa (pyproject.toml) +pwo==0.1.2 + # via + # kaya-core + # kaya-rsgi + # kaya-session + # scopa (pyproject.toml) +pycparser==3.0 + # via cffi +pyjwt[crypto]==2.14.0 + # via + # kaya-oidc + # scopa (pyproject.toml) +pypika-tortoise==0.6.5 + # via tortoise-orm +redis==8.1.0 + # via + # kaya-session-redis + # scopa (pyproject.toml) +tortoise-orm==1.1.8 + # via + # aerich + # scopa (pyproject.toml) +typing-extensions==4.16.0 + # via + # anyio + # kaya-core + # pwo diff --git a/src/scopa/__init__.py b/src/scopa/__init__.py new file mode 100644 index 0000000..e29c713 --- /dev/null +++ b/src/scopa/__init__.py @@ -0,0 +1 @@ +"""Scopone scientifico backend built on the kaya framework.""" diff --git a/src/scopa/aerich_config.py b/src/scopa/aerich_config.py new file mode 100644 index 0000000..9979eb7 --- /dev/null +++ b/src/scopa/aerich_config.py @@ -0,0 +1,23 @@ +"""Tortoise ORM configuration consumed by the aerich CLI. + +Kept separate from :mod:`scopa.app` so ``aerich`` can import it without +assembling the whole application (mixins, routes). The database URL comes +from the same :class:`~scopa.config.Settings` the app uses, so the CLI +and the app always point at the same database. + +``aerich.models`` is required alongside the app models: it provides the +table aerich uses to track applied migrations. +""" +from __future__ import annotations + +from .config import settings + +TORTOISE_ORM = { + "connections": {"default": settings.database_url}, + "apps": { + "models": { + "models": ["scopa.models", "aerich.models"], + "default_connection": "default", + } + }, +} diff --git a/src/scopa/app.py b/src/scopa/app.py new file mode 100644 index 0000000..8a67955 --- /dev/null +++ b/src/scopa/app.py @@ -0,0 +1,75 @@ +"""Application entry point. + +Assembles the :class:`~kaya.core.KayaApp` with four mixins: + +- :class:`~kaya.session.SessionMixin` (sessions persisted in Redis via + :class:`~kaya.session.redis.RedisSessionStore` when ``REDIS_URL`` is set, + otherwise an in-memory store — e.g. for tests) +- :class:`~kaya.oidc.OIDCMixin` (OIDC login) +- :class:`~scopa.tortoise_mixin.TortoiseMixin` (Postgres match statistics; + skipped for ``/api/health`` and the OpenAPI documentation endpoints) +- :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at + ``/api/openapi.json`` and a Swagger UI at ``/api/docs``) + +Live games are kept in :data:`game_store` (Redis when configured, in-memory +otherwise). Routes and the websocket handlers are registered by importing +their modules at the bottom; imports must happen after ``app`` is built. +""" +from __future__ import annotations + +from importlib.metadata import version as _pkg_version + +from kaya.core import KayaApp +from kaya.oidc import OIDCConfig, OIDCMixin +from kaya.openapi import OpenAPIMixin +from kaya.session import InMemorySessionStore, SessionMixin, SessionStore +from kaya.session.redis import RedisSessionStore +from redis.asyncio import Redis + +from .config import settings +from .store import GameStore, InMemoryGameStore, RedisGameStore +from .tortoise_mixin import TortoiseMixin + +session_store: SessionStore +if settings.redis_url is not None: + # Lazy client: no connection is opened until a session is actually + # loaded/saved, so importing this module never requires a live Redis. + session_store = RedisSessionStore(Redis.from_url(settings.redis_url)) + game_store: GameStore = RedisGameStore( + Redis.from_url(settings.redis_url, decode_responses=False), + ttl_seconds=settings.game_ttl_seconds, + ) +else: + session_store = InMemorySessionStore() + game_store = InMemoryGameStore() + +session_mixin = SessionMixin(session_store) +oidc_mixin = OIDCMixin( + OIDCConfig( + issuer=settings.oidc_issuer, + client_id=settings.oidc_client_id, + client_secret=settings.oidc_client_secret, + redirect_uri=settings.oidc_redirect_uri, + fetch_userinfo=True, + ), + session=session_mixin, +) +openapi_mixin = OpenAPIMixin( + title="scopa", + version=_pkg_version("scopa"), + description="Scopone scientifico multiplayer API", + spec_path="/api/openapi.json", + docs_path="/api/docs", +) +tortoise_mixin = TortoiseMixin( + database_url=settings.database_url, + models_modules=["scopa.models"], + skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}), +) + +app = KayaApp(mixins=[session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin]) + +# Register routes by importing modules. Order does not matter; each module +# pulls ``app`` from here and decorates its handlers at import time. +from .routes import games, health, stats # noqa: E402,F401 +from . import ws # noqa: E402,F401 diff --git a/src/scopa/auth.py b/src/scopa/auth.py new file mode 100644 index 0000000..72fff96 --- /dev/null +++ b/src/scopa/auth.py @@ -0,0 +1,59 @@ +"""Authentication helpers on top of the kaya-oidc mixin. + +Scopa has no application roles: every authenticated user may create and +join games. Authorization beyond login is game membership, checked against +the live game state in Redis. +""" +from __future__ import annotations + +from typing import Any, Callable, Mapping, Optional + +from kaya.core import HttpContext, WebSocket +from kaya.oidc import OIDCUser + +from .app import oidc_mixin + + +def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]: + """Return the authenticated user of a WebSocket connection, if any. + + The session mixin injects ``session`` into the websocket wrapper; the + OIDC mixin stores the userinfo there at login. Patched in tests. + """ + session = getattr(ws, "session", None) + if session is None: + return None + claims = session.get("oidc_user") + if not isinstance(claims, Mapping): + return None + return OIDCUser(claims) + + +def display_name(user: OIDCUser) -> str: + """Best-effort human-readable name for a user.""" + for key in ("name", "preferred_username", "email"): + value = user.get(key) + if isinstance(value, str) and value: + return value + return user.sub + + +def require_auth(handler: Callable[..., Any]) -> Callable[..., Any]: + """Decorator: gate a handler on being authenticated (any OIDC user). + + Responds ``401`` with a JSON error envelope when unauthenticated — + unlike kaya's built-in ``OIDCMixin.require_auth`` which redirects to + the login page (wrong for a JSON API). + """ + + async def guarded(ctx: HttpContext, *args: Any, **kwargs: Any) -> None: + if oidc_mixin.get_user(ctx) is None: + await ctx.send_bytes( + 401, + b'{"error":"unauthenticated"}', + {"content-type": ("application/json",)}, + ) + return + await handler(ctx, *args, **kwargs) + + return guarded diff --git a/src/scopa/config.py b/src/scopa/config.py new file mode 100644 index 0000000..65d0814 --- /dev/null +++ b/src/scopa/config.py @@ -0,0 +1,54 @@ +"""Environment-driven configuration for the scopa application. + +Mirrors kaya's own pattern: read ``os.environ`` directly into a plain +dataclass. No pydantic-settings, no settings module. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Optional + + +def _env(name: str, default: Optional[str] = None) -> str: + value = os.environ.get(name) + if value is None or value == "": + if default is None: + raise RuntimeError(f"Missing required environment variable: {name}") + return default + return value + + +@dataclass(frozen=True) +class Settings: + database_url: str + oidc_issuer: str + oidc_client_id: str + oidc_client_secret: Optional[str] + oidc_redirect_uri: str + app_host: str + app_port: int + redis_url: Optional[str] + # How long a live game (and its join-code index) survives in Redis + # without activity, in seconds. Defaults to 24h. + game_ttl_seconds: int + + @staticmethod + def from_env() -> "Settings": + return Settings( + database_url=_env("DATABASE_URL", "postgres://scopa:scopa@localhost:5432/scopa"), + oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/scopa"), + oidc_client_id=_env("OIDC_CLIENT_ID", "scopa"), + oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"), + oidc_redirect_uri=_env("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback"), + app_host=_env("APP_HOST", "0.0.0.0"), + app_port=int(_env("APP_PORT", "8080")), + # When unset, sessions and live games fall back to in-memory + # stores (tests, ephemeral dev). Set to e.g. + # redis://localhost:6379/0 to persist both in Redis. + redis_url=os.environ.get("REDIS_URL"), + game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")), + ) + + +settings: Settings = Settings.from_env() diff --git a/src/scopa/game/__init__.py b/src/scopa/game/__init__.py new file mode 100644 index 0000000..cd972e1 --- /dev/null +++ b/src/scopa/game/__init__.py @@ -0,0 +1 @@ +"""Scopone scientifico domain package.""" diff --git a/src/scopa/game/engine.py b/src/scopa/game/engine.py new file mode 100644 index 0000000..f43c8cb --- /dev/null +++ b/src/scopa/game/engine.py @@ -0,0 +1,372 @@ +"""Pure rules engine for scopone scientifico. + +Every function here is deterministic and I/O-free: it mutates (or reads) +:class:`~scopa.game.state.GameState` and raises +:class:`~scopa.game.errors.GameError` subclasses on rule violations. This +makes the whole rule set unit-testable without Redis, Postgres or HTTP. + +Rules implemented +----------------- +* 40-card Italian deck (4 suits x ranks 1-10), ten cards per player, empty + table at the start of every hand. +* A card captures either a **single card of equal rank** or a **combination + of cards whose ranks sum to its own**. When an equal-ranked card is on the + table that capture is mandatory; the player may not take an alternative + combination instead. +* Emptying the table with a capture is a **scopa** (+1), except on the very + last play of a hand. +* At the end of a hand the remaining table cards go to the player who made + the last capture. +* Hand points: ``carte`` (most captured cards), ``denara`` (most diamond + cards), ``settebello`` (the 7 of diamonds), ``primiera`` (best + seven/five/four/three card of each suit, all four suits required), plus + one point per ``scopa``. Ties on carte/denara/primiera award nothing. +* The match ends when a team reaches the target score with a clear lead; a + tie at or above the target is broken by playing another hand. +""" +from __future__ import annotations + +import random +from datetime import datetime, timezone +from itertools import combinations +from typing import Dict, List, Optional, Sequence, Tuple + +from .errors import ( + AlreadyJoined, + CardNotInHand, + GameFinished, + GameNotStarted, + IllegalMove, + LobbyFull, + NotYourTurn, +) +from .state import ( + DEFAULT_TARGET_SCORE, + PHASE_FINISHED, + PHASE_LOBBY, + PHASE_PLAYING, + SUITS, + TEAM_NAMES, + Card, + GameState, + PlayerState, + parse_card, +) + +# Number of cards dealt to each player at the start of a hand. +HAND_SIZE = 10 +PLAYERS = 4 + +# Primiera card values: sevens are best, then sixes, then aces, then the +# remaining ranks in descending order. All of 8/9/10 are worth 10. +PRIMIERA_VALUES: Dict[int, int] = { + 7: 21, + 6: 18, + 1: 16, + 5: 15, + 4: 14, + 3: 13, + 2: 12, + 8: 10, + 9: 10, + 10: 10, +} + +_rng = random.SystemRandom() + + +def full_deck() -> List[Card]: + """Return the 40 cards of the Italian deck in canonical order.""" + return [Card(rank=rank, suit=suit) for suit in SUITS for rank in range(1, 11)] + + +def shuffled_deck(rng: Optional[random.Random] = None) -> List[Card]: + """Return a shuffled deck. Pass ``rng`` for deterministic tests.""" + deck = full_deck() + (rng or _rng).shuffle(deck) + return deck + + +def legal_captures(table: Sequence[Card], card: Card) -> List[List[Card]]: + """Return every legal capture (a list of card sets) for ``card``. + + If an equal-ranked card is on the table, only those single-card + captures are returned (the rule forbids taking a combination instead). + Otherwise every subset of the table whose ranks sum to ``card.rank`` is + returned. + """ + equal = [c for c in table if c.rank == card.rank] + if equal: + return [[c] for c in equal] + + candidates = [c for c in table if c.rank <= card.rank] + captures: List[List[Card]] = [] + # A sum-equal capture needs at least two cards (single non-equal cards + # cannot sum to the played card). + for size in range(2, len(candidates) + 1): + for combo in combinations(candidates, size): + if sum(c.rank for c in combo) == card.rank: + captures.append(list(combo)) + return captures + + +def create_game( + game_id: str, + join_code: str, + creator_sub: str, + creator_name: str, + target_score: int = DEFAULT_TARGET_SCORE, +) -> GameState: + """Create a lobby game with the creator seated first.""" + if target_score < 1 or target_score > 100: + raise IllegalMove("target_score must be between 1 and 100") + return GameState( + id=game_id, + join_code=join_code, + creator_sub=creator_sub, + target_score=target_score, + phase=PHASE_LOBBY, + players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)], + created_at=datetime.now(timezone.utc).isoformat(), + ) + + +def join_game(state: GameState, sub: str, name: str) -> None: + """Seat ``sub`` in the next free chair, starting the match when full.""" + if state.phase != PHASE_LOBBY: + raise GameNotStarted("game has already started") + if state.seated(sub): + raise AlreadyJoined("already joined this game") + if len(state.players) >= PLAYERS: + raise LobbyFull("game is full") + seat = len(state.players) + state.players.append(PlayerState(sub=sub, name=name, seat=seat)) + if len(state.players) == PLAYERS: + start_game(state) + + +def start_game(state: GameState) -> None: + """Deal the first hand and switch the game to playing.""" + if len(state.players) != PLAYERS: + raise GameNotStarted("need exactly four players to start") + state.phase = PHASE_PLAYING + _deal_hand(state) + + +def _deal_hand(state: GameState) -> None: + deck = shuffled_deck() + for player in state.players: + player.hand = [] + player.captured = [] + player.scope = 0 + state.table = [] + state.last_taker = None + # Dealer rotates each hand; the first card is played by the player to + # the dealer's left. + state.turn = (state.dealer + 1) % PLAYERS + for offset in range(HAND_SIZE): + for seat in range(PLAYERS): + player = _player_at(state, (state.dealer + 1 + seat) % PLAYERS) + player.hand.append(deck.pop()) + + +def _player_at(state: GameState, seat: int) -> PlayerState: + for player in state.players: + if player.seat == seat: + return player + raise IllegalMove(f"no player in seat {seat}") + + +def play( + state: GameState, + sub: str, + card_code: str, + capture_codes: Optional[Sequence[str]] = None, +) -> None: + """Apply one move by the player identified by ``sub``. + + ``capture_codes`` selects which table cards to capture; it must be a + legal capture (see :func:`legal_captures`) when one exists and empty + otherwise. Raises a :class:`~scopa.game.errors.GameError` subclass on + any violation. + """ + if state.phase == PHASE_FINISHED: + raise GameFinished("the match is over") + if state.phase != PHASE_PLAYING: + raise GameNotStarted("the game has not started yet") + + player = state.player_for(sub) + if player is None or player.seat != state.turn: + raise NotYourTurn("it is not your turn") + + card = parse_card(card_code) + if card not in player.hand: + raise CardNotInHand(f"card {card.code} is not in your hand") + # Remove the card now so the scopa check below can tell whether this + # was the last play of the hand. + player.hand.remove(card) + + requested = [parse_card(c) for c in (capture_codes or [])] + options = legal_captures(state.table, card) + + if not options: + if requested: + raise IllegalMove("no capture is possible with that card") + state.table.append(card) + else: + chosen = _match_option(options, requested) + if chosen is None: + raise IllegalMove("the requested capture is not legal") + for captured in chosen: + state.table.remove(captured) + player.captured.append(captured) + player.captured.append(card) + state.last_taker = player.seat + # A scopa scores only if cards remain to be played this hand. + hands_empty = all(not p.hand for p in state.players) + if not state.table and not hands_empty: + player.scope += 1 + + if all(not p.hand for p in state.players): + _end_hand(state) + else: + state.turn = (state.turn + 1) % PLAYERS + + +def _match_option( + options: Sequence[Sequence[Card]], requested: Sequence[Card] +) -> Optional[List[Card]]: + """Return the option matching ``requested`` exactly, if any.""" + wanted = sorted(c.code for c in requested) + if not wanted: + return None + for option in options: + if sorted(c.code for c in option) == wanted: + return list(option) + return None + + +def _end_hand(state: GameState) -> None: + """Sweep the table, score the hand and either deal again or finish.""" + if state.table and state.last_taker is not None: + taker = _player_at(state, state.last_taker) + taker.captured.extend(state.table) + state.table = [] + + points, details = hand_points(state) + for team in (0, 1): + state.scores[team] += points[team] + details["hand"] = state.hand_number + details["team_a_points"] = points[0] + details["team_b_points"] = points[1] + state.hand_scores.append(details) + + a, b = state.scores + reached = max(a, b) >= state.target_score + if reached and a != b: + state.phase = PHASE_FINISHED + state.winner = 0 if a > b else 1 + state.finished_at = datetime.now(timezone.utc).isoformat() + return + + state.hand_number += 1 + state.dealer = (state.dealer + 1) % PLAYERS + _deal_hand(state) + + +def primiera_score(captured: Sequence[Card]) -> int: + """Return the primiera value of a capture pile (0 if a suit is absent).""" + best: Dict[str, int] = {} + for card in captured: + value = PRIMIERA_VALUES[card.rank] + if card.suit not in best or value > best[card.suit]: + best[card.suit] = value + if len(best) < len(SUITS): + return 0 + return sum(best.values()) + + +def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]: + """Compute the hand points for both teams (index 0 = team A).""" + piles: List[List[Card]] = [[], []] + scope: List[int] = [0, 0] + for player in state.players: + piles[player.team].extend(player.captured) + scope[player.team] += player.scope + + points = [0, 0] + # Carte: most captured cards. Ties award nothing. + cards = [len(piles[0]), len(piles[1])] + if cards[0] != cards[1]: + points[0 if cards[0] > cards[1] else 1] += 1 + # Denara: most diamond cards. Ties award nothing. + coins = [ + sum(1 for c in piles[t] if c.suit == "D") for t in (0, 1) + ] + if coins[0] != coins[1]: + points[0 if coins[0] > coins[1] else 1] += 1 + # Settebello: the 7 of diamonds. + settebello = [ + any(c.rank == 7 and c.suit == "D" for c in piles[t]) for t in (0, 1) + ] + if settebello[0] != settebello[1]: + points[0 if settebello[0] else 1] += 1 + # Primiera: highest value, only if the team holds all four suits. + primiera = [primiera_score(piles[t]) for t in (0, 1)] + if primiera[0] != primiera[1]: + points[0 if primiera[0] > primiera[1] else 1] += 1 + # Scope: one point each. + points[0] += scope[0] + points[1] += scope[1] + + details: Dict[str, object] = { + "cards": {"A": cards[0], "B": cards[1]}, + "denara": {"A": coins[0], "B": coins[1]}, + "settebello": {"A": settebello[0], "B": settebello[1]}, + "primiera": {"A": primiera[0], "B": primiera[1]}, + "scope": {"A": scope[0], "B": scope[1]}, + } + return points, details + + +def state_for_player(state: GameState, sub: str) -> Dict[str, object]: + """Serialize ``state`` hiding other players' hands. + + Hands are reduced to a count, except for the requesting player's own + hand. Raises :class:`~scopa.game.errors.GameNotFound`-style access via + the caller; this function assumes ``sub`` may or may not be seated and + simply omits the hand for non-seated viewers. + """ + viewer = state.player_for(sub) + players: List[Dict[str, object]] = [] + for player in state.players: + view: Dict[str, object] = { + "sub": player.sub, + "name": player.name, + "seat": player.seat, + "team": TEAM_NAMES[player.team], + "cards_left": len(player.hand), + "captured_count": len(player.captured), + "scope": player.scope, + } + if viewer is not None and viewer.seat == player.seat: + view["hand"] = [c.code for c in player.hand] + players.append(view) + + payload: Dict[str, object] = { + "id": state.id, + "join_code": state.join_code, + "phase": state.phase, + "target_score": state.target_score, + "hand_number": state.hand_number, + "dealer": state.dealer, + "turn": state.turn, + "scores": {"A": state.scores[0], "B": state.scores[1]}, + "winner": None if state.winner is None else TEAM_NAMES[state.winner], + "table": [c.code for c in state.table], + "players": players, + "last_hand": state.hand_scores[-1] if state.hand_scores else None, + } + if viewer is not None and state.phase == PHASE_PLAYING and viewer.seat == state.turn: + payload["your_turn"] = True + return payload diff --git a/src/scopa/game/errors.py b/src/scopa/game/errors.py new file mode 100644 index 0000000..b937274 --- /dev/null +++ b/src/scopa/game/errors.py @@ -0,0 +1,42 @@ +"""Typed errors raised by the scopone engine. + +Route/WebSocket handlers translate these into 4xx responses or ``error`` +WebSocket messages; the engine itself stays transport-agnostic. +""" +from __future__ import annotations + + +class GameError(Exception): + """Base class for every rule/validation failure.""" + + +class IllegalMove(GameError): + """The requested play violates the rules of scopone scientifico.""" + + +class NotYourTurn(GameError): + """A player attempted to play out of turn.""" + + +class CardNotInHand(GameError): + """The played card is not held by the player.""" + + +class GameNotStarted(GameError): + """An action was attempted before the game left the lobby.""" + + +class GameFinished(GameError): + """An action was attempted after the match ended.""" + + +class LobbyFull(GameError): + """A game already has four players.""" + + +class AlreadyJoined(GameError): + """A player tried to join a game they are already seated in.""" + + +class GameNotFound(GameError): + """No live game exists for the given id or join code.""" diff --git a/src/scopa/game/state.py b/src/scopa/game/state.py new file mode 100644 index 0000000..5b79841 --- /dev/null +++ b/src/scopa/game/state.py @@ -0,0 +1,187 @@ +"""In-memory representation of a scopone scientifico game. + +The whole mutable game lives in :class:`GameState`, which is serialized to +and from plain JSON for storage in Redis (see :mod:`scopa.store`). Keeping +the representation JSON-native means the store needs no custom codecs and +the state is inspectable with ``redis-cli``. + +Deck convention: a 40-card Italian deck. Suits are ``D`` (denari), +``C`` (coppe), ``S`` (spade) and ``B`` (bastoni); ranks are ``1``..``10``. +A card is rendered as ``RRSUIT`` (e.g. ``07D`` is the settebello). +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +SUITS = ("D", "C", "S", "B") +RANKS = tuple(range(1, 11)) + +# Teams are derived from the seat: seats 0 and 2 form team A (index 0), +# seats 1 and 3 form team B (index 1). Team pairs always sit opposite each +# other, as in real scopone scientifico. +TEAM_A = 0 +TEAM_B = 1 +TEAM_NAMES = ("A", "B") + +PHASE_LOBBY = "lobby" +PHASE_PLAYING = "playing" +PHASE_FINISHED = "finished" + +DEFAULT_TARGET_SCORE = 11 + + +def team_of(seat: int) -> int: + return seat % 2 + + +@dataclass(frozen=True) +class Card: + rank: int + suit: str + + def __post_init__(self) -> None: + if self.suit not in SUITS: + raise ValueError(f"invalid suit: {self.suit!r}") + if self.rank not in RANKS: + raise ValueError(f"invalid rank: {self.rank!r}") + + @property + def code(self) -> str: + return f"{self.rank:02d}{self.suit}" + + @staticmethod + def parse(code: str) -> "Card": + code = str(code).upper() + if len(code) != 3 or not code[:2].isdigit(): + raise ValueError(f"invalid card code: {code!r}") + return Card(rank=int(code[:2]), suit=code[2]) + + def to_json(self) -> str: + return self.code + + @staticmethod + def from_json(value: Any) -> "Card": + return Card.parse(str(value)) + + +def parse_card(code: Any) -> Card: + """Parse a card code, raising :class:`ValueError` on malformed input.""" + try: + return Card.parse(str(code)) + except ValueError: + raise + + +@dataclass +class PlayerState: + sub: str + name: str + seat: int + hand: List[Card] = field(default_factory=list) + captured: List[Card] = field(default_factory=list) + scope: int = 0 + + @property + def team(self) -> int: + return team_of(self.seat) + + def to_json(self) -> Dict[str, Any]: + return { + "sub": self.sub, + "name": self.name, + "seat": self.seat, + "hand": [c.to_json() for c in self.hand], + "captured": [c.to_json() for c in self.captured], + "scope": self.scope, + } + + @staticmethod + def from_json(data: Dict[str, Any]) -> "PlayerState": + return PlayerState( + sub=str(data["sub"]), + name=str(data["name"]), + seat=int(data["seat"]), + hand=[Card.from_json(c) for c in data.get("hand", [])], + captured=[Card.from_json(c) for c in data.get("captured", [])], + scope=int(data.get("scope", 0)), + ) + + +@dataclass +class GameState: + id: str + join_code: str + creator_sub: str + target_score: int = DEFAULT_TARGET_SCORE + phase: str = PHASE_LOBBY + players: List[PlayerState] = field(default_factory=list) + table: List[Card] = field(default_factory=list) + dealer: int = 0 + turn: int = 0 + hand_number: int = 1 + scores: List[int] = field(default_factory=lambda: [0, 0]) + winner: Optional[int] = None + last_taker: Optional[int] = None + # Per-hand points awarded, for a compact audit trail in the API. + hand_scores: List[Dict[str, Any]] = field(default_factory=list) + stats_saved: bool = False + # ISO-8601 timestamps, used when the match result is written to Postgres. + created_at: Optional[str] = None + finished_at: Optional[str] = None + + # -- serialization ---------------------------------------------------- + + def to_json(self) -> Dict[str, Any]: + return { + "id": self.id, + "join_code": self.join_code, + "creator_sub": self.creator_sub, + "target_score": self.target_score, + "phase": self.phase, + "players": [p.to_json() for p in self.players], + "table": [c.to_json() for c in self.table], + "dealer": self.dealer, + "turn": self.turn, + "hand_number": self.hand_number, + "scores": list(self.scores), + "winner": self.winner, + "last_taker": self.last_taker, + "hand_scores": list(self.hand_scores), + "stats_saved": self.stats_saved, + "created_at": self.created_at, + "finished_at": self.finished_at, + } + + @staticmethod + def from_json(data: Dict[str, Any]) -> "GameState": + return GameState( + id=str(data["id"]), + join_code=str(data["join_code"]), + creator_sub=str(data.get("creator_sub", "")), + target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)), + phase=str(data.get("phase", PHASE_LOBBY)), + players=[PlayerState.from_json(p) for p in data.get("players", [])], + table=[Card.from_json(c) for c in data.get("table", [])], + dealer=int(data.get("dealer", 0)), + turn=int(data.get("turn", 0)), + hand_number=int(data.get("hand_number", 1)), + scores=[int(x) for x in data.get("scores", [0, 0])], + winner=data.get("winner"), + last_taker=data.get("last_taker"), + hand_scores=list(data.get("hand_scores", [])), + stats_saved=bool(data.get("stats_saved", False)), + created_at=data.get("created_at"), + finished_at=data.get("finished_at"), + ) + + # -- helpers ---------------------------------------------------------- + + def player_for(self, sub: str) -> Optional[PlayerState]: + for player in self.players: + if player.sub == sub: + return player + return None + + def seated(self, sub: str) -> bool: + return self.player_for(sub) is not None diff --git a/src/scopa/http.py b/src/scopa/http.py new file mode 100644 index 0000000..bcd6ece --- /dev/null +++ b/src/scopa/http.py @@ -0,0 +1,73 @@ +"""JSON helpers for kaya HTTP handlers. + +Kaya has no built-in request/response JSON helpers: the request body is an +async byte stream on ``ctx.request_body`` and responses are sent with +``ctx.send_*``. These wrappers handle the boilerplate of draining the body, +parsing JSON, and sending JSON responses. +""" +from __future__ import annotations + +import json +from typing import Any, List, Mapping + +from kaya.core import HttpContext + +JSON_HEADERS = {"content-type": ("application/json",)} + + +class JsonRequestError(ValueError): + """Raised by :func:`read_json` when the request body is not valid JSON + or is not a JSON object.""" + + +def extract_query_params(query_string: str) -> Mapping[str, List[str]]: + """Parse a raw query string into a mapping of param name to list of + values. + + Wraps :func:`urllib.parse.parse_qs` so callers don't repeat the + incantation; always returns a mapping (never None). + """ + from urllib.parse import parse_qs + + return parse_qs(query_string, keep_blank_values=True) + + +async def read_json(ctx: HttpContext) -> dict: + """Drain and parse the request body as JSON. + + Returns the parsed ``dict`` on success. Raises :class:`JsonRequestError` + with a short human-readable reason on failure. + """ + body = b"" + async for chunk in ctx.request_body: + body += chunk + if not body: + raise JsonRequestError("empty body") + try: + parsed = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise JsonRequestError("invalid JSON") from exc + if not isinstance(parsed, dict): + raise JsonRequestError("JSON body must be an object") + return parsed + + +async def read_json_optional(ctx: HttpContext) -> dict: + """Like :func:`read_json` but treats an empty body as ``{}``.""" + try: + return await read_json(ctx) + except JsonRequestError as exc: + if str(exc) == "empty body": + return {} + raise + + +async def send_json(ctx: HttpContext, status: int, payload: Any) -> None: + """Send ``payload`` as a JSON response.""" + body = json.dumps(payload).encode("utf-8") + await ctx.send_bytes(status, body, JSON_HEADERS) + + +async def send_error(ctx: HttpContext, status: int, message: str) -> None: + """Send a JSON error envelope.""" + await send_json(ctx, status, {"error": message}) diff --git a/src/scopa/models.py b/src/scopa/models.py new file mode 100644 index 0000000..5085c11 --- /dev/null +++ b/src/scopa/models.py @@ -0,0 +1,53 @@ +"""Tortoise ORM models: match statistics persisted in Postgres. + +Live game state lives in Redis (see :mod:`scopa.store`); only completed +matches are written here. The two tables answer the question "every match +a player took part in, with the final score": + +* :class:`Match` — one row per finished match with both teams' scores. +* :class:`MatchPlayer` — one row per participant, linking an OIDC + ``sub`` to a seat/team and whether they won. +""" +from __future__ import annotations + +from tortoise import fields +from tortoise.models import Model + + +class Match(Model): + """A completed scopone scientifico match.""" + + id = fields.UUIDField(pk=True) + team_a_score = fields.SmallIntField() + team_b_score = fields.SmallIntField() + # "A" or "B". + winner_team = fields.CharField(max_length=1) + target_score = fields.SmallIntField() + hands_played = fields.SmallIntField() + started_at = fields.DatetimeField() + finished_at = fields.DatetimeField() + + players: fields.ReverseRelation["MatchPlayer"] + + class Meta: + table = "match" + ordering = ["-finished_at"] + + +class MatchPlayer(Model): + """Participation of one user in one match.""" + + id = fields.UUIDField(pk=True) + match: fields.ForeignKeyRelation[Match] = fields.ForeignKeyField( + "models.Match", related_name="players", on_delete=fields.CASCADE + ) + # OIDC subject of the player; no local users table. + user_sub = fields.CharField(max_length=255, db_index=True) + display_name = fields.CharField(max_length=200) + seat = fields.SmallIntField() + team = fields.CharField(max_length=1) + won = fields.BooleanField() + + class Meta: + table = "match_player" + unique_together = (("match", "user_sub"),) diff --git a/src/scopa/openapi.py b/src/scopa/openapi.py new file mode 100644 index 0000000..b5d0220 --- /dev/null +++ b/src/scopa/openapi.py @@ -0,0 +1,21 @@ +"""Shared OpenAPI fragments for the ``@operation`` decorators in ``routes/``.""" +from __future__ import annotations + +from typing import Any, Dict, List + +PAGINATION_PARAMETERS: List[Dict[str, Any]] = [ + { + "name": "limit", + "in": "query", + "required": False, + "schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20}, + "description": "Maximum number of results per page (clamped to [1, 100]).", + }, + { + "name": "cursor", + "in": "query", + "required": False, + "schema": {"type": "string"}, + "description": "Opaque pagination cursor from a previous response's next_cursor.", + }, +] diff --git a/src/scopa/pagination.py b/src/scopa/pagination.py new file mode 100644 index 0000000..549b21c --- /dev/null +++ b/src/scopa/pagination.py @@ -0,0 +1,128 @@ +"""Cursor-based pagination for listing endpoints. + +Uses keyset pagination (not OFFSET/LIMIT): each page ends with an opaque +cursor encoding the sort key tuple of the last item on that page; the next +request passes that cursor and the query continues from the point it left +off. This is stable under concurrent inserts and cheaper than OFFSET for +large result sets. + +Cursor is a base64-encoded JSON object mapping the sort-field names to the +values of the last item on the previous page. It's opaque to callers and +must be treated as a black box. +""" +from __future__ import annotations + +import base64 +import json +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple + +from tortoise.queryset import QuerySet + +from .http import extract_query_params + +DEFAULT_LIMIT = 20 +MAX_LIMIT = 100 +MIN_LIMIT = 1 + +CURSOR_PARAM = "cursor" +LIMIT_PARAM = "limit" + + +class CursorDecodeError(ValueError): + """Raised when the ``cursor`` query param cannot be decoded.""" + + +def encode_cursor(values: Dict[str, Any]) -> str: + # ``default=str`` handles datetimes (ISO) so keyset cursors can carry + # datetime-typed sort fields (e.g. finished_at for match history). + raw = json.dumps(values, separators=(",", ":"), default=str).encode("utf-8") + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def decode_cursor(s: Optional[str]) -> Optional[Dict[str, Any]]: + if s is None or s == "": + return None + try: + # Tolerate missing padding. + padded = s + "=" * (-len(s) % 4) + raw = base64.urlsafe_b64decode(padded.encode("ascii")) + obj = json.loads(raw.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as exc: + raise CursorDecodeError("invalid cursor") from exc + if not isinstance(obj, dict): + raise CursorDecodeError("invalid cursor") + return obj + + +@dataclass(frozen=True) +class Cursor: + limit: int + after: Optional[Dict[str, Any]] + + +def parse_cursor_params(query_string: str) -> Cursor: + params = extract_query_params(query_string) + limit_raw = params.get(LIMIT_PARAM) + if limit_raw: + try: + limit = int(limit_raw[0]) + except ValueError as exc: + raise CursorDecodeError("invalid limit") from exc + else: + limit = DEFAULT_LIMIT + limit = max(MIN_LIMIT, min(MAX_LIMIT, limit)) + after = decode_cursor(params.get(CURSOR_PARAM, [None])[0]) + return Cursor(limit=limit, after=after) + + +# A sort field: (model field name, "ASC" or "DESC"). The tuple is the full +# keyset; the cursor encodes exactly these fields. +Sort = List[Tuple[str, str]] + + +def _keyset_where(sort: Sort, after: Dict[str, Any]): + """Build a Tortoise ``Q`` filter from a cursor.""" + from tortoise.queryset import Q # local to keep import edge narrow + + clauses = [] + for i, (field, direction) in enumerate(sort): + key = f"{field}__{'gt' if direction == 'ASC' else 'lt'}" + value = after.get(field) + if value is None: + return Q() + clause = Q(**{key: value}) + for j in range(i): + prev_field, _ = sort[j] + prev_value = after.get(prev_field) + if prev_value is None: + return Q() + clause = clause & Q(**{prev_field: prev_value}) + clauses.append(clause) + result = clauses[0] + for clause in clauses[1:]: + result = result | clause + return result + + +async def paginate( + queryset: QuerySet, + sort: Sort, + cursor: Cursor, +) -> Tuple[List[Any], Optional[str]]: + """Return one page of ``queryset`` plus the opaque cursor to continue.""" + order_by: List[str] = [] + for field, direction in sort: + order_by.append(field if direction == "ASC" else f"-{field}") + qs = queryset.order_by(*order_by) + if cursor.after: + qs = qs.filter(_keyset_where(sort, cursor.after)) + rows = await qs.limit(cursor.limit + 1) + if len(rows) <= cursor.limit: + return rows, None + page = rows[: cursor.limit] + last = page[-1] + key: Dict[str, Any] = {} + for field, _ in sort: + key[field] = getattr(last, field) + return page, encode_cursor(key) diff --git a/src/scopa/routes/__init__.py b/src/scopa/routes/__init__.py new file mode 100644 index 0000000..cd2194d --- /dev/null +++ b/src/scopa/routes/__init__.py @@ -0,0 +1 @@ +"""HTTP route modules.""" diff --git a/src/scopa/routes/games.py b/src/scopa/routes/games.py new file mode 100644 index 0000000..13071f7 --- /dev/null +++ b/src/scopa/routes/games.py @@ -0,0 +1,197 @@ +"""Game lobby endpoints. + +A game starts as a lobby: the creator is seated first and shares the +six-character ``join_code``. When the fourth player joins, the engine deals +the first hand and the match begins. Live play then happens over the +``/ws/games/{id}`` websocket (see :mod:`scopa.ws`); these endpoints cover +creation, joining and snapshotting state. +""" +from __future__ import annotations + +import secrets +import uuid +from typing import Any, Dict, Optional + +from kaya.core import HttpContext +from kaya.openapi import operation + +from .. import auth +from ..app import app, game_store, oidc_mixin +from ..auth import require_auth +from ..game import engine +from ..game.errors import GameError +from ..game.state import DEFAULT_TARGET_SCORE, PHASE_LOBBY, GameState +from ..http import JsonRequestError, read_json, read_json_optional, send_error, send_json + +# Join codes avoid characters that are easy to confuse when read aloud. +_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" +_CODE_LENGTH = 6 +_MAX_CODE_ATTEMPTS = 20 + + +def _now_code() -> str: + return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LENGTH)) + + +async def _unique_code() -> str: + for _ in range(_MAX_CODE_ATTEMPTS): + code = _now_code() + if await game_store.find_by_code(code) is None: + return code + raise RuntimeError("could not allocate a unique join code") + + +def _lobby_payload(state: GameState) -> Dict[str, Any]: + return { + "id": state.id, + "join_code": state.join_code, + "target_score": state.target_score, + "phase": state.phase, + "players": [ + {"sub": p.sub, "name": p.name, "seat": p.seat, "team": "A" if p.seat % 2 == 0 else "B"} + for p in state.players + ], + "seats_open": 4 - len(state.players), + } + + +@app.POST("/api/games") +@operation(summary="Create a game", + description="Creates a lobby game and seats the caller in seat 0. " + "Share the returned join_code with three other players.", + tags=["games"], + request_body={ + "required": False, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "target_score": {"type": "integer", "minimum": 1, "maximum": 100}, + }, + } + } + }, + }, + responses={ + 201: {"description": "The created lobby"}, + 400: {"description": "Invalid target_score or body"}, + 401: {"description": "Authentication required"}, + }) +@require_auth +async def create_game(ctx: HttpContext) -> None: + body: dict = {} + try: + body = await read_json_optional(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + + target_score: Any = body.get("target_score", DEFAULT_TARGET_SCORE) + if isinstance(target_score, bool) or not isinstance(target_score, int): + await send_error(ctx, 400, "target_score must be an integer") + return + + user = oidc_mixin.get_user(ctx) + assert user is not None # enforced by @require_auth + game_id = str(uuid.uuid4()) + join_code = await _unique_code() + try: + state = engine.create_game( + game_id=game_id, + join_code=join_code, + creator_sub=user.sub, + creator_name=auth.display_name(user), + target_score=target_score, + ) + except GameError as exc: + await send_error(ctx, 400, str(exc)) + return + await game_store.save(state) + await send_json(ctx, 201, _lobby_payload(state)) + + +@app.POST("/api/games/join") +@operation(summary="Join a game by code", + description="Seats the caller in the next free chair. Joining as the " + "fourth player starts the match.", + tags=["games"], + request_body={ + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"code": {"type": "string"}}, + "required": ["code"], + } + } + }, + }, + responses={ + 200: {"description": "Seated; game state (may be playing)"}, + 400: {"description": "Missing code"}, + 401: {"description": "Authentication required"}, + 404: {"description": "Unknown join code"}, + 409: {"description": "Already joined or lobby full"}, + }) +@require_auth +async def join_game(ctx: HttpContext) -> None: + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + code = body.get("code") + if not isinstance(code, str) or not code: + await send_error(ctx, 400, "code is required") + return + + user = oidc_mixin.get_user(ctx) + assert user is not None + existing = await game_store.find_by_code(code) + if existing is None: + await send_error(ctx, 404, "unknown join code") + return + + async with game_store.lock(existing.id): + state = await game_store.load(existing.id) + if state is None: + await send_error(ctx, 404, "unknown join code") + return + try: + engine.join_game(state, user.sub, auth.display_name(user)) + except GameError as exc: + await send_error(ctx, 409, str(exc)) + return + await game_store.save(state) + await game_store.publish(state.id) + if state.phase == PHASE_LOBBY: + await send_json(ctx, 200, _lobby_payload(state)) + else: + await send_json(ctx, 200, engine.state_for_player(state, user.sub)) + + +@app.GET("/api/games/${game_id}") +@operation(summary="Get a game snapshot", + description="Only seated players may read a game; other players' " + "hands are hidden.", + tags=["games"], + responses={ + 200: {"description": "The personalized game state"}, + 401: {"description": "Authentication required"}, + 403: {"description": "Not a player in this game"}, + 404: {"description": "Game not found"}, + }) +@require_auth +async def get_game(ctx: HttpContext, game_id: str) -> None: + state = await game_store.load(game_id) + if state is None: + await send_error(ctx, 404, "game not found") + return + user = oidc_mixin.get_user(ctx) + assert user is not None + if not state.seated(user.sub): + await send_error(ctx, 403, "forbidden") + return + await send_json(ctx, 200, engine.state_for_player(state, user.sub)) diff --git a/src/scopa/routes/health.py b/src/scopa/routes/health.py new file mode 100644 index 0000000..c042844 --- /dev/null +++ b/src/scopa/routes/health.py @@ -0,0 +1,19 @@ +"""Liveness probe.""" +from __future__ import annotations + +from kaya.core import HttpContext +from kaya.openapi import operation + +from ..app import app + + +@app.GET("/api/health") +@operation(summary="Health check", + tags=["health"], + responses={200: {"description": "The service is up"}}) +async def health(ctx: HttpContext) -> None: + await ctx.send_bytes( + 200, + b'{"status":"ok"}', + {"content-type": ("application/json",)}, + ) diff --git a/src/scopa/routes/stats.py b/src/scopa/routes/stats.py new file mode 100644 index 0000000..f88025e --- /dev/null +++ b/src/scopa/routes/stats.py @@ -0,0 +1,110 @@ +"""Player statistics endpoints, served from Postgres. + +Every finished match is persisted by :func:`scopa.stats.save_match_result`. +These endpoints expose a player's own match history and a global +leaderboard aggregated from the same two tables. +""" +from __future__ import annotations + +from typing import Any, Dict, List + +from kaya.core import HttpContext +from kaya.openapi import operation + +from ..app import app, oidc_mixin +from ..auth import require_auth +from ..http import send_error, send_json +from ..models import Match, MatchPlayer +from ..openapi import PAGINATION_PARAMETERS +from ..pagination import CursorDecodeError, paginate, parse_cursor_params + + +async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]: + participants = await MatchPlayer.filter(match_id=match.id).order_by("seat") + return { + "id": str(match.id), + "team_a_score": match.team_a_score, + "team_b_score": match.team_b_score, + "winner_team": match.winner_team, + "target_score": match.target_score, + "hands_played": match.hands_played, + "started_at": match.started_at.isoformat(), + "finished_at": match.finished_at.isoformat(), + "you_won": any(p.user_sub == viewer and p.won for p in participants), + "players": [ + { + "user_sub": p.user_sub, + "display_name": p.display_name, + "seat": p.seat, + "team": p.team, + "won": p.won, + } + for p in participants + ], + } + + +@app.GET("/api/me/matches") +@operation(summary="List my matches", + description="Cursor-paginated history of finished matches the caller " + "played, newest first, with the final score.", + tags=["stats"], + parameters=PAGINATION_PARAMETERS, + responses={ + 200: {"description": "A page of matches"}, + 400: {"description": "Invalid pagination cursor"}, + 401: {"description": "Authentication required"}, + }) +@require_auth +async def my_matches(ctx: HttpContext) -> None: + try: + cursor = parse_cursor_params(ctx.query_string) + except CursorDecodeError as exc: + await send_error(ctx, 400, str(exc)) + return + user = oidc_mixin.get_user(ctx) + assert user is not None + queryset = Match.filter(players__user_sub=user.sub).distinct() + matches, next_cursor = await paginate( + queryset, [("finished_at", "DESC"), ("id", "ASC")], cursor + ) + results = [await _serialize_match(m, user.sub) for m in matches] + await send_json(ctx, 200, {"results": results, "next_cursor": next_cursor}) + + +@app.GET("/api/leaderboard") +@operation(summary="Global leaderboard", + description="Aggregated wins, matches played and team points for every " + "player with at least one finished match. Sorted by wins.", + tags=["stats"], + responses={200: {"description": "The leaderboard"}}) +async def leaderboard(ctx: HttpContext) -> None: + rows = await MatchPlayer.all().prefetch_related("match") + aggregate: Dict[str, Dict[str, Any]] = {} + for row in rows: + entry = aggregate.setdefault( + row.user_sub, + { + "user_sub": row.user_sub, + "display_name": row.display_name, + "matches": 0, + "wins": 0, + "points": 0, + }, + ) + entry["matches"] += 1 + entry["wins"] += 1 if row.won else 0 + match = row.match + if match is not None: + entry["points"] += ( + match.team_a_score if row.team == "A" else match.team_b_score + ) + # Keep the most recent display name seen. + entry["display_name"] = row.display_name + + ranking: List[Dict[str, Any]] = sorted( + aggregate.values(), + key=lambda e: (e["wins"], e["points"], -e["matches"]), + reverse=True, + ) + await send_json(ctx, 200, {"results": ranking}) diff --git a/src/scopa/stats.py b/src/scopa/stats.py new file mode 100644 index 0000000..1a85ef7 --- /dev/null +++ b/src/scopa/stats.py @@ -0,0 +1,57 @@ +"""Copy finished match results from Redis into Postgres. + +Called once when a game reaches the finished phase (guarded by the +``stats_saved`` flag on the state). The write is transactional so a match +never appears with only some of its players. +""" +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import Optional + +from tortoise.transactions import in_transaction + +from .game.state import PHASE_FINISHED, TEAM_NAMES, GameState + + +def _parse_timestamp(value: Optional[str]) -> datetime: + if value: + try: + return datetime.fromisoformat(value) + except ValueError: + pass + return datetime.now(timezone.utc) + + +async def save_match_result(state: GameState) -> None: + """Persist ``state`` to Postgres if it is finished and not yet saved.""" + if state.stats_saved or state.phase != PHASE_FINISHED or state.winner is None: + return + + from .models import Match, MatchPlayer + + started_at = _parse_timestamp(state.created_at) + finished_at = _parse_timestamp(state.finished_at) + async with in_transaction(): + match = await Match.create( + id=uuid.uuid4(), + team_a_score=state.scores[0], + team_b_score=state.scores[1], + winner_team=TEAM_NAMES[state.winner], + target_score=state.target_score, + hands_played=state.hand_number, + started_at=started_at, + finished_at=finished_at, + ) + for player in state.players: + await MatchPlayer.create( + id=uuid.uuid4(), + match=match, + user_sub=player.sub, + display_name=player.name, + seat=player.seat, + team=TEAM_NAMES[player.team], + won=player.team == state.winner, + ) + state.stats_saved = True diff --git a/src/scopa/store.py b/src/scopa/store.py new file mode 100644 index 0000000..e94ad86 --- /dev/null +++ b/src/scopa/store.py @@ -0,0 +1,187 @@ +"""Persistence for live games. + +Game state is small, mutable and short-lived, which makes Redis a natural +fit: the whole match is a single JSON value under ``scopa:game:`` with +a sliding TTL, and a join-code index maps the short code a player shares to +that id. Completed matches are copied to Postgres (see +:mod:`scopa.models`); Redis keeps serving the finished state until it +expires. + +Two implementations satisfy the same interface: + +* :class:`RedisGameStore` — production, used when ``REDIS_URL`` is set. +* :class:`InMemoryGameStore` — tests and ephemeral dev, used otherwise. + +Concurrency is handled with a per-game lock so two simultaneous plays +cannot interleave. State changes are broadcast on a per-game pub/sub +channel as a simple "something changed" signal; every open websocket +reloads the state and renders the personalized view. Publishing only a +signal (never the state) means updated state reaches connections on every +worker without leaking hidden hands into the channel. +""" +from __future__ import annotations + +import asyncio +import contextlib +import json +from abc import ABC, abstractmethod +from typing import AsyncContextManager, AsyncIterator, Dict, Optional, Set + +from redis.asyncio import Redis + +from .game.state import GameState + +GAME_KEY_PREFIX = "scopa:game:" +CODE_KEY_PREFIX = "scopa:code:" +CHANNEL_PREFIX = "scopa:game:" + +# Sentinel pushed into in-memory subscriber queues to signal a change. +_BUMP = b"update" + + +class GameStore(ABC): + """Abstract persistence + notification layer for live games.""" + + @abstractmethod + async def load(self, game_id: str) -> Optional[GameState]: + """Return the live state for ``game_id`` or ``None``.""" + + @abstractmethod + async def save(self, state: GameState) -> None: + """Persist ``state``, refreshing its TTL and code index.""" + + @abstractmethod + async def find_by_code(self, code: str) -> Optional[GameState]: + """Return the live state for a join ``code`` or ``None``.""" + + @abstractmethod + def lock(self, game_id: str) -> AsyncContextManager[None]: + """Async context manager serializing mutations of one game.""" + + @abstractmethod + def subscribe(self, game_id: str) -> AsyncContextManager[AsyncIterator[None]]: + """Async context manager yielding an async iterator of change signals.""" + + @abstractmethod + async def publish(self, game_id: str) -> None: + """Signal that the state of ``game_id`` changed.""" + + +def _channel(game_id: str) -> str: + return f"{CHANNEL_PREFIX}{game_id}:events" + + +class RedisGameStore(GameStore): + def __init__(self, redis: Redis, ttl_seconds: int = 86400) -> None: + self._redis = redis + self._ttl = ttl_seconds + + def lock(self, game_id: str): + # Lock and state use distinct key names; the lock expires on its own + # if a worker dies mid-mutation. + return self._redis.lock(f"{GAME_KEY_PREFIX}{game_id}:lock", + timeout=10, blocking_timeout=10) + + async def load(self, game_id: str) -> Optional[GameState]: + + raw = await self._redis.get(f"{GAME_KEY_PREFIX}{game_id}") + if raw is None: + return None + if isinstance(raw, bytes): + raw = raw.decode("utf-8") + return GameState.from_json(json.loads(raw)) + + async def save(self, state: GameState) -> None: + + payload = json.dumps(state.to_json()) + async with self._redis.pipeline(transaction=True) as pipe: + pipe.set(f"{GAME_KEY_PREFIX}{state.id}", payload, ex=self._ttl) + pipe.set(f"{CODE_KEY_PREFIX}{state.join_code}", state.id, ex=self._ttl) + await pipe.execute() + + async def find_by_code(self, code: str) -> Optional[GameState]: + game_id = await self._redis.get(f"{CODE_KEY_PREFIX}{code.upper()}") + if game_id is None: + return None + if isinstance(game_id, bytes): + game_id = game_id.decode("utf-8") + return await self.load(str(game_id)) + + @contextlib.asynccontextmanager + async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]: + pubsub = self._redis.pubsub() + await pubsub.subscribe(_channel(game_id)) + try: + yield _redis_events(pubsub) + finally: + with contextlib.suppress(Exception): + await pubsub.unsubscribe(_channel(game_id)) + await pubsub.aclose() + + async def publish(self, game_id: str) -> None: + await self._redis.publish(_channel(game_id), "update") + + +async def _redis_events(pubsub) -> AsyncIterator[None]: + async for message in pubsub.listen(): + if message.get("type") == "message": + yield None + + +class InMemoryGameStore(GameStore): + """Process-local store used by tests and when Redis is not configured.""" + + def __init__(self) -> None: + self._games: Dict[str, GameState] = {} + self._codes: Dict[str, str] = {} + self._locks: Dict[str, asyncio.Lock] = {} + self._subscribers: Dict[str, Set[asyncio.Queue]] = {} + + def _lock_for(self, game_id: str) -> asyncio.Lock: + lock = self._locks.get(game_id) + if lock is None: + lock = asyncio.Lock() + self._locks[game_id] = lock + return lock + + @contextlib.asynccontextmanager + async def lock(self, game_id: str) -> AsyncIterator[None]: + async with self._lock_for(game_id): + yield + + async def load(self, game_id: str) -> Optional[GameState]: + state = self._games.get(game_id) + return GameState.from_json(state.to_json()) if state else None + + async def save(self, state: GameState) -> None: + self._games[state.id] = GameState.from_json(state.to_json()) + self._codes[state.join_code] = state.id + + async def find_by_code(self, code: str) -> Optional[GameState]: + game_id = self._codes.get(code.upper()) + if game_id is None: + return None + return await self.load(game_id) + + @contextlib.asynccontextmanager + async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]: + queue: asyncio.Queue = asyncio.Queue() + self._subscribers.setdefault(game_id, set()).add(queue) + try: + yield _queue_events(queue) + finally: + subscribers = self._subscribers.get(game_id) + if subscribers is not None: + subscribers.discard(queue) + if not subscribers: + self._subscribers.pop(game_id, None) + + async def publish(self, game_id: str) -> None: + for queue in list(self._subscribers.get(game_id, ())): + queue.put_nowait(_BUMP) + + +async def _queue_events(queue: asyncio.Queue) -> AsyncIterator[None]: + while True: + await queue.get() + yield None diff --git a/src/scopa/tortoise_mixin.py b/src/scopa/tortoise_mixin.py new file mode 100644 index 0000000..f087e59 --- /dev/null +++ b/src/scopa/tortoise_mixin.py @@ -0,0 +1,104 @@ +"""A :class:`~kaya.core.KayaMixin` that drives the TortoiseORM lifecycle. + +kaya calls ``KayaMixin.setup`` / ``shutdown`` synchronously from inside a +running event loop. Tortoise 1.1.7 binds database connections to a +:class:`~tortoise.context.TortoiseContext` looked up via a contextvar, and +kaya dispatches each HTTP request (and WebSocket connection) as a separate +``loop.create_task``, so a context set by an early request does not +automatically reach later requests. + +This mixin therefore: + +1. Lazily builds a :class:`TortoiseContext` for the active event loop + (rebuilding it if the running loop changes, which happens in tests that + use a fresh ``asyncio.run`` per test). +2. Per HTTP request, binds that context to the current task via the + ``_current_context`` contextvar so the handler — running in the same + task as the ``before_request`` hook — sees an active context. +3. Binds the same context at the start of every WebSocket connection + (``before_websocket`` hook), because the match-result write happens at + the end of a WebSocket match. The long-lived connection task keeps the + context for its whole lifetime. + +It deliberately avoids the global-fallback singleton +(``_enable_global_fallback``), which Tortoise only allows to be set once +per process and would therefore break across event loops. + +Schema management is split by backend: in-memory sqlite databases (the +test suite) get ``generate_schemas`` on every fresh context; Postgres +schemas are owned by aerich migrations and must be applied externally +(``aerich upgrade``, run by the ``db-migrate`` compose service) before +the app serves requests. +""" +from __future__ import annotations + +from asyncio import AbstractEventLoop, get_running_loop +from logging import getLogger +from typing import AbstractSet, Optional, Sequence + +from kaya.core import HttpContext, KayaApp, KayaMixin, WebSocket +from tortoise.context import TortoiseContext, _current_context + +log = getLogger(__name__) + + +class TortoiseMixin(KayaMixin): + """Initialize and tear down a per-loop :class:`TortoiseContext`.""" + + def __init__(self, + database_url: str, + models_modules: Sequence[str], + skip_paths: AbstractSet[str] = frozenset({"/api/health"})) -> None: + self._database_url = database_url + self._models_modules = list(models_modules) + self._skip_paths = skip_paths + self._ctx: "Optional[TortoiseContext]" = None + self._init_loop: "Optional[AbstractEventLoop]" = None + + def apply(self, app: KayaApp) -> None: + app.add_before_request_hook(self._ensure_context) + app.add_before_websocket_hook(self._ensure_ws_context) + + def setup(self, loop: AbstractEventLoop) -> None: + pass + + def shutdown(self, loop: AbstractEventLoop) -> None: + if self._init_loop is loop and self._ctx is not None: + loop.create_task(self._ctx.close_connections()) + self._ctx = None + self._init_loop = None + + async def _build_context(self) -> TortoiseContext: + ctx = TortoiseContext() + with ctx: + await ctx.init( + db_url=self._database_url, + modules={"models": self._models_modules}, + ) + # Schema creation is only done for sqlite (in-memory test + # databases). Postgres schemas are managed by aerich migrations + # (applied by the db-migrate compose service / `aerich upgrade`). + if self._database_url.startswith("sqlite"): + await ctx.generate_schemas() + return ctx + + async def _bind(self) -> None: + loop = get_running_loop() + if self._init_loop is not loop: + if self._ctx is not None: + # A previous test loop went away; drop its context. + self._ctx = None + self._ctx = await self._build_context() + self._init_loop = loop + assert self._ctx is not None + _current_context.set(self._ctx) + + async def _ensure_context(self, ctx: HttpContext): + if ctx.path in self._skip_paths: + return None + await self._bind() + return None + + async def _ensure_ws_context(self, ws: WebSocket): + await self._bind() + return None diff --git a/src/scopa/ws.py b/src/scopa/ws.py new file mode 100644 index 0000000..f7c87fe --- /dev/null +++ b/src/scopa/ws.py @@ -0,0 +1,175 @@ +"""WebSocket endpoint for live play. + +Clients connect to ``/ws/games/{game_id}`` using their session cookie (the +OIDC login stores the user in the session, which the session mixin loads +onto the websocket). Only seated players are accepted. + +Protocol +-------- +Server -> client messages are JSON objects with a ``type``: + +* ``state`` — the personalized game view (own hand visible, others hidden). +* ``game_over`` — sent once when the match ends, with the final scores. +* ``error`` — a rejected action or malformed message. + +Client -> server messages are JSON objects:: + + {"action": "play", "card": "07D", "capture": ["02D", "05C"]} + {"action": "play", "card": "07D"} + {"action": "state"} + +``capture`` lists the table cards to take and must be a legal capture when +one exists (see :func:`scopa.game.engine.legal_captures`); it is omitted +when the played card cannot capture. + +Mutations run under the per-game lock; after a successful move the new +state is saved to Redis and a change signal is published. Every connected +websocket is subscribed to that signal and re-renders the state, so all +players see the move immediately (and consistently across workers). +""" +from __future__ import annotations + +import asyncio +import json +from contextlib import suppress +from typing import Any, Awaitable, Callable, Dict, Optional + +from kaya.core import WebSocket + +from . import auth +from .app import app, game_store +from .game import engine +from .game.errors import GameError +from .game.state import PHASE_FINISHED, GameState +from .stats import save_match_result + +Send = Callable[[Dict[str, Any]], Awaitable[None]] + + +def _error(message: str, code: str = "invalid") -> Dict[str, Any]: + return {"type": "error", "code": code, "message": message} + + +def _state_message(state: GameState, sub: str) -> Dict[str, Any]: + return {"type": "state", "game": engine.state_for_player(state, sub)} + + +@app.websocket("/ws/games/${game_id}") +async def game_socket(ws: WebSocket, game_id: str) -> None: + user = auth.get_ws_user(ws) + if user is None: + await ws.close(4401) + return + + state = await game_store.load(game_id) + if state is None: + await ws.close(4404) + return + if not state.seated(user.sub): + await ws.close(4403) + return + + await ws.accept() + + send_lock = asyncio.Lock() + + async def send(payload: Dict[str, Any]) -> None: + async with send_lock: + await ws.send_text(json.dumps(payload)) + + await send(_state_message(state, user.sub)) + + async with game_store.subscribe(game_id) as events: + forward = asyncio.create_task( + _forward(events, game_id, user.sub, send) + ) + try: + async for message in ws: + if message.kind == "close": + break + if message.kind != "text" or not isinstance(message.data, str): + await send(_error("expected a text frame with a JSON object")) + continue + await _handle_message(send, game_id, user.sub, message.data) + finally: + forward.cancel() + with suppress(asyncio.CancelledError): + await forward + + +async def _forward( + events, + game_id: str, + sub: str, + send: Send, +) -> None: + async for _ in events: + state = await game_store.load(game_id) + if state is None: + return + await send(_state_message(state, sub)) + if state.phase == PHASE_FINISHED: + await send( + { + "type": "game_over", + "scores": {"A": state.scores[0], "B": state.scores[1]}, + "winner": "A" if state.winner == 0 else "B", + } + ) + return + + +async def _handle_message(send: Send, game_id: str, sub: str, raw: str) -> None: + try: + data = json.loads(raw) + except (ValueError, TypeError): + await send(_error("invalid JSON")) + return + if not isinstance(data, dict): + await send(_error("message must be a JSON object")) + return + + action = data.get("action") + if action == "play": + await _handle_play(send, game_id, sub, data) + elif action in ("state", "sync"): + state = await game_store.load(game_id) + if state is not None: + await send(_state_message(state, sub)) + else: + await send(_error(f"unknown action: {action!r}")) + + +async def _handle_play( + send: Send, game_id: str, sub: str, data: Dict[str, Any] +) -> None: + card = data.get("card") + capture = data.get("capture") + if not isinstance(card, str): + await send(_error("'card' must be a card code string")) + return + if capture is not None and ( + not isinstance(capture, list) + or any(not isinstance(item, str) for item in capture) + ): + await send(_error("'capture' must be a list of card codes")) + return + + async with game_store.lock(game_id): + state = await game_store.load(game_id) + if state is None: + await send(_error("game not found", code="not_found")) + return + try: + engine.play(state, sub, card, capture) + except GameError as exc: + await send(_error(str(exc), code="illegal_move")) + return + except ValueError: + await send(_error("invalid card code", code="illegal_move")) + return + + if state.phase == PHASE_FINISHED: + await save_match_result(state) + await game_store.save(state) + await game_store.publish(game_id) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..da1935b --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,17 @@ +"""Test package init. + +Sets environment overrides BEFORE any test module imports +:mod:`scopa.app` (which evaluates :data:`scopa.config.settings` +at import time). Works under both ``python -m unittest discover`` and +``pytest``; conftest.py mirrors this for pytest-only collection. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "sqlite://:memory:") +os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/scopa") +os.environ.setdefault("OIDC_CLIENT_ID", "scopa") +os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback") +# Unset REDIS_URL: sessions and live games use the in-memory fallbacks. +os.environ.pop("REDIS_URL", None) diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 0000000..8f5d941 --- /dev/null +++ b/tests/helpers/__init__.py @@ -0,0 +1,4 @@ +"""Test helpers package.""" +from .oidc import make_user, oidc_user, ws_users + +__all__ = ["make_user", "oidc_user", "ws_users"] diff --git a/tests/helpers/oidc.py b/tests/helpers/oidc.py new file mode 100644 index 0000000..5b5c937 --- /dev/null +++ b/tests/helpers/oidc.py @@ -0,0 +1,56 @@ +"""Helpers for faking the OIDC authenticated user during tests. + +HTTP handlers funnel through ``oidc_mixin.get_user``; websocket handlers +through :func:`scopa.auth.get_ws_user`. Patching those two entry points +lets route and websocket tests run entirely in-process with no IdP. +""" +from __future__ import annotations + +import contextlib +import unittest.mock as _mock +from typing import Iterator, Optional, Sequence + +from kaya.oidc import OIDCUser + +# Import the app first: it pulls in the route modules, which import +# ``scopa.auth`` themselves. Importing ``auth`` before ``app`` would hit a +# partially initialized module (same constraint as reimpasto). +from scopa.app import oidc_mixin +from scopa import auth + + +def make_user(sub: str, name: Optional[str] = None) -> OIDCUser: + return OIDCUser({"sub": sub, "preferred_username": name or sub}) + + +@contextlib.contextmanager +def oidc_user(sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]: + """Context manager: patch ``oidc_mixin.get_user`` to return this user.""" + user = make_user(sub, name) + patcher = _mock.patch.object(oidc_mixin, "get_user", return_value=user) + patcher.start() + try: + yield user + finally: + patcher.stop() + + +@contextlib.contextmanager +def ws_users(users: Sequence[OIDCUser]) -> Iterator[None]: + """Context manager: patch ``auth.get_ws_user`` to hand out ``users`` + one per websocket connection, in order. Once exhausted it keeps + returning the last user.""" + remaining = list(users) + last = remaining[-1] if remaining else None + + def _next(_ws): + if remaining: + return remaining.pop(0) + return last + + patcher = _mock.patch.object(auth, "get_ws_user", side_effect=_next) + patcher.start() + try: + yield + finally: + patcher.stop() diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..815208d --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,282 @@ +"""Rule engine tests: captures, scope, scoring and full-match simulation.""" +from __future__ import annotations + +import unittest + +from scopa.game import engine +from scopa.game.errors import ( + CardNotInHand, + GameFinished, + IllegalMove, + NotYourTurn, +) +from scopa.game.state import ( + PHASE_FINISHED, + PHASE_PLAYING, + Card, + GameState, + PlayerState, +) + + +def card(code: str) -> Card: + return Card.parse(code) + + +def make_state( + hands, + table, + turn: int = 0, + *, + captured=None, + scope=None, + target: int = 11, + last_taker=None, +) -> GameState: + """Build a controlled game state directly (bypassing the deal).""" + state = GameState( + id="game-1", + join_code="ABC123", + creator_sub="p0", + target_score=target, + phase=PHASE_PLAYING, + turn=turn, + last_taker=last_taker, + ) + for seat, hand in enumerate(hands): + state.players.append( + PlayerState(sub=f"p{seat}", name=f"p{seat}", seat=seat, + hand=[card(c) for c in hand]) + ) + if captured is not None: + for player, codes in zip(state.players, captured): + player.captured = [card(c) for c in codes] + if scope is not None: + for player, value in zip(state.players, scope): + player.scope = value + state.table = [card(c) for c in table] + return state + + +class DeckTest(unittest.TestCase): + def test_full_deck_has_40_unique_cards(self) -> None: + deck = engine.full_deck() + self.assertEqual(40, len(deck)) + self.assertEqual(40, len({c.code for c in deck})) + self.assertEqual(4, len({c.suit for c in deck})) + self.assertEqual(4, sum(1 for c in deck if c.rank == 7)) + + def test_shuffled_deck_is_permutation(self) -> None: + deck = engine.shuffled_deck() + self.assertEqual( + sorted(c.code for c in engine.full_deck()), + sorted(c.code for c in deck), + ) + + +class CaptureTest(unittest.TestCase): + def test_equal_card_is_mandatory(self) -> None: + table = [card("05C"), card("02D"), card("03S")] + options = engine.legal_captures(table, card("05D")) + self.assertEqual([["05C"]], [[c.code for c in o] for o in options]) + + def test_sum_combination(self) -> None: + table = [card("01C"), card("03C"), card("02S")] + options = engine.legal_captures(table, card("04D")) + self.assertEqual([["01C", "03C"]], [[c.code for c in o] for o in options]) + + def test_multiple_equal_cards_each_a_separate_option(self) -> None: + table = [card("05C"), card("05S")] + options = engine.legal_captures(table, card("05D")) + self.assertEqual( + [["05C"], ["05S"]], sorted([[c.code for c in o] for o in options]) + ) + + def test_no_capture(self) -> None: + table = [card("09C"), card("08S")] + self.assertEqual([], engine.legal_captures(table, card("02D"))) + + def test_play_without_capture_places_card_on_table(self) -> None: + state = make_state([["02D", "03D"], ["01C"], ["01S"], ["01B"]], + table=["09C"]) + engine.play(state, "p0", "02D") + self.assertIn("02D", [c.code for c in state.table]) + self.assertNotIn("02D", [c.code for c in state.players[0].hand]) + self.assertEqual(1, state.turn) + + def test_play_capture_and_scopa(self) -> None: + state = make_state([["02D", "09C"], ["01C"], ["01S"], ["01B"]], + table=["02C"]) + engine.play(state, "p0", "02D", ["02C"]) + self.assertEqual(1, state.players[0].scope) + self.assertEqual([], state.table) + self.assertEqual( + ["02C", "02D"], [c.code for c in state.players[0].captured] + ) + + def test_illegal_combination_when_equal_card_present(self) -> None: + state = make_state([["05D"], ["01C"], ["01S"], ["01B"]], + table=["05C", "02D", "03S"]) + with self.assertRaises(IllegalMove): + engine.play(state, "p0", "05D", ["02D", "03S"]) + + def test_illegal_capture_rejected(self) -> None: + state = make_state([["04D"], ["01C"], ["01S"], ["01B"]], + table=["02C", "03S"]) + with self.assertRaises(IllegalMove): + engine.play(state, "p0", "04D", ["02C"]) + + def test_no_capture_requested_when_capture_possible(self) -> None: + state = make_state([["05D"], ["01C"], ["01S"], ["01B"]], + table=["05C"]) + with self.assertRaises(IllegalMove): + engine.play(state, "p0", "05D") + + def test_not_your_turn(self) -> None: + state = make_state([["02D"], ["03D"], ["04D"], ["05D"]], + table=[], turn=1) + with self.assertRaises(NotYourTurn): + engine.play(state, "p0", "02D") + + def test_card_not_in_hand(self) -> None: + state = make_state([["02D"], ["03D"], ["04D"], ["05D"]], table=[]) + with self.assertRaises(CardNotInHand): + engine.play(state, "p0", "07D") + + def test_finished_game_rejects_moves(self) -> None: + state = make_state([["02D"], ["03D"], ["04D"], ["05D"]], table=[]) + state.phase = PHASE_FINISHED + with self.assertRaises(GameFinished): + engine.play(state, "p0", "02D") + + +class LastPlayTest(unittest.TestCase): + def test_no_scopa_on_last_play_of_hand(self) -> None: + # p0 plays the last card of the hand (everyone else is already + # empty): the capture empties the table but must NOT count as a + # scopa. Team A still reaches the target of 2 with carte + denara. + state = make_state([["02D"], [], [], []], + table=["02C"], target=2) + engine.play(state, "p0", "02D", ["02C"]) + self.assertEqual(PHASE_FINISHED, state.phase) + self.assertEqual(0, state.winner) + self.assertEqual(0, state.hand_scores[-1]["scope"]["A"]) + + def test_table_swept_to_last_taker(self) -> None: + # target 2 so the game ends on this hand and the capture piles are + # not reset by the next deal. + state = make_state([["02D"], [], [], []], + table=["05C", "04D"], last_taker=1, target=2) + engine.play(state, "p0", "02D") + captured = {c.code for c in state.players[1].captured} + self.assertEqual({"05C", "04D", "02D"}, captured) + self.assertEqual(PHASE_FINISHED, state.phase) + self.assertEqual(1, state.winner) + + +class ScoringTest(unittest.TestCase): + def test_primiera_values_and_all_suits_requirement(self) -> None: + self.assertEqual(70, engine.primiera_score( + [card(c) for c in ["07D", "06C", "01S", "05B"]])) + self.assertEqual(0, engine.primiera_score( + [card(c) for c in ["07D", "06C", "01S"]])) + self.assertEqual(40, engine.primiera_score( + [card(c) for c in ["08D", "09C", "10S", "10B"]])) + + def test_hand_points_carte_denara_settebello_primiera_scope(self) -> None: + state = make_state( + [[], [], [], []], + table=[], + captured=[ + ["07D", "06C", "01S", "05B"], # seat 0, team A + ["03D", "04C", "07S", "02B"], # seat 1, team B + ["02D"], # seat 2, team A + ["10D", "10C", "10S", "10B"], # seat 3, team B + ], + scope=[1, 0, 0, 2], + ) + points, details = engine.hand_points(state) + self.assertEqual([3, 3], points) + self.assertEqual({"A": 5, "B": 8}, details["cards"]) + self.assertEqual({"A": 2, "B": 2}, details["denara"]) + self.assertEqual({"A": True, "B": False}, details["settebello"]) + self.assertEqual({"A": 70, "B": 60}, details["primiera"]) + self.assertEqual({"A": 1, "B": 2}, details["scope"]) + + def test_ties_award_nothing(self) -> None: + state = make_state( + [[], [], [], []], + table=[], + captured=[ + ["06C", "01S", "05B", "02D"], + ["06S", "01B", "05D", "02C"], + [], + [], + ], + scope=[0, 0, 0, 0], + ) + points, _ = engine.hand_points(state) + # Equal cards, equal denara, equal primiera and no settebello: + # everything ties, so no points at all. + self.assertEqual([0, 0], points) + + +class MatchFlowTest(unittest.TestCase): + def test_join_starts_when_full(self) -> None: + state = engine.create_game("g", "CODE42", "p0", "p0", target_score=11) + self.assertEqual(1, len(state.players)) + engine.join_game(state, "p1", "p1") + engine.join_game(state, "p2", "p2") + self.assertEqual("lobby", state.phase) + engine.join_game(state, "p3", "p3") + self.assertEqual(PHASE_PLAYING, state.phase) + self.assertEqual(4, len(state.players)) + for player in state.players: + self.assertEqual(10, len(player.hand)) + self.assertEqual([], state.table) + self.assertEqual(1, state.turn) # dealer is seat 0 + + def test_match_ends_when_target_reached(self) -> None: + state = make_state([["02D"], [], [], []], + table=["02C"], target=1) + engine.play(state, "p0", "02D", ["02C"]) + self.assertEqual(PHASE_FINISHED, state.phase) + self.assertEqual(0, state.winner) + self.assertGreaterEqual(state.scores[0], 1) + + def test_state_for_player_hides_other_hands(self) -> None: + state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]], + table=["07C"]) + view = engine.state_for_player(state, "p0") + players = {p["seat"]: p for p in view["players"]} + self.assertEqual(["02D", "03C"], players[0]["hand"]) + self.assertNotIn("hand", players[1]) + self.assertEqual(1, players[1]["cards_left"]) + self.assertEqual(["07C"], view["table"]) + self.assertTrue(view.get("your_turn")) + + def test_full_random_match_reaches_completion(self) -> None: + state = engine.create_game("g", "CODE99", "p0", "p0", target_score=11) + for i in range(1, 4): + engine.join_game(state, f"p{i}", f"p{i}") + + moves = 0 + while state.phase != PHASE_FINISHED and moves < 200000: + player = next(p for p in state.players if p.seat == state.turn) + played = player.hand[0] + options = engine.legal_captures(state.table, played) + capture = [c.code for c in options[0]] if options else None + engine.play(state, player.sub, played.code, capture) + moves += 1 + + self.assertEqual(PHASE_FINISHED, state.phase) + self.assertIn(state.winner, (0, 1)) + # At the end all 40 cards are captured and no hand is left. + self.assertEqual([], state.table) + self.assertTrue(all(not p.hand for p in state.players)) + self.assertEqual(40, sum(len(p.captured) for p in state.players)) + self.assertTrue(state.finished_at) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_routes_games.py b/tests/test_routes_games.py new file mode 100644 index 0000000..ae2f78a --- /dev/null +++ b/tests/test_routes_games.py @@ -0,0 +1,123 @@ +"""Game lobby route tests via kaya's ASGI transport.""" +from __future__ import annotations + +import unittest + +from httpx import ASGITransport, AsyncClient +from pwo import async_test + +from scopa.app import app +from tests.helpers import oidc_user + + +class GamesRouteTest(unittest.TestCase): + @async_test + async def test_create_requires_auth(self) -> None: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + response = await client.post("/api/games", json={}) + self.assertEqual(401, response.status_code) + self.assertEqual({"error": "unauthenticated"}, response.json()) + + @async_test + async def test_create_and_read_lobby(self) -> None: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + with oidc_user("alice"): + created = await client.post("/api/games", json={"target_score": 16}) + self.assertEqual(201, created.status_code) + body = created.json() + self.assertEqual("lobby", body["phase"]) + self.assertEqual(3, body["seats_open"]) + self.assertEqual(16, body["target_score"]) + self.assertEqual(6, len(body["join_code"])) + game_id = body["id"] + + with oidc_user("alice"): + snapshot = await client.get(f"/api/games/{game_id}") + self.assertEqual(200, snapshot.status_code) + self.assertEqual("alice", snapshot.json()["players"][0]["sub"]) + + with oidc_user("mallory"): + forbidden = await client.get(f"/api/games/{game_id}") + self.assertEqual(403, forbidden.status_code) + + @async_test + async def test_join_fills_seats_and_starts_game(self) -> None: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + with oidc_user("alice"): + created = await client.post("/api/games", json={}) + code = created.json()["join_code"] + + for player in ("bob", "carol"): + with oidc_user(player): + joined = await client.post("/api/games/join", json={"code": code}) + self.assertEqual(200, joined.status_code) + self.assertEqual("lobby", joined.json()["phase"]) + + with oidc_user("dave"): + started = await client.post("/api/games/join", json={"code": code}) + self.assertEqual(200, started.status_code) + state = started.json() + self.assertEqual("playing", state["phase"]) + self.assertEqual(4, len(state["players"])) + self.assertEqual([], state["table"]) + for participant in state["players"]: + self.assertEqual(10, participant["cards_left"]) + # The view is personalized to Dave: he sees his own hand in + # seat 3 but not Alice's in seat 0. + self.assertIn("hand", state["players"][3]) + self.assertNotIn("hand", state["players"][0]) + self.assertEqual(1, state["turn"]) + + @async_test + async def test_join_errors(self) -> None: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + with oidc_user("alice"): + created = await client.post("/api/games", json={}) + code = created.json()["join_code"] + + with oidc_user("bob"): + unknown = await client.post("/api/games/join", json={"code": "ZZZZZZ"}) + self.assertEqual(404, unknown.status_code) + + with oidc_user("alice"): + duplicate = await client.post("/api/games/join", json={"code": code}) + self.assertEqual(409, duplicate.status_code) + + with oidc_user("bob"): + missing = await client.post("/api/games/join", json={}) + self.assertEqual(400, missing.status_code) + + for player in ("bob", "carol", "dave"): + with oidc_user(player): + await client.post("/api/games/join", json={"code": code}) + with oidc_user("erin"): + late = await client.post("/api/games/join", json={"code": code}) + self.assertEqual(409, late.status_code) + + @async_test + async def test_create_rejects_bad_target_score(self) -> None: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + with oidc_user("alice"): + zero = await client.post("/api/games", json={"target_score": 0}) + text = await client.post("/api/games", json={"target_score": "eleven"}) + huge = await client.post("/api/games", json={"target_score": 1000}) + self.assertEqual(400, zero.status_code) + self.assertEqual(400, text.status_code) + self.assertEqual(400, huge.status_code) + + @async_test + async def test_get_unknown_game(self) -> None: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + with oidc_user("alice"): + response = await client.get("/api/games/does-not-exist") + self.assertEqual(404, response.status_code) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_stats.py b/tests/test_stats.py new file mode 100644 index 0000000..09209b1 --- /dev/null +++ b/tests/test_stats.py @@ -0,0 +1,169 @@ +"""Match-statistics tests: Postgres persistence and the stats endpoints.""" +from __future__ import annotations + +import unittest +import uuid +from datetime import datetime, timezone + +from httpx import ASGITransport, AsyncClient +from pwo import async_test + +from scopa.app import app, tortoise_mixin +from scopa.game import engine +from scopa.game.state import GameState +from scopa.models import Match, MatchPlayer +from scopa.stats import save_match_result +from tests.helpers import oidc_user + + +async def _use_app_db(): + """Bind the same Tortoise context the app uses for this event loop and + return it, so tests can seed rows the route handlers will see.""" + await tortoise_mixin._bind() + ctx = tortoise_mixin._ctx + assert ctx is not None + return ctx + + +def _finished_state() -> GameState: + # Team A sweeps the (single-card) table with carte + denara and reaches + # a target of 2, ending the match. + state = GameState( + id="stats-game", + join_code="STATS1", + creator_sub="alice", + target_score=2, + phase=engine.PHASE_PLAYING, + turn=0, + table=[engine.parse_card("02C")], + ) + from scopa.game.state import PlayerState, Card + + state.players = [ + PlayerState(sub="alice", name="alice", seat=0, hand=[Card.parse("02D")]), + PlayerState(sub="bob", name="bob", seat=1), + PlayerState(sub="carol", name="carol", seat=2), + PlayerState(sub="dave", name="dave", seat=3), + ] + return state + + +class SaveMatchResultTest(unittest.TestCase): + @async_test + async def test_finished_match_is_persisted_once(self) -> None: + ctx = await _use_app_db() + state = _finished_state() + engine.play(state, "alice", "02D", ["02C"]) + self.assertEqual(engine.PHASE_FINISHED, state.phase) + + with ctx: + await save_match_result(state) + await save_match_result(state) # idempotent + self.assertEqual(1, await Match.all().count()) + self.assertEqual(4, await MatchPlayer.all().count()) + + match = await Match.all().first() + assert match is not None + self.assertEqual(state.scores[0], match.team_a_score) + self.assertEqual("A", match.winner_team) + winners = await MatchPlayer.filter(won=True) + self.assertEqual({"alice", "carol"}, {p.user_sub for p in winners}) + + +async def _seed_two_matches() -> None: + ctx = await _use_app_db() + with ctx: + for index, (a_score, b_score, winner, finished) in enumerate( + [ + (11, 5, "A", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)), + (8, 11, "B", datetime(2026, 1, 2, 10, tzinfo=timezone.utc)), + ] + ): + match = await Match.create( + id=uuid.uuid4(), + team_a_score=a_score, + team_b_score=b_score, + winner_team=winner, + target_score=11, + hands_played=2 + index, + started_at=datetime(2025, 12, 31, tzinfo=timezone.utc), + finished_at=finished, + ) + seats = [ + ("alice", 0, "A"), + ("bob", 1, "B"), + ("carol", 2, "A"), + ("dave", 3, "B"), + ] + for sub, seat, team in seats: + await MatchPlayer.create( + id=uuid.uuid4(), + match=match, + user_sub=sub, + display_name=sub, + seat=seat, + team=team, + won=(team == winner), + ) + + +class StatsRouteTest(unittest.TestCase): + @async_test + async def test_my_matches_newest_first(self) -> None: + await _seed_two_matches() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + with oidc_user("alice"): + response = await client.get("/api/me/matches") + self.assertEqual(200, response.status_code) + results = response.json()["results"] + self.assertEqual(2, len(results)) + self.assertEqual("B", results[0]["winner_team"]) # newest first + self.assertFalse(results[0]["you_won"]) + self.assertTrue(results[1]["you_won"]) + self.assertEqual(4, len(results[0]["players"])) + self.assertIn("next_cursor", response.json()) + + @async_test + async def test_my_matches_pagination(self) -> None: + await _seed_two_matches() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + with oidc_user("alice"): + first = await client.get("/api/me/matches?limit=1") + cursor = first.json()["next_cursor"] + self.assertIsNotNone(cursor) + second = await client.get(f"/api/me/matches?limit=1&cursor={cursor}") + self.assertEqual(1, len(first.json()["results"])) + self.assertEqual(1, len(second.json()["results"])) + self.assertNotEqual( + first.json()["results"][0]["id"], + second.json()["results"][0]["id"], + ) + + @async_test + async def test_my_matches_requires_auth(self) -> None: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + response = await client.get("/api/me/matches") + self.assertEqual(401, response.status_code) + + @async_test + async def test_leaderboard_aggregates(self) -> None: + await _seed_two_matches() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + response = await client.get("/api/leaderboard") + self.assertEqual(200, response.status_code) + by_sub = {row["user_sub"]: row for row in response.json()["results"]} + self.assertEqual(2, by_sub["alice"]["matches"]) + self.assertEqual(1, by_sub["alice"]["wins"]) # team A won match 1 + self.assertEqual(19, by_sub["alice"]["points"]) + self.assertEqual(1, by_sub["bob"]["wins"]) # team B won match 2 + self.assertEqual(16, by_sub["bob"]["points"]) + # Alice leads on points after tying Bob on wins. + self.assertEqual("alice", response.json()["results"][0]["user_sub"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..263b9fe --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,95 @@ +"""In-memory game store behaviour (the Redis store shares this interface).""" +from __future__ import annotations + +import asyncio +import unittest + +from pwo import async_test + +from scopa.game import engine +from scopa.store import InMemoryGameStore + + +class InMemoryGameStoreTest(unittest.TestCase): + @async_test + async def test_save_load_roundtrip(self) -> None: + store = InMemoryGameStore() + state = engine.create_game("g1", "CODE01", "alice", "alice", target_score=16) + engine.join_game(state, "bob", "bob") + await store.save(state) + + loaded = await store.load("g1") + self.assertIsNotNone(loaded) + assert loaded is not None + self.assertEqual("CODE01", loaded.join_code) + self.assertEqual(16, loaded.target_score) + self.assertEqual(["alice", "bob"], [p.sub for p in loaded.players]) + + @async_test + async def test_load_missing_returns_none(self) -> None: + store = InMemoryGameStore() + self.assertIsNone(await store.load("nope")) + self.assertIsNone(await store.find_by_code("NOPE01")) + + @async_test + async def test_find_by_code(self) -> None: + store = InMemoryGameStore() + state = engine.create_game("g2", "CODE02", "alice", "alice") + await store.save(state) + found = await store.find_by_code("code02") # case-insensitive + self.assertIsNotNone(found) + assert found is not None + self.assertEqual("g2", found.id) + + @async_test + async def test_load_returns_a_copy(self) -> None: + store = InMemoryGameStore() + state = engine.create_game("g3", "CODE03", "alice", "alice") + await store.save(state) + first = await store.load("g3") + assert first is not None + first.phase = "tampered" + second = await store.load("g3") + assert second is not None + self.assertEqual("lobby", second.phase) + + @async_test + async def test_publish_reaches_subscriber(self) -> None: + store = InMemoryGameStore() + state = engine.create_game("g4", "CODE04", "alice", "alice") + await store.save(state) + + received = [] + + async with store.subscribe("g4") as events: + await store.publish("g4") + async for _ in events: + received.append(True) + break + + self.assertEqual([True], received) + + @async_test + async def test_lock_serializes_concurrent_mutations(self) -> None: + store = InMemoryGameStore() + order = [] + + async def holder() -> None: + async with store.lock("g5"): + order.append("holder-enter") + await asyncio.sleep(0.05) + order.append("holder-exit") + + async def contender() -> None: + await asyncio.sleep(0.01) + async with store.lock("g5"): + order.append("contender") + + await asyncio.gather(holder(), contender()) + self.assertEqual( + ["holder-enter", "holder-exit", "contender"], order + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_websocket.py b/tests/test_websocket.py new file mode 100644 index 0000000..48227bd --- /dev/null +++ b/tests/test_websocket.py @@ -0,0 +1,131 @@ +"""WebSocket live-play tests via kaya's ASGI websocket transport.""" +from __future__ import annotations + +import unittest + +from httpx import ASGITransport, AsyncClient +from httpx_ws import WebSocketDisconnect, aconnect_ws +from httpx_ws.transport import ASGIWebSocketTransport +from pwo import async_test + +from scopa.app import app +from tests.helpers import make_user, oidc_user, ws_users + +PLAYERS = ("alice", "bob", "carol", "dave") + + +class WebSocketTest(unittest.TestCase): + async def _started_game(self, client: AsyncClient) -> dict: + """Create a game and seat four players; return the playing state.""" + with oidc_user("alice"): + created = await client.post("/api/games", json={}) + code = created.json()["join_code"] + response = created + for player in PLAYERS[1:]: + with oidc_user(player): + response = await client.post("/api/games/join", json={"code": code}) + return response.json() + + async def _bob_view(self, client: AsyncClient, game_id: str) -> dict: + with oidc_user("bob"): + return (await client.get(f"/api/games/{game_id}")).json() + + @async_test + async def test_move_updates_all_connections(self) -> None: + api_transport = ASGITransport(app=app) + async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: + state = await self._started_game(client) + game_id = state["id"] + bob_hand = (await self._bob_view(client, game_id))["players"][1]["hand"] + + ws_transport = ASGIWebSocketTransport(app=app) + async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: + with ws_users([make_user("bob"), make_user("alice")]): + async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws: + first = await bob_ws.receive_json() + self.assertEqual("state", first["type"]) + self.assertEqual(1, first["game"]["turn"]) + self.assertTrue(first["game"].get("your_turn")) + + async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws: + alice_first = await alice_ws.receive_json() + self.assertEqual("state", alice_first["type"]) + self.assertEqual( + "alice", alice_first["game"]["players"][0]["sub"] + ) + self.assertNotIn("hand", alice_first["game"]["players"][1]) + + await bob_ws.send_json( + {"action": "play", "card": bob_hand[0]} + ) + bob_update = await bob_ws.receive_json() + alice_update = await alice_ws.receive_json() + + for update in (bob_update, alice_update): + self.assertEqual("state", update["type"]) + self.assertEqual(2, update["game"]["turn"]) + self.assertEqual(1, len(update["game"]["table"])) + + @async_test + async def test_illegal_move_returns_error(self) -> None: + api_transport = ASGITransport(app=app) + async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: + state = await self._started_game(client) + game_id = state["id"] + bob_hand = (await self._bob_view(client, game_id))["players"][1]["hand"] + + ws_transport = ASGIWebSocketTransport(app=app) + async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: + with ws_users([make_user("bob")]): + async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws: + await bob_ws.receive_json() + await bob_ws.send_json( + {"action": "play", "card": bob_hand[0]} + ) + await bob_ws.receive_json() # the resulting state + # Bob cannot play twice in a row. + await bob_ws.send_json( + {"action": "play", "card": bob_hand[1]} + ) + error = await bob_ws.receive_json() + self.assertEqual("error", error["type"]) + self.assertEqual("illegal_move", error["code"]) + + @async_test + async def test_unknown_game_is_closed(self) -> None: + ws_transport = ASGIWebSocketTransport(app=app) + async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: + with ws_users([make_user("alice")]): + with self.assertRaises(WebSocketDisconnect) as caught: + async with aconnect_ws("/ws/games/no-such-game", ws_client): + pass + self.assertEqual(4404, caught.exception.code) + + @async_test + async def test_non_player_is_closed(self) -> None: + api_transport = ASGITransport(app=app) + async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: + state = await self._started_game(client) + game_id = state["id"] + + ws_transport = ASGIWebSocketTransport(app=app) + async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: + with ws_users([make_user("mallory")]): + with self.assertRaises(WebSocketDisconnect) as caught: + async with aconnect_ws(f"/ws/games/{game_id}", ws_client): + pass + self.assertEqual(4403, caught.exception.code) + + @async_test + async def test_unauthenticated_is_closed(self) -> None: + ws_transport = ASGIWebSocketTransport(app=app) + async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: + with ws_users([]): + with self.assertRaises(WebSocketDisconnect) as caught: + async with aconnect_ws("/ws/games/whatever", ws_client): + pass + self.assertEqual(4401, caught.exception.code) + + +if __name__ == "__main__": + unittest.main()