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 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<u32>, Option<bool>);
/// third bool is if cyan player
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>;
@ -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)]
pub enum DBHandlerRequest {
GetID {
@ -140,6 +186,11 @@ pub enum DBHandlerRequest {
pos: usize,
response_sender: SyncSender<PlaceResultType>,
},
SendEmote {
id: u32,
emote_type: EmoteEnum,
response_sender: SyncSender<Result<(), ()>>,
},
}
#[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<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
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;",
@ -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<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(
@ -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);
}
}
}
});

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/>.
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<DBHandlerRequest>) -> Result<St
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 {
Ok(format!(
"{{\"type\":\"game_state\", \"status\":\"{}\", \"board\":\"{}\"}}",
db_game_state, board_string
))
if let Some(emote) = received_emote_opt {
Ok(format!(
"{{\"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 {
Ok(format!(
"{{\"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())
}
}
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,
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

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