Author SHA1 Message Date
woggioni 40e4cfec68 ci: publish image under the woggioni owner after repo transfer 2026-09-16 05:17:42 +00:00
woggioni 5260bdc9df ci: build and publish the docker image on release/* tags
Mirrors the reimpasto blueprint pipeline: tag release/x.y.z builds the
image (backend + WASM frontend in one multi-stage build) and pushes
gitea.woggioni.net/woggioni-opencode-agent/scopa:x.y.z plus :latest.

The build context is the repository root and the Dockerfile is
server/Dockerfile (monorepo layout). Requires the repo secret
PUBLISHER_TOKEN with write:package scope.
2026-09-16 05:11:40 +00:00
woggioni 633e2fbe10 web: fix OIDC navigation intercepted by the SPA router; tidy table layout
- Login/Logout anchors now carry rel="external": sycamore-router
  intercepts same-origin anchor clicks otherwise, so the OIDC login
  round-trip never left the SPA (found with a real browser test)
- Opponents' hidden hands render as a tidy 5x2 grid of card backs, seats
  have a fixed height and shrink-wrap so the table no longer jumps as
  cards are played
- Dockerfile: cache the Rust target dir so frontend rebuilds are fast
- ignore .playwright-mcp/ tooling output
2026-09-16 04:40:31 +00:00
woggioni 3583c411c3 Add Sycamore/WASM frontend and restructure into server/ + web/
Repo is now a monorepo:

- server/: the kaya backend, unchanged in behaviour, plus:
  - GET /api/me for SPA session detection
  - last_move recorded on every play and broadcast in the game state, so
    clients can show who played which card the moment they play it
  - legal_moves per hand card for the player on turn (rules stay
    server-side)
  - static catch-all route serving the compiled SPA with index.html
    fallback; Tortoise context now bound only for /api/* requests
  - configurable OIDC post-login/logout redirects for dev against trunk
- web/: Sycamore 0.9 + WASM frontend (trunk): login via the OIDC flow,
  lobby (create match / join by code), live game page over websocket with
  card images (CC0 woodcut napoletane deck), capture picker, move banner,
  game-over overlay, match history and leaderboard pages
- server/Dockerfile gains a rust+trunk stage building web/dist; the single
  app image serves the SPA; compose builds from the repo root with
  overridable ports/OIDC env

Verified end-to-end against the compose stack: four OIDC logins, game
creation, three joins by code, websocket play with broadcasts to all
players and out-of-turn rejection. 51 backend tests, mypy and cargo tests
all green.
2026-09-16 03:14:07 +00:00
woggioni aa7ac056d3 Initial scopone scientifico backend
Multiplayer scopone scientifico backend on the kaya framework:

- OIDC login (kaya-oidc), session-backed WebSocket auth
- Pure rules engine (forced captures, scopa, primiera scoring) with
  full-match simulation tests
- Live game state in Redis (JSON + TTL, join codes, per-game locks,
  pub/sub state push); in-memory fallback for tests
- WebSocket /ws/games/{id} for real-time play; REST lobby endpoints
  (create/join/snapshot) with hidden-hand views
- Finished matches persisted to Postgres (Tortoise + aerich) for match
  history and leaderboard endpoints
- Docker Compose stack: postgres, redis, mock-oauth2-server, db-migrate, app
- 45 tests passing; mypy clean
2026-09-15 23:10:04 +00:00
62 changed files with 517 additions and 2703 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 -21
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,28 +22,12 @@ 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
``` ```
## Between hands
When a hand ends but the match is not decided, the game pauses on a
**scoring summary screen**: every player sees how each category was won
(carte, denara, settebello, primiera, scope) with the running totals and
must click "Understood" before the next hand is dealt. If someone is away
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
(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 +55,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`
-291
View File
@@ -1,291 +0,0 @@
# Tavolo — Kubernetes deployment (namespace: tavolo)
#
# Single apply:
# kubectl apply -f deploy/k8s/tavolo.yaml
#
# Replace every value marked REPLACE_ME before applying.
#
# Scope:
# - Postgres and the OIDC provider are NOT part of this file; they are
# deployed in other namespaces and referenced by DNS name.
# - Redis IS included (ephemeral: sessions and live games are disposable,
# mirroring docker-compose's no-volume choice).
#
# Reachability notes:
# - The OIDC issuer URL must resolve from the browser AND from the pods
# (kaya does discovery lazily on the first login, so startup succeeds
# even when the issuer is unreachable, but logins then fail). If the
# provider is internal-only, you need split DNS or a public issuer URL.
# - The Postgres NetworkPolicy (if any) must allow ingress from the
# tavolo namespace.
# - This file deploys a ClusterIP Service only. Until you add an Ingress,
# reach the app with:
# kubectl port-forward -n tavolo svc/tavolo 8080:80
# and set OIDC_REDIRECT_URI to match whatever URL the browser uses.
---
apiVersion: v1
kind: Namespace
metadata:
name: tavolo
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/part-of: tavolo
---
apiVersion: v1
kind: ConfigMap
metadata:
name: tavolo-config
namespace: tavolo
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: app
app.kubernetes.io/part-of: tavolo
data:
# In-cluster Redis deployed by this file.
REDIS_URL: redis://redis.tavolo.svc.cluster.local:6379/0
# Postgres (external, lives in another namespace): everything except the
# password, which is the only entry in the tavolo-secrets Secret.
# Use its Service DNS name, e.g. postgres.<namespace>.svc.cluster.local.
DATABASE_ENGINE: postgres
DATABASE_HOST: REPLACE_ME
DATABASE_PORT: "5432"
DATABASE_NAME: REPLACE_ME
DATABASE_USER: REPLACE_ME
# Extra DSN query parameters appended to the URL (e.g. ssl=require).
# Empty means none.
DATABASE_OPTIONS: ""
# The image bakes STATIC_DIR=/app/web/dist; repeat it here for clarity.
STATIC_DIR: /app/web/dist
GAME_TTL_SECONDS: "86400"
HAND_ACK_TIMEOUT_SECONDS: "30"
TURN_TIMEOUT_SECONDS: "30"
# OIDC (provider lives in another namespace).
OIDC_CLIENT_ID: tavolo
OIDC_POST_LOGIN_REDIRECT: /
OIDC_POST_LOGOUT_REDIRECT: /
# Issuer URL as seen by the browser AND the pods (see notes above).
OIDC_ISSUER: REPLACE_ME
# Public callback URL of this deployment, e.g.
# http://<host>:<port>/auth/callback (must be allowed at the provider).
OIDC_REDIRECT_URI: REPLACE_ME
---
apiVersion: v1
kind: Secret
metadata:
name: tavolo-secrets
namespace: tavolo
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: app
app.kubernetes.io/part-of: tavolo
type: Opaque
stringData:
# The only Postgres secret: the password for DATABASE_USER at
# DATABASE_HOST (both configured in the tavolo-config ConfigMap).
DATABASE_PASSWORD: REPLACE_ME
# Client secret for OIDC_CLIENT_ID at the provider.
OIDC_CLIENT_SECRET: REPLACE_ME
---
# Redis: sessions + live game state. Ephemeral by design (emptyDir): worst
# case after a restart users log in again and games in progress expire.
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: tavolo
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: redis
app.kubernetes.io/part-of: tavolo
spec:
replicas: 1
# Redis is a singleton here; avoid a window with two masters on rollout.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: redis
template:
metadata:
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: redis
app.kubernetes.io/part-of: tavolo
spec:
securityContext:
runAsNonRoot: true
runAsUser: 999 # redis user in the official image
fsGroup: 999
containers:
- name: redis
image: redis:8-alpine
# Snapshots would only live on an emptyDir anyway: skip them.
args: ["--save", "", "--appendonly", "no"]
ports:
- name: redis
containerPort: 6379
volumeMounts:
- name: data
mountPath: /data
readinessProbe:
exec:
command: ["redis-cli", "ping"]
periodSeconds: 5
livenessProbe:
exec:
command: ["redis-cli", "ping"]
periodSeconds: 10
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
cpu: 250m
memory: 128Mi
volumes:
- name: data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: tavolo
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: redis
app.kubernetes.io/part-of: tavolo
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: redis
ports:
- name: redis
port: 6379
targetPort: redis
---
# The app (backend + compiled SPA + WebSocket endpoint, one image).
# The "migrate" initContainer runs `aerich upgrade` before the app starts,
# so this single file can be applied safely in any order. Migrations are
# idempotent, but if you ever scale above 1 replica, move this to a Job to
# avoid concurrent upgrades.
apiVersion: apps/v1
kind: Deployment
metadata:
name: tavolo
namespace: tavolo
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: app
app.kubernetes.io/part-of: tavolo
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: app
template:
metadata:
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: app
app.kubernetes.io/part-of: tavolo
spec:
# Let WebSocket connections drain on rollout.
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
initContainers:
- name: migrate
image: gitea.woggioni.net/woggioni/tavolo:latest
# aerich reads [tool.aerich] from pyproject.toml in /app.
command: ["aerich", "upgrade"]
workingDir: /app
envFrom:
# Migrations need the non-secret DATABASE_* parts too.
- configMapRef:
name: tavolo-config
- secretRef:
name: tavolo-secrets
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
containers:
- name: tavolo
# Pin a release/* tag for production instead of :latest.
image: gitea.woggioni.net/woggioni/tavolo:latest
ports:
- name: http
containerPort: 8080
envFrom:
- configMapRef:
name: tavolo-config
- secretRef:
name: tavolo-secrets
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: true
volumeMounts:
- name: tmp
mountPath: /tmp
startupProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 2
failureThreshold: 30
readinessProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 10
livenessProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: tmp
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: tavolo
namespace: tavolo
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: app
app.kubernetes.io/part-of: tavolo
spec:
type: ClusterIP
# No session affinity needed: sessions and live games live in Redis.
selector:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: app
ports:
- name: http
port: 80
targetPort: http
+11 -24
View File
@@ -2,16 +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
# Override via the DATABASE_PASSWORD env var (shell or root .env). POSTGRES_PASSWORD: scopa
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-password}
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
@@ -22,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:
@@ -44,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
" "
@@ -72,17 +71,12 @@ services:
working_dir: /app working_dir: /app
command: ["aerich", "upgrade"] command: ["aerich", "upgrade"]
environment: environment:
DATABASE_ENGINE: postgres DATABASE_URL: postgres://scopa:scopa@postgres:5432/scopa
DATABASE_HOST: postgres
DATABASE_PORT: "5432"
DATABASE_NAME: tavolo
DATABASE_USER: tavolo
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
tavolo: scopa:
build: build:
context: . context: .
dockerfile: server/Dockerfile dockerfile: server/Dockerfile
@@ -96,23 +90,16 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_ENGINE: postgres DATABASE_URL: postgres://scopa:scopa@postgres:5432/scopa
DATABASE_HOST: postgres
DATABASE_PORT: "5432"
DATABASE_NAME: tavolo
DATABASE_USER: tavolo
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password}
# 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}
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"
+9 -28
View File
@@ -1,22 +1,15 @@
# Database (match statistics). The app assembles the DSN from these # Postgres
# parts; DATABASE_PORT may be left unset to use the driver default POSTGRES_HOST=localhost
# (5432 for Postgres). DATABASE_OPTIONS is a raw query string appended POSTGRES_PORT=5432
# to the URL (e.g. ssl=require); leave empty for none. POSTGRES_DB=scopa
DATABASE_ENGINE=postgres POSTGRES_USER=scopa
DATABASE_HOST=localhost POSTGRES_PASSWORD=scopa
DATABASE_PORT=5432 DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa
DATABASE_NAME=tavolo
DATABASE_USER=tavolo
DATABASE_PASSWORD=password
DATABASE_OPTIONS=
# Full-DSN override: when set, the parts above are ignored. Used by the
# test suite (sqlite://:memory:) and handy for managed-DB URLs.
#DATABASE_URL=postgres://tavolo:password@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/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
@@ -27,18 +20,6 @@ REDIS_URL=redis://localhost:6379/0
# How long a live game survives in Redis without activity. # How long a live game survives in Redis without activity.
GAME_TTL_SECONDS=86400 GAME_TTL_SECONDS=86400
# Seconds the between-hands scoring summary waits for acknowledgements
# before dealing the next hand anyway.
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
# Path to a YAML logging configuration file (logging.config.dictConfig
# schema). Unset logs DEBUG to the console.
#LOGGING_CONFIG=/path/to/logging.yaml
# App server # App server
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8080 APP_PORT=8080
+4 -15
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:
# #
@@ -33,12 +33,9 @@ COPY web/Cargo.toml web/Cargo.lock web/index.html web/style.css web/Trunk.toml .
COPY web/assets ./assets COPY web/assets ./assets
COPY web/src ./src COPY web/src ./src
# --public-url makes trunk emit asset URLs under /static, the prefix Granian
# serves in the runtime image (see GRANIAN_STATIC_PATH_* below). Dev builds
# (trunk serve) keep the default "/" public URL.
RUN --mount=type=cache,target=/usr/local/cargo/registry \ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/web/target \ --mount=type=cache,target=/web/target \
trunk build --release --public-url /static/ trunk build --release
# --- Python builder ---------------------------------------------------------- # --- Python builder ----------------------------------------------------------
FROM alpine:3.24 AS builder FROM alpine:3.24 AS builder
@@ -71,12 +68,7 @@ COPY --from=builder /build/migrations /app/migrations
# aerich reads [tool.aerich] from pyproject.toml (its default config file); # aerich reads [tool.aerich] from pyproject.toml (its default config file);
# the db-migrate compose service runs `aerich upgrade` with working_dir=/app. # the db-migrate compose service runs `aerich upgrade` with working_dir=/app.
COPY --from=builder /build/pyproject.toml /app/pyproject.toml COPY --from=builder /build/pyproject.toml /app/pyproject.toml
# The compiled single-page application. Granian serves the assets directly # The compiled single-page application, served by the backend itself.
# in Rust — hashed js/wasm/css under /static/* and the card images under
# /assets/* (see the GRANIAN_STATIC_PATH_* env vars below; click splits
# multi-value env vars on whitespace for routes and ':' for paths). The
# Python app only serves the SPA shell (index.html) at / and for
# client-side routes (STATIC_DIR).
COPY --from=web-builder /web/dist /app/web/dist COPY --from=web-builder /web/dist /app/web/dist
ENV PATH="/opt/venv/bin:$PATH" \ ENV PATH="/opt/venv/bin:$PATH" \
@@ -85,9 +77,6 @@ ENV PATH="/opt/venv/bin:$PATH" \
GRANIAN_HOST=0.0.0.0 \ GRANIAN_HOST=0.0.0.0 \
GRANIAN_PORT=8080 \ GRANIAN_PORT=8080 \
GRANIAN_INTERFACE=rsgi \ GRANIAN_INTERFACE=rsgi \
GRANIAN_STATIC_PATH_ROUTE="/static /assets" \
GRANIAN_STATIC_PATH_MOUNT="/app/web/dist:/app/web/dist/assets" \
GRANIAN_STATIC_PATH_DIR_TO_FILE=index.html \
STATIC_DIR=/app/web/dist STATIC_DIR=/app/web/dist
USER app USER app
@@ -97,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"]
+23 -95
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,80 +43,33 @@ All configuration comes from environment variables (see `.env.example`):
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `DATABASE_ENGINE` | `postgres` | Database DSN scheme/driver | | `DATABASE_URL` | `postgres://scopa:scopa@localhost:5432/scopa` | Postgres DSN for match statistics |
| `DATABASE_HOST` | `localhost` | Postgres host |
| `DATABASE_PORT` | unset | Postgres port; omitted from the DSN when empty (driver default, 5432 for Postgres) |
| `DATABASE_NAME` | `tavolo` | Postgres database name |
| `DATABASE_USER` | `tavolo` | Postgres user |
| `DATABASE_PASSWORD` | `password` | Postgres password |
| `DATABASE_OPTIONS` | unset | Extra DSN query parameters, e.g. `ssl=require` |
| `DATABASE_URL` | unset | Full-DSN override; when set, the `DATABASE_*` parts above are ignored (used for sqlite in tests and for managed-DB URLs) |
| `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 |
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them |
| `LOGGING_CONFIG` | unset | Path to a YAML logging configuration file (see below). Unset logs DEBUG to the console |
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address | | `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
## Logging
The application logs through the Python stdlib `logging` module, one
`getLogger(__name__)` per module: lifecycle and business events at INFO
(game created/joined, websocket connections, match results, auto-plays),
per-move and store detail at DEBUG.
By default everything at DEBUG level goes to the console. Set
`LOGGING_CONFIG` to the path of a YAML file to take over the
configuration; the file follows the
[`logging.config.dictConfig`](https://docs.python.org/3/library/logging.config.html#logging-config-dictschema)
schema. Keep `disable_existing_loggers: false` — Granian configures its own
loggers before importing the app, and disabling them would silence the
server. Example for quieter production logs (WARNING for third parties,
INFO for the application):
```yaml
version: 1
disable_existing_loggers: false
formatters:
default:
format: "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}"
style: "{"
handlers:
console:
class: logging.StreamHandler
formatter: default
root:
level: WARNING
handlers: [console]
loggers:
tavolo:
level: INFO
```
## 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.
### Postgres (statistics, via Tortoise ORM + aerich migrations) ### Postgres (statistics, via Tortoise ORM + aerich migrations)
- `match` — one row per finished match: the game played (`game_type`, one - `match` — one row per finished match: both teams' final scores, winner,
of the ids from `GET /api/game-types`, indexed so statistics can be target score, hands played, start/finish timestamps.
scoped per game), both teams' final scores, winner, target score, hands
played, start/finish timestamps.
- `match_player` — one row per participant: the OIDC `sub`, display name, - `match_player` — one row per participant: the OIDC `sub`, display name,
seat, team and whether they won. Unique per `(match, user_sub)`. seat, team and whether they won. Unique per `(match, user_sub)`.
@@ -127,17 +79,16 @@ Redis until its TTL expires so clients can still fetch the final board.
## REST API ## REST API
All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`, All endpoints except `/api/health`, `/api/docs` and `/api/openapi.json`
`/api/game-types` and `/api/leaderboard` require authentication. require authentication.
| Method | Path | Description | | Method | Path | Description |
|---|---|---| |---|---|---|
| `GET` | `/api/game-types` | The card games the platform can host (for the creation dropdown) | | `POST` | `/api/games` | Create a lobby game. Optional body `{"target_score": 11}`. Returns `{id, join_code}` |
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "target_score": 11}`. Returns `{id, join_code}` |
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The fourth player triggers the deal | | `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The fourth player triggers the deal |
| `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) | | `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) |
| `GET` | `/api/me/matches` | Cursor-paginated match history with final scores (`?limit=&cursor=&game_type=`) | | `GET` | `/api/me/matches` | Cursor-paginated match history with final scores (`?limit=&cursor=`) |
| `GET` | `/api/leaderboard` | Aggregated wins / matches / team points per player (`?game_type=`) | | `GET` | `/api/leaderboard` | Aggregated wins / matches / team points per player |
## WebSocket protocol ## WebSocket protocol
@@ -158,7 +109,6 @@ Client → server messages:
```json ```json
{"action": "play", "card": "07D", "capture": ["02D", "05C"]} {"action": "play", "card": "07D", "capture": ["02D", "05C"]}
{"action": "play", "card": "07D"} {"action": "play", "card": "07D"}
{"action": "ack"}
{"action": "state"} {"action": "state"}
``` ```
@@ -167,32 +117,10 @@ Client → server messages:
settebello). settebello).
- `capture` lists the table cards to take. When a capture is legal it is - `capture` lists the table cards to take. When a capture is legal it is
mandatory to provide one; when no capture exists it must be omitted. mandatory to provide one; when no capture exists it must be omitted.
- `ack` acknowledges the hand-end scoring summary (see below). The next
hand is dealt once all four players have acknowledged, or automatically
after `HAND_ACK_TIMEOUT_SECONDS`.
- `state` asks for a fresh snapshot. - `state` asks for a fresh snapshot.
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
When a hand finishes but the match continues, the game enters the
`hand_end` phase instead of dealing immediately: the state carries
`last_hand` (a full scoring breakdown with an `award` map naming the team
that won each category), the `acknowledged` seats and a
`hand_end_deadline`. The frontend renders this as a screen every player
must dismiss. A play attempted in this phase is rejected with an
`illegal_move` error.
## Rules implemented ## Rules implemented
- 40-card Italian deck, ten cards per player, empty table at hand start. - 40-card Italian deck, ten cards per player, empty table at hand start.
@@ -225,11 +153,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;
@@ -241,7 +169,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": [
{ {
@@ -1,42 +0,0 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "match" ADD "game_type" VARCHAR(32) NOT NULL DEFAULT 'scopone_scientifico';
CREATE INDEX IF NOT EXISTS "idx_match_game_ty_7d519c" ON "match" ("game_type");"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
DROP INDEX IF EXISTS "idx_match_game_ty_7d519c";
ALTER TABLE "match" DROP COLUMN "game_type";"""
MODELS_STATE = (
"eJztmW1P4zgQgP9KlE+ctIdoYGF1Op2UlqLtLW0RLXenXa0sN3FTi8TO2s51K47/frbTNI"
"3zQgsUAeoXaGY8jv2MX2Ymd3ZEfRTywz4U3sz+zbqzCYyQ/FFUfLBsGMe5WAkEnIRpy1WT"
"CRcMekIKpzDkSIp8xD2GY4EpUU1dy6NRHCKBfEubWXRqUYLUPzFDFkMB5gIxqQ7kOCyxiB"
"E/VH371JOdYxI8rZuE4B8JAoIGSDZksrNv36UYEx/9RDx7jG/BFKPQLwDBvupAy4HqUMlu"
"bnrnF7qlGuIEeDRMIpK3jhdiRsmqeZJg/1DZKF2ACGJQTmENF0nCcIk1E6UjlgLBErQaqp"
"8LfDSFSaig279PE+Ip1pZ+k/pz8sdyaGvNABgMx2DUHQNgl3ykhmDwXoo8SpR/MREK1N19"
"2m8OREtt9YLOZ/f64Pj0F42AchEwrdS47HttCAVMTTX0nLLyV8qrBLszg6wadsHIYC6H/B"
"jamaAJN/doLFcd4B5GROAp9uiuYMtN9hOEiARC7dJjpwH+X+51yt/R/Knckek+HSw1jlYp"
"N+TYBYIRgHImlFWQH0UwDHtEVNM3bQ0HyCk8jwPyMyWDm+HbBfBADeLXY+fs9JPU6jGqh7"
"MG8qO+e3nZG4yr2E6ewHayZ1vHdo6JnDpQmLY5MAyzXR0ZL0u1cES0NjghWrUHRKt0PkAm"
"b8xHrmHDdr+Gi2xnkPgcxCFcoIoQo5mtabtnW2TL5eKT8wZQlMmeSyICR6iabNHS4OovTQ"
"+zH2+QcgPMca/fHY3d/pXqPuL8R6h5ueOu0jhaujCkB6fGabLqxPq7N/5sqUfr63DQNYPC"
"VbvxV1uNCSaCAkLnAPrrTDJxJiq4eYoJ5rNH+dkw3Tv61TlaZWTT27VsQQkm0LudQ+aDgi"
"ZfEfpIZLy8GtpLw4sv1yiEmmXZ7evJ75Xu6e36PZfaaRKjgVKH1hEtqyInMiWQwEBPSb1b"
"vakCWV05ISf6QFEhvdjYZrWFK3leYw/H2qdZQSDhiFmY6N+6y3IxYQu7iurBt7z8oWwATy"
"b2931J4TWVFFZ+2SJBWLd5sYLCiyUHzsePG6QHslVtgqB1xVDLx1xtV6Cft0Bt2r3DZMw5"
"OtqE99FRPW+lM0JbVBXsNKcLmc0+TSiXaLZZs/vCQc06LRUO5ml8ZcRglIYIkpraTGVENp"
"Emb5BtA8z2cHhZCLvbvbGB9abf7ma0ZSMstLi8fNNgabtoYt3mOWOK1wv8gRCilGYYfMtw"
"LyhDOCBf0EIj7smBQOJVXWLmx7S3CbWUSUgxg/NVYFtYU3L2cs4oXbIdd9Rxz7v2fX3qts"
"ukxEUMV3/eXGo+NKUiMG/zUBJST/SZPzzW3vCVG7rifl+672nZwe4vd6d1cnby6fj0ZHXD"
"ryRN13x2RNYnBf8ixnHV3VR/6a+ZvMN7fyc5gdpUWxBeNn+HdFsbZQCthgygVc4A5BsFIh"
"VJwJ+j4aCa8JqJWenEnrD+s0LM32Iu0ABXwSjEWRnTg777j4m7czlsm/GB6qBdFSC85GV2"
"/z8H5XI/"
)
+6 -7
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 = [
@@ -22,7 +22,6 @@ dependencies = [
"httpx", "httpx",
"PyJWT[crypto]", "PyJWT[crypto]",
"pwo", "pwo",
"PyYAML",
"redis", "redis",
] ]
@@ -38,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]
@@ -50,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 -16
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,44 +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
pyyaml==6.0.3
# via tavolo (pyproject.toml)
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``)
@@ -18,7 +18,6 @@ their modules at the bottom; imports must happen after ``app`` is built.
from __future__ import annotations from __future__ import annotations
from importlib.metadata import version as _pkg_version from importlib.metadata import version as _pkg_version
from logging import getLogger
from kaya.core import KayaApp from kaya.core import KayaApp
from kaya.oidc import OIDCConfig, OIDCMixin from kaya.oidc import OIDCConfig, OIDCMixin
@@ -28,13 +27,9 @@ from kaya.session.redis import RedisSessionStore
from redis.asyncio import Redis from redis.asyncio import Redis
from .config import settings from .config import settings
from .logging_config import configure_logging
from .store import GameStore, InMemoryGameStore, RedisGameStore from .store import GameStore, InMemoryGameStore, RedisGameStore
from .tortoise_mixin import TortoiseMixin from .tortoise_mixin import TortoiseMixin
configure_logging(settings.logging_config)
log = getLogger(__name__)
session_store: SessionStore session_store: SessionStore
if settings.redis_url is not None: if settings.redis_url is not None:
# Lazy client: no connection is opened until a session is actually # Lazy client: no connection is opened until a session is actually
@@ -44,11 +39,9 @@ if settings.redis_url is not None:
Redis.from_url(settings.redis_url, decode_responses=False), Redis.from_url(settings.redis_url, decode_responses=False),
ttl_seconds=settings.game_ttl_seconds, ttl_seconds=settings.game_ttl_seconds,
) )
log.info("using Redis stores (sessions + live games, game TTL %ds)", settings.game_ttl_seconds)
else: else:
session_store = InMemorySessionStore() session_store = InMemorySessionStore()
game_store = InMemoryGameStore() game_store = InMemoryGameStore()
log.info("REDIS_URL unset: using in-memory stores (sessions + live games)")
session_mixin = SessionMixin(session_store) session_mixin = SessionMixin(session_store)
oidc_mixin = OIDCMixin( oidc_mixin = OIDCMixin(
@@ -64,30 +57,24 @@ 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"}),
) )
app = KayaApp(mixins=[session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin]) app = KayaApp(mixins=[session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin])
log.debug(
"timeouts: hand_ack=%ds turn=%ds",
settings.hand_ack_timeout_seconds,
settings.turn_timeout_seconds,
)
# Register routes by importing modules. Order does not matter; each module # Register routes by importing modules. Order does not matter; each module
# pulls ``app`` from here and decorates its handlers at import time. The # pulls ``app`` from here and decorates its handlers at import time. The
# static SPA-shell catch-all is registered last and only matches paths no # static SPA catch-all is registered last and only matches paths no other
# other route claimed (asset files under /static are served by Granian # route claimed.
# itself and never reach the app).
from .routes import games, health, me, stats # noqa: E402,F401 from .routes import games, health, me, stats # noqa: E402,F401
from . import ws # noqa: E402,F401 from . import ws # noqa: E402,F401
from .routes import static # noqa: E402,F401 from .routes import static # noqa: E402,F401
@@ -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.
""" """
+65
View File
@@ -0,0 +1,65 @@
"""Environment-driven configuration for the scopa application.
Mirrors kaya's own pattern: read ``os.environ`` directly into a plain
dataclass. No pydantic-settings, no settings module.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Optional
def _env(name: str, default: Optional[str] = None) -> str:
value = os.environ.get(name)
if value is None or value == "":
if default is None:
raise RuntimeError(f"Missing required environment variable: {name}")
return default
return value
@dataclass(frozen=True)
class Settings:
database_url: str
oidc_issuer: str
oidc_client_id: str
oidc_client_secret: Optional[str]
oidc_redirect_uri: str
# Where the browser is sent after login/logout. In production the SPA is
# served by this app ("/"); in development point these at the trunk dev
# server (e.g. "http://localhost:8000/").
oidc_post_login_redirect: str
oidc_post_logout_redirect: str
app_host: str
app_port: int
redis_url: Optional[str]
# How long a live game (and its join-code index) survives in Redis
# without activity, in seconds. Defaults to 24h.
game_ttl_seconds: int
# Directory holding the compiled frontend (trunk's dist output),
# served for every path that is not under /api or /auth.
static_dir: str
@staticmethod
def from_env() -> "Settings":
return Settings(
database_url=_env("DATABASE_URL", "postgres://scopa:scopa@localhost:5432/scopa"),
oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/scopa"),
oidc_client_id=_env("OIDC_CLIENT_ID", "scopa"),
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"),
oidc_redirect_uri=_env("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback"),
oidc_post_login_redirect=_env("OIDC_POST_LOGIN_REDIRECT", "/"),
oidc_post_logout_redirect=_env("OIDC_POST_LOGOUT_REDIRECT", "/"),
app_host=_env("APP_HOST", "0.0.0.0"),
app_port=int(_env("APP_PORT", "8080")),
# When unset, sessions and live games fall back to in-memory
# stores (tests, ephemeral dev). Set to e.g.
# redis://localhost:6379/0 to persist both in Redis.
redis_url=os.environ.get("REDIS_URL"),
game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")),
static_dir=_env("STATIC_DIR", "web/dist"),
)
settings: Settings = Settings.from_env()
@@ -1,9 +1,8 @@
"""Pure rules engine for scopone scientifico. """Pure rules engine for scopone scientifico.
Every function here is deterministic and I/O-free (the only side effect is Every function here is deterministic and I/O-free: it mutates (or reads)
debug logging): 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
@@ -28,9 +27,8 @@ Rules implemented
from __future__ import annotations from __future__ import annotations
import random import random
from datetime import datetime, timedelta, timezone from datetime import datetime, timezone
from itertools import combinations from itertools import combinations
from logging import getLogger
from typing import Dict, List, Optional, Sequence, Tuple from typing import Dict, List, Optional, Sequence, Tuple
from .errors import ( from .errors import (
@@ -45,7 +43,6 @@ from .errors import (
from .state import ( from .state import (
DEFAULT_TARGET_SCORE, DEFAULT_TARGET_SCORE,
PHASE_FINISHED, PHASE_FINISHED,
PHASE_HAND_END,
PHASE_LOBBY, PHASE_LOBBY,
PHASE_PLAYING, PHASE_PLAYING,
SUITS, SUITS,
@@ -57,22 +54,10 @@ from .state import (
parse_card, parse_card,
) )
log = getLogger(__name__)
# Number of cards dealt to each player at the start of a hand. # Number of cards dealt to each player at the start of a hand.
HAND_SIZE = 10 HAND_SIZE = 10
PLAYERS = 4 PLAYERS = 4
# Default seconds the hand-end summary waits before dealing anyway. Games
# carry their own copy in ``GameState.hand_ack_timeout`` (configurable via
# the HAND_ACK_TIMEOUT_SECONDS environment variable).
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] = {
@@ -132,9 +117,6 @@ def create_game(
creator_sub: str, creator_sub: str,
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,
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
game_type: str = "scopone_scientifico",
) -> 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:
@@ -143,12 +125,9 @@ def create_game(
id=game_id, id=game_id,
join_code=join_code, join_code=join_code,
creator_sub=creator_sub, creator_sub=creator_sub,
game_type=game_type,
target_score=target_score, target_score=target_score,
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,
turn_timeout=turn_timeout,
created_at=datetime.now(timezone.utc).isoformat(), created_at=datetime.now(timezone.utc).isoformat(),
) )
@@ -175,12 +154,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:
@@ -192,12 +165,10 @@ 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)
player.hand.append(deck.pop()) player.hand.append(deck.pop())
log.debug("game %s: hand %d dealt (dealer seat %d)", state.id, state.hand_number, state.dealer)
def _player_at(state: GameState, seat: int) -> PlayerState: def _player_at(state: GameState, seat: int) -> PlayerState:
@@ -217,13 +188,11 @@ 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:
raise GameFinished("the match is over") raise GameFinished("the match is over")
if state.phase == PHASE_HAND_END:
raise IllegalMove("the hand is over; acknowledge the summary to continue")
if state.phase != PHASE_PLAYING: if state.phase != PHASE_PLAYING:
raise GameNotStarted("the game has not started yet") raise GameNotStarted("the game has not started yet")
@@ -275,7 +244,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(
@@ -291,42 +259,8 @@ 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, score the hand and either deal again or finish."""
If the match continues, the game pauses in the ``hand_end`` phase so
every player can read the scoring summary; the next hand is dealt by
:func:`acknowledge_hand` once all four players have acknowledged (or by
the hand-end timeout in the websocket layer). If the match is over the
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)
@@ -341,54 +275,15 @@ def _end_hand(state: GameState) -> None:
state.hand_scores.append(details) state.hand_scores.append(details)
a, b = state.scores a, b = state.scores
log.debug(
"game %s: hand %d scored A+%d B+%d (totals %d-%d)",
state.id,
state.hand_number,
points[0],
points[1],
a,
b,
)
reached = max(a, b) >= state.target_score reached = max(a, b) >= state.target_score
if reached and a != b: if reached and a != b:
state.phase = PHASE_FINISHED state.phase = PHASE_FINISHED
state.winner = 0 if a > b else 1 state.winner = 0 if a > b else 1
state.finished_at = datetime.now(timezone.utc).isoformat() state.finished_at = datetime.now(timezone.utc).isoformat()
log.debug("game %s: match ended, team %s wins", state.id, "A" if state.winner == 0 else "B")
return
# Pause for the scoring summary instead of dealing immediately.
state.phase = PHASE_HAND_END
state.acked = []
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.hand_ack_timeout)
state.hand_end_deadline = deadline.isoformat()
def acknowledge_hand(state: GameState, sub: str) -> None:
"""Record that ``sub`` has read the hand-end summary.
When all four players have acknowledged, the next hand is dealt.
Acknowledging twice is a no-op; acknowledging outside the ``hand_end``
phase raises an error.
"""
if state.phase != PHASE_HAND_END:
raise IllegalMove("no hand summary is waiting for acknowledgement")
player = state.player_for(sub)
if player is None:
raise NotYourTurn("you are not seated in this game")
if player.seat in state.acked:
return
state.acked.append(player.seat)
if len(state.acked) < PLAYERS:
return return
state.hand_number += 1 state.hand_number += 1
state.dealer = (state.dealer + 1) % PLAYERS state.dealer = (state.dealer + 1) % PLAYERS
state.acked = []
state.hand_end_deadline = None
state.last_move = None
state.phase = PHASE_PLAYING
_deal_hand(state) _deal_hand(state)
@@ -413,41 +308,26 @@ def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]:
scope[player.team] += player.scope scope[player.team] += player.scope
points = [0, 0] points = [0, 0]
award: Dict[str, Optional[str]] = {}
# Carte: most captured cards. Ties award nothing. # Carte: most captured cards. Ties award nothing.
cards = [len(piles[0]), len(piles[1])] cards = [len(piles[0]), len(piles[1])]
if cards[0] != cards[1]: if cards[0] != cards[1]:
winner = 0 if cards[0] > cards[1] else 1 points[0 if cards[0] > cards[1] else 1] += 1
points[winner] += 1
award["carte"] = TEAM_NAMES[winner]
else:
award["carte"] = None
# Denara: most diamond cards. Ties award nothing. # Denara: most diamond cards. Ties award nothing.
coins = [ coins = [
sum(1 for c in piles[t] if c.suit == "D") for t in (0, 1) sum(1 for c in piles[t] if c.suit == "D") for t in (0, 1)
] ]
if coins[0] != coins[1]: if coins[0] != coins[1]:
winner = 0 if coins[0] > coins[1] else 1 points[0 if coins[0] > coins[1] else 1] += 1
points[winner] += 1 # Settebello: the 7 of diamonds.
award["denara"] = TEAM_NAMES[winner]
else:
award["denara"] = None
# Settebello: the 7 of diamonds always belongs to someone.
settebello = [ settebello = [
any(c.rank == 7 and c.suit == "D" for c in piles[t]) for t in (0, 1) any(c.rank == 7 and c.suit == "D" for c in piles[t]) for t in (0, 1)
] ]
if settebello[0] != settebello[1]: if settebello[0] != settebello[1]:
winner = 0 if settebello[0] else 1 points[0 if settebello[0] else 1] += 1
points[winner] += 1
award["settebello"] = TEAM_NAMES[winner]
# Primiera: highest value, only if the team holds all four suits. # Primiera: highest value, only if the team holds all four suits.
primiera = [primiera_score(piles[t]) for t in (0, 1)] primiera = [primiera_score(piles[t]) for t in (0, 1)]
if primiera[0] != primiera[1]: if primiera[0] != primiera[1]:
winner = 0 if primiera[0] > primiera[1] else 1 points[0 if primiera[0] > primiera[1] else 1] += 1
points[winner] += 1
award["primiera"] = TEAM_NAMES[winner]
else:
award["primiera"] = None
# Scope: one point each. # Scope: one point each.
points[0] += scope[0] points[0] += scope[0]
points[1] += scope[1] points[1] += scope[1]
@@ -458,7 +338,6 @@ def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]:
"settebello": {"A": settebello[0], "B": settebello[1]}, "settebello": {"A": settebello[0], "B": settebello[1]},
"primiera": {"A": primiera[0], "B": primiera[1]}, "primiera": {"A": primiera[0], "B": primiera[1]},
"scope": {"A": scope[0], "B": scope[1]}, "scope": {"A": scope[0], "B": scope[1]},
"award": award,
} }
return points, details return points, details
@@ -467,7 +346,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.
""" """
@@ -490,7 +369,6 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
payload: Dict[str, object] = { payload: Dict[str, object] = {
"id": state.id, "id": state.id,
"join_code": state.join_code, "join_code": state.join_code,
"game_type": state.game_type,
"phase": state.phase, "phase": state.phase,
"target_score": state.target_score, "target_score": state.target_score,
"hand_number": state.hand_number, "hand_number": state.hand_number,
@@ -502,9 +380,6 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
"players": players, "players": players,
"last_hand": state.hand_scores[-1] if state.hand_scores else None, "last_hand": state.hand_scores[-1] if state.hand_scores else None,
"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),
"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``.
@@ -26,10 +26,6 @@ TEAM_NAMES = ("A", "B")
PHASE_LOBBY = "lobby" PHASE_LOBBY = "lobby"
PHASE_PLAYING = "playing" PHASE_PLAYING = "playing"
# Between hands of an unfinished match: scoring summary shown to every
# player; the next hand is dealt once all four acknowledge (or the
# hand-end timeout elapses).
PHASE_HAND_END = "hand_end"
PHASE_FINISHED = "finished" PHASE_FINISHED = "finished"
DEFAULT_TARGET_SCORE = 11 DEFAULT_TARGET_SCORE = 11
@@ -148,9 +144,6 @@ class GameState:
id: str id: str
join_code: str join_code: str
creator_sub: str creator_sub: str
# Which card game this state belongs to (see tavolo.games.GAME_TYPES).
# Defaults so states serialized before game types existed still load.
game_type: str = "scopone_scientifico"
target_score: int = DEFAULT_TARGET_SCORE target_score: int = DEFAULT_TARGET_SCORE
phase: str = PHASE_LOBBY phase: str = PHASE_LOBBY
players: List[PlayerState] = field(default_factory=list) players: List[PlayerState] = field(default_factory=list)
@@ -169,16 +162,6 @@ class GameState:
finished_at: Optional[str] = None finished_at: Optional[str] = None
# The most recent play in the current hand, for move announcements. # The most recent play in the current hand, for move announcements.
last_move: Optional[Move] = None last_move: Optional[Move] = None
# While phase == "hand_end": seats that acknowledged the summary, and
# when the auto-continue timeout fires.
acked: List[int] = field(default_factory=list)
hand_end_deadline: Optional[str] = None
# Seconds the hand-end summary waits before dealing anyway.
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 ----------------------------------------------------
@@ -187,7 +170,6 @@ class GameState:
"id": self.id, "id": self.id,
"join_code": self.join_code, "join_code": self.join_code,
"creator_sub": self.creator_sub, "creator_sub": self.creator_sub,
"game_type": self.game_type,
"target_score": self.target_score, "target_score": self.target_score,
"phase": self.phase, "phase": self.phase,
"players": [p.to_json() for p in self.players], "players": [p.to_json() for p in self.players],
@@ -203,11 +185,6 @@ class GameState:
"created_at": self.created_at, "created_at": self.created_at,
"finished_at": self.finished_at, "finished_at": self.finished_at,
"last_move": self.last_move.to_json() if self.last_move else None, "last_move": self.last_move.to_json() if self.last_move else None,
"acked": list(self.acked),
"hand_end_deadline": self.hand_end_deadline,
"hand_ack_timeout": self.hand_ack_timeout,
"turn_deadline": self.turn_deadline,
"turn_timeout": self.turn_timeout,
} }
@staticmethod @staticmethod
@@ -216,7 +193,6 @@ class GameState:
id=str(data["id"]), id=str(data["id"]),
join_code=str(data["join_code"]), join_code=str(data["join_code"]),
creator_sub=str(data.get("creator_sub", "")), creator_sub=str(data.get("creator_sub", "")),
game_type=str(data.get("game_type", "scopone_scientifico")),
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)), target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
phase=str(data.get("phase", PHASE_LOBBY)), phase=str(data.get("phase", PHASE_LOBBY)),
players=[PlayerState.from_json(p) for p in data.get("players", [])], players=[PlayerState.from_json(p) for p in data.get("players", [])],
@@ -232,11 +208,6 @@ class GameState:
created_at=data.get("created_at"), created_at=data.get("created_at"),
finished_at=data.get("finished_at"), finished_at=data.get("finished_at"),
last_move=Move.from_json(data["last_move"]) if data.get("last_move") else None, last_move=Move.from_json(data["last_move"]) if data.get("last_move") else None,
acked=[int(s) for s in data.get("acked", [])],
hand_end_deadline=data.get("hand_end_deadline"),
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":
@@ -15,12 +15,9 @@ from tortoise.models import Model
class Match(Model): class Match(Model):
"""A completed match of one of the registered game types.""" """A completed scopone scientifico match."""
id = fields.UUIDField(pk=True) id = fields.UUIDField(pk=True)
# Which card game was played (tavolo.games.GAME_TYPES); the default
# backfills matches recorded before game types existed.
game_type = fields.CharField(max_length=32, db_index=True, default="scopone_scientifico")
team_a_score = fields.SmallIntField() team_a_score = fields.SmallIntField()
team_b_score = fields.SmallIntField() team_b_score = fields.SmallIntField()
# "A" or "B". # "A" or "B".
@@ -3,14 +3,13 @@
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
import secrets import secrets
import uuid import uuid
from logging import getLogger
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from kaya.core import HttpContext from kaya.core import HttpContext
@@ -19,15 +18,11 @@ from kaya.openapi import operation
from .. import auth from .. import auth
from ..app import app, game_store, oidc_mixin from ..app import app, game_store, oidc_mixin
from ..auth import require_auth from ..auth import require_auth
from ..config import settings
from ..game import engine from ..game import engine
from ..game.errors import GameError from ..game.errors import GameError
from ..game.state import DEFAULT_TARGET_SCORE, PHASE_LOBBY, GameState from ..game.state import DEFAULT_TARGET_SCORE, PHASE_LOBBY, GameState
from ..games import GAME_TYPES, get_game_type
from ..http import JsonRequestError, read_json, read_json_optional, send_error, send_json from ..http import JsonRequestError, read_json, read_json_optional, send_error, send_json
log = getLogger(__name__)
# Join codes avoid characters that are easy to confuse when read aloud. # Join codes avoid characters that are easy to confuse when read aloud.
_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" _CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
_CODE_LENGTH = 6 _CODE_LENGTH = 6
@@ -50,7 +45,6 @@ def _lobby_payload(state: GameState) -> Dict[str, Any]:
return { return {
"id": state.id, "id": state.id,
"join_code": state.join_code, "join_code": state.join_code,
"game_type": state.game_type,
"target_score": state.target_score, "target_score": state.target_score,
"phase": state.phase, "phase": state.phase,
"players": [ "players": [
@@ -61,21 +55,6 @@ def _lobby_payload(state: GameState) -> Dict[str, Any]:
} }
@app.GET("/api/game-types")
@operation(summary="List available games",
description="Every card game the platform can host, for the "
"match-creation dropdown.",
tags=["games"],
responses={200: {"description": "The available game types"}})
async def list_game_types(ctx: HttpContext) -> None:
await send_json(ctx, 200, {
"results": [
{"id": g.id, "name": g.name, "description": g.description}
for g in GAME_TYPES.values()
]
})
@app.POST("/api/games") @app.POST("/api/games")
@operation(summary="Create a game", @operation(summary="Create a game",
description="Creates a lobby game and seats the caller in seat 0. " description="Creates a lobby game and seats the caller in seat 0. "
@@ -85,23 +64,18 @@ async def list_game_types(ctx: HttpContext) -> None:
"required": False, "required": False,
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
"game_type": { "target_score": {"type": "integer", "minimum": 1, "maximum": 100},
"type": "string", },
"default": "scopone_scientifico", }
"description": "One of the ids from GET /api/game-types",
},
"target_score": {"type": "integer", "minimum": 1, "maximum": 100},
},
}
} }
}, },
}, },
responses={ responses={
201: {"description": "The created lobby"}, 201: {"description": "The created lobby"},
400: {"description": "Invalid game_type, target_score or body"}, 400: {"description": "Invalid target_score or body"},
401: {"description": "Authentication required"}, 401: {"description": "Authentication required"},
}) })
@require_auth @require_auth
@@ -118,11 +92,6 @@ async def create_game(ctx: HttpContext) -> None:
await send_error(ctx, 400, "target_score must be an integer") await send_error(ctx, 400, "target_score must be an integer")
return return
game_type: Any = body.get("game_type", "scopone_scientifico")
if not isinstance(game_type, str) or get_game_type(game_type) is None:
await send_error(ctx, 400, f"unknown game_type: {game_type!r}")
return
user = oidc_mixin.get_user(ctx) user = oidc_mixin.get_user(ctx)
assert user is not None # enforced by @require_auth assert user is not None # enforced by @require_auth
game_id = str(uuid.uuid4()) game_id = str(uuid.uuid4())
@@ -134,21 +103,11 @@ async def create_game(ctx: HttpContext) -> None:
creator_sub=user.sub, creator_sub=user.sub,
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,
turn_timeout=settings.turn_timeout_seconds,
game_type=game_type,
) )
except GameError as exc: except GameError as exc:
await send_error(ctx, 400, str(exc)) await send_error(ctx, 400, str(exc))
return return
await game_store.save(state) await game_store.save(state)
log.info(
"game %s created by %s (%s, target score %d)",
game_id,
user.sub,
game_type,
target_score,
)
await send_json(ctx, 201, _lobby_payload(state)) await send_json(ctx, 201, _lobby_payload(state))
@@ -192,7 +151,6 @@ async def join_game(ctx: HttpContext) -> None:
assert user is not None assert user is not None
existing = await game_store.find_by_code(code) existing = await game_store.find_by_code(code)
if existing is None: if existing is None:
log.debug("join rejected for %s: unknown code %r", user.sub, code)
await send_error(ctx, 404, "unknown join code") await send_error(ctx, 404, "unknown join code")
return return
@@ -204,19 +162,14 @@ async def join_game(ctx: HttpContext) -> None:
try: try:
engine.join_game(state, user.sub, auth.display_name(user)) engine.join_game(state, user.sub, auth.display_name(user))
except GameError as exc: except GameError as exc:
log.debug("join rejected for %s in game %s: %s", user.sub, state.id, exc)
await send_error(ctx, 409, str(exc)) await send_error(ctx, 409, str(exc))
return return
await game_store.save(state) await game_store.save(state)
await game_store.publish(state.id) await game_store.publish(state.id)
seat = next(p.seat for p in state.players if p.sub == user.sub)
if state.phase == PHASE_LOBBY: if state.phase == PHASE_LOBBY:
log.info("%s joined game %s (seat %d, %d/4 players)", user.sub, state.id, seat, len(state.players)) await send_json(ctx, 200, _lobby_payload(state))
else: else:
log.info("%s joined game %s (seat %d); match started", user.sub, state.id, seat)
await send_json(ctx, 200, engine.state_for_player(state, user.sub)) await send_json(ctx, 200, engine.state_for_player(state, user.sub))
return
await send_json(ctx, 200, _lobby_payload(state))
@app.GET("/api/games/${game_id}") @app.GET("/api/games/${game_id}")
+79
View File
@@ -0,0 +1,79 @@
"""Static hosting for the compiled single-page application.
In production the kaya backend itself serves the WASM frontend built into
``STATIC_DIR`` (the ``web/dist`` output of ``trunk build --release``; see
the Docker image). A glob catch-all (``/*``) handles every path that did
not match an API or auth route: real files are served with their content
type, anything else falls back to ``index.html`` so client-side routes
(``/game/<id>`` etc.) work on direct loads and refreshes.
kaya-openapi deliberately skips glob routes, so this handler never appears
in the API specification.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Mapping
from kaya.core import HttpContext
from ..app import app
from ..config import settings
# Explicit content types: wasm-pack/trunk outputs (.wasm, .js) are not
# consistently covered by the system mime database in slim containers.
_CONTENT_TYPES: Mapping[str, str] = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".wasm": "application/wasm",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".ico": "image/x-icon",
".json": "application/json",
".webmanifest": "application/manifest+json",
".woff2": "font/woff2",
}
def _static_root() -> Path:
return Path(settings.static_dir).resolve()
async def _send_path(ctx: HttpContext, target: Path) -> None:
"""Serve a static file, or 404 when it does not exist.
Uses ``send_bytes`` rather than kaya's ``send_file`` (unimplemented by
the ASGI adapter); frontend artifacts are small enough to buffer.
"""
if not target.is_file():
await ctx.send_empty(404)
return
content_type = _CONTENT_TYPES.get(target.suffix.lower(), "application/octet-stream")
body = await asyncio.to_thread(target.read_bytes)
await ctx.send_bytes(200, body, {"content-type": (content_type,)})
@app.GET("/")
async def index(ctx: HttpContext) -> None:
"""Serve the SPA shell at the site root (the glob below cannot match
an empty path)."""
await _send_path(ctx, _static_root() / "index.html")
@app.GET("/*", recursive=True)
async def spa(ctx: HttpContext, _matched: object = None) -> None:
root = _static_root()
relative = ctx.path.lstrip("/")
target = (root / relative).resolve() if relative else root
# Path-traversal guard: the resolved target must stay inside the dist
# directory.
if root != target and root not in target.parents:
await ctx.send_empty(404)
return
if not target.is_file():
# SPA fallback: unknown paths render the app shell.
target = root / "index.html"
await _send_path(ctx, target)
@@ -1,52 +1,28 @@
"""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.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List
from kaya.core import HttpContext from kaya.core import HttpContext
from kaya.openapi import operation from kaya.openapi import operation
from ..app import app, oidc_mixin from ..app import app, oidc_mixin
from ..auth import require_auth from ..auth import require_auth
from ..games import get_game_type from ..http import send_error, send_json
from ..http import extract_query_params, send_error, send_json
from ..models import Match, MatchPlayer from ..models import Match, MatchPlayer
from ..openapi import PAGINATION_PARAMETERS from ..openapi import PAGINATION_PARAMETERS
from ..pagination import CursorDecodeError, paginate, parse_cursor_params from ..pagination import CursorDecodeError, paginate, parse_cursor_params
GAME_TYPE_PARAMETER: Dict[str, Any] = {
"name": "game_type",
"in": "query",
"required": False,
"schema": {"type": "string"},
"description": "Only count matches of this game (id from GET /api/game-types).",
}
def _parse_game_type(query_string: str) -> Tuple[Optional[str], Optional[str]]:
"""Parse the ``game_type`` query parameter.
Returns ``(value, error)``: ``(None, None)`` when absent, ``(id, None)``
when valid, ``(None, message)`` when it names no registered game."""
values = extract_query_params(query_string).get("game_type")
if not values:
return None, None
game_type = values[0]
if get_game_type(game_type) is None:
return None, f"unknown game_type: {game_type!r}"
return game_type, None
async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]: async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
participants = await MatchPlayer.filter(match_id=match.id).order_by("seat") participants = await MatchPlayer.filter(match_id=match.id).order_by("seat")
return { return {
"id": str(match.id), "id": str(match.id),
"game_type": match.game_type,
"team_a_score": match.team_a_score, "team_a_score": match.team_a_score,
"team_b_score": match.team_b_score, "team_b_score": match.team_b_score,
"winner_team": match.winner_team, "winner_team": match.winner_team,
@@ -73,10 +49,10 @@ async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
description="Cursor-paginated history of finished matches the caller " description="Cursor-paginated history of finished matches the caller "
"played, newest first, with the final score.", "played, newest first, with the final score.",
tags=["stats"], tags=["stats"],
parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER], parameters=PAGINATION_PARAMETERS,
responses={ responses={
200: {"description": "A page of matches"}, 200: {"description": "A page of matches"},
400: {"description": "Invalid pagination cursor or game_type"}, 400: {"description": "Invalid pagination cursor"},
401: {"description": "Authentication required"}, 401: {"description": "Authentication required"},
}) })
@require_auth @require_auth
@@ -86,15 +62,9 @@ async def my_matches(ctx: HttpContext) -> None:
except CursorDecodeError as exc: except CursorDecodeError as exc:
await send_error(ctx, 400, str(exc)) await send_error(ctx, 400, str(exc))
return return
game_type, error = _parse_game_type(ctx.query_string)
if error is not None:
await send_error(ctx, 400, error)
return
user = oidc_mixin.get_user(ctx) user = oidc_mixin.get_user(ctx)
assert user is not None assert user is not None
queryset = Match.filter(players__user_sub=user.sub).distinct() queryset = Match.filter(players__user_sub=user.sub).distinct()
if game_type is not None:
queryset = queryset.filter(game_type=game_type)
matches, next_cursor = await paginate( matches, next_cursor = await paginate(
queryset, [("finished_at", "DESC"), ("id", "ASC")], cursor queryset, [("finished_at", "DESC"), ("id", "ASC")], cursor
) )
@@ -107,20 +77,9 @@ async def my_matches(ctx: HttpContext) -> None:
description="Aggregated wins, matches played and team points for every " description="Aggregated wins, matches played and team points for every "
"player with at least one finished match. Sorted by wins.", "player with at least one finished match. Sorted by wins.",
tags=["stats"], tags=["stats"],
parameters=[GAME_TYPE_PARAMETER], responses={200: {"description": "The leaderboard"}})
responses={
200: {"description": "The leaderboard"},
400: {"description": "Unknown game_type"},
})
async def leaderboard(ctx: HttpContext) -> None: async def leaderboard(ctx: HttpContext) -> None:
game_type, error = _parse_game_type(ctx.query_string) rows = await MatchPlayer.all().prefetch_related("match")
if error is not None:
await send_error(ctx, 400, error)
return
queryset = MatchPlayer.all()
if game_type is not None:
queryset = queryset.filter(match__game_type=game_type)
rows = await queryset.prefetch_related("match")
aggregate: Dict[str, Dict[str, Any]] = {} aggregate: Dict[str, Dict[str, Any]] = {}
for row in rows: for row in rows:
entry = aggregate.setdefault( entry = aggregate.setdefault(
@@ -8,15 +8,12 @@ from __future__ import annotations
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from logging import getLogger
from typing import Optional from typing import Optional
from tortoise.transactions import in_transaction from tortoise.transactions import in_transaction
from .game.state import PHASE_FINISHED, TEAM_NAMES, GameState from .game.state import PHASE_FINISHED, TEAM_NAMES, GameState
log = getLogger(__name__)
def _parse_timestamp(value: Optional[str]) -> datetime: def _parse_timestamp(value: Optional[str]) -> datetime:
if value: if value:
@@ -39,7 +36,6 @@ async def save_match_result(state: GameState) -> None:
async with in_transaction(): async with in_transaction():
match = await Match.create( match = await Match.create(
id=uuid.uuid4(), id=uuid.uuid4(),
game_type=state.game_type,
team_a_score=state.scores[0], team_a_score=state.scores[0],
team_b_score=state.scores[1], team_b_score=state.scores[1],
winner_team=TEAM_NAMES[state.winner], winner_team=TEAM_NAMES[state.winner],
@@ -59,12 +55,3 @@ async def save_match_result(state: GameState) -> None:
won=player.team == state.winner, won=player.team == state.winner,
) )
state.stats_saved = True state.stats_saved = True
log.info(
"match result persisted: game %s (%s), team %s won %d-%d over %d hands",
state.id,
state.game_type,
TEAM_NAMES[state.winner],
state.scores[0],
state.scores[1],
state.hand_number,
)
@@ -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:
@@ -25,18 +25,15 @@ import asyncio
import contextlib import contextlib
import json import json
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from logging import getLogger
from typing import AsyncContextManager, AsyncIterator, Dict, Optional, Set from typing import AsyncContextManager, AsyncIterator, Dict, Optional, Set
from redis.asyncio import Redis from redis.asyncio import Redis
from .game.state import GameState from .game.state import GameState
log = getLogger(__name__) GAME_KEY_PREFIX = "scopa:game:"
CODE_KEY_PREFIX = "scopa:code:"
GAME_KEY_PREFIX = "tavolo:game:" CHANNEL_PREFIX = "scopa:game:"
CODE_KEY_PREFIX = "tavolo:code:"
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"
@@ -89,11 +86,9 @@ class RedisGameStore(GameStore):
raw = await self._redis.get(f"{GAME_KEY_PREFIX}{game_id}") raw = await self._redis.get(f"{GAME_KEY_PREFIX}{game_id}")
if raw is None: if raw is None:
log.debug("redis load %s: miss", game_id)
return None return None
if isinstance(raw, bytes): if isinstance(raw, bytes):
raw = raw.decode("utf-8") raw = raw.decode("utf-8")
log.debug("redis load %s: hit", game_id)
return GameState.from_json(json.loads(raw)) return GameState.from_json(json.loads(raw))
async def save(self, state: GameState) -> None: async def save(self, state: GameState) -> None:
@@ -103,7 +98,6 @@ class RedisGameStore(GameStore):
pipe.set(f"{GAME_KEY_PREFIX}{state.id}", payload, ex=self._ttl) pipe.set(f"{GAME_KEY_PREFIX}{state.id}", payload, ex=self._ttl)
pipe.set(f"{CODE_KEY_PREFIX}{state.join_code}", state.id, ex=self._ttl) pipe.set(f"{CODE_KEY_PREFIX}{state.join_code}", state.id, ex=self._ttl)
await pipe.execute() await pipe.execute()
log.debug("redis save %s (phase %s, ttl %ds)", state.id, state.phase, self._ttl)
async def find_by_code(self, code: str) -> Optional[GameState]: async def find_by_code(self, code: str) -> Optional[GameState]:
game_id = await self._redis.get(f"{CODE_KEY_PREFIX}{code.upper()}") game_id = await self._redis.get(f"{CODE_KEY_PREFIX}{code.upper()}")
@@ -126,7 +120,6 @@ class RedisGameStore(GameStore):
async def publish(self, game_id: str) -> None: async def publish(self, game_id: str) -> None:
await self._redis.publish(_channel(game_id), "update") await self._redis.publish(_channel(game_id), "update")
log.debug("redis publish %s", game_id)
async def _redis_events(pubsub) -> AsyncIterator[None]: async def _redis_events(pubsub) -> AsyncIterator[None]:
@@ -35,7 +35,6 @@ from __future__ import annotations
from asyncio import AbstractEventLoop, get_running_loop from asyncio import AbstractEventLoop, get_running_loop
from logging import getLogger from logging import getLogger
from typing import AbstractSet, Optional, Sequence from typing import AbstractSet, Optional, Sequence
from urllib.parse import urlsplit, urlunsplit
from kaya.core import HttpContext, KayaApp, KayaMixin, WebSocket from kaya.core import HttpContext, KayaApp, KayaMixin, WebSocket
from tortoise.context import TortoiseContext, _current_context from tortoise.context import TortoiseContext, _current_context
@@ -43,19 +42,6 @@ from tortoise.context import TortoiseContext, _current_context
log = getLogger(__name__) log = getLogger(__name__)
def _safe_url(database_url: str) -> str:
"""The database URL with any credentials stripped, for logging."""
parts = urlsplit(database_url)
host = parts.hostname or ""
try:
if parts.port:
host = f"{host}:{parts.port}"
except ValueError:
# Non-numeric netloc (e.g. sqlite://:memory:): keep the host only.
pass
return urlunsplit((parts.scheme, host, parts.path, "", ""))
class TortoiseMixin(KayaMixin): class TortoiseMixin(KayaMixin):
"""Initialize and tear down a per-loop :class:`TortoiseContext`.""" """Initialize and tear down a per-loop :class:`TortoiseContext`."""
@@ -78,7 +64,6 @@ class TortoiseMixin(KayaMixin):
def shutdown(self, loop: AbstractEventLoop) -> None: def shutdown(self, loop: AbstractEventLoop) -> None:
if self._init_loop is loop and self._ctx is not None: if self._init_loop is loop and self._ctx is not None:
log.info("closing database connections")
loop.create_task(self._ctx.close_connections()) loop.create_task(self._ctx.close_connections())
self._ctx = None self._ctx = None
self._init_loop = None self._init_loop = None
@@ -90,13 +75,11 @@ class TortoiseMixin(KayaMixin):
db_url=self._database_url, db_url=self._database_url,
modules={"models": self._models_modules}, modules={"models": self._models_modules},
) )
log.info("database context initialized (%s)", _safe_url(self._database_url))
# Schema creation is only done for sqlite (in-memory test # Schema creation is only done for sqlite (in-memory test
# databases). Postgres schemas are managed by aerich migrations # databases). Postgres schemas are managed by aerich migrations
# (applied by the db-migrate compose service / `aerich upgrade`). # (applied by the db-migrate compose service / `aerich upgrade`).
if self._database_url.startswith("sqlite"): if self._database_url.startswith("sqlite"):
await ctx.generate_schemas() await ctx.generate_schemas()
log.info("sqlite schemas generated")
return ctx return ctx
async def _bind(self) -> None: async def _bind(self) -> None:
@@ -105,7 +88,6 @@ class TortoiseMixin(KayaMixin):
if self._ctx is not None: if self._ctx is not None:
# A previous test loop went away; drop its context. # A previous test loop went away; drop its context.
self._ctx = None self._ctx = None
log.debug("building a Tortoise context for a new event loop")
self._ctx = await self._build_context() self._ctx = await self._build_context()
self._init_loop = loop self._init_loop = loop
assert self._ctx is not None assert self._ctx is not None
+175
View File
@@ -0,0 +1,175 @@
"""WebSocket endpoint for live play.
Clients connect to ``/ws/games/{game_id}`` using their session cookie (the
OIDC login stores the user in the session, which the session mixin loads
onto the websocket). Only seated players are accepted.
Protocol
--------
Server -> client messages are JSON objects with a ``type``:
* ``state`` — the personalized game view (own hand visible, others hidden).
* ``game_over`` — sent once when the match ends, with the final scores.
* ``error`` — a rejected action or malformed message.
Client -> server messages are JSON objects::
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
{"action": "play", "card": "07D"}
{"action": "state"}
``capture`` lists the table cards to take and must be a legal capture when
one exists (see :func:`scopa.game.engine.legal_captures`); it is omitted
when the played card cannot capture.
Mutations run under the per-game lock; after a successful move the new
state is saved to Redis and a change signal is published. Every connected
websocket is subscribed to that signal and re-renders the state, so all
players see the move immediately (and consistently across workers).
"""
from __future__ import annotations
import asyncio
import json
from contextlib import suppress
from typing import Any, Awaitable, Callable, Dict, Optional
from kaya.core import WebSocket
from . import auth
from .app import app, game_store
from .game import engine
from .game.errors import GameError
from .game.state import PHASE_FINISHED, GameState
from .stats import save_match_result
Send = Callable[[Dict[str, Any]], Awaitable[None]]
def _error(message: str, code: str = "invalid") -> Dict[str, Any]:
return {"type": "error", "code": code, "message": message}
def _state_message(state: GameState, sub: str) -> Dict[str, Any]:
return {"type": "state", "game": engine.state_for_player(state, sub)}
@app.websocket("/ws/games/${game_id}")
async def game_socket(ws: WebSocket, game_id: str) -> None:
user = auth.get_ws_user(ws)
if user is None:
await ws.close(4401)
return
state = await game_store.load(game_id)
if state is None:
await ws.close(4404)
return
if not state.seated(user.sub):
await ws.close(4403)
return
await ws.accept()
send_lock = asyncio.Lock()
async def send(payload: Dict[str, Any]) -> None:
async with send_lock:
await ws.send_text(json.dumps(payload))
await send(_state_message(state, user.sub))
async with game_store.subscribe(game_id) as events:
forward = asyncio.create_task(
_forward(events, game_id, user.sub, send)
)
try:
async for message in ws:
if message.kind == "close":
break
if message.kind != "text" or not isinstance(message.data, str):
await send(_error("expected a text frame with a JSON object"))
continue
await _handle_message(send, game_id, user.sub, message.data)
finally:
forward.cancel()
with suppress(asyncio.CancelledError):
await forward
async def _forward(
events,
game_id: str,
sub: str,
send: Send,
) -> None:
async for _ in events:
state = await game_store.load(game_id)
if state is None:
return
await send(_state_message(state, sub))
if state.phase == PHASE_FINISHED:
await send(
{
"type": "game_over",
"scores": {"A": state.scores[0], "B": state.scores[1]},
"winner": "A" if state.winner == 0 else "B",
}
)
return
async def _handle_message(send: Send, game_id: str, sub: str, raw: str) -> None:
try:
data = json.loads(raw)
except (ValueError, TypeError):
await send(_error("invalid JSON"))
return
if not isinstance(data, dict):
await send(_error("message must be a JSON object"))
return
action = data.get("action")
if action == "play":
await _handle_play(send, game_id, sub, data)
elif action in ("state", "sync"):
state = await game_store.load(game_id)
if state is not None:
await send(_state_message(state, sub))
else:
await send(_error(f"unknown action: {action!r}"))
async def _handle_play(
send: Send, game_id: str, sub: str, data: Dict[str, Any]
) -> None:
card = data.get("card")
capture = data.get("capture")
if not isinstance(card, str):
await send(_error("'card' must be a card code string"))
return
if capture is not None and (
not isinstance(capture, list)
or any(not isinstance(item, str) for item in capture)
):
await send(_error("'capture' must be a list of card codes"))
return
async with game_store.lock(game_id):
state = await game_store.load(game_id)
if state is None:
await send(_error("game not found", code="not_found"))
return
try:
engine.play(state, sub, card, capture)
except GameError as exc:
await send(_error(str(exc), code="illegal_move"))
return
except ValueError:
await send(_error("invalid card code", code="illegal_move"))
return
if state.phase == PHASE_FINISHED:
await save_match_result(state)
await game_store.save(state)
await game_store.publish(game_id)
-120
View File
@@ -1,120 +0,0 @@
"""Environment-driven configuration for the tavolo application.
Mirrors kaya's own pattern: read ``os.environ`` directly into a plain
dataclass. No pydantic-settings, no settings module.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Optional
from urllib.parse import quote
def _env(name: str, default: Optional[str] = None) -> str:
value = os.environ.get(name)
if value is None or value == "":
if default is None:
raise RuntimeError(f"Missing required environment variable: {name}")
return default
return value
def _database_url_from_parts(engine: str,
user: str,
password: Optional[str],
host: str,
port: str,
name: str,
options: str) -> str:
"""Assemble a database DSN from individual components.
``user`` and ``password`` are percent-encoded so credentials containing
URL-reserved characters (``@``, ``:``, ``/``, ...) do not corrupt the
DSN. ``port`` and ``options`` are omitted when empty: a missing port
lets the driver pick its default (5432 for asyncpg). ``options`` is a
raw query string (e.g. ``ssl=require``) appended after a ``?``.
"""
netloc = quote(user, safe="")
if password:
netloc += ":" + quote(password, safe="")
netloc += "@" + host
if port:
netloc += ":" + port
url = f"{engine}://{netloc}/{name}"
options = options.lstrip("?")
if options:
url += "?" + options
return url
@dataclass(frozen=True)
class Settings:
database_url: str
oidc_issuer: str
oidc_client_id: str
oidc_client_secret: Optional[str]
oidc_redirect_uri: str
# Where the browser is sent after login/logout. In production the SPA is
# served by this app ("/"); in development point these at the trunk dev
# server (e.g. "http://localhost:8000/").
oidc_post_login_redirect: str
oidc_post_logout_redirect: str
app_host: str
app_port: int
redis_url: Optional[str]
# How long a live game (and its join-code index) survives in Redis
# without activity, in seconds. Defaults to 24h.
game_ttl_seconds: int
# Directory holding the compiled frontend (trunk's dist output). Only
# used to locate index.html for the SPA shell; the assets themselves
# are served by Granian under /static (GRANIAN_STATIC_PATH_* env vars).
static_dir: str
# Seconds the between-hands scoring summary waits for acknowledgements
# before dealing the next hand anyway.
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
# Path to a YAML logging configuration file (logging.config.dictConfig
# schema). Unset uses the built-in default: DEBUG to the console.
logging_config: Optional[str]
@staticmethod
def from_env() -> "Settings":
return Settings(
# DATABASE_URL, when set, is used verbatim and the DATABASE_*
# parts below are ignored (sqlite in tests, managed-DB DSNs).
database_url=os.environ.get("DATABASE_URL") or _database_url_from_parts(
engine=_env("DATABASE_ENGINE", "postgres"),
user=_env("DATABASE_USER", "tavolo"),
password=_env("DATABASE_PASSWORD", "password"),
host=_env("DATABASE_HOST", "localhost"),
# Unset: the port segment is omitted and the driver default
# (5432 for asyncpg) applies.
port=os.environ.get("DATABASE_PORT", ""),
name=_env("DATABASE_NAME", "tavolo"),
# Raw DSN query string (e.g. "ssl=require"); empty = none.
options=os.environ.get("DATABASE_OPTIONS", ""),
),
oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/tavolo"),
oidc_client_id=_env("OIDC_CLIENT_ID", "tavolo"),
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"),
oidc_redirect_uri=_env("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback"),
oidc_post_login_redirect=_env("OIDC_POST_LOGIN_REDIRECT", "/"),
oidc_post_logout_redirect=_env("OIDC_POST_LOGOUT_REDIRECT", "/"),
app_host=_env("APP_HOST", "0.0.0.0"),
app_port=int(_env("APP_PORT", "8080")),
# When unset, sessions and live games fall back to in-memory
# stores (tests, ephemeral dev). Set to e.g.
# redis://localhost:6379/0 to persist both in Redis.
redis_url=os.environ.get("REDIS_URL"),
game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")),
static_dir=_env("STATIC_DIR", "web/dist"),
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
logging_config=os.environ.get("LOGGING_CONFIG"),
)
settings: Settings = Settings.from_env()
-42
View File
@@ -1,42 +0,0 @@
"""Registry of the card games the platform can host.
Only *scopone scientifico* is implemented for now; adding a game means a
new entry here plus its engine. The registry is the single source of truth
for the ``game_type`` carried by every live game (:mod:`tavolo.game.state`)
and persisted on each finished match (:mod:`tavolo.models`), which is what
makes match statistics game-scoped.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, Optional
@dataclass(frozen=True)
class GameType:
"""Metadata describing one playable card game."""
id: str
name: str
description: str
SCOPONE_SCIENTIFICO = "scopone_scientifico"
GAME_TYPES: Dict[str, GameType] = {
SCOPONE_SCIENTIFICO: GameType(
id=SCOPONE_SCIENTIFICO,
name="Scopone scientifico",
description=(
"Four players in fixed partnerships, ten cards each and an empty "
"table. First team to the target score wins."
),
),
}
DEFAULT_GAME_TYPE = SCOPONE_SCIENTIFICO
def get_game_type(game_type_id: str) -> Optional[GameType]:
"""Return the registered game type with id ``game_type_id``, if any."""
return GAME_TYPES.get(game_type_id)
-74
View File
@@ -1,74 +0,0 @@
"""Logging setup for the tavolo application.
Configured once at app import time (see :mod:`tavolo.app`). By default a
single console handler at DEBUG level is installed, formatting records as
``{asctime} [{levelname}] ({processName}/{threadName}) - {name} - {message}``.
Point the ``LOGGING_CONFIG`` environment variable at a YAML file to take
over the configuration entirely; the file follows the
:data:`logging.config.dictConfig` schema, e.g.::
version: 1
disable_existing_loggers: false
formatters:
default:
format: "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}"
style: "{"
handlers:
console:
class: logging.StreamHandler
formatter: default
root:
level: WARNING
handlers: [console]
loggers:
tavolo:
level: INFO
``disable_existing_loggers`` should stay ``false``: Granian configures its
own loggers before importing the application, and disabling them would
silence the server and Tortoise.
"""
from __future__ import annotations
from logging.config import dictConfig
from typing import Optional
import yaml
DEFAULT_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"format": "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "default",
"level": "DEBUG",
},
},
"root": {
"level": "DEBUG",
"handlers": ["console"],
},
}
def configure_logging(config_path: Optional[str]) -> None:
"""Apply the YAML logging configuration at ``config_path``, or the
built-in default when unset."""
if config_path is None:
dictConfig(DEFAULT_CONFIG)
return
try:
with open(config_path, "rb") as handle:
config = yaml.safe_load(handle)
except OSError as exc:
raise RuntimeError(f"Cannot read LOGGING_CONFIG file: {config_path}") from exc
if not isinstance(config, dict):
raise RuntimeError(f"LOGGING_CONFIG file is not a YAML mapping: {config_path}")
dictConfig(config)
-46
View File
@@ -1,46 +0,0 @@
"""SPA shell hosting for the compiled single-page application.
Static assets (wasm, js, css, card images) are served by Granian itself
under the ``/static`` prefix (``GRANIAN_STATIC_PATH_*`` env vars; see the
Dockerfile) and never reach Python. This module only serves ``index.html``
from ``STATIC_DIR``: at the site root and — via the glob catch-all — for
every path no API or auth route claimed, so client-side routes
(``/game/<id>`` etc.) work on direct loads and refreshes.
kaya-openapi deliberately skips glob routes, so this handler never appears
in the API specification.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from kaya.core import HttpContext
from ..app import app
from ..config import settings
async def _send_shell(ctx: HttpContext) -> None:
"""Serve the SPA shell, or 404 when the frontend build is missing."""
index = Path(settings.static_dir) / "index.html"
if not index.is_file():
await ctx.send_empty(404)
return
body = await asyncio.to_thread(index.read_bytes)
await ctx.send_bytes(200, body, {"content-type": ("text/html; charset=utf-8",)})
@app.GET("/")
async def index(ctx: HttpContext) -> None:
"""Serve the SPA shell at the site root (the glob below cannot match
an empty path)."""
await _send_shell(ctx)
@app.GET("/*", recursive=True)
async def spa(ctx: HttpContext, _matched: object = None) -> None:
"""SPA fallback: any path that matched no other route renders the app
shell. Requests under ``/static`` are answered by Granian before the
app is ever called, so they never arrive here."""
await _send_shell(ctx)
-338
View File
@@ -1,338 +0,0 @@
"""WebSocket endpoint for live play.
Clients connect to ``/ws/games/{game_id}`` using their session cookie (the
OIDC login stores the user in the session, which the session mixin loads
onto the websocket). Only seated players are accepted.
Protocol
--------
Server -> client messages are JSON objects with a ``type``:
* ``state`` — the personalized game view (own hand visible, others hidden).
* ``game_over`` — sent once when the match ends, with the final scores.
* ``error`` — a rejected action or malformed message.
Client -> server messages are JSON objects::
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
{"action": "play", "card": "07D"}
{"action": "ack"}
{"action": "state"}
``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
when the played card cannot capture. ``ack`` acknowledges the hand-end
scoring summary; the next hand is dealt when all four players have
acknowledged or the timeout fires.
Mutations run under the per-game lock; after a successful move the new
state is saved to Redis and a change signal is published. Every connected
websocket is subscribed to that signal and re-renders the state, so all
players see the move immediately (and consistently across workers).
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
import asyncio
import json
from contextlib import suppress
from datetime import datetime, timezone
from logging import getLogger
from typing import Any, Awaitable, Callable, Dict, Optional
from kaya.core import WebSocket
from . import auth
from .app import app, game_store
from .game import engine
from .game.errors import GameError
from .game.state import PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING, GameState
from .stats import save_match_result
log = getLogger(__name__)
Send = Callable[[Dict[str, Any]], Awaitable[None]]
def _error(message: str, code: str = "invalid") -> Dict[str, Any]:
return {"type": "error", "code": code, "message": message}
def _state_message(state: GameState, sub: str) -> Dict[str, Any]:
return {"type": "state", "game": engine.state_for_player(state, sub)}
@app.websocket("/ws/games/${game_id}")
async def game_socket(ws: WebSocket, game_id: str) -> None:
user = auth.get_ws_user(ws)
if user is None:
log.debug("websocket %s rejected: no authenticated user", game_id)
await ws.close(4401)
return
state = await game_store.load(game_id)
if state is None:
log.debug("websocket rejected: unknown game %s", game_id)
await ws.close(4404)
return
if not state.seated(user.sub):
log.debug("websocket %s rejected: %s is not seated", game_id, user.sub)
await ws.close(4403)
return
await ws.accept()
log.info("%s connected to game %s", user.sub, game_id)
send_lock = asyncio.Lock()
async def send(payload: Dict[str, Any]) -> None:
async with send_lock:
await ws.send_text(json.dumps(payload))
await send(_state_message(state, user.sub))
schedule_turn_timer(game_id, state)
async with game_store.subscribe(game_id) as events:
forward = asyncio.create_task(
_forward(events, game_id, user.sub, send)
)
try:
async for message in ws:
if message.kind == "close":
break
if message.kind != "text" or not isinstance(message.data, str):
await send(_error("expected a text frame with a JSON object"))
continue
await _handle_message(send, game_id, user.sub, message.data)
finally:
forward.cancel()
with suppress(asyncio.CancelledError):
await forward
log.debug("%s disconnected from game %s", user.sub, game_id)
async def _forward(
events,
game_id: str,
sub: str,
send: Send,
) -> None:
async for _ in events:
state = await game_store.load(game_id)
if state is None:
return
schedule_turn_timer(game_id, state)
await send(_state_message(state, sub))
if state.phase == PHASE_FINISHED:
await send(
{
"type": "game_over",
"scores": {"A": state.scores[0], "B": state.scores[1]},
"winner": "A" if state.winner == 0 else "B",
}
)
return
async def _handle_message(send: Send, game_id: str, sub: str, raw: str) -> None:
try:
data = json.loads(raw)
except (ValueError, TypeError):
log.debug("game %s: malformed message from %s (not JSON)", game_id, sub)
await send(_error("invalid JSON"))
return
if not isinstance(data, dict):
log.debug("game %s: malformed message from %s (not an object)", game_id, sub)
await send(_error("message must be a JSON object"))
return
action = data.get("action")
if action == "play":
await _handle_play(send, game_id, sub, data)
elif action == "ack":
await _handle_ack(send, game_id, sub)
elif action in ("state", "sync"):
state = await game_store.load(game_id)
if state is not None:
await send(_state_message(state, sub))
else:
await send(_error(f"unknown action: {action!r}"))
# --- hand-end acknowledgement ------------------------------------------------
# Running auto-continue timers, keyed by (game_id, hand_number), so a hand's
# timeout is scheduled only once even when several clients are connected.
_hand_end_timers: Dict[tuple, asyncio.Task] = {}
async def _handle_ack(send: Send, game_id: str, sub: str) -> None:
async with game_store.lock(game_id):
state = await game_store.load(game_id)
if state is None:
await send(_error("game not found", code="not_found"))
return
try:
engine.acknowledge_hand(state, sub)
except GameError as exc:
await send(_error(str(exc), code="illegal_move"))
return
log.debug("game %s: %s acknowledged hand %d", game_id, sub, state.hand_number)
await game_store.save(state)
await game_store.publish(game_id)
def schedule_hand_end_timer(game_id: str, hand_number: int, timeout: int) -> None:
"""Deal the next hand after the acknowledgement timeout, even if not
everyone has clicked. Fizzles if the hand already advanced."""
key = (game_id, hand_number)
if key in _hand_end_timers:
return
async def _auto_advance() -> None:
try:
await asyncio.sleep(timeout)
async with game_store.lock(game_id):
state = await game_store.load(game_id)
if (
state is None
or state.phase != engine.PHASE_HAND_END
or state.hand_number != hand_number
):
return
for player in state.players:
engine.acknowledge_hand(state, player.sub)
await game_store.save(state)
await game_store.publish(game_id)
log.info(
"game %s: hand %d auto-advanced after the acknowledgement timeout",
game_id,
hand_number,
)
finally:
_hand_end_timers.pop(key, None)
_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
log.info(
"game %s: auto-played for %s (turn timeout, hand %d)",
game_id,
state.players[turn].sub if turn < len(state.players) else "?",
hand_number,
)
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)
log.info(
"game %s finished: team %s wins %d-%d",
game_id,
"A" if state.winner == 0 else "B",
state.scores[0],
state.scores[1],
)
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(
send: Send, game_id: str, sub: str, data: Dict[str, Any]
) -> None:
card = data.get("card")
capture = data.get("capture")
if not isinstance(card, str):
await send(_error("'card' must be a card code string"))
return
if capture is not None and (
not isinstance(capture, list)
or any(not isinstance(item, str) for item in capture)
):
await send(_error("'capture' must be a list of card codes"))
return
async with game_store.lock(game_id):
state = await game_store.load(game_id)
if state is None:
await send(_error("game not found", code="not_found"))
return
try:
engine.play(state, sub, card, capture)
except GameError as exc:
log.debug("game %s: illegal move by %s: %s", game_id, sub, exc)
await send(_error(str(exc), code="illegal_move"))
return
except ValueError:
log.debug("game %s: invalid card code from %s: %r", game_id, sub, card)
await send(_error("invalid card code", code="illegal_move"))
return
log.debug("game %s: %s played %s (capture: %s)", game_id, sub, card, capture or "-")
await _after_play(state, 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:
-85
View File
@@ -1,85 +0,0 @@
"""Unit tests for the database DSN assembly in :mod:`tavolo.config`.
``Settings.from_env`` is called directly with a fully replaced
``os.environ`` so no test leaks its ``DATABASE_*`` overrides into the
suite (``tests/__init__.py`` sets ``DATABASE_URL=sqlite://:memory:``
globally for the application tests).
"""
from __future__ import annotations
import os
import unittest
from unittest.mock import patch
from tavolo.config import Settings
def _settings(env: dict) -> Settings:
with patch.dict(os.environ, env, clear=True):
return Settings.from_env()
class DatabaseUrlTests(unittest.TestCase):
def test_defaults_assemble_from_parts(self):
settings = _settings({})
self.assertEqual(
settings.database_url,
"postgres://tavolo:password@localhost/tavolo",
)
def test_components_override_defaults(self):
settings = _settings({
"DATABASE_ENGINE": "postgres",
"DATABASE_HOST": "db.internal",
"DATABASE_PORT": "5433",
"DATABASE_NAME": "cards",
"DATABASE_USER": "scopa",
"DATABASE_PASSWORD": "s3cret",
})
self.assertEqual(
settings.database_url,
"postgres://scopa:s3cret@db.internal:5433/cards",
)
def test_options_are_appended_as_query_string(self):
settings = _settings({"DATABASE_OPTIONS": "ssl=require"})
self.assertEqual(
settings.database_url,
"postgres://tavolo:password@localhost/tavolo?ssl=require",
)
def test_options_leading_question_mark_is_stripped(self):
settings = _settings({"DATABASE_OPTIONS": "?ssl=require"})
self.assertEqual(
settings.database_url,
"postgres://tavolo:password@localhost/tavolo?ssl=require",
)
def test_credentials_are_percent_encoded(self):
settings = _settings({
"DATABASE_USER": "u@x",
"DATABASE_PASSWORD": "p@ss/word:1",
})
self.assertEqual(
settings.database_url,
"postgres://u%40x:p%40ss%2Fword%3A1@localhost/tavolo",
)
def test_database_url_takes_precedence_over_parts(self):
settings = _settings({
"DATABASE_URL": "sqlite://:memory:",
"DATABASE_HOST": "db.internal",
"DATABASE_PASSWORD": "ignored",
})
self.assertEqual(settings.database_url, "sqlite://:memory:")
def test_empty_database_url_falls_back_to_parts(self):
settings = _settings({"DATABASE_URL": ""})
self.assertEqual(
settings.database_url,
"postgres://tavolo:password@localhost/tavolo",
)
if __name__ == "__main__":
unittest.main()
+3 -136
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,
@@ -299,10 +297,6 @@ class MatchFlowTest(unittest.TestCase):
moves = 0 moves = 0
while state.phase != PHASE_FINISHED and moves < 200000: while state.phase != PHASE_FINISHED and moves < 200000:
if state.phase == "hand_end":
for p in state.players:
engine.acknowledge_hand(state, p.sub)
continue
player = next(p for p in state.players if p.seat == state.turn) player = next(p for p in state.players if p.seat == state.turn)
played = player.hand[0] played = player.hand[0]
options = engine.legal_captures(state.table, played) options = engine.legal_captures(state.table, played)
@@ -319,132 +313,5 @@ class MatchFlowTest(unittest.TestCase):
self.assertTrue(state.finished_at) self.assertTrue(state.finished_at)
class HandEndAckTest(unittest.TestCase):
def _hand_end_state(self) -> GameState:
"""Drive a game into the hand_end phase with a one-card hand."""
state = make_state([["02D"], [], [], []],
table=["02C"], target=11)
engine.play(state, "p0", "02D", ["02C"])
return state
def test_end_of_hand_pauses_for_acknowledgement(self) -> None:
state = self._hand_end_state()
self.assertEqual("hand_end", state.phase)
# Nobody has acknowledged yet, and no new hand was dealt.
self.assertEqual([], state.acked)
self.assertEqual(1, state.hand_number)
self.assertTrue(state.hand_end_deadline)
# Capture piles stay visible during the summary.
self.assertEqual(["02C", "02D"],
[c.code for c in state.players[0].captured])
# The summary carries the award map.
summary = state.hand_scores[-1]
self.assertEqual(1, summary["hand"])
self.assertIn("award", summary)
def test_play_during_hand_end_is_rejected(self) -> None:
state = self._hand_end_state()
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "02D")
def test_ack_all_four_deals_next_hand(self) -> None:
state = self._hand_end_state()
dealer_before = state.dealer
for i, sub in enumerate(("p0", "p1", "p2")):
engine.acknowledge_hand(state, sub)
self.assertEqual(list(range(i + 1)), state.acked)
self.assertEqual("hand_end", state.phase)
engine.acknowledge_hand(state, "p3")
self.assertEqual("playing", state.phase)
self.assertEqual(2, state.hand_number)
self.assertEqual((dealer_before + 1) % 4, state.dealer)
self.assertEqual([], state.acked)
self.assertIsNone(state.hand_end_deadline)
self.assertIsNone(state.last_move)
for player in state.players:
self.assertEqual(10, len(player.hand))
self.assertEqual([], player.captured)
self.assertEqual((dealer_before + 2) % 4, state.turn)
def test_double_ack_is_idempotent(self) -> None:
state = self._hand_end_state()
engine.acknowledge_hand(state, "p0")
engine.acknowledge_hand(state, "p0")
self.assertEqual([0], state.acked)
def test_ack_outside_hand_end_is_rejected(self) -> None:
state = make_state([["02D"], ["03D"], ["04D"], ["05D"]], table=[])
with self.assertRaises(IllegalMove):
engine.acknowledge_hand(state, "p0")
def test_ack_by_non_player_is_rejected(self) -> None:
state = self._hand_end_state()
with self.assertRaises(NotYourTurn):
engine.acknowledge_hand(state, "mallory")
def test_state_exposes_ack_progress(self) -> None:
state = self._hand_end_state()
engine.acknowledge_hand(state, "p1")
view = engine.state_for_player(state, "p0")
self.assertEqual([1], view["acknowledged"])
self.assertTrue(view["hand_end_deadline"])
self.assertIsNotNone(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()
-66
View File
@@ -1,66 +0,0 @@
"""Tests for the logging configuration entry point."""
from __future__ import annotations
import logging
import tempfile
import unittest
from pathlib import Path
from tavolo.logging_config import configure_logging
class LoggingConfigTest(unittest.TestCase):
def setUp(self) -> None:
# configure_logging mutates the global logging state; snapshot and
# restore it so the rest of the suite is unaffected.
root = logging.getLogger()
self._root_handlers = root.handlers[:]
self._root_level = root.level
tavolo = logging.getLogger("tavolo")
self._tavolo_level = tavolo.level
def tearDown(self) -> None:
root = logging.getLogger()
root.handlers = self._root_handlers
root.level = self._root_level
logging.getLogger("tavolo").level = self._tavolo_level
def test_default_config_when_unset(self) -> None:
configure_logging(None)
root = logging.getLogger()
self.assertEqual(logging.DEBUG, root.level)
self.assertTrue(
any(isinstance(h, logging.StreamHandler) for h in root.handlers),
"default config installs a console stream handler",
)
def test_yaml_config_is_applied(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
config = Path(tmp) / "logging.yaml"
config.write_text(
"version: 1\n"
"disable_existing_loggers: false\n"
"root:\n"
" level: WARNING\n"
"loggers:\n"
" tavolo:\n"
" level: DEBUG\n"
)
configure_logging(str(config))
self.assertEqual(logging.DEBUG, logging.getLogger("tavolo").getEffectiveLevel())
self.assertEqual(logging.WARNING, logging.getLogger().getEffectiveLevel())
def test_missing_file_raises(self) -> None:
with self.assertRaises(RuntimeError):
configure_logging("/nonexistent/logging.yaml")
def test_non_mapping_yaml_raises(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
config = Path(tmp) / "logging.yaml"
config.write_text("- just\n- a\n- list\n")
with self.assertRaises(RuntimeError):
configure_logging(str(config))
if __name__ == "__main__":
unittest.main()
+1 -49
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
@@ -119,53 +119,5 @@ class GamesRouteTest(unittest.TestCase):
self.assertEqual(404, response.status_code) self.assertEqual(404, response.status_code)
class GameTypesRouteTest(unittest.TestCase):
@async_test
async def test_lists_available_game_types(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/game-types")
self.assertEqual(200, response.status_code)
results = response.json()["results"]
self.assertEqual(["scopone_scientifico"], [g["id"] for g in results])
self.assertEqual("Scopone scientifico", results[0]["name"])
self.assertTrue(results[0]["description"])
@async_test
async def test_create_defaults_game_type(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
created = await client.post("/api/games", json={})
self.assertEqual(201, created.status_code)
self.assertEqual("scopone_scientifico", created.json()["game_type"])
@async_test
async def test_create_with_explicit_game_type(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
created = await client.post(
"/api/games", json={"game_type": "scopone_scientifico"}
)
self.assertEqual(201, created.status_code)
body = created.json()
self.assertEqual("scopone_scientifico", body["game_type"])
with oidc_user("alice"):
snapshot = await client.get(f"/api/games/{body['id']}")
self.assertEqual("scopone_scientifico", snapshot.json()["game_type"])
@async_test
async def test_create_rejects_unknown_game_type(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
unknown = await client.post("/api/games", json={"game_type": "briscola"})
non_string = await client.post("/api/games", json={"game_type": 42})
self.assertEqual(400, unknown.status_code)
self.assertEqual(400, non_string.status_code)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+25 -10
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
@@ -34,32 +34,47 @@ class MeRouteTest(unittest.TestCase):
class StaticRouteTest(unittest.TestCase): class StaticRouteTest(unittest.TestCase):
"""The app only serves the SPA shell; asset files under /static are
served by Granian and are not reachable through the ASGI transport."""
@async_test @async_test
async def test_serves_shell_and_spa_fallback(self) -> None: async def test_serves_files_and_spa_fallback(self) -> None:
with tempfile.TemporaryDirectory() as dist: with tempfile.TemporaryDirectory() as dist:
(Path(dist) / "index.html").write_text("<html>spa</html>") root = Path(dist)
(root / "index.html").write_text("<html>spa</html>")
(root / "app.js").write_text("console.log(1)")
cards = root / "assets" / "cards"
cards.mkdir(parents=True)
(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("/")
self.assertEqual(200, index.status_code) self.assertEqual(200, index.status_code)
self.assertEqual("text/html; charset=utf-8", index.headers["content-type"])
self.assertIn(b"spa", index.content) self.assertIn(b"spa", index.content)
js = await client.get("/app.js")
self.assertEqual(200, js.status_code)
self.assertEqual("text/javascript; charset=utf-8", js.headers["content-type"])
svg = await client.get("/assets/cards/07D.svg")
self.assertEqual(200, svg.status_code)
self.assertEqual("image/svg+xml", svg.headers["content-type"])
# Unknown client-side route falls back to the app shell. # Unknown client-side route falls back to the app shell.
fallback = await client.get("/game/some-id") fallback = await client.get("/game/some-id")
self.assertEqual(200, fallback.status_code) self.assertEqual(200, fallback.status_code)
self.assertIn(b"spa", fallback.content) self.assertIn(b"spa", fallback.content)
# Traversal attempts never escape the dist directory.
traversal = await client.get("/..%2F..%2Fetc%2Fpasswd")
self.assertIn(traversal.status_code, (200, 404))
if traversal.status_code == 200:
self.assertIn(b"spa", traversal.content)
@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("/")
+7 -50
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")]),
@@ -66,13 +66,11 @@ class SaveMatchResultTest(unittest.TestCase):
assert match is not None assert match is not None
self.assertEqual(state.scores[0], match.team_a_score) self.assertEqual(state.scores[0], match.team_a_score)
self.assertEqual("A", match.winner_team) self.assertEqual("A", match.winner_team)
# The game type travels from the live state onto the row.
self.assertEqual("scopone_scientifico", match.game_type)
winners = await MatchPlayer.filter(won=True) winners = await MatchPlayer.filter(won=True)
self.assertEqual({"alice", "carol"}, {p.user_sub for p in winners}) self.assertEqual({"alice", "carol"}, {p.user_sub for p in winners})
async def _seed_two_matches(game_types: tuple = ("scopone_scientifico", "scopone_scientifico")) -> None: async def _seed_two_matches() -> None:
ctx = await _use_app_db() ctx = await _use_app_db()
with ctx: with ctx:
for index, (a_score, b_score, winner, finished) in enumerate( for index, (a_score, b_score, winner, finished) in enumerate(
@@ -83,7 +81,6 @@ async def _seed_two_matches(game_types: tuple = ("scopone_scientifico", "scopone
): ):
match = await Match.create( match = await Match.create(
id=uuid.uuid4(), id=uuid.uuid4(),
game_type=game_types[index],
team_a_score=a_score, team_a_score=a_score,
team_b_score=b_score, team_b_score=b_score,
winner_team=winner, winner_team=winner,
@@ -168,45 +165,5 @@ class StatsRouteTest(unittest.TestCase):
self.assertEqual("alice", response.json()["results"][0]["user_sub"]) self.assertEqual("alice", response.json()["results"][0]["user_sub"])
class GameTypeFilterTest(unittest.TestCase):
"""Stats endpoints scope results by the match's game type."""
@async_test
async def test_my_matches_filter_by_game_type(self) -> None:
# The second seed names a game the registry does not know; rows are
# written directly, so this only exercises the SQL filter.
await _seed_two_matches(game_types=("scopone_scientifico", "other_game"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
all_matches = await client.get("/api/me/matches")
scoped = await client.get("/api/me/matches?game_type=scopone_scientifico")
unknown = await client.get("/api/me/matches?game_type=briscola")
self.assertEqual(2, len(all_matches.json()["results"]))
self.assertEqual(
{"scopone_scientifico", "other_game"},
{m["game_type"] for m in all_matches.json()["results"]},
)
scoped_results = scoped.json()["results"]
self.assertEqual(1, len(scoped_results))
self.assertEqual("scopone_scientifico", scoped_results[0]["game_type"])
self.assertEqual(400, unknown.status_code)
@async_test
async def test_leaderboard_filter_by_game_type(self) -> None:
await _seed_two_matches(game_types=("scopone_scientifico", "other_game"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
scoped = await client.get("/api/leaderboard?game_type=scopone_scientifico")
unknown = await client.get("/api/leaderboard?game_type=briscola")
self.assertEqual(200, scoped.status_code)
by_sub = {row["user_sub"]: row for row in scoped.json()["results"]}
# Only the first match counts: one match per player, team A won.
self.assertEqual(1, by_sub["alice"]["matches"])
self.assertEqual(1, by_sub["alice"]["wins"])
self.assertEqual(0, by_sub["bob"]["wins"])
self.assertEqual(400, unknown.status_code)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+2 -20
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):
@@ -25,24 +25,6 @@ class InMemoryGameStoreTest(unittest.TestCase):
self.assertEqual(16, loaded.target_score) self.assertEqual(16, loaded.target_score)
self.assertEqual(["alice", "bob"], [p.sub for p in loaded.players]) self.assertEqual(["alice", "bob"], [p.sub for p in loaded.players])
@async_test
async def test_game_type_roundtrip_and_default(self) -> None:
store = InMemoryGameStore()
state = engine.create_game(
"g1b", "CODE1B", "alice", "alice", game_type="scopone_scientifico"
)
await store.save(state)
loaded = await store.load("g1b")
assert loaded is not None
self.assertEqual("scopone_scientifico", loaded.game_type)
# States serialized before game types existed load with the default.
legacy = state.to_json()
del legacy["game_type"]
from tavolo.game.state import GameState
self.assertEqual("scopone_scientifico", GameState.from_json(legacy).game_type)
@async_test @async_test
async def test_load_missing_returns_none(self) -> None: async def test_load_missing_returns_none(self) -> None:
store = InMemoryGameStore() store = InMemoryGameStore()
+1 -157
View File
@@ -1,7 +1,6 @@
"""WebSocket live-play tests via kaya's ASGI websocket transport.""" """WebSocket live-play tests via kaya's ASGI websocket transport."""
from __future__ import annotations from __future__ import annotations
import asyncio
import unittest import unittest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
@@ -9,9 +8,7 @@ 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
from tavolo.game import engine
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")
@@ -130,158 +127,5 @@ class WebSocketTest(unittest.TestCase):
self.assertEqual(4401, caught.exception.code) self.assertEqual(4401, caught.exception.code)
async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
"""Seed a game where a single play ends the hand: p0 holds the only
card left and can capture the only table card."""
state = GameState(
id="hand-end-1",
join_code="HEND01",
creator_sub="alice",
target_score=11,
phase="playing",
turn=0,
table=[Card.parse("02C")],
)
state.players = [
PlayerState(sub="alice", name="Alice", seat=0, hand=[Card.parse("02D")]),
PlayerState(sub="bob", name="Bob", seat=1),
PlayerState(sub="carol", name="Carol", seat=2),
PlayerState(sub="dave", name="Dave", seat=3),
]
state.hand_ack_timeout = hand_ack_timeout
await game_store.save(state)
return state.id
class HandEndWebSocketTest(unittest.TestCase):
@async_test
async def test_hand_end_ack_flow(self) -> None:
import contextlib
game_id = await _seed_last_play_state()
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
async with contextlib.AsyncExitStack() as stack:
with ws_users([make_user(name) for name in PLAYERS]):
sockets = [
await stack.enter_async_context(
aconnect_ws(f"/ws/games/{game_id}", ws_client)
)
for _ in PLAYERS
]
for ws in sockets:
await ws.receive_json() # initial state
# Alice plays the last card: the hand ends and the game
# pauses for acknowledgements.
await sockets[0].send_json(
{"action": "play", "card": "02D", "capture": ["02C"]}
)
summaries = [await ws.receive_json() for ws in sockets]
for summary in summaries:
self.assertEqual("state", summary["type"])
self.assertEqual("hand_end", summary["game"]["phase"])
self.assertEqual([], summary["game"]["acknowledged"])
self.assertIsNotNone(summary["game"]["hand_end_deadline"])
award = summary["game"]["last_hand"]["award"]
self.assertEqual("A", award["carte"])
self.assertEqual("A", award["denara"])
# Everyone acknowledges; the fourth ack deals the next hand.
for i, ws in enumerate(sockets):
await ws.send_json({"action": "ack"})
updates = [await other.receive_json() for other in sockets]
for update in updates:
if i < 3:
self.assertEqual("hand_end", update["game"]["phase"])
self.assertEqual(
list(range(i + 1)),
update["game"]["acknowledged"],
)
else:
self.assertEqual("playing", update["game"]["phase"])
self.assertEqual(2, update["game"]["hand_number"])
self.assertEqual(
10, update["game"]["players"][i]["cards_left"]
)
@async_test
async def test_hand_end_timeout_deals_next_hand(self) -> None:
game_id = await _seed_last_play_state(hand_ack_timeout=1)
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/{game_id}", ws_client) as ws:
await ws.receive_json() # initial state
await ws.send_json(
{"action": "play", "card": "02D", "capture": ["02C"]}
)
summary = await ws.receive_json()
self.assertEqual("hand_end", summary["game"]["phase"])
# Nobody acks: the timer must deal the next hand.
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"]["phase"] == "playing"
):
break
self.assertIsNotNone(update)
assert update is not None
self.assertEqual("playing", update["game"]["phase"])
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()
+16 -28
View File
@@ -174,16 +174,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "gloo-timers"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "gloo-utils" name = "gloo-utils"
version = "0.2.0" version = "0.2.0"
@@ -369,6 +359,22 @@ 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",
"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 +567,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 -4
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"
@@ -14,8 +14,6 @@ wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"
futures = "0.3" futures = "0.3"
web-sys = { version = "0.3", features = ["Window", "Location", "console"] } web-sys = { version = "0.3", features = ["Window", "Location", "console"] }
js-sys = "0.3"
gloo-timers = "0.3"
console_error_panic_hook = "0.1" console_error_panic_hook = "0.1"
[profile.release] [profile.release]
+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. -->
+3 -16
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;
@@ -22,22 +22,9 @@ pub async fn me() -> Result<Option<User>, String> {
resp.json().await.map(Some).map_err(|e| e.to_string()) resp.json().await.map(Some).map_err(|e| e.to_string())
} }
/// Fetch the card games the platform can host (for the creation dropdown). pub async fn create_game(target_score: i32) -> Result<GameView, String> {
pub async fn game_types() -> Result<Vec<GameTypeInfo>, String> {
let resp = Request::get("/api/game-types")
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
let page: GameTypesPage = resp.json().await.map_err(|e| e.to_string())?;
Ok(page.results)
}
pub async fn create_game(game_type: &str, target_score: i32) -> Result<GameView, String> {
let resp = Request::post("/api/games") let resp = Request::post("/api/games")
.json(&serde_json::json!({ "game_type": game_type, "target_score": target_score })) .json(&serde_json::json!({ "target_score": target_score }))
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.send() .send()
.await .await
-1
View File
@@ -1,2 +1 @@
pub mod card; pub mod card;
pub mod summary;
-250
View File
@@ -1,250 +0,0 @@
//! The hand-end scoring summary screen.
//!
//! Shown when a hand finishes but the match continues: explains, in plain
//! language, how each scoring category played out and how the running
//! totals moved toward the target. Every player must acknowledge it before
//! the next hand is dealt (or the server-side timeout deals anyway).
use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
use crate::model::{GameView, HandSummary, Scores};
use crate::ws::GameSocket;
/// One explanatory row: icon, title, plain-language sentence, points chip.
fn award_row(
icon: View,
title: &'static str,
text: String,
winner: Option<String>,
points: &'static str,
) -> View {
let cls = match winner.as_deref() {
Some("A") => "score-row team-a",
Some("B") => "score-row team-b",
_ => "score-row tie",
};
let chip = match &winner {
Some(t) => format!("Team {t} {points}"),
None => "tie".to_string(),
};
view! {
div(class=cls) {
div(class="score-icon") { (icon) }
div(class="score-body") {
div(class="score-title") { (title) }
div(class="score-text") { (text) }
}
div(class="score-points") { (chip) }
}
}
}
/// Winner's count first, then the loser's, for a natural sentence.
fn winner_first(a: i32, b: i32, winner: Option<&String>) -> (i32, i32) {
match winner.map(String::as_str) {
Some("B") => (b, a),
_ => (a, b),
}
}
/// The five scoring rows of a completed hand.
pub fn summary_rows(summary: HandSummary) -> View {
let rows: Vec<View> = vec![
// Carte
award_row(
card_back("score-mini"),
"Carte",
match &summary.award.carte {
Some(t) => {
let (w, l) = winner_first(summary.cards.a, summary.cards.b, Some(t));
format!("Team {t} captured more cards ({w} vs {l})")
}
None => format!(
"Both teams captured {} cards — no point",
summary.cards.a
),
},
summary.award.carte.clone(),
"+1",
),
// Denara
award_row(
card_img("02D".to_string(), "score-mini"),
"Denara",
match &summary.award.denara {
Some(t) => {
let (w, l) = winner_first(summary.denara.a, summary.denara.b, Some(t));
format!("Team {t} collected more denari cards ({w} vs {l})")
}
None => format!(
"Both teams collected {} denari cards — no point",
summary.denara.a
),
},
summary.award.denara.clone(),
"+1",
),
// Settebello
award_row(
card_img("07D".to_string(), "score-mini"),
"Settebello",
match &summary.award.settebello {
Some(t) => format!("Team {t} captured the 7 of denari — the Settebello"),
None => "Nobody captured the Settebello".to_string(),
},
summary.award.settebello.clone(),
"+1",
),
// Primiera
award_row(
card_img("10D".to_string(), "score-mini"),
"Primiera",
match &summary.award.primiera {
Some(t) => {
let (w, l) =
winner_first(summary.primiera.a, summary.primiera.b, Some(t));
format!("Team {t} holds the strongest primiera ({w} vs {l})")
}
None => format!(
"Both primiere are worth {} — no point",
summary.primiera.a
),
},
summary.award.primiera.clone(),
"+1",
),
// Scope
{
let a = summary.scope.a;
let b = summary.scope.b;
let text = if a == 0 && b == 0 {
"No scope this hand".to_string()
} else {
format!("Team A made {a} scope · Team B made {b} scope")
};
let chip = format!("+{a} · +{b}");
view! {
div(class="score-row scope-row") {
div(class="score-icon") {
(card_back("score-mini"))
}
div(class="score-body") {
div(class="score-title") { "Scope" }
div(class="score-text") { (text) }
}
div(class="score-points") { (chip) }
}
}
},
];
view! {
div(class="score-rows") { (rows) }
}
}
/// Running totals with progress toward the target score, including the
/// points gained in the hand just played.
pub fn totals(game: &GameView) -> View {
let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 });
let target = game.target_score.max(1);
let pct_a = (100 * scores.a / target).min(100);
let pct_b = (100 * scores.b / target).min(100);
let (gained_a, gained_b) = game
.last_hand
.as_ref()
.map(|s| (s.team_a_points, s.team_b_points))
.unwrap_or((0, 0));
view! {
div(class="totals") {
div(class="total-row team-a") {
span(class="total-label") { "Team A" }
div(class="progress") {
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
}
span(class="total-value") {
(scores.a) " / " (target)
span(class="gained") { "+" (gained_a) }
}
}
div(class="total-row team-b") {
span(class="total-label") { "Team B" }
div(class="progress") {
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
}
span(class="total-value") {
(scores.b) " / " (target)
span(class="gained") { "+" (gained_b) }
}
}
}
}
}
/// The full hand-end modal: explanation + totals + acknowledgement button.
pub fn hand_summary_modal(
game: GameView,
socket: Signal<Option<GameSocket>>,
now: Signal<f64>,
) -> View {
let Some(summary) = game.last_hand.clone() else {
return view! {};
};
let viewer_seat = game
.players
.iter()
.find(|p| p.hand.is_some())
.map(|p| p.seat);
let acked = game.acknowledged.clone();
let already_acked = viewer_seat.is_some_and(|s| acked.contains(&s));
let waiting: Vec<String> = game
.players
.iter()
.filter(|p| !acked.contains(&p.seat))
.map(|p| p.name.clone())
.collect();
let countdown = game.hand_end_deadline.as_ref().map(|deadline| {
// A dynamic closure so only the ticking number re-renders, not the
// whole modal (which would swap DOM nodes under the user's cursor).
let deadline_ms = js_sys::Date::parse(deadline);
view! {
p(class="hint") {
"Auto-continuing in "
(move || {
((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32
})
"s"
}
}
});
let rows = summary_rows(summary.clone());
let totals_view = totals(&game);
let title = format!("Hand {} — results", summary.hand);
let action = if already_acked {
let waiting_text = format!("Waiting for {}", waiting.join(", "));
view! {
button(class="button primary", disabled=true) { (waiting_text) }
}
} else {
view! {
button(class="button primary", on:click=move |_| {
if let Some(s) = socket.get_clone() {
s.ack();
}
}) { "Understood — next hand" }
}
};
view! {
div(class="overlay") {
div(class="picker summary-panel") {
h2 { (title) }
(rows)
(totals_view)
div(class="summary-actions") { (action) }
(countdown)
}
}
}
}
-83
View File
@@ -47,63 +47,12 @@ pub struct MoveView {
pub scopa: bool, pub scopa: bool,
} }
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct TeamCounts {
#[serde(rename = "A")]
pub a: i32,
#[serde(rename = "B")]
pub b: i32,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[allow(dead_code)]
pub struct TeamBools {
#[serde(rename = "A")]
pub a: bool,
#[serde(rename = "B")]
pub b: bool,
}
/// Which team (if any) won each scoring category of a hand.
#[derive(Debug, Clone, Deserialize)]
pub struct Award {
#[serde(default)]
pub carte: Option<String>,
#[serde(default)]
pub denara: Option<String>,
#[serde(default)]
pub settebello: Option<String>,
#[serde(default)]
pub primiera: Option<String>,
}
/// The scoring breakdown of one completed hand.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct HandSummary {
pub cards: TeamCounts,
pub denara: TeamCounts,
pub settebello: TeamBools,
pub primiera: TeamCounts,
pub scope: TeamCounts,
pub award: Award,
#[serde(default)]
pub hand: i32,
#[serde(default)]
pub team_a_points: i32,
#[serde(default)]
pub team_b_points: i32,
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct GameView { pub struct GameView {
pub id: String, pub id: String,
#[serde(default)] #[serde(default)]
pub join_code: String, pub join_code: String,
/// Which card game this match is (id from /api/game-types).
#[serde(default)]
pub game_type: String,
pub phase: String, pub phase: String,
#[serde(default)] #[serde(default)]
pub target_score: i32, pub target_score: i32,
@@ -125,20 +74,6 @@ pub struct GameView {
pub seats_open: Option<usize>, pub seats_open: Option<usize>,
#[serde(default)] #[serde(default)]
pub last_move: Option<MoveView>, pub last_move: Option<MoveView>,
/// Scoring breakdown of the most recent hand (present once a hand has
/// been completed).
#[serde(default)]
pub last_hand: Option<HandSummary>,
/// Seats that acknowledged the hand-end summary.
#[serde(default)]
pub acknowledged: Vec<usize>,
/// ISO-8601 instant at which the next hand is dealt automatically.
#[serde(default)]
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.
@@ -177,8 +112,6 @@ pub struct MatchPlayer {
#[allow(dead_code)] #[allow(dead_code)]
pub struct MatchSummary { pub struct MatchSummary {
pub id: String, pub id: String,
#[serde(default)]
pub game_type: String,
pub team_a_score: i32, pub team_a_score: i32,
pub team_b_score: i32, pub team_b_score: i32,
pub winner_team: String, pub winner_team: String,
@@ -216,22 +149,6 @@ pub struct LeaderboardPage {
pub results: Vec<LeaderboardEntry>, pub results: Vec<LeaderboardEntry>,
} }
/// A card game the platform can host (GET /api/game-types).
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[allow(dead_code)]
pub struct GameTypeInfo {
pub id: String,
pub name: String,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GameTypesPage {
#[serde(default)]
pub results: Vec<GameTypeInfo>,
}
/// Map a card code (e.g. `07D`) to its asset path. /// Map a card code (e.g. `07D`) to its asset path.
pub fn card_asset(code: &str) -> String { pub fn card_asset(code: &str) -> String {
format!("/assets/cards/{code}.svg") format!("/assets/cards/{code}.svg")
+4 -38
View File
@@ -2,7 +2,6 @@
use sycamore::prelude::*; use sycamore::prelude::*;
use crate::components::card::{card_back, card_img}; use crate::components::card::{card_back, card_img};
use crate::components::summary::{hand_summary_modal, summary_rows};
use crate::model::{card_label, GameView, MoveView, PlayerView, Scores, ServerMessage}; use crate::model::{card_label, GameView, MoveView, PlayerView, Scores, ServerMessage};
use crate::ws::{self, GameSocket}; use crate::ws::{self, GameSocket};
@@ -78,9 +77,6 @@ pub fn GamePage(id: String) -> View {
let over = create_signal(Option::<(Scores, Option<String>)>::None); let over = create_signal(Option::<(Scores, Option<String>)>::None);
let closed = create_signal(false); let closed = create_signal(false);
let socket = create_signal(Option::<GameSocket>::None); let socket = create_signal(Option::<GameSocket>::None);
// Ticking clock driving the hand-end countdown display.
let now = create_signal(js_sys::Date::now());
gloo_timers::callback::Interval::new(500, move || now.set(js_sys::Date::now())).forget();
{ {
let on_message = move |msg: ServerMessage| match msg { let on_message = move |msg: ServerMessage| match msg {
@@ -139,15 +135,11 @@ 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)
})) }))
(move || match game.get_clone() {
Some(g) if g.phase == "hand_end" => hand_summary_modal(g, socket, now),
_ => view! {},
})
(move || game_over_view(over.get_clone(), game.get_clone())) (move || game_over_view(over.get_clone(), game.get_clone()))
} }
} }
@@ -193,7 +185,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
@@ -208,8 +199,6 @@ fn table_view(
let my_turn = game.your_turn == Some(true); let my_turn = game.your_turn == Some(true);
let turn_note = if game.phase == "finished" { let turn_note = if game.phase == "finished" {
"Match finished".to_string() "Match finished".to_string()
} else if game.phase == "hand_end" {
"Hand finished".to_string()
} else if my_turn { } else if my_turn {
"Your turn".to_string() "Your turn".to_string()
} else { } else {
@@ -219,19 +208,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 +262,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)
@@ -346,8 +321,7 @@ fn capture_picker(
/// End-of-match overlay. /// End-of-match overlay.
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View { fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
let result = over.or_else(|| { let result = over.or_else(|| {
game.clone() game.filter(|g| g.phase == "finished")
.filter(|g| g.phase == "finished")
.map(|g| (g.scores.unwrap_or(Scores { a: 0, b: 0 }), g.winner)) .map(|g| (g.scores.unwrap_or(Scores { a: 0, b: 0 }), g.winner))
}); });
match result { match result {
@@ -355,19 +329,11 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
Some((scores, winner)) => { Some((scores, winner)) => {
let winner = winner.unwrap_or_else(|| "?".to_string()); let winner = winner.unwrap_or_else(|| "?".to_string());
let line = format!("Team {winner} wins {} {}", scores.a, scores.b); let line = format!("Team {winner} wins {} {}", scores.a, scores.b);
// Explain the final hand's scoring before the result.
let final_summary = game
.and_then(|g| g.last_hand)
.map(|s| {
let rows = summary_rows(s);
view! { (rows) }
});
view! { view! {
div(class="overlay") { div(class="overlay") {
div(class="picker summary-panel") { div(class="picker") {
h2 { "Match over" } h2 { "Match over" }
(final_summary) p { (line) }
p(class="final-score") { (line) }
div(class="gameover-actions") { div(class="gameover-actions") {
a(class="button primary", href="/") { "Back to lobby" } a(class="button primary", href="/") { "Back to lobby" }
a(class="button", href="/history") { "My matches" } a(class="button", href="/history") { "My matches" }
+2 -32
View File
@@ -4,16 +4,7 @@ use sycamore::prelude::*;
use sycamore_router::navigate; use sycamore_router::navigate;
use crate::api; use crate::api;
use crate::model::{GameTypeInfo, User}; use crate::model::User;
/// Used when the game-types fetch fails: match creation must still work.
fn fallback_game_types() -> Vec<GameTypeInfo> {
vec![GameTypeInfo {
id: "scopone_scientifico".to_string(),
name: "Scopone scientifico".to_string(),
description: String::new(),
}]
}
#[component] #[component]
pub fn LobbyPage() -> View { pub fn LobbyPage() -> View {
@@ -21,8 +12,6 @@ pub fn LobbyPage() -> View {
let user = create_signal(Option::<Option<User>>::None); let user = create_signal(Option::<Option<User>>::None);
let error = create_signal(Option::<String>::None); let error = create_signal(Option::<String>::None);
let code = create_signal(String::new()); let code = create_signal(String::new());
let game_types = create_signal(fallback_game_types());
let selected_game = create_signal("scopone_scientifico".to_string());
spawn_local(async move { spawn_local(async move {
match api::me().await { match api::me().await {
@@ -34,20 +23,9 @@ pub fn LobbyPage() -> View {
} }
}); });
spawn_local(async move {
match api::game_types().await {
Ok(types) if !types.is_empty() => {
selected_game.set(types[0].id.clone());
game_types.set(types);
}
_ => {} // keep the scopone fallback
}
});
let on_create = move |target: i32| { let on_create = move |target: i32| {
let game_type = selected_game.get_clone();
spawn_local(async move { spawn_local(async move {
match api::create_game(&game_type, target).await { match api::create_game(target).await {
Ok(game) => navigate(&format!("/game/{}", game.id)), Ok(game) => navigate(&format!("/game/{}", game.id)),
Err(e) => error.set(Some(e)), Err(e) => error.set(Some(e)),
} }
@@ -92,14 +70,6 @@ pub fn LobbyPage() -> View {
} }
div(class="panel") { div(class="panel") {
h2 { "New match" } h2 { "New match" }
label(r#for="game-type") { "Game" }
select(id="game-type", bind:value=selected_game) {
Keyed(
list=game_types,
view=|g| view! { option(value=g.id.clone()) { (g.name) } },
key=|g| g.id.clone(),
)
}
p { "First team to reach the target score wins." } p { "First team to reach the target score wins." }
div(class="target-buttons") { div(class="target-buttons") {
button(class="button", on:click=move |_| on_create(11)) { "Target 11" } button(class="button", on:click=move |_| on_create(11)) { "Target 11" }
-5
View File
@@ -40,11 +40,6 @@ impl GameSocket {
self.send_json(serde_json::json!({ "action": "state" })); self.send_json(serde_json::json!({ "action": "state" }));
} }
/// Acknowledge the hand-end scoring summary.
pub fn ack(&self) {
self.send_json(serde_json::json!({ "action": "ack" }));
}
fn send_json(&self, value: serde_json::Value) { fn send_json(&self, value: serde_json::Value) {
let _ = self.sender.borrow_mut().unbounded_send(value.to_string()); let _ = self.sender.borrow_mut().unbounded_send(value.to_string());
} }
-158
View File
@@ -9,8 +9,6 @@
--muted: #a9b7ab; --muted: #a9b7ab;
--accent: #e8c547; --accent: #e8c547;
--danger: #d9534f; --danger: #d9534f;
--team-a: #7db4e8;
--team-b: #e8967d;
} }
* { * {
@@ -212,11 +210,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;
@@ -455,154 +448,3 @@ table.matches td.lost {
justify-content: center; justify-content: center;
margin-top: 1rem; margin-top: 1rem;
} }
/* ---------- hand-end scoring summary ---------- */
.summary-panel {
min-width: 420px;
max-width: 560px;
text-align: left;
}
.summary-panel h2 {
text-align: center;
margin-top: 0;
}
.score-rows {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 0.75rem 0;
}
.score-row {
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--panel-light);
border-left: 4px solid transparent;
border-radius: 8px;
padding: 0.45rem 0.75rem;
}
.score-row.team-a {
border-left-color: var(--team-a);
}
.score-row.team-b {
border-left-color: var(--team-b);
}
.score-row.tie {
opacity: 0.65;
}
.score-icon {
width: 32px;
flex-shrink: 0;
display: flex;
justify-content: center;
}
.card-img.score-mini {
height: 38px;
}
.score-body {
flex: 1;
}
.score-title {
font-weight: 700;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.score-text {
font-size: 0.95rem;
}
.score-points {
font-weight: 700;
white-space: nowrap;
}
.score-row.team-a .score-points {
color: var(--team-a);
}
.score-row.team-b .score-points {
color: var(--team-b);
}
.totals {
margin: 1rem 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.total-row {
display: grid;
grid-template-columns: 4.5rem 1fr 4rem;
gap: 0.6rem;
align-items: center;
}
.total-label {
font-weight: 600;
}
.total-value {
font-weight: 700;
text-align: right;
}
.gained {
margin-left: 0.35rem;
font-size: 0.85rem;
opacity: 0.85;
}
.total-row.team-a .gained {
color: var(--team-a);
}
.total-row.team-b .gained {
color: var(--team-b);
}
.progress {
height: 10px;
background: rgba(0, 0, 0, 0.35);
border-radius: 999px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 999px;
transition: width 0.4s ease;
}
.total-row.team-a .progress-fill {
background: var(--team-a);
}
.total-row.team-b .progress-fill {
background: var(--team-b);
}
.summary-actions {
text-align: center;
margin-top: 0.5rem;
}
.final-score {
text-align: center;
font-size: 1.25rem;
font-weight: 700;
}