diff --git a/back_end/src/json_handlers.rs b/back_end/src/json_handlers.rs index badeb72..5c56027 100644 --- a/back_end/src/json_handlers.rs +++ b/back_end/src/json_handlers.rs @@ -71,21 +71,21 @@ fn handle_check_pairing(root: Value, tx: SyncSender) -> Result }) .is_err() { - return Err("{\"type\":\"pairing_response\", \"status\":\"internal_error\"}".into()); + return Err("{\"type\":\"pairing_status\", \"status\":\"internal_error\"}".into()); } if let Ok((exists, is_paired, is_cyan)) = request_rx.recv_timeout(DB_REQUEST_TIMEOUT) { if !exists { - Err("{\"type\":\"pairing_response\", \"status\":\"unknown_id\"}".into()) + Err("{\"type\":\"pairing_status\", \"status\":\"unknown_id\"}".into()) } else if is_paired { Ok(format!( - "{{\"type\":\"pairing_response\", \"status\":\"paired\", \"color\":\"{}\"}}", + "{{\"type\":\"pairing_status\", \"status\":\"paired\", \"color\":\"{}\"}}", if is_cyan { "cyan" } else { "magenta" } )) } else { - Ok("{\"type\"\"pairing_response\", \"status\":\"waiting\"}".into()) + Ok("{\"type\":\"pairing_status\", \"status\":\"waiting\"}".into()) } } else { - Err("{\"type\":\"pairing_response\", \"status\":\"internal_error_timeout\"}".into()) + Err("{\"type\":\"pairing_status\", \"status\":\"internal_error_timeout\"}".into()) } } diff --git a/backend_protocol_specification.md b/backend_protocol_specification.md index 88c0a06..7118bdc 100644 --- a/backend_protocol_specification.md +++ b/backend_protocol_specification.md @@ -85,14 +85,14 @@ then the back-end will respond with "too\_many\_players". ``` { - "type": "pairing_response", + "type": "pairing_status", "status": "waiting", // or "unknown_id" } ``` ``` { - "type": "pairing_response", + "type": "pairing_status", "status": "paired", "color": "magenta", // or "cyan" } diff --git a/front_end/src/html_helper.rs b/front_end/src/html_helper.rs index 3d61b27..8da238e 100644 --- a/front_end/src/html_helper.rs +++ b/front_end/src/html_helper.rs @@ -84,6 +84,15 @@ pub fn element_remove_class(document: &Document, id: &str, class: &str) -> Resul Ok(()) } +pub fn element_has_class(document: &Document, id: &str, class: &str) -> Result { + let element = document + .get_element_by_id(id) + .ok_or_else(|| format!("Failed to get element with id \"{}\"", id))?; + let element_class: String = element.class_name(); + + Ok(element_class.contains(class)) +} + 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))); @@ -117,9 +126,15 @@ pub async fn send_to_backend(entries: HashMap) -> Result Option { + if let GameState::NetworkedMultiplayer { + paired, + current_side, + current_turn, + } = *self + { + current_side + } else { + None + } + } + + pub fn set_networked_current_side(&mut self, side: Option) { + if let GameState::NetworkedMultiplayer { + paired, + ref mut current_side, + current_turn, + } = self + { + *current_side = side; + } + } + + pub fn get_current_turn(&self) -> Turn { + if let GameState::SinglePlayer(turn, _) = *self { + turn + } else if let GameState::NetworkedMultiplayer { + paired: _, + current_side: _, + current_turn, + } = *self + { + current_turn + } else { + Turn::CyanPlayer + } + } + + pub fn set_networked_current_turn(&mut self, turn: Turn) { + if let GameState::NetworkedMultiplayer { + paired: _, + current_side: _, + ref mut current_turn, + } = self + { + *current_turn = turn; + } + } } impl Default for GameState { @@ -338,8 +399,10 @@ pub fn board_from_string(board_string: String) -> BoardType { for (idx, c) in board_string.chars().enumerate() { match c { 'a' => board[idx].replace(BoardState::Empty), - 'b' | 'd' | 'f' => board[idx].replace(BoardState::Cyan), - 'c' | 'e' | 'g' => board[idx].replace(BoardState::Magenta), + 'b' | 'f' => board[idx].replace(BoardState::Cyan), + 'd' => board[idx].replace(BoardState::CyanWin), + 'c' | 'g' => board[idx].replace(BoardState::Magenta), + 'e' => board[idx].replace(BoardState::MagentaWin), _ => BoardState::Empty, }; } @@ -434,6 +497,48 @@ pub struct PairingRequestResponse { pub color: Option, } +#[derive(Debug, Serialize, Deserialize)] +pub struct PairingStatusResponse { + pub r#type: String, + pub status: String, + pub color: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GameStateResponse { + pub r#type: String, + pub status: String, + pub board: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PlaceTokenResponse { + pub r#type: String, + pub status: String, + pub board: String, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum NetworkedGameState { + CyanTurn, + MagentaTurn, + CyanWon, + MagentaWon, + Draw, + Disconnected, + InternalError, + NotPaired, + UnknownID, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum PlacedEnum { + Accepted, + Illegal, + NotYourTurn, + Other(NetworkedGameState), +} + #[cfg(test)] mod tests { use super::*; diff --git a/front_end/src/yew_components.rs b/front_end/src/yew_components.rs index 582822b..ac5a7f7 100644 --- a/front_end/src/yew_components.rs +++ b/front_end/src/yew_components.rs @@ -5,12 +5,14 @@ use crate::constants::{ }; use crate::game_logic::{check_win_draw, WinType}; use crate::html_helper::{ - append_to_info_text, create_json_request, element_append_class, element_remove_class, - get_window_document, send_to_backend, + append_to_info_text, create_json_request, element_append_class, element_has_class, + element_remove_class, get_window_document, send_to_backend, }; use crate::random_helper::get_seeded_random; use crate::state::{ - BoardState, GameState, MainMenuMessage, PairingRequestResponse, SharedState, Turn, + board_from_string, BoardState, BoardType, GameState, GameStateResponse, MainMenuMessage, + NetworkedGameState, PairingRequestResponse, PairingStatusResponse, PlaceTokenResponse, + PlacedEnum, SharedState, Turn, }; use std::cell::Cell; @@ -19,7 +21,7 @@ use std::rc::Rc; use js_sys::{Function, Promise}; use wasm_bindgen::JsCast; -use web_sys::Response; +use web_sys::{Document, Response}; use serde_json::Value as SerdeJSONValue; @@ -121,20 +123,24 @@ impl Component for MainMenu { mainmenu.set_class_name("hidden_menu"); mainmenu.set_inner_html(""); - let info_text_turn = document - .get_element_by_id("info_text1") - .expect("info_text1 should exist"); - 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

", - ); + append_to_info_text( + &document, + "info_text1", + "It is CyanPlayer's (player) Turn", + 1, + ) + .ok(); } else { - info_text_turn.set_inner_html( - "

It is CyanPlayer's (ai) Turn

", - ); + append_to_info_text( + &document, + "info_text1", + "It is CyanPlayer's (ai) Turn", + 1, + ) + .ok(); // AI player starts first ctx.link() .get_parent() @@ -149,6 +155,13 @@ impl Component for MainMenu { current_side: _, current_turn: _, } => { + append_to_info_text( + &document, + "info_text1", + "Waiting to pair with another player...", + 1, + ) + .ok(); // start the Wrapper Tick loop ctx.link() .get_parent() @@ -158,8 +171,13 @@ impl Component for MainMenu { .send_message(WrapperMsg::BackendTick); } _ => { - info_text_turn - .set_inner_html("

It is CyanPlayer's Turn

"); + append_to_info_text( + &document, + "info_text1", + "It is CyanPlayer's Turn", + 1, + ) + .ok(); } } } @@ -222,13 +240,18 @@ impl Component for Slot { 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, - }); + if paired && current_side.is_some() { + if current_side.as_ref().unwrap() == ¤t_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, + }, + ); + return false; + } + } } } GameState::PostGameResults(_) => return false, @@ -336,12 +359,215 @@ impl Wrapper { } }); } + + fn get_networked_player_type(&mut self, ctx: &Context) { + // make a request to get the pairing status + if self.player_id.is_none() { + log::warn!("Cannot request pairing status if ID is unknown"); + return; + } + let player_id: u32 = self.player_id.unwrap(); + ctx.link().send_future(async move { + let mut json_entries = HashMap::new(); + json_entries.insert("type".into(), "check_pairing".into()); + json_entries.insert("id".into(), format!("{}", player_id)); + + let send_to_backend_result = send_to_backend(json_entries).await; + if let Err(e) = send_to_backend_result { + return WrapperMsg::BackendResponse(BREnum::Error(format!("{:?}", e))); + } + + let request_result: Result = + serde_json::from_str(&send_to_backend_result.unwrap()); + if let Err(e) = request_result { + return WrapperMsg::BackendResponse(BREnum::Error(format!("{:?}", e))); + } + let response = request_result.unwrap(); + + if response.r#type != "pairing_status" { + return WrapperMsg::BackendResponse(BREnum::Error( + "Invalid response type when check_pairing".into(), + )); + } + + if response.status == "paired" && response.color.is_some() { + WrapperMsg::BackendResponse(BREnum::GotPairing(response.color.map(|string| { + if string == "cyan" { + Turn::CyanPlayer + } else { + Turn::MagentaPlayer + } + }))) + } else { + WrapperMsg::BackendResponse(BREnum::Error("Not paired".into())) + } + }); + } + + fn get_game_status(&mut self, ctx: &Context) { + if self.player_id.is_none() { + log::warn!("Cannot request pairing status if ID is unknown"); + return; + } + let player_id: u32 = self.player_id.unwrap(); + ctx.link().send_future(async move { + let mut json_entries = HashMap::new(); + json_entries.insert("id".into(), format!("{}", player_id)); + json_entries.insert("type".into(), "game_state".into()); + + let send_to_backend_result = send_to_backend(json_entries).await; + if let Err(e) = send_to_backend_result { + return WrapperMsg::BackendResponse(BREnum::Error(format!("{:?}", e))); + } + + let response_result: Result = + serde_json::from_str(&send_to_backend_result.unwrap()); + if let Err(e) = response_result { + return WrapperMsg::BackendResponse(BREnum::Error(format!("{:?}", e))); + } + let response = response_result.unwrap(); + + if response.r#type != "game_state" { + return WrapperMsg::BackendResponse(BREnum::Error( + "Invalid state when checking game_state".into(), + )); + } + + let networked_game_state = match response.status.as_str() { + "not_paired" => NetworkedGameState::NotPaired, + "unknown_id" => NetworkedGameState::UnknownID, + "cyan_turn" => NetworkedGameState::CyanTurn, + "magenta_turn" => NetworkedGameState::MagentaTurn, + "cyan_won" => NetworkedGameState::CyanWon, + "magenta_won" => NetworkedGameState::MagentaWon, + "draw" => NetworkedGameState::Draw, + "opponent_disconnected" => NetworkedGameState::Disconnected, + _ => NetworkedGameState::InternalError, + }; + + WrapperMsg::BackendResponse(BREnum::GotStatus(networked_game_state, response.board)) + }); + } + + fn send_place_request(&mut self, ctx: &Context, placement: u8) { + if self.player_id.is_none() { + log::warn!("Cannot request pairing status if ID is unknown"); + return; + } + let player_id: u32 = self.player_id.unwrap(); + ctx.link().send_future(async move { + let mut json_entries = HashMap::new(); + json_entries.insert("id".into(), format!("{}", player_id)); + json_entries.insert("position".into(), format!("{}", placement)); + json_entries.insert("type".into(), "place_token".into()); + + let send_to_backend_result = send_to_backend(json_entries).await; + if let Err(e) = send_to_backend_result { + return WrapperMsg::BackendResponse(BREnum::Error(format!("{:?}", e))); + } + + let response_result: Result = + serde_json::from_str(&send_to_backend_result.unwrap()); + if let Err(e) = response_result { + return WrapperMsg::BackendResponse(BREnum::Error(format!("{:?}", e))); + } + let response = response_result.unwrap(); + + if response.r#type != "place_token" { + return WrapperMsg::BackendResponse(BREnum::Error( + "Invalid state when place_token".into(), + )); + } + + let placed_enum = match response.status.as_str() { + "accepted" => PlacedEnum::Accepted, + "illegal" => PlacedEnum::Illegal, + "not_your_turn" => PlacedEnum::NotYourTurn, + "game_ended_draw" => PlacedEnum::Other(NetworkedGameState::Draw), + "game_ended_cyan_won" => PlacedEnum::Other(NetworkedGameState::CyanWon), + "game_ended_magenta_won" => PlacedEnum::Other(NetworkedGameState::MagentaWon), + "unknown_id" => PlacedEnum::Other(NetworkedGameState::UnknownID), + "not_paired_yet" => PlacedEnum::Other(NetworkedGameState::NotPaired), + _ => PlacedEnum::Other(NetworkedGameState::InternalError), + }; + + WrapperMsg::BackendResponse(BREnum::GotPlaced(placed_enum, response.board)) + }); + } + + fn update_board_from_string( + &mut self, + shared: &SharedState, + document: &Document, + board_string: String, + ) { + let board = board_from_string(board_string.clone()); + for (idx, slot) in board.iter().enumerate() { + let was_open = + element_has_class(&document, &format!("slot{}", idx), "open").unwrap_or(false); + element_remove_class(&document, &format!("slot{}", idx), "open").ok(); + element_remove_class(&document, &format!("slot{}", idx), "placed").ok(); + element_remove_class(&document, &format!("slot{}", idx), "win").ok(); + element_remove_class(&document, &format!("slot{}", idx), "cyan").ok(); + element_remove_class(&document, &format!("slot{}", idx), "magenta").ok(); + match slot.get() { + BoardState::Empty => { + element_append_class(&document, &format!("slot{}", idx), "open").ok(); + } + BoardState::Cyan => { + element_append_class(&document, &format!("slot{}", idx), "cyan").ok(); + } + BoardState::CyanWin => { + element_append_class(&document, &format!("slot{}", idx), "cyan").ok(); + element_append_class(&document, &format!("slot{}", idx), "win").ok(); + } + BoardState::Magenta => { + element_append_class(&document, &format!("slot{}", idx), "magenta").ok(); + } + BoardState::MagentaWin => { + element_append_class(&document, &format!("slot{}", idx), "magenta").ok(); + element_append_class(&document, &format!("slot{}", idx), "win").ok(); + } + } + let char_at_idx = board_string + .chars() + .nth(idx) + .expect("idx into board_string should be in range"); + if char_at_idx == 'f' { + element_append_class(&document, &format!("slot{}", idx), "placed").ok(); + if was_open { + append_to_info_text( + &document, + "info_text0", + &format!("CyanPlayer placed at {}", idx), + INFO_TEXT_MAX_ITEMS, + ) + .ok(); + } + } else if char_at_idx == 'g' { + element_append_class(&document, &format!("slot{}", idx), "placed").ok(); + if was_open { + append_to_info_text( + &document, + "info_text0", + &format!("MagentaPlayer placed at {}", idx), + INFO_TEXT_MAX_ITEMS, + ) + .ok(); + } + } + shared.board[idx].set(slot.get()); + } + } } #[derive(Clone, Debug, PartialEq, Eq)] pub enum BREnum { Error(String), GotID(u32, Option), + GotPairing(Option), + GotStatus(NetworkedGameState, Option), + GotPlaced(PlacedEnum, String), } #[derive(Clone, Debug, PartialEq, Eq)] @@ -476,7 +702,21 @@ impl Component for Wrapper { current_side, current_turn, } => { - // TODO + log::warn!( + "paired is {}, current_side is {:?}, current_turn is {:?}", + paired, + current_side, + current_turn + ); + if paired { + if let Some(current_side) = current_side { + if current_side == current_turn { + self.place_request.replace(idx); + } + } + } + log::warn!("Set place request to {:?}", self.place_request); + return true; } GameState::PostGameResults(_) => (), } @@ -552,7 +792,7 @@ impl Component for Wrapper { } } else { format!( - "It is {}'s turn", + "It is {}'s Turn", turn.get_color(), turn ) @@ -585,8 +825,11 @@ impl Component for Wrapper { } else { // a player won let turn = Turn::from(endgame_state); - let text_string = - format!("{} has won", turn.get_color(), turn); + let text_string = format!( + "{} has won the game", + turn.get_color(), + turn + ); let text_append_result = append_to_info_text( &document, "info_text0", @@ -942,6 +1185,23 @@ impl Component for Wrapper { WrapperMsg::BackendTick => { if self.player_id.is_none() { self.get_networked_player_id(ctx); + } else if shared + .game_state + .get() + .get_networked_current_side() + .is_none() + { + self.get_networked_player_type(ctx); + } else if !matches!(shared.game_state.get(), GameState::PostGameResults(_)) { + if self.place_request.is_some() { + let placement = self.place_request.take().unwrap(); + self.send_place_request(ctx, placement); + } else { + self.get_game_status(ctx); + } + } else { + self.do_backend_tick = false; + log::warn!("Ended backend tick"); } // repeat BackendTick handling while "connected" to backend @@ -954,11 +1214,284 @@ impl Component for Wrapper { } WrapperMsg::BackendResponse(br_enum) => match br_enum { BREnum::Error(string) => { + // TODO maybe suppress this for release builds log::warn!("{}", string); } BREnum::GotID(id, turn_opt) => { self.player_id = Some(id); - log::warn!("Got player id {}", id); + let mut game_state = shared.game_state.get(); + game_state.set_networked_paired(); + game_state.set_networked_current_side(turn_opt); + shared.game_state.set(game_state); + if let Some(turn_type) = turn_opt { + append_to_info_text( + &document, + "info_text0", + &format!( + "Paired with player, you are the {}", + turn_type.get_color(), + turn_type + ), + INFO_TEXT_MAX_ITEMS, + ) + .ok(); + append_to_info_text( + &document, + "info_text1", + "It is CyanPlayer's Turn", + 1, + ) + .ok(); + } + } + BREnum::GotPairing(turn_opt) => { + let mut game_state = shared.game_state.get(); + game_state.set_networked_current_side(turn_opt); + shared.game_state.set(game_state); + if let Some(turn_type) = turn_opt { + append_to_info_text( + &document, + "info_text0", + &format!( + "Paired with player, you are the {}", + turn_type.get_color(), + turn_type + ), + INFO_TEXT_MAX_ITEMS, + ) + .ok(); + append_to_info_text( + &document, + "info_text1", + "It is CyanPlayer's Turn", + 1, + ) + .ok(); + } + } + BREnum::GotStatus(networked_game_state, board_opt) => { + if let Some(board_string) = board_opt { + self.update_board_from_string(&shared, &document, board_string); + } + + let mut current_game_state = shared.game_state.get(); + match networked_game_state { + NetworkedGameState::CyanTurn => { + if current_game_state.get_current_turn() != Turn::CyanPlayer { + current_game_state.set_networked_current_turn(Turn::CyanPlayer); + shared.game_state.set(current_game_state); + append_to_info_text( + &document, + "info_text1", + "It is CyanPlayer's Turn", + 1, + ) + .ok(); + } + } + NetworkedGameState::MagentaTurn => { + if current_game_state.get_current_turn() != Turn::MagentaPlayer { + current_game_state.set_networked_current_turn(Turn::MagentaPlayer); + shared.game_state.set(current_game_state); + append_to_info_text( + &document, + "info_text1", + "It is MagentaPlayer's Turn", + 1, + ) + .ok(); + } + } + NetworkedGameState::CyanWon => { + append_to_info_text( + &document, + "info_text1", + "CyanPlayer won the game", + 1, + ) + .ok(); + shared + .game_state + .set(GameState::PostGameResults(BoardState::CyanWin)); + self.do_backend_tick = false; + } + NetworkedGameState::MagentaWon => { + append_to_info_text( + &document, + "info_text1", + "MagentaPlayer won the game", + 1, + ) + .ok(); + shared + .game_state + .set(GameState::PostGameResults(BoardState::MagentaWin)); + self.do_backend_tick = false; + } + NetworkedGameState::Draw => { + append_to_info_text( + &document, + "info_text1", + "The game ended in a draw", + 1, + ) + .ok(); + shared + .game_state + .set(GameState::PostGameResults(BoardState::Empty)); + self.do_backend_tick = false; + } + NetworkedGameState::Disconnected => { + append_to_info_text( + &document, + "info_text1", + "The opponent disconnected", + 1, + ) + .ok(); + shared + .game_state + .set(GameState::PostGameResults(BoardState::Empty)); + self.do_backend_tick = false; + } + NetworkedGameState::InternalError => { + append_to_info_text( + &document, + "info_text1", + "There was an internal error", + 1, + ) + .ok(); + shared + .game_state + .set(GameState::PostGameResults(BoardState::Empty)); + self.do_backend_tick = false; + } + NetworkedGameState::NotPaired => (), + NetworkedGameState::UnknownID => { + append_to_info_text( + &document, + "info_text1", + "The game has ended (disconnected?)", + 1, + ) + .ok(); + shared + .game_state + .set(GameState::PostGameResults(BoardState::Empty)); + self.do_backend_tick = false; + } + } + } + BREnum::GotPlaced(placed_status, board_string) => { + self.update_board_from_string(&shared, &document, board_string); + + match placed_status { + PlacedEnum::Accepted => { + // noop, handled by update_board_from_string + } + PlacedEnum::Illegal => { + append_to_info_text( + &document, + "info_text0", + "Cannot place a token there", + INFO_TEXT_MAX_ITEMS, + ) + .ok(); + } + PlacedEnum::NotYourTurn => { + append_to_info_text( + &document, + "info_text0", + "Cannot place a token, not your turn", + INFO_TEXT_MAX_ITEMS, + ) + .ok(); + } + PlacedEnum::Other(networked_game_state) => match networked_game_state { + NetworkedGameState::CyanTurn => (), + NetworkedGameState::MagentaTurn => (), + NetworkedGameState::CyanWon => { + append_to_info_text( + &document, + "info_text1", + "CyanPlayer has won the game", + 1, + ) + .ok(); + shared + .game_state + .set(GameState::PostGameResults(BoardState::CyanWin)); + self.do_backend_tick = false; + } + NetworkedGameState::MagentaWon => { + append_to_info_text( + &document, + "info_text1", + "MagentaPlayer has won the game", + 1, + ) + .ok(); + shared + .game_state + .set(GameState::PostGameResults(BoardState::MagentaWin)); + self.do_backend_tick = false; + } + NetworkedGameState::Draw => { + append_to_info_text( + &document, + "info_text1", + "The game ended in a draw", + 1, + ) + .ok(); + shared + .game_state + .set(GameState::PostGameResults(BoardState::Empty)); + self.do_backend_tick = false; + } + NetworkedGameState::Disconnected => { + append_to_info_text( + &document, + "info_text1", + "The opponent disconnected", + 1, + ) + .ok(); + shared + .game_state + .set(GameState::PostGameResults(BoardState::Empty)); + self.do_backend_tick = false; + } + NetworkedGameState::InternalError => { + append_to_info_text( + &document, + "info_text1", + "There was an internal error", + 1, + ) + .ok(); + shared + .game_state + .set(GameState::PostGameResults(BoardState::Empty)); + self.do_backend_tick = false; + } + NetworkedGameState::NotPaired => (), + NetworkedGameState::UnknownID => { + append_to_info_text( + &document, + "info_text1", + "The game has ended (disconnected?)", + 1, + ) + .ok(); + shared + .game_state + .set(GameState::PostGameResults(BoardState::Empty)); + self.do_backend_tick = false; + } + }, + } } }, } // match (msg)