From 45b6a24eda1ad57cbf4a4db1569e09713e9e62de Mon Sep 17 00:00:00 2001 From: Pierre Hubert Date: Sat, 24 Sep 2022 11:46:12 +0200 Subject: [PATCH] Add invite mode --- sea_battle_backend/src/consts.rs | 2 + sea_battle_backend/src/dispatcher_actor.rs | 100 +++++++++++++++++++++ sea_battle_backend/src/human_player_ws.rs | 81 +++++++++++++---- sea_battle_backend/src/lib.rs | 2 + sea_battle_backend/src/server.rs | 65 +++++++++++++- sea_battle_backend/src/test/bot_client.rs | 57 ++++++++++-- sea_battle_backend/src/test/invite_mode.rs | 84 +++++++++++++++++ sea_battle_backend/src/test/mod.rs | 3 + sea_battle_backend/src/utils.rs | 19 ++++ 9 files changed, 387 insertions(+), 26 deletions(-) create mode 100644 sea_battle_backend/src/dispatcher_actor.rs create mode 100644 sea_battle_backend/src/test/invite_mode.rs create mode 100644 sea_battle_backend/src/utils.rs diff --git a/sea_battle_backend/src/consts.rs b/sea_battle_backend/src/consts.rs index 5e08298..206cb8c 100644 --- a/sea_battle_backend/src/consts.rs +++ b/sea_battle_backend/src/consts.rs @@ -19,3 +19,5 @@ pub const MULTI_PLAYER_PLAYER_CAN_CONTINUE_AFTER_HIT: bool = true; pub const MULTI_PLAYER_PLAYER_BOATS: [usize; 5] = [2, 3, 3, 4, 5]; pub const ALPHABET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +pub const INVITE_CODE_LENGTH: usize = 5; diff --git a/sea_battle_backend/src/dispatcher_actor.rs b/sea_battle_backend/src/dispatcher_actor.rs new file mode 100644 index 0000000..95a4b58 --- /dev/null +++ b/sea_battle_backend/src/dispatcher_actor.rs @@ -0,0 +1,100 @@ +//! # Dispatcher actors +//! +//! Allows to establish connections between human players + +use std::collections::HashMap; +use std::time::Duration; + +use actix::{Actor, Addr, AsyncContext, Context, Handler, Message}; + +use crate::consts::INVITE_CODE_LENGTH; +use crate::data::GameRules; +use crate::game::Game; +use crate::human_player_ws::{CloseConnection, HumanPlayerWS, ServerMessage, SetGame}; +use crate::utils::rand_str; + +/// How often garbage collector is run +const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60); + +#[derive(Message)] +#[rtype(result = "()")] +pub struct CreateInvite(pub GameRules, pub Addr); + +#[derive(Message)] +#[rtype(result = "()")] +pub struct AcceptInvite(pub String, pub Addr); + +struct PendingPlayer { + player: Addr, + rules: GameRules, +} + +#[derive(Default)] +pub struct DispatcherActor { + with_invite: HashMap, +} + +impl Actor for DispatcherActor { + type Context = Context; + + fn started(&mut self, ctx: &mut Self::Context) { + ctx.run_interval(HEARTBEAT_INTERVAL, |act, _ctx| { + // Garbage collect invites + let ids = act + .with_invite + .iter() + .filter(|p| !p.1.player.connected()) + .map(|p| p.0.clone()) + .collect::>(); + + for id in ids { + log::debug!("Remove dead invite: {}", id); + act.with_invite.remove(&id); + } + }); + } +} + +impl Handler for DispatcherActor { + type Result = (); + + fn handle(&mut self, msg: CreateInvite, _ctx: &mut Self::Context) -> Self::Result { + let mut invite_code = rand_str(INVITE_CODE_LENGTH).to_uppercase(); + + while self.with_invite.contains_key(&invite_code) { + invite_code = rand_str(INVITE_CODE_LENGTH).to_uppercase(); + } + + log::debug!("Insert new invitation: {}", invite_code); + msg.1.do_send(ServerMessage::SetInviteCode { + code: invite_code.clone(), + }); + msg.1.do_send(ServerMessage::WaitingForAnotherPlayer); + self.with_invite.insert( + invite_code, + PendingPlayer { + player: msg.1, + rules: msg.0, + }, + ); + } +} + +impl Handler for DispatcherActor { + type Result = (); + + fn handle(&mut self, msg: AcceptInvite, _ctx: &mut Self::Context) -> Self::Result { + let entry = match self.with_invite.remove(&msg.0) { + None => { + msg.1.do_send(ServerMessage::InvalidInviteCode); + msg.1.do_send(CloseConnection); + return; + } + Some(e) => e, + }; + + let game = Game::new(entry.rules).start(); + entry.player.do_send(SetGame(game.clone())); + msg.1.do_send(SetGame(game)); + } +} diff --git a/sea_battle_backend/src/human_player_ws.rs b/sea_battle_backend/src/human_player_ws.rs index 94e6a40..18b126f 100644 --- a/sea_battle_backend/src/human_player_ws.rs +++ b/sea_battle_backend/src/human_player_ws.rs @@ -12,6 +12,7 @@ use crate::bots::linear_bot::LinearBot; use crate::bots::random_bot::RandomBot; use crate::bots::smart_bot::SmartBot; use crate::data::{BoatsLayout, BotType, Coordinates, CurrentGameStatus, FireResult, GameRules}; +use crate::dispatcher_actor::{AcceptInvite, CreateInvite, DispatcherActor}; use crate::game::{AddPlayer, Game}; use crate::human_player::HumanPlayer; @@ -20,13 +21,12 @@ const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10); /// How long before lack of client response causes a timeout const CLIENT_TIMEOUT: Duration = Duration::from_secs(120); -#[derive(Default, Debug)] +#[derive(Debug)] pub enum StartMode { Bot(GameRules), - - #[default] - RandomHuman, - //TODO : create invite + CreateInvite(GameRules), + AcceptInvite { code: String }, + // TODO : random human } #[derive(serde::Deserialize, serde::Serialize, Debug)] @@ -45,6 +45,10 @@ pub enum ClientMessage { #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] #[serde(tag = "type")] pub enum ServerMessage { + SetInviteCode { + code: String, + }, + InvalidInviteCode, WaitingForAnotherPlayer, SetOpponentName { name: String, @@ -85,23 +89,33 @@ pub enum ServerMessage { OpponentReplacedByBot, } +#[derive(Message)] +#[rtype(result = "()")] +pub struct SetGame(pub Addr); + +#[derive(Message)] +#[rtype(result = "()")] +pub struct CloseConnection; + pub struct HumanPlayerWS { inner: Option>, pub start_mode: StartMode, hb: Instant, -} - -impl Default for HumanPlayerWS { - fn default() -> Self { - Self { - inner: None, - start_mode: Default::default(), - hb: Instant::now(), - } - } + dispatcher: Addr, + name: String, } impl HumanPlayerWS { + pub fn new(start_mode: StartMode, dispatcher: &Addr, name: String) -> Self { + Self { + inner: None, + start_mode, + hb: Instant::now(), + dispatcher: dispatcher.clone(), + name, + } + } + /// helper method that sends ping to client every second. /// /// also this method checks heartbeats from client @@ -158,7 +172,7 @@ impl Actor for HumanPlayerWS { }; let player = Arc::new(HumanPlayer { - name: "Human".to_string(), + name: self.name.to_string(), game: game.clone(), player: ctx.address(), uuid: Uuid::new_v4(), @@ -167,8 +181,15 @@ impl Actor for HumanPlayerWS { game.do_send(AddPlayer(player)); } - StartMode::RandomHuman => { - unimplemented!(); + StartMode::CreateInvite(rules) => { + log::info!("Create new play invite"); + self.dispatcher + .do_send(CreateInvite(rules.clone(), ctx.address())); + } + StartMode::AcceptInvite { code } => { + log::info!("Accept play invite {}", code); + self.dispatcher + .do_send(AcceptInvite(code.clone(), ctx.address())); } } } @@ -223,3 +244,27 @@ impl Handler for HumanPlayerWS { ctx.text(serde_json::to_string(&msg).unwrap()); } } + +impl Handler for HumanPlayerWS { + type Result = (); + + fn handle(&mut self, msg: SetGame, ctx: &mut Self::Context) -> Self::Result { + let game = msg.0; + let player = Arc::new(HumanPlayer { + name: self.name.clone(), + game: game.clone(), + player: ctx.address(), + uuid: Uuid::new_v4(), + }); + self.inner = Some(player.clone()); + game.do_send(AddPlayer(player)); + } +} + +impl Handler for HumanPlayerWS { + type Result = (); + + fn handle(&mut self, _msg: CloseConnection, ctx: &mut Self::Context) -> Self::Result { + ctx.close(None) + } +} diff --git a/sea_battle_backend/src/lib.rs b/sea_battle_backend/src/lib.rs index 36a05f7..32a9c10 100644 --- a/sea_battle_backend/src/lib.rs +++ b/sea_battle_backend/src/lib.rs @@ -4,9 +4,11 @@ pub mod args; pub mod bots; pub mod consts; pub mod data; +pub mod dispatcher_actor; pub mod game; pub mod human_player; pub mod human_player_ws; pub mod server; #[cfg(test)] mod test; +pub mod utils; diff --git a/sea_battle_backend/src/server.rs b/sea_battle_backend/src/server.rs index 1048568..769e007 100644 --- a/sea_battle_backend/src/server.rs +++ b/sea_battle_backend/src/server.rs @@ -1,9 +1,11 @@ +use actix::{Actor, Addr}; use actix_cors::Cors; use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer, Responder}; use actix_web_actors::ws; use crate::args::Args; use crate::data::{GameRules, PlayConfiguration}; +use crate::dispatcher_actor::DispatcherActor; use crate::human_player_ws::{HumanPlayerWS, StartMode}; /// The default '/' route @@ -26,23 +28,78 @@ async fn start_bot_play( req: HttpRequest, stream: web::Payload, query: web::Query, + dispatcher: web::Data>, ) -> Result { let errors = query.0.get_errors(); if !errors.is_empty() { return Ok(HttpResponse::BadRequest().json(errors)); } - let mut player_ws = HumanPlayerWS::default(); - player_ws.start_mode = StartMode::Bot(query.0.clone()); + let player_ws = HumanPlayerWS::new( + StartMode::Bot(query.0.clone()), + &dispatcher, + "Human".to_string(), + ); let resp = ws::start(player_ws, &req, stream); log::info!("New bot play with configuration: {:?}", &query.0); resp } +/// Start game by creating invite +async fn start_create_invite( + req: HttpRequest, + stream: web::Payload, + query: web::Query, // TODO : add player name to query + dispatcher: web::Data>, +) -> Result { + let errors = query.0.get_errors(); + if !errors.is_empty() { + return Ok(HttpResponse::BadRequest().json(errors)); + } + + let player_ws = HumanPlayerWS::new( + StartMode::CreateInvite(query.0.clone()), + &dispatcher, + "Human".to_string(), + ); + + let resp = ws::start(player_ws, &req, stream); + log::info!("New create invite play with configuration: {:?}", &query.0); + resp +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct AcceptInviteQuery { + pub code: String, + // TODO : add player name to query +} + +/// Start game by creating invite +async fn start_accept_invite( + req: HttpRequest, + stream: web::Payload, + query: web::Query, + dispatcher: web::Data>, +) -> Result { + let player_ws = HumanPlayerWS::new( + StartMode::AcceptInvite { + code: query.code.clone(), + }, + &dispatcher, + "Human".to_string(), + ); + + let resp = ws::start(player_ws, &req, stream); + log::info!("New accept invite: {:?}", &query.0.code); + resp +} + pub async fn start_server(args: Args) -> std::io::Result<()> { let args_clone = args.clone(); + let dispatcher_actor = DispatcherActor::default().start(); + HttpServer::new(move || { let mut cors = Cors::default(); match args_clone.cors.as_deref() { @@ -52,10 +109,12 @@ pub async fn start_server(args: Args) -> std::io::Result<()> { } App::new() + .app_data(web::Data::new(dispatcher_actor.clone())) .wrap(cors) .route("/config", web::get().to(game_configuration)) .route("/play/bot", web::get().to(start_bot_play)) - // TODO : create & accept invite + .route("/play/create_invite", web::get().to(start_create_invite)) + .route("/play/accept_invite", web::get().to(start_accept_invite)) // TODO : join random .route("/", web::get().to(index)) .route("{tail:.*}", web::get().to(not_found)) diff --git a/sea_battle_backend/src/test/bot_client.rs b/sea_battle_backend/src/test/bot_client.rs index 7974da0..95b2936 100644 --- a/sea_battle_backend/src/test/bot_client.rs +++ b/sea_battle_backend/src/test/bot_client.rs @@ -6,6 +6,17 @@ use tokio_tungstenite::tungstenite::Message; use crate::data::{BoatsLayout, GameRules}; use crate::human_player_ws::{ClientMessage, ServerMessage}; +use crate::server::AcceptInviteQuery; + +#[derive(Default)] +pub enum RunMode { + #[default] + AgainstBot, + CreateInvite, + AcceptInvite { + code: String, + }, +} #[derive(Debug, Eq, PartialEq)] pub enum ClientEndResult { @@ -16,10 +27,12 @@ pub enum ClientEndResult { InvalidBoatsLayout, OpponentRejectedRematch, OpponentLeftGame, + InvalidInviteCode, } pub struct BotClient { server: String, + run_mode: RunMode, requested_rules: GameRules, layout: Option, number_plays: usize, @@ -33,6 +46,7 @@ impl BotClient { { Self { server: server.to_string(), + run_mode: RunMode::default(), requested_rules: GameRules::random_players_rules(), layout: None, number_plays: 1, @@ -40,6 +54,11 @@ impl BotClient { } } + pub fn with_run_mode(mut self, mode: RunMode) -> Self { + self.run_mode = mode; + self + } + pub fn with_rules(mut self, rules: GameRules) -> Self { self.requested_rules = rules; self @@ -68,11 +87,32 @@ impl BotClient { let mut number_victories = 0; let mut number_defeats = 0; - let url = format!( - "{}/play/bot?{}", - self.server.replace("http", "ws"), - serde_urlencoded::to_string(&self.requested_rules).unwrap() - ); + let url = match &self.run_mode { + RunMode::AgainstBot => { + format!( + "{}/play/bot?{}", + self.server.replace("http", "ws"), + serde_urlencoded::to_string(&self.requested_rules).unwrap() + ) + } + RunMode::CreateInvite => { + format!( + "{}/play/create_invite?{}", + self.server.replace("http", "ws"), + serde_urlencoded::to_string(&self.requested_rules).unwrap() + ) + } + RunMode::AcceptInvite { code } => { + format!( + "{}/play/accept_invite?{}", + self.server.replace("http", "ws"), + serde_urlencoded::to_string(&AcceptInviteQuery { + code: code.to_string() + }) + .unwrap() + ) + } + }; log::debug!("Connecting to {}...", url); let (mut socket, _) = match tokio_tungstenite::connect_async(url).await { Ok(s) => s, @@ -120,6 +160,13 @@ impl BotClient { ServerMessage::WaitingForAnotherPlayer => { log::debug!("Waiting for other player...") } + ServerMessage::SetInviteCode { code } => { + log::debug!("Got invite code: {}", code); + } + ServerMessage::InvalidInviteCode => { + log::debug!("Got invalid invite code!"); + return Ok(ClientEndResult::InvalidInviteCode); + } ServerMessage::QueryBoatsLayout { rules } => { assert_eq!(&rules, &self.requested_rules); log::debug!("Server requested boats layout"); diff --git a/sea_battle_backend/src/test/invite_mode.rs b/sea_battle_backend/src/test/invite_mode.rs new file mode 100644 index 0000000..ab053b7 --- /dev/null +++ b/sea_battle_backend/src/test/invite_mode.rs @@ -0,0 +1,84 @@ +use std::error::Error; + +use tokio::sync::mpsc; +use tokio::sync::mpsc::Sender; +use tokio::task; + +use crate::args::Args; +use crate::human_player_ws::ServerMessage; +use crate::server::start_server; +use crate::test::bot_client::{ClientEndResult, RunMode}; +use crate::test::network_utils::wait_for_port; +use crate::test::{bot_client, TestPort}; + +#[tokio::test] +async fn invalid_accept_code() { + let _ = env_logger::builder().is_test(true).try_init(); + + let local_set = task::LocalSet::new(); + local_set + .run_until(async move { + task::spawn_local(start_server(Args::for_test( + TestPort::InviteModeInvalidCode, + ))); + wait_for_port(TestPort::InviteModeInvalidCode.port()).await; + + let res = bot_client::BotClient::new(TestPort::InviteModeInvalidCode.as_url()) + .with_run_mode(RunMode::AcceptInvite { + code: "BadCode".to_string(), + }) + .run_client() + .await + .unwrap(); + + assert_eq!(res, ClientEndResult::InvalidInviteCode) + }) + .await; +} + +async fn run_other_invite_side( + sender: Sender>>, + port: TestPort, + code: String, +) { + let res = bot_client::BotClient::new(port.as_url()) + .with_run_mode(RunMode::AcceptInvite { code }) + .run_client() + .await; + + sender.send(res).await.unwrap() +} + +#[tokio::test] +async fn full_game() { + let _ = env_logger::builder().is_test(true).try_init(); + + let local_set = task::LocalSet::new(); + local_set + .run_until(async move { + task::spawn_local(start_server(Args::for_test(TestPort::InviteModeFullGame))); + wait_for_port(TestPort::InviteModeFullGame.port()).await; + + let (sender, mut receiver) = mpsc::channel(1); + + let res = bot_client::BotClient::new(TestPort::InviteModeFullGame.as_url()) + .with_run_mode(RunMode::CreateInvite) + .with_server_msg_callback(move |msg| { + if let ServerMessage::SetInviteCode { code } = msg { + task::spawn_local(run_other_invite_side( + sender.clone(), + TestPort::InviteModeFullGame, + code.clone(), + )); + } + }) + .run_client() + .await + .unwrap(); + + assert!(matches!(res, ClientEndResult::Finished { .. })); + let other_side_res = receiver.recv().await.unwrap().unwrap(); + assert!(matches!(other_side_res, ClientEndResult::Finished { .. })); + }) + .await; +} diff --git a/sea_battle_backend/src/test/mod.rs b/sea_battle_backend/src/test/mod.rs index 7ea11b2..34691da 100644 --- a/sea_battle_backend/src/test/mod.rs +++ b/sea_battle_backend/src/test/mod.rs @@ -13,6 +13,8 @@ enum TestPort { LinearBotFullGame, LinearBotNoReplayOnHit, IntermediateBotFullGame, + InviteModeInvalidCode, + InviteModeFullGame, } impl TestPort { @@ -40,5 +42,6 @@ mod bot_client_bot_intermediate_play; mod bot_client_bot_linear_play; mod bot_client_bot_random_play; mod bot_client_bot_smart_play; +mod invite_mode; mod network_utils; mod play_utils; diff --git a/sea_battle_backend/src/utils.rs b/sea_battle_backend/src/utils.rs new file mode 100644 index 0000000..831f3be --- /dev/null +++ b/sea_battle_backend/src/utils.rs @@ -0,0 +1,19 @@ +use rand::distributions::Alphanumeric; +use rand::{thread_rng, Rng}; + +/// Generate a random string of a given size +/// +/// ``` +/// use sea_battle_backend::utils::rand_str; +/// +/// let size = 10; +/// let rand = rand_str(size); +/// assert_eq!(size, rand.len()); +/// ``` +pub fn rand_str(len: usize) -> String { + thread_rng() + .sample_iter(&Alphanumeric) + .map(char::from) + .take(len) + .collect() +}