mod disk_db; mod types; use std::path::Path; use async_std::channel::{bounded, Sender}; use async_trait::async_trait; use disk_db::{db_handler, DiskDb}; pub use types::{CharacterId, CharsheetRow, GameId, SessionId, UserId}; use crate::types::{AccountState, FatalError, Game, User}; #[derive(Debug)] enum Request { Charsheet(CharacterId), CreateGame(UserId, String, String), CreateSession(UserId), CreateUser(String, String, bool, AccountState), Game(GameId), Games, SaveGame(Game), SaveUser(User), Session(SessionId), User(UserId), UserByUsername(String), Users, } #[derive(Debug)] struct DatabaseRequest { tx: Sender<DatabaseResponse>, req: Request, } #[derive(Debug)] enum DatabaseResponse { Charsheet(Option<CharsheetRow>), CreateSession(SessionId), Games(Vec<Game>), Game(Option<Game>), SaveGame(GameId), SaveUser(UserId), Session(Option<User>), User(Option<User>), Users(Vec<User>), } #[async_trait] pub trait Database: Send + Sync { async fn create_session(&self, id: &UserId) -> Result<SessionId, FatalError>; async fn session(&self, id: &SessionId) -> Result<Option<User>, FatalError>; async fn character(&self, id: &CharacterId) -> Result<Option<CharsheetRow>, FatalError>; async fn create_game( &self, gm: &UserId, game_type: &str, name: &str, ) -> Result<GameId, FatalError>; async fn save_game(&self, game: Game) -> Result<GameId, FatalError>; async fn game(&self, _: &GameId) -> Result<Option<Game>, FatalError>; async fn games(&self) -> Result<Vec<Game>, FatalError>; async fn create_user( &self, name: &str, password: &str, admin: bool, state: AccountState, ) -> Result<UserId, FatalError>; async fn save_user(&self, user: User) -> Result<UserId, FatalError>; async fn user(&self, _: &UserId) -> Result<Option<User>, FatalError>; async fn user_by_username(&self, _: &str) -> Result<Option<User>, FatalError>; async fn users(&self) -> Result<Vec<User>, FatalError>; } pub struct DbConn { conn: Sender<DatabaseRequest>, handle: tokio::task::JoinHandle<()>, } impl DbConn { pub fn new<P>(path: Option<P>) -> Self where P: AsRef<Path>, { let (tx, rx) = bounded::<DatabaseRequest>(5); let db = DiskDb::new(path).unwrap(); let handle = tokio::spawn(async move { db_handler(db, rx).await; }); Self { conn: tx, handle } } } macro_rules! send_request { ($s:expr, $req:expr, $resp_h:pat => $block:expr) => {{ let (tx, rx) = bounded::<DatabaseResponse>(1); let request = DatabaseRequest { tx, req: $req }; match $s.conn.send(request).await { Ok(()) => (), Err(_) => return Err(FatalError::DatabaseConnectionLost), }; match rx .recv() .await .map_err(|_| FatalError::DatabaseConnectionLost) { Ok($resp_h) => $block, Ok(_) => Err(FatalError::MessageMismatch), Err(_) => Err(FatalError::DatabaseConnectionLost), } }}; } #[async_trait] impl Database for DbConn { async fn create_session(&self, id: &UserId) -> Result<SessionId, FatalError> { send_request!(self, Request::CreateSession(id.to_owned()), DatabaseResponse::CreateSession(session_id) => Ok(session_id)) } async fn session(&self, id: &SessionId) -> Result<Option<User>, FatalError> { send_request!(self, Request::Session(id.to_owned()), DatabaseResponse::Session(row) => Ok(row)) } async fn character(&self, id: &CharacterId) -> Result<Option<CharsheetRow>, FatalError> { send_request!(self, Request::Charsheet(id.to_owned()), DatabaseResponse::Charsheet(row) => Ok(row)) } async fn create_game( &self, user_id: &UserId, game_type: &str, game_name: &str, ) -> Result<GameId, FatalError> { send_request!(self, Request::CreateGame(user_id.to_owned(), game_type.to_owned(), game_name.to_owned()), DatabaseResponse::SaveGame(game_id) => Ok(game_id)) } async fn save_game(&self, game: Game) -> Result<GameId, FatalError> { send_request!(self, Request::SaveGame(game), DatabaseResponse::SaveGame(game_id) => Ok(game_id)) } async fn game(&self, game_id: &GameId) -> Result<Option<Game>, FatalError> { send_request!(self, Request::Game(game_id.clone()), DatabaseResponse::Game(game) => Ok(game)) } async fn games(&self) -> Result<Vec<Game>, FatalError> { send_request!(self, Request::Games, DatabaseResponse::Games(lst) => Ok(lst)) } async fn create_user( &self, name: &str, password: &str, admin: bool, state: AccountState, ) -> Result<UserId, FatalError> { send_request!(self, Request::CreateUser(name.to_owned(), password.to_owned(), admin, state), DatabaseResponse::SaveUser(user_id) => Ok(user_id)) } async fn save_user(&self, user: User) -> Result<UserId, FatalError> { send_request!(self, Request::SaveUser(user), DatabaseResponse::SaveUser(user_id) => Ok(user_id)) } async fn user(&self, uid: &UserId) -> Result<Option<User>, FatalError> { send_request!(self, Request::User(uid.clone()), DatabaseResponse::User(user) => Ok(user)) } async fn user_by_username(&self, username: &str) -> Result<Option<User>, FatalError> { send_request!(self, Request::UserByUsername(username.to_owned()), DatabaseResponse::User(user) => Ok(user)) } async fn users(&self) -> Result<Vec<User>, FatalError> { send_request!(self, Request::Users, DatabaseResponse::Users(lst) => Ok(lst)) } } #[cfg(test)] mod test { use std::path::PathBuf; use cool_asserts::assert_matches; use disk_db::DiskDb; use types::GameId; use super::*; const SOREN: &str = r#"{ "type_": "Candela", "name": "Soren Jensen", "pronouns": "he/him", "circle": "Circle of the Bluest Sky", "style": "dapper gentleman", "catalyst": "a cursed book", "question": "What were the contents of that book?", "nerve": { "type_": "nerve", "drives": { "current": 1, "max": 2 }, "resistances": { "current": 0, "max": 3 }, "move": { "gilded": false, "score": 2 }, "strike": { "gilded": false, "score": 1 }, "control": { "gilded": true, "score": 0 } }, "cunning": { "type_": "cunning", "drives": { "current": 1, "max": 1 }, "resistances": { "current": 0, "max": 3 }, "sway": { "gilded": false, "score": 0 }, "read": { "gilded": false, "score": 0 }, "hide": { "gilded": false, "score": 0 } }, "intuition": { "type_": "intuition", "drives": { "current": 0, "max": 0 }, "resistances": { "current": 0, "max": 3 }, "survey": { "gilded": false, "score": 0 }, "focus": { "gilded": false, "score": 0 }, "sense": { "gilded": false, "score": 0 } }, "role": "Slink", "role_abilities": [ "Scout: If you have time to observe a location, you can spend 1 Intuition to ask a question: What do I notice here that others do not see? What in this place might be of use to us? What path should we follow?" ], "specialty": "Detective", "specialty_abilities": [ "Mind Palace: When you want to figure out how two clues might relate or what path they should point you towards, burn 1 Intution resistance. The GM will give you the information you have deduced." ] }"#; fn setup_db() -> (DiskDb, GameId) { let no_path: Option<PathBuf> = None; let db = DiskDb::new(no_path).unwrap(); db.create_user("admin", "abcdefg", true, AccountState::Normal) .unwrap(); let game_id = db .create_game( &UserId::from("admin"), "Candela", "Circle of the Winter Solstice", ) .unwrap(); (db, game_id) } #[test] fn it_can_retrieve_a_character() { let (db, game_id) = setup_db(); assert_matches!(db.character(CharacterId::from("1")), Ok(None)); let js: serde_json::Value = serde_json::from_str(SOREN).unwrap(); let soren_id = db.save_character(None, game_id, js.clone()).unwrap(); assert_matches!(db.character(soren_id).unwrap(), Some(CharsheetRow{ data, .. }) => assert_eq!(js, data)); } #[tokio::test] async fn it_can_retrieve_a_character_through_conn() { let memory_db: Option<PathBuf> = None; let conn = DbConn::new(memory_db); assert_matches!(conn.character(&CharacterId::from("1")).await, Ok(None)); } }