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,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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user