200 lines
7.1 KiB
Rust
200 lines
7.1 KiB
Rust
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, GameRow, SessionId, UserId, UserRow};
|
|
|
|
use crate::types::FatalError;
|
|
|
|
#[derive(Debug)]
|
|
enum Request {
|
|
Charsheet(CharacterId),
|
|
CreateSession(UserId),
|
|
Games,
|
|
SaveUser(Option<UserId>, String, String, bool, bool),
|
|
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<GameRow>),
|
|
SaveUser(UserId),
|
|
Session(Option<UserRow>),
|
|
User(Option<UserRow>),
|
|
Users(Vec<UserRow>),
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait Database: Send + Sync {
|
|
async fn user(&self, _: &UserId) -> Result<Option<UserRow>, FatalError>;
|
|
|
|
async fn user_by_username(&self, _: &str) -> Result<Option<UserRow>, FatalError>;
|
|
|
|
async fn save_user(
|
|
&self,
|
|
user_id: Option<UserId>,
|
|
name: &str,
|
|
password: &str,
|
|
admin: bool,
|
|
enabled: bool,
|
|
) -> Result<UserId, FatalError>;
|
|
|
|
async fn users(&mut self) -> Result<Vec<UserRow>, FatalError>;
|
|
|
|
async fn games(&mut self) -> Result<Vec<GameRow>, FatalError>;
|
|
|
|
async fn character(&mut self, id: &CharacterId) -> Result<Option<CharsheetRow>, FatalError>;
|
|
|
|
async fn session(&self, id: &SessionId) -> Result<Option<UserRow>, FatalError>;
|
|
|
|
async fn create_session(&self, id: &UserId) -> Result<SessionId, 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 user(&self, uid: &UserId) -> Result<Option<UserRow>, FatalError> {
|
|
send_request!(self, Request::User(uid.clone()), DatabaseResponse::User(user) => Ok(user))
|
|
}
|
|
|
|
async fn user_by_username(&self, username: &str) -> Result<Option<UserRow>, FatalError> {
|
|
send_request!(self, Request::UserByUsername(username.to_owned()), DatabaseResponse::User(user) => Ok(user))
|
|
}
|
|
|
|
async fn save_user(
|
|
&self,
|
|
user_id: Option<UserId>,
|
|
name: &str,
|
|
password: &str,
|
|
admin: bool,
|
|
enabled: bool,
|
|
) -> Result<UserId, FatalError> {
|
|
send_request!(self,
|
|
Request::SaveUser(
|
|
user_id,
|
|
name.to_owned(),
|
|
password.to_owned(),
|
|
admin,
|
|
enabled,
|
|
),
|
|
DatabaseResponse::SaveUser(user_id) => Ok(user_id))
|
|
}
|
|
|
|
async fn users(&mut self) -> Result<Vec<UserRow>, FatalError> {
|
|
send_request!(self, Request::Users, DatabaseResponse::Users(lst) => Ok(lst))
|
|
}
|
|
|
|
async fn games(&mut self) -> Result<Vec<GameRow>, FatalError> {
|
|
send_request!(self, Request::Games, DatabaseResponse::Games(lst) => Ok(lst))
|
|
}
|
|
|
|
async fn character(&mut self, id: &CharacterId) -> Result<Option<CharsheetRow>, FatalError> {
|
|
send_request!(self, Request::Charsheet(id.to_owned()), DatabaseResponse::Charsheet(row) => Ok(row))
|
|
}
|
|
|
|
async fn session(&self, id: &SessionId) -> Result<Option<UserRow>, FatalError> {
|
|
send_request!(self, Request::Session(id.to_owned()), DatabaseResponse::Session(row) => Ok(row))
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use std::path::PathBuf;
|
|
|
|
use cool_asserts::assert_matches;
|
|
use disk_db::DiskDb;
|
|
use types::GameId;
|
|
|
|
use super::*;
|
|
|
|
const SOREN: &'static 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.save_user(Some(UserId::from("admin")), "admin", "abcdefg", true, true).unwrap();
|
|
let game_id = db.save_game(None, "Candela").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 mut conn = DbConn::new(memory_db);
|
|
|
|
assert_matches!(conn.character(&CharacterId::from("1")).await, Ok(None));
|
|
}
|
|
}
|