Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
294a93912d
|
||
|
|
ab4130a4ca
|
||
|
|
876b4abd8b
|
||
|
|
f6239d2637
|
@@ -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
@@ -4,7 +4,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: tavolo
|
POSTGRES_DB: tavolo
|
||||||
POSTGRES_USER: tavolo
|
POSTGRES_USER: tavolo
|
||||||
POSTGRES_PASSWORD: tavolo
|
# Override via the DATABASE_PASSWORD env var (shell or root .env).
|
||||||
|
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-password}
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -71,7 +72,12 @@ services:
|
|||||||
working_dir: /app
|
working_dir: /app
|
||||||
command: ["aerich", "upgrade"]
|
command: ["aerich", "upgrade"]
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://tavolo:tavolo@postgres:5432/tavolo
|
DATABASE_ENGINE: postgres
|
||||||
|
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
|
||||||
@@ -90,7 +96,12 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
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
|
# 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.
|
||||||
|
|||||||
+18
-7
@@ -1,10 +1,17 @@
|
|||||||
# Postgres
|
# Database (match statistics). The app assembles the DSN from these
|
||||||
POSTGRES_HOST=localhost
|
# parts; DATABASE_PORT may be left unset to use the driver default
|
||||||
POSTGRES_PORT=5432
|
# (5432 for Postgres). DATABASE_OPTIONS is a raw query string appended
|
||||||
POSTGRES_DB=tavolo
|
# to the URL (e.g. ssl=require); leave empty for none.
|
||||||
POSTGRES_USER=tavolo
|
DATABASE_ENGINE=postgres
|
||||||
POSTGRES_PASSWORD=tavolo
|
DATABASE_HOST=localhost
|
||||||
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo
|
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
|
# 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.)
|
||||||
@@ -28,6 +35,10 @@ HAND_ACK_TIMEOUT_SECONDS=30
|
|||||||
# for them (covers disconnects and idle players).
|
# for them (covers disconnects and idle players).
|
||||||
TURN_TIMEOUT_SECONDS=30
|
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
|
||||||
|
|||||||
+13
-2
@@ -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/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
|
trunk build --release --public-url /static/
|
||||||
|
|
||||||
# --- Python builder ----------------------------------------------------------
|
# --- Python builder ----------------------------------------------------------
|
||||||
FROM alpine:3.24 AS 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);
|
# 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, 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
|
COPY --from=web-builder /web/dist /app/web/dist
|
||||||
|
|
||||||
ENV PATH="/opt/venv/bin:$PATH" \
|
ENV PATH="/opt/venv/bin:$PATH" \
|
||||||
@@ -77,6 +85,9 @@ 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
|
||||||
|
|||||||
+54
-8
@@ -44,7 +44,14 @@ All configuration comes from environment variables (see `.env.example`):
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `DATABASE_URL` | `postgres://tavolo:tavolo@localhost:5432/tavolo` | Postgres DSN for match statistics |
|
| `DATABASE_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 |
|
| `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/tavolo` | OIDC issuer URL |
|
||||||
| `OIDC_CLIENT_ID` | `tavolo` | OIDC client id |
|
| `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 |
|
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
|
||||||
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the between-hands scoring summary waits for acknowledgements |
|
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the between-hands scoring summary waits for acknowledgements |
|
||||||
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them |
|
| `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)
|
||||||
@@ -71,8 +114,10 @@ All configuration comes from environment variables (see `.env.example`):
|
|||||||
|
|
||||||
### Postgres (statistics, via Tortoise ORM + aerich migrations)
|
### Postgres (statistics, via Tortoise ORM + aerich migrations)
|
||||||
|
|
||||||
- `match` — one row per finished match: both teams' final scores, winner,
|
- `match` — one row per finished match: the game played (`game_type`, one
|
||||||
target score, hands played, start/finish timestamps.
|
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,
|
- `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)`.
|
||||||
|
|
||||||
@@ -82,16 +127,17 @@ Redis until its TTL expires so clients can still fetch the final board.
|
|||||||
|
|
||||||
## REST API
|
## REST API
|
||||||
|
|
||||||
All endpoints except `/api/health`, `/api/docs` and `/api/openapi.json`
|
All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
|
||||||
require authentication.
|
`/api/game-types` and `/api/leaderboard` require authentication.
|
||||||
|
|
||||||
| Method | Path | Description |
|
| 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 |
|
| `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=`) |
|
| `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 |
|
| `GET` | `/api/leaderboard` | Aggregated wins / matches / team points per player (`?game_type=`) |
|
||||||
|
|
||||||
## WebSocket protocol
|
## 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/"
|
||||||
|
)
|
||||||
@@ -22,6 +22,7 @@ dependencies = [
|
|||||||
"httpx",
|
"httpx",
|
||||||
"PyJWT[crypto]",
|
"PyJWT[crypto]",
|
||||||
"pwo",
|
"pwo",
|
||||||
|
"PyYAML",
|
||||||
"redis",
|
"redis",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ pyjwt[crypto]==2.14.0
|
|||||||
# tavolo (pyproject.toml)
|
# tavolo (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
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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
|
||||||
@@ -27,9 +28,13 @@ 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
|
||||||
@@ -39,9 +44,11 @@ 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(
|
||||||
@@ -70,11 +77,17 @@ tortoise_mixin = TortoiseMixin(
|
|||||||
)
|
)
|
||||||
|
|
||||||
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 catch-all is registered last and only matches paths no other
|
# static SPA-shell catch-all is registered last and only matches paths no
|
||||||
# route claimed.
|
# 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 .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
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
||||||
def _env(name: str, default: Optional[str] = None) -> str:
|
def _env(name: str, default: Optional[str] = None) -> str:
|
||||||
@@ -19,6 +20,34 @@ def _env(name: str, default: Optional[str] = None) -> str:
|
|||||||
return value
|
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)
|
@dataclass(frozen=True)
|
||||||
class Settings:
|
class Settings:
|
||||||
database_url: str
|
database_url: str
|
||||||
@@ -37,8 +66,9 @@ class Settings:
|
|||||||
# How long a live game (and its join-code index) survives in Redis
|
# How long a live game (and its join-code index) survives in Redis
|
||||||
# without activity, in seconds. Defaults to 24h.
|
# without activity, in seconds. Defaults to 24h.
|
||||||
game_ttl_seconds: int
|
game_ttl_seconds: int
|
||||||
# Directory holding the compiled frontend (trunk's dist output),
|
# Directory holding the compiled frontend (trunk's dist output). Only
|
||||||
# served for every path that is not under /api or /auth.
|
# 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
|
static_dir: str
|
||||||
# Seconds the between-hands scoring summary waits for acknowledgements
|
# Seconds the between-hands scoring summary waits for acknowledgements
|
||||||
# before dealing the next hand anyway.
|
# before dealing the next hand anyway.
|
||||||
@@ -46,11 +76,27 @@ class Settings:
|
|||||||
# Seconds a player has to play before the server plays a random legal
|
# Seconds a player has to play before the server plays a random legal
|
||||||
# card for them (covering disconnects and idle players).
|
# card for them (covering disconnects and idle players).
|
||||||
turn_timeout_seconds: int
|
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
|
@staticmethod
|
||||||
def from_env() -> "Settings":
|
def from_env() -> "Settings":
|
||||||
return Settings(
|
return Settings(
|
||||||
database_url=_env("DATABASE_URL", "postgres://tavolo:tavolo@localhost:5432/tavolo"),
|
# DATABASE_URL, 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_issuer=_env("OIDC_ISSUER", "http://localhost:8180/tavolo"),
|
||||||
oidc_client_id=_env("OIDC_CLIENT_ID", "tavolo"),
|
oidc_client_id=_env("OIDC_CLIENT_ID", "tavolo"),
|
||||||
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"),
|
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"),
|
||||||
@@ -67,6 +113,7 @@ class Settings:
|
|||||||
static_dir=_env("STATIC_DIR", "web/dist"),
|
static_dir=_env("STATIC_DIR", "web/dist"),
|
||||||
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
|
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
|
||||||
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
|
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
|
||||||
|
logging_config=os.environ.get("LOGGING_CONFIG"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Pure rules engine for scopone scientifico.
|
"""Pure rules engine for scopone scientifico.
|
||||||
|
|
||||||
Every function here is deterministic and I/O-free: it mutates (or reads)
|
Every function here is deterministic and I/O-free (the only side effect is
|
||||||
|
debug logging): it mutates (or reads)
|
||||||
:class:`~tavolo.game.state.GameState` and raises
|
:class:`~tavolo.game.state.GameState` and raises
|
||||||
:class:`~tavolo.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.
|
||||||
@@ -29,6 +30,7 @@ from __future__ import annotations
|
|||||||
import random
|
import random
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, 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 (
|
||||||
@@ -55,6 +57,8 @@ 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
|
||||||
@@ -130,6 +134,7 @@ def create_game(
|
|||||||
target_score: int = DEFAULT_TARGET_SCORE,
|
target_score: int = DEFAULT_TARGET_SCORE,
|
||||||
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||||
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
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:
|
||||||
@@ -138,6 +143,7 @@ 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)],
|
||||||
@@ -191,6 +197,7 @@ def _deal_hand(state: GameState) -> None:
|
|||||||
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:
|
||||||
@@ -334,11 +341,21 @@ 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
|
return
|
||||||
|
|
||||||
# Pause for the scoring summary instead of dealing immediately.
|
# 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] = {
|
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,
|
||||||
|
|||||||
@@ -148,6 +148,9 @@ 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)
|
||||||
@@ -184,6 +187,7 @@ 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],
|
||||||
@@ -212,6 +216,7 @@ 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", [])],
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -15,9 +15,12 @@ from tortoise.models import Model
|
|||||||
|
|
||||||
|
|
||||||
class Match(Model):
|
class Match(Model):
|
||||||
"""A completed scopone scientifico match."""
|
"""A completed match of one of the registered game types."""
|
||||||
|
|
||||||
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".
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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
|
||||||
@@ -22,8 +23,11 @@ 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
|
||||||
@@ -46,6 +50,7 @@ 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": [
|
||||||
@@ -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")
|
@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. "
|
||||||
@@ -68,6 +88,11 @@ def _lobby_payload(state: GameState) -> Dict[str, Any]:
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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},
|
"target_score": {"type": "integer", "minimum": 1, "maximum": 100},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -76,7 +101,7 @@ def _lobby_payload(state: GameState) -> Dict[str, Any]:
|
|||||||
},
|
},
|
||||||
responses={
|
responses={
|
||||||
201: {"description": "The created lobby"},
|
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"},
|
401: {"description": "Authentication required"},
|
||||||
})
|
})
|
||||||
@require_auth
|
@require_auth
|
||||||
@@ -93,6 +118,11 @@ 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())
|
||||||
@@ -106,11 +136,19 @@ async def create_game(ctx: HttpContext) -> None:
|
|||||||
target_score=target_score,
|
target_score=target_score,
|
||||||
hand_ack_timeout=settings.hand_ack_timeout_seconds,
|
hand_ack_timeout=settings.hand_ack_timeout_seconds,
|
||||||
turn_timeout=settings.turn_timeout_seconds,
|
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))
|
||||||
|
|
||||||
|
|
||||||
@@ -154,6 +192,7 @@ 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
|
||||||
|
|
||||||
@@ -165,14 +204,19 @@ 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:
|
||||||
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:
|
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}")
|
||||||
|
|||||||
@@ -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 assets (wasm, js, css, card images) are served by Granian itself
|
||||||
``STATIC_DIR`` (the ``web/dist`` output of ``trunk build --release``; see
|
under the ``/static`` prefix (``GRANIAN_STATIC_PATH_*`` env vars; see the
|
||||||
the Docker image). A glob catch-all (``/*``) handles every path that did
|
Dockerfile) and never reach Python. This module only serves ``index.html``
|
||||||
not match an API or auth route: real files are served with their content
|
from ``STATIC_DIR``: at the site root and — via the glob catch-all — for
|
||||||
type, anything else falls back to ``index.html`` so client-side routes
|
every path no API or auth route claimed, so client-side routes
|
||||||
(``/game/<id>`` etc.) work on direct loads and refreshes.
|
(``/game/<id>`` etc.) work on direct loads and refreshes.
|
||||||
|
|
||||||
kaya-openapi deliberately skips glob routes, so this handler never appears
|
kaya-openapi deliberately skips glob routes, so this handler never appears
|
||||||
@@ -14,66 +14,33 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Mapping
|
|
||||||
|
|
||||||
from kaya.core import HttpContext
|
from kaya.core import HttpContext
|
||||||
|
|
||||||
from ..app import app
|
from ..app import app
|
||||||
from ..config import settings
|
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",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
async def _send_shell(ctx: HttpContext) -> None:
|
||||||
def _static_root() -> Path:
|
"""Serve the SPA shell, or 404 when the frontend build is missing."""
|
||||||
return Path(settings.static_dir).resolve()
|
index = Path(settings.static_dir) / "index.html"
|
||||||
|
if not index.is_file():
|
||||||
|
|
||||||
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)
|
await ctx.send_empty(404)
|
||||||
return
|
return
|
||||||
content_type = _CONTENT_TYPES.get(target.suffix.lower(), "application/octet-stream")
|
body = await asyncio.to_thread(index.read_bytes)
|
||||||
body = await asyncio.to_thread(target.read_bytes)
|
await ctx.send_bytes(200, body, {"content-type": ("text/html; charset=utf-8",)})
|
||||||
await ctx.send_bytes(200, body, {"content-type": (content_type,)})
|
|
||||||
|
|
||||||
|
|
||||||
@app.GET("/")
|
@app.GET("/")
|
||||||
async def index(ctx: HttpContext) -> None:
|
async def index(ctx: HttpContext) -> None:
|
||||||
"""Serve the SPA shell at the site root (the glob below cannot match
|
"""Serve the SPA shell at the site root (the glob below cannot match
|
||||||
an empty path)."""
|
an empty path)."""
|
||||||
await _send_path(ctx, _static_root() / "index.html")
|
await _send_shell(ctx)
|
||||||
|
|
||||||
|
|
||||||
@app.GET("/*", recursive=True)
|
@app.GET("/*", recursive=True)
|
||||||
async def spa(ctx: HttpContext, _matched: object = None) -> None:
|
async def spa(ctx: HttpContext, _matched: object = None) -> None:
|
||||||
root = _static_root()
|
"""SPA fallback: any path that matched no other route renders the app
|
||||||
relative = ctx.path.lstrip("/")
|
shell. Requests under ``/static`` are answered by Granian before the
|
||||||
target = (root / relative).resolve() if relative else root
|
app is ever called, so they never arrive here."""
|
||||||
# Path-traversal guard: the resolved target must stay inside the dist
|
await _send_shell(ctx)
|
||||||
# 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)
|
|
||||||
|
|||||||
@@ -6,23 +6,47 @@ leaderboard aggregated from the same two tables.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
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.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 ..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 ..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,
|
||||||
@@ -49,10 +73,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,
|
parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER],
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "A page of matches"},
|
200: {"description": "A page of matches"},
|
||||||
400: {"description": "Invalid pagination cursor"},
|
400: {"description": "Invalid pagination cursor or game_type"},
|
||||||
401: {"description": "Authentication required"},
|
401: {"description": "Authentication required"},
|
||||||
})
|
})
|
||||||
@require_auth
|
@require_auth
|
||||||
@@ -62,9 +86,15 @@ 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
|
||||||
)
|
)
|
||||||
@@ -77,9 +107,20 @@ 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"],
|
||||||
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:
|
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]] = {}
|
aggregate: Dict[str, Dict[str, Any]] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
entry = aggregate.setdefault(
|
entry = aggregate.setdefault(
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ 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:
|
||||||
@@ -36,6 +39,7 @@ 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],
|
||||||
@@ -55,3 +59,12 @@ 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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -25,12 +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 = "tavolo:game:"
|
GAME_KEY_PREFIX = "tavolo:game:"
|
||||||
CODE_KEY_PREFIX = "tavolo:code:"
|
CODE_KEY_PREFIX = "tavolo:code:"
|
||||||
CHANNEL_PREFIX = "tavolo:game:"
|
CHANNEL_PREFIX = "tavolo:game:"
|
||||||
@@ -86,9 +89,11 @@ 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:
|
||||||
@@ -98,6 +103,7 @@ 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()}")
|
||||||
@@ -120,6 +126,7 @@ 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,6 +35,7 @@ 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
|
||||||
@@ -42,6 +43,19 @@ 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`."""
|
||||||
|
|
||||||
@@ -64,6 +78,7 @@ 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
|
||||||
@@ -75,11 +90,13 @@ 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:
|
||||||
@@ -88,6 +105,7 @@ 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
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from logging import getLogger
|
||||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||||
|
|
||||||
from kaya.core import WebSocket
|
from kaya.core import WebSocket
|
||||||
@@ -53,6 +54,8 @@ from .game.errors import GameError
|
|||||||
from .game.state import PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING, GameState
|
from .game.state import PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING, GameState
|
||||||
from .stats import save_match_result
|
from .stats import save_match_result
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
Send = Callable[[Dict[str, Any]], Awaitable[None]]
|
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:
|
async def game_socket(ws: WebSocket, game_id: str) -> None:
|
||||||
user = auth.get_ws_user(ws)
|
user = auth.get_ws_user(ws)
|
||||||
if user is None:
|
if user is None:
|
||||||
|
log.debug("websocket %s rejected: no authenticated user", game_id)
|
||||||
await ws.close(4401)
|
await ws.close(4401)
|
||||||
return
|
return
|
||||||
|
|
||||||
state = await game_store.load(game_id)
|
state = await game_store.load(game_id)
|
||||||
if state is None:
|
if state is None:
|
||||||
|
log.debug("websocket rejected: unknown game %s", game_id)
|
||||||
await ws.close(4404)
|
await ws.close(4404)
|
||||||
return
|
return
|
||||||
if not state.seated(user.sub):
|
if not state.seated(user.sub):
|
||||||
|
log.debug("websocket %s rejected: %s is not seated", game_id, user.sub)
|
||||||
await ws.close(4403)
|
await ws.close(4403)
|
||||||
return
|
return
|
||||||
|
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
|
log.info("%s connected to game %s", user.sub, game_id)
|
||||||
|
|
||||||
send_lock = asyncio.Lock()
|
send_lock = asyncio.Lock()
|
||||||
|
|
||||||
@@ -106,6 +113,7 @@ async def game_socket(ws: WebSocket, game_id: str) -> None:
|
|||||||
forward.cancel()
|
forward.cancel()
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
await forward
|
await forward
|
||||||
|
log.debug("%s disconnected from game %s", user.sub, game_id)
|
||||||
|
|
||||||
|
|
||||||
async def _forward(
|
async def _forward(
|
||||||
@@ -135,9 +143,11 @@ async def _handle_message(send: Send, game_id: str, sub: str, raw: str) -> None:
|
|||||||
try:
|
try:
|
||||||
data = json.loads(raw)
|
data = json.loads(raw)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
|
log.debug("game %s: malformed message from %s (not JSON)", game_id, sub)
|
||||||
await send(_error("invalid JSON"))
|
await send(_error("invalid JSON"))
|
||||||
return
|
return
|
||||||
if not isinstance(data, dict):
|
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"))
|
await send(_error("message must be a JSON object"))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -172,6 +182,7 @@ async def _handle_ack(send: Send, game_id: str, sub: str) -> None:
|
|||||||
except GameError as exc:
|
except GameError as exc:
|
||||||
await send(_error(str(exc), code="illegal_move"))
|
await send(_error(str(exc), code="illegal_move"))
|
||||||
return
|
return
|
||||||
|
log.debug("game %s: %s acknowledged hand %d", game_id, sub, state.hand_number)
|
||||||
await game_store.save(state)
|
await game_store.save(state)
|
||||||
await game_store.publish(game_id)
|
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)
|
engine.acknowledge_hand(state, player.sub)
|
||||||
await game_store.save(state)
|
await game_store.save(state)
|
||||||
await game_store.publish(game_id)
|
await game_store.publish(game_id)
|
||||||
|
log.info(
|
||||||
|
"game %s: hand %d auto-advanced after the acknowledgement timeout",
|
||||||
|
game_id,
|
||||||
|
hand_number,
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
_hand_end_timers.pop(key, None)
|
_hand_end_timers.pop(key, None)
|
||||||
|
|
||||||
@@ -252,6 +268,12 @@ def schedule_turn_timer(game_id: str, state: GameState) -> None:
|
|||||||
engine.auto_play(state)
|
engine.auto_play(state)
|
||||||
except GameError:
|
except GameError:
|
||||||
return
|
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)
|
await _after_play(state, game_id)
|
||||||
finally:
|
finally:
|
||||||
_turn_timers.pop(key, None)
|
_turn_timers.pop(key, None)
|
||||||
@@ -268,6 +290,13 @@ async def _after_play(state: GameState, game_id: str) -> None:
|
|||||||
"""
|
"""
|
||||||
if state.phase == PHASE_FINISHED:
|
if state.phase == PHASE_FINISHED:
|
||||||
await save_match_result(state)
|
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:
|
elif state.phase == PHASE_HAND_END:
|
||||||
schedule_hand_end_timer(game_id, state.hand_number, state.hand_ack_timeout)
|
schedule_hand_end_timer(game_id, state.hand_number, state.hand_ack_timeout)
|
||||||
await game_store.save(state)
|
await game_store.save(state)
|
||||||
@@ -297,10 +326,13 @@ async def _handle_play(
|
|||||||
try:
|
try:
|
||||||
engine.play(state, sub, card, capture)
|
engine.play(state, sub, card, capture)
|
||||||
except GameError as exc:
|
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"))
|
await send(_error(str(exc), code="illegal_move"))
|
||||||
return
|
return
|
||||||
except ValueError:
|
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"))
|
await send(_error("invalid card code", code="illegal_move"))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
log.debug("game %s: %s played %s (capture: %s)", game_id, sub, card, capture or "-")
|
||||||
await _after_play(state, game_id)
|
await _after_play(state, game_id)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -119,5 +119,53 @@ 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()
|
||||||
|
|||||||
@@ -34,15 +34,13 @@ 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_files_and_spa_fallback(self) -> None:
|
async def test_serves_shell_and_spa_fallback(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as dist:
|
with tempfile.TemporaryDirectory() as dist:
|
||||||
root = Path(dist)
|
(Path(dist) / "index.html").write_text("<html>spa</html>")
|
||||||
(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("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:
|
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")
|
||||||
|
|||||||
@@ -66,11 +66,13 @@ 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() -> None:
|
async def _seed_two_matches(game_types: tuple = ("scopone_scientifico", "scopone_scientifico")) -> 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(
|
||||||
@@ -81,6 +83,7 @@ async def _seed_two_matches() -> None:
|
|||||||
):
|
):
|
||||||
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,
|
||||||
@@ -165,5 +168,45 @@ 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()
|
||||||
|
|||||||
@@ -25,6 +25,24 @@ 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()
|
||||||
|
|||||||
+15
-2
@@ -22,9 +22,22 @@ 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())
|
||||||
}
|
}
|
||||||
|
|
||||||
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")
|
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())?
|
.map_err(|e| e.to_string())?
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ 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,
|
||||||
@@ -174,6 +177,8 @@ 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,
|
||||||
@@ -211,6 +216,22 @@ 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")
|
||||||
|
|||||||
+33
-3
@@ -4,7 +4,16 @@ use sycamore::prelude::*;
|
|||||||
use sycamore_router::navigate;
|
use sycamore_router::navigate;
|
||||||
|
|
||||||
use crate::api;
|
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]
|
#[component]
|
||||||
pub fn LobbyPage() -> View {
|
pub fn LobbyPage() -> View {
|
||||||
@@ -12,6 +21,8 @@ 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 {
|
||||||
@@ -23,9 +34,20 @@ pub fn LobbyPage() -> View {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let on_create = move |target: i32| {
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::create_game(target).await {
|
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(&game_type, 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)),
|
||||||
}
|
}
|
||||||
@@ -70,6 +92,14 @@ 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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user