Author SHA1 Message Date
woggioni 047f43fa21 Add hand-end scoring summary screen with acknowledgement
After each hand of an unfinished match the game now pauses in a new
hand_end phase instead of dealing immediately:

- engine: hand_points gains an 'award' map (which team won each category),
  _end_hand stops at hand_end with a deadline, new acknowledge_hand deals
  the next hand once all four players have acked; plays are rejected while
  the summary is up
- state: acked seats, hand_end_deadline and hand_ack_timeout are persisted
  and exposed in the personalized view (also on the finished state, so the
  final hand is explained before the result)
- ws: new {"action": "ack"}; a per-hand timer force-deals the next hand
  after HAND_ACK_TIMEOUT_SECONDS (new env var, default 30s) so an away
  player cannot stall the match
- web: modal explaining each category in plain language with icons (card
  images for denara/settebello/primiera), team-coloured rows, running
  totals with progress bars, an 'Understood — next hand' button that turns
  into 'Waiting for …' plus an auto-continue countdown; the final screen
  shows the last hand's breakdown too

Verified in the browser against the compose stack: hand played to
completion, summary rendered (including a carte tie), ack from all four
players dealt the next hand live, and the auto-continue path fired when
nobody acked. 60 backend tests + mypy + cargo tests green.
2026-09-16 09:58:36 +00:00
49 changed files with 148 additions and 426 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/tavolo images: gitea.woggioni.net/woggioni/scopa
flavor: latest=false flavor: latest=false
tags: | tags: |
type=match,pattern=release/(.*),group=1 type=match,pattern=release/(.*),group=1
+5 -12
View File
@@ -1,7 +1,7 @@
# tavolo # scopa
A platform for multiplayer card games. The first game is **scopone Multiplayer **scopone scientifico** — the four-player, fixed-partnership
scientifico** — the four-player, fixed-partnership Italian card game: Italian card game — as a web application:
- **`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/tavolo`, add a host entry once: `http://mockoauth:8180/scopa`, 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
@@ -37,13 +37,6 @@ must click "Understood" before the next hand is dealt. If someone is away
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS` the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
(default 30s). The match-ending hand is explained on the final screen. (default 30s). The match-ending hand is explained on the final screen.
## On your turn
Every turn shows a countdown (`TURN_TIMEOUT_SECONDS`, default 30s). If a
player does not move — disconnected or fallen asleep — the server plays a
random legal card for them (randomizing among the legal captures when the
rules require a capture), so one absent player cannot stall the table.
## Development ## Development
Backend (from `server/`): Backend (from `server/`):
@@ -71,7 +64,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 tavolo.app:app .venv/bin/granian --host 127.0.0.1 --port 8080 scopa.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 -12
View File
@@ -2,15 +2,15 @@ services:
postgres: postgres:
image: postgres:18-alpine image: postgres:18-alpine
environment: environment:
POSTGRES_DB: tavolo POSTGRES_DB: scopa
POSTGRES_USER: tavolo POSTGRES_USER: scopa
POSTGRES_PASSWORD: tavolo POSTGRES_PASSWORD: scopa
ports: ports:
- "5432:5432" - "5432:5432"
volumes: volumes:
- pgdata:/var/lib/postgresql - pgdata:/var/lib/postgresql
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U tavolo"] test: ["CMD-SHELL", "pg_isready -U scopa"]
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/tavolo) is identical for # issuer URL (http://mockoauth:8180/scopa) 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/tavolo/.well-known/openid-configuration > /dev/null; do until curl -sf http://mockoauth:8180/scopa/.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://tavolo:tavolo@postgres:5432/tavolo DATABASE_URL: postgres://scopa:scopa@postgres:5432/scopa
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
tavolo: scopa:
build: build:
context: . context: .
dockerfile: server/Dockerfile dockerfile: server/Dockerfile
@@ -90,18 +90,17 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_URL: postgres://tavolo:tavolo@postgres:5432/tavolo DATABASE_URL: postgres://scopa:scopa@postgres:5432/scopa
# 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/tavolo} OIDC_ISSUER: ${OIDC_ISSUER:-http://mockoauth:8180/scopa}
# 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: tavolo OIDC_CLIENT_ID: scopa
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
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30} HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30}
ports: ports:
- "127.0.0.1:${APP_PORT:-8080}:8080" - "127.0.0.1:${APP_PORT:-8080}:8080"
+6 -10
View File
@@ -1,15 +1,15 @@
# Postgres # Postgres
POSTGRES_HOST=localhost POSTGRES_HOST=localhost
POSTGRES_PORT=5432 POSTGRES_PORT=5432
POSTGRES_DB=tavolo POSTGRES_DB=scopa
POSTGRES_USER=tavolo POSTGRES_USER=scopa
POSTGRES_PASSWORD=tavolo POSTGRES_PASSWORD=scopa
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa
# 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/tavolo OIDC_ISSUER=http://localhost:8180/scopa
OIDC_CLIENT_ID=tavolo OIDC_CLIENT_ID=scopa
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
@@ -24,10 +24,6 @@ GAME_TTL_SECONDS=86400
# before dealing the next hand anyway. # before dealing the next hand anyway.
HAND_ACK_TIMEOUT_SECONDS=30 HAND_ACK_TIMEOUT_SECONDS=30
# Seconds a player has to play before the server plays a random legal card
# for them (covers disconnects and idle players).
TURN_TIMEOUT_SECONDS=30
# App server # App server
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8080 APP_PORT=8080
+2 -2
View File
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# Multi-stage build for the tavolo stack (kaya backend + Sycamore/WASM # Multi-stage build for the scopa 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", "tavolo.app:app"] CMD ["granian", "scopa.app:app"]
+16 -27
View File
@@ -1,9 +1,8 @@
# tavolo # scopa
The backend for a multiplayer card-game platform, built on the A multiplayer backend for **scopone scientifico** (the four-player,
[kaya](../kaya) framework. The first game implemented is **scopone fixed-partnership variant of the classic Italian card game), built on the
scientifico**, the four-player, fixed-partnership variant of the classic [kaya](../kaya) framework.
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
@@ -25,7 +24,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/tavolo`, add a host entry once: `http://mockoauth:8180/scopa`, 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
@@ -44,28 +43,27 @@ All configuration comes from environment variables (see `.env.example`):
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `DATABASE_URL` | `postgres://tavolo:tavolo@localhost:5432/tavolo` | Postgres DSN for match statistics | | `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 | | `REDIS_URL` | unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores |
| `OIDC_ISSUER` | `http://localhost:8180/tavolo` | OIDC issuer URL | | `OIDC_ISSUER` | `http://localhost:8180/scopa` | OIDC issuer URL |
| `OIDC_CLIENT_ID` | `tavolo` | OIDC client id | | `OIDC_CLIENT_ID` | `scopa` | 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 |
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the between-hands scoring summary waits for acknowledgements | | `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the between-hands scoring summary waits for acknowledgements |
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them |
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address | | `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
## Data model ## Data model
### Redis (live games) ### Redis (live games)
- `tavolo:game:<uuid>` — the whole match as JSON: players (seat 0/2 = team A, - `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, 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`).
- `tavolo:code:<JOINCODE>` — the 6-character join code → game id index. - `scopa:code:<JOINCODE>` — the 6-character join code → game id index.
- `tavolo:game:<uuid>:lock` — a short-lived lock serializing every mutation. - `scopa:game:<uuid>:lock` — a short-lived lock serializing every mutation.
- `tavolo:game:<uuid>:events` — a pub/sub channel carrying "state changed" - `scopa: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.
@@ -128,15 +126,6 @@ Client → server messages:
After every accepted move the new state is broadcast to all four players. After every accepted move the new state is broadcast to all four players.
### Turn timeout
The state carries a `turn_deadline` while a hand is being played. If the
player on turn does not move before it, the server plays a random legal
card for them (picking one of the legal captures at random when a capture
is required), so a disconnected or idle player cannot stall the match. The
timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
broadcast like any other.
### Hand-end summary ### Hand-end summary
When a hand finishes but the match continues, the game enters the When a hand finishes but the match continues, the game enters the
@@ -179,11 +168,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/tavolo/models.py`: To add a migration after changing `src/scopa/models.py`:
```sh ```sh
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich migrate DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa .venv/bin/aerich migrate
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich upgrade DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa .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;
@@ -195,7 +184,7 @@ baseline.)
Everything lives under `server/`: Everything lives under `server/`:
``` ```
src/tavolo/ src/scopa/
├── 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:`tavolo.app` is imported by the test modules. :mod:`scopa.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/tavolo") os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/scopa")
os.environ.setdefault("OIDC_CLIENT_ID", "tavolo") os.environ.setdefault("OIDC_CLIENT_ID", "scopa")
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 tavolo test # Dev OIDC provider: navikt/mock-oauth2-server with the scopa 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": "tavolo", "issuerId": "scopa",
"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 = "tavolo" name = "scopa"
version = "0.1.0" version = "0.1.0"
description = "Multiplayer card-game platform backend built on the kaya framework" description = "Scopone scientifico multiplayer 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 = "tavolo.aerich_config.TORTOISE_ORM" tortoise_orm = "scopa.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 = "tavolo.models" module = "scopa.models"
disable_error_code = ["attr-defined"] disable_error_code = ["attr-defined"]
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = "tavolo.routes.*" module = "scopa.routes.*"
disable_error_code = ["attr-defined"] disable_error_code = ["attr-defined"]
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = "tavolo.game.*" module = "scopa.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 tavolo (pyproject.toml) # via scopa (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 tavolo (pyproject.toml) # via scopa (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
# tavolo (pyproject.toml) # scopa (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
# tavolo (pyproject.toml) # scopa (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
# tavolo (pyproject.toml) # scopa (pyproject.toml)
kaya-oidc==0.0.3 kaya-oidc==0.0.3
# via tavolo (pyproject.toml) # via scopa (pyproject.toml)
kaya-openapi==0.0.3 kaya-openapi==0.0.3
# via tavolo (pyproject.toml) # via scopa (pyproject.toml)
kaya-rsgi==0.0.3 kaya-rsgi==0.0.3
# via tavolo (pyproject.toml) # via scopa (pyproject.toml)
kaya-session==0.0.3 kaya-session==0.0.3
# via # via
# kaya-oidc # kaya-oidc
# kaya-session-redis # kaya-session-redis
# tavolo (pyproject.toml) # scopa (pyproject.toml)
kaya-session-redis==0.0.3 kaya-session-redis==0.0.3
# via tavolo (pyproject.toml) # via scopa (pyproject.toml)
pwo==0.1.2 pwo==0.1.2
# via # via
# kaya-core # kaya-core
# kaya-rsgi # kaya-rsgi
# kaya-session # kaya-session
# tavolo (pyproject.toml) # scopa (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
# tavolo (pyproject.toml) # scopa (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
# tavolo (pyproject.toml) # scopa (pyproject.toml)
tortoise-orm==1.1.8 tortoise-orm==1.1.8
# via # via
# aerich # aerich
# tavolo (pyproject.toml) # scopa (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:`tavolo.app` so ``aerich`` can import it without Kept separate from :mod:`scopa.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:`~tavolo.config.Settings` the app uses, so the CLI from the same :class:`~scopa.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": ["tavolo.models", "aerich.models"], "models": ["scopa.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:`~tavolo.tortoise_mixin.TortoiseMixin` (Postgres match statistics; - :class:`~scopa.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="tavolo", title="scopa",
version=_pkg_version("tavolo"), version=_pkg_version("scopa"),
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=["tavolo.models"], models_modules=["scopa.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.
Tavolo has no application roles: every authenticated user may create and Scopa 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 tavolo application. """Environment-driven configuration for the scopa 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.
@@ -43,16 +43,13 @@ class Settings:
# Seconds the between-hands scoring summary waits for acknowledgements # Seconds the between-hands scoring summary waits for acknowledgements
# before dealing the next hand anyway. # before dealing the next hand anyway.
hand_ack_timeout_seconds: int hand_ack_timeout_seconds: int
# Seconds a player has to play before the server plays a random legal
# card for them (covering disconnects and idle players).
turn_timeout_seconds: int
@staticmethod @staticmethod
def from_env() -> "Settings": def from_env() -> "Settings":
return Settings( return Settings(
database_url=_env("DATABASE_URL", "postgres://tavolo:tavolo@localhost:5432/tavolo"), database_url=_env("DATABASE_URL", "postgres://scopa:scopa@localhost:5432/scopa"),
oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/tavolo"), oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/scopa"),
oidc_client_id=_env("OIDC_CLIENT_ID", "tavolo"), oidc_client_id=_env("OIDC_CLIENT_ID", "scopa"),
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", "/"),
@@ -66,7 +63,6 @@ class Settings:
game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")), game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")),
static_dir=_env("STATIC_DIR", "web/dist"), static_dir=_env("STATIC_DIR", "web/dist"),
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")), hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
) )
@@ -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:`~tavolo.game.state.GameState` and raises :class:`~scopa.game.state.GameState` and raises
:class:`~tavolo.game.errors.GameError` subclasses on rule violations. This :class:`~scopa.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
@@ -64,11 +64,6 @@ PLAYERS = 4
# the HAND_ACK_TIMEOUT_SECONDS environment variable). # the HAND_ACK_TIMEOUT_SECONDS environment variable).
DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30 DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30
# Default seconds a player has to play before the server plays a random
# legal card for them. Games carry their own copy in
# ``GameState.turn_timeout`` (configurable via TURN_TIMEOUT_SECONDS).
DEFAULT_TURN_TIMEOUT_SECONDS = 30
# Primiera card values: sevens are best, then sixes, then aces, then the # 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. # remaining ranks in descending order. All of 8/9/10 are worth 10.
PRIMIERA_VALUES: Dict[int, int] = { PRIMIERA_VALUES: Dict[int, int] = {
@@ -129,7 +124,6 @@ def create_game(
creator_name: str, creator_name: str,
target_score: int = DEFAULT_TARGET_SCORE, target_score: int = DEFAULT_TARGET_SCORE,
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS, hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
) -> GameState: ) -> GameState:
"""Create a lobby game with the creator seated first.""" """Create a lobby game with the creator seated first."""
if target_score < 1 or target_score > 100: if target_score < 1 or target_score > 100:
@@ -142,7 +136,6 @@ def create_game(
phase=PHASE_LOBBY, phase=PHASE_LOBBY,
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)], players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
hand_ack_timeout=hand_ack_timeout, hand_ack_timeout=hand_ack_timeout,
turn_timeout=turn_timeout,
created_at=datetime.now(timezone.utc).isoformat(), created_at=datetime.now(timezone.utc).isoformat(),
) )
@@ -169,12 +162,6 @@ def start_game(state: GameState) -> None:
_deal_hand(state) _deal_hand(state)
def _set_turn_deadline(state: GameState) -> None:
"""Arm the auto-play deadline for whoever is on turn."""
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.turn_timeout)
state.turn_deadline = deadline.isoformat()
def _deal_hand(state: GameState) -> None: def _deal_hand(state: GameState) -> None:
deck = shuffled_deck() deck = shuffled_deck()
for player in state.players: for player in state.players:
@@ -186,7 +173,6 @@ def _deal_hand(state: GameState) -> None:
# Dealer rotates each hand; the first card is played by the player to # Dealer rotates each hand; the first card is played by the player to
# the dealer's left. # the dealer's left.
state.turn = (state.dealer + 1) % PLAYERS state.turn = (state.dealer + 1) % PLAYERS
_set_turn_deadline(state)
for offset in range(HAND_SIZE): for offset in range(HAND_SIZE):
for seat in range(PLAYERS): for seat in range(PLAYERS):
player = _player_at(state, (state.dealer + 1 + seat) % PLAYERS) player = _player_at(state, (state.dealer + 1 + seat) % PLAYERS)
@@ -210,7 +196,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:`~tavolo.game.errors.GameError` subclass on otherwise. Raises a :class:`~scopa.game.errors.GameError` subclass on
any violation. any violation.
""" """
if state.phase == PHASE_FINISHED: if state.phase == PHASE_FINISHED:
@@ -268,7 +254,6 @@ def play(
_end_hand(state) _end_hand(state)
else: else:
state.turn = (state.turn + 1) % PLAYERS state.turn = (state.turn + 1) % PLAYERS
_set_turn_deadline(state)
def _match_option( def _match_option(
@@ -284,32 +269,6 @@ def _match_option(
return None return None
def auto_play(state: GameState, rng: Optional[random.Random] = None) -> None:
"""Play a random legal move for the player currently on turn.
A card is drawn at random from that player's hand; if it can capture,
one of the legal captures is chosen at random (the rules require a
capture when one exists). Delegates to :func:`play`, so the move is
fully validated and can end the hand or the match. Pass ``rng`` for
deterministic tests.
"""
if state.phase != PHASE_PLAYING:
raise GameNotStarted("the game has not started yet")
player = _player_at(state, state.turn)
if not player.hand:
raise IllegalMove("the player on turn has no cards")
chooser = rng or _rng
card = chooser.choice(player.hand)
options = legal_captures(state.table, card)
capture = chooser.choice(options) if options else None
play(
state,
player.sub,
card.code,
[c.code for c in capture] if capture else None,
)
def _end_hand(state: GameState) -> None: def _end_hand(state: GameState) -> None:
"""Sweep the table and score the hand. """Sweep the table and score the hand.
@@ -319,7 +278,6 @@ def _end_hand(state: GameState) -> None:
the hand-end timeout in the websocket layer). If the match is over the the hand-end timeout in the websocket layer). If the match is over the
game goes to ``finished`` immediately. game goes to ``finished`` immediately.
""" """
state.turn_deadline = None
if state.table and state.last_taker is not None: if state.table and state.last_taker is not None:
taker = _player_at(state, state.last_taker) taker = _player_at(state, state.last_taker)
taker.captured.extend(state.table) taker.captured.extend(state.table)
@@ -450,7 +408,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:`~tavolo.game.errors.GameNotFound`-style access via hand. Raises :class:`~scopa.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.
""" """
@@ -486,7 +444,6 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
"last_move": state.last_move.to_json() if state.last_move else None, "last_move": state.last_move.to_json() if state.last_move else None,
"acknowledged": list(state.acked), "acknowledged": list(state.acked),
"hand_end_deadline": state.hand_end_deadline, "hand_end_deadline": state.hand_end_deadline,
"turn_deadline": state.turn_deadline,
} }
if viewer is not None and state.phase == PHASE_PLAYING and viewer.seat == state.turn: if viewer is not None and state.phase == PHASE_PLAYING and viewer.seat == state.turn:
payload["your_turn"] = True payload["your_turn"] = True
@@ -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:`tavolo.store`). Keeping 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 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``.
@@ -172,10 +172,6 @@ class GameState:
hand_end_deadline: Optional[str] = None hand_end_deadline: Optional[str] = None
# Seconds the hand-end summary waits before dealing anyway. # Seconds the hand-end summary waits before dealing anyway.
hand_ack_timeout: int = 30 hand_ack_timeout: int = 30
# While phase == "playing": when the server plays a random legal card
# for the player on turn. Copied from settings at creation.
turn_deadline: Optional[str] = None
turn_timeout: int = 30
# -- serialization ---------------------------------------------------- # -- serialization ----------------------------------------------------
@@ -202,8 +198,6 @@ class GameState:
"acked": list(self.acked), "acked": list(self.acked),
"hand_end_deadline": self.hand_end_deadline, "hand_end_deadline": self.hand_end_deadline,
"hand_ack_timeout": self.hand_ack_timeout, "hand_ack_timeout": self.hand_ack_timeout,
"turn_deadline": self.turn_deadline,
"turn_timeout": self.turn_timeout,
} }
@staticmethod @staticmethod
@@ -230,8 +224,6 @@ class GameState:
acked=[int(s) for s in data.get("acked", [])], acked=[int(s) for s in data.get("acked", [])],
hand_end_deadline=data.get("hand_end_deadline"), hand_end_deadline=data.get("hand_end_deadline"),
hand_ack_timeout=int(data.get("hand_ack_timeout", 30)), hand_ack_timeout=int(data.get("hand_ack_timeout", 30)),
turn_deadline=data.get("turn_deadline"),
turn_timeout=int(data.get("turn_timeout", 30)),
) )
# -- helpers ---------------------------------------------------------- # -- helpers ----------------------------------------------------------
@@ -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:`tavolo.store`); only completed Live game state lives in Redis (see :mod:`scopa.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:`tavolo.ws`); these endpoints cover ``/ws/games/{id}`` websocket (see :mod:`scopa.ws`); these endpoints cover
creation, joining and snapshotting state. creation, joining and snapshotting state.
""" """
from __future__ import annotations from __future__ import annotations
@@ -105,7 +105,6 @@ async def create_game(ctx: HttpContext) -> None:
creator_name=auth.display_name(user), creator_name=auth.display_name(user),
target_score=target_score, target_score=target_score,
hand_ack_timeout=settings.hand_ack_timeout_seconds, hand_ack_timeout=settings.hand_ack_timeout_seconds,
turn_timeout=settings.turn_timeout_seconds,
) )
except GameError as exc: except GameError as exc:
await send_error(ctx, 400, str(exc)) await send_error(ctx, 400, str(exc))
@@ -1,6 +1,6 @@
"""Player statistics endpoints, served from Postgres. """Player statistics endpoints, served from Postgres.
Every finished match is persisted by :func:`tavolo.stats.save_match_result`. Every finished match is persisted by :func:`scopa.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 ``tavolo:game:<id>`` with 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 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:`tavolo.models`); Redis keeps serving the finished state until it :mod:`scopa.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 = "tavolo:game:" GAME_KEY_PREFIX = "scopa:game:"
CODE_KEY_PREFIX = "tavolo:code:" CODE_KEY_PREFIX = "scopa:code:"
CHANNEL_PREFIX = "tavolo:game:" CHANNEL_PREFIX = "scopa: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:`tavolo.game.engine.legal_captures`); it is omitted one exists (see :func:`scopa.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.
@@ -29,19 +29,12 @@ 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 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 websocket is subscribed to that signal and re-renders the state, so all
players see the move immediately (and consistently across workers). players see the move immediately (and consistently across workers).
If a player does not move before the per-game ``turn_timeout``, the server
plays a random card (with a random legal capture when one is required) for
them, so a disconnected or idle player cannot stall the match. The timer is
re-armed by every client connection and state broadcast, and fires
immediately when a reconnect finds the deadline already past.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json import json
from contextlib import suppress from contextlib import suppress
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable, Dict, Optional from typing import Any, Awaitable, Callable, Dict, Optional
from kaya.core import WebSocket from kaya.core import WebSocket
@@ -50,7 +43,7 @@ from . import auth
from .app import app, game_store from .app import app, game_store
from .game import engine from .game import engine
from .game.errors import GameError from .game.errors import GameError
from .game.state import PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING, GameState from .game.state import PHASE_FINISHED, PHASE_HAND_END, GameState
from .stats import save_match_result from .stats import save_match_result
Send = Callable[[Dict[str, Any]], Awaitable[None]] Send = Callable[[Dict[str, Any]], Awaitable[None]]
@@ -88,7 +81,6 @@ async def game_socket(ws: WebSocket, game_id: str) -> None:
await ws.send_text(json.dumps(payload)) await ws.send_text(json.dumps(payload))
await send(_state_message(state, user.sub)) await send(_state_message(state, user.sub))
schedule_turn_timer(game_id, state)
async with game_store.subscribe(game_id) as events: async with game_store.subscribe(game_id) as events:
forward = asyncio.create_task( forward = asyncio.create_task(
@@ -118,7 +110,6 @@ async def _forward(
state = await game_store.load(game_id) state = await game_store.load(game_id)
if state is None: if state is None:
return return
schedule_turn_timer(game_id, state)
await send(_state_message(state, sub)) await send(_state_message(state, sub))
if state.phase == PHASE_FINISHED: if state.phase == PHASE_FINISHED:
await send( await send(
@@ -204,76 +195,6 @@ def schedule_hand_end_timer(game_id: str, hand_number: int, timeout: int) -> Non
_hand_end_timers[key] = asyncio.create_task(_auto_advance()) _hand_end_timers[key] = asyncio.create_task(_auto_advance())
# --- auto-play on turn timeout ------------------------------------------------
# Running turn timers, keyed by (game_id, hand_number, turn, deadline), so a
# turn's timeout is scheduled only once even when several clients are
# connected. Including the deadline means a re-arm after a reconnect cannot
# duplicate a timer for a turn that was already auto-played.
_turn_timers: Dict[tuple, asyncio.Task] = {}
def schedule_turn_timer(game_id: str, state: GameState) -> None:
"""Auto-play a random legal card if the player on turn misses the
deadline. Fizzles if the turn already advanced."""
if state.phase != PHASE_PLAYING or not state.turn_deadline:
return
key = (game_id, state.hand_number, state.turn, state.turn_deadline)
if key in _turn_timers:
return
hand_number = state.hand_number
turn = state.turn
deadline_raw = state.turn_deadline
try:
deadline = datetime.fromisoformat(deadline_raw)
except ValueError:
return
async def _auto_play() -> None:
try:
delay = (deadline - datetime.now(timezone.utc)).total_seconds()
await asyncio.sleep(max(delay, 0))
async with game_store.lock(game_id):
state = await game_store.load(game_id)
if (
state is None
or state.phase != PHASE_PLAYING
or state.hand_number != hand_number
or state.turn != turn
or state.turn_deadline != deadline_raw
):
# The turn moved on (or the game ended) without this
# timer firing: make sure the current turn is armed.
if state is not None:
schedule_turn_timer(game_id, state)
return
try:
engine.auto_play(state)
except GameError:
return
await _after_play(state, game_id)
finally:
_turn_timers.pop(key, None)
_turn_timers[key] = asyncio.create_task(_auto_play())
async def _after_play(state: GameState, game_id: str) -> None:
"""Persist a successful move and notify every connected player.
Callers must hold the per-game lock. Handles the two terminal
transitions: the match result is written to Postgres once, and a
hand-end summary schedules the auto-continue timeout.
"""
if state.phase == PHASE_FINISHED:
await save_match_result(state)
elif state.phase == PHASE_HAND_END:
schedule_hand_end_timer(game_id, state.hand_number, state.hand_ack_timeout)
await game_store.save(state)
await game_store.publish(game_id)
async def _handle_play( async def _handle_play(
send: Send, game_id: str, sub: str, data: Dict[str, Any] send: Send, game_id: str, sub: str, data: Dict[str, Any]
) -> None: ) -> None:
@@ -303,4 +224,9 @@ async def _handle_play(
await send(_error("invalid card code", code="illegal_move")) await send(_error("invalid card code", code="illegal_move"))
return return
await _after_play(state, game_id) if state.phase == PHASE_FINISHED:
await save_match_result(state)
elif state.phase == PHASE_HAND_END:
schedule_hand_end_timer(game_id, state.hand_number, state.hand_ack_timeout)
await game_store.save(state)
await game_store.publish(game_id)
+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:`tavolo.app` (which evaluates :data:`tavolo.config.settings` :mod:`scopa.app` (which evaluates :data:`scopa.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/tavolo") os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/scopa")
os.environ.setdefault("OIDC_CLIENT_ID", "tavolo") os.environ.setdefault("OIDC_CLIENT_ID", "scopa")
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:`tavolo.auth.get_ws_user`. Patching those two entry points through :func:`scopa.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
# ``tavolo.auth`` themselves. Importing ``auth`` before ``app`` would hit a # ``scopa.auth`` themselves. Importing ``auth`` before ``app`` would hit a
# partially initialized module (same constraint as reimpasto). # partially initialized module (same constraint as reimpasto).
from tavolo.app import oidc_mixin from scopa.app import oidc_mixin
from tavolo import auth from scopa import auth
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser: def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
+3 -59
View File
@@ -1,18 +1,16 @@
"""Rule engine tests: captures, scope, scoring and full-match simulation.""" """Rule engine tests: captures, scope, scoring and full-match simulation."""
from __future__ import annotations from __future__ import annotations
import random
import unittest import unittest
from tavolo.game import engine from scopa.game import engine
from tavolo.game.errors import ( from scopa.game.errors import (
CardNotInHand, CardNotInHand,
GameFinished, GameFinished,
GameNotStarted,
IllegalMove, IllegalMove,
NotYourTurn, NotYourTurn,
) )
from tavolo.game.state import ( from scopa.game.state import (
PHASE_FINISHED, PHASE_FINISHED,
PHASE_PLAYING, PHASE_PLAYING,
Card, Card,
@@ -392,59 +390,5 @@ class HandEndAckTest(unittest.TestCase):
self.assertIn("award", view["last_hand"]) self.assertIn("award", view["last_hand"])
class AutoPlayTest(unittest.TestCase):
def test_auto_play_plays_a_card_and_advances_turn(self) -> None:
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
table=["09B"])
state.turn_deadline = "2000-01-01T00:00:00+00:00"
engine.auto_play(state, random.Random(7))
self.assertEqual(1, state.turn)
self.assertEqual(1, len(state.players[0].hand))
# The played card could not capture the nine, so the table grew.
self.assertEqual(2, len(state.table))
self.assertIsNotNone(state.last_move)
assert state.last_move is not None
self.assertEqual(0, state.last_move.seat)
self.assertNotEqual("2000-01-01T00:00:00+00:00", state.turn_deadline)
def test_auto_play_takes_a_mandatory_capture(self) -> None:
# p0 holds only the five of denari, which must capture the equal
# five of coppe instead of the unrelated nine on the table.
state = make_state([["05D"], ["04D"], ["05D"], ["06D"]],
table=["05C", "09B"])
engine.auto_play(state)
self.assertIsNotNone(state.last_move)
assert state.last_move is not None
self.assertEqual("05D", state.last_move.card)
self.assertEqual(["05C"], state.last_move.captured)
self.assertEqual(["09B"], [c.code for c in state.table])
self.assertEqual(["05C", "05D"],
[c.code for c in state.players[0].captured])
def test_auto_play_can_end_the_hand_and_clears_deadline(self) -> None:
state = make_state([["02D"], [], [], []], table=["02C"])
state.turn_deadline = "2000-01-01T00:00:00+00:00"
engine.auto_play(state)
self.assertEqual("hand_end", state.phase)
self.assertIsNone(state.turn_deadline)
self.assertTrue(state.hand_end_deadline)
def test_auto_play_requires_playing_phase(self) -> None:
state = make_state([["02D"], ["04D"], ["05D"], ["06D"]], table=[])
state.phase = "hand_end"
with self.assertRaises(GameNotStarted):
engine.auto_play(state)
def test_create_game_copies_turn_timeout_and_arms_deadline(self) -> None:
state = engine.create_game("g", "CODE98", "p0", "p0", turn_timeout=7)
self.assertEqual(7, state.turn_timeout)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertTrue(state.turn_deadline)
view = engine.state_for_player(state, "p0")
self.assertTrue(view["turn_deadline"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+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 tavolo.app import app from scopa.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 tavolo.app import app from scopa.app import app
from tavolo.config import settings from scopa.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("tavolo.routes.static.settings", patched): with mock.patch("scopa.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("tavolo.routes.static.settings", patched): with mock.patch("scopa.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 tavolo.app import app, tortoise_mixin from scopa.app import app, tortoise_mixin
from tavolo.game import engine from scopa.game import engine
from tavolo.game.state import GameState from scopa.game.state import GameState
from tavolo.models import Match, MatchPlayer from scopa.models import Match, MatchPlayer
from tavolo.stats import save_match_result from scopa.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 tavolo.game.state import PlayerState, Card from scopa.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 tavolo.game import engine from scopa.game import engine
from tavolo.store import InMemoryGameStore from scopa.store import InMemoryGameStore
class InMemoryGameStoreTest(unittest.TestCase): class InMemoryGameStoreTest(unittest.TestCase):
+3 -48
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 tavolo.app import app, game_store from scopa.app import app, game_store
from tavolo.game import engine from scopa.game import engine
from tavolo.game.state import Card, GameState, PlayerState from scopa.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")
@@ -238,50 +238,5 @@ class HandEndWebSocketTest(unittest.TestCase):
self.assertEqual(2, update["game"]["hand_number"]) self.assertEqual(2, update["game"]["hand_number"])
class TurnTimeoutWebSocketTest(unittest.TestCase):
@async_test
async def test_turn_timeout_auto_plays_a_card(self) -> None:
state = engine.create_game(
"turn-timeout-1", "TT0001", "alice", "Alice",
target_score=11, turn_timeout=1,
)
for name in PLAYERS[1:]:
engine.join_game(state, name, name.capitalize())
await game_store.save(state)
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
async with aconnect_ws(f"/ws/games/{state.id}", ws_client) as ws:
first = await ws.receive_json()
# Bob (seat 1) is first to act and never connects.
self.assertEqual(1, first["game"]["turn"])
deadline = first["game"]["turn_deadline"]
self.assertIsNotNone(deadline)
# Nobody plays: the timer must play a random card for Bob.
update = None
for _ in range(20):
try:
update = await asyncio.wait_for(
ws.receive_json(), timeout=2
)
except asyncio.TimeoutError:
break
if (
update.get("type") == "state"
and update["game"]["turn"] == 2
):
break
self.assertIsNotNone(update)
assert update is not None
self.assertEqual(2, update["game"]["turn"])
self.assertEqual(1, update["game"]["last_move"]["seat"])
self.assertEqual(
9, update["game"]["players"][1]["cards_left"]
)
self.assertNotEqual(deadline, update["game"]["turn_deadline"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+18 -18
View File
@@ -369,6 +369,24 @@ 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"
@@ -561,24 +579,6 @@ 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 = "tavolo-web" name = "scopa-web"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Sycamore/WASM frontend for the tavolo card-game platform" description = "Sycamore/WASM frontend for the scopone scientifico backend"
[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>Tavolo</title> <title>Scopone scientifico</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 tavolo backend. Same-origin requests carry the //! REST client for the scopa 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;
-4
View File
@@ -132,10 +132,6 @@ pub struct GameView {
/// ISO-8601 instant at which the next hand is dealt automatically. /// ISO-8601 instant at which the next hand is dealt automatically.
#[serde(default)] #[serde(default)]
pub hand_end_deadline: Option<String>, pub hand_end_deadline: Option<String>,
/// ISO-8601 instant at which the server plays a random legal card for
/// the player on turn.
#[serde(default)]
pub turn_deadline: Option<String>,
#[serde(default)] #[serde(default)]
pub your_turn: Option<bool>, pub your_turn: Option<bool>,
/// Legal captures per hand card; present only for the player on turn. /// Legal captures per hand card; present only for the player on turn.
+1 -16
View File
@@ -139,7 +139,7 @@ pub fn GamePage(id: String) -> View {
} }
} }
Some(g) if g.phase == "lobby" => lobby_view(g), Some(g) if g.phase == "lobby" => lobby_view(g),
Some(g) => table_view(g, on_hand_card, selected, now), Some(g) => table_view(g, on_hand_card, selected),
}) })
(move || capture_choice.get_clone().map(|(card, options)| { (move || capture_choice.get_clone().map(|(card, options)| {
capture_picker(card, options, socket, capture_choice) capture_picker(card, options, socket, capture_choice)
@@ -193,7 +193,6 @@ fn table_view(
game: GameView, game: GameView,
on_hand_card: impl Fn(String) + Copy + 'static, on_hand_card: impl Fn(String) + Copy + 'static,
selected: Signal<Option<String>>, selected: Signal<Option<String>>,
now: Signal<f64>,
) -> View { ) -> View {
// Own seat: the only player entry carrying a hand. // Own seat: the only player entry carrying a hand.
let viewer_seat = game let viewer_seat = game
@@ -219,19 +218,6 @@ fn table_view(
format!("{name}'s turn") format!("{name}'s turn")
}; };
let turn_cls = if my_turn { "turn-note you" } else { "turn-note" }; let turn_cls = if my_turn { "turn-note you" } else { "turn-note" };
let countdown = game.turn_deadline.as_ref().map(|deadline| {
// A dynamic closure so only the ticking number re-renders.
let deadline_ms = js_sys::Date::parse(deadline);
view! {
span(class="turn-timer") {
"Auto-play in "
(move || {
((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32
})
"s"
}
}
});
let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 }); let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 });
let table_cards = game let table_cards = game
@@ -286,7 +272,6 @@ fn table_view(
(target_score) ")" (target_score) ")"
} }
span(class=turn_cls) { (turn_note) } span(class=turn_cls) { (turn_note) }
(countdown)
} }
div(class="table-grid") { div(class="table-grid") {
(top) (top)
-5
View File
@@ -212,11 +212,6 @@ table.matches td.lost {
font-weight: 700; font-weight: 700;
} }
.turn-timer {
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.table-wrap { .table-wrap {
display: flex; display: flex;
flex-direction: column; flex-direction: column;