Files
tavolo/server/crates/tavolo-platform/src/routes/games.rs
T
woggioni 7b3b38ccbc
CI / Build and push docker image (push) Successful in 4m20s
Rewrite the backend in Rust with Axum
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.
2026-10-02 13:54:55 +00:00

422 lines
14 KiB
Rust

//! 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,
}
}