Rename the app from scopa to tavolo
CI / Build and push docker image (push) Successful in 3m12s

The platform now hosts multiple card games, with scopone scientifico as
the first one. Rename the brand wherever it is not a game rule:

- move the Python package to server/src/tavolo and update imports
- rename the Postgres database/user, OIDC issuer path, client id and
  Redis key prefixes to tavolo (clean break: existing pgdata volumes and
  live games are not migrated)
- rename the Cargo package to tavolo-web and set the page title to Tavolo
- update docs and the Docker image path to woggioni/tavolo

The scopa game term (clearing the table) in the engine, state and web UI
is intentionally left untouched.
This commit is contained in:
2026-09-16 21:46:06 +08:00
parent 89cf0a4c96
commit 6932a3272c
46 changed files with 141 additions and 140 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: gitea.woggioni.net/woggioni/scopa images: gitea.woggioni.net/woggioni/tavolo
flavor: latest=false flavor: latest=false
tags: | tags: |
type=match,pattern=release/(.*),group=1 type=match,pattern=release/(.*),group=1
+5 -5
View File
@@ -1,7 +1,7 @@
# scopa # tavolo
Multiplayer **scopone scientifico** — the four-player, fixed-partnership A platform for multiplayer card games. The first game is **scopone
Italian card game — as a web application: scientifico** — the four-player, fixed-partnership Italian card game:
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya) - **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
framework, OIDC login, live games in Redis, match statistics in Postgres. framework, OIDC login, live games in Redis, match statistics in Postgres.
@@ -22,7 +22,7 @@ Postgres, Redis, a mock OIDC provider (test users `alice`, `bob`, `carol`,
frontend and API — listens on `http://127.0.0.1:8080`. frontend and API — listens on `http://127.0.0.1:8080`.
Because both the browser and the app talk to the OIDC issuer at Because both the browser and the app talk to the OIDC issuer at
`http://mockoauth:8180/scopa`, add a host entry once: `http://mockoauth:8180/tavolo`, add a host entry once:
```sh ```sh
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
@@ -71,7 +71,7 @@ run the backend with:
```sh ```sh
OIDC_POST_LOGIN_REDIRECT=http://localhost:8000/ \ OIDC_POST_LOGIN_REDIRECT=http://localhost:8000/ \
OIDC_POST_LOGOUT_REDIRECT=http://localhost:8000/ \ OIDC_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
.venv/bin/granian --host 127.0.0.1 --port 8080 scopa.app:app .venv/bin/granian --host 127.0.0.1 --port 8080 tavolo.app:app
``` ```
Card images are committed under `web/assets/cards/`; `web/fetch-cards.sh` Card images are committed under `web/assets/cards/`; `web/fetch-cards.sh`
+11 -11
View File
@@ -2,15 +2,15 @@ services:
postgres: postgres:
image: postgres:18-alpine image: postgres:18-alpine
environment: environment:
POSTGRES_DB: scopa POSTGRES_DB: tavolo
POSTGRES_USER: scopa POSTGRES_USER: tavolo
POSTGRES_PASSWORD: scopa POSTGRES_PASSWORD: tavolo
ports: ports:
- "5432:5432" - "5432:5432"
volumes: volumes:
- pgdata:/var/lib/postgresql - pgdata:/var/lib/postgresql
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U scopa"] test: ["CMD-SHELL", "pg_isready -U tavolo"]
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 10 retries: 10
@@ -21,7 +21,7 @@ services:
# The mock does not validate clients, so any client id/secret works. # 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 # It listens on 8180 both inside and outside the network so the OIDC
# issuer URL (http://mockoauth:8180/scopa) is identical for # issuer URL (http://mockoauth:8180/tavolo) is identical for
# container-to-container calls and for browser redirects (via the # container-to-container calls and for browser redirects (via the
# /etc/hosts entry documented in the README). # /etc/hosts entry documented in the README).
mockoauth: mockoauth:
@@ -43,7 +43,7 @@ services:
- mockoauth - mockoauth
entrypoint: > entrypoint: >
/bin/sh -c " /bin/sh -c "
until curl -sf http://mockoauth:8180/scopa/.well-known/openid-configuration > /dev/null; do until curl -sf http://mockoauth:8180/tavolo/.well-known/openid-configuration > /dev/null; do
echo 'waiting for mockoauth...'; sleep 2; echo 'waiting for mockoauth...'; sleep 2;
done done
" "
@@ -71,12 +71,12 @@ services:
working_dir: /app working_dir: /app
command: ["aerich", "upgrade"] command: ["aerich", "upgrade"]
environment: environment:
DATABASE_URL: postgres://scopa:scopa@postgres:5432/scopa DATABASE_URL: postgres://tavolo:tavolo@postgres:5432/tavolo
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
scopa: tavolo:
build: build:
context: . context: .
dockerfile: server/Dockerfile dockerfile: server/Dockerfile
@@ -90,13 +90,13 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_URL: postgres://scopa:scopa@postgres:5432/scopa DATABASE_URL: postgres://tavolo:tavolo@postgres:5432/tavolo
# By default the app and browsers reach the mock IdP under the same # By default the app and browsers reach the mock IdP under the same
# name (see README /etc/hosts note); override OIDC_ISSUER and # name (see README /etc/hosts note); override OIDC_ISSUER and
# OIDC_REDIRECT_URI to use a real provider or a different host port. # OIDC_REDIRECT_URI to use a real provider or a different host port.
OIDC_ISSUER: ${OIDC_ISSUER:-http://mockoauth:8180/scopa} OIDC_ISSUER: ${OIDC_ISSUER:-http://mockoauth:8180/tavolo}
# The mock OIDC server does not validate clients: any id/secret works. # The mock OIDC server does not validate clients: any id/secret works.
OIDC_CLIENT_ID: scopa OIDC_CLIENT_ID: tavolo
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dev-secret} OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dev-secret}
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback} OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback}
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
+6 -6
View File
@@ -1,15 +1,15 @@
# Postgres # Postgres
POSTGRES_HOST=localhost POSTGRES_HOST=localhost
POSTGRES_PORT=5432 POSTGRES_PORT=5432
POSTGRES_DB=scopa POSTGRES_DB=tavolo
POSTGRES_USER=scopa POSTGRES_USER=tavolo
POSTGRES_PASSWORD=scopa POSTGRES_PASSWORD=tavolo
DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo
# OIDC (mock-oauth2-server in dev; it does not validate clients, so any # 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.) # client id/secret works. For a real IdP like Keycloak, use its values here.)
OIDC_ISSUER=http://localhost:8180/scopa OIDC_ISSUER=http://localhost:8180/tavolo
OIDC_CLIENT_ID=scopa OIDC_CLIENT_ID=tavolo
OIDC_CLIENT_SECRET=dev-secret OIDC_CLIENT_SECRET=dev-secret
OIDC_REDIRECT_URI=http://localhost:8080/auth/callback OIDC_REDIRECT_URI=http://localhost:8080/auth/callback
+2 -2
View File
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# Multi-stage build for the scopa stack (kaya backend + Sycamore/WASM # Multi-stage build for the tavolo stack (kaya backend + Sycamore/WASM
# frontend). The Docker build context is the REPOSITORY ROOT (see # frontend). The Docker build context is the REPOSITORY ROOT (see
# docker-compose.yml) so this single image assembles both parts: # docker-compose.yml) so this single image assembles both parts:
# #
@@ -86,4 +86,4 @@ EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD wget -q -O- http://127.0.0.1:8080/api/health || exit 1 CMD wget -q -O- http://127.0.0.1:8080/api/health || exit 1
CMD ["granian", "scopa.app:app"] CMD ["granian", "tavolo.app:app"]
+17 -16
View File
@@ -1,8 +1,9 @@
# scopa # tavolo
A multiplayer backend for **scopone scientifico** (the four-player, The backend for a multiplayer card-game platform, built on the
fixed-partnership variant of the classic Italian card game), built on the [kaya](../kaya) framework. The first game implemented is **scopone
[kaya](../kaya) framework. scientifico**, the four-player, fixed-partnership variant of the classic
Italian card game.
Players authenticate with the configured **OIDC** provider. Live game state Players authenticate with the configured **OIDC** provider. Live game state
is kept in **Redis** (with real-time play over WebSocket), and completed is kept in **Redis** (with real-time play over WebSocket), and completed
@@ -24,7 +25,7 @@ serves the Sycamore/WASM frontend (built from `../web/` by the Docker
image), so the UI is available at that address. image), so the UI is available at that address.
Because the browser and the app both talk to the OIDC issuer at Because the browser and the app both talk to the OIDC issuer at
`http://mockoauth:8180/scopa`, add a host entry once: `http://mockoauth:8180/tavolo`, add a host entry once:
```sh ```sh
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
@@ -43,10 +44,10 @@ All configuration comes from environment variables (see `.env.example`):
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `DATABASE_URL` | `postgres://scopa:scopa@localhost:5432/scopa` | Postgres DSN for match statistics | | `DATABASE_URL` | `postgres://tavolo:tavolo@localhost:5432/tavolo` | Postgres DSN for match statistics |
| `REDIS_URL` | unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores | | `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_ISSUER` | `http://localhost:8180/tavolo` | OIDC issuer URL |
| `OIDC_CLIENT_ID` | `scopa` | OIDC client id | | `OIDC_CLIENT_ID` | `tavolo` | OIDC client id |
| `OIDC_CLIENT_SECRET` | unset | OIDC client secret | | `OIDC_CLIENT_SECRET` | unset | OIDC client secret |
| `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL | | `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL |
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis | | `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
@@ -58,13 +59,13 @@ All configuration comes from environment variables (see `.env.example`):
### Redis (live games) ### Redis (live games)
- `scopa:game:<uuid>` — the whole match as JSON: players (seat 0/2 = team A, - `tavolo: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, 1/3 = team B), hands, table, captured piles, scope, current turn, dealer,
scores, phase (`lobby``playing``finished`). Sliding TTL scores, phase (`lobby``playing``finished`). Sliding TTL
(`GAME_TTL_SECONDS`). (`GAME_TTL_SECONDS`).
- `scopa:code:<JOINCODE>` — the 6-character join code → game id index. - `tavolo:code:<JOINCODE>` — the 6-character join code → game id index.
- `scopa:game:<uuid>:lock` — a short-lived lock serializing every mutation. - `tavolo:game:<uuid>:lock` — a short-lived lock serializing every mutation.
- `scopa:game:<uuid>:events` — a pub/sub channel carrying "state changed" - `tavolo:game:<uuid>:events` — a pub/sub channel carrying "state changed"
signals; every open WebSocket reloads the state and pushes the signals; every open WebSocket reloads the state and pushes the
personalized view to its player. personalized view to its player.
@@ -178,11 +179,11 @@ for Redis, a fake OIDC user patched onto the mixins, and `httpx` /
The Postgres schema is owned by aerich migrations in `migrations/`. The The Postgres schema is owned by aerich migrations in `migrations/`. The
`db-migrate` compose service runs `aerich upgrade` before the app starts. `db-migrate` compose service runs `aerich upgrade` before the app starts.
To add a migration after changing `src/scopa/models.py`: To add a migration after changing `src/tavolo/models.py`:
```sh ```sh
DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa .venv/bin/aerich migrate DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich migrate
DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa .venv/bin/aerich upgrade DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich upgrade
``` ```
(`aerich init-db` produces sqlite-flavored DDL when pointed at sqlite; (`aerich init-db` produces sqlite-flavored DDL when pointed at sqlite;
@@ -194,7 +195,7 @@ baseline.)
Everything lives under `server/`: Everything lives under `server/`:
``` ```
src/scopa/ src/tavolo/
├── app.py # composition root: session/OIDC/Tortoise/OpenAPI mixins ├── app.py # composition root: session/OIDC/Tortoise/OpenAPI mixins
├── config.py # env -> frozen Settings ├── config.py # env -> frozen Settings
├── auth.py # auth helpers (HTTP + WebSocket) ├── auth.py # auth helpers (HTTP + WebSocket)
+3 -3
View File
@@ -3,14 +3,14 @@
Tests run against an in-memory sqlite database (overriding ``DATABASE_URL``) Tests run against an in-memory sqlite database (overriding ``DATABASE_URL``)
so they need no running Postgres, and with ``REDIS_URL`` unset so sessions 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 and live games use their in-memory stores. The environment is set before
:mod:`scopa.app` is imported by the test modules. :mod:`tavolo.app` is imported by the test modules.
""" """
from __future__ import annotations from __future__ import annotations
import os import os
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:") os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/scopa") os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/tavolo")
os.environ.setdefault("OIDC_CLIENT_ID", "scopa") os.environ.setdefault("OIDC_CLIENT_ID", "tavolo")
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback") os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback")
os.environ.pop("REDIS_URL", None) os.environ.pop("REDIS_URL", None)
+1 -1
View File
@@ -1,4 +1,4 @@
# Dev OIDC provider: navikt/mock-oauth2-server with the scopa test # Dev OIDC provider: navikt/mock-oauth2-server with the tavolo test
# configuration (four ready-made players) baked in. The config is # configuration (four ready-made players) baked in. The config is
# COPYed instead of bind-mounted so this also works against containerized # COPYed instead of bind-mounted so this also works against containerized
# (e.g. rootless/DinD) docker daemons that cannot see the host workspace. # (e.g. rootless/DinD) docker daemons that cannot see the host workspace.
+1 -1
View File
@@ -2,7 +2,7 @@
"interactiveLogin": true, "interactiveLogin": true,
"tokenCallbacks": [ "tokenCallbacks": [
{ {
"issuerId": "scopa", "issuerId": "tavolo",
"tokenExpiry": 3600, "tokenExpiry": 3600,
"requestMappings": [ "requestMappings": [
{ {
+6 -6
View File
@@ -3,9 +3,9 @@ requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "scopa" name = "tavolo"
version = "0.1.0" version = "0.1.0"
description = "Scopone scientifico multiplayer backend built on the kaya framework" description = "Multiplayer card-game platform backend built on the kaya framework"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
@@ -37,7 +37,7 @@ namespaces = false
# Database migrations (aerich). See the Migrations section in README.md. # Database migrations (aerich). See the Migrations section in README.md.
[tool.aerich] [tool.aerich]
tortoise_orm = "scopa.aerich_config.TORTOISE_ORM" tortoise_orm = "tavolo.aerich_config.TORTOISE_ORM"
location = "./migrations" location = "./migrations"
[tool.mypy] [tool.mypy]
@@ -49,13 +49,13 @@ plugins = []
# runtime; without the (unavailable here) tortoise mypy plugin the stubs # runtime; without the (unavailable here) tortoise mypy plugin the stubs
# only declare the relation field. These are real attributes, not bugs. # only declare the relation field. These are real attributes, not bugs.
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = "scopa.models" module = "tavolo.models"
disable_error_code = ["attr-defined"] disable_error_code = ["attr-defined"]
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = "scopa.routes.*" module = "tavolo.routes.*"
disable_error_code = ["attr-defined"] disable_error_code = ["attr-defined"]
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = "scopa.game.*" module = "tavolo.game.*"
disable_error_code = ["attr-defined"] disable_error_code = ["attr-defined"]
+14 -14
View File
@@ -8,7 +8,7 @@
--extra-index-url https://pypi.org/simple --extra-index-url https://pypi.org/simple
aerich==0.10.1 aerich==0.10.1
# via scopa (pyproject.toml) # via tavolo (pyproject.toml)
aiosqlite==0.22.1 aiosqlite==0.22.1
# via tortoise-orm # via tortoise-orm
anyio==4.15.1 anyio==4.15.1
@@ -19,7 +19,7 @@ anyio==4.15.1
asyncclick==8.4.2.1 asyncclick==8.4.2.1
# via aerich # via aerich
asyncpg==0.31.0 asyncpg==0.31.0
# via scopa (pyproject.toml) # via tavolo (pyproject.toml)
certifi==2026.7.22 certifi==2026.7.22
# via # via
# httpcore # httpcore
@@ -35,7 +35,7 @@ dictdiffer==0.10.0
granian==2.8.3 granian==2.8.3
# via # via
# kaya-rsgi # kaya-rsgi
# scopa (pyproject.toml) # tavolo (pyproject.toml)
h11==0.16.0 h11==0.16.0
# via httpcore # via httpcore
httpcore==1.0.9 httpcore==1.0.9
@@ -43,7 +43,7 @@ httpcore==1.0.9
httpx==0.28.1 httpx==0.28.1
# via # via
# kaya-oidc # kaya-oidc
# scopa (pyproject.toml) # tavolo (pyproject.toml)
idna==3.19 idna==3.19
# via # via
# anyio # anyio
@@ -56,42 +56,42 @@ kaya-core==0.0.3
# kaya-openapi # kaya-openapi
# kaya-rsgi # kaya-rsgi
# kaya-session # kaya-session
# scopa (pyproject.toml) # tavolo (pyproject.toml)
kaya-oidc==0.0.3 kaya-oidc==0.0.3
# via scopa (pyproject.toml) # via tavolo (pyproject.toml)
kaya-openapi==0.0.3 kaya-openapi==0.0.3
# via scopa (pyproject.toml) # via tavolo (pyproject.toml)
kaya-rsgi==0.0.3 kaya-rsgi==0.0.3
# via scopa (pyproject.toml) # via tavolo (pyproject.toml)
kaya-session==0.0.3 kaya-session==0.0.3
# via # via
# kaya-oidc # kaya-oidc
# kaya-session-redis # kaya-session-redis
# scopa (pyproject.toml) # tavolo (pyproject.toml)
kaya-session-redis==0.0.3 kaya-session-redis==0.0.3
# via scopa (pyproject.toml) # via tavolo (pyproject.toml)
pwo==0.1.2 pwo==0.1.2
# via # via
# kaya-core # kaya-core
# kaya-rsgi # kaya-rsgi
# kaya-session # kaya-session
# scopa (pyproject.toml) # tavolo (pyproject.toml)
pycparser==3.0 pycparser==3.0
# via cffi # via cffi
pyjwt[crypto]==2.14.0 pyjwt[crypto]==2.14.0
# via # via
# kaya-oidc # kaya-oidc
# scopa (pyproject.toml) # tavolo (pyproject.toml)
pypika-tortoise==0.6.5 pypika-tortoise==0.6.5
# via tortoise-orm # via tortoise-orm
redis==8.1.0 redis==8.1.0
# via # via
# kaya-session-redis # kaya-session-redis
# scopa (pyproject.toml) # tavolo (pyproject.toml)
tortoise-orm==1.1.8 tortoise-orm==1.1.8
# via # via
# aerich # aerich
# scopa (pyproject.toml) # tavolo (pyproject.toml)
typing-extensions==4.16.0 typing-extensions==4.16.0
# via # via
# anyio # anyio
@@ -1,8 +1,8 @@
"""Tortoise ORM configuration consumed by the aerich CLI. """Tortoise ORM configuration consumed by the aerich CLI.
Kept separate from :mod:`scopa.app` so ``aerich`` can import it without Kept separate from :mod:`tavolo.app` so ``aerich`` can import it without
assembling the whole application (mixins, routes). The database URL comes assembling the whole application (mixins, routes). The database URL comes
from the same :class:`~scopa.config.Settings` the app uses, so the CLI from the same :class:`~tavolo.config.Settings` the app uses, so the CLI
and the app always point at the same database. and the app always point at the same database.
``aerich.models`` is required alongside the app models: it provides the ``aerich.models`` is required alongside the app models: it provides the
@@ -16,7 +16,7 @@ TORTOISE_ORM = {
"connections": {"default": settings.database_url}, "connections": {"default": settings.database_url},
"apps": { "apps": {
"models": { "models": {
"models": ["scopa.models", "aerich.models"], "models": ["tavolo.models", "aerich.models"],
"default_connection": "default", "default_connection": "default",
} }
}, },
@@ -6,7 +6,7 @@ Assembles the :class:`~kaya.core.KayaApp` with four mixins:
:class:`~kaya.session.redis.RedisSessionStore` when ``REDIS_URL`` is set, :class:`~kaya.session.redis.RedisSessionStore` when ``REDIS_URL`` is set,
otherwise an in-memory store e.g. for tests) otherwise an in-memory store e.g. for tests)
- :class:`~kaya.oidc.OIDCMixin` (OIDC login) - :class:`~kaya.oidc.OIDCMixin` (OIDC login)
- :class:`~scopa.tortoise_mixin.TortoiseMixin` (Postgres match statistics; - :class:`~tavolo.tortoise_mixin.TortoiseMixin` (Postgres match statistics;
skipped for ``/api/health`` and the OpenAPI documentation endpoints) skipped for ``/api/health`` and the OpenAPI documentation endpoints)
- :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at - :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at
``/api/openapi.json`` and a Swagger UI at ``/api/docs``) ``/api/openapi.json`` and a Swagger UI at ``/api/docs``)
@@ -57,15 +57,15 @@ oidc_mixin = OIDCMixin(
session=session_mixin, session=session_mixin,
) )
openapi_mixin = OpenAPIMixin( openapi_mixin = OpenAPIMixin(
title="scopa", title="tavolo",
version=_pkg_version("scopa"), version=_pkg_version("tavolo"),
description="Scopone scientifico multiplayer API", description="Scopone scientifico multiplayer API",
spec_path="/api/openapi.json", spec_path="/api/openapi.json",
docs_path="/api/docs", docs_path="/api/docs",
) )
tortoise_mixin = TortoiseMixin( tortoise_mixin = TortoiseMixin(
database_url=settings.database_url, database_url=settings.database_url,
models_modules=["scopa.models"], models_modules=["tavolo.models"],
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}), skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
) )
@@ -1,6 +1,6 @@
"""Authentication helpers on top of the kaya-oidc mixin. """Authentication helpers on top of the kaya-oidc mixin.
Scopa has no application roles: every authenticated user may create and Tavolo has no application roles: every authenticated user may create and
join games. Authorization beyond login is game membership, checked against join games. Authorization beyond login is game membership, checked against
the live game state in Redis. the live game state in Redis.
""" """
@@ -1,4 +1,4 @@
"""Environment-driven configuration for the scopa application. """Environment-driven configuration for the tavolo application.
Mirrors kaya's own pattern: read ``os.environ`` directly into a plain Mirrors kaya's own pattern: read ``os.environ`` directly into a plain
dataclass. No pydantic-settings, no settings module. dataclass. No pydantic-settings, no settings module.
@@ -50,9 +50,9 @@ class Settings:
@staticmethod @staticmethod
def from_env() -> "Settings": def from_env() -> "Settings":
return Settings( return Settings(
database_url=_env("DATABASE_URL", "postgres://scopa:scopa@localhost:5432/scopa"), database_url=_env("DATABASE_URL", "postgres://tavolo:tavolo@localhost:5432/tavolo"),
oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/scopa"), oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/tavolo"),
oidc_client_id=_env("OIDC_CLIENT_ID", "scopa"), oidc_client_id=_env("OIDC_CLIENT_ID", "tavolo"),
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"), oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"),
oidc_redirect_uri=_env("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback"), oidc_redirect_uri=_env("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback"),
oidc_post_login_redirect=_env("OIDC_POST_LOGIN_REDIRECT", "/"), oidc_post_login_redirect=_env("OIDC_POST_LOGIN_REDIRECT", "/"),
@@ -1,8 +1,8 @@
"""Pure rules engine for scopone scientifico. """Pure rules engine for scopone scientifico.
Every function here is deterministic and I/O-free: it mutates (or reads) Every function here is deterministic and I/O-free: it mutates (or reads)
:class:`~scopa.game.state.GameState` and raises :class:`~tavolo.game.state.GameState` and raises
:class:`~scopa.game.errors.GameError` subclasses on rule violations. This :class:`~tavolo.game.errors.GameError` subclasses on rule violations. This
makes the whole rule set unit-testable without Redis, Postgres or HTTP. makes the whole rule set unit-testable without Redis, Postgres or HTTP.
Rules implemented Rules implemented
@@ -210,7 +210,7 @@ def play(
``capture_codes`` selects which table cards to capture; it must be a ``capture_codes`` selects which table cards to capture; it must be a
legal capture (see :func:`legal_captures`) when one exists and empty legal capture (see :func:`legal_captures`) when one exists and empty
otherwise. Raises a :class:`~scopa.game.errors.GameError` subclass on otherwise. Raises a :class:`~tavolo.game.errors.GameError` subclass on
any violation. any violation.
""" """
if state.phase == PHASE_FINISHED: if state.phase == PHASE_FINISHED:
@@ -450,7 +450,7 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
"""Serialize ``state`` hiding other players' hands. """Serialize ``state`` hiding other players' hands.
Hands are reduced to a count, except for the requesting player's own Hands are reduced to a count, except for the requesting player's own
hand. Raises :class:`~scopa.game.errors.GameNotFound`-style access via hand. Raises :class:`~tavolo.game.errors.GameNotFound`-style access via
the caller; this function assumes ``sub`` may or may not be seated and the caller; this function assumes ``sub`` may or may not be seated and
simply omits the hand for non-seated viewers. simply omits the hand for non-seated viewers.
""" """
@@ -1,7 +1,7 @@
"""In-memory representation of a scopone scientifico game. """In-memory representation of a scopone scientifico game.
The whole mutable game lives in :class:`GameState`, which is serialized to 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 and from plain JSON for storage in Redis (see :mod:`tavolo.store`). Keeping
the representation JSON-native means the store needs no custom codecs and the representation JSON-native means the store needs no custom codecs and
the state is inspectable with ``redis-cli``. the state is inspectable with ``redis-cli``.
@@ -1,6 +1,6 @@
"""Tortoise ORM models: match statistics persisted in Postgres. """Tortoise ORM models: match statistics persisted in Postgres.
Live game state lives in Redis (see :mod:`scopa.store`); only completed Live game state lives in Redis (see :mod:`tavolo.store`); only completed
matches are written here. The two tables answer the question "every match matches are written here. The two tables answer the question "every match
a player took part in, with the final score": a player took part in, with the final score":
@@ -3,7 +3,7 @@
A game starts as a lobby: the creator is seated first and shares the 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 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 the first hand and the match begins. Live play then happens over the
``/ws/games/{id}`` websocket (see :mod:`scopa.ws`); these endpoints cover ``/ws/games/{id}`` websocket (see :mod:`tavolo.ws`); these endpoints cover
creation, joining and snapshotting state. creation, joining and snapshotting state.
""" """
from __future__ import annotations from __future__ import annotations
@@ -1,6 +1,6 @@
"""Player statistics endpoints, served from Postgres. """Player statistics endpoints, served from Postgres.
Every finished match is persisted by :func:`scopa.stats.save_match_result`. Every finished match is persisted by :func:`tavolo.stats.save_match_result`.
These endpoints expose a player's own match history and a global These endpoints expose a player's own match history and a global
leaderboard aggregated from the same two tables. leaderboard aggregated from the same two tables.
""" """
@@ -1,10 +1,10 @@
"""Persistence for live games. """Persistence for live games.
Game state is small, mutable and short-lived, which makes Redis a natural 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 fit: the whole match is a single JSON value under ``tavolo:game:<id>`` with
a sliding TTL, and a join-code index maps the short code a player shares to 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 that id. Completed matches are copied to Postgres (see
:mod:`scopa.models`); Redis keeps serving the finished state until it :mod:`tavolo.models`); Redis keeps serving the finished state until it
expires. expires.
Two implementations satisfy the same interface: Two implementations satisfy the same interface:
@@ -31,9 +31,9 @@ from redis.asyncio import Redis
from .game.state import GameState from .game.state import GameState
GAME_KEY_PREFIX = "scopa:game:" GAME_KEY_PREFIX = "tavolo:game:"
CODE_KEY_PREFIX = "scopa:code:" CODE_KEY_PREFIX = "tavolo:code:"
CHANNEL_PREFIX = "scopa:game:" CHANNEL_PREFIX = "tavolo:game:"
# Sentinel pushed into in-memory subscriber queues to signal a change. # Sentinel pushed into in-memory subscriber queues to signal a change.
_BUMP = b"update" _BUMP = b"update"
@@ -20,7 +20,7 @@ Client -> server messages are JSON objects::
{"action": "state"} {"action": "state"}
``capture`` lists the table cards to take and must be a legal capture when ``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 one exists (see :func:`tavolo.game.engine.legal_captures`); it is omitted
when the played card cannot capture. ``ack`` acknowledges the hand-end when the played card cannot capture. ``ack`` acknowledges the hand-end
scoring summary; the next hand is dealt when all four players have scoring summary; the next hand is dealt when all four players have
acknowledged or the timeout fires. acknowledged or the timeout fires.
+3 -3
View File
@@ -1,7 +1,7 @@
"""Test package init. """Test package init.
Sets environment overrides BEFORE any test module imports Sets environment overrides BEFORE any test module imports
:mod:`scopa.app` (which evaluates :data:`scopa.config.settings` :mod:`tavolo.app` (which evaluates :data:`tavolo.config.settings`
at import time). Works under both ``python -m unittest discover`` and at import time). Works under both ``python -m unittest discover`` and
``pytest``; conftest.py mirrors this for pytest-only collection. ``pytest``; conftest.py mirrors this for pytest-only collection.
""" """
@@ -10,8 +10,8 @@ from __future__ import annotations
import os import os
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:") os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/scopa") os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/tavolo")
os.environ.setdefault("OIDC_CLIENT_ID", "scopa") os.environ.setdefault("OIDC_CLIENT_ID", "tavolo")
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback") os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback")
# Unset REDIS_URL: sessions and live games use the in-memory fallbacks. # Unset REDIS_URL: sessions and live games use the in-memory fallbacks.
os.environ.pop("REDIS_URL", None) os.environ.pop("REDIS_URL", None)
+4 -4
View File
@@ -1,7 +1,7 @@
"""Helpers for faking the OIDC authenticated user during tests. """Helpers for faking the OIDC authenticated user during tests.
HTTP handlers funnel through ``oidc_mixin.get_user``; websocket handlers HTTP handlers funnel through ``oidc_mixin.get_user``; websocket handlers
through :func:`scopa.auth.get_ws_user`. Patching those two entry points through :func:`tavolo.auth.get_ws_user`. Patching those two entry points
lets route and websocket tests run entirely in-process with no IdP. lets route and websocket tests run entirely in-process with no IdP.
""" """
from __future__ import annotations from __future__ import annotations
@@ -13,10 +13,10 @@ from typing import Iterator, Optional, Sequence
from kaya.oidc import OIDCUser from kaya.oidc import OIDCUser
# Import the app first: it pulls in the route modules, which import # Import the app first: it pulls in the route modules, which import
# ``scopa.auth`` themselves. Importing ``auth`` before ``app`` would hit a # ``tavolo.auth`` themselves. Importing ``auth`` before ``app`` would hit a
# partially initialized module (same constraint as reimpasto). # partially initialized module (same constraint as reimpasto).
from scopa.app import oidc_mixin from tavolo.app import oidc_mixin
from scopa import auth from tavolo import auth
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser: def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
+3 -3
View File
@@ -4,15 +4,15 @@ from __future__ import annotations
import random import random
import unittest import unittest
from scopa.game import engine from tavolo.game import engine
from scopa.game.errors import ( from tavolo.game.errors import (
CardNotInHand, CardNotInHand,
GameFinished, GameFinished,
GameNotStarted, GameNotStarted,
IllegalMove, IllegalMove,
NotYourTurn, NotYourTurn,
) )
from scopa.game.state import ( from tavolo.game.state import (
PHASE_FINISHED, PHASE_FINISHED,
PHASE_PLAYING, PHASE_PLAYING,
Card, Card,
+1 -1
View File
@@ -6,7 +6,7 @@ import unittest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from pwo import async_test from pwo import async_test
from scopa.app import app from tavolo.app import app
from tests.helpers import oidc_user from tests.helpers import oidc_user
+4 -4
View File
@@ -10,8 +10,8 @@ from unittest import mock
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from pwo import async_test from pwo import async_test
from scopa.app import app from tavolo.app import app
from scopa.config import settings from tavolo.config import settings
from tests.helpers import oidc_user from tests.helpers import oidc_user
@@ -45,7 +45,7 @@ class StaticRouteTest(unittest.TestCase):
(cards / "07D.svg").write_text("<svg/>") (cards / "07D.svg").write_text("<svg/>")
patched = dataclasses.replace(settings, static_dir=dist) patched = dataclasses.replace(settings, static_dir=dist)
with mock.patch("scopa.routes.static.settings", patched): with mock.patch("tavolo.routes.static.settings", patched):
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
index = await client.get("/") index = await client.get("/")
@@ -74,7 +74,7 @@ class StaticRouteTest(unittest.TestCase):
@async_test @async_test
async def test_missing_dist_returns_404(self) -> None: async def test_missing_dist_returns_404(self) -> None:
patched = dataclasses.replace(settings, static_dir="/nonexistent-dist") patched = dataclasses.replace(settings, static_dir="/nonexistent-dist")
with mock.patch("scopa.routes.static.settings", patched): with mock.patch("tavolo.routes.static.settings", patched):
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/") response = await client.get("/")
+6 -6
View File
@@ -8,11 +8,11 @@ from datetime import datetime, timezone
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from pwo import async_test from pwo import async_test
from scopa.app import app, tortoise_mixin from tavolo.app import app, tortoise_mixin
from scopa.game import engine from tavolo.game import engine
from scopa.game.state import GameState from tavolo.game.state import GameState
from scopa.models import Match, MatchPlayer from tavolo.models import Match, MatchPlayer
from scopa.stats import save_match_result from tavolo.stats import save_match_result
from tests.helpers import oidc_user from tests.helpers import oidc_user
@@ -37,7 +37,7 @@ def _finished_state() -> GameState:
turn=0, turn=0,
table=[engine.parse_card("02C")], table=[engine.parse_card("02C")],
) )
from scopa.game.state import PlayerState, Card from tavolo.game.state import PlayerState, Card
state.players = [ state.players = [
PlayerState(sub="alice", name="alice", seat=0, hand=[Card.parse("02D")]), PlayerState(sub="alice", name="alice", seat=0, hand=[Card.parse("02D")]),
+2 -2
View File
@@ -6,8 +6,8 @@ import unittest
from pwo import async_test from pwo import async_test
from scopa.game import engine from tavolo.game import engine
from scopa.store import InMemoryGameStore from tavolo.store import InMemoryGameStore
class InMemoryGameStoreTest(unittest.TestCase): class InMemoryGameStoreTest(unittest.TestCase):
+3 -3
View File
@@ -9,9 +9,9 @@ from httpx_ws import WebSocketDisconnect, aconnect_ws
from httpx_ws.transport import ASGIWebSocketTransport from httpx_ws.transport import ASGIWebSocketTransport
from pwo import async_test from pwo import async_test
from scopa.app import app, game_store from tavolo.app import app, game_store
from scopa.game import engine from tavolo.game import engine
from scopa.game.state import Card, GameState, PlayerState from tavolo.game.state import Card, GameState, PlayerState
from tests.helpers import make_user, oidc_user, ws_users from tests.helpers import make_user, oidc_user, ws_users
PLAYERS = ("alice", "bob", "carol", "dave") PLAYERS = ("alice", "bob", "carol", "dave")
+18 -18
View File
@@ -369,24 +369,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "scopa-web"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"futures",
"gloo-net",
"gloo-timers",
"js-sys",
"serde",
"serde_json",
"sycamore",
"sycamore-router",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.229" version = "1.0.229"
@@ -579,6 +561,24 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "tavolo-web"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"futures",
"gloo-net",
"gloo-timers",
"js-sys",
"serde",
"serde_json",
"sycamore",
"sycamore-router",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
+2 -2
View File
@@ -1,8 +1,8 @@
[package] [package]
name = "scopa-web" name = "tavolo-web"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Sycamore/WASM frontend for the scopone scientifico backend" description = "Sycamore/WASM frontend for the tavolo card-game platform"
[dependencies] [dependencies]
sycamore = "0.9" sycamore = "0.9"
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scopone scientifico</title> <title>Tavolo</title>
<link data-trunk rel="rust" href="Cargo.toml"> <link data-trunk rel="rust" href="Cargo.toml">
<link data-trunk rel="css" href="style.css"> <link data-trunk rel="css" href="style.css">
<!-- Card images (CC0 woodcut napoletane deck) copied verbatim into dist. --> <!-- Card images (CC0 woodcut napoletane deck) copied verbatim into dist. -->
+1 -1
View File
@@ -1,4 +1,4 @@
//! REST client for the scopa backend. Same-origin requests carry the //! REST client for the tavolo backend. Same-origin requests carry the
//! session cookie automatically. //! session cookie automatically.
use crate::model::*; use crate::model::*;
use gloo_net::http::Request; use gloo_net::http::Request;