From 5381578b08b48ddfb1dae672116543a57aeb514a Mon Sep 17 00:00:00 2001 From: Stephen Seo Date: Fri, 29 Apr 2022 17:16:32 +0900 Subject: [PATCH] Update specs, impl back-end support for send emote --- back_end/src/db_handler.rs | 230 ++++++++++++++++-- back_end/src/json_handlers.rs | 75 +++++- .../backend_database_specification.md | 8 + .../backend_protocol_specification.md | 10 + 4 files changed, 295 insertions(+), 28 deletions(-) diff --git a/back_end/src/db_handler.rs b/back_end/src/db_handler.rs index 16cc79d..0c73f83 100644 --- a/back_end/src/db_handler.rs +++ b/back_end/src/db_handler.rs @@ -14,6 +14,7 @@ use crate::constants::{ use crate::state::{board_from_string, new_string_board, string_from_board, BoardState, Turn}; use std::collections::HashMap; +use std::fmt::Display; use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender}; use std::time::{Duration, Instant}; use std::{fmt, thread}; @@ -29,7 +30,8 @@ pub type GetIDSenderType = (Option, Option); /// third bool is if cyan player pub type CheckPairingType = (bool, bool, bool); -pub type BoardStateType = (DBGameState, Option); +/// second String is board string, third String is received emote type +pub type BoardStateType = (DBGameState, Option, Option); pub type PlaceResultType = Result<(DBPlaceStatus, Option), DBPlaceError>; @@ -117,6 +119,50 @@ impl fmt::Display for DBPlaceError { } } +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum EmoteEnum { + Smile, + Neutral, + Frown, + Think, +} + +impl Display for EmoteEnum { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + EmoteEnum::Smile => f.write_str("smile"), + EmoteEnum::Neutral => f.write_str("neutral"), + EmoteEnum::Frown => f.write_str("frown"), + EmoteEnum::Think => f.write_str("think"), + } + } +} + +impl TryFrom<&str> for EmoteEnum { + type Error = (); + + fn try_from(value: &str) -> Result { + match value.to_lowercase().as_str() { + "smile" => Ok(Self::Smile), + "neutral" => Ok(Self::Neutral), + "frown" => Ok(Self::Frown), + "think" => Ok(Self::Think), + _ => Err(()), + } + } +} + +impl From for String { + fn from(e: EmoteEnum) -> Self { + match e { + EmoteEnum::Smile => "smile".into(), + EmoteEnum::Neutral => "neutral".into(), + EmoteEnum::Frown => "frown".into(), + EmoteEnum::Think => "think".into(), + } + } +} + #[derive(Clone, Debug)] pub enum DBHandlerRequest { GetID { @@ -140,6 +186,11 @@ pub enum DBHandlerRequest { pos: usize, response_sender: SyncSender, }, + SendEmote { + id: u32, + emote_type: EmoteEnum, + response_sender: SyncSender>, + }, } #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -241,7 +292,9 @@ impl DBHandler { println!("{}", e); // don't stop server on send fail, may have timed out and // dropped the receiver - response_sender.send((DBGameState::UnknownID, None)).ok(); + response_sender + .send((DBGameState::UnknownID, None, None)) + .ok(); return false; } // don't stop server on send fail, may have timed out and @@ -268,6 +321,23 @@ impl DBHandler { // dropped the receiver response_sender.send(place_result).ok(); } + DBHandlerRequest::SendEmote { + id, + emote_type, + response_sender, + } => { + let result = self.create_new_sent_emote(None, id, emote_type); + if let Err(error_string) = result { + println!("{}", error_string); + // don't stop server on send fail, may have timed + // out and dropped the receiver + response_sender.send(Err(())).ok(); + } else { + // don't stop server on send fail, may have timed + // out and dropped the receiver + response_sender.send(Ok(())).ok(); + } + } } // match db_request false @@ -319,6 +389,25 @@ impl DBHandler { } else if first_run == DBFirstRun::FirstRun { println!("\"games\" table exists"); } + + let result = conn.execute( + " + CREATE TABLE emotes (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + type TEXT NOT NULL, + date_added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + receiver_id INTEGER NOT NULL, + FOREIGN KEY(receiver_id) REFERENCES players (id) ON DELETE CASCADE); + ", + [], + ); + if result.is_ok() { + if first_run == DBFirstRun::FirstRun { + println!("Created \"emotes\" table"); + } + } else if first_run == DBFirstRun::FirstRun { + println!("\"emotes\" table exists"); + } + Ok(conn) } else { Err(String::from("Failed to open connection")) @@ -603,6 +692,33 @@ impl DBHandler { _conn_result.as_ref().unwrap() }; + let mut received_emote: Option = None; + { + let row_result: Result<(u64, String), RusqliteError> = conn.query_row( + "SELECT id, type FROM emotes WHERE receiver_id = ? ORDER BY date_added ASC;", + [player_id], + |row| { + Ok(( + row.get(0).expect("emotes.id should exist"), + row.get(1).expect("emotes.type should exist"), + )) + }, + ); + if let Err(RusqliteError::QueryReturnedNoRows) = row_result { + // no-op + } else if let Err(e) = row_result { + println!("Error while fetching received emotes: {:?}", e); + } else { + let (emote_id, emote_type) = row_result.unwrap(); + received_emote = emote_type.as_str().try_into().ok(); + if received_emote.is_none() { + println!("WARNING: Invalid emote type \"{}\" in db", emote_type); + } + conn.execute("DELETE FROM emotes WHERE id = ?;", [emote_id]) + .ok(); + } + } + // TODO maybe handle "opponent_disconnected" case let row_result: Result<(String, i64, Option, Option), RusqliteError> = conn.query_row( "SELECT games.board, games.status, games.cyan_player, games.magenta_player FROM games JOIN players WHERE players.id = ? AND games.id = players.game_id;", @@ -636,30 +752,34 @@ impl DBHandler { if let Ok((board, status, cyan_opt, magenta_opt)) = row_result { if board.len() != (ROWS * COLS) as usize { // board is invalid size - Ok((DBGameState::InternalError, None)) + Ok((DBGameState::InternalError, None, received_emote)) } else if cyan_opt.is_none() || magenta_opt.is_none() { // One player disconnected self.disconnect_player(Some(conn), player_id).ok(); // Remove the game(s) with disconnected players if self.clear_empty_games(Some(conn)).is_err() { - Ok((DBGameState::InternalError, None)) + Ok((DBGameState::InternalError, None, received_emote)) } else if status == 2 || status == 3 { - Ok((DBGameState::from(status), Some(board))) + Ok((DBGameState::from(status), Some(board), received_emote)) } else { - Ok((DBGameState::OpponentDisconnected, Some(board))) + Ok(( + DBGameState::OpponentDisconnected, + Some(board), + received_emote, + )) } } else { // Game in progress, or other state depending on "status" - Ok((DBGameState::from(status), Some(board))) + Ok((DBGameState::from(status), Some(board), received_emote)) } } else if let Err(RusqliteError::QueryReturnedNoRows) = row_result { // No rows is either player doesn't exist or not paired let (exists, is_paired, _is_cyan) = self.check_if_player_is_paired(Some(conn), player_id)?; if !exists { - Ok((DBGameState::UnknownID, None)) + Ok((DBGameState::UnknownID, None, received_emote)) } else if !is_paired { - Ok((DBGameState::NotPaired, None)) + Ok((DBGameState::NotPaired, None, received_emote)) } else { unreachable!("either exists or is_paired must be false"); } @@ -805,26 +925,14 @@ impl DBHandler { } 2 => { // game over, cyan won - self.disconnect_player(Some(conn), player_id).ok(); - if self.clear_empty_games(Some(conn)).is_err() { - return Err(DBPlaceError::InternalError); - } return Ok((DBPlaceStatus::GameEndedCyanWon, Some(board_string))); } 3 => { // game over, magenta won - self.disconnect_player(Some(conn), player_id).ok(); - if self.clear_empty_games(Some(conn)).is_err() { - return Err(DBPlaceError::InternalError); - } return Ok((DBPlaceStatus::GameEndedMagentaWon, Some(board_string))); } 4 => { // game over, draw - self.disconnect_player(Some(conn), player_id).ok(); - if self.clear_empty_games(Some(conn)).is_err() { - return Err(DBPlaceError::InternalError); - } return Ok((DBPlaceStatus::GameEndedDraw, Some(board_string))); } _ => (), @@ -888,7 +996,6 @@ impl DBHandler { } if let Some(ended_state) = ended_state_opt { - self.disconnect_player(Some(conn), player_id).ok(); Ok(( match ended_state { BoardState::Empty => DBPlaceStatus::GameEndedDraw, @@ -1065,6 +1172,79 @@ impl DBHandler { Ok(()) } + + fn cleanup_stale_emotes(&self, conn: Option<&Connection>) -> Result<(), String> { + let mut _conn_result = Err(String::new()); + let conn = if let Some(c) = conn { + c + } else { + _conn_result = self.get_conn(DBFirstRun::NotFirstRun); + _conn_result.as_ref().unwrap() + }; + + conn.execute( + "DELETE FROM emotes WHERE unixepoch() - unixepoch(date_added) > ?;", + [GAME_CLEANUP_TIMEOUT], + ) + .ok(); + Ok(()) + } + + fn create_new_sent_emote( + &self, + conn: Option<&Connection>, + sender_id: u32, + emote: EmoteEnum, + ) -> Result<(), String> { + let mut _conn_result = Err(String::new()); + let conn = if let Some(c) = conn { + c + } else { + _conn_result = self.get_conn(DBFirstRun::NotFirstRun); + _conn_result.as_ref().unwrap() + }; + + let mut prepared_stmt = conn.prepare("SELECT games.cyan_player, games.magenta_player FROM games JOIN players WHERE players.id = ?, games.id = players.game_id;") + .map_err(|_| String::from("Failed to prepare db query for getting opponent id for sending emote"))?; + let row_result: Result<(Option, Option), RusqliteError> = + prepared_stmt.query_row([sender_id], |row| Ok((row.get(0).ok(), row.get(1).ok()))); + if let Err(RusqliteError::QueryReturnedNoRows) = row_result { + return Err(String::from("Failed to send emote, game doesn't exist")); + } else if let Err(e) = row_result { + return Err(format!("Failed to send emote: {:?}", e)); + } + let (cyan_player_opt, magenta_player_opt) = row_result.unwrap(); + if cyan_player_opt.is_none() { + return Err(String::from( + "Failed to send emote, cyan player disconnected", + )); + } else if magenta_player_opt.is_none() { + return Err(String::from( + "Failed to send emote, magenta player disconnected", + )); + } + let cyan_player_id = cyan_player_opt.unwrap(); + let magenta_player_id = magenta_player_opt.unwrap(); + + let receiver_id = if cyan_player_id == sender_id { + magenta_player_id + } else { + cyan_player_id + }; + + conn.execute( + "INSERT INTO emotes (type, receiver_id) VALUES (?, ?);", + params![String::from(emote), receiver_id], + ) + .map_err(|_| { + format!( + "Failed to store emote from player {} to player {}", + sender_id, receiver_id + ) + })?; + + Ok(()) + } } pub fn start_db_handler_thread( @@ -1106,6 +1286,12 @@ pub fn start_db_handler_thread( if let Err(e) = handler.cleanup_stale_players(None) { println!("{}", e); } + if let Err(e) = handler.cleanup_stale_emotes(None) { + println!("{}", e); + } + if let Err(e) = handler.clear_empty_games(None) { + println!("{}", e); + } } } }); diff --git a/back_end/src/json_handlers.rs b/back_end/src/json_handlers.rs index e10a62f..95ff212 100644 --- a/back_end/src/json_handlers.rs +++ b/back_end/src/json_handlers.rs @@ -8,7 +8,7 @@ //You should have received a copy of the GNU General Public License along with this program. If not, see . use crate::{ constants::BACKEND_PHRASE_MAX_LENGTH, - db_handler::{CheckPairingType, DBHandlerRequest, GetIDSenderType}, + db_handler::{CheckPairingType, DBHandlerRequest, EmoteEnum, GetIDSenderType}, }; use std::{ @@ -32,6 +32,7 @@ pub fn handle_json( "place_token" => handle_place_token(root, tx), "disconnect" => handle_disconnect(root, tx), "game_state" => handle_game_state(root, tx), + "send_emote" => handle_send_emote(root, tx), _ => Err("{\"type\":\"invalid_type\"}".into()), } } else { @@ -264,12 +265,21 @@ fn handle_game_state(root: Value, tx: SyncSender) -> Result) -> Result) -> Result { + let id_option = root.get("id"); + if id_option.is_none() { + return Err("{\"type\":\"invalid_syntax\"}".into()); + } + let player_id = id_option + .unwrap() + .as_u64() + .ok_or_else(|| String::from("{\"type\":\"invalid_syntax\"}"))?; + let player_id: u32 = player_id + .try_into() + .map_err(|_| String::from("{\"type\":\"invalid_syntax\"}"))?; + + let emote_type_option = root.get("emote"); + if emote_type_option.is_none() { + return Err("{\"type\":\"invalid_syntax\"}".into()); + } + let emote_type_option = emote_type_option.unwrap().as_str(); + if emote_type_option.is_none() { + return Err("{\"type\":\"invalid_syntax\"}".into()); + } + let emote_type = emote_type_option.unwrap(); + + let emote_enum: Result = emote_type.try_into(); + if emote_enum.is_err() { + return Err("{\"type\":\"invalid_syntax\"}".into()); + } + let emote_enum = emote_enum.unwrap(); + + let (resp_tx, resp_rx) = sync_channel(1); + + if tx + .send(DBHandlerRequest::SendEmote { + id: player_id, + emote_type: emote_enum, + response_sender: resp_tx, + }) + .is_err() + { + return Err("{\"type\":\"send_emote\", \"status\":\"internal_error\"}".into()); + } + + if let Ok(db_response) = resp_rx.recv_timeout(DB_REQUEST_TIMEOUT) { + if db_response.is_ok() { + Ok("{\"type\":\"send_emote\", \"status\":\"ok\"}".into()) + } else { + Err("{\"type\":\"send_emote\", \"status\":\"internal_error\"}".into()) + } + } else { + Err("{\"type\":\"send_emote\", \"status\":\"internal_error\"}".into()) + } +} diff --git a/specifications/backend_database_specification.md b/specifications/backend_database_specification.md index 6c8e427..b70bcc3 100644 --- a/specifications/backend_database_specification.md +++ b/specifications/backend_database_specification.md @@ -37,6 +37,14 @@ CREATE TABLE games (id INTEGER PRIMARY KEY NOT NULL, turn_time_start TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(cyan_player) REFERENCES players (id) ON DELETE SET NULL, FOREIGN KEY(magenta_player) REFERENCES players (id) ON DELETE SET NULL); + +// "type" is one of the four possible emotes + +CREATE TABLE emotes (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + type TEXT NOT NULL, + date_added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + receiver_id INTEGER NOT NULL, + FOREIGN KEY(receiver_id) REFERENCES players (id) ON DELETE CASCADE); ``` "date" entries are used for garbage collection of the database. A predefined diff --git a/specifications/backend_protocol_specification.md b/specifications/backend_protocol_specification.md index 68049b6..119cffe 100644 --- a/specifications/backend_protocol_specification.md +++ b/specifications/backend_protocol_specification.md @@ -164,6 +164,16 @@ then the back-end will respond with "too\_many\_players". } ``` +6. Send Emote Request Response + +``` + { + "type": "send_emote", + "status": "ok", // or "invalid_emote", "peer_disconnected", + // "internal_error" + } +``` + Note that the backend will stop keeping track of the game once both players have successfully requested the Game State once after the game has ended. Thus, future requests may return "unknown\_id" as the "status".