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:
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.env
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
+60
@@ -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"]
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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="
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Scopone scientifico backend built on the kaya framework."""
|
||||
@@ -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",
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Scopone scientifico domain package."""
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
@@ -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
|
||||
@@ -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})
|
||||
@@ -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"),)
|
||||
@@ -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.",
|
||||
},
|
||||
]
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP route modules."""
|
||||
@@ -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))
|
||||
@@ -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",)},
|
||||
)
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Test helpers package."""
|
||||
from .oidc import make_user, oidc_user, ws_users
|
||||
|
||||
__all__ = ["make_user", "oidc_user", "ws_users"]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user