diff --git a/crates/lobby/src/games/db.rs b/crates/lobby/src/games/db.rs index 819c5175a..63e0d4537 100644 --- a/crates/lobby/src/games/db.rs +++ b/crates/lobby/src/games/db.rs @@ -1,7 +1,9 @@ +use std::net::SocketAddr; + use anyhow::{Context, Result}; use de_lobby_model::{ - Game, GameConfig, GameListing, GameMap, GamePartial, MAP_HASH_LEN, MAX_GAME_NAME_LEN, - MAX_MAP_NAME_LEN, MAX_USERNAME_LEN, + Game, GameConfig, GameListing, GameMap, GamePartial, GameSetup, MAP_HASH_LEN, + MAX_GAME_NAME_LEN, MAX_MAP_NAME_LEN, MAX_USERNAME_LEN, }; use futures_util::TryStreamExt; use log::info; @@ -16,6 +18,10 @@ use crate::{ db_error, }; +// This should correspond to the longest valid socket address. IPv6 hast up to +// 39 characters + colon + 5 characters for port number. +const SERVER_LEN: usize = 45; + #[derive(Clone)] pub(super) struct Games { pool: &'static Pool, @@ -33,6 +39,7 @@ impl Games { game_name_len = MAX_GAME_NAME_LEN, map_name_lenght = MAX_MAP_NAME_LEN, map_hash_lenght = MAP_HASH_LEN, + server_len = SERVER_LEN, ); info!("Initializing games..."); @@ -64,18 +71,50 @@ impl Games { Ok(games) } + /// This method retrieves complete info about a single game. + pub(super) async fn get(&self, game: &str) -> Result> { + let Some(game_row) = query("SELECT * FROM games WHERE game = ?;") + .bind(game) + .fetch_optional(self.pool) + .await + .context("Failed to retrieve a game from the DB")? + else { + return Ok(None); + }; + + let setup = GameSetup::try_from_row(game_row)?; + + let mut players = Vec::new(); + let mut player_rows = query("SELECT username FROM players WHERE game = ?;") + .bind(game) + .fetch(self.pool); + + while let Some(player_row) = player_rows + .try_next() + .await + .context("Failed to retrieve game players from the DB")? + { + let username: String = player_row.try_get("username")?; + players.push(username); + } + + Ok(Some(Game::new(setup, players))) + } + /// This method creates a new game in the DB and places all users to it. pub(super) async fn create(&self, game: Game) -> Result<(), CreationError> { - let game_config = game.config(); + let game_setup = game.setup(); + let game_config = game_setup.config(); let mut transaction = self.pool.begin().await.map_err(CreationError::Database)?; let result = - query("INSERT INTO games (name, max_players, map_hash, map_name) VALUES(?, ?, ?, ?);") + query("INSERT INTO games (name, max_players, map_hash, map_name, server) VALUES(?, ?, ?, ?, ?);") .bind(game_config.name()) .bind(game_config.max_players()) .bind(game_config.map().hash()) .bind(game_config.map().name()) + .bind(game_setup.server().to_string()) .execute(&mut transaction) .await; db_error!( @@ -244,6 +283,17 @@ pub(super) enum RemovalError { Database(#[source] sqlx::Error), } +impl FromRow for GameSetup { + type Error = anyhow::Error; + + fn try_from_row(row: SqliteRow) -> Result { + let server: String = row.try_get("server")?; + let server: SocketAddr = server.parse()?; + let config = GameConfig::try_from_row(row)?; + Ok(Self::new(server, config)) + } +} + impl FromRow for GamePartial { type Error = anyhow::Error; diff --git a/crates/lobby/src/games/endpoints.rs b/crates/lobby/src/games/endpoints.rs index c5612a792..4bf187527 100644 --- a/crates/lobby/src/games/endpoints.rs +++ b/crates/lobby/src/games/endpoints.rs @@ -1,5 +1,5 @@ use actix_web::{get, post, put, web, HttpResponse, Responder}; -use de_lobby_model::{Game, GameConfig, Validatable}; +use de_lobby_model::{Game, GameSetup, Validatable}; use log::{error, warn}; use super::db::{AdditionError, CreationError, Games, RemovalError}; @@ -10,6 +10,7 @@ pub(super) fn configure(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/games") .service(create) + .service(get) .service(list) .service(join) .service(leave), @@ -20,15 +21,15 @@ pub(super) fn configure(cfg: &mut web::ServiceConfig) { async fn create( claims: web::ReqData, games: web::Data, - game_config: web::Json, + game_setup: web::Json, ) -> impl Responder { - let game_config = game_config.into_inner(); - if let Err(error) = game_config.validate() { - warn!("Invalid game configuration: {:?}", error); + let game_setup = game_setup.into_inner(); + if let Err(error) = game_setup.validate() { + warn!("Invalid game setup: {:?}", error); return HttpResponse::BadRequest().json(format!("{error}")); } - let game = Game::new(game_config, claims.username().to_owned()); + let game = Game::from_author(game_setup, claims.username().to_owned()); match games.create(game).await { Ok(_) => HttpResponse::Ok().json(()), Err(CreationError::NameTaken) => { @@ -46,6 +47,19 @@ async fn create( } } +#[get("/{name}")] +async fn get(path: web::Path, games: web::Data) -> impl Responder { + let name = path.into_inner(); + match games.get(&name).await { + Ok(Some(game)) => HttpResponse::Ok().json(game), + Ok(None) => HttpResponse::NotFound().json("Game not found"), + Err(error) => { + error!("Game get error: {:?}", error); + HttpResponse::InternalServerError().finish() + } + } +} + #[get("")] async fn list(games: web::Data) -> impl Responder { match games.list().await { diff --git a/crates/lobby/src/games/init.sql b/crates/lobby/src/games/init.sql index ac2fd3077..175a1a33c 100644 --- a/crates/lobby/src/games/init.sql +++ b/crates/lobby/src/games/init.sql @@ -2,7 +2,8 @@ CREATE TABLE IF NOT EXISTS games ( name CHARACTER({game_name_len}) NOT NULL PRIMARY KEY, max_players TINYINT NOT NULL, map_hash CHARACTER({map_hash_lenght}) NOT NULL, - map_name CHARACTER({map_name_lenght}) NOT NULL + map_name CHARACTER({map_name_lenght}) NOT NULL, + server CHARACTER({server_len}) NOT NULL ); CREATE TABLE IF NOT EXISTS players ( diff --git a/crates/lobby_model/src/games.rs b/crates/lobby_model/src/games.rs index b5ac08abe..c0f363800 100644 --- a/crates/lobby_model/src/games.rs +++ b/crates/lobby_model/src/games.rs @@ -1,3 +1,5 @@ +use std::net::SocketAddr; + use serde::{Deserialize, Serialize}; use crate::{ensure, validation}; @@ -10,21 +12,22 @@ const MAX_PLAYERS: u8 = 4; #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Game { - config: GameConfig, + setup: GameSetup, players: Vec, } impl Game { /// Creates a new game with the author being the only player. - pub fn new(config: GameConfig, author: String) -> Self { - Self { - config, - players: vec![author], - } + pub fn from_author(setup: GameSetup, author: String) -> Self { + Self::new(setup, vec![author]) } - pub fn config(&self) -> &GameConfig { - &self.config + pub fn new(setup: GameSetup, players: Vec) -> Self { + Self { setup, players } + } + + pub fn setup(&self) -> &GameSetup { + &self.setup } pub fn players(&self) -> &[String] { @@ -74,6 +77,33 @@ impl GamePartial { } } +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GameSetup { + server: SocketAddr, + config: GameConfig, +} + +impl GameSetup { + pub fn new(server: SocketAddr, config: GameConfig) -> Self { + Self { server, config } + } + + pub fn server(&self) -> SocketAddr { + self.server + } + + pub fn config(&self) -> &GameConfig { + &self.config + } +} + +impl validation::Validatable for GameSetup { + fn validate(&self) -> validation::Result { + self.config.validate() + } +} + #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GameConfig { diff --git a/crates/lobby_model/src/lib.rs b/crates/lobby_model/src/lib.rs index ca30f6b07..1201a39cf 100644 --- a/crates/lobby_model/src/lib.rs +++ b/crates/lobby_model/src/lib.rs @@ -3,8 +3,8 @@ pub use auth::{ MIN_PASSWORD_LEN, }; pub use games::{ - Game, GameConfig, GameListing, GameMap, GamePartial, MAP_HASH_LEN, MAX_GAME_NAME_LEN, - MAX_MAP_NAME_LEN, + Game, GameConfig, GameListing, GameMap, GamePartial, GameSetup, MAP_HASH_LEN, + MAX_GAME_NAME_LEN, MAX_MAP_NAME_LEN, }; pub use validation::Validatable; diff --git a/docs/src/multiplayer/openapi.yaml b/docs/src/multiplayer/openapi.yaml index b64bd3b2c..eccbe89c2 100644 --- a/docs/src/multiplayer/openapi.yaml +++ b/docs/src/multiplayer/openapi.yaml @@ -95,7 +95,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/game-config" + $ref: "#/components/schemas/game-setup" responses: "200": description: The game was successfully crated and the user joined it. @@ -112,6 +112,34 @@ paths: "409": description: A different game with the same name already exists. + /a/games/{name}: + get: + summary: Get complete information about a game. + security: + - bearerAuth: [] + parameters: + - name: name + in: path + required: true + schema: + type: string + responses: + "200": + description: Game successfully retrieved. + content: + application/json: + schema: + type: object + properties: + players: + type: array + items: + type: string + setup: + $ref: "#/components/schemas/game-setup" + "404": + description: The game does not exist. + /a/games/{name}/join: put: summary: Join the game. @@ -189,6 +217,16 @@ components: A unique user name. It is a non-empty Unicode string with maximum length of 32 bytes when encoded in UTF-8. It does not start or end with whitespace. + game-setup: + type: object + properties: + server: + type: string + description: >- + An IPv4 or IPv6 socket address. For example 127.0.0.1:8082. + config: + $ref: "#/components/schemas/game-config" + game-config: type: object properties: