Compare commits

..

2 Commits

Author SHA1 Message Date
jeff 4bff55cc6b Implement editing of attributes. 2020-12-27 21:49:08 +00:00
jeff c0a48245b1 Start of adding protobuf-based editable character sheet. 2020-12-27 21:03:10 +00:00
19 changed files with 500 additions and 35 deletions

View File

@ -3,6 +3,9 @@ fn main() {
config.type_attribute(".", "#[derive(Serialize)]");
config.type_attribute(".", "#[serde(rename_all = \"camelCase\")]");
config
.compile_protos(&["proto/cofd.proto"], &["src/", "proto/"])
.compile_protos(
&["proto/cofd.proto", "proto/cofd_api.proto"],
&["src/", "proto/"],
)
.unwrap();
}

50
proto/cofd_api.proto Normal file
View File

@ -0,0 +1,50 @@
syntax = "proto3";
import "cofd.proto";
package models.proto.cofd.api;
//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 BasicInfo {
string name = 1;
string gender = 2;
string concept = 3;
string chronicle = 4;
int32 age = 5;
}
//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;
}
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 Condition {
string name = 1;
}

View File

@ -13,16 +13,15 @@ pub(crate) trait Dao {
async fn load_user(&self, for_username: String) -> QueryResult<Option<User>>;
//async fn insert_user<'a>(&self, new_user: &'a NewUser<'a>) -> QueryResult<User>;
async fn insert_user(&self, new_user: NewUser) -> QueryResult<User>;
async fn load_character_list(&self, for_user_id: i32) -> QueryResult<Vec<StrippedCharacter>>;
async fn load_character(&self, character_id: i32) -> QueryResult<Option<Character>>;
//async fn insert_character<'a>(&self, new_character: NewCharacter<'a>) -> QueryResult<()>;
async fn insert_character(&self, new_character: NewCharacter) -> QueryResult<()>;
async fn update_character_sheet(&self, character: Character) -> QueryResult<()>;
}
type StrippedCharacterColumns = (
@ -107,4 +106,16 @@ impl Dao for TenebrousDbConn {
Ok(())
}
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(())
}
}

View File

@ -17,6 +17,9 @@ pub enum Error {
#[error("you do not have permission to access this")]
NoPermission,
#[error("invalid input")]
InvalidInput,
#[error("query error: {0}")]
QueryError(#[from] diesel::result::Error),
@ -25,6 +28,9 @@ pub enum Error {
#[error("deserialization error: {0}")]
DeserializationError(#[from] prost::DecodeError),
#[error("i/o error: {0}")]
IoError(#[from] std::io::Error),
}
impl Error {
@ -32,6 +38,7 @@ impl Error {
use Error::*;
match self {
QueryError(_) => true,
IoError(_) => true,
_ => false,
}
}

View File

@ -31,6 +31,7 @@ async fn main() -> Result<(), rocket::error::Error> {
.collect()
};
let api_routes = routes::api::routes();
let character_routes = routes::characters::routes();
let catchers = catchers::catchers();
@ -39,6 +40,11 @@ async fn main() -> Result<(), rocket::error::Error> {
.attach(db::TenebrousDbConn::fairing())
.mount("/", root_routes)
.mount("/characters", character_routes)
.mount("/api", api_routes)
.mount(
"/scripts",
StaticFiles::from(concat!(env!("CARGO_MANIFEST_DIR"), "/static/scripts")),
)
.mount(
"/protos",
StaticFiles::from(concat!(env!("CARGO_MANIFEST_DIR"), "/proto")),

View File

@ -73,7 +73,7 @@ impl CharacterDataType {
/// An entry that appears in a user's character list. Properties are
/// in order of table columns.
#[derive(Serialize, Debug, Queryable)]
#[derive(Serialize, Debug, Queryable, Identifiable, AsChangeset)]
pub struct Character {
pub id: i32,
pub user_id: i32,
@ -129,8 +129,22 @@ impl Character {
// fields.
Ok(self)
}
/// Update the existing character with new serialized protobuf
/// data. Consumes the data.
pub fn update_data<T>(&mut self, data: T) -> Result<(), Error>
where
T: prost::Message + std::default::Default,
{
let mut buf = BytesMut::with_capacity(std::mem::size_of_val(&data));
data.encode(&mut buf)?;
self.data = buf.to_vec();
Ok(())
}
}
/// Same as regular character type, but without the actual protobuf
/// data loaded into memory.
#[derive(Serialize, Debug, Queryable)]
pub struct StrippedCharacter {
pub id: i32,

View File

@ -1,8 +1,20 @@
use crate::errors::Error;
use rocket::data::{Data, FromData, Outcome, ToByteUnit};
use rocket::request::Request;
use std::default::Default;
use std::ops::Deref;
/// 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;
}
@ -13,3 +25,46 @@ pub mod cofd {
}
}
}
/// 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>(T)
where
T: prost::Message + Default;
/// Converts the body of a POST request containing encoded protobuf
/// data into the wrapped type.
#[rocket::async_trait]
impl<T> FromData for Proto<T>
where
T: prost::Message + Default,
{
type Error = crate::errors::Error;
async fn from_data(_req: &Request<'_>, data: Data) -> Outcome<Self, Error> {
use rocket::http::Status;
let bytes: Vec<u8> = match data.open(2.mebibytes()).stream_to_vec().await {
Ok(read_bytes) => read_bytes,
Err(e) => return Outcome::Failure((Status::new(422, "invalid protobuf"), e.into())),
};
match T::decode(bytes.as_ref()) {
Ok(decoded) => Outcome::Success(Proto(decoded)),
Err(e) => Outcome::Failure((Status::new(422, "invalid protobuf"), e.into())),
}
}
}
impl<T> Deref for Proto<T>
where
T: prost::Message + Default,
{
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}

View File

@ -1,3 +1,4 @@
pub mod api;
pub mod auth;
pub mod characters;
pub mod common;

115
src/routes/api.rs Normal file
View File

@ -0,0 +1,115 @@
use crate::db::{Dao, TenebrousDbConn};
use crate::errors::Error;
use crate::models::characters::{Character, CharacterDataType, DynCharacterData, Visibility};
use crate::models::proto::{cofd::*, Proto};
use crate::models::users::User;
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_attributes,
cofd::update_attribute,
cofd::update_skills,
cofd::add_condition,
cofd::remove_condition
]
}
/// Protobuf-based REST endpoints for editing a character.
mod cofd {
use super::*;
use crate::models::proto::{cofd::api::*, cofd::*, Proto};
#[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"
}
#[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

@ -3,8 +3,8 @@ use crate::errors::Error;
use crate::models::characters::{Character, CharacterDataType, DynCharacterData, Visibility};
use crate::models::users::User;
use rocket_contrib::templates::Template;
use std::collections::HashMap;
mod edit;
mod new;
pub(crate) fn routes() -> Vec<rocket::Route> {
@ -13,12 +13,13 @@ pub(crate) fn routes() -> Vec<rocket::Route> {
new::new_character_page,
new::new_character_submit,
new::new_character_not_logged_in,
edit_character
edit::edit_character_page
]
}
#[derive(Serialize)]
struct ViewCharacterTemplate<'a> {
struct ViewCharacterContext<'a> {
pub id: i32,
pub name: &'a str,
pub username: &'a str,
pub data_type: &'a CharacterDataType,
@ -28,7 +29,8 @@ struct ViewCharacterTemplate<'a> {
fn view_character_template(user: &User, character: Character) -> Result<Template, Error> {
let character = character.uprade()?;
let context = ViewCharacterTemplate {
let context = ViewCharacterContext {
id: character.id,
name: &character.character_name,
username: &user.username,
data_type: &character.data_type,
@ -62,28 +64,3 @@ async fn view_character(
let template = view_character_template(user, character)?;
Ok(template)
}
#[get("/<owner>/<character_id>/edit")]
async fn edit_character(
character_id: i32,
owner: String,
logged_in_user: Option<&User>,
conn: TenebrousDbConn,
) -> Result<Template, Error> {
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
.load_character(character_id)
.await?
.ok_or(Error::NotFound)?;
if logged_in_user != &owner {
return Err(Error::NoPermission);
}
let mut context = HashMap::new();
context.insert("name", character.character_name);
context.insert("username", owner.username);
Ok(Template::render("view_character", context))
}

View File

@ -0,0 +1,53 @@
use crate::db::{Dao, TenebrousDbConn};
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;
use strum::IntoEnumIterator;
#[derive(Serialize)]
struct EditCharacterContext<'a> {
pub name: &'a str,
pub username: &'a str,
pub data_type: &'a CharacterDataType,
pub sheet: Box<DynCharacterData>,
pub state: FormStateContext<'a>,
}
#[derive(Serialize)]
struct FormStateContext<'a> {
pub selected_system: &'a CharacterDataType,
}
#[get("/<owner>/<character_id>/edit")]
pub(super) async fn edit_character_page(
character_id: i32,
owner: String,
logged_in_user: Option<&User>,
conn: TenebrousDbConn,
) -> Result<Template, Error> {
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
.load_character(character_id)
.await?
.ok_or(Error::NotFound)?;
if logged_in_user != &owner {
return Err(Error::NoPermission);
}
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,7 @@ use rocket_contrib::templates::Template;
use serde_derive::Serialize;
pub fn routes() -> Vec<rocket::Route> {
routes![index, user_index]
routes![index, user_index, proto_test]
}
/// Information to display to the user on their home page.
@ -39,3 +39,12 @@ async fn user_index(user: &User, conn: TenebrousDbConn) -> Result<Template, Erro
fn index() -> Redirect {
super::common::redirect_to_login()
}
use crate::models::proto::{cofd::*, Proto};
#[post("/proto-test", data = "<buf>")]
async fn proto_test<'a>(buf: Proto<CofdSheet>) -> &'a str {
println!("buf is {:#?}", buf);
println!("str is: {}", buf.strength);
"lol"
}

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

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

@ -8,4 +8,8 @@
<p>System: {{data_type}}</h3>
<p>Strength: {{sheet.strength}}</p>
</div>
<div>
<a href="/characters/{{username}}/{{id}}/edit">Edit Character</a>
</div>
{% endblock content %}