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",
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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)
|
||||||
|
);
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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} />
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
|
@ -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} />
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
|
@ -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>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -9,6 +9,6 @@
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.7.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue