From dbb1c3ad354af1d066621fb2043641ca6e047536 Mon Sep 17 00:00:00 2001 From: Stephen Seo Date: Thu, 31 Mar 2022 20:38:22 +0900 Subject: [PATCH] backend: Impl "place_token" protocol Some edge-cases might not be addressed. Need to impl. "timers" for clearing out stale entries in the database. --- back_end/src/db_handler.rs | 375 +++++++++++++++++++++++++++--- back_end/src/json_handlers.rs | 67 +++++- backend_protocol_specification.md | 8 +- 3 files changed, 414 insertions(+), 36 deletions(-) diff --git a/back_end/src/db_handler.rs b/back_end/src/db_handler.rs index f77a977..8169a99 100644 --- a/back_end/src/db_handler.rs +++ b/back_end/src/db_handler.rs @@ -1,5 +1,8 @@ use crate::constants::{COLS, ROWS}; +use crate::game_logic::{check_win_draw, WinType}; +use crate::state::{new_empty_board, BoardState, BoardType}; +use std::collections::hash_set::HashSet; use std::sync::mpsc::{Receiver, SyncSender}; use std::{fmt, thread}; @@ -14,6 +17,10 @@ pub type CheckPairingType = (bool, bool, bool); pub type BoardStateType = (DBGameState, Option); +pub type PlaceResultType = Result<(DBPlaceStatus, Option), DBPlaceError>; + +// TODO use Error types instead of Strings for Result Errs + #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum DBGameState { CyanTurn, @@ -56,6 +63,44 @@ impl From for DBGameState { } } +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum DBPlaceStatus { + Accepted, + GameEnded, +} + +impl fmt::Display for DBPlaceStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + DBPlaceStatus::Accepted => write!(f, "accepted"), + DBPlaceStatus::GameEnded => write!(f, "game_ended"), + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum DBPlaceError { + NotPairedYet, + NotYourTurn, + Illegal, + OpponentDisconnected, + UnknownID, + InternalError, +} + +impl fmt::Display for DBPlaceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + DBPlaceError::NotPairedYet => write!(f, "not_paired_yet"), + DBPlaceError::NotYourTurn => write!(f, "not_your_turn"), + DBPlaceError::Illegal => write!(f, "illegal"), + DBPlaceError::OpponentDisconnected => write!(f, "opponent_disconnected"), + DBPlaceError::UnknownID => write!(f, "unknown_id"), + DBPlaceError::InternalError => write!(f, "internal_error"), + } + } +} + #[derive(Clone, Debug)] pub enum DBHandlerRequest { GetID(SyncSender), @@ -71,6 +116,11 @@ pub enum DBHandlerRequest { id: u32, response_sender: SyncSender, }, + PlaceToken { + id: u32, + pos: usize, + response_sender: SyncSender, + }, } #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -195,6 +245,16 @@ impl DBHandler { .send(self.disconnect_player(None, id).is_ok()) .ok(); } + DBHandlerRequest::PlaceToken { + id, + pos, + response_sender, + } => { + let place_result = self.place_token(None, id, pos); + // don't stop server on send fail, may have timed out and + // dropped the receiver + response_sender.send(place_result).ok(); + } } // match db_request false @@ -373,9 +433,9 @@ impl DBHandler { .check_if_player_exists(Some(&self.get_conn(DBFirstRun::NotFirstRun)?), player_id); } let conn = conn.unwrap(); - let check_player_row = + let check_player_row: Result = conn.query_row("SELECT id FROM players WHERE id = ?;", [player_id], |row| { - row.get::(0) + row.get(0) }); if let Ok(_id) = check_player_row { Ok(true) @@ -384,6 +444,30 @@ impl DBHandler { } } + fn check_if_player_in_game( + &self, + conn: Option<&Connection>, + player_id: u32, + ) -> Result { + if conn.is_none() { + return self.check_if_player_in_game( + Some(&self.get_conn(DBFirstRun::NotFirstRun)?), + player_id, + ); + } + let conn = conn.unwrap(); + + let check_player_game_row: Result = conn.query_row( + "SELECT games.id FROM games JOIN players WHERE players.id = ? AND players.game_id NOTNULL AND players.game_id = games.id;", + [player_id], + |row| row.get(0)); + if check_player_game_row.is_ok() { + Ok(true) + } else { + Ok(false) + } + } + fn get_board_state( &self, conn: Option<&Connection>, @@ -395,36 +479,35 @@ impl DBHandler { let conn = conn.unwrap(); // 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;", - [player_id], - |row| { - let board_result = row.get(0); - let status_result = row.get(1); - let cyan_player = row.get(2); - let magenta_player = row.get(3); - if board_result.is_ok() && status_result.is_ok() && cyan_player.is_ok() && magenta_player.is_ok() { - if let (Ok(board), Ok(status), Ok(cyan_id), Ok(magenta_id)) = (board_result, status_result, cyan_player, magenta_player) { - Ok((board, status, cyan_id, magenta_id)) - } else { - unreachable!("Both row items should be Ok"); - } - } else if board_result.is_err() { - board_result - .map(|_| (String::from("this value should never be returned"), 0, None, None)) - } else if status_result.is_err() { - status_result - .map(|_| (String::from("this value should never be returned"), 0, None, None)) - } else if cyan_player.is_err() { - cyan_player - .map(|_| (String::from("this value should never be returned"), 0, None, None)) + 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;", + [player_id], + |row| { + let board_result = row.get(0); + let status_result = row.get(1); + let cyan_player = row.get(2); + let magenta_player = row.get(3); + if board_result.is_ok() && status_result.is_ok() && cyan_player.is_ok() && magenta_player.is_ok() { + if let (Ok(board), Ok(status), Ok(cyan_id), Ok(magenta_id)) = (board_result, status_result, cyan_player, magenta_player) { + Ok((board, status, cyan_id, magenta_id)) } else { - magenta_player - .map(|_| (String::from("this value should never be returned"), 0, None, None)) + unreachable!("Both row items should be Ok"); } + } else if board_result.is_err() { + board_result + .map(|_| (String::from("this value should never be returned"), 0, None, None)) + } else if status_result.is_err() { + status_result + .map(|_| (String::from("this value should never be returned"), 0, None, None)) + } else if cyan_player.is_err() { + cyan_player + .map(|_| (String::from("this value should never be returned"), 0, None, None)) + } else { + magenta_player + .map(|_| (String::from("this value should never be returned"), 0, None, None)) } - ); + } + ); if let Ok((board, status, cyan_opt, magenta_opt)) = row_result { if board.len() != (ROWS * COLS) as usize { // board is invalid size @@ -494,6 +577,160 @@ impl DBHandler { Ok(()) } + + fn place_token( + &self, + conn: Option<&Connection>, + player_id: u32, + pos: usize, + ) -> PlaceResultType { + if conn.is_none() { + return self.place_token( + Some( + &self + .get_conn(DBFirstRun::NotFirstRun) + .map_err(|_| DBPlaceError::InternalError)?, + ), + player_id, + pos, + ); + } + let conn = conn.unwrap(); + + // check if player exists + let player_exist_check_result = self.check_if_player_exists(Some(conn), player_id); + if let Ok(exists) = player_exist_check_result { + if !exists { + return Err(DBPlaceError::UnknownID); + } + } else { + return Err(DBPlaceError::InternalError); + } + + // check if player belongs to a game + let player_game_result = self.check_if_player_in_game(Some(conn), player_id); + if let Ok(is_in_game) = player_game_result { + if !is_in_game { + return Err(DBPlaceError::NotPairedYet); + } + } else { + return Err(DBPlaceError::InternalError); + } + + // check if player is cyan or magenta + let query_result_result: Result, _> = + conn.query_row( + "SELECT cyan_player, magenta_player, status, board FROM games JOIN players WHERE players.id = ? AND players.game_id = games.id;", + [player_id], + |row| { + let cyan_id_result: Result, _> = row.get(0); + let magenta_id_result: Result, _> = row.get(1); + let status_result: Result = row.get(2); + let board_result: Result = row.get(3); + if status_result.is_err() { + return status_result.map(|_| Ok((false, 0, "".into()))); + } + let status: u32 = status_result.unwrap(); + if board_result.is_err() { + return board_result.map(|_| Ok((false, 0, "".into()))); + } + let board = board_result.unwrap(); + if cyan_id_result.is_ok() && magenta_id_result.is_ok() { + if let (Ok(cyan_id_opt), Ok(magenta_id_opt)) = (cyan_id_result, magenta_id_result) { + if let (Some(cyan_id), Some(magenta_id)) = (cyan_id_opt, magenta_id_opt) { + Ok(Ok((cyan_id == player_id, status, board))) + } else { + Ok(Err(DBPlaceError::OpponentDisconnected)) + } + } else { + unreachable!("both row items should be Ok") + } + } else if cyan_id_result.is_err() { + cyan_id_result.map(|_| Err(DBPlaceError::InternalError)) + } else { + magenta_id_result.map(|_| Err(DBPlaceError::InternalError)) + } + }); + + let query_result = query_result_result.map_err(|_| DBPlaceError::InternalError)?; + + // if opponent has disconnected, disconnect the remaining player as well + if let Err(DBPlaceError::OpponentDisconnected) = query_result { + if self.disconnect_player(Some(conn), player_id).is_err() + || self.clear_empty_games(Some(conn)).is_err() + { + return Err(DBPlaceError::InternalError); + } + } + + let (is_cyan, status, board_string) = query_result?; + + match status { + 0 => { + // cyan's turn + if !is_cyan { + return Err(DBPlaceError::NotYourTurn); + } + } + 1 => { + // magenta's turn + if is_cyan { + return Err(DBPlaceError::NotYourTurn); + } + } + 2 | 3 | 4 => { + // game over, cyan won, or magenta won, or draw + return Ok((DBPlaceStatus::GameEnded, Some(board_string))); + } + _ => (), + } + + // get board state + let board = board_from_string(board_string); + + // find placement position or return "illegal move" if unable to + let mut final_pos = pos; + loop { + if board[final_pos].get() == BoardState::Empty { + if final_pos + COLS as usize >= board.len() + || board[final_pos + COLS as usize].get() != BoardState::Empty + { + break; + } else if board[final_pos + COLS as usize].get() == BoardState::Empty { + final_pos += COLS as usize; + } + } else { + return Err(DBPlaceError::Illegal); + } + } + + // place into board + if is_cyan { + board[final_pos].replace(BoardState::Cyan); + } else { + board[final_pos].replace(BoardState::Magenta); + } + + // board back to string + let (board_string, ended) = string_from_board(board, final_pos); + + // update DB + let update_result = conn.execute("UPDATE games SET status = ?, board = ? FROM players WHERE players.game_id = games.id AND players.id = ?;" , params![if status == 0 { 1u8 } else { 0u8 }, board_string, player_id]); + if let Err(_e) = update_result { + return Err(DBPlaceError::InternalError); + } else if let Ok(count) = update_result { + if count != 1 { + return Err(DBPlaceError::InternalError); + } + } + + if ended { + self.disconnect_player(Some(conn), player_id).ok(); + Ok((DBPlaceStatus::GameEnded, Some(board_string))) + } else { + Ok((DBPlaceStatus::Accepted, Some(board_string))) + } + } } pub fn start_db_handler_thread( @@ -531,3 +768,83 @@ fn new_board() -> String { } board } + +fn board_from_string(board_string: String) -> BoardType { + let board = new_empty_board(); + + 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), + _ => BoardState::Empty, + }; + } + + board +} + +/// Returns the board as a String, and true if the game has ended +fn string_from_board(board: BoardType, placed: usize) -> (String, bool) { + let mut board_string = String::with_capacity(56); + + // check for winning pieces + let mut win_set: HashSet = HashSet::new(); + let win_opt = check_win_draw(&board); + if let Some((board_state, win_type)) = win_opt { + match win_type { + WinType::Horizontal(pos) => { + for i in pos..(pos + 4) { + win_set.insert(i); + } + } + WinType::Vertical(pos) => { + for i in 0..4 { + win_set.insert(pos + i * COLS as usize); + } + } + WinType::DiagonalUp(pos) => { + for i in 0..4 { + win_set.insert(pos + i - i * COLS as usize); + } + } + WinType::DiagonalDown(pos) => { + for i in 0..4 { + win_set.insert(pos + i + i * COLS as usize); + } + } + WinType::None => (), + } + } + + // set values to String + let mut is_full = true; + for (idx, board_state) in board.iter().enumerate().take((COLS * ROWS) as usize) { + board_string.push(match board_state.get() { + BoardState::Empty => { + is_full = false; + 'a' + } + BoardState::Cyan | BoardState::CyanWin => { + if win_set.contains(&idx) { + 'd' + } else if idx == placed { + 'f' + } else { + 'b' + } + } + BoardState::Magenta | BoardState::MagentaWin => { + if win_set.contains(&idx) { + 'e' + } else if idx == placed { + 'g' + } else { + 'c' + } + } + }); + } + + (board_string, is_full || !win_set.is_empty()) +} diff --git a/back_end/src/json_handlers.rs b/back_end/src/json_handlers.rs index d50e51a..03fb98e 100644 --- a/back_end/src/json_handlers.rs +++ b/back_end/src/json_handlers.rs @@ -18,7 +18,7 @@ pub fn handle_json( match type_str.as_str() { "pairing_request" => handle_pairing_request(tx), "check_pairing" => handle_check_pairing(root, tx), - "place_token" => handle_place_token(root), + "place_token" => handle_place_token(root, tx), "disconnect" => handle_disconnect(root, tx), "game_state" => handle_game_state(root, tx), _ => Err("{\"type\":\"invalid_type\"}".into()), @@ -89,8 +89,69 @@ fn handle_check_pairing(root: Value, tx: SyncSender) -> Result } } -fn handle_place_token(root: Value) -> Result { - Err("{\"type\":\"unimplemented\"}".into()) +fn handle_place_token(root: Value, tx: SyncSender) -> 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 position_option = root.get("position"); + if position_option.is_none() { + return Err("{\"type\":\"invalid_syntax\"}".into()); + } + let position = position_option + .unwrap() + .as_u64() + .ok_or_else(|| String::from("{\"type\":\"invalid_syntax\"}"))?; + let position: usize = position + .try_into() + .map_err(|_| String::from("{\"type\":\"invalid_syntax\"}"))?; + + let (resp_tx, resp_rx) = sync_channel(1); + + if tx + .send(DBHandlerRequest::PlaceToken { + id: player_id, + pos: position, + response_sender: resp_tx, + }) + .is_err() + { + return Err(String::from( + "{\"type\":\"place_token\", \"status\":\"internal_error\"}", + )); + } + + let place_result = resp_rx.recv_timeout(DB_REQUEST_TIMEOUT); + if let Ok(Ok((place_status, board_opt))) = place_result { + if let Some(board_string) = board_opt { + Ok(format!( + "{{\"type\":\"place_token\", \"status\":\"{}\", \"board\":\"{}\"}}", + place_status, board_string + )) + } else { + Ok(format!( + "{{\"type\":\"place_token\", \"status\":\"{}\"}}", + place_status + )) + } + } else if let Ok(Err(place_error)) = place_result { + Err(format!( + "{{\"type\":\"place_token\", \"status\":\"{}\"}}", + place_error + )) + } else { + Err(String::from( + "{\"type\":\"place_token\", \"status\":\"internal_error\"}", + )) + } } fn handle_disconnect(root: Value, tx: SyncSender) -> Result { diff --git a/backend_protocol_specification.md b/backend_protocol_specification.md index 86ea079..59b9bfb 100644 --- a/backend_protocol_specification.md +++ b/backend_protocol_specification.md @@ -132,10 +132,10 @@ then the back-end will respond with "too\_many\_players". // a - empty // b - cyan // c - magenta - // d - cyan placed - // e - magenta placed - // f - cyan winning piece - // g - magenta winning piece + // d - cyan winning piece + // e - magenta winning piece + // f - cyan placed + // g - magenta placed } ```