Compare commits

...

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

70 changed files with 918 additions and 15077 deletions

3
.env
View File

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

7
.gitignore vendored
View File

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

1593
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,50 +4,32 @@ version = "0.1.0"
authors = ["jeff <jeff@agnos.is>"]
edition = "2018"
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]
prost-build = "0.6"
tonic-build = "0.3"
[dependencies]
tonic = "0.3"
prost = "0.6"
serde = { version = "1.0", features = ["derive"] }
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"
erased-serde = "0.3"
diesel = "1.4"
diesel-derive-enum = { version = "1", features = ["sqlite"] }
thiserror = "1.0"
rust-argon2 = "0.8"
log = "0.4"
rand = "0.7"
futures = "0.3"
tokio = { version = "1.0", features = ["macros"] }
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]
git = "https://github.com/SergioBenitez/Rocket"
branch = "master"
features = ["secrets", "tls"]
features = ["secrets"]
[dependencies.rocket_contrib]
git = "https://github.com/SergioBenitez/Rocket"
branch = "master"
default-features = false
features = [ "tera_templates", "serve" ]
features = [ "tera_templates", "diesel_sqlite_pool", "serve" ]

105
README.md
View File

@ -4,101 +4,48 @@ An open source character sheet service for tabletop roleplaying games.
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
These are very basic build instructions. They assume you already have
cargo set up and installed.
cargo set up and installed. Building the application requires **Rust
Nightly!** See [rustup documentation][rustup] for more, particularly
the part about using [Rust Nightly][nightly].
### Initial Setup
### Install Dependencies
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:
```
xbps-install sqlite sqlite-devel protobuf nodejs docker
```
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.
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. 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`).
* 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
Follow these instructions from the root of the repository. Set up database:
```
cargo install diesel_cli --no-default-features --features sqlite
diesel setup
diesel migration run
```
### 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:
```
cargo run-script grpc-proxy # only required if proxy not already running
cargo run
```
The sqlite database is created in the directory `cargo run` was
invoked from by default. You can also pass a path to a different
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.
The sqlite database is currently always created in the same directory
that `cargo run` was invoked from, so make sure you invoke it from the
same place every time.
[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,6 +1,3 @@
[default]
template_dir = "generated/templates/"
[development]
address = "localhost"
port = 8000

View File

@ -1,50 +1,9 @@
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() {
println!("cargo:rerun-if-changed=static/scripts/webpack.config.js");
js_protos();
webpack();
let mut config = prost_build::Config::new();
config.btree_map(&["."]);
config.type_attribute(".", "#[derive(::serde::Serialize)]");
config.type_attribute(".", "#[derive(Serialize)]");
config.type_attribute(".", "#[serde(rename_all = \"camelCase\")]");
tonic_build::configure()
.build_server(false)
.build_client(false)
.compile_with_config(
config,
config
.compile_protos(
&["proto/cofd.proto", "proto/cofd_api.proto"],
&["src/", "proto/"],
)

View File

@ -1,54 +0,0 @@
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

View File

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

0
migrations/.gitkeep Normal file
View File

View File

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

View File

@ -0,0 +1,9 @@
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

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

View File

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

5371
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,34 +0,0 @@
{
"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,24 +2,6 @@ syntax = "proto3";
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.
message CofdSheet {
message Merit {
@ -57,47 +39,41 @@ message CofdSheet {
}
string name = 1;
string gender = 2;
string concept = 3;
int32 age = 4;
string player = 2;
string campaign = 3;
string description = 4;
string player = 5;
string chronicle = 6;
string description = 7;
int32 strength = 6;
int32 dexterity = 7;
int32 stamina = 8;
int32 strength = 8;
int32 dexterity = 9;
int32 stamina = 10;
int32 intelligence = 9;
int32 wits = 10;
int32 resolve = 11;
int32 intelligence = 11;
int32 wits = 12;
int32 resolve = 13;
int32 presence = 12;
int32 manipulation = 13;
int32 composure = 14;
int32 presence = 14;
int32 manipulation = 15;
int32 composure = 16;
map<string, Skill> physical_skills = 16;
map<string, Skill> mental_skills = 17;
map<string, Skill> social_skills = 18;
map<string, Skill> physical_skills = 17;
map<string, Skill> mental_skills = 18;
map<string, Skill> social_skills = 19;
repeated Merit merits = 15;
repeated Condition conditions = 19;
repeated Merit merits = 20;
repeated Condition conditions = 21;
int32 size = 20;
int32 health = 21;
int32 willpower = 22;
int32 experience_points = 23;
int32 beats = 24;
int32 size = 22;
int32 health = 23;
int32 willpower = 24;
int32 experience_points = 25;
int32 beats = 26;
repeated Item items = 25;
repeated Attack attacks = 26;
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;
}
map<string, string> other_data = 27;
}
message ChangelingSheet {
CofdSheet base = 1;
}

View File

@ -3,84 +3,48 @@ import "cofd.proto";
package models.proto.cofd.api;
message CharacterIdentifier {
string owner = 1;
int32 id = 2;
}
//Update basic information about a Chronicles of Darkness (or
//derivative system) character sheet. This is a straight overwrite of
//all basic information on the sheet.
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 {
message BasicInfo {
string name = 1;
repeated string specializations = 2;
string gender = 2;
string concept = 3;
string chronicle = 4;
int32 age = 5;
}
//Update all merits on the character sheet by overwriting them.
//Primarily for the web UI.
message UpdateMeritsRequest {
CharacterIdentifier character = 1;
repeated CofdSheet.Merit merits = 2;
//Update all attributes in a Chronicles of Darkness character (or
//derivative system) character sheet. This is a straight overwrite of
//all basic information on the sheet.
message Attributes {
int32 strength = 1;
int32 dexterity = 2;
int32 stamina = 3;
int32 intelligence = 4;
int32 wits = 5;
int32 resolve = 6;
int32 presence = 7;
int32 manipulation = 8;
int32 composure = 9;
}
//Update all items on the character sheet by overwriting them.
//Primarily for the web UI.
message UpdateItemsRequest {
CharacterIdentifier character = 1;
repeated CofdSheet.Item items = 2;
message Attribute {
string name = 1;
int32 value = 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.
message AddConditionRequest {
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;
message Condition {
string name = 1;
}

View File

@ -1,165 +0,0 @@
{
"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
]
}
}
}

210
src/db.rs
View File

@ -1,157 +1,121 @@
use crate::models::characters::{Character, NewCharacter, StrippedCharacter};
use crate::models::users::{NewUser, User};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use sqlx::ConnectOptions;
use std::str::FromStr;
use crate::schema::characters;
use diesel::prelude::*;
use rocket_contrib::databases::diesel;
/// Type alias for the Rocket-managed singleton database connection.
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())
}
#[database("tenebrous_db")]
pub(crate) struct TenebrousDbConn(SqliteConnection);
#[rocket::async_trait]
pub(crate) trait Dao {
async fn load_user_by_id(&self, id: i32) -> sqlx::Result<Option<User>>;
async fn load_user_by_id(&self, id: i32) -> QueryResult<Option<User>>;
async fn load_user(&self, for_username: &str) -> sqlx::Result<Option<User>>;
async fn load_user(&self, for_username: String) -> QueryResult<Option<User>>;
async fn insert_user(&self, new_user: NewUser<'_>) -> sqlx::Result<User>;
async fn insert_user(&self, new_user: NewUser) -> QueryResult<User>;
async fn load_character_list(&self, for_user_id: i32) -> sqlx::Result<Vec<StrippedCharacter>>;
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>>;
async fn load_character(&self, character_id: i32) -> QueryResult<Option<Character>>;
async fn insert_character(&self, new_character: NewCharacter<'_>) -> sqlx::Result<()>;
async fn insert_character(&self, new_character: NewCharacter) -> QueryResult<()>;
async fn update_character<'a>(&self, character: &'a Character) -> sqlx::Result<()>;
async fn update_character_sheet<'a>(&self, character: &'a Character) -> sqlx::Result<()>;
async fn update_character_sheet(&self, character: Character) -> QueryResult<()>;
}
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]
impl Dao for SqlitePool {
async fn load_user_by_id(&self, user_id: i32) -> sqlx::Result<Option<User>> {
sqlx::query_as!(
User,
r#"SELECT id as "id: _", username, password FROM users WHERE id = ?"#,
user_id
)
.fetch_optional(self)
.await
}
async fn load_user(&self, for_username: &str) -> sqlx::Result<Option<User>> {
sqlx::query_as!(
User,
r#"SELECT id as "id: _", username, password FROM users WHERE username = ?"#,
for_username
)
.fetch_optional(self)
.await
}
async fn insert_user(&self, new_user: NewUser<'_>) -> sqlx::Result<User> {
sqlx::query("INSERT INTO users (username, password) values (?, ?)")
.bind(new_user.username)
.bind(new_user.password)
.execute(self)
.await?;
self.load_user(new_user.username)
impl Dao for TenebrousDbConn {
async fn load_user_by_id(&self, user_id: i32) -> QueryResult<Option<User>> {
use crate::schema::users::dsl::*;
self.run(move |conn| users.filter(id.eq(user_id)).first(conn).optional())
.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)
async fn load_user(&self, for_username: String) -> QueryResult<Option<User>> {
use crate::schema::users::dsl::*;
self.run(move |conn| {
users
.filter(username.eq(for_username))
.first(conn)
.optional()
})
.await
}
async fn load_character(&self, character_id: i32) -> sqlx::Result<Option<Character>> {
sqlx::query_as!(
Character,
r#"SELECT id as "id: _",
user_id as "user_id: _",
viewable, character_name, data,
data_type as "data_type: _",
data_version as "data_version: _"
FROM characters WHERE id = ?"#,
character_id
)
.fetch_optional(self)
async fn insert_user(&self, new_user: NewUser) -> QueryResult<User> {
self.run(move |conn| {
diesel::insert_into(users).values(&new_user).execute(conn)?;
use crate::schema::users::dsl::*;
users.filter(username.eq(new_user.username)).first(conn)
})
.await
}
async fn insert_character(&self, new_character: NewCharacter<'_>) -> sqlx::Result<()> {
sqlx::query(
"INSERT INTO characters
(user_id, viewable, character_name, data_type, data_version, data)
values (?, ?, ?, ?, ?, ?)",
)
.bind(new_character.user_id)
.bind(new_character.viewable)
.bind(new_character.character_name)
.bind(new_character.data_type)
.bind(new_character.data_version)
.bind(new_character.data)
.execute(self)
async fn load_character_list(&self, for_user_id: i32) -> QueryResult<Vec<StrippedCharacter>> {
use crate::schema::characters::dsl::*;
self.run(move |conn| {
characters
.filter(user_id.eq(for_user_id))
.select(STRIPPED_CHARACTER_COLUMNS)
.load(conn)
})
.await
}
async fn load_character(&self, character_id: i32) -> QueryResult<Option<Character>> {
use crate::schema::characters::dsl::*;
self.run(move |conn| {
characters
.filter(id.eq(character_id))
.first(conn)
.optional()
})
.await
}
async fn insert_character(&self, new_character: NewCharacter) -> QueryResult<()> {
self.run(|conn| {
diesel::insert_into(characters::table)
.values(new_character)
.execute(conn)
})
.await?;
Ok(())
}
async fn update_character<'a>(&self, character: &'a Character) -> sqlx::Result<()> {
sqlx::query(
"UPDATE characters
set user_id = ?, viewable = ?, character_name = ?,
data_type = ?, data_version = ?, data = ? where id = ?",
)
.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)
async fn update_character_sheet(&self, character: Character) -> QueryResult<()> {
use crate::schema::characters::dsl::*;
self.run(move |conn| {
diesel::update(&character)
.set(data.eq(&character.data))
.execute(conn)
})
.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?;
Ok(())
}
}

View File

@ -4,7 +4,6 @@ use rocket::response::status;
use rocket::response::{self, Responder};
use rocket_contrib::templates::Template;
use std::collections::HashMap;
use std::convert::Into;
use thiserror::Error;
#[derive(Error, Debug)]
@ -21,8 +20,8 @@ pub enum Error {
#[error("invalid input")]
InvalidInput,
#[error("validation error: {0}")]
ValidationError(#[from] crate::models::convert::ValidationError),
#[error("query error: {0}")]
QueryError(#[from] diesel::result::Error),
#[error("serialization error: {0}")]
SerializationError(#[from] prost::EncodeError),
@ -32,12 +31,6 @@ pub enum Error {
#[error("i/o error: {0}")]
IoError(#[from] std::io::Error),
#[error("query error: {0}")]
QueryError(#[from] sqlx::Error),
#[error("rocket error: {0}")]
RocketError(#[from] rocket::error::Error),
}
impl Error {
@ -49,28 +42,6 @@ impl Error {
_ => 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]

View File

@ -1,322 +0,0 @@
// 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

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

View File

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

View File

@ -1,373 +0,0 @@
// 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

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

View File

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

View File

@ -1,40 +0,0 @@
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

@ -1,49 +0,0 @@
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

@ -1,215 +0,0 @@
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

@ -1,285 +0,0 @@
{% 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

@ -1,29 +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 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,267 +0,0 @@
{% 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

@ -1,20 +0,0 @@
{% 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

@ -4,21 +4,26 @@ extern crate rocket;
#[macro_use]
extern crate rocket_contrib;
use log::{error, info};
#[macro_use]
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::templates::Template;
use std::env;
use tonic::transport::Server;
pub mod catchers;
pub mod db;
pub mod errors;
pub mod migrator;
pub mod models;
pub mod routes;
pub mod schema;
async fn make_rocket(database: sqlx::SqlitePool) -> Result<(), Box<dyn std::error::Error>> {
info!("Running Rocket");
#[rocket::main]
async fn main() -> Result<(), rocket::error::Error> {
let root_routes: Vec<rocket::Route> = {
routes::root::routes()
.into_iter()
@ -32,13 +37,13 @@ async fn make_rocket(database: sqlx::SqlitePool) -> Result<(), Box<dyn std::erro
rocket::ignite()
.attach(Template::fairing())
.manage(database)
.attach(db::TenebrousDbConn::fairing())
.mount("/", root_routes)
.mount("/characters", character_routes)
.mount("/api", api_routes)
.mount(
"/scripts",
StaticFiles::from(concat!(env!("CARGO_MANIFEST_DIR"), "/generated/scripts")),
StaticFiles::from(concat!(env!("CARGO_MANIFEST_DIR"), "/static/scripts")),
)
.mount(
"/protos",
@ -46,25 +51,5 @@ async fn make_rocket(database: sqlx::SqlitePool) -> Result<(), Box<dyn std::erro
)
.register(catchers)
.launch()
.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
.await
}

View File

@ -1,17 +0,0 @@
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
}

View File

@ -1,22 +0,0 @@
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

@ -1,15 +0,0 @@
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

@ -1,20 +0,0 @@
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

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

View File

@ -1,8 +1,10 @@
use crate::errors::Error;
use crate::models::proto::cofd::*;
use crate::models::users::User;
use crate::schema::characters;
use diesel_derive_enum::DbEnum;
use prost::bytes::BytesMut;
use serde::Serialize;
use serde_derive::Serialize;
use strum::{EnumIter, EnumString};
/// Dynamic character data is an opaque container type that holds
@ -39,34 +41,26 @@ pub(crate) trait Visibility {
}
}
/// 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")]
#[derive(DbEnum, Debug, Serialize, PartialEq, Clone, Copy, EnumIter, EnumString)]
pub enum CharacterDataType {
/// A character for the core Chronicles of Darkness rules.
ChroniclesOfDarkness,
/// A character for Changeling 2E rules.
Changeling,
ChroniclesOfDarknessV1,
ChangelingV1,
}
impl CharacterDataType {
/// 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> {
pub fn create_data(&self) -> Result<BytesMut, Error> {
use prost::Message;
use CharacterDataType::*;
let data: BytesMut = match self {
ChroniclesOfDarkness | Changeling => {
let sheet = CofdSheet::default_sheet(*self);
ChroniclesOfDarknessV1 => {
let sheet = CofdSheet::default();
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));
sheet.encode(&mut buf)?;
buf
@ -75,20 +69,11 @@ impl CharacterDataType {
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
/// in order of table columns.
#[derive(Serialize, Debug, sqlx::FromRow)]
#[derive(Serialize, Debug, Queryable, Identifiable, AsChangeset)]
pub struct Character {
pub id: i32,
pub user_id: i32,
@ -127,8 +112,8 @@ impl Character {
pub fn dyn_deserialize(&self) -> Result<Box<DynCharacterData>, Error> {
use CharacterDataType::*;
let decoded: Box<dyn erased_serde::Serialize> = match self.data_type {
ChroniclesOfDarkness => Box::new(self.try_deserialize::<CofdSheet>()?),
Changeling => Box::new(self.try_deserialize::<CofdSheet>()?),
ChroniclesOfDarknessV1 => Box::new(self.try_deserialize::<CofdSheet>()?),
ChangelingV1 => Box::new(self.try_deserialize::<ChangelingSheet>()?),
};
Ok(decoded)
@ -146,8 +131,8 @@ impl Character {
}
/// Update the existing character with new serialized protobuf
/// data.
pub fn update_data<T>(&mut self, data: &T) -> Result<(), Error>
/// data. Consumes the data.
pub fn update_data<T>(&mut self, data: T) -> Result<(), Error>
where
T: prost::Message + std::default::Default,
{
@ -160,7 +145,7 @@ impl Character {
/// Same as regular character type, but without the actual protobuf
/// data loaded into memory.
#[derive(Serialize, Debug, sqlx::FromRow)]
#[derive(Serialize, Debug, Queryable)]
pub struct StrippedCharacter {
pub id: i32,
pub user_id: i32,
@ -182,11 +167,13 @@ impl Visibility for StrippedCharacter {
/// Represents insert of a new character into the database. Property
/// names correspond to columns.
pub struct NewCharacter<'a> {
#[derive(Insertable)]
#[table_name = "characters"]
pub struct NewCharacter {
pub user_id: i32,
pub viewable: bool,
pub character_name: &'a str,
pub character_name: String,
pub data_type: CharacterDataType,
pub data_version: i32,
pub data: &'a [u8],
pub data: Vec<u8>,
}

View File

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

View File

@ -1,21 +1,36 @@
use crate::errors::Error;
use prost::bytes::BytesMut;
use rocket::data::{Data, FromData, Outcome, ToByteUnit};
use rocket::http::{ContentType, Status};
use rocket::request::Request;
use rocket::response::status;
use rocket::response::{self, Responder, Response};
use std::default::Default;
use std::io::Cursor;
use std::ops::{Deref, DerefMut};
use std::ops::Deref;
pub mod cofd;
/// Contains the generated Chronicles of Darkness-related protocol
/// 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
/// data submitted via POST using fetch API. Can automatically be
/// dereferenced into its wrapped type.
#[derive(Debug)]
pub(crate) struct Proto<T>(pub T)
pub(crate) struct Proto<T>(T)
where
T: prost::Message + Default;
@ -28,17 +43,8 @@ where
{
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;
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 {
Ok(read_bytes) => read_bytes,
@ -52,23 +58,6 @@ 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>
where
T: prost::Message + Default,
@ -79,12 +68,3 @@ where
&self.0
}
}
impl<T> DerefMut for Proto<T>
where
T: prost::Message + Default,
{
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}

View File

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

View File

@ -1,43 +1,115 @@
use crate::db::{Dao, TenebrousDbConn};
use crate::errors::Error;
use crate::models::characters::Character;
use crate::models::characters::{Character, CharacterDataType, DynCharacterData, Visibility};
use crate::models::proto::{cofd::*, Proto};
use crate::models::users::User;
mod cofd;
use rocket_contrib::templates::Template;
use serde::Serialize;
use std::collections::HashMap;
pub(crate) fn routes() -> Vec<rocket::Route> {
routes![
cofd::update_basic_info,
cofd::update_attribute_value,
cofd::update_skill,
cofd::update_skill_value,
cofd::update_merits,
cofd::update_items,
cofd::update_attributes,
cofd::update_attribute,
cofd::update_skills,
cofd::add_condition,
cofd::remove_condition
]
}
/// Load the character belonging to the given user, as long as they're
/// the owner of that character. Returns an error if user is not
/// logged in, the owner of the character is not found, or the logged
/// in user does not have the permission to access this character.
async fn load_character(
conn: &TenebrousDbConn<'_>,
logged_in_user: Option<&User>,
owner: &str,
character_id: i32,
) -> Result<Character, Error> {
let logged_in_user = logged_in_user.ok_or(Error::NotLoggedIn)?;
/// Protobuf-based REST endpoints for editing a character.
mod cofd {
use super::*;
use crate::models::proto::{cofd::api::*, cofd::*, Proto};
let character: Character = conn
.load_character(character_id)
.await?
.ok_or(Error::NotFound)?;
if &logged_in_user.username != owner {
return Err(Error::NoPermission);
#[post("/cofd/<owner>/<character_id>/basic-info", data = "<info>")]
pub(super) fn update_basic_info<'a>(
owner: String,
character_id: i32,
info: Proto<BasicInfo>,
) -> &'a str {
"lol"
}
Ok(character)
#[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>,
) -> Result<&'a str, Error> {
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
.load_character(character_id)
.await?
.ok_or(Error::NotFound)?;
if logged_in_user != &owner {
return Err(Error::NoPermission);
}
let mut sheet: CofdSheet = character.try_deserialize()?;
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"
}
}

View File

@ -1,222 +0,0 @@
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(
cookies: &CookieJar<'_>,
login: Form<Login>,
conn: TenebrousDbConn<'_>,
conn: TenebrousDbConn,
) -> Result<Redirect, Flash<Redirect>> {
let user = conn
.load_user(&login.username)
.load_user(login.username.clone())
.await
.map_err(|e| {
error!("login - error loading user user: {}", e);
@ -104,12 +104,15 @@ fn register_page(flash: Option<FlashMessage>) -> Template {
async fn register(
mut cookies: &CookieJar<'_>,
registration: Form<Registration>,
conn: TenebrousDbConn<'_>,
conn: TenebrousDbConn,
) -> Result<Redirect, Flash<Redirect>> {
let existing_user = conn.load_user(&registration.username).await.map_err(|e| {
error!("registration - error loading existing user: {}", e);
registration_error_redirect("There was an error attempting to register.")
})?;
let existing_user = conn
.load_user(registration.username.clone())
.await
.map_err(|e| {
error!("registration - error loading existing user: {}", e);
registration_error_redirect("There was an error attempting to register.")
})?;
if existing_user.is_some() {
return Err(registration_error_redirect(format!(
@ -124,8 +127,8 @@ async fn register(
})?;
let user = NewUser {
username: &registration.username,
password: &hashed_pw,
username: registration.username.clone(),
password: hashed_pw,
};
let user = conn.insert_user(user).await.map_err(|e| {

View File

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

View File

@ -10,7 +10,6 @@ use strum::IntoEnumIterator;
struct EditCharacterContext<'a> {
pub name: &'a str,
pub username: &'a str,
pub id: i32,
pub data_type: &'a CharacterDataType,
pub sheet: Box<DynCharacterData>,
pub state: FormStateContext<'a>,
@ -21,37 +20,14 @@ struct FormStateContext<'a> {
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")]
pub(super) async fn edit_character_page(
character_id: i32,
owner: String,
logged_in_user: Option<&User>,
conn: TenebrousDbConn<'_>,
conn: TenebrousDbConn,
) -> 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 character = conn
@ -63,6 +39,15 @@ pub(super) async fn edit_character_page(
return Err(Error::NoPermission);
}
let template = edit_character_template(logged_in_user, character)?;
Ok(template)
let context = EditCharacterContext {
name: &character.character_name,
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,7 +7,6 @@ use crate::models::{
};
use rocket::{request::Form, response::Redirect};
use rocket_contrib::templates::Template;
use serde::Serialize;
use strum::IntoEnumIterator;
/// Form submission for creating a new character.
@ -33,7 +32,7 @@ impl NewCharacterContext {
NewCharacterContext {
name: "".to_string(),
error_message: "".to_string(),
selected_system: CharacterDataType::ChroniclesOfDarkness,
selected_system: CharacterDataType::ChroniclesOfDarknessV1,
systems: CharacterDataType::iter().collect(),
}
}
@ -44,7 +43,7 @@ impl NewCharacterContext {
let system: CharacterDataType = *form
.system
.as_ref()
.unwrap_or(&CharacterDataType::ChroniclesOfDarkness);
.unwrap_or(&CharacterDataType::ChroniclesOfDarknessV1);
NewCharacterContext {
name: form.name.clone(),
@ -60,18 +59,18 @@ impl NewCharacterContext {
async fn create_new_character(
form: &Form<NewCharacterForm>,
user_id: i32,
conn: TenebrousDbConn<'_>,
conn: TenebrousDbConn,
) -> Result<(), Error> {
let system: CharacterDataType = *form.system.as_ref().map_err(|_| Error::InvalidInput)?;
let sheet: Vec<u8> = system.default_serialized_data()?.to_vec();
let system: CharacterDataType = *form.system.as_ref().unwrap();
let sheet: Vec<u8> = system.create_data()?.to_vec();
let insert = NewCharacter {
user_id: user_id,
viewable: true,
character_name: &form.name,
character_name: form.name.clone(),
data_type: system,
data_version: 1,
data: &sheet,
data: sheet,
};
conn.insert_character(insert).await?;
@ -98,7 +97,7 @@ pub(super) fn new_character_page(_logged_in_user: &User) -> Result<Template, Err
pub(super) async fn new_character_submit(
form: Form<NewCharacterForm>,
logged_in_user: &User,
conn: TenebrousDbConn<'_>,
conn: TenebrousDbConn,
) -> Result<Redirect, Template> {
if let Err(e) = &form.system {
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 rocket::response::Redirect;
use rocket_contrib::templates::Template;
use serde::Serialize;
use serde_derive::Serialize;
pub fn routes() -> Vec<rocket::Route> {
routes![index, user_index, proto_test]
@ -18,7 +18,7 @@ pub struct UserHomeContext<'a> {
}
#[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
.load_character_list(user.id)
.await?

30
src/schema.rs Normal file
View File

@ -0,0 +1,30 @@
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,
);

30
static/scripts/api.js Normal file
View File

@ -0,0 +1,30 @@
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

@ -0,0 +1,29 @@
(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

@ -0,0 +1,17 @@
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

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

View File

@ -0,0 +1,76 @@
{% 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

@ -0,0 +1,6 @@
{% 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,6 +1,8 @@
{% extends "base" %}
{% 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>
New character page.

View File

@ -0,0 +1,11 @@
{% 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

@ -0,0 +1,15 @@
{% 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 %}

View File

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

View File

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

View File

@ -1,69 +0,0 @@
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'),
]
};