Rewrite the backend in Rust with Axum
CI / Build and push docker image (push) Successful in 4m20s

Replace the Python/kaya server in server/ with a Rust Cargo workspace of
three crates mirroring the old layout: tavolo-platform (lobby API, live-game
store, websocket endpoint, deadline scheduler, stats/Elo, OIDC sessions),
tavolo-scopone (pure scopone scientifico rules plus the platform adapter)
and tavolo-app (composition root and the `tavolo` binary with
serve/migrate/backfill-elo).

The HTTP and WebSocket contracts, error messages, Redis key layout and
Postgres schema are unchanged, so the existing web/ frontend and in-flight
games keep working. Notable differences: sessions are JSON under
tavolo:session: (Python pickle sessions invalidate once), LOGGING_CONFIG is
ignored in favour of RUST_LOG, DATABASE_URL must be Postgres, sqlx migrations
replace aerich, and OpenTelemetry exports Rust process metrics instead of
CPython gc metrics (no Postgres/Redis client instrumentation).

Docker Compose and the Kubernetes manifest now run the single Rust image
(embedded migrations, /static and /assets served by the app); the release CI
keeps building server/Dockerfile. The full test suite (scopone rules,
platform store/deadlines/stats/websockets, OIDC login flow, scopone e2e) is
ported and runs with cargo test --workspace; Redis/Postgres integration tests
are gated on TEST_REDIS_URL/TEST_DATABASE_URL.
This commit is contained in:
2026-10-02 13:54:55 +00:00
parent adc3d340b1
commit 7b3b38ccbc
136 changed files with 16821 additions and 11777 deletions
+63
View File
@@ -0,0 +1,63 @@
[package]
name = "tavolo-app"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "The deployable tavolo application: composition root and HTTP/WebSocket server"
[[bin]]
name = "tavolo"
path = "src/main.rs"
[dependencies]
async-trait.workspace = true
axum.workspace = true
base64.workspace = true
chrono.workspace = true
clap.workspace = true
futures-util.workspace = true
hex.workspace = true
jsonwebtoken.workspace = true
opentelemetry = { version = "0.33", optional = true, default-features = false, features = ["trace", "metrics", "logs"] }
opentelemetry-appender-tracing = { version = "0.33", optional = true, default-features = false }
opentelemetry-otlp = { version = "0.33", optional = true, default-features = false, features = ["http-proto", "reqwest-client", "trace", "metrics", "logs"] }
opentelemetry_sdk = { version = "0.33", optional = true, default-features = false, features = ["trace", "metrics", "logs", "rt-tokio"] }
percent-encoding.workspace = true
rand.workspace = true
redis.workspace = true
regex.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
sqlx.workspace = true
sysinfo = { version = "0.39", optional = true }
tavolo-platform = { path = "../tavolo-platform" }
tavolo-scopone = { path = "../tavolo-scopone" }
thiserror.workspace = true
tokio.workspace = true
tower.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-opentelemetry = { version = "0.34", optional = true, default-features = false }
tracing-subscriber.workspace = true
url.workspace = true
uuid.workspace = true
[dev-dependencies]
axum = { workspace = true, features = ["ws", "macros"] }
futures-util.workspace = true
http-body-util = "0.1"
tokio-tungstenite = "0.24"
tower = { workspace = true, features = ["util"] }
[features]
default = ["otel"]
otel = [
"dep:opentelemetry",
"dep:opentelemetry_sdk",
"dep:opentelemetry-otlp",
"dep:opentelemetry-appender-tracing",
"dep:tracing-opentelemetry",
"dep:sysinfo",
]
@@ -0,0 +1,41 @@
-- Final tavolo schema, expressed idempotently so the migration can be applied
-- to a database created by the Python aerich migrations as well as to a fresh
-- one. The `aerich` bookkeeping table (when present) is left untouched.
CREATE TABLE IF NOT EXISTS "match" (
"id" UUID NOT NULL PRIMARY KEY,
"game_type" VARCHAR(32) NOT NULL,
"started_at" TIMESTAMPTZ NOT NULL,
"finished_at" TIMESTAMPTZ NOT NULL,
"result" JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS "idx_match_game_type" ON "match" ("game_type");
CREATE TABLE IF NOT EXISTS "match_player" (
"id" UUID NOT NULL PRIMARY KEY,
"user_sub" VARCHAR(255) NOT NULL,
"display_name" VARCHAR(200) NOT NULL,
"seat" SMALLINT NOT NULL,
"team" VARCHAR(32),
"won" BOOL NOT NULL,
"match_id" UUID NOT NULL REFERENCES "match" ("id") ON DELETE CASCADE,
"score" DOUBLE PRECISION NOT NULL DEFAULT 0,
"elo_delta" SMALLINT,
"details" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "uid_match_player_match_user" UNIQUE ("match_id", "user_sub")
);
CREATE INDEX IF NOT EXISTS "idx_match_player_user_sub" ON "match_player" ("user_sub");
CREATE TABLE IF NOT EXISTS "player_rating" (
"id" UUID NOT NULL PRIMARY KEY,
"user_sub" VARCHAR(255) NOT NULL,
"game_type" VARCHAR(32) NOT NULL,
"rating" INT NOT NULL,
"matches_played" INT NOT NULL,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "uid_player_rating_user_game" UNIQUE ("user_sub", "game_type")
);
CREATE INDEX IF NOT EXISTS "idx_player_rating_game_rating" ON "player_rating" ("game_type", "rating");
+191
View File
@@ -0,0 +1,191 @@
//! Application composition root: registry, stores, platform, router.
//!
//! Mirrors `tavolo.app` from the Python implementation:
//!
//! * the [`ScoponeEngine`] is registered with the configured timeouts;
//! * sessions and live games use Redis when `REDIS_URL` is set, in-memory
//! stores otherwise;
//! * finished matches are persisted to Postgres through
//! [`PostgresStatsRepo`];
//! * the platform routes are merged with the SPA shell hosting and wrapped in
//! the session middleware and the optional CORS layer.
use std::sync::Arc;
use std::time::Duration;
use axum::Router;
use tavolo_platform::deadlines::DeadlineScheduler;
use tavolo_platform::oidc::OidcProviders;
use tavolo_platform::platform::{Platform, SharedPlatform};
use tavolo_platform::registry::GameRegistry;
use tavolo_platform::routes;
use tavolo_platform::session::{
session_middleware, InMemorySessionStore, RedisSessionStore, SharedSessionStore,
};
use tavolo_platform::stats::{InMemoryStatsRepo, PostgresStatsRepo, SharedStatsRepo};
use tavolo_platform::store::{InMemoryGameStore, RedisGameStore, SharedGameStore};
use tavolo_scopone::ScoponeEngine;
use tower_http::services::ServeDir;
use crate::config::Settings;
use crate::cors::CorsConfig;
use crate::static_files::{mount_dirs, SpaFallback};
pub struct App {
pub router: Router,
pub platform: SharedPlatform,
pub session_store: SharedSessionStore,
}
/// Build the game registry (the first registered game is the default).
pub fn build_registry(settings: &Settings) -> Result<Arc<GameRegistry>, String> {
let mut registry = GameRegistry::new();
registry
.register(Arc::new(ScoponeEngine::new(
settings.turn_timeout_seconds,
settings.hand_ack_timeout_seconds,
)))
.map_err(|err| err.to_string())?;
Ok(Arc::new(registry))
}
/// Build the session and live-game stores from `REDIS_URL`.
pub async fn build_stores(
settings: &Settings,
registry: Arc<GameRegistry>,
) -> Result<(SharedSessionStore, SharedGameStore), String> {
if let Some(url) = &settings.redis_url {
let session_store: SharedSessionStore = Arc::new(
RedisSessionStore::new(url)
.await
.map_err(|err| format!("could not connect to Redis: {err}"))?,
);
let game_store: SharedGameStore = Arc::new(
RedisGameStore::new(url, registry, settings.game_ttl_seconds)
.await
.map_err(|err| format!("could not connect to Redis: {err}"))?,
);
Ok((session_store, game_store))
} else {
let session_store: SharedSessionStore = Arc::new(InMemorySessionStore::new());
let game_store: SharedGameStore = Arc::new(InMemoryGameStore::new(registry));
Ok((session_store, game_store))
}
}
/// Build the [`Platform`] bundle and start the deadline scheduler.
pub fn build_platform(
settings: &Settings,
registry: Arc<GameRegistry>,
game_store: SharedGameStore,
stats: SharedStatsRepo,
) -> Result<SharedPlatform, String> {
let oidc = Arc::new(
OidcProviders::new(settings.oidc_providers.clone()).map_err(|err| err.to_string())?,
);
let scheduler = DeadlineScheduler::new(
game_store.clone(),
registry.clone(),
stats.clone(),
settings.deadline_heartbeat_ms,
1000,
);
scheduler.start();
Ok(Arc::new(Platform {
registry,
game_store,
scheduler,
oidc,
stats,
}))
}
/// Build the axum router: platform routes, static mounts, SPA fallback,
/// session middleware and optional CORS.
pub fn build_router(
platform: SharedPlatform,
session_store: SharedSessionStore,
settings: &Settings,
) -> Router {
let (root, assets) = mount_dirs(&settings.static_dir);
let provider_names: Vec<String> = settings
.oidc_providers
.iter()
.map(|provider| provider.provider.clone())
.collect();
let mut router = Router::new()
.merge(routes::router(&provider_names).with_state(platform))
.nest_service("/static", ServeDir::new(root))
.nest_service("/assets", ServeDir::new(assets))
.fallback_service(SpaFallback::new(&settings.static_dir));
// Added before the session layer, so request spans see the loaded
// session while the session middleware stays the outer layer.
let excluded = Arc::new(settings.otel_excluded_paths.clone());
router = router.layer(axum::middleware::from_fn(move |request, next| {
let excluded = Arc::clone(&excluded);
crate::telemetry::trace_middleware(excluded, request, next)
}));
router = router.layer(axum::middleware::from_fn_with_state(
session_store,
session_middleware,
));
if let Some(config) = CorsConfig::new(
settings.cors_allow_origins.as_deref().unwrap_or(&[]),
settings.cors_allow_methods.as_deref(),
settings.cors_allow_headers.as_deref(),
settings.cors_allow_credentials,
settings.cors_allow_origin_regex.as_deref(),
settings.cors_expose_headers.as_deref().unwrap_or(&[]),
settings.cors_max_age,
) {
router = router.layer(axum::middleware::from_fn_with_state(
Arc::new(config),
crate::cors::cors_middleware,
));
}
router
}
/// Build everything in-memory: useful for tests and ephemeral dev without
/// Postgres or Redis.
pub fn in_memory_platform(
settings: &Settings,
) -> Result<(SharedPlatform, SharedSessionStore), String> {
let registry = build_registry(settings)?;
let session_store: SharedSessionStore = Arc::new(InMemorySessionStore::new());
let game_store: SharedGameStore = Arc::new(InMemoryGameStore::new(registry.clone()));
let stats: SharedStatsRepo = Arc::new(InMemoryStatsRepo::new());
let platform = build_platform(settings, registry, game_store, stats)?;
Ok((platform, session_store))
}
/// Connect to Postgres.
pub async fn connect_database(settings: &Settings) -> Result<sqlx::PgPool, String> {
if settings.database_url.starts_with("sqlite") {
return Err(
"the Rust server supports Postgres only; sqlite DATABASE_URLs are not supported"
.to_string(),
);
}
sqlx::postgres::PgPoolOptions::new()
.max_connections(10)
.acquire_timeout(Duration::from_secs(10))
.connect(&settings.database_url)
.await
.map_err(|err| format!("could not connect to the database: {err}"))
}
/// Build the production application (requires Postgres; Redis optional).
pub async fn build(settings: &Settings) -> Result<App, String> {
let registry = build_registry(settings)?;
let (session_store, game_store) = build_stores(settings, registry.clone()).await?;
let pool = connect_database(settings).await?;
let stats: SharedStatsRepo = Arc::new(PostgresStatsRepo::new(pool));
let platform = build_platform(settings, registry, game_store, stats)?;
let router = build_router(platform.clone(), session_store.clone(), settings);
Ok(App {
router,
platform,
session_store,
})
}
+308
View File
@@ -0,0 +1,308 @@
//! Environment-driven configuration for the tavolo application.
//!
//! Mirrors `tavolo.config` from the Python implementation: read environment
//! variables into a plain struct, with the same defaults and validation.
use std::collections::BTreeSet;
use regex::Regex;
use tavolo_platform::oidc::OidcProviderConfig;
const OIDC_VARIABLES: [&str; 7] = [
"PROVIDER",
"ISSUER",
"CLIENT_ID",
"CLIENT_SECRET",
"REDIRECT_URI",
"POST_LOGIN_REDIRECT",
"POST_LOGOUT_REDIRECT",
];
/// The pre-0.0.6 unindexed names, detected only to point the migration out.
const LEGACY_OIDC_VARIABLES: [&str; 6] = [
"OIDC_ISSUER",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_REDIRECT_URI",
"OIDC_POST_LOGIN_REDIRECT",
"OIDC_POST_LOGOUT_REDIRECT",
];
const DEFAULT_OIDC_ISSUER: &str = "http://localhost:8180/tavolo";
const DEFAULT_OIDC_CLIENT_ID: &str = "tavolo";
fn env(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|value| !value.is_empty())
}
fn env_or(name: &str, default: &str) -> String {
env(name).unwrap_or_else(|| default.to_string())
}
fn env_list(name: &str) -> Option<Vec<String>> {
let value = std::env::var(name).ok()?;
if value.trim().is_empty() {
return None;
}
Some(
value
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(str::to_string)
.collect(),
)
}
fn env_bool(name: &str, default: bool) -> bool {
match std::env::var(name) {
Ok(value) if !value.is_empty() => {
matches!(
value.trim().to_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
_ => default,
}
}
fn parse_oidc_providers() -> Result<Vec<OidcProviderConfig>, String> {
let legacy: Vec<&str> = LEGACY_OIDC_VARIABLES
.iter()
.copied()
.filter(|name| env(name).is_some())
.collect();
if !legacy.is_empty() {
let renamed: Vec<String> = legacy
.iter()
.map(|name| format!("{name} -> OIDC_0_{}", &name["OIDC_".len()..]))
.collect();
return Err(format!(
"the OIDC variables are now indexed per provider ({}; add OIDC_0_PROVIDER)",
renamed.join(", ")
));
}
let pattern = Regex::new(r"^OIDC_(\d+)_([A-Z0-9_]+)$").expect("valid regex");
let mut indices: BTreeSet<usize> = BTreeSet::new();
for (name, _) in std::env::vars() {
let Some(captures) = pattern.captures(&name) else {
continue;
};
let variable = captures.get(2).expect("group 2").as_str();
if !OIDC_VARIABLES.contains(&variable) {
return Err(format!(
"unknown OIDC environment variable {name:?}; expected one of {}",
OIDC_VARIABLES
.iter()
.map(|variable| format!("OIDC_<n>_{variable}"))
.collect::<Vec<_>>()
.join(", ")
));
}
indices.insert(
captures
.get(1)
.expect("group 1")
.as_str()
.parse()
.map_err(|_| format!("invalid OIDC provider index in {name:?}"))?,
);
}
if indices.is_empty() {
return Ok(vec![OidcProviderConfig {
provider: "default".into(),
issuer: DEFAULT_OIDC_ISSUER.into(),
client_id: DEFAULT_OIDC_CLIENT_ID.into(),
client_secret: None,
redirect_uri: "http://localhost:8080/auth/default/callback".into(),
post_login_redirect: "/".into(),
post_logout_redirect: "/".into(),
}]);
}
let max = *indices.iter().next_back().expect("non-empty");
let missing: Vec<usize> = (0..=max).filter(|index| !indices.contains(index)).collect();
if !missing.is_empty() {
return Err(format!(
"OIDC provider indices must be contiguous from 0; missing {}",
missing
.iter()
.map(|index| format!("OIDC_{index}_*"))
.collect::<Vec<_>>()
.join(", ")
));
}
let provider_name = Regex::new(r"^[A-Za-z0-9_-]+$").expect("valid regex");
let mut seen = std::collections::HashSet::new();
let mut providers = Vec::new();
for index in indices {
let prefix = format!("OIDC_{index}_");
let required = |variable: &str| -> Result<String, String> {
env(&format!("{prefix}{variable}"))
.ok_or_else(|| format!("Missing required environment variable: {prefix}{variable}"))
};
let provider = required("PROVIDER")?;
if !provider_name.is_match(&provider) {
return Err(format!(
"{prefix}PROVIDER must be a non-empty string of letters, digits, underscores or hyphens (got {provider:?})"
));
}
if !seen.insert(provider.clone()) {
return Err(format!("duplicate OIDC provider name {provider:?}"));
}
providers.push(OidcProviderConfig {
issuer: required("ISSUER")?,
client_id: required("CLIENT_ID")?,
client_secret: env(&format!("{prefix}CLIENT_SECRET")),
redirect_uri: env_or(
&format!("{prefix}REDIRECT_URI"),
&format!("http://localhost:8080/auth/{provider}/callback"),
),
post_login_redirect: env_or(&format!("{prefix}POST_LOGIN_REDIRECT"), "/"),
post_logout_redirect: env_or(&format!("{prefix}POST_LOGOUT_REDIRECT"), "/"),
provider,
});
}
Ok(providers)
}
/// Percent-encode a DSN component like Python's `quote(value, safe="")`,
/// which leaves the RFC 3986 unreserved characters alone.
fn quote(value: &str) -> String {
const QUOTE_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
.remove(b'_')
.remove(b'-')
.remove(b'.')
.remove(b'~');
percent_encoding::utf8_percent_encode(value, QUOTE_SET).to_string()
}
fn database_url_from_parts(
engine: &str,
user: &str,
password: Option<&str>,
host: &str,
port: &str,
name: &str,
options: &str,
) -> String {
let mut netloc = quote(user);
if let Some(password) = password {
if !password.is_empty() {
netloc.push(':');
netloc.push_str(&quote(password));
}
}
netloc.push('@');
netloc.push_str(host);
if !port.is_empty() {
netloc.push(':');
netloc.push_str(port);
}
let mut url = format!("{engine}://{netloc}/{name}");
let options = options.trim_start_matches('?');
if !options.is_empty() {
url.push('?');
url.push_str(options);
}
url
}
#[derive(Debug, Clone)]
pub struct Settings {
pub database_url: String,
pub oidc_providers: Vec<OidcProviderConfig>,
pub app_host: String,
pub app_port: u16,
pub redis_url: Option<String>,
pub game_ttl_seconds: i64,
pub static_dir: String,
pub hand_ack_timeout_seconds: i64,
pub turn_timeout_seconds: i64,
pub deadline_heartbeat_ms: u64,
/// Accepted for compatibility; Rust logging is configured via RUST_LOG.
pub logging_config: Option<String>,
pub cors_allow_origins: Option<Vec<String>>,
pub cors_allow_origin_regex: Option<String>,
pub cors_allow_methods: Option<Vec<String>>,
pub cors_allow_headers: Option<Vec<String>>,
pub cors_allow_credentials: bool,
pub cors_expose_headers: Option<Vec<String>>,
pub cors_max_age: i64,
pub otel_enabled: bool,
pub otel_service_name: String,
pub otel_exporter_endpoint: Option<String>,
pub otel_exporter_headers: Option<Vec<String>>,
pub otel_excluded_paths: Vec<String>,
pub otel_metric_export_interval_millis: u64,
pub otel_metrics_poll_interval_seconds: u64,
}
impl Settings {
pub fn from_env() -> Result<Self, String> {
let database_url = env("DATABASE_URL").unwrap_or_else(|| {
database_url_from_parts(
&env_or("DATABASE_ENGINE", "postgres"),
&env_or("DATABASE_USER", "tavolo"),
Some(&env_or("DATABASE_PASSWORD", "password")),
&env_or("DATABASE_HOST", "localhost"),
&std::env::var("DATABASE_PORT").unwrap_or_default(),
&env_or("DATABASE_NAME", "tavolo"),
&std::env::var("DATABASE_OPTIONS").unwrap_or_default(),
)
});
let app_port = env_or("APP_PORT", "8080")
.parse::<u16>()
.map_err(|_| "APP_PORT must be a valid port number".to_string())?;
let otel_headers = env_list("OTEL_EXPORTER_OTLP_HEADERS");
Ok(Self {
database_url,
oidc_providers: parse_oidc_providers()?,
app_host: env_or("APP_HOST", "0.0.0.0"),
app_port,
redis_url: env("REDIS_URL"),
game_ttl_seconds: env_or("GAME_TTL_SECONDS", "86400")
.parse()
.map_err(|_| "GAME_TTL_SECONDS must be an integer".to_string())?,
static_dir: env_or("STATIC_DIR", "web/dist"),
hand_ack_timeout_seconds: env_or("HAND_ACK_TIMEOUT_SECONDS", "30")
.parse()
.map_err(|_| "HAND_ACK_TIMEOUT_SECONDS must be an integer".to_string())?,
turn_timeout_seconds: env_or("TURN_TIMEOUT_SECONDS", "30")
.parse()
.map_err(|_| "TURN_TIMEOUT_SECONDS must be an integer".to_string())?,
deadline_heartbeat_ms: env_or("DEADLINE_HEARTBEAT_MS", "30000")
.parse()
.map_err(|_| "DEADLINE_HEARTBEAT_MS must be an integer".to_string())?,
logging_config: env("LOGGING_CONFIG"),
cors_allow_origins: env_list("CORS_ALLOW_ORIGINS"),
cors_allow_origin_regex: env("CORS_ALLOW_ORIGIN_REGEX"),
cors_allow_methods: env_list("CORS_ALLOW_METHODS"),
cors_allow_headers: env_list("CORS_ALLOW_HEADERS"),
cors_allow_credentials: env_bool("CORS_ALLOW_CREDENTIALS", false),
cors_expose_headers: env_list("CORS_EXPOSE_HEADERS"),
cors_max_age: env_or("CORS_MAX_AGE", "600")
.parse()
.map_err(|_| "CORS_MAX_AGE must be an integer".to_string())?,
otel_enabled: env_bool("OTEL_ENABLED", false),
otel_service_name: env_or("OTEL_SERVICE_NAME", "tavolo"),
otel_exporter_endpoint: env("OTEL_EXPORTER_OTLP_ENDPOINT"),
otel_exporter_headers: otel_headers,
otel_excluded_paths: env_list("OTEL_EXCLUDED_PATHS")
.unwrap_or_else(|| vec!["/api/health".to_string()]),
otel_metric_export_interval_millis: env_or(
"OTEL_METRIC_EXPORT_INTERVAL_MILLIS",
"60000",
)
.parse()
.map_err(|_| "OTEL_METRIC_EXPORT_INTERVAL_MILLIS must be an integer".to_string())?,
otel_metrics_poll_interval_seconds: env_or("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15")
.parse()
.map_err(|_| "OTEL_METRICS_POLL_INTERVAL_SECONDS must be an integer".to_string())?,
})
}
}
+354
View File
@@ -0,0 +1,354 @@
//! CORS middleware replicating kaya-cors' `CorsMixin` (modeled after
//! Starlette's `CORSMiddleware`).
//!
//! * requests without an `Origin` header pass through untouched;
//! * preflight requests (`OPTIONS` carrying `Origin` and
//! `Access-Control-Request-Method`) are answered directly: `200 OK` when
//! the origin, method and headers are allowed, `400` with a
//! `Disallowed CORS ...` body otherwise;
//! * simple cross-origin requests get `Access-Control-Allow-Origin` (and the
//! configured credentials/expose headers) merged into the response without
//! overwriting headers the handler already set.
use std::collections::HashSet;
use std::sync::Arc;
use axum::extract::{Request, State};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
use axum::middleware::Next;
use axum::response::Response;
use regex::Regex;
const ALL_METHODS: [&str; 8] = [
"DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "QUERY",
];
const SAFELISTED_HEADERS: [&str; 4] = [
"Accept",
"Accept-Language",
"Content-Language",
"Content-Type",
];
pub struct CorsConfig {
allow_all_origins: bool,
allow_credentials: bool,
allow_origins: Vec<String>,
allow_origin_regex: Option<Regex>,
allow_methods: Vec<String>,
allow_all_headers: bool,
/// Lowercased set checked against requested headers.
allow_headers_lower: HashSet<String>,
preflight_explicit_allow_origin: bool,
simple_headers: Vec<(HeaderName, HeaderValue)>,
preflight_headers: Vec<(HeaderName, HeaderValue)>,
}
impl CorsConfig {
pub fn new(
allow_origins: &[String],
allow_methods: Option<&[String]>,
allow_headers: Option<&[String]>,
allow_credentials: bool,
allow_origin_regex: Option<&str>,
expose_headers: &[String],
max_age: i64,
) -> Option<Self> {
if allow_origins.is_empty() && allow_origin_regex.is_none() {
return None;
}
let allow_all_origins = allow_origins.iter().any(|origin| origin == "*");
let allow_all_headers = allow_headers
.map(|headers| headers.iter().any(|header| header == "*"))
.unwrap_or(false);
let methods: Vec<String> = match allow_methods {
Some(methods) if methods.iter().any(|method| method == "*") => ALL_METHODS
.iter()
.map(|method| method.to_string())
.collect(),
Some(methods) => methods.to_vec(),
None => vec!["GET".to_string()],
};
let configured = allow_headers.unwrap_or_default();
let mut sorted_allow_headers: Vec<String> = SAFELISTED_HEADERS
.iter()
.map(|header| header.to_string())
.chain(configured.iter().cloned())
.collect::<HashSet<_>>()
.into_iter()
.collect();
sorted_allow_headers.sort();
let allow_headers_lower: HashSet<String> = sorted_allow_headers
.iter()
.map(|header| header.to_lowercase())
.collect();
let preflight_explicit_allow_origin = !allow_all_origins || allow_credentials;
let simple_headers =
build_simple_headers(allow_all_origins, allow_credentials, expose_headers);
let preflight_headers = build_preflight_headers(
&methods,
&sorted_allow_headers,
allow_all_headers,
allow_credentials,
max_age,
preflight_explicit_allow_origin,
);
Some(Self {
allow_all_origins,
allow_credentials,
allow_origins: allow_origins.to_vec(),
allow_origin_regex: allow_origin_regex.and_then(|pattern| Regex::new(pattern).ok()),
allow_methods: methods,
allow_all_headers,
allow_headers_lower,
preflight_explicit_allow_origin,
simple_headers,
preflight_headers,
})
}
fn is_allowed_origin(&self, origin: &str) -> bool {
if self.allow_all_origins {
return true;
}
if let Some(regex) = &self.allow_origin_regex {
if regex.is_match(origin) {
return true;
}
}
self.allow_origins.iter().any(|allowed| allowed == origin)
}
fn simple_response_headers(&self, origin: &str) -> Vec<(HeaderName, HeaderValue)> {
let mut headers = self.simple_headers.clone();
if self.allow_all_origins && self.allow_credentials {
// Credentials require the specific origin instead of '*'.
upsert(
&mut headers,
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_str(origin).unwrap_or(HeaderValue::from_static("*")),
);
upsert(
&mut headers,
header::VARY,
HeaderValue::from_static("Origin"),
);
} else if !self.allow_all_origins && self.is_allowed_origin(origin) {
// Specific origins are mirrored back in the response.
upsert(
&mut headers,
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_str(origin).unwrap_or(HeaderValue::from_static("*")),
);
upsert(
&mut headers,
header::VARY,
HeaderValue::from_static("Origin"),
);
}
headers
}
fn preflight_response(&self, request_headers: &HeaderMap, origin: &str) -> Response {
let mut headers = self.preflight_headers.clone();
let mut failures: Vec<&'static str> = Vec::new();
if self.is_allowed_origin(origin) {
if self.preflight_explicit_allow_origin {
if let Ok(value) = HeaderValue::from_str(origin) {
headers.push((header::ACCESS_CONTROL_ALLOW_ORIGIN, value));
}
}
} else {
failures.push("origin");
}
let requested_method = request_headers
.get("access-control-request-method")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
if !self
.allow_methods
.iter()
.any(|method| method == requested_method)
{
failures.push("method");
}
let requested_headers = request_headers
.get("access-control-request-headers")
.and_then(|value| value.to_str().ok());
if self.allow_all_headers && requested_headers.is_some() {
if let Ok(value) = HeaderValue::from_str(requested_headers.unwrap_or_default()) {
headers.push((header::ACCESS_CONTROL_ALLOW_HEADERS, value));
}
} else if let Some(requested_headers) = requested_headers {
for header in requested_headers.split(',') {
if !self
.allow_headers_lower
.contains(header.trim().to_lowercase().as_str())
{
failures.push("headers");
break;
}
}
}
let (status, body) = if failures.is_empty() {
(StatusCode::OK, "OK".to_string())
} else {
(
StatusCode::BAD_REQUEST,
format!("Disallowed CORS {}", failures.join(", ")),
)
};
let mut response = Response::new(axum::body::Body::from(body));
*response.status_mut() = status;
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
for (name, value) in headers {
if !response.headers().contains_key(&name) {
response.headers_mut().insert(name, value);
}
}
response
}
}
fn upsert(headers: &mut Vec<(HeaderName, HeaderValue)>, name: HeaderName, value: HeaderValue) {
if let Some(existing) = headers.iter_mut().find(|(existing, _)| *existing == name) {
existing.1 = value;
} else {
headers.push((name, value));
}
}
fn build_simple_headers(
allow_all_origins: bool,
allow_credentials: bool,
expose_headers: &[String],
) -> Vec<(HeaderName, HeaderValue)> {
let mut headers = Vec::new();
if allow_all_origins {
headers.push((
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_static("*"),
));
}
if allow_credentials {
headers.push((
header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
HeaderValue::from_static("true"),
));
}
if !expose_headers.is_empty() {
if let Ok(value) = HeaderValue::from_str(&expose_headers.join(", ")) {
headers.push((header::ACCESS_CONTROL_EXPOSE_HEADERS, value));
}
}
headers
}
fn build_preflight_headers(
methods: &[String],
sorted_allow_headers: &[String],
allow_all_headers: bool,
allow_credentials: bool,
max_age: i64,
explicit_allow_origin: bool,
) -> Vec<(HeaderName, HeaderValue)> {
let mut headers = Vec::new();
if explicit_allow_origin {
headers.push((header::VARY, HeaderValue::from_static("Origin")));
} else {
headers.push((
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_static("*"),
));
}
if let Ok(value) = HeaderValue::from_str(&methods.join(", ")) {
headers.push((header::ACCESS_CONTROL_ALLOW_METHODS, value));
}
if let Ok(value) = HeaderValue::from_str(&max_age.to_string()) {
headers.push((header::ACCESS_CONTROL_MAX_AGE, value));
}
if !sorted_allow_headers.is_empty() && !allow_all_headers {
if let Ok(value) = HeaderValue::from_str(&sorted_allow_headers.join(", ")) {
headers.push((header::ACCESS_CONTROL_ALLOW_HEADERS, value));
}
}
if allow_credentials {
headers.push((
header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
HeaderValue::from_static("true"),
));
}
headers
}
/// Merge CORS headers into a response: headers already set by the handler are
/// never overwritten; a `Vary` value is appended to an existing header when
/// not already present.
fn merge_headers(response: &mut Response, headers: Vec<(HeaderName, HeaderValue)>) {
for (name, value) in headers {
if name == header::VARY {
let existing = response
.headers()
.get(header::VARY)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string();
let present = existing
.split(',')
.any(|part| part.trim().eq_ignore_ascii_case("origin"));
if present {
continue;
}
if existing.is_empty() {
response.headers_mut().insert(header::VARY, value);
} else {
if let Ok(combined) = HeaderValue::from_str(&format!(
"{existing}, {}",
value.to_str().unwrap_or("Origin")
)) {
response.headers_mut().insert(header::VARY, combined);
}
}
continue;
}
if !response.headers().contains_key(&name) {
response.headers_mut().insert(name, value);
}
}
}
pub async fn cors_middleware(
State(config): State<Arc<CorsConfig>>,
request: Request,
next: Next,
) -> Response {
let origin = request
.headers()
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let Some(origin) = origin else {
return next.run(request).await;
};
if request.method() == Method::OPTIONS
&& request
.headers()
.contains_key("access-control-request-method")
{
return config.preflight_response(request.headers(), &origin);
}
let headers = config.simple_response_headers(&origin);
let mut response = next.run(request).await;
merge_headers(&mut response, headers);
response
}
+6
View File
@@ -0,0 +1,6 @@
pub mod app;
pub mod config;
pub mod cors;
pub mod logging;
pub mod static_files;
pub mod telemetry;
+58
View File
@@ -0,0 +1,58 @@
//! Logging setup for the tavolo application.
//!
//! The Python server configured logging through `LOGGING_CONFIG` (a
//! `logging.config.dictConfig` YAML file). The Rust server deliberately does
//! not emulate that schema: logging is configured through `RUST_LOG`
//! (tracing-subscriber env filters). `LOGGING_CONFIG` is still accepted so
//! existing deployments do not break, but its value is ignored with a
//! warning.
//!
//! When telemetry is enabled the same subscriber also carries the
//! OpenTelemetry trace layer and the OTLP log bridge.
use crate::config::Settings;
use crate::telemetry::Telemetry;
/// Default filter mirrors the Python default (DEBUG for the application) while
/// keeping noisy dependencies at INFO+.
const DEFAULT_FILTER: &str =
"debug,sqlx=warn,redis=warn,hyper=warn,reqwest=warn,tower_http=info,tungstenite=warn";
pub fn configure_logging(settings: &Settings, telemetry: Option<&Telemetry>) {
use tracing_subscriber::layer::{Layer, SubscriberExt};
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
if settings.logging_config.is_some() {
eprintln!(
"LOGGING_CONFIG is set but the Rust server configures logging via RUST_LOG; \
the YAML file is ignored"
);
}
let filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(DEFAULT_FILTER));
let subscriber = tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer().with_target(true));
#[cfg(feature = "otel")]
if let Some(telemetry) = telemetry {
use tracing_subscriber::filter::FilterFn;
let telemetry_layer = tracing_opentelemetry::layer().with_tracer(telemetry.tracer());
// Drop OpenTelemetry's own records to avoid export loops.
let log_bridge = opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new(
telemetry.logger_provider(),
)
.with_filter(FilterFn::new(|metadata| {
!metadata.target().starts_with("opentelemetry")
}));
let subscriber = subscriber.with(telemetry_layer).with(log_bridge);
let _ = subscriber.try_init();
return;
}
#[cfg(not(feature = "otel"))]
let _ = telemetry;
let _ = subscriber.try_init();
}
+160
View File
@@ -0,0 +1,160 @@
//! The `tavolo` binary: `serve` (default), `migrate` and `backfill-elo`.
//!
//! `migrate` replaces the Python `aerich upgrade` migration service: it
//! applies the embedded sqlx migrations (idempotent against databases already
//! at the aerich schema head). `backfill-elo` replaces
//! `python -m tavolo.platform.backfill_elo`.
use std::net::SocketAddr;
use clap::{Parser, Subcommand};
use tavolo_app::app;
use tavolo_app::config::Settings;
use tavolo_app::logging::configure_logging;
use tavolo_platform::stats::{PostgresStatsRepo, StatsRepo};
#[derive(Parser)]
#[command(
name = "tavolo",
version,
about = "Multiplayer card-game platform backend"
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
/// Serve the HTTP + WebSocket application (default).
Serve,
/// Apply the database migrations and exit.
Migrate,
/// Recompute every Elo rating from the recorded match history.
BackfillElo {
/// Database URL; defaults to the configured DATABASE_* parts.
#[arg(long, env = "DATABASE_URL")]
database_url: Option<String>,
},
}
fn main() {
let cli = Cli::parse();
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime");
let result = match cli.command.unwrap_or(Command::Serve) {
Command::Serve => runtime.block_on(serve()),
Command::Migrate => runtime.block_on(migrate()),
Command::BackfillElo { database_url } => {
runtime.block_on(backfill_elo(database_url.as_deref()))
}
};
if let Err(message) = result {
eprintln!("error: {message}");
std::process::exit(1);
}
}
async fn serve() -> Result<(), String> {
use std::sync::Arc;
use tavolo_platform::stats::{PostgresStatsRepo, SharedStatsRepo};
let settings = Settings::from_env()?;
let registry = app::build_registry(&settings)?;
let (session_store, game_store) = app::build_stores(&settings, registry.clone()).await?;
let telemetry =
tavolo_app::telemetry::init(&settings, session_store.clone(), game_store.clone())?;
configure_logging(&settings, telemetry.as_ref());
tracing::info!(
"registered games: {}",
registry
.all()
.iter()
.map(|engine| engine.id())
.collect::<Vec<_>>()
.join(", ")
);
if settings.redis_url.is_some() {
tracing::info!(
"using Redis stores (sessions + live games, game TTL {}s)",
settings.game_ttl_seconds
);
} else {
tracing::info!("REDIS_URL unset: using in-memory stores (sessions + live games)");
}
if telemetry.is_some() {
tracing::info!(
"OpenTelemetry enabled: service={} endpoint={} (traces, metrics, logs)",
settings.otel_service_name,
settings
.otel_exporter_endpoint
.as_deref()
.unwrap_or("(OTLP default)"),
);
}
let pool = app::connect_database(&settings).await?;
let stats: SharedStatsRepo = Arc::new(PostgresStatsRepo::new(pool));
let platform = app::build_platform(&settings, registry, game_store, stats)?;
let router = app::build_router(platform, session_store, &settings);
let address: SocketAddr = format!("{}:{}", settings.app_host, settings.app_port)
.parse()
.map_err(|_| {
format!(
"invalid bind address {}:{}",
settings.app_host, settings.app_port
)
})?;
let listener = tokio::net::TcpListener::bind(address)
.await
.map_err(|err| format!("could not bind {address}: {err}"))?;
tracing::info!("listening on http://{address}");
axum::serve(listener, router)
.with_graceful_shutdown(shutdown_signal())
.await
.map_err(|err| format!("server error: {err}"))
}
async fn shutdown_signal() {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("shutting down");
}
fn database_url(override_url: Option<&str>) -> Result<String, String> {
match override_url {
Some(url) => Ok(url.to_string()),
None => Ok(Settings::from_env()?.database_url),
}
}
async fn migrate() -> Result<(), String> {
let settings = Settings::from_env()?;
configure_logging(&settings, None);
let url = database_url(None)?;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&url)
.await
.map_err(|err| format!("could not connect to the database: {err}"))?;
sqlx::migrate!("./migrations")
.run(&pool)
.await
.map_err(|err| format!("migration failed: {err}"))?;
println!("migrations applied");
Ok(())
}
async fn backfill_elo(override_url: Option<&str>) -> Result<(), String> {
let url = database_url(override_url)?;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&url)
.await
.map_err(|err| format!("could not connect to the database: {err}"))?;
let repo = PostgresStatsRepo::new(pool);
let replayed = repo
.backfill_elo()
.await
.map_err(|err| format!("backfill failed: {err}"))?;
println!("Recomputed ratings from {replayed} matches.");
Ok(())
}
@@ -0,0 +1,74 @@
//! SPA shell hosting and static asset mounts.
//!
//! Static assets (wasm, js, css, card images) are served by
//! [`tower_http::services::ServeDir`] under `/static` (mapped to the frontend
//! build root) and `/assets` (mapped to `<root>/assets`), mirroring the
//! Granian static mounts of the Python deployment. Every path no API or auth
//! route claimed falls back to `index.html`, so client-side routes
//! (`/game/<id>`) work on direct loads and refreshes.
use std::convert::Infallible;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::task::{Context, Poll};
use axum::body::Body;
use axum::extract::Request;
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use tower::Service;
/// Fallback service serving the SPA shell, or 404 when the frontend build is
/// missing.
#[derive(Clone)]
pub struct SpaFallback {
index: Arc<PathBuf>,
}
impl SpaFallback {
pub fn new(static_dir: &str) -> Self {
Self {
index: Arc::new(Path::new(static_dir).join("index.html")),
}
}
}
impl Service<Request> for SpaFallback {
type Response = Response;
type Error = Infallible;
type Future = std::pin::Pin<
Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _request: Request) -> Self::Future {
let index = Arc::clone(&self.index);
Box::pin(async move {
match tokio::fs::read(index.as_ref()).await {
Ok(body) => Ok((
StatusCode::OK,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
body,
)
.into_response()),
Err(_) => Ok(StatusCode::NOT_FOUND.into_response()),
}
})
}
}
/// The `/static` and `/assets` mount directories for a build root.
pub fn mount_dirs(static_dir: &str) -> (PathBuf, PathBuf) {
let root = PathBuf::from(static_dir);
let assets = root.join("assets");
(root, assets)
}
/// Build a 404 response body-less response (kept for symmetry with the SPA
/// fallback).
pub fn not_found() -> Response {
Body::empty().into_response()
}
+389
View File
@@ -0,0 +1,389 @@
//! OpenTelemetry wiring: OTLP traces, metrics and logs.
//!
//! Mirrors `tavolo.telemetry` from the Python implementation within what the
//! Rust ecosystem offers:
//!
//! * **traces** — HTTP request spans (tagged with a hashed `session.id` and
//! the OIDC `user.id`) and one span per WebSocket connection, exported over
//! OTLP/HTTP;
//! * **metrics** — `tavolo.active_users` (sessions in the session store),
//! `tavolo.ongoing_games` (live matches not finished yet) and Rust process
//! metrics (`process.cpu.time`, `process.cpu.utilization`,
//! `process.memory.usage`, `process.memory.virtual`) in place of the
//! CPython-specific gauges;
//! * **logs** — tracing events forwarded to OTLP (OpenTelemetry's own records
//! are filtered out upstream).
//!
//! Database client instrumentation (asyncpg/redis spans in Python) has no
//! direct Rust equivalent and is omitted.
use std::sync::Arc;
use crate::config::Settings;
use tavolo_platform::session::SharedSessionStore;
use tavolo_platform::store::SharedGameStore;
#[cfg(feature = "otel")]
mod inner {
use std::collections::HashMap;
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use opentelemetry_otlp::{
LogExporter, MetricExporter, SpanExporter, WithExportConfig, WithHttpConfig,
};
use opentelemetry_sdk::logs::SdkLoggerProvider;
use opentelemetry_sdk::metrics::{PeriodicReader, SdkMeterProvider};
use opentelemetry_sdk::trace::SdkTracerProvider;
use opentelemetry_sdk::Resource;
use super::{Settings, SharedGameStore, SharedSessionStore};
pub(super) static HTTP_DURATION: OnceLock<opentelemetry::metrics::Histogram<f64>> =
OnceLock::new();
#[derive(Clone, Copy, Default)]
pub(super) struct ProcessSnapshot {
pub cpu_seconds: f64,
pub cpu_utilization: f64,
pub memory_usage: u64,
pub memory_virtual: u64,
}
pub(super) struct OtelInner {
pub tracer_provider: SdkTracerProvider,
pub meter_provider: SdkMeterProvider,
pub logger_provider: SdkLoggerProvider,
pub poll_task: tokio::task::JoinHandle<()>,
}
impl Drop for OtelInner {
fn drop(&mut self) {
self.poll_task.abort();
let _ = self.logger_provider.shutdown();
let _ = self.meter_provider.shutdown();
let _ = self.tracer_provider.shutdown();
}
}
fn signal_endpoint(base: Option<&str>, signal: &str) -> Option<String> {
base.map(|base| format!("{base}/{signal}"))
}
fn parse_headers(raw: Option<&[String]>) -> HashMap<String, String> {
raw.unwrap_or(&[])
.iter()
.filter_map(|pair| pair.split_once('='))
.map(|(key, value)| (key.to_string(), value.trim().to_string()))
.collect()
}
fn refresh_process(snapshot: &Mutex<ProcessSnapshot>, pid: sysinfo::Pid) {
use sysinfo::ProcessesToUpdate;
let mut system = sysinfo::System::new();
system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
if let Some(process) = system.process(pid) {
let mut snapshot = snapshot.lock().expect("process snapshot");
snapshot.cpu_seconds = process.accumulated_cpu_time() as f64 / 1000.0;
snapshot.cpu_utilization = process.cpu_usage() as f64;
snapshot.memory_usage = process.memory();
snapshot.memory_virtual = process.virtual_memory();
}
}
pub(super) fn init(
settings: &Settings,
session_store: SharedSessionStore,
game_store: SharedGameStore,
) -> Result<Option<super::Telemetry>, String> {
let endpoint = settings.otel_exporter_endpoint.clone();
let headers = parse_headers(settings.otel_exporter_headers.as_deref());
let resource = Resource::builder()
.with_service_name(settings.otel_service_name.clone())
.build();
let span_exporter = SpanExporter::builder()
.with_http()
.with_endpoint(signal_endpoint(endpoint.as_deref(), "v1/traces").unwrap_or_default())
.with_headers(headers.clone())
.build()
.map_err(|err| format!("could not build the OTLP span exporter: {err}"))?;
let tracer_provider = SdkTracerProvider::builder()
.with_batch_exporter(span_exporter)
.with_resource(resource.clone())
.build();
let metric_exporter = MetricExporter::builder()
.with_http()
.with_endpoint(signal_endpoint(endpoint.as_deref(), "v1/metrics").unwrap_or_default())
.with_headers(headers.clone())
.build()
.map_err(|err| format!("could not build the OTLP metric exporter: {err}"))?;
let reader = PeriodicReader::builder(metric_exporter)
.with_interval(Duration::from_millis(
settings.otel_metric_export_interval_millis.max(1),
))
.build();
let meter_provider = SdkMeterProvider::builder()
.with_reader(reader)
.with_resource(resource.clone())
.build();
let log_exporter = LogExporter::builder()
.with_http()
.with_endpoint(signal_endpoint(endpoint.as_deref(), "v1/logs").unwrap_or_default())
.with_headers(headers)
.build()
.map_err(|err| format!("could not build the OTLP log exporter: {err}"))?;
let logger_provider = SdkLoggerProvider::builder()
.with_batch_exporter(log_exporter)
.with_resource(resource)
.build();
opentelemetry::global::set_meter_provider(meter_provider.clone());
let meter = opentelemetry::metrics::MeterProvider::meter(&meter_provider, "tavolo");
let active_users = Arc::new(AtomicI64::new(0));
let ongoing_games = Arc::new(AtomicI64::new(0));
let process = Arc::new(Mutex::new(ProcessSnapshot::default()));
let pid = sysinfo::get_current_pid()
.map_err(|err| format!("could not determine the current process id: {err}"))?;
refresh_process(&process, pid);
let _ = meter
.u64_observable_gauge("tavolo.active_users")
.with_description("Sessions currently held in the session store.")
.with_unit("{user}")
.with_callback({
let active_users = active_users.clone();
move |observer| {
observer.observe(active_users.load(Ordering::Relaxed).max(0) as u64, &[])
}
})
.build();
let _ = meter
.u64_observable_gauge("tavolo.ongoing_games")
.with_description("Live games that have not finished yet.")
.with_unit("{game}")
.with_callback({
let ongoing_games = ongoing_games.clone();
move |observer| {
observer.observe(ongoing_games.load(Ordering::Relaxed).max(0) as u64, &[])
}
})
.build();
let _ = meter
.f64_observable_gauge("process.cpu.utilization")
.with_description("Process CPU utilization since the last poll, in percent.")
.with_callback({
let process = process.clone();
move |observer| {
let snapshot = process.lock().expect("process snapshot");
observer.observe(snapshot.cpu_utilization / 100.0, &[])
}
})
.build();
let _ = meter
.u64_observable_gauge("process.memory.usage")
.with_description("Process resident memory, in bytes.")
.with_unit("By")
.with_callback({
let process = process.clone();
move |observer| {
let snapshot = process.lock().expect("process snapshot");
observer.observe(snapshot.memory_usage, &[])
}
})
.build();
let _ = meter
.u64_observable_gauge("process.memory.virtual")
.with_description("Process virtual memory, in bytes.")
.with_unit("By")
.with_callback({
let process = process.clone();
move |observer| {
let snapshot = process.lock().expect("process snapshot");
observer.observe(snapshot.memory_virtual, &[])
}
})
.build();
let _ = meter
.f64_observable_counter("process.cpu.time")
.with_description("Process CPU time, in seconds.")
.with_unit("s")
.with_callback({
let process = process.clone();
move |observer| {
let snapshot = process.lock().expect("process snapshot");
observer.observe(snapshot.cpu_seconds, &[]);
}
})
.build();
let _ = HTTP_DURATION.set(
meter
.f64_histogram("http.server.request.duration")
.with_description("HTTP server request duration.")
.with_unit("s")
.build(),
);
let poll_interval = Duration::from_secs(settings.otel_metrics_poll_interval_seconds.max(1));
let poll_task = tokio::spawn({
let session_store = session_store.clone();
let game_store = game_store.clone();
let active_users = active_users.clone();
let ongoing_games = ongoing_games.clone();
let process = process.clone();
async move {
loop {
match session_store.count().await {
Ok(count) => active_users.store(count as i64, Ordering::Relaxed),
Err(err) => tracing::warn!("active_users refresh failed: {err}"),
}
match game_store.count_ongoing().await {
Ok(count) => ongoing_games.store(count as i64, Ordering::Relaxed),
Err(err) => tracing::warn!("ongoing_games refresh failed: {err}"),
}
let process = process.clone();
let _ =
tokio::task::spawn_blocking(move || refresh_process(&process, pid)).await;
tokio::time::sleep(poll_interval).await;
}
}
});
Ok(Some(super::Telemetry {
inner: OtelInner {
tracer_provider,
meter_provider,
logger_provider,
poll_task,
},
}))
}
}
/// The live telemetry pipeline; dropping it shuts the providers down.
pub struct Telemetry {
#[cfg(feature = "otel")]
inner: inner::OtelInner,
}
#[cfg(feature = "otel")]
impl Telemetry {
pub fn tracer(&self) -> opentelemetry_sdk::trace::SdkTracer {
use opentelemetry::trace::TracerProvider as _;
self.inner.tracer_provider.tracer("tavolo")
}
pub fn logger_provider(&self) -> &opentelemetry_sdk::logs::SdkLoggerProvider {
&self.inner.logger_provider
}
}
/// Initialize the telemetry pipeline when `OTEL_ENABLED` is set.
pub fn init(
settings: &Settings,
session_store: SharedSessionStore,
game_store: SharedGameStore,
) -> Result<Option<Telemetry>, String> {
if !settings.otel_enabled {
return Ok(None);
}
#[cfg(feature = "otel")]
{
inner::init(settings, session_store, game_store)
}
#[cfg(not(feature = "otel"))]
{
let _ = (session_store, game_store);
Err("OTEL_ENABLED is set but the binary was built without the 'otel' feature".to_string())
}
}
/// Tag a span with the hashed session id and the authenticated user.
pub fn session_attributes(
session: &tavolo_platform::session::SessionData,
) -> (Option<String>, Option<String>) {
let session_id = session.id().map(hash_session_id);
let user = tavolo_platform::auth::session_user(session).map(|user| user.sub);
(session_id, user)
}
/// Truncated SHA-256 of the session id (the cookie is a credential, so it is
/// never exported verbatim).
pub fn hash_session_id(session_id: &str) -> String {
tavolo_platform::auth::session_tag(session_id)
}
/// An HTTP span per request, tagged with session/user; excludes the paths in
/// `OTEL_EXCLUDED_PATHS`. Also records the HTTP server duration histogram.
pub async fn trace_middleware(
excluded: Arc<Vec<String>>,
request: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
use tracing::Instrument;
let path = request.uri().path().to_string();
if excluded.contains(&path) {
return next.run(request).await;
}
let method = request.method().clone();
let span = tracing::info_span!(
"http.server.request",
"http.request.method" = %method,
"url.path" = %path,
"http.response.status_code" = tracing::field::Empty,
"session.id" = tracing::field::Empty,
"user.id" = tracing::field::Empty,
);
let session = request
.extensions()
.get::<tavolo_platform::session::SessionHandle>()
.cloned();
async move {
let started = std::time::Instant::now();
let response = next.run(request).await;
let status = response.status().as_u16();
tracing::Span::current().record("http.response.status_code", status);
// Recorded after the handler ran so logins/mutations are visible;
// the session middleware mints the cookie (and its id on first use)
// after this layer returns.
if let Some(session) = session {
let guard = session.lock().await;
let (session_id, user) = session_attributes(&guard);
if let Some(session_id) = session_id {
tracing::Span::current().record("session.id", session_id);
}
if let Some(user) = user {
tracing::Span::current().record("user.id", user);
}
}
record_http_duration(started.elapsed(), &method, status);
response
}
.instrument(span)
.await
}
fn record_http_duration(duration: std::time::Duration, method: &axum::http::Method, status: u16) {
#[cfg(feature = "otel")]
{
if let Some(histogram) = inner::HTTP_DURATION.get() {
histogram.record(
duration.as_secs_f64(),
&[
opentelemetry::KeyValue::new("http.request.method", method.to_string()),
opentelemetry::KeyValue::new("http.response.status_code", status as i64),
],
);
}
}
#[cfg(not(feature = "otel"))]
{
let _ = (duration, method, status);
}
}
+319
View File
@@ -0,0 +1,319 @@
//! End-to-end OIDC login flow against a mock provider.
use std::net::SocketAddr;
use std::sync::Arc;
use axum::extract::{Query, State};
use axum::routing::{get, post};
use axum::{Json, Router};
use chrono::Utc;
use serde_json::{json, Value};
use tokio::sync::Mutex;
use tower::ServiceExt;
use tavolo_app::app;
use tavolo_app::config::Settings;
use tavolo_platform::oidc::OidcProviderConfig;
const RSA_PEM: &str = r#"-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCMKcE4AgsStq5u
l3Rtg9p2XNN5l5N5z1orqDJmRHAJvSNqhr2BHxu5Q1m3o8prvE+lHRqe1lgJXKi5
8aITv6hDKkS9gmp+tvVQ/VJnnKSbgaSJSMEf5P5GoLR43SrsTEtbxlVO03kwCkiH
EoOihK0h4GgyIE/3UWkgsJ8eJKi+czBzBffX8+qfR29vJGEk1mhpHIBnHI+19pLV
q0GvMcqKA4hnTZwB43/8EI6lZb59r1ZJLvz87AKd/VnXW4zki16PHntkrrRfRl+U
Q5k+ErSYp0SXtyh9mONzxGDQ+ra7gIx8itTnSAbYTwi846UzSTltCCYqTwkvi4nm
aCmKn3mdAgMBAAECggEARUGKegC0dPo0JWwLuONKSmriiXWmhq04qlKxGIwfagh9
W/EY6Ri0a63GQnGZnvNMzVKcqThTBsk0i7BD/Asa+pkDnzo2IceIXozVL2IcicQc
x0LePkK5i69zjTcHMvwdzkqpM0pV2rFyFd/FkEOLgq4t/fquWI9u6RqsfMnzYSqx
XxS4JsC6dEgNK69lH2OTWvmpgpujrwWAiJVNlKY0Ehh0R8Wruy7iPjo6NLWH8sQq
XlCxIoeWCcPgCbtQRmLgNJPdFB8zSTYGFqGJ5lfYq/7qkcQsAEwIUJBF+EMU+yK5
y8jxiGF+8wDg4bYovNxs0uxU/HltHPGDfe1Yn2OyNwKBgQDA7GNsj4hxDME0rXnI
8SMnSjOVQRsmNTa6Je3XKXuTxzqXwYBndILmTYOmejYnQJ7oToAn1Jiyk9yo+Mzd
2sxKU9iHMVABuLiFmbUm269WB2Zh7fQ7ErhIVGTNsPsHv2iuSuOktGQw6Cd+9pBH
0H8HCf9dWOzQ01lqdKw60UfH2wKBgQC5/VnnCtQ4n+aAzSYLYgZf97XTTKN2+gGq
z2caPga/3yA+1dPx18JjVqlEuAxazXQPxgAL2ZnGHQfLR+M0lInHhljrALKHTzMl
MsmLTq+J8DChlGIPJIKc0Uk4vLSJLMkaHBS9gM9z49zdGx2ltI4gGUJIKk4z5gT0
iqRFXM1Z5wKBgAuOna4lMxFCqFhVhBQrjeWOpmRskAnL6klgeE0JaSPDLZlluCF6
c1UEk7xnWdMkJeDYwwifzb306aUdsngf4VkaiMAgjiBsRnGKcbWwN2K58sIvFRBs
4KlElP1VetRN/SFjzxtQyye64JLUhnQm41Uynh+jchxYbOTZtG+Zlek9AoGADPsP
sT5iPWl7jvXCd/zpcP065Xpt1jnwr9k12vEFS5LpDzmhRznFyWUc/zzRL+0y8n7h
NfKD9v3Ds35a7yqGyzQnNI+h5iuy6B+5TU26lqf63OwcQ+5KZ2N79sfE2eYUSww2
rls1H2OB4LSpxwzo9niM1biOKLUfF2otuOGXO3UCgYBIDAvpabTpw6z4IBQGwHKP
WOTFrtI/y0Zqf3iNusqb8RdCIokDHvT+fKdvI7VXzaASWt2GajMHArWB6uu/1Xeh
nF9SaK1F+mKyGv9a5m5We9VzRNXmaUaQBRLK3XjBr1Co4/UduYRDdqZo/E/5g8gr
BMZbIcEMr49N2ns4yHzJ3w==
-----END PRIVATE KEY-----
"#;
const JWK: &str = r#"{"kty":"RSA","use":"sig","alg":"RS256","kid":"test-key","n":"jCnBOAILEraubpd0bYPadlzTeZeTec9aK6gyZkRwCb0jaoa9gR8buUNZt6PKa7xPpR0antZYCVyoufGiE7-oQypEvYJqfrb1UP1SZ5ykm4GkiUjBH-T-RqC0eN0q7ExLW8ZVTtN5MApIhxKDooStIeBoMiBP91FpILCfHiSovnMwcwX31_Pqn0dvbyRhJNZoaRyAZxyPtfaS1atBrzHKigOIZ02cAeN__BCOpWW-fa9WSS78_OwCnf1Z11uM5Itejx57ZK60X0ZflEOZPhK0mKdEl7cofZjjc8Rg0Pq2u4CMfIrU50gG2E8IvOOlM0k5bQgmKk8JL4uJ5mgpip95nQ","e":"AQAB"}"#;
#[derive(Clone, Default)]
struct IdpState {
issuer: String,
nonce: Arc<Mutex<String>>,
}
struct Idp {
state: IdpState,
addr: SocketAddr,
}
async fn start_idp() -> Idp {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind idp");
let addr = listener.local_addr().expect("idp addr");
let base = format!("http://{addr}");
let state = IdpState {
issuer: base.clone(),
..Default::default()
};
let router = Router::new()
.route(
"/.well-known/openid-configuration",
get({
let base = base.clone();
move || async move {
Json(json!({
"issuer": base,
"authorization_endpoint": format!("{base}/authorize"),
"token_endpoint": format!("{base}/token"),
"userinfo_endpoint": format!("{base}/userinfo"),
"jwks_uri": format!("{base}/jwks"),
}))
}
}),
)
.route(
"/authorize",
get(
|State(state): State<IdpState>,
Query(params): Query<std::collections::HashMap<String, String>>| async move {
if let Some(nonce) = params.get("nonce") {
*state.nonce.lock().await = nonce.clone();
}
let state_param = params.get("state").cloned().unwrap_or_default();
let redirect_uri = params
.get("redirect_uri")
.cloned()
.unwrap_or_else(|| "/".into());
let location = format!("{redirect_uri}?code=test-code&state={state_param}");
(
axum::http::StatusCode::FOUND,
[(axum::http::header::LOCATION, location)],
)
},
),
)
.route(
"/token",
post(|State(state): State<IdpState>| async move {
let nonce = state.nonce.lock().await.clone();
let now = Utc::now().timestamp();
let claims = json!({
"iss": state.issuer,
"aud": "tavolo",
"sub": "alice",
"exp": now + 600,
"iat": now,
"nonce": nonce,
});
let token = jsonwebtoken::encode(
&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256),
&claims,
&jsonwebtoken::EncodingKey::from_rsa_pem(RSA_PEM.as_bytes()).unwrap(),
)
.unwrap();
Json(json!({
"access_token": "access-token",
"token_type": "Bearer",
"id_token": token,
}))
}),
)
.route(
"/userinfo",
get(|| async { Json(json!({"sub": "alice", "preferred_username": "Alice"})) }),
)
.route(
"/jwks",
get(|| async { Json(json!({"keys": [serde_json::from_str::<Value>(JWK).unwrap()]})) }),
)
.with_state(state.clone());
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
Idp { state, addr }
}
fn test_settings(issuer: &str, redirect_uri: &str) -> Settings {
Settings {
database_url: "postgres://unused".into(),
oidc_providers: vec![OidcProviderConfig {
provider: "default".into(),
issuer: issuer.into(),
client_id: "tavolo".into(),
client_secret: Some("dev-secret".into()),
redirect_uri: redirect_uri.into(),
post_login_redirect: "/".into(),
post_logout_redirect: "/".into(),
}],
app_host: "127.0.0.1".into(),
app_port: 0,
redis_url: None,
game_ttl_seconds: 86400,
static_dir: "/nonexistent-web-dist".into(),
hand_ack_timeout_seconds: 30,
turn_timeout_seconds: 30,
deadline_heartbeat_ms: 50,
logging_config: None,
cors_allow_origins: None,
cors_allow_origin_regex: None,
cors_allow_methods: None,
cors_allow_headers: None,
cors_allow_credentials: false,
cors_expose_headers: None,
cors_max_age: 600,
otel_enabled: false,
otel_service_name: "tavolo".into(),
otel_exporter_endpoint: None,
otel_exporter_headers: None,
otel_excluded_paths: vec!["/api/health".into()],
otel_metric_export_interval_millis: 60000,
otel_metrics_poll_interval_seconds: 15,
}
}
fn cookie_header(response: &axum::response::Response) -> String {
let value = response
.headers()
.get(axum::http::header::SET_COOKIE)
.expect("set-cookie")
.to_str()
.expect("cookie header");
value.split(';').next().expect("cookie pair").to_string()
}
#[tokio::test]
async fn oidc_login_callback_and_logout_flow() {
// The mock IdP must know its own issuer before we can start it, so bind
// first and rebuild the settings from the recorded address.
let idp = start_idp().await;
let issuer = format!("http://{}", idp.addr);
let callback = "http://localhost:8080/auth/default/callback".to_string();
let settings = test_settings(&issuer, &callback);
let (platform, session_store) = app::in_memory_platform(&settings).expect("platform");
let router = app::build_router(platform, session_store.clone(), &settings);
// 1. Start the login: a 302 to the provider with state/nonce/PKCE and a
// fresh session cookie.
let response = router
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/auth/default/login")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 302);
let location = response.headers()["location"].to_str().unwrap().to_string();
let login_cookie = cookie_header(&response);
let url = url::Url::parse(&location).expect("authorization url");
let params: std::collections::HashMap<String, String> = url
.query_pairs()
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect();
assert_eq!(params["client_id"], "tavolo");
assert_eq!(params["code_challenge_method"], "S256");
assert_eq!(params["scope"], "openid email profile");
assert_eq!(
params["redirect_uri"],
"http://localhost:8080/auth/default/callback"
);
let state = params["state"].clone();
// Simulate the browser visiting the provider: the mock signs the nonce
// the client generated.
*idp.state.nonce.lock().await = params["nonce"].clone();
// 2. The provider redirects back with the code and state.
let response = router
.clone()
.oneshot(
axum::http::Request::builder()
.uri(format!(
"/auth/default/callback?code=test-code&state={state}"
))
.header("cookie", &login_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 302);
assert_eq!(response.headers()["location"], "/");
let session_cookie = cookie_header(&response);
assert_ne!(
session_cookie, login_cookie,
"session id must rotate on login"
);
// A wrong state is rejected without touching the provider.
let response = router
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/auth/default/callback?code=test-code&state=wrong")
.header("cookie", &login_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 400);
// 3. The session is authenticated and /api/me reflects the userinfo.
let response = router
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/api/me")
.header("cookie", &session_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let me: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(me, json!({"sub": "alice", "name": "Alice"}));
// 4. Logout clears the session and sends an expired cookie.
let response = router
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/auth/default/logout")
.header("cookie", &session_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 302);
let clear_cookie = response
.headers()
.get(axum::http::header::SET_COOKIE)
.unwrap()
.to_str()
.unwrap();
assert!(
clear_cookie.contains("Max-Age=0"),
"cookie not cleared: {clear_cookie}"
);
}
@@ -0,0 +1,380 @@
//! Configuration parsing and CORS behavior of the app composition root.
use std::sync::Mutex;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
use tavolo_app::app;
use tavolo_app::config::Settings;
use tavolo_platform::oidc::OidcProviderConfig;
static ENV_LOCK: Mutex<()> = Mutex::new(());
const ENV_VARS: &[&str] = &[
"DATABASE_URL",
"DATABASE_ENGINE",
"DATABASE_HOST",
"DATABASE_PORT",
"DATABASE_NAME",
"DATABASE_USER",
"DATABASE_PASSWORD",
"DATABASE_OPTIONS",
"APP_HOST",
"APP_PORT",
"REDIS_URL",
"GAME_TTL_SECONDS",
"STATIC_DIR",
"HAND_ACK_TIMEOUT_SECONDS",
"TURN_TIMEOUT_SECONDS",
"DEADLINE_HEARTBEAT_MS",
"LOGGING_CONFIG",
"CORS_ALLOW_ORIGINS",
"CORS_ALLOW_ORIGIN_REGEX",
"CORS_ALLOW_METHODS",
"CORS_ALLOW_HEADERS",
"CORS_ALLOW_CREDENTIALS",
"CORS_EXPOSE_HEADERS",
"CORS_MAX_AGE",
"OTEL_ENABLED",
"OTEL_SERVICE_NAME",
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_HEADERS",
"OTEL_EXCLUDED_PATHS",
"OTEL_METRIC_EXPORT_INTERVAL_MILLIS",
"OTEL_METRICS_POLL_INTERVAL_SECONDS",
"OIDC_ISSUER",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_REDIRECT_URI",
"OIDC_POST_LOGIN_REDIRECT",
"OIDC_POST_LOGOUT_REDIRECT",
];
fn clear_oidc_env() {
for (name, _) in std::env::vars() {
if name.starts_with("OIDC_") {
std::env::remove_var(&name);
}
}
for name in ENV_VARS {
std::env::remove_var(name);
}
}
fn with_env<T>(vars: &[(&str, &str)], f: impl FnOnce() -> T) -> T {
let _guard = ENV_LOCK.lock().expect("env lock");
clear_oidc_env();
for (name, value) in vars {
std::env::set_var(name, value);
}
let result = f();
clear_oidc_env();
result
}
#[test]
fn defaults_match_the_python_settings() {
let settings = with_env(&[], Settings::from_env).expect("settings");
assert_eq!(settings.app_host, "0.0.0.0");
assert_eq!(settings.app_port, 8080);
assert_eq!(settings.game_ttl_seconds, 86400);
assert_eq!(settings.static_dir, "web/dist");
assert_eq!(settings.oidc_providers.len(), 1);
let provider = &settings.oidc_providers[0];
assert_eq!(provider.provider, "default");
assert_eq!(provider.issuer, "http://localhost:8180/tavolo");
assert_eq!(provider.client_id, "tavolo");
assert_eq!(
provider.redirect_uri,
"http://localhost:8080/auth/default/callback"
);
assert_eq!(
settings.database_url,
"postgres://tavolo:password@localhost/tavolo"
);
assert!(!settings.otel_enabled);
assert_eq!(settings.otel_excluded_paths, vec!["/api/health"]);
}
#[test]
fn indexed_oidc_providers_are_parsed_in_order() {
let settings = with_env(
&[
("OIDC_0_PROVIDER", "google"),
("OIDC_0_ISSUER", "https://accounts.google.com"),
("OIDC_0_CLIENT_ID", "gid"),
("OIDC_0_CLIENT_SECRET", "gsecret"),
("OIDC_1_PROVIDER", "github"),
("OIDC_1_ISSUER", "https://github.com/login/oauth"),
("OIDC_1_CLIENT_ID", "ghid"),
],
Settings::from_env,
)
.expect("settings");
assert_eq!(settings.oidc_providers.len(), 2);
assert_eq!(settings.oidc_providers[0].provider, "google");
assert_eq!(
settings.oidc_providers[0].redirect_uri,
"http://localhost:8080/auth/google/callback"
);
assert_eq!(
settings.oidc_providers[0].client_secret.as_deref(),
Some("gsecret")
);
assert_eq!(settings.oidc_providers[1].provider, "github");
assert!(settings.oidc_providers[1].client_secret.is_none());
}
#[test]
fn invalid_oidc_configurations_are_rejected() {
let error = with_env(
&[
("OIDC_0_PROVIDER", "a"),
("OIDC_0_ISSUER", "https://x"),
("OIDC_0_CLIENT_ID", "x"),
("OIDC_2_PROVIDER", "b"),
("OIDC_2_ISSUER", "https://y"),
("OIDC_2_CLIENT_ID", "y"),
],
Settings::from_env,
)
.expect_err("missing index must fail");
assert!(error.contains("contiguous"), "{error}");
let error = with_env(&[("OIDC_ISSUER", "https://legacy")], Settings::from_env)
.expect_err("legacy variables must fail");
assert!(error.contains("indexed per provider"), "{error}");
let error = with_env(
&[
("OIDC_0_PROVIDER", "bad name!"),
("OIDC_0_ISSUER", "https://x"),
("OIDC_0_CLIENT_ID", "x"),
],
Settings::from_env,
)
.expect_err("invalid provider names must fail");
assert!(error.contains("letters, digits"), "{error}");
let error = with_env(
&[
("OIDC_0_PROVIDER", "a"),
("OIDC_0_ISSUER", "https://x"),
("OIDC_0_CLIENT_ID", "x"),
("OIDC_0_WAT", "?"),
],
Settings::from_env,
)
.expect_err("unknown variables must fail");
assert!(
error.contains("unknown OIDC environment variable"),
"{error}"
);
}
#[test]
fn database_dsn_quotes_credentials() {
let settings = with_env(
&[
("DATABASE_USER", "ta:v@lo"),
("DATABASE_PASSWORD", "p@ss word"),
("DATABASE_HOST", "db.example"),
("DATABASE_PORT", "5433"),
("DATABASE_NAME", "tavolo"),
("DATABASE_OPTIONS", "ssl=require"),
],
Settings::from_env,
)
.expect("settings");
assert_eq!(
settings.database_url,
"postgres://ta%3Av%40lo:p%40ss%20word@db.example:5433/tavolo?ssl=require"
);
}
#[tokio::test]
async fn cors_is_disabled_without_configuration() {
let settings = base_settings();
let (platform, sessions) = app::in_memory_platform(&settings).expect("platform");
let router = app::build_router(platform, sessions, &settings);
let response = router
.clone()
.oneshot(
Request::builder()
.uri("/api/health")
.header("origin", "https://cards.example")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(response
.headers()
.get("access-control-allow-origin")
.is_none());
}
#[tokio::test]
async fn cors_allows_configured_origins_and_regexes() {
let mut settings = base_settings();
settings.cors_allow_origins = Some(vec!["https://cards.example".into()]);
settings.cors_allow_origin_regex = Some(r"https://preview-[a-z0-9-]+\.example\.com".into());
settings.cors_allow_methods = Some(vec!["GET".into(), "POST".into()]);
settings.cors_max_age = 3600;
let (platform, sessions) = app::in_memory_platform(&settings).expect("platform");
let router = app::build_router(platform, sessions, &settings);
// Allowed origin: echoed. Disallowed origin: no header, request still runs.
let response = router
.clone()
.oneshot(
Request::builder()
.uri("/api/health")
.header("origin", "https://cards.example")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.headers()["access-control-allow-origin"],
"https://cards.example"
);
let response = router
.clone()
.oneshot(
Request::builder()
.uri("/api/health")
.header("origin", "https://preview-abc.example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.headers()["access-control-allow-origin"],
"https://preview-abc.example.com"
);
let response = router
.clone()
.oneshot(
Request::builder()
.uri("/api/health")
.header("origin", "https://mallory.example")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(response
.headers()
.get("access-control-allow-origin")
.is_none());
// Preflight.
let response = router
.clone()
.oneshot(
Request::builder()
.method("OPTIONS")
.uri("/api/health")
.header("origin", "https://cards.example")
.header("access-control-request-method", "POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let methods = response.headers()["access-control-allow-methods"]
.to_str()
.unwrap()
.to_string();
assert!(methods.contains("POST"), "{methods}");
assert_eq!(response.headers()["access-control-max-age"], "3600");
let response = router
.clone()
.oneshot(
Request::builder()
.method("OPTIONS")
.uri("/api/health")
.header("origin", "https://mallory.example")
.header("access-control-request-method", "GET")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn cors_credentials_echo_the_origin_for_wildcards() {
let mut settings = base_settings();
settings.cors_allow_origins = Some(vec!["*".into()]);
settings.cors_allow_credentials = true;
let (platform, sessions) = app::in_memory_platform(&settings).expect("platform");
let router = app::build_router(platform, sessions, &settings);
let response = router
.clone()
.oneshot(
Request::builder()
.uri("/api/health")
.header("origin", "https://cards.example")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.headers()["access-control-allow-origin"],
"https://cards.example"
);
assert_eq!(
response.headers()["access-control-allow-credentials"],
"true"
);
}
fn base_settings() -> Settings {
Settings {
database_url: "postgres://unused".into(),
oidc_providers: vec![OidcProviderConfig {
provider: "default".into(),
issuer: "http://localhost:8180/tavolo".into(),
client_id: "tavolo".into(),
client_secret: None,
redirect_uri: "http://localhost:8080/auth/default/callback".into(),
post_login_redirect: "/".into(),
post_logout_redirect: "/".into(),
}],
app_host: "127.0.0.1".into(),
app_port: 0,
redis_url: None,
game_ttl_seconds: 86400,
static_dir: "/nonexistent-web-dist".into(),
hand_ack_timeout_seconds: 30,
turn_timeout_seconds: 30,
deadline_heartbeat_ms: 50,
logging_config: None,
cors_allow_origins: None,
cors_allow_origin_regex: None,
cors_allow_methods: None,
cors_allow_headers: None,
cors_allow_credentials: false,
cors_expose_headers: None,
cors_max_age: 600,
otel_enabled: false,
otel_service_name: "tavolo".into(),
otel_exporter_endpoint: None,
otel_exporter_headers: None,
otel_excluded_paths: vec!["/api/health".into()],
otel_metric_export_interval_millis: 60000,
otel_metrics_poll_interval_seconds: 15,
}
}
@@ -0,0 +1,320 @@
//! End-to-end scopone flow: lobby, four players, live play over the
//! WebSocket and the turn-timeout auto-play, plus SPA/static hosting.
use std::net::SocketAddr;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Map, Value};
use tower::ServiceExt;
use tavolo_app::app;
use tavolo_app::config::Settings;
use tavolo_platform::oidc::OidcProviderConfig;
use tavolo_platform::session::SharedSessionStore;
const PLAYERS: [&str; 4] = ["alice", "bob", "carol", "dave"];
fn settings(static_dir: &str, turn_timeout: i64) -> Settings {
Settings {
database_url: "postgres://unused".into(),
oidc_providers: vec![OidcProviderConfig {
provider: "default".into(),
issuer: "http://localhost:8180/tavolo".into(),
client_id: "tavolo".into(),
client_secret: None,
redirect_uri: "http://localhost:8080/auth/default/callback".into(),
post_login_redirect: "/".into(),
post_logout_redirect: "/".into(),
}],
app_host: "127.0.0.1".into(),
app_port: 0,
redis_url: None,
game_ttl_seconds: 86400,
static_dir: static_dir.into(),
hand_ack_timeout_seconds: 30,
turn_timeout_seconds: turn_timeout,
deadline_heartbeat_ms: 50,
logging_config: None,
cors_allow_origins: None,
cors_allow_origin_regex: None,
cors_allow_methods: None,
cors_allow_headers: None,
cors_allow_credentials: false,
cors_expose_headers: None,
cors_max_age: 600,
otel_enabled: false,
otel_service_name: "tavolo".into(),
otel_exporter_endpoint: None,
otel_exporter_headers: None,
otel_excluded_paths: vec!["/api/health".into()],
otel_metric_export_interval_millis: 60000,
otel_metrics_poll_interval_seconds: 15,
}
}
async fn login(store: &SharedSessionStore, sub: &str, name: &str) -> String {
let id = format!("session-{sub}");
let mut data = Map::new();
data.insert(
"oidc:default:user".into(),
json!({"sub": sub, "preferred_username": name}),
);
store.save(&id, &data, Some(3600)).await.expect("session");
format!("session_id={id}")
}
async fn body_json(response: axum::response::Response) -> Value {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
serde_json::from_slice(&bytes).expect("json")
}
fn post(uri: &str, cookie: &str, body: &Value) -> Request<Body> {
Request::builder()
.method("POST")
.uri(uri)
.header("cookie", cookie)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(body).unwrap()))
.unwrap()
}
fn get(uri: &str, cookie: Option<&str>) -> Request<Body> {
let mut builder = Request::builder().uri(uri);
if let Some(cookie) = cookie {
builder = builder.header("cookie", cookie);
}
builder.body(Body::empty()).unwrap()
}
async fn start_server(router: axum::Router) -> SocketAddr {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
addr
}
fn ws_request(addr: SocketAddr, game_id: &str, cookie: &str) -> Request<()> {
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
let mut request = format!("ws://{addr}/ws/games/{game_id}")
.into_client_request()
.expect("request");
request.headers_mut().insert(
"cookie",
axum::http::HeaderValue::from_str(cookie).expect("cookie"),
);
request
}
async fn read_json(
socket: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> Value {
let message = socket.next().await.expect("message").expect("ok");
match message {
tokio_tungstenite::tungstenite::Message::Text(text) => {
serde_json::from_str(&text).expect("json")
}
other => panic!("expected text, got {other:?}"),
}
}
#[tokio::test]
async fn scopone_lobby_play_and_turn_timeout() {
let temp = std::env::temp_dir().join(format!("tavolo-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(temp.join("assets")).expect("temp dir");
let test_settings = settings(temp.to_str().unwrap(), 1);
let (platform, sessions) = app::in_memory_platform(&test_settings).expect("platform");
let router = app::build_router(platform, sessions.clone(), &test_settings);
// Create the game as alice and join with the other three players.
let alice = login(&sessions, "alice", "Alice").await;
let created = router
.clone()
.oneshot(post(
"/api/games",
&alice,
&json!({"options": {"target_score": 11}}),
))
.await
.unwrap();
assert_eq!(created.status(), StatusCode::CREATED);
let lobby = body_json(created).await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
assert_eq!(lobby["seats_open"], 3);
assert_eq!(lobby["players"][0]["team"], "A");
for (index, player) in PLAYERS.iter().enumerate().skip(1) {
let cookie = login(&sessions, player, &player.to_uppercase()).await;
let response = router
.clone()
.oneshot(post("/api/games/join", &cookie, &json!({"code": code})))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "player {player}");
let body = body_json(response).await;
assert_eq!(body["players"].as_array().unwrap().len(), index + 1);
if index == 3 {
assert_eq!(body["phase"], "playing");
assert_eq!(body["hand_number"], 1);
assert_eq!(body["dealer"], 0);
assert_eq!(body["turn"], 1);
assert_eq!(body["players"][index]["hand"].as_array().unwrap().len(), 10);
}
}
// Bob is on turn (seat 1): his hand is visible to him and hidden from
// alice's personalized view.
let bob = login(&sessions, "bob", "Bob").await;
let bob_view = body_json(
router
.clone()
.oneshot(get(&format!("/api/games/{game_id}"), Some(&bob)))
.await
.unwrap(),
)
.await;
assert_eq!(bob_view["turn"], 1);
assert_eq!(bob_view["your_turn"], true);
assert_eq!(bob_view["players"][1]["hand"].as_array().unwrap().len(), 10);
assert!(bob_view["players"][0].get("hand").is_none());
assert!(bob_view["legal_moves"].is_object());
let alice_view = body_json(
router
.clone()
.oneshot(get(&format!("/api/games/{game_id}"), Some(&alice)))
.await
.unwrap(),
)
.await;
assert_eq!(
alice_view["players"][0]["hand"].as_array().unwrap().len(),
10
);
assert!(alice_view["players"][1].get("hand").is_none());
let addr = start_server(router.clone()).await;
let (mut ws, _) = tokio_tungstenite::connect_async(ws_request(addr, &game_id, &bob))
.await
.expect("bob connects");
let state = read_json(&mut ws).await;
assert_eq!(state["type"], "state");
// The table starts empty and the turn belongs to bob (seat 1).
assert_eq!(state["game"]["turn"], 1);
assert_eq!(state["game"]["table"].as_array().unwrap().len(), 0);
// Bob is on turn on the websocket view.
assert_eq!(state["game"]["your_turn"], true);
let hand = state["game"]["players"][1]["hand"]
.as_array()
.unwrap()
.clone();
let card = hand[0].as_str().unwrap().to_string();
let legal = state["game"]["legal_moves"]
.as_object()
.and_then(|moves| moves.get(&card))
.cloned();
let mut message = json!({"action": "play", "card": card});
if let Some(legal) = legal {
if let Some(option) = legal.as_array().and_then(|options| options.first()) {
message["capture"] = option.clone();
}
}
ws.send(tokio_tungstenite::tungstenite::Message::Text(
message.to_string(),
))
.await
.expect("send play");
let update = read_json(&mut ws).await;
assert_eq!(update["type"], "state");
assert_eq!(update["game"]["turn"], 2);
assert_eq!(update["game"]["last_move"]["seat"], 1);
assert_eq!(update["game"]["last_move"]["card"], card);
// Carol (seat 2) is on turn now with a 1s timeout: the server auto-plays.
let before = update["game"]["players"][2]["cards_left"].as_u64().unwrap();
tokio::time::sleep(std::time::Duration::from_millis(1800)).await;
let after = body_json(
router
.clone()
.oneshot(get(&format!("/api/games/{game_id}"), Some(&alice)))
.await
.unwrap(),
)
.await;
assert_eq!(after["turn"], 3);
assert_eq!(
after["players"][2]["cards_left"].as_u64().unwrap(),
before - 1
);
assert_eq!(after["last_move"]["seat"], 2);
}
#[tokio::test]
async fn spa_shell_and_static_mounts() {
let temp = std::env::temp_dir().join(format!("tavolo-static-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(temp.join("assets")).expect("temp dir");
std::fs::write(temp.join("index.html"), "<html>app shell</html>").expect("index");
std::fs::write(temp.join("assets/card.png"), b"png-bytes").expect("asset");
let test_settings = settings(temp.to_str().unwrap(), 30);
let (platform, sessions) = app::in_memory_platform(&test_settings).expect("platform");
let router = app::build_router(platform, sessions, &test_settings);
let response = router.clone().oneshot(get("/", None)).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers()["content-type"],
"text/html; charset=utf-8"
);
let response = router
.clone()
.oneshot(get("/game/abc-123", None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "SPA fallback");
let response = router
.clone()
.oneshot(get("/static/index.html", None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = router
.clone()
.oneshot(get("/assets/card.png", None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&bytes[..], b"png-bytes");
// Unknown API paths also fall back to the SPA shell, like kaya's glob;
// an unknown OIDC provider path (no route registered) does too.
let response = router
.clone()
.oneshot(get("/api/does-not-exist", None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = router
.clone()
.oneshot(get("/auth/nope/login", None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
std::fs::remove_dir_all(temp).ok();
}
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "tavolo-platform"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Game-independent half of the tavolo platform: lobby, live play, history, ratings"
[dependencies]
async-trait.workspace = true
axum.workspace = true
base64.workspace = true
chrono.workspace = true
futures-util.workspace = true
jsonwebtoken.workspace = true
rand.workspace = true
redis.workspace = true
regex.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
sqlx.workspace = true
thiserror.workspace = true
tokio.workspace = true
tower.workspace = true
tracing.workspace = true
url.workspace = true
uuid.workspace = true
[dev-dependencies]
axum = { workspace = true }
futures-util = { workspace = true }
http-body-util = "0.1"
tokio = { workspace = true }
tokio-tungstenite = "0.24"
tower = { workspace = true }
+79
View File
@@ -0,0 +1,79 @@
//! Authentication helpers on top of the session store.
//!
//! The platform has no application roles: every authenticated user may create
//! and join games. Authorization beyond login is game membership, checked
//! against the seats of the live session.
//!
//! kaya-oidc namespaces its session state per provider as
//! `oidc:{provider}:user`, so the lookup scans for any provider's user.
use serde_json::{Map, Value};
use crate::session::{SessionData, SessionHandle};
const OIDC_PREFIX: &str = "oidc:";
const USER_SUFFIX: &str = ":user";
/// Read-only view of the OIDC userinfo / ID token claims.
#[derive(Debug, Clone)]
pub struct User {
pub sub: String,
pub claims: Map<String, Value>,
}
/// Return the authenticated user stored in a session, if any.
pub fn session_user(session: &SessionData) -> Option<User> {
for key in session.keys() {
if key.starts_with(OIDC_PREFIX) && key.ends_with(USER_SUFFIX) {
if let Some(Value::Object(claims)) = session.get(key) {
let sub = claims.get("sub").and_then(Value::as_str)?.to_string();
return Some(User {
sub,
claims: claims.clone(),
});
}
}
}
None
}
/// Return the authenticated user of a request, if any.
pub async fn current_user(session: &SessionHandle) -> Option<User> {
let session = session.lock().await;
session_user(&session)
}
/// Best-effort human-readable name for a user.
pub fn display_name(user: &User) -> String {
for key in ["name", "preferred_username", "email"] {
if let Some(Value::String(value)) = user.claims.get(key) {
if !value.is_empty() {
return value.clone();
}
}
}
user.sub.clone()
}
/// Truncated SHA-256 of a session id; the cookie is a credential, so it is
/// never exported verbatim (used for span attributes).
pub fn session_tag(session_id: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(session_id.as_bytes());
let hex = format!("{digest:x}");
hex[..16].to_string()
}
/// The names of every provider with a user in this session.
pub fn logged_in_providers(session: &SessionData) -> Vec<String> {
let mut providers = Vec::new();
for key in session.keys() {
if key.starts_with(OIDC_PREFIX) && key.ends_with(USER_SUFFIX) {
let provider = &key[OIDC_PREFIX.len()..key.len() - USER_SUFFIX.len()];
if !provider.is_empty() {
providers.push(provider.to_string());
}
}
}
providers
}
@@ -0,0 +1,64 @@
//! ISO-8601 datetime helpers matching Python's `datetime.isoformat()` /
//! `datetime.fromisoformat()` behavior for UTC timestamps.
use chrono::{DateTime, NaiveDateTime, Utc};
/// Format a UTC timestamp the way Python's `datetime.isoformat()` does for an
/// aware UTC datetime: microseconds are included only when non-zero, and the
/// UTC offset is rendered as `+00:00` (never `Z`).
pub fn to_iso(dt: DateTime<Utc>) -> String {
if dt.timestamp_subsec_micros() == 0 {
dt.format("%Y-%m-%dT%H:%M:%S+00:00").to_string()
} else {
dt.format("%Y-%m-%dT%H:%M:%S%.6f+00:00").to_string()
}
}
/// Parse an ISO-8601 timestamp, tolerating both `Z` and explicit offsets.
///
/// Timestamps without an offset are interpreted as UTC (Python would treat
/// them as local time, but every timestamp this platform produces carries an
/// offset).
pub fn from_iso(value: &str) -> Option<DateTime<Utc>> {
if let Ok(parsed) = DateTime::parse_from_rfc3339(value) {
return Some(parsed.with_timezone(&Utc));
}
if let Ok(naive) = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%.f") {
return Some(DateTime::from_naive_utc_and_offset(naive, Utc));
}
None
}
/// Parse a JSON value carrying an ISO timestamp.
pub fn from_json(value: &serde_json::Value) -> Option<DateTime<Utc>> {
value.as_str().and_then(from_iso)
}
/// Serde `serialize_with` helper emitting Python-compatible ISO-8601.
pub fn serialize_iso<S>(dt: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&to_iso(*dt))
}
/// The current UTC instant, with microsecond precision.
pub fn now() -> DateTime<Utc> {
Utc::now()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn round_trips_with_and_without_microseconds() {
let base = Utc.with_ymd_and_hms(2026, 9, 19, 6, 59, 17).unwrap();
assert_eq!(to_iso(base), "2026-09-19T06:59:17+00:00");
let dt = base + chrono::Duration::microseconds(123456);
assert_eq!(to_iso(dt), "2026-09-19T06:59:17.123456+00:00");
assert_eq!(from_iso("2026-09-19T06:59:17+00:00"), Some(base));
assert_eq!(from_iso("2026-09-19T06:59:17.123456Z"), Some(dt));
}
}
@@ -0,0 +1,250 @@
//! Deadline-driven timeouts, independent of player connections.
//!
//! In-match timeouts (e.g. scopone's per-turn auto-play and hand-end
//! auto-continue) are driven by absolute deadlines, never by which players
//! (or whether any players) are connected. The platform owns the scheduling
//! machinery; the meaning of each deadline belongs to the game engine:
//!
//! * after every mutation the engine's
//! [`GameEngine::next_deadline`](crate::engine::GameEngine::next_deadline)
//! computes the deadline the new state implies, and the scheduler enqueues
//! it in the store's shared deadline queue (a Redis sorted set in
//! production); enqueueing is idempotent;
//! * enqueueing a deadline announces it on a shared pub/sub channel so every
//! worker's consumer wakes immediately; the consumer also wakes on a slow
//! heartbeat, the backstop recovering lost notifications. Due entries are
//! handed to the engine's
//! [`GameEngine::fire_deadline`](crate::engine::GameEngine::fire_deadline)
//! under the per-game lock; the engine revalidates the token against the
//! live state, so duplicate or stale deliveries are harmless.
//!
//! Delivery is at-least-once: an entry is removed from the queue only after
//! it has been processed. Entries whose game has expired are dropped the
//! first time they fire.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use serde_json::json;
use tokio::sync::Notify;
use crate::engine::GameSession;
use crate::errors::{Error, Result};
use crate::registry::GameRegistry;
use crate::stats::SharedStatsRepo;
use crate::store::SharedGameStore;
pub struct DeadlineScheduler {
store: SharedGameStore,
registry: Arc<GameRegistry>,
stats: SharedStatsRepo,
heartbeat: Duration,
retry_delay: Duration,
wake: Notify,
started: AtomicBool,
}
impl DeadlineScheduler {
pub fn new(
store: SharedGameStore,
registry: Arc<GameRegistry>,
stats: SharedStatsRepo,
heartbeat_ms: u64,
retry_ms: u64,
) -> Arc<Self> {
Arc::new(Self {
store,
registry,
stats,
heartbeat: Duration::from_millis(heartbeat_ms),
retry_delay: Duration::from_millis(retry_ms),
wake: Notify::new(),
started: AtomicBool::new(false),
})
}
/// Start the consumer and listener tasks (idempotent).
pub fn start(self: &Arc<Self>) {
if self.started.swap(true, Ordering::SeqCst) {
return;
}
tokio::spawn(run_consumer(Arc::clone(self)));
tokio::spawn(run_listener(Arc::clone(self)));
tracing::debug!("deadline consumer started");
}
/// Enqueue the deadline the current state implies, if any.
///
/// Called after every mutation that can set a deadline and as a backstop
/// when a client connects. Enqueueing is idempotent: an identical entry
/// is already queued with the same due time, so re-adding changes
/// nothing.
pub async fn sync_deadline(self: &Arc<Self>, session: &GameSession) -> Result<()> {
let engine = self.registry.require(&session.game_type)?.clone();
let Some(deadline) = engine.next_deadline(session)? else {
return Ok(());
};
self.start();
let entry = json!({
"game_id": session.id,
"kind": deadline.kind,
"token": deadline.token,
});
let member = serde_json::to_string(&entry)?;
let due_at = deadline.due_at.timestamp() as f64
+ deadline.due_at.timestamp_subsec_micros() as f64 / 1e6;
self.store.add_deadline(&member, due_at).await?;
if let Err(err) = self.store.notify_deadline().await {
tracing::warn!(
"deadline enqueue notification failed; relying on the poll backstop: {err}"
);
}
self.wake.notify_waiters();
Ok(())
}
/// Persist a successful mutation, notify subscribers and enqueue the next
/// deadline. Callers must hold the per-game lock. Handles the terminal
/// transition: the match result is written to Postgres once (guarded by
/// `stats_saved`).
pub async fn finalize_mutation(self: &Arc<Self>, session: &mut GameSession) -> Result<()> {
let engine = self.registry.require(&session.game_type)?.clone();
if engine.is_finished(session) {
if session.finished_at.is_none() {
session.finished_at = Some(crate::datetime::now());
}
if !session.stats_saved {
self.stats.save_match(session, engine.as_ref()).await?;
session.stats_saved = true;
}
tracing::info!("game {} ({}) finished", session.id, session.game_type);
}
self.store.save(session).await?;
self.store.publish(&session.id).await?;
self.sync_deadline(session).await?;
Ok(())
}
/// Fire a single due deadline entry.
///
/// The engine revalidates the entry against the live state under the
/// per-game lock; stale or foreign entries are discarded without effect.
/// The entry is removed from the queue once handled; if handling fails
/// unexpectedly (e.g. the lock cannot be acquired), the entry is left in
/// the queue so another consumer retries it.
pub async fn process_due(self: &Arc<Self>, member: &str) -> Result<()> {
let Ok(entry) = serde_json::from_str::<serde_json::Value>(member) else {
tracing::warn!("deadline consumer: dropping malformed entry {member:?}");
self.store.remove_deadline(member).await?;
return Ok(());
};
let game_id = entry.get("game_id").and_then(serde_json::Value::as_str);
let kind = entry.get("kind").and_then(serde_json::Value::as_str);
let token = entry
.get("token")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let (Some(game_id), Some(kind)) = (game_id, kind) else {
self.store.remove_deadline(member).await?;
return Ok(());
};
let lock = self.store.acquire_lock(game_id).await?;
let outcome = async {
let Some(mut session) = self.store.load(game_id).await? else {
return Ok(());
};
let engine = self.registry.require(&session.game_type)?.clone();
match engine.fire_deadline(&mut session, kind, token) {
Ok(()) => self.finalize_mutation(&mut session).await,
Err(err) if err.as_game().is_some() => {
// Stale or inapplicable entry: nothing to do.
Ok(())
}
Err(err) => Err(err),
}
}
.await;
lock.release().await?;
outcome?;
self.store.remove_deadline(member).await?;
Ok(())
}
}
async fn run_consumer(scheduler: Arc<DeadlineScheduler>) {
loop {
// Clear before polling so an enqueue racing the poll re-wakes us.
let mut delay = scheduler.heartbeat;
let mut failed = false;
match scheduler.store.due_deadlines(now_secs(), 32).await {
Ok(members) => {
for member in members {
match scheduler.process_due(&member).await {
Ok(()) => {}
Err(err) => {
tracing::error!(
"deadline consumer: failed to process {member:?}: {err}"
);
failed = true;
}
}
}
}
Err(err) => {
tracing::error!("deadline consumer: poll failed; retrying: {err}");
failed = true;
}
}
match scheduler.store.next_deadline().await {
Ok(Some(next_due)) => {
let until = (next_due - now_secs()).max(0.0);
delay = delay.min(Duration::from_secs_f64(until));
}
Ok(None) => {}
Err(err) => {
tracing::error!("deadline consumer: next_deadline failed: {err}");
failed = true;
}
}
if failed {
// A failed entry is still due: back off briefly instead of
// polling it again immediately.
delay = scheduler.retry_delay;
}
tokio::select! {
_ = scheduler.wake.notified() => {}
_ = tokio::time::sleep(delay) => {}
}
}
}
async fn run_listener(scheduler: Arc<DeadlineScheduler>) {
loop {
match scheduler.store.subscribe_deadlines().await {
Ok(mut events) => {
use futures_util::StreamExt;
while let Some(()) = events.next().await {
scheduler.wake.notify_waiters();
}
}
Err(err) => {
tracing::error!("deadline listener failed; reconnecting: {err}");
}
}
tokio::time::sleep(scheduler.retry_delay).await;
}
}
fn now_secs() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or_default()
}
/// Convenience constructor returning an error when the registry is empty.
pub fn scheduler_error() -> Error {
Error::Internal("deadline scheduler misconfigured".into())
}
+65
View File
@@ -0,0 +1,65 @@
//! Chess-style Elo ratings, generalized to two-team matches.
//!
//! Every player starts at [`INITIAL_RATING`]. A team's rating is the mean of
//! its members' current ratings, so the usual chess formula applies unchanged
//! between the two teams:
//!
//! * expected score `E = 1 / (1 + 10 ** ((R_opponent - R_team) / 400))`
//! * actual score `S` is 1 for a win and 0 for a loss (matches never draw)
//! * every member of a team gains/loses the same `round(K * (S - E))`
//!
//! Deltas are rounded to integers and ratings are stored as integers, so the
//! system is exactly zero-sum. Rounding uses Python's banker's rounding
//! (`round()` half-to-even), which Rust exposes as `f64::round_ties_even`.
pub const INITIAL_RATING: i64 = 1500;
pub const K_FACTOR: f64 = 32.0;
/// Expected score (0..1) of a side rated `rating` against `opponent_rating`.
pub fn expected_score(rating: f64, opponent_rating: f64) -> f64 {
1.0 / (1.0 + 10.0_f64.powf((opponent_rating - rating) / 400.0))
}
/// A team's rating is the mean of its members' ratings.
pub fn team_rating(ratings: &[f64]) -> Result<f64, &'static str> {
if ratings.is_empty() {
return Err("a team needs at least one rating");
}
Ok(ratings.iter().sum::<f64>() / ratings.len() as f64)
}
/// Rating change applied to each member of team A.
///
/// `winner_team` is 0 when team A won, 1 when team B won. Team B members
/// change by the negation of the returned value (zero-sum).
pub fn match_delta(team_a_ratings: &[f64], team_b_ratings: &[f64], winner_team: usize) -> i64 {
let rating_a = team_rating(team_a_ratings).unwrap_or_default();
let rating_b = team_rating(team_b_ratings).unwrap_or_default();
let expected = expected_score(rating_a, rating_b);
let score = if winner_team == 0 { 1.0 } else { 0.0 };
(K_FACTOR * (score - expected)).round_ties_even() as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn equal_ratings_move_16_points() {
assert_eq!(match_delta(&[1500.0], &[1500.0], 0), 16);
assert_eq!(match_delta(&[1500.0], &[1500.0], 1), -16);
}
#[test]
fn team_rating_is_the_mean() {
assert_eq!(team_rating(&[1400.0, 1600.0]).unwrap(), 1500.0);
assert!(team_rating(&[]).is_err());
}
#[test]
fn banker_rounding_matches_python() {
// Python: round(32 * (1 - 0.5)) == 16; ties-to-even for exact halves.
assert_eq!((16.5_f64).round_ties_even(), 16.0);
assert_eq!((17.5_f64).round_ties_even(), 18.0);
}
}
+335
View File
@@ -0,0 +1,335 @@
//! The contract between the platform and a game implementation.
//!
//! The platform owns everything that is game-independent: the lobby, live
//! game storage, websockets, timeouts, match history and leaderboards. A
//! game (e.g. scopone scientifico) implements [`GameEngine`] and plugs into
//! the platform through [`crate::registry::GameRegistry`]; the platform
//! never imports a concrete game module.
//!
//! The central type is [`GameSession`]: the platform-owned envelope carrying
//! the lifecycle metadata (id, join code, seats, timestamps) plus an opaque
//! `state` blob that only the engine interprets. The engine serializes and
//! deserializes that blob so the store stays game-agnostic.
use std::any::Any;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::errors::{GameError, Result};
/// One occupied chair in a game, owned by the platform.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Seat {
pub user_sub: String,
pub display_name: String,
/// Game-defined team label ("A"/"B", ...); `None` for games without
/// fixed teams.
pub team: Option<String>,
}
impl Seat {
pub fn new(user_sub: impl Into<String>, display_name: impl Into<String>) -> Self {
Self {
user_sub: user_sub.into(),
display_name: display_name.into(),
team: None,
}
}
pub fn to_json(&self) -> Value {
serde_json::json!({
"user_sub": self.user_sub,
"display_name": self.display_name,
"team": self.team,
})
}
pub fn from_json(data: &Value) -> Result<Self> {
Ok(Self {
user_sub: data
.get("user_sub")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
display_name: data
.get("display_name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
team: data.get("team").and_then(Value::as_str).map(str::to_string),
})
}
}
/// The platform-owned envelope of a live game.
///
/// `state` is opaque to the platform: only the engine registered for
/// `game_type` reads or writes it.
pub struct GameSession {
pub id: String,
pub game_type: String,
pub join_code: String,
pub creator_sub: String,
pub players: Vec<Seat>,
pub created_at: Option<DateTime<Utc>>,
pub finished_at: Option<DateTime<Utc>>,
/// Set once the finished match has been copied to Postgres.
pub stats_saved: bool,
pub state: Option<Box<dyn Any + Send + Sync>>,
}
impl GameSession {
/// A fresh lobby session created by [`GameEngine::create`].
pub fn new(
id: impl Into<String>,
game_type: impl Into<String>,
join_code: impl Into<String>,
) -> Self {
Self {
id: id.into(),
game_type: game_type.into(),
join_code: join_code.into(),
creator_sub: String::new(),
players: Vec::new(),
created_at: None,
finished_at: None,
stats_saved: false,
state: None,
}
}
/// Whether `sub` occupies a seat in this game.
pub fn seated(&self, sub: &str) -> bool {
self.players.iter().any(|seat| seat.user_sub == sub)
}
/// The seat occupied by `sub`, if any.
pub fn seat_of(&self, sub: &str) -> Option<&Seat> {
self.players.iter().find(|seat| seat.user_sub == sub)
}
/// The seat index occupied by `sub`, if any.
pub fn seat_index_of(&self, sub: &str) -> Option<usize> {
self.players.iter().position(|seat| seat.user_sub == sub)
}
}
/// A timeout the engine wants the platform to fire.
///
/// `kind` is an engine-defined string (e.g. `"turn"`); `token` is an opaque
/// revalidation token: when the deadline fires, the engine must recompute it
/// from the live session and refuse to act (returning an error) if it no
/// longer matches, which makes duplicate or overtaken deliveries harmless.
#[derive(Debug, Clone, PartialEq)]
pub struct Deadline {
pub kind: String,
pub due_at: DateTime<Utc>,
pub token: String,
}
/// The outcome of a finished match for one participant.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlayerResult {
pub user_sub: String,
pub seat: i64,
pub won: bool,
pub team: Option<String>,
/// Points scored, aggregated by the leaderboard.
pub score: f64,
/// Game-specific extras persisted alongside the participation.
pub details: Value,
}
/// The game-independent outcome of a finished match.
///
/// `teams` groups the participants' subs into the sides the Elo rating treats
/// as opponents (exactly two for now); `winner_team` is the index of the
/// winning side. `summary` is persisted verbatim as the match's JSON
/// `result` column.
#[derive(Debug, Clone)]
pub struct MatchResult {
pub teams: Vec<Vec<String>>,
pub winner_team: usize,
pub players: Vec<PlayerResult>,
pub summary: Value,
}
impl MatchResult {
pub fn new(
teams: Vec<Vec<String>>,
winner_team: usize,
players: Vec<PlayerResult>,
summary: Value,
) -> Self {
Self {
teams,
winner_team,
players,
summary,
}
}
}
/// The interface every playable game implements.
///
/// Implementations must be deterministic and I/O-free apart from logging:
/// all persistence, locking and transport concerns belong to the platform.
/// Rule violations are reported by returning [`GameError`] values.
pub trait GameEngine: Send + Sync {
/// Unique id carried by sessions and persisted matches.
fn id(&self) -> &'static str;
/// Human-readable name, for the lobby UI.
fn name(&self) -> &'static str;
fn description(&self) -> &'static str;
fn min_players(&self) -> usize;
fn max_players(&self) -> usize;
/// JSON-schema fragment describing the game-specific creation options
/// accepted by [`GameEngine::create`] (exposed via `GET /api/game-types`).
fn options_schema(&self) -> Value;
/// Initialize `session.state` for a fresh lobby game.
///
/// The platform has already filled the session envelope and seated the
/// creator (`session.players[0]`, without a team label yet). Assign the
/// creator's team here if the game has fixed teams, then validate
/// `options` and initialize `session.state`.
fn create(&self, session: &mut GameSession, options: &Map<String, Value>) -> Result<()>;
/// Seat a player, starting the match when the lobby fills up.
///
/// Must append a [`Seat`] to `session.players` and mirror whatever
/// per-player state the game keeps in `session.state`.
fn join(&self, session: &mut GameSession, user_sub: &str, display_name: &str) -> Result<()>;
/// Apply one player action received over the websocket.
///
/// `payload` is the client message minus its `action` field. Unknown
/// actions and rule violations return [`GameError`].
fn handle_action(
&self,
session: &mut GameSession,
user_sub: &str,
action: &str,
payload: &Map<String, Value>,
) -> Result<()>;
/// The personalized game view for one player.
///
/// Must hide information the player is not entitled to (e.g. other
/// players' hands). The platform merges in the envelope fields
/// (`id`, `join_code`, `game_type`) itself.
fn view_for(&self, session: &GameSession, user_sub: &str) -> Value;
/// Game-specific fields of the lobby payload (options echo, phase).
fn lobby_view(&self, session: &GameSession) -> Value;
/// Whether the game is still waiting for players.
fn in_lobby(&self, session: &GameSession) -> bool;
/// Whether the match is over and a result can be extracted.
fn is_finished(&self, session: &GameSession) -> bool;
/// The outcome of the match. Only called when finished.
fn result(&self, session: &GameSession) -> Result<MatchResult>;
/// Serialize the opaque game state to plain JSON.
fn state_to_json(&self, state: &(dyn Any + Send + Sync)) -> Result<Value>;
/// Rebuild the opaque game state from its JSON form.
fn state_from_json(&self, data: &Value) -> Result<Box<dyn Any + Send + Sync>>;
/// The deadline the current state implies, if any.
///
/// Called after every mutation; the platform enqueues the returned
/// deadline (enqueueing is idempotent) and never removes previously
/// enqueued ones — stale entries are discarded at fire time by
/// [`GameEngine::fire_deadline`] revalidation.
fn next_deadline(&self, session: &GameSession) -> Result<Option<Deadline>>;
/// Apply the timeout action for a due deadline.
///
/// Must recompute the current deadline and return a [`GameError`] without
/// acting when `kind`/`token` no longer match the live state.
fn fire_deadline(&self, session: &mut GameSession, kind: &str, token: &str) -> Result<()>;
/// Extra fields merged into the `game_over` websocket message.
fn game_over_view(&self, _session: &GameSession, _user_sub: &str) -> Value {
serde_json::json!({})
}
}
/// Build the JSON envelope for a session: platform fields plus the engine's
/// state blob (mirrors `tavolo.platform.store.session_to_json`).
pub fn session_to_json(session: &GameSession, engine: &dyn GameEngine) -> Result<Value> {
let state = match session.state.as_ref() {
Some(state) => engine.state_to_json(state.as_ref())?,
None => Value::Object(Map::new()),
};
Ok(serde_json::json!({
"id": session.id,
"game_type": session.game_type,
"join_code": session.join_code,
"creator_sub": session.creator_sub,
"players": session.players.iter().map(Seat::to_json).collect::<Vec<_>>(),
"created_at": session.created_at.map(crate::datetime::to_iso),
"finished_at": session.finished_at.map(crate::datetime::to_iso),
"stats_saved": session.stats_saved,
"state": state,
}))
}
/// Rebuild a session from its JSON envelope, delegating the state blob to
/// the engine registered for its `game_type` (mirrors
/// `tavolo.platform.store.session_from_json`).
pub fn session_from_json(data: &Value, engine: &dyn GameEngine) -> Result<GameSession> {
let game_type = data
.get("game_type")
.and_then(Value::as_str)
.ok_or_else(|| GameError::illegal_move("session is missing game_type"))?
.to_string();
if game_type != engine.id() {
return Err(
GameError::unknown_game_type(format!("unknown game_type: {game_type:?}")).into(),
);
}
let players = data
.get("players")
.and_then(Value::as_array)
.map(|list| list.iter().map(Seat::from_json).collect::<Result<Vec<_>>>())
.transpose()?
.unwrap_or_default();
let state = match data.get("state") {
Some(Value::Object(map)) if !map.is_empty() => {
Some(engine.state_from_json(data.get("state").unwrap())?)
}
_ => Some(engine.state_from_json(&Value::Object(Map::new()))?),
};
Ok(GameSession {
id: data
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
game_type,
join_code: data
.get("join_code")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
creator_sub: data
.get("creator_sub")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
players,
created_at: data.get("created_at").and_then(crate::datetime::from_json),
finished_at: data.get("finished_at").and_then(crate::datetime::from_json),
stats_saved: data
.get("stats_saved")
.and_then(Value::as_bool)
.unwrap_or(false),
state,
})
}
+101
View File
@@ -0,0 +1,101 @@
//! Typed errors shared between the platform and game implementations.
//!
//! Game engines return [`GameError`] on rule/validation failures; the
//! platform translates them into 4xx HTTP responses or `error` WebSocket
//! messages, using [`GameError::code`] as the machine-readable error code.
//! Engines themselves stay transport-agnostic.
//!
//! Mirrors `tavolo.platform.errors` from the Python implementation.
use thiserror::Error;
/// Base error for every rule/validation failure a game can raise.
#[derive(Debug, Clone, Error)]
#[error("{message}")]
pub struct GameError {
/// Machine-readable code carried by WebSocket `error` messages.
pub code: &'static str,
/// Human-readable failure reason.
pub message: String,
}
impl GameError {
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
/// The requested action violates the rules of the game.
pub fn illegal_move(message: impl Into<String>) -> Self {
Self::new("illegal_move", message)
}
/// A player attempted to act out of turn.
pub fn not_your_turn(message: impl Into<String>) -> Self {
Self::new("illegal_move", message)
}
/// An action was attempted before the game left the lobby.
pub fn game_not_started(message: impl Into<String>) -> Self {
Self::new("illegal_move", message)
}
/// An action was attempted after the match ended.
pub fn game_finished(message: impl Into<String>) -> Self {
Self::new("illegal_move", message)
}
/// A game already has its full complement of players.
pub fn lobby_full(message: impl Into<String>) -> Self {
Self::new("illegal_move", message)
}
/// A player tried to join a game they are already seated in.
pub fn already_joined(message: impl Into<String>) -> Self {
Self::new("illegal_move", message)
}
/// No live game exists for the given id or join code.
pub fn game_not_found(message: impl Into<String>) -> Self {
Self::new("illegal_move", message)
}
/// A request named a `game_type` no registered engine provides.
pub fn unknown_game_type(message: impl Into<String>) -> Self {
Self::new("unknown_game_type", message)
}
}
/// Errors raised by the platform outside game rules (storage, transport,
/// serialization).
#[derive(Debug, Error)]
pub enum Error {
#[error(transparent)]
Game(#[from] GameError),
#[error("store error: {0}")]
Store(String),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Redis(#[from] redis::RedisError),
#[error(transparent)]
Sql(#[from] sqlx::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("{0}")]
Internal(String),
}
impl Error {
/// The game error behind this error, when there is one.
pub fn as_game(&self) -> Option<&GameError> {
match self {
Error::Game(err) => Some(err),
_ => None,
}
}
}
pub type Result<T, E = Error> = std::result::Result<T, E>;
+138
View File
@@ -0,0 +1,138 @@
//! JSON request/response helpers for the HTTP handlers.
//!
//! Mirrors `tavolo.platform.http` from the Python implementation: the error
//! messages produced here are part of the public API contract.
use std::collections::HashMap;
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use serde_json::{json, Value};
/// Raised when the request body is not valid JSON or not a JSON object.
#[derive(Debug, Clone, thiserror::Error)]
#[error("{0}")]
pub struct JsonRequestError(pub String);
/// Drain and parse a request body as a JSON object.
pub fn read_json(body: &[u8]) -> Result<Value, JsonRequestError> {
if body.is_empty() {
return Err(JsonRequestError("empty body".into()));
}
let parsed: Value =
serde_json::from_slice(body).map_err(|_| JsonRequestError("invalid JSON".into()))?;
if !parsed.is_object() {
return Err(JsonRequestError("JSON body must be an object".into()));
}
Ok(parsed)
}
/// Like [`read_json`] but treats an empty body as `{}`.
pub fn read_json_optional(body: &[u8]) -> Result<Value, JsonRequestError> {
match read_json(body) {
Ok(value) => Ok(value),
Err(JsonRequestError(message)) if message == "empty body" => Ok(json!({})),
Err(err) => Err(err),
}
}
/// Parse a raw query string into a mapping of param name to all values.
pub fn extract_query_params(query: Option<&str>) -> HashMap<String, Vec<String>> {
let mut params: HashMap<String, Vec<String>> = HashMap::new();
if let Some(query) = query {
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
params
.entry(key.into_owned())
.or_default()
.push(value.into_owned());
}
}
params
}
/// Send `payload` as a JSON response.
pub fn send_json(status: StatusCode, payload: &Value) -> Response {
let body = serde_json::to_vec(payload).unwrap_or_else(|_| b"null".to_vec());
(status, [(header::CONTENT_TYPE, "application/json")], body).into_response()
}
/// Send a JSON error envelope.
pub fn send_error(status: StatusCode, message: impl Into<String>) -> Response {
let message = message.into();
send_json(status, &json!({ "error": message }))
}
/// Send a plain body with an explicit content type.
pub fn send_text(status: StatusCode, body: impl Into<String>, content_type: &str) -> Response {
(
status,
[(header::CONTENT_TYPE, content_type.to_string())],
body.into(),
)
.into_response()
}
/// Send an empty response with a `Location` header.
pub fn redirect(location: impl Into<String>) -> Response {
(StatusCode::FOUND, [(header::LOCATION, location.into())]).into_response()
}
/// The 401 JSON envelope used by the authentication gate.
pub fn unauthorized() -> Response {
send_json(
StatusCode::UNAUTHORIZED,
&json!({ "error": "unauthenticated" }),
)
}
/// Render a JSON value the way Python's `repr()` would, so error messages
/// like `unknown game_type: 'foo'` match the Python server.
pub fn py_repr(value: &Value) -> String {
match value {
Value::Null => "None".to_string(),
Value::Bool(true) => "True".to_string(),
Value::Bool(false) => "False".to_string(),
Value::Number(number) => {
if let Some(int) = number.as_i64() {
int.to_string()
} else if let Some(float) = number.as_f64() {
if float.fract() == 0.0 && float.is_finite() {
format!("{float:.1}")
} else {
float.to_string()
}
} else {
number.to_string()
}
}
Value::String(text) => py_repr_str(text),
Value::Array(items) => {
let rendered: Vec<String> = items.iter().map(py_repr).collect();
format!("[{}]", rendered.join(", "))
}
Value::Object(map) => {
let rendered: Vec<String> = map
.iter()
.map(|(key, value)| format!("{}: {}", py_repr_str(key), py_repr(value)))
.collect();
format!("{{{}}}", rendered.join(", "))
}
}
}
fn py_repr_str(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 2);
out.push('\'');
for ch in text.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'\'' => out.push_str("\\'"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(ch),
}
}
out.push('\'');
out
}
+34
View File
@@ -0,0 +1,34 @@
#![allow(clippy::result_large_err)]
//! The game-independent half of tavolo: lobby, live play, history, ratings.
//!
//! This crate hosts every concern that does not depend on the rules of a
//! specific game: the lobby HTTP API, the live-game store, the websocket
//! endpoint, the deadline scheduler, match persistence and the Elo
//! leaderboards. A game plugs in by implementing [`engine::GameEngine`] and
//! registering it in a [`registry::GameRegistry`].
pub mod auth;
pub mod datetime;
pub mod deadlines;
pub mod elo;
pub mod engine;
pub mod errors;
pub mod http;
pub mod models;
pub mod oidc;
pub mod pagination;
pub mod platform;
pub mod registry;
pub mod routes;
pub mod session;
pub mod stats;
pub mod store;
pub mod ws;
pub use engine::{
session_from_json, session_to_json, Deadline, GameEngine, GameSession, MatchResult,
PlayerResult, Seat,
};
pub use errors::{Error, GameError, Result};
pub use platform::{Platform, SharedPlatform};
pub use registry::GameRegistry;
@@ -0,0 +1,43 @@
//! Database row models: match statistics persisted in Postgres.
//!
//! The schema is unchanged from the Python implementation
//! (`server/migrations/`):
//!
//! * [`MatchRow`] — one row per finished match; game-specific data lives in
//! the JSON `result` column produced by the engine.
//! * [`MatchPlayerRow`] — one row per participant.
//! * [`PlayerRatingRow`] — current Elo rating per `(user_sub, game_type)`.
use chrono::{DateTime, Utc};
use serde_json::Value;
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct MatchRow {
pub id: uuid::Uuid,
pub game_type: String,
pub started_at: DateTime<Utc>,
pub finished_at: DateTime<Utc>,
pub result: Value,
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct MatchPlayerRow {
pub id: uuid::Uuid,
pub match_id: uuid::Uuid,
pub user_sub: String,
pub display_name: String,
pub seat: i16,
pub team: Option<String>,
pub won: bool,
pub score: f64,
pub elo_delta: Option<i16>,
pub details: Value,
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct PlayerRatingRow {
pub user_sub: String,
pub game_type: String,
pub rating: i32,
pub matches_played: i32,
}
+551
View File
@@ -0,0 +1,551 @@
//! OpenID Connect login flows (discovery, PKCE, ID-token validation).
//!
//! Mirrors `kaya.oidc`'s `OIDCProviders` / `OIDCMixin`: each configured
//! provider gets `/auth/<provider>/login`, `/auth/<provider>/callback` and
//! `/auth/<provider>/logout` routes, namespaces its session state as
//! `oidc:<provider>:...`, and shares one session store. The configured
//! strategy is single-identity: a successful login evicts every other
//! provider's identity.
use std::collections::HashMap;
use std::sync::Arc;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use rand::RngCore;
use regex::Regex;
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use crate::errors::{Error, Result};
use crate::session::SessionHandle;
pub const BASE_PATH: &str = "/auth";
const SCOPES: &str = "openid email profile";
const IDENTITY_FIELDS: [&str; 4] = ["user", "id_token", "access_token", "refresh_token"];
/// Configuration for one OpenID Connect provider.
#[derive(Debug, Clone)]
pub struct OidcProviderConfig {
pub provider: String,
pub issuer: String,
pub client_id: String,
pub client_secret: Option<String>,
pub redirect_uri: String,
pub post_login_redirect: String,
pub post_logout_redirect: String,
}
/// A flow error surfaced to the browser as a plain-text 400 (like kaya's
/// callback error responses).
#[derive(Debug, Clone, thiserror::Error)]
pub enum OidcFlowError {
#[error("{0}")]
BadRequest(String),
#[error("{0}")]
Internal(String),
}
impl OidcFlowError {
pub fn status(&self) -> axum::http::StatusCode {
match self {
OidcFlowError::BadRequest(_) => axum::http::StatusCode::BAD_REQUEST,
OidcFlowError::Internal(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
fn random_token(bytes: usize) -> String {
let mut buf = vec![0u8; bytes];
rand::thread_rng().fill_bytes(&mut buf);
URL_SAFE_NO_PAD.encode(buf)
}
struct OidcClient {
config: OidcProviderConfig,
http: reqwest::Client,
metadata: Mutex<Option<Value>>,
jwks: Mutex<Option<Value>>,
}
impl OidcClient {
fn new(config: OidcProviderConfig) -> Self {
Self {
config,
http: reqwest::Client::new(),
metadata: Mutex::new(None),
jwks: Mutex::new(None),
}
}
async fn metadata(&self) -> Result<Value, OidcFlowError> {
let mut cache = self.metadata.lock().await;
if let Some(metadata) = cache.as_ref() {
return Ok(metadata.clone());
}
let url = format!(
"{}/.well-known/openid-configuration",
self.config.issuer.trim_end_matches('/')
);
let response = self
.http
.get(&url)
.send()
.await
.map_err(|err| OidcFlowError::Internal(format!("OIDC discovery failed: {err}")))?;
let response = response
.error_for_status()
.map_err(|err| OidcFlowError::Internal(format!("OIDC discovery failed: {err}")))?;
let metadata: Value = response
.json()
.await
.map_err(|err| OidcFlowError::Internal(format!("OIDC discovery failed: {err}")))?;
*cache = Some(metadata.clone());
Ok(metadata)
}
async fn jwks(&self) -> Result<Value, OidcFlowError> {
{
let cache = self.jwks.lock().await;
if let Some(jwks) = cache.as_ref() {
return Ok(jwks.clone());
}
}
let metadata = self.metadata().await?;
let jwks_uri = metadata
.get("jwks_uri")
.and_then(Value::as_str)
.ok_or_else(|| {
OidcFlowError::Internal("OIDC provider does not advertise a jwks_uri".into())
})?;
let response = self
.http
.get(jwks_uri)
.send()
.await
.map_err(|err| OidcFlowError::Internal(format!("JWKS fetch failed: {err}")))?;
let response = response
.error_for_status()
.map_err(|err| OidcFlowError::Internal(format!("JWKS fetch failed: {err}")))?;
let jwks: Value = response
.json()
.await
.map_err(|err| OidcFlowError::Internal(format!("JWKS fetch failed: {err}")))?;
*self.jwks.lock().await = Some(jwks.clone());
Ok(jwks)
}
/// Return (authorization_url, state, nonce, code_verifier).
async fn build_authorization_url(
&self,
) -> Result<(String, String, String, String), OidcFlowError> {
let metadata = self.metadata().await?;
let endpoint = metadata
.get("authorization_endpoint")
.and_then(Value::as_str)
.ok_or_else(|| {
OidcFlowError::Internal(
"OIDC provider does not advertise an authorization_endpoint".into(),
)
})?;
let state = random_token(32);
let nonce = random_token(32);
let code_verifier = random_token(64);
let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes()));
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
serializer.append_pair("client_id", &self.config.client_id);
serializer.append_pair("response_type", "code");
serializer.append_pair("scope", SCOPES);
serializer.append_pair("redirect_uri", &self.config.redirect_uri);
serializer.append_pair("state", &state);
serializer.append_pair("nonce", &nonce);
serializer.append_pair("code_challenge", &code_challenge);
serializer.append_pair("code_challenge_method", "S256");
let query = serializer.finish();
Ok((format!("{endpoint}?{query}"), state, nonce, code_verifier))
}
async fn fetch_token(
&self,
code: &str,
code_verifier: &str,
) -> Result<Map<String, Value>, OidcFlowError> {
let metadata = self.metadata().await?;
let endpoint = metadata
.get("token_endpoint")
.and_then(Value::as_str)
.ok_or_else(|| {
OidcFlowError::Internal("OIDC provider does not advertise a token_endpoint".into())
})?;
let form = [
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", self.config.redirect_uri.as_str()),
("client_id", self.config.client_id.as_str()),
("code_verifier", code_verifier),
];
let mut request = self.http.post(endpoint).form(&form);
if let Some(secret) = &self.config.client_secret {
request = request.basic_auth(&self.config.client_id, Some(secret));
}
let response = request
.send()
.await
.map_err(|err| OidcFlowError::Internal(format!("token request failed: {err}")))?;
let response = response
.error_for_status()
.map_err(|err| OidcFlowError::Internal(format!("token request failed: {err}")))?;
response
.json()
.await
.map_err(|err| OidcFlowError::Internal(format!("token response is not JSON: {err}")))
}
async fn validate_id_token(
&self,
id_token: &str,
nonce: &str,
) -> Result<Map<String, Value>, OidcFlowError> {
let payload = self.decode_id_token(id_token).await?;
if payload.get("nonce").and_then(Value::as_str) != Some(nonce) {
return Err(OidcFlowError::BadRequest(
"Authentication failed: ID token nonce mismatch".into(),
));
}
Ok(payload)
}
async fn decode_id_token(&self, id_token: &str) -> Result<Map<String, Value>, OidcFlowError> {
let header = jsonwebtoken::decode_header(id_token)
.map_err(|err| OidcFlowError::BadRequest(format!("Authentication failed: {err}")))?;
let alg_name = format!("{:?}", header.alg);
if alg_name == "none" {
return Err(OidcFlowError::BadRequest(
"Authentication failed: Unsigned ID tokens are not allowed".into(),
));
}
let allowed = [
"RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512",
"EdDSA",
];
if !allowed.contains(&alg_name.as_str()) {
return Err(OidcFlowError::BadRequest(format!(
"Authentication failed: ID token algorithm {alg_name} is not allowed"
)));
}
let jwks = self.jwks().await?;
let keys = jwks.get("keys").and_then(Value::as_array).ok_or_else(|| {
OidcFlowError::BadRequest("Authentication failed: JWKS contains no keys".into())
})?;
let selected = if let Some(kid) = header.kid.as_deref() {
keys.iter()
.find(|key| key.get("kid").and_then(Value::as_str) == Some(kid))
.ok_or_else(|| {
OidcFlowError::BadRequest(format!(
"Authentication failed: No signing key found for kid {kid}"
))
})?
} else if keys.len() == 1 {
&keys[0]
} else {
return Err(OidcFlowError::BadRequest(
"Authentication failed: ID token has no kid and JWKS contains multiple keys".into(),
));
};
let jwk: jsonwebtoken::jwk::Jwk = serde_json::from_value(selected.clone())
.map_err(|err| OidcFlowError::BadRequest(format!("Authentication failed: {err}")))?;
let key = jsonwebtoken::DecodingKey::from_jwk(&jwk)
.map_err(|err| OidcFlowError::BadRequest(format!("Authentication failed: {err}")))?;
let mut validation = jsonwebtoken::Validation::new(header.alg);
validation.set_audience(&[self.config.client_id.as_str()]);
validation.validate_nbf = true;
let data = jsonwebtoken::decode::<Map<String, Value>>(id_token, &key, &validation)
.map_err(|err| OidcFlowError::BadRequest(format!("Authentication failed: {err}")))?;
let claims = data.claims;
let metadata = self.metadata().await?;
let expected = metadata
.get("issuer")
.and_then(Value::as_str)
.ok_or_else(|| {
OidcFlowError::Internal("OIDC provider does not advertise an issuer".into())
})?;
let expected = if expected.contains("{tenantid}") || expected.contains("{tenantId}") {
let tenant = claims.get("tid").and_then(Value::as_str).ok_or_else(|| {
OidcFlowError::BadRequest("Authentication failed: Invalid issuer".into())
})?;
let re = Regex::new(r"(?i)\{tenantid\}").expect("valid tenant regex");
re.replace(expected, tenant).to_string()
} else {
expected.to_string()
};
if claims.get("iss").and_then(Value::as_str) != Some(expected.as_str()) {
return Err(OidcFlowError::BadRequest(
"Authentication failed: Invalid issuer".into(),
));
}
Ok(claims)
}
async fn fetch_userinfo(
&self,
access_token: &str,
) -> Result<Map<String, Value>, OidcFlowError> {
let metadata = self.metadata().await?;
let endpoint = metadata
.get("userinfo_endpoint")
.and_then(Value::as_str)
.ok_or_else(|| {
OidcFlowError::Internal(
"OIDC provider does not advertise a userinfo_endpoint".into(),
)
})?;
let response = self
.http
.get(endpoint)
.bearer_auth(access_token)
.send()
.await
.map_err(|err| OidcFlowError::Internal(format!("userinfo request failed: {err}")))?;
let response = response
.error_for_status()
.map_err(|err| OidcFlowError::Internal(format!("userinfo request failed: {err}")))?;
response
.json()
.await
.map_err(|err| OidcFlowError::Internal(format!("userinfo response is not JSON: {err}")))
}
async fn build_logout_url(
&self,
id_token: Option<&str>,
) -> Result<Option<String>, OidcFlowError> {
let metadata = self.metadata().await?;
let Some(endpoint) = metadata.get("end_session_endpoint").and_then(Value::as_str) else {
return Ok(None);
};
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
serializer.append_pair(
"post_logout_redirect_uri",
&self.config.post_logout_redirect,
);
if let Some(id_token) = id_token {
serializer.append_pair("id_token_hint", id_token);
}
Ok(Some(format!("{endpoint}?{}", serializer.finish())))
}
}
pub struct OidcProvider {
config: OidcProviderConfig,
client: OidcClient,
}
impl OidcProvider {
fn key(&self, name: &str) -> String {
format!("oidc:{}:{}", self.config.provider, name)
}
pub fn name(&self) -> &str {
&self.config.provider
}
/// Start the login flow: build the authorization URL and stash state,
/// nonce and PKCE verifier in the session; returns the redirect target.
pub async fn begin_login(&self, session: &SessionHandle) -> Result<String, OidcFlowError> {
let (url, state, nonce, code_verifier) = self.client.build_authorization_url().await?;
let mut session = session.lock().await;
session.insert(self.key("state"), json!(state));
session.insert(self.key("nonce"), json!(nonce));
session.insert(self.key("code_verifier"), json!(code_verifier));
Ok(url)
}
/// Handle the provider callback; returns the post-login redirect target.
pub async fn handle_callback(
&self,
session: &SessionHandle,
query: &HashMap<String, Vec<String>>,
) -> Result<String, OidcFlowError> {
let first = |name: &str| query.get(name).and_then(|values| values.first()).cloned();
if let Some(error) = first("error") {
let mut message = format!("OIDC error: {error}");
if let Some(description) = first("error_description") {
message.push_str(&format!(": {description}"));
}
return Err(OidcFlowError::BadRequest(message));
}
let (Some(code), Some(state)) = (first("code"), first("state")) else {
return Err(OidcFlowError::BadRequest(
"Missing code or state".to_string(),
));
};
let (expected_state, nonce, code_verifier) = {
let session = session.lock().await;
(
session
.get(&self.key("state"))
.and_then(Value::as_str)
.map(str::to_string),
session
.get(&self.key("nonce"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
session
.get(&self.key("code_verifier"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
)
};
if Some(state.as_str()) != expected_state.as_deref() {
return Err(OidcFlowError::BadRequest("Invalid state".to_string()));
}
let token_response = self.client.fetch_token(&code, &code_verifier).await?;
let Some(id_token) = token_response.get("id_token").and_then(Value::as_str) else {
return Err(OidcFlowError::BadRequest(
"No id_token in token response".to_string(),
));
};
let id_token = id_token.to_string();
let claims = self.client.validate_id_token(&id_token, &nonce).await?;
let mut user = claims.clone();
let access_token = token_response
.get("access_token")
.and_then(Value::as_str)
.map(str::to_string);
if let Some(access_token) = access_token.as_deref() {
let userinfo = self.client.fetch_userinfo(access_token).await?;
for (key, value) in userinfo {
user.insert(key, value);
}
}
let mut session = session.lock().await;
session.remove(&self.key("state"));
session.remove(&self.key("code_verifier"));
session.insert(self.key("user"), Value::Object(user));
session.insert(self.key("id_token"), json!(id_token));
if let Some(access_token) = access_token {
session.insert(self.key("access_token"), json!(access_token));
}
if let Some(refresh_token) = token_response.get("refresh_token") {
session.insert(self.key("refresh_token"), refresh_token.clone());
}
self.strip_other_identities(&mut session);
session.regenerate_id();
Ok(self.config.post_login_redirect.clone())
}
/// Drop this provider's session state; returns the post-logout redirect.
pub async fn logout(&self, session: &SessionHandle) -> Result<String, OidcFlowError> {
let mut session = session.lock().await;
let id_token = session
.get(&self.key("id_token"))
.and_then(Value::as_str)
.map(str::to_string);
let prefix = format!("oidc:{}:", self.config.provider);
let keys: Vec<String> = session
.keys()
.filter(|key| key.starts_with(&prefix))
.cloned()
.collect();
for key in keys {
session.remove(&key);
}
let any_user = session
.keys()
.any(|key| key.starts_with("oidc:") && key.ends_with(":user"));
if !any_user {
session.invalidate();
}
drop(session);
match self.client.build_logout_url(id_token.as_deref()).await {
Ok(Some(url)) => Ok(url),
_ => Ok(self.config.post_logout_redirect.clone()),
}
}
fn strip_other_identities(&self, session: &mut crate::session::SessionData) {
let own_prefix = format!("oidc:{}:", self.config.provider);
let keys: Vec<String> = session
.keys()
.filter(|key| {
key.starts_with("oidc:")
&& !key.starts_with(&own_prefix)
&& IDENTITY_FIELDS
.iter()
.any(|field| key.ends_with(&format!(":{field}")))
})
.cloned()
.collect();
for key in keys {
session.remove(&key);
}
}
}
/// The set of configured providers, in configuration order.
pub struct OidcProviders {
providers: Vec<Arc<OidcProvider>>,
}
impl OidcProviders {
pub fn new(configs: Vec<OidcProviderConfig>) -> Result<Self> {
let pattern = Regex::new(r"^[A-Za-z0-9_-]+$").expect("valid provider regex");
let mut seen = std::collections::HashSet::new();
let mut providers = Vec::new();
for config in configs {
if !pattern.is_match(&config.provider) {
return Err(Error::Internal(format!(
"provider must be a non-empty string of letters, digits, underscores or hyphens (got {:?})",
config.provider
)));
}
if !seen.insert(config.provider.clone()) {
return Err(Error::Internal(format!(
"duplicate OIDC provider name {:?}",
config.provider
)));
}
let client = OidcClient::new(config.clone());
providers.push(Arc::new(OidcProvider { config, client }));
}
if providers.is_empty() {
return Err(Error::Internal(
"At least one OIDC provider is required".into(),
));
}
Ok(Self { providers })
}
pub fn base_path(&self) -> &'static str {
BASE_PATH
}
pub fn providers(&self) -> impl Iterator<Item = &Arc<OidcProvider>> {
self.providers.iter()
}
pub fn first(&self) -> &Arc<OidcProvider> {
&self.providers[0]
}
pub fn get(&self, name: &str) -> Option<&Arc<OidcProvider>> {
self.providers
.iter()
.find(|provider| provider.name() == name)
}
pub fn login_url(&self, name: &str) -> String {
format!("{BASE_PATH}/{name}/login")
}
}
@@ -0,0 +1,122 @@
//! Cursor-based pagination for listing endpoints.
//!
//! Uses keyset pagination (not OFFSET/LIMIT): each page ends with an opaque
//! cursor encoding the sort key tuple of the last item on that page. The
//! cursor is a base64url-encoded JSON object mapping the sort-field names to
//! the values of the last item; it is opaque to callers.
use std::collections::HashMap;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use serde_json::{Map, Value};
pub const DEFAULT_LIMIT: usize = 20;
pub const MAX_LIMIT: usize = 100;
pub const MIN_LIMIT: usize = 1;
pub const CURSOR_PARAM: &str = "cursor";
pub const LIMIT_PARAM: &str = "limit";
/// Raised when the `cursor`/`limit` query params cannot be decoded; the
/// message is sent verbatim in the 400 response.
#[derive(Debug, Clone, thiserror::Error)]
pub enum CursorDecodeError {
#[error("invalid cursor")]
InvalidCursor,
#[error("invalid limit")]
InvalidLimit,
}
#[derive(Debug, Clone)]
pub struct Cursor {
pub limit: usize,
pub after: Option<Map<String, Value>>,
}
/// Encode sort-key values as an opaque cursor.
pub fn encode_cursor(values: &Map<String, Value>) -> String {
let raw = serde_json::to_vec(values).unwrap_or_default();
URL_SAFE_NO_PAD.encode(raw)
}
/// Decode an opaque cursor; tolerates missing base64 padding.
pub fn decode_cursor(value: Option<&str>) -> Result<Option<Map<String, Value>>, CursorDecodeError> {
let Some(value) = value else {
return Ok(None);
};
if value.is_empty() {
return Ok(None);
}
// Tolerate both padded and unpadded base64url input.
let unpadded = value.trim_end_matches('=');
let raw = URL_SAFE_NO_PAD
.decode(unpadded)
.map_err(|_| CursorDecodeError::InvalidCursor)?;
let parsed: Value =
serde_json::from_slice(&raw).map_err(|_| CursorDecodeError::InvalidCursor)?;
match parsed {
Value::Object(map) => Ok(Some(map)),
_ => Err(CursorDecodeError::InvalidCursor),
}
}
/// Parse the `limit` and `cursor` query parameters.
pub fn parse_cursor_params(
params: &HashMap<String, Vec<String>>,
) -> Result<Cursor, CursorDecodeError> {
let limit = match params.get(LIMIT_PARAM).and_then(|values| values.first()) {
Some(value) => value
.parse::<i64>()
.map_err(|_| CursorDecodeError::InvalidLimit)?,
None => DEFAULT_LIMIT as i64,
};
let limit = limit.clamp(MIN_LIMIT as i64, MAX_LIMIT as i64) as usize;
let after = decode_cursor(
params
.get(CURSOR_PARAM)
.and_then(|values| values.first())
.map(String::as_str),
)?;
Ok(Cursor { limit, after })
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn cursor_round_trip() {
let mut values = Map::new();
values.insert("finished_at".into(), json!("2026-09-19T06:59:17+00:00"));
values.insert("id".into(), json!("7c9e6679-7425-40de-944b-e07fc1f90ae7"));
let encoded = encode_cursor(&values);
assert!(!encoded.contains('='));
let decoded = decode_cursor(Some(&encoded)).unwrap().unwrap();
assert_eq!(decoded, values);
// Padded input is tolerated too.
let padded = format!("{encoded}==");
let decoded = decode_cursor(Some(&padded)).unwrap().unwrap();
assert_eq!(decoded, values);
}
#[test]
fn parse_defaults_and_clamps() {
let params = HashMap::new();
let cursor = parse_cursor_params(&params).unwrap();
assert_eq!(cursor.limit, 20);
assert!(cursor.after.is_none());
let mut params = HashMap::new();
params.insert("limit".into(), vec!["1000".into()]);
assert_eq!(parse_cursor_params(&params).unwrap().limit, 100);
let mut params = HashMap::new();
params.insert("limit".into(), vec!["zero".into()]);
assert!(matches!(
parse_cursor_params(&params),
Err(CursorDecodeError::InvalidLimit)
));
}
}
@@ -0,0 +1,22 @@
//! The collaborators every platform endpoint needs, bundled so route and
//! websocket modules never reach for global state (mirrors
//! `tavolo.platform.mixin.Platform`).
use std::sync::Arc;
use crate::deadlines::DeadlineScheduler;
use crate::oidc::OidcProviders;
use crate::registry::GameRegistry;
use crate::stats::SharedStatsRepo;
use crate::store::SharedGameStore;
#[derive(Clone)]
pub struct Platform {
pub registry: Arc<GameRegistry>,
pub game_store: SharedGameStore,
pub scheduler: Arc<DeadlineScheduler>,
pub oidc: Arc<OidcProviders>,
pub stats: SharedStatsRepo,
}
pub type SharedPlatform = Arc<Platform>;
@@ -0,0 +1,64 @@
//! Registry of the games the platform can host.
//!
//! The registry maps a `game_type` id to its [`GameEngine`] implementation
//! and is the single source of truth for which games exist: the lobby lists
//! it, the store uses it to deserialize opaque game state, and the stats
//! endpoints use it to validate `game_type` filters. Games register
//! themselves at composition time; the platform itself ships no game.
use std::collections::HashMap;
use std::sync::Arc;
use crate::engine::GameEngine;
use crate::errors::{GameError, Result};
/// An ordered collection of game engines, keyed by their id.
#[derive(Default)]
pub struct GameRegistry {
engines: Vec<Arc<dyn GameEngine>>,
by_id: HashMap<String, usize>,
}
impl GameRegistry {
pub fn new() -> Self {
Self::default()
}
/// Add `engine`; the first registered becomes the default.
pub fn register(&mut self, engine: Arc<dyn GameEngine>) -> Result<()> {
if self.by_id.contains_key(engine.id()) {
return Err(GameError::illegal_move(format!(
"duplicate game engine id: {:?}",
engine.id()
))
.into());
}
self.by_id
.insert(engine.id().to_string(), self.engines.len());
self.engines.push(engine);
Ok(())
}
/// Return the engine for `game_type`, or `None`.
pub fn get(&self, game_type: &str) -> Option<&Arc<dyn GameEngine>> {
self.by_id.get(game_type).map(|index| &self.engines[*index])
}
/// Return the engine for `game_type` or raise an error.
pub fn require(&self, game_type: &str) -> Result<&Arc<dyn GameEngine>> {
self.get(game_type).ok_or_else(|| {
GameError::unknown_game_type(format!("unknown game_type: {game_type:?}")).into()
})
}
/// The first registered engine, used when no type is requested.
pub fn default_engine(&self) -> Result<&Arc<dyn GameEngine>> {
self.engines
.first()
.ok_or_else(|| crate::errors::Error::Internal("no game engines registered".into()))
}
pub fn all(&self) -> &[Arc<dyn GameEngine>] {
&self.engines
}
}
@@ -0,0 +1,175 @@
//! Authentication routes for the single-page app.
//!
//! Each configured provider gets `/auth/<provider>/login`, `.../callback`
//! and `.../logout` routes (registered per provider, so an unknown provider
//! path falls through to the SPA fallback like in the Python server). This
//! module adds the provider-agnostic pieces:
//!
//! * `GET /api/auth/providers` lists the configured providers with their
//! login URLs, so the SPA can offer a choice without hardcoding names;
//! * `GET /auth/login` redirects to the first configured provider;
//! * `GET /auth/logout` redirects to the provider this session is logged in
//! with (or the SPA root when not logged in).
use axum::extract::{Extension, RawQuery, State};
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Router;
use serde_json::json;
use crate::auth::logged_in_providers;
use crate::http::{extract_query_params, redirect, send_json, send_text};
use crate::oidc::OidcFlowError;
use crate::platform::SharedPlatform;
use crate::session::SessionHandle;
pub fn routes(providers: &[String]) -> Router<SharedPlatform> {
let mut router = Router::new()
.route("/api/auth/providers", get(providers_handler))
.route("/auth/login", get(generic_login))
.route("/auth/logout", get(generic_logout));
for provider in providers {
let name = provider.clone();
router = router.route(
&format!("/auth/{name}/login"),
get(
move |state: State<SharedPlatform>, session: Extension<SessionHandle>| {
let name = name.clone();
async move { provider_login(state, session, name).await }
},
),
);
let name = provider.clone();
router = router.route(
&format!("/auth/{name}/callback"),
get(
move |state: State<SharedPlatform>,
query: RawQuery,
session: Extension<SessionHandle>| {
let name = name.clone();
async move { provider_callback(state, query, session, name).await }
},
),
);
let name = provider.clone();
router = router.route(
&format!("/auth/{name}/logout"),
get(
move |state: State<SharedPlatform>, session: Extension<SessionHandle>| {
let name = name.clone();
async move { provider_logout(state, session, name).await }
},
),
);
}
router
}
fn flow_error_response(error: OidcFlowError) -> Response {
match error {
OidcFlowError::BadRequest(message) => send_text(
StatusCode::BAD_REQUEST,
message,
"text/plain; charset=utf-8",
),
OidcFlowError::Internal(message) => send_text(
StatusCode::INTERNAL_SERVER_ERROR,
message,
"text/plain; charset=utf-8",
),
}
}
/// Names and login URLs of the configured providers, in configuration order.
async fn providers_handler(State(platform): State<SharedPlatform>) -> Response {
let results: Vec<serde_json::Value> = platform
.oidc
.providers()
.map(|provider| {
json!({
"name": provider.name(),
"login_url": platform.oidc.login_url(provider.name()),
})
})
.collect();
send_json(StatusCode::OK, &json!({ "providers": results }))
}
/// Redirect to the first configured provider's login.
async fn generic_login(State(platform): State<SharedPlatform>) -> Response {
redirect(platform.oidc.login_url(platform.oidc.first().name()))
}
/// Redirect to the logged-in provider's logout (or the SPA root).
async fn generic_logout(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
) -> Response {
let logged_in = {
let session = session.lock().await;
logged_in_providers(&session)
};
match logged_in.first() {
Some(provider) if platform.oidc.get(provider).is_some() => {
redirect(format!("/auth/{provider}/logout"))
}
_ => redirect("/"),
}
}
async fn provider_login(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
provider: String,
) -> Response {
let Some(provider) = platform.oidc.get(&provider) else {
return send_text(
StatusCode::NOT_FOUND,
"not found",
"text/plain; charset=utf-8",
);
};
match provider.begin_login(&session).await {
Ok(location) => redirect(location),
Err(error) => flow_error_response(error),
}
}
async fn provider_callback(
State(platform): State<SharedPlatform>,
RawQuery(query): RawQuery,
Extension(session): Extension<SessionHandle>,
provider: String,
) -> Response {
let Some(provider) = platform.oidc.get(&provider) else {
return send_text(
StatusCode::NOT_FOUND,
"not found",
"text/plain; charset=utf-8",
);
};
let params = extract_query_params(query.as_deref());
match provider.handle_callback(&session, &params).await {
Ok(location) => redirect(location),
Err(error) => flow_error_response(error),
}
}
async fn provider_logout(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
provider: String,
) -> Response {
let Some(provider) = platform.oidc.get(&provider) else {
return send_text(
StatusCode::NOT_FOUND,
"not found",
"text/plain; charset=utf-8",
);
};
match provider.logout(&session).await {
Ok(location) => redirect(location),
Err(error) => flow_error_response(error),
}
}
@@ -0,0 +1,421 @@
//! Game lobby endpoints.
//!
//! A game starts as a lobby: the creator is seated first and shares the
//! six-character `join_code`. When the lobby fills up, the engine starts the
//! match. Live play then happens over the `/ws/games/{id}` websocket; these
//! endpoints cover creation, joining and snapshotting state.
//!
//! Everything game-specific — which options a game accepts, when the lobby
//! is full, what the personalized view looks like — is delegated to the
//! engine registered for the session's `game_type`.
use axum::body::Bytes;
use axum::extract::{Path, RawQuery, State};
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::{get, post};
use axum::Extension;
use axum::Router;
use rand::seq::SliceRandom;
use serde_json::{json, Value};
use crate::auth::{current_user, display_name};
use crate::datetime::now;
use crate::engine::GameSession;
use crate::http::{
extract_query_params, py_repr, read_json, read_json_optional, send_error, send_json,
unauthorized,
};
use crate::platform::{Platform, SharedPlatform};
use crate::session::SessionHandle;
/// Join codes avoid characters that are easy to confuse when read aloud.
const CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const CODE_LENGTH: usize = 6;
const MAX_CODE_ATTEMPTS: usize = 20;
pub fn routes() -> Router<SharedPlatform> {
Router::new()
.route("/api/game-types", get(list_game_types))
.route("/api/games", post(create_game))
.route("/api/games/join", post(join_game))
.route("/api/games/preview", get(preview_game))
.route("/api/games/{game_id}", get(get_game))
}
fn random_code() -> String {
let mut rng = rand::thread_rng();
(0..CODE_LENGTH)
.map(|_| *CODE_ALPHABET.choose(&mut rng).expect("non-empty alphabet") as char)
.collect()
}
async fn unique_code(platform: &Platform) -> Result<String, Response> {
for _ in 0..MAX_CODE_ATTEMPTS {
let code = random_code();
match platform.game_store.find_by_code(&code).await {
Ok(None) => return Ok(code),
Ok(Some(_)) => continue,
Err(err) => {
tracing::error!("join code lookup failed: {err}");
return Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not allocate a join code",
));
}
}
}
Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not allocate a unique join code",
))
}
fn merge(base: &mut Value, extra: Value) {
if let (Value::Object(base), Value::Object(extra)) = (base, extra) {
for (key, value) in extra {
base.insert(key, value);
}
}
}
fn lobby_payload(platform: &Platform, game: &GameSession) -> Result<Value, Response> {
let engine = platform
.registry
.require(&game.game_type)
.map_err(|_| send_error(StatusCode::INTERNAL_SERVER_ERROR, "unknown game type"))?;
let players: Vec<Value> = game
.players
.iter()
.enumerate()
.map(|(index, seat)| {
json!({
"sub": seat.user_sub,
"name": seat.display_name,
"seat": index,
"team": seat.team,
})
})
.collect();
let mut payload = json!({
"id": game.id,
"join_code": game.join_code,
"game_type": game.game_type,
"players": players,
"seats_open": engine.max_players().saturating_sub(game.players.len()),
});
merge(&mut payload, engine.lobby_view(game));
Ok(payload)
}
fn view_payload(platform: &Platform, game: &GameSession, sub: &str) -> Result<Value, Response> {
let engine = platform
.registry
.require(&game.game_type)
.map_err(|_| send_error(StatusCode::INTERNAL_SERVER_ERROR, "unknown game type"))?;
let mut payload = json!({
"id": game.id,
"join_code": game.join_code,
"game_type": game.game_type,
});
merge(&mut payload, engine.view_for(game, sub));
Ok(payload)
}
fn preview_payload(platform: &Platform, game: &GameSession) -> Result<Value, Response> {
let engine = platform
.registry
.require(&game.game_type)
.map_err(|_| send_error(StatusCode::INTERNAL_SERVER_ERROR, "unknown game type"))?;
let mut payload = lobby_payload(platform, game)?;
merge(
&mut payload,
json!({
"game_name": engine.name(),
"game_description": engine.description(),
}),
);
Ok(payload)
}
/// Every game the platform can host, for the match-creation dropdown.
async fn list_game_types(State(platform): State<SharedPlatform>) -> Response {
let results: Vec<Value> = platform
.registry
.all()
.iter()
.map(|engine| {
json!({
"id": engine.id(),
"name": engine.name(),
"description": engine.description(),
"min_players": engine.min_players(),
"max_players": engine.max_players(),
"options_schema": engine.options_schema(),
})
})
.collect();
send_json(StatusCode::OK, &json!({ "results": results }))
}
/// Create a lobby game and seat the caller in seat 0.
async fn create_game(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
body: Bytes,
) -> Response {
let body = match read_json_optional(&body) {
Ok(body) => body,
Err(err) => return send_error(StatusCode::BAD_REQUEST, err.to_string()),
};
let engine = match body.get("game_type") {
None | Some(Value::Null) => match platform.registry.default_engine() {
Ok(engine) => engine.clone(),
Err(_) => {
return send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"no game engines registered",
)
}
},
Some(Value::String(name)) => match platform.registry.require(name) {
Ok(engine) => engine.clone(),
Err(_) => {
return send_error(
StatusCode::BAD_REQUEST,
format!(
"unknown game_type: {}",
py_repr(&Value::String(name.clone()))
),
)
}
},
Some(other) => {
return send_error(
StatusCode::BAD_REQUEST,
format!("unknown game_type: {}", py_repr(other)),
)
}
};
let options = body.get("options").cloned().unwrap_or_else(|| json!({}));
let Value::Object(options) = options else {
return send_error(StatusCode::BAD_REQUEST, "options must be an object");
};
let Some(user) = current_user(&session).await else {
return unauthorized();
};
let join_code = match unique_code(&platform).await {
Ok(code) => code,
Err(response) => return response,
};
let mut game = GameSession::new(uuid::Uuid::new_v4().to_string(), engine.id(), join_code);
game.creator_sub = user.sub.clone();
game.players = vec![crate::engine::Seat::new(&user.sub, display_name(&user))];
game.created_at = Some(now());
if let Err(err) = engine.create(&mut game, &options) {
match err.as_game() {
Some(game_error) => {
return send_error(StatusCode::BAD_REQUEST, game_error.message.clone())
}
None => {
tracing::error!("game create failed: {err}");
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not create game");
}
}
}
if let Err(err) = platform.game_store.save(&game).await {
tracing::error!("game save failed: {err}");
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not create game");
}
tracing::info!(
"game {} created by {} ({}, options {})",
game.id,
user.sub,
engine.id(),
serde_json::to_string(&options).unwrap_or_default()
);
match lobby_payload(&platform, &game) {
Ok(payload) => send_json(StatusCode::CREATED, &payload),
Err(response) => response,
}
}
/// Join with `{"code": "ABC123"}`; the last player triggers the start.
async fn join_game(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
body: Bytes,
) -> Response {
let body = match read_json(&body) {
Ok(body) => body,
Err(err) => return send_error(StatusCode::BAD_REQUEST, err.to_string()),
};
let Some(code) = body
.get("code")
.and_then(Value::as_str)
.filter(|code| !code.is_empty())
else {
return send_error(StatusCode::BAD_REQUEST, "code is required");
};
let Some(user) = current_user(&session).await else {
return unauthorized();
};
let existing = match platform.game_store.find_by_code(code).await {
Ok(Some(existing)) => existing,
Ok(None) => {
tracing::debug!("join rejected for {}: unknown code {code:?}", user.sub);
return send_error(StatusCode::NOT_FOUND, "unknown join code");
}
Err(err) => {
tracing::error!("join lookup failed: {err}");
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not join game");
}
};
let game_id = existing.id.clone();
let lock = match platform.game_store.acquire_lock(&game_id).await {
Ok(lock) => lock,
Err(err) => {
tracing::error!("join lock failed: {err}");
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not join game");
}
};
let result = async {
let Some(mut game) = (match platform.game_store.load(&game_id).await {
Ok(game) => game,
Err(err) => {
tracing::error!("join load failed: {err}");
return Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not join game",
));
}
}) else {
return Err(send_error(StatusCode::NOT_FOUND, "unknown join code"));
};
let engine = match platform.registry.require(&game.game_type) {
Ok(engine) => engine.clone(),
Err(err) => {
tracing::error!("join engine lookup failed: {err}");
return Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not join game",
));
}
};
if let Err(err) = engine.join(&mut game, &user.sub, &display_name(&user)) {
match err.as_game() {
Some(game_error) => {
tracing::debug!(
"join rejected for {} in game {game_id}: {}",
user.sub,
game_error.message
);
return Err(send_error(StatusCode::CONFLICT, game_error.message.clone()));
}
None => {
tracing::error!("join failed: {err}");
return Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not join game",
));
}
}
}
if let Err(err) = platform.game_store.save(&game).await {
tracing::error!("join save failed: {err}");
return Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not join game",
));
}
let _ = platform.game_store.publish(&game.id).await;
if let Err(err) = platform.scheduler.sync_deadline(&game).await {
tracing::error!("join sync_deadline failed: {err}");
}
let seat = game.seat_index_of(&user.sub).unwrap_or(0);
if engine.in_lobby(&game) {
tracing::info!(
"{} joined game {} (seat {}, {}/{} players)",
user.sub,
game.id,
seat,
game.players.len(),
engine.max_players()
);
return lobby_payload(&platform, &game);
}
tracing::info!(
"{} joined game {} (seat {}); match started",
user.sub,
game.id,
seat
);
view_payload(&platform, &game, &user.sub)
}
.await;
let _ = lock.release().await;
match result {
Ok(payload) => send_json(StatusCode::OK, &payload),
Err(response) => response,
}
}
/// Pre-join summary of the lobby behind a code.
async fn preview_game(
State(platform): State<SharedPlatform>,
RawQuery(query): RawQuery,
) -> Response {
let params = extract_query_params(query.as_deref());
let code = params
.get("code")
.and_then(|values| values.first())
.map(|code| code.trim().to_string())
.unwrap_or_default();
if code.is_empty() {
return send_error(StatusCode::BAD_REQUEST, "code is required");
}
match platform.game_store.find_by_code(&code).await {
Ok(Some(game)) => match preview_payload(&platform, &game) {
Ok(payload) => send_json(StatusCode::OK, &payload),
Err(response) => response,
},
Ok(None) => send_error(StatusCode::NOT_FOUND, "unknown join code"),
Err(err) => {
tracing::error!("preview lookup failed: {err}");
send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not preview game")
}
}
}
/// Personalized snapshot; only seated players may read a game.
async fn get_game(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
Path(game_id): Path<String>,
) -> Response {
let game = match platform.game_store.load(&game_id).await {
Ok(Some(game)) => game,
Ok(None) => return send_error(StatusCode::NOT_FOUND, "game not found"),
Err(err) => {
tracing::error!("game load failed: {err}");
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not load game");
}
};
let Some(user) = current_user(&session).await else {
return unauthorized();
};
if !game.seated(&user.sub) {
return send_error(StatusCode::FORBIDDEN, "forbidden");
}
match view_payload(&platform, &game, &user.sub) {
Ok(payload) => send_json(StatusCode::OK, &payload),
Err(response) => response,
}
}
@@ -0,0 +1,20 @@
//! Liveness probe.
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Router;
use serde_json::json;
use crate::http::send_json;
use crate::platform::SharedPlatform;
pub fn routes() -> Router<SharedPlatform> {
Router::new().route("/api/health", get(health))
}
/// Health check: the service is up.
async fn health(State(_platform): State<SharedPlatform>) -> Response {
send_json(StatusCode::OK, &json!({ "status": "ok" }))
}
@@ -0,0 +1,32 @@
//! Whoami endpoint: lets the single-page app detect the login state.
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Extension;
use axum::Router;
use serde_json::json;
use crate::auth::{current_user, display_name};
use crate::http::{send_json, unauthorized};
use crate::platform::SharedPlatform;
use crate::session::SessionHandle;
pub fn routes() -> Router<SharedPlatform> {
Router::new().route("/api/me", get(me))
}
/// Current user; 401 when not logged in.
async fn me(
State(_platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
) -> Response {
let Some(user) = current_user(&session).await else {
return unauthorized();
};
send_json(
StatusCode::OK,
&json!({ "sub": user.sub, "name": display_name(&user) }),
)
}
@@ -0,0 +1,33 @@
//! The platform's HTTP surface: lobby, stats, auth and health routes.
//!
//! Handlers live in submodules mirroring the Python implementation; this
//! module assembles them into one axum [`Router`].
pub mod auth;
pub mod games;
pub mod health;
pub mod me;
pub mod openapi;
pub mod stats;
use axum::routing::get;
use axum::Router;
use crate::platform::SharedPlatform;
use crate::ws;
/// All platform routes (HTTP + the live-play websocket).
///
/// `providers` lists the configured OIDC provider names; login/callback/
/// logout routes are registered per provider so unknown provider paths fall
/// through to the router's fallback.
pub fn router(providers: &[String]) -> Router<SharedPlatform> {
Router::new()
.merge(health::routes())
.merge(me::routes())
.merge(auth::routes(providers))
.merge(games::routes())
.merge(stats::routes())
.merge(openapi::routes())
.route("/ws/games/{game_id}", get(ws::game_socket))
}
@@ -0,0 +1,314 @@
//! OpenAPI document and Swagger UI, mirroring kaya-openapi's output for the
//! tavolo API.
//!
//! The document is assembled from the route metadata below (kaya generated it
//! from the routing tree on every request). Wildcard SPA routes and the
//! websocket endpoint are skipped, exactly like kaya does.
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Router;
use serde_json::{json, Value};
use crate::http::{send_json, send_text};
use crate::platform::SharedPlatform;
pub const TITLE: &str = "tavolo";
pub const VERSION: &str = "0.1.0";
pub const DESCRIPTION: &str = "Multiplayer card-game platform API";
pub const SPEC_PATH: &str = "/api/openapi.json";
pub const DOCS_PATH: &str = "/api/docs";
fn query_parameter(name: &str, description: &str, schema: Value) -> Value {
json!({
"name": name,
"in": "query",
"required": false,
"schema": schema,
"description": description,
})
}
fn game_type_parameter() -> Value {
query_parameter(
"game_type",
"Only count matches of this game (id from GET /api/game-types).",
json!({"type": "string"}),
)
}
fn game_id_parameter() -> Value {
json!({
"name": "game_id",
"in": "path",
"required": true,
"schema": {"type": "string"},
})
}
/// The OpenAPI 3.1 document for the platform API.
pub fn spec() -> Value {
json!({
"openapi": "3.1.0",
"info": {
"title": TITLE,
"version": VERSION,
"description": DESCRIPTION,
},
"paths": {
"/api/health": {
"get": {
"summary": "Health check",
"tags": ["health"],
"responses": {"200": {"description": "The service is up"}},
}
},
"/api/me": {
"get": {
"summary": "Current user",
"description": "Returns the authenticated user's identity from the session; 401 when not logged in.",
"tags": ["auth"],
"responses": {
"200": {"description": "The current user"},
"401": {"description": "Not logged in"},
},
}
},
"/api/auth/providers": {
"get": {
"summary": "Configured login providers",
"description": "Names and login URLs of the configured OIDC providers, in configuration order.",
"tags": ["auth"],
"responses": {"200": {"description": "The configured providers"}},
}
},
"/auth/{provider}/login": {
"get": {
"summary": "Start an OIDC login",
"tags": ["auth"],
"parameters": [{
"name": "provider",
"in": "path",
"required": true,
"schema": {"type": "string"},
}],
"responses": {"302": {"description": "Redirect to the provider"}},
}
},
"/auth/{provider}/callback": {
"get": {
"summary": "OIDC login callback",
"tags": ["auth"],
"parameters": [{
"name": "provider",
"in": "path",
"required": true,
"schema": {"type": "string"},
}],
"responses": {
"302": {"description": "Redirect to the post-login target"},
"400": {"description": "Authentication failed"},
},
}
},
"/auth/{provider}/logout": {
"get": {
"summary": "Log out of an OIDC provider",
"tags": ["auth"],
"parameters": [{
"name": "provider",
"in": "path",
"required": true,
"schema": {"type": "string"},
}],
"responses": {"302": {"description": "Redirect to the provider or the SPA root"}},
}
},
"/api/game-types": {
"get": {
"summary": "List available games",
"description": "Every game the platform can host, for the match-creation dropdown. ``options_schema`` describes the per-game creation options accepted by POST /api/games.",
"tags": ["games"],
"responses": {"200": {"description": "The available game types"}},
}
},
"/api/games": {
"post": {
"summary": "Create a game",
"description": "Creates a lobby game and seats the caller in seat 0. Share the returned join_code with the other players. ``options`` is an opaque object validated by the chosen game (see GET /api/game-types).",
"tags": ["games"],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"game_type": {
"type": "string",
"description": "One of the ids from GET /api/game-types; defaults to the platform's first registered game",
},
"options": {
"type": "object",
"description": "Game-specific creation options",
},
},
}
}
}
},
"responses": {
"201": {"description": "The created lobby"},
"400": {"description": "Invalid game_type, options or body"},
"401": {"description": "Authentication required"},
},
}
},
"/api/games/join": {
"post": {
"summary": "Join a game by code",
"description": "Seats the caller in the next free chair. Joining as the last player starts the match.",
"tags": ["games"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"code": {"type": "string"}},
"required": ["code"],
}
}
}
},
"responses": {
"200": {"description": "Seated; game state (may be playing)"},
"400": {"description": "Missing code"},
"401": {"description": "Authentication required"},
"404": {"description": "Unknown join code"},
"409": {"description": "Already joined or lobby full"},
},
}
},
"/api/games/preview": {
"get": {
"summary": "Preview a game by join code",
"description": "Looks up a game by its join code without joining, for the lobby's pre-join summary: game name and description, seated players, open seats and the game-specific options echoed by its engine.",
"tags": ["games"],
"responses": {
"200": {"description": "The lobby preview"},
"400": {"description": "Missing code"},
"401": {"description": "Authentication required"},
"404": {"description": "Unknown join code"},
},
}
},
"/api/games/{game_id}": {
"get": {
"summary": "Get a game snapshot",
"description": "Only seated players may read a game; the view is personalized by the engine (e.g. hidden hands).",
"tags": ["games"],
"parameters": [game_id_parameter()],
"responses": {
"200": {"description": "The personalized game state"},
"401": {"description": "Authentication required"},
"403": {"description": "Not a player in this game"},
"404": {"description": "Game not found"},
},
}
},
"/api/me/matches": {
"get": {
"summary": "List my matches",
"description": "Cursor-paginated history of finished matches the caller played, newest first, with the result.",
"tags": ["stats"],
"parameters": [
query_parameter(
"limit",
"Maximum number of results per page (clamped to [1, 100]).",
json!({"type": "integer", "minimum": 1, "maximum": 100, "default": 20}),
),
query_parameter(
"cursor",
"Opaque pagination cursor from a previous response's next_cursor.",
json!({"type": "string"}),
),
game_type_parameter(),
],
"responses": {
"200": {"description": "A page of matches"},
"400": {"description": "Invalid pagination cursor or game_type"},
"401": {"description": "Authentication required"},
},
}
},
"/api/leaderboard": {
"get": {
"summary": "Global leaderboard",
"description": "Elo rating, aggregated wins, matches played and points for every player with at least one finished match. Sorted by Elo rating (the rating for the requested game_type, or the default game when the filter is absent).",
"tags": ["stats"],
"parameters": [game_type_parameter()],
"responses": {
"200": {"description": "The leaderboard"},
"400": {"description": "Unknown game_type"},
},
}
},
"/api/me/ratings": {
"get": {
"summary": "My Elo ratings",
"description": "The caller's current Elo rating for every game type they have played.",
"tags": ["stats"],
"responses": {
"200": {"description": "The caller's ratings"},
"401": {"description": "Authentication required"},
},
}
}
}
})
}
/// The Swagger UI page (assets loaded from a CDN, like kaya-openapi).
pub fn docs_page() -> String {
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{TITLE} - API documentation</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
<script>
window.onload = () => {{
window.ui = SwaggerUIBundle({{
url: '{SPEC_PATH}',
dom_id: '#swagger-ui',
}});
}};
</script>
</body>
</html>
"#
)
}
pub fn routes() -> Router<SharedPlatform> {
Router::new()
.route(SPEC_PATH, get(spec_handler))
.route(DOCS_PATH, get(docs_handler))
}
async fn spec_handler(State(_platform): State<SharedPlatform>) -> Response {
send_json(StatusCode::OK, &spec())
}
async fn docs_handler(State(_platform): State<SharedPlatform>) -> Response {
send_text(StatusCode::OK, docs_page(), "text/html; charset=utf-8")
}
@@ -0,0 +1,165 @@
//! Player statistics endpoints, served from the stats repository.
//!
//! Every finished match is persisted by
//! [`StatsRepo::save_match`](crate::stats::StatsRepo::save_match). These
//! endpoints expose a player's own match history and a global leaderboard
//! aggregated from the same tables. The game-specific outcome of each match
//! is exposed verbatim through the `result` JSON column.
use axum::extract::{RawQuery, State};
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Extension;
use axum::Router;
use serde_json::{json, Value};
use crate::auth::current_user;
use crate::http::{extract_query_params, py_repr, send_error, send_json, unauthorized};
use crate::pagination::{parse_cursor_params, CursorDecodeError};
use crate::platform::{Platform, SharedPlatform};
use crate::session::SessionHandle;
pub fn routes() -> Router<SharedPlatform> {
Router::new()
.route("/api/me/matches", get(my_matches))
.route("/api/me/ratings", get(my_ratings))
.route("/api/leaderboard", get(leaderboard))
}
fn parse_game_type(
platform: &Platform,
params: &std::collections::HashMap<String, Vec<String>>,
) -> Result<Option<String>, Response> {
let Some(game_type) = params.get("game_type").and_then(|values| values.first()) else {
return Ok(None);
};
if platform.registry.get(game_type).is_none() {
return Err(send_error(
StatusCode::BAD_REQUEST,
format!(
"unknown game_type: {}",
py_repr(&Value::String(game_type.clone()))
),
));
}
Ok(Some(game_type.clone()))
}
fn cursor_error(error: CursorDecodeError) -> Response {
send_error(StatusCode::BAD_REQUEST, error.to_string())
}
/// Cursor-paginated history of finished matches the caller played.
async fn my_matches(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
RawQuery(query): RawQuery,
) -> Response {
let params = extract_query_params(query.as_deref());
let cursor = match parse_cursor_params(&params) {
Ok(cursor) => cursor,
Err(err) => return cursor_error(err),
};
let game_type = match parse_game_type(&platform, &params) {
Ok(game_type) => game_type,
Err(response) => return response,
};
let Some(user) = current_user(&session).await else {
return unauthorized();
};
match platform
.stats
.my_matches(
&user.sub,
game_type.as_deref(),
cursor.limit,
cursor.after.as_ref(),
)
.await
{
Ok((entries, next_cursor)) => {
let results: Vec<Value> = entries
.iter()
.map(|entry| serde_json::to_value(entry).unwrap_or(Value::Null))
.collect();
send_json(
StatusCode::OK,
&json!({ "results": results, "next_cursor": next_cursor }),
)
}
Err(err) => {
tracing::error!("match history query failed: {err}");
send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not load matches")
}
}
}
/// Global leaderboard, sorted by Elo (the rating for the requested
/// game_type, or the default game when the filter is absent).
async fn leaderboard(
State(platform): State<SharedPlatform>,
RawQuery(query): RawQuery,
) -> Response {
let params = extract_query_params(query.as_deref());
let game_type = match parse_game_type(&platform, &params) {
Ok(game_type) => game_type,
Err(response) => return response,
};
let rating_game = match &game_type {
Some(game_type) => game_type.clone(),
None => match platform.registry.default_engine() {
Ok(engine) => engine.id().to_string(),
Err(err) => {
tracing::error!("leaderboard default game lookup failed: {err}");
return send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not load leaderboard",
);
}
},
};
match platform
.stats
.leaderboard(game_type.as_deref(), &rating_game)
.await
{
Ok(ranking) => {
let results: Vec<Value> = ranking
.iter()
.map(|entry| serde_json::to_value(entry).unwrap_or(Value::Null))
.collect();
send_json(StatusCode::OK, &json!({ "results": results }))
}
Err(err) => {
tracing::error!("leaderboard query failed: {err}");
send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not load leaderboard",
)
}
}
}
/// The caller's current Elo rating for every game type they have played.
async fn my_ratings(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
) -> Response {
let Some(user) = current_user(&session).await else {
return unauthorized();
};
match platform.stats.my_ratings(&user.sub).await {
Ok(rows) => {
let results: Vec<Value> = rows
.iter()
.map(|entry| serde_json::to_value(entry).unwrap_or(Value::Null))
.collect();
send_json(StatusCode::OK, &json!({ "results": results }))
}
Err(err) => {
tracing::error!("ratings query failed: {err}");
send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not load ratings")
}
}
}
@@ -0,0 +1,531 @@
//! Server-side HTTP sessions.
//!
//! Mirrors kaya's `SessionMixin` / `SessionStore`:
//!
//! * [`SessionData`] is a dict-like container with modification tracking
//! (`modified` / `regenerate` / `invalidated` flags) used by the
//! middleware to decide whether to persist, rotate or delete it;
//! * [`SessionStore`] is the pluggable backend: Redis in production,
//! in-memory for tests and ephemeral single-process dev;
//! * [`session_middleware`] loads the session from the `session_id` cookie,
//! exposes it as a [`SessionHandle`] request extension, and persists it
//! (refreshing the cookie) after the handler ran.
//!
//! Session data is serialized as JSON (the Python server used pickle; old
//! sessions are deliberately not readable, users log in again).
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use axum::extract::{Request, State};
use axum::middleware::Next;
use axum::response::Response;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use rand::RngCore;
use serde_json::{Map, Value};
use tokio::sync::{Mutex, MutexGuard};
use crate::errors::{Error, Result};
/// The session cookie name (kaya's default).
pub const COOKIE_NAME: &str = "session_id";
/// The session idle timeout (kaya's default: 14 days).
pub const MAX_AGE_SECONDS: i64 = 14 * 24 * 60 * 60;
/// Key prefix of the Redis session store. The prefix differs from kaya's
/// (`kaya:session:`) because the payload format changed from pickle to JSON.
pub const REDIS_KEY_PREFIX: &str = "tavolo:session:";
/// Opaque session identifier with 256 bits of entropy, matching Python's
/// `secrets.token_urlsafe(32)` (43 URL-safe base64 characters, no padding).
pub fn new_session_id() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
/// Dict-like session container with modification tracking.
#[derive(Debug, Clone, Default)]
pub struct SessionData {
id: Option<String>,
old_id: Option<String>,
data: Map<String, Value>,
modified: bool,
regenerate: bool,
invalidated: bool,
}
impl SessionData {
pub fn new() -> Self {
Self::default()
}
pub fn with_id(id: impl Into<String>, data: Map<String, Value>) -> Self {
Self {
id: Some(id.into()),
old_id: None,
data,
modified: false,
regenerate: false,
invalidated: false,
}
}
pub fn id(&self) -> Option<&str> {
self.id.as_deref()
}
pub fn is_modified(&self) -> bool {
self.modified
}
pub fn is_regenerate(&self) -> bool {
self.regenerate
}
pub fn is_invalidated(&self) -> bool {
self.invalidated
}
pub fn get(&self, key: &str) -> Option<&Value> {
self.data.get(key)
}
pub fn insert(&mut self, key: impl Into<String>, value: Value) {
self.data.insert(key.into(), value);
self.modified = true;
}
pub fn remove(&mut self, key: &str) -> Option<Value> {
let removed = self.data.remove(key);
if removed.is_some() {
self.modified = true;
}
removed
}
pub fn keys(&self) -> impl Iterator<Item = &String> {
self.data.keys()
}
pub fn data(&self) -> &Map<String, Value> {
&self.data
}
pub fn mark_modified(&mut self) {
self.modified = true;
}
/// Mark the session for ID rotation (session fixation defense); the
/// middleware mints a new id and deletes the old store entry.
pub fn regenerate_id(&mut self) {
self.old_id = self.id.take();
self.regenerate = true;
self.modified = true;
}
/// Mark the session for deletion and send an expired cookie.
pub fn invalidate(&mut self) {
self.invalidated = true;
self.modified = true;
self.data.clear();
}
}
/// Shared, mutable session attached to a request.
#[derive(Clone)]
pub struct SessionHandle(pub Arc<Mutex<SessionData>>);
impl SessionHandle {
pub fn new(session: SessionData) -> Self {
Self(Arc::new(Mutex::new(session)))
}
pub async fn lock(&self) -> MutexGuard<'_, SessionData> {
self.0.lock().await
}
}
/// Pluggable backend for session persistence.
#[async_trait]
pub trait SessionStore: Send + Sync {
/// Load an existing session, or `None` if unknown or expired. When
/// `max_age` is given the store slides the server-side expiry.
async fn load(&self, session_id: &str, max_age: Option<i64>) -> Result<Option<SessionData>>;
/// Persist the session data under `session_id`.
async fn save(
&self,
session_id: &str,
data: &Map<String, Value>,
max_age: Option<i64>,
) -> Result<()>;
/// Remove the session from the store.
async fn delete(&self, session_id: &str) -> Result<()>;
/// Number of live sessions (for the `tavolo.active_users` gauge).
async fn count(&self) -> Result<usize>;
}
// ---------------------------------------------------------------------------
// In-memory store
// ---------------------------------------------------------------------------
struct MemorySession {
data: Map<String, Value>,
expires: Option<Instant>,
}
/// Process-local session store used by tests and when Redis is not configured.
#[derive(Default)]
pub struct InMemorySessionStore {
sessions: Mutex<HashMap<String, MemorySession>>,
}
impl InMemorySessionStore {
pub fn new() -> Self {
Self::default()
}
fn clock(&self) -> Instant {
Instant::now()
}
}
#[async_trait]
impl SessionStore for InMemorySessionStore {
async fn load(&self, session_id: &str, max_age: Option<i64>) -> Result<Option<SessionData>> {
let mut sessions = self.sessions.lock().await;
let Some(entry) = sessions.get_mut(session_id) else {
return Ok(None);
};
let now = self.clock();
if let Some(expires) = entry.expires {
if now > expires {
sessions.remove(session_id);
return Ok(None);
}
}
if max_age.is_some() {
entry.expires = Some(now + Duration::from_secs(max_age.unwrap_or(0) as u64));
}
Ok(Some(SessionData::with_id(
session_id.to_string(),
entry.data.clone(),
)))
}
async fn save(
&self,
session_id: &str,
data: &Map<String, Value>,
max_age: Option<i64>,
) -> Result<()> {
let mut sessions = self.sessions.lock().await;
sessions.insert(
session_id.to_string(),
MemorySession {
data: data.clone(),
expires: max_age.map(|age| self.clock() + Duration::from_secs(age as u64)),
},
);
Ok(())
}
async fn delete(&self, session_id: &str) -> Result<()> {
self.sessions.lock().await.remove(session_id);
Ok(())
}
async fn count(&self) -> Result<usize> {
let sessions = self.sessions.lock().await;
let now = self.clock();
Ok(sessions
.values()
.filter(|entry| entry.expires.is_none_or(|expires| expires > now))
.count())
}
}
// ---------------------------------------------------------------------------
// Redis store
// ---------------------------------------------------------------------------
/// Redis-backed session store: one JSON value per session under
/// `tavolo:session:<id>`, expiry delegated to the key TTL.
pub struct RedisSessionStore {
client: redis::Client,
manager: redis::aio::ConnectionManager,
prefix: String,
}
impl RedisSessionStore {
pub async fn new(url: &str) -> Result<Self> {
let client = redis::Client::open(url)?;
let manager = redis::aio::ConnectionManager::new(client.clone()).await?;
Ok(Self {
client,
manager,
prefix: REDIS_KEY_PREFIX.to_string(),
})
}
pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = prefix.into();
self
}
pub fn client(&self) -> redis::Client {
self.client.clone()
}
fn key(&self, session_id: &str) -> String {
format!("{}{}", self.prefix, session_id)
}
}
#[async_trait]
impl SessionStore for RedisSessionStore {
async fn load(&self, session_id: &str, max_age: Option<i64>) -> Result<Option<SessionData>> {
let mut conn = self.manager.clone();
let key = self.key(session_id);
let payload: Option<String> = redis::cmd("GET").arg(&key).query_async(&mut conn).await?;
let Some(payload) = payload else {
return Ok(None);
};
if let Some(max_age) = max_age {
redis::cmd("EXPIRE")
.arg(&key)
.arg(max_age)
.query_async::<i64>(&mut conn)
.await?;
}
let data: Map<String, Value> = serde_json::from_str(&payload)?;
Ok(Some(SessionData::with_id(session_id.to_string(), data)))
}
async fn save(
&self,
session_id: &str,
data: &Map<String, Value>,
max_age: Option<i64>,
) -> Result<()> {
let mut conn = self.manager.clone();
let payload = serde_json::to_string(data)?;
let mut cmd = redis::cmd("SET");
cmd.arg(self.key(session_id)).arg(payload);
if let Some(max_age) = max_age {
cmd.arg("EX").arg(max_age);
}
cmd.query_async::<()>(&mut conn).await?;
Ok(())
}
async fn delete(&self, session_id: &str) -> Result<()> {
let mut conn = self.manager.clone();
redis::cmd("DEL")
.arg(self.key(session_id))
.query_async::<i64>(&mut conn)
.await?;
Ok(())
}
async fn count(&self) -> Result<usize> {
let mut conn = self.manager.clone();
let mut cursor: u64 = 0;
let mut total = 0usize;
loop {
let (next, keys): (u64, Vec<String>) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(format!("{}*", self.prefix))
.arg("COUNT")
.arg(500)
.query_async(&mut conn)
.await?;
total += keys.len();
cursor = next;
if cursor == 0 {
break;
}
}
Ok(total)
}
}
/// Convenience alias used by the application.
pub type SharedSessionStore = Arc<dyn SessionStore>;
// ---------------------------------------------------------------------------
// Cookie helpers
// ---------------------------------------------------------------------------
/// Return the value of `name` from a `Cookie` header, if present.
pub fn parse_cookie_value(header_value: &str, name: &str) -> Option<String> {
for part in header_value.split(';') {
let part = part.trim();
if let Some((key, value)) = part.split_once('=') {
if key.trim() == name {
return Some(value.trim().trim_matches('"').to_string());
}
}
}
None
}
/// Build a `Set-Cookie` value the way Python's `SimpleCookie` renders one,
/// i.e. sorted attributes: `HttpOnly; Max-Age=n; Path=/; SameSite=Lax`.
pub fn format_set_cookie(
name: &str,
value: &str,
path: &str,
max_age: Option<i64>,
httponly: bool,
secure: bool,
samesite: Option<&str>,
) -> String {
let mut out = format!("{name}={value}");
if httponly {
out.push_str("; HttpOnly");
}
if let Some(max_age) = max_age {
out.push_str(&format!("; Max-Age={max_age}"));
}
out.push_str(&format!("; Path={path}"));
if let Some(samesite) = samesite {
out.push_str(&format!("; SameSite={samesite}"));
}
if secure {
out.push_str("; Secure");
}
out
}
fn extract_session_id(headers: &axum::http::HeaderMap) -> Option<String> {
let mut found = None;
for value in headers.get_all(axum::http::header::COOKIE) {
if let Ok(value) = value.to_str() {
if let Some(id) = parse_cookie_value(value, COOKIE_NAME) {
found = Some(id);
break;
}
}
}
found
}
/// Load the session, expose it as a [`SessionHandle`] extension and persist
/// it (refreshing the cookie) after the handler ran.
///
/// Mirrors kaya's `SessionMixin`: an existing session is always re-saved and
/// its cookie re-sent, so the client and server expiry windows slide on
/// every request.
pub async fn session_middleware(
State(store): State<SharedSessionStore>,
mut request: Request,
next: Next,
) -> Response {
// Static assets never reach the application in the Python deployment
// (Granian serves them): skip the session round-trip for them.
let path = request.uri().path();
if path.starts_with("/static") || path.starts_with("/assets") {
return next.run(request).await;
}
let session_id = extract_session_id(request.headers());
let session = match session_id.as_deref() {
Some(id) => match store.load(id, Some(MAX_AGE_SECONDS)).await {
Ok(Some(session)) => session,
Ok(None) => SessionData::new(),
Err(err) => {
tracing::error!("session load failed: {err}");
SessionData::new()
}
},
None => SessionData::new(),
};
let handle = SessionHandle::new(session);
request.extensions_mut().insert(handle.clone());
let mut response = next.run(request).await;
let mut session = handle.lock().await;
let mut cookie: Option<String> = None;
if session.invalidated {
let id = session.old_id.clone().or_else(|| session.id.clone());
if let Some(id) = &id {
if let Err(err) = store.delete(id).await {
tracing::error!("session delete failed: {err}");
}
}
cookie = Some(format_set_cookie(
COOKIE_NAME,
id.as_deref().unwrap_or(""),
"/",
Some(0),
true,
false,
Some("Lax"),
));
} else if session.regenerate {
if let Some(old_id) = session.old_id.clone() {
if let Err(err) = store.delete(&old_id).await {
tracing::error!("session delete failed: {err}");
}
session.old_id = None;
}
if session.id.is_none() {
session.id = Some(new_session_id());
}
session.regenerate = false;
if let Some(id) = session.id.clone() {
if let Err(err) = store.save(&id, session.data(), Some(MAX_AGE_SECONDS)).await {
tracing::error!("session save failed: {err}");
}
cookie = Some(format_set_cookie(
COOKIE_NAME,
&id,
"/",
Some(MAX_AGE_SECONDS),
true,
false,
Some("Lax"),
));
}
} else {
if session.id.is_none() && session.is_modified() {
session.id = Some(new_session_id());
}
if let Some(id) = session.id.clone() {
if let Err(err) = store.save(&id, session.data(), Some(MAX_AGE_SECONDS)).await {
tracing::error!("session save failed: {err}");
}
cookie = Some(format_set_cookie(
COOKIE_NAME,
&id,
"/",
Some(MAX_AGE_SECONDS),
true,
false,
Some("Lax"),
));
}
}
drop(session);
if let Some(cookie) = cookie {
if let Ok(value) = axum::http::HeaderValue::from_str(&cookie) {
response
.headers_mut()
.append(axum::http::header::SET_COOKIE, value);
}
}
response
}
/// Errors from the session layer surfaced as platform errors.
pub fn session_error(message: impl Into<String>) -> Error {
Error::Internal(message.into())
}
+825
View File
@@ -0,0 +1,825 @@
//! Copy finished match results from the live store into Postgres, plus the
//! read side of the statistics endpoints.
//!
//! Called once when a session reaches the finished state (guarded by the
//! `stats_saved` flag on the session). The write is transactional so a match
//! never appears with only some of its players. The same transaction also
//! updates the participants' Elo ratings (see [`crate::elo`]).
//!
//! This module is game-independent: everything it persists comes from the
//! engine's [`crate::engine::MatchResult`]. The data access is behind
//! [`StatsRepo`] so tests can run without a database.
use std::collections::HashMap;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::Serialize;
use serde_json::{Map, Value};
use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
use crate::datetime::serialize_iso;
use crate::elo::{match_delta, INITIAL_RATING};
use crate::engine::{GameEngine, GameSession};
use crate::errors::{Error, Result};
use crate::models::{MatchPlayerRow, MatchRow};
#[derive(Debug, Clone, Serialize)]
pub struct MatchPlayerView {
pub user_sub: String,
pub display_name: String,
pub seat: i32,
pub team: Option<String>,
pub won: bool,
pub score: f64,
pub elo_delta: Option<i32>,
pub details: Value,
}
#[derive(Debug, Clone, Serialize)]
pub struct MatchHistoryEntry {
pub id: String,
pub game_type: String,
pub result: Value,
#[serde(serialize_with = "serialize_iso")]
pub started_at: DateTime<Utc>,
#[serde(serialize_with = "serialize_iso")]
pub finished_at: DateTime<Utc>,
pub you_won: bool,
pub your_elo_delta: Option<i32>,
pub players: Vec<MatchPlayerView>,
}
#[derive(Debug, Clone, Serialize)]
pub struct RatingEntry {
pub game_type: String,
pub rating: i32,
pub matches_played: i32,
}
#[derive(Debug, Clone, Serialize)]
pub struct LeaderboardEntry {
pub user_sub: String,
pub display_name: String,
pub matches: u64,
pub wins: u64,
pub points: f64,
pub elo: i32,
}
/// Persistence + queries for finished matches and ratings.
#[async_trait]
pub trait StatsRepo: Send + Sync {
/// Persist a finished session. Callers must check `stats_saved` and
/// `engine.is_finished` first and set `session.stats_saved` after success.
async fn save_match(&self, session: &GameSession, engine: &dyn GameEngine) -> Result<()>;
/// One page of finished matches the user played, newest first.
async fn my_matches(
&self,
user_sub: &str,
game_type: Option<&str>,
limit: usize,
after: Option<&Map<String, Value>>,
) -> Result<(Vec<MatchHistoryEntry>, Option<String>)>;
/// The caller's rating per game type, ordered by game type.
async fn my_ratings(&self, user_sub: &str) -> Result<Vec<RatingEntry>>;
/// Global leaderboard; `rating_game` selects the rating table to use when
/// no game type filter is given.
async fn leaderboard(
&self,
game_type: Option<&str>,
rating_game: &str,
) -> Result<Vec<LeaderboardEntry>>;
/// Recompute every rating from the match history; returns the number of
/// matches replayed.
async fn backfill_elo(&self) -> Result<usize>;
}
pub type SharedStatsRepo = std::sync::Arc<dyn StatsRepo>;
// ---------------------------------------------------------------------------
// Postgres implementation
// ---------------------------------------------------------------------------
pub struct PostgresStatsRepo {
pool: PgPool,
}
impl PostgresStatsRepo {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
pub fn pool(&self) -> &PgPool {
&self.pool
}
}
/// Update the Elo ratings of `teams` for `game_type` inside a transaction.
/// Returns the per-player delta.
pub async fn apply_elo(
tx: &mut Transaction<'_, Postgres>,
game_type: &str,
teams: &[Vec<String>],
winner_team: usize,
) -> Result<HashMap<String, i64>> {
if teams.len() != 2 {
return Err(Error::Internal(
"Elo rating requires exactly two teams".into(),
));
}
let mut ratings: HashMap<String, i32> = HashMap::new();
for team in teams {
for sub in team {
sqlx::query(
r#"INSERT INTO player_rating (id, user_sub, game_type, rating, matches_played, updated_at)
VALUES ($1, $2, $3, $4, 0, now())
ON CONFLICT (user_sub, game_type) DO NOTHING"#,
)
.bind(Uuid::new_v4())
.bind(sub)
.bind(game_type)
.bind(INITIAL_RATING as i32)
.execute(&mut **tx)
.await?;
let rating: (i32,) = sqlx::query_as(
r#"SELECT rating FROM player_rating WHERE user_sub = $1 AND game_type = $2 FOR UPDATE"#,
)
.bind(sub)
.bind(game_type)
.fetch_one(&mut **tx)
.await?;
ratings.insert(sub.clone(), rating.0);
}
}
let team_a: Vec<f64> = teams[0].iter().map(|sub| ratings[sub] as f64).collect();
let team_b: Vec<f64> = teams[1].iter().map(|sub| ratings[sub] as f64).collect();
let delta_a = match_delta(&team_a, &team_b, winner_team);
let mut deltas = HashMap::new();
for sub in &teams[0] {
deltas.insert(sub.clone(), delta_a);
}
for sub in &teams[1] {
deltas.insert(sub.clone(), -delta_a);
}
for (sub, delta) in &deltas {
sqlx::query(
r#"UPDATE player_rating
SET rating = rating + $1, matches_played = matches_played + 1, updated_at = now()
WHERE user_sub = $2 AND game_type = $3"#,
)
.bind(*delta as i32)
.bind(sub)
.bind(game_type)
.execute(&mut **tx)
.await?;
}
Ok(deltas)
}
fn row_to_entry(row: MatchRow, viewer: &str, players: &[MatchPlayerRow]) -> MatchHistoryEntry {
let viewer_won = players.iter().any(|p| p.user_sub == viewer && p.won);
let viewer_delta = players
.iter()
.find(|p| p.user_sub == viewer)
.and_then(|p| p.elo_delta)
.map(i32::from);
MatchHistoryEntry {
id: row.id.to_string(),
game_type: row.game_type,
result: row.result,
started_at: row.started_at,
finished_at: row.finished_at,
you_won: viewer_won,
your_elo_delta: viewer_delta,
players: players
.iter()
.map(|p| MatchPlayerView {
user_sub: p.user_sub.clone(),
display_name: p.display_name.clone(),
seat: p.seat as i32,
team: p.team.clone(),
won: p.won,
score: p.score,
elo_delta: p.elo_delta.map(i32::from),
details: p.details.clone(),
})
.collect(),
}
}
#[async_trait]
impl StatsRepo for PostgresStatsRepo {
async fn save_match(&self, session: &GameSession, engine: &dyn GameEngine) -> Result<()> {
let result = engine.result(session)?;
let started_at = session.created_at.unwrap_or_else(Utc::now);
let finished_at = session.finished_at.unwrap_or_else(Utc::now);
let display_names: HashMap<&str, &str> = session
.players
.iter()
.map(|seat| (seat.user_sub.as_str(), seat.display_name.as_str()))
.collect();
let mut tx = self.pool.begin().await?;
let match_id = Uuid::new_v4();
sqlx::query(
r#"INSERT INTO "match" (id, game_type, started_at, finished_at, result)
VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(match_id)
.bind(&session.game_type)
.bind(started_at)
.bind(finished_at)
.bind(&result.summary)
.execute(&mut *tx)
.await?;
let deltas = apply_elo(
&mut tx,
&session.game_type,
&result.teams,
result.winner_team,
)
.await?;
for player in &result.players {
let display_name = display_names
.get(player.user_sub.as_str())
.copied()
.unwrap_or(player.user_sub.as_str());
let delta = deltas.get(&player.user_sub).copied().unwrap_or(0);
sqlx::query(
r#"INSERT INTO match_player
(id, match_id, user_sub, display_name, seat, team, won, score, elo_delta, details)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"#,
)
.bind(Uuid::new_v4())
.bind(match_id)
.bind(&player.user_sub)
.bind(display_name)
.bind(player.seat as i16)
.bind(&player.team)
.bind(player.won)
.bind(player.score)
.bind(delta as i16)
.bind(&player.details)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
tracing::info!(
"match result persisted: game {} ({}), team {} of {} won",
session.id,
session.game_type,
result.winner_team,
result.teams.len()
);
Ok(())
}
async fn my_matches(
&self,
user_sub: &str,
game_type: Option<&str>,
limit: usize,
after: Option<&Map<String, Value>>,
) -> Result<(Vec<MatchHistoryEntry>, Option<String>)> {
let cursor_finished = after
.and_then(|map| map.get("finished_at"))
.and_then(crate::datetime::from_json);
let cursor_id = after
.and_then(|map| map.get("id"))
.and_then(Value::as_str)
.and_then(|value| Uuid::parse_str(value).ok());
let rows: Vec<MatchRow> = sqlx::query_as(
r#"SELECT DISTINCT m.id, m.game_type, m.started_at, m.finished_at, m.result
FROM "match" m
JOIN match_player p ON p.match_id = m.id
WHERE p.user_sub = $1
AND ($2::text IS NULL OR m.game_type = $2)
AND ($3::timestamptz IS NULL
OR m.finished_at < $3
OR (m.finished_at = $3 AND m.id > $4))
ORDER BY m.finished_at DESC, m.id ASC
LIMIT $5"#,
)
.bind(user_sub)
.bind(game_type)
.bind(cursor_finished)
.bind(cursor_id)
.bind((limit + 1) as i64)
.fetch_all(&self.pool)
.await?;
let has_more = rows.len() > limit;
let page: Vec<MatchRow> = rows.into_iter().take(limit).collect();
let next_cursor = if has_more {
page.last().map(|row| {
let mut values = Map::new();
values.insert(
"finished_at".into(),
Value::String(crate::datetime::to_iso(row.finished_at)),
);
values.insert("id".into(), Value::String(row.id.to_string()));
crate::pagination::encode_cursor(&values)
})
} else {
None
};
let mut players_by_match: HashMap<Uuid, Vec<MatchPlayerRow>> = HashMap::new();
if !page.is_empty() {
let ids: Vec<Uuid> = page.iter().map(|row| row.id).collect();
let players: Vec<MatchPlayerRow> = sqlx::query_as(
r#"SELECT id, match_id, user_sub, display_name, seat, team, won, score, elo_delta, details
FROM match_player WHERE match_id = ANY($1)
ORDER BY match_id, seat"#,
)
.bind(&ids)
.fetch_all(&self.pool)
.await?;
for player in players {
players_by_match
.entry(player.match_id)
.or_default()
.push(player);
}
}
let entries = page
.into_iter()
.map(|row| {
let players = players_by_match.remove(&row.id).unwrap_or_default();
row_to_entry(row, user_sub, &players)
})
.collect();
Ok((entries, next_cursor))
}
async fn my_ratings(&self, user_sub: &str) -> Result<Vec<RatingEntry>> {
let rows: Vec<(String, i32, i32)> = sqlx::query_as(
r#"SELECT game_type, rating, matches_played FROM player_rating
WHERE user_sub = $1 ORDER BY game_type"#,
)
.bind(user_sub)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|(game_type, rating, matches_played)| RatingEntry {
game_type,
rating,
matches_played,
})
.collect())
}
async fn leaderboard(
&self,
game_type: Option<&str>,
rating_game: &str,
) -> Result<Vec<LeaderboardEntry>> {
let rows: Vec<(String, String, bool, f64)> = sqlx::query_as(
r#"SELECT mp.user_sub, mp.display_name, mp.won, mp.score
FROM match_player mp
JOIN "match" m ON m.id = mp.match_id
WHERE ($1::text IS NULL OR m.game_type = $1)"#,
)
.bind(game_type)
.fetch_all(&self.pool)
.await?;
let ratings: Vec<(String, i32)> =
sqlx::query_as(r#"SELECT user_sub, rating FROM player_rating WHERE game_type = $1"#)
.bind(rating_game)
.fetch_all(&self.pool)
.await?;
let ratings: HashMap<String, i32> = ratings.into_iter().collect();
let mut order: Vec<String> = Vec::new();
let mut aggregate: HashMap<String, LeaderboardEntry> = HashMap::new();
for (user_sub, display_name, won, score) in rows {
let entry = aggregate.entry(user_sub.clone()).or_insert_with(|| {
order.push(user_sub.clone());
LeaderboardEntry {
user_sub: user_sub.clone(),
display_name: display_name.clone(),
matches: 0,
wins: 0,
points: 0.0,
elo: ratings
.get(&user_sub)
.copied()
.unwrap_or(INITIAL_RATING as i32),
}
});
entry.matches += 1;
entry.wins += u64::from(won);
entry.points += score;
// Keep the most recent display name seen.
entry.display_name = display_name;
}
let mut ranking: Vec<LeaderboardEntry> = order
.into_iter()
.filter_map(|sub| aggregate.remove(&sub))
.collect();
ranking.sort_by(|a, b| {
b.elo
.cmp(&a.elo)
.then(b.wins.cmp(&a.wins))
.then(
b.points
.partial_cmp(&a.points)
.unwrap_or(std::cmp::Ordering::Equal),
)
.then(a.matches.cmp(&b.matches))
});
Ok(ranking)
}
async fn backfill_elo(&self) -> Result<usize> {
let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM player_rating")
.execute(&mut *tx)
.await?;
let matches: Vec<MatchRow> = sqlx::query_as(
r#"SELECT id, game_type, started_at, finished_at, result FROM "match"
ORDER BY finished_at, id"#,
)
.fetch_all(&mut *tx)
.await?;
let mut replayed = 0usize;
for row in matches {
let players: Vec<MatchPlayerRow> = sqlx::query_as(
r#"SELECT id, match_id, user_sub, display_name, seat, team, won, score, elo_delta, details
FROM match_player WHERE match_id = $1"#,
)
.bind(row.id)
.fetch_all(&mut *tx)
.await?;
let mut by_team: std::collections::BTreeMap<String, Vec<String>> = Default::default();
for player in &players {
if let Some(team) = &player.team {
by_team
.entry(team.clone())
.or_default()
.push(player.user_sub.clone());
}
}
let teams: Vec<Vec<String>> = by_team.into_values().collect();
let winner_index = teams
.iter()
.position(|members| {
players
.iter()
.any(|p| p.won && members.contains(&p.user_sub))
})
.unwrap_or(0);
let deltas = apply_elo(&mut tx, &row.game_type, &teams, winner_index).await?;
for player in &players {
let delta = deltas.get(&player.user_sub).copied().unwrap_or(0);
sqlx::query("UPDATE match_player SET elo_delta = $1 WHERE id = $2")
.bind(delta as i16)
.bind(player.id)
.execute(&mut *tx)
.await?;
}
replayed += 1;
}
tx.commit().await?;
tracing::info!("elo backfill complete: {replayed} matches replayed");
Ok(replayed)
}
}
// ---------------------------------------------------------------------------
// In-memory implementation (tests and ephemeral dev)
// ---------------------------------------------------------------------------
#[derive(Clone)]
struct StoredMatch {
id: Uuid,
game_type: String,
started_at: DateTime<Utc>,
finished_at: DateTime<Utc>,
result: Value,
}
#[derive(Clone)]
struct StoredPlayer {
match_id: Uuid,
user_sub: String,
display_name: String,
seat: i16,
team: Option<String>,
won: bool,
score: f64,
elo_delta: Option<i32>,
details: Value,
}
#[derive(Default)]
struct MemoryStats {
matches: Vec<StoredMatch>,
players: Vec<StoredPlayer>,
ratings: HashMap<(String, String), (i32, i32)>,
}
#[derive(Default)]
pub struct InMemoryStatsRepo {
inner: tokio::sync::Mutex<MemoryStats>,
}
impl InMemoryStatsRepo {
pub fn new() -> Self {
Self::default()
}
}
fn memory_apply_elo(
inner: &mut MemoryStats,
game_type: &str,
teams: &[Vec<String>],
winner_team: usize,
) -> Result<HashMap<String, i64>> {
if teams.len() != 2 {
return Err(Error::Internal(
"Elo rating requires exactly two teams".into(),
));
}
let mut ratings: HashMap<String, i32> = HashMap::new();
for team in teams {
for sub in team {
let entry = inner
.ratings
.entry((sub.clone(), game_type.to_string()))
.or_insert((INITIAL_RATING as i32, 0));
ratings.insert(sub.clone(), entry.0);
}
}
let team_a: Vec<f64> = teams[0].iter().map(|sub| ratings[sub] as f64).collect();
let team_b: Vec<f64> = teams[1].iter().map(|sub| ratings[sub] as f64).collect();
let delta_a = match_delta(&team_a, &team_b, winner_team);
let mut deltas = HashMap::new();
for sub in &teams[0] {
deltas.insert(sub.clone(), delta_a);
}
for sub in &teams[1] {
deltas.insert(sub.clone(), -delta_a);
}
for (sub, delta) in &deltas {
let entry = inner
.ratings
.get_mut(&(sub.clone(), game_type.to_string()))
.expect("rating entry exists");
entry.0 += *delta as i32;
entry.1 += 1;
}
Ok(deltas)
}
#[async_trait]
impl StatsRepo for InMemoryStatsRepo {
async fn save_match(&self, session: &GameSession, engine: &dyn GameEngine) -> Result<()> {
let result = engine.result(session)?;
let started_at = session.created_at.unwrap_or_else(Utc::now);
let finished_at = session.finished_at.unwrap_or_else(Utc::now);
let mut inner = self.inner.lock().await;
let match_id = Uuid::new_v4();
let deltas = memory_apply_elo(
&mut inner,
&session.game_type,
&result.teams,
result.winner_team,
)?;
inner.matches.push(StoredMatch {
id: match_id,
game_type: session.game_type.clone(),
started_at,
finished_at,
result: result.summary.clone(),
});
for player in &result.players {
let display_name = session
.seat_of(&player.user_sub)
.map(|seat| seat.display_name.clone())
.unwrap_or_else(|| player.user_sub.clone());
inner.players.push(StoredPlayer {
match_id,
user_sub: player.user_sub.clone(),
display_name,
seat: player.seat as i16,
team: player.team.clone(),
won: player.won,
score: player.score,
elo_delta: deltas.get(&player.user_sub).map(|d| *d as i32),
details: player.details.clone(),
});
}
Ok(())
}
async fn my_matches(
&self,
user_sub: &str,
game_type: Option<&str>,
limit: usize,
after: Option<&Map<String, Value>>,
) -> Result<(Vec<MatchHistoryEntry>, Option<String>)> {
let inner = self.inner.lock().await;
let cursor_finished = after
.and_then(|map| map.get("finished_at"))
.and_then(crate::datetime::from_json);
let cursor_id = after
.and_then(|map| map.get("id"))
.and_then(Value::as_str)
.and_then(|value| Uuid::parse_str(value).ok());
let mut candidate_ids: Vec<Uuid> = inner
.players
.iter()
.filter(|p| p.user_sub == user_sub)
.map(|p| p.match_id)
.collect();
candidate_ids.sort();
candidate_ids.dedup();
let mut matches: Vec<&StoredMatch> = candidate_ids
.iter()
.filter_map(|id| inner.matches.iter().find(|m| m.id == *id))
.filter(|m| game_type.is_none_or(|game_type| m.game_type == game_type))
.filter(|m| match (cursor_finished, cursor_id) {
(Some(finished), Some(id)) => {
m.finished_at < finished || (m.finished_at == finished && m.id > id)
}
_ => true,
})
.collect();
matches.sort_by(|a, b| b.finished_at.cmp(&a.finished_at).then(a.id.cmp(&b.id)));
let has_more = matches.len() > limit;
matches.truncate(limit);
let next_cursor = if has_more {
matches.last().map(|row| {
let mut values = Map::new();
values.insert(
"finished_at".into(),
Value::String(crate::datetime::to_iso(row.finished_at)),
);
values.insert("id".into(), Value::String(row.id.to_string()));
crate::pagination::encode_cursor(&values)
})
} else {
None
};
let entries = matches
.into_iter()
.map(|row| {
let players: Vec<MatchPlayerRow> = inner
.players
.iter()
.filter(|p| p.match_id == row.id)
.map(|p| MatchPlayerRow {
id: Uuid::nil(),
match_id: p.match_id,
user_sub: p.user_sub.clone(),
display_name: p.display_name.clone(),
seat: p.seat,
team: p.team.clone(),
won: p.won,
score: p.score,
elo_delta: p.elo_delta.map(|d| d as i16),
details: p.details.clone(),
})
.collect();
row_to_entry(
MatchRow {
id: row.id,
game_type: row.game_type.clone(),
started_at: row.started_at,
finished_at: row.finished_at,
result: row.result.clone(),
},
user_sub,
&players,
)
})
.collect();
Ok((entries, next_cursor))
}
async fn my_ratings(&self, user_sub: &str) -> Result<Vec<RatingEntry>> {
let inner = self.inner.lock().await;
let mut rows: Vec<RatingEntry> = inner
.ratings
.iter()
.filter(|((sub, _), _)| sub == user_sub)
.map(|((_, game_type), (rating, matches_played))| RatingEntry {
game_type: game_type.clone(),
rating: *rating,
matches_played: *matches_played,
})
.collect();
rows.sort_by(|a, b| a.game_type.cmp(&b.game_type));
Ok(rows)
}
async fn leaderboard(
&self,
game_type: Option<&str>,
rating_game: &str,
) -> Result<Vec<LeaderboardEntry>> {
let inner = self.inner.lock().await;
let ratings: HashMap<&str, i32> = inner
.ratings
.iter()
.filter(|((_, gt), _)| gt == rating_game)
.map(|((sub, _), (rating, _))| (sub.as_str(), *rating))
.collect();
let mut order: Vec<String> = Vec::new();
let mut aggregate: HashMap<String, LeaderboardEntry> = HashMap::new();
for player in &inner.players {
let in_scope = match game_type {
Some(game_type) => inner
.matches
.iter()
.any(|m| m.id == player.match_id && m.game_type == game_type),
None => true,
};
if !in_scope {
continue;
}
let entry = aggregate.entry(player.user_sub.clone()).or_insert_with(|| {
order.push(player.user_sub.clone());
LeaderboardEntry {
user_sub: player.user_sub.clone(),
display_name: player.display_name.clone(),
matches: 0,
wins: 0,
points: 0.0,
elo: ratings
.get(player.user_sub.as_str())
.copied()
.unwrap_or(INITIAL_RATING as i32),
}
});
entry.matches += 1;
entry.wins += u64::from(player.won);
entry.points += player.score;
entry.display_name = player.display_name.clone();
}
let mut ranking: Vec<LeaderboardEntry> = order
.into_iter()
.filter_map(|sub| aggregate.remove(&sub))
.collect();
ranking.sort_by(|a, b| {
b.elo
.cmp(&a.elo)
.then(b.wins.cmp(&a.wins))
.then(
b.points
.partial_cmp(&a.points)
.unwrap_or(std::cmp::Ordering::Equal),
)
.then(a.matches.cmp(&b.matches))
});
Ok(ranking)
}
async fn backfill_elo(&self) -> Result<usize> {
let mut inner = self.inner.lock().await;
inner.ratings.clear();
let matches = inner.matches.clone();
for row in &matches {
let players: Vec<StoredPlayer> = inner
.players
.iter()
.filter(|p| p.match_id == row.id)
.cloned()
.collect();
let mut by_team: std::collections::BTreeMap<String, Vec<String>> = Default::default();
for player in &players {
if let Some(team) = &player.team {
by_team
.entry(team.clone())
.or_default()
.push(player.user_sub.clone());
}
}
let teams: Vec<Vec<String>> = by_team.into_values().collect();
let winner_index = teams
.iter()
.position(|members| {
players
.iter()
.any(|p| p.won && members.contains(&p.user_sub))
})
.unwrap_or(0);
let deltas = memory_apply_elo(&mut inner, &row.game_type, &teams, winner_index)?;
for player in inner.players.iter_mut().filter(|p| p.match_id == row.id) {
player.elo_delta = deltas.get(&player.user_sub).map(|d| *d as i32);
}
}
Ok(matches.len())
}
}
+588
View File
@@ -0,0 +1,588 @@
//! Persistence for live games.
//!
//! Game sessions are small, mutable and short-lived, which makes Redis a
//! natural fit: the whole match is a single JSON value under
//! `tavolo:game:<id>` with a sliding TTL, and a join-code index maps the
//! short code a player shares to that id. Completed matches are copied to
//! Postgres (see [`crate::stats`]); Redis keeps serving the finished session
//! until it expires.
//!
//! Two implementations satisfy [`GameStore`]:
//!
//! * [`RedisGameStore`] — production, used when `REDIS_URL` is set.
//! * [`InMemoryGameStore`] — tests and ephemeral dev, used otherwise.
//!
//! Concurrency is handled with a per-game lock so two simultaneous actions
//! cannot interleave. State changes are broadcast on a per-game pub/sub
//! channel as a simple "something changed" signal; every open websocket
//! reloads the session and renders the personalized view.
//!
//! Timeouts are driven by a shared delayed-deadline queue (see
//! [`crate::deadlines`]).
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use futures_util::stream::{BoxStream, StreamExt};
use tokio::sync::{broadcast, mpsc, Mutex, OwnedMutexGuard};
use crate::engine::{session_from_json, session_to_json, GameSession};
use crate::errors::{Error, Result};
use crate::registry::GameRegistry;
pub const GAME_KEY_PREFIX: &str = "tavolo:game:";
pub const CODE_KEY_PREFIX: &str = "tavolo:code:";
/// Channel prefix of the per-game change notifications.
pub const CHANNEL_PREFIX: &str = "tavolo:game:";
pub const DEADLINES_KEY: &str = "tavolo:deadlines";
/// Channel announcing newly enqueued deadline entries, so every worker's
/// consumer can wake immediately instead of polling on its heartbeat.
pub const DEADLINES_CHANNEL: &str = "tavolo:deadlines:events";
/// Sorted set of running games (score = expiry timestamp), maintained by the
/// stores so the telemetry gauge can count ongoing matches without loading
/// every session. Finished games are removed on their final save.
pub const ONGOING_GAMES_KEY: &str = "tavolo:games:ongoing";
const LOCK_TIMEOUT_SECONDS: f64 = 10.0;
const LOCK_BLOCKING_TIMEOUT_SECONDS: f64 = 10.0;
fn channel(game_id: &str) -> String {
format!("{CHANNEL_PREFIX}{game_id}:events")
}
fn now_secs() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or_default()
}
/// A held per-game mutation lock. Released explicitly with
/// [`GameLock::release`] or best-effort on drop.
pub enum GameLock {
Memory(OwnedMutexGuard<()>),
Redis {
manager: redis::aio::ConnectionManager,
key: String,
token: String,
released: bool,
},
}
const UNLOCK_SCRIPT: &str = "if redis.call('get', KEYS[1]) == ARGV[1] \
then return redis.call('del', KEYS[1]) else return 0 end";
impl GameLock {
/// Release the lock. Consumes the guard; plain drop also releases it
/// (best-effort for the Redis variant).
pub async fn release(mut self) -> Result<()> {
if let GameLock::Redis {
manager,
key,
token,
released,
} = &mut self
{
if !*released {
*released = true;
let script = redis::Script::new(UNLOCK_SCRIPT);
let mut conn = manager.clone();
let _: i64 = script
.key(key.clone())
.arg(token.clone())
.invoke_async(&mut conn)
.await?;
}
}
Ok(())
}
}
impl Drop for GameLock {
fn drop(&mut self) {
if let GameLock::Redis {
manager,
key,
token,
released,
} = self
{
if !*released {
*released = true;
let mut manager = manager.clone();
let key = key.clone();
let token = token.clone();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
let script = redis::Script::new(UNLOCK_SCRIPT);
let _: std::result::Result<i64, _> =
script.key(key).arg(token).invoke_async(&mut manager).await;
});
}
}
}
}
}
/// Abstract persistence + notification layer for live games.
#[async_trait]
pub trait GameStore: Send + Sync {
/// Return the live session for `game_id` or `None`.
async fn load(&self, game_id: &str) -> Result<Option<GameSession>>;
/// Persist `session`, refreshing its TTL and code index.
async fn save(&self, session: &GameSession) -> Result<()>;
/// Return the live session for a join `code` or `None`.
async fn find_by_code(&self, code: &str) -> Result<Option<GameSession>>;
/// Number of live games that have not finished yet.
async fn count_ongoing(&self) -> Result<usize>;
/// Acquire the per-game mutation lock, waiting up to ten seconds.
async fn acquire_lock(&self, game_id: &str) -> Result<GameLock>;
/// Signal that the session of `game_id` changed.
async fn publish(&self, game_id: &str) -> Result<()>;
/// Stream of change signals for one game.
async fn subscribe(&self, game_id: &str) -> Result<BoxStream<'static, ()>>;
/// Enqueue `member` to fire at `due_at` (epoch seconds). Idempotent for
/// identical members: re-adding only updates the due time.
async fn add_deadline(&self, member: &str, due_at: f64) -> Result<()>;
/// Return up to `limit` enqueued members due at or before `now`.
async fn due_deadlines(&self, now: f64, limit: usize) -> Result<Vec<String>>;
/// Return the earliest pending due time (epoch seconds), if any.
async fn next_deadline(&self) -> Result<Option<f64>>;
/// Remove `member` from the queue; a no-op when absent.
async fn remove_deadline(&self, member: &str) -> Result<()>;
/// Wake every worker's deadline consumer (best effort).
async fn notify_deadline(&self) -> Result<()>;
/// Stream of deadline-enqueue signals.
async fn subscribe_deadlines(&self) -> Result<BoxStream<'static, ()>>;
}
pub type SharedGameStore = Arc<dyn GameStore>;
fn broadcast_stream(rx: broadcast::Receiver<()>) -> BoxStream<'static, ()> {
Box::pin(futures_util::stream::unfold(rx, |mut rx| async move {
loop {
match rx.recv().await {
Ok(()) => return Some(((), rx)),
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => return None,
}
}
}))
}
fn mpsc_stream(rx: mpsc::Receiver<()>) -> BoxStream<'static, ()> {
Box::pin(futures_util::stream::unfold(rx, |mut rx| async move {
rx.recv().await.map(|()| ((), rx))
}))
}
// ---------------------------------------------------------------------------
// Redis store
// ---------------------------------------------------------------------------
pub struct RedisGameStore {
client: redis::Client,
manager: redis::aio::ConnectionManager,
registry: Arc<GameRegistry>,
ttl: i64,
}
impl RedisGameStore {
pub async fn new(url: &str, registry: Arc<GameRegistry>, ttl_seconds: i64) -> Result<Self> {
let client = redis::Client::open(url)?;
let manager = redis::aio::ConnectionManager::new(client.clone()).await?;
Ok(Self {
client,
manager,
registry,
ttl: ttl_seconds,
})
}
fn game_key(&self, game_id: &str) -> String {
format!("{GAME_KEY_PREFIX}{game_id}")
}
fn lock_key(&self, game_id: &str) -> String {
format!("{GAME_KEY_PREFIX}{game_id}:lock")
}
fn code_key(&self, code: &str) -> String {
format!("{CODE_KEY_PREFIX}{}", code.to_uppercase())
}
async fn subscribe_to(&self, target: String) -> Result<BoxStream<'static, ()>> {
let mut pubsub = self.client.get_async_pubsub().await?;
pubsub.subscribe(target).await?;
let (tx, rx) = mpsc::channel(32);
let mut messages = pubsub.into_on_message();
tokio::spawn(async move {
while let Some(_message) = messages.next().await {
if tx.send(()).await.is_err() {
break;
}
}
});
Ok(mpsc_stream(rx))
}
}
#[async_trait]
impl GameStore for RedisGameStore {
async fn load(&self, game_id: &str) -> Result<Option<GameSession>> {
let mut conn = self.manager.clone();
let raw: Option<String> = redis::cmd("GET")
.arg(self.game_key(game_id))
.query_async(&mut conn)
.await?;
let Some(raw) = raw else {
tracing::debug!("redis load {game_id}: miss");
return Ok(None);
};
tracing::debug!("redis load {game_id}: hit");
let data: serde_json::Value = serde_json::from_str(&raw)?;
let game_type = data
.get("game_type")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| Error::Store("stored session is missing game_type".into()))?;
let engine = self.registry.require(game_type)?;
Ok(Some(session_from_json(&data, engine.as_ref())?))
}
async fn save(&self, session: &GameSession) -> Result<()> {
let engine = self.registry.require(&session.game_type)?;
let payload = serde_json::to_string(&session_to_json(session, engine.as_ref())?)?;
let mut conn = self.manager.clone();
let mut pipe = redis::pipe();
pipe.atomic();
pipe.cmd("SET")
.arg(self.game_key(&session.id))
.arg(&payload)
.arg("EX")
.arg(self.ttl);
pipe.cmd("SET")
.arg(self.code_key(&session.join_code))
.arg(&session.id)
.arg("EX")
.arg(self.ttl);
if engine.is_finished(session) {
pipe.cmd("ZREM").arg(ONGOING_GAMES_KEY).arg(&session.id);
} else {
pipe.cmd("ZADD")
.arg(ONGOING_GAMES_KEY)
.arg(now_secs() + self.ttl as f64)
.arg(&session.id);
pipe.cmd("EXPIRE").arg(ONGOING_GAMES_KEY).arg(self.ttl);
}
let _: Vec<redis::Value> = pipe.query_async(&mut conn).await?;
tracing::debug!(
"redis save {} (game {}, ttl {}s)",
session.id,
session.game_type,
self.ttl
);
Ok(())
}
async fn find_by_code(&self, code: &str) -> Result<Option<GameSession>> {
let mut conn = self.manager.clone();
let game_id: Option<String> = redis::cmd("GET")
.arg(self.code_key(code))
.query_async(&mut conn)
.await?;
match game_id {
Some(game_id) => self.load(&game_id).await,
None => Ok(None),
}
}
async fn count_ongoing(&self) -> Result<usize> {
let mut conn = self.manager.clone();
let mut pipe = redis::pipe();
pipe.atomic();
pipe.cmd("ZREMRANGEBYSCORE")
.arg(ONGOING_GAMES_KEY)
.arg("-inf")
.arg(now_secs());
pipe.cmd("ZCARD").arg(ONGOING_GAMES_KEY);
let values: Vec<i64> = pipe.query_async(&mut conn).await?;
Ok(values.last().copied().unwrap_or(0).max(0) as usize)
}
async fn acquire_lock(&self, game_id: &str) -> Result<GameLock> {
let key = self.lock_key(game_id);
let token = uuid::Uuid::new_v4().to_string();
let mut conn = self.manager.clone();
let deadline = std::time::Instant::now()
+ std::time::Duration::from_secs_f64(LOCK_BLOCKING_TIMEOUT_SECONDS);
loop {
let acquired: Option<String> = redis::cmd("SET")
.arg(&key)
.arg(&token)
.arg("NX")
.arg("PX")
.arg((LOCK_TIMEOUT_SECONDS * 1000.0) as i64)
.query_async(&mut conn)
.await?;
if acquired.is_some() {
return Ok(GameLock::Redis {
manager: self.manager.clone(),
key,
token,
released: false,
});
}
if std::time::Instant::now() >= deadline {
return Err(Error::Store(format!(
"could not acquire the lock for game {game_id}"
)));
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}
async fn publish(&self, game_id: &str) -> Result<()> {
let mut conn = self.manager.clone();
redis::cmd("PUBLISH")
.arg(channel(game_id))
.arg("update")
.query_async::<i64>(&mut conn)
.await?;
tracing::debug!("redis publish {game_id}");
Ok(())
}
async fn subscribe(&self, game_id: &str) -> Result<BoxStream<'static, ()>> {
self.subscribe_to(channel(game_id)).await
}
async fn add_deadline(&self, member: &str, due_at: f64) -> Result<()> {
let mut conn = self.manager.clone();
redis::cmd("ZADD")
.arg(DEADLINES_KEY)
.arg(due_at)
.arg(member)
.query_async::<i64>(&mut conn)
.await?;
Ok(())
}
async fn due_deadlines(&self, now: f64, limit: usize) -> Result<Vec<String>> {
let mut conn = self.manager.clone();
let members: Vec<String> = redis::cmd("ZRANGE")
.arg(DEADLINES_KEY)
.arg("-inf")
.arg(now)
.arg("BYSCORE")
.arg("LIMIT")
.arg(0)
.arg(limit)
.query_async(&mut conn)
.await?;
Ok(members)
}
async fn next_deadline(&self) -> Result<Option<f64>> {
let mut conn = self.manager.clone();
let earliest: Vec<(String, f64)> = redis::cmd("ZRANGE")
.arg(DEADLINES_KEY)
.arg(0)
.arg(0)
.arg("WITHSCORES")
.query_async(&mut conn)
.await?;
Ok(earliest.first().map(|(_, score)| *score))
}
async fn remove_deadline(&self, member: &str) -> Result<()> {
let mut conn = self.manager.clone();
redis::cmd("ZREM")
.arg(DEADLINES_KEY)
.arg(member)
.query_async::<i64>(&mut conn)
.await?;
Ok(())
}
async fn notify_deadline(&self) -> Result<()> {
let mut conn = self.manager.clone();
redis::cmd("PUBLISH")
.arg(DEADLINES_CHANNEL)
.arg("deadline")
.query_async::<i64>(&mut conn)
.await?;
Ok(())
}
async fn subscribe_deadlines(&self) -> Result<BoxStream<'static, ()>> {
self.subscribe_to(DEADLINES_CHANNEL.to_string()).await
}
}
// ---------------------------------------------------------------------------
// In-memory store
// ---------------------------------------------------------------------------
/// Process-local store used by tests and when Redis is not configured.
pub struct InMemoryGameStore {
registry: Arc<GameRegistry>,
games: Mutex<HashMap<String, String>>,
codes: Mutex<HashMap<String, String>>,
ongoing: Mutex<HashSet<String>>,
locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
subscribers: Mutex<HashMap<String, broadcast::Sender<()>>>,
deadlines: Mutex<HashMap<String, f64>>,
deadline_subscribers: broadcast::Sender<()>,
}
impl InMemoryGameStore {
pub fn new(registry: Arc<GameRegistry>) -> Self {
let (deadline_subscribers, _) = broadcast::channel(64);
Self {
registry,
games: Mutex::new(HashMap::new()),
codes: Mutex::new(HashMap::new()),
ongoing: Mutex::new(HashSet::new()),
locks: Mutex::new(HashMap::new()),
subscribers: Mutex::new(HashMap::new()),
deadlines: Mutex::new(HashMap::new()),
deadline_subscribers,
}
}
async fn sender_for(&self, game_id: &str) -> broadcast::Sender<()> {
let mut subscribers = self.subscribers.lock().await;
subscribers
.entry(game_id.to_string())
.or_insert_with(|| broadcast::channel(64).0)
.clone()
}
}
#[async_trait]
impl GameStore for InMemoryGameStore {
async fn load(&self, game_id: &str) -> Result<Option<GameSession>> {
let raw = self.games.lock().await.get(game_id).cloned();
let Some(raw) = raw else {
return Ok(None);
};
let data: serde_json::Value = serde_json::from_str(&raw)?;
let game_type = data
.get("game_type")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| Error::Store("stored session is missing game_type".into()))?;
let engine = self.registry.require(game_type)?;
Ok(Some(session_from_json(&data, engine.as_ref())?))
}
async fn save(&self, session: &GameSession) -> Result<()> {
let engine = self.registry.require(&session.game_type)?;
let payload = serde_json::to_string(&session_to_json(session, engine.as_ref())?)?;
self.games.lock().await.insert(session.id.clone(), payload);
self.codes
.lock()
.await
.insert(session.join_code.to_uppercase(), session.id.clone());
if engine.is_finished(session) {
self.ongoing.lock().await.remove(&session.id);
} else {
self.ongoing.lock().await.insert(session.id.clone());
}
Ok(())
}
async fn find_by_code(&self, code: &str) -> Result<Option<GameSession>> {
let game_id = self.codes.lock().await.get(&code.to_uppercase()).cloned();
match game_id {
Some(game_id) => self.load(&game_id).await,
None => Ok(None),
}
}
async fn count_ongoing(&self) -> Result<usize> {
Ok(self.ongoing.lock().await.len())
}
async fn acquire_lock(&self, game_id: &str) -> Result<GameLock> {
let lock = {
let mut locks = self.locks.lock().await;
locks
.entry(game_id.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
};
Ok(GameLock::Memory(lock.lock_owned().await))
}
async fn publish(&self, game_id: &str) -> Result<()> {
let sender = self.sender_for(game_id).await;
let _ = sender.send(());
Ok(())
}
async fn subscribe(&self, game_id: &str) -> Result<BoxStream<'static, ()>> {
let sender = self.sender_for(game_id).await;
Ok(broadcast_stream(sender.subscribe()))
}
async fn add_deadline(&self, member: &str, due_at: f64) -> Result<()> {
self.deadlines
.lock()
.await
.insert(member.to_string(), due_at);
Ok(())
}
async fn due_deadlines(&self, now: f64, limit: usize) -> Result<Vec<String>> {
let deadlines = self.deadlines.lock().await;
let mut due: Vec<(String, f64)> = deadlines
.iter()
.filter(|(_, due_at)| **due_at <= now)
.map(|(member, due_at)| (member.clone(), *due_at))
.collect();
due.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
Ok(due
.into_iter()
.take(limit)
.map(|(member, _)| member)
.collect())
}
async fn next_deadline(&self) -> Result<Option<f64>> {
Ok(self
.deadlines
.lock()
.await
.values()
.copied()
.min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)))
}
async fn remove_deadline(&self, member: &str) -> Result<()> {
self.deadlines.lock().await.remove(member);
Ok(())
}
async fn notify_deadline(&self) -> Result<()> {
let _ = self.deadline_subscribers.send(());
Ok(())
}
async fn subscribe_deadlines(&self) -> Result<BoxStream<'static, ()>> {
Ok(broadcast_stream(self.deadline_subscribers.subscribe()))
}
}
+324
View File
@@ -0,0 +1,324 @@
//! WebSocket endpoint for live play.
//!
//! Clients connect to `/ws/games/{game_id}` using their session cookie. Only
//! seated players are accepted; the upgrade is refused before the handshake
//! completes with an HTTP status mirroring `GET /api/games/{id}`: 401
//! unauthenticated, 404 unknown game, 403 not seated.
//!
//! Server -> client messages are JSON objects with a `type`: `state`,
//! `game_over` and `error`. Client -> server messages are JSON objects with
//! an `action`: the platform handles `state`/`sync` itself; every other
//! action is dispatched to the session's game engine with the rest of the
//! message as its payload.
//!
//! Mutations run under the per-game lock; after a successful action the new
//! session is saved and a change signal is published. Every connected
//! websocket is subscribed to that signal and re-renders the state.
use std::sync::Arc;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Extension;
use futures_util::stream::{SplitSink, SplitStream};
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Map, Value};
use tokio::sync::Mutex;
use tracing::Instrument;
use crate::auth::current_user;
use crate::engine::GameSession;
use crate::http::py_repr;
use crate::platform::{Platform, SharedPlatform};
use crate::session::SessionHandle;
/// The live-play endpoint; rejects non-seated callers before the upgrade.
pub async fn game_socket(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
Path(game_id): Path<String>,
ws: WebSocketUpgrade,
) -> Response {
let Some(user) = current_user(&session).await else {
tracing::debug!("websocket {game_id} rejected: no authenticated user");
return StatusCode::UNAUTHORIZED.into_response();
};
let game = match platform.game_store.load(&game_id).await {
Ok(Some(game)) => game,
Ok(None) => {
tracing::debug!("websocket rejected: unknown game {game_id}");
return StatusCode::NOT_FOUND.into_response();
}
Err(err) => {
tracing::error!("websocket game load failed: {err}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
if !game.seated(&user.sub) {
tracing::debug!("websocket {game_id} rejected: {} is not seated", user.sub);
return StatusCode::FORBIDDEN.into_response();
}
let session_tag = {
let guard = session.lock().await;
guard.id().map(crate::auth::session_tag)
};
let sub = user.sub.clone();
let span = tracing::info_span!(
"websocket",
"user.id" = %user.sub,
"session.id" = tracing::field::Empty,
"game.id" = %game_id,
);
if let Some(tag) = session_tag {
span.record("session.id", tag);
}
ws.on_upgrade(move |socket| {
async move {
run_socket(platform, socket, game_id, sub).await;
}
.instrument(span)
})
}
#[derive(Clone)]
struct Sender {
sink: Arc<Mutex<SplitSink<WebSocket, Message>>>,
}
impl Sender {
async fn send(&self, payload: Value) {
let Ok(text) = serde_json::to_string(&payload) else {
return;
};
let mut sink = self.sink.lock().await;
let _ = sink.send(Message::Text(text.into())).await;
}
}
fn error_payload(message: impl Into<String>, code: &str) -> Value {
json!({ "type": "error", "code": code, "message": message.into() })
}
fn state_message(platform: &Platform, game: &GameSession, sub: &str) -> Option<Value> {
let engine = platform.registry.require(&game.game_type).ok()?;
let mut game_view = json!({
"id": game.id,
"join_code": game.join_code,
"game_type": game.game_type,
});
if let (Value::Object(view), Value::Object(base)) = (engine.view_for(game, sub), &mut game_view)
{
for (key, value) in view {
base.insert(key, value);
}
}
Some(json!({ "type": "state", "game": game_view }))
}
async fn run_socket(platform: SharedPlatform, socket: WebSocket, game_id: String, sub: String) {
let (sink, mut stream): (SplitSink<WebSocket, Message>, SplitStream<WebSocket>) =
socket.split();
let sender = Sender {
sink: Arc::new(Mutex::new(sink)),
};
tracing::info!("{sub} connected to game {game_id}");
// Initial snapshot and a deadline backstop (the entry may have been lost
// while the game lived on thanks to its sliding TTL).
match platform.game_store.load(&game_id).await {
Ok(Some(game)) => {
if let Some(message) = state_message(&platform, &game, &sub) {
sender.send(message).await;
}
let _ = platform.scheduler.sync_deadline(&game).await;
}
_ => return,
}
let mut events = match platform.game_store.subscribe(&game_id).await {
Ok(events) => events,
Err(err) => {
tracing::error!("websocket subscribe failed: {err}");
return;
}
};
let forward_sender = sender.clone();
let forward_platform = platform.clone();
let forward_game_id = game_id.clone();
let forward_sub = sub.clone();
let forward = tokio::spawn(async move {
while events.next().await.is_some() {
let Ok(Some(game)) = forward_platform.game_store.load(&forward_game_id).await else {
return;
};
if let Some(message) = state_message(&forward_platform, &game, &forward_sub) {
forward_sender.send(message).await;
}
if let Ok(engine) = forward_platform.registry.require(&game.game_type) {
if engine.is_finished(&game) {
let mut payload = json!({ "type": "game_over" });
if let (Value::Object(extra), Value::Object(base)) =
(engine.game_over_view(&game, &forward_sub), &mut payload)
{
for (key, value) in extra {
base.insert(key, value);
}
}
forward_sender.send(payload).await;
return;
}
}
}
});
while let Some(message) = stream.next().await {
match message {
Ok(Message::Text(text)) => {
handle_message(&platform, &sender, &game_id, &sub, text.as_str()).await;
}
Ok(Message::Binary(_)) => {
sender
.send(error_payload(
"expected a text frame with a JSON object",
"invalid",
))
.await;
}
Ok(Message::Close(_)) => break,
Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => {}
Err(err) => {
tracing::debug!("websocket {game_id} receive error: {err}");
break;
}
}
}
forward.abort();
tracing::debug!("{sub} disconnected from game {game_id}");
}
async fn handle_message(
platform: &SharedPlatform,
sender: &Sender,
game_id: &str,
sub: &str,
raw: &str,
) {
let parsed: Value = match serde_json::from_str(raw) {
Ok(parsed) => parsed,
Err(_) => {
tracing::debug!("game {game_id}: malformed message from {sub} (not JSON)");
sender.send(error_payload("invalid JSON", "invalid")).await;
return;
}
};
let Value::Object(data) = parsed else {
tracing::debug!("game {game_id}: malformed message from {sub} (not an object)");
sender
.send(error_payload("message must be a JSON object", "invalid"))
.await;
return;
};
let action = data.get("action");
if let Some(Value::String(action)) = action {
if action == "state" || action == "sync" {
if let Ok(Some(game)) = platform.game_store.load(game_id).await {
if let Some(message) = state_message(platform, &game, sub) {
sender.send(message).await;
}
}
return;
}
}
let Some(Value::String(action)) = action else {
let value = action.cloned().unwrap_or(Value::Null);
sender
.send(error_payload(
format!("unknown action: {}", py_repr(&value)),
"invalid",
))
.await;
return;
};
handle_action(platform, sender, game_id, sub, action, &data).await;
}
async fn handle_action(
platform: &SharedPlatform,
sender: &Sender,
game_id: &str,
sub: &str,
action: &str,
data: &Map<String, Value>,
) {
let lock = match platform.game_store.acquire_lock(game_id).await {
Ok(lock) => lock,
Err(err) => {
tracing::error!("game {game_id}: lock failed: {err}");
sender
.send(error_payload("internal error", "internal"))
.await;
return;
}
};
let result = async {
let Some(mut game) = (match platform.game_store.load(game_id).await {
Ok(game) => game,
Err(err) => {
tracing::error!("game {game_id}: load failed: {err}");
return Err(());
}
}) else {
sender
.send(error_payload("game not found", "not_found"))
.await;
return Ok(());
};
let engine = match platform.registry.require(&game.game_type) {
Ok(engine) => engine.clone(),
Err(err) => {
tracing::error!("game {game_id}: engine lookup failed: {err}");
return Err(());
}
};
let payload: Map<String, Value> = data
.iter()
.filter(|(key, _)| key.as_str() != "action")
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
match engine.handle_action(&mut game, sub, action, &payload) {
Ok(()) => {}
Err(err) => match err.as_game() {
Some(game_error) => {
tracing::debug!(
"game {game_id}: rejected {action:?} by {sub}: {}",
game_error.message
);
sender
.send(error_payload(&game_error.message, game_error.code))
.await;
return Ok(());
}
None => {
tracing::error!("game {game_id}: action failed: {err}");
return Err(());
}
},
}
tracing::debug!("game {game_id}: {sub} performed {action:?}");
if let Err(err) = platform.scheduler.finalize_mutation(&mut game).await {
tracing::error!("game {game_id}: finalize failed: {err}");
}
Ok(())
}
.await;
let _ = lock.release().await;
if result.is_err() {
sender
.send(error_payload("internal error", "internal"))
.await;
}
}
@@ -0,0 +1,469 @@
//! Shared fixtures for the tavolo-platform test suite.
//!
//! `DummyEngine` is a tiny two-player game implementing the platform's
//! `GameEngine` contract, so every platform behaviour (lobby, store,
//! websockets, deadlines, stats) is exercised without importing any real
//! game. Its rules: the first player to reach `target` plays wins.
#![allow(dead_code)]
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, Response, StatusCode};
use axum::Router;
use futures_util::StreamExt;
use serde_json::{json, Map, Value};
use tower::ServiceExt;
use tavolo_platform::deadlines::DeadlineScheduler;
use tavolo_platform::engine::{Deadline, GameEngine, GameSession, MatchResult, PlayerResult, Seat};
use tavolo_platform::errors::{Error, GameError, Result};
use tavolo_platform::oidc::{OidcProviderConfig, OidcProviders};
use tavolo_platform::platform::{Platform, SharedPlatform};
use tavolo_platform::registry::GameRegistry;
use tavolo_platform::session::{session_middleware, InMemorySessionStore, SharedSessionStore};
use tavolo_platform::stats::{InMemoryStatsRepo, SharedStatsRepo};
use tavolo_platform::store::{InMemoryGameStore, SharedGameStore};
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct DummyState {
pub target: i64,
pub plays: Vec<String>,
pub started: bool,
pub finished: bool,
pub winner: Option<String>,
pub deadline_in_seconds: Option<f64>,
}
pub struct DummyEngine;
fn state_of(session: &GameSession) -> Result<&DummyState> {
session
.state
.as_ref()
.and_then(|state| state.downcast_ref::<DummyState>())
.ok_or_else(|| Error::Internal("dummy state missing".into()))
}
fn state_of_mut(session: &mut GameSession) -> Result<&mut DummyState> {
session
.state
.as_mut()
.and_then(|state| state.downcast_mut::<DummyState>())
.ok_or_else(|| Error::Internal("dummy state missing".into()))
}
impl DummyEngine {
fn play(session: &mut GameSession, user_sub: &str) -> Result<()> {
let state = state_of_mut(session)?;
state.plays.push(user_sub.to_string());
if state.plays.len() as i64 >= state.target {
state.finished = true;
state.winner = Some(user_sub.to_string());
}
Ok(())
}
}
impl GameEngine for DummyEngine {
fn id(&self) -> &'static str {
"dummy"
}
fn name(&self) -> &'static str {
"Dummy game"
}
fn description(&self) -> &'static str {
"A two-player toy game for testing the platform."
}
fn min_players(&self) -> usize {
2
}
fn max_players(&self) -> usize {
2
}
fn options_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"target": {
"type": "integer",
"minimum": 1,
"default": 3,
"description": "Plays needed to win the match.",
},
"deadline_in_seconds": {
"type": "number",
"description": "Arm a tick deadline this far in the future.",
},
},
})
}
fn create(&self, session: &mut GameSession, options: &Map<String, Value>) -> Result<()> {
let target = options.get("target").and_then(Value::as_i64).unwrap_or(3);
if target < 1 || options.get("target").is_some_and(|v| v.is_boolean()) {
return Err(GameError::illegal_move("target must be a positive integer").into());
}
let creator = session.players[0].clone();
session.players[0] = Seat {
user_sub: creator.user_sub,
display_name: creator.display_name,
team: Some("A".into()),
};
session.state = Some(Box::new(DummyState {
target,
plays: Vec::new(),
started: false,
finished: false,
winner: None,
deadline_in_seconds: options.get("deadline_in_seconds").and_then(Value::as_f64),
}));
Ok(())
}
fn join(&self, session: &mut GameSession, user_sub: &str, display_name: &str) -> Result<()> {
if state_of(session)?.started {
return Err(GameError::game_not_started("game has already started").into());
}
if session.seated(user_sub) {
return Err(GameError::already_joined("already joined this game").into());
}
if session.players.len() >= 2 {
return Err(GameError::lobby_full("game is full").into());
}
let team = if session.players.is_empty() { "A" } else { "B" };
session.players.push(Seat {
user_sub: user_sub.into(),
display_name: display_name.into(),
team: Some(team.into()),
});
if session.players.len() == 2 {
state_of_mut(session)?.started = true;
}
Ok(())
}
fn handle_action(
&self,
session: &mut GameSession,
user_sub: &str,
action: &str,
_payload: &Map<String, Value>,
) -> Result<()> {
if !state_of(session)?.started {
return Err(GameError::game_not_started("the game has not started yet").into());
}
if state_of(session)?.finished {
return Err(GameError::game_finished("the match is over").into());
}
if !session.seated(user_sub) {
return Err(GameError::not_your_turn("you are not seated in this game").into());
}
if action != "play" {
return Err(GameError::illegal_move(format!(
"unknown action: {}",
tavolo_platform::http::py_repr(&Value::String(action.to_string()))
))
.into());
}
DummyEngine::play(session, user_sub)
}
fn view_for(&self, session: &GameSession, user_sub: &str) -> Value {
let state = state_of(session).expect("dummy state");
json!({
"started": state.started,
"finished": state.finished,
"winner": state.winner,
"plays": state.plays.len(),
"target": state.target,
"viewer_seated": session.seated(user_sub),
})
}
fn lobby_view(&self, session: &GameSession) -> Value {
let state = state_of(session).expect("dummy state");
json!({
"phase": if state.started { "playing" } else { "lobby" },
"target": state.target,
})
}
fn in_lobby(&self, session: &GameSession) -> bool {
state_of(session)
.map(|state| !state.started)
.unwrap_or(false)
}
fn is_finished(&self, session: &GameSession) -> bool {
state_of(session)
.map(|state| state.finished)
.unwrap_or(false)
}
fn result(&self, session: &GameSession) -> Result<MatchResult> {
let state = state_of(session)?;
let Some(winner) = state.winner.clone() else {
return Err(GameError::illegal_move("no result: the match is not finished").into());
};
let subs: Vec<String> = session
.players
.iter()
.map(|seat| seat.user_sub.clone())
.collect();
Ok(MatchResult {
teams: vec![vec![subs[0].clone()], vec![subs[1].clone()]],
winner_team: subs.iter().position(|sub| *sub == winner).unwrap_or(0),
players: session
.players
.iter()
.enumerate()
.map(|(index, seat)| PlayerResult {
user_sub: seat.user_sub.clone(),
seat: index as i64,
won: seat.user_sub == winner,
team: seat.team.clone(),
score: state
.plays
.iter()
.filter(|sub| **sub == seat.user_sub)
.count() as f64,
details: json!({
"plays": state.plays.iter().filter(|sub| **sub == seat.user_sub).count(),
}),
})
.collect(),
summary: json!({
"target": state.target,
"plays": state.plays.len(),
"winner": winner,
}),
})
}
fn state_to_json(&self, state: &(dyn std::any::Any + Send + Sync)) -> Result<Value> {
let state = state
.downcast_ref::<DummyState>()
.ok_or_else(|| Error::Internal("dummy state missing".into()))?;
Ok(serde_json::to_value(state)?)
}
fn state_from_json(&self, data: &Value) -> Result<Box<dyn std::any::Any + Send + Sync>> {
Ok(Box::new(serde_json::from_value::<DummyState>(
data.clone(),
)?))
}
fn next_deadline(&self, session: &GameSession) -> Result<Option<Deadline>> {
let state = state_of(session)?;
let Some(seconds) = state.deadline_in_seconds else {
return Ok(None);
};
if !state.started || state.finished {
return Ok(None);
}
let due_at = tavolo_platform::datetime::now()
+ chrono::Duration::milliseconds((seconds * 1000.0) as i64);
Ok(Some(Deadline {
kind: "tick".into(),
due_at,
token: format!("tick:{}", state.plays.len()),
}))
}
fn fire_deadline(&self, session: &mut GameSession, kind: &str, token: &str) -> Result<()> {
let current = self.next_deadline(session)?;
match current {
Some(current) if current.kind == kind && current.token == token => {}
_ => return Err(GameError::illegal_move("stale deadline").into()),
}
let user_sub = session.players[0].user_sub.clone();
DummyEngine::play(session, &user_sub)
}
}
fn test_oidc_config(provider: &str) -> OidcProviderConfig {
OidcProviderConfig {
provider: provider.into(),
issuer: format!("http://localhost:8180/{provider}"),
client_id: "tavolo".into(),
client_secret: None,
redirect_uri: format!("http://localhost:8080/auth/{provider}/callback"),
post_login_redirect: "/".into(),
post_logout_redirect: "/".into(),
}
}
pub struct TestPlatform {
pub platform: SharedPlatform,
pub session_store: SharedSessionStore,
pub stats: Arc<InMemoryStatsRepo>,
}
impl TestPlatform {
pub fn router(&self) -> Router {
Router::new()
.merge(
tavolo_platform::routes::router(&["default".to_string()])
.with_state(self.platform.clone()),
)
.layer(axum::middleware::from_fn_with_state(
self.session_store.clone(),
session_middleware,
))
}
}
pub fn make_platform(heartbeat_ms: u64) -> TestPlatform {
let mut registry = GameRegistry::new();
registry
.register(Arc::new(DummyEngine))
.expect("register dummy");
let registry = Arc::new(registry);
let game_store: SharedGameStore = Arc::new(InMemoryGameStore::new(registry.clone()));
let session_store: SharedSessionStore = Arc::new(InMemorySessionStore::new());
let stats = Arc::new(InMemoryStatsRepo::new());
let stats_repo: SharedStatsRepo = stats.clone();
let oidc =
Arc::new(OidcProviders::new(vec![test_oidc_config("default")]).expect("oidc providers"));
let scheduler = DeadlineScheduler::new(
game_store.clone(),
registry.clone(),
stats_repo.clone(),
heartbeat_ms,
20,
);
scheduler.start();
let platform = Arc::new(Platform {
registry,
game_store,
scheduler,
oidc,
stats: stats_repo,
});
TestPlatform {
platform,
session_store,
stats,
}
}
/// Persist a logged-in session and return the `Cookie` header value for it.
pub async fn login_cookie(
store: &SharedSessionStore,
provider: &str,
sub: &str,
name: &str,
) -> String {
let id = format!("session-{sub}");
let mut data = Map::new();
data.insert(
format!("oidc:{provider}:user"),
json!({"sub": sub, "preferred_username": name}),
);
store
.save(&id, &data, Some(3600))
.await
.expect("save session");
format!("session_id={id}")
}
pub async fn call(router: &Router, request: Request<Body>) -> Response<axum::body::Body> {
router.clone().oneshot(request).await.expect("request")
}
pub async fn body_json(response: Response<Body>) -> Value {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
serde_json::from_slice(&bytes).expect("json body")
}
pub fn get(uri: &str, cookie: Option<&str>) -> Request<Body> {
let mut builder = Request::builder().method("GET").uri(uri);
if let Some(cookie) = cookie {
builder = builder.header("cookie", cookie);
}
builder.body(Body::empty()).expect("request")
}
pub fn post_json(uri: &str, cookie: Option<&str>, body: &Value) -> Request<Body> {
let mut builder = Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json");
if let Some(cookie) = cookie {
builder = builder.header("cookie", cookie);
}
builder
.body(Body::from(serde_json::to_vec(body).expect("json")))
.expect("request")
}
pub async fn read_text(
mut socket: tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> String {
let message = socket
.next()
.await
.expect("some message")
.expect("ok message");
match message {
tokio_tungstenite::tungstenite::Message::Text(text) => text.to_string(),
other => panic!("expected text message, got {other:?}"),
}
}
pub async fn read_json(
socket: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> Value {
let message = socket
.next()
.await
.expect("some message")
.expect("ok message");
match message {
tokio_tungstenite::tungstenite::Message::Text(text) => {
serde_json::from_str(&text).expect("json message")
}
other => panic!("expected text message, got {other:?}"),
}
}
pub fn ws_request(addr: std::net::SocketAddr, game_id: &str, cookie: Option<&str>) -> Request<()> {
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
let url = format!("ws://{addr}/ws/games/{game_id}");
let mut request = url.into_client_request().expect("websocket client request");
if let Some(cookie) = cookie {
request.headers_mut().insert(
"cookie",
axum::http::HeaderValue::from_str(cookie).expect("cookie header"),
);
}
request
}
pub async fn start_server(router: Router) -> std::net::SocketAddr {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
addr
}
pub fn json_status(response: &Response<Body>) -> StatusCode {
response.status()
}
@@ -0,0 +1,410 @@
//! HTTP routes and WebSocket protocol of the platform.
mod helpers;
use futures_util::SinkExt;
use helpers::*;
use serde_json::json;
use tokio_tungstenite::tungstenite::handshake::client::Response as WsResponse;
async fn create_and_start(test: &helpers::TestPlatform) -> (String, String, String, String) {
let alice = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let bob = login_cookie(&test.session_store, "default", "bob", "Bob").await;
let router = test.router();
let lobby = body_json(
call(
&router,
post_json(
"/api/games",
Some(&alice),
&json!({"options": {"target": 3}}),
),
)
.await,
)
.await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
let response = call(
&router,
post_json("/api/games/join", Some(&bob), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 200);
(game_id, code, alice, bob)
}
#[tokio::test]
async fn game_types_lists_the_registered_engine() {
let test = make_platform(50);
let router = test.router();
let response = call(&router, get("/api/game-types", None)).await;
assert_eq!(response.status(), 200);
let body = body_json(response).await;
assert_eq!(body["results"][0]["id"], "dummy");
assert_eq!(body["results"][0]["min_players"], 2);
assert_eq!(body["results"][0]["options_schema"]["type"], "object");
}
#[tokio::test]
async fn create_requires_authentication() {
let test = make_platform(50);
let router = test.router();
let response = call(&router, post_json("/api/games", None, &json!({}))).await;
assert_eq!(response.status(), 401);
let body = body_json(response).await;
assert_eq!(body["error"], "unauthenticated");
}
#[tokio::test]
async fn create_validates_game_type_and_options() {
let test = make_platform(50);
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let response = call(
&router,
post_json("/api/games", Some(&cookie), &json!({"game_type": "nope"})),
)
.await;
assert_eq!(response.status(), 400);
let body = body_json(response).await;
assert_eq!(body["error"], "unknown game_type: 'nope'");
let response = call(
&router,
post_json(
"/api/games",
Some(&cookie),
&json!({"options": {"target": 0}}),
),
)
.await;
assert_eq!(response.status(), 400);
let body = body_json(response).await;
assert_eq!(body["error"], "target must be a positive integer");
let response = call(
&router,
post_json("/api/games", Some(&cookie), &json!({"options": 3})),
)
.await;
assert_eq!(response.status(), 400);
assert_eq!(
body_json(response).await["error"],
"options must be an object"
);
}
#[tokio::test]
async fn join_flow_and_snapshot_authorization() {
let test = make_platform(50);
let alice = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let lobby =
body_json(call(&router, post_json("/api/games", Some(&alice), &json!({}))).await).await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
// Missing / unknown code.
let response = call(
&router,
post_json("/api/games/join", Some(&alice), &json!({})),
)
.await;
assert_eq!(response.status(), 400);
assert_eq!(body_json(response).await["error"], "code is required");
let bob = login_cookie(&test.session_store, "default", "bob", "Bob").await;
let response = call(
&router,
post_json("/api/games/join", Some(&bob), &json!({"code": "ZZZZZZ"})),
)
.await;
assert_eq!(response.status(), 404);
// Preview before joining.
let preview = body_json(
call(
&router,
get(&format!("/api/games/preview?code={code}"), Some(&bob)),
)
.await,
)
.await;
assert_eq!(preview["game_name"], "Dummy game");
assert_eq!(preview["seats_open"], 1);
assert_eq!(preview["players"][0]["sub"], "alice");
// The creator cannot join their own (still open) lobby twice.
let response = call(
&router,
post_json("/api/games/join", Some(&alice), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 409);
assert_eq!(
body_json(response).await["error"],
"already joined this game"
);
// Bob joins and starts the match.
let response = call(
&router,
post_json("/api/games/join", Some(&bob), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 200);
let started = body_json(response).await;
assert_eq!(started["started"], true);
assert_eq!(started["your_turn"], json!(null));
// Bob cannot rejoin a started game; a third player finds the lobby full.
let response = call(
&router,
post_json("/api/games/join", Some(&bob), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 409);
assert_eq!(
body_json(response).await["error"],
"game has already started"
);
let carol = login_cookie(&test.session_store, "default", "carol", "Carol").await;
let response = call(
&router,
post_json("/api/games/join", Some(&carol), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 409);
assert_eq!(
body_json(response).await["error"],
"game has already started"
);
// Snapshots: unknown game 404, non-seated 403, seated 200, anonymous 401.
let response = call(&router, get("/api/games/no-such-id", Some(&alice))).await;
assert_eq!(response.status(), 404);
let response = call(&router, get(&format!("/api/games/{game_id}"), Some(&carol))).await;
assert_eq!(response.status(), 403);
let response = call(&router, get(&format!("/api/games/{game_id}"), Some(&alice))).await;
assert_eq!(response.status(), 200);
let snapshot = body_json(response).await;
assert_eq!(snapshot["id"], game_id);
assert_eq!(snapshot["viewer_seated"], true);
let response = call(&router, get(&format!("/api/games/{game_id}"), None)).await;
assert_eq!(response.status(), 401);
// The whoami endpoint reflects the session identity.
let me = body_json(call(&router, get("/api/me", Some(&alice))).await).await;
assert_eq!(me["sub"], "alice");
assert_eq!(me["name"], "Alice");
let response = call(&router, get("/api/me", None)).await;
assert_eq!(response.status(), 401);
}
#[tokio::test]
async fn auth_helpers_are_provider_agnostic() {
let test = make_platform(50);
let alice = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let providers = body_json(call(&router, get("/api/auth/providers", None)).await).await;
assert_eq!(
providers["providers"],
json!([{"name": "default", "login_url": "/auth/default/login"}])
);
let response = call(&router, get("/auth/login", None)).await;
assert_eq!(response.status(), 302);
assert_eq!(
response.headers()["location"].to_str().unwrap(),
"/auth/default/login"
);
let response = call(&router, get("/auth/logout", Some(&alice))).await;
assert_eq!(response.status(), 302);
assert_eq!(
response.headers()["location"].to_str().unwrap(),
"/auth/default/logout"
);
let response = call(&router, get("/auth/logout", None)).await;
assert_eq!(response.headers()["location"].to_str().unwrap(), "/");
let response = call(&router, get("/api/openapi.json", None)).await;
assert_eq!(response.status(), 200);
}
#[tokio::test]
async fn websocket_rejects_unauthorized_unknown_and_unseated() {
let test = make_platform(50);
let (game_id, _code, alice, _bob) = create_and_start(&test).await;
let carol = login_cookie(&test.session_store, "default", "carol", "Carol").await;
let addr = start_server(test.router()).await;
let error = tokio_tungstenite::connect_async(ws_request(addr, &game_id, None))
.await
.expect_err("anonymous upgrade must be refused");
match error {
tokio_tungstenite::tungstenite::Error::Http(response) => {
assert_eq!(response.status(), 401);
}
other => panic!("unexpected error: {other:?}"),
}
let error = tokio_tungstenite::connect_async(ws_request(addr, "unknown-game", Some(&alice)))
.await
.expect_err("unknown game upgrade must be refused");
match error {
tokio_tungstenite::tungstenite::Error::Http(response) => {
assert_eq!(response.status(), 404);
}
other => panic!("unexpected error: {other:?}"),
}
let error = tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&carol)))
.await
.expect_err("non-seated upgrade must be refused");
match error {
tokio_tungstenite::tungstenite::Error::Http(response) => {
assert_eq!(response.status(), 403);
}
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn websocket_broadcasts_moves_and_game_over() {
let test = make_platform(50);
let (game_id, _code, alice, bob) = create_and_start(&test).await;
let addr = start_server(test.router()).await;
let (mut alice_ws, _): (_, WsResponse) =
tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&alice)))
.await
.expect("alice connects");
let (mut bob_ws, _): (_, WsResponse) =
tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&bob)))
.await
.expect("bob connects");
let first = read_json(&mut alice_ws).await;
assert_eq!(first["type"], "state");
assert_eq!(first["game"]["viewer_seated"], true);
let first_bob = read_json(&mut bob_ws).await;
assert_eq!(first_bob["type"], "state");
// Alice plays; both connections see the update.
alice_ws
.send(tokio_tungstenite::tungstenite::Message::Text(
json!({"action": "play"}).to_string(),
))
.await
.expect("send play");
let update = read_json(&mut alice_ws).await;
assert_eq!(update["game"]["plays"], 1);
let update = read_json(&mut bob_ws).await;
assert_eq!(update["game"]["plays"], 1);
// Requesting a snapshot resends the state.
bob_ws
.send(tokio_tungstenite::tungstenite::Message::Text(
json!({"action": "state"}).to_string(),
))
.await
.expect("send state");
let snapshot = read_json(&mut bob_ws).await;
assert_eq!(snapshot["type"], "state");
assert_eq!(snapshot["game"]["plays"], 1);
}
#[tokio::test]
async fn websocket_reports_errors_and_malformed_messages() {
let test = make_platform(50);
let (game_id, _code, alice, _bob) = create_and_start(&test).await;
let addr = start_server(test.router()).await;
let (mut ws, _): (_, WsResponse) =
tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&alice)))
.await
.expect("alice connects");
read_json(&mut ws).await;
ws.send(tokio_tungstenite::tungstenite::Message::Text(
"not json".into(),
))
.await
.unwrap();
let error = read_json(&mut ws).await;
assert_eq!(error["type"], "error");
assert_eq!(error["message"], "invalid JSON");
ws.send(tokio_tungstenite::tungstenite::Message::Text(
json!([1, 2]).to_string(),
))
.await
.unwrap();
let error = read_json(&mut ws).await;
assert_eq!(error["message"], "message must be a JSON object");
ws.send(tokio_tungstenite::tungstenite::Message::Text(
json!({"action": "dance"}).to_string(),
))
.await
.unwrap();
let error = read_json(&mut ws).await;
assert_eq!(error["type"], "error");
assert_eq!(error["code"], "illegal_move");
assert_eq!(error["message"], "unknown action: 'dance'");
ws.send(tokio_tungstenite::tungstenite::Message::Text(
json!({"action": 7}).to_string(),
))
.await
.unwrap();
let error = read_json(&mut ws).await;
assert_eq!(error["message"], "unknown action: 7");
}
#[tokio::test]
async fn websocket_sends_game_over_once_the_match_finishes() {
let test = make_platform(50);
let alice = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let bob = login_cookie(&test.session_store, "default", "bob", "Bob").await;
let router = test.router();
let lobby = body_json(
call(
&router,
post_json(
"/api/games",
Some(&alice),
&json!({"options": {"target": 1}}),
),
)
.await,
)
.await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
call(
&router,
post_json("/api/games/join", Some(&bob), &json!({"code": code})),
)
.await;
let addr = start_server(test.router()).await;
let (mut ws, _): (_, WsResponse) =
tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&alice)))
.await
.expect("alice connects");
read_json(&mut ws).await;
ws.send(tokio_tungstenite::tungstenite::Message::Text(
json!({"action": "play"}).to_string(),
))
.await
.unwrap();
let state = read_json(&mut ws).await;
assert_eq!(state["type"], "state");
assert_eq!(state["game"]["finished"], true);
let game_over = read_json(&mut ws).await;
assert_eq!(game_over["type"], "game_over");
}
@@ -0,0 +1,160 @@
//! Postgres-backed stats tests, run only when `TEST_DATABASE_URL` points at a
//! throwaway Postgres database. The compose stack exposes one on
//! `postgres://tavolo:password@localhost:5432/tavolo`.
mod helpers;
use helpers::DummyEngine;
use serde_json::Map;
use sqlx::PgPool;
use tavolo_platform::engine::{GameEngine, GameSession, Seat};
use tavolo_platform::stats::{PostgresStatsRepo, StatsRepo};
async fn pool() -> Option<PgPool> {
let url = std::env::var("TEST_DATABASE_URL")
.ok()
.filter(|url| !url.is_empty())?;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(4)
.connect(&url)
.await
.expect("connect");
sqlx::raw_sql(include_str!("../../tavolo-app/migrations/0001_init.sql"))
.execute(&pool)
.await
.expect("schema");
// Each test starts from a clean slate; other sessions may hold rows, so
// only this test's users are removed.
sqlx::query("DELETE FROM match_player WHERE user_sub IN ('alice', 'bob', 'carol', 'dave')")
.execute(&pool)
.await
.expect("clean players");
sqlx::query("DELETE FROM player_rating WHERE user_sub IN ('alice', 'bob', 'carol', 'dave')")
.execute(&pool)
.await
.expect("clean ratings");
sqlx::query(
"DELETE FROM \"match\" WHERE id NOT IN (SELECT DISTINCT match_id FROM match_player)",
)
.execute(&pool)
.await
.expect("clean matches");
Some(pool)
}
fn finished_game(id: &str, winner_plays: &str) -> GameSession {
let engine = DummyEngine;
let mut game = GameSession::new(id.to_string(), engine.id(), "ABCDEF");
game.creator_sub = "alice".into();
game.players = vec![Seat::new("alice", "Alice"), Seat::new("bob", "Bob")];
game.players[0].team = Some("A".into());
game.players[1].team = Some("B".into());
game.created_at = Some(tavolo_platform::datetime::now() - chrono::Duration::minutes(5));
let mut options = Map::new();
options.insert("target".into(), serde_json::json!(1));
engine
.create(&mut game, &options)
.expect("create dummy state");
{
let state = game
.state
.as_mut()
.and_then(|state| state.downcast_mut::<helpers::DummyState>())
.expect("dummy state");
state.started = true;
}
engine
.handle_action(&mut game, winner_plays, "play", &Map::new())
.expect("winning play");
game.finished_at = Some(tavolo_platform::datetime::now());
game
}
#[tokio::test]
async fn postgres_persists_matches_history_ratings_and_leaderboard() {
let Some(pool) = pool().await else {
eprintln!("TEST_DATABASE_URL unset: skipping");
return;
};
let repo = PostgresStatsRepo::new(pool.clone());
let engine = DummyEngine;
let game = finished_game("00000000-0000-0000-0000-000000000001", "bob");
assert!(engine.is_finished(&game));
repo.save_match(&game, &engine).await.expect("save match");
// History for the winner includes the result and the Elo delta.
let (matches, cursor) = repo
.my_matches("bob", None, 20, None)
.await
.expect("history");
assert_eq!(matches.len(), 1);
assert!(cursor.is_none());
assert_eq!(matches[0].result["winner"], "bob");
assert!(matches[0].you_won);
assert_eq!(matches[0].your_elo_delta, Some(16));
assert_eq!(matches[0].players.len(), 2);
// Ratings: 1500 + 16 for bob, 1500 - 16 for alice.
let ratings = repo.my_ratings("bob").await.expect("ratings");
assert_eq!(ratings.len(), 1);
assert_eq!(ratings[0].rating, 1516);
assert_eq!(ratings[0].matches_played, 1);
let alice_ratings = repo.my_ratings("alice").await.expect("ratings");
assert_eq!(alice_ratings[0].rating, 1484);
// Leaderboard: winner first.
let leaderboard = repo.leaderboard(None, "dummy").await.expect("leaderboard");
let bob = leaderboard
.iter()
.find(|entry| entry.user_sub == "bob")
.expect("bob ranked");
let alice = leaderboard
.iter()
.find(|entry| entry.user_sub == "alice")
.expect("alice ranked");
assert_eq!(bob.elo, 1516);
assert_eq!(bob.wins, 1);
assert_eq!(alice.elo, 1484);
assert_eq!(leaderboard[0].user_sub, "bob");
// Backfill replays the same match and reproduces the deltas.
let replayed = repo.backfill_elo().await.expect("backfill");
assert!(replayed >= 1);
let ratings = repo.my_ratings("bob").await.expect("ratings");
assert_eq!(ratings[0].rating, 1516);
// A second match loses to a fresh page cursor: page size 1 has more.
let game2 = finished_game("00000000-0000-0000-0000-000000000002", "alice");
repo.save_match(&game2, &engine)
.await
.expect("save match 2");
let (page, cursor) = repo.my_matches("alice", None, 1, None).await.expect("page");
assert_eq!(page.len(), 1);
assert!(cursor.is_some(), "expected a next cursor");
let cursor_map = cursor.as_deref().map(parse_cursor);
let (next, _) = repo
.my_matches("alice", None, 1, cursor_map.as_ref())
.await
.expect("next page");
assert_eq!(next.len(), 1);
assert_ne!(page[0].id, next[0].id);
// Game-type filters are applied.
let (filtered, _) = repo
.my_matches("alice", Some("dummy"), 20, None)
.await
.expect("filtered");
assert_eq!(filtered.len(), 2);
let (none, _) = repo
.my_matches("alice", Some("scopone_scientifico"), 20, None)
.await
.expect("filtered");
assert!(none.is_empty());
}
fn parse_cursor(cursor: &str) -> serde_json::Map<String, serde_json::Value> {
tavolo_platform::pagination::decode_cursor(Some(cursor))
.expect("cursor")
.expect("some")
}
@@ -0,0 +1,138 @@
//! Redis-backed store tests, run only when `TEST_REDIS_URL` points at a
//! throwaway Redis instance. The compose stack exposes one on
//! `redis://127.0.0.1:6379/0`.
mod helpers;
use std::sync::Arc;
use helpers::DummyEngine;
use tavolo_platform::registry::GameRegistry;
use tavolo_platform::store::{GameStore, RedisGameStore};
fn redis_url() -> Option<String> {
std::env::var("TEST_REDIS_URL")
.ok()
.filter(|url| !url.is_empty())
}
async fn store(ttl: i64) -> Option<RedisGameStore> {
let url = redis_url()?;
let mut registry = GameRegistry::new();
registry.register(Arc::new(DummyEngine)).expect("register");
Some(
RedisGameStore::new(&url, Arc::new(registry), ttl)
.await
.expect("redis store"),
)
}
async fn create_game(store: &RedisGameStore) -> tavolo_platform::GameSession {
use tavolo_platform::engine::{GameEngine, GameSession, Seat};
let engine = DummyEngine;
let unique = uuid::Uuid::new_v4().to_string();
let mut game = GameSession::new(unique.clone(), engine.id(), unique[..6].to_uppercase());
game.creator_sub = "alice".into();
game.players = vec![Seat::new("alice", "Alice")];
game.created_at = Some(tavolo_platform::datetime::now());
engine
.create(&mut game, &serde_json::Map::new())
.expect("create");
store.save(&game).await.expect("save");
game
}
#[tokio::test]
async fn redis_round_trips_sessions_codes_and_ongoing_counts() {
let Some(store) = store(60).await else {
eprintln!("TEST_REDIS_URL unset: skipping");
return;
};
let game = create_game(&store).await;
let loaded = store.load(&game.id).await.expect("load").expect("found");
assert_eq!(loaded.join_code, game.join_code);
assert!(loaded.seated("alice"));
let by_code = store
.find_by_code(&game.join_code.to_lowercase())
.await
.expect("find")
.expect("found");
assert_eq!(by_code.id, game.id);
assert!(store.count_ongoing().await.expect("count") >= 1);
// Save with TTL still lists the game; a second save does not duplicate.
store.save(&loaded).await.expect("save again");
assert!(store.count_ongoing().await.expect("count") >= 1);
store
.remove_deadline(&format!("missing-{}", game.id))
.await
.unwrap();
store
.remove_deadline(&serde_json::json!({}).to_string())
.await
.unwrap();
}
#[tokio::test]
async fn redis_locks_are_exclusive_and_released() {
let Some(store) = store(60).await else {
eprintln!("TEST_REDIS_URL unset: skipping");
return;
};
let game = create_game(&store).await;
let first = store.acquire_lock(&game.id).await.expect("first lock");
let second_store = store_second().await.expect("second store");
let acquired = Arc::new(std::sync::atomic::AtomicBool::new(false));
let acquired_clone = acquired.clone();
let game_id = game.id.clone();
let waiter = tokio::spawn(async move {
let _lock = second_store
.acquire_lock(&game_id)
.await
.expect("second lock");
acquired_clone.store(true, std::sync::atomic::Ordering::SeqCst);
});
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert!(!acquired.load(std::sync::atomic::Ordering::SeqCst));
first.release().await.expect("release");
tokio::time::timeout(std::time::Duration::from_secs(3), waiter)
.await
.expect("waiter acquired")
.expect("waiter task");
assert!(acquired.load(std::sync::atomic::Ordering::SeqCst));
}
#[tokio::test]
async fn redis_pubsub_signals_and_deadline_queue() {
let Some(store) = store(60).await else {
eprintln!("TEST_REDIS_URL unset: skipping");
return;
};
use futures_util::StreamExt;
let game = create_game(&store).await;
let mut events = store.subscribe(&game.id).await.expect("subscribe");
// The subscription registers asynchronously; a short delay avoids racing
// the PUBLISH with the SUBSCRIBE.
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
store.publish(&game.id).await.expect("publish");
let signalled = tokio::time::timeout(std::time::Duration::from_secs(3), events.next()).await;
assert!(signalled.is_ok(), "no change signal received");
let member = format!("deadline-{}", uuid::Uuid::new_v4());
store.add_deadline(&member, 0.0).await.expect("add");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let due = store
.due_deadlines(tavolo_platform::datetime::now().timestamp() as f64, 10)
.await
.expect("due");
assert!(due.contains(&member), "{due:?}");
assert!(store.next_deadline().await.expect("next").is_some());
store.remove_deadline(&member).await.expect("remove");
}
async fn store_second() -> Option<RedisGameStore> {
store(60).await
}
@@ -0,0 +1,317 @@
//! Store, deadline scheduler and stats behavior of the platform.
mod helpers;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use futures_util::StreamExt;
use helpers::*;
use serde_json::{json, Map};
use tokio::sync::Notify;
#[tokio::test]
async fn store_round_trips_a_session_and_indexes_join_codes() {
let test = make_platform(50);
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let response = call(&router, post_json("/api/games", Some(&cookie), &json!({}))).await;
assert_eq!(response.status(), 201);
let lobby = body_json(response).await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
assert_eq!(lobby["seats_open"], 1);
let platform = &test.platform;
let loaded = platform.game_store.load(&game_id).await.unwrap().unwrap();
assert_eq!(loaded.join_code, code);
assert!(loaded.seated("alice"));
assert_eq!(platform.game_store.count_ongoing().await.unwrap(), 1);
// Codes are case-insensitive.
let by_code = platform
.game_store
.find_by_code(&code.to_lowercase())
.await
.unwrap()
.unwrap();
assert_eq!(by_code.id, game_id);
}
#[tokio::test]
async fn store_lock_serializes_mutations() {
let test = make_platform(50);
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let lobby =
body_json(call(&router, post_json("/api/games", Some(&cookie), &json!({}))).await).await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let store = test.platform.game_store.clone();
let first = store.acquire_lock(&game_id).await.unwrap();
let acquired = Arc::new(AtomicBool::new(false));
let acquired_clone = acquired.clone();
let store_clone = store.clone();
let game_id_clone = game_id.clone();
let waiter = tokio::spawn(async move {
let _lock = store_clone.acquire_lock(&game_id_clone).await.unwrap();
acquired_clone.store(true, Ordering::SeqCst);
});
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
assert!(!acquired.load(Ordering::SeqCst), "lock was not exclusive");
first.release().await.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(2), waiter)
.await
.expect("waiter acquired the lock")
.expect("waiter task");
assert!(acquired.load(Ordering::SeqCst));
}
#[tokio::test]
async fn store_publish_reaches_subscribers() {
let test = make_platform(50);
let store = test.platform.game_store.clone();
let mut events = store.subscribe("some-game").await.unwrap();
store.publish("some-game").await.unwrap();
let notified = tokio::time::timeout(std::time::Duration::from_secs(2), events.next()).await;
assert!(notified.is_ok(), "subscriber was not notified");
}
#[tokio::test]
async fn deadline_fires_an_auto_play() {
let test = make_platform(20);
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let lobby = body_json(
call(
&router,
post_json(
"/api/games",
Some(&cookie),
&json!({"options": {"target": 100, "deadline_in_seconds": 0.05}}),
),
)
.await,
)
.await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
let bob_cookie = login_cookie(&test.session_store, "default", "bob", "Bob").await;
// Joining the last player arms the tick deadline.
let response = call(
&router,
post_json("/api/games/join", Some(&bob_cookie), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 200);
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
let game = test
.platform
.game_store
.load(&game_id)
.await
.unwrap()
.unwrap();
let view = test
.platform
.registry
.require("dummy")
.unwrap()
.view_for(&game, "alice");
assert!(
view["plays"].as_u64().unwrap() >= 1,
"deadline never fired: {view}"
);
}
#[tokio::test]
async fn deadline_finishes_a_match_and_persists_it() {
let test = make_platform(20);
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let lobby = body_json(
call(
&router,
post_json(
"/api/games",
Some(&cookie),
&json!({"options": {"target": 1, "deadline_in_seconds": 0.05}}),
),
)
.await,
)
.await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
let bob_cookie = login_cookie(&test.session_store, "default", "bob", "Bob").await;
call(
&router,
post_json("/api/games/join", Some(&bob_cookie), &json!({"code": code})),
)
.await;
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
let game = test
.platform
.game_store
.load(&game_id)
.await
.unwrap()
.unwrap();
let engine = test.platform.registry.require("dummy").unwrap().clone();
assert!(engine.is_finished(&game));
assert!(game.stats_saved, "the deadline finish was not persisted");
assert_eq!(test.platform.game_store.count_ongoing().await.unwrap(), 0);
let matches = test
.platform
.stats
.my_matches("alice", None, 20, None)
.await
.unwrap()
.0;
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].result["target"], 1);
}
#[tokio::test]
async fn finalized_match_updates_history_ratings_and_leaderboard() {
let test = make_platform(50);
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let lobby = body_json(
call(
&router,
post_json(
"/api/games",
Some(&cookie),
&json!({"options": {"target": 1}}),
),
)
.await,
)
.await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
let bob_cookie = login_cookie(&test.session_store, "default", "bob", "Bob").await;
call(
&router,
post_json("/api/games/join", Some(&bob_cookie), &json!({"code": code})),
)
.await;
// Bob plays the winning move directly through the platform.
let mut game = test
.platform
.game_store
.load(&game_id)
.await
.unwrap()
.unwrap();
let engine = test.platform.registry.require("dummy").unwrap().clone();
engine
.handle_action(&mut game, "bob", "play", &Map::new())
.unwrap();
test.platform
.scheduler
.finalize_mutation(&mut game)
.await
.unwrap();
let response = call(&router, get("/api/me/matches", Some(&bob_cookie))).await;
assert_eq!(response.status(), 200);
let history = body_json(response).await;
assert_eq!(history["next_cursor"], json!(null));
let entry = &history["results"][0];
assert_eq!(entry["you_won"], true);
assert_eq!(entry["your_elo_delta"], 16);
assert_eq!(entry["players"].as_array().unwrap().len(), 2);
let ratings = body_json(call(&router, get("/api/me/ratings", Some(&bob_cookie))).await).await;
assert_eq!(ratings["results"][0]["rating"], 1516);
assert_eq!(ratings["results"][0]["matches_played"], 1);
let leaderboard = body_json(call(&router, get("/api/leaderboard", None)).await).await;
let results = leaderboard["results"].as_array().unwrap();
assert_eq!(results[0]["user_sub"], "bob");
assert_eq!(results[0]["wins"], 1);
assert_eq!(results[1]["user_sub"], "alice");
assert_eq!(results[1]["elo"], 1484);
}
#[tokio::test]
async fn stale_deadline_is_discarded_without_effect() {
let test = make_platform(50);
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let lobby = body_json(
call(
&router,
post_json(
"/api/games",
Some(&cookie),
&json!({"options": {"target": 100, "deadline_in_seconds": 0.05}}),
),
)
.await,
)
.await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
let bob_cookie = login_cookie(&test.session_store, "default", "bob", "Bob").await;
call(
&router,
post_json("/api/games/join", Some(&bob_cookie), &json!({"code": code})),
)
.await;
// Wait for one auto-play, then deliver the same (now stale) entry again.
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
let game = test
.platform
.game_store
.load(&game_id)
.await
.unwrap()
.unwrap();
let engine = test.platform.registry.require("dummy").unwrap().clone();
let plays = engine.view_for(&game, "alice")["plays"].as_u64().unwrap();
let stale = json!({
"game_id": game_id,
"kind": "tick",
"token": format!("tick:{}", plays.saturating_sub(1)),
})
.to_string();
test.platform.scheduler.process_due(&stale).await.unwrap();
let after = test
.platform
.game_store
.load(&game_id)
.await
.unwrap()
.unwrap();
assert_eq!(
engine.view_for(&after, "alice")["plays"].as_u64().unwrap(),
plays
);
}
#[tokio::test]
async fn in_memory_notify_wakes_without_deadlines() {
let notify = Arc::new(Notify::new());
let wait = notify.clone();
let task = tokio::spawn(async move {
wait.notified().await;
true
});
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
notify.notify_waiters();
assert!(
tokio::time::timeout(std::time::Duration::from_secs(1), task)
.await
.unwrap()
.unwrap()
);
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "tavolo-scopone"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Scopone scientifico rules engine and platform adapter"
[dependencies]
chrono.workspace = true
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
tavolo-platform = { path = "../tavolo-platform" }
thiserror.workspace = true
tracing.workspace = true
[dev-dependencies]
tokio = { workspace = true }
+791
View File
@@ -0,0 +1,791 @@
//! Pure rules engine for scopone scientifico.
//!
//! Every function here is deterministic and I/O-free (the only side effect is
//! debug logging): it mutates (or reads) [`ScoponeState`] and returns a
//! [`GameError`] on rule violations. This makes the whole rule set
//! unit-testable without a store or transport. The platform-facing adapter is
//! [`crate::plugin::ScoponeEngine`].
//!
//! Rules implemented
//! -----------------
//! * 40-card Italian deck (4 suits x ranks 1-10), ten cards per player, empty
//! table at the start of every hand.
//! * A card captures either a **single card of equal rank** or a
//! **combination of cards whose ranks sum to its own**. When an equal-ranked
//! card is on the table that capture is mandatory; the player may not take
//! an alternative combination instead.
//! * Emptying the table with a capture is a **scopa** (+1), except on the very
//! last play of a hand.
//! * At the end of a hand the remaining table cards go to the player who made
//! the last capture.
//! * Hand points: `carte` (most captured cards), `denara` (most diamond
//! cards), `settebello` (the 7 of diamonds), `primiera` (best
//! seven/five/four/three card of each suit, all four suits required), plus
//! one point per `scopa`. Ties on carte/denara/primiera award nothing.
//! * Optional `napola` rule (enabled by default): the longest run of
//! consecutive denari starting from the ace scores one point per card when
//! it reaches at least three cards (A-2-3 = 3, A-2-3-4 = 4, ...). A team
//! capturing the whole denari suit (ace to king) wins the match instantly.
//! * Optional `asso_pigliatutto` rule (disabled by default): an ace played
//! when no equal rank is on the table sweeps the whole table — or captures
//! itself, leaving the table empty, when the table is bare. The sweep never
//! scores a scopa, even when cards remain in hand, but it counts as a
//! capture for the last-taker rule. When another ace is on the table the
//! mandatory equal-rank capture still applies.
//! * The match ends when a team reaches the target score with a clear lead; a
//! tie at or above the target is broken by playing another hand.
use std::collections::HashSet;
use rand::seq::SliceRandom;
use rand::Rng;
use serde_json::{json, Map, Value};
use tavolo_platform::datetime::{now, to_iso};
use tavolo_platform::errors::{GameError, Result};
use crate::errors::card_not_in_hand;
use crate::state::{
parse_card, Card, Move, PlayerState, ScoponeState, PHASE_FINISHED, PHASE_HAND_END, PHASE_LOBBY,
PHASE_PLAYING, SUITS, TEAM_NAMES,
};
/// Number of cards dealt to each player at the start of a hand.
pub const HAND_SIZE: usize = 10;
pub const PLAYERS: usize = 4;
/// Default seconds the hand-end summary waits before dealing anyway. Games
/// carry their own copy in [`ScoponeState::hand_ack_timeout`].
pub const DEFAULT_HAND_ACK_TIMEOUT_SECONDS: i64 = 30;
/// Default seconds a player has to play before the server plays a random
/// legal card for them. Games carry their own copy in
/// [`ScoponeState::turn_timeout`].
pub const DEFAULT_TURN_TIMEOUT_SECONDS: i64 = 30;
/// Primiera card values: sevens are best, then sixes, then aces, then the
/// remaining ranks in descending order. All of 8/9/10 are worth 10.
fn primiera_value(rank: u32) -> i64 {
match rank {
7 => 21,
6 => 18,
1 => 16,
5 => 15,
4 => 14,
3 => 13,
2 => 12,
8..=10 => 10,
_ => 0,
}
}
/// Return the 40 cards of the Italian deck in canonical order.
pub fn full_deck() -> Vec<Card> {
let mut deck = Vec::with_capacity(40);
for &suit in SUITS.iter() {
for rank in 1..=10u32 {
deck.push(Card { rank, suit });
}
}
deck
}
/// Return a shuffled deck. Pass `rng` for deterministic tests.
pub fn shuffled_deck<R: Rng + ?Sized>(rng: &mut R) -> Vec<Card> {
let mut deck = full_deck();
deck.shuffle(rng);
deck
}
/// Every `size`-element subset of `items`, in the same lexicographic order
/// `itertools.combinations` produces.
fn combinations(items: &[Card], size: usize) -> Vec<Vec<Card>> {
let mut result = Vec::new();
if size == 0 {
return vec![Vec::new()];
}
if size > items.len() {
return result;
}
let mut indices: Vec<usize> = (0..size).collect();
loop {
result.push(indices.iter().map(|&i| items[i].clone()).collect());
// Advance to the next combination; stop once the last one is reached.
let mut i = size;
loop {
if i == 0 {
return result;
}
i -= 1;
if indices[i] != i + items.len() - size {
break;
}
}
indices[i] += 1;
for j in (i + 1)..size {
indices[j] = indices[j - 1] + 1;
}
}
}
/// Return every legal capture (a list of card sets) for `card`.
///
/// If an equal-ranked card is on the table, only those single-card captures
/// are returned (the rule forbids taking a combination instead). Otherwise,
/// when `asso_pigliatutto` is enabled and `card` is an ace, the only capture
/// is the whole table — or an empty capture when the table is bare, the ace
/// taking itself. Without the option every subset of the table whose ranks sum
/// to `card.rank` is returned.
pub fn legal_captures(table: &[Card], card: Card, asso_pigliatutto: bool) -> Vec<Vec<Card>> {
let equal: Vec<Card> = table
.iter()
.filter(|c| c.rank == card.rank)
.cloned()
.collect();
if !equal.is_empty() {
return equal.into_iter().map(|c| vec![c]).collect();
}
if asso_pigliatutto && card.rank == 1 {
return vec![table.to_vec()];
}
let candidates: Vec<Card> = table
.iter()
.filter(|c| c.rank <= card.rank)
.cloned()
.collect();
let mut captures: Vec<Vec<Card>> = Vec::new();
// A sum-equal capture needs at least two cards (single non-equal cards
// cannot sum to the played card).
for size in 2..=candidates.len() {
for combo in combinations(&candidates, size) {
if combo.iter().map(|c| c.rank).sum::<u32>() == card.rank {
captures.push(combo);
}
}
}
captures
}
/// Create a lobby game with the creator seated first.
pub fn create_game(
creator_sub: &str,
creator_name: &str,
target_score: i64,
hand_ack_timeout: i64,
turn_timeout: i64,
napola: bool,
asso_pigliatutto: bool,
) -> Result<ScoponeState> {
if !(1..=100).contains(&target_score) {
return Err(GameError::illegal_move("target_score must be between 1 and 100").into());
}
Ok(ScoponeState {
target_score,
napola,
asso_pigliatutto,
phase: PHASE_LOBBY.to_string(),
players: vec![PlayerState {
sub: creator_sub.to_string(),
name: creator_name.to_string(),
seat: 0,
hand: Vec::new(),
captured: Vec::new(),
scope: 0,
}],
hand_ack_timeout,
turn_timeout,
..Default::default()
})
}
/// Seat `sub` in the next free chair, starting the match when full.
pub fn join_game<R: Rng + ?Sized>(
state: &mut ScoponeState,
sub: &str,
name: &str,
rng: &mut R,
) -> Result<()> {
if state.phase != PHASE_LOBBY {
return Err(GameError::game_not_started("game has already started").into());
}
if state.seated(sub) {
return Err(GameError::already_joined("already joined this game").into());
}
if state.players.len() >= PLAYERS {
return Err(GameError::lobby_full("game is full").into());
}
let seat = state.players.len() as i64;
state.players.push(PlayerState {
sub: sub.to_string(),
name: name.to_string(),
seat,
hand: Vec::new(),
captured: Vec::new(),
scope: 0,
});
if state.players.len() == PLAYERS {
start_game(state, rng)?;
}
Ok(())
}
/// Deal the first hand and switch the game to playing.
pub fn start_game<R: Rng + ?Sized>(state: &mut ScoponeState, rng: &mut R) -> Result<()> {
if state.players.len() != PLAYERS {
return Err(GameError::game_not_started("need exactly four players to start").into());
}
state.phase = PHASE_PLAYING.to_string();
deal_hand(state, rng);
Ok(())
}
/// Arm the auto-play deadline for whoever is on turn.
fn set_turn_deadline(state: &mut ScoponeState) {
let deadline = now() + chrono::Duration::seconds(state.turn_timeout);
state.turn_deadline = Some(to_iso(deadline));
}
fn deal_hand<R: Rng + ?Sized>(state: &mut ScoponeState, rng: &mut R) {
let mut deck = shuffled_deck(rng);
for player in state.players.iter_mut() {
player.hand = Vec::new();
player.captured = Vec::new();
player.scope = 0;
}
state.table = Vec::new();
state.last_taker = None;
// Dealer rotates each hand; the first card is played by the player to the
// dealer's left.
state.turn = (state.dealer + 1) % PLAYERS as i64;
set_turn_deadline(state);
for _offset in 0..HAND_SIZE {
for seat_offset in 0..PLAYERS {
let seat = (state.dealer + 1 + seat_offset as i64) % PLAYERS as i64;
let index = player_index_at(state, seat).expect("seated player");
state.players[index]
.hand
.push(deck.pop().expect("deck has enough cards"));
}
}
}
/// The index in `state.players` of the player in `seat`.
fn player_index_at(state: &ScoponeState, seat: i64) -> Result<usize> {
state
.players
.iter()
.position(|player| player.seat == seat)
.ok_or_else(|| GameError::illegal_move(format!("no player in seat {seat}")).into())
}
/// Return the option matching `requested` exactly, if any.
///
/// An empty request matches only an empty option — the asso pigliatutto
/// self-capture — so requesting nothing never satisfies a real capture.
fn match_option(options: &[Vec<Card>], requested: &[Card]) -> Option<Vec<Card>> {
let mut wanted: Vec<String> = requested.iter().map(Card::code).collect();
wanted.sort();
for option in options {
let mut have: Vec<String> = option.iter().map(Card::code).collect();
have.sort();
if have == wanted {
return Some(option.clone());
}
}
None
}
/// Apply one move by the player identified by `sub`.
///
/// `capture_codes` selects which table cards to capture; it must be a legal
/// capture (see [`legal_captures`]) when one exists and empty otherwise.
/// Returns a [`GameError`] on any violation.
pub fn play(
state: &mut ScoponeState,
sub: &str,
card_code: &str,
capture_codes: Option<&[String]>,
) -> Result<()> {
if state.phase == PHASE_FINISHED {
return Err(GameError::game_finished("the match is over").into());
}
if state.phase == PHASE_HAND_END {
return Err(GameError::illegal_move(
"the hand is over; acknowledge the summary to continue",
)
.into());
}
if state.phase != PHASE_PLAYING {
return Err(GameError::game_not_started("the game has not started yet").into());
}
let index = state
.players
.iter()
.position(|player| player.sub == sub)
.filter(|&i| state.players[i].seat == state.turn);
let index = match index {
Some(index) => index,
None => return Err(GameError::not_your_turn("it is not your turn").into()),
};
let card = parse_card(card_code)?;
let hand_position = state.players[index].hand.iter().position(|c| c == &card);
let hand_position = match hand_position {
Some(position) => position,
None => return Err(card_not_in_hand(&card.code()).into()),
};
// Remove the card now so the scopa check below can tell whether this was
// the last play of the hand.
state.players[index].hand.remove(hand_position);
let mut requested: Vec<Card> = Vec::new();
if let Some(codes) = capture_codes {
for code in codes {
requested.push(parse_card(code)?);
}
}
let table_before = state.table.clone();
let options = legal_captures(&table_before, card.clone(), state.asso_pigliatutto);
// An asso pigliatutto capture: the ace takes the whole table because no
// equal rank was on it, or takes itself when the table is bare. Such a
// capture never scores a scopa.
let pigliatutto =
state.asso_pigliatutto && card.rank == 1 && table_before.iter().all(|c| c.rank != 1);
let mut taken: Vec<Card> = Vec::new();
let mut scopa = false;
if options.is_empty() {
if !requested.is_empty() {
return Err(GameError::illegal_move("no capture is possible with that card").into());
}
state.table.push(card.clone());
} else {
let chosen = match match_option(&options, &requested) {
Some(chosen) => chosen,
None => {
return Err(GameError::illegal_move("the requested capture is not legal").into())
}
};
for captured in &chosen {
if let Some(position) = state.table.iter().position(|c| c == captured) {
state.table.remove(position);
}
state.players[index].captured.push(captured.clone());
}
state.players[index].captured.push(card.clone());
state.last_taker = Some(state.players[index].seat);
// A scopa scores only if cards remain to be played this hand.
let hands_empty = state.players.iter().all(|player| player.hand.is_empty());
if state.table.is_empty() && !hands_empty && !pigliatutto {
state.players[index].scope += 1;
scopa = true;
}
taken = chosen;
}
state.last_move = Some(Move {
seat: state.players[index].seat,
name: state.players[index].name.clone(),
card: card.code(),
captured: taken.iter().map(Card::code).collect(),
scopa,
pigliatutto,
});
if state.players.iter().all(|player| player.hand.is_empty()) {
end_hand(state)?;
} else {
state.turn = (state.turn + 1) % PLAYERS as i64;
set_turn_deadline(state);
}
Ok(())
}
/// Play a random legal move for the player currently on turn.
///
/// A card is drawn at random from that player's hand; if it can capture, one
/// of the legal captures is chosen at random (the rules require a capture when
/// one exists). Delegates to [`play`], so the move is fully validated and can
/// end the hand or the match. Pass `rng` for deterministic tests.
pub fn auto_play<R: Rng + ?Sized>(state: &mut ScoponeState, rng: &mut R) -> Result<()> {
if state.phase != PHASE_PLAYING {
return Err(GameError::game_not_started("the game has not started yet").into());
}
let index = player_index_at(state, state.turn)?;
if state.players[index].hand.is_empty() {
return Err(GameError::illegal_move("the player on turn has no cards").into());
}
let card = state.players[index]
.hand
.choose(rng)
.cloned()
.expect("hand is not empty");
let options = legal_captures(&state.table, card.clone(), state.asso_pigliatutto);
let capture: Option<Vec<Card>> = if options.is_empty() {
None
} else {
options.choose(rng).cloned()
};
let sub = state.players[index].sub.clone();
let capture_codes: Option<Vec<String>> = capture
.as_ref()
.map(|cards| cards.iter().map(Card::code).collect());
play(state, &sub, &card.code(), capture_codes.as_deref())
}
/// Sweep the table and score the hand.
///
/// If the match continues, the game pauses in the `hand_end` phase so every
/// player can read the scoring summary; the next hand is dealt by
/// [`acknowledge_hand`] once all four players have acknowledged (or by the
/// hand-end timeout fired by the platform's deadline scheduler). If the match
/// is over the game goes to `finished` immediately.
fn end_hand(state: &mut ScoponeState) -> Result<()> {
state.turn_deadline = None;
if !state.table.is_empty() {
if let Some(taker) = state.last_taker {
let index = player_index_at(state, taker)?;
let swept: Vec<Card> = std::mem::take(&mut state.table);
state.players[index].captured.extend(swept);
}
}
let (points, mut details) = hand_points(state);
for (score, awarded) in state.scores.iter_mut().zip(points.iter()) {
*score += awarded;
}
details.insert("hand".to_string(), json!(state.hand_number));
details.insert("team_a_points".to_string(), json!(points[0]));
details.insert("team_b_points".to_string(), json!(points[1]));
state.hand_scores.push(Value::Object(details.clone()));
// A full napola (the whole denari suit) wins the match outright,
// regardless of the score.
if let Some(Value::Object(napola)) = details.get("napola") {
for (team, name) in TEAM_NAMES.iter().enumerate() {
if napola.get(*name).and_then(Value::as_i64) == Some(10) {
state.phase = PHASE_FINISHED.to_string();
state.winner = Some(team as i64);
return Ok(());
}
}
}
let a = state.scores[0];
let b = state.scores[1];
let reached = a.max(b) >= state.target_score;
if reached && a != b {
state.phase = PHASE_FINISHED.to_string();
state.winner = Some(if a > b { 0 } else { 1 });
return Ok(());
}
// Pause for the scoring summary instead of dealing immediately.
state.phase = PHASE_HAND_END.to_string();
state.acked = Vec::new();
let deadline = now() + chrono::Duration::seconds(state.hand_ack_timeout);
state.hand_end_deadline = Some(to_iso(deadline));
Ok(())
}
/// Record that `sub` has read the hand-end summary.
///
/// When all four players have acknowledged, the next hand is dealt.
/// Acknowledging twice is a no-op; acknowledging outside the `hand_end` phase
/// returns an error.
pub fn acknowledge_hand<R: Rng + ?Sized>(
state: &mut ScoponeState,
sub: &str,
rng: &mut R,
) -> Result<()> {
if state.phase != PHASE_HAND_END {
return Err(
GameError::illegal_move("no hand summary is waiting for acknowledgement").into(),
);
}
let seat = match state.players.iter().find(|player| player.sub == sub) {
Some(player) => player.seat,
None => return Err(GameError::not_your_turn("you are not seated in this game").into()),
};
if state.acked.contains(&seat) {
return Ok(());
}
state.acked.push(seat);
if state.acked.len() < PLAYERS {
return Ok(());
}
state.hand_number += 1;
state.dealer = (state.dealer + 1) % PLAYERS as i64;
state.acked = Vec::new();
state.hand_end_deadline = None;
state.last_move = None;
state.phase = PHASE_PLAYING.to_string();
deal_hand(state, rng);
Ok(())
}
/// Return the primiera value of a capture pile (0 if a suit is absent).
pub fn primiera_score(captured: &[Card]) -> i64 {
let mut best: [Option<i64>; 4] = [None; 4];
for card in captured {
let value = primiera_value(card.rank);
if let Some(index) = SUITS.iter().position(|&suit| suit == card.suit) {
if best[index].is_none_or(|current| value > current) {
best[index] = Some(value);
}
}
}
if best.iter().any(Option::is_none) {
return 0;
}
best.iter().map(|value| value.unwrap_or(0)).sum()
}
/// Return the napola value of a capture pile.
///
/// The longest run of consecutive denari starting from the ace scores one
/// point per card once it reaches three cards (A-2-3 = 3, A-2-3-4 = 4, ...),
/// so the whole suit (ace to king) is worth 10. Shorter runs score nothing.
/// Only one team can score a napola: the ace of denari belongs to exactly one
/// capture pile.
pub fn napola_score(captured: &[Card]) -> i64 {
let ranks: HashSet<u32> = captured
.iter()
.filter(|card| card.suit == 'D')
.map(|card| card.rank)
.collect();
let mut run = 0u32;
while ranks.contains(&(run + 1)) {
run += 1;
}
if run >= 3 {
run as i64
} else {
0
}
}
/// Compute the hand points for both teams (index 0 = team A).
pub fn hand_points(state: &ScoponeState) -> (Vec<i64>, Map<String, Value>) {
let mut piles: [Vec<Card>; 2] = [Vec::new(), Vec::new()];
let mut scope: [i64; 2] = [0, 0];
for player in &state.players {
let team = player.team() as usize;
piles[team].extend(player.captured.iter().cloned());
scope[team] += player.scope;
}
let mut points = [0i64, 0i64];
let mut award: Map<String, Value> = Map::new();
// Carte: most captured cards. Ties award nothing.
let cards = [piles[0].len() as i64, piles[1].len() as i64];
if cards[0] != cards[1] {
let winner = if cards[0] > cards[1] { 0 } else { 1 };
points[winner] += 1;
award.insert(
"carte".to_string(),
Value::String(TEAM_NAMES[winner].to_string()),
);
} else {
award.insert("carte".to_string(), Value::Null);
}
// Denara: most diamond cards. Ties award nothing.
let coins = [
piles[0].iter().filter(|c| c.suit == 'D').count() as i64,
piles[1].iter().filter(|c| c.suit == 'D').count() as i64,
];
if coins[0] != coins[1] {
let winner = if coins[0] > coins[1] { 0 } else { 1 };
points[winner] += 1;
award.insert(
"denara".to_string(),
Value::String(TEAM_NAMES[winner].to_string()),
);
} else {
award.insert("denara".to_string(), Value::Null);
}
// Settebello: the 7 of diamonds always belongs to someone.
let settebello = [
piles[0].iter().any(|c| c.rank == 7 && c.suit == 'D'),
piles[1].iter().any(|c| c.rank == 7 && c.suit == 'D'),
];
if settebello[0] != settebello[1] {
let winner = if settebello[0] { 0 } else { 1 };
points[winner] += 1;
award.insert(
"settebello".to_string(),
Value::String(TEAM_NAMES[winner].to_string()),
);
}
// Primiera: highest value, only if the team holds all four suits.
let primiera = [primiera_score(&piles[0]), primiera_score(&piles[1])];
if primiera[0] != primiera[1] {
let winner = if primiera[0] > primiera[1] { 0 } else { 1 };
points[winner] += 1;
award.insert(
"primiera".to_string(),
Value::String(TEAM_NAMES[winner].to_string()),
);
} else {
award.insert("primiera".to_string(), Value::Null);
}
// Scope: one point each.
points[0] += scope[0];
points[1] += scope[1];
// Napola (optional rule): consecutive denari from the ace. A run of 10
// means the team swept the whole suit and wins the match instantly.
let mut napola_details: Option<Value> = None;
if state.napola {
let napola = [napola_score(&piles[0]), napola_score(&piles[1])];
for team in 0..2 {
points[team] += napola[team];
}
let name = (0..2usize)
.find(|&team| napola[team] > 0)
.map(|team| Value::String(TEAM_NAMES[team].to_string()))
.unwrap_or(Value::Null);
award.insert("napola".to_string(), name);
napola_details = Some(json!({"A": napola[0], "B": napola[1]}));
}
let mut details: Map<String, Value> = Map::new();
details.insert("cards".to_string(), json!({"A": cards[0], "B": cards[1]}));
details.insert("denara".to_string(), json!({"A": coins[0], "B": coins[1]}));
details.insert(
"settebello".to_string(),
json!({"A": settebello[0], "B": settebello[1]}),
);
details.insert(
"primiera".to_string(),
json!({"A": primiera[0], "B": primiera[1]}),
);
details.insert("scope".to_string(), json!({"A": scope[0], "B": scope[1]}));
details.insert("award".to_string(), Value::Object(award));
if let Some(napola) = napola_details {
details.insert("napola".to_string(), napola);
}
(points.to_vec(), details)
}
fn optional_string(value: &Option<String>) -> Value {
value
.as_ref()
.map(|text| Value::String(text.clone()))
.unwrap_or(Value::Null)
}
/// Serialize `state` hiding other players' hands.
///
/// Hands are reduced to a count, except for the requesting player's own hand.
/// Non-seated viewers simply see no hand at all.
pub fn state_for_player(state: &ScoponeState, sub: &str) -> Value {
let viewer = state.player_for(sub);
let viewer_seat = viewer.map(|player| player.seat);
let mut players: Vec<Value> = Vec::new();
for player in &state.players {
let mut view: Map<String, Value> = Map::new();
view.insert("sub".to_string(), json!(player.sub));
view.insert("name".to_string(), json!(player.name));
view.insert("seat".to_string(), json!(player.seat));
view.insert(
"team".to_string(),
json!(TEAM_NAMES[player.team() as usize]),
);
view.insert("cards_left".to_string(), json!(player.hand.len()));
view.insert("captured_count".to_string(), json!(player.captured.len()));
view.insert("scope".to_string(), json!(player.scope));
if viewer_seat == Some(player.seat) {
view.insert(
"hand".to_string(),
Value::Array(player.hand.iter().map(Card::to_json).collect()),
);
}
players.push(Value::Object(view));
}
let mut payload: Map<String, Value> = Map::new();
payload.insert("phase".to_string(), json!(state.phase));
payload.insert("target_score".to_string(), json!(state.target_score));
payload.insert("napola".to_string(), json!(state.napola));
payload.insert(
"asso_pigliatutto".to_string(),
json!(state.asso_pigliatutto),
);
payload.insert("hand_number".to_string(), json!(state.hand_number));
payload.insert("dealer".to_string(), json!(state.dealer));
payload.insert("turn".to_string(), json!(state.turn));
payload.insert(
"scores".to_string(),
json!({"A": state.scores[0], "B": state.scores[1]}),
);
payload.insert(
"winner".to_string(),
match state.winner {
Some(winner) => json!(TEAM_NAMES[winner as usize]),
None => Value::Null,
},
);
payload.insert(
"table".to_string(),
Value::Array(state.table.iter().map(Card::to_json).collect()),
);
payload.insert("players".to_string(), Value::Array(players));
payload.insert(
"last_hand".to_string(),
state.hand_scores.last().cloned().unwrap_or(Value::Null),
);
payload.insert(
"last_move".to_string(),
state
.last_move
.as_ref()
.map(Move::to_json)
.unwrap_or(Value::Null),
);
payload.insert("acknowledged".to_string(), json!(state.acked));
payload.insert(
"hand_end_deadline".to_string(),
optional_string(&state.hand_end_deadline),
);
payload.insert(
"turn_deadline".to_string(),
optional_string(&state.turn_deadline),
);
if let Some(seat) = viewer_seat {
if state.phase == PHASE_PLAYING && seat == state.turn {
payload.insert("your_turn".to_string(), Value::Bool(true));
// Only the player on turn receives their legal captures, so all
// the rule logic stays server-side.
let mut legal_moves: Map<String, Value> = Map::new();
if let Some(viewer) = viewer {
for hand_card in &viewer.hand {
let options =
legal_captures(&state.table, hand_card.clone(), state.asso_pigliatutto);
if !options.is_empty() {
let list: Vec<Value> = options
.iter()
.map(|option| {
Value::Array(option.iter().map(|c| json!(c.code())).collect())
})
.collect();
legal_moves.insert(hand_card.code(), Value::Array(list));
}
}
}
payload.insert("legal_moves".to_string(), Value::Object(legal_moves));
}
}
Value::Object(payload)
}
@@ -0,0 +1,15 @@
//! Scopone scientifico errors.
//!
//! Most failure modes are game-independent and live in
//! [`tavolo_platform::errors`]; only genuinely scopone-specific errors are
//! defined here. The Rust platform folds every game error into a single
//! [`GameError`] value carrying an `"illegal_move"` code, so a Python-style
//! exception hierarchy is unnecessary; this module keeps named constructors so
//! call sites (and tests) stay readable.
use tavolo_platform::errors::GameError;
/// The played card is not held by the player.
pub fn card_not_in_hand(code: &str) -> GameError {
GameError::illegal_move(format!("card {code} is not in your hand"))
}
+19
View File
@@ -0,0 +1,19 @@
//! Scopone scientifico rules engine and platform adapter.
//!
//! [`engine`] holds the pure, deterministic rules; [`state`] the JSON-native
//! game representation; [`plugin`] the [`tavolo_platform::engine::GameEngine`]
//! implementation that plugs the game into the platform.
pub mod engine;
pub mod errors;
pub mod plugin;
pub mod state;
pub use engine::{
DEFAULT_HAND_ACK_TIMEOUT_SECONDS, DEFAULT_TURN_TIMEOUT_SECONDS, HAND_SIZE, PLAYERS,
};
pub use plugin::ScoponeEngine;
pub use state::{
Card, Move, PlayerState, ScoponeState, DEFAULT_TARGET_SCORE, PHASE_FINISHED, PHASE_HAND_END,
PHASE_LOBBY, PHASE_PLAYING, TEAM_NAMES,
};
+428
View File
@@ -0,0 +1,428 @@
//! The platform-facing adapter for scopone scientifico.
//!
//! [`ScoponeEngine`] implements [`GameEngine`] on top of the pure rules in
//! [`crate::engine`]: it translates platform calls (create, join, websocket
//! actions, deadlines) into rules-engine calls and back, keeping all scopone
//! knowledge inside this package. Nothing outside `tavolo_scopone` needs to
//! know about phases, turns or hand scoring.
use std::any::Any;
use rand::thread_rng;
use serde_json::{json, Map, Value};
use tavolo_platform::engine::{Deadline, GameEngine, GameSession, MatchResult, PlayerResult, Seat};
use tavolo_platform::errors::{GameError, Result};
use crate::engine::{self, DEFAULT_HAND_ACK_TIMEOUT_SECONDS, DEFAULT_TURN_TIMEOUT_SECONDS};
use crate::state::{
ScoponeState, DEFAULT_TARGET_SCORE, PHASE_FINISHED, PHASE_HAND_END, PHASE_LOBBY, PHASE_PLAYING,
TEAM_NAMES,
};
/// Downcast a session's opaque state to [`ScoponeState`].
fn state_ref(session: &GameSession) -> Result<&ScoponeState> {
session
.state
.as_ref()
.and_then(|state| state.downcast_ref::<ScoponeState>())
.ok_or_else(|| GameError::illegal_move("missing scopone state").into())
}
/// Downcast a session's opaque state to [`ScoponeState`], mutably.
fn state_mut(session: &mut GameSession) -> Result<&mut ScoponeState> {
session
.state
.as_mut()
.and_then(|state| state.downcast_mut::<ScoponeState>())
.ok_or_else(|| GameError::illegal_move("missing scopone state").into())
}
/// Epoch milliseconds for an ISO-8601 deadline, `None` when absent or
/// unparseable.
fn deadline_ms(iso: Option<&str>) -> Option<i64> {
iso.and_then(tavolo_platform::datetime::from_iso)
.map(|dt| dt.timestamp_millis())
}
/// Scopone scientifico as a platform game engine.
pub struct ScoponeEngine {
turn_timeout_seconds: i64,
hand_ack_timeout_seconds: i64,
}
impl ScoponeEngine {
pub fn new(turn_timeout_seconds: i64, hand_ack_timeout_seconds: i64) -> Self {
Self {
turn_timeout_seconds,
hand_ack_timeout_seconds,
}
}
}
impl Default for ScoponeEngine {
fn default() -> Self {
Self::new(
DEFAULT_TURN_TIMEOUT_SECONDS,
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
)
}
}
impl GameEngine for ScoponeEngine {
fn id(&self) -> &'static str {
"scopone_scientifico"
}
fn name(&self) -> &'static str {
"Scopone scientifico"
}
fn description(&self) -> &'static str {
"Four players in fixed partnerships, ten cards each and an empty table. \
First team to the target score wins."
}
fn min_players(&self) -> usize {
4
}
fn max_players(&self) -> usize {
4
}
fn options_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"target_score": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": DEFAULT_TARGET_SCORE,
"description": "Match points the winning team must reach.",
},
"napola": {
"type": "boolean",
"default": true,
"description": "Score the napola rule; a full denari sweep wins the match",
},
"asso_pigliatutto": {
"type": "boolean",
"default": false,
"description": "Score the asso pigliatutto rule; an ace sweeps the whole table (no scopa)",
},
},
})
}
// -- lobby ------------------------------------------------------------
fn create(&self, session: &mut GameSession, options: &Map<String, Value>) -> Result<()> {
let target_score = match options.get("target_score") {
None => DEFAULT_TARGET_SCORE,
Some(Value::Bool(_)) => {
return Err(GameError::illegal_move("target_score must be an integer").into())
}
Some(value) => value
.as_i64()
.ok_or_else(|| GameError::illegal_move("target_score must be an integer"))?,
};
let napola = match options.get("napola") {
None => true,
Some(Value::Bool(value)) => *value,
Some(_) => return Err(GameError::illegal_move("napola must be a boolean").into()),
};
let asso_pigliatutto = match options.get("asso_pigliatutto") {
None => false,
Some(Value::Bool(value)) => *value,
Some(_) => {
return Err(GameError::illegal_move("asso_pigliatutto must be a boolean").into())
}
};
let creator = session
.players
.first()
.ok_or_else(|| GameError::illegal_move("session has no creator seat"))?;
let creator_sub = creator.user_sub.clone();
let creator_name = creator.display_name.clone();
let state = engine::create_game(
&creator_sub,
&creator_name,
target_score,
self.hand_ack_timeout_seconds,
self.turn_timeout_seconds,
napola,
asso_pigliatutto,
)?;
session.state = Some(Box::new(state));
// The creator takes seat 0, i.e. team A.
if let Some(seat) = session.players.get_mut(0) {
seat.team = Some(TEAM_NAMES[0].to_string());
}
Ok(())
}
fn join(&self, session: &mut GameSession, user_sub: &str, display_name: &str) -> Result<()> {
let seat = session.players.len();
{
let state = state_mut(session)?;
engine::join_game(state, user_sub, display_name, &mut thread_rng())?;
}
// join_game raises before appending on any violation, so the seat list
// stays in sync with the engine's players.
session.players.push(Seat {
user_sub: user_sub.to_string(),
display_name: display_name.to_string(),
team: Some(TEAM_NAMES[seat % 2].to_string()),
});
Ok(())
}
fn in_lobby(&self, session: &GameSession) -> bool {
state_ref(session)
.map(|state| state.phase == PHASE_LOBBY)
.unwrap_or(false)
}
fn lobby_view(&self, session: &GameSession) -> Value {
match state_ref(session) {
Ok(state) => json!({
"phase": state.phase,
"target_score": state.target_score,
"napola": state.napola,
"asso_pigliatutto": state.asso_pigliatutto,
}),
Err(_) => json!({}),
}
}
// -- play -------------------------------------------------------------
fn handle_action(
&self,
session: &mut GameSession,
user_sub: &str,
action: &str,
payload: &Map<String, Value>,
) -> Result<()> {
match action {
"play" => {
let card = payload
.get("card")
.and_then(Value::as_str)
.ok_or_else(|| GameError::illegal_move("'card' must be a card code string"))?;
let capture: Option<Vec<String>> = match payload.get("capture") {
None | Some(Value::Null) => None,
Some(Value::Array(items)) => {
let mut codes = Vec::with_capacity(items.len());
for item in items {
match item.as_str() {
Some(code) => codes.push(code.to_string()),
None => {
return Err(GameError::illegal_move(
"'capture' must be a list of card codes",
)
.into())
}
}
}
Some(codes)
}
Some(_) => {
return Err(GameError::illegal_move(
"'capture' must be a list of card codes",
)
.into())
}
};
let state = state_mut(session)?;
engine::play(state, user_sub, card, capture.as_deref())
}
"ack" => {
let state = state_mut(session)?;
engine::acknowledge_hand(state, user_sub, &mut thread_rng())
}
_ => Err(GameError::illegal_move(format!(
"unknown action: {}",
tavolo_platform::http::py_repr(&serde_json::Value::String(action.to_string()))
))
.into()),
}
}
fn view_for(&self, session: &GameSession, user_sub: &str) -> Value {
match state_ref(session) {
Ok(state) => engine::state_for_player(state, user_sub),
Err(_) => Value::Null,
}
}
fn is_finished(&self, session: &GameSession) -> bool {
state_ref(session)
.map(|state| state.phase == PHASE_FINISHED)
.unwrap_or(false)
}
fn game_over_view(&self, session: &GameSession, _user_sub: &str) -> Value {
match state_ref(session) {
Ok(state) => json!({
"scores": {"A": state.scores[0], "B": state.scores[1]},
"winner": state.winner.map(|winner| TEAM_NAMES[winner as usize]),
}),
Err(_) => json!({}),
}
}
// -- (de)serialization -------------------------------------------------
fn state_to_json(&self, state: &(dyn Any + Send + Sync)) -> Result<Value> {
state
.downcast_ref::<ScoponeState>()
.map(ScoponeState::to_json)
.ok_or_else(|| GameError::illegal_move("state is not a ScoponeState").into())
}
fn state_from_json(&self, data: &Value) -> Result<Box<dyn Any + Send + Sync>> {
Ok(Box::new(ScoponeState::from_json(data)?))
}
// -- deadlines ----------------------------------------------------------
fn next_deadline(&self, session: &GameSession) -> Result<Option<Deadline>> {
let state = state_ref(session)?;
if state.phase == PHASE_PLAYING {
if let Some(iso) = state.turn_deadline.as_deref() {
let due_ms = match deadline_ms(Some(iso)) {
Some(due_ms) => due_ms,
None => return Ok(None),
};
return Ok(Some(Deadline {
kind: "turn".to_string(),
due_at: timestamp_millis(due_ms),
token: format!("turn:{}:{}:{}", state.hand_number, state.turn, due_ms),
}));
}
}
if state.phase == PHASE_HAND_END {
if let Some(iso) = state.hand_end_deadline.as_deref() {
let due_ms = match deadline_ms(Some(iso)) {
Some(due_ms) => due_ms,
None => return Ok(None),
};
return Ok(Some(Deadline {
kind: "hand_end".to_string(),
due_at: timestamp_millis(due_ms),
token: format!("hand_end:{}:{}", state.hand_number, due_ms),
}));
}
}
Ok(None)
}
fn fire_deadline(&self, session: &mut GameSession, kind: &str, token: &str) -> Result<()> {
let current = self.next_deadline(session)?;
let stale = match &current {
Some(deadline) => deadline.kind != kind || deadline.token != token,
None => true,
};
if stale {
// Overtaken by events (a play landed in time, the hand was
// acknowledged, the deadline moved): nothing to do.
return Err(GameError::illegal_move("stale deadline").into());
}
match kind {
"turn" => {
let state = state_mut(session)?;
engine::auto_play(state, &mut thread_rng())?;
let actor = state.last_move.as_ref().map(|move_| move_.seat);
tracing::info!(
actor,
hand = state.hand_number,
"auto-played for the turn timeout"
);
Ok(())
}
"hand_end" => {
let state = state_mut(session)?;
let subs: Vec<String> = state
.players
.iter()
.map(|player| player.sub.clone())
.collect();
let mut rng = thread_rng();
for sub in subs {
engine::acknowledge_hand(state, &sub, &mut rng)?;
}
tracing::info!(
hand = state.hand_number,
"hand auto-advanced after the acknowledgement timeout"
);
Ok(())
}
// next_deadline never emits other kinds.
_ => Err(GameError::illegal_move(format!("unknown deadline kind: {kind:?}")).into()),
}
}
// -- results -------------------------------------------------------------
fn result(&self, session: &GameSession) -> Result<MatchResult> {
let state = state_ref(session)?;
let winner = state
.winner
.ok_or_else(|| GameError::illegal_move("no result: the match is not finished"))?
as usize;
let teams = vec![
state
.players
.iter()
.filter(|player| player.team() == 0)
.map(|player| player.sub.clone())
.collect::<Vec<_>>(),
state
.players
.iter()
.filter(|player| player.team() == 1)
.map(|player| player.sub.clone())
.collect::<Vec<_>>(),
];
let players = state
.players
.iter()
.map(|player| {
let team = player.team() as usize;
PlayerResult {
user_sub: player.sub.clone(),
seat: player.seat,
won: team == winner,
team: Some(TEAM_NAMES[team].to_string()),
score: state.scores[team] as f64,
details: json!({"scope": player.scope}),
}
})
.collect();
let summary = json!({
"team_a_score": state.scores[0],
"team_b_score": state.scores[1],
"winner_team": TEAM_NAMES[winner],
"target_score": state.target_score,
"hands_played": state.hand_number,
"hand_scores": state.hand_scores,
});
Ok(MatchResult::new(teams, winner, players, summary))
}
}
/// Convert epoch milliseconds back to a UTC timestamp, falling back to now
/// when the value is out of range (it never is for parsed deadlines).
fn timestamp_millis(millis: i64) -> chrono::DateTime<chrono::Utc> {
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(millis)
.unwrap_or_else(tavolo_platform::datetime::now)
}
+388
View File
@@ -0,0 +1,388 @@
//! In-memory representation of a scopone scientifico game.
//!
//! The whole mutable game lives in [`ScoponeState`], which is serialized to
//! and from plain JSON for storage inside the platform's session envelope.
//! Keeping the representation JSON-native means the store needs no custom
//! codecs and the state is inspectable.
//!
//! The state contains only game data: the platform owns the session envelope
//! (id, join code, seats, timestamps, stats persistence).
//!
//! Deck convention: a 40-card Italian deck. Suits are `D` (denari), `C`
//! (coppe), `S` (spade) and `B` (bastoni); ranks are `1`..`10`. A card is
//! rendered as `RRSUIT` (e.g. `07D` is the settebello).
use serde_json::{json, Value};
use tavolo_platform::errors::{GameError, Result};
/// The four suits, in canonical order.
pub const SUITS: [char; 4] = ['D', 'C', 'S', 'B'];
/// Teams are derived from the seat: seats 0 and 2 form team A (index 0),
/// seats 1 and 3 form team B (index 1). Team pairs always sit opposite each
/// other, as in real scopone scientifico.
pub const TEAM_NAMES: [&str; 2] = ["A", "B"];
pub const PHASE_LOBBY: &str = "lobby";
pub const PHASE_PLAYING: &str = "playing";
/// Between hands of an unfinished match: scoring summary shown to every
/// player; the next hand is dealt once all four acknowledge (or the hand-end
/// timeout elapses).
pub const PHASE_HAND_END: &str = "hand_end";
pub const PHASE_FINISHED: &str = "finished";
pub const DEFAULT_TARGET_SCORE: i64 = 11;
/// The team index for a seat (0 or 1).
pub fn team_of(seat: i64) -> i64 {
seat.rem_euclid(2)
}
/// A single playing card.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Card {
pub rank: u32,
pub suit: char,
}
impl Card {
/// Build a validated card, or `None` when the suit/rank is out of range.
pub fn new(rank: u32, suit: char) -> Option<Card> {
if SUITS.contains(&suit) && (1..=10).contains(&rank) {
Some(Card { rank, suit })
} else {
None
}
}
/// The `RRSUIT` code (e.g. `07D`).
pub fn code(&self) -> String {
format!("{:02}{}", self.rank, self.suit)
}
/// Parse a card code, uppercasing the input like Python's `Card.parse`.
pub fn parse(code: &str) -> Option<Card> {
let upper = code.to_ascii_uppercase();
let chars: Vec<char> = upper.chars().collect();
if chars.len() != 3 || !chars[0].is_ascii_digit() || !chars[1].is_ascii_digit() {
return None;
}
let rank: u32 = format!("{}{}", chars[0], chars[1]).parse().ok()?;
Card::new(rank, chars[2])
}
pub fn to_json(&self) -> Value {
Value::String(self.code())
}
pub fn from_json(value: &Value) -> Result<Card> {
value
.as_str()
.and_then(Card::parse)
.ok_or_else(|| GameError::illegal_move("invalid card code").into())
}
}
/// Parse a card code, raising an `"invalid card code"` error on malformed
/// input.
pub fn parse_card(code: &str) -> Result<Card> {
Card::parse(code).ok_or_else(|| GameError::illegal_move("invalid card code").into())
}
/// Record of a single play, broadcast so every client can show who played
/// which card and what it captured.
#[derive(Debug, Clone, Default)]
pub struct Move {
pub seat: i64,
pub name: String,
pub card: String,
pub captured: Vec<String>,
pub scopa: bool,
/// The play was an asso pigliatutto capture (the whole table, or the ace
/// itself when the table was empty).
pub pigliatutto: bool,
}
impl Move {
pub fn to_json(&self) -> Value {
json!({
"seat": self.seat,
"name": self.name,
"card": self.card,
"captured": self.captured,
"scopa": self.scopa,
"pigliatutto": self.pigliatutto,
})
}
pub fn from_json(data: &Value) -> Move {
Move {
seat: data.get("seat").and_then(Value::as_i64).unwrap_or(0),
name: data
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
card: data
.get("card")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
captured: data
.get("captured")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.map(|c| c.as_str().unwrap_or_default().to_string())
.collect()
})
.unwrap_or_default(),
scopa: data.get("scopa").and_then(Value::as_bool).unwrap_or(false),
pigliatutto: data
.get("pigliatutto")
.and_then(Value::as_bool)
.unwrap_or(false),
}
}
}
/// One player's private state within a game.
#[derive(Debug, Clone, Default)]
pub struct PlayerState {
pub sub: String,
pub name: String,
pub seat: i64,
pub hand: Vec<Card>,
pub captured: Vec<Card>,
pub scope: i64,
}
impl PlayerState {
pub fn team(&self) -> i64 {
team_of(self.seat)
}
pub fn to_json(&self) -> Value {
json!({
"sub": self.sub,
"name": self.name,
"seat": self.seat,
"hand": self.hand.iter().map(Card::to_json).collect::<Vec<_>>(),
"captured": self.captured.iter().map(Card::to_json).collect::<Vec<_>>(),
"scope": self.scope,
})
}
pub fn from_json(data: &Value) -> Result<PlayerState> {
let parse_cards = |value: Option<&Value>| -> Result<Vec<Card>> {
let items = value.and_then(Value::as_array).cloned().unwrap_or_default();
items.iter().map(Card::from_json).collect()
};
Ok(PlayerState {
sub: data
.get("sub")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
name: data
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
seat: data.get("seat").and_then(Value::as_i64).unwrap_or(0),
hand: parse_cards(data.get("hand"))?,
captured: parse_cards(data.get("captured"))?,
scope: data.get("scope").and_then(Value::as_i64).unwrap_or(0),
})
}
}
/// The whole mutable state of one scopone scientifico game.
#[derive(Debug, Clone)]
pub struct ScoponeState {
pub target_score: i64,
/// Whether the napola rule is scored (denari run from the ace; a full suit
/// wins the match instantly). Default on.
pub napola: bool,
/// Whether the asso pigliatutto rule is scored (an ace sweeps the whole
/// table when no equal rank is on it, or captures itself when the table is
/// empty; the capture never scores a scopa). Default off, also for states
/// serialized before the option existed.
pub asso_pigliatutto: bool,
pub phase: String,
pub players: Vec<PlayerState>,
pub table: Vec<Card>,
pub dealer: i64,
pub turn: i64,
pub hand_number: i64,
pub scores: Vec<i64>,
pub winner: Option<i64>,
pub last_taker: Option<i64>,
/// Per-hand points awarded, for a compact audit trail in the API.
pub hand_scores: Vec<Value>,
/// The most recent play in the current hand, for move announcements.
pub last_move: Option<Move>,
/// While `phase == "hand_end"`: seats that acknowledged the summary, and
/// when the auto-continue timeout fires.
pub acked: Vec<i64>,
pub hand_end_deadline: Option<String>,
/// Seconds the hand-end summary waits before dealing anyway.
pub hand_ack_timeout: i64,
/// While `phase == "playing"`: when the server plays a random legal card
/// for the player on turn.
pub turn_deadline: Option<String>,
pub turn_timeout: i64,
}
impl Default for ScoponeState {
fn default() -> Self {
ScoponeState {
target_score: DEFAULT_TARGET_SCORE,
napola: true,
asso_pigliatutto: false,
phase: PHASE_LOBBY.to_string(),
players: Vec::new(),
table: Vec::new(),
dealer: 0,
turn: 0,
hand_number: 1,
scores: vec![0, 0],
winner: None,
last_taker: None,
hand_scores: Vec::new(),
last_move: None,
acked: Vec::new(),
hand_end_deadline: None,
hand_ack_timeout: 30,
turn_deadline: None,
turn_timeout: 30,
}
}
}
impl ScoponeState {
pub fn to_json(&self) -> Value {
json!({
"target_score": self.target_score,
"napola": self.napola,
"asso_pigliatutto": self.asso_pigliatutto,
"phase": self.phase,
"players": self.players.iter().map(PlayerState::to_json).collect::<Vec<_>>(),
"table": self.table.iter().map(Card::to_json).collect::<Vec<_>>(),
"dealer": self.dealer,
"turn": self.turn,
"hand_number": self.hand_number,
"scores": self.scores,
"winner": self.winner,
"last_taker": self.last_taker,
"hand_scores": self.hand_scores,
"last_move": self.last_move.as_ref().map(Move::to_json),
"acked": self.acked,
"hand_end_deadline": self.hand_end_deadline,
"hand_ack_timeout": self.hand_ack_timeout,
"turn_deadline": self.turn_deadline,
"turn_timeout": self.turn_timeout,
})
}
pub fn from_json(data: &Value) -> Result<ScoponeState> {
let obj = data
.as_object()
.ok_or_else(|| GameError::illegal_move("state must be a JSON object"))?;
let parse_array = |value: Option<&Value>| -> Vec<Value> {
value.and_then(Value::as_array).cloned().unwrap_or_default()
};
let players = parse_array(obj.get("players"))
.iter()
.map(PlayerState::from_json)
.collect::<Result<Vec<_>>>()?;
let table = parse_array(obj.get("table"))
.iter()
.map(Card::from_json)
.collect::<Result<Vec<_>>>()?;
let scores = obj
.get("scores")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.map(|x| x.as_i64().unwrap_or(0))
.collect::<Vec<_>>()
})
.unwrap_or_else(|| vec![0, 0]);
let last_move = match obj.get("last_move") {
Some(Value::Object(map)) if !map.is_empty() => {
Some(Move::from_json(obj.get("last_move").unwrap()))
}
_ => None,
};
Ok(ScoponeState {
target_score: obj
.get("target_score")
.and_then(Value::as_i64)
.unwrap_or(DEFAULT_TARGET_SCORE),
napola: obj.get("napola").and_then(Value::as_bool).unwrap_or(true),
asso_pigliatutto: obj
.get("asso_pigliatutto")
.and_then(Value::as_bool)
.unwrap_or(false),
phase: obj
.get("phase")
.and_then(Value::as_str)
.unwrap_or(PHASE_LOBBY)
.to_string(),
players,
table,
dealer: obj.get("dealer").and_then(Value::as_i64).unwrap_or(0),
turn: obj.get("turn").and_then(Value::as_i64).unwrap_or(0),
hand_number: obj.get("hand_number").and_then(Value::as_i64).unwrap_or(1),
scores,
winner: obj.get("winner").and_then(Value::as_i64),
last_taker: obj.get("last_taker").and_then(Value::as_i64),
hand_scores: parse_array(obj.get("hand_scores")),
last_move,
acked: obj
.get("acked")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.map(|x| x.as_i64().unwrap_or(0))
.collect::<Vec<_>>()
})
.unwrap_or_default(),
hand_end_deadline: obj
.get("hand_end_deadline")
.and_then(Value::as_str)
.map(str::to_string),
hand_ack_timeout: obj
.get("hand_ack_timeout")
.and_then(Value::as_i64)
.unwrap_or(30),
turn_deadline: obj
.get("turn_deadline")
.and_then(Value::as_str)
.map(str::to_string),
turn_timeout: obj
.get("turn_timeout")
.and_then(Value::as_i64)
.unwrap_or(30),
})
}
// -- helpers ----------------------------------------------------------
pub fn player_for(&self, sub: &str) -> Option<&PlayerState> {
self.players.iter().find(|player| player.sub == sub)
}
pub fn seated(&self, sub: &str) -> bool {
self.player_for(sub).is_some()
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,483 @@
//! `ScoponeEngine` adapter tests: the platform contract over the pure rules.
//!
//! Ported from `tavolo-scopone/tests/test_plugin.py`.
use serde_json::{json, Map, Value};
use tavolo_platform::engine::GameSession;
use tavolo_platform::errors::Error;
use tavolo_platform::{GameEngine, Seat};
use tavolo_scopone::engine as rules;
use tavolo_scopone::state::{Card, ScoponeState, PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING};
use tavolo_scopone::ScoponeEngine;
fn opts(value: Value) -> Map<String, Value> {
value.as_object().unwrap().clone()
}
fn empty() -> Map<String, Value> {
Map::new()
}
fn capitalize(name: &str) -> String {
let mut chars = name.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn base_session(engine: &ScoponeEngine) -> GameSession {
let mut session = GameSession::new("s1", engine.id(), "CODE01");
session.creator_sub = "alice".to_string();
session.players = vec![Seat {
user_sub: "alice".to_string(),
display_name: "Alice".to_string(),
team: None,
}];
session
}
fn make_session(engine: &ScoponeEngine, options: Map<String, Value>) -> Result<GameSession, Error> {
let mut session = base_session(engine);
engine.create(&mut session, &options)?;
Ok(session)
}
fn started(engine: &ScoponeEngine, options: Map<String, Value>) -> GameSession {
let mut session = make_session(engine, options).unwrap();
for name in ["bob", "carol", "dave"] {
engine.join(&mut session, name, &capitalize(name)).unwrap();
}
session
}
fn state(session: &GameSession) -> &ScoponeState {
session
.state
.as_ref()
.unwrap()
.downcast_ref::<ScoponeState>()
.unwrap()
}
fn state_mut(session: &mut GameSession) -> &mut ScoponeState {
session
.state
.as_mut()
.unwrap()
.downcast_mut::<ScoponeState>()
.unwrap()
}
fn err_message(error: &Error) -> String {
error
.as_game()
.map(|game| game.message.clone())
.unwrap_or_default()
}
/// Extract the error from a failed `make_session` without requiring
/// `GameSession: Debug` (which `unwrap_err` would).
fn session_error(result: Result<GameSession, Error>) -> Error {
match result {
Ok(_) => panic!("expected an error"),
Err(error) => error,
}
}
/// Play legal moves (and acknowledge hand ends) until the match finishes.
fn drive_to_finish(engine: &ScoponeEngine, session: &mut GameSession) -> usize {
let mut moves = 0;
while !engine.is_finished(session) && moves < 200_000 {
let (action, sub, payload): (&str, String, Map<String, Value>) = {
let st = state(session);
if st.phase == PHASE_HAND_END {
let player = st
.players
.iter()
.find(|player| !st.acked.contains(&player.seat))
.unwrap();
("ack", player.sub.clone(), Map::new())
} else {
let player = st
.players
.iter()
.find(|player| player.seat == st.turn)
.unwrap();
let played = player.hand[0].clone();
let options = rules::legal_captures(&st.table, played.clone(), st.asso_pigliatutto);
let mut payload = Map::new();
payload.insert("card".to_string(), json!(played.code()));
if let Some(option) = options.first() {
payload.insert(
"capture".to_string(),
json!(option.iter().map(Card::code).collect::<Vec<_>>()),
);
}
("play", player.sub.clone(), payload)
}
};
engine
.handle_action(session, &sub, action, &payload)
.unwrap();
moves += 1;
}
moves
}
// -- create ----------------------------------------------------------------
#[test]
fn create_seats_creator_on_team_a() {
let engine = ScoponeEngine::default();
let session = make_session(&engine, empty()).unwrap();
assert_eq!(Some("A".to_string()), session.players[0].team);
let st = state(&session);
assert_eq!(11, st.target_score);
assert!(st.napola);
}
#[test]
fn create_options() {
let engine = ScoponeEngine::default();
let session =
make_session(&engine, opts(json!({"target_score": 16, "napola": false}))).unwrap();
let st = state(&session);
assert_eq!(16, st.target_score);
assert!(!st.napola);
assert!(!st.asso_pigliatutto);
}
#[test]
fn create_asso_pigliatutto_option() {
let engine = ScoponeEngine::default();
let session = make_session(&engine, opts(json!({"asso_pigliatutto": true}))).unwrap();
assert!(state(&session).asso_pigliatutto);
}
#[test]
fn create_rejects_bad_options() {
let engine = ScoponeEngine::default();
assert_eq!(
"target_score must be between 1 and 100",
err_message(&session_error(make_session(
&engine,
opts(json!({"target_score": 0}))
)))
);
assert_eq!(
"target_score must be an integer",
err_message(&session_error(make_session(
&engine,
opts(json!({"target_score": "eleven"}))
)))
);
assert_eq!(
"napola must be a boolean",
err_message(&session_error(make_session(
&engine,
opts(json!({"napola": "yes"}))
)))
);
assert_eq!(
"asso_pigliatutto must be a boolean",
err_message(&session_error(make_session(
&engine,
opts(json!({"asso_pigliatutto": "yes"}))
)))
);
}
#[test]
fn timeouts_come_from_the_engine() {
let engine = ScoponeEngine::new(7, 9);
let session = make_session(&engine, empty()).unwrap();
let st = state(&session);
assert_eq!(7, st.turn_timeout);
assert_eq!(9, st.hand_ack_timeout);
}
// -- join ------------------------------------------------------------------
#[test]
fn join_assigns_teams_and_starts() {
let engine = ScoponeEngine::default();
let mut session = make_session(&engine, empty()).unwrap();
assert!(engine.in_lobby(&session));
for (name, team) in [("bob", "B"), ("carol", "A"), ("dave", "B")] {
engine.join(&mut session, name, &capitalize(name)).unwrap();
assert_eq!(Some(team.to_string()), session.players.last().unwrap().team);
}
assert!(!engine.in_lobby(&session));
assert_eq!(PHASE_PLAYING, state(&session).phase);
}
#[test]
fn join_errors() {
let engine = ScoponeEngine::default();
let mut session = make_session(&engine, empty()).unwrap();
assert_eq!(
"already joined this game",
err_message(&engine.join(&mut session, "alice", "Alice").unwrap_err())
);
for name in ["bob", "carol", "dave"] {
engine.join(&mut session, name, &capitalize(name)).unwrap();
}
// The lobby filled up and the match started: late joins and even re-joins
// are rejected as "already started".
assert_eq!(
"game has already started",
err_message(&engine.join(&mut session, "erin", "Erin").unwrap_err())
);
assert_eq!(
"game has already started",
err_message(&engine.join(&mut session, "alice", "Alice").unwrap_err())
);
}
// -- actions ---------------------------------------------------------------
#[test]
fn unknown_action_rejected() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
assert!(engine
.handle_action(&mut session, "alice", "dance", &Map::new())
.is_err());
}
#[test]
fn play_validates_payload() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
assert!(engine
.handle_action(&mut session, "bob", "play", &opts(json!({"card": 42})))
.is_err());
assert!(engine
.handle_action(&mut session, "bob", "play", &Map::new())
.is_err());
assert!(engine
.handle_action(
&mut session,
"bob",
"play",
&opts(json!({"card": "01D", "capture": "02C"}))
)
.is_err());
}
#[test]
fn invalid_card_code_is_illegal_move() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
let error = engine
.handle_action(&mut session, "bob", "play", &opts(json!({"card": "nope"})))
.unwrap_err();
assert_eq!("invalid card code", err_message(&error));
}
#[test]
fn finished_match_rejects_actions() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, opts(json!({"target_score": 1})));
drive_to_finish(&engine, &mut session);
assert!(engine.is_finished(&session));
let error = engine
.handle_action(&mut session, "alice", "play", &opts(json!({"card": "01D"})))
.unwrap_err();
assert_eq!("the match is over", err_message(&error));
}
// -- views -----------------------------------------------------------------
#[test]
fn view_for_hides_other_hands() {
let engine = ScoponeEngine::default();
let session = started(&engine, empty());
let view = engine.view_for(&session, "alice");
let players = view["players"].as_array().unwrap();
let alice = players.iter().find(|p| p["seat"] == 0).unwrap();
let bob = players.iter().find(|p| p["seat"] == 1).unwrap();
assert!(alice.get("hand").is_some());
assert!(bob.get("hand").is_none());
// The envelope is the platform's job, not the view's.
assert!(view.get("id").is_none());
assert!(view.get("join_code").is_none());
assert!(view.get("game_type").is_none());
}
#[test]
fn lobby_view() {
let engine = ScoponeEngine::default();
let session = make_session(&engine, opts(json!({"target_score": 16}))).unwrap();
let lobby = engine.lobby_view(&session);
assert_eq!(json!("lobby"), lobby["phase"]);
assert_eq!(json!(16), lobby["target_score"]);
assert_eq!(json!(true), lobby["napola"]);
assert_eq!(json!(false), lobby["asso_pigliatutto"]);
}
// -- serialization ---------------------------------------------------------
#[test]
fn state_roundtrip() {
let engine = ScoponeEngine::default();
let session = started(&engine, empty());
let state_value = engine
.state_to_json(session.state.as_ref().unwrap().as_ref())
.unwrap();
let restored = engine.state_from_json(&state_value).unwrap();
let restored = restored.downcast_ref::<ScoponeState>().unwrap();
assert_eq!(state(&session).phase, restored.phase);
assert_eq!(state(&session).turn, restored.turn);
assert_eq!(
state(&session)
.players
.iter()
.map(|p| p.sub.clone())
.collect::<Vec<_>>(),
restored
.players
.iter()
.map(|p| p.sub.clone())
.collect::<Vec<_>>()
);
}
// -- deadlines -------------------------------------------------------------
#[test]
fn no_deadline_in_lobby() {
let engine = ScoponeEngine::default();
let session = make_session(&engine, empty()).unwrap();
assert!(engine.next_deadline(&session).unwrap().is_none());
}
#[test]
fn turn_deadline_and_revalidation() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
let deadline = engine.next_deadline(&session).unwrap().unwrap();
assert_eq!("turn", deadline.kind);
// A forged token is stale.
assert!(engine
.fire_deadline(&mut session, "turn", "turn:1:1:0")
.is_err());
let turn_before = state(&session).turn;
engine
.fire_deadline(&mut session, &deadline.kind, &deadline.token)
.unwrap();
assert_ne!(turn_before, state(&session).turn);
}
#[test]
fn stale_deadline_after_play() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
let deadline = engine.next_deadline(&session).unwrap().unwrap();
// A play lands in time: the armed deadline is overtaken.
let (sub, payload) = {
let st = state(&session);
let player = st.players.iter().find(|p| p.seat == st.turn).unwrap();
let played = player.hand[0].clone();
let options = rules::legal_captures(&st.table, played.clone(), st.asso_pigliatutto);
let mut payload = Map::new();
payload.insert("card".to_string(), json!(played.code()));
if let Some(option) = options.first() {
payload.insert(
"capture".to_string(),
json!(option.iter().map(Card::code).collect::<Vec<_>>()),
);
}
(player.sub.clone(), payload)
};
engine
.handle_action(&mut session, &sub, "play", &payload)
.unwrap();
assert!(engine
.fire_deadline(&mut session, &deadline.kind, &deadline.token)
.is_err());
}
#[test]
fn hand_end_deadline_advances() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
{
let st = state_mut(&mut session);
st.phase = PHASE_HAND_END.to_string();
st.hand_end_deadline = Some(tavolo_platform::datetime::to_iso(
tavolo_platform::datetime::now() + chrono::Duration::seconds(60),
));
}
let deadline = engine.next_deadline(&session).unwrap().unwrap();
assert_eq!("hand_end", deadline.kind);
engine
.fire_deadline(&mut session, &deadline.kind, &deadline.token)
.unwrap();
let st = state(&session);
assert_eq!(PHASE_PLAYING, st.phase);
assert_eq!(2, st.hand_number);
}
// -- results ---------------------------------------------------------------
#[test]
fn result_of_finished_match() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, opts(json!({"target_score": 1})));
drive_to_finish(&engine, &mut session);
let result = engine.result(&session).unwrap();
assert_eq!(2, result.teams.len());
assert!(result.winner_team == 0 || result.winner_team == 1);
assert_eq!(4, result.players.len());
let winner_name = if result.winner_team == 0 { "A" } else { "B" };
for player in &result.players {
assert_eq!(player.won, player.team.as_deref() == Some(winner_name));
}
assert!(result.summary.get("team_a_score").is_some());
assert!(result.summary.get("hands_played").is_some());
}
#[test]
fn result_requires_finished_match() {
let engine = ScoponeEngine::default();
let session = started(&engine, empty());
assert!(engine.result(&session).is_err());
}
#[test]
fn game_over_view() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
{
let st = state_mut(&mut session);
st.phase = PHASE_FINISHED.to_string();
st.winner = Some(1);
st.scores = vec![3, 11];
}
let over = engine.game_over_view(&session, "alice");
assert_eq!(json!({"A": 3, "B": 11}), over["scores"]);
assert_eq!(json!("B"), over["winner"]);
}
#[test]
fn registry_metadata() {
let engine = ScoponeEngine::default();
assert_eq!("scopone_scientifico", engine.id());
assert_eq!(4, engine.min_players());
assert_eq!(4, engine.max_players());
let schema = engine.options_schema();
assert!(schema["properties"]["target_score"].is_object());
assert!(schema["properties"]["napola"].is_object());
assert!(schema["properties"]["asso_pigliatutto"].is_object());
// Byte-compatible option schema: descriptions and defaults match Python.
assert_eq!(
json!("Score the napola rule; a full denari sweep wins the match"),
schema["properties"]["napola"]["description"]
);
assert_eq!(
json!("Score the asso pigliatutto rule; an ace sweeps the whole table (no scopa)"),
schema["properties"]["asso_pigliatutto"]["description"]
);
}