diff --git a/crates/server/src/games/db.rs b/crates/server/src/games/db.rs index a66eaff04..dd5761023 100644 --- a/crates/server/src/games/db.rs +++ b/crates/server/src/games/db.rs @@ -72,10 +72,12 @@ impl Games { ); result.map_err(CreationError::Database)?; + let mut author = true; for username in game.players() { - Self::add_player_inner(&mut transaction, username, game_config.name()) + Self::add_player_inner(&mut transaction, author, username, game_config.name()) .await .map_err(CreationError::AdditionError)?; + author = false; } transaction @@ -88,13 +90,15 @@ impl Games { async fn add_player_inner<'c, E>( executor: E, + author: bool, username: &str, game: &str, ) -> Result<(), AdditionError> where E: SqliteExecutor<'c>, { - let result = query("INSERT INTO players (username, game) VALUES (?, ?);") + let result = query("INSERT INTO players (author, username, game) VALUES (?, ?, ?);") + .bind(author) .bind(username) .bind(game) .execute(executor) @@ -109,6 +113,81 @@ impl Games { Ok(()) } + + /// Removes a player from a game. Deletes the game if the player was the + /// game author. + pub(super) async fn remove_player( + &self, + username: &str, + game: &str, + ) -> Result<(), RemovalError> { + let mut transaction = self.pool.begin().await.map_err(RemovalError::Database)?; + + let mut rows = query("SELECT author FROM players WHERE username = ? AND game = ?;") + .bind(username) + .bind(game) + .fetch(self.pool); + + let action = match rows.try_next().await.map_err(RemovalError::Database)? { + Some(row) => { + let author: bool = row.try_get("author").map_err(RemovalError::Database)?; + if author { + RemovalAction::Abandoned + } else { + RemovalAction::Removed + } + } + None => return Err(RemovalError::NotInTheGame), + }; + + match action { + RemovalAction::Abandoned => { + query("DELETE FROM games WHERE name = ?;") + .bind(game) + .execute(&mut transaction) + .await + .map_err(RemovalError::Database)?; + } + RemovalAction::Removed => { + Self::remove_player_inner(&mut transaction, username, game).await?; + } + } + + transaction.commit().await.map_err(RemovalError::Database)?; + Ok(()) + } + + async fn remove_player_inner<'c, E>( + executor: E, + username: &str, + game: &str, + ) -> Result<(), RemovalError> + where + E: SqliteExecutor<'c>, + { + let query_result = query("DELETE FROM players WHERE username = ? AND game = ?;") + .bind(username) + .bind(game) + .execute(executor) + .await + .map_err(RemovalError::Database)?; + + let rows_affected = query_result.rows_affected(); + assert!(rows_affected <= 1); + if rows_affected == 0 { + return Err(RemovalError::NotInTheGame); + } + + Ok(()) + } +} + +/// Action taken during removal of a player from a game. +enum RemovalAction { + /// The game was abandoned and all players removed from the game. + Abandoned, + /// The player left the game without any further action taken. + Removed, } #[derive(Error, Debug)] @@ -133,6 +212,14 @@ pub(super) enum AdditionError { Other(#[from] anyhow::Error), } +#[derive(Error, Debug)] +pub(super) enum RemovalError { + #[error("User is not in the game")] + NotInTheGame, + #[error("A database error encountered")] + Database(#[source] sqlx::Error), +} + impl TryFrom for GameConfig { type Error = anyhow::Error; diff --git a/crates/server/src/games/endpoints.rs b/crates/server/src/games/endpoints.rs index 960d989ea..025f54b49 100644 --- a/crates/server/src/games/endpoints.rs +++ b/crates/server/src/games/endpoints.rs @@ -1,15 +1,20 @@ -use actix_web::{get, post, web, HttpResponse, Responder}; +use actix_web::{get, post, put, web, HttpResponse, Responder}; use log::{error, warn}; use super::{ - db::{AdditionError, CreationError, Games}, + db::{AdditionError, CreationError, Games, RemovalError}, model::{Game, GameConfig}, }; use crate::auth::Claims; /// Registers all authentication endpoints. pub(super) fn configure(cfg: &mut web::ServiceConfig) { - cfg.service(web::scope("/games").service(create).service(list)); + cfg.service( + web::scope("/games") + .service(create) + .service(list) + .service(leave), + ); } #[post("/")] @@ -52,3 +57,25 @@ async fn list(games: web::Data) -> impl Responder { } } } + +#[put("/{name}/leave")] +async fn leave( + claims: web::ReqData, + games: web::Data, + path: web::Path, +) -> impl Responder { + let name = path.into_inner(); + + match games.remove_player(claims.username(), name.as_str()).await { + Ok(_) => HttpResponse::Ok().finish(), + + Err(RemovalError::NotInTheGame) => { + warn!("Game leaving error: the user is not in the game."); + HttpResponse::Forbidden().json("The user is not in the game.") + } + Err(error) => { + error!("Error while removing a player from a game: {:?}", error); + HttpResponse::InternalServerError().finish() + } + } +} diff --git a/crates/server/src/games/init.sql b/crates/server/src/games/init.sql index f2df59ec7..7d1c20504 100644 --- a/crates/server/src/games/init.sql +++ b/crates/server/src/games/init.sql @@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS games ( CREATE TABLE IF NOT EXISTS players ( ordinal INTEGER PRIMARY KEY AUTOINCREMENT, + author BOOLEAN NOT NULL, username CHARACTER({username_len}) NOT NULL UNIQUE, game CHARACTER({game_name_len}) NOT NULL, diff --git a/crates/server/src/games/model.rs b/crates/server/src/games/model.rs index 43897a372..63d43568c 100644 --- a/crates/server/src/games/model.rs +++ b/crates/server/src/games/model.rs @@ -25,6 +25,8 @@ impl Game { &self.config } + /// Returns a slice with usernames of the game players. The first user is + /// game author. pub(super) fn players(&self) -> &[String] { self.players.as_slice() } diff --git a/docs/content/lobby/openapi.yaml b/docs/content/lobby/openapi.yaml index 882361966..84b9aa48e 100644 --- a/docs/content/lobby/openapi.yaml +++ b/docs/content/lobby/openapi.yaml @@ -100,6 +100,26 @@ paths: "409": description: A different game with the same name already exists. + /a/games/{name}/leave: + put: + summary: Leave or abandon a game. + description: >- + Leave the game. The client needs to be part of the game. The game is + abandoned / canceled when the author leaves the game. + security: + - bearerAuth: [] + parameters: + - name: name + in: path + required: true + schema: + type: string + responses: + "200": + description: The user successfully left the game. + "403": + description: The user is not part of the game. + components: securitySchemes: bearerAuth: