Author SHA1 Message Date
woggioni 294a93912d Split database config into DATABASE_* components
CI / Build and push docker image (push) Successful in 2m56s
Assemble the Postgres DSN from DATABASE_ENGINE/HOST/PORT/NAME/USER/
PASSWORD/OPTIONS so only the password needs to live in a secret; the
rest can go in a ConfigMap. DATABASE_URL remains a full-DSN override
(used by the sqlite test suite). Credentials are percent-encoded, the
port and options are omitted when empty, and the k8s migrate
initContainer now also reads the config ConfigMap.
2026-09-17 19:46:19 +08:00
woggioni ab4130a4ca Add preliminary support for multiple card games
CI / Build and push docker image (push) Successful in 3m5s
A game-type registry (server/src/tavolo/games.py) is now the single
source of truth for the games the platform can host; only scopone
scientifico is registered so far. GET /api/game-types exposes it for the
lobby's new game dropdown, and POST /api/games accepts a validated
game_type (default scopone_scientifico) which is carried on the live
GameState and onto each finished Match row (new indexed column,
migration 1_20260916235833_update), so statistics can be scoped per
game: /api/me/matches and /api/leaderboard take an optional game_type
filter and every serialized match includes its game_type.

Game states serialized before this change still load with the default
game type.
2026-09-17 08:26:46 +08:00
woggioni 876b4abd8b Serve static assets with Granian and add YAML-configurable logging
Granian serves the compiled SPA assets directly in Rust: hashed js/wasm/css
under /static (the release build uses --public-url /static/) and the card
images under /assets, configured with the GRANIAN_STATIC_PATH_ROUTE/MOUNT/
DIR_TO_FILE env vars in the Dockerfile. The Python catch-all now only serves
the SPA shell (index.html) at / and for client-side routes.

Every module logs through getLogger(__name__): lifecycle and business events
at INFO, per-move and store detail at DEBUG. The built-in default writes
DEBUG to the console; LOGGING_CONFIG points at a YAML file in the
logging.config.dictConfig schema to take over the configuration. PyYAML
becomes a direct dependency.
2026-09-17 08:26:45 +08:00
woggioni f6239d2637 Add Kubernetes manifest for the tavolo namespace
Single-file deployment under deploy/k8s/tavolo.yaml:

- Namespace, ConfigMap and Secret (placeholders) for the app
- Ephemeral Redis Deployment + Service (sessions and live games are
  disposable, mirroring the docker-compose no-volume choice)
- App Deployment with an aerich-migrate initContainer so the schema is
  upgraded before rollout, /api/health probes and a hardened pod
  security context
- ClusterIP Service; Postgres and the OIDC provider stay in their own
  namespaces and are referenced by DNS
2026-09-17 08:26:42 +08:00
31 changed files with 1165 additions and 117 deletions
+291
View File
@@ -0,0 +1,291 @@
# 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
+14 -3
View File
@@ -4,7 +4,8 @@ services:
environment:
POSTGRES_DB: tavolo
POSTGRES_USER: tavolo
POSTGRES_PASSWORD: tavolo
# Override via the DATABASE_PASSWORD env var (shell or root .env).
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-password}
ports:
- "5432:5432"
volumes:
@@ -71,7 +72,12 @@ services:
working_dir: /app
command: ["aerich", "upgrade"]
environment:
DATABASE_URL: postgres://tavolo:tavolo@postgres:5432/tavolo
DATABASE_ENGINE: postgres
DATABASE_HOST: postgres
DATABASE_PORT: "5432"
DATABASE_NAME: tavolo
DATABASE_USER: tavolo
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password}
depends_on:
postgres:
condition: service_healthy
@@ -90,7 +96,12 @@ services:
redis:
condition: service_healthy
environment:
DATABASE_URL: postgres://tavolo:tavolo@postgres:5432/tavolo
DATABASE_ENGINE: postgres
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
# name (see README /etc/hosts note); override OIDC_ISSUER and
# OIDC_REDIRECT_URI to use a real provider or a different host port.
+18 -7
View File
@@ -1,10 +1,17 @@
# Postgres
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=tavolo
POSTGRES_USER=tavolo
POSTGRES_PASSWORD=tavolo
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo
# Database (match statistics). The app assembles the DSN from these
# parts; DATABASE_PORT may be left unset to use the driver default
# (5432 for Postgres). DATABASE_OPTIONS is a raw query string appended
# to the URL (e.g. ssl=require); leave empty for none.
DATABASE_ENGINE=postgres
DATABASE_HOST=localhost
DATABASE_PORT=5432
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
# client id/secret works. For a real IdP like Keycloak, use its values here.)
@@ -28,6 +35,10 @@ HAND_ACK_TIMEOUT_SECONDS=30
# 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_HOST=0.0.0.0
APP_PORT=8080
+13 -2
View File
@@ -33,9 +33,12 @@ COPY web/Cargo.toml web/Cargo.lock web/index.html web/style.css web/Trunk.toml .
COPY web/assets ./assets
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 \
--mount=type=cache,target=/web/target \
trunk build --release
trunk build --release --public-url /static/
# --- Python builder ----------------------------------------------------------
FROM alpine:3.24 AS builder
@@ -68,7 +71,12 @@ COPY --from=builder /build/migrations /app/migrations
# aerich reads [tool.aerich] from pyproject.toml (its default config file);
# the db-migrate compose service runs `aerich upgrade` with working_dir=/app.
COPY --from=builder /build/pyproject.toml /app/pyproject.toml
# The compiled single-page application, served by the backend itself.
# The compiled single-page application. Granian serves the assets directly
# 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
ENV PATH="/opt/venv/bin:$PATH" \
@@ -77,6 +85,9 @@ ENV PATH="/opt/venv/bin:$PATH" \
GRANIAN_HOST=0.0.0.0 \
GRANIAN_PORT=8080 \
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
USER app
+54 -8
View File
@@ -44,7 +44,14 @@ All configuration comes from environment variables (see `.env.example`):
| Variable | Default | Description |
|---|---|---|
| `DATABASE_URL` | `postgres://tavolo:tavolo@localhost:5432/tavolo` | Postgres DSN for match statistics |
| `DATABASE_ENGINE` | `postgres` | Database DSN scheme/driver |
| `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 |
| `OIDC_ISSUER` | `http://localhost:8180/tavolo` | OIDC issuer URL |
| `OIDC_CLIENT_ID` | `tavolo` | OIDC client id |
@@ -53,8 +60,44 @@ All configuration comes from environment variables (see `.env.example`):
| `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 |
## 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
### Redis (live games)
@@ -71,8 +114,10 @@ All configuration comes from environment variables (see `.env.example`):
### Postgres (statistics, via Tortoise ORM + aerich migrations)
- `match` — one row per finished match: both teams' final scores, winner,
target score, hands played, start/finish timestamps.
- `match` — one row per finished match: the game played (`game_type`, one
of the ids from `GET /api/game-types`, indexed so statistics can be
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,
seat, team and whether they won. Unique per `(match, user_sub)`.
@@ -82,16 +127,17 @@ Redis until its TTL expires so clients can still fetch the final board.
## REST API
All endpoints except `/api/health`, `/api/docs` and `/api/openapi.json`
require authentication.
All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
`/api/game-types` and `/api/leaderboard` require authentication.
| Method | Path | Description |
|---|---|---|
| `POST` | `/api/games` | Create a lobby game. Optional body `{"target_score": 11}`. Returns `{id, join_code}` |
| `GET` | `/api/game-types` | The card games the platform can host (for the creation dropdown) |
| `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 |
| `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) |
| `GET` | `/api/me/matches` | Cursor-paginated match history with final scores (`?limit=&cursor=`) |
| `GET` | `/api/leaderboard` | Aggregated wins / matches / team points per player |
| `GET` | `/api/me/matches` | Cursor-paginated match history with final scores (`?limit=&cursor=&game_type=`) |
| `GET` | `/api/leaderboard` | Aggregated wins / matches / team points per player (`?game_type=`) |
## WebSocket protocol
@@ -0,0 +1,42 @@
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/"
)
+1
View File
@@ -22,6 +22,7 @@ dependencies = [
"httpx",
"PyJWT[crypto]",
"pwo",
"PyYAML",
"redis",
]
+2
View File
@@ -84,6 +84,8 @@ pyjwt[crypto]==2.14.0
# tavolo (pyproject.toml)
pypika-tortoise==0.6.5
# via tortoise-orm
pyyaml==6.0.3
# via tavolo (pyproject.toml)
redis==8.1.0
# via
# kaya-session-redis
+15 -2
View File
@@ -18,6 +18,7 @@ their modules at the bottom; imports must happen after ``app`` is built.
from __future__ import annotations
from importlib.metadata import version as _pkg_version
from logging import getLogger
from kaya.core import KayaApp
from kaya.oidc import OIDCConfig, OIDCMixin
@@ -27,9 +28,13 @@ from kaya.session.redis import RedisSessionStore
from redis.asyncio import Redis
from .config import settings
from .logging_config import configure_logging
from .store import GameStore, InMemoryGameStore, RedisGameStore
from .tortoise_mixin import TortoiseMixin
configure_logging(settings.logging_config)
log = getLogger(__name__)
session_store: SessionStore
if settings.redis_url is not None:
# Lazy client: no connection is opened until a session is actually
@@ -39,9 +44,11 @@ if settings.redis_url is not None:
Redis.from_url(settings.redis_url, decode_responses=False),
ttl_seconds=settings.game_ttl_seconds,
)
log.info("using Redis stores (sessions + live games, game TTL %ds)", settings.game_ttl_seconds)
else:
session_store = InMemorySessionStore()
game_store = InMemoryGameStore()
log.info("REDIS_URL unset: using in-memory stores (sessions + live games)")
session_mixin = SessionMixin(session_store)
oidc_mixin = OIDCMixin(
@@ -70,11 +77,17 @@ tortoise_mixin = TortoiseMixin(
)
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
# pulls ``app`` from here and decorates its handlers at import time. The
# static SPA catch-all is registered last and only matches paths no other
# route claimed.
# static SPA-shell catch-all is registered last and only matches paths no
# other route claimed (asset files under /static are served by Granian
# itself and never reach the app).
from .routes import games, health, me, stats # noqa: E402,F401
from . import ws # noqa: E402,F401
from .routes import static # noqa: E402,F401
+50 -3
View File
@@ -8,6 +8,7 @@ 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:
@@ -19,6 +20,34 @@ def _env(name: str, default: Optional[str] = None) -> str:
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
@@ -37,8 +66,9 @@ class Settings:
# 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.
# 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.
@@ -46,11 +76,27 @@ class Settings:
# 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=_env("DATABASE_URL", "postgres://tavolo:tavolo@localhost:5432/tavolo"),
# 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"),
@@ -67,6 +113,7 @@ class Settings:
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"),
)
+19 -1
View File
@@ -1,6 +1,7 @@
"""Pure rules engine for scopone scientifico.
Every function here is deterministic and I/O-free: it mutates (or reads)
Every function here is deterministic and I/O-free (the only side effect is
debug logging): it mutates (or reads)
:class:`~tavolo.game.state.GameState` and raises
:class:`~tavolo.game.errors.GameError` subclasses on rule violations. This
makes the whole rule set unit-testable without Redis, Postgres or HTTP.
@@ -29,6 +30,7 @@ from __future__ import annotations
import random
from datetime import datetime, timedelta, timezone
from itertools import combinations
from logging import getLogger
from typing import Dict, List, Optional, Sequence, Tuple
from .errors import (
@@ -55,6 +57,8 @@ from .state import (
parse_card,
)
log = getLogger(__name__)
# Number of cards dealt to each player at the start of a hand.
HAND_SIZE = 10
PLAYERS = 4
@@ -130,6 +134,7 @@ def create_game(
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:
"""Create a lobby game with the creator seated first."""
if target_score < 1 or target_score > 100:
@@ -138,6 +143,7 @@ def create_game(
id=game_id,
join_code=join_code,
creator_sub=creator_sub,
game_type=game_type,
target_score=target_score,
phase=PHASE_LOBBY,
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
@@ -191,6 +197,7 @@ def _deal_hand(state: GameState) -> None:
for seat in range(PLAYERS):
player = _player_at(state, (state.dealer + 1 + seat) % PLAYERS)
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:
@@ -334,11 +341,21 @@ def _end_hand(state: GameState) -> None:
state.hand_scores.append(details)
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
if reached and a != b:
state.phase = PHASE_FINISHED
state.winner = 0 if a > b else 1
state.finished_at = datetime.now(timezone.utc).isoformat()
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.
@@ -473,6 +490,7 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
payload: Dict[str, object] = {
"id": state.id,
"join_code": state.join_code,
"game_type": state.game_type,
"phase": state.phase,
"target_score": state.target_score,
"hand_number": state.hand_number,
+5
View File
@@ -148,6 +148,9 @@ class GameState:
id: str
join_code: 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
phase: str = PHASE_LOBBY
players: List[PlayerState] = field(default_factory=list)
@@ -184,6 +187,7 @@ class GameState:
"id": self.id,
"join_code": self.join_code,
"creator_sub": self.creator_sub,
"game_type": self.game_type,
"target_score": self.target_score,
"phase": self.phase,
"players": [p.to_json() for p in self.players],
@@ -212,6 +216,7 @@ class GameState:
id=str(data["id"]),
join_code=str(data["join_code"]),
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)),
phase=str(data.get("phase", PHASE_LOBBY)),
players=[PlayerState.from_json(p) for p in data.get("players", [])],
+42
View File
@@ -0,0 +1,42 @@
"""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
@@ -0,0 +1,74 @@
"""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)
+4 -1
View File
@@ -15,9 +15,12 @@ from tortoise.models import Model
class Match(Model):
"""A completed scopone scientifico match."""
"""A completed match of one of the registered game types."""
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_b_score = fields.SmallIntField()
# "A" or "B".
+52 -8
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import secrets
import uuid
from logging import getLogger
from typing import Any, Dict, Optional
from kaya.core import HttpContext
@@ -22,8 +23,11 @@ from ..config import settings
from ..game import engine
from ..game.errors import GameError
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
log = getLogger(__name__)
# Join codes avoid characters that are easy to confuse when read aloud.
_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
_CODE_LENGTH = 6
@@ -46,6 +50,7 @@ def _lobby_payload(state: GameState) -> Dict[str, Any]:
return {
"id": state.id,
"join_code": state.join_code,
"game_type": state.game_type,
"target_score": state.target_score,
"phase": state.phase,
"players": [
@@ -56,6 +61,21 @@ 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")
@operation(summary="Create a game",
description="Creates a lobby game and seats the caller in seat 0. "
@@ -65,18 +85,23 @@ def _lobby_payload(state: GameState) -> Dict[str, Any]:
"required": False,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target_score": {"type": "integer", "minimum": 1, "maximum": 100},
},
}
"schema": {
"type": "object",
"properties": {
"game_type": {
"type": "string",
"default": "scopone_scientifico",
"description": "One of the ids from GET /api/game-types",
},
"target_score": {"type": "integer", "minimum": 1, "maximum": 100},
},
}
}
},
},
responses={
201: {"description": "The created lobby"},
400: {"description": "Invalid target_score or body"},
400: {"description": "Invalid game_type, target_score or body"},
401: {"description": "Authentication required"},
})
@require_auth
@@ -93,6 +118,11 @@ async def create_game(ctx: HttpContext) -> None:
await send_error(ctx, 400, "target_score must be an integer")
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)
assert user is not None # enforced by @require_auth
game_id = str(uuid.uuid4())
@@ -106,11 +136,19 @@ async def create_game(ctx: HttpContext) -> None:
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:
await send_error(ctx, 400, str(exc))
return
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))
@@ -154,6 +192,7 @@ async def join_game(ctx: HttpContext) -> None:
assert user is not None
existing = await game_store.find_by_code(code)
if existing is None:
log.debug("join rejected for %s: unknown code %r", user.sub, code)
await send_error(ctx, 404, "unknown join code")
return
@@ -165,14 +204,19 @@ async def join_game(ctx: HttpContext) -> None:
try:
engine.join_game(state, user.sub, auth.display_name(user))
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))
return
await game_store.save(state)
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:
await send_json(ctx, 200, _lobby_payload(state))
log.info("%s joined game %s (seat %d, %d/4 players)", user.sub, state.id, seat, len(state.players))
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))
return
await send_json(ctx, 200, _lobby_payload(state))
@app.GET("/api/games/${game_id}")
+17 -50
View File
@@ -1,10 +1,10 @@
"""Static hosting for the compiled single-page application.
"""SPA shell 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
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
@@ -14,66 +14,33 @@ 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():
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
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,)})
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_path(ctx, _static_root() / "index.html")
await _send_shell(ctx)
@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)
"""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)
+47 -6
View File
@@ -6,23 +6,47 @@ leaderboard aggregated from the same two tables.
"""
from __future__ import annotations
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional, Tuple
from kaya.core import HttpContext
from kaya.openapi import operation
from ..app import app, oidc_mixin
from ..auth import require_auth
from ..http import send_error, send_json
from ..games import get_game_type
from ..http import extract_query_params, send_error, send_json
from ..models import Match, MatchPlayer
from ..openapi import PAGINATION_PARAMETERS
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]:
participants = await MatchPlayer.filter(match_id=match.id).order_by("seat")
return {
"id": str(match.id),
"game_type": match.game_type,
"team_a_score": match.team_a_score,
"team_b_score": match.team_b_score,
"winner_team": match.winner_team,
@@ -49,10 +73,10 @@ async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
description="Cursor-paginated history of finished matches the caller "
"played, newest first, with the final score.",
tags=["stats"],
parameters=PAGINATION_PARAMETERS,
parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER],
responses={
200: {"description": "A page of matches"},
400: {"description": "Invalid pagination cursor"},
400: {"description": "Invalid pagination cursor or game_type"},
401: {"description": "Authentication required"},
})
@require_auth
@@ -62,9 +86,15 @@ async def my_matches(ctx: HttpContext) -> None:
except CursorDecodeError as exc:
await send_error(ctx, 400, str(exc))
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)
assert user is not None
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(
queryset, [("finished_at", "DESC"), ("id", "ASC")], cursor
)
@@ -77,9 +107,20 @@ async def my_matches(ctx: HttpContext) -> None:
description="Aggregated wins, matches played and team points for every "
"player with at least one finished match. Sorted by wins.",
tags=["stats"],
responses={200: {"description": "The leaderboard"}})
parameters=[GAME_TYPE_PARAMETER],
responses={
200: {"description": "The leaderboard"},
400: {"description": "Unknown game_type"},
})
async def leaderboard(ctx: HttpContext) -> None:
rows = await MatchPlayer.all().prefetch_related("match")
game_type, error = _parse_game_type(ctx.query_string)
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]] = {}
for row in rows:
entry = aggregate.setdefault(
+13
View File
@@ -8,12 +8,15 @@ from __future__ import annotations
import uuid
from datetime import datetime, timezone
from logging import getLogger
from typing import Optional
from tortoise.transactions import in_transaction
from .game.state import PHASE_FINISHED, TEAM_NAMES, GameState
log = getLogger(__name__)
def _parse_timestamp(value: Optional[str]) -> datetime:
if value:
@@ -36,6 +39,7 @@ async def save_match_result(state: GameState) -> None:
async with in_transaction():
match = await Match.create(
id=uuid.uuid4(),
game_type=state.game_type,
team_a_score=state.scores[0],
team_b_score=state.scores[1],
winner_team=TEAM_NAMES[state.winner],
@@ -55,3 +59,12 @@ async def save_match_result(state: GameState) -> None:
won=player.team == state.winner,
)
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,
)
+7
View File
@@ -25,12 +25,15 @@ import asyncio
import contextlib
import json
from abc import ABC, abstractmethod
from logging import getLogger
from typing import AsyncContextManager, AsyncIterator, Dict, Optional, Set
from redis.asyncio import Redis
from .game.state import GameState
log = getLogger(__name__)
GAME_KEY_PREFIX = "tavolo:game:"
CODE_KEY_PREFIX = "tavolo:code:"
CHANNEL_PREFIX = "tavolo:game:"
@@ -86,9 +89,11 @@ class RedisGameStore(GameStore):
raw = await self._redis.get(f"{GAME_KEY_PREFIX}{game_id}")
if raw is None:
log.debug("redis load %s: miss", game_id)
return None
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
log.debug("redis load %s: hit", game_id)
return GameState.from_json(json.loads(raw))
async def save(self, state: GameState) -> None:
@@ -98,6 +103,7 @@ class RedisGameStore(GameStore):
pipe.set(f"{GAME_KEY_PREFIX}{state.id}", payload, ex=self._ttl)
pipe.set(f"{CODE_KEY_PREFIX}{state.join_code}", state.id, ex=self._ttl)
await pipe.execute()
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]:
game_id = await self._redis.get(f"{CODE_KEY_PREFIX}{code.upper()}")
@@ -120,6 +126,7 @@ class RedisGameStore(GameStore):
async def publish(self, game_id: str) -> None:
await self._redis.publish(_channel(game_id), "update")
log.debug("redis publish %s", game_id)
async def _redis_events(pubsub) -> AsyncIterator[None]:
+18
View File
@@ -35,6 +35,7 @@ from __future__ import annotations
from asyncio import AbstractEventLoop, get_running_loop
from logging import getLogger
from typing import AbstractSet, Optional, Sequence
from urllib.parse import urlsplit, urlunsplit
from kaya.core import HttpContext, KayaApp, KayaMixin, WebSocket
from tortoise.context import TortoiseContext, _current_context
@@ -42,6 +43,19 @@ from tortoise.context import TortoiseContext, _current_context
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):
"""Initialize and tear down a per-loop :class:`TortoiseContext`."""
@@ -64,6 +78,7 @@ class TortoiseMixin(KayaMixin):
def shutdown(self, loop: AbstractEventLoop) -> 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())
self._ctx = None
self._init_loop = None
@@ -75,11 +90,13 @@ class TortoiseMixin(KayaMixin):
db_url=self._database_url,
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
# databases). Postgres schemas are managed by aerich migrations
# (applied by the db-migrate compose service / `aerich upgrade`).
if self._database_url.startswith("sqlite"):
await ctx.generate_schemas()
log.info("sqlite schemas generated")
return ctx
async def _bind(self) -> None:
@@ -88,6 +105,7 @@ class TortoiseMixin(KayaMixin):
if self._ctx is not None:
# A previous test loop went away; drop its context.
self._ctx = None
log.debug("building a Tortoise context for a new event loop")
self._ctx = await self._build_context()
self._init_loop = loop
assert self._ctx is not None
+32
View File
@@ -42,6 +42,7 @@ 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
@@ -53,6 +54,8 @@ 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]]
@@ -68,18 +71,22 @@ def _state_message(state: GameState, sub: str) -> Dict[str, Any]:
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()
@@ -106,6 +113,7 @@ async def game_socket(ws: WebSocket, game_id: str) -> None:
forward.cancel()
with suppress(asyncio.CancelledError):
await forward
log.debug("%s disconnected from game %s", user.sub, game_id)
async def _forward(
@@ -135,9 +143,11 @@ 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
@@ -172,6 +182,7 @@ async def _handle_ack(send: Send, game_id: str, sub: str) -> None:
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)
@@ -198,6 +209,11 @@ def schedule_hand_end_timer(game_id: str, hand_number: int, timeout: int) -> Non
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)
@@ -252,6 +268,12 @@ def schedule_turn_timer(game_id: str, state: GameState) -> None:
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)
@@ -268,6 +290,13 @@ async def _after_play(state: GameState, game_id: str) -> None:
"""
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)
@@ -297,10 +326,13 @@ async def _handle_play(
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)
+85
View File
@@ -0,0 +1,85 @@
"""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()
+66
View File
@@ -0,0 +1,66 @@
"""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()
+48
View File
@@ -119,5 +119,53 @@ class GamesRouteTest(unittest.TestCase):
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__":
unittest.main()
+6 -21
View File
@@ -34,15 +34,13 @@ class MeRouteTest(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 def test_serves_files_and_spa_fallback(self) -> None:
async def test_serves_shell_and_spa_fallback(self) -> None:
with tempfile.TemporaryDirectory() as dist:
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/>")
(Path(dist) / "index.html").write_text("<html>spa</html>")
patched = dataclasses.replace(settings, static_dir=dist)
with mock.patch("tavolo.routes.static.settings", patched):
@@ -50,27 +48,14 @@ class StaticRouteTest(unittest.TestCase):
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
index = await client.get("/")
self.assertEqual(200, index.status_code)
self.assertEqual("text/html; charset=utf-8", index.headers["content-type"])
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.
fallback = await client.get("/game/some-id")
self.assertEqual(200, fallback.status_code)
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 def test_missing_dist_returns_404(self) -> None:
patched = dataclasses.replace(settings, static_dir="/nonexistent-dist")
+44 -1
View File
@@ -66,11 +66,13 @@ class SaveMatchResultTest(unittest.TestCase):
assert match is not None
self.assertEqual(state.scores[0], match.team_a_score)
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)
self.assertEqual({"alice", "carol"}, {p.user_sub for p in winners})
async def _seed_two_matches() -> None:
async def _seed_two_matches(game_types: tuple = ("scopone_scientifico", "scopone_scientifico")) -> None:
ctx = await _use_app_db()
with ctx:
for index, (a_score, b_score, winner, finished) in enumerate(
@@ -81,6 +83,7 @@ async def _seed_two_matches() -> None:
):
match = await Match.create(
id=uuid.uuid4(),
game_type=game_types[index],
team_a_score=a_score,
team_b_score=b_score,
winner_team=winner,
@@ -165,5 +168,45 @@ class StatsRouteTest(unittest.TestCase):
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__":
unittest.main()
+18
View File
@@ -25,6 +25,24 @@ class InMemoryGameStoreTest(unittest.TestCase):
self.assertEqual(16, loaded.target_score)
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 def test_load_missing_returns_none(self) -> None:
store = InMemoryGameStore()
+15 -2
View File
@@ -22,9 +22,22 @@ pub async fn me() -> Result<Option<User>, String> {
resp.json().await.map(Some).map_err(|e| e.to_string())
}
pub async fn create_game(target_score: i32) -> Result<GameView, String> {
/// Fetch the card games the platform can host (for the creation dropdown).
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")
.json(&serde_json::json!({ "target_score": target_score }))
.json(&serde_json::json!({ "game_type": game_type, "target_score": target_score }))
.map_err(|e| e.to_string())?
.send()
.await
+21
View File
@@ -101,6 +101,9 @@ pub struct GameView {
pub id: String,
#[serde(default)]
pub join_code: String,
/// Which card game this match is (id from /api/game-types).
#[serde(default)]
pub game_type: String,
pub phase: String,
#[serde(default)]
pub target_score: i32,
@@ -174,6 +177,8 @@ pub struct MatchPlayer {
#[allow(dead_code)]
pub struct MatchSummary {
pub id: String,
#[serde(default)]
pub game_type: String,
pub team_a_score: i32,
pub team_b_score: i32,
pub winner_team: String,
@@ -211,6 +216,22 @@ pub struct LeaderboardPage {
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.
pub fn card_asset(code: &str) -> String {
format!("/assets/cards/{code}.svg")
+32 -2
View File
@@ -4,7 +4,16 @@ use sycamore::prelude::*;
use sycamore_router::navigate;
use crate::api;
use crate::model::User;
use crate::model::{GameTypeInfo, 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]
pub fn LobbyPage() -> View {
@@ -12,6 +21,8 @@ pub fn LobbyPage() -> View {
let user = create_signal(Option::<Option<User>>::None);
let error = create_signal(Option::<String>::None);
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 {
match api::me().await {
@@ -23,9 +34,20 @@ 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 game_type = selected_game.get_clone();
spawn_local(async move {
match api::create_game(target).await {
match api::create_game(&game_type, target).await {
Ok(game) => navigate(&format!("/game/{}", game.id)),
Err(e) => error.set(Some(e)),
}
@@ -70,6 +92,14 @@ pub fn LobbyPage() -> View {
}
div(class="panel") {
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." }
div(class="target-buttons") {
button(class="button", on:click=move |_| on_create(11)) { "Target 11" }