Set up a database and serve character sheets from it #277

Merged
savanni merged 7 commits from visions-database into main 2024-11-30 23:56:21 +00:00
21 changed files with 526 additions and 809 deletions

818
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -15,13 +15,17 @@ warp = { version = "0.3" }
mime_guess = "2.0.5" mime_guess = "2.0.5"
mime = "0.3.17" mime = "0.3.17"
uuid = { version = "1.11.0", features = ["v4"] } uuid = { version = "1.11.0", features = ["v4"] }
futures = "0.3.31"
tokio-stream = "0.1.16" tokio-stream = "0.1.16"
typeshare = "1.0.4" typeshare = "1.0.4"
urlencoding = "2.1.3" 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"
async-trait = "0.1.83"
futures = "0.3.31"
async-std = "1.13.0"
[dev-dependencies] [dev-dependencies]
cool_asserts = "2.0.3" cool_asserts = "2.0.3"

View File

@ -7,7 +7,8 @@ tasks:
test: test:
cmds: cmds:
- cargo watch -x test # - cargo watch -x 'test -- --nocapture'
- cargo watch -x 'nextest run'
dev: dev:
cmds: cmds:

View File

@ -0,0 +1,12 @@
CREATE TABLE games(
uuid TEXT PRIMARY KEY,
name TEXT
);
CREATE TABLE characters(
uuid TEXT PRIMARY KEY,
game TEXT,
data TEXT,
FOREIGN KEY(game) REFERENCES games(uuid)
);

View File

@ -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." ] }

View File

@ -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())
} }

View File

@ -1,17 +1,13 @@
use std::{ use std::{collections::HashMap, sync::Arc};
collections::HashMap,
io::Read,
path::PathBuf,
sync::{Arc, RwLock},
};
use async_std::sync::RwLock;
use mime::Mime; use mime::Mime;
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
use urlencoding::decode;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
asset_db::{self, AssetId, Assets}, asset_db::{self, AssetId, Assets},
database::{CharacterId, Database},
types::{AppError, Message, Tabletop, RGB}, types::{AppError, Message, Tabletop, RGB},
}; };
@ -27,7 +23,8 @@ 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 db: Box<dyn Database + Sync + Send + 'static>,
pub clients: HashMap<String, WebsocketClient>, pub clients: HashMap<String, WebsocketClient>,
pub tabletop: Tabletop, pub tabletop: Tabletop,
@ -37,12 +34,14 @@ pub struct AppState {
pub struct Core(Arc<RwLock<AppState>>); pub struct Core(Arc<RwLock<AppState>>);
impl Core { impl Core {
pub fn new<A>(assetdb: A) -> Self pub fn new<A, DB>(assetdb: A, db: DB) -> Self
where where
A: Assets + Sync + Send + 'static, A: Assets + Sync + Send + 'static,
DB: Database + 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),
db: Box::new(db),
clients: HashMap::new(), clients: HashMap::new(),
tabletop: Tabletop { tabletop: Tabletop {
background_color: DEFAULT_BACKGROUND_COLOR, background_color: DEFAULT_BACKGROUND_COLOR,
@ -51,8 +50,8 @@ impl Core {
}))) })))
} }
pub fn register_client(&self) -> String { pub async fn register_client(&self) -> String {
let mut state = self.0.write().unwrap(); let mut state = self.0.write().await;
let uuid = Uuid::new_v4().simple().to_string(); let uuid = Uuid::new_v4().simple().to_string();
let client = WebsocketClient { sender: None }; let client = WebsocketClient { sender: None };
@ -61,13 +60,13 @@ impl Core {
uuid uuid
} }
pub fn unregister_client(&self, client_id: String) { pub async fn unregister_client(&self, client_id: String) {
let mut state = self.0.write().unwrap(); let mut state = self.0.write().await;
let _ = state.clients.remove(&client_id); let _ = state.clients.remove(&client_id);
} }
pub fn connect_client(&self, client_id: String) -> UnboundedReceiver<Message> { pub async fn connect_client(&self, client_id: String) -> UnboundedReceiver<Message> {
let mut state = self.0.write().unwrap(); let mut state = self.0.write().await;
match state.clients.get_mut(&client_id) { match state.clients.get_mut(&client_id) {
Some(client) => { Some(client) => {
@ -81,15 +80,15 @@ impl Core {
} }
} }
pub fn tabletop(&self) -> Tabletop { pub async fn tabletop(&self) -> Tabletop {
self.0.read().unwrap().tabletop.clone() self.0.read().await.tabletop.clone()
} }
pub async fn get_asset(&self, asset_id: AssetId) -> Result<(Mime, Vec<u8>), AppError> { pub async fn get_asset(&self, asset_id: AssetId) -> Result<(Mime, Vec<u8>), AppError> {
self.0 self.0
.read() .read()
.unwrap() .await
.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)),
@ -98,35 +97,48 @@ impl Core {
}) })
} }
pub fn available_images(&self) -> Vec<AssetId> { pub async fn available_images(&self) -> Vec<AssetId> {
println!("available_images");
self.0 self.0
.read() .read()
.unwrap() .await
.asset_db .asset_store
.assets() .assets()
.filter_map(|(asset_id, value)| { .filter_map(
println!("[{:?}] {}", mime_guess::from_path(&value).first(), value); |(asset_id, 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,
} },
}) )
.collect() .collect()
} }
pub fn set_background_image(&self, asset: AssetId) -> Result<(), AppError> { pub async fn set_background_image(&self, asset: AssetId) -> Result<(), AppError> {
let tabletop = { let tabletop = {
let mut state = self.0.write().unwrap(); let mut state = self.0.write().await;
state.tabletop.background_image = Some(asset.clone()); state.tabletop.background_image = Some(asset.clone());
state.tabletop.clone() state.tabletop.clone()
}; };
self.publish(Message::UpdateTabletop(tabletop)); self.publish(Message::UpdateTabletop(tabletop)).await;
Ok(()) Ok(())
} }
pub fn publish(&self, message: Message) { pub async fn get_charsheet(
let state = self.0.read().unwrap(); &self,
id: CharacterId,
) -> Result<Option<serde_json::Value>, AppError> {
Ok(self
.0
.write()
.await
.db
.charsheet(id)
.await
.unwrap()
.map(|cr| cr.data))
}
pub async fn publish(&self, message: Message) {
let state = self.0.read().await;
state.clients.values().for_each(|client| { state.clients.values().for_each(|client| {
if let Some(ref sender) = client.sender { if let Some(ref sender) = client.sender {
@ -138,11 +150,16 @@ impl Core {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use std::path::PathBuf;
use super::*; use super::*;
use cool_asserts::assert_matches; use cool_asserts::assert_matches;
use crate::asset_db::mocks::MemoryAssets; use crate::{
asset_db::mocks::MemoryAssets,
database::{DbConn, DiskDb},
};
fn test_core() -> Core { fn test_core() -> Core {
let assets = MemoryAssets::new(vec![ let assets = MemoryAssets::new(vec![
@ -172,7 +189,9 @@ mod test {
String::from("abcdefg").into_bytes(), String::from("abcdefg").into_bytes(),
), ),
]); ]);
Core::new(assets) let memory_db: Option<PathBuf> = None;
let conn = DbConn::new(memory_db);
Core::new(assets, conn)
} }
#[tokio::test] #[tokio::test]

View File

@ -0,0 +1,274 @@
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<DatabaseResponse>,
req: Request,
}
#[derive(Debug)]
enum DatabaseResponse {
Charsheet(Option<CharsheetRow>),
}
#[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,
pub data: serde_json::Value,
}
#[async_trait]
pub trait Database: Send + Sync {
async fn charsheet(&mut self, id: CharacterId) -> Result<Option<CharsheetRow>, 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::<usize, usize>(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<P>(path: Option<P>) -> Result<Self, Error>
where
P: AsRef<Path>,
{
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<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)
}
}
}
}
async fn db_handler(db: DiskDb, requestor: Receiver<DatabaseRequest>) {
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<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 }
}
}
#[async_trait]
impl Database for DbConn {
async fn charsheet(&mut self, id: CharacterId) -> Result<Option<CharsheetRow>, Error> {
let (tx, rx) = bounded::<DatabaseResponse>(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<PathBuf> = 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<PathBuf> = None;
let mut conn = DbConn::new(memory_db);
assert_matches!(conn.charsheet(CharacterId::from("1")).await, Ok(None));
}
}

View File

@ -4,7 +4,7 @@ use futures::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use warp::{http::Response, http::StatusCode, reply::Reply, ws::Message}; use warp::{http::Response, http::StatusCode, reply::Reply, ws::Message};
use crate::{asset_db::AssetId, core::Core, types::AppError}; use crate::{asset_db::AssetId, core::Core, database::CharacterId, types::AppError};
/* /*
pub async fn handle_auth( pub async fn handle_auth(
@ -62,6 +62,7 @@ pub async fn handle_available_images(core: Core) -> impl Reply {
handler(async move { handler(async move {
let image_paths: Vec<String> = core let image_paths: Vec<String> = core
.available_images() .available_images()
.await
.into_iter() .into_iter()
.map(|path| format!("{}", path.as_str())) .map(|path| format!("{}", path.as_str()))
.collect(); .collect();
@ -85,7 +86,7 @@ pub struct RegisterResponse {
pub async fn handle_register_client(core: Core, _request: RegisterRequest) -> impl Reply { pub async fn handle_register_client(core: Core, _request: RegisterRequest) -> impl Reply {
handler(async move { handler(async move {
let client_id = core.register_client(); let client_id = core.register_client().await;
Ok(Response::builder() Ok(Response::builder()
.header("Access-Control-Allow-Origin", "*") .header("Access-Control-Allow-Origin", "*")
@ -105,7 +106,10 @@ pub async fn handle_unregister_client(core: Core, client_id: String) -> impl Rep
handler(async move { handler(async move {
core.unregister_client(client_id); core.unregister_client(client_id);
Ok(Response::builder().status(StatusCode::NO_CONTENT).body(vec![]).unwrap()) Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(vec![])
.unwrap())
}) })
.await .await
} }
@ -120,10 +124,10 @@ pub async fn handle_connect_websocket(
let core = core.clone(); let core = core.clone();
async move { async move {
let (mut ws_sender, _) = socket.split(); let (mut ws_sender, _) = socket.split();
let mut receiver = core.connect_client(client_id.clone()); let mut receiver = core.connect_client(client_id.clone()).await;
tokio::task::spawn(async move { tokio::task::spawn(async move {
let tabletop = core.tabletop(); let tabletop = core.tabletop().await;
let _ = ws_sender let _ = ws_sender
.send(Message::text( .send(Message::text(
serde_json::to_string(&crate::types::Message::UpdateTabletop(tabletop)) serde_json::to_string(&crate::types::Message::UpdateTabletop(tabletop))
@ -144,7 +148,7 @@ pub async fn handle_connect_websocket(
pub async fn handle_set_background_image(core: Core, image_name: String) -> impl Reply { pub async fn handle_set_background_image(core: Core, image_name: String) -> impl Reply {
handler(async move { handler(async move {
let _ = core.set_background_image(AssetId::from(image_name)); let _ = core.set_background_image(AssetId::from(image_name)).await;
Ok(Response::builder() Ok(Response::builder()
.header("Access-Control-Allow-Origin", "*") .header("Access-Control-Allow-Origin", "*")
@ -155,3 +159,22 @@ pub async fn handle_set_background_image(core: Core, image_name: String) -> impl
}) })
.await .await
} }
pub async fn handle_get_charsheet(core: Core, charid: String) -> impl Reply {
handler(async move {
let sheet = core.get_charsheet(CharacterId::from(charid)).await.unwrap();
match sheet {
Some(sheet) => Ok(Response::builder()
.header("Access-Control-Allow-Origin", "*")
.header("Content-Type", "application/json")
.body(serde_json::to_vec(&sheet).unwrap())
.unwrap()),
None => Ok(Response::builder()
.status(StatusCode::NOT_FOUND)
.body(vec![])
.unwrap()),
}
})
.await
}

View File

@ -1,11 +1,14 @@
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::DbConn;
use handlers::{
handle_available_images, handle_connect_websocket, handle_file, handle_get_charsheet, handle_register_client, handle_set_background_image, handle_unregister_client, RegisterRequest
}; };
use warp::{ use warp::{
// header, // header,
@ -16,8 +19,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 +99,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 conn = DbConn::new(Some("/home/savanni/game.db"));
let core = core::Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn);
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 +157,15 @@ 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 route_get_charsheet = warp::path!("api" / "v1" / "charsheet" / String)
.and(warp::get())
.then({
let core = core.clone();
move |charid| handle_get_charsheet(core.clone(), charid)
});
let filter = route_register_client let filter = route_register_client
.or(route_unregister_client) .or(route_unregister_client)
@ -161,6 +174,7 @@ pub async fn main() {
.or(route_available_images) .or(route_available_images)
.or(route_set_bg_image_options) .or(route_set_bg_image_options)
.or(route_set_bg_image) .or(route_set_bg_image)
.or(route_get_charsheet)
.recover(handle_rejection); .recover(handle_rejection);
let server = warp::serve(filter); let server = warp::serve(filter);

View File

@ -3,5 +3,7 @@ version: '3'
tasks: tasks:
dev: dev:
cmds: cmds:
- cd ../visions-types && task build
- npm install
- npm run start - npm run start

View File

@ -33,7 +33,7 @@
"version": "0.0.1", "version": "0.0.1",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"typescript": "^5.6.3" "typescript": "^5.7.2"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {

View File

@ -12,6 +12,16 @@ interface AppProps {
client: Client; client: Client;
} }
const CandelaCharsheet = ({ client }: { client: Client }) => {
let [sheet, setSheet] = useState(undefined);
useEffect(
() => { client.charsheet("db7a2585-5dcf-4909-8743-2741111f8b9a").then((c) => setSheet(c)); },
[client, setSheet]
);
return sheet ? <Candela.CharsheetElement sheet={sheet} /> : <div> </div>
}
const App = ({ client }: AppProps) => { const App = ({ client }: AppProps) => {
console.log("rendering app"); console.log("rendering app");
const [websocketUrl, setWebsocketUrl] = useState<string | undefined>(undefined); const [websocketUrl, setWebsocketUrl] = useState<string | undefined>(undefined);
@ -32,7 +42,7 @@ const App = ({ client }: AppProps) => {
}, },
{ {
path: "/candela", path: "/candela",
element: <Candela.CharsheetElement /> element: <CandelaCharsheet client={client} />
}, },
{ {
path: "/design", path: "/design",

View File

@ -44,4 +44,10 @@ export class Client {
url.pathname = `/api/v1/tabletop/bg_image`; url.pathname = `/api/v1/tabletop/bg_image`;
return fetch(url, { method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify(name) }); return fetch(url, { method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify(name) });
} }
async charsheet(id: string) {
const url = new URL(this.base);
url.pathname = `/api/v1/charsheet/${id}`;
return fetch(url).then((response) => response.json());
}
} }

View File

@ -88,7 +88,7 @@ const AbilitiesElement = ({ role, role_abilities, specialty, specialty_abilities
); );
} }
const CharsheetElement_ = ({ sheet }: CharsheetProps) => { export const CharsheetElement = ({ sheet }: CharsheetProps) => {
return (<div> return (<div>
<div className="charsheet__header"> <div className="charsheet__header">
<div> Candela Obscura </div> <div> Candela Obscura </div>
@ -115,6 +115,7 @@ const CharsheetElement_ = ({ sheet }: CharsheetProps) => {
</div>); </div>);
} }
/*
export const CharsheetElement = () => { export const CharsheetElement = () => {
const sheet = { const sheet = {
type_: 'Candela', type_: 'Candela',
@ -160,3 +161,4 @@ export const CharsheetElement = () => {
return <CharsheetElement_ sheet={sheet} /> return <CharsheetElement_ sheet={sheet} />
} }
*/

View File

@ -66,7 +66,7 @@ const ActionGroupElement = ({ group }: ActionGroupElementProps) => {
} }
const CharsheetPanelElement_ = ({ sheet }: CharsheetPanelProps) => { export const CharsheetPanelElement = ({ sheet }: CharsheetPanelProps) => {
return (<div className="candela-panel"> return (<div className="candela-panel">
<div className="candela-panel__header"> <div className="candela-panel__header">
<p> {sheet.name} ({sheet.pronouns}) </p> <p> {sheet.name} ({sheet.pronouns}) </p>
@ -88,6 +88,7 @@ const CharsheetPanelElement_ = ({ sheet }: CharsheetPanelProps) => {
</div>); </div>);
} }
/*
export const CharsheetPanelElement = () => { export const CharsheetPanelElement = () => {
const sheet = { const sheet = {
type_: 'Candela', type_: 'Candela',
@ -133,3 +134,4 @@ export const CharsheetPanelElement = () => {
return <CharsheetPanelElement_ sheet={sheet} /> return <CharsheetPanelElement_ sheet={sheet} />
} }
*/

View File

@ -1,4 +1,4 @@
import React, { useContext } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import './PlayerView.css'; import './PlayerView.css';
import { WebsocketContext } from '../../components/WebsocketProvider'; import { WebsocketContext } from '../../components/WebsocketProvider';
import { Client } from '../../client'; import { Client } from '../../client';
@ -12,13 +12,25 @@ interface PlayerViewProps {
export const PlayerView = ({ client }: PlayerViewProps) => { export const PlayerView = ({ client }: PlayerViewProps) => {
const { tabletop } = useContext(WebsocketContext); const { tabletop } = useContext(WebsocketContext);
const [charsheet, setCharsheet] = useState(undefined);
useEffect(
() => {
client.charsheet("db7a2585-5dcf-4909-8743-2741111f8b9a").then((c) => {
setCharsheet(c)
});
},
[client, setCharsheet]
);
const backgroundColor = tabletop.backgroundColor; const backgroundColor = tabletop.backgroundColor;
const tabletopColorStyle = `rgb(${backgroundColor.red}, ${backgroundColor.green}, ${backgroundColor.blue})`; const tabletopColorStyle = `rgb(${backgroundColor.red}, ${backgroundColor.green}, ${backgroundColor.blue})`;
const backgroundUrl = tabletop.backgroundImage ? client.imageUrl(tabletop.backgroundImage) : undefined; const backgroundUrl = tabletop.backgroundImage ? client.imageUrl(tabletop.backgroundImage) : undefined;
return (<div className="player-view" style={{ backgroundColor: tabletopColorStyle }}> return (<div className="player-view" style={{ backgroundColor: tabletopColorStyle }}>
<div className="player-view__middle-panel"> <TabletopElement backgroundColor={backgroundColor} backgroundUrl={backgroundUrl} /> </div> <div className="player-view__middle-panel"> <TabletopElement backgroundColor={backgroundColor} backgroundUrl={backgroundUrl} /> </div>
<div className="player-view__right-panel"> <Candela.CharsheetPanelElement /> </div> <div className="player-view__right-panel">
{charsheet ? <Candela.CharsheetPanelElement sheet={charsheet} /> : <div> </div>}</div>
</div>) </div>)
} }

View File

@ -3,5 +3,6 @@ version: '3'
tasks: tasks:
build: build:
cmds: cmds:
- npm install typescript
- typeshare --lang typescript --output-file visions.ts ../server/src - typeshare --lang typescript --output-file visions.ts ../server/src
- npx tsc - npx tsc

View File

@ -9,13 +9,13 @@
"version": "0.0.1", "version": "0.0.1",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"typescript": "^5.6.3" "typescript": "^5.7.2"
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.6.3", "version": "5.7.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"

View File

@ -9,6 +9,6 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"typescript": "^5.6.3" "typescript": "^5.7.2"
} }
} }