Update specs, impl back-end support for send emote

This commit is contained in:
Stephen Seo 2022-04-29 17:16:32 +09:00
parent f498f2c475
commit 5381578b08
4 changed files with 295 additions and 28 deletions

View file

@ -14,6 +14,7 @@ use crate::constants::{
use crate::state::{board_from_string, new_string_board, string_from_board, BoardState, Turn}; use crate::state::{board_from_string, new_string_board, string_from_board, BoardState, Turn};
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt::Display;
use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender}; use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use std::{fmt, thread}; use std::{fmt, thread};
@ -29,7 +30,8 @@ pub type GetIDSenderType = (Option<u32>, Option<bool>);
/// third bool is if cyan player /// third bool is if cyan player
pub type CheckPairingType = (bool, bool, bool); pub type CheckPairingType = (bool, bool, bool);
pub type BoardStateType = (DBGameState, Option<String>); /// second String is board string, third String is received emote type
pub type BoardStateType = (DBGameState, Option<String>, Option<EmoteEnum>);
pub type PlaceResultType = Result<(DBPlaceStatus, Option<String>), DBPlaceError>; pub type PlaceResultType = Result<(DBPlaceStatus, Option<String>), 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<Self, Self::Error> {
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<EmoteEnum> 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)] #[derive(Clone, Debug)]
pub enum DBHandlerRequest { pub enum DBHandlerRequest {
GetID { GetID {
@ -140,6 +186,11 @@ pub enum DBHandlerRequest {
pos: usize, pos: usize,
response_sender: SyncSender<PlaceResultType>, response_sender: SyncSender<PlaceResultType>,
}, },
SendEmote {
id: u32,
emote_type: EmoteEnum,
response_sender: SyncSender<Result<(), ()>>,
},
} }
#[derive(Copy, Clone, Debug, PartialEq, Eq)] #[derive(Copy, Clone, Debug, PartialEq, Eq)]
@ -241,7 +292,9 @@ impl DBHandler {
println!("{}", e); println!("{}", e);
// don't stop server on send fail, may have timed out and // don't stop server on send fail, may have timed out and
// dropped the receiver // dropped the receiver
response_sender.send((DBGameState::UnknownID, None)).ok(); response_sender
.send((DBGameState::UnknownID, None, None))
.ok();
return false; return false;
} }
// don't stop server on send fail, may have timed out and // don't stop server on send fail, may have timed out and
@ -268,6 +321,23 @@ impl DBHandler {
// dropped the receiver // dropped the receiver
response_sender.send(place_result).ok(); 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 } // match db_request
false false
@ -319,6 +389,25 @@ impl DBHandler {
} else if first_run == DBFirstRun::FirstRun { } else if first_run == DBFirstRun::FirstRun {
println!("\"games\" table exists"); 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) Ok(conn)
} else { } else {
Err(String::from("Failed to open connection")) Err(String::from("Failed to open connection"))
@ -603,6 +692,33 @@ impl DBHandler {
_conn_result.as_ref().unwrap() _conn_result.as_ref().unwrap()
}; };
let mut received_emote: Option<EmoteEnum> = 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 // TODO maybe handle "opponent_disconnected" case
let row_result: Result<(String, i64, Option<u32>, Option<u32>), RusqliteError> = conn.query_row( let row_result: Result<(String, i64, Option<u32>, Option<u32>), 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;", "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 let Ok((board, status, cyan_opt, magenta_opt)) = row_result {
if board.len() != (ROWS * COLS) as usize { if board.len() != (ROWS * COLS) as usize {
// board is invalid size // board is invalid size
Ok((DBGameState::InternalError, None)) Ok((DBGameState::InternalError, None, received_emote))
} else if cyan_opt.is_none() || magenta_opt.is_none() { } else if cyan_opt.is_none() || magenta_opt.is_none() {
// One player disconnected // One player disconnected
self.disconnect_player(Some(conn), player_id).ok(); self.disconnect_player(Some(conn), player_id).ok();
// Remove the game(s) with disconnected players // Remove the game(s) with disconnected players
if self.clear_empty_games(Some(conn)).is_err() { 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 { } else if status == 2 || status == 3 {
Ok((DBGameState::from(status), Some(board))) Ok((DBGameState::from(status), Some(board), received_emote))
} else { } else {
Ok((DBGameState::OpponentDisconnected, Some(board))) Ok((
DBGameState::OpponentDisconnected,
Some(board),
received_emote,
))
} }
} else { } else {
// Game in progress, or other state depending on "status" // 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 { } else if let Err(RusqliteError::QueryReturnedNoRows) = row_result {
// No rows is either player doesn't exist or not paired // No rows is either player doesn't exist or not paired
let (exists, is_paired, _is_cyan) = let (exists, is_paired, _is_cyan) =
self.check_if_player_is_paired(Some(conn), player_id)?; self.check_if_player_is_paired(Some(conn), player_id)?;
if !exists { if !exists {
Ok((DBGameState::UnknownID, None)) Ok((DBGameState::UnknownID, None, received_emote))
} else if !is_paired { } else if !is_paired {
Ok((DBGameState::NotPaired, None)) Ok((DBGameState::NotPaired, None, received_emote))
} else { } else {
unreachable!("either exists or is_paired must be false"); unreachable!("either exists or is_paired must be false");
} }
@ -805,26 +925,14 @@ impl DBHandler {
} }
2 => { 2 => {
// game over, cyan won // 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))); return Ok((DBPlaceStatus::GameEndedCyanWon, Some(board_string)));
} }
3 => { 3 => {
// game over, magenta won // 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))); return Ok((DBPlaceStatus::GameEndedMagentaWon, Some(board_string)));
} }
4 => { 4 => {
// game over, draw // 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))); return Ok((DBPlaceStatus::GameEndedDraw, Some(board_string)));
} }
_ => (), _ => (),
@ -888,7 +996,6 @@ impl DBHandler {
} }
if let Some(ended_state) = ended_state_opt { if let Some(ended_state) = ended_state_opt {
self.disconnect_player(Some(conn), player_id).ok();
Ok(( Ok((
match ended_state { match ended_state {
BoardState::Empty => DBPlaceStatus::GameEndedDraw, BoardState::Empty => DBPlaceStatus::GameEndedDraw,
@ -1065,6 +1172,79 @@ impl DBHandler {
Ok(()) 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<u32>, Option<u32>), 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( 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) { if let Err(e) = handler.cleanup_stale_players(None) {
println!("{}", e); println!("{}", e);
} }
if let Err(e) = handler.cleanup_stale_emotes(None) {
println!("{}", e);
}
if let Err(e) = handler.clear_empty_games(None) {
println!("{}", e);
}
} }
} }
}); });

View file

@ -8,7 +8,7 @@
//You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. //You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
use crate::{ use crate::{
constants::BACKEND_PHRASE_MAX_LENGTH, constants::BACKEND_PHRASE_MAX_LENGTH,
db_handler::{CheckPairingType, DBHandlerRequest, GetIDSenderType}, db_handler::{CheckPairingType, DBHandlerRequest, EmoteEnum, GetIDSenderType},
}; };
use std::{ use std::{
@ -32,6 +32,7 @@ pub fn handle_json(
"place_token" => handle_place_token(root, tx), "place_token" => handle_place_token(root, tx),
"disconnect" => handle_disconnect(root, tx), "disconnect" => handle_disconnect(root, tx),
"game_state" => handle_game_state(root, tx), "game_state" => handle_game_state(root, tx),
"send_emote" => handle_send_emote(root, tx),
_ => Err("{\"type\":\"invalid_type\"}".into()), _ => Err("{\"type\":\"invalid_type\"}".into()),
} }
} else { } else {
@ -264,12 +265,21 @@ fn handle_game_state(root: Value, tx: SyncSender<DBHandlerRequest>) -> Result<St
return Err("{\"type\":\"game_state\", \"status\":\"internal_error\"}".into()); return Err("{\"type\":\"game_state\", \"status\":\"internal_error\"}".into());
} }
if let Ok((db_game_state, board_string_opt)) = resp_rx.recv_timeout(DB_REQUEST_TIMEOUT) { if let Ok((db_game_state, board_string_opt, received_emote_opt)) =
resp_rx.recv_timeout(DB_REQUEST_TIMEOUT)
{
if let Some(board_string) = board_string_opt { if let Some(board_string) = board_string_opt {
Ok(format!( if let Some(emote) = received_emote_opt {
"{{\"type\":\"game_state\", \"status\":\"{}\", \"board\":\"{}\"}}", Ok(format!(
db_game_state, board_string "{{\"type\":\"game_state\", \"status\":\"{}\", \"board\":\"{}\", \"peer_emote\": \"{}\"}}",
)) db_game_state, board_string, emote
))
} else {
Ok(format!(
"{{\"type\":\"game_state\", \"status\":\"{}\", \"board\":\"{}\"}}",
db_game_state, board_string
))
}
} else { } else {
Ok(format!( Ok(format!(
"{{\"type\":\"game_state\", \"status\":\"{}\"}}", "{{\"type\":\"game_state\", \"status\":\"{}\"}}",
@ -280,3 +290,56 @@ fn handle_game_state(root: Value, tx: SyncSender<DBHandlerRequest>) -> Result<St
Err("{\"type\":\"game_state\", \"status\":\"internal_error_timeout\"}".into()) Err("{\"type\":\"game_state\", \"status\":\"internal_error_timeout\"}".into())
} }
} }
fn handle_send_emote(root: Value, tx: SyncSender<DBHandlerRequest>) -> Result<String, String> {
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<EmoteEnum, ()> = 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())
}
}

View file

@ -37,6 +37,14 @@ CREATE TABLE games (id INTEGER PRIMARY KEY NOT NULL,
turn_time_start TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, turn_time_start TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(cyan_player) REFERENCES players (id) ON DELETE SET NULL, FOREIGN KEY(cyan_player) REFERENCES players (id) ON DELETE SET NULL,
FOREIGN KEY(magenta_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 "date" entries are used for garbage collection of the database. A predefined

View file

@ -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 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, successfully requested the Game State once after the game has ended. Thus,
future requests may return "unknown\_id" as the "status". future requests may return "unknown\_id" as the "status".