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
This commit is contained in:
2026-09-16 13:20:05 +08:00
commit e9ddb82e9a
41 changed files with 3527 additions and 0 deletions
+21
View File
@@ -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
+25
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.pyc
.venv/
.env
*.egg-info/
dist/
build/
.mypy_cache/
.pytest_cache/
+60
View File
@@ -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"]
+188
View File
@@ -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:<uuid>` — 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:<JOINCODE>` — the 6-character join code → game id index.
- `scopa:game:<uuid>:lock` — a short-lived lock serializing every mutation.
- `scopa:game:<uuid>: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
```
+16
View File
@@ -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)
+9
View File
@@ -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
+55
View File
@@ -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}"
}
}
]
}
]
}
+101
View File
@@ -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:
@@ -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="
)
+61
View File
@@ -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 `<fk>_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"]
+99
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Scopone scientifico backend built on the kaya framework."""
+23
View File
@@ -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",
}
},
}
+75
View File
@@ -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
+59
View File
@@ -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
+54
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""Scopone scientifico domain package."""
+372
View File
@@ -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
+42
View File
@@ -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."""
+187
View File
@@ -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
+73
View File
@@ -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})
+53
View File
@@ -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"),)
+21
View File
@@ -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.",
},
]
+128
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""HTTP route modules."""
+197
View File
@@ -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))
+19
View File
@@ -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",)},
)
+110
View File
@@ -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})
+57
View File
@@ -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
+187
View File
@@ -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:<id>`` 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
+104
View File
@@ -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
+175
View File
@@ -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)
+17
View File
@@ -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)
+4
View File
@@ -0,0 +1,4 @@
"""Test helpers package."""
from .oidc import make_user, oidc_user, ws_users
__all__ = ["make_user", "oidc_user", "ws_users"]
+56
View File
@@ -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()
+282
View File
@@ -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()
+123
View File
@@ -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()
+169
View File
@@ -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()
+95
View File
@@ -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()
+131
View File
@@ -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()