Compare commits

...

5 Commits

Author SHA1 Message Date
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
17 changed files with 402 additions and 133 deletions

View File

@@ -52,3 +52,4 @@ bozhao:link-accounts
peerlibrary:reactive-publish peerlibrary:reactive-publish
simple:rest simple:rest
simple:rest-method-mixin 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:browser-tests@1.3.3
meteortesting:mocha@1.1.5 meteortesting:mocha@1.1.5
meteortesting:mocha-core@7.0.1 meteortesting:mocha-core@7.0.1
mikowals:batch-insert@1.1.9
minifier-css@1.5.0 minifier-css@1.5.0
minifier-js@2.6.0 minifier-js@2.6.0
minimongo@1.6.0 minimongo@1.6.0

View File

@@ -0,0 +1,11 @@
import SimpleSchema from 'simpl-schema';
let Campaigns = new Mongo.Collection('campaigns');
let CampaignSchema = new SimpleSchema({
});
Campaigns.attachSchema(CampaignSchema);
export default Campaigns;

View File

@@ -1,9 +1,51 @@
import SimpleSchema from 'simpl-schema'; import SimpleSchema from 'simpl-schema';
let Encounters = new Mongo.Collection("encounters"); let Encounters = new Mongo.Collection('encounters');
const CreatureInitiativeSchema = new SimpleSchema({
name: {
type: String,
optional: true,
},
initiativeRoll: {
type: SimpleSchema.Integer,
},
});
const InitiativeSchema = new SimpleSchema({
// An ordered list of all creatures in the initiative order
creatures: {
type: Array,
defaultValue: [],
},
'creatures.$': {
type: CreatureInitiativeSchema,
},
active: {
type: Boolean,
defaultValue: false,
},
roundNumber: {
type: SimpleSchema.Integer,
defaultValue: 0,
},
initiativeNumber: {
type: SimpleSchema.Integer,
optional: true,
},
});
// A creature can be in one ecounter at a time.
// All creatures in an encounter have a shared time and space.
let EncounterSchema = new SimpleSchema({ let EncounterSchema = new SimpleSchema({
//an encounter is a single flow of time all parties in an encounter are in-sync time wise name: {
type: String,
optional: true,
},
initiative: {
type: InitiativeSchema,
defaultValue: {},
},
}); });
Encounters.attachSchema(EncounterSchema); Encounters.attachSchema(EncounterSchema);

View File

@@ -1,22 +1,21 @@
import SimpleSchema from 'simpl-schema'; import SimpleSchema from 'simpl-schema';
let Parties = new Mongo.Collection("parties"); let Parties = new Mongo.Collection('parties');
let partySchema = new SimpleSchema({ let partySchema = new SimpleSchema({
name: { name: {
type: String, type: String,
defaultValue: "New Party", defaultValue: 'New Party',
trim: false, trim: false,
optional: true, optional: true,
}, },
characters: { creatures: {
type: Array, type: Array,
defaultValue: [], defaultValue: [],
}, },
characters: { 'creatures.$': {
type: String, type: String,
regEx: SimpleSchema.RegEx.Id, regEx: SimpleSchema.RegEx.Id,
index: 1,
}, },
owner: { owner: {
type: String, type: String,
@@ -26,24 +25,4 @@ let partySchema = new SimpleSchema({
Parties.attachSchema(partySchema); 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; export default Parties;

View File

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

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,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,19 @@
import evaluateString from '/imports/api/creature/computation/afterComputation/evaluateString.js';
export default function applyDamage({
prop,
creature,
targets,
actionContext
}){
let damageTargets = prop.target === 'self' ? [creature] : targets;
let scope = {
...creature.variables,
...actionContext,
};
let {result, errors} = evaluateString(prop.amount, scope);
if (Meteor.isClient) errors.forEach(e => console.error(e));
if (Number.isFinite(result)) {
damageTargets.forEach()
}
}

View File

@@ -0,0 +1,62 @@
import applyAction from '/imports/api/creature/actions/applyAction.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':
case 'attack':
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 SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; 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 { assertEditPermission } from '/imports/api/creature/creaturePermissions.js';
import { recomputeCreatureByDoc } from '/imports/api/creature/computation/recomputeCreature.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({ const doAction = new ValidatedMethod({
name: 'creatureProperties.doAction', name: 'creatureProperties.doAction',
validate: new SimpleSchema({ validate: new SimpleSchema({
actionId: SimpleSchema.RegEx.Id, actionId: SimpleSchema.RegEx.Id,
targetId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
optional: true,
},
}).validator(), }).validator(),
mixins: [RateLimiterMixin], mixins: [RateLimiterMixin],
rateLimit: { rateLimit: {
numRequests: 10, numRequests: 10,
timeInterval: 5000, timeInterval: 5000,
}, },
run({actionId}) { run({actionId, targetId}) {
let action = CreatureProperties.findOne(actionId); let action = CreatureProperties.findOne(actionId);
// Check permissions // Check permissions
let creature = getCreature(action); let creature = getCreature(action);
assertEditPermission(creature, this.userId); 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 // Note this only recomputes the top-level creature, not the nearest one
recomputeCreatureByDoc(creature); recomputeCreatureByDoc(creature);
if (target){
recomputeCreatureByDoc(target);
}
}, },
}); });
function doActionWork(action){ function doActionWork({action, creature, target}){
spendResources(action); let actionContext = {};
} let decendantForest = nodesToTree({
collection: CreatureProperties,
function spendResources(action){ ancestorId: action._id,
// 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 let startingForest = [{
// Now that we have confirmed that there are no errors, do actual work node: action,
//Items children: decendantForest,
itemQuantityAdjustments.forEach(adjustQuantityWork); }];
// Use uses applyProperties({
CreatureProperties.update(action._id, { forest: startingForest,
$inc: {usesUsed: 1} creature,
}, { target,
selector: action actionContext
});
// 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

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

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

View File

@@ -130,7 +130,7 @@ export function renewDocIds({docArray, collectionMap}){
const remapReference = ref => { const remapReference = ref => {
if (idMap[ref.id]){ if (idMap[ref.id]){
ref.id = 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 => { docArray.forEach(doc => {
@@ -204,17 +204,11 @@ export function getName(doc){
} }
} }
export function nodesToTree({collection, ancestorId, filter, options}){ export function nodeArrayToTree(nodes){
// Store a dict of all the nodes // Store a dict of all the nodes
let nodeIndex = {}; let nodeIndex = {};
let nodeList = []; let nodeList = [];
if (!options) options = {}; nodes.forEach( node => {
options.sort = {order: 1};
collection.find({
'ancestors.id': ancestorId,
removed: {$ne: true},
...filter,
}, options).forEach( node => {
let treeNode = { let treeNode = {
node: node, node: node,
children: [], children: [],
@@ -238,3 +232,14 @@ export function nodesToTree({collection, ancestorId, filter, options}){
}); });
return forest; return forest;
} }
export function nodesToTree({collection, ancestorId, filter, options}){
if (!options) options = {};
options.sort = {order: 1};
let nodes = collection.find({
'ancestors.id': ancestorId,
removed: {$ne: true},
...filter,
}, options);
return nodeArrayToTree(nodes);
}

View File

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

View File

@@ -21,6 +21,39 @@
</v-card-text> </v-card-text>
</v-card> </v-card>
</div> </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"> <div class="ability-scores">
<v-card> <v-card>
<v-list> <v-list>
@@ -165,6 +198,30 @@
</v-card> </v-card>
</div> </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 <div
v-if="weapons && weapons.length" v-if="weapons && weapons.length"
class="weapon-proficiencies" class="weapon-proficiencies"
@@ -187,7 +244,7 @@
</div> </div>
<div <div
v-if="armors && armors.length" v-if="armors && armors.length"
class="weapon-proficiencies" class="armor-proficiencies"
> >
<v-card> <v-card>
<v-list> <v-list>
@@ -207,7 +264,7 @@
</div> </div>
<div <div
v-if="tools && tools.length" v-if="tools && tools.length"
class="weapon-proficiencies" class="tool-proficiencies"
> >
<v-card> <v-card>
<v-list> <v-list>
@@ -245,37 +302,13 @@
</v-list> </v-list>
</v-card> </v-card>
</div> </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> </column-layout>
</div> </div>
</template> </template>
<script> <script>
import Creatures from '/imports/api/creature/Creatures.js'; 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 AttributeCard from '/imports/ui/properties/components/attributes/AttributeCard.vue';
import AbilityListTile from '/imports/ui/properties/components/attributes/AbilityListTile.vue'; import AbilityListTile from '/imports/ui/properties/components/attributes/AbilityListTile.vue';
import ColumnLayout from '/imports/ui/components/ColumnLayout.vue'; import ColumnLayout from '/imports/ui/components/ColumnLayout.vue';
@@ -381,6 +414,9 @@
actions(){ actions(){
return getProperties(this.creature, {type: 'action'}); return getProperties(this.creature, {type: 'action'});
}, },
appliedBuffs(){
return getProperties(this.creature, {type: 'buff', applied: true});
},
attacks(){ attacks(){
let props = getProperties(this.creature, {type: 'attack'}).map(attack => { let props = getProperties(this.creature, {type: 'attack'}).map(attack => {
attack.children = getActiveProperties({ attack.children = getActiveProperties({
@@ -409,6 +445,11 @@
if (!obj) return 0; if (!obj) return 0;
return Object.keys(obj).length; return Object.keys(obj).length;
}, },
softRemove(_id){
softRemoveProperty.call({_id}, error => {
if (error) console.error(error);
});
}
}, },
}; };
</script> </script>

View File

@@ -13,6 +13,7 @@
:error-messages="errors.description" :error-messages="errors.description"
@change="change('description', ...arguments)" @change="change('description', ...arguments)"
/> />
<!-- Duration not implemented yet
<text-field <text-field
label="Duration" label="Duration"
hint="How long the buff lasts" hint="How long the buff lasts"
@@ -20,6 +21,16 @@
:error-messages="errors.duration" :error-messages="errors.duration"
@change="change('duration', ...arguments)" @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> </div>
</template> </template>