Compare commits

...

31 Commits

Author SHA1 Message Date
Stefan Zermatten
a5c16ba83a Overhauled inventory tab again. Closer in functionality to V1 2020-08-22 00:36:17 +02:00
Stefan Zermatten
46501f2759 Spells that aren't prepared no longer count as active properties 2020-08-21 16:34:52 +02:00
Stefan Zermatten
a6ed1004be Added Resistance, Vulnerability, and Immunity to the health bar card 2020-08-21 16:27:01 +02:00
Stefan Zermatten
8539356b9e Added UI to prepare spells 2020-08-21 16:04:31 +02:00
Stefan Zermatten
93db5e9288 Made library link readonly instead of disabled so it can be copied 2020-08-10 04:23:55 +02:00
Stefan Zermatten
b4cb91a892 Multiple libraries can now be opened, allowing library items to be moved 2020-08-10 04:19:54 +02:00
Stefan Zermatten
9c93747845 Fixed bug where array accessors were attempting to use the substitution engine prematurely 2020-08-10 04:14:53 +02:00
Stefan Zermatten
a51154e434 Prevented test webhooks being sent in production 2020-07-26 19:56:08 +02:00
Stefan Zermatten
1bde0db0ba Fixed features showing up when disabled by an ancestor 2020-07-26 19:53:07 +02:00
Stefan Zermatten
bc001202ec Fixed spell list tiles not being opaque 2020-07-26 19:47:44 +02:00
Stefan Zermatten
c7985af83b Continued work on tabletops, hidden from main app and all methods disallowed for non-admins 2020-07-26 19:45:07 +02:00
Stefan Zermatten
0f20cd4bd9 Implemented drag and drop on spells page 2020-07-26 19:39:50 +02:00
Stefan Zermatten
1ac01941c7 Fixed bug where multiple classes woudn't show up in persona tab 2020-07-26 16:43:26 +02:00
Stefan Zermatten
95d8d2cb9a Started work on tabletop view 2020-07-17 23:31:12 +02:00
Stefan Zermatten
47345b3694 Experimenting with webhooks. 2020-07-13 16:38:24 +02:00
Stefan Zermatten
308168791b Made dX rolls work as 1dX 2020-06-30 15:15:55 +02:00
Stefan Zermatten
7be4280508 Began implementing dice rolls in the maths parser 2020-06-30 14:40:20 +02:00
Stefan Zermatten
56f9e82326 Improved spells tab to be more in line with the v1 implementation 2020-06-29 14:52:47 +02:00
Stefan Zermatten
6ddea8a8ab Improved slot schema, added ui for slots 2020-06-29 14:15:49 +02:00
Stefan Zermatten
e1ddfb2cab Fixed a bug where registering a property without disabledByToggle breaks recompuation 2020-06-24 18:20:56 +02:00
Stefan Zermatten
d36d5b15d0 hide unused spell card 2020-06-24 18:17:35 +02:00
Stefan Zermatten
2af687361e Improved spell appearance in spell tab 2020-06-23 01:36:48 +02:00
Stefan Zermatten
e572807082 Attributes of type spell slot now store their slot level 2020-06-23 01:36:21 +02:00
Stefan Zermatten
c44aeac198 Added 'prepared' field to spells 2020-06-22 13:45:47 +02:00
Stefan Zermatten
757cf5c34b Fixed spells not being able to be inserted or editing in characters 2020-06-22 13:45:35 +02:00
Stefan Zermatten
8cbfec25b3 Added the setting to swap ability scores and modifiers to the account page 2020-06-22 13:22:53 +02:00
Stefan Zermatten
c4dc5895aa Relaxed rate limiting on icon search, improved error messaging 2020-06-22 00:20:40 +02:00
Stefan Zermatten
cffe0ee574 Added minimal UI to display applied buffs 2020-06-22 00:14:07 +02:00
Stefan Zermatten
ce51be7b8e moved proficiencies after actions on the stats tab 2020-06-21 23:57:19 +02:00
Stefan Zermatten
315073bd8e Refactored actions and let actions apply buffs to self 2020-06-21 23:54:51 +02:00
Stefan Zermatten
50b99ef54f Improved performance of adding library properties with many decendants 2020-06-21 23:24:07 +02:00
86 changed files with 2688 additions and 387 deletions

View File

@@ -52,3 +52,4 @@ bozhao:link-accounts
peerlibrary:reactive-publish
simple:rest
simple:rest-method-mixin
mikowals:batch-insert

View File

@@ -72,6 +72,7 @@ meteorhacks:subs-manager@1.6.4
meteortesting:browser-tests@1.3.3
meteortesting:mocha@1.1.5
meteortesting:mocha-core@7.0.1
mikowals:batch-insert@1.1.9
minifier-css@1.5.0
minifier-js@2.6.0
minimongo@1.6.0

View File

@@ -1,11 +0,0 @@
import SimpleSchema from 'simpl-schema';
let Encounters = new Mongo.Collection("encounters");
let EncounterSchema = new SimpleSchema({
//an encounter is a single flow of time all parties in an encounter are in-sync time wise
});
Encounters.attachSchema(EncounterSchema);
export default Encounters;

View File

@@ -1,49 +0,0 @@
import SimpleSchema from 'simpl-schema';
let Parties = new Mongo.Collection("parties");
let partySchema = new SimpleSchema({
name: {
type: String,
defaultValue: "New Party",
trim: false,
optional: true,
},
characters: {
type: Array,
defaultValue: [],
},
characters: {
type: String,
regEx: SimpleSchema.RegEx.Id,
index: 1,
},
owner: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
});
Parties.attachSchema(partySchema);
Parties.allow({
insert: function(userId, doc) {
return userId && doc.owner === userId;
},
update: function(userId, doc, fields, modifier) {
return userId && doc.owner === userId;
},
remove: function(userId, doc) {
return userId && doc.owner === userId;
},
fetch: ["owner"],
});
Parties.deny({
update: function(userId, docs, fields, modifier) {
// can't change owners
return _.contains(fields, "owner");
}
});
export default Parties;

View File

@@ -158,7 +158,7 @@ const insertPropertyFromLibraryNode = new ValidatedMethod({
'ancestors.id': nodeId,
removed: {$ne: true},
}).fetch();
// The root node is last in the array of nodes
// The root node is last in the array of nodes
nodes.push(node);
// re-map all the ancestors
@@ -181,17 +181,16 @@ const insertPropertyFromLibraryNode = new ValidatedMethod({
});
// Insert the creature properties
let docId;
nodes.forEach(doc => {
docId = CreatureProperties.insert(doc);
});
let insertedDocIds = CreatureProperties.batchInsert(nodes);
// get the root inserted doc
let rootId = insertedDocIds[insertedDocIds.length - 1];
// Recompute the creatures doc was attached to
let doc = CreatureProperties.findOne(docId);
recomputeCreatures(doc);
recomputeCreatures(node);
// Return the docId of the last property, the inserted root property
return docId;
return rootId;
},
})

View File

@@ -5,7 +5,7 @@ import deathSaveSchema from '/imports/api/properties/subSchemas/DeathSavesSchema
import ColorSchema from '/imports/api/properties/subSchemas/ColorSchema.js';
import SharingSchema from '/imports/api/sharing/SharingSchema.js';
import {assertEditPermission} from '/imports/api/sharing/sharingPermissions.js';
import { getUserTier } from '/imports/api/users/patreon/tiers.js';
import { assertUserHasPaidBenefits } from '/imports/api/users/patreon/tiers.js';
import '/imports/api/creature/removeCreature.js';
import '/imports/api/creature/restCreature.js';
@@ -107,6 +107,17 @@ let CreatureSchema = new SimpleSchema({
defaultValue: {}
},
// Tabletop
tabletop: {
type: String,
regEx: SimpleSchema.RegEx.id,
optional: true,
},
initiativeRoll: {
type: SimpleSchema.Integer,
optional: true,
},
// Settings
settings: {
type: CreatureSettingsSchema,
@@ -136,11 +147,7 @@ const insertCreature = new ValidatedMethod({
throw new Meteor.Error('Creatures.methods.insert.denied',
'You need to be logged in to insert a creature');
}
let tier = getUserTier(this.userId);
if (!tier.paidBenefits){
throw new Meteor.Error('Creatures.methods.insert.denied',
`The ${tier.name} tier does not allow you to insert a creature`);
}
assertUserHasPaidBenefits(this.userId);
// Create the creature document
let charId = Creatures.insert({

View File

@@ -0,0 +1,28 @@
import SimpleSchema from 'simpl-schema';
let Parties = new Mongo.Collection('parties');
let partySchema = new SimpleSchema({
name: {
type: String,
defaultValue: 'New Party',
trim: false,
optional: true,
},
creatures: {
type: Array,
defaultValue: [],
},
'creatures.$': {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
owner: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
});
Parties.attachSchema(partySchema);
export default Parties;

View File

@@ -0,0 +1,5 @@
import spendResources from '/imports/api/creature/actions/spendResources.js'
export default function applyAction({prop}){
spendResources(prop);
}

View File

@@ -0,0 +1,21 @@
import math from '/imports/math.js';
//if (Meteor.isServer){
// var sendWebhook = require('/imports/server/discord/webhook.js').default;
//}
export default function applyAttack({
prop,
//children,
creature,
//targets,
//actionContext
}){
let result = math.roll(1, 20) + prop.rollBonusResult;
if (Meteor.isClient){
console.log(`${creature.name} makes a ${prop.name} attack! Rolls ${result} to hit`);
}
//if (Meteor.isServer) sendWebhook({
// webhook: creature.webhook,
// message: `${creature.name} makes a ${prop.name} attack! Rolls ${result} to hit`,
//});
}

View File

@@ -0,0 +1,61 @@
import {
setLineageOfDocs,
renewDocIds
} from '/imports/api/parenting/parenting.js';
import {setDocToLastOrder} from '/imports/api/parenting/order.js';
import CreatureProperties from '/imports/api/creature/CreatureProperties.js';
export default function applyBuff({
prop,
children,
creature,
targets = [],
//actionContext,
}){
let buffTargets = prop.target === 'self' ? [creature] : targets;
//let scope = {
// ...creature.variables,
// ...actionContext,
//};
// TODO
// If the target is not self, walk through all decendants and replace
// variables in calculations with their values from the creature scope
// If the target is self, replace all the target.x references with just x
// Then copy the decendants of the buff to the targets
prop.applied = true;
let propList = [prop];
function addChildrenToPropList(children){
children.forEach(child => {
propList.push(child.node);
addChildrenToPropList(child.children);
});
}
addChildrenToPropList(children);
let oldParent = {
id: prop.parent.id,
collection: prop.parent.collection,
};
buffTargets.forEach(target => {
copyNodeListToTarget(propList, target, oldParent);
});
}
function copyNodeListToTarget(propList, target, oldParent){
let ancestry = [{collection: 'creatures', id: target._id}];
setLineageOfDocs({
docArray: propList,
newAncestry: ancestry,
oldParent,
});
renewDocIds({
docArray: propList,
});
setDocToLastOrder({
collection: CreatureProperties,
doc: propList[0],
});
CreatureProperties.batchInsert(propList);
}

View File

@@ -0,0 +1,27 @@
import evaluateAndRollString from '/imports/api/creature/computation/afterComputation/evaluateAndRollString.js';
//if (Meteor.isServer){
// var sendWebhook = require('/imports/server/discord/webhook.js').default;
//}
export default function applyDamage({
prop,
creature,
//targets,
actionContext
}){
//let damageTargets = prop.target === 'self' ? [creature] : targets;
let scope = {
...creature.variables,
...actionContext,
};
let {result, errors} = evaluateAndRollString(prop.amount, scope);
if (Meteor.isClient){
errors.forEach(e => console.error(e));
console.log(`${result} ${prop.damageType}${prop.damageType !== 'healing'? ' damage': ''}`);
}
//if (Meteor.isServer) sendWebhook({
// webhook: creature.webhook,
// message: `${result} ${prop.damageType}${prop.damageType !== 'healing'? ' damage': ''}`,
//});
}

View File

@@ -0,0 +1,66 @@
import applyAction from '/imports/api/creature/actions/applyAction.js';
import applyAttack from '/imports/api/creature/actions/applyAttack.js';
import applyDamage from '/imports/api/creature/actions/applyDamage.js';
import applyBuff from '/imports/api/creature/actions/applyBuff.js';
function applyProperty(options){
let prop = options.prop;
if (
prop.disabled === true || // ignore disabled props
prop.equipped === false || // ignore unequipped items
prop.toggleResult === false || // ignore untoggled toggles
prop.applied === true // ignore buffs that are already applied
){
return false;
}
switch (prop.type){
case 'action':
case 'spell':
applyAction(options);
return true;
case 'attack':
applyAttack(options);
applyAction(options);
return true;
case 'damage':
applyDamage(options);
return true;
case 'adjustment':
// applyAdjustment(options);
return true;
case 'buff':
applyBuff(options);
return false;
case 'roll':
// applyRoll(options);
return true;
case 'savingThrow':
// applySavingThrow(options);
return false;
}
}
export default function applyProperties({
forest,
creature,
targets,
actionContext
}){
forest.forEach(child => {
let walkChildren = applyProperty({
prop: child.node,
children: child.children,
creature,
targets,
actionContext
});
if (walkChildren){
applyProperties({
forest: child.children,
creature,
targets,
actionContext
});
}
});
}

View File

@@ -1,88 +1,61 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import CreatureProperties, { getCreature, damagePropertyWork, adjustQuantityWork } from '/imports/api/creature/CreatureProperties.js';
import CreatureProperties, { getCreature } from '/imports/api/creature/CreatureProperties.js';
import { assertEditPermission } from '/imports/api/creature/creaturePermissions.js';
import { recomputeCreatureByDoc } from '/imports/api/creature/computation/recomputeCreature.js';
import { nodesToTree } from '/imports/api/parenting/parenting.js';
import applyProperties from '/imports/api/creature/actions/applyProperties.js';
const doAction = new ValidatedMethod({
name: 'creatureProperties.doAction',
validate: new SimpleSchema({
actionId: SimpleSchema.RegEx.Id,
targetId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
optional: true,
},
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 10,
timeInterval: 5000,
},
run({actionId}) {
run({actionId, targetId}) {
let action = CreatureProperties.findOne(actionId);
// Check permissions
let creature = getCreature(action);
assertEditPermission(creature, this.userId);
doActionWork(action);
let target = undefined;
if (targetId) {
target = getCreature(targetId);
assertEditPermission(target, this.userId);
}
doActionWork({action, creature, target});
// Note this only recomputes the top-level creature, not the nearest one
recomputeCreatureByDoc(creature);
if (target){
recomputeCreatureByDoc(target);
}
},
});
function doActionWork(action){
spendResources(action);
}
function spendResources(action){
// Check Uses
if (action.usesUsed >= action.usesResult){
throw new Meteor.Error('Insufficient Uses',
'This action has no uses left');
}
// Resources
if (action.insufficientResources){
throw new Meteor.Error('Insufficient Resources',
'This creature doesn\'t have sufficient resources to perform this action');
}
// Items
let itemQuantityAdjustments = [];
action.resources.itemsConsumed.forEach(itemConsumed => {
if (!itemConsumed.itemId){
throw new Meteor.Error('Ammo not selected',
'No ammo was selected for this action');
}
let item = CreatureProperties.findOne(itemConsumed.itemId);
if (!item || item.ancestors[0].id !== action.ancestors[0].id){
throw new Meteor.Error('Ammo not found',
'The action\'s ammo was not found on the creature');
}
if (!item.equipped){
throw new Meteor.Error('Ammo not equipped',
'The selected ammo is not equipped');
}
if (!itemConsumed.quantity) return;
itemQuantityAdjustments.push({
property: item,
operation: 'increment',
value: itemConsumed.quantity,
});
function doActionWork({action, creature, target}){
let actionContext = {};
let decendantForest = nodesToTree({
collection: CreatureProperties,
ancestorId: action._id,
});
// No more errors should be thrown after this line
// Now that we have confirmed that there are no errors, do actual work
//Items
itemQuantityAdjustments.forEach(adjustQuantityWork);
// Use uses
CreatureProperties.update(action._id, {
$inc: {usesUsed: 1}
}, {
selector: action
});
// Damage stats
action.resources.attributesConsumed.forEach(attConsumed => {
if (!attConsumed.quantity) return;
let stat = CreatureProperties.findOne(attConsumed.statId);
damagePropertyWork({
property: stat,
operation: 'increment',
value: attConsumed.quantity,
});
let startingForest = [{
node: action,
children: decendantForest,
}];
applyProperties({
forest: startingForest,
creature,
target,
actionContext
});
}

View File

@@ -0,0 +1,57 @@
import CreatureProperties, { damagePropertyWork, adjustQuantityWork } from '/imports/api/creature/CreatureProperties.js';
export default function spendResources(action){
// Check Uses
if (action.usesUsed >= action.usesResult){
throw new Meteor.Error('Insufficient Uses',
'This action has no uses left');
}
// Resources
if (action.insufficientResources){
throw new Meteor.Error('Insufficient Resources',
'This creature doesn\'t have sufficient resources to perform this action');
}
// Items
let itemQuantityAdjustments = [];
action.resources.itemsConsumed.forEach(itemConsumed => {
if (!itemConsumed.itemId){
throw new Meteor.Error('Ammo not selected',
'No ammo was selected for this action');
}
let item = CreatureProperties.findOne(itemConsumed.itemId);
if (!item || item.ancestors[0].id !== action.ancestors[0].id){
throw new Meteor.Error('Ammo not found',
'The action\'s ammo was not found on the creature');
}
if (!item.equipped){
throw new Meteor.Error('Ammo not equipped',
'The selected ammo is not equipped');
}
if (!itemConsumed.quantity) return;
itemQuantityAdjustments.push({
property: item,
operation: 'increment',
value: itemConsumed.quantity,
});
});
// No more errors should be thrown after this line
// Now that we have confirmed that there are no errors, do actual work
//Items
itemQuantityAdjustments.forEach(adjustQuantityWork);
// Use uses
CreatureProperties.update(action._id, {
$inc: {usesUsed: 1}
}, {
selector: action
});
// Damage stats
action.resources.attributesConsumed.forEach(attConsumed => {
if (!attConsumed.quantity) return;
let stat = CreatureProperties.findOne(attConsumed.statId);
damagePropertyWork({
property: stat,
operation: 'increment',
value: attConsumed.quantity,
});
});
}

View File

@@ -224,10 +224,17 @@ function isSkillOperation(prop){
}
function propDetails(prop){
return propDetailsByType[prop.type] && propDetailsByType[prop.type]() || {};
return propDetailsByType[prop.type] && propDetailsByType[prop.type]() ||
propDetailsByType.default();
}
const propDetailsByType = {
default(){
return {
toggleAncestors: [],
disabledByToggle: false,
};
},
toggle(){
return {
computed: false,

View File

@@ -0,0 +1,77 @@
import math from '/imports/math.js';
import bareSymbolSubtitutor from '/imports/api/creature/computation/utility/bareSymbolSubtitutor.js';
import substituteRollsWithFunctions from '/imports/api/creature/computation/afterComputation/substituteRollsWithFunctions.js'
export default function evaluateAndRollString(string, scope){
let errors = [];
if (!string){
errors.push('No string provided');
return {result: string, errors};
}
if (!scope) errors.push('No scope provided');
// Parse the string using mathjs
let calc;
try {
calc = math.parse(string);
} catch (e) {
errors.push(e);
return {result: string, errors};
}
// Replace all bare symbols with symbol.value
let transformedCalc = calc.transform(bareSymbolSubtitutor(scope));
// Replace all rolls with the function to call them
transformedCalc = calc.transform(substituteRollsWithFunctions);
// Evaluate the expression to a number or return with substitutions
try {
let result = transformedCalc.evaluate(scope);
return {result, errors};
} catch (e1){
errors.push(e1);
try {
let result = simplifyWithAccessors(transformedCalc, scope).toHTML();
return {result, errors};
} catch (e2){
errors.push(e2);
return {result: transformedCalc.toHTML(), errors};
}
}
}
function simplifyWithAccessors(calc, scope){
let noAccessorCalc = calc.transform(substituteAccessors(scope));
return math.simplify(noAccessorCalc);
}
// returns a function to replace all accessors with either their resolved value
// or a symbol to simplify with
function substituteAccessors(scope){
return function(node){
if (node.isAccessorNode){
try {
return evaluateAccessor(node, scope);
} catch (e) {
return replaceAccessorWithSymbol(node);
}
} else {
return node;
}
}
}
// Throws error if symbol is undefined in scope
function evaluateAccessor(node, scope){
let value = node.evaluate(scope);
if (value === undefined){
throw 'Undefined symbol'
}
return new math.ConstantNode(value);
}
function replaceAccessorWithSymbol(node){
let symbolNode = new math.SymbolNode(node.toString());
return symbolNode;
}

View File

@@ -0,0 +1,28 @@
import math from '/imports/math.js';
const diceRegex = /d\d+/;
export default function substituteRollsWithFunctions(node){
// TODO also replace dx as 1dx
if (
node.isOperatorNode &&
node.fn === 'multiply' &&
node.implicit &&
node.args[1].isSymbolNode &&
diceRegex.test(node.args[1].name)
){
let diceSize = node.args[1].name.slice(1);
let diceSizeNode = new math.ConstantNode(diceSize);
return new math.FunctionNode('roll', [node.args[0], diceSizeNode]);
} else if (
node.isSymbolNode &&
diceRegex.test(node.name)
) {
let diceSize = node.name.slice(1);
let diceSizeNode = new math.ConstantNode(diceSize);
let diceNumberNode = new math.ConstantNode(1);
return new math.FunctionNode('roll', [diceNumberNode, diceSizeNode]);
} else {
return node;
}
}

View File

@@ -1,9 +1,10 @@
import computeStat from '/imports/api/creature/computation/computeStat.js';
import applyToggles from '/imports/api/creature/computation/applyToggles.js';
import evaluateCalculation from '/imports/api/creature/computation/evaluateCalculation.js';
export default function combineStat(stat, aggregator, memo){
if (stat.type === 'attribute'){
combineAttribute(stat, aggregator);
combineAttribute(stat, aggregator, memo);
} else if (stat.type === 'skill'){
combineSkill(stat, aggregator, memo);
} else if (stat.type === 'damageMultiplier'){
@@ -28,13 +29,18 @@ function getAggregatorResult(stat, aggregator){
return result;
}
function combineAttribute(stat, aggregator){
function combineAttribute(stat, aggregator, memo){
stat.value = getAggregatorResult(stat, aggregator);
stat.baseValue = aggregator.statBaseValue;
stat.baseValueErrors = aggregator.baseValueErrors;
if (stat.attributeType === 'ability') {
stat.modifier = Math.floor((stat.value - 10) / 2);
}
if (stat.attributeType === 'spellSlot'){
let {value, errors} = evaluateCalculation(stat.spellSlotLevelCalculation, memo);
stat.spellSlotLevelValue = value,
stat.spellSlotLevelErrors = errors;
}
stat.currentValue = stat.value - (stat.damage || 0);
stat.hide = aggregator.hasNoEffects &&
stat.baseValue === undefined ||

View File

@@ -16,6 +16,8 @@ export default function computeEndStepProperty(prop, memo){
case 'spellList':
computeSpellList(prop, memo);
break;
case 'propertySlot':
computeSlot(prop, memo);
}
}
@@ -94,3 +96,13 @@ function computeSpellList(prop, memo){
delete prop.maxPreparedErrors;
}
}
function computeSlot(prop, memo){
let {value, errors} = evaluateCalculation(prop.slotCondition, memo);
prop.slotConditionResult = value;
if (errors.length){
prop.slotConditionErrors = errors;
} else {
delete prop.slotConditionErrors;
}
}

View File

@@ -79,7 +79,7 @@ function symbolSubtitutor(scope, errors){
return new math.ConstantNode(0);
}
}
} else if (node.isAccessorNode){
} else if (node.isAccessorNode && node.object.isSymbolNode){
try {
let value = node.evaluate(scope);
if (value === undefined) throw 'Not found';

View File

@@ -49,6 +49,7 @@ const calculationPropertyTypes = [
'savingThrow',
'spellList',
'spell',
'propertySlot',
];
export function recomputeCreatureById(creatureId){

View File

@@ -13,6 +13,7 @@ import { ComputedOnlyAttackSchema } from '/imports/api/properties/Attacks.js';
import { ComputedOnlySavingThrowSchema } from '/imports/api/properties/SavingThrows.js';
import { ComputedOnlySpellListSchema } from '/imports/api/properties/SpellLists.js';
import { ComputedOnlySpellSchema } from '/imports/api/properties/Spells.js';
import { ComputedOnlySlotSchema } from '/imports/api/properties/Slots.js';
const schemasByType = {
'skill': ComputedOnlySkillSchema,
@@ -24,6 +25,7 @@ const schemasByType = {
'savingThrow': ComputedOnlySavingThrowSchema,
'spellList': ComputedOnlySpellListSchema,
'spell': ComputedOnlySpellSchema,
'propertySlot': ComputedOnlySlotSchema,
};
export default function writeAlteredProperties(memo){

View File

@@ -4,17 +4,30 @@ import CreatureProperties from '/imports/api/creature/CreatureProperties.js';
export default function getActiveProperties({
ancestorId,
filter = {},
options,
includeUntoggled = false
options = {sort: {order: 1}},
includeUntoggled = false,
includeUnprepared = false,
includeUnequipped = false,
excludeAncestors,
}){
filter = getActivePropertyFilter({ancestorId, filter, includeUntoggled});
filter = getActivePropertyFilter({
ancestorId,
filter,
includeUntoggled,
includeUnprepared,
includeUnequipped,
excludeAncestors,
});
return CreatureProperties.find(filter, options).fetch();
}
export function getActivePropertyFilter({
ancestorId,
filter = {},
includeUntoggled = false
includeUntoggled = false,
includeUnprepared = false,
includeUnequipped = false,
excludeAncestors = [],
}){
if (!ancestorId){
throw 'Ancestor Id is required to get active properties'
@@ -24,13 +37,22 @@ export function getActivePropertyFilter({
'ancestors.id': ancestorId,
$or: [
{disabled: true}, // Everything can be disabled
{equipped: false}, // Items can be equipped
{applied: false}, // Buffs can be applied
],
};
if (!includeUnequipped){
disabledAncestorsFilter.$or.push({type: 'item', equipped: {$ne: true}});
}
if (!includeUntoggled){
disabledAncestorsFilter.$or.push({toggleResult: false});
}
if (!includeUnprepared){
disabledAncestorsFilter.$or.push({
type: 'spell',
prepared: {$ne: true},
alwaysPrepared: {$ne: true}
});
}
let disabledAncestorIds = CreatureProperties.find(disabledAncestorsFilter, {
fields: {_id: 1},
}).map(prop => prop._id);
@@ -47,9 +69,12 @@ export function getActivePropertyFilter({
// Get all the properties that are decendents of the ancestor of interest but
// aren't from the excluded decendents
if (filter['ancestors.id'] && Meteor.isClient){
console.warn('Filtering on ancestor id is ignored')
}
filter['ancestors.id'] = {
$eq: ancestorId,
$nin: disabledAncestorIds,
$nin: disabledAncestorIds.concat(excludeAncestors),
};
// Get properties that aren't removed
filter.removed = {$ne: true};

View File

@@ -71,8 +71,8 @@ const findIcons = new ValidatedMethod({
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
numRequests: 20,
timeInterval: 10000,
},
run({search}){
if (!search) return [];

View File

@@ -130,7 +130,7 @@ export function renewDocIds({docArray, collectionMap}){
const remapReference = ref => {
if (idMap[ref.id]){
ref.id = idMap[ref.id];
ref.collection = collectionMap[ref.collection] || ref.collection;
ref.collection = collectionMap && collectionMap[ref.collection] || ref.collection;
}
}
docArray.forEach(doc => {
@@ -154,6 +154,14 @@ export function updateParent({docRef, parentRef}){
// Get the parent and its ancestry
let {parentDoc, parent, ancestors} = getAncestry({parentRef});
// Check that the doc isn't its own ancestor
ancestors.forEach(ancestor => {
if (docRef.id === ancestor.id){
throw new Meteor.Error('invalid parenting',
'A doc can\'t be its own ancestor')
}
});
// If the doc and its parent are in the same collection, apply the allowed
// parent rules based on type
if (docRef.collection === parentRef.collection){
@@ -204,17 +212,11 @@ export function getName(doc){
}
}
export function nodesToTree({collection, ancestorId, filter, options}){
// Store a dict of all the nodes
export function nodeArrayToTree(nodes){
// Store a dict and list of all the nodes
let nodeIndex = {};
let nodeList = [];
if (!options) options = {};
options.sort = {order: 1};
collection.find({
'ancestors.id': ancestorId,
removed: {$ne: true},
...filter,
}, options).forEach( node => {
nodes.forEach( node => {
let treeNode = {
node: node,
children: [],
@@ -238,3 +240,12 @@ export function nodesToTree({collection, ancestorId, filter, options}){
});
return forest;
}
export function nodesToTree({collection, ancestorId, filter = {}, options = {}}){
if (!('ancestors.id' in filter)) filter['ancestors.id'] = ancestorId;
if (!('removed' in filter)) filter['removed'] = {$ne: true};
if (!options.sort) options.sort = {order: 1};
if (!('order' in options.sort)) options.sort.order = 1;
let nodes = collection.find(filter, options);
return nodeArrayToTree(nodes);
}

View File

@@ -39,6 +39,11 @@ let AttributeSchema = new SimpleSchema({
type: String,
allowedValues: ['d4', 'd6', 'd8', 'd10', 'd12', 'd20'],
optional: true,
},
// For type spellSlot, the level needs to be stored separately
spellSlotLevelCalculation: {
type: String,
optional: true,
},
// The starting value, before effects
baseValueCalculation: {
@@ -81,6 +86,18 @@ let ComputedOnlyAttributeSchema = new SimpleSchema({
},
'baseValueErrors.$': {
type: ErrorSchema,
},
// The result of spellSlotLevelCalculation
spellSlotLevelValue: {
type: SimpleSchema.oneOf(Number, String, Boolean),
optional: true,
},
spellSlotLevelErrors: {
type: Array,
optional: true,
},
'spellSlotLevelErrors.$': {
type: ErrorSchema,
},
// The computed value of the attribute
value: {

View File

@@ -1,6 +1,19 @@
import SimpleSchema from 'simpl-schema';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
let SlotSchema = new SimpleSchema({
name: {
type: String,
optional: true,
},
description: {
type: String,
optional: true,
},
slotType: {
type: String,
optional: true,
},
slotTags: {
type: Array,
defaultValue: [],
@@ -8,6 +21,43 @@ let SlotSchema = new SimpleSchema({
'slotTags.$': {
type: String,
},
quantityExpected: {
type: SimpleSchema.Integer,
defaultValue: 1,
},
ignored: {
type: Boolean,
optional: true,
},
slotCondition: {
type: String,
optional: true,
},
// How many properties have been selected to fill this slot
quantityFilled: {
type: SimpleSchema.Integer,
defaultValue: 0,
},
});
export { SlotSchema };
const ComputedOnlySlotSchema = new SimpleSchema({
// The computed result of the effect
slotConditionResult: {
type: SimpleSchema.oneOf(Number, String, Boolean),
optional: true,
},
// The errors encountered while computing the result
slotConditionErrors: {
type: Array,
optional: true,
},
'slotConditionErrors.$':{
type: ErrorSchema,
},
});
const ComputedSlotSchema = new SimpleSchema()
.extend(ComputedOnlySlotSchema)
.extend(SlotSchema);
export { SlotSchema, ComputedSlotSchema, ComputedOnlySlotSchema };

View File

@@ -1,5 +1,6 @@
import SimpleSchema from 'simpl-schema';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js';
let SpellListSchema = new SimpleSchema({
name: {
@@ -10,6 +11,12 @@ let SpellListSchema = new SimpleSchema({
type: String,
optional: true,
},
variableName: {
type: String,
regEx: VARIABLE_NAME_REGEX,
min: 2,
optional: true,
},
// Calculation of how many spells in this list can be prepared
maxPrepared: {
type: String,

View File

@@ -1,5 +1,6 @@
import { ActionSchema, ComputedOnlyActionSchema } from '/imports/api/properties/Actions.js';
import SimpleSchema from 'simpl-schema';
import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js';
const magicSchools = [
'abjuration',
@@ -25,6 +26,10 @@ let SpellSchema = new SimpleSchema({})
type: Boolean,
optional: true,
},
prepared: {
type: Boolean,
optional: true,
},
// This spell ignores spell slot rules
castWithoutSpellSlots: {
type: Boolean,
@@ -33,14 +38,6 @@ let SpellSchema = new SimpleSchema({})
hasAttackRoll: {
type: Boolean,
optional: true,
},
// Spell lists that this spell appears on
spellLists: {
type: Array,
defaultValue: [],
},
'spellLists.$': {
type: String,
},
description: {
type: String,

View File

@@ -17,7 +17,7 @@ import { ProficiencySchema } from '/imports/api/properties/Proficiencies.js';
import { RollSchema } from '/imports/api/properties/Rolls.js';
import { ComputedSavingThrowSchema } from '/imports/api/properties/SavingThrows.js';
import { ComputedSkillSchema } from '/imports/api/properties/Skills.js';
import { SlotSchema } from '/imports/api/properties/Slots.js';
import { ComputedSlotSchema } from '/imports/api/properties/Slots.js';
import { ComputedSpellSchema } from '/imports/api/properties/Spells.js';
import { ComputedSpellListSchema } from '/imports/api/properties/SpellLists.js';
import { ToggleSchema } from '/imports/api/properties/Toggles.js';
@@ -39,9 +39,9 @@ const propertySchemasIndex = {
roll: RollSchema,
savingThrow: ComputedSavingThrowSchema,
skill: ComputedSkillSchema,
slot: SlotSchema,
spellList: ComputedSpellSchema,
spell: ComputedSpellListSchema,
propertySlot: ComputedSlotSchema,
spellList: ComputedSpellListSchema,
spell: ComputedSpellSchema,
toggle: ToggleSchema,
container: ContainerSchema,
item: ItemSchema,

View File

@@ -39,7 +39,7 @@ const propertySchemasIndex = {
roll: RollSchema,
savingThrow: SavingThrowSchema,
skill: SkillSchema,
slot: SlotSchema,
propertySlot: SlotSchema,
spellList: SpellListSchema,
spell: SpellSchema,
toggle: ToggleSchema,

View File

@@ -0,0 +1,115 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import Creatures from '/imports/api/creature/Creatures.js';
import Tabletops, { assertUserInTabletop } from '/imports/api/tabletop/Tabletops.js';
let Messages = new Mongo.Collection('messages');
let MessagesSchema = new SimpleSchema({
tabletopId: {
type: String,
regEx: SimpleSchema.RegEx.id,
},
content: {
type: String,
max: 1000,
},
timestamp: {
type: Date,
index: 1,
},
userId: {
type: String,
regEx: SimpleSchema.RegEx.id,
},
username: {
type: String,
},
});
Messages.attachSchema(MessagesSchema);
const sendMessage = new ValidatedMethod({
name: 'messages.send',
validate: new SimpleSchema({
content: {
type: String,
max: 1000,
},
tabletopId: {
type: String,
regEx: SimpleSchema.RegEx.id,
},
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 10,
timeInterval: 5000,
},
run({content, tabletopId}) {
let user = Meteor.user();
if (!user) {
throw new Meteor.Error('messages.send.denied',
'You need to be logged in to send a message');
}
assertUserInTabletop(tabletopId, this.userId);
return Messages.insert({
content,
tabletopId,
timestamp: new Date(),
userId: user._id,
username: user.username,
});
},
});
const removeMessages = new ValidatedMethod({
name: 'messages.remove',
validate: new SimpleSchema({
messageId: {
type: String,
regEx: SimpleSchema.RegEx.id,
},
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run({messageId, tabletopId}) {
if (!this.userId) {
throw new Meteor.Error('messages.remove.denied',
'You need to be logged in to remove a tabletop');
}
let message = Messages.findOne(messageId);
let tabletop = Tabletops.findOne(message.tabletopId);
if (this.userId !== message.userId && this.userId !== tabletop.gameMaster){
throw new Meteor.Error('messages.remove.denied',
'You don\'t have permission to remove this message');
}
let removed = Messages.remove({
_id: messageId,
});
Creatures.update({
tabletop: tabletopId,
}, {
$unset: {tabletop: 1},
});
return removed;
},
});
export default Messages;
export { sendMessage, removeMessages };

View File

@@ -0,0 +1,201 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import { assertUserHasPaidBenefits } from '/imports/api/users/patreon/tiers.js';
import Creatures from '/imports/api/creature/Creatures.js';
let Tabletops = new Mongo.Collection('tabletops');
const InitiativeSchema = new SimpleSchema({
active: {
type: Boolean,
defaultValue: false,
},
roundNumber: {
type: SimpleSchema.Integer,
defaultValue: 0,
},
initiativeNumber: {
type: SimpleSchema.Integer,
optional: true,
},
activeCreature: {
type: String,
regEx: SimpleSchema.RegEx.id,
optional: true,
},
});
// All creatures in a tabletop have a shared time and space.
let TabletopSchema = new SimpleSchema({
name: {
type: String,
optional: true,
},
initiative: {
type: InitiativeSchema,
defaultValue: {},
},
gameMaster: {
type: String,
regEx: SimpleSchema.RegEx.id,
},
players: {
type: Array,
defaultValue: [],
},
'players.$': {
type: String,
regEx: SimpleSchema.RegEx.id,
},
});
Tabletops.attachSchema(TabletopSchema);
function assertUserIsTabletopOwner(tabletopId, userId){
let tabletop = Tabletops.findOne(tabletopId);
if (!tabletop){
throw new Meteor.Error('Tabletop does not exist',
'No tabletop could be found for the given tabletop id');
}
if (tabletop.gameMaster !== userId){
throw new Meteor.Error('Not the owner',
'The user is not the owner of the given tabletop');
}
}
export function assertUserInTabletop(tabletopId, userId){
let tabletop = Tabletops.findOne(tabletopId);
if (!tabletop){
throw new Meteor.Error('Tabletop does not exist',
'No tabletop could be found for the given tabletop id');
}
if (tabletop.gameMaster !== userId && !tabletop.players.includes(userId)){
throw new Meteor.Error('Not in tabletop',
'The user is not the gamemaster or a player in the given tabletop');
}
}
function assertUserIsAdmin(userId){
if (!Meteor.users.isAdmin(userId)){
throw new Meteor.Error('Admin only',
'This is restricted to admins for now');
}
}
const insertTabletop = new ValidatedMethod({
name: 'tabletops.insert',
validate: null,
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run() {
if (!this.userId) {
throw new Meteor.Error('tabletops.insert.denied',
'You need to be logged in to insert a tabletop');
}
assertUserHasPaidBenefits(this.userId);
assertUserIsAdmin(this.userId);
return Tabletops.insert({
gameMaster: this.userId,
});
},
});
const removeTabletop = new ValidatedMethod({
name: 'tabletops.remove',
validate: new SimpleSchema({
tabletopId: {
type: String,
regEx: SimpleSchema.RegEx.id,
},
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run({tabletopId}) {
if (!this.userId) {
throw new Meteor.Error('tabletops.remove.denied',
'You need to be logged in to remove a tabletop');
}
assertUserHasPaidBenefits(this.userId);
assertUserIsTabletopOwner(tabletopId, this.userId);
assertUserIsAdmin(this.userId);
let removed = Tabletops.remove({
_id: tabletopId,
});
Creatures.update({
tabletop: tabletopId,
}, {
$unset: {tabletop: 1},
});
return removed;
},
});
const addCreaturesToTabletop = new ValidatedMethod({
name: 'tabletops.addCreatures',
validate: new SimpleSchema({
'creatureIds': {
type: Array,
},
'creatureIds.$': {
type: String,
regEx: SimpleSchema.RegEx.id,
},
tabletopId: {
type: String,
regEx: SimpleSchema.RegEx.id,
},
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 10,
timeInterval: 5000,
},
run({tabletopId, creatureIds}) {
if (!this.userId) {
throw new Meteor.Error('tabletops.addCreatures.denied',
'You need to be logged in to remove a tabletop');
}
assertUserHasPaidBenefits(this.userId);
assertUserInTabletop(tabletopId, this.userId);
assertUserIsAdmin(this.userId);
Creatures.update({
_id: {$in: creatureIds},
$or: [
{writers: this.userId},
{owner: this.userId},
],
}, {
$set: {tabletop: tabletopId},
}, {
multi: true,
});
},
});
export default Tabletops;
export { insertTabletop, removeTabletop, addCreaturesToTabletop };

View File

@@ -83,6 +83,15 @@ const userSchema = new SimpleSchema({
blackbox: true,
optional: true,
},
preferences: {
type: Object,
optional: true,
defaultValue: {},
},
'preferences.swapAbilityScoresAndModifiers': {
type: Boolean,
optional: true,
},
});
Meteor.users.attachSchema(userSchema);
@@ -192,6 +201,36 @@ Meteor.users.setUsername = new ValidatedMethod({
}
});
Meteor.users.setPreference = new ValidatedMethod({
name: 'users.setPreference',
validate: new SimpleSchema({
preference:{
type: String,
},
value: {
type: SimpleSchema.oneOf(Boolean),
},
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run({preference, value}){
if (!this.userId) throw 'You can only set preferences once logged in';
let prefPath = `preferences.${preference}`
if (value == true){
return Meteor.users.update(this.userId, {
$set: {[prefPath]: true},
});
} else {
return Meteor.users.update(this.userId, {
$unset: {[prefPath]: 1},
});
}
},
});
Meteor.users.subscribeToLibrary = new ValidatedMethod({
name: 'users.subscribeToLibrary',
validate: new SimpleSchema({

View File

@@ -77,5 +77,13 @@ export function getUserTier(user){
}
}
export function assertUserHasPaidBenefits(user){
let tier = getUserTier(user);
if (!tier.paidBenefits){
throw new Meteor.Error('Creatures.methods.insert.denied',
`The ${tier.name} tier does not allow you to insert a creature`);
}
}
export default TIERS;
export { GUEST_TIER };

View File

@@ -71,6 +71,10 @@ const PROPERTIES = Object.freeze({
icon: '$vuetify.icons.skill',
name: 'Skill'
},
propertySlot: {
icon: 'tab_unselected',
name: 'Slot'
},
spellList: {
icon: '$vuetify.icons.spell_list',
name: 'Spell list'

View File

@@ -1,8 +1,20 @@
import { create, all } from 'mathjs';
const math = create(all);
math.import({
'if': function(pred, a, b) {
return pred ? a : b;
},
'roll': function(number, diceSize){
let randomSrc = DDP.randomStream('diceRoller');
if (number > 100) throw 'Can only roll 100 dice at a time';
let rollTotal = 0;
let i, roll;
for (i = 0; i < number; i++){
roll = ~~(randomSrc.fraction() * diceSize) + 1
rollTotal += roll;
}
return rollTotal;
}
});

View File

@@ -0,0 +1,7 @@
import Discord from 'discord.js'
export default function sendWebhook({webhook, message}){
// const hook = new Discord.WebhookClient(webhook.id, webhook.token);
const hook = new Discord.WebhookClient('420492135716880394', 'KHmRsf9QHd81C4LZOyQe_cUw5ua4ugSaIlpDMNWo3vcNHs0p0JBOHfeGWtHKqPXMYgkk');
// Send a message using the webhook
hook.send(message);
}

View File

@@ -1,5 +1,5 @@
import Creatures from '/imports/api/creature/Creatures.js';
import Parties from '/imports/api/campaign/Parties.js';
import Parties from '/imports/api/creature/Parties.js';
Meteor.publish('characterList', function(){
this.autorun(function (){

View File

@@ -5,3 +5,4 @@ import '/imports/server/publications/singleCharacter.js';
import '/imports/server/publications/experiences.js';
import '/imports/server/publications/users.js';
import '/imports/server/publications/icons.js';
import '/imports/server/publications/tabletops.js';

View File

@@ -0,0 +1,60 @@
import Tabletops from '/imports/api/tabletop/Tabletops.js';
import Creatures from '/imports/api/creature/Creatures.js';
import Messages from '/imports/api/tabletop/Messages.js';
Meteor.publish('tabletops', function(){
var userId = this.userId;
if (!userId) {
return this.ready();
}
return Tabletops.find({
$or: [
{players: userId},
{gameMaster: userId},
],
});
});
Meteor.publish('tabletop', function(tabletopId){
var userId = this.userId;
if (!userId) {
return this.ready();
}
this.autorun(function (){
let tabletopCursor = Tabletops.find({
_id: tabletopId,
$or: [
{players: userId},
{gameMaster: userId},
]
});
let tabletop = tabletopCursor.fetch()[0];
if (!tabletop){
return this.ready();
}
// Warning, this leaks data to users of the same tabletop who may not have
// read permission of this specific creature, so publish as few fields as
// possible
let creatureSummaries = Creatures.find({
tabletop: tabletopId,
}, {
fields: {
name: 1,
picture: 1,
avatarPicture: 1,
variables: 1,
tabletop: 1,
initiativeRoll: 1,
},
});
let recentMessages = Messages.find({
tabletopId,
}, {
sort: {
timeStamp: -1,
},
limit: 100,
});
return [ tabletopCursor, creatureSummaries, recentMessages]
})
});

View File

@@ -11,6 +11,7 @@ Meteor.publish('user', function(){
darkMode: 1,
subscribedLibraries: 1,
profile: 1,
preferences: 1,
'services.patreon.id': 1,
'services.patreon.entitledCents': 1,
'services.patreon.entitledCentsOverride': 1,

View File

@@ -11,6 +11,7 @@
<v-btn
v-bind="$attrs"
v-on="on"
@click.stop
>
<slot>
<v-icon>add</v-icon>

View File

@@ -75,7 +75,9 @@ export default {
this.ackErrors = null;
} else if (typeof error === 'string'){
this.ackErrors = error;
} else {
} else if (error.reason){
this.ackErrors = error.reason;
} else {
this.ackErrors = 'Something went wrong'
console.error(error);
}

View File

@@ -0,0 +1,47 @@
<template
lang="html"
functional
>
<v-list-tile v-bind="$attrs">
<v-list-tile-avatar :color="model.color || 'grey'">
<img
v-if="model.avatarPicture"
:src="model.avatarPicture"
:alt="model.name"
>
<template v-else>
{{ model.initial }}
</template>
</v-list-tile-avatar>
<v-list-tile-content>
<v-list-tile-title>
{{ model.name }}
</v-list-tile-title>
<v-list-tile-sub-title>
{{ model.alignment }} {{ model.gender }} {{ model.race }}
</v-list-tile-sub-title>
</v-list-tile-content>
<v-list-tile-action v-if="selection">
<v-checkbox
:input-value="selected && selected.has(model._id)"
@change="$emit('select')"
/>
</v-list-tile-action>
</v-list-tile>
</template>
<script type="text/javascript">
export default {
props: {
model: {
type: Object,
required: true,
},
selection: Boolean,
selected: {
type: Set,
default: () => new Set(),
},
}
}
</script>

View File

@@ -0,0 +1,11 @@
<template lang="html">
<div class="mini-character-sheet" />
</template>
<script>
export default {
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -16,7 +16,7 @@
</template>
<script>
import CreatureProperties from '/imports/api/creature/CreatureProperties.js';
import getActiveProperties from '/imports/api/creature/getActiveProperties.js';
import ColumnLayout from '/imports/ui/components/ColumnLayout.vue';
import FeatureCard from '/imports/ui/properties/components/features/FeatureCard.vue';
@@ -33,13 +33,12 @@
},
meteor: {
features(){
return CreatureProperties.find({
'ancestors.id': this.creatureId,
type: 'feature',
removed: {$ne: true},
}, {
sort: {order: 1},
});
return getActiveProperties({
ancestorId: this.creatureId,
filter: {
type: 'feature',
},
});
},
},
methods: {

View File

@@ -1,27 +1,33 @@
<template lang="html">
<div class="inventory">
<column-layout>
<column-layout wide-columns>
<div>
<toolbar-card :color="$vuetify.theme.secondary">
<v-spacer slot="toolbar" />
<v-switch
v-if="context.editPermission !== false"
slot="toolbar"
v-model="organize"
label="Organize"
class="justify-end"
/>
<toolbar-card
:color="context.creature.color"
>
<v-toolbar-title slot="toolbar">
Equipped
</v-toolbar-title>
<v-card-text class="px-0">
<creature-properties-tree
:root="{collection: 'creatures', id: creatureId}"
:filter="{
type: {$in: ['item']},
'ancestors.id': {$nin: containerIds}
}"
:organize="organize"
group="inventory"
@selected="e => clickProperty(e)"
@reorganized="({doc}) => setEquipped(doc, false)"
<item-list
equipment
:items="equippedItems"
:parent-ref="{id: creatureId, collection: 'creatures'}"
/>
</v-card-text>
</toolbar-card>
</div>
<div>
<toolbar-card
:color="context.creature.color"
>
<v-toolbar-title slot="toolbar">
Carried
</v-toolbar-title>
<v-card-text class="px-0">
<item-list
:items="carriedItems"
:parent-ref="{id: creatureId, collection: 'creatures'}"
/>
</v-card-text>
</toolbar-card>
@@ -32,7 +38,6 @@
>
<container-card
:model="container"
:organize="organize"
/>
</div>
</column-layout>
@@ -42,23 +47,27 @@
<script>
import CreatureProperties from '/imports/api/creature/CreatureProperties.js';
import ColumnLayout from '/imports/ui/components/ColumnLayout.vue';
import CreaturePropertiesTree from '/imports/ui/creature/creatureProperties/CreaturePropertiesTree.vue';
import ContainerCard from '/imports/ui/properties/components/inventory/ContainerCard.vue';
import ToolbarCard from '/imports/ui/components/ToolbarCard.vue';
import ItemList from '/imports/ui/properties/components/inventory/ItemList.vue';
import { updateProperty } from '/imports/api/creature/CreatureProperties.js';
import getActiveProperties from '/imports/api/creature/getActiveProperties.js';
export default {
components: {
ColumnLayout,
CreaturePropertiesTree,
ContainerCard,
ToolbarCard,
ItemList,
},
inject: {
context: { default: {} }
},
props: {
creatureId: String,
creatureId: {
type: String,
required: true,
},
},
data(){ return {
organize: false,
@@ -85,6 +94,26 @@ export default {
sort: {order: 1},
});
},
carriedItems(){
return getActiveProperties({
ancestorId: this.creatureId,
includeUnequipped: true,
filter: {
type: 'item',
equipped: {$ne: true},
'parent.id': this.creatureId
},
});
},
equippedItems(){
return getActiveProperties({
ancestorId: this.creatureId,
filter: {
type: 'item',
equipped: true,
},
});
},
},
computed: {
containerIds(){

View File

@@ -139,7 +139,7 @@ export default {
let highestLevels = {};
let highestLevelsList = [];
this.classLevels.forEach(classLevel => {
let name = classLevel.vairableName;
let name = classLevel.variableName;
if (
!highestLevels[name] ||
highestLevels[name].level < classLevel.level

View File

@@ -1,27 +1,12 @@
<template lang="html">
<div class="spells">
<column-layout>
<div>
<column-layout wide-columns>
<div v-if="spellsWithoutList.length">
<v-card>
<v-card-text>
<v-switch
v-if="context.editPermission !== false"
v-model="organize"
label="Organize"
class="justify-end"
/>
<creature-properties-tree
:root="{collection: 'creatures', id: creatureId}"
:filter="{
equipped: {$ne: true},
type: 'spell',
'ancestors.id': {$nin: spellListIds}
}"
:organize="organize"
group="spells"
@selected="e => clickProperty(e)"
/>
</v-card-text>
<spell-list
:spells="spellsWithoutList"
:parent-ref="{id: creatureId, collection: 'creatures'}"
/>
</v-card>
</div>
<div
@@ -38,15 +23,15 @@
</template>
<script>
import CreatureProperties from '/imports/api/creature/CreatureProperties.js';
import ColumnLayout from '/imports/ui/components/ColumnLayout.vue';
import CreaturePropertiesTree from '/imports/ui/creature/creatureProperties/CreaturePropertiesTree.vue';
import getActiveProperties from '/imports/api/creature/getActiveProperties.js';
import SpellListCard from '/imports/ui/properties/components/spells/SpellListCard.vue';
import SpellList from '/imports/ui/properties/components/spells/SpellList.vue';
export default {
components: {
ColumnLayout,
CreaturePropertiesTree,
SpellList,
SpellListCard,
},
inject: {
@@ -63,25 +48,36 @@ export default {
}},
meteor: {
spellLists(){
return CreatureProperties.find({
'ancestors.id': this.creatureId,
type: 'spellList',
removed: {$ne: true},
}, {
sort: {order: 1},
});
return getActiveProperties({
ancestorId: this.creatureId,
filter: {
type: 'spellList',
},
});
},
spellsWithoutList(){
return getActiveProperties({
ancestorId: this.creatureId,
excludeAncestors: this.spellListIds,
filter: {
type: 'spell',
},
options: {
sort: {
level: 1,
order: 1,
},
},
});
},
spellListsWithoutAncestorSpellLists(){
return CreatureProperties.find({
'ancestors.id': {
$eq: this.creatureId,
$nin: this.spellListIds
},
type: 'spellList',
removed: {$ne: true},
}, {
sort: {order: 1},
});
return getActiveProperties({
ancestorId: this.creatureId,
excludeAncestors: this.spellListIds,
filter: {
type: 'spellList',
},
});
},
},
computed: {
@@ -93,7 +89,7 @@ export default {
clickProperty(_id){
this.$store.commit('pushDialogStack', {
component: 'creature-property-dialog',
elementId: `tree-node-${_id}`,
elementId: `spell-list-tile-${_id}`,
data: {_id},
});
},

View File

@@ -21,6 +21,39 @@
</v-card-text>
</v-card>
</div>
<div
v-if="appliedBuffs.length"
class="buffs"
>
<v-card>
<v-list>
<v-subheader>Buffs and conditions</v-subheader>
<v-list-tile
v-for="buff in appliedBuffs"
:key="buff._id"
:data-id="buff._id"
@click="clickProperty({_id: buff._id})"
>
<v-list-tile-content>
<v-list-tile-title>
{{ buff.name }}
</v-list-tile-title>
</v-list-tile-content>
<v-list-tile-action>
<v-btn
icon
flat
@click.stop="softRemove(buff._id)"
>
<v-icon>delete</v-icon>
</v-btn>
</v-list-tile-action>
</v-list-tile>
</v-list>
</v-card>
</div>
<div class="ability-scores">
<v-card>
<v-list>
@@ -165,6 +198,30 @@
</v-card>
</div>
<div
v-for="action in actions"
:key="action._id"
class="actions"
>
<action-card
:model="action"
:data-id="action._id"
@click="clickProperty({_id: action._id})"
/>
</div>
<div
v-for="attack in attacks"
:key="attack._id"
class="attacks"
>
<action-card
attack
:model="attack"
:data-id="attack._id"
@click="clickProperty({_id: attack._id})"
/>
</div>
<div
v-if="weapons && weapons.length"
class="weapon-proficiencies"
@@ -187,7 +244,7 @@
</div>
<div
v-if="armors && armors.length"
class="weapon-proficiencies"
class="armor-proficiencies"
>
<v-card>
<v-list>
@@ -207,7 +264,7 @@
</div>
<div
v-if="tools && tools.length"
class="weapon-proficiencies"
class="tool-proficiencies"
>
<v-card>
<v-list>
@@ -245,37 +302,13 @@
</v-list>
</v-card>
</div>
<div
v-for="action in actions"
:key="action._id"
class="actions"
>
<action-card
:model="action"
:data-id="action._id"
@click="clickProperty({_id: action._id})"
/>
</div>
<div
v-for="attack in attacks"
:key="attack._id"
class="attacks"
>
<action-card
attack
:model="attack"
:data-id="attack._id"
@click="clickProperty({_id: attack._id})"
/>
</div>
</column-layout>
</div>
</template>
<script>
import Creatures from '/imports/api/creature/Creatures.js';
import { damageProperty } from '/imports/api/creature/CreatureProperties.js';
import { damageProperty, softRemoveProperty } from '/imports/api/creature/CreatureProperties.js';
import AttributeCard from '/imports/ui/properties/components/attributes/AttributeCard.vue';
import AbilityListTile from '/imports/ui/properties/components/attributes/AbilityListTile.vue';
import ColumnLayout from '/imports/ui/components/ColumnLayout.vue';
@@ -381,6 +414,9 @@
actions(){
return getProperties(this.creature, {type: 'action'});
},
appliedBuffs(){
return getProperties(this.creature, {type: 'buff', applied: true});
},
attacks(){
let props = getProperties(this.creature, {type: 'attack'}).map(attack => {
attack.children = getActiveProperties({
@@ -409,6 +445,11 @@
if (!obj) return 0;
return Object.keys(obj).length;
},
softRemove(_id){
softRemoveProperty.call({_id}, error => {
if (error) console.error(error);
});
}
},
};
</script>

View File

@@ -11,6 +11,7 @@ import LibraryEditDialog from '/imports/ui/library/LibraryEditDialog.vue';
import LibraryNodeCreationDialog from '/imports/ui/library/LibraryNodeCreationDialog.vue';
import LibraryNodeDialog from '/imports/ui/library/LibraryNodeDialog.vue';
import MoveLibraryNodeDialog from '/imports/ui/library/MoveLibraryNodeDialog.vue'
import SelectCreaturesDialog from '/imports/ui/tabletop/SelectCreaturesDialog.vue';
import ShareDialog from '/imports/ui/sharing/ShareDialog.vue';
import TierTooLowDialog from '/imports/ui/user/TierTooLowDialog.vue';
import UsernameDialog from '/imports/ui/user/UsernameDialog.vue';
@@ -29,6 +30,7 @@ export default {
LibraryNodeCreationDialog,
LibraryNodeDialog,
MoveLibraryNodeDialog,
SelectCreaturesDialog,
ShareDialog,
TierTooLowDialog,
UsernameDialog,

View File

@@ -115,7 +115,7 @@
<script>
import Creatures from '/imports/api/creature/Creatures.js';
import Parties from '/imports/api/campaign/Parties.js';
import Parties from '/imports/api/creature/Parties.js';
export default {
meteor: {
@@ -135,6 +135,7 @@
{title: 'Home', icon: 'home', to: '/'},
{title: 'Characters', icon: 'portrait', to: '/characterList', requireLogin: true},
{title: 'Library', icon: 'book', to: '/library', requireLogin: true},
//{title: 'Tabletops', icon: 'api', to: '/tabletops', requireLogin: true},
//{title: 'Friends', icon: 'people', to: '/friends', requireLogin: true},
{title: 'Feedback', icon: 'bug_report', to: '/feedback'},
{title: 'About', icon: 'subject', to: '/about'},

View File

@@ -8,6 +8,7 @@
<v-expansion-panel
v-model="expandedLibrary"
style="box-shadow: none;"
expand
>
<v-expansion-panel-content
v-for="library in libraries"

View File

@@ -1,14 +1,13 @@
<template lang="html">
<tree-node-list
:children="libraryChildren"
:group="library && library._id"
:organize="organizeMode"
:selected-node-id="selectedNodeId"
@selected="e => $emit('selected', e)"
@reordered="reordered"
@reorganized="reorganized"
/>
<tree-node-list
group="library"
:children="libraryChildren"
:organize="organizeMode"
:selected-node-id="selectedNodeId"
@selected="e => $emit('selected', e)"
@reordered="reordered"
@reorganized="reorganized"
/>
</template>
<script>

View File

@@ -6,12 +6,23 @@
>
<v-list>
<v-list-tile>
<v-switch
:input-value="darkMode"
<smart-switch
:value="darkMode"
label="Dark mode"
@change="setDarkMode"
/>
</v-list-tile>
<v-list-tile>
<smart-switch
label="Swap ability scores and modifiers"
:value="
user &&
user.preferences &&
user.preferences.swapAbilityScoresAndModifiers
"
@change="swapAbilityScoresAndModifiers"
/>
</v-list-tile>
<v-subheader>
Username
@@ -186,8 +197,14 @@
Meteor.logout();
router.push('/');
},
setDarkMode(value){
Meteor.users.setDarkMode.call({darkMode: !!value});
setDarkMode(value, ack){
Meteor.users.setDarkMode.call({darkMode: !!value}, ack);
},
swapAbilityScoresAndModifiers(value, ack){
Meteor.users.setPreference.call({
preference: 'swapAbilityScoresAndModifiers',
value: !!value,
}, ack);
},
generateKey(){
Meteor.users.gnerateApiKey.call(error => {

View File

@@ -2,30 +2,12 @@
<div>
<v-card class="ma-4">
<v-list v-if="CreaturesWithNoParty.length">
<v-list-tile
<creature-list-tile
v-for="character in CreaturesWithNoParty"
:key="character._id"
:to="character.url"
>
<v-list-tile-avatar :color="character.color || 'grey'">
<img
v-if="character.avatarPicture"
:src="character.avatarPicture"
:alt="character.name"
>
<template v-else>
{{ character.initial }}
</template>
</v-list-tile-avatar>
<v-list-tile-content>
<v-list-tile-title>
{{ character.name }}
</v-list-tile-title>
<v-list-tile-sub-title>
{{ character.alignment }} {{ character.gender }} {{ character.race }}
</v-list-tile-sub-title>
</v-list-tile-content>
</v-list-tile>
:model="character"
/>
</v-list>
<v-expansion-panel popout>
<v-expansion-panel-content
@@ -98,9 +80,10 @@
<script>
import Creatures, {insertCreature} from '/imports/api/creature/Creatures.js';
import Parties from '/imports/api/campaign/Parties.js';
import Parties from '/imports/api/creature/Parties.js';
import LabeledFab from '/imports/ui/components/LabeledFab.vue';
import { getUserTier } from '/imports/api/users/patreon/tiers.js';
import CreatureListTile from '/imports/ui/creature/CreatureListTile.vue';
const characterTransform = function(char){
char.url = `/character/${char._id}/${char.urlName || '-'}`;
@@ -110,6 +93,7 @@
export default {
components: {
LabeledFab,
CreatureListTile,
},
data(){ return{
fab: false,

View File

@@ -0,0 +1,46 @@
<template lang="html">
<div
class="tabletop-page"
style="height: 100%;"
>
<div
v-if="!this.$subReady.tabletop"
class="layout column align-center justify-center"
style="height: 100%;"
>
<v-progress-circular indeterminate />
</div>
<tabletop-component
v-else-if="tabletop"
:model="tabletop"
/>
<div
v-else
class="pa-4"
>
<p>This tabletop was not found</p>
<p>Either it does not exist, or you do not have permission to view it</p>
</div>
</div>
</template>
<script>
import Tabletops from '/imports/api/tabletop/Tabletops.js';
import TabletopComponent from '/imports/ui/tabletop/TabletopComponent.vue';
export default {
components: {
TabletopComponent,
},
meteor: {
tabletop(){
return Tabletops.findOne(this.$route.params.id);
},
$subscribe: {
'tabletop'(){
return [this.$route.params.id];
},
}
}
}
</script>

View File

@@ -0,0 +1,67 @@
<template lang="html">
<single-card-layout class="tabletops">
<v-list
v-if="tabletops.length"
class="tabletops"
>
<v-list-tile
v-for="tabletop in tabletops"
:key="tabletop._id"
:to="`/tabletop/${tabletop._id}`"
>
<v-list-tile-content>
<v-list-tile-title>
{{ tabletop.name || 'Unnamed Tabletop' }}
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
</v-list>
<v-card-text v-else>
You don't own or belong to any tabletops yet
</v-card-text>
<v-btn
color="primary"
fab
fixed
bottom
right
:loading="addTabletopLoading"
@click="addTabletop"
>
<v-icon>add</v-icon>
</v-btn>
</single-card-layout>
</template>
<script>
import SingleCardLayout from '/imports/ui/layouts/SingleCardLayout.vue'
import Tabletops, { insertTabletop } from '/imports/api/tabletop/Tabletops.js';
export default {
components: {
SingleCardLayout,
},
data(){return {
addTabletopLoading: false,
}},
meteor: {
tabletops(){
return Tabletops.find();
},
$subscribe: {
'tabletops': [],
},
},
methods: {
addTabletop(){
this.addTabletopLoading = true;
insertTabletop.call(() => {
this.addTabletopLoading = false;
});
}
}
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -5,10 +5,20 @@
>
<v-list-tile-action class="mr-4">
<div class="display-1 mod">
{{ numberToSignedString(model.modifier) }}
<template v-if="swapScoresAndMods">
{{ model.value }}
</template>
<template v-else>
{{ numberToSignedString(model.modifier) }}
</template>
</div>
<div class="title value">
{{ model.value }}
<template v-if="swapScoresAndMods">
{{ numberToSignedString(model.modifier) }}
</template>
<template v-else>
{{ model.value }}
</template>
</div>
</v-list-tile-action>
@@ -36,7 +46,15 @@ export default {
click(e){
this.$emit('click', e);
},
}
},
meteor: {
swapScoresAndMods(){
let user = Meteor.user();
return user &&
user.preferences &&
user.preferences.swapAbilityScoresAndModifiers;
}
}
}
</script>

View File

@@ -10,6 +10,25 @@
@change="e => $emit('change', {_id: attribute._id, change: e})"
@click="e => $emit('click', {_id: attribute._id})"
/>
<div class="ma-3">
<span
v-if="multipliers.vulnerabilities.length"
:class="{'mr-2': multipliers.resistances.length || multipliers.immunities.length}"
>
<b>Vulnerability:</b> {{ multipliers.vulnerabilities.join(', ') }}
</span>
<span
v-if="multipliers.resistances.length"
:class="{'mr-2': multipliers.immunities.length}"
>
<b>Resistance:</b> {{ multipliers.resistances.join(', ') }}
</span>
<span
v-if="multipliers.immunities.length"
>
<b>Immunity:</b> {{ multipliers.immunities.join(', ') }}
</span>
</div>
</v-card>
</template>
@@ -17,11 +36,33 @@
import HealthBar from '/imports/ui/properties/components/attributes/HealthBar.vue';
export default {
inject: {
context: { default: {} }
},
components: {
HealthBar,
},
props: {
attributes: Array,
attributes: {
type: Array,
required: true
},
},
computed: {
multipliers() {
let damageMultipliers = this.context.creature.damageMultipliers;
let vulnerabilities = [];
let resistances = [];
let immunities = [];
for (let key in damageMultipliers){
switch(damageMultipliers[key]){
case 2: vulnerabilities.push(key); break;
case 0.5: resistances.push(key); break;
case 0: immunities.push(key); break;
}
}
return {vulnerabilities, resistances, immunities};
}
},
}
</script>

View File

@@ -11,12 +11,9 @@
<v-spacer />
</template>
<v-card-text class="px-0">
<creature-properties-tree
:root="{collection: 'creatureProperties', id: model._id}"
:filter="{type: {$in: ['container', 'item', 'folder']}}"
:organize="organize"
group="inventory"
@selected="e => clickProperty(e)"
<item-list
:items="items"
:parent-ref="{id: model._id, collection: 'creatureProperties'}"
/>
</v-card-text>
</toolbar-card>
@@ -24,16 +21,19 @@
<script>
import ToolbarCard from '/imports/ui/components/ToolbarCard.vue';
import CreaturePropertiesTree from '/imports/ui/creature/creatureProperties/CreaturePropertiesTree.vue';
import ItemList from '/imports/ui/properties/components/inventory/ItemList.vue';
import getActiveProperties from '/imports/api/creature/getActiveProperties.js';
export default {
components: {
ToolbarCard,
CreaturePropertiesTree,
ItemList,
},
props: {
model: Object,
organize: Boolean,
model: {
type: Object,
required: true,
},
},
methods: {
clickContainer(_id){
@@ -50,7 +50,20 @@ export default {
data: {_id},
});
},
}
},
meteor: {
items(){
return getActiveProperties({
ancestorId: this.model._id,
includeUnequipped: true,
filter: {
type: {$in: ['item', 'container']},
'parent.id': this.model._id,
equipped: {$ne: true},
},
});
},
}
};
</script>

View File

@@ -0,0 +1,113 @@
<template lang="html">
<v-list
two-line
dense
class="item-list"
>
<draggable
v-model="dataItems"
style="min-height: 24px;"
:group="`item-list`"
ghost-class="ghost"
draggable=".item"
handle=".handle"
:animation="200"
@change="change"
>
<item-list-tile
v-for="item in dataItems"
:key="item._id"
class="item"
:data-id="`item-list-tile-${item._id}`"
:model="item"
@click="clickProperty(item._id)"
/>
</draggable>
</v-list>
</template>
<script>
import draggable from 'vuedraggable';
import ItemListTile from '/imports/ui/properties/components/inventory/ItemListTile.vue';
import { organizeDoc } from '/imports/api/parenting/organizeMethods.js';
import { updateProperty } from '/imports/api/creature/CreatureProperties.js';
export default {
components: {
draggable,
ItemListTile,
},
props: {
items: {
type: Array,
default: () => [],
},
parentRef: {
type: Object,
required: true,
},
preparingSpells: Boolean,
equipment: Boolean,
},
data(){ return {
dataItems: [],
}},
computed: {
levels(){
let levels = new Set();
this.items.forEach(item => levels.add(item.level));
return levels;
},
},
watch: {
items(value){
this.dataItems = value;
}
},
mounted(){
this.dataItems = this.items;
},
methods: {
clickProperty(_id){
this.$store.commit('pushDialogStack', {
component: 'creature-property-dialog',
elementId: `item-list-tile-${_id}`,
data: {_id},
});
},
change({added, moved}){
let event = added || moved;
if (event){
// If this item is now adjacent to another, set the order accordingly
let order;
let before = this.dataItems[event.newIndex - 1];
let after = this.dataItems[event.newIndex + 1];
if (before && before._id){
order = before.order + 0.5;
} else if (after && after._id) {
order = after.order - 0.5;
} else {
order = -0.5;
}
let doc = event.element;
organizeDoc.call({
docRef: {
id: doc._id,
collection: 'creatureProperties',
},
parentRef: this.parentRef,
order,
});
if (doc.type === 'item' && doc.equipped != this.equipment){
updateProperty.call({
_id: doc._id,
path: ['equipped'],
value: !!this.equipment,
});
}
}
setTimeout(() => this.dataItems = this.items, 0);
},
}
}
</script>

View File

@@ -0,0 +1,102 @@
<template lang="html">
<v-list-tile
class="item"
v-on="hasClickListener ? {click} : {}"
>
<v-list-tile-avatar class="item-avatar">
<property-icon
class="mr-2"
:model="model"
:color="model.color"
/>
</v-list-tile-avatar>
<v-list-tile-content>
<v-list-tile-title>
{{ title }}
</v-list-tile-title>
</v-list-tile-content>
<v-list-tile-action>
<increment-button
v-if="context.creature && model.showIncrement"
icon
flat
color="primary"
:value="model.quantity"
@change="changeQuantity"
>
<v-icon>
$vuetify.icons.abacus
</v-icon>
</increment-button>
</v-list-tile-action>
<v-list-tile-action>
<v-icon
style="height: 100%; width: 40px; cursor: move;"
class="handle"
>
drag_indicator
</v-icon>
</v-list-tile-action>
</v-list-tile>
</template>
<script>
import treeNodeViewMixin from '/imports/ui/properties/treeNodeViews/treeNodeViewMixin.js';
import PROPERTIES from '/imports/constants/PROPERTIES.js';
import { adjustQuantity } from '/imports/api/creature/CreatureProperties.js';
import IncrementButton from '/imports/ui/components/IncrementButton.vue';
export default {
components:{
IncrementButton,
},
mixins: [treeNodeViewMixin],
inject: {
context: { default: {} }
},
props: {
preparingSpells: Boolean,
},
computed: {
hasClickListener(){
return this.$listeners && !!this.$listeners.click;
},
title(){
let model = this.model;
if (!model) return;
if (model.quantity !== 1){
if (model.plural){
return `${model.quantity} ${model.plural}`;
} else if (model.name){
return `${model.quantity} ${model.name}`;
}
} else if (model.name) {
return model.name;
}
let prop = PROPERTIES[model.type]
return prop && prop.name;
}
},
methods: {
click(e){
this.$emit('click', e);
},
changeQuantity({type, value}) {
adjustQuantity.call({
_id: this.model._id,
operation: type,
value: value
});
}
},
}
</script>
<style lang="css" scoped>
.item-avatar {
min-width: 32px;
}
.item {
background-color: inherit;
}
</style>

View File

@@ -0,0 +1,142 @@
<template lang="html">
<v-list
two-line
dense
class="spell-list"
>
<draggable
v-model="computedSpells"
style="min-height: 24px;"
:group="`spell-list`"
ghost-class="ghost"
draggable=".item"
handle=".handle"
:animation="200"
@change="change"
>
<template v-for="spell in computedSpells">
<v-subheader
v-if="spell.isSubheader"
:key="`${spell.level}-header`"
class="item"
>
{{ spell.level === 0 ? 'Cantrips' : `Level ${spell.level}` }}
</v-subheader>
<spell-list-tile
v-else
:key="spell._id"
class="item"
:data-id="`spell-list-tile-${spell._id}`"
:model="spell"
:preparing-spells="preparingSpells"
@click="clickProperty(spell._id)"
/>
</template>
</draggable>
</v-list>
</template>
<script>
import draggable from 'vuedraggable';
import SpellListTile from '/imports/ui/properties/components/spells/SpellListTile.vue';
import { organizeDoc } from '/imports/api/parenting/organizeMethods.js';
function spellsWithSubheaders(spells = []){
let result = [];
let lastSpell = undefined;
let sortedSpells = [...spells].sort((a, b) => a.level - b.level)
sortedSpells.forEach(spell => {
if (spell.isSubheader) return;
if (!lastSpell || spell.level > lastSpell.level){
result.push({
isSubheader: true,
level: spell.level,
});
}
result.push(spell);
lastSpell = spell;
});
return result;
}
export default {
components: {
draggable,
SpellListTile,
},
props: {
spells: {
type: Array,
default: () => [],
},
parentRef: {
type: Object,
required: true,
},
preparingSpells: Boolean,
},
data(){ return {
dataSpells: [],
}},
computed: {
levels(){
let levels = new Set();
this.spells.forEach(spell => levels.add(spell.level));
return levels;
},
computedSpells: {
get(){
return spellsWithSubheaders(this.dataSpells);
},
set(value){
this.dataSpells = value;
},
}
},
watch: {
spells(value){
this.dataSpells = spellsWithSubheaders(value);
}
},
mounted(){
this.dataSpells = spellsWithSubheaders(this.spells);
},
methods: {
clickProperty(_id){
this.$store.commit('pushDialogStack', {
component: 'creature-property-dialog',
elementId: `spell-list-tile-${_id}`,
data: {_id},
});
},
change({added, moved}){
let event = added || moved;
if (event){
// If this spell is now adjacent to another, set the order accordingly
let order;
let before = this.dataSpells[event.newIndex - 1];
let after = this.dataSpells[event.newIndex + 1];
if (before && before._id){
order = before.order + 0.5;
} else if (after && after._id) {
order = after.order - 0.5;
} else {
order = -0.5;
}
let doc = event.element;
organizeDoc.call({
docRef: {
id: doc._id,
collection: 'creatureProperties',
},
parentRef: this.parentRef,
order,
});
}
},
}
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -9,33 +9,104 @@
{{ model.name }}
</v-toolbar-title>
<v-spacer />
<v-menu
bottom
left
transition="slide-y-transition"
style="margin-right: -12px;"
>
<template #activator="{ on }">
<v-btn
icon
v-on="on"
@click.stop
>
<v-icon>more_vert</v-icon>
</v-btn>
</template>
<v-list class="pa-2">
<v-switch
v-model="preparingSpells"
class="ma-2"
label="Change prepared spells"
hide-details
/>
</v-list>
</v-menu>
</template>
<v-card-text>
<creature-properties-tree
:root="{collection: 'creatureProperties', id: model._id}"
:filter="{type: {$in: ['spellList', 'spell', 'folder']}}"
:organize="organize"
group="spells"
@selected="e => clickProperty(e)"
/>
</v-card-text>
<v-expand-transition>
<v-card-text
v-if="preparedError || preparingSpells"
:class="{'error--text' : preparedError}"
class="pb-0"
>
{{ numPrepared }}/{{ model.maxPreparedResult }} spells prepared
<v-switch
v-model="preparingSpells"
label="Change prepared spells"
/>
</v-card-text>
</v-expand-transition>
<spell-list
:spells="spells"
:parent-ref="{id: model._id, collection: 'creatureProperties'}"
:preparing-spells="preparingSpells"
/>
</toolbar-card>
</template>
<script>
import CreatureProperties from '/imports/api/creature/CreatureProperties.js';
import ToolbarCard from '/imports/ui/components/ToolbarCard.vue';
import CreaturePropertiesTree from '/imports/ui/creature/creatureProperties/CreaturePropertiesTree.vue';
import SpellList from '/imports/ui/properties/components/spells/SpellList.vue';
import getActiveProperties from '/imports/api/creature/getActiveProperties.js';
export default {
components: {
ToolbarCard,
CreaturePropertiesTree,
SpellList,
},
props: {
model: Object,
model: {
type: Object,
required: true,
},
organize: Boolean,
},
data(){ return {
preparingSpells: false,
}},
meteor: {
spells(){
return getActiveProperties({
ancestorId: this.model._id,
filter: {
type: 'spell',
},
options: {
sort: {
level: 1,
order: 1,
},
},
includeUnprepared: this.preparingSpells,
});
},
numPrepared(){
return getActiveProperties({
ancestorId: this.model._id,
filter: {
type: 'spell',
prepared: true,
alwaysPrepared: {$ne: true},
},
}).length;
},
preparedError(){
let numPrepared = this.numPrepared;
let maxPrepared = this.model.maxPreparedResult;
return numPrepared !== maxPrepared
},
},
methods: {
clickSpellList(_id){
this.$store.commit('pushDialogStack', {
@@ -44,13 +115,6 @@ export default {
data: {_id},
});
},
clickProperty(_id){
this.$store.commit('pushDialogStack', {
component: 'creature-property-dialog',
elementId: `tree-node-${_id}`,
data: {_id},
});
},
}
};
</script>

View File

@@ -0,0 +1,85 @@
<template lang="html">
<v-list-tile
class="spell"
v-on="hasClickListener ? {click} : {}"
>
<v-list-tile-avatar class="spell-avatar">
<property-icon
class="mr-2"
:model="model"
:color="model.color"
/>
</v-list-tile-avatar>
<v-list-tile-content>
<v-list-tile-title>
{{ title }}
</v-list-tile-title>
<v-list-tile-sub-title v-if="components">
{{ components }}
</v-list-tile-sub-title>
</v-list-tile-content>
<v-list-tile-action>
<smart-checkbox
v-if="preparingSpells"
:value="model.prepared || model.alwaysPrepared"
:disabled="model.alwaysPrepared"
@click.native.stop="() => {}"
@change="setPrepared"
/>
<v-icon
v-else
style="height: 100%; width: 40px; cursor: move;"
class="handle"
>
drag_indicator
</v-icon>
</v-list-tile-action>
</v-list-tile>
</template>
<script>
import treeNodeViewMixin from '/imports/ui/properties/treeNodeViews/treeNodeViewMixin.js';
import {updateProperty} from '/imports/api/creature/CreatureProperties.js';
export default {
mixins: [treeNodeViewMixin],
props: {
preparingSpells: Boolean,
},
computed: {
hasClickListener(){
return this.$listeners && !!this.$listeners.click;
},
components(){
let components = [];
if (this.model.ritual) components.push('R');
if (this.model.concentration) components.push('C');
if (this.model.verbal) components.push('V');
if (this.model.somatic) components.push('S');
if (this.model.material) components.push(`M (${this.model.material})`);
return components.join(', ');
},
},
methods: {
click(e){
this.$emit('click', e);
},
setPrepared(val, ack){
updateProperty.call({
_id: this.model._id,
path: ['prepared'],
value: val
}, ack);
}
},
}
</script>
<style lang="css" scoped>
.spell-avatar {
min-width: 32px;
}
.spell {
background-color: inherit;
}
</style>

View File

@@ -47,6 +47,14 @@
:menu-props="{auto: true, lazy: true}"
@change="change('hitDiceSize', ...arguments)"
/>
<text-field
v-if="model.attributeType === 'spellSlot'"
label="Spell slot level"
:value="model.spellSlotLevelCalculation"
:error-messages="errors.spellSlotLevelCalculation"
@change="change('spellSlotLevelCalculation', ...arguments)"
/>
<calculation-error-list :errors="model.spellSlotLevelErrors" />
<text-area
label="Description"
:value="model.description"

View File

@@ -13,6 +13,7 @@
:error-messages="errors.description"
@change="change('description', ...arguments)"
/>
<!-- Duration not implemented yet
<text-field
label="Duration"
hint="How long the buff lasts"
@@ -20,6 +21,16 @@
:error-messages="errors.duration"
@change="change('duration', ...arguments)"
/>
-->
<smart-select
label="Target"
:hint="targetOptionHint"
:items="targetOptions"
:value="model.target"
:error-messages="errors.target"
:menu-props="{auto: true, lazy: true}"
@change="change('target', ...arguments)"
/>
</div>
</template>

View File

@@ -0,0 +1,99 @@
<template lang="html">
<div class="spell-form">
<text-field
ref="focusFirst"
label="Name"
:value="model.name"
:error-messages="errors.name"
@change="change('name', ...arguments)"
/>
<smart-select
label="Type"
style="flex-basis: 300px;"
:items="slotTypes"
:value="model.slotType"
:error-messages="errors.slotType"
@change="change('slotType', ...arguments)"
/>
<smart-combobox
label="Tags"
hint="The slot must be filled with a property which has all the listed tags"
multiple
chips
deletable-chips
:value="model.slotTags"
:error-messages="errors.slotTags"
@change="change('slotTags', ...arguments)"
/>
<text-field
label="Quantity"
type="number"
min="1"
hint="How many matching properties must be used to fill this slot"
:value="model.quantityExpected"
:error-messages="errors.quantityExpected"
@change="change('quantityExpected', ...arguments)"
/>
<text-field
label="Condition"
hint="A caclulation to determine if this slot should be active"
placeholder="Always active"
:value="model.slotCondition"
:error-messages="errors.slotCondition"
@change="change('slotCondition', ...arguments)"
/>
<calculation-error-list :errors="model.slotConditionErrors" />
<text-area
label="Description"
:value="model.description"
:error-messages="errors.description"
@change="change('description', ...arguments)"
/>
<form-section
name="Advanced"
standalone
>
<text-field
label="Quantity filled"
type="number"
min="0"
hint="How many properties have already been selected to fill this slot"
:value="model.quantityFilled"
:error-messages="errors.quantityFilled"
@change="change('quantityFilled', ...arguments)"
/>
<div class="layout row wrap justify-space-between">
<smart-switch
label="Ignored"
style="width: 200px; flex-grow: 0;"
class="mx-2"
:value="model.ignored"
:error-messages="errors.ignored"
@change="change('ignored', ...arguments)"
/>
</div>
</form-section>
</div>
</template>
<script>
import propertyFormMixin from '/imports/ui/properties/forms/shared/propertyFormMixin.js';
import FormSection from '/imports/ui/properties/forms/shared/FormSection.vue';
import CalculationErrorList from '/imports/ui/properties/forms/shared/CalculationErrorList.vue';
import PROPERTIES from '/imports/constants/PROPERTIES.js';
export default {
components: {
FormSection,
CalculationErrorList,
},
mixins: [propertyFormMixin],
data(){
let slotTypes = [];
for (let key in PROPERTIES){
slotTypes.push({text: PROPERTIES[key].name, value: key});
}
return {slotTypes};
},
};
</script>

View File

@@ -1,5 +1,24 @@
<template lang="html">
<div class="attribute-form">
<div class="spell-form">
<div class="layout row wrap justify-space-between">
<smart-switch
label="Always prepared"
style="width: 200px; flex-grow: 0;"
class="mx-2"
:value="model.alwaysPrepared"
:error-messages="errors.alwaysPrepared"
@change="change('alwaysPrepared', ...arguments)"
/>
<smart-switch
v-show="!model.alwaysPrepared"
label="Prepared"
style="width: 200px; flex-grow: 0;"
class="mx-2"
:value="model.prepared"
:error-messages="errors.prepared"
@change="change('prepared', ...arguments)"
/>
</div>
<text-field
ref="focusFirst"
label="Name"
@@ -27,16 +46,6 @@
@change="change('school', ...arguments)"
/>
</div>
<div class="layout row wrap">
<smart-switch
label="Always prepared"
style="width: 200px; flex-grow: 0;"
class="ml-2"
:value="model.alwaysPrepared"
:error-messages="errors.alwaysPrepared"
@change="change('alwaysPrepared', ...arguments)"
/>
</div>
<text-field
label="Casting Time"
:value="model.castingTime"
@@ -94,19 +103,6 @@
@change="change('description', ...arguments)"
/>
<form-sections>
<form-section
name="Advanced"
>
<smart-combobox
label="Spell lists"
multiple
chips
deletable-chips
:value="model.spellLists"
:error-messages="errors.spellLists"
@change="change('spellLists', ...arguments)"
/>
</form-section>
<form-section
name="Casting"
>

View File

@@ -8,6 +8,14 @@
:error-messages="errors.name"
@change="change('name', ...arguments)"
/>
<text-field
label="Variable name"
:value="model.variableName"
style="flex-basis: 300px;"
hint="This name is used by spells to reference which lists they appear on"
:error-messages="errors.variableName"
@change="change('variableName', ...arguments)"
/>
</div>
<text-area
label="Description"
@@ -22,13 +30,18 @@
:error-messages="errors.maxPrepared"
@change="change('maxPrepared', ...arguments)"
/>
<calculation-error-list :errors="model.maxPreparedErrors" />
</div>
</template>
<script>
import propertyFormMixin from '/imports/ui/properties/forms/shared/propertyFormMixin.js';
import CalculationErrorList from '/imports/ui/properties/forms/shared/CalculationErrorList.vue';
export default {
components: {
CalculationErrorList,
},
mixins: [propertyFormMixin],
};
</script>

View File

@@ -16,6 +16,7 @@ import ProficiencyForm from '/imports/ui/properties/forms/ProficiencyForm.vue';
import RollForm from '/imports/ui/properties/forms/RollForm.vue';
import SavingThrowForm from '/imports/ui/properties/forms/SavingThrowForm.vue';
import SkillForm from '/imports/ui/properties/forms/SkillForm.vue';
import SlotForm from '/imports/ui/properties/forms/SlotForm.vue';
import SpellListForm from '/imports/ui/properties/forms/SpellListForm.vue';
import SpellForm from '/imports/ui/properties/forms/SpellForm.vue';
import ToggleForm from '/imports/ui/properties/forms/ToggleForm.vue';
@@ -39,6 +40,7 @@ export default {
roll: RollForm,
savingThrow: SavingThrowForm,
skill: SkillForm,
propertySlot: SlotForm,
spellList: SpellListForm,
spell: SpellForm,
toggle: ToggleForm,

View File

@@ -1,20 +1,45 @@
<template lang="html">
<div>
<v-container fluid grid-list-lg fill-height>
<v-layout row wrap fill-height>
<v-flex v-for="(property, type) in PROPERTIES" :key="type" sm4 xs6>
<v-card hover @click="$emit('select', type)" style="height: 100%; overflow: hidden;">
<div class="layout row align-center justify-center" style="min-height: 70px;">
<v-icon x-large>{{property.icon}}</v-icon>
</div>
<h3 class="subtitle pb-3" style="text-align: center;">
{{ property.name }}
</h3>
</v-card>
</v-flex>
</v-layout>
</v-container>
</div>
<div>
<v-container
fluid
grid-list-lg
fill-height
>
<v-layout
row
wrap
fill-height
>
<v-flex
v-for="(property, type) in PROPERTIES"
:key="type"
sm4
xs6
>
<v-card
hover
style="height: 100%; overflow: hidden;"
@click="$emit('select', type)"
>
<div
class="layout row align-center justify-center"
style="min-height: 70px;"
>
<v-icon x-large>
{{ property.icon }}
</v-icon>
</div>
<h3
class="subtitle pb-3"
style="text-align: center;"
>
{{ property.name }}
</h3>
</v-card>
</v-flex>
</v-layout>
</v-container>
</div>
</template>
<script>

View File

@@ -4,7 +4,7 @@
<property-variable-name :value="model.variableName" />
<property-field
name="Maximum prepared spells"
:value="model.maxPrepared"
:value="model.maxPreparedResult"
/>
<property-description
v-if="model.description"

View File

@@ -20,6 +20,9 @@ import InviteSuccess from '/imports/ui/pages/InviteSuccess.vue' ;
import InviteError from '/imports/ui/pages/InviteError.vue' ;
import NotImplemented from '/imports/ui/pages/NotImplemented.vue';
import PatreonLevelTooLow from '/imports/ui/pages/PatreonLevelTooLow.vue';
import Tabletops from '/imports/ui/pages/Tabletops.vue';
import Tabletop from '/imports/ui/pages/Tabletop.vue';
import TabletopToolbar from '/imports/ui/tabletop/TabletopToolbar.vue';
let userSubscription = Meteor.subscribe('user');
@@ -143,6 +146,19 @@ RouterFactory.configure(factory => {
meta: {
title: 'Character Sheet',
},
},{
path: '/tabletops',
name: 'tabletops',
component: Tabletops,
beforeEnter: ensureLoggedIn,
},{
path: '/tabletop/:id',
name: 'tabletop',
components: {
default: Tabletop,
toolbar: TabletopToolbar,
},
beforeEnter: ensureLoggedIn,
},{
path: '/friends',
components: {

View File

@@ -15,7 +15,7 @@
/>
<text-field
v-if="model.public && docRef.collection === 'libraries'"
disabled
readonly
label="Link"
:value="'https://beta.dicecloud.com' + this.$router.resolve({
name: 'singleLibrary',

View File

@@ -0,0 +1,66 @@
<template lang="html">
<dialog-base>
<v-toolbar-title slot="toolbar">
Add Characters
</v-toolbar-title>
<v-list>
<p v-if="!creatures.length">
There are no creatures to add or you have already added them all
</p>
<creature-list-tile
v-for="creature in creatures"
:key="creature._id"
:model="creature"
:selected="selected"
selection
@select="toggleSelect(creature._id)"
/>
</v-list>
<template slot="actions">
<v-spacer />
<v-btn
flat
color="primary"
@click="$store.dispatch('popDialogStack', selected)"
>
Add characters
</v-btn>
</template>
</dialog-base>
</template>
<script>
import DialogBase from '/imports/ui/dialogStack/DialogBase.vue';
import Creatures from '/imports/api/creature/Creatures.js';
import CreatureListTile from '/imports/ui/creature/CreatureListTile.vue';
export default {
components: {
DialogBase,
CreatureListTile,
},
props: {
startingSelection: {
type: Array,
default: () => [],
},
},
data(){return {
selected: new Set(),
}},
meteor: {
creatures(){
return Creatures.find({_id: {$nin: this.startingSelection}});
},
},
methods: {
toggleSelect(id){
let hadId = this.selected.delete(id);
if (!hadId) this.selected.add(id);
},
}
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -0,0 +1,51 @@
<template lang="html">
<div class="action-cards">
<action-card
v-for="action in actions"
:key="action._id"
:model="action"
/>
</div>
</template>
<script>
import getActiveProperties from '/imports/api/creature/getActiveProperties.js';
import ActionCard from '/imports/ui/properties/components/actions/ActionCard.vue';
function getProperties(ancestorId, type){
if (!ancestorId) return [];
return getActiveProperties({
ancestorId,
filter: {type},
});
}
export default {
components: {
ActionCard,
},
props: {
creatureId: {
type: String,
default: undefined,
},
},
data(){ return {
actionType: 'action',
}},
meteor: {
actions(){
return getProperties(this.creatureId, 'action');
},
attacks(){
return getProperties(this.creatureId, 'attack');
},
spells(){
return getProperties(this.creatureId, 'spell');
},
}
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -0,0 +1,100 @@
<template lang="html">
<div class="tabletop">
<section class="initiative-row layout row center">
<tabletop-creature-card
v-for="creature in creatures"
:key="creature._id"
:model="creature"
/>
<v-card
class="layout column justify-center align-center"
style="height: 162px; width: 100px;"
data-id="select-creatures"
hover
@click="addCreature"
>
<div class="flex layout row justify-center align-center">
<v-icon>add</v-icon>
</div>
<v-card-title>
Add creature
</v-card-title>
</v-card>
</section>
<section class="play-area">
<tabletop-map />
<tabletop-log :tabletop-id="model._id" />
</section>
<section class="action-row">
<mini-character-sheet />
<tabletop-action-cards />
</section>
</div>
</template>
<script>
import { addCreaturesToTabletop } from '/imports/api/tabletop/Tabletops.js';
import TabletopCreatureCard from '/imports/ui/tabletop/TabletopCreatureCard.vue';
import TabletopMap from '/imports/ui/tabletop/TabletopMap.vue';
import TabletopLog from '/imports/ui/tabletop/TabletopLog.vue';
import Creatures from '/imports/api/creature/Creatures.js';
import TabletopActionCards from '/imports/ui/tabletop/TabletopActionCards.vue';
import MiniCharacterSheet from '/imports/ui/creature/character/MiniCharacterSheet.vue';
export default {
components: {
TabletopCreatureCard,
TabletopMap,
TabletopLog,
TabletopActionCards,
MiniCharacterSheet,
},
props: {
model: {
type: Object,
required: true,
},
},
data(){ return {
activeCreature: undefined,
}},
meteor: {
$subscribe:{
'tabletop'(){
return [this.model._id];
},
},
creatures(){
return Creatures.find({tabletop: this.model._id});
},
},
methods: {
addCreature(){
this.$store.commit('pushDialogStack', {
component: 'select-creatures-dialog',
elementId: 'select-creatures',
data: {
startingSelection: this.creatures.map(c => c._id),
},
callback: (characterSet) => {
if (!characterSet) return;
addCreaturesToTabletop.call({
tabletopId: this.model._id,
creatureIds: Array.from(characterSet),
});
},
});
}
}
}
</script>
<style lang="css" scoped>
.initiative-row > .v-card {
flex-grow: 0;
height: 162px;
width: 100px;
margin: 4px;
}
</style>

View File

@@ -0,0 +1,24 @@
<template lang="html">
<v-card>
<v-img
:src="model.picture"
aspect-ratio="1"
position="top center"
/>
<v-card-title>{{ model.name }}</v-card-title>
</v-card>
</template>
<script>
export default {
props: {
model: {
type: Object,
required: true,
},
},
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -0,0 +1,55 @@
<template lang="html">
<div class="tabletop-log">
<div class="messages layout column justify-end align-end">
<div
v-for="message in messages"
:key="message._id"
class="message"
>
{{ message.content }}
</div>
</div>
<v-textarea
v-model="messageContent"
@keyup.enter.prevent="sendMessage"
/>
</div>
</template>
<script>
import Messages, { sendMessage } from '/imports/api/tabletop/Messages.js';
export default {
props: {
tabletopId: {
type: String,
required: true,
},
},
data(){ return {
messageContent: '',
}},
meteor: {
messages() {
return Messages.find({
tabletopId: this.tabletopId,
}, {
sort: {
timeStamp: 1,
},
});
}
},
methods: {
sendMessage(){
sendMessage.call({
content: this.messageContent,
tabletopId: this.tabletopId,
});
this.messageContent = '';
},
},
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -0,0 +1,11 @@
<template lang="html">
<div class="tabletop-map" />
</template>
<script>
export default {
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -0,0 +1,29 @@
<template lang="html">
<v-toolbar
app
color="secondary"
dark
dense
>
<v-toolbar-side-icon @click="toggleDrawer" />
<v-toolbar-title>
Tabletop
</v-toolbar-title>
<v-spacer />
</v-toolbar>
</template>
<script>
import { mapMutations } from 'vuex';
export default {
methods: {
...mapMutations([
'toggleDrawer',
]),
}
}
</script>
<style lang="css" scoped>
</style>

99
app/package-lock.json generated
View File

@@ -43,6 +43,21 @@
"resolved": "https://registry.npmjs.org/@chenfengyuan/vue-countdown/-/vue-countdown-1.1.5.tgz",
"integrity": "sha512-BB0taTfJzxsXFUPioREWLKpMDdHOoD8EiSW6lhB3yCdJh3I7jiNQzC8Hw5dPxoPNgZekeEPMQwsdPkjQPyxIeA=="
},
"@discordjs/collection": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-0.1.5.tgz",
"integrity": "sha512-CU1q0UXQUpFNzNB7gufgoisDHP7n+T3tkqTsp3MNUkVJ5+hS3BCvME8uCXAUFlz+6T2FbTCu75A+yQ7HMKqRKw=="
},
"@discordjs/form-data": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@discordjs/form-data/-/form-data-3.0.1.tgz",
"integrity": "sha512-ZfFsbgEXW71Rw/6EtBdrP5VxBJy4dthyC0tpQKGKmYFImlmmrykO14Za+BiIVduwjte0jXEBlhSKf0MWbFp9Eg==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
},
"@types/color-name": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
@@ -54,6 +69,14 @@
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="
},
"abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"requires": {
"event-target-shim": "^5.0.0"
}
},
"acorn": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.1.tgz",
@@ -142,6 +165,11 @@
"integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==",
"dev": true
},
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
},
"babel-runtime": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
@@ -330,6 +358,14 @@
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
},
"combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"requires": {
"delayed-stream": "~1.0.0"
}
},
"commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@@ -428,6 +464,11 @@
"integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=",
"dev": true
},
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
},
"delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
@@ -448,6 +489,21 @@
"resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz",
"integrity": "sha1-44Mx8IRLukm5qctxx3FYWqsbxlo="
},
"discord.js": {
"version": "12.2.0",
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-12.2.0.tgz",
"integrity": "sha512-Ueb/0SOsxXyqwvwFYFe0msMrGqH1OMqpp2Dpbplnlr4MzcRrFWwsBM9gKNZXPVBHWUKiQkwU8AihXBXIvTTSvg==",
"requires": {
"@discordjs/collection": "^0.1.5",
"@discordjs/form-data": "^3.0.1",
"abort-controller": "^3.0.0",
"node-fetch": "^2.6.0",
"prism-media": "^1.2.0",
"setimmediate": "^1.0.5",
"tweetnacl": "^1.0.3",
"ws": "^7.2.1"
}
},
"doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@@ -670,6 +726,11 @@
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true
},
"event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
},
"extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -2020,6 +2081,19 @@
}
}
},
"mime-db": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz",
"integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg=="
},
"mime-types": {
"version": "2.1.27",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz",
"integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==",
"requires": {
"mime-db": "1.44.0"
}
},
"mimic-fn": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.0.0.tgz",
@@ -2130,6 +2204,11 @@
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
"dev": true
},
"node-fetch": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz",
"integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA=="
},
"node-pre-gyp": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.14.0.tgz",
@@ -2321,6 +2400,11 @@
"integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=",
"dev": true
},
"prism-media": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.2.2.tgz",
"integrity": "sha512-I+nkWY212lJ500jLe4tN9tWO7nRiBAVdMv76P9kffZjYhw20raMlW1HSSvS+MLXC9MmbNZCazMrAr+5jEEgTuw=="
},
"process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -2503,6 +2587,11 @@
"resolved": false,
"integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc="
},
"setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU="
},
"shebang-command": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
@@ -2720,6 +2809,11 @@
"integrity": "sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA==",
"dev": true
},
"tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="
},
"type-check": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
@@ -2918,6 +3012,11 @@
"mkdirp": "^0.5.1"
}
},
"ws": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.3.0.tgz",
"integrity": "sha512-iFtXzngZVXPGgpTlP1rBqsUK82p9tKqsWRPg5L56egiljujJT3vGAYnHANvFxBieXrTFavhzhxW52jnaWV+w2w=="
},
"y18n": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz",

View File

@@ -25,6 +25,7 @@
"css-box-shadow": "^1.0.0-3",
"date-fns": "^1.30.1",
"ddp-rate-limiter-mixin": "^1.1.10",
"discord.js": "^12.2.0",
"dompurify": "^2.0.10",
"lodash": "^4.17.15",
"marked": "^0.8.2",