diff --git a/front_end/Cargo.lock b/front_end/Cargo.lock index 86e6f6e..d625db5 100644 --- a/front_end/Cargo.lock +++ b/front_end/Cargo.lock @@ -43,6 +43,7 @@ dependencies = [ "js-sys", "log", "oorandom", + "serde_json", "wasm-bindgen", "wasm-bindgen-futures", "wasm-logger", diff --git a/front_end/Cargo.toml b/front_end/Cargo.toml index b75322d..cff3e69 100644 --- a/front_end/Cargo.toml +++ b/front_end/Cargo.toml @@ -9,8 +9,9 @@ edition = "2021" yew = "0.19" log = "0.4.6" wasm-logger = "0.2.0" -web-sys = { version = "0.3.56", features = ["Window", "Document", "Element"] } +web-sys = { version = "0.3.56", features = ["Window", "Document", "Element", "Request", "RequestInit"] } js-sys = "0.3.56" oorandom = "11.1.3" wasm-bindgen = "0.2.79" wasm-bindgen-futures = "0.4.29" +serde_json = "1.0" diff --git a/front_end/src/constants.rs b/front_end/src/constants.rs index ffcce75..7b63ae2 100644 --- a/front_end/src/constants.rs +++ b/front_end/src/constants.rs @@ -5,8 +5,14 @@ pub const INFO_TEXT_MAX_ITEMS: u32 = 100; pub const AI_EASY_MAX_CHOICES: usize = 5; pub const AI_NORMAL_MAX_CHOICES: usize = 3; +pub const AI_CHOICE_DURATION_MILLIS: i32 = 1000; pub const PLAYER_COUNT_LIMIT: usize = 1000; pub const TURN_SECONDS: u64 = 25; pub const GAME_CLEANUP_TIMEOUT: u64 = (TURN_SECONDS + 1) * ((ROWS * COLS) as u64 + 5u64); pub const PLAYER_CLEANUP_TIMEOUT: u64 = 300; + +pub const BACKEND_TICK_DURATION_MILLIS: i32 = 500; + +// TODO: Change this to "https://asdm.seodisparate.com/api" when backend is installed +pub const BACKEND_URL: &str = "http://localhost:1237/"; diff --git a/front_end/src/html_helper.rs b/front_end/src/html_helper.rs index 7dd1476..4bb3368 100644 --- a/front_end/src/html_helper.rs +++ b/front_end/src/html_helper.rs @@ -1,4 +1,5 @@ -use web_sys::{window, Document, Window}; +use wasm_bindgen::JsValue; +use web_sys::{window, Document, Request, RequestInit, Window}; pub fn get_window_document() -> Result<(Window, Document), String> { let window = window().ok_or_else(|| String::from("Failed to get window"))?; @@ -77,3 +78,14 @@ pub fn element_remove_class(document: &Document, id: &str, class: &str) -> Resul Ok(()) } + +pub fn create_json_request(target_url: &str, json_body: &str) -> Result { + let mut req_init: RequestInit = RequestInit::new(); + req_init.body(Some(&JsValue::from_str(json_body))); + req_init.headers( + &JsValue::from_serde("'headers': { 'Content-Type': 'application/json' }") + .map_err(|e| format!("{}", e))?, + ); + + Ok(Request::new_with_str_and_init(target_url, &req_init).map_err(|e| format!("{:?}", e))?) +} diff --git a/front_end/src/state.rs b/front_end/src/state.rs index acaa2fb..2ceb3f9 100644 --- a/front_end/src/state.rs +++ b/front_end/src/state.rs @@ -12,10 +12,27 @@ pub enum GameState { MainMenu, SinglePlayer(Turn, AIDifficulty), LocalMultiplayer, - NetworkedMultiplayer(Turn), + NetworkedMultiplayer { + paired: bool, + current_side: Option, + current_turn: Turn, + }, PostGameResults(BoardState), } +impl GameState { + pub fn is_networked_multiplayer(self) -> bool { + matches!( + self, + GameState::NetworkedMultiplayer { + paired, + current_side, + current_turn + } + ) + } +} + impl Default for GameState { fn default() -> Self { GameState::MainMenu @@ -27,7 +44,11 @@ impl From for GameState { match msg { MainMenuMessage::SinglePlayer(t, ai) => GameState::SinglePlayer(t, ai), MainMenuMessage::LocalMultiplayer => GameState::LocalMultiplayer, - MainMenuMessage::NetworkedMultiplayer(t) => GameState::NetworkedMultiplayer(t), + MainMenuMessage::NetworkedMultiplayer => GameState::NetworkedMultiplayer { + paired: false, + current_side: None, + current_turn: Turn::CyanPlayer, + }, } } } @@ -298,7 +319,7 @@ impl Default for SharedState { pub enum MainMenuMessage { SinglePlayer(Turn, AIDifficulty), LocalMultiplayer, - NetworkedMultiplayer(Turn), + NetworkedMultiplayer, } pub fn new_string_board() -> String { @@ -402,3 +423,27 @@ pub fn string_from_board(board: BoardType, placed: usize) -> (String, Option {"Please pick a game mode."} @@ -77,7 +88,7 @@ impl Component for MainMenu { - @@ -110,31 +121,42 @@ impl Component for MainMenu { .get_element_by_id("info_text1") .expect("info_text1 should exist"); - if let GameState::SinglePlayer(turn, _) = shared.game_state.get() { - if shared.turn.get() == turn { - info_text_turn.set_inner_html( - "

It is CyanPlayer's (player) Turn

", - ); - } else { - info_text_turn.set_inner_html( - "

It is CyanPlayer's (ai) Turn

", - ); + match shared.game_state.get() { + GameState::SinglePlayer(turn, _) => { + if shared.turn.get() == turn { + info_text_turn.set_inner_html( + "

It is CyanPlayer's (player) Turn

", + ); + } else { + info_text_turn.set_inner_html( + "

It is CyanPlayer's (ai) Turn

", + ); + // AI player starts first + ctx.link() + .get_parent() + .expect("Wrapper should be parent of MainMenu") + .clone() + .downcast::() + .send_message(WrapperMsg::AIChoice); + } + } + GameState::NetworkedMultiplayer { + paired: _, + current_side: _, + current_turn: _, + } => { + // start the Wrapper Tick loop + ctx.link() + .get_parent() + .expect("Wrapper should be a parent of MainMenu") + .clone() + .downcast::() + .send_message(WrapperMsg::BackendTick); + } + _ => { + info_text_turn + .set_inner_html("

It is CyanPlayer's Turn

"); } - } else { - info_text_turn - .set_inner_html("

It is CyanPlayer's Turn

"); - } - - if let GameState::SinglePlayer(Turn::MagentaPlayer, _ai_difficulty) = - shared.game_state.get() - { - // AI player starts first - ctx.link() - .get_parent() - .expect("Wrapper should be parent of MainMenu") - .clone() - .downcast::() - .send_message(WrapperMsg::AIChoice); } } @@ -190,9 +212,21 @@ impl Component for Slot { match shared.game_state.get() { GameState::MainMenu => return false, - GameState::SinglePlayer(_, _) - | GameState::LocalMultiplayer - | GameState::NetworkedMultiplayer(_) => (), + GameState::SinglePlayer(_, _) | GameState::LocalMultiplayer => (), + GameState::NetworkedMultiplayer { + paired, + current_side, + current_turn, + } => { + // notify Wrapper with picked slot + if let Some(p) = ctx.link().get_parent() { + p.clone() + .downcast::() + .send_message(WrapperMsg::BackendRequest { + place: ctx.props().idx, + }); + } + } GameState::PostGameResults(_) => return false, } if shared.game_state.get() == GameState::MainMenu { @@ -213,14 +247,166 @@ impl Component for Slot { } } -pub struct Wrapper {} +pub struct Wrapper { + player_id: Option, + place_request: Option, + do_backend_tick: bool, +} -#[derive(Copy, Clone, Debug, PartialEq, Eq)] +impl Wrapper { + fn defer_message( + &self, + ctx: &Context, + msg: ::Message, + millis: i32, + ) { + ctx.link().send_future(async move { + let promise = Promise::new(&mut |resolve: js_sys::Function, _reject| { + let window = web_sys::window(); + if window.is_none() { + resolve.call0(&resolve).ok(); + return; + } + let window = window.unwrap(); + if window + .set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, millis) + .is_err() + { + resolve.call0(&resolve).ok(); + } + }); + let js_fut = JsFuture::from(promise); + js_fut.await.ok(); + msg + }); + } + + fn get_networked_player_id(&mut self, ctx: &Context) { + // make a request to get the player_id + ctx.link().send_future(async { + // get window + let window = web_sys::window().expect("Should be able to get Window"); + // get request + let request = create_json_request(BACKEND_URL, "'type': 'pairing_request'") + .expect("Should be able to create the JSON request for player_id"); + // send request + let promise = window.fetch_with_request(&request); + // get request result + let response_result = JsFuture::from(promise) + .await + .map_err(|e| format!("{:?}", e)); + if let Err(e) = response_result { + return WrapperMsg::BackendResponse(BREnum::Error(format!("ERROR: {:?}", e))); + } + // get response from request result + let response = response_result.unwrap(); + let json_value_result: Result = response.into_serde(); + if let Err(e) = json_value_result { + return WrapperMsg::BackendResponse(BREnum::Error(format!("ERROR: {:?}", e))); + } + // get serde json Value from result + let json_value = json_value_result.unwrap(); + + // get and check "type" in JSON + let type_opt = json_value.get("type"); + if type_opt.is_none() { + return WrapperMsg::BackendResponse(BREnum::Error( + "ERROR: No \"type\" entry in JSON".into(), + )); + } + let json_type = type_opt.unwrap(); + if let Some(type_string) = json_type.as_str() { + if type_string != "pairing_response" { + return WrapperMsg::BackendResponse(BREnum::Error( + "ERROR: Invalid \"type\" from response JSON".into(), + )); + } + } else { + return WrapperMsg::BackendResponse(BREnum::Error( + "ERROR: Missing \"type\" from response JSON".into(), + )); + } + + // get and check "id" in JSON + let player_id: u32; + if let Some(wrapped_player_id) = json_value.get("id") { + if let Some(player_id_u64) = wrapped_player_id.as_u64() { + let player_id_conv_result: Result = player_id_u64.try_into(); + if player_id_conv_result.is_err() { + return WrapperMsg::BackendResponse(BREnum::Error( + "ERROR: \"id\" is too large".into(), + )); + } + player_id = player_id_conv_result.unwrap(); + } else { + return WrapperMsg::BackendResponse(BREnum::Error( + "ERROR: \"id\" is not a u64".into(), + )); + } + } else { + return WrapperMsg::BackendResponse(BREnum::Error( + "ERROR: Missing \"id\" from response JSON".into(), + )); + } + + // get and check status + #[derive(Copy, Clone, Debug, PartialEq, Eq)] + enum Status { + Waiting, + Paired, + } + let mut status: Status; + if let Some(status_value) = json_value.get("status") { + if let Some(status_str) = status_value.as_str() { + if status_str == "waiting" { + status = Status::Waiting; + } else if status_str == "paired" { + status = Status::Paired; + } else { + return WrapperMsg::BackendResponse(BREnum::Error( + "ERROR: Got invalid \"status\" response in JSON".into(), + )); + } + } else { + return WrapperMsg::BackendResponse(BREnum::Error( + "ERROR: \"status\" response in JSON is not a str".into(), + )); + } + } else { + return WrapperMsg::BackendResponse(BREnum::Error( + "ERROR: \"status\" response is missing in JSON".into(), + )); + } + + // TODO set "disconnect" callback here so that the client sends + // disconnect message when the page is closed + + if status == Status::Paired { + // Get which side current player is on if paired + // TODO + return WrapperMsg::BackendResponse(BREnum::Error("ERROR: unimplemented".into())); + } else { + WrapperMsg::BackendResponse(BREnum::GotID(player_id, None)) + } + }); + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BREnum { + Error(String), + GotID(u32, Option), +} + +#[derive(Clone, Debug, PartialEq, Eq)] pub enum WrapperMsg { Pressed(u8), AIPressed(u8), AIChoice, AIChoiceImpl, + BackendTick, + BackendRequest { place: u8 }, + BackendResponse(BREnum), } impl WrapperMsg { @@ -234,7 +420,11 @@ impl Component for Wrapper { type Properties = (); fn create(_ctx: &Context) -> Self { - Self {} + Self { + player_id: None, + place_request: None, + do_backend_tick: true, + } } fn view(&self, ctx: &Context) -> Html { @@ -335,10 +525,12 @@ impl Component for Wrapper { } } GameState::LocalMultiplayer => (), - GameState::NetworkedMultiplayer(turn) => { - if current_player != turn { - return false; - } + GameState::NetworkedMultiplayer { + paired, + current_side, + current_turn, + } => { + // TODO } GameState::PostGameResults(_) => (), } @@ -786,25 +978,7 @@ impl Component for Wrapper { } // WrapperMsg::Pressed(idx) => WrapperMsg::AIChoice => { // defer by 1 second - ctx.link().send_future(async { - let promise = Promise::new(&mut |resolve: js_sys::Function, _reject| { - let window = web_sys::window(); - if window.is_none() { - resolve.call0(&resolve).ok(); - return; - } - let window = window.unwrap(); - if window - .set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, 1000) - .is_err() - { - resolve.call0(&resolve).ok(); - } - }); - let js_fut = JsFuture::from(promise); - js_fut.await.ok(); - WrapperMsg::AIChoiceImpl - }); + self.defer_message(ctx, WrapperMsg::AIChoiceImpl, AI_CHOICE_DURATION_MILLIS); } WrapperMsg::AIChoiceImpl => { // get AI's choice @@ -819,6 +993,20 @@ impl Component for Wrapper { } } } + WrapperMsg::BackendTick => { + if self.player_id.is_none() { + self.get_networked_player_id(ctx); + } + + // repeat BackendTick handling while "connected" to backend + if self.do_backend_tick { + self.defer_message(ctx, WrapperMsg::BackendTick, BACKEND_TICK_DURATION_MILLIS); + } + } + WrapperMsg::BackendRequest { place } => { + self.place_request = Some(place); + } + WrapperMsg::BackendResponse(string) => {} } // match (msg) true