Compare commits

..

No commits in common. "edit-character" and "master" have entirely different histories.

70 changed files with 15088 additions and 929 deletions

3
.env
View File

@ -1 +1,2 @@
DATABASE_URL="./tenebrous.sqlite" DATABASE_URL="sqlite://tenebrous.sqlite"
SQLX_OFFLINE="true"

7
.gitignore vendored
View File

@ -1,3 +1,8 @@
/target /target
todo.org todo.org
*.sqlite *.sqlite*
*.sqlite.*
node_modules
static/scripts/dist
static/templates/*
generated/

1617
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,32 +4,50 @@ version = "0.1.0"
authors = ["jeff <jeff@agnos.is>"] authors = ["jeff <jeff@agnos.is>"]
edition = "2018" edition = "2018"
build = "build.rs" build = "build.rs"
default-run = "tenebrous"
[package.metadata.scripts]
grpc-proxy = 'docker run -d --rm -v "$(pwd)"/envoy.yaml:/etc/envoy/envoy.yaml:ro --network=host envoyproxy/envoy:v1.16.1'
[[bin]]
name = "tenebrous-migrate"
path = "src/migrate.rs"
[[bin]]
name = "tenebrous"
path = "src/main.rs"
[build-dependencies] [build-dependencies]
prost-build = "0.6" prost-build = "0.6"
tonic-build = "0.3"
[dependencies] [dependencies]
tonic = "0.3"
prost = "0.6" prost = "0.6"
serde = "1.0" serde = { version = "1.0", features = ["derive"] }
serde_derive = "1.0"
serde_json = "1.0" serde_json = "1.0"
erased-serde = "0.3" erased-serde = "0.3"
diesel = "1.4"
diesel-derive-enum = { version = "1", features = ["sqlite"] }
thiserror = "1.0" thiserror = "1.0"
rust-argon2 = "0.8" rust-argon2 = "0.8"
log = "0.4" log = "0.4"
rand = "0.7" rand = "0.7"
futures = "0.3" futures = "0.3"
tokio = { version = "1.0", features = ["macros"] }
strum = { version = "0.20", features = ["derive"] } strum = { version = "0.20", features = ["derive"] }
refinery = { version = "0.5", features = ["rusqlite"]}
barrel = { version = "0.6", features = ["sqlite3"] }
[dependencies.sqlx]
version = "0.5"
features = [ "offline", "sqlite", "runtime-tokio-native-tls" ]
[dependencies.rocket] [dependencies.rocket]
git = "https://github.com/SergioBenitez/Rocket" git = "https://github.com/SergioBenitez/Rocket"
branch = "master" branch = "master"
features = ["secrets"] features = ["secrets", "tls"]
[dependencies.rocket_contrib] [dependencies.rocket_contrib]
git = "https://github.com/SergioBenitez/Rocket" git = "https://github.com/SergioBenitez/Rocket"
branch = "master" branch = "master"
default-features = false default-features = false
features = [ "tera_templates", "diesel_sqlite_pool", "serve" ] features = [ "tera_templates", "serve" ]

103
README.md
View File

@ -4,48 +4,101 @@ An open source character sheet service for tabletop roleplaying games.
Currently under heavy development. Currently under heavy development.
## The Stack
This project makes use of these technologies:
- Rust
- Rocket Web Framework
- Tonic gRPC Framework
- Typescript
Building is backed by: cargo, npm, and webpack.
## Build Instructions ## Build Instructions
These are very basic build instructions. They assume you already have These are very basic build instructions. They assume you already have
cargo set up and installed. Building the application requires **Rust cargo set up and installed.
Nightly!** See [rustup documentation][rustup] for more, particularly
the part about using [Rust Nightly][nightly].
### Install Dependencies
Install dependencies. The exact method depends on your OS.
* sqlite3 and development headers (Void Linux: `xbps-install
sqlite sqlite-devel`, Ubuntu: `apt install sqlite3 libsqlite3-dev`).
* protoc: protocol buffers compiler. There is one baked into the
build, so you should not need this unless you are not using
Linux/Mac/Windows.
### Initial Setup ### Initial Setup
Follow these instructions from the root of the repository. Set up database: Quick initial setup instructions that will set up a development
environment.
First, install dependencies by either using the command below (Void
Linux), or reading the "Dependencies Required" section:
``` ```
cargo install diesel_cli --no-default-features --features sqlite xbps-install sqlite sqlite-devel protobuf nodejs docker
diesel setup
diesel migration run
``` ```
Then run the required `cargo` commands to install management tools and
create a development database:
```
cargo install --version=0.2.0 sqlx-cli
cargo install cargo-run-script
cargo run --bin tenebrous-migrate
```
### Dependencies Required
Dependencies required to build the project. The exact installation
method depends on your OS.
* sqlite3 and development headers (Void Linux: `xbps-install
sqlite sqlite-devel`, Ubuntu: `apt install sqlite3 libsqlite3-dev`).
* protoc: protocol buffers compiler. Needed to compile protobuf files
for both the server and web frontends (Void Linux: `xbps-install
protobuf`).
* Node and npm: Needed to run webpack for compiling and injecting
Typescript into various web pages (Void Linux: `xbps-install
nodejs`).
* Docker: Needed to run the grpc proxy (Void Linux `xbps-install
docker`).
### Run Application ### Run Application
If you are using `rustup`, then it should automatically switch to the
nightly version of Rust in this repository. This is because of the
`rust-toolchain` file.
Command line "instructions" to build and run the application: Command line "instructions" to build and run the application:
``` ```
cargo run-script grpc-proxy # only required if proxy not already running
cargo run cargo run
``` ```
The sqlite database is currently always created in the same directory The sqlite database is created in the directory `cargo run` was
that `cargo run` was invoked from, so make sure you invoke it from the invoked from by default. You can also pass a path to a different
same place every time. location as a single argument to the program.
## Development
Development instructions.
To set up a local database, or run migrations, run:
```
cargo run --bin tenebrous-migrate
```
### Database Queries and Migrations
When adding/updating a compile-checked query or a migration, you need
to update the SQLx data JSON file:
```
cargo sqlx prepare -- --bin tenebrous
```
### gRPC-Web Proxy
The frontend web application makes use of the gRPC-Web protocol to
call gPRC endpoints from the browser. This requires a proxy to
translate the calls from the browser to HTTP2 calls gRPC understands.
For development, executing the `cargo run-script grpc-proxy` command
will start the envoy proxy recommended by Google's gRPC-Web project.
The envoy configuration assumes you are on Linux. If you are using Mac
OS or Windows, see the note in the envoy configuration.
[rustup]: https://rust-lang.github.io/rustup/index.html [rustup]: https://rust-lang.github.io/rustup/index.html
[nightly]: https://rust-lang.github.io/rustup/concepts/channels.html#working-with-nightly-rust

View File

@ -1,3 +1,6 @@
[default]
template_dir = "generated/templates/"
[development] [development]
address = "localhost" address = "localhost"
port = 8000 port = 8000

View File

@ -1,9 +1,50 @@
use std::process::Command;
fn js_protos() {
let output = Command::new("npm")
.arg("run")
.arg("build:protobuf")
.output()
.unwrap();
if output.status.success() {
return;
} else {
let err = String::from_utf8(output.stderr).unwrap();
panic!("JS Protobuf build failure:\n {}", err);
}
}
fn webpack() {
let output = Command::new("npm")
.arg("run")
.arg("build:webpack")
.output()
.unwrap();
if output.status.success() {
return;
} else {
let err = String::from_utf8(output.stderr).unwrap();
panic!("Webpack build failure:\n {}", err);
}
}
fn main() { fn main() {
println!("cargo:rerun-if-changed=static/scripts/webpack.config.js");
js_protos();
webpack();
let mut config = prost_build::Config::new(); let mut config = prost_build::Config::new();
config.type_attribute(".", "#[derive(Serialize)]"); config.btree_map(&["."]);
config.type_attribute(".", "#[derive(::serde::Serialize)]");
config.type_attribute(".", "#[serde(rename_all = \"camelCase\")]"); config.type_attribute(".", "#[serde(rename_all = \"camelCase\")]");
config
.compile_protos( tonic_build::configure()
.build_server(false)
.build_client(false)
.compile_with_config(
config,
&["proto/cofd.proto", "proto/cofd_api.proto"], &["proto/cofd.proto", "proto/cofd_api.proto"],
&["src/", "proto/"], &["src/", "proto/"],
) )

54
envoy.yaml Normal file
View File

@ -0,0 +1,54 @@
admin:
access_log_path: /tmp/admin_access.log
address:
socket_address: { address: 0.0.0.0, port_value: 9901 }
static_resources:
listeners:
- name: listener_0
address:
socket_address: { address: 0.0.0.0, port_value: 8080 }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.config.filter.network.http_connection_manager.v2.HttpConnectionManager
codec_type: auto
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/" }
route:
cluster: greeter_service
max_grpc_timeout: 0s
cors:
allow_origin_string_match:
- prefix: "*"
allow_methods: GET, PUT, DELETE, POST, OPTIONS
allow_headers: keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
max_age: "1728000"
expose_headers: custom-header-1,grpc-status,grpc-message
http_filters:
- name: envoy.filters.http.grpc_web
- name: envoy.filters.http.cors
- name: envoy.filters.http.router
clusters:
- name: greeter_service
connect_timeout: 0.25s
type: logical_dns
http2_protocol_options: {}
lb_policy: round_robin
# win/mac hosts: Use address: host.docker.internal instead of address: localhost in the line below
load_assignment:
cluster_name: cluster_0
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: localhost
port_value: 9090

3
envoy/Dockerfile Normal file
View File

@ -0,0 +1,3 @@
FROM envoyproxy/envoy:v1.16-latest
COPY ./envoy.yaml /etc/envoy/envoy.yaml
CMD /usr/local/bin/envoy -c /etc/envoy/envoy.yaml

View File

View File

@ -1 +0,0 @@
DROP TABLE characters;

View File

@ -1,9 +0,0 @@
CREATE TABLE characters(
id INTEGER NOT NULL PRIMARY KEY,
user_id INTEGER NOT NULL,
viewable BOOLEAN NOT NULL,
character_name TEXT NOT NULL,
data_type TEXT CHECK(data_type IN ('chronicles_of_darkness_v1', 'changeling_v1')) NOT NULL,
data_version INTEGER NOT NULL,
data BLOB NOT NULL
);

View File

@ -1 +0,0 @@
DROP TABLE users;

View File

@ -1,5 +0,0 @@
CREATE TABLE users (
id INTEGER NOT NULL PRIMARY KEY,
username TEXT NOT NULL,
password TEXT NOT NULL
);

5371
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "tenebrous-sheets",
"version": "0.1.0",
"description": "An open source character sheet service for tabletop roleplaying games.",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"webpack-dev": "webpack --watch",
"build:protobuf": "mkdir -p src/frontend/_proto && protoc --plugin=protoc-gen-ts=./node_modules/.bin/protoc-gen-ts -I ./proto --js_out=import_style=commonjs,binary:./src/frontend/_proto --ts_out=service=grpc-web:./src/frontend/_proto ./proto/*.proto",
"build:webpack": "webpack"
},
"repository": {
"type": "git",
"url": "ssh://git@git.agnos.is:22022/projectmoon/tenebrous-sheets.git"
},
"author": "",
"license": "AGPL",
"dependencies": {
"@improbable-eng/grpc-web": "^0.13.0",
"google-protobuf": "^3.14.0"
},
"devDependencies": {
"@types/google-protobuf": "^3.7.4",
"clean-webpack-plugin": "^3.0.0",
"compression-webpack-plugin": "^7.1.2",
"html-webpack-plugin": "^4.5.0",
"ts-loader": "^8.0.13",
"ts-protoc-gen": "^0.14.0",
"typescript": "^4.1.3",
"webpack": "^5.11.1",
"webpack-cli": "^4.3.1",
"webpack-dev-server": "^3.11.1"
}
}

View File

@ -2,6 +2,24 @@ syntax = "proto3";
package models.proto.cofd; package models.proto.cofd;
//TODO do we want a single "morality" value, or keep it separate
//inside the system-specific fields?
//Information and values specific to the core game.
message CoreFields {
int32 integrity = 1;
}
//Information and values specific to Mage 2E.
message MageFields {
int32 widsom = 1;
}
//Information and values specific to Changeling 2E.
message ChangelingFields {
int32 clarity = 1;
}
//Base sheet for Chronicles of Darkness systems. //Base sheet for Chronicles of Darkness systems.
message CofdSheet { message CofdSheet {
message Merit { message Merit {
@ -39,41 +57,47 @@ message CofdSheet {
} }
string name = 1; string name = 1;
string player = 2; string gender = 2;
string campaign = 3; string concept = 3;
string description = 4; int32 age = 4;
int32 strength = 6; string player = 5;
int32 dexterity = 7; string chronicle = 6;
int32 stamina = 8; string description = 7;
int32 intelligence = 9; int32 strength = 8;
int32 wits = 10; int32 dexterity = 9;
int32 resolve = 11; int32 stamina = 10;
int32 presence = 12; int32 intelligence = 11;
int32 manipulation = 13; int32 wits = 12;
int32 composure = 14; int32 resolve = 13;
map<string, Skill> physical_skills = 16; int32 presence = 14;
map<string, Skill> mental_skills = 17; int32 manipulation = 15;
map<string, Skill> social_skills = 18; int32 composure = 16;
repeated Merit merits = 15; map<string, Skill> physical_skills = 17;
repeated Condition conditions = 19; map<string, Skill> mental_skills = 18;
map<string, Skill> social_skills = 19;
int32 size = 20; repeated Merit merits = 20;
int32 health = 21; repeated Condition conditions = 21;
int32 willpower = 22;
int32 experience_points = 23;
int32 beats = 24;
repeated Item items = 25; int32 size = 22;
repeated Attack attacks = 26; int32 health = 23;
int32 willpower = 24;
int32 experience_points = 25;
int32 beats = 26;
map<string, string> other_data = 27; repeated Item items = 27;
repeated Attack attacks = 28;
map<string, string> other_data = 29;
oneof system_fields {
CoreFields core = 30;
MageFields mage = 31;
ChangelingFields changeling = 32;
} }
message ChangelingSheet {
CofdSheet base = 1;
} }

View File

@ -3,48 +3,84 @@ import "cofd.proto";
package models.proto.cofd.api; package models.proto.cofd.api;
message CharacterIdentifier {
string owner = 1;
int32 id = 2;
}
//Update basic information about a Chronicles of Darkness (or //Update basic information about a Chronicles of Darkness (or
//derivative system) character sheet. This is a straight overwrite of //derivative system) character sheet. This is a straight overwrite of
//all basic information on the sheet. //all basic information on the sheet.
message BasicInfo { message UpdateBasicInfoRequest {
CharacterIdentifier character = 1;
reserved 2;
// string owner = 1;
// int32 character_id = 2;
string name = 3;
string gender = 4;
string concept = 5;
string chronicle = 6;
int32 age = 7;
}
//Generic "did something succeed or not" response.
message ApiResult {
bool success = 1;
string error = 2;
}
//Update an attribute's dot amount. TODO rename to AttributesUpdate.
message UpdateAttributeRequest {
CharacterIdentifier character = 1;
string attribute_name = 2;
int32 attribute_value = 3;
}
//Full update of a single skill
message UpdateSkillRequest {
CharacterIdentifier character = 1;
CofdSheet.Skill skill = 2;
}
//Partial update of a single skill dot amount.
message UpdateSkillValueRequest {
CharacterIdentifier character = 1;
string skill_name = 2;
int32 skill_value = 3;
}
//Partial update of only a skill's specializations. The
//specializations will be overwritten with the new values.
message UpdateSkillSpecializationsRequest {
string name = 1; string name = 1;
string gender = 2; repeated string specializations = 2;
string concept = 3;
string chronicle = 4;
int32 age = 5;
} }
//Update all attributes in a Chronicles of Darkness character (or //Update all merits on the character sheet by overwriting them.
//derivative system) character sheet. This is a straight overwrite of //Primarily for the web UI.
//all basic information on the sheet. message UpdateMeritsRequest {
message Attributes { CharacterIdentifier character = 1;
int32 strength = 1; repeated CofdSheet.Merit merits = 2;
int32 dexterity = 2;
int32 stamina = 3;
int32 intelligence = 4;
int32 wits = 5;
int32 resolve = 6;
int32 presence = 7;
int32 manipulation = 8;
int32 composure = 9;
} }
message Attribute { //Update all items on the character sheet by overwriting them.
string name = 1; //Primarily for the web UI.
int32 value = 2; message UpdateItemsRequest {
} CharacterIdentifier character = 1;
repeated CofdSheet.Item items = 2;
//Update skill entries in a Chronicles of Darkness character sheet.
//This is a straight overwrite of all skills in the sheet.
message Skills {
repeated CofdSheet.Skill physical_skills = 1;
repeated CofdSheet.Skill mental_skills = 2;
repeated CofdSheet.Skill social_skills = 3;
} }
//Add a Condition to a Chronicles of Darkness character sheet. //Add a Condition to a Chronicles of Darkness character sheet.
message Condition { message AddConditionRequest {
string name = 1; string character_username = 1;
int32 character_id = 2;
string condition_name = 3;
}
//Remove a Condition from a Chronicles of Darkness character sheet.
message RemoveConditionRequest {
string character_username = 1;
int32 character_id = 2;
string condition_name = 3;
} }

165
sqlx-data.json Normal file
View File

@ -0,0 +1,165 @@
{
"db": "SQLite",
"492e1e087edc6eff4004033227e3f3510f1165c3ec7626e89b695c760bc113d2": {
"query": "SELECT id as \"id: _\",\n user_id as \"user_id: _\",\n data_type as \"data_type: _\",\n data_version as \"data_version: _\",\n viewable, character_name\n FROM characters WHERE user_id = ?",
"describe": {
"columns": [
{
"name": "id: _",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "user_id: _",
"ordinal": 1,
"type_info": "Int64"
},
{
"name": "data_type: _",
"ordinal": 2,
"type_info": "Null"
},
{
"name": "data_version: _",
"ordinal": 3,
"type_info": "Int64"
},
{
"name": "viewable",
"ordinal": 4,
"type_info": "Bool"
},
{
"name": "character_name",
"ordinal": 5,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false
]
}
},
"7a55ee917cf1ec732b10b2844dd5a3d0089c177b0eb183932fd73e20387a1610": {
"query": "SELECT id as \"id: _\", username, password FROM users WHERE username = ?",
"describe": {
"columns": [
{
"name": "id: _",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "username",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "password",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
}
},
"d7d1fc9ceff3b7659c8f04fd5b574104d1866cb1ea67d9534b6f3f4064699fb6": {
"query": "SELECT id as \"id: _\", username, password FROM users WHERE id = ?",
"describe": {
"columns": [
{
"name": "id: _",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "username",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "password",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
}
},
"e3c4b224ce8ce70a4d7709af7abe82a6f53bff834460fa7d32bb0b6daea94565": {
"query": "SELECT id as \"id: _\",\n user_id as \"user_id: _\",\n viewable, character_name, data,\n data_type as \"data_type: _\",\n data_version as \"data_version: _\"\n FROM characters WHERE id = ?",
"describe": {
"columns": [
{
"name": "id: _",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "user_id: _",
"ordinal": 1,
"type_info": "Int64"
},
{
"name": "viewable",
"ordinal": 2,
"type_info": "Bool"
},
{
"name": "character_name",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "data",
"ordinal": 4,
"type_info": "Blob"
},
{
"name": "data_type: _",
"ordinal": 5,
"type_info": "Null"
},
{
"name": "data_version: _",
"ordinal": 6,
"type_info": "Int64"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false
]
}
}
}

202
src/db.rs
View File

@ -1,119 +1,155 @@
use crate::models::characters::{Character, NewCharacter, StrippedCharacter}; use crate::models::characters::{Character, NewCharacter, StrippedCharacter};
use crate::models::users::{NewUser, User}; use crate::models::users::{NewUser, User};
use crate::schema::characters; use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use diesel::prelude::*; use sqlx::ConnectOptions;
use rocket_contrib::databases::diesel; use std::str::FromStr;
#[database("tenebrous_db")] /// Type alias for the Rocket-managed singleton database connection.
pub(crate) struct TenebrousDbConn(SqliteConnection); pub type TenebrousDbConn<'a> = rocket::State<'a, SqlitePool>;
/// Create a connection pool to the database.
pub(crate) async fn create_pool(db_path: &str) -> Result<SqlitePool, crate::errors::Error> {
//Create database if missing.
let conn = SqliteConnectOptions::from_str(&format!("sqlite://{}", db_path))?
.create_if_missing(true)
.connect()
.await?;
drop(conn);
//Return actual conncetion pool.
SqlitePoolOptions::new()
.max_connections(5)
.connect(db_path)
.await
.map_err(|e| e.into())
}
#[rocket::async_trait] #[rocket::async_trait]
pub(crate) trait Dao { pub(crate) trait Dao {
async fn load_user_by_id(&self, id: i32) -> QueryResult<Option<User>>; async fn load_user_by_id(&self, id: i32) -> sqlx::Result<Option<User>>;
async fn load_user(&self, for_username: String) -> QueryResult<Option<User>>; async fn load_user(&self, for_username: &str) -> sqlx::Result<Option<User>>;
async fn insert_user(&self, new_user: NewUser) -> QueryResult<User>; async fn insert_user(&self, new_user: NewUser<'_>) -> sqlx::Result<User>;
async fn load_character_list(&self, for_user_id: i32) -> QueryResult<Vec<StrippedCharacter>>; async fn load_character_list(&self, for_user_id: i32) -> sqlx::Result<Vec<StrippedCharacter>>;
async fn load_character(&self, character_id: i32) -> QueryResult<Option<Character>>; async fn load_character(&self, character_id: i32) -> sqlx::Result<Option<Character>>;
async fn insert_character(&self, new_character: NewCharacter) -> QueryResult<()>; async fn insert_character(&self, new_character: NewCharacter<'_>) -> sqlx::Result<()>;
async fn update_character_sheet(&self, character: Character) -> QueryResult<()>; async fn update_character<'a>(&self, character: &'a Character) -> sqlx::Result<()>;
async fn update_character_sheet<'a>(&self, character: &'a Character) -> sqlx::Result<()>;
} }
type StrippedCharacterColumns = (
characters::id,
characters::user_id,
characters::viewable,
characters::character_name,
characters::data_type,
characters::data_version,
);
const STRIPPED_CHARACTER_COLUMNS: StrippedCharacterColumns = (
characters::id,
characters::user_id,
characters::viewable,
characters::character_name,
characters::data_type,
characters::data_version,
);
#[rocket::async_trait] #[rocket::async_trait]
impl Dao for TenebrousDbConn { impl Dao for SqlitePool {
async fn load_user_by_id(&self, user_id: i32) -> QueryResult<Option<User>> { async fn load_user_by_id(&self, user_id: i32) -> sqlx::Result<Option<User>> {
use crate::schema::users::dsl::*; sqlx::query_as!(
self.run(move |conn| users.filter(id.eq(user_id)).first(conn).optional()) User,
r#"SELECT id as "id: _", username, password FROM users WHERE id = ?"#,
user_id
)
.fetch_optional(self)
.await .await
} }
async fn load_user(&self, for_username: String) -> QueryResult<Option<User>> { async fn load_user(&self, for_username: &str) -> sqlx::Result<Option<User>> {
use crate::schema::users::dsl::*; sqlx::query_as!(
User,
self.run(move |conn| { r#"SELECT id as "id: _", username, password FROM users WHERE username = ?"#,
users for_username
.filter(username.eq(for_username)) )
.first(conn) .fetch_optional(self)
.optional()
})
.await .await
} }
async fn insert_user(&self, new_user: NewUser) -> QueryResult<User> { async fn insert_user(&self, new_user: NewUser<'_>) -> sqlx::Result<User> {
self.run(move |conn| { sqlx::query("INSERT INTO users (username, password) values (?, ?)")
diesel::insert_into(users).values(&new_user).execute(conn)?; .bind(new_user.username)
.bind(new_user.password)
.execute(self)
.await?;
use crate::schema::users::dsl::*; self.load_user(new_user.username)
users.filter(username.eq(new_user.username)).first(conn) .await
}) .and_then(|user| user.ok_or(sqlx::Error::RowNotFound))
}
async fn load_character_list(&self, for_user_id: i32) -> sqlx::Result<Vec<StrippedCharacter>> {
sqlx::query_as!(
StrippedCharacter,
r#"SELECT id as "id: _",
user_id as "user_id: _",
data_type as "data_type: _",
data_version as "data_version: _",
viewable, character_name
FROM characters WHERE user_id = ?"#,
for_user_id
)
.fetch_all(self)
.await .await
} }
async fn load_character_list(&self, for_user_id: i32) -> QueryResult<Vec<StrippedCharacter>> { async fn load_character(&self, character_id: i32) -> sqlx::Result<Option<Character>> {
use crate::schema::characters::dsl::*; sqlx::query_as!(
Character,
self.run(move |conn| { r#"SELECT id as "id: _",
characters user_id as "user_id: _",
.filter(user_id.eq(for_user_id)) viewable, character_name, data,
.select(STRIPPED_CHARACTER_COLUMNS) data_type as "data_type: _",
.load(conn) data_version as "data_version: _"
}) FROM characters WHERE id = ?"#,
character_id
)
.fetch_optional(self)
.await .await
} }
async fn load_character(&self, character_id: i32) -> QueryResult<Option<Character>> { async fn insert_character(&self, new_character: NewCharacter<'_>) -> sqlx::Result<()> {
use crate::schema::characters::dsl::*; sqlx::query(
"INSERT INTO characters
self.run(move |conn| { (user_id, viewable, character_name, data_type, data_version, data)
characters values (?, ?, ?, ?, ?, ?)",
.filter(id.eq(character_id)) )
.first(conn) .bind(new_character.user_id)
.optional() .bind(new_character.viewable)
}) .bind(new_character.character_name)
.await .bind(new_character.data_type)
} .bind(new_character.data_version)
.bind(new_character.data)
async fn insert_character(&self, new_character: NewCharacter) -> QueryResult<()> { .execute(self)
self.run(|conn| {
diesel::insert_into(characters::table)
.values(new_character)
.execute(conn)
})
.await?; .await?;
Ok(()) Ok(())
} }
async fn update_character_sheet(&self, character: Character) -> QueryResult<()> { async fn update_character<'a>(&self, character: &'a Character) -> sqlx::Result<()> {
use crate::schema::characters::dsl::*; sqlx::query(
self.run(move |conn| { "UPDATE characters
diesel::update(&character) set user_id = ?, viewable = ?, character_name = ?,
.set(data.eq(&character.data)) data_type = ?, data_version = ?, data = ? where id = ?",
.execute(conn) )
}) .bind(character.user_id)
.bind(character.viewable)
.bind(&character.character_name)
.bind(character.data_type)
.bind(character.data_version)
.bind(&character.data)
.bind(character.id)
.execute(self)
.await?;
Ok(())
}
async fn update_character_sheet<'a>(&self, character: &'a Character) -> sqlx::Result<()> {
sqlx::query("UPDATE characters set data = ? where id = ?")
.bind(&character.data)
.bind(character.id)
.execute(self)
.await?; .await?;
Ok(()) Ok(())

View File

@ -4,6 +4,7 @@ use rocket::response::status;
use rocket::response::{self, Responder}; use rocket::response::{self, Responder};
use rocket_contrib::templates::Template; use rocket_contrib::templates::Template;
use std::collections::HashMap; use std::collections::HashMap;
use std::convert::Into;
use thiserror::Error; use thiserror::Error;
#[derive(Error, Debug)] #[derive(Error, Debug)]
@ -20,8 +21,8 @@ pub enum Error {
#[error("invalid input")] #[error("invalid input")]
InvalidInput, InvalidInput,
#[error("query error: {0}")] #[error("validation error: {0}")]
QueryError(#[from] diesel::result::Error), ValidationError(#[from] crate::models::convert::ValidationError),
#[error("serialization error: {0}")] #[error("serialization error: {0}")]
SerializationError(#[from] prost::EncodeError), SerializationError(#[from] prost::EncodeError),
@ -31,6 +32,12 @@ pub enum Error {
#[error("i/o error: {0}")] #[error("i/o error: {0}")]
IoError(#[from] std::io::Error), IoError(#[from] std::io::Error),
#[error("query error: {0}")]
QueryError(#[from] sqlx::Error),
#[error("rocket error: {0}")]
RocketError(#[from] rocket::error::Error),
} }
impl Error { impl Error {
@ -42,6 +49,28 @@ impl Error {
_ => false, _ => false,
} }
} }
fn message(&self) -> String {
if self.is_sensitive() {
"internal error".to_string()
} else {
self.to_string()
}
}
}
impl From<Error> for tonic::Status {
fn from(err: Error) -> tonic::Status {
use tonic::{Code, Status};
use Error::*;
match err {
NotFound => Status::new(Code::NotFound, err.message()),
NotLoggedIn => Status::new(Code::Unauthenticated, err.message()),
NoPermission => Status::new(Code::PermissionDenied, err.message()),
InvalidInput => Status::new(Code::InvalidArgument, err.message()),
_ => Status::new(Code::Internal, err.message()),
}
}
} }
#[rocket::async_trait] #[rocket::async_trait]

322
src/frontend/_proto/cofd_api_pb.d.ts vendored Normal file
View File

@ -0,0 +1,322 @@
// package: models.proto.cofd.api
// file: cofd_api.proto
import * as jspb from "google-protobuf";
import * as cofd_pb from "./cofd_pb";
export class CharacterIdentifier extends jspb.Message {
getOwner(): string;
setOwner(value: string): void;
getId(): number;
setId(value: number): void;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): CharacterIdentifier.AsObject;
static toObject(includeInstance: boolean, msg: CharacterIdentifier): CharacterIdentifier.AsObject;
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
static serializeBinaryToWriter(message: CharacterIdentifier, writer: jspb.BinaryWriter): void;
static deserializeBinary(bytes: Uint8Array): CharacterIdentifier;
static deserializeBinaryFromReader(message: CharacterIdentifier, reader: jspb.BinaryReader): CharacterIdentifier;
}
export namespace CharacterIdentifier {
export type AsObject = {
owner: string,
id: number,
}
}
export class UpdateBasicInfoRequest extends jspb.Message {
hasCharacter(): boolean;
clearCharacter(): void;
getCharacter(): CharacterIdentifier | undefined;
setCharacter(value?: CharacterIdentifier): void;
getName(): string;
setName(value: string): void;
getGender(): string;
setGender(value: string): void;
getConcept(): string;
setConcept(value: string): void;
getChronicle(): string;
setChronicle(value: string): void;
getAge(): number;
setAge(value: number): void;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): UpdateBasicInfoRequest.AsObject;
static toObject(includeInstance: boolean, msg: UpdateBasicInfoRequest): UpdateBasicInfoRequest.AsObject;
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
static serializeBinaryToWriter(message: UpdateBasicInfoRequest, writer: jspb.BinaryWriter): void;
static deserializeBinary(bytes: Uint8Array): UpdateBasicInfoRequest;
static deserializeBinaryFromReader(message: UpdateBasicInfoRequest, reader: jspb.BinaryReader): UpdateBasicInfoRequest;
}
export namespace UpdateBasicInfoRequest {
export type AsObject = {
character?: CharacterIdentifier.AsObject,
name: string,
gender: string,
concept: string,
chronicle: string,
age: number,
}
}
export class ApiResult extends jspb.Message {
getSuccess(): boolean;
setSuccess(value: boolean): void;
getError(): string;
setError(value: string): void;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): ApiResult.AsObject;
static toObject(includeInstance: boolean, msg: ApiResult): ApiResult.AsObject;
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
static serializeBinaryToWriter(message: ApiResult, writer: jspb.BinaryWriter): void;
static deserializeBinary(bytes: Uint8Array): ApiResult;
static deserializeBinaryFromReader(message: ApiResult, reader: jspb.BinaryReader): ApiResult;
}
export namespace ApiResult {
export type AsObject = {
success: boolean,
error: string,
}
}
export class UpdateAttributeRequest extends jspb.Message {
hasCharacter(): boolean;
clearCharacter(): void;
getCharacter(): CharacterIdentifier | undefined;
setCharacter(value?: CharacterIdentifier): void;
getAttributeName(): string;
setAttributeName(value: string): void;
getAttributeValue(): number;
setAttributeValue(value: number): void;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): UpdateAttributeRequest.AsObject;
static toObject(includeInstance: boolean, msg: UpdateAttributeRequest): UpdateAttributeRequest.AsObject;
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
static serializeBinaryToWriter(message: UpdateAttributeRequest, writer: jspb.BinaryWriter): void;
static deserializeBinary(bytes: Uint8Array): UpdateAttributeRequest;
static deserializeBinaryFromReader(message: UpdateAttributeRequest, reader: jspb.BinaryReader): UpdateAttributeRequest;
}
export namespace UpdateAttributeRequest {
export type AsObject = {
character?: CharacterIdentifier.AsObject,
attributeName: string,
attributeValue: number,
}
}
export class UpdateSkillRequest extends jspb.Message {
hasCharacter(): boolean;
clearCharacter(): void;
getCharacter(): CharacterIdentifier | undefined;
setCharacter(value?: CharacterIdentifier): void;
hasSkill(): boolean;
clearSkill(): void;
getSkill(): cofd_pb.CofdSheet.Skill | undefined;
setSkill(value?: cofd_pb.CofdSheet.Skill): void;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): UpdateSkillRequest.AsObject;
static toObject(includeInstance: boolean, msg: UpdateSkillRequest): UpdateSkillRequest.AsObject;
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
static serializeBinaryToWriter(message: UpdateSkillRequest, writer: jspb.BinaryWriter): void;
static deserializeBinary(bytes: Uint8Array): UpdateSkillRequest;
static deserializeBinaryFromReader(message: UpdateSkillRequest, reader: jspb.BinaryReader): UpdateSkillRequest;
}
export namespace UpdateSkillRequest {
export type AsObject = {
character?: CharacterIdentifier.AsObject,
skill?: cofd_pb.CofdSheet.Skill.AsObject,
}
}
export class UpdateSkillValueRequest extends jspb.Message {
hasCharacter(): boolean;
clearCharacter(): void;
getCharacter(): CharacterIdentifier | undefined;
setCharacter(value?: CharacterIdentifier): void;
getSkillName(): string;
setSkillName(value: string): void;
getSkillValue(): number;
setSkillValue(value: number): void;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): UpdateSkillValueRequest.AsObject;
static toObject(includeInstance: boolean, msg: UpdateSkillValueRequest): UpdateSkillValueRequest.AsObject;
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
static serializeBinaryToWriter(message: UpdateSkillValueRequest, writer: jspb.BinaryWriter): void;
static deserializeBinary(bytes: Uint8Array): UpdateSkillValueRequest;
static deserializeBinaryFromReader(message: UpdateSkillValueRequest, reader: jspb.BinaryReader): UpdateSkillValueRequest;
}
export namespace UpdateSkillValueRequest {
export type AsObject = {
character?: CharacterIdentifier.AsObject,
skillName: string,
skillValue: number,
}
}
export class UpdateSkillSpecializationsRequest extends jspb.Message {
getName(): string;
setName(value: string): void;
clearSpecializationsList(): void;
getSpecializationsList(): Array<string>;
setSpecializationsList(value: Array<string>): void;
addSpecializations(value: string, index?: number): string;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): UpdateSkillSpecializationsRequest.AsObject;
static toObject(includeInstance: boolean, msg: UpdateSkillSpecializationsRequest): UpdateSkillSpecializationsRequest.AsObject;
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
static serializeBinaryToWriter(message: UpdateSkillSpecializationsRequest, writer: jspb.BinaryWriter): void;
static deserializeBinary(bytes: Uint8Array): UpdateSkillSpecializationsRequest;
static deserializeBinaryFromReader(message: UpdateSkillSpecializationsRequest, reader: jspb.BinaryReader): UpdateSkillSpecializationsRequest;
}
export namespace UpdateSkillSpecializationsRequest {
export type AsObject = {
name: string,
specializationsList: Array<string>,
}
}
export class UpdateMeritsRequest extends jspb.Message {
hasCharacter(): boolean;
clearCharacter(): void;
getCharacter(): CharacterIdentifier | undefined;
setCharacter(value?: CharacterIdentifier): void;
clearMeritsList(): void;
getMeritsList(): Array<cofd_pb.CofdSheet.Merit>;
setMeritsList(value: Array<cofd_pb.CofdSheet.Merit>): void;
addMerits(value?: cofd_pb.CofdSheet.Merit, index?: number): cofd_pb.CofdSheet.Merit;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): UpdateMeritsRequest.AsObject;
static toObject(includeInstance: boolean, msg: UpdateMeritsRequest): UpdateMeritsRequest.AsObject;
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
static serializeBinaryToWriter(message: UpdateMeritsRequest, writer: jspb.BinaryWriter): void;
static deserializeBinary(bytes: Uint8Array): UpdateMeritsRequest;
static deserializeBinaryFromReader(message: UpdateMeritsRequest, reader: jspb.BinaryReader): UpdateMeritsRequest;
}
export namespace UpdateMeritsRequest {
export type AsObject = {
character?: CharacterIdentifier.AsObject,
meritsList: Array<cofd_pb.CofdSheet.Merit.AsObject>,
}
}
export class UpdateItemsRequest extends jspb.Message {
hasCharacter(): boolean;
clearCharacter(): void;
getCharacter(): CharacterIdentifier | undefined;
setCharacter(value?: CharacterIdentifier): void;
clearItemsList(): void;
getItemsList(): Array<cofd_pb.CofdSheet.Item>;
setItemsList(value: Array<cofd_pb.CofdSheet.Item>): void;
addItems(value?: cofd_pb.CofdSheet.Item, index?: number): cofd_pb.CofdSheet.Item;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): UpdateItemsRequest.AsObject;
static toObject(includeInstance: boolean, msg: UpdateItemsRequest): UpdateItemsRequest.AsObject;
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
static serializeBinaryToWriter(message: UpdateItemsRequest, writer: jspb.BinaryWriter): void;
static deserializeBinary(bytes: Uint8Array): UpdateItemsRequest;
static deserializeBinaryFromReader(message: UpdateItemsRequest, reader: jspb.BinaryReader): UpdateItemsRequest;
}
export namespace UpdateItemsRequest {
export type AsObject = {
character?: CharacterIdentifier.AsObject,
itemsList: Array<cofd_pb.CofdSheet.Item.AsObject>,
}
}
export class AddConditionRequest extends jspb.Message {
getCharacterUsername(): string;
setCharacterUsername(value: string): void;
getCharacterId(): number;
setCharacterId(value: number): void;
getConditionName(): string;
setConditionName(value: string): void;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): AddConditionRequest.AsObject;
static toObject(includeInstance: boolean, msg: AddConditionRequest): AddConditionRequest.AsObject;
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
static serializeBinaryToWriter(message: AddConditionRequest, writer: jspb.BinaryWriter): void;
static deserializeBinary(bytes: Uint8Array): AddConditionRequest;
static deserializeBinaryFromReader(message: AddConditionRequest, reader: jspb.BinaryReader): AddConditionRequest;
}
export namespace AddConditionRequest {
export type AsObject = {
characterUsername: string,
characterId: number,
conditionName: string,
}
}
export class RemoveConditionRequest extends jspb.Message {
getCharacterUsername(): string;
setCharacterUsername(value: string): void;
getCharacterId(): number;
setCharacterId(value: number): void;
getConditionName(): string;
setConditionName(value: string): void;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): RemoveConditionRequest.AsObject;
static toObject(includeInstance: boolean, msg: RemoveConditionRequest): RemoveConditionRequest.AsObject;
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
static serializeBinaryToWriter(message: RemoveConditionRequest, writer: jspb.BinaryWriter): void;
static deserializeBinary(bytes: Uint8Array): RemoveConditionRequest;
static deserializeBinaryFromReader(message: RemoveConditionRequest, reader: jspb.BinaryReader): RemoveConditionRequest;
}
export namespace RemoveConditionRequest {
export type AsObject = {
characterUsername: string,
characterId: number,
conditionName: string,
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
// package: models.proto.cofd.api
// file: cofd_api.proto

View File

@ -0,0 +1,3 @@
// package: models.proto.cofd.api
// file: cofd_api.proto

373
src/frontend/_proto/cofd_pb.d.ts vendored Normal file
View File

@ -0,0 +1,373 @@
// package: models.proto.cofd
// file: cofd.proto
import * as jspb from "google-protobuf";
export class CoreFields extends jspb.Message {
getIntegrity(): number;
setIntegrity(value: number): void;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): CoreFields.AsObject;
static toObject(includeInstance: boolean, msg: CoreFields): CoreFields.AsObject;
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
static serializeBinaryToWriter(message: CoreFields, writer: jspb.BinaryWriter): void;
static deserializeBinary(bytes: Uint8Array): CoreFields;
static deserializeBinaryFromReader(message: CoreFields, reader: jspb.BinaryReader): CoreFields;
}
export namespace CoreFields {
export type AsObject = {
integrity: number,
}
}
export class MageFields extends jspb.Message {
getWidsom(): number;
setWidsom(value: number): void;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): MageFields.AsObject;
static toObject(includeInstance: boolean, msg: MageFields): MageFields.AsObject;
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
static serializeBinaryToWriter(message: MageFields, writer: jspb.BinaryWriter): void;
static deserializeBinary(bytes: Uint8Array): MageFields;
static deserializeBinaryFromReader(message: MageFields, reader: jspb.BinaryReader): MageFields;
}
export namespace MageFields {
export type AsObject = {
widsom: number,
}
}
export class ChangelingFields extends jspb.Message {
getClarity(): number;
setClarity(value: number): void;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): ChangelingFields.AsObject;
static toObject(includeInstance: boolean, msg: ChangelingFields): ChangelingFields.AsObject;
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
static serializeBinaryToWriter(message: ChangelingFields, writer: jspb.BinaryWriter): void;
static deserializeBinary(bytes: Uint8Array): ChangelingFields;
static deserializeBinaryFromReader(message: ChangelingFields, reader: jspb.BinaryReader): ChangelingFields;
}
export namespace ChangelingFields {
export type AsObject = {
clarity: number,
}
}
export class CofdSheet extends jspb.Message {
getName(): string;
setName(value: string): void;
getGender(): string;
setGender(value: string): void;
getConcept(): string;
setConcept(value: string): void;
getAge(): number;
setAge(value: number): void;
getPlayer(): string;
setPlayer(value: string): void;
getChronicle(): string;
setChronicle(value: string): void;
getDescription(): string;
setDescription(value: string): void;
getStrength(): number;
setStrength(value: number): void;
getDexterity(): number;
setDexterity(value: number): void;
getStamina(): number;
setStamina(value: number): void;
getIntelligence(): number;
setIntelligence(value: number): void;
getWits(): number;
setWits(value: number): void;
getResolve(): number;
setResolve(value: number): void;
getPresence(): number;
setPresence(value: number): void;
getManipulation(): number;
setManipulation(value: number): void;
getComposure(): number;
setComposure(value: number): void;
getPhysicalSkillsMap(): jspb.Map<string, CofdSheet.Skill>;
clearPhysicalSkillsMap(): void;
getMentalSkillsMap(): jspb.Map<string, CofdSheet.Skill>;
clearMentalSkillsMap(): void;
getSocialSkillsMap(): jspb.Map<string, CofdSheet.Skill>;
clearSocialSkillsMap(): void;
clearMeritsList(): void;
getMeritsList(): Array<CofdSheet.Merit>;
setMeritsList(value: Array<CofdSheet.Merit>): void;
addMerits(value?: CofdSheet.Merit, index?: number): CofdSheet.Merit;
clearConditionsList(): void;
getConditionsList(): Array<CofdSheet.Condition>;
setConditionsList(value: Array<CofdSheet.Condition>): void;
addConditions(value?: CofdSheet.Condition, index?: number): CofdSheet.Condition;
getSize(): number;
setSize(value: number): void;
getHealth(): number;
setHealth(value: number): void;
getWillpower(): number;
setWillpower(value: number): void;
getExperiencePoints(): number;
setExperiencePoints(value: number): void;
getBeats(): number;
setBeats(value: number): void;
clearItemsList(): void;
getItemsList(): Array<CofdSheet.Item>;
setItemsList(value: Array<CofdSheet.Item>): void;
addItems(value?: CofdSheet.Item, index?: number): CofdSheet.Item;
clearAttacksList(): void;
getAttacksList(): Array<CofdSheet.Attack>;
setAttacksList(value: Array<CofdSheet.Attack>): void;
addAttacks(value?: CofdSheet.Attack, index?: number): CofdSheet.Attack;
getOtherDataMap(): jspb.Map<string, string>;
clearOtherDataMap(): void;
hasCore(): boolean;
clearCore(): void;
getCore(): CoreFields | undefined;
setCore(value?: CoreFields): void;
hasMage(): boolean;
clearMage(): void;
getMage(): MageFields | undefined;
setMage(value?: MageFields): void;
hasChangeling(): boolean;
clearChangeling(): void;
getChangeling(): ChangelingFields | undefined;
setChangeling(value?: ChangelingFields): void;
getSystemFieldsCase(): CofdSheet.SystemFieldsCase;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): CofdSheet.AsObject;
static toObject(includeInstance: boolean, msg: CofdSheet): CofdSheet.AsObject;
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
static serializeBinaryToWriter(message: CofdSheet, writer: jspb.BinaryWriter): void;
static deserializeBinary(bytes: Uint8Array): CofdSheet;
static deserializeBinaryFromReader(message: CofdSheet, reader: jspb.BinaryReader): CofdSheet;
}
export namespace CofdSheet {
export type AsObject = {
name: string,
gender: string,
concept: string,
age: number,
player: string,
chronicle: string,
description: string,
strength: number,
dexterity: number,
stamina: number,
intelligence: number,
wits: number,
resolve: number,
presence: number,
manipulation: number,
composure: number,
physicalSkillsMap: Array<[string, CofdSheet.Skill.AsObject]>,
mentalSkillsMap: Array<[string, CofdSheet.Skill.AsObject]>,
socialSkillsMap: Array<[string, CofdSheet.Skill.AsObject]>,
meritsList: Array<CofdSheet.Merit.AsObject>,
conditionsList: Array<CofdSheet.Condition.AsObject>,
size: number,
health: number,
willpower: number,
experiencePoints: number,
beats: number,
itemsList: Array<CofdSheet.Item.AsObject>,
attacksList: Array<CofdSheet.Attack.AsObject>,
otherDataMap: Array<[string, string]>,
core?: CoreFields.AsObject,
mage?: MageFields.AsObject,
changeling?: ChangelingFields.AsObject,
}
export class Merit extends jspb.Message {
getDots(): number;
setDots(value: number): void;
getName(): string;
setName(value: string): void;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): Merit.AsObject;
static toObject(includeInstance: boolean, msg: Merit): Merit.AsObject;
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
static serializeBinaryToWriter(message: Merit, writer: jspb.BinaryWriter): void;
static deserializeBinary(bytes: Uint8Array): Merit;
static deserializeBinaryFromReader(message: Merit, reader: jspb.BinaryReader): Merit;
}
export namespace Merit {
export type AsObject = {
dots: number,
name: string,
}
}
export class Condition extends jspb.Message {
getName(): string;
setName(value: string): void;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): Condition.AsObject;
static toObject(includeInstance: boolean, msg: Condition): Condition.AsObject;
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
static serializeBinaryToWriter(message: Condition, writer: jspb.BinaryWriter): void;
static deserializeBinary(bytes: Uint8Array): Condition;
static deserializeBinaryFromReader(message: Condition, reader: jspb.BinaryReader): Condition;
}
export namespace Condition {
export type AsObject = {
name: string,
}
}
export class Skill extends jspb.Message {
getDots(): number;
setDots(value: number): void;
getName(): string;
setName(value: string): void;
getUntrainedPenalty(): number;
setUntrainedPenalty(value: number): void;
clearSpecializationsList(): void;
getSpecializationsList(): Array<string>;
setSpecializationsList(value: Array<string>): void;
addSpecializations(value: string, index?: number): string;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): Skill.AsObject;
static toObject(includeInstance: boolean, msg: Skill): Skill.AsObject;
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
static serializeBinaryToWriter(message: Skill, writer: jspb.BinaryWriter): void;
static deserializeBinary(bytes: Uint8Array): Skill;
static deserializeBinaryFromReader(message: Skill, reader: jspb.BinaryReader): Skill;
}
export namespace Skill {
export type AsObject = {
dots: number,
name: string,
untrainedPenalty: number,
specializationsList: Array<string>,
}
}
export class Item extends jspb.Message {
getName(): string;
setName(value: string): void;
getDescription(): string;
setDescription(value: string): void;
getRules(): string;
setRules(value: string): void;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): Item.AsObject;
static toObject(includeInstance: boolean, msg: Item): Item.AsObject;
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
static serializeBinaryToWriter(message: Item, writer: jspb.BinaryWriter): void;
static deserializeBinary(bytes: Uint8Array): Item;
static deserializeBinaryFromReader(message: Item, reader: jspb.BinaryReader): Item;
}
export namespace Item {
export type AsObject = {
name: string,
description: string,
rules: string,
}
}
export class Attack extends jspb.Message {
getName(): string;
setName(value: string): void;
getDicePool(): number;
setDicePool(value: number): void;
getDamage(): number;
setDamage(value: number): void;
getRange(): number;
setRange(value: number): void;
getInitiativeModifier(): number;
setInitiativeModifier(value: number): void;
getSize(): number;
setSize(value: number): void;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): Attack.AsObject;
static toObject(includeInstance: boolean, msg: Attack): Attack.AsObject;
static extensions: {[key: number]: jspb.ExtensionFieldInfo<jspb.Message>};
static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>};
static serializeBinaryToWriter(message: Attack, writer: jspb.BinaryWriter): void;
static deserializeBinary(bytes: Uint8Array): Attack;
static deserializeBinaryFromReader(message: Attack, reader: jspb.BinaryReader): Attack;
}
export namespace Attack {
export type AsObject = {
name: string,
dicePool: number,
damage: number,
range: number,
initiativeModifier: number,
size: number,
}
}
export enum SystemFieldsCase {
SYSTEM_FIELDS_NOT_SET = 0,
CORE = 30,
MAGE = 31,
CHANGELING = 32,
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
// package: models.proto.cofd
// file: cofd.proto

View File

@ -0,0 +1,3 @@
// package: models.proto.cofd
// file: cofd.proto

View File

@ -0,0 +1,40 @@
import * as jspb from "google-protobuf";
import { ApiResult, UpdateBasicInfoRequest, UpdateAttributeRequest, UpdateSkillValueRequest, UpdateMeritsRequest, UpdateItemsRequest } from "../_proto/cofd_api_pb";
const PROTOBUF_CONTENT_TYPE = { 'Content-Type': 'application/x-protobuf' };
async function makeRequest<T extends jspb.Message>(uri: string, params: T): Promise<Uint8Array> {
let resp = await fetch(uri, {
method: 'POST',
headers: { ...PROTOBUF_CONTENT_TYPE },
body: params.serializeBinary()
});
const data = await resp.arrayBuffer();
return new Uint8Array(data);
}
export async function updateSkillValue(params: UpdateSkillValueRequest): Promise<ApiResult> {
let data = await makeRequest('/api/rpc/cofd/update_skill_value', params);
return ApiResult.deserializeBinary(data);
}
export async function updateAttributeValue(params: UpdateAttributeRequest): Promise<ApiResult> {
let data = await makeRequest('/api/rpc/cofd/update_attribute_value', params);
return ApiResult.deserializeBinary(data);
}
export async function updateBasicInfo(params: UpdateBasicInfoRequest): Promise<ApiResult> {
let data = await makeRequest('/api/rpc/cofd/update_basic_info', params);
return ApiResult.deserializeBinary(data);
}
export async function updateMerits(params: UpdateMeritsRequest): Promise<ApiResult> {
let data = await makeRequest('/api/rpc/cofd/update_merits', params);
return ApiResult.deserializeBinary(data);
}
export async function updateItems(params: UpdateItemsRequest): Promise<ApiResult> {
let data = await makeRequest('/api/rpc/cofd/update_items', params);
return ApiResult.deserializeBinary(data);
}

View File

@ -0,0 +1,49 @@
export function addMeritLine(parent: Element) {
const meritName = document.createElement('input');
meritName.type = 'text';
meritName.value = '';
meritName.classList.add('merit-name');
const meritDots = document.createElement('input');
meritDots.type = 'number';
meritDots.min = '0';
meritDots.value = '0';
meritDots.classList.add('merit-dots');
const removeButton = document.createElement('button');
removeButton.innerText = "X";
removeButton.setAttribute('data-event-type', 'remove-merit');
removeButton.classList.add('remove-merit');
const dots = document.createElement('span');
dots.innerText = 'Dots';
const merit = document.createElement('div');
merit.classList.add('merit');
merit.appendChild(meritName);
merit.appendChild(meritDots);
merit.appendChild(dots);
merit.appendChild(removeButton);
parent.appendChild(merit);
}
export function addItemLine(parent: Element) {
const itemName = document.createElement('input');
itemName.type = 'text';
itemName.value = '';
itemName.classList.add('item-name');
const removeButton = document.createElement('button');
removeButton.innerText = "X";
removeButton.setAttribute('data-event-type', 'remove-item');
removeButton.classList.add('remove-item');
const item = document.createElement('div');
item.classList.add('item');
item.appendChild(itemName);
item.appendChild(removeButton);
parent.appendChild(item);
}

View File

@ -0,0 +1,215 @@
import { CharacterIdentifier, UpdateBasicInfoRequest, UpdateSkillValueRequest, UpdateAttributeRequest, UpdateMeritsRequest, UpdateItemsRequest } from "../../_proto/cofd_api_pb";
import { CofdSheet } from "../../_proto/cofd_pb";
import * as api from "../api";
import * as ui from "./edit-ui";
// This is the scripting for the edit character page, which submits
// changes to the server as the user makes them.
(async () => {
// Useful for making sure elements actually exist in event handler.
type Option<T> = T | null | undefined;
const [, , USERNAME, CHARACTER_ID] = window.location.pathname.split('/');
function characterId(): CharacterIdentifier {
const id = new CharacterIdentifier();
id.setId(parseInt(CHARACTER_ID));
id.setOwner(USERNAME);
return id;
}
const getTextValue = (selector: string) =>
document.querySelector<HTMLInputElement>(selector)?.value ?? "";
const getIntValue = (selector: string) =>
parseInt(document.querySelector<HTMLInputElement>(selector)?.value ?? "0")
function setupAttributes() {
const attributeInputs = document.querySelectorAll('#attributes input[type="number"]');
async function attributeHandler(event: Event) {
const input = event.target as Option<HTMLInputElement>;
if (!input) return;
console.log("updating attr");
const attribute = input.id;
const newValue = parseInt(input.value);
const params = new UpdateAttributeRequest();
params.setCharacter(characterId());
params.setAttributeName(attribute);
params.setAttributeValue(newValue);
let resp = await api.updateAttributeValue(params);
console.log("got a response back", resp);
}
Array.from(attributeInputs).forEach(input => {
input.addEventListener('change', attributeHandler);
});
}
function setupSkills() {
const skillInputs = document.querySelectorAll('#skills input[type="number"]');
async function skillValueHandler(event: Event) {
const input = event.target as Option<HTMLInputElement>;
if (!input) return;
console.log("updating skill");
const attribute = input.id;
const newValue = parseInt(input.value);
const params = new UpdateSkillValueRequest();
params.setCharacter(characterId());
params.setSkillName(attribute);
params.setSkillValue(newValue);
let resp = await api.updateSkillValue(params);
console.log("got a response back", resp);
}
Array.from(skillInputs).forEach(input => {
input.addEventListener('change', skillValueHandler);
});
}
function setupBasicInfo() {
async function updateInfo() {
const params = new UpdateBasicInfoRequest();
params.setCharacter(characterId());
params.setName(getTextValue("#characterName"));
params.setAge(getIntValue("#age"));
params.setConcept(getTextValue("#concept"));
params.setChronicle(getTextValue("#chronicle"));
params.setGender(getTextValue("#gender"));
let resp = await api.updateBasicInfo(params);
console.log("got a response back", resp);
}
const inputs = document.querySelectorAll("#basics input");
inputs.forEach(input => {
console.log('got an input', input);
input.addEventListener('blur', updateInfo);
});
}
function setupMerits() {
async function updateMerits() {
let meritElements = document.querySelectorAll<HTMLInputElement>('#merits input[class="merit-name"]');
let merits: CofdSheet.Merit[] =
Array.from(meritElements).map(input => {
const dotsInput = input.parentElement?.querySelector<HTMLInputElement>('input[class="merit-dots"]');
let dotsAmount = dotsInput?.value ?? "0";
if (dotsAmount.length == 0) { dotsAmount = "0"; }
const merit = new CofdSheet.Merit();
merit.setName(input.value);
merit.setDots(parseInt(dotsAmount));
return merit;
});
const params = new UpdateMeritsRequest();
params.setCharacter(characterId());
params.setMeritsList(merits);
let resp = await api.updateMerits(params);
console.log("got a response back", resp);
}
async function removeMerit(event: Event) {
const button = event.target as Option<Element>;
const meritLine = button?.parentElement;
if (meritLine) {
document.querySelector("#merit-list")?.removeChild(meritLine);
await updateMerits();
}
}
async function processEvent(event: Event) {
console.log('processing merit control event');
const element = event.target as Element;
const eventType = element.getAttribute('data-event-type') ?? '';
if (eventType == 'remove-merit') {
await removeMerit(event);
} else if (eventType == 'add-merit') {
const meritList = document.querySelector<HTMLDivElement>("#merit-list")!;
ui.addMeritLine(meritList);
}
}
const meritControls = document.querySelector<HTMLDivElement>("#merit-controls");
meritControls?.addEventListener('click', processEvent);
const meritList = document.querySelector<HTMLDivElement>("#merit-list");
meritList?.addEventListener('click', processEvent);
meritList?.addEventListener('blur', updateMerits);
meritList?.addEventListener('change', updateMerits);
}
function setupItems() {
async function updateItems() {
let meritElements = document.querySelectorAll<HTMLInputElement>('#items input[class="item-name"]');
let items: CofdSheet.Item[] =
Array.from(meritElements).map(input => {
const item = new CofdSheet.Item();
item.setName(input.value);
item.setDescription(""); //TODO add description
item.setRules(""); //TODO add rules
return item;
});
console.log("items are", items);
const params = new UpdateItemsRequest();
params.setCharacter(characterId());
params.setItemsList(items);
let resp = await api.updateItems(params);
console.log("got a response back", resp);
}
async function removeItem(event: Event) {
const button = event.target as Option<Element>;
const itemLine = button?.parentElement;
if (itemLine) {
document.querySelector("#item-list")?.removeChild(itemLine);
await updateItems();
}
}
async function processEvent(event: Event) {
console.log('processing item control event');
const element = event.target as Element;
const eventType = element.getAttribute('data-event-type') ?? '';
if (eventType == 'remove-item') {
await removeItem(event);
} else if (eventType == 'add-item') {
const itemList = document.querySelector<HTMLDivElement>("#item-list")!;
ui.addItemLine(itemList);
}
}
const meritControls = document.querySelector<HTMLDivElement>("#item-controls");
meritControls?.addEventListener('click', processEvent);
const meritList = document.querySelector<HTMLDivElement>("#item-list");
meritList?.addEventListener('click', processEvent);
meritList?.addEventListener('blur', updateItems);
meritList?.addEventListener('change', updateItems);
}
setupAttributes();
setupSkills();
setupBasicInfo();
setupMerits();
setupItems();
})().catch(e => {
alert(e);
});

View File

@ -2,6 +2,7 @@
<html> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tera Demo</title> <title>Tera Demo</title>
</head> </head>
<body> <body>

View File

@ -0,0 +1,285 @@
{% import "characters/edit_character_macros" as macros %}
{% extends "base" %}
{% block content %}
<style type="text/css">
body {
font-family: Liberation Sans, Arial;
}
#basics .flex-container {
display: flex;
flex-flow: row wrap;
}
.basics-row {
display: block;
}
.basics-entry {
display: flex;
border: 1px solid gray;
margin-left: -1px;
margin-top: -1px;
}
.basics-entry label {
display: inline;
width: 5em;
text-align: right;
vertical-align: text-bottom;
padding: 8px;
margin: 0;
}
.basics-entry input {
display: inline;
padding: 8px;
border: none;
background-color: lightgray;
margin: 0;
}
#attributes .flex-container {
display: flex;
flex-flow: row wrap;
}
#attributes .attributes-section {
border: 1px solid gray;
margin-left: -1px;
margin-top: -1px;
}
.attribute {
display: flex;
}
.attribute label {
width: 10em;
display: inline;
text-align: right;
vertical-align: text-bottom;
padding: 8px;
margin: 0;
}
.attribute input {
max-width: 4em;
display: inline;
padding: 8px;
border: none;
background-color: lightgray;
margin: 0;
}
#skills {
}
#skills .flex-container {
display: flex;
flex-flow: row wrap;
}
#skills .skills-section {
border: 1px solid gray;
margin-top: -1px;
margin-left: -1px;
}
.skill {
display: flex;
}
.skill label {
width: 10em;
text-align: right;
vertical-align: text-bottom;
padding: 8px;
margin: 0;
}
.skill input {
max-width: 4em;
padding: 8px;
border: none;
background-color: lightgray;
margin: 0;
}
#merits, #items {
padding: 4px;
display: inline-flex;
flex-direction: column;
}
#merit-list, #item-list {
display: inline-flex;
flex-direction: column;
}
.merit, .item {
margin: 0;
padding: 0;
display: inline-flex;
border: 1px solid gray;
margin-top: -1px;
}
.merit .merit-name, .item .item-name {
width: 10em;
vertical-align: text-bottom;
padding: 0;
margin: 8px;
border: none;
border: 0px solid gray;
border-bottom-width: 1px;
}
.merit .merit-dots {
max-width: 4em;
padding: 8px;
border: none;
background-color: lightgray;
margin: 0;
}
.merit span {
width: 3em;
text-align: left;
vertical-align: text-bottom;
padding: 8px;
margin: 0;
}
.merit .remove-merit, .item .remove-item {
max-width: 4em;
padding: 0;
padding: 3px;
padding-left: 6px;
padding-right:6px;
margin: 3px;
background-color: lightgray;
}
</style>
{# Webpack Templating for API script #}
<%= htmlWebpackPlugin.tags.bodyTags %>
<div>
<div id="basics">
<h1>
<label for="characterName">Name</label>
<input type="text" id="characterName" name="characterName" value="{{name}}" />
</h1>
<div class="links">
<a href="/">Home</a> |
<a href="/characters/{{username}}/{{id}}">Save Changes</a> |
<a href="/characters/{{username}}/{{id}}/delete">Delete Character</a>
</div>
<h2>Basics</h2>
<div>System: {{data_type}}</div>
<div class="flex-container">
<div class="basics-row">
<div class="basics-entry">
<label for="gender">Gender:</label>
<input type="text" id="gender" name="gender" value="{{sheet.gender}}" />
</div>
<div class="basics-entry">
<label for="age">Age:</label>
<input type="number" id="age" name="age" min="0" value="{{sheet.age}}" />
</div>
</div>
<div class="basics-row">
<div class="basics-entry">
<label for="concept">Concept:</label>
<input type="text" id="concept" name="concept" value="{{sheet.concept}}" />
</div>
<div class="basics-entry">
<label for="chronicle">Chronicle:</label>
<input type="text" id="chronicle" name="chronicle" value="{{sheet.chronicle}}" />
</div>
</div>
</div>
</div>
<div id="attributes">
<h2>Attributes</h2>
<div class="flex-container">
<div class="attributes-section" id="mentalAttributes">
{{ macros::attribute(name="Intelligence", value=sheet.intelligence) }}
{{ macros::attribute(name="Wits", value=sheet.wits) }}
{{ macros::attribute(name="Resolve", value=sheet.resolve) }}
</div>
<div class="attributes-section" id="physicalAttributes">
{{ macros::attribute(name="Strength", value=sheet.strength) }}
{{ macros::attribute(name="Dexterity", value=sheet.dexterity) }}
{{ macros::attribute(name="Stamina", value=sheet.stamina) }}
</div>
<div class="attributes-section" id="socicalAttributes">
{{ macros::attribute(name="Presence", value=sheet.presence) }}
{{ macros::attribute(name="Manipulation", value=sheet.manipulation) }}
{{ macros::attribute(name="Composure", value=sheet.composure) }}
</div>
</div>
</div>
<div id="skills">
<h2>Skills</h2>
<div class="flex-container">
<div class="skills-section" id="mentalSkills">
{% for skill_name, skill in sheet.mentalSkills %}
{{ macros::skill(name=skill_name, value=skill.dots) }}
{% endfor %}
</div>
<div class="skills-section" id="physicalSkills">
{% for skill_name, skill in sheet.physicalSkills %}
{{ macros::skill(name=skill_name, value=skill.dots) }}
{% endfor %}
</div>
<div class="skills-section" id="socialSkills">
{% for skill_name, skill in sheet.socialSkills %}
{{ macros::skill(name=skill_name, value=skill.dots) }}
{% endfor %}
</div>
</div>
</div>
<div id="merits">
<h2>Merits</h2>
<div id="merit-list">
{% for merit in sheet.merits %}
{{ macros::merit(name=merit.name, value=merit.dots) }}
{% endfor %}
</div>
<div id="merit-controls">
<button data-event-type="add-merit">Add Merit</button>
</div>
</div>
<div id="items">
<h2>Inventory</h2>
<div id="item-list">
{% for item in sheet.items %}
{{ macros::item(name=item.name) }}
{% endfor %}
</div>
<div id="item-controls">
<button data-event-type="add-item">Add Item</button>
</div>
</div>
{% endblock content %}

View File

@ -0,0 +1,29 @@
{% macro attribute(name, value) %}
<div class="attribute">
<label for="{{name}}">{{name}}:</label>
<input id="{{name}}" name="{{name}}" type="number" min="0" value="{{value}}" />
</div>
{% endmacro attribute %}
{% macro skill(name, value) %}
<div class="skill">
<label for="{{name}}">{{name}}:</label>
<input id="{{name}}" name="{{name}}" type="number" min="0" value="{{value}}" />
</div>
{% endmacro skill %}
{% macro merit(name, value) %}
<div class="merit">
<input type="text" class="merit-name" value="{{name}}" />
<input type="number" class="merit-dots" min="0" value="{{value}}" /> <span>Dots</span>
<button class="remove-merit" data-event-type="remove-merit">X</button>
</div>
{% endmacro merit %}
{% macro item(name) %}
<div class="item">
<input type="text" class="item-name" value="{{name}}" />
<button class="remove-item" data-event-type="remove-item">X</button>
</div>
{% endmacro item %}

View File

@ -1,8 +1,6 @@
{% extends "base" %} {% extends "base" %}
{% block content %} {% block content %}
<script src="https://cdn.jsdelivr.net/npm/protobufjs@6.10.2/dist/protobuf.min.js"></script>
<script type="text/javascript" src="/scripts/characters/new-character.js"></script>
<div> <div>
New character page. New character page.

View File

@ -0,0 +1,267 @@
{% import "characters/view_character_macros" as macros %}
{% extends "base" %}
{% block content %}
<style type="text/css">
body {
font-family: Liberation Sans, Arial;
}
#basics .flex-container {
display: flex;
flex-flow: row wrap;
}
.basics-row {
display: block;
}
.basics-entry {
display: flex;
border: 1px solid gray;
margin-left: -1px;
margin-top: -1px;
}
.basics-entry label {
display: inline;
width: 5em;
text-align: right;
vertical-align: text-bottom;
padding: 8px;
margin: 0;
}
.basics-entry span {
width: 15em;
overflow: hidden;
padding: 8px;
border: none;
background-color: lightgray;
margin: 0;
}
#attributes .flex-container {
display: flex;
flex-flow: row wrap;
}
#attributes .attributes-section {
border: 1px solid gray;
margin-left: -1px;
margin-top: -1px;
}
.attribute {
display: flex;
}
.attribute label {
width: 10em;
display: inline;
text-align: right;
vertical-align: text-bottom;
padding: 8px;
margin: 0;
}
.attribute span {
width: 2em;
overflow: hidden;
padding: 8px;
border: none;
background-color: lightgray;
margin: 0;
}
#skills {
}
#skills .flex-container {
display: flex;
flex-flow: row wrap;
}
#skills .skills-section {
border: 1px solid gray;
margin-top: -1px;
margin-left: -1px;
}
.skill {
display: flex;
}
.skill label {
width: 10em;
text-align: right;
vertical-align: text-bottom;
padding: 8px;
margin: 0;
}
.skill span {
width: 2em;
overflow: hidden;
padding: 8px;
border: none;
background-color: lightgray;
margin: 0;
}
#merits, #items {
padding: 4px;
display: inline-flex;
flex-direction: column;
}
#merit-list, #item-list {
display: inline-flex;
flex-direction: column;
}
.merit, .item {
display: flex;
border: 1px solid gray;
margin-top: -1px;
}
.merit span, .item span {
width: 3em;
text-align: left;
padding: 8px;
margin: 0;
}
.merit .merit-name, .item .item-name {
width: 10em;
white-space: nowrap;
overflow: auto;
padding: 0;
margin: 8px;
border: none;
border: 0px solid gray;
border-bottom-width: 1px;
}
.merit .merit-dots {
max-width: 4em;
padding: 8px;
border: none;
background-color: lightgray;
margin: 0;
}
</style>
<div>
<div id="basics">
<h1>
<span id="characterName" name="characterName">{{name}}</span>
</h1>
<div class="links">
<a href="/">Home</a> |
<a href="/characters/{{username}}/{{id}}/edit">Edit Character</a> |
<a href="/characters/{{username}}/{{id}}/delete">Delete Character</a>
</div>
<h2>Basics</h2>
<div>System: {{data_type}}</div>
<div class="flex-container">
<div class="basics-row">
<div class="basics-entry">
<label for="gender">Gender:</label>
<span id="gender" name="gender">{{sheet.gender}}</span>
</div>
<div class="basics-entry">
<label for="age">Age:</label>
<span id="age" name="age">{{sheet.age}}</span>
</div>
</div>
<div class="basics-row">
<div class="basics-entry">
<label for="concept">Concept:</label>
<span id="concept" name="concept">{{sheet.concept}}</span>
</div>
<div class="basics-entry">
<label for="chronicle">Chronicle:</label>
<span id="chronicle" name="chronicle">{{sheet.chronicle}}</span>
</div>
</div>
</div>
</div>
<div id="attributes">
<h2>Attributes</h2>
<div class="flex-container">
<div class="attributes-section" id="mentalAttributes">
{{ macros::attribute(name="Intelligence", value=sheet.intelligence) }}
{{ macros::attribute(name="Wits", value=sheet.wits) }}
{{ macros::attribute(name="Resolve", value=sheet.resolve) }}
</div>
<div class="attributes-section" id="physicalAttributes">
{{ macros::attribute(name="Strength", value=sheet.strength) }}
{{ macros::attribute(name="Dexterity", value=sheet.dexterity) }}
{{ macros::attribute(name="Stamina", value=sheet.stamina) }}
</div>
<div class="attributes-section" id="socicalAttributes">
{{ macros::attribute(name="Presence", value=sheet.presence) }}
{{ macros::attribute(name="Manipulation", value=sheet.manipulation) }}
{{ macros::attribute(name="Composure", value=sheet.composure) }}
</div>
</div>
</div>
<div id="skills">
<h2>Skills</h2>
<div class="flex-container">
<div class="skills-section" id="mentalSkills">
{% for skill_name, skill in sheet.mentalSkills %}
{{ macros::skill(name=skill_name, value=skill.dots) }}
{% endfor %}
</div>
<div class="skills-section" id="physicalSkills">
{% for skill_name, skill in sheet.physicalSkills %}
{{ macros::skill(name=skill_name, value=skill.dots) }}
{% endfor %}
</div>
<div class="skills-section" id="socialSkills">
{% for skill_name, skill in sheet.socialSkills %}
{{ macros::skill(name=skill_name, value=skill.dots) }}
{% endfor %}
</div>
</div>
</div>
<div id="merits">
<h2>Merits</h2>
<div id="merit-list">
{% for merit in sheet.merits %}
{{ macros::merit(name=merit.name, value=merit.dots) }}
{% endfor %}
</div>
</div>
<div id="items">
<h2>Items</h2>
<div id="item-list">
{% for item in sheet.items %}
<div class="item">
<span class="item-name">{{item.name}}</span>
</div>
{% endfor %}
</div>
</div>
</div>
{% endblock content %}

View File

@ -0,0 +1,20 @@
{% macro attribute(name, value) %}
<div class="attribute">
<label for="{{name}}">{{name}}:</label>
<span class="value" id="{{name}}">{{value}}</span>
</div>
{% endmacro attribute %}
{% macro skill(name, value) %}
<div class="skill">
<label for="{{name}}">{{name}}:</label>
<span class="value" id="{{name}}">{{value}}</span>
</div>
{% endmacro skill %}
{% macro merit(name, value) %}
<div class="merit">
<span class="merit-name">{{name}}</span>
<span class="merit-dots">{{value}}</span> <span>Dots</span>
</div>
{% endmacro merit %}

View File

@ -1,6 +1,7 @@
{% extends "base" %} {% extends "base" %}
{% block content %} {% block content %}
<%= htmlWebpackPlugin.tags.bodyTags %>
<div> <div>
<h1>Tenebrous: Login</h1> <h1>Tenebrous: Login</h1>

View File

@ -4,26 +4,21 @@ extern crate rocket;
#[macro_use] #[macro_use]
extern crate rocket_contrib; extern crate rocket_contrib;
#[macro_use] use log::{error, info};
extern crate diesel;
// Seemingly necessary to get serde::Serialize into scope for Prost
// code generation.
#[macro_use]
extern crate serde_derive;
use rocket_contrib::serve::StaticFiles; use rocket_contrib::serve::StaticFiles;
use rocket_contrib::templates::Template; use rocket_contrib::templates::Template;
use std::env;
use tonic::transport::Server;
pub mod catchers; pub mod catchers;
pub mod db; pub mod db;
pub mod errors; pub mod errors;
pub mod migrator;
pub mod models; pub mod models;
pub mod routes; pub mod routes;
pub mod schema;
#[rocket::main] async fn make_rocket(database: sqlx::SqlitePool) -> Result<(), Box<dyn std::error::Error>> {
async fn main() -> Result<(), rocket::error::Error> { info!("Running Rocket");
let root_routes: Vec<rocket::Route> = { let root_routes: Vec<rocket::Route> = {
routes::root::routes() routes::root::routes()
.into_iter() .into_iter()
@ -37,13 +32,13 @@ async fn main() -> Result<(), rocket::error::Error> {
rocket::ignite() rocket::ignite()
.attach(Template::fairing()) .attach(Template::fairing())
.attach(db::TenebrousDbConn::fairing()) .manage(database)
.mount("/", root_routes) .mount("/", root_routes)
.mount("/characters", character_routes) .mount("/characters", character_routes)
.mount("/api", api_routes) .mount("/api", api_routes)
.mount( .mount(
"/scripts", "/scripts",
StaticFiles::from(concat!(env!("CARGO_MANIFEST_DIR"), "/static/scripts")), StaticFiles::from(concat!(env!("CARGO_MANIFEST_DIR"), "/generated/scripts")),
) )
.mount( .mount(
"/protos", "/protos",
@ -51,5 +46,25 @@ async fn main() -> Result<(), rocket::error::Error> {
) )
.register(catchers) .register(catchers)
.launch() .launch()
.await .await?;
Ok(())
}
#[rocket::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().collect();
let db_path: &str = match &args[..] {
[_, path] => path.as_ref(),
[_, _, ..] => panic!("Expected exactly 0 or 1 argument"),
_ => "tenebrous.sqlite",
};
println!("Using database: {}", db_path);
migrator::migrate(db_path).await?;
let db = crate::db::create_pool(db_path).await?;
make_rocket(db.clone()).await
} }

17
src/migrate.rs Normal file
View File

@ -0,0 +1,17 @@
use std::env;
pub mod migrator;
#[rocket::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().collect();
let db_path: &str = match &args[..] {
[_, path] => path.as_ref(),
[_, _, ..] => panic!("Expected exactly 0 or 1 argument"),
_ => "tenebrous.sqlite",
};
println!("Using database: {}", db_path);
crate::migrator::migrate(db_path).await
}

22
src/migrator.rs Normal file
View File

@ -0,0 +1,22 @@
use refinery::config::{Config, ConfigDbType};
use sqlx::sqlite::SqliteConnectOptions;
use sqlx::ConnectOptions;
use std::str::FromStr;
pub mod migrations;
/// Run database migrations against the sqlite database.
pub(crate) async fn migrate(db_path: &str) -> Result<(), Box<dyn std::error::Error>> {
//Create database if missing.
let conn = SqliteConnectOptions::from_str(&format!("sqlite://{}", db_path))?
.create_if_missing(true)
.connect()
.await?;
drop(conn);
let mut conn = Config::new(ConfigDbType::Sqlite).set_db_path(db_path);
println!("Running migrations");
migrations::runner().run(&mut conn)?;
Ok(())
}

View File

@ -0,0 +1,15 @@
use barrel::backend::Sqlite;
use barrel::{types, Migration};
pub fn migration() -> String {
let mut m = Migration::new();
println!("Applying migration: {}", file!());
m.create_table("users", |t| {
t.add_column("id", types::primary());
t.add_column("username", types::text());
t.add_column("password", types::text());
});
m.make::<Sqlite>()
}

View File

@ -0,0 +1,20 @@
use barrel::backend::Sqlite;
use barrel::{types, Migration};
pub fn migration() -> String {
let mut m = Migration::new();
println!("Applying migration: {}", file!());
m.create_table("characters", move |t| {
let db_enum = r#"CHECK(data_type IN ('chronicles_of_darkness', 'changeling'))"#;
t.add_column("id", types::primary());
t.add_column("user_id", types::integer());
t.add_column("viewable", types::boolean());
t.add_column("character_name", types::text());
t.add_column("data_type", types::custom(db_enum));
t.add_column("data_version", types::integer());
t.add_column("data", types::custom("BLOB"));
});
m.make::<Sqlite>()
}

View File

@ -0,0 +1,2 @@
use refinery::include_migration_mods;
include_migration_mods!("src/migrator/migrations");

View File

@ -1,10 +1,8 @@
use crate::errors::Error; use crate::errors::Error;
use crate::models::proto::cofd::*; use crate::models::proto::cofd::*;
use crate::models::users::User; use crate::models::users::User;
use crate::schema::characters;
use diesel_derive_enum::DbEnum;
use prost::bytes::BytesMut; use prost::bytes::BytesMut;
use serde_derive::Serialize; use serde::Serialize;
use strum::{EnumIter, EnumString}; use strum::{EnumIter, EnumString};
/// Dynamic character data is an opaque container type that holds /// Dynamic character data is an opaque container type that holds
@ -41,26 +39,34 @@ pub(crate) trait Visibility {
} }
} }
#[derive(DbEnum, Debug, Serialize, PartialEq, Clone, Copy, EnumIter, EnumString)] /// Enum representing all game systems supported by the character
/// service. Game systems are kept unique instead of lumping them
/// together under common umbrella systems, even if the different
/// games use the same (or similar) character sheets. This is because
/// of the possibility for slight differences in rules and data
/// between even similar systems. It's simpler to err on the side of
/// uniqueness. Usually, systems based on the same ruleset will have
/// one character sheet type, with a oneof field for game-specific
/// information.
#[derive(Debug, Serialize, PartialEq, Clone, Copy, EnumIter, EnumString, sqlx::Type)]
#[sqlx(rename_all = "snake_case")]
pub enum CharacterDataType { pub enum CharacterDataType {
ChroniclesOfDarknessV1, /// A character for the core Chronicles of Darkness rules.
ChangelingV1, ChroniclesOfDarkness,
/// A character for Changeling 2E rules.
Changeling,
} }
impl CharacterDataType { impl CharacterDataType {
pub fn create_data(&self) -> Result<BytesMut, Error> { /// Create the default serialized protobuf data (character sheet)
/// for the game system represented by the enum variant.
pub fn default_serialized_data(&self) -> Result<BytesMut, Error> {
use prost::Message; use prost::Message;
use CharacterDataType::*; use CharacterDataType::*;
let data: BytesMut = match self { let data: BytesMut = match self {
ChroniclesOfDarknessV1 => { ChroniclesOfDarkness | Changeling => {
let sheet = CofdSheet::default(); let sheet = CofdSheet::default_sheet(*self);
let mut buf = BytesMut::with_capacity(std::mem::size_of_val(&sheet));
sheet.encode(&mut buf)?;
buf
}
ChangelingV1 => {
let mut sheet = ChangelingSheet::default();
sheet.base = Some(CofdSheet::default());
let mut buf = BytesMut::with_capacity(std::mem::size_of_val(&sheet)); let mut buf = BytesMut::with_capacity(std::mem::size_of_val(&sheet));
sheet.encode(&mut buf)?; sheet.encode(&mut buf)?;
buf buf
@ -69,11 +75,20 @@ impl CharacterDataType {
Ok(data) Ok(data)
} }
/// Returns whether or not this enum variant represents a Chronicles
/// of Darkness game system.
pub fn is_cofd_system(&self) -> bool {
use CharacterDataType::*;
match self {
ChroniclesOfDarkness | Changeling => true,
}
}
} }
/// An entry that appears in a user's character list. Properties are /// An entry that appears in a user's character list. Properties are
/// in order of table columns. /// in order of table columns.
#[derive(Serialize, Debug, Queryable, Identifiable, AsChangeset)] #[derive(Serialize, Debug, sqlx::FromRow)]
pub struct Character { pub struct Character {
pub id: i32, pub id: i32,
pub user_id: i32, pub user_id: i32,
@ -112,8 +127,8 @@ impl Character {
pub fn dyn_deserialize(&self) -> Result<Box<DynCharacterData>, Error> { pub fn dyn_deserialize(&self) -> Result<Box<DynCharacterData>, Error> {
use CharacterDataType::*; use CharacterDataType::*;
let decoded: Box<dyn erased_serde::Serialize> = match self.data_type { let decoded: Box<dyn erased_serde::Serialize> = match self.data_type {
ChroniclesOfDarknessV1 => Box::new(self.try_deserialize::<CofdSheet>()?), ChroniclesOfDarkness => Box::new(self.try_deserialize::<CofdSheet>()?),
ChangelingV1 => Box::new(self.try_deserialize::<ChangelingSheet>()?), Changeling => Box::new(self.try_deserialize::<CofdSheet>()?),
}; };
Ok(decoded) Ok(decoded)
@ -131,8 +146,8 @@ impl Character {
} }
/// Update the existing character with new serialized protobuf /// Update the existing character with new serialized protobuf
/// data. Consumes the data. /// data.
pub fn update_data<T>(&mut self, data: T) -> Result<(), Error> pub fn update_data<T>(&mut self, data: &T) -> Result<(), Error>
where where
T: prost::Message + std::default::Default, T: prost::Message + std::default::Default,
{ {
@ -145,7 +160,7 @@ impl Character {
/// Same as regular character type, but without the actual protobuf /// Same as regular character type, but without the actual protobuf
/// data loaded into memory. /// data loaded into memory.
#[derive(Serialize, Debug, Queryable)] #[derive(Serialize, Debug, sqlx::FromRow)]
pub struct StrippedCharacter { pub struct StrippedCharacter {
pub id: i32, pub id: i32,
pub user_id: i32, pub user_id: i32,
@ -167,13 +182,11 @@ impl Visibility for StrippedCharacter {
/// Represents insert of a new character into the database. Property /// Represents insert of a new character into the database. Property
/// names correspond to columns. /// names correspond to columns.
#[derive(Insertable)] pub struct NewCharacter<'a> {
#[table_name = "characters"]
pub struct NewCharacter {
pub user_id: i32, pub user_id: i32,
pub viewable: bool, pub viewable: bool,
pub character_name: String, pub character_name: &'a str,
pub data_type: CharacterDataType, pub data_type: CharacterDataType,
pub data_version: i32, pub data_version: i32,
pub data: Vec<u8>, pub data: &'a [u8],
} }

View File

@ -1,10 +1,11 @@
use super::characters::CharacterDataType; use super::characters::CharacterDataType;
use rocket::http::RawStr; use rocket::http::RawStr;
use rocket::request::FromFormValue; use rocket::request::FromFormValue;
use serde::Serialize;
use std::str::FromStr; use std::str::FromStr;
use thiserror::Error; use thiserror::Error;
/// Validation errors specific to the new character form. /// Validation errors from form submissions.
#[derive(Serialize, Error, Debug, Clone)] #[derive(Serialize, Error, Debug, Clone)]
pub enum ValidationError { pub enum ValidationError {
#[error("bad UTF-8 encoding")] #[error("bad UTF-8 encoding")]

View File

@ -1,36 +1,21 @@
use crate::errors::Error; use crate::errors::Error;
use prost::bytes::BytesMut;
use rocket::data::{Data, FromData, Outcome, ToByteUnit}; use rocket::data::{Data, FromData, Outcome, ToByteUnit};
use rocket::http::{ContentType, Status};
use rocket::request::Request; use rocket::request::Request;
use rocket::response::status;
use rocket::response::{self, Responder, Response};
use std::default::Default; use std::default::Default;
use std::ops::Deref; use std::io::Cursor;
use std::ops::{Deref, DerefMut};
/// Contains the generated Chronicles of Darkness-related protocol pub mod cofd;
/// buffer types.
pub mod cofd {
include!(concat!(env!("OUT_DIR"), "/models.proto.cofd.rs"));
pub mod api {
include!(concat!(env!("OUT_DIR"), "/models.proto.cofd.api.rs"));
}
// TODO these values are not available in tera templates, so how to
// handle?
pub(crate) trait DerivedStats {
fn speed(&self) -> i32;
}
impl DerivedStats for CofdSheet {
fn speed(&self) -> i32 {
self.size + self.stamina
}
}
}
/// A struct wrapping a protobuf that allows it to be used as binary /// A struct wrapping a protobuf that allows it to be used as binary
/// data submitted via POST using fetch API. Can automatically be /// data submitted via POST using fetch API. Can automatically be
/// dereferenced into its wrapped type. /// dereferenced into its wrapped type.
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct Proto<T>(T) pub(crate) struct Proto<T>(pub T)
where where
T: prost::Message + Default; T: prost::Message + Default;
@ -43,8 +28,17 @@ where
{ {
type Error = crate::errors::Error; type Error = crate::errors::Error;
async fn from_data(_req: &Request<'_>, data: Data) -> Outcome<Self, Error> { async fn from_data(req: &Request<'_>, data: Data) -> Outcome<Self, Error> {
use rocket::http::Status; use rocket::http::Status;
let content_type = req.content_type();
let is_protobuf = content_type
.map(|ct| ct.top() == "application" && ct.sub() == "x-protobuf")
.unwrap_or(false);
if !is_protobuf {
return Outcome::Failure((Status::new(422, "invalid protobuf"), Error::InvalidInput));
}
let bytes: Vec<u8> = match data.open(2.mebibytes()).stream_to_vec().await { let bytes: Vec<u8> = match data.open(2.mebibytes()).stream_to_vec().await {
Ok(read_bytes) => read_bytes, Ok(read_bytes) => read_bytes,
@ -58,6 +52,23 @@ where
} }
} }
impl<'r, T> Responder<'r, 'static> for Proto<T>
where
T: prost::Message + Default,
{
fn respond_to(self, req: &Request) -> response::Result<'static> {
let mut buf = BytesMut::with_capacity(std::mem::size_of_val(&self.0));
match self.0.encode(&mut buf) {
Ok(_) => Response::build()
.header(ContentType::new("application", "x-protobuf"))
.sized_body(buf.len(), Cursor::new(buf))
.ok(),
Err(e) => status::Custom(Status::InternalServerError, e.to_string()).respond_to(req),
}
}
}
/// Enable automatically calling methods on a decoded Proto instance.
impl<T> Deref for Proto<T> impl<T> Deref for Proto<T>
where where
T: prost::Message + Default, T: prost::Message + Default,
@ -68,3 +79,12 @@ where
&self.0 &self.0
} }
} }
impl<T> DerefMut for Proto<T>
where
T: prost::Message + Default,
{
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}

137
src/models/proto/cofd.rs Normal file
View File

@ -0,0 +1,137 @@
//! Contains the generated Chronicles of Darkness-related protocol
//! buffer types, as well as utilities and extensions for working with
//! them.
use crate::models::characters::CharacterDataType;
use std::collections::BTreeMap;
//Add the generated protobuf code into this module.
//include!(concat!(env!("OUT_DIR"), "/models.proto.cofd.rs"));
tonic::include_proto!("models.proto.cofd");
//Add the API protobuf genreated code for the api module.
pub mod api {
//include!(concat!(env!("OUT_DIR"), "/models.proto.cofd.api.rs"));
tonic::include_proto!("models.proto.cofd.api");
use std::borrow::Cow;
/// Trait to extract values out of a CharacterIdentifier whose
/// instance may or may not exist on an API request.
pub trait DefaultCharacterIdentifier {
/// Retrieve the specified owner, or a default fallback (empty
/// string). Will not allocate if the owner is present in the
/// request.
fn owner(&self) -> Cow<'_, str>;
/// Retrieve the character ID specified, or the default i32
/// value (0).
fn id(&self) -> i32;
}
impl DefaultCharacterIdentifier for Option<CharacterIdentifier> {
fn owner(&self) -> Cow<'_, str> {
self.as_ref()
.map(|ident| Cow::from(&ident.owner))
.unwrap_or_default()
}
fn id(&self) -> i32 {
self.as_ref().map(|ident| ident.id).unwrap_or_default()
}
}
/// Helpers for the ApiResult class.
impl ApiResult {
pub fn success() -> Self {
ApiResult {
success: true,
error: "".to_string(),
}
}
}
}
/// Default mental skill names for a regular Chronicles of Darkness
/// (or derivative system) game.
const MENTAL_SKILLS: &'static [&'static str] = &[
"Academics",
"Computer",
"Crafts",
"Investigation",
"Medicine",
"Occult",
"Politics",
"Science",
];
/// Default physical skill names for a regular Chronicles of Darkness
/// (or derivative system) game.
const PHYSICAL_SKILLS: &'static [&'static str] = &[
"Athletics",
"Brawl",
"Drive",
"Firearms",
"Larceny",
"Stealth",
"Survival",
"Weaponry",
];
/// Default social skill names for a regular Chronicles of Darkness
/// (or derivative system) game.
const SOCIAL_SKILLS: &'static [&'static str] = &[
"Animal Ken",
"Empathy",
"Expression",
"Intimidation",
"Persuasion",
"Socialize",
"Streetwise",
"Subterfuge",
];
/// Create a pre-populated skill list based on skill names given to
/// the function. The list of skill names is turned into a sorted Map
/// of skill name to default Skill protobuf instances.
fn create_skill_list(skill_names: &[&str]) -> BTreeMap<String, cofd_sheet::Skill> {
skill_names
.into_iter()
.map(|skill_name| (skill_name.to_string(), cofd_sheet::Skill::default()))
.collect()
}
impl CofdSheet {
/// Create the default (blank) character sheet for a Chronicles of
/// Darkness-based character. This fills in skills and other
/// information that needs to be pre-populated. System specifics
/// are set based on the given character data type (aka game
/// system).
pub fn default_sheet(system: CharacterDataType) -> CofdSheet {
let mut sheet = Self::default();
sheet.mental_skills = create_skill_list(&MENTAL_SKILLS);
sheet.physical_skills = create_skill_list(&PHYSICAL_SKILLS);
sheet.social_skills = create_skill_list(&SOCIAL_SKILLS);
use crate::models::proto::cofd::cofd_sheet::SystemFields;
let specifics: SystemFields = match system {
CharacterDataType::Changeling => SystemFields::Changeling(ChangelingFields::default()),
CharacterDataType::ChroniclesOfDarkness => SystemFields::Core(CoreFields::default()),
};
sheet.system_fields = Some(specifics);
sheet
}
}
// TODO these values are not available in tera templates, so how to
// handle?
pub(crate) trait DerivedStats {
fn speed(&self) -> i32;
}
impl DerivedStats for CofdSheet {
fn speed(&self) -> i32 {
self.size + self.stamina
}
}

View File

@ -1,10 +1,9 @@
use crate::db::{Dao, TenebrousDbConn}; use crate::db::{Dao, TenebrousDbConn};
use crate::schema::users;
use argon2::{self, Config, Error as ArgonError}; use argon2::{self, Config, Error as ArgonError};
use rand::Rng; use rand::Rng;
use rocket::outcome::IntoOutcome; use rocket::outcome::IntoOutcome;
use rocket::request::{self, FromRequest, Request}; use rocket::request::{self, FromRequest, Request};
use serde_derive::Serialize; use serde::Serialize;
pub(crate) fn hash_password(raw_password: &str) -> Result<String, ArgonError> { pub(crate) fn hash_password(raw_password: &str) -> Result<String, ArgonError> {
let salt = rand::thread_rng().gen::<[u8; 16]>(); let salt = rand::thread_rng().gen::<[u8; 16]>();
@ -12,7 +11,7 @@ pub(crate) fn hash_password(raw_password: &str) -> Result<String, ArgonError> {
argon2::hash_encoded(raw_password.as_bytes(), &salt, &config) argon2::hash_encoded(raw_password.as_bytes(), &salt, &config)
} }
#[derive(Eq, PartialEq, Serialize, Debug, Queryable)] #[derive(Eq, PartialEq, Serialize, Debug, sqlx::FromRow)]
pub struct User { pub struct User {
pub id: i32, pub id: i32,
pub username: String, pub username: String,
@ -25,10 +24,11 @@ impl User {
} }
} }
async fn attempt_load_user<'a>(db: &'a TenebrousDbConn, id: i32) -> Option<User> { async fn attempt_load_user<'a>(db: &'a TenebrousDbConn<'a>, id: i32) -> Option<User> {
db.load_user_by_id(id).await.ok().flatten() db.load_user_by_id(id).await.ok().flatten()
} }
/// Trait implementation to get the logged in user.
#[rocket::async_trait] #[rocket::async_trait]
impl<'a, 'r> FromRequest<'a, 'r> for &'a User { impl<'a, 'r> FromRequest<'a, 'r> for &'a User {
type Error = (); type Error = ();
@ -53,9 +53,7 @@ impl<'a, 'r> FromRequest<'a, 'r> for &'a User {
} }
} }
#[derive(Insertable)] pub struct NewUser<'a> {
#[table_name = "users"] pub username: &'a str,
pub struct NewUser { pub password: &'a str,
pub username: String,
pub password: String,
} }

View File

@ -1,115 +1,43 @@
use crate::db::{Dao, TenebrousDbConn}; use crate::db::{Dao, TenebrousDbConn};
use crate::errors::Error; use crate::errors::Error;
use crate::models::characters::{Character, CharacterDataType, DynCharacterData, Visibility}; use crate::models::characters::Character;
use crate::models::proto::{cofd::*, Proto};
use crate::models::users::User; use crate::models::users::User;
use rocket_contrib::templates::Template;
use serde::Serialize; mod cofd;
use std::collections::HashMap;
pub(crate) fn routes() -> Vec<rocket::Route> { pub(crate) fn routes() -> Vec<rocket::Route> {
routes![ routes![
cofd::update_basic_info, cofd::update_basic_info,
cofd::update_attributes, cofd::update_attribute_value,
cofd::update_attribute, cofd::update_skill,
cofd::update_skills, cofd::update_skill_value,
cofd::update_merits,
cofd::update_items,
cofd::add_condition, cofd::add_condition,
cofd::remove_condition cofd::remove_condition
] ]
} }
/// Protobuf-based REST endpoints for editing a character. /// Load the character belonging to the given user, as long as they're
mod cofd { /// the owner of that character. Returns an error if user is not
use super::*; /// logged in, the owner of the character is not found, or the logged
use crate::models::proto::{cofd::api::*, cofd::*, Proto}; /// in user does not have the permission to access this character.
async fn load_character(
#[post("/cofd/<owner>/<character_id>/basic-info", data = "<info>")] conn: &TenebrousDbConn<'_>,
pub(super) fn update_basic_info<'a>(
owner: String,
character_id: i32,
info: Proto<BasicInfo>,
) -> &'a str {
"lol"
}
#[post("/cofd/<owner>/<character_id>/attributes", data = "<info>")]
pub(super) fn update_attributes<'a>(
owner: String,
character_id: i32,
info: Proto<Attributes>,
) -> &'a str {
"lol"
}
#[patch("/cofd/<owner>/<character_id>/attributes", data = "<attr_update>")]
pub(super) async fn update_attribute<'a>(
owner: String,
character_id: i32,
attr_update: Proto<Attribute>,
conn: TenebrousDbConn,
logged_in_user: Option<&User>, logged_in_user: Option<&User>,
) -> Result<&'a str, Error> { owner: &str,
character_id: i32,
) -> Result<Character, Error> {
let logged_in_user = logged_in_user.ok_or(Error::NotLoggedIn)?; let logged_in_user = logged_in_user.ok_or(Error::NotLoggedIn)?;
let owner = conn.load_user(owner).await?.ok_or(Error::NotFound)?;
let mut character: Character = conn let character: Character = conn
.load_character(character_id) .load_character(character_id)
.await? .await?
.ok_or(Error::NotFound)?; .ok_or(Error::NotFound)?;
if logged_in_user != &owner { if &logged_in_user.username != owner {
return Err(Error::NoPermission); return Err(Error::NoPermission);
} }
let mut sheet: CofdSheet = character.try_deserialize()?; Ok(character)
match attr_update.name.to_lowercase().as_ref() {
"strength" => Ok(sheet.strength += attr_update.value),
"dexterity" => Ok(sheet.dexterity += attr_update.value),
"stamina" => Ok(sheet.stamina += attr_update.value),
"intelligence" => Ok(sheet.intelligence += attr_update.value),
"wits" => Ok(sheet.wits += attr_update.value),
"resolve" => Ok(sheet.resolve += attr_update.value),
"presence" => Ok(sheet.presence += attr_update.value),
"manipulation" => Ok(sheet.manipulation += attr_update.value),
"composure" => Ok(sheet.composure += attr_update.value),
_ => Err(Error::InvalidInput),
}?;
println!(
"updated {} attribute {} to {}",
character.character_name, attr_update.name, attr_update.value
);
character.update_data(sheet)?;
conn.update_character_sheet(character).await?;
Ok("lol")
}
#[post("/cofd/<owner>/<character_id>/skills", data = "<info>")]
pub(super) fn update_skills<'a>(
owner: String,
character_id: i32,
info: Proto<Skills>,
conn: TenebrousDbConn,
) -> &'a str {
"lol"
}
#[put("/cofd/<owner>/<character_id>/conditions", data = "<info>")]
pub(super) fn add_condition<'a>(
owner: String,
character_id: i32,
info: Proto<Condition>,
) -> &'a str {
"lol"
}
#[delete("/cofd/<owner>/<character_id>/conditions", data = "<info>")]
pub(super) fn remove_condition<'a>(
owner: String,
character_id: i32,
info: Proto<Condition>,
) -> &'a str {
"lol"
}
} }

222
src/routes/api/cofd.rs Normal file
View File

@ -0,0 +1,222 @@
use super::load_character;
use crate::db::{Dao, TenebrousDbConn};
use crate::errors::Error;
use crate::models::characters::Character;
use crate::models::proto::cofd::api::DefaultCharacterIdentifier;
use crate::models::proto::cofd::cofd_sheet::Skill;
use crate::models::proto::cofd::*;
use crate::models::proto::{cofd::api::*, cofd::*, Proto};
use crate::models::users::User;
use std::collections::btree_map::{Entry, OccupiedEntry};
fn find_skill_entry<'a>(
sheet: &'a mut CofdSheet,
skill_name: &'a str,
) -> Option<OccupiedEntry<'a, String, Skill>> {
let all_skills = vec![
&mut sheet.mental_skills,
&mut sheet.physical_skills,
&mut sheet.social_skills,
];
// Search all skill lists for this value using "workaround" to
// break value from for loops.
let skill: Option<OccupiedEntry<_, _>> = 'l: loop {
for skill_map in all_skills {
if let Entry::Occupied(entry) = skill_map.entry(skill_name.to_owned()) {
break 'l Some(entry);
}
}
break None;
};
skill
}
fn find_skill<'a>(sheet: &'a mut CofdSheet, skill_name: &'a str) -> Option<&'a mut Skill> {
find_skill_entry(sheet, skill_name).map(|entry| entry.into_mut())
}
#[post("/rpc/cofd/update_basic_info", data = "<req>")]
pub(super) async fn update_basic_info<'a>(
req: Proto<UpdateBasicInfoRequest>,
conn: TenebrousDbConn<'_>,
user: Option<&User>,
) -> Result<Proto<ApiResult>, Error> {
let mut character =
load_character(&conn, user, &req.character.owner(), req.character.id()).await?;
let mut sheet: CofdSheet = character.try_deserialize()?;
println!("name will now be {}", req.name);
character.character_name = req.name.clone(); //Should probably remove name from the sheet?
sheet.name = req.name.clone();
sheet.gender = req.gender.clone();
sheet.concept = req.concept.clone();
sheet.chronicle = req.chronicle.clone();
sheet.age = req.age;
character.update_data(&sheet)?;
conn.update_character(&character).await?;
println!("Updated basic info");
Ok(Proto(ApiResult::success()))
}
#[post("/rpc/cofd/update_attribute_value", data = "<req>")]
pub(super) async fn update_attribute_value(
req: Proto<UpdateAttributeRequest>,
conn: TenebrousDbConn<'_>,
logged_in_user: Option<&User>,
) -> Result<Proto<ApiResult>, Error> {
let mut character = load_character(
&conn,
logged_in_user,
&req.character.owner(),
req.character.id(),
)
.await?;
let mut sheet: CofdSheet = character.try_deserialize()?;
let value = req.attribute_value;
match req.attribute_name.to_lowercase().as_ref() {
"strength" => Ok(sheet.strength = value),
"dexterity" => Ok(sheet.dexterity = value),
"stamina" => Ok(sheet.stamina = value),
"intelligence" => Ok(sheet.intelligence = value),
"wits" => Ok(sheet.wits = value),
"resolve" => Ok(sheet.resolve = value),
"presence" => Ok(sheet.presence = value),
"manipulation" => Ok(sheet.manipulation = value),
"composure" => Ok(sheet.composure = value),
_ => Err(Error::InvalidInput),
}?;
character.update_data(&sheet)?;
conn.update_character_sheet(&character).await?;
Ok(Proto(ApiResult {
success: true,
error: "".to_string(),
}))
}
#[patch("/rpc/cofd/update_skill", data = "<skill_update>")]
pub(super) async fn update_skill<'a>(
skill_update: Proto<UpdateSkillRequest>,
conn: TenebrousDbConn<'_>,
logged_in_user: Option<&User>,
) -> Result<&'a str, Error> {
let mut character = load_character(
&conn,
logged_in_user,
&skill_update.character.owner(),
skill_update.character.id(),
)
.await?;
let mut sheet: CofdSheet = character.try_deserialize()?;
let updated_skill: &Skill = skill_update.skill.as_ref().ok_or(Error::InvalidInput)?;
let skill_entry = find_skill_entry(&mut sheet, &updated_skill.name);
skill_entry
.map(|mut entry| entry.insert(updated_skill.clone()))
.ok_or(Error::InvalidInput)?;
println!(
"updated skill {} with {:?}",
updated_skill.name, skill_update.skill
);
character.update_data(&sheet)?;
conn.update_character_sheet(&character).await?;
Ok("lol")
}
#[post("/rpc/cofd/update_skill_value", data = "<req>")]
pub(super) async fn update_skill_value<'a>(
req: Proto<UpdateSkillValueRequest>,
conn: TenebrousDbConn<'_>,
logged_in_user: Option<&User>,
) -> Result<Proto<ApiResult>, Error> {
let mut character = load_character(
&conn,
logged_in_user,
&req.character.owner(),
req.character.id(),
)
.await?;
let mut sheet: CofdSheet = character.try_deserialize()?;
let mut skill: Option<&mut Skill> = find_skill(&mut sheet, &req.skill_name);
if let Some(ref mut s) = skill {
s.dots = req.skill_value;
}
println!("updated skill value");
character.update_data(&sheet)?;
conn.update_character_sheet(&character).await?;
Ok(Proto(ApiResult {
success: true,
error: "".to_string(),
}))
}
#[post("/rpc/cofd/update_merits", data = "<req>")]
pub(super) async fn update_merits<'a>(
req: Proto<UpdateMeritsRequest>,
conn: TenebrousDbConn<'_>,
logged_in_user: Option<&User>,
) -> Result<Proto<ApiResult>, Error> {
let mut character = load_character(
&conn,
logged_in_user,
&req.character.owner(),
req.character.id(),
)
.await?;
let mut sheet: CofdSheet = character.try_deserialize()?;
sheet.merits = req.merits.clone();
println!("updated merits");
character.update_data(&sheet)?;
conn.update_character_sheet(&character).await?;
Ok(Proto(ApiResult::success()))
}
#[post("/rpc/cofd/update_items", data = "<req>")]
pub(super) async fn update_items<'a>(
req: Proto<UpdateItemsRequest>,
conn: TenebrousDbConn<'_>,
logged_in_user: Option<&User>,
) -> Result<Proto<ApiResult>, Error> {
let mut character = load_character(
&conn,
logged_in_user,
&req.character.owner(),
req.character.id(),
)
.await?;
let mut sheet: CofdSheet = character.try_deserialize()?;
sheet.items = req.items.clone();
println!("updated items");
character.update_data(&sheet)?;
conn.update_character_sheet(&character).await?;
Ok(Proto(ApiResult::success()))
}
#[put("/rpc/cofd/add_condition", data = "<info>")]
pub(super) fn add_condition<'a>(info: Proto<AddConditionRequest>) -> &'a str {
"lol"
}
#[delete("/rpc/cofd/remove_condition", data = "<info>")]
pub(super) fn remove_condition<'a>(info: Proto<RemoveConditionRequest>) -> &'a str {
"lol"
}

View File

@ -50,10 +50,10 @@ fn registration_error_redirect<S: AsRef<str>>(message: S) -> Flash<Redirect> {
async fn login( async fn login(
cookies: &CookieJar<'_>, cookies: &CookieJar<'_>,
login: Form<Login>, login: Form<Login>,
conn: TenebrousDbConn, conn: TenebrousDbConn<'_>,
) -> Result<Redirect, Flash<Redirect>> { ) -> Result<Redirect, Flash<Redirect>> {
let user = conn let user = conn
.load_user(login.username.clone()) .load_user(&login.username)
.await .await
.map_err(|e| { .map_err(|e| {
error!("login - error loading user user: {}", e); error!("login - error loading user user: {}", e);
@ -104,12 +104,9 @@ fn register_page(flash: Option<FlashMessage>) -> Template {
async fn register( async fn register(
mut cookies: &CookieJar<'_>, mut cookies: &CookieJar<'_>,
registration: Form<Registration>, registration: Form<Registration>,
conn: TenebrousDbConn, conn: TenebrousDbConn<'_>,
) -> Result<Redirect, Flash<Redirect>> { ) -> Result<Redirect, Flash<Redirect>> {
let existing_user = conn let existing_user = conn.load_user(&registration.username).await.map_err(|e| {
.load_user(registration.username.clone())
.await
.map_err(|e| {
error!("registration - error loading existing user: {}", e); error!("registration - error loading existing user: {}", e);
registration_error_redirect("There was an error attempting to register.") registration_error_redirect("There was an error attempting to register.")
})?; })?;
@ -127,8 +124,8 @@ async fn register(
})?; })?;
let user = NewUser { let user = NewUser {
username: registration.username.clone(), username: &registration.username,
password: hashed_pw, password: &hashed_pw,
}; };
let user = conn.insert_user(user).await.map_err(|e| { let user = conn.insert_user(user).await.map_err(|e| {

View File

@ -3,6 +3,7 @@ use crate::errors::Error;
use crate::models::characters::{Character, CharacterDataType, DynCharacterData, Visibility}; use crate::models::characters::{Character, CharacterDataType, DynCharacterData, Visibility};
use crate::models::users::User; use crate::models::users::User;
use rocket_contrib::templates::Template; use rocket_contrib::templates::Template;
use serde::Serialize;
mod edit; mod edit;
mod new; mod new;
@ -37,10 +38,10 @@ fn view_character_template(user: &User, character: Character) -> Result<Template
sheet: character.dyn_deserialize()?, sheet: character.dyn_deserialize()?,
}; };
use CharacterDataType::*; let template = if character.data_type.is_cofd_system() {
let template = match character.data_type { Template::render("characters/view_character", context)
ChroniclesOfDarknessV1 => Template::render("characters/view_character", context), } else {
ChangelingV1 => Template::render("characters/view_changeling_character", context), return Err(Error::InvalidInput);
}; };
Ok(template) Ok(template)
@ -50,10 +51,10 @@ fn view_character_template(user: &User, character: Character) -> Result<Template
async fn view_character( async fn view_character(
character_id: i32, character_id: i32,
username: String, username: String,
conn: TenebrousDbConn, conn: TenebrousDbConn<'_>,
logged_in_user: Option<&User>, logged_in_user: Option<&User>,
) -> Result<Template, Error> { ) -> Result<Template, Error> {
let user = &conn.load_user(username).await?.ok_or(Error::NotFound)?; let user = &conn.load_user(&username).await?.ok_or(Error::NotFound)?;
let character = conn let character = conn
.load_character(character_id) .load_character(character_id)

View File

@ -10,6 +10,7 @@ use strum::IntoEnumIterator;
struct EditCharacterContext<'a> { struct EditCharacterContext<'a> {
pub name: &'a str, pub name: &'a str,
pub username: &'a str, pub username: &'a str,
pub id: i32,
pub data_type: &'a CharacterDataType, pub data_type: &'a CharacterDataType,
pub sheet: Box<DynCharacterData>, pub sheet: Box<DynCharacterData>,
pub state: FormStateContext<'a>, pub state: FormStateContext<'a>,
@ -20,14 +21,37 @@ struct FormStateContext<'a> {
pub selected_system: &'a CharacterDataType, pub selected_system: &'a CharacterDataType,
} }
fn edit_character_template(user: &User, character: Character) -> Result<Template, Error> {
let character = character.uprade()?;
let context = EditCharacterContext {
name: &character.character_name,
username: &user.username,
id: character.id,
data_type: &character.data_type,
sheet: character.dyn_deserialize()?,
state: FormStateContext {
selected_system: &character.data_type,
},
};
let template = if character.data_type.is_cofd_system() {
Template::render("characters/edit_character", context)
} else {
return Err(Error::InvalidInput);
};
Ok(template)
}
#[get("/<owner>/<character_id>/edit")] #[get("/<owner>/<character_id>/edit")]
pub(super) async fn edit_character_page( pub(super) async fn edit_character_page(
character_id: i32, character_id: i32,
owner: String, owner: String,
logged_in_user: Option<&User>, logged_in_user: Option<&User>,
conn: TenebrousDbConn, conn: TenebrousDbConn<'_>,
) -> Result<Template, Error> { ) -> Result<Template, Error> {
let owner = conn.load_user(owner).await?.ok_or(Error::NotFound)?; let owner = conn.load_user(&owner).await?.ok_or(Error::NotFound)?;
let logged_in_user = logged_in_user.ok_or(Error::NotLoggedIn)?; let logged_in_user = logged_in_user.ok_or(Error::NotLoggedIn)?;
let character = conn let character = conn
@ -39,15 +63,6 @@ pub(super) async fn edit_character_page(
return Err(Error::NoPermission); return Err(Error::NoPermission);
} }
let context = EditCharacterContext { let template = edit_character_template(logged_in_user, character)?;
name: &character.character_name, Ok(template)
username: &owner.username,
data_type: &character.data_type,
sheet: character.dyn_deserialize()?,
state: FormStateContext {
selected_system: &character.data_type,
},
};
Ok(Template::render("characters/edit_character", context))
} }

View File

@ -7,6 +7,7 @@ use crate::models::{
}; };
use rocket::{request::Form, response::Redirect}; use rocket::{request::Form, response::Redirect};
use rocket_contrib::templates::Template; use rocket_contrib::templates::Template;
use serde::Serialize;
use strum::IntoEnumIterator; use strum::IntoEnumIterator;
/// Form submission for creating a new character. /// Form submission for creating a new character.
@ -32,7 +33,7 @@ impl NewCharacterContext {
NewCharacterContext { NewCharacterContext {
name: "".to_string(), name: "".to_string(),
error_message: "".to_string(), error_message: "".to_string(),
selected_system: CharacterDataType::ChroniclesOfDarknessV1, selected_system: CharacterDataType::ChroniclesOfDarkness,
systems: CharacterDataType::iter().collect(), systems: CharacterDataType::iter().collect(),
} }
} }
@ -43,7 +44,7 @@ impl NewCharacterContext {
let system: CharacterDataType = *form let system: CharacterDataType = *form
.system .system
.as_ref() .as_ref()
.unwrap_or(&CharacterDataType::ChroniclesOfDarknessV1); .unwrap_or(&CharacterDataType::ChroniclesOfDarkness);
NewCharacterContext { NewCharacterContext {
name: form.name.clone(), name: form.name.clone(),
@ -59,18 +60,18 @@ impl NewCharacterContext {
async fn create_new_character( async fn create_new_character(
form: &Form<NewCharacterForm>, form: &Form<NewCharacterForm>,
user_id: i32, user_id: i32,
conn: TenebrousDbConn, conn: TenebrousDbConn<'_>,
) -> Result<(), Error> { ) -> Result<(), Error> {
let system: CharacterDataType = *form.system.as_ref().unwrap(); let system: CharacterDataType = *form.system.as_ref().map_err(|_| Error::InvalidInput)?;
let sheet: Vec<u8> = system.create_data()?.to_vec(); let sheet: Vec<u8> = system.default_serialized_data()?.to_vec();
let insert = NewCharacter { let insert = NewCharacter {
user_id: user_id, user_id: user_id,
viewable: true, viewable: true,
character_name: form.name.clone(), character_name: &form.name,
data_type: system, data_type: system,
data_version: 1, data_version: 1,
data: sheet, data: &sheet,
}; };
conn.insert_character(insert).await?; conn.insert_character(insert).await?;
@ -97,7 +98,7 @@ pub(super) fn new_character_page(_logged_in_user: &User) -> Result<Template, Err
pub(super) async fn new_character_submit( pub(super) async fn new_character_submit(
form: Form<NewCharacterForm>, form: Form<NewCharacterForm>,
logged_in_user: &User, logged_in_user: &User,
conn: TenebrousDbConn, conn: TenebrousDbConn<'_>,
) -> Result<Redirect, Template> { ) -> Result<Redirect, Template> {
if let Err(e) = &form.system { if let Err(e) = &form.system {
return Err(render_error(&form, e.to_string().clone())); return Err(render_error(&form, e.to_string().clone()));

View File

@ -4,7 +4,7 @@ use crate::models::characters::Visibility;
use crate::models::{characters::StrippedCharacter, users::User}; use crate::models::{characters::StrippedCharacter, users::User};
use rocket::response::Redirect; use rocket::response::Redirect;
use rocket_contrib::templates::Template; use rocket_contrib::templates::Template;
use serde_derive::Serialize; use serde::Serialize;
pub fn routes() -> Vec<rocket::Route> { pub fn routes() -> Vec<rocket::Route> {
routes![index, user_index, proto_test] routes![index, user_index, proto_test]
@ -18,7 +18,7 @@ pub struct UserHomeContext<'a> {
} }
#[get("/")] #[get("/")]
async fn user_index(user: &User, conn: TenebrousDbConn) -> Result<Template, Error> { async fn user_index(user: &User, conn: TenebrousDbConn<'_>) -> Result<Template, Error> {
let characters: Vec<StrippedCharacter> = conn let characters: Vec<StrippedCharacter> = conn
.load_character_list(user.id) .load_character_list(user.id)
.await? .await?

View File

@ -1,30 +0,0 @@
table! {
use diesel::sql_types::*;
use crate::models::characters::*;
characters (id) {
id -> Integer,
user_id -> Integer,
viewable -> Bool,
character_name -> Text,
data_type -> CharacterDataTypeMapping,
data_version -> Integer,
data -> Binary,
}
}
table! {
use diesel::sql_types::*;
use crate::models::characters::*;
users (id) {
id -> Integer,
username -> Text,
password -> Text,
}
}
allow_tables_to_appear_in_same_query!(
characters,
users,
);

View File

@ -1,30 +0,0 @@
function makeAPI(root) {
const Attribute = root.lookupType("models.proto.cofd.api.Attribute");
const attributesResource = (username, characterID) =>
'/api/cofd/' + username + '/' + characterID + '/attributes';
async function updateAttribute(params) {
const { username, characterID, attribute, newValue } = params;
let req = Attribute.create({
name: attribute,
value: parseInt(newValue)
});
const resource = attributesResource(username, characterID);
let resp = await fetch(resource, {
method: 'PATCH',
body: Attribute.encode(req).finish()
}).then(async resp => {
console.log("resp is", await resp.text());
}).catch(async err => {
console.log("err is", err.text());
});
}
return {
updateAttribute
};
}

View File

@ -1,29 +0,0 @@
(async () => {
//TODO start refactoring these into a separate script, and make API calls
//take all necessary info (e.g. username and character ID, plus other stuff)
//as object params.
const root = await protobuf.load("/protos/cofd_api.proto");
const [, , USERNAME, CHARACTER_ID] = window.location.pathname.split('/');
const api = makeAPI(root);
console.log("api is", api);
function setupAttributes() {
const attributeInputs = document.querySelectorAll('#attributes input[type="number"]');
Array.from(attributeInputs).forEach(input => {
input.addEventListener('change', async function(event) {
console.log("updating attr");
const attribute = event.target.id;
const newValue = parseInt(event.target.value);
const params = { username: USERNAME, characterID: CHARACTER_ID, attribute, newValue };
await api.updateAttribute(params);
});
});
}
setupAttributes();
})().catch(e => {
alert(e);
});

View File

@ -1,17 +0,0 @@
document.addEventListener('DOMContentLoaded', event => {
protobuf.load("/protos/cofd.proto").then(function(root) {
console.log("root is", root);
let CofdSheet = root.lookupType("models.proto.cofd.CofdSheet");
let sheet = CofdSheet.fromObject({ name: 'lol', strength: 100 });
let buffer = CofdSheet.encode(sheet).finish();
fetch('/proto-test', {
method: 'POST',
body: buffer
}).then(async resp => {
console.log("resp is", await resp.text());
}).catch(async err => {
console.log("err is", err.text());
});
});
});

View File

@ -1,76 +0,0 @@
{% import "characters/edit_character_macros" as macros %}
{% extends "base" %}
{% block content %}
<style type="text/css">
body {
font-family: Liberation Sans, Arial;
}
#attributes {
padding: 4px;
border-collapse: collapse;
}
#attributes .attributes-section {
border: 1px solid gray;
border-collapse: collapse;
display: table-cell;
}
.attribute {
margin: 0;
padding: 0;
display: flex;
}
.attribute label {
display: inline-block;
float: left;
clear: left;
width: 10em;
text-align: right;
vertical-align: text-bottom;
padding: 8px;
margin: 0;
}
.attribute input {
max-width: 4em;
display: inline-block;
float: left;
padding: 8px;
border: none;
background-color: lightgray;
margin: 0;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/protobufjs@6.10.2/dist/protobuf.min.js"></script>
<script defer type="text/javascript" src="/scripts/api.js"></script>
<script defer type="text/javascript" src="/scripts/characters/edit.js"></script>
<h1>Core Sheet</h1>
<div>
<h1>Name: <input type="text" value="{{name}}" /></h1>
<p>System: {{data_type}}</p>
<div id="attributes">
<div class="attributes-section" id="mentalAttributes">
{{ macros::attribute(name="Intelligence", value=sheet.intelligence) }}
{{ macros::attribute(name="Wits", value=sheet.wits) }}
{{ macros::attribute(name="Resolve", value=sheet.resolve) }}
</div>
<div class="attributes-section" id="physicalAttributes">
{{ macros::attribute(name="Strength", value=sheet.strength) }}
{{ macros::attribute(name="Dexterity", value=sheet.dexterity) }}
{{ macros::attribute(name="Stamina", value=sheet.stamina) }}
</div>
<div class="attributes-section" id="socicalAttributes">
{{ macros::attribute(name="Presence", value=sheet.presence) }}
{{ macros::attribute(name="Manipulation", value=sheet.manipulation) }}
{{ macros::attribute(name="Composure", value=sheet.composure) }}
</div>
</div>
</div>
{% endblock content %}

View File

@ -1,6 +0,0 @@
{% macro attribute(name, value) %}
<div class="attribute">
<label for="{{name}}">{{name}}:</label>
<input id="{{name}}" name="{{name}}" type="number" min="0" value="{{value}}" />
</div>
{% endmacro input %}

View File

@ -1,11 +0,0 @@
{% extends "base" %}
{% block content %}
<h1>Changeling Sheet</h1>
<div>
<h1>Character {{name}}</h1>
<h3>User: {{username}}</h3>
<p>System: {{data_type}}</h3>
<p>Strength: {{sheet.base.strength}}</p>
</div>
{% endblock content %}

View File

@ -1,15 +0,0 @@
{% extends "base" %}
{% block content %}
<h1>Core Sheet</h1>
<div>
<h1>Character {{name}}</h1>
<h3>User: {{username}}</h3>
<p>System: {{data_type}}</h3>
<p>Strength: {{sheet.strength}}</p>
</div>
<div>
<a href="/characters/{{username}}/{{id}}/edit">Edit Character</a>
</div>
{% endblock content %}

17
tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"alwaysStrict": true,
"sourceMap": true,
"target": "es5",
"removeComments": true,
"noImplicitReturns": true,
"noImplicitAny": true,
"strictNullChecks": true,
"stripInternal": true,
"noFallthroughCasesInSwitch": true,
"noEmitOnError": true,
"lib": [
"dom"
]
}
}

69
webpack.config.js Normal file
View File

@ -0,0 +1,69 @@
const webpack = require('webpack');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const CompressionPlugin = require("compression-webpack-plugin");
const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');
const root = path.resolve(__dirname, '.');
function packPage(page, chunks) {
if (!chunks) chunks = [];
return new HtmlWebpackPlugin({
template: `${root}/src/frontend/templates/${page}`,
filename: `${root}/generated/templates/${page}`,
publicPath: '/',
scriptLoading: 'defer',
chunks: chunks,
inject: false,
minify: false
});
}
module.exports = {
entry: {
edit_character: "./src/frontend/scripts/characters/edit.ts",
},
optimization: {
runtimeChunk: "single",
splitChunks: {
chunks: 'all',
},
},
mode: "development",
output: {
path: `${root}/generated`,
filename: 'scripts/dist/[name].bundle.js'
},
devtool: 'inline-source-map',
module: {
rules: [
{
test: /\.ts$/,
include: /src|_proto/,
exclude: /node_modules/,
loader: "ts-loader"
}
]
},
resolve: {
extensions: [".ts", ".js"]
},
plugins: [
new CleanWebpackPlugin({
cleanOnceBeforeBuildPatterns: ['!templates/**/*'],
cleanAfterEveryBuildPatterns: ['!templates/**/*'],
dry: false
}),
new CompressionPlugin(),
packPage('login.html.tera'),
packPage('base.html.tera'),
packPage('error.html.tera'),
packPage('index.html.tera'),
packPage('registration.html.tera'),
packPage('characters/edit_character.html.tera', ['edit_character']),
packPage('characters/edit_character_macros.html.tera'),
packPage('characters/new_character.html.tera'),
packPage('characters/view_character.html.tera'),
packPage('characters/view_character_macros.html.tera'),
]
};