CI / Build and push docker image (push) Successful in 4m20s
Replace the Python/kaya server in server/ with a Rust Cargo workspace of three crates mirroring the old layout: tavolo-platform (lobby API, live-game store, websocket endpoint, deadline scheduler, stats/Elo, OIDC sessions), tavolo-scopone (pure scopone scientifico rules plus the platform adapter) and tavolo-app (composition root and the `tavolo` binary with serve/migrate/backfill-elo). The HTTP and WebSocket contracts, error messages, Redis key layout and Postgres schema are unchanged, so the existing web/ frontend and in-flight games keep working. Notable differences: sessions are JSON under tavolo:session: (Python pickle sessions invalidate once), LOGGING_CONFIG is ignored in favour of RUST_LOG, DATABASE_URL must be Postgres, sqlx migrations replace aerich, and OpenTelemetry exports Rust process metrics instead of CPython gc metrics (no Postgres/Redis client instrumentation). Docker Compose and the Kubernetes manifest now run the single Rust image (embedded migrations, /static and /assets served by the app); the release CI keeps building server/Dockerfile. The full test suite (scopone rules, platform store/deadlines/stats/websockets, OIDC login flow, scopone e2e) is ported and runs with cargo test --workspace; Redis/Postgres integration tests are gated on TEST_REDIS_URL/TEST_DATABASE_URL.
422 lines
14 KiB
Rust
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,
|
|
}
|
|
}
|