Set up the user interface state model and set up the admin user onboarding #283
|
@ -4294,6 +4294,7 @@ dependencies = [
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"mime",
|
"mime",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
|
"result-extended",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"rusqlite_migration",
|
"rusqlite_migration",
|
||||||
"serde 1.0.210",
|
"serde 1.0.210",
|
||||||
|
|
|
@ -33,9 +33,9 @@ use std::{error::Error, fmt};
|
||||||
/// statement.
|
/// statement.
|
||||||
pub trait FatalError: Error {}
|
pub trait FatalError: Error {}
|
||||||
|
|
||||||
/// Result<A, FE, E> represents a return value that might be a success, might be a fatal error, or
|
/// ResultExt<A, FE, E> represents a return value that might be a success, might be a fatal error, or
|
||||||
/// might be a normal handleable error.
|
/// might be a normal handleable error.
|
||||||
pub enum Result<A, E, FE> {
|
pub enum ResultExt<A, E, FE> {
|
||||||
/// The operation was successful
|
/// The operation was successful
|
||||||
Ok(A),
|
Ok(A),
|
||||||
/// Ordinary errors. These should be handled and the application should recover gracefully.
|
/// Ordinary errors. These should be handled and the application should recover gracefully.
|
||||||
|
@ -45,72 +45,72 @@ pub enum Result<A, E, FE> {
|
||||||
Fatal(FE),
|
Fatal(FE),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<A, E, FE> Result<A, E, FE> {
|
impl<A, E, FE> ResultExt<A, E, FE> {
|
||||||
/// Apply an infallible function to a successful value.
|
/// Apply an infallible function to a successful value.
|
||||||
pub fn map<B, O>(self, mapper: O) -> Result<B, E, FE>
|
pub fn map<B, O>(self, mapper: O) -> ResultExt<B, E, FE>
|
||||||
where
|
where
|
||||||
O: FnOnce(A) -> B,
|
O: FnOnce(A) -> B,
|
||||||
{
|
{
|
||||||
match self {
|
match self {
|
||||||
Result::Ok(val) => Result::Ok(mapper(val)),
|
ResultExt::Ok(val) => ResultExt::Ok(mapper(val)),
|
||||||
Result::Err(err) => Result::Err(err),
|
ResultExt::Err(err) => ResultExt::Err(err),
|
||||||
Result::Fatal(err) => Result::Fatal(err),
|
ResultExt::Fatal(err) => ResultExt::Fatal(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a potentially fallible function to a successful value.
|
/// Apply a potentially fallible function to a successful value.
|
||||||
///
|
///
|
||||||
/// Like `Result.and_then`, the mapping function can itself fail.
|
/// Like `Result.and_then`, the mapping function can itself fail.
|
||||||
pub fn and_then<B, O>(self, handler: O) -> Result<B, E, FE>
|
pub fn and_then<B, O>(self, handler: O) -> ResultExt<B, E, FE>
|
||||||
where
|
where
|
||||||
O: FnOnce(A) -> Result<B, E, FE>,
|
O: FnOnce(A) -> ResultExt<B, E, FE>,
|
||||||
{
|
{
|
||||||
match self {
|
match self {
|
||||||
Result::Ok(val) => handler(val),
|
ResultExt::Ok(val) => handler(val),
|
||||||
Result::Err(err) => Result::Err(err),
|
ResultExt::Err(err) => ResultExt::Err(err),
|
||||||
Result::Fatal(err) => Result::Fatal(err),
|
ResultExt::Fatal(err) => ResultExt::Fatal(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a normal error from one type to another. This is useful for converting an error from
|
/// Map a normal error from one type to another. This is useful for converting an error from
|
||||||
/// one type to another, especially in re-throwing an underlying error. `?` syntax does not
|
/// one type to another, especially in re-throwing an underlying error. `?` syntax does not
|
||||||
/// work with `Result`, so you will likely need to use this a lot.
|
/// work with `Result`, so you will likely need to use this a lot.
|
||||||
pub fn map_err<F, O>(self, mapper: O) -> Result<A, F, FE>
|
pub fn map_err<F, O>(self, mapper: O) -> ResultExt<A, F, FE>
|
||||||
where
|
where
|
||||||
O: FnOnce(E) -> F,
|
O: FnOnce(E) -> F,
|
||||||
{
|
{
|
||||||
match self {
|
match self {
|
||||||
Result::Ok(val) => Result::Ok(val),
|
ResultExt::Ok(val) => ResultExt::Ok(val),
|
||||||
Result::Err(err) => Result::Err(mapper(err)),
|
ResultExt::Err(err) => ResultExt::Err(mapper(err)),
|
||||||
Result::Fatal(err) => Result::Fatal(err),
|
ResultExt::Fatal(err) => ResultExt::Fatal(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provide a function to use to recover from (or simply re-throw) an error.
|
/// Provide a function to use to recover from (or simply re-throw) an error.
|
||||||
pub fn or_else<O, F>(self, handler: O) -> Result<A, F, FE>
|
pub fn or_else<O, F>(self, handler: O) -> ResultExt<A, F, FE>
|
||||||
where
|
where
|
||||||
O: FnOnce(E) -> Result<A, F, FE>,
|
O: FnOnce(E) -> ResultExt<A, F, FE>,
|
||||||
{
|
{
|
||||||
match self {
|
match self {
|
||||||
Result::Ok(val) => Result::Ok(val),
|
ResultExt::Ok(val) => ResultExt::Ok(val),
|
||||||
Result::Err(err) => handler(err),
|
ResultExt::Err(err) => handler(err),
|
||||||
Result::Fatal(err) => Result::Fatal(err),
|
ResultExt::Fatal(err) => ResultExt::Fatal(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert from a normal `Result` type to a `Result` type. The error condition for a `Result` will
|
/// Convert from a normal `Result` type to a `ResultExt` type. The error condition for a `Result` will
|
||||||
/// be treated as `Result::Err`, never `Result::Fatal`.
|
/// be treated as `Result::Err`, never `Result::Fatal`.
|
||||||
impl<A, E, FE> From<std::result::Result<A, E>> for Result<A, E, FE> {
|
impl<A, E, FE> From<std::result::Result<A, E>> for ResultExt<A, E, FE> {
|
||||||
fn from(r: std::result::Result<A, E>) -> Self {
|
fn from(r: std::result::Result<A, E>) -> Self {
|
||||||
match r {
|
match r {
|
||||||
Ok(val) => Result::Ok(val),
|
Ok(val) => ResultExt::Ok(val),
|
||||||
Err(err) => Result::Err(err),
|
Err(err) => ResultExt::Err(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<A, E, FE> fmt::Debug for Result<A, E, FE>
|
impl<A, E, FE> fmt::Debug for ResultExt<A, E, FE>
|
||||||
where
|
where
|
||||||
A: fmt::Debug,
|
A: fmt::Debug,
|
||||||
FE: fmt::Debug,
|
FE: fmt::Debug,
|
||||||
|
@ -118,14 +118,14 @@ where
|
||||||
{
|
{
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Result::Ok(val) => f.write_fmt(format_args!("Result::Ok {:?}", val)),
|
ResultExt::Ok(val) => f.write_fmt(format_args!("Result::Ok {:?}", val)),
|
||||||
Result::Err(err) => f.write_fmt(format_args!("Result::Err {:?}", err)),
|
ResultExt::Err(err) => f.write_fmt(format_args!("Result::Err {:?}", err)),
|
||||||
Result::Fatal(err) => f.write_fmt(format_args!("Result::Fatal {:?}", err)),
|
ResultExt::Fatal(err) => f.write_fmt(format_args!("Result::Fatal {:?}", err)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<A, E, FE> PartialEq for Result<A, E, FE>
|
impl<A, E, FE> PartialEq for ResultExt<A, E, FE>
|
||||||
where
|
where
|
||||||
A: PartialEq,
|
A: PartialEq,
|
||||||
FE: PartialEq,
|
FE: PartialEq,
|
||||||
|
@ -133,27 +133,27 @@ where
|
||||||
{
|
{
|
||||||
fn eq(&self, rhs: &Self) -> bool {
|
fn eq(&self, rhs: &Self) -> bool {
|
||||||
match (self, rhs) {
|
match (self, rhs) {
|
||||||
(Result::Ok(val), Result::Ok(rhs)) => val == rhs,
|
(ResultExt::Ok(val), ResultExt::Ok(rhs)) => val == rhs,
|
||||||
(Result::Err(_), Result::Err(_)) => true,
|
(ResultExt::Err(_), ResultExt::Err(_)) => true,
|
||||||
(Result::Fatal(_), Result::Fatal(_)) => true,
|
(ResultExt::Fatal(_), ResultExt::Fatal(_)) => true,
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience function to create an ok value.
|
/// Convenience function to create an ok value.
|
||||||
pub fn ok<A, E: Error, FE: FatalError>(val: A) -> Result<A, E, FE> {
|
pub fn ok<A, E: Error, FE: FatalError>(val: A) -> ResultExt<A, E, FE> {
|
||||||
Result::Ok(val)
|
ResultExt::Ok(val)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience function to create an error value.
|
/// Convenience function to create an error value.
|
||||||
pub fn error<A, E: Error, FE: FatalError>(err: E) -> Result<A, E, FE> {
|
pub fn error<A, E: Error, FE: FatalError>(err: E) -> ResultExt<A, E, FE> {
|
||||||
Result::Err(err)
|
ResultExt::Err(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience function to create a fatal value.
|
/// Convenience function to create a fatal value.
|
||||||
pub fn fatal<A, E: Error, FE: FatalError>(err: FE) -> Result<A, E, FE> {
|
pub fn fatal<A, E: Error, FE: FatalError>(err: FE) -> ResultExt<A, E, FE> {
|
||||||
Result::Fatal(err)
|
ResultExt::Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return early from the current function if the value is a fatal error.
|
/// Return early from the current function if the value is a fatal error.
|
||||||
|
@ -161,9 +161,9 @@ pub fn fatal<A, E: Error, FE: FatalError>(err: FE) -> Result<A, E, FE> {
|
||||||
macro_rules! return_fatal {
|
macro_rules! return_fatal {
|
||||||
($x:expr) => {
|
($x:expr) => {
|
||||||
match $x {
|
match $x {
|
||||||
Result::Fatal(err) => return Result::Fatal(err),
|
ResultExt::Fatal(err) => return ResultExt::Fatal(err),
|
||||||
Result::Err(err) => Err(err),
|
ResultExt::Err(err) => Err(err),
|
||||||
Result::Ok(val) => Ok(val),
|
ResultExt::Ok(val) => Ok(val),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -173,9 +173,9 @@ macro_rules! return_fatal {
|
||||||
macro_rules! return_error {
|
macro_rules! return_error {
|
||||||
($x:expr) => {
|
($x:expr) => {
|
||||||
match $x {
|
match $x {
|
||||||
Result::Ok(val) => val,
|
ResultExt::Ok(val) => val,
|
||||||
Result::Err(err) => return Result::Err(err),
|
ResultExt::Err(err) => return ResultExt::Err(err),
|
||||||
Result::Fatal(err) => return Result::Fatal(err),
|
ResultExt::Fatal(err) => return ResultExt::Fatal(err),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -210,19 +210,19 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn it_can_map_things() {
|
fn it_can_map_things() {
|
||||||
let success: Result<i32, Error, FatalError> = ok(15);
|
let success: ResultExt<i32, Error, FatalError> = ok(15);
|
||||||
assert_eq!(ok(16), success.map(|v| v + 1));
|
assert_eq!(ok(16), success.map(|v| v + 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn it_can_chain_success() {
|
fn it_can_chain_success() {
|
||||||
let success: Result<i32, Error, FatalError> = ok(15);
|
let success: ResultExt<i32, Error, FatalError> = ok(15);
|
||||||
assert_eq!(ok(16), success.and_then(|v| ok(v + 1)));
|
assert_eq!(ok(16), success.and_then(|v| ok(v + 1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn it_can_handle_an_error() {
|
fn it_can_handle_an_error() {
|
||||||
let failure: Result<i32, Error, FatalError> = error(Error::Error);
|
let failure: ResultExt<i32, Error, FatalError> = error(Error::Error);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ok::<i32, Error, FatalError>(16),
|
ok::<i32, Error, FatalError>(16),
|
||||||
failure.or_else(|_| ok(16))
|
failure.or_else(|_| ok(16))
|
||||||
|
@ -231,7 +231,7 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn early_exit_on_fatal() {
|
fn early_exit_on_fatal() {
|
||||||
fn ok_func() -> Result<i32, Error, FatalError> {
|
fn ok_func() -> ResultExt<i32, Error, FatalError> {
|
||||||
let value = return_fatal!(ok::<i32, Error, FatalError>(15));
|
let value = return_fatal!(ok::<i32, Error, FatalError>(15));
|
||||||
match value {
|
match value {
|
||||||
Ok(_) => ok(14),
|
Ok(_) => ok(14),
|
||||||
|
@ -239,7 +239,7 @@ mod test {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn err_func() -> Result<i32, Error, FatalError> {
|
fn err_func() -> ResultExt<i32, Error, FatalError> {
|
||||||
let value = return_fatal!(error::<i32, Error, FatalError>(Error::Error));
|
let value = return_fatal!(error::<i32, Error, FatalError>(Error::Error));
|
||||||
match value {
|
match value {
|
||||||
Ok(_) => panic!("shouldn't have gotten here"),
|
Ok(_) => panic!("shouldn't have gotten here"),
|
||||||
|
@ -247,7 +247,7 @@ mod test {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fatal_func() -> Result<i32, Error, FatalError> {
|
fn fatal_func() -> ResultExt<i32, Error, FatalError> {
|
||||||
let _ = return_fatal!(fatal::<i32, Error, FatalError>(FatalError::FatalError));
|
let _ = return_fatal!(fatal::<i32, Error, FatalError>(FatalError::FatalError));
|
||||||
panic!("failed to bail");
|
panic!("failed to bail");
|
||||||
}
|
}
|
||||||
|
@ -259,18 +259,18 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn it_can_early_exit_on_all_errors() {
|
fn it_can_early_exit_on_all_errors() {
|
||||||
fn ok_func() -> Result<i32, Error, FatalError> {
|
fn ok_func() -> ResultExt<i32, Error, FatalError> {
|
||||||
let value = return_error!(ok::<i32, Error, FatalError>(15));
|
let value = return_error!(ok::<i32, Error, FatalError>(15));
|
||||||
assert_eq!(value, 15);
|
assert_eq!(value, 15);
|
||||||
ok(14)
|
ok(14)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn err_func() -> Result<i32, Error, FatalError> {
|
fn err_func() -> ResultExt<i32, Error, FatalError> {
|
||||||
return_error!(error::<i32, Error, FatalError>(Error::Error));
|
return_error!(error::<i32, Error, FatalError>(Error::Error));
|
||||||
panic!("failed to bail");
|
panic!("failed to bail");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fatal_func() -> Result<i32, Error, FatalError> {
|
fn fatal_func() -> ResultExt<i32, Error, FatalError> {
|
||||||
return_error!(fatal::<i32, Error, FatalError>(FatalError::FatalError));
|
return_error!(fatal::<i32, Error, FatalError>(FatalError::FatalError));
|
||||||
panic!("failed to bail");
|
panic!("failed to bail");
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,26 +6,27 @@ edition = "2021"
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
async-std = { version = "1.13.0" }
|
||||||
|
async-trait = { version = "0.1.83" }
|
||||||
authdb = { path = "../../authdb/" }
|
authdb = { path = "../../authdb/" }
|
||||||
|
futures = { version = "0.3.31" }
|
||||||
http = { version = "1" }
|
http = { version = "1" }
|
||||||
serde_json = { version = "*" }
|
include_dir = { version = "0.7.4" }
|
||||||
serde = { version = "1" }
|
lazy_static = { version = "1.5.0" }
|
||||||
tokio = { version = "1", features = [ "full" ] }
|
mime = { version = "0.3.17" }
|
||||||
warp = { version = "0.3" }
|
mime_guess = { version = "2.0.5" }
|
||||||
mime_guess = "2.0.5"
|
result-extended = { path = "../../result-extended" }
|
||||||
mime = "0.3.17"
|
rusqlite = { version = "0.32.1" }
|
||||||
uuid = { version = "1.11.0", features = ["v4"] }
|
|
||||||
tokio-stream = "0.1.16"
|
|
||||||
typeshare = "1.0.4"
|
|
||||||
urlencoding = "2.1.3"
|
|
||||||
thiserror = "2.0.3"
|
|
||||||
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"
|
serde = { version = "1" }
|
||||||
include_dir = "0.7.4"
|
serde_json = { version = "*" }
|
||||||
async-trait = "0.1.83"
|
thiserror = { version = "2.0.3" }
|
||||||
futures = "0.3.31"
|
tokio = { version = "1", features = [ "full" ] }
|
||||||
async-std = "1.13.0"
|
tokio-stream = { version = "0.1.16" }
|
||||||
|
typeshare = { version = "1.0.4" }
|
||||||
|
urlencoding = { version = "2.1.3" }
|
||||||
|
uuid = { version = "1.11.0", features = ["v4"] }
|
||||||
|
warp = { version = "0.3" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
cool_asserts = "2.0.3"
|
cool_asserts = "2.0.3"
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
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,32 @@
|
||||||
|
CREATE TABLE users(
|
||||||
|
uuid TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
password TEXT,
|
||||||
|
admin BOOLEAN,
|
||||||
|
enabled BOOLEAN
|
||||||
|
);
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE roles(
|
||||||
|
user_id TEXT,
|
||||||
|
game_id TEXT,
|
||||||
|
role TEXT,
|
||||||
|
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(uuid),
|
||||||
|
FOREIGN KEY(game_id) REFERENCES games(uuid)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO users VALUES ("admin", "admin", "", true, true);
|
||||||
|
|
|
@ -2,13 +2,16 @@ use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
use async_std::sync::RwLock;
|
use async_std::sync::RwLock;
|
||||||
use mime::Mime;
|
use mime::Mime;
|
||||||
|
use result_extended::{error, fatal, ok, return_error, ResultExt};
|
||||||
|
use serde::Serialize;
|
||||||
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
|
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
|
||||||
|
use typeshare::typeshare;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
asset_db::{self, AssetId, Assets},
|
asset_db::{self, AssetId, Assets},
|
||||||
database::{CharacterId, Database},
|
database::{CharacterId, Database, UserId},
|
||||||
types::{AppError, Message, Tabletop, RGB},
|
types::{AppError, FatalError, Game, Message, Tabletop, User, RGB},
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_BACKGROUND_COLOR: RGB = RGB {
|
const DEFAULT_BACKGROUND_COLOR: RGB = RGB {
|
||||||
|
@ -17,6 +20,12 @@ const DEFAULT_BACKGROUND_COLOR: RGB = RGB {
|
||||||
blue: 0xbb,
|
blue: 0xbb,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
#[typeshare]
|
||||||
|
pub struct Status {
|
||||||
|
pub admin_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct WebsocketClient {
|
struct WebsocketClient {
|
||||||
sender: Option<UnboundedSender<Message>>,
|
sender: Option<UnboundedSender<Message>>,
|
||||||
|
@ -50,6 +59,23 @@ impl Core {
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn status(&self) -> ResultExt<Status, AppError, FatalError> {
|
||||||
|
let mut state = self.0.write().await;
|
||||||
|
let admin_user = return_error!(match state.db.user(UserId::from("admin")).await {
|
||||||
|
Ok(Some(admin_user)) => ok(admin_user),
|
||||||
|
Ok(None) => {
|
||||||
|
return ok(Status {
|
||||||
|
admin_enabled: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(err) => fatal(err),
|
||||||
|
});
|
||||||
|
|
||||||
|
ok(Status {
|
||||||
|
admin_enabled: !admin_user.password.is_empty(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn register_client(&self) -> String {
|
pub async fn register_client(&self) -> String {
|
||||||
let mut state = self.0.write().await;
|
let mut state = self.0.write().await;
|
||||||
let uuid = Uuid::new_v4().simple().to_string();
|
let uuid = Uuid::new_v4().simple().to_string();
|
||||||
|
@ -80,11 +106,32 @@ impl Core {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_users(&self) -> ResultExt<Vec<User>, AppError, FatalError> {
|
||||||
|
let users = self.0.write().await.db.users().await;
|
||||||
|
match users {
|
||||||
|
Ok(users) => ok(users.into_iter().map(|u| User::from(u)).collect()),
|
||||||
|
Err(err) => fatal(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_games(&self) -> ResultExt<Vec<Game>, AppError, FatalError> {
|
||||||
|
let games = self.0.write().await.db.games().await;
|
||||||
|
match games {
|
||||||
|
// Ok(games) => ok(games.into_iter().map(|g| Game::from(g)).collect()),
|
||||||
|
Ok(games) => unimplemented!(),
|
||||||
|
Err(err) => fatal(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn tabletop(&self) -> Tabletop {
|
pub async fn tabletop(&self) -> Tabletop {
|
||||||
self.0.read().await.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,
|
||||||
|
) -> ResultExt<(Mime, Vec<u8>), AppError, FatalError> {
|
||||||
|
ResultExt::from(
|
||||||
self.0
|
self.0
|
||||||
.read()
|
.read()
|
||||||
.await
|
.await
|
||||||
|
@ -92,9 +139,14 @@ impl Core {
|
||||||
.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)),
|
||||||
asset_db::Error::Inaccessible => AppError::Inaccessible(format!("{}", asset_id)),
|
asset_db::Error::Inaccessible => {
|
||||||
asset_db::Error::UnexpectedError(err) => AppError::Inaccessible(format!("{}", err)),
|
AppError::Inaccessible(format!("{}", asset_id))
|
||||||
})
|
}
|
||||||
|
asset_db::Error::UnexpectedError(err) => {
|
||||||
|
AppError::Inaccessible(format!("{}", err))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn available_images(&self) -> Vec<AssetId> {
|
pub async fn available_images(&self) -> Vec<AssetId> {
|
||||||
|
@ -112,29 +164,30 @@ impl Core {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_background_image(&self, asset: AssetId) -> Result<(), AppError> {
|
pub async fn set_background_image(
|
||||||
|
&self,
|
||||||
|
asset: AssetId,
|
||||||
|
) -> ResultExt<(), AppError, FatalError> {
|
||||||
let tabletop = {
|
let tabletop = {
|
||||||
let mut state = self.0.write().await;
|
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)).await;
|
self.publish(Message::UpdateTabletop(tabletop)).await;
|
||||||
Ok(())
|
ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_charsheet(
|
pub async fn get_charsheet(
|
||||||
&self,
|
&self,
|
||||||
id: CharacterId,
|
id: CharacterId,
|
||||||
) -> Result<Option<serde_json::Value>, AppError> {
|
) -> ResultExt<Option<serde_json::Value>, AppError, FatalError> {
|
||||||
Ok(self
|
let mut state = self.0.write().await;
|
||||||
.0
|
let cr = state.db.character(id).await;
|
||||||
.write()
|
match cr {
|
||||||
.await
|
Ok(Some(row)) => ok(Some(row.data)),
|
||||||
.db
|
Ok(None) => ok(None),
|
||||||
.charsheet(id)
|
Err(err) => fatal(err),
|
||||||
.await
|
}
|
||||||
.unwrap()
|
|
||||||
.map(|cr| cr.data))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn publish(&self, message: Message) {
|
pub async fn publish(&self, message: Message) {
|
||||||
|
@ -146,6 +199,27 @@ impl Core {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn set_password(
|
||||||
|
&self,
|
||||||
|
uuid: UserId,
|
||||||
|
password: String,
|
||||||
|
) -> ResultExt<(), AppError, FatalError> {
|
||||||
|
let mut state = self.0.write().await;
|
||||||
|
let user = match state.db.user(uuid.clone()).await {
|
||||||
|
Ok(Some(row)) => row,
|
||||||
|
Ok(None) => return error(AppError::NotFound(uuid.as_str().to_owned())),
|
||||||
|
Err(err) => return fatal(err),
|
||||||
|
};
|
||||||
|
match state
|
||||||
|
.db
|
||||||
|
.save_user(Some(uuid), &user.name, &password, user.admin, user.enabled)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => ok(()),
|
||||||
|
Err(err) => fatal(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
@ -197,14 +271,14 @@ mod test {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn it_lists_available_images() {
|
async fn it_lists_available_images() {
|
||||||
let core = test_core();
|
let core = test_core();
|
||||||
let image_paths = core.available_images();
|
let image_paths = core.available_images().await;
|
||||||
assert_eq!(image_paths.len(), 2);
|
assert_eq!(image_paths.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn it_retrieves_an_asset() {
|
async fn it_retrieves_an_asset() {
|
||||||
let core = test_core();
|
let core = test_core();
|
||||||
assert_matches!(core.get_asset(AssetId::from("asset_1")).await, Ok((mime, data)) => {
|
assert_matches!(core.get_asset(AssetId::from("asset_1")).await, ResultExt::Ok((mime, data)) => {
|
||||||
assert_eq!(mime.type_(), mime::IMAGE);
|
assert_eq!(mime.type_(), mime::IMAGE);
|
||||||
assert_eq!(data, "abcdefg".as_bytes());
|
assert_eq!(data, "abcdefg".as_bytes());
|
||||||
});
|
});
|
||||||
|
@ -213,7 +287,7 @@ mod test {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn it_can_retrieve_the_default_tabletop() {
|
async fn it_can_retrieve_the_default_tabletop() {
|
||||||
let core = test_core();
|
let core = test_core();
|
||||||
assert_matches!(core.tabletop(), Tabletop{ background_color, background_image } => {
|
assert_matches!(core.tabletop().await, Tabletop{ background_color, background_image } => {
|
||||||
assert_eq!(background_color, DEFAULT_BACKGROUND_COLOR);
|
assert_eq!(background_color, DEFAULT_BACKGROUND_COLOR);
|
||||||
assert_eq!(background_image, None);
|
assert_eq!(background_image, None);
|
||||||
});
|
});
|
||||||
|
@ -222,8 +296,11 @@ mod test {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn it_can_change_the_tabletop_background() {
|
async fn it_can_change_the_tabletop_background() {
|
||||||
let core = test_core();
|
let core = test_core();
|
||||||
assert_matches!(core.set_background_image(AssetId::from("asset_1")), Ok(()));
|
assert_matches!(
|
||||||
assert_matches!(core.tabletop(), Tabletop{ background_color, background_image } => {
|
core.set_background_image(AssetId::from("asset_1")).await,
|
||||||
|
ResultExt::Ok(())
|
||||||
|
);
|
||||||
|
assert_matches!(core.tabletop().await, Tabletop{ background_color, background_image } => {
|
||||||
assert_eq!(background_color, DEFAULT_BACKGROUND_COLOR);
|
assert_eq!(background_color, DEFAULT_BACKGROUND_COLOR);
|
||||||
assert_eq!(background_image, Some(AssetId::from("asset_1")));
|
assert_eq!(background_image, Some(AssetId::from("asset_1")));
|
||||||
});
|
});
|
||||||
|
@ -232,10 +309,13 @@ mod test {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn it_sends_notices_to_clients_on_tabletop_change() {
|
async fn it_sends_notices_to_clients_on_tabletop_change() {
|
||||||
let core = test_core();
|
let core = test_core();
|
||||||
let client_id = core.register_client();
|
let client_id = core.register_client().await;
|
||||||
let mut receiver = core.connect_client(client_id);
|
let mut receiver = core.connect_client(client_id).await;
|
||||||
|
|
||||||
assert_matches!(core.set_background_image(AssetId::from("asset_1")), Ok(()));
|
assert_matches!(
|
||||||
|
core.set_background_image(AssetId::from("asset_1")).await,
|
||||||
|
ResultExt::Ok(())
|
||||||
|
);
|
||||||
match receiver.recv().await {
|
match receiver.recv().await {
|
||||||
Some(Message::UpdateTabletop(Tabletop {
|
Some(Message::UpdateTabletop(Tabletop {
|
||||||
background_color,
|
background_color,
|
||||||
|
|
|
@ -1,18 +1,19 @@
|
||||||
use std::{
|
use std::path::Path;
|
||||||
path::{Path, PathBuf},
|
|
||||||
thread::JoinHandle,
|
|
||||||
};
|
|
||||||
|
|
||||||
use async_std::channel::{bounded, Receiver, Sender};
|
use async_std::channel::{bounded, Receiver, Sender};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use include_dir::{include_dir, Dir};
|
use include_dir::{include_dir, Dir};
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use rusqlite::Connection;
|
use rusqlite::{
|
||||||
|
types::{FromSql, FromSqlResult, ValueRef},
|
||||||
|
Connection,
|
||||||
|
};
|
||||||
use rusqlite_migration::Migrations;
|
use rusqlite_migration::Migrations;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::types::FatalError;
|
||||||
|
|
||||||
static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations");
|
static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations");
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
|
@ -20,21 +21,13 @@ lazy_static! {
|
||||||
Migrations::from_directory(&MIGRATIONS_DIR).unwrap();
|
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)]
|
#[derive(Debug)]
|
||||||
enum Request {
|
enum Request {
|
||||||
Charsheet(CharacterId),
|
Charsheet(CharacterId),
|
||||||
|
Games,
|
||||||
|
User(UserId),
|
||||||
|
Users,
|
||||||
|
SaveUser(Option<UserId>, String, String, bool, bool),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
@ -46,6 +39,78 @@ struct DatabaseRequest {
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum DatabaseResponse {
|
enum DatabaseResponse {
|
||||||
Charsheet(Option<CharsheetRow>),
|
Charsheet(Option<CharsheetRow>),
|
||||||
|
Games(Vec<GameRow>),
|
||||||
|
User(Option<UserRow>),
|
||||||
|
Users(Vec<UserRow>),
|
||||||
|
SaveUser(UserId),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
||||||
|
pub struct UserId(String);
|
||||||
|
|
||||||
|
impl UserId {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(format!("{}", Uuid::new_v4().hyphenated()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str<'a>(&'a self) -> &'a str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for UserId {
|
||||||
|
fn from(s: &str) -> Self {
|
||||||
|
Self(s.to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for UserId {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
Self(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromSql for UserId {
|
||||||
|
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
||||||
|
match value {
|
||||||
|
ValueRef::Text(text) => Ok(Self::from(String::from_utf8(text.to_vec()).unwrap())),
|
||||||
|
_ => unimplemented!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
||||||
|
pub struct GameId(String);
|
||||||
|
|
||||||
|
impl GameId {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(format!("{}", Uuid::new_v4().hyphenated()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str<'a>(&'a self) -> &'a str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for GameId {
|
||||||
|
fn from(s: &str) -> Self {
|
||||||
|
Self(s.to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for GameId {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
Self(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromSql for GameId {
|
||||||
|
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
||||||
|
match value {
|
||||||
|
ValueRef::Text(text) => Ok(Self::from(String::from_utf8(text.to_vec()).unwrap())),
|
||||||
|
_ => unimplemented!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
||||||
|
@ -53,7 +118,7 @@ pub struct CharacterId(String);
|
||||||
|
|
||||||
impl CharacterId {
|
impl CharacterId {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
CharacterId(format!("{}", Uuid::new_v4().hyphenated()))
|
Self(format!("{}", Uuid::new_v4().hyphenated()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn as_str<'a>(&'a self) -> &'a str {
|
pub fn as_str<'a>(&'a self) -> &'a str {
|
||||||
|
@ -63,53 +128,126 @@ impl CharacterId {
|
||||||
|
|
||||||
impl From<&str> for CharacterId {
|
impl From<&str> for CharacterId {
|
||||||
fn from(s: &str) -> Self {
|
fn from(s: &str) -> Self {
|
||||||
CharacterId(s.to_owned())
|
Self(s.to_owned())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<String> for CharacterId {
|
impl From<String> for CharacterId {
|
||||||
fn from(s: String) -> Self {
|
fn from(s: String) -> Self {
|
||||||
CharacterId(s)
|
Self(s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FromSql for CharacterId {
|
||||||
|
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
||||||
|
match value {
|
||||||
|
ValueRef::Text(text) => Ok(Self::from(String::from_utf8(text.to_vec()).unwrap())),
|
||||||
|
_ => unimplemented!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct UserRow {
|
||||||
|
pub id: UserId,
|
||||||
|
pub name: String,
|
||||||
|
pub password: String,
|
||||||
|
pub admin: bool,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Role {
|
||||||
|
userid: UserId,
|
||||||
|
gameid: GameId,
|
||||||
|
role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct GameRow {
|
||||||
|
pub id: UserId,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct CharsheetRow {
|
pub struct CharsheetRow {
|
||||||
id: String,
|
id: String,
|
||||||
gametype: String,
|
game: GameId,
|
||||||
pub data: serde_json::Value,
|
pub data: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait Database: Send + Sync {
|
pub trait Database: Send + Sync {
|
||||||
async fn charsheet(&mut self, id: CharacterId) -> Result<Option<CharsheetRow>, Error>;
|
async fn user(&mut self, _: UserId) -> Result<Option<UserRow>, FatalError>;
|
||||||
|
|
||||||
|
async fn save_user(
|
||||||
|
&mut self,
|
||||||
|
user_id: Option<UserId>,
|
||||||
|
name: &str,
|
||||||
|
password: &str,
|
||||||
|
admin: bool,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<UserId, FatalError>;
|
||||||
|
|
||||||
|
async fn users(&mut self) -> Result<Vec<UserRow>, FatalError>;
|
||||||
|
|
||||||
|
async fn games(&mut self) -> Result<Vec<GameRow>, FatalError>;
|
||||||
|
|
||||||
|
async fn character(&mut self, id: CharacterId) -> Result<Option<CharsheetRow>, FatalError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct DiskDb {
|
pub struct DiskDb {
|
||||||
conn: Connection,
|
conn: Connection,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn setup_test_database(conn: &Connection) {
|
/*
|
||||||
|
fn setup_test_database(conn: &Connection) -> Result<(), FatalError> {
|
||||||
let mut gamecount_stmt = conn.prepare("SELECT count(*) FROM games").unwrap();
|
let mut gamecount_stmt = conn.prepare("SELECT count(*) FROM games").unwrap();
|
||||||
let mut count = gamecount_stmt.query([]).unwrap();
|
let mut count = gamecount_stmt.query([]).unwrap();
|
||||||
if count.next().unwrap().unwrap().get::<usize, usize>(0) == Ok(0) {
|
if count.next().unwrap().unwrap().get::<usize, usize>(0) == Ok(0) {
|
||||||
|
let admin_id = format!("{}", Uuid::new_v4());
|
||||||
|
let user_id = format!("{}", Uuid::new_v4());
|
||||||
let game_id = format!("{}", Uuid::new_v4());
|
let game_id = format!("{}", Uuid::new_v4());
|
||||||
let char_id = CharacterId::new();
|
let char_id = CharacterId::new();
|
||||||
|
|
||||||
let mut game_stmt = conn.prepare("INSERT INTO games VALUES (?, ?)").unwrap();
|
let mut user_stmt = conn
|
||||||
game_stmt.execute((game_id.clone(), "Circle of Bluest Sky"));
|
.prepare("INSERT INTO users VALUES (?, ?, ?, ?, ?)")
|
||||||
|
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
|
||||||
|
user_stmt
|
||||||
|
.execute((admin_id.clone(), "admin", "abcdefg", true, true))
|
||||||
|
.unwrap();
|
||||||
|
user_stmt
|
||||||
|
.execute((user_id.clone(), "savanni", "abcdefg", false, true))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut game_stmt = conn
|
||||||
|
.prepare("INSERT INTO games VALUES (?, ?)")
|
||||||
|
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
|
||||||
|
game_stmt
|
||||||
|
.execute((game_id.clone(), "Circle of Bluest Sky"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut role_stmt = conn
|
||||||
|
.prepare("INSERT INTO roles VALUES (?, ?, ?)")
|
||||||
|
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
|
||||||
|
role_stmt
|
||||||
|
.execute((user_id.clone(), game_id.clone(), "gm"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let mut sheet_stmt = conn
|
let mut sheet_stmt = conn
|
||||||
.prepare("INSERT INTO characters VALUES (?, ?, ?)")
|
.prepare("INSERT INTO characters VALUES (?, ?, ?)")
|
||||||
.unwrap();
|
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
|
||||||
|
|
||||||
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." ] }"#))
|
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();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
impl DiskDb {
|
impl DiskDb {
|
||||||
pub fn new<P>(path: Option<P>) -> Result<Self, Error>
|
pub fn new<P>(path: Option<P>) -> Result<Self, FatalError>
|
||||||
where
|
where
|
||||||
P: AsRef<Path>,
|
P: AsRef<Path>,
|
||||||
{
|
{
|
||||||
|
@ -117,24 +255,128 @@ impl DiskDb {
|
||||||
None => Connection::open(":memory:").expect("to create a memory connection"),
|
None => Connection::open(":memory:").expect("to create a memory connection"),
|
||||||
Some(path) => Connection::open(path).expect("to create connection"),
|
Some(path) => Connection::open(path).expect("to create connection"),
|
||||||
};
|
};
|
||||||
MIGRATIONS.to_latest(&mut conn).expect("to run migrations");
|
|
||||||
|
|
||||||
setup_test_database(&conn);
|
MIGRATIONS
|
||||||
|
.to_latest(&mut conn)
|
||||||
|
.map_err(|err| FatalError::DatabaseMigrationFailure(format!("{}", err)))?;
|
||||||
|
|
||||||
|
// setup_test_database(&conn)?;
|
||||||
|
|
||||||
Ok(DiskDb { conn })
|
Ok(DiskDb { conn })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn charsheet(&self, id: CharacterId) -> Result<Option<CharsheetRow>, Error> {
|
fn user(&self, id: UserId) -> Result<Option<UserRow>, FatalError> {
|
||||||
let mut stmt = self
|
let mut stmt = self
|
||||||
.conn
|
.conn
|
||||||
.prepare("SELECT uuid, gametype, data FROM charsheet WHERE uuid=?")
|
.prepare("SELECT uuid, name, password, admin, enabled FROM users WHERE uuid=?")
|
||||||
|
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
|
||||||
|
let items: Vec<UserRow> = stmt
|
||||||
|
.query_map([id.as_str()], |row| {
|
||||||
|
Ok(UserRow {
|
||||||
|
id: row.get(0).unwrap(),
|
||||||
|
name: row.get(1).unwrap(),
|
||||||
|
password: row.get(2).unwrap(),
|
||||||
|
admin: row.get(3).unwrap(),
|
||||||
|
enabled: row.get(4).unwrap(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.collect::<Result<Vec<UserRow>, rusqlite::Error>>()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
match &items[..] {
|
||||||
|
[] => Ok(None),
|
||||||
|
[item] => Ok(Some(item.clone())),
|
||||||
|
_ => Err(FatalError::NonUniqueDatabaseKey(id.as_str().to_owned())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn users(&self) -> Result<Vec<UserRow>, FatalError> {
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare("SELECT * FROM users")
|
||||||
|
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
|
||||||
|
let items = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(UserRow {
|
||||||
|
id: row.get(0).unwrap(),
|
||||||
|
name: row.get(1).unwrap(),
|
||||||
|
password: row.get(2).unwrap(),
|
||||||
|
admin: row.get(3).unwrap(),
|
||||||
|
enabled: row.get(4).unwrap(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.collect::<Result<Vec<UserRow>, rusqlite::Error>>()
|
||||||
|
.unwrap();
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_user(
|
||||||
|
&self,
|
||||||
|
user_id: Option<UserId>,
|
||||||
|
name: &str,
|
||||||
|
password: &str,
|
||||||
|
admin: bool,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<UserId, FatalError> {
|
||||||
|
match user_id {
|
||||||
|
None => {
|
||||||
|
let user_id = UserId::new();
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare("INSERT INTO users VALUES (?, ?, ?, ?, ?)")
|
||||||
|
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
|
||||||
|
stmt.execute((user_id.as_str(), name, password, admin, enabled))
|
||||||
|
.unwrap();
|
||||||
|
Ok(user_id)
|
||||||
|
}
|
||||||
|
Some(user_id) => {
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare(
|
||||||
|
"UPDATE users SET name=?, password=?, admin=?, enabled=? WHERE uuid=?",
|
||||||
|
)
|
||||||
|
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
|
||||||
|
stmt.execute((name, password, admin, enabled, user_id.as_str()))
|
||||||
|
.unwrap();
|
||||||
|
Ok(user_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_game(&self, game_id: Option<GameId>, name: &str) -> Result<GameId, FatalError> {
|
||||||
|
match game_id {
|
||||||
|
None => {
|
||||||
|
let game_id = GameId::new();
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare("INSERT INTO games VALUES (?, ?)")
|
||||||
|
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
|
||||||
|
stmt.execute((game_id.as_str(), name)).unwrap();
|
||||||
|
Ok(game_id)
|
||||||
|
}
|
||||||
|
Some(game_id) => {
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare("UPDATE games SET name=? WHERE uuid=?")
|
||||||
|
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
|
||||||
|
stmt.execute((name, game_id.as_str())).unwrap();
|
||||||
|
Ok(game_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn character(&self, id: CharacterId) -> Result<Option<CharsheetRow>, FatalError> {
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare("SELECT uuid, game, data FROM characters WHERE uuid=?")
|
||||||
|
.map_err(|err| FatalError::ConstructQueryFailure(format!("{}", err)))?;
|
||||||
let items: Vec<CharsheetRow> = stmt
|
let items: Vec<CharsheetRow> = stmt
|
||||||
.query_map([id.as_str()], |row| {
|
.query_map([id.as_str()], |row| {
|
||||||
let data: String = row.get(2).unwrap();
|
let data: String = row.get(2).unwrap();
|
||||||
Ok(CharsheetRow {
|
Ok(CharsheetRow {
|
||||||
id: row.get(0).unwrap(),
|
id: row.get(0).unwrap(),
|
||||||
gametype: row.get(1).unwrap(),
|
game: row.get(1).unwrap(),
|
||||||
data: serde_json::from_str(&data).unwrap(),
|
data: serde_json::from_str(&data).unwrap(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -144,24 +386,24 @@ impl DiskDb {
|
||||||
match &items[..] {
|
match &items[..] {
|
||||||
[] => Ok(None),
|
[] => Ok(None),
|
||||||
[item] => Ok(Some(item.clone())),
|
[item] => Ok(Some(item.clone())),
|
||||||
_ => unimplemented!(),
|
_ => Err(FatalError::NonUniqueDatabaseKey(id.as_str().to_owned())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save_charsheet(
|
fn save_character(
|
||||||
&self,
|
&self,
|
||||||
char_id: Option<CharacterId>,
|
char_id: Option<CharacterId>,
|
||||||
game_type: String,
|
game: GameId,
|
||||||
charsheet: serde_json::Value,
|
character: serde_json::Value,
|
||||||
) -> Result<CharacterId, Error> {
|
) -> std::result::Result<CharacterId, FatalError> {
|
||||||
match char_id {
|
match char_id {
|
||||||
None => {
|
None => {
|
||||||
let char_id = CharacterId::new();
|
let char_id = CharacterId::new();
|
||||||
let mut stmt = self
|
let mut stmt = self
|
||||||
.conn
|
.conn
|
||||||
.prepare("INSERT INTO charsheet VALUES (?, ?, ?)")
|
.prepare("INSERT INTO characters VALUES (?, ?, ?)")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
stmt.execute((char_id.as_str(), game_type, charsheet.to_string()))
|
stmt.execute((char_id.as_str(), game.as_str(), character.to_string()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
Ok(char_id)
|
Ok(char_id)
|
||||||
|
@ -169,9 +411,9 @@ impl DiskDb {
|
||||||
Some(char_id) => {
|
Some(char_id) => {
|
||||||
let mut stmt = self
|
let mut stmt = self
|
||||||
.conn
|
.conn
|
||||||
.prepare("UPDATE charsheet SET data=? WHERE uuid=?")
|
.prepare("UPDATE characters SET data=? WHERE uuid=?")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
stmt.execute((charsheet.to_string(), char_id.as_str()))
|
stmt.execute((character.to_string(), char_id.as_str()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
Ok(char_id)
|
Ok(char_id)
|
||||||
|
@ -181,12 +423,11 @@ impl DiskDb {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn db_handler(db: DiskDb, requestor: Receiver<DatabaseRequest>) {
|
async fn db_handler(db: DiskDb, requestor: Receiver<DatabaseRequest>) {
|
||||||
println!("Starting db_handler");
|
|
||||||
while let Ok(DatabaseRequest { tx, req }) = requestor.recv().await {
|
while let Ok(DatabaseRequest { tx, req }) = requestor.recv().await {
|
||||||
println!("Request received: {:?}", req);
|
println!("Request received: {:?}", req);
|
||||||
match req {
|
match req {
|
||||||
Request::Charsheet(id) => {
|
Request::Charsheet(id) => {
|
||||||
let sheet = db.charsheet(id);
|
let sheet = db.character(id);
|
||||||
println!("sheet retrieved: {:?}", sheet);
|
println!("sheet retrieved: {:?}", sheet);
|
||||||
match sheet {
|
match sheet {
|
||||||
Ok(sheet) => {
|
Ok(sheet) => {
|
||||||
|
@ -195,6 +436,36 @@ async fn db_handler(db: DiskDb, requestor: Receiver<DatabaseRequest>) {
|
||||||
_ => unimplemented!(),
|
_ => unimplemented!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Request::Games => {
|
||||||
|
unimplemented!();
|
||||||
|
}
|
||||||
|
Request::User(uid) => {
|
||||||
|
let user = db.user(uid);
|
||||||
|
match user {
|
||||||
|
Ok(user) => {
|
||||||
|
tx.send(DatabaseResponse::User(user)).await.unwrap();
|
||||||
|
}
|
||||||
|
err => panic!("{:?}", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Request::SaveUser(user_id, username, password, admin, enabled) => {
|
||||||
|
let user_id = db.save_user(user_id, username.as_ref(), password.as_ref(), admin, enabled);
|
||||||
|
match user_id {
|
||||||
|
Ok(user_id) => {
|
||||||
|
tx.send(DatabaseResponse::SaveUser(user_id)).await.unwrap();
|
||||||
|
}
|
||||||
|
err => panic!("{:?}", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Request::Users => {
|
||||||
|
let users = db.users();
|
||||||
|
match users {
|
||||||
|
Ok(users) => {
|
||||||
|
tx.send(DatabaseResponse::Users(users)).await.unwrap();
|
||||||
|
}
|
||||||
|
_ => unimplemented!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
println!("ending db_handler");
|
println!("ending db_handler");
|
||||||
|
@ -223,52 +494,158 @@ impl DbConn {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Database for DbConn {
|
impl Database for DbConn {
|
||||||
async fn charsheet(&mut self, id: CharacterId) -> Result<Option<CharsheetRow>, Error> {
|
async fn user(&mut self, uid: UserId) -> Result<Option<UserRow>, FatalError> {
|
||||||
|
let (tx, rx) = bounded::<DatabaseResponse>(1);
|
||||||
|
|
||||||
|
let request = DatabaseRequest {
|
||||||
|
tx,
|
||||||
|
req: Request::User(uid),
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.conn.send(request).await {
|
||||||
|
Ok(()) => (),
|
||||||
|
Err(_) => return Err(FatalError::DatabaseConnectionLost),
|
||||||
|
};
|
||||||
|
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(DatabaseResponse::User(user)) => Ok(user),
|
||||||
|
Ok(_) => Err(FatalError::MessageMismatch),
|
||||||
|
Err(_) => Err(FatalError::DatabaseConnectionLost),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_user(
|
||||||
|
&mut self,
|
||||||
|
user_id: Option<UserId>,
|
||||||
|
name: &str,
|
||||||
|
password: &str,
|
||||||
|
admin: bool,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<UserId, FatalError> {
|
||||||
|
let (tx, rx) = bounded::<DatabaseResponse>(1);
|
||||||
|
|
||||||
|
let request = DatabaseRequest {
|
||||||
|
tx,
|
||||||
|
req: Request::SaveUser(
|
||||||
|
user_id,
|
||||||
|
name.to_owned(),
|
||||||
|
password.to_owned(),
|
||||||
|
admin,
|
||||||
|
enabled,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.conn.send(request).await {
|
||||||
|
Ok(()) => (),
|
||||||
|
Err(_) => return Err(FatalError::DatabaseConnectionLost),
|
||||||
|
};
|
||||||
|
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(DatabaseResponse::SaveUser(user_id)) => Ok(user_id),
|
||||||
|
Ok(_) => Err(FatalError::MessageMismatch),
|
||||||
|
Err(_) => Err(FatalError::DatabaseConnectionLost),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn users(&mut self) -> Result<Vec<UserRow>, FatalError> {
|
||||||
|
let (tx, rx) = bounded::<DatabaseResponse>(1);
|
||||||
|
|
||||||
|
let request = DatabaseRequest {
|
||||||
|
tx,
|
||||||
|
req: Request::Users,
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.conn.send(request).await {
|
||||||
|
Ok(()) => (),
|
||||||
|
Err(_) => return Err(FatalError::DatabaseConnectionLost),
|
||||||
|
};
|
||||||
|
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(DatabaseResponse::Users(lst)) => Ok(lst),
|
||||||
|
Ok(_) => Err(FatalError::MessageMismatch),
|
||||||
|
Err(_) => Err(FatalError::DatabaseConnectionLost),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn games(&mut self) -> Result<Vec<GameRow>, FatalError> {
|
||||||
|
let (tx, rx) = bounded::<DatabaseResponse>(1);
|
||||||
|
|
||||||
|
let request = DatabaseRequest {
|
||||||
|
tx,
|
||||||
|
req: Request::Games,
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.conn.send(request).await {
|
||||||
|
Ok(()) => (),
|
||||||
|
Err(_) => return Err(FatalError::DatabaseConnectionLost),
|
||||||
|
};
|
||||||
|
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(DatabaseResponse::Games(lst)) => Ok(lst),
|
||||||
|
Ok(_) => Err(FatalError::MessageMismatch),
|
||||||
|
Err(_) => Err(FatalError::DatabaseConnectionLost),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn character(&mut self, id: CharacterId) -> Result<Option<CharsheetRow>, FatalError> {
|
||||||
let (tx, rx) = bounded::<DatabaseResponse>(1);
|
let (tx, rx) = bounded::<DatabaseResponse>(1);
|
||||||
|
|
||||||
let request = DatabaseRequest {
|
let request = DatabaseRequest {
|
||||||
tx,
|
tx,
|
||||||
req: Request::Charsheet(id),
|
req: Request::Charsheet(id),
|
||||||
};
|
};
|
||||||
self.conn.send(request).await.unwrap();
|
|
||||||
|
match self.conn.send(request).await {
|
||||||
|
Ok(()) => (),
|
||||||
|
Err(_) => return Err(FatalError::DatabaseConnectionLost),
|
||||||
|
};
|
||||||
|
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
Ok(DatabaseResponse::Charsheet(row)) => Ok(row),
|
Ok(DatabaseResponse::Charsheet(row)) => Ok(row),
|
||||||
Ok(_) => Err(Error::MessageMismatch),
|
Ok(_) => Err(FatalError::MessageMismatch),
|
||||||
Err(err) => {
|
Err(_) => Err(FatalError::DatabaseConnectionLost),
|
||||||
println!("error: {:?}", err);
|
|
||||||
Err(Error::NoResponse)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use cool_asserts::assert_matches;
|
use cool_asserts::assert_matches;
|
||||||
|
|
||||||
use super::*;
|
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." ] }"#;
|
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 setup_db() -> (DiskDb, GameId) {
|
||||||
fn it_can_retrieve_a_charsheet() {
|
|
||||||
let no_path: Option<PathBuf> = None;
|
let no_path: Option<PathBuf> = None;
|
||||||
let db = DiskDb::new(no_path).unwrap();
|
let db = DiskDb::new(no_path).unwrap();
|
||||||
|
|
||||||
assert_matches!(db.charsheet(CharacterId::from("1")), Ok(None));
|
db.save_user(None, "admin", "abcdefg", true, true);
|
||||||
|
let game_id = db.save_game(None, "Candela").unwrap();
|
||||||
|
(db, game_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn it_can_retrieve_a_character() {
|
||||||
|
let (db, game_id) = setup_db();
|
||||||
|
|
||||||
|
assert_matches!(db.character(CharacterId::from("1")), Ok(None));
|
||||||
|
|
||||||
let js: serde_json::Value = serde_json::from_str(soren).unwrap();
|
let js: serde_json::Value = serde_json::from_str(soren).unwrap();
|
||||||
let soren_id = db
|
let soren_id = db.save_character(None, game_id, js.clone()).unwrap();
|
||||||
.save_charsheet(None, "candela".to_owned(), js.clone())
|
assert_matches!(db.character(soren_id).unwrap(), Some(CharsheetRow{ data, .. }) => assert_eq!(js, data));
|
||||||
.unwrap();
|
|
||||||
assert_matches!(db.charsheet(soren_id).unwrap(), Some(CharsheetRow{ data, .. }) => assert_eq!(js, data));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn it_can_retrieve_a_charsheet_through_conn() {
|
async fn it_can_retrieve_a_character_through_conn() {
|
||||||
let memory_db: Option<PathBuf> = None;
|
let memory_db: Option<PathBuf> = None;
|
||||||
let mut conn = DbConn::new(memory_db);
|
let mut conn = DbConn::new(memory_db);
|
||||||
|
|
||||||
assert_matches!(conn.charsheet(CharacterId::from("1")).await, Ok(None));
|
assert_matches!(
|
||||||
|
conn.character(CharacterId::from("1")).await,
|
||||||
|
ResultExt::Ok(None)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,16 @@
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
|
|
||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
|
use result_extended::{error, ok, return_error, ResultExt};
|
||||||
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, database::CharacterId, types::AppError};
|
use crate::{
|
||||||
|
asset_db::AssetId,
|
||||||
|
core::Core,
|
||||||
|
database::{CharacterId, UserId},
|
||||||
|
types::{AppError, FatalError},
|
||||||
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
pub async fn handle_auth(
|
pub async fn handle_auth(
|
||||||
|
@ -32,26 +38,41 @@ pub async fn handle_auth(
|
||||||
|
|
||||||
pub async fn handler<F>(f: F) -> impl Reply
|
pub async fn handler<F>(f: F) -> impl Reply
|
||||||
where
|
where
|
||||||
F: Future<Output = Result<Response<Vec<u8>>, AppError>>,
|
F: Future<Output = ResultExt<Response<Vec<u8>>, AppError, FatalError>>,
|
||||||
{
|
{
|
||||||
match f.await {
|
match f.await {
|
||||||
Ok(response) => response,
|
ResultExt::Ok(response) => response,
|
||||||
Err(AppError::NotFound(_)) => Response::builder()
|
ResultExt::Err(AppError::NotFound(_)) => Response::builder()
|
||||||
.status(StatusCode::NOT_FOUND)
|
.status(StatusCode::NOT_FOUND)
|
||||||
.body(vec![])
|
.body(vec![])
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
Err(_) => Response::builder()
|
ResultExt::Err(_) => Response::builder()
|
||||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
.body(vec![])
|
.body(vec![])
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
|
ResultExt::Fatal(err) => {
|
||||||
|
panic!("Shutting down with fatal error: {:?}", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_server_status(core: Core) -> impl Reply {
|
||||||
|
handler(async move {
|
||||||
|
let status = return_error!(core.status().await);
|
||||||
|
ok(Response::builder()
|
||||||
|
.header("Access-Control-Allow-Origin", "*")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(serde_json::to_vec(&status).unwrap())
|
||||||
|
.unwrap())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn handle_file(core: Core, asset_id: AssetId) -> impl Reply {
|
pub async fn handle_file(core: Core, asset_id: AssetId) -> impl Reply {
|
||||||
handler(async move {
|
handler(async move {
|
||||||
let (mime, bytes) = core.get_asset(asset_id).await?;
|
let (mime, bytes) = return_error!(core.get_asset(asset_id).await);
|
||||||
Ok(Response::builder()
|
ok(Response::builder()
|
||||||
.header("application-type", mime.to_string())
|
.header("content-type", mime.to_string())
|
||||||
.body(bytes)
|
.body(bytes)
|
||||||
.unwrap())
|
.unwrap())
|
||||||
})
|
})
|
||||||
|
@ -67,7 +88,7 @@ pub async fn handle_available_images(core: Core) -> impl Reply {
|
||||||
.map(|path| format!("{}", path.as_str()))
|
.map(|path| format!("{}", path.as_str()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(Response::builder()
|
ok(Response::builder()
|
||||||
.header("Access-Control-Allow-Origin", "*")
|
.header("Access-Control-Allow-Origin", "*")
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.body(serde_json::to_vec(&image_paths).unwrap())
|
.body(serde_json::to_vec(&image_paths).unwrap())
|
||||||
|
@ -88,7 +109,7 @@ pub async fn handle_register_client(core: Core, _request: RegisterRequest) -> im
|
||||||
handler(async move {
|
handler(async move {
|
||||||
let client_id = core.register_client().await;
|
let client_id = core.register_client().await;
|
||||||
|
|
||||||
Ok(Response::builder()
|
ok(Response::builder()
|
||||||
.header("Access-Control-Allow-Origin", "*")
|
.header("Access-Control-Allow-Origin", "*")
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.body(
|
.body(
|
||||||
|
@ -106,7 +127,7 @@ 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()
|
ok(Response::builder()
|
||||||
.status(StatusCode::NO_CONTENT)
|
.status(StatusCode::NO_CONTENT)
|
||||||
.body(vec![])
|
.body(vec![])
|
||||||
.unwrap())
|
.unwrap())
|
||||||
|
@ -150,7 +171,7 @@ pub async fn handle_set_background_image(core: Core, image_name: String) -> impl
|
||||||
handler(async move {
|
handler(async move {
|
||||||
let _ = core.set_background_image(AssetId::from(image_name)).await;
|
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", "*")
|
||||||
.header("Access-Control-Allow-Methods", "*")
|
.header("Access-Control-Allow-Methods", "*")
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
|
@ -160,17 +181,55 @@ pub async fn handle_set_background_image(core: Core, image_name: String) -> impl
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn handle_get_users(core: Core) -> impl Reply {
|
||||||
|
handler(async move {
|
||||||
|
let users = match core.list_users().await {
|
||||||
|
ResultExt::Ok(users) => users,
|
||||||
|
ResultExt::Err(err) => return ResultExt::Err(err),
|
||||||
|
ResultExt::Fatal(err) => return ResultExt::Fatal(err),
|
||||||
|
};
|
||||||
|
|
||||||
|
ok(Response::builder()
|
||||||
|
.header("Access-Control-Allow-Origin", "*")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(serde_json::to_vec(&users).unwrap())
|
||||||
|
.unwrap())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_get_games(core: Core) -> impl Reply {
|
||||||
|
handler(async move {
|
||||||
|
let games = match core.list_games().await {
|
||||||
|
ResultExt::Ok(games) => games,
|
||||||
|
ResultExt::Err(err) => return ResultExt::Err(err),
|
||||||
|
ResultExt::Fatal(err) => return ResultExt::Fatal(err),
|
||||||
|
};
|
||||||
|
|
||||||
|
ok(Response::builder()
|
||||||
|
.header("Access-Control-Allow-Origin", "*")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(serde_json::to_vec(&games).unwrap())
|
||||||
|
.unwrap())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn handle_get_charsheet(core: Core, charid: String) -> impl Reply {
|
pub async fn handle_get_charsheet(core: Core, charid: String) -> impl Reply {
|
||||||
handler(async move {
|
handler(async move {
|
||||||
let sheet = core.get_charsheet(CharacterId::from(charid)).await.unwrap();
|
let sheet = match core.get_charsheet(CharacterId::from(charid)).await {
|
||||||
|
ResultExt::Ok(sheet) => sheet,
|
||||||
|
ResultExt::Err(err) => return ResultExt::Err(err),
|
||||||
|
ResultExt::Fatal(err) => return ResultExt::Fatal(err),
|
||||||
|
};
|
||||||
|
|
||||||
match sheet {
|
match sheet {
|
||||||
Some(sheet) => Ok(Response::builder()
|
Some(sheet) => ok(Response::builder()
|
||||||
.header("Access-Control-Allow-Origin", "*")
|
.header("Access-Control-Allow-Origin", "*")
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.body(serde_json::to_vec(&sheet).unwrap())
|
.body(serde_json::to_vec(&sheet).unwrap())
|
||||||
.unwrap()),
|
.unwrap()),
|
||||||
None => Ok(Response::builder()
|
None => ok(Response::builder()
|
||||||
.status(StatusCode::NOT_FOUND)
|
.status(StatusCode::NOT_FOUND)
|
||||||
.body(vec![])
|
.body(vec![])
|
||||||
.unwrap()),
|
.unwrap()),
|
||||||
|
@ -178,3 +237,21 @@ pub async fn handle_get_charsheet(core: Core, charid: String) -> impl Reply {
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn handle_set_admin_password(core: Core, password: String) -> impl Reply {
|
||||||
|
handler(async move {
|
||||||
|
let status = return_error!(core.status().await);
|
||||||
|
if status.admin_enabled {
|
||||||
|
return error(AppError::PermissionDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
core.set_password(UserId::from("admin"), password).await;
|
||||||
|
ok(Response::builder()
|
||||||
|
.header("Access-Control-Allow-Origin", "*")
|
||||||
|
.header("Access-Control-Allow-Methods", "*")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(vec![])
|
||||||
|
.unwrap())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ use asset_db::{AssetId, FsAssets};
|
||||||
use authdb::AuthError;
|
use authdb::AuthError;
|
||||||
use database::DbConn;
|
use database::DbConn;
|
||||||
use handlers::{
|
use handlers::{
|
||||||
handle_available_images, handle_connect_websocket, handle_file, handle_get_charsheet, handle_register_client, handle_set_background_image, handle_unregister_client, RegisterRequest
|
handle_available_images, handle_connect_websocket, handle_file, handle_get_charsheet, handle_get_users, handle_register_client, handle_server_status, handle_set_admin_password, handle_set_background_image, handle_unregister_client, RegisterRequest
|
||||||
};
|
};
|
||||||
use warp::{
|
use warp::{
|
||||||
// header,
|
// header,
|
||||||
|
@ -104,6 +104,13 @@ pub async fn main() {
|
||||||
let core = core::Core::new(FsAssets::new(PathBuf::from("/home/savanni/Pictures")), conn);
|
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_server_status = warp::path!("api" / "v1" / "status")
|
||||||
|
.and(warp::get())
|
||||||
|
.then({
|
||||||
|
let core = core.clone();
|
||||||
|
move || handle_server_status(core.clone())
|
||||||
|
});
|
||||||
|
|
||||||
let route_image = warp::path!("api" / "v1" / "image" / String)
|
let route_image = warp::path!("api" / "v1" / "image" / String)
|
||||||
.and(warp::get())
|
.and(warp::get())
|
||||||
.then({
|
.then({
|
||||||
|
@ -160,6 +167,13 @@ pub async fn main() {
|
||||||
})
|
})
|
||||||
.with(log);
|
.with(log);
|
||||||
|
|
||||||
|
let route_get_users = warp::path!("api" / "v1" / "users")
|
||||||
|
.and(warp::get())
|
||||||
|
.then({
|
||||||
|
let core = core.clone();
|
||||||
|
move || handle_get_users(core.clone())
|
||||||
|
});
|
||||||
|
|
||||||
let route_get_charsheet = warp::path!("api" / "v1" / "charsheet" / String)
|
let route_get_charsheet = warp::path!("api" / "v1" / "charsheet" / String)
|
||||||
.and(warp::get())
|
.and(warp::get())
|
||||||
.then({
|
.then({
|
||||||
|
@ -167,14 +181,39 @@ pub async fn main() {
|
||||||
move |charid| handle_get_charsheet(core.clone(), charid)
|
move |charid| handle_get_charsheet(core.clone(), charid)
|
||||||
});
|
});
|
||||||
|
|
||||||
let filter = route_register_client
|
let route_set_admin_password_options = warp::path!("api" / "v1" / "admin_password")
|
||||||
|
.and(warp::options())
|
||||||
|
.map({
|
||||||
|
move || {
|
||||||
|
Response::builder()
|
||||||
|
.header("Access-Control-Allow-Origin", "*")
|
||||||
|
.header("Access-Control-Allow-Methods", "PUT")
|
||||||
|
.header("Access-Control-Allow-Headers", "content-type")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body("")
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let route_set_admin_password = warp::path!("api" / "v1" / "admin_password")
|
||||||
|
.and(warp::put())
|
||||||
|
.and(warp::body::json())
|
||||||
|
.then({
|
||||||
|
let core = core.clone();
|
||||||
|
move |body| handle_set_admin_password(core.clone(), body)
|
||||||
|
});
|
||||||
|
|
||||||
|
let filter = route_server_status
|
||||||
|
.or(route_register_client)
|
||||||
.or(route_unregister_client)
|
.or(route_unregister_client)
|
||||||
.or(route_websocket)
|
.or(route_websocket)
|
||||||
.or(route_image)
|
.or(route_image)
|
||||||
.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_users)
|
||||||
.or(route_get_charsheet)
|
.or(route_get_charsheet)
|
||||||
|
.or(route_set_admin_password_options)
|
||||||
|
.or(route_set_admin_password)
|
||||||
.recover(handle_rejection);
|
.recover(handle_rejection);
|
||||||
|
|
||||||
let server = warp::serve(filter);
|
let server = warp::serve(filter);
|
||||||
|
|
|
@ -1,14 +1,45 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
use typeshare::typeshare;
|
use typeshare::typeshare;
|
||||||
|
|
||||||
use crate::asset_db::AssetId;
|
use crate::{asset_db::AssetId, database::UserRow};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Error)]
|
||||||
|
pub enum FatalError {
|
||||||
|
#[error("Non-unique database key {0}")]
|
||||||
|
NonUniqueDatabaseKey(String),
|
||||||
|
|
||||||
|
#[error("Database migrations failed {0}")]
|
||||||
|
DatabaseMigrationFailure(String),
|
||||||
|
|
||||||
|
#[error("Failed to construct a query")]
|
||||||
|
ConstructQueryFailure(String),
|
||||||
|
|
||||||
|
#[error("Database connection lost")]
|
||||||
|
DatabaseConnectionLost,
|
||||||
|
|
||||||
|
#[error("Unexpected response for message")]
|
||||||
|
MessageMismatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl result_extended::FatalError for FatalError {}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
|
#[error("something wasn't found {0}")]
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("object inaccessible {0}")]
|
||||||
Inaccessible(String),
|
Inaccessible(String),
|
||||||
|
|
||||||
|
#[error("the requested operation is not allowed")]
|
||||||
|
PermissionDenied,
|
||||||
|
|
||||||
|
#[error("invalid json {0}")]
|
||||||
JsonError(serde_json::Error),
|
JsonError(serde_json::Error),
|
||||||
|
|
||||||
|
#[error("wat {0}")]
|
||||||
UnexpectedError(String),
|
UnexpectedError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,6 +52,53 @@ pub struct RGB {
|
||||||
pub blue: u32,
|
pub blue: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[typeshare]
|
||||||
|
pub struct User {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub password: String,
|
||||||
|
pub admin: bool,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<UserRow> for User {
|
||||||
|
fn from(row: UserRow) -> Self {
|
||||||
|
Self {
|
||||||
|
id: row.id.as_str().to_owned(),
|
||||||
|
name: row.name.to_owned(),
|
||||||
|
password: row.password.to_owned(),
|
||||||
|
admin: row.admin,
|
||||||
|
enabled: row.enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[typeshare]
|
||||||
|
pub enum PlayerRole {
|
||||||
|
Gm,
|
||||||
|
Player,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[typeshare]
|
||||||
|
pub struct Player {
|
||||||
|
user_id: String,
|
||||||
|
role: PlayerRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[typeshare]
|
||||||
|
pub struct Game {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub players: Vec<Player>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
|
|
||||||
release:
|
|
||||||
NODE_ENV=production npm run build
|
|
||||||
|
|
||||||
dev:
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
server:
|
|
||||||
npx http-server ./dist
|
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,24 +0,0 @@
|
||||||
{
|
|
||||||
"name": "ui",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "",
|
|
||||||
"main": "webpack.config.js",
|
|
||||||
"scripts": {
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
|
||||||
"build": "webpack"
|
|
||||||
},
|
|
||||||
"author": "",
|
|
||||||
"license": "GPL-3.0-or-later",
|
|
||||||
"dependencies": {
|
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "^18.2.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "^18.2.8",
|
|
||||||
"@types/react-dom": "^18.2.4",
|
|
||||||
"copy-webpack-plugin": "^11.0.0",
|
|
||||||
"ts-loader": "^9.4.3",
|
|
||||||
"webpack": "^5.85.0",
|
|
||||||
"webpack-cli": "^5.1.3"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,23 +0,0 @@
|
||||||
type PlayingField = {
|
|
||||||
backgroundImage: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class Client {
|
|
||||||
playingField(): PlayingField {
|
|
||||||
return { backgroundImage: "tower-in-mist.jpg" };
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFile(filename: string): Promise<Blob | undefined> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`http://localhost:8001/api/v1/file/${filename}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Response status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await response.blob();
|
|
||||||
return body;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1,11 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import ReactDOM from "react-dom";
|
|
||||||
|
|
||||||
const App = () => <div>App</div>;
|
|
||||||
|
|
||||||
ReactDOM.render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>,
|
|
||||||
document.getElementById("root")
|
|
||||||
);
|
|
|
@ -1,24 +0,0 @@
|
||||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
mode: "development",
|
|
||||||
entry: {
|
|
||||||
"main": "./src/main.tsx"
|
|
||||||
},
|
|
||||||
module: {
|
|
||||||
rules: [
|
|
||||||
{ test: /\.tsx?$/, use: "ts-loader", exclude: /node_modules/ }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
new CopyWebpackPlugin({
|
|
||||||
patterns: [
|
|
||||||
{ from: "src/index.html" },
|
|
||||||
{ from: "src/visions.css" },
|
|
||||||
]
|
|
||||||
})
|
|
||||||
],
|
|
||||||
resolve: {
|
|
||||||
extensions: ['.ts', '.tsx'],
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { PropsWithChildren, useContext, useEffect, useState } from 'react';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { Client } from './client';
|
import { Client } from './client';
|
||||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||||
|
@ -6,7 +6,12 @@ import { DesignPage } from './views/Design/Design';
|
||||||
import { GmView } from './views/GmView/GmView';
|
import { GmView } from './views/GmView/GmView';
|
||||||
import { WebsocketProvider } from './components/WebsocketProvider';
|
import { WebsocketProvider } from './components/WebsocketProvider';
|
||||||
import { PlayerView } from './views/PlayerView/PlayerView';
|
import { PlayerView } from './views/PlayerView/PlayerView';
|
||||||
|
import { Admin } from './views/Admin/Admin';
|
||||||
import Candela from './plugins/Candela';
|
import Candela from './plugins/Candela';
|
||||||
|
import { Authentication } from './views/Authentication/Authentication';
|
||||||
|
import { StateContext, StateProvider } from './providers/StateProvider/StateProvider';
|
||||||
|
|
||||||
|
const TEST_CHARSHEET_UUID = "12df9c09-1f2f-4147-8eda-a97bd2a7a803";
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
client: Client;
|
client: Client;
|
||||||
|
@ -15,13 +20,28 @@ interface AppProps {
|
||||||
const CandelaCharsheet = ({ client }: { client: Client }) => {
|
const CandelaCharsheet = ({ client }: { client: Client }) => {
|
||||||
let [sheet, setSheet] = useState(undefined);
|
let [sheet, setSheet] = useState(undefined);
|
||||||
useEffect(
|
useEffect(
|
||||||
() => { client.charsheet("db7a2585-5dcf-4909-8743-2741111f8b9a").then((c) => setSheet(c)); },
|
() => { client.charsheet(TEST_CHARSHEET_UUID).then((c) => setSheet(c)); },
|
||||||
[client, setSheet]
|
[client, setSheet]
|
||||||
);
|
);
|
||||||
|
|
||||||
return sheet ? <Candela.CharsheetElement sheet={sheet} /> : <div> </div>
|
return sheet ? <Candela.CharsheetElement sheet={sheet} /> : <div> </div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AuthedViewProps {
|
||||||
|
client: Client;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthedView = ({ client, children }: PropsWithChildren<AuthedViewProps>) => {
|
||||||
|
const [state, manager] = useContext(StateContext);
|
||||||
|
return (
|
||||||
|
<Authentication onAdminPassword={(password) => {
|
||||||
|
manager.setAdminPassword(password);
|
||||||
|
}} onAuth={(username, password) => console.log(username, password)}>
|
||||||
|
{children}
|
||||||
|
</Authentication>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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,13 +52,17 @@ const App = ({ client }: AppProps) => {
|
||||||
|
|
||||||
let router =
|
let router =
|
||||||
createBrowserRouter([
|
createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <StateProvider client={client}><AuthedView client={client}> <PlayerView client={client} /> </AuthedView> </StateProvider>
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/gm",
|
path: "/gm",
|
||||||
element: websocketUrl ? <WebsocketProvider websocketUrl={websocketUrl}> <GmView client={client} /> </WebsocketProvider> : <div> </div>
|
element: websocketUrl ? <WebsocketProvider websocketUrl={websocketUrl}> <GmView client={client} /> </WebsocketProvider> : <div> </div>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/admin",
|
||||||
element: websocketUrl ? <WebsocketProvider websocketUrl={websocketUrl}> <PlayerView client={client} /> </WebsocketProvider> : <div> </div>
|
element: <Admin client={client} />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/candela",
|
path: "/candela",
|
||||||
|
|
|
@ -45,9 +45,29 @@ export class Client {
|
||||||
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 users() {
|
||||||
|
const url = new URL(this.base);
|
||||||
|
url.pathname = '/api/v1/users/';
|
||||||
|
return fetch(url).then((response) => response.json());
|
||||||
|
}
|
||||||
|
|
||||||
async charsheet(id: string) {
|
async charsheet(id: string) {
|
||||||
const url = new URL(this.base);
|
const url = new URL(this.base);
|
||||||
url.pathname = `/api/v1/charsheet/${id}`;
|
url.pathname = `/api/v1/charsheet/${id}`;
|
||||||
return fetch(url).then((response) => response.json());
|
return fetch(url).then((response) => response.json());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setAdminPassword(password: string) {
|
||||||
|
const url = new URL(this.base);
|
||||||
|
url.pathname = `/api/v1/admin_password`;
|
||||||
|
console.log("setting the admin password to: ", password);
|
||||||
|
return fetch(url, { method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify(password) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async status() {
|
||||||
|
const url = new URL(this.base);
|
||||||
|
url.pathname = `/api/v1/status`;
|
||||||
|
return fetch(url).then((response) => response.json());
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,19 +2,17 @@ import React, { createContext, PropsWithChildren, useEffect, useReducer } from "
|
||||||
import useWebSocket from "react-use-websocket";
|
import useWebSocket from "react-use-websocket";
|
||||||
import { Message, Tabletop } from "visions-types";
|
import { Message, Tabletop } from "visions-types";
|
||||||
|
|
||||||
type TabletopState = {
|
type WebsocketState = { }
|
||||||
tabletop: Tabletop;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState = (): TabletopState => ({ tabletop: { backgroundColor: { red: 0, green: 0, blue: 0 }, backgroundImage: undefined } });
|
export const WebsocketContext = createContext<WebsocketState>({});
|
||||||
|
|
||||||
export const WebsocketContext = createContext<TabletopState>(initialState());
|
|
||||||
|
|
||||||
interface WebsocketProviderProps {
|
interface WebsocketProviderProps {
|
||||||
websocketUrl: string;
|
websocketUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WebsocketProvider = ({ websocketUrl, children }: PropsWithChildren<WebsocketProviderProps>) => {
|
export const WebsocketProvider = ({ websocketUrl, children }: PropsWithChildren<WebsocketProviderProps>) => {
|
||||||
|
return <div> {children} </div>;
|
||||||
|
/*
|
||||||
const { lastMessage } = useWebSocket(websocketUrl);
|
const { lastMessage } = useWebSocket(websocketUrl);
|
||||||
|
|
||||||
const [state, dispatch] = useReducer(handleMessage, initialState());
|
const [state, dispatch] = useReducer(handleMessage, initialState());
|
||||||
|
@ -29,8 +27,10 @@ export const WebsocketProvider = ({ websocketUrl, children }: PropsWithChildren<
|
||||||
return (<WebsocketContext.Provider value={state}>
|
return (<WebsocketContext.Provider value={state}>
|
||||||
{children}
|
{children}
|
||||||
</WebsocketContext.Provider>);
|
</WebsocketContext.Provider>);
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
const handleMessage = (state: TabletopState, message: Message): TabletopState => {
|
const handleMessage = (state: TabletopState, message: Message): TabletopState => {
|
||||||
console.log(message);
|
console.log(message);
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
|
@ -42,3 +42,4 @@ const handleMessage = (state: TabletopState, message: Message): TabletopState =>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
:root {
|
||||||
|
--border-standard: 2px solid black;
|
||||||
|
--border-radius-standard: 4px;
|
||||||
|
--border-shadow-shallow: 1px 1px 2px black;
|
||||||
|
--padding-m: 8px;
|
||||||
|
--margin-s: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: var(--border-standard);
|
||||||
|
border-radius: var(--border-radius-standard);
|
||||||
|
box-shadow: var(--border-shadow-shallow);
|
||||||
|
padding: var(--padding-m);
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,70 @@
|
||||||
|
import React, { createContext, PropsWithChildren, useCallback, useEffect, useReducer, useRef } from "react";
|
||||||
|
import { Status, Tabletop } from "visions-types";
|
||||||
|
import { Client } from "../../client";
|
||||||
|
import { assertNever } from "../../plugins/Candela";
|
||||||
|
|
||||||
|
type AuthState = { type: "NoAdmin" } | { type: "Unauthed" } | { type: "Authed", userid: string };
|
||||||
|
|
||||||
|
type AppState = {
|
||||||
|
auth: AuthState;
|
||||||
|
tabletop: Tabletop;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action = { type: "SetAuthState", content: AuthState };
|
||||||
|
|
||||||
|
const initialState = (): AppState => (
|
||||||
|
{
|
||||||
|
auth: { type: "NoAdmin" },
|
||||||
|
tabletop: { backgroundColor: { red: 0, green: 0, blue: 0 }, backgroundImage: undefined }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const stateReducer = (state: AppState, action: Action): AppState => {
|
||||||
|
return { ...state, auth: action.content }
|
||||||
|
}
|
||||||
|
|
||||||
|
class StateManager {
|
||||||
|
client: Client | undefined;
|
||||||
|
dispatch: React.Dispatch<Action> | undefined;
|
||||||
|
|
||||||
|
constructor(client: Client | undefined, dispatch: React.Dispatch<any> | undefined) {
|
||||||
|
this.client = client;
|
||||||
|
this.dispatch = dispatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
async status() {
|
||||||
|
if (!this.client || !this.dispatch) return;
|
||||||
|
|
||||||
|
const { admin_enabled } = await this.client.status();
|
||||||
|
if (!admin_enabled) {
|
||||||
|
this.dispatch({ type: "SetAuthState", content: { type: "NoAdmin" } });
|
||||||
|
} else {
|
||||||
|
this.dispatch({ type: "SetAuthState", content: { type: "Unauthed" } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAdminPassword(password: string) {
|
||||||
|
if (!this.client || !this.dispatch) return;
|
||||||
|
|
||||||
|
await this.client.setAdminPassword(password);
|
||||||
|
await this.status();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StateContext = createContext<[AppState, StateManager]>([initialState(), new StateManager(undefined, undefined)]);
|
||||||
|
|
||||||
|
interface StateProviderProps { client: Client; }
|
||||||
|
|
||||||
|
export const StateProvider = ({ client, children }: PropsWithChildren<StateProviderProps>) => {
|
||||||
|
const [state, dispatch] = useReducer(stateReducer, initialState());
|
||||||
|
|
||||||
|
const stateManager = useRef(new StateManager(client, dispatch));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
stateManager.current.status();
|
||||||
|
}, [stateManager]);
|
||||||
|
|
||||||
|
return <StateContext.Provider value={[state, stateManager.current]}>
|
||||||
|
{children}
|
||||||
|
</StateContext.Provider>;
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Game, User } from 'visions-types';
|
||||||
|
import { Client } from '../../client';
|
||||||
|
|
||||||
|
interface UserRowProps {
|
||||||
|
user: User,
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserRow = ({ user }: UserRowProps) => {
|
||||||
|
return (<tr>
|
||||||
|
<td> {user.name} </td>
|
||||||
|
<td> {user.admin && "admin"} </td>
|
||||||
|
<td> {user.enabled && "enabled"} </td>
|
||||||
|
</tr>);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GameRowProps {
|
||||||
|
game: Game,
|
||||||
|
}
|
||||||
|
|
||||||
|
const GameRow = ({ game }: GameRowProps) => {
|
||||||
|
return (<tr>
|
||||||
|
<td> {game.name} </td>
|
||||||
|
</tr>);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminProps {
|
||||||
|
client: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Admin = ({ client }: AdminProps) => {
|
||||||
|
const [users, setUsers] = useState<Array<User>>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
client.users().then((u) => {
|
||||||
|
console.log(u);
|
||||||
|
setUsers(u);
|
||||||
|
});
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
|
console.log(users);
|
||||||
|
return (<table>
|
||||||
|
<tbody>
|
||||||
|
{users.map((user) => <UserRow user={user} />)}
|
||||||
|
</tbody>
|
||||||
|
</table>);
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
@import '../../design.css';
|
||||||
|
|
||||||
|
.auth {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth__input-line {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth__input-line > * {
|
||||||
|
margin: var(--margin-s);
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { PropsWithChildren, useContext, useState } from 'react';
|
||||||
|
import { StateContext } from '../../providers/StateProvider/StateProvider';
|
||||||
|
import { assertNever } from '../../plugins/Candela';
|
||||||
|
import './Authentication.css';
|
||||||
|
|
||||||
|
interface AuthenticationProps {
|
||||||
|
onAdminPassword: (password: string) => void;
|
||||||
|
onAuth: (username: string, password: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Authentication = ({ onAdminPassword, onAuth, children }: PropsWithChildren<AuthenticationProps>) => {
|
||||||
|
// No admin password set: prompt for the admin password
|
||||||
|
// Password set, nobody logged in: prompt for login
|
||||||
|
// User logged in: show the children
|
||||||
|
|
||||||
|
let [userField, setUserField] = useState<string>("");
|
||||||
|
let [pwField, setPwField] = useState<string>("");
|
||||||
|
let [state, _] = useContext(StateContext);
|
||||||
|
|
||||||
|
switch (state.auth.type) {
|
||||||
|
case "NoAdmin": {
|
||||||
|
return <div className="auth">
|
||||||
|
<div className="card">
|
||||||
|
<h1> Welcome to your new Visions VTT Instance </h1>
|
||||||
|
<p> Set your admin password: </p>
|
||||||
|
<input type="password" placeholder="Password" onChange={(evt) => setPwField(evt.target.value)} />
|
||||||
|
<input type="submit" value="Submit" onClick={() => onAdminPassword(pwField)} />
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
case "Unauthed": {
|
||||||
|
return <div className="auth card">
|
||||||
|
<div className="card">
|
||||||
|
<h1> Welcome to Visions VTT </h1>
|
||||||
|
<div className="auth__input-line">
|
||||||
|
<input type="text" placeholder="Username" onChange={(evt) => setUserField(evt.target.value)} />
|
||||||
|
<input type="password" placeholder="Password" onChange={(evt) => setPwField(evt.target.value)} />
|
||||||
|
<input type="submit" value="Sign in" onClick={() => onAuth(userField, pwField)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
case "Authed": {
|
||||||
|
return <div> {children} </div>;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
assertNever(state.auth);
|
||||||
|
return <div></div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { useContext, useEffect, useState } from 'react';
|
import { useContext, useEffect, useState } from 'react';
|
||||||
import { Client, PlayingField } from '../../client';
|
import { Client } from '../../client';
|
||||||
|
import { StateContext } from '../../providers/StateProvider/StateProvider';
|
||||||
import { TabletopElement } from '../../components/Tabletop/Tabletop';
|
import { TabletopElement } from '../../components/Tabletop/Tabletop';
|
||||||
import { ThumbnailElement } from '../../components/Thumbnail/Thumbnail';
|
import { ThumbnailElement } from '../../components/Thumbnail/Thumbnail';
|
||||||
import { WebsocketContext } from '../../components/WebsocketProvider';
|
import { WebsocketContext } from '../../components/WebsocketProvider';
|
||||||
|
@ -10,19 +11,19 @@ interface GmViewProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GmView = ({ client }: GmViewProps) => {
|
export const GmView = ({ client }: GmViewProps) => {
|
||||||
const { tabletop } = useContext(WebsocketContext);
|
const [state, dispatch] = useContext(StateContext);
|
||||||
|
|
||||||
const [images, setImages] = useState<string[]>([]);
|
const [images, setImages] = useState<string[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
client.availableImages().then((images) => setImages(images));
|
client.availableImages().then((images) => setImages(images));
|
||||||
}, [client]);
|
}, [client]);
|
||||||
|
|
||||||
const backgroundUrl = tabletop.backgroundImage ? client.imageUrl(tabletop.backgroundImage) : undefined;
|
const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined;
|
||||||
return (<div className="gm-view">
|
return (<div className="gm-view">
|
||||||
<div>
|
<div>
|
||||||
{images.map((imageName) => <ThumbnailElement id={imageName} url={client.imageUrl(imageName)} onclick={() => { client.setBackgroundImage(imageName); }} />)}
|
{images.map((imageName) => <ThumbnailElement id={imageName} url={client.imageUrl(imageName)} onclick={() => { client.setBackgroundImage(imageName); }} />)}
|
||||||
</div>
|
</div>
|
||||||
<TabletopElement backgroundColor={tabletop.backgroundColor} backgroundUrl={backgroundUrl} />
|
<TabletopElement backgroundColor={state.tabletop.backgroundColor} backgroundUrl={backgroundUrl} />
|
||||||
</div>)
|
</div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,28 +4,31 @@ import { WebsocketContext } from '../../components/WebsocketProvider';
|
||||||
import { Client } from '../../client';
|
import { Client } from '../../client';
|
||||||
import { TabletopElement } from '../../components/Tabletop/Tabletop';
|
import { TabletopElement } from '../../components/Tabletop/Tabletop';
|
||||||
import Candela from '../../plugins/Candela';
|
import Candela from '../../plugins/Candela';
|
||||||
|
import { StateContext } from '../../providers/StateProvider/StateProvider';
|
||||||
|
|
||||||
|
const TEST_CHARSHEET_UUID = "12df9c09-1f2f-4147-8eda-a97bd2a7a803";
|
||||||
|
|
||||||
interface PlayerViewProps {
|
interface PlayerViewProps {
|
||||||
client: Client;
|
client: Client;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayerView = ({ client }: PlayerViewProps) => {
|
export const PlayerView = ({ client }: PlayerViewProps) => {
|
||||||
const { tabletop } = useContext(WebsocketContext);
|
const [state, dispatch] = useContext(StateContext);
|
||||||
|
|
||||||
const [charsheet, setCharsheet] = useState(undefined);
|
const [charsheet, setCharsheet] = useState(undefined);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => {
|
() => {
|
||||||
client.charsheet("db7a2585-5dcf-4909-8743-2741111f8b9a").then((c) => {
|
client.charsheet(TEST_CHARSHEET_UUID).then((c) => {
|
||||||
setCharsheet(c)
|
setCharsheet(c)
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[client, setCharsheet]
|
[client, setCharsheet]
|
||||||
);
|
);
|
||||||
|
|
||||||
const backgroundColor = tabletop.backgroundColor;
|
const backgroundColor = state.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 = state.tabletop.backgroundImage ? client.imageUrl(state.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>
|
||||||
|
|
Loading…
Reference in New Issue