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:
1 parent
adc3d340b1
commit
7b3b38ccbc
136 files changed
+16821
-11777
No files matched your search
@@ -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 }
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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(¶ms).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(¶ms).unwrap().limit, 100);
|
||||
|
||||
let mut params = HashMap::new();
|
||||
params.insert("limit".into(), vec!["zero".into()]);
|
||||
assert!(matches!(
|
||||
parse_cursor_params(¶ms),
|
||||
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, ¶ms).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(¶ms) {
|
||||
Ok(cursor) => cursor,
|
||||
Err(err) => return cursor_error(err),
|
||||
};
|
||||
let game_type = match parse_game_type(&platform, ¶ms) {
|
||||
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, ¶ms) {
|
||||
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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user