use std::{ path::{Path, PathBuf}, thread::JoinHandle, }; use async_std::channel::{bounded, Receiver, Sender}; use async_trait::async_trait; use include_dir::{include_dir, Dir}; use lazy_static::lazy_static; use rusqlite::Connection; use rusqlite_migration::Migrations; use serde::{Deserialize, Serialize}; use thiserror::Error; use uuid::Uuid; static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations"); lazy_static! { static ref MIGRATIONS: Migrations<'static> = Migrations::from_directory(&MIGRATIONS_DIR).unwrap(); } #[derive(Debug, Error)] pub enum Error { #[error("Duplicate item found for id {0}")] DuplicateItem(String), #[error("Unexpected response for message")] MessageMismatch, #[error("No response to request")] NoResponse, } #[derive(Debug)] enum Request { Charsheet(CharacterId), } #[derive(Debug)] struct DatabaseRequest { tx: Sender, req: Request, } #[derive(Debug)] enum DatabaseResponse { Charsheet(Option), } #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] pub struct CharacterId(String); impl CharacterId { pub fn new() -> Self { CharacterId(format!("{}", Uuid::new_v4().hyphenated())) } pub fn as_str<'a>(&'a self) -> &'a str { &self.0 } } impl From<&str> for CharacterId { fn from(s: &str) -> Self { CharacterId(s.to_owned()) } } impl From for CharacterId { fn from(s: String) -> Self { CharacterId(s) } } #[derive(Clone, Debug)] pub struct CharsheetRow { id: String, gametype: String, pub data: serde_json::Value, } #[async_trait] pub trait Database: Send + Sync { async fn charsheet(&mut self, id: CharacterId) -> Result, Error>; } pub struct DiskDb { conn: Connection, } fn setup_test_database(conn: &Connection) { let mut gamecount_stmt = conn.prepare("SELECT count(*) FROM games").unwrap(); let mut count = gamecount_stmt.query([]).unwrap(); if count.next().unwrap().unwrap().get::(0) == Ok(0) { let game_id = format!("{}", Uuid::new_v4()); let char_id = CharacterId::new(); let mut game_stmt = conn.prepare("INSERT INTO games VALUES (?, ?)").unwrap(); game_stmt.execute((game_id.clone(), "Circle of Bluest Sky")); let mut sheet_stmt = conn .prepare("INSERT INTO characters VALUES (?, ?, ?)") .unwrap(); sheet_stmt.execute((char_id.as_str(), game_id, 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." ] }"#)) .unwrap(); } } impl DiskDb { pub fn new

(path: Option

) -> Result where P: AsRef, { let mut conn = match path { None => Connection::open(":memory:").expect("to create a memory connection"), Some(path) => Connection::open(path).expect("to create connection"), }; MIGRATIONS.to_latest(&mut conn).expect("to run migrations"); setup_test_database(&conn); Ok(DiskDb { conn }) } fn charsheet(&self, id: CharacterId) -> Result, Error> { let mut stmt = self .conn .prepare("SELECT uuid, gametype, data FROM charsheet WHERE uuid=?") .unwrap(); let items: Vec = stmt .query_map([id.as_str()], |row| { let data: String = row.get(2).unwrap(); Ok(CharsheetRow { id: row.get(0).unwrap(), gametype: row.get(1).unwrap(), data: serde_json::from_str(&data).unwrap(), }) }) .unwrap() .collect::, rusqlite::Error>>() .unwrap(); match &items[..] { [] => Ok(None), [item] => Ok(Some(item.clone())), _ => unimplemented!(), } } fn save_charsheet( &self, char_id: Option, game_type: String, charsheet: serde_json::Value, ) -> Result { match char_id { None => { let char_id = CharacterId::new(); let mut stmt = self .conn .prepare("INSERT INTO charsheet VALUES (?, ?, ?)") .unwrap(); stmt.execute((char_id.as_str(), game_type, charsheet.to_string())) .unwrap(); Ok(char_id) } Some(char_id) => { let mut stmt = self .conn .prepare("UPDATE charsheet SET data=? WHERE uuid=?") .unwrap(); stmt.execute((charsheet.to_string(), char_id.as_str())) .unwrap(); Ok(char_id) } } } } async fn db_handler(db: DiskDb, requestor: Receiver) { println!("Starting db_handler"); while let Ok(DatabaseRequest { tx, req }) = requestor.recv().await { println!("Request received: {:?}", req); match req { Request::Charsheet(id) => { let sheet = db.charsheet(id); println!("sheet retrieved: {:?}", sheet); match sheet { Ok(sheet) => { tx.send(DatabaseResponse::Charsheet(sheet)).await.unwrap(); } _ => unimplemented!(), } } } } println!("ending db_handler"); } pub struct DbConn { conn: Sender, handle: tokio::task::JoinHandle<()>, } impl DbConn { pub fn new

(path: Option

) -> Self where P: AsRef, { let (tx, rx) = bounded::(5); let db = DiskDb::new(path).unwrap(); let handle = tokio::spawn(async move { db_handler(db, rx).await; }); Self { conn: tx, handle } } } #[async_trait] impl Database for DbConn { async fn charsheet(&mut self, id: CharacterId) -> Result, Error> { let (tx, rx) = bounded::(1); let request = DatabaseRequest { tx, req: Request::Charsheet(id), }; self.conn.send(request).await.unwrap(); match rx.recv().await { Ok(DatabaseResponse::Charsheet(row)) => Ok(row), Ok(_) => Err(Error::MessageMismatch), Err(err) => { println!("error: {:?}", err); Err(Error::NoResponse) } } } } #[cfg(test)] mod test { use cool_asserts::assert_matches; 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." ] }"#; #[test] fn it_can_retrieve_a_charsheet() { let no_path: Option = None; let db = DiskDb::new(no_path).unwrap(); assert_matches!(db.charsheet(CharacterId::from("1")), Ok(None)); let js: serde_json::Value = serde_json::from_str(soren).unwrap(); let soren_id = db .save_charsheet(None, "candela".to_owned(), js.clone()) .unwrap(); assert_matches!(db.charsheet(soren_id).unwrap(), Some(CharsheetRow{ data, .. }) => assert_eq!(js, data)); } #[tokio::test] async fn it_can_retrieve_a_charsheet_through_conn() { let memory_db: Option = None; let mut conn = DbConn::new(memory_db); assert_matches!(conn.charsheet(CharacterId::from("1")).await, Ok(None)); } }