Compare commits

...

29 Commits
1.7.4 ... 1.9.0

Author SHA1 Message Date
Stefan Zermatten
3fbb006783 Added Patreon notification badge for new patreon posts 2019-02-21 11:51:46 +02:00
Stefan Zermatten
2253672f43 Merge pull request #202 from mommothazaz123/master
Add multiple new API endpoints
2019-02-21 10:58:19 +02:00
Andrew Zhu
ed6d557f8a Merge branch 'master' into master 2019-02-12 13:51:20 -08:00
Andrew Zhu
4d642b56bb use direct insert, add schema check 2019-02-12 13:51:39 -08:00
Stefan Zermatten
436c5bb785 Fixed bug in view permission causing 500 errors for Avrae 2019-02-12 09:53:29 +02:00
Stefan Zermatten
8489ef5ec0 Fixed restoring characters not working correctly for sub documents 2019-02-11 13:04:14 +02:00
Stefan Zermatten
c9710bdb09 Fixed restored characters not belonging to the user restoring them 2019-02-11 12:10:04 +02:00
Stefan Zermatten
26784f11b6 Merge branch 'feature-backup-restore' 2019-02-11 12:01:52 +02:00
Stefan Zermatten
23d43f7d43 Added character restore functionality 2019-02-11 11:58:45 +02:00
Stefan Zermatten
1ebb0d2527 Got character copying working 2019-02-11 11:11:51 +02:00
Stefan Zermatten
9d86cb8bee Added the copy character method 2019-02-11 10:21:11 +02:00
Stefan Zermatten
3343f8a813 Allowed canViewCharacter to take in a character instead of a charId to save a database read 2019-02-11 10:17:43 +02:00
Stefan Zermatten
0260824c2f Made gave backup and restore the ability to change ids for all docs 2019-02-11 10:09:18 +02:00
Stefan Zermatten
66ee3ff808 Merge remote-tracking branch 'origin/master' into feature-backup-restore 2019-02-11 09:45:05 +02:00
Andrew Zhu
cb71f6d380 move everything to Meteor methods 2019-02-07 22:05:24 -08:00
Andrew Zhu
2f04d9ec1c remove server check overrides 2019-02-07 15:45:45 -08:00
Andrew Zhu
40c54524a7 add delete character endpoint 2019-02-05 15:46:06 -08:00
Andrew Zhu
b890a3b11e add feature, effect, prof, class insert 2019-02-05 15:21:32 -08:00
Andrew Zhu
c9242a95f3 add createCharacter, transferCharacter endpoints 2019-02-05 15:14:11 -08:00
Andrew Zhu
fedda62c7c add endpoint to add spells 2019-02-05 13:59:55 -08:00
Andrew Zhu
612575d0e6 add skeletons, ratelimits for endpoints 2019-02-05 13:14:09 -08:00
Andrew Zhu
d1d22c0d89 formatting, add helper func for POST endpoints 2019-02-05 13:09:56 -08:00
Andrew Zhu
b94f5ebb4b add getUserId API endpoint 2019-02-05 13:08:28 -08:00
Stefan Zermatten
3f32535666 fixed link to dicecloud repo 2019-01-31 10:11:32 +02:00
Stefan Zermatten
4ea02c4fbb Fixed hidden link to localhost 2019-01-31 10:10:56 +02:00
Stefan Zermatten
b052e8dd19 Update README.md 2019-01-31 10:09:19 +02:00
Stefan Zermatten
e2822b9f22 added naive backup restore 2019-01-28 11:35:56 +02:00
Stefan Zermatten
c46b836985 Merge pull request #192 from Frogvall/master
Updated heat metal according to SRD/PHB
2018-11-14 10:14:19 +02:00
Frogvall
65d1bac0dc Updated heat metal according to SRD/PHB 2018-11-14 06:40:22 +01:00
23 changed files with 1231 additions and 130 deletions

View File

@@ -1,13 +1,71 @@
RPG Docs
DiceCloud
========
This is the repo for [DiceCloud](dicecloud.com).
DiceCloud is a free, auditable, real-time character sheet for D&D 5e.
Philosophy
----------
Setting up your character on DiceCloud takes a little longer than
just filling it in on a paper character sheet would. The goal of using an
online sheet is to make actually playing the game more streamlined, and
ultimately more fun. So putting a little extra effort into setting up a
character now pays off over and over again once you're playing.
The idea is to track where each number comes from, and allow you to easily make
changes on the fly. Let's look at a hypothetical example.
> You need to swim through a sunken section of dungeon to fetch the quest's Thing.
> You'll need to take off your magical Plate Armor of +1 Constitution to swim
> without sinking, of course.
>
> Taking it off will take away that disadvantage on
> stealth checks, change your armor class, your speed and your constitution, and
> which in turn changes your hit points and your constitution saving throw.
> Working out all those changes in the middle of a game will drag the game to a
> halt.
>
> Fortunately you have DiceCloud, so it's a matter of dragging
> your Plate Armor +1 Con from your "equipment" box to your "backpack" box and
> you're done. Your hitpoints change correctly, your saving throws are up to date,
> your armor class goes back to reflecting the fact that you have natural armor
> from being a dragonborn. Your character sheet keeps up and you
> ultimately get more time to play the game. Huzzah!
Getting started
---------------
`git clone https://github.com/ThaumRystra/DiceCloud1 dicecloud`
Running DiceCloud locally, either to host it yourself away from an internet
connection, or to contribute to developing it further, is fairly
straightforward and it should work on Linux, Windows, and Mac.
You'll need to have installed:
- [git](https://www.atlassian.com/git/tutorials/install-git)
- [Meteor](https://www.meteor.com/install)
- [Bower](https://bower.io/)
Then, it's just a matter of cloning this repository into a folder, installing the bower dependencies and running
`meteor` in the app directory.
`git clone https://github.com/ThaumRystra/DiceCloud dicecloud`
`cd dicecloud`
`cd app`
`bower install`
`meteor`
You should see this:
```
=> Started proxy.
=> Started MongoDB.
=> Started your app.
=> App running at: http://localhost:3000/
```
Now, visiting http://localhost:3000/ should show you an empty instance of
DiceCloud running.

2
app/.gitignore vendored
View File

@@ -8,3 +8,5 @@ private/oldClient
nohup.out
node_modules
dump
.idea/
.cache

View File

@@ -0,0 +1,26 @@
PatreonPosts = new Mongo.Collection("patreonPosts");
Schemas.PatreonPosts = new SimpleSchema({
link: {
type: String,
},
dateAdded: {
type: Date,
autoValue(){
return new Date();
},
},
});
PatreonPosts.attachSchema(Schemas.PatreonPosts);
PatreonPosts.allow({
insert: function(userId, doc) {
var user = Meteor.users.findOne(userId);
if (user) return _.contains(user.roles, "admin");
},
remove: function(userId, doc) {
var user = Meteor.users.findOne(userId);
if (user) return _.contains(user.roles, "admin");
},
});

View File

@@ -70,6 +70,10 @@ Schemas.User = new SimpleSchema({
index: 1,
optional: true,
},
lastPatreonPostClicked: {
type: String,
optional: true,
},
});
Meteor.users.attachSchema(Schemas.User);
@@ -107,3 +111,11 @@ if (Meteor.isServer) Meteor.methods({
Meteor.users.update(this.userId, {$set: {apiKey}});
},
});
Meteor.methods({
clickPatreonPost(link) {
Meteor.users.update(this.userId, {$set: {
lastPatreonPostClicked: link
}});
},
});

View File

@@ -1,91 +1,268 @@
Router.map(function() {
this.route("vmixCharacter", {
path: "/vmix-character/:_id/",
where: "server",
action: function() {
this.response.setHeader("Content-Type", "application/json");
var query = this.params.query;
var key = query && query.key;
ifKeyValid(key, this.response, "vmixCharacter", () =>
this.response.end(vMixCharacter(this.params._id))
);
},
});
this.route("vmixParty", {
path: "/vmix-party/:_id/",
where: "server",
action: function() {
this.response.setHeader("Content-Type", "application/json");
var query = this.params.query;
var key = query && query.key;
ifKeyValid(key, this.response, "vmixParty", () =>
this.response.end(vMixParty(this.params._id))
);
},
});
Router.map(function () {
this.route("vmixCharacter", {
path: "/vmix-character/:_id/",
where: "server",
action: function () {
this.response.setHeader("Content-Type", "application/json");
var query = this.params.query;
var key = query && query.key;
ifKeyValid(key, this.response, "vmixCharacter", () =>
this.response.end(vMixCharacter(this.params._id))
);
},
});
this.route("vmixParty", {
path: "/vmix-party/:_id/",
where: "server",
action: function () {
this.response.setHeader("Content-Type", "application/json");
var query = this.params.query;
var key = query && query.key;
ifKeyValid(key, this.response, "vmixParty", () =>
this.response.end(vMixParty(this.params._id))
);
},
});
this.route("jsonCharacterSheet", {
path: "/character/:_id/json",
where: "server",
action: function() {
this.response.setHeader("Content-Type", "application/json");
var query = this.params.query;
var key = query && query.key;
ifKeyValid(key, this.response, "jsonCharacterSheet", () => {
if (canViewCharacter(this.params._id, userIdFromKey(key))){
this.response.end(JSONExport(this.params._id))
} else {
this.response.writeHead(403, "You do not have permission to view this character");
this.response.end();
}
}
);
},
});
this.route("jsonCharacterSheet", { // GET /character/:_id/json?key=:key
path: "/character/:_id/json",
where: "server",
action: function () {
this.response.setHeader("Content-Type", "application/json");
var query = this.params.query;
var key = query && query.key;
ifKeyValid(key, this.response, "jsonCharacterSheet", () => {
if (canViewCharacter(this.params._id, userIdFromKey(key))) {
this.response.end(JSONExport(this.params._id))
} else {
this.response.writeHead(403, "You do not have permission to view this character");
this.response.end();
}
}
);
},
});
this.route("getUserId", { // GET /api/user?username=:un&key=:key
path: "/api/user",
where: "server",
action: function () {
this.response.setHeader("Content-Type", "application/json");
var query = this.params.query;
var key = query && query.key;
var username = query && query.username;
ifKeyValid(key, this.response, "getUserId", () => {
Meteor.call("getUserId", username, (err, result) => {
if (err) {
console.error(err);
this.response.writeHead(404, "User not found");
this.response.end();
} else {
console.log(result);
this.response.end(JSON.stringify({id: result}));
}
});
});
}
});
this.route("addSpellsToCharacter", { // POST /api/character/:_id/spellList/:listId
path: "/api/character/:_id/spellList/:listId",
where: "server"
}).post(function () {
const key = startPOSTResponse(this);
const spells = this.request.body;
const charId = this.params._id;
const listId = this.params.listId;
Meteor.call("insertSpells", key, charId, listId, spells, (err, res) => {
if (err) {
this.response.writeHead(err.error, err.reason);
this.response.end(err.details);
} else {
this.response.end(JSON.stringify(res));
}
});
});
this.route("createCharacter", { // POST /api/character
path: "/api/character",
where: "server"
}).post(function () {
const key = startPOSTResponse(this);
const character = this.request.body;
Meteor.call("insertCharacter", key, character, (err, res) => {
if (err) {
this.response.writeHead(err.error, err.reason);
this.response.end(err.details);
} else {
this.response.end(JSON.stringify(res));
}
});
});
this.route("deleteCharacter", { // DELETE /api/character/:_id
path: "/api/character/:_id",
where: "server"
}).delete(function () {
const key = startPOSTResponse(this);
const charId = this.params._id;
Meteor.call("deleteCharacter", key, charId, (err, res) => {
if (err) {
this.response.writeHead(err.error, err.reason);
this.response.end(err.details);
} else {
this.response.end(JSON.stringify(res));
}
});
});
this.route("transferCharacterOwnership", { // PUT /api/character/:_id/owner
path: "/api/character/:_id/owner",
where: "server"
}).put(function () {
const key = startPOSTResponse(this);
const charId = this.params._id;
const ownerId = this.request.body['id'];
Meteor.call("transferCharacterOwnership", key, charId, ownerId, (err, res) => {
if (err) {
this.response.writeHead(err.error, err.reason);
this.response.end(err.details);
} else {
this.response.end(JSON.stringify(res));
}
});
});
this.route("insertFeatures", { // POST /api/character/:_id/feature
path: "/api/character/:_id/feature",
where: "server",
}).post(function () {
const key = startPOSTResponse(this);
const charId = this.params._id;
const features = this.request.body;
Meteor.call("insertFeatures", key, charId, features, (err, res) => {
if (err) {
this.response.writeHead(err.error, err.reason);
this.response.end(err.details);
} else {
this.response.end(JSON.stringify(res));
}
});
});
this.route("insertProfs", { // POST /api/character/:_id/prof
path: "/api/character/:_id/prof",
where: "server",
}).post(function () {
const key = startPOSTResponse(this);
const charId = this.params._id;
const profs = this.request.body;
Meteor.call("insertProfs", key, charId, profs, (err, res) => {
if (err) {
this.response.writeHead(err.error, err.reason);
this.response.end(err.details);
} else {
this.response.end(JSON.stringify(res));
}
});
});
this.route("insertEffects", { // POST /api/character/:_id/effect
path: "/api/character/:_id/effect",
where: "server",
}).post(function () {
const key = startPOSTResponse(this);
const charId = this.params._id;
const effects = this.request.body;
Meteor.call("insertEffects", key, charId, effects, (err, res) => {
if (err) {
this.response.writeHead(err.error, err.reason);
this.response.end(err.details);
} else {
this.response.end(JSON.stringify(res));
}
});
});
this.route("insertClasses", { // POST /api/character/:_id/class
path: "/api/character/:_id/class",
where: "server",
}).post(function () {
const key = startPOSTResponse(this);
const charId = this.params._id;
const classes = this.request.body;
Meteor.call("insertClasses", key, charId, classes, (err, res) => {
if (err) {
this.response.writeHead(err.error, err.reason);
this.response.end(err.details);
} else {
this.response.end(JSON.stringify(res));
}
});
});
});
var ifKeyValid = function(apiKey, response, method, callback){
if (!apiKey){
response.writeHead(403, "You must use an api key to access this api");
response.end();
} else if (!isKeyValid(apiKey)){
response.writeHead(403, "API key is invalid");
response.end();
} else if (isRateLimited(apiKey, method)){
response.writeHead(429, "Too many requests");
response.end(JSON.stringify({
"timeToReset": rateLimiter.check({apiKey: apiKey, method: method}).timeToReset
}));
} else {
rateLimiter.increment({apiKey: apiKey, method: method})
callback();
}
const startPOSTResponse = function (request) {
request.response.setHeader("Content-Type", "application/json");
const header = request.request.headers;
return header && header['authorization'];
};
var isKeyValid = function(apiKey){
var user = Meteor.users.findOne({apiKey});
if (!user) return false;
var blackListed = Blacklist.findOne({userId: user._id});
return !blackListed;
var ifKeyValid = function (apiKey, response, method, callback) {
if (!apiKey) {
response.writeHead(403, "You must use an api key to access this api");
response.end();
} else if (!isKeyValid(apiKey)) {
response.writeHead(403, "API key is invalid");
response.end();
} else if (isRateLimited(apiKey, method)) {
response.writeHead(429, "Too many requests");
response.end(JSON.stringify({
"timeToReset": rateLimiter.check({apiKey: apiKey, method: method}).timeToReset
}));
} else {
rateLimiter.increment({apiKey: apiKey, method: method});
callback();
}
};
var userIdFromKey = function(apiKey){
var user = Meteor.users.findOne({apiKey}); // we know user exists from isKeyValid
return user._id;
}
isKeyValid = function (apiKey) {
var user = Meteor.users.findOne({apiKey});
if (!user) return false;
var blackListed = Blacklist.findOne({userId: user._id});
return !blackListed;
};
var rateLimiter = new RateLimiter();
rateLimiter.addRule({apiKey: String}, 5, 5000);
userIdFromKey = function (apiKey) {
var user = Meteor.users.findOne({apiKey}); // we know user exists from isKeyValid
return user._id;
};
rateLimiter = new RateLimiter();
// global limit
rateLimiter.addRule({apiKey: String}, 10, 1000);
// vmix stuff
rateLimiter.addRule({apiKey: String, method: "vmixCharacter"}, 2, 10000);
rateLimiter.addRule({apiKey: String, method: "vmixParty"}, 2, 10000);
rateLimiter.addRule({apiKey: String, method: "jsonCharacterSheet"}, 5, 5000);
var isRateLimited = function(apiKey, method){
const limited = !rateLimiter.check({apiKey: apiKey, method: method}).allowed
if (limited) {
console.log(`Rate limit hit by API key ${apiKey}`);
return true;
} else {
return false;
}
// bot API endpoints
rateLimiter.addRule({apiKey: String, method: "jsonCharacterSheet"}, 5, 5000);
rateLimiter.addRule({apiKey: String, method: "getUserId"}, 5, 5000);
rateLimiter.addRule({apiKey: String, method: "addSpellsToCharacter"}, 5, 5000);
rateLimiter.addRule({apiKey: String, method: "createCharacter"}, 5, 5000);
rateLimiter.addRule({apiKey: String, method: "transferCharacterOwnership"}, 5, 5000);
rateLimiter.addRule({apiKey: String, method: "insertFeatures"}, 5, 5000);
rateLimiter.addRule({apiKey: String, method: "insertProfs"}, 5, 5000);
rateLimiter.addRule({apiKey: String, method: "insertEffects"}, 5, 5000);
rateLimiter.addRule({apiKey: String, method: "insertClasses"}, 5, 5000);
isRateLimited = function (apiKey, method) {
const limited = !rateLimiter.check({apiKey: apiKey, method: method}).allowed;
if (limited) {
console.log(`Rate limit hit by API key ${apiKey}`);
return true;
} else {
return false;
}
};

View File

@@ -31,9 +31,17 @@
Settings
</paper-icon-item>
<paper-icon-item id="characterExport">
<iron-icon icon="content-copy" item-icon></iron-icon>
<iron-icon icon="exit-to-app" item-icon></iron-icon>
Export to Improved Initiative
</paper-icon-item>
<paper-icon-item id="characterCopy">
<iron-icon icon="content-copy" item-icon></iron-icon>
Make a copy
</paper-icon-item>
<paper-icon-item id="characterDump">
<iron-icon icon="file-download" item-icon></iron-icon>
Download a backup
</paper-icon-item>
</paper-menu>
</paper-menu-button>
{{else}}

View File

@@ -234,6 +234,18 @@ Template.characterSheet.events({
element: event.currentTarget.parentElement.parentElement,
});
},
"click #characterCopy": function(event, instance){
Meteor.call("copyCharacter", this._id, (error, char) => {
if (error){
console.error(error);
} else {
Router.go(`/character/${char._id}/${char.urlName || "-"}`);
}
});
},
"click #characterDump": function(event, instance){
saveCharacterDump(this._id);
},
"click #unshareCharacter": function(event, instance){
pushDialogStack({
data: this,

View File

@@ -58,6 +58,13 @@
</paper-fab>
{{#simpleTooltip class="always"}} New Character {{/simpleTooltip}}
</div>
<div>
<paper-fab icon="file-upload"
class="restoreCharacter"
mini>
</paper-fab>
{{#simpleTooltip class="always"}} Restore from backup {{/simpleTooltip}}
</div>
{{/fabMenu}}
</div>
</app-header-layout>

View File

@@ -81,4 +81,13 @@ Template.characterList.events({
returnElement: instance.find(`.party[data-id='${partyId}']`),
});
},
"click .restoreCharacter": function(event, instance) {
pushDialogStack({
template: "characterRestoreDialog",
element: event.currentTarget,
callback(dump){
return;
},
})
},
});

View File

@@ -0,0 +1,34 @@
<template name="characterRestoreDialog">
<div class="fit layout vertical">
<app-header-layout has-scrolling-region class="new-character-dialog flex">
<app-header fixed effects="waterfall">
<app-toolbar>
<div main-title>Restore Character</div>
</app-toolbar>
</app-header>
<div class="form">
<p>
Restore a character from a backup file, this will create a new copy of
the restored character
</p>
<paper-input class="fileInput" type="file" label="File"></paper-input><br>
{{#if error}}
<p style="color: red;">
{{error}}
</p>
{{/if}}
{{#if loading}}
<paper-spinner active></paper-spinner>
{{/if}}
</div>
</app-header-layout>
<div class="buttons layout horizontal end-justified">
<paper-button class="cancelButton">
Cancel
</paper-button>
<paper-button class="addButton" disabled={{invalid}}>
Restore
</paper-button>
</div>
</div>
</template>

View File

@@ -0,0 +1,66 @@
Template.characterRestoreDialog.onCreated(function(){
this.dump = {};
this.valid = new ReactiveVar(false);
this.error = new ReactiveVar(null);
this.loading = new ReactiveVar(false);
});
Template.characterRestoreDialog.helpers({
invalid(){
return !Template.instance().valid.get();
},
error(){
return Template.instance().error.get();
},
loading(){
return Template.instance().loading.get();
},
});
const fail = function(instance){
instance.valid.set(false);
instance.error.set("Failed to convert file into a valid character");
instance.dump = undefined;
};
Template.characterRestoreDialog.events({
"input .fileInput": function(event, instance){
let input = event.currentTarget.$.input;
let reader = new FileReader();
reader.onload = function(){
let dumpString = reader.result;
try {
let dump = JSON.parse(dumpString);
if (dump && dump.character && dump.collections){
instance.valid.set(true);
instance.error.set(null);
instance.dump = dump;
} else {
fail(instance);
}
} catch (e) {
fail(instance);
}
};
reader.readAsText(input.files[0]);
},
"click .cancelButton": function(event, instance){
popDialogStack();
},
"click .addButton": function(event, instance){
let dump = instance.dump;
if (!dump) return;
Meteor.call('restoreCharacter', dump, (e, char) => {
instance.loading.set(false);
if (!char){
instance.error.set(e.message)
} else {
popDialogStack();
Router.go("characterSheet", {
_id: char._id,
urlName: char.urlName || '-',
});
}
});
},
});

View File

@@ -51,9 +51,16 @@
<iron-icon icon="bug-report" item-icon></iron-icon>
Send Feedback
</paper-icon-item>
<a class="patreon" href="https://www.patreon.com/dicecloud" target="_blank" tabindex="-1">
<a class="patreon" href="{{patreonLink}}" target="_blank" tabindex="-1">
<paper-icon-item>
<iron-icon icon="dicecloud:patreon" item-icon></iron-icon>
<iron-icon id="patreon-link-icon" icon="dicecloud:patreon" item-icon></iron-icon>
{{#if showPatreonBadge}}
<paper-badge
icon="av:new-releases"
for="patreon-link-icon"
label="New post">
</paper-badge>
{{/if}}
Patreon
</paper-icon-item>
</a>

View File

@@ -7,6 +7,16 @@ Template.appDrawer.helpers({
var user = Meteor.user();
return user.profile && user.profile.username || user.username || "My Account";
},
showPatreonBadge: function(){
let post = PatreonPosts.findOne({}, {sort: {date: -1}});
let user = Meteor.user();
if (!post || !user) return false;
return post.link !== user.lastPatreonPostClicked;
},
patreonLink: function(){
let post = PatreonPosts.findOne({}, {sort: {date: -1}});
return (post && post.link) || 'https://www.patreon.com/dicecloud';
},
});
let drawerLayout;
@@ -37,6 +47,9 @@ Template.appDrawer.events({
closeDrawer(instance);
},
"click .patreon": function(event, instance){
let post = PatreonPosts.findOne({}, {sort: {date: -1}});
let link = (post && post.link) || 'https://www.patreon.com/dicecloud';
Meteor.call('clickPatreonPost', link);
ga("send", "event", "externalLink", "patreon");
},
"click .github": function(event, instance){

View File

@@ -2,13 +2,13 @@
"polyfill": "/components/webcomponentsjs/webcomponents.min.js",
"useShadowDom": true,
"imports": [
"/components/app-layout/app-layout.html",
"/components/app-layout/app-layout.html",
"/components/app-layout/app-layout.html",
"/components/app-layout/app-layout.html",
"/components/app-layout/app-scroll-effects/effects/waterfall.html",
"/components/app-layout/app-scroll-effects/effects/parallax-background.html",
"/components/app-layout/app-scroll-effects/effects/resize-title.html",
"/components/iron-collapse/iron-collapse.html",
"/components/iron-collapse/iron-collapse.html",
"/components/iron-icon/iron-icon.html",
"/components/iron-icons/av-icons.html",
"/components/iron-icons/editor-icons.html",
@@ -21,7 +21,8 @@
"/components/neon-animation/neon-animation.html",
"/components/paper-button/paper-button.html",
"/components/paper-button/paper-button.html",
"/components/paper-badge/paper-badge.html",
"/components/paper-swatch-picker/paper-swatch-picker.html",
"/components/paper-dialog/paper-dialog.html",
"/components/paper-dropdown-menu/paper-dropdown-menu.html",

471
app/lib/functions/api.js Normal file
View File

@@ -0,0 +1,471 @@
/**
* @return {string}
*/
JSONExport = function (charId) {
const character = {
"attacks": Attacks.find({charId: charId}).fetch(),
"characters": Characters.find({_id: charId}).fetch(),
"classes": Classes.find({charId: charId}).fetch(),
"containers": Containers.find({charId: charId}).fetch(),
"effects": Effects.find({charId: charId}).fetch(),
"experience": Experiences.find({charId: charId}).fetch(),
"features": Features.find({charId: charId}).fetch(),
"items": Items.find({charId: charId}).fetch(),
"notes": Notes.find({charId: charId}).fetch(),
"proficiencies": Proficiencies.find({charId: charId}).fetch(),
"spellLists": SpellLists.find({charId: charId}).fetch(),
"spells": Spells.find({charId: charId}).fetch()
};
return JSON.stringify(character);
};
Meteor.methods({
"insertSpells": function (key, charId, listId, spells) {
if (Meteor.isClient) return;
assertCanEdit(key, charId, "addSpellsToCharacter");
let ids = [];
let error;
for (let spell of spells) {
spell.charId = charId;
try {
Schemas.Spell.clean(spell);
} catch (e) {
// console.log(e);
error = e.error;
}
if (!error) {
spell.parent = {id: listId, collection: "SpellLists"};
let id = Spells.direct.insert(spell, (err) => {
if (err) {
error = err.message;
}
});
// console.log(id);
ids.push(id);
} else {
break;
}
}
if (error) {
throw new Meteor.Error(400, "Failed to insert one or more spells", JSON.stringify({
err: error,
inserted: ids
}));
} else {
return ids;
}
},
"insertCharacter": function (key, character) {
if (Meteor.isClient) return;
assertAuthorized(key, "createCharacter");
let error, id;
character.owner = userIdFromKey(key);
try {
Schemas.Character.clean(character);
} catch (e) {
console.log(e);
error = e.error;
}
if (!error) {
id = Characters.direct.insert(character, (err) => {
if (err)
error = err.message;
});
afterCharacterInsert(id);
return {id: id};
} else {
throw new Meteor.Error(400, "Failed to insert character", JSON.stringify({err: error}));
}
},
"deleteCharacter": function (key, charId) {
if (Meteor.isClient) return;
assertAuthorized(key, "deleteCharacter");
if (isOwner(charId, userIdFromKey(key))) {
let error;
Characters.direct.remove({_id: charId}, (err) => {
if (err)
error = err.message;
});
if (error) {
throw new Meteor.Error(400, "Failed to delete character", JSON.stringify({err: error}));
} else {
return {success: true};
}
} else {
throw new Meteor.Error(403, "You do not have permission to delete the requested character");
}
},
"transferCharacterOwnership": function (key, charId, newOwner) {
if (Meteor.isClient) return;
assertAuthorized(key, "transferCharacterOwnership");
if (isOwner(charId, userIdFromKey(key))) {
let error;
Characters.direct.update({_id: charId}, {"$set": {owner: newOwner}}, null,
(err) => {
if (err)
error = err.message;
});
if (error) {
throw new Meteor.Error(400, "Failed to update character", JSON.stringify({err: error}));
} else {
return {success: true};
}
} else {
throw new Meteor.Error(403, "You do not have permission to transfer the requested character");
}
},
"insertFeatures": function (key, charId, features) {
if (Meteor.isClient) return;
assertCanEdit(key, charId, "insertFeatures");
let ids = [];
let error;
for (let feature of features) {
feature.charId = charId;
try {
Schemas.Feature.clean(feature);
} catch (e) {
error = e.error;
}
if (!error) {
let id = Features.direct.insert(feature, (err) => {
if (err) {
error = err.message;
}
});
ids.push(id);
} else {
break;
}
}
if (error) {
throw new Meteor.Error(400, "Failed to insert one or more features", JSON.stringify({
err: error,
inserted: ids
}));
} else {
return ids;
}
},
"insertProfs": function (key, charId, profs) {
if (Meteor.isClient) return;
assertCanEdit(key, charId, "insertProfs");
let ids = [];
let error;
for (let prof of profs) {
prof.charId = charId;
try {
Schemas.Proficiency.clean(prof, {filter: false});
} catch (e) {
error = e.error;
}
if (!error) {
let id = Proficiencies.direct.insert(prof, (err) => {
if (err) {
error = err.message;
}
});
ids.push(id);
} else {
break;
}
}
if (error) {
throw new Meteor.Error(400, "Failed to insert one or more profs", JSON.stringify({
err: error,
inserted: ids
}));
} else {
return ids;
}
},
"insertEffects": function (key, charId, effects) {
if (Meteor.isClient) return;
assertCanEdit(key, charId, "insertEffects");
let ids = [];
let error;
for (let effect of effects) {
effect.charId = charId;
try {
Schemas.Effect.clean(effect, {filter: false});
} catch (e) {
error = e.error;
}
if (!error) {
let id = Effects.direct.insert(effect, (err) => {
if (err) {
error = err.message;
}
});
ids.push(id);
} else {
break;
}
}
if (error) {
throw new Meteor.Error(400, "Failed to insert one or more effects", JSON.stringify({
err: error,
inserted: ids
}));
} else {
return ids;
}
},
"insertClasses": function (key, charId, klasses) {
if (Meteor.isClient) return;
assertCanEdit(key, charId, "insertClasses");
let ids = [];
let error;
for (let klass of klasses) {
klass.charId = charId;
try {
Schemas.Class.clean(klass);
} catch (e) {
error = e.error;
}
if (!error) {
let id = Classes.direct.insert(klass, (err) => {
if (err) {
error = err.message;
}
});
ids.push(id);
} else {
break;
}
}
if (error) {
throw new Meteor.Error(400, "Failed to insert one or more classes", JSON.stringify({
err: error,
inserted: ids
}));
} else {
return ids;
}
}
});
var assertCanEdit = function (key, charId, method) {
if (canEditCharacter(charId, userIdFromKey(key))) {
assertAuthorized(key, method);
} else {
throw new Meteor.Error(403, "You do not have permission to edit the requested character");
}
};
var assertAuthorized = function (apiKey, method) {
if (!apiKey) {
throw new Meteor.Error(403, "You must use an api key to access this api");
} else if (!isKeyValid(apiKey)) {
throw new Meteor.Error(403, "API key is invalid");
} else if (isRateLimited(apiKey, method)) {
throw new Meteor.Error(429, "Too many requests", JSON.stringify({
"timeToReset": rateLimiter.check({apiKey: apiKey, method: method}).timeToReset
}));
} else {
rateLimiter.increment({apiKey: apiKey, method: method});
}
};
var afterCharacterInsert = function (charId) {
// Effects
Effects.direct.insert({
charId: charId,
name: "Constitution modifier for each level",
stat: "hitPoints",
operation: "add",
calculation: "level * constitutionMod",
parent: {
id: charId,
collection: "Characters",
group: "Inate",
},
});
Effects.direct.insert({
charId: charId,
name: "Proficiency bonus by level",
stat: "proficiencyBonus",
operation: "add",
calculation: "floor(level / 4 + 1.75)",
parent: {
id: charId,
collection: "Characters",
group: "Inate",
},
});
Effects.direct.insert({
charId: charId,
name: "Dexterity Armor Bonus",
stat: "armor",
operation: "add",
calculation: "dexterityArmor",
parent: {
id: charId,
collection: "Characters",
group: "Inate",
},
});
Effects.direct.insert({
charId: charId,
name: "Natural Armor",
stat: "armor",
operation: "base",
value: 10,
parent: {
id: charId,
collection: "Characters",
group: "Inate",
},
});
Effects.direct.insert({
charId: charId,
name: "Natural Carrying Capacity",
stat: "carryMultiplier",
operation: "base",
value: "1",
parent: {
id: charId,
collection: "Characters",
group: "Inate",
},
});
// Features
let featureId = Features.direct.insert({
name: "Base Ability Scores",
charId: charId,
enabled: true,
alwaysEnabled: true,
});
Effects.direct.insert({
stat: "strength",
charId: charId,
parent: {
id: featureId,
collection: "Features",
},
operation: "base",
value: 10,
enabled: true,
});
Effects.direct.insert({
stat: "dexterity",
charId: charId,
parent: {
id: featureId,
collection: "Features",
},
operation: "base",
value: 10,
enabled: true,
});
Effects.direct.insert({
stat: "constitution",
charId: charId,
parent: {
id: featureId,
collection: "Features",
},
operation: "base",
value: 10,
enabled: true,
});
Effects.direct.insert({
stat: "intelligence",
charId: charId,
parent: {
id: featureId,
collection: "Features",
},
operation: "base",
value: 10,
enabled: true,
});
Effects.direct.insert({
stat: "wisdom",
charId: charId,
parent: {
id: featureId,
collection: "Features",
},
operation: "base",
value: 10,
enabled: true,
});
Effects.direct.insert({
stat: "charisma",
charId: charId,
parent: {
id: featureId,
collection: "Features",
},
operation: "base",
value: 10,
enabled: true,
});
// Items
let containerId = Containers.direct.insert({
name: "Coin Pouch",
charId: charId,
isCarried: true,
description: "A sturdy pouch for coins",
color: "d",
});
Items.direct.insert({
name: "Gold piece",
plural: "Gold pieces",
charId: charId,
quantity: 0,
weight: 0.02,
value: 1,
color: "n",
parent: {
id: containerId,
collection: "Containers",
},
settings: {
showIncrement: true,
},
});
Items.direct.insert({
name: "Silver piece",
plural: "Silver pieces",
charId: charId,
quantity: 0,
weight: 0.02,
value: 0.1,
color: "q",
parent: {
id: containerId,
collection: "Containers",
},
settings: {
showIncrement: true,
},
});
Items.direct.insert({
name: "Copper piece",
plural: "Copper pieces",
charId: charId,
quantity: 0,
weight: 0.02,
value: 0.01,
color: "s",
parent: {
id: containerId,
collection: "Containers",
},
settings: {
showIncrement: true,
},
});
};

View File

@@ -0,0 +1,99 @@
import { saveAs } from 'file-saver';
let characterCollections = [];
Meteor.startup(() => {
characterCollections = [
Actions,
Attacks,
Buffs,
Classes,
Conditions,
CustomBuffs,
Effects,
Experiences,
Features,
Notes,
Proficiencies,
SpellLists,
Spells,
TemporaryHitPoints,
Items,
Containers,
];
});
dumpCharacter = function(charId){
let characterDump = {collections: {}};
characterDump.character = Characters.findOne(charId);
characterCollections.forEach(c => {
characterDump.collections[c._name] = c.find({charId}).fetch();
});
return characterDump;
};
saveCharacterDump = function(charId){
let dump = dumpCharacter(charId);
let textDump = JSON.stringify(dump, null, 2);
let charName = dump.character.name;
let blob = new Blob([textDump], {type: "application/json;charset=utf-8"});
saveAs(blob, `${charName}.JSON`);
};
giveCharacterDumpNewIds = function(characterDump){
// Give the character a new Id
const oldCharId = characterDump.character._id;
const newCharId = Random.id();
characterDump.character._id = newCharId;
let idMap = {[oldCharId]: newCharId}; // {oldId: newId}
// Give all documents a new Id, and store the mapping from old to new
for (let colName in characterDump.collections){
for (let doc of characterDump.collections[colName]){
let oldId = doc._id;
let newId = Random.id();
doc._id = newId;
idMap[oldId] = newId;
}
}
// Replace all references to old Ids with new ones
for (let colName in characterDump.collections){
for (let doc of characterDump.collections[colName]){
// Replace the character Id with the new one
doc.charId = newCharId;
// Replace the parent reference id with a new id
if (doc.parent && doc.parent.id){
let newParentId = idMap[doc.parent.id];
if(!newParentId) throw `Can't find the mapping for id ${doc.parent.id}`;
doc.parent.id = newParentId;
}
}
}
}
restoreCharacter = function(characterDump){
Characters.direct.insert(characterDump.character);
for (collectionName in characterDump.collections){
let collection = Meteor.Collection.get(collectionName);
for (doc of characterDump.collections[collectionName]){
// delete problematic keys that shouldn't ever be available on insert
delete doc.restoredAt;
delete doc.restoredBy;
// Insert the doc with no hooks
collection.direct.insert(doc);
}
}
};
Meteor.methods({
restoreCharacter(characterDump){
characterDump.character.name += " - Restored"
characterDump.character.owner = Meteor.userId();
characterDump.character.readers = [];
characterDump.character.writers = [];
giveCharacterDumpNewIds(characterDump);
restoreCharacter(characterDump);
return characterDump.character
},
});

View File

@@ -1,17 +0,0 @@
JSONExport = function(charId) {
var character = {
"attacks": Attacks.find({charId: charId}).fetch(),
"characters": Characters.find({_id: charId}).fetch(),
"classes": Classes.find({charId: charId}).fetch(),
"containers": Containers.find({charId: charId}).fetch(),
"effects": Effects.find({charId: charId}).fetch(),
"experience": Experiences.find({charId: charId}).fetch(),
"features": Features.find({charId: charId}).fetch(),
"items": Items.find({charId: charId}).fetch(),
"notes": Notes.find({charId: charId}).fetch(),
"proficiencies": Proficiencies.find({charId: charId}).fetch(),
"spellLists": SpellLists.find({charId: charId}).fetch(),
"spells": Spells.find({charId: charId}).fetch()
};
return JSON.stringify(character);
}

View File

@@ -1,3 +1,10 @@
isOwner = function(charId, userId) {
userId = userId || Meteor.userId();
var char = Characters.findOne(charId, {fields: {owner: 1}});
if (!char) return true;
return (userId === char.owner);
};
canEditCharacter = function(charId, userId){
userId = userId || Meteor.userId();
var char = Characters.findOne(charId, {fields: {owner: 1, writers: 1}});
@@ -5,12 +12,14 @@ canEditCharacter = function(charId, userId){
return (userId === char.owner || _.contains(char.writers, userId));
};
canViewCharacter = function(charId, userId){
canViewCharacter = function(char, userId){
userId = userId || Meteor.userId();
var char = Characters.findOne(
charId,
{fields: {owner: 1, writers: 1, readers: 1, "settings.viewPermission": 1}}
);
if (typeof char !== 'object'){
char = Characters.findOne(
char,
{fields: {owner: 1, writers: 1, readers: 1, "settings.viewPermission": 1}}
);
}
if (!char) return true;
return userId === char.owner ||
char.settings.viewPermission === "public" ||

View File

@@ -0,0 +1,26 @@
// Uses '/lib/functions/backupRestoreCharacter.js' to do most the work
Meteor.methods({
copyCharacter: function(charId) {
const userId = Meteor.userId();
let character = Characters.findOne(charId);
// Need at least view level permission to make a copy for yourself
if (!canViewCharacter(character, userId)) return;
let characterDump = dumpCharacter(charId);
giveCharacterDumpNewIds(characterDump);
// Remove all readers and writers, make this user the new owner
characterDump.character.readers = [];
characterDump.character.writers = [];
characterDump.character.owner = userId;
// Rename the character so it's obviously a copy
characterDump.character.name += " - Copy";
// Write the character back to the database
restoreCharacter(characterDump);
return characterDump.character;
},
});

93
app/package-lock.json generated
View File

@@ -45,7 +45,7 @@
},
"ansi-regex": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
"resolved": false,
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
},
"aproba": {
@@ -74,6 +74,7 @@
},
"block-stream": {
"version": "0.0.9",
"resolved": false,
"integrity": "sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=",
"requires": {
"inherits": "~2.0.0"
@@ -131,7 +132,7 @@
},
"code-point-at": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
"resolved": false,
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c="
},
"core-js": {
@@ -139,6 +140,11 @@
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.7.tgz",
"integrity": "sha512-RszJCAxg/PP6uzXVXL6BsxSXx/B05oJAQ2vkJRjyjrEcNVycaqOmNb5OTxZPE3xa5gwZduqza6L9JOCenh/Ecw=="
},
"core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
},
"cross-spawn": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
@@ -209,6 +215,11 @@
"resolved": "https://registry.npmjs.org/fibers/-/fibers-2.0.2.tgz",
"integrity": "sha512-HfVRxhYG7C8Jl9FqtrlElMR2z/8YiLQVDKf67MLY25Ic+ILx3ecmklfT1v3u+7P5/4vEFjuxaAFXhr2/Afwk5g=="
},
"file-saver": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.1.tgz",
"integrity": "sha512-dCB3K7/BvAcUmtmh1DzFdv0eXSVJ9IAFt1mw3XZfAexodNRoE29l3xB2EX4wH2q8m/UTzwzEPq/ArYk98kUkBQ=="
},
"find-up": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz",
@@ -259,7 +270,7 @@
},
"graceful-fs": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz",
"resolved": false,
"integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg="
},
"har-schema": {
@@ -361,6 +372,7 @@
},
"inherits": {
"version": "2.0.3",
"resolved": false,
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
},
"invert-kv": {
@@ -383,7 +395,7 @@
},
"is-fullwidth-code-point": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
"resolved": false,
"integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
"requires": {
"number-is-nan": "^1.0.0"
@@ -1064,6 +1076,37 @@
"requires": {
"inherits": "~2.0.1",
"readable-stream": "^2.0.2"
},
"dependencies": {
"readable-stream": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
"integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
"requires": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
},
"dependencies": {
"inherits": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
}
}
},
"string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"requires": {
"safe-buffer": "~5.1.0"
}
}
}
},
"stream-http": {
@@ -1076,6 +1119,37 @@
"readable-stream": "^2.3.3",
"to-arraybuffer": "^1.0.0",
"xtend": "^4.0.0"
},
"dependencies": {
"readable-stream": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
"integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
"requires": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
},
"dependencies": {
"inherits": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
}
}
},
"string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"requires": {
"safe-buffer": "~5.1.0"
}
}
}
},
"string_decoder": {
@@ -1434,7 +1508,7 @@
},
"number-is-nan": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
"resolved": false,
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0="
},
"os-homedir": {
@@ -1578,6 +1652,7 @@
},
"minimist": {
"version": "1.2.0",
"resolved": false,
"integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ="
},
"strip-json-comments": {
@@ -1815,7 +1890,7 @@
},
"set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"resolved": false,
"integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc="
},
"shebang-command": {
@@ -1833,7 +1908,7 @@
},
"signal-exit": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz",
"resolved": false,
"integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0="
},
"source-map": {
@@ -1909,7 +1984,7 @@
},
"strip-ansi": {
"version": "3.0.1",
"resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
"resolved": false,
"integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
"requires": {
"ansi-regex": "^2.0.0"
@@ -2159,7 +2234,7 @@
},
"wrap-ansi": {
"version": "2.1.0",
"resolved": "http://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz",
"integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=",
"requires": {
"string-width": "^1.0.1",

View File

@@ -17,6 +17,7 @@
"bower": "^1.7.9",
"core-js": "^2.5.7",
"fibers": "^2.0.2",
"file-saver": "^2.0.1",
"meteor-node-stubs": "^0.3.3",
"qrcode": "^1.3.0",
"source-map-support": "^0.5.9",

View File

@@ -1,9 +1,13 @@
Meteor.publish("user", function(){
return Meteor.users.find(this.userId, {fields: {
roles: 1,
username: 1,
profile: 1,
apiKey: 1,
librarySubscriptions: 1,
}});
return [
Meteor.users.find(this.userId, {fields: {
roles: 1,
username: 1,
profile: 1,
apiKey: 1,
librarySubscriptions: 1,
lastPatreonPostClicked: 1,
}}),
PatreonPosts.find({},{sort: {dateAdded: -1}, limit: 1})
];
});

View File

@@ -2194,9 +2194,9 @@
}
},
{
"castingTime": "bonus action",
"castingTime": "action",
"description": "Choose a manufactured metal object, such as a metal weapon or a suit of heavy or medium metal armor, that you can see within range. You cause the object to glow red-hot. Any creature in physical contact with the object takes 2d8 fire damage when you cast the spell. Until the spell ends, you can use a bonus action on each of your subsequent turns to cause this damage again.\n\nIf a creature is holding or wearing the object and takes the damage from it, the creature must succeed on a DC {DC} Constitution saving throw or drop the object if it can. If it doesnt drop the object, it has disadvantage on attack rolls and ability checks until the start of your next turn.\n\n***At Higher Levels.*** When you cast this spell using a spell slot of 3rd level or higher, the damage increases by 1d8 for each slot level above 2nd.",
"duration": "Instantaneous",
"duration": "Concentration, up to 1 minute",
"level": 2,
"range": "60 feet",
"school": "Transmutation",
@@ -2204,8 +2204,9 @@
"name": "Heat Metal",
"components": {
"verbal": true,
"somatic": false,
"concentration": false
"somatic": true,
"concentration": true,
"material": "a piece of iron and a flame"
}
},
{