Set up a database and serve character sheets from it #277
File diff suppressed because it is too large
Load Diff
|
@ -1,7 +1,7 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"authdb",
|
# "authdb",
|
||||||
# "bike-lights/bike",
|
# "bike-lights/bike",
|
||||||
"bike-lights/core",
|
"bike-lights/core",
|
||||||
"bike-lights/simulator",
|
"bike-lights/simulator",
|
||||||
|
@ -14,7 +14,7 @@ members = [
|
||||||
"cyberpunk-splash",
|
"cyberpunk-splash",
|
||||||
"dashboard",
|
"dashboard",
|
||||||
"emseries",
|
"emseries",
|
||||||
"file-service",
|
# "file-service",
|
||||||
"fitnesstrax/core",
|
"fitnesstrax/core",
|
||||||
"fitnesstrax/app",
|
"fitnesstrax/app",
|
||||||
"fluent-ergonomics",
|
"fluent-ergonomics",
|
||||||
|
|
|
@ -22,6 +22,8 @@ urlencoding = "2.1.3"
|
||||||
thiserror = "2.0.3"
|
thiserror = "2.0.3"
|
||||||
rusqlite = "0.32.1"
|
rusqlite = "0.32.1"
|
||||||
rusqlite_migration = { version = "1.3.1", features = ["from-directory"] }
|
rusqlite_migration = { version = "1.3.1", features = ["from-directory"] }
|
||||||
|
lazy_static = "1.5.0"
|
||||||
|
include_dir = "0.7.4"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
cool_asserts = "2.0.3"
|
cool_asserts = "2.0.3"
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
CREATE TABLE game(
|
||||||
|
uuid TEXT PRIMARY KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE charsheet(
|
||||||
|
uuid TEXT PRIMARY KEY,
|
||||||
|
gametype TEXT NOT NULL,
|
||||||
|
data TEXT
|
||||||
|
);
|
|
@ -0,0 +1 @@
|
||||||
|
{ "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." ] }
|
|
@ -84,7 +84,6 @@ impl FsAssets {
|
||||||
let mut assets = HashMap::new();
|
let mut assets = HashMap::new();
|
||||||
|
|
||||||
for dir_ent in dir {
|
for dir_ent in dir {
|
||||||
println!("{:?}", dir_ent);
|
|
||||||
let path = dir_ent.unwrap().path();
|
let path = dir_ent.unwrap().path();
|
||||||
let file_name = path.file_name().unwrap().to_str().unwrap();
|
let file_name = path.file_name().unwrap().to_str().unwrap();
|
||||||
assets.insert(AssetId::from(file_name), path.to_str().unwrap().to_owned());
|
assets.insert(AssetId::from(file_name), path.to_str().unwrap().to_owned());
|
||||||
|
@ -97,7 +96,6 @@ impl FsAssets {
|
||||||
|
|
||||||
impl Assets for FsAssets {
|
impl Assets for FsAssets {
|
||||||
fn assets<'a>(&'a self) -> AssetIter<'a> {
|
fn assets<'a>(&'a self) -> AssetIter<'a> {
|
||||||
println!("FsAssets assets: {:?}", self.assets);
|
|
||||||
AssetIter(self.assets.iter())
|
AssetIter(self.assets.iter())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,8 +11,7 @@ use urlencoding::decode;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
asset_db::{self, AssetId, Assets},
|
asset_db::{self, AssetId, Assets}, database::Database, types::{AppError, Message, Tabletop, RGB}
|
||||||
types::{AppError, Message, Tabletop, RGB},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_BACKGROUND_COLOR: RGB = RGB {
|
const DEFAULT_BACKGROUND_COLOR: RGB = RGB {
|
||||||
|
@ -27,7 +26,7 @@ struct WebsocketClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub asset_db: Box<dyn Assets + Sync + Send + 'static>,
|
pub asset_store: Box<dyn Assets + Sync + Send + 'static>,
|
||||||
pub clients: HashMap<String, WebsocketClient>,
|
pub clients: HashMap<String, WebsocketClient>,
|
||||||
|
|
||||||
pub tabletop: Tabletop,
|
pub tabletop: Tabletop,
|
||||||
|
@ -42,7 +41,7 @@ impl Core {
|
||||||
A: Assets + Sync + Send + 'static,
|
A: Assets + Sync + Send + 'static,
|
||||||
{
|
{
|
||||||
Self(Arc::new(RwLock::new(AppState {
|
Self(Arc::new(RwLock::new(AppState {
|
||||||
asset_db: Box::new(assetdb),
|
asset_store: Box::new(assetdb),
|
||||||
clients: HashMap::new(),
|
clients: HashMap::new(),
|
||||||
tabletop: Tabletop {
|
tabletop: Tabletop {
|
||||||
background_color: DEFAULT_BACKGROUND_COLOR,
|
background_color: DEFAULT_BACKGROUND_COLOR,
|
||||||
|
@ -89,7 +88,7 @@ impl Core {
|
||||||
self.0
|
self.0
|
||||||
.read()
|
.read()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.asset_db
|
.asset_store
|
||||||
.get(asset_id.clone())
|
.get(asset_id.clone())
|
||||||
.map_err(|err| match err {
|
.map_err(|err| match err {
|
||||||
asset_db::Error::NotFound => AppError::NotFound(format!("{}", asset_id)),
|
asset_db::Error::NotFound => AppError::NotFound(format!("{}", asset_id)),
|
||||||
|
@ -99,14 +98,12 @@ impl Core {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn available_images(&self) -> Vec<AssetId> {
|
pub fn available_images(&self) -> Vec<AssetId> {
|
||||||
println!("available_images");
|
|
||||||
self.0
|
self.0
|
||||||
.read()
|
.read()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.asset_db
|
.asset_store
|
||||||
.assets()
|
.assets()
|
||||||
.filter_map(|(asset_id, value)| {
|
.filter_map(|(asset_id, value)| {
|
||||||
println!("[{:?}] {}", mime_guess::from_path(&value).first(), value);
|
|
||||||
match mime_guess::from_path(&value).first() {
|
match mime_guess::from_path(&value).first() {
|
||||||
Some(mime) if mime.type_() == mime::IMAGE => Some(asset_id.clone()),
|
Some(mime) if mime.type_() == mime::IMAGE => Some(asset_id.clone()),
|
||||||
_ => None,
|
_ => None,
|
||||||
|
|
|
@ -0,0 +1,192 @@
|
||||||
|
use std::{
|
||||||
|
path::PathBuf,
|
||||||
|
sync::mpsc::{channel, Receiver, Sender},
|
||||||
|
thread::JoinHandle,
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String> for CharacterId {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
CharacterId(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct CharsheetRow {
|
||||||
|
id: String,
|
||||||
|
gametype: String,
|
||||||
|
data: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Database {
|
||||||
|
async fn charsheet(&self, id: CharacterId) -> Result<Option<CharsheetRow>, Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DiskDb {
|
||||||
|
conn: Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiskDb {
|
||||||
|
pub fn new(path: Option<PathBuf>) -> Result<Self, Error> {
|
||||||
|
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");
|
||||||
|
Ok(DiskDb { conn })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn charsheet(&self, id: CharacterId) -> Result<Option<CharsheetRow>, Error> {
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare("SELECT uuid, gametype, data FROM charsheet WHERE uuid=?")
|
||||||
|
.unwrap();
|
||||||
|
let items: Vec<CharsheetRow> = 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::<Result<Vec<CharsheetRow>, rusqlite::Error>>()
|
||||||
|
.unwrap();
|
||||||
|
match &items[..] {
|
||||||
|
[] => Ok(None),
|
||||||
|
[item] => Ok(Some(item.clone())),
|
||||||
|
_ => unimplemented!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_charsheet(
|
||||||
|
&self,
|
||||||
|
char_id: Option<CharacterId>,
|
||||||
|
game_type: String,
|
||||||
|
charsheet: serde_json::Value,
|
||||||
|
) -> Result<CharacterId, Error> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
enum DatabaseRequest {
|
||||||
|
Charsheet(CharacterId),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DatabaseResponse {
|
||||||
|
Charsheet(Option<CharsheetRow>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DiskDb {
|
||||||
|
db_handle: JoinHandle<()>,
|
||||||
|
to_db: Sender<DatabaseRequest>,
|
||||||
|
from_db: Receiver<DatabaseResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiskDb {
|
||||||
|
pub fn new(path: PathBuf) -> DiskDb {
|
||||||
|
DiskDb {
|
||||||
|
db_handle,
|
||||||
|
to_db: interface_to_db_tx,
|
||||||
|
from_db: db_to_interface_rx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database for DiskDb {
|
||||||
|
async fn charsheet(&self, id: CharacterId) -> Result<Option<CharsheetRow>, Error> {
|
||||||
|
self.to_db(DatabaseRequest::Charsheet(id)).unwrap();
|
||||||
|
match self.from_db.recv() {
|
||||||
|
Ok(DatabaseResponse::Charsheet(sheet)) => Ok(sheet),
|
||||||
|
Ok(_) => Err(Error::MessageMismatch),
|
||||||
|
Err(_) => unimplemented!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
#[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 db = DiskDb::new(None).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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +1,15 @@
|
||||||
use asset_db::{AssetId, FsAssets};
|
|
||||||
use authdb::AuthError;
|
|
||||||
use handlers::{
|
|
||||||
handle_available_images, handle_connect_websocket, handle_file, handle_register_client, handle_set_background_image, handle_unregister_client, RegisterRequest
|
|
||||||
};
|
|
||||||
use std::{
|
use std::{
|
||||||
convert::Infallible,
|
convert::Infallible,
|
||||||
net::{IpAddr, Ipv4Addr, SocketAddr}, path::PathBuf,
|
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||||
|
path::PathBuf,
|
||||||
|
};
|
||||||
|
|
||||||
|
use asset_db::{AssetId, FsAssets};
|
||||||
|
use authdb::AuthError;
|
||||||
|
use database::{CharacterId, Database};
|
||||||
|
use handlers::{
|
||||||
|
handle_available_images, handle_connect_websocket, handle_file, handle_register_client,
|
||||||
|
handle_set_background_image, handle_unregister_client, RegisterRequest,
|
||||||
};
|
};
|
||||||
use warp::{
|
use warp::{
|
||||||
// header,
|
// header,
|
||||||
|
@ -16,8 +20,8 @@ use warp::{
|
||||||
|
|
||||||
mod asset_db;
|
mod asset_db;
|
||||||
mod core;
|
mod core;
|
||||||
|
mod database;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
|
|
||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
@ -96,7 +100,9 @@ async fn handle_rejection(err: warp::Rejection) -> Result<impl Reply, Infallible
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
pub async fn main() {
|
pub async fn main() {
|
||||||
let core = core::Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")));
|
let core = core::Core::new(
|
||||||
|
FsAssets::new(PathBuf::from("/home/savanni/Pictures"))
|
||||||
|
);
|
||||||
let log = warp::log("visions::api");
|
let log = warp::log("visions::api");
|
||||||
|
|
||||||
let route_image = warp::path!("api" / "v1" / "image" / String)
|
let route_image = warp::path!("api" / "v1" / "image" / String)
|
||||||
|
@ -152,7 +158,8 @@ pub async fn main() {
|
||||||
.then({
|
.then({
|
||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
move |body| handle_set_background_image(core.clone(), body)
|
move |body| handle_set_background_image(core.clone(), body)
|
||||||
}).with(log);
|
})
|
||||||
|
.with(log);
|
||||||
|
|
||||||
let filter = route_register_client
|
let filter = route_register_client
|
||||||
.or(route_unregister_client)
|
.or(route_unregister_client)
|
||||||
|
|
Loading…
Reference in New Issue