diff --git a/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js b/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js index c35546de..f1c0c832 100644 --- a/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js +++ b/app/imports/api/engine/actions/applyPropertyByType/applyDamage.js @@ -1,6 +1,6 @@ import { some, intersection, difference, remove, includes } from 'lodash'; import applyProperty from '../applyProperty.js'; -import {insertCreatureLog} from '/imports/api/creature/log/CreatureLogs.js'; +import { insertCreatureLog } from '/imports/api/creature/log/CreatureLogs.js'; import resolve, { Context, toString } from '/imports/parser/resolve.js'; import logErrors from './shared/logErrors.js'; import applyEffectsToCalculationParseNode from '/imports/api/engine/actions/applyPropertyByType/shared/applyEffectsToCalculationParseNode.js'; @@ -10,9 +10,9 @@ import { } from '/imports/api/engine/loadCreatures.js'; import { applyNodeTriggers } from '/imports/api/engine/actions/applyTriggers.js'; -export default function applyDamage(node, actionContext){ +export default function applyDamage(node, actionContext) { applyNodeTriggers(node, 'before', actionContext); - const applyChildren = function(){ + const applyChildren = function () { applyNodeTriggers(node, 'after', actionContext); node.children.forEach(child => applyProperty(child, actionContext)); }; @@ -28,10 +28,10 @@ export default function applyDamage(node, actionContext){ // Determine if the hit is critical let criticalHit = scope['$criticalHit']?.value && prop.damageType !== 'healing' // Can't critically heal - ; + ; // Double the damage rolls if the hit is critical let context = new Context({ - options: {doubleRolls: criticalHit}, + options: { doubleRolls: criticalHit }, }); // Gather all the lines we need to log into an array @@ -40,8 +40,8 @@ export default function applyDamage(node, actionContext){ // roll the dice only and store that string applyEffectsToCalculationParseNode(prop.amount, actionContext.log); - const {result: rolled} = resolve('roll', prop.amount.parseNode, scope, context); - if (rolled.parseType !== 'constant'){ + const { result: rolled } = resolve('roll', prop.amount.parseNode, scope, context); + if (rolled.parseType !== 'constant') { logValue.push(toString(rolled)); } logErrors(context.errors, actionContext); @@ -50,13 +50,13 @@ export default function applyDamage(node, actionContext){ context.errors = []; // Resolve the roll to a final value - const {result: reduced} = resolve('reduce', rolled, scope, context); + const { result: reduced } = resolve('reduce', rolled, scope, context); logErrors(context.errors, actionContext); // Store the result - if (reduced.parseType === 'constant'){ + if (reduced.parseType === 'constant') { prop.amount.value = reduced.value; - } else if (reduced.parseType === 'error'){ + } else if (reduced.parseType === 'error') { prop.amount.value = null; } else { prop.amount.value = toString(reduced); @@ -64,7 +64,7 @@ export default function applyDamage(node, actionContext){ let damage = +reduced.value; // If we didn't end up with a constant of finite amount, give up - if (reduced?.parseType !== 'constant' || !isFinite(reduced.value)){ + if (reduced?.parseType !== 'constant' || !isFinite(reduced.value)) { return applyChildren(); } @@ -83,7 +83,7 @@ export default function applyDamage(node, actionContext){ // Memoise the damage suffix for the log let suffix = (criticalHit ? ' critical ' : ' ') + prop.damageType + - (prop.damageType !== 'healing' ? ' damage ': ''); + (prop.damageType !== 'healing' ? ' damage ' : ''); if (damageTargets && damageTargets.length) { // Iterate through all the targets @@ -107,7 +107,7 @@ export default function applyDamage(node, actionContext){ }); // Log the damage done - if (target._id === actionContext.creature._id){ + if (target._id === actionContext.creature._id) { // Target is same as self, log damage as such logValue.push(`**${damageDealt}** ${suffix} to self`); } else { @@ -136,33 +136,33 @@ export default function applyDamage(node, actionContext){ return applyChildren(); } -function applyDamageMultipliers({target, damage, damageProp, logValue}){ +function applyDamageMultipliers({ target, damage, damageProp, logValue }) { const damageType = damageProp?.damageType; if (!damageType) return damage; const multiplier = target?.variables?.[damageType]; if (!multiplier) return damage; - const damageTypeText = damageType == 'healing' ? 'healing': `${damageType} damage`; + const damageTypeText = damageType == 'healing' ? 'healing' : `${damageType} damage`; if ( multiplier.immunity && some(multiplier.immunities, multiplierAppliesTo(damageProp, 'immunity')) - ){ + ) { logValue.push(`Immune to ${damageTypeText}`); return 0; } else { if ( multiplier.resistance && some(multiplier.resistances, multiplierAppliesTo(damageProp, 'resistance')) - ){ + ) { logValue.push(`Resistant to ${damageTypeText}`); damage = Math.floor(damage / 2); } if ( multiplier.vulnerability && some(multiplier.vulnerabilities, multiplierAppliesTo(damageProp, 'vulnerability')) - ){ + ) { logValue.push(`Vulnerable to ${damageTypeText}`); damage = Math.floor(damage * 2); } @@ -170,7 +170,7 @@ function applyDamageMultipliers({target, damage, damageProp, logValue}){ return damage; } -function multiplierAppliesTo(damageProp, multiplierType){ +function multiplierAppliesTo(damageProp, multiplierType) { return multiplier => { // Apply the default 'ignore x' tags if (includes(damageProp.tags, `ignore ${multiplierType}`)) return false; @@ -187,7 +187,7 @@ function multiplierAppliesTo(damageProp, multiplierType){ } } -function dealDamage({target, damageType, amount, actionContext}){ +function dealDamage({ target, damageType, amount, actionContext }) { // Get all the health bars and do damage to them let healthBars = getPropertiesOfType(target._id, 'attribute'); @@ -239,6 +239,14 @@ function dealDamage({target, damageType, amount, actionContext}){ actionContext }); damageLeft -= damageAdded; + // Prevent overflow + if ( + damageType === 'healing' ? + healthBar.healthBarNoHealingOverflow : + healthBar.healthBarNoDamageOverflow + ) { + damageLeft = 0; + } }); return totalDamage; } diff --git a/app/imports/api/library/methods/copyLibraryNodeTo.js b/app/imports/api/library/methods/copyLibraryNodeTo.js new file mode 100644 index 00000000..f99b184f --- /dev/null +++ b/app/imports/api/library/methods/copyLibraryNodeTo.js @@ -0,0 +1,97 @@ +import { ValidatedMethod } from 'meteor/mdg:validated-method'; +import SimpleSchema from 'simpl-schema'; +import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; +import { RefSchema } from '/imports/api/parenting/ChildSchema.js'; +import LibraryNodes from '/imports/api/library/LibraryNodes.js'; +import { + assertDocCopyPermission, + assertDocEditPermission +} from '/imports/api/sharing/sharingPermissions.js'; +import { + setLineageOfDocs, + renewDocIds +} from '/imports/api/parenting/parenting.js'; +import { reorderDocs } from '/imports/api/parenting/order.js'; +import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js'; + +var snackbar; +if (Meteor.isClient) { + snackbar = require( + '/imports/ui/components/snackbars/SnackbarQueue.js' + ).snackbar +} + +const DUPLICATE_CHILDREN_LIMIT = 500; + +const copyLibraryNodeTo = new ValidatedMethod({ + name: 'libraryNodes.copyTo', + validate: new SimpleSchema({ + _id: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + parent: { + type: RefSchema, + }, + }).validator(), + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 1, + timeInterval: 10000, + }, + run({ _id, parent }) { + if (parent.collection !== 'libraryNodes' && parent.collection !== 'libraries') { + throw new Meteor.Error('Invalid destination', + 'Library documents can only be copied to destinations inside other libraries' + ); + } + const libraryNode = LibraryNodes.findOne(_id); + const parentDoc = fetchDocByRef(parent); + assertDocCopyPermission(libraryNode, this.userId); + assertDocEditPermission(parentDoc, this.userId); + + let decendants = LibraryNodes.find({ + 'ancestors.id': _id, + removed: { $ne: true }, + }, { + limit: DUPLICATE_CHILDREN_LIMIT + 1, + sort: { order: 1 }, + }).fetch(); + + if (decendants.length > DUPLICATE_CHILDREN_LIMIT) { + decendants.pop(); + if (Meteor.isClient) { + snackbar({ + text: `Only the first ${DUPLICATE_CHILDREN_LIMIT} children were duplicated`, + }); + } + } + + const nodes = [libraryNode, ...decendants]; + + const newAncestry = parentDoc.ancestors || []; + newAncestry.push(parent); + // re-map all the ancestors + setLineageOfDocs({ + docArray: nodes, + newAncestry, + oldParent: libraryNode.parent, + }); + + // Give the docs new IDs without breaking internal references + renewDocIds({ docArray: nodes }); + + // Order the root node + libraryNode.order = (parentDoc.order || 0) + 0.5; + + LibraryNodes.batchInsert(nodes); + + // Tree structure changed by inserts, reorder the tree + reorderDocs({ + collection: LibraryNodes, + ancestorId: parent.collection === 'libraries' ? parent.id : parentDoc.ancestors[0].id, + }); + }, +}); + +export default copyLibraryNodeTo; diff --git a/app/imports/api/library/methods/duplicateLibraryNode.js b/app/imports/api/library/methods/duplicateLibraryNode.js index 107f3c6b..ff35b8fb 100644 --- a/app/imports/api/library/methods/duplicateLibraryNode.js +++ b/app/imports/api/library/methods/duplicateLibraryNode.js @@ -16,7 +16,7 @@ if (Meteor.isClient) { ).snackbar } -const DUPLICATE_CHILDREN_LIMIT = 50; +const DUPLICATE_CHILDREN_LIMIT = 500; const duplicateLibraryNode = new ValidatedMethod({ name: 'libraryNodes.duplicate', @@ -28,7 +28,7 @@ const duplicateLibraryNode = new ValidatedMethod({ }).validator(), mixins: [RateLimiterMixin], rateLimit: { - numRequests: 5, + numRequests: 1, timeInterval: 5000, }, run({ _id }) { diff --git a/app/imports/api/library/methods/index.js b/app/imports/api/library/methods/index.js index 1b566bc5..e67eec8a 100644 --- a/app/imports/api/library/methods/index.js +++ b/app/imports/api/library/methods/index.js @@ -1,2 +1,3 @@ +import '/imports/api/library/methods/copyLibraryNodeTo.js'; import '/imports/api/library/methods/duplicateLibraryNode.js'; import '/imports/api/library/methods/updateReferenceNode.js'; diff --git a/app/imports/api/properties/Attributes.js b/app/imports/api/properties/Attributes.js index d486d5f9..1052e2e5 100644 --- a/app/imports/api/properties/Attributes.js +++ b/app/imports/api/properties/Attributes.js @@ -69,6 +69,16 @@ let AttributeSchema = createPropertySchema({ type: Boolean, optional: true, }, + // Control how the health bar handles overflow + healthBarNoDamageOverflow: { + type: Boolean, + optional: true, + }, + healthBarNoHealingOverflow: { + type: Boolean, + optional: true, + }, + // Control when the health bar takes damage or healing healthBarDamageOrder: { type: SimpleSchema.Integer, optional: true, @@ -107,6 +117,14 @@ let AttributeSchema = createPropertySchema({ type: Boolean, optional: true, }, + hideWhenTotalZero: { + type: Boolean, + optional: true, + }, + hideWhenValueZero: { + type: Boolean, + optional: true, + }, // Automatically zero the adjustment on these conditions reset: { type: String, diff --git a/app/imports/api/sharing/SharingSchema.js b/app/imports/api/sharing/SharingSchema.js index 2a4f87c3..629f90bb 100644 --- a/app/imports/api/sharing/SharingSchema.js +++ b/app/imports/api/sharing/SharingSchema.js @@ -33,6 +33,10 @@ let SharingSchema = new SimpleSchema({ defaultValue: false, index: 1, }, + readersCanCopy: { + type: Boolean, + optional: true, + }, }); export default SharingSchema; diff --git a/app/imports/api/sharing/sharing.js b/app/imports/api/sharing/sharing.js index f6c8d8b7..7062ff40 100644 --- a/app/imports/api/sharing/sharing.js +++ b/app/imports/api/sharing/sharing.js @@ -27,6 +27,26 @@ const setPublic = new ValidatedMethod({ }, }); +const setReadersCanCopy = new ValidatedMethod({ + name: 'sharing.setReadersCanCopy', + validate: new SimpleSchema({ + docRef: RefSchema, + readersCanCopy: { type: Boolean }, + }).validator(), + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 5, + timeInterval: 5000, + }, + run({ docRef, readersCanCopy }) { + let doc = fetchDocByRef(docRef); + assertOwnership(doc, this.userId); + return getCollectionByName(docRef.collection).update(docRef.id, { + $set: { readersCanCopy }, + }); + }, +}); + const updateUserSharePermissions = new ValidatedMethod({ name: 'sharing.updateUserSharePermissions', validate: new SimpleSchema({ @@ -129,4 +149,4 @@ const transferOwnership = new ValidatedMethod({ }, }); -export { setPublic, updateUserSharePermissions, transferOwnership }; +export { setPublic, setReadersCanCopy, updateUserSharePermissions, transferOwnership }; diff --git a/app/imports/api/sharing/sharingPermissions.js b/app/imports/api/sharing/sharingPermissions.js index 58629c62..5591c59c 100644 --- a/app/imports/api/sharing/sharingPermissions.js +++ b/app/imports/api/sharing/sharingPermissions.js @@ -1,24 +1,25 @@ import { _ } from 'meteor/underscore'; import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js'; -function assertIdValid(userId){ - if (!userId || typeof userId !== 'string'){ +function assertIdValid(userId) { + if (!userId || typeof userId !== 'string') { throw new Meteor.Error('Permission denied', 'No user ID. Are you logged in?'); } } -function assertdocExists(doc){ - if (!doc){ +function assertdocExists(doc) { + if (!doc) { throw new Meteor.Error('Permission denied', 'Permission denied: No such document exists'); } } -export function assertOwnership(doc, userId){ +export function assertOwnership(doc, userId) { assertIdValid(userId); assertdocExists(doc); - if (doc.owner === userId ){ + + if (doc.owner === userId) { return true; } else { throw new Meteor.Error('Permission denied', @@ -37,13 +38,12 @@ export function assertEditPermission(doc, userId) { assertdocExists(doc); const user = Meteor.users.findOne(userId, { fields: { - 'services.patreon': 1, 'roles': 1, } }); // Admin override - if (user.roles && user.roles.includes('admin')){ + if (user.roles && user.roles.includes('admin')) { return true; } @@ -51,7 +51,7 @@ export function assertEditPermission(doc, userId) { if ( doc.owner === userId || _.contains(doc.writers, userId) - ){ + ) { return true; } else { throw new Meteor.Error('Edit permission denied', @@ -59,9 +59,46 @@ export function assertEditPermission(doc, userId) { } } -function getRoot(doc){ +/** + * Assert that the user can edit the root document which manages its own sharing + * permissions. + * + * Warning: the doc and userId must be set by a trusted source + */ +export function assertCopyPermission(doc, userId) { + assertIdValid(userId); assertdocExists(doc); - if (doc.ancestors && doc.ancestors.length && doc.ancestors[0]){ + const user = Meteor.users.findOne(userId, { + fields: { + 'roles': 1, + } + }); + + // Admin override + if (user.roles && user.roles.includes('admin')) { + return true; + } + + // Ensure the user is authorized for this specific document + if ( + doc.owner === userId || + _.contains(doc.writers, userId) + ) { + return true; + } else if ( + (_.contains(doc.readers, userId) || doc.public) && + doc.readersCanCopy + ) { + return true; + } else { + throw new Meteor.Error('Copy permission denied', + 'You do not have permission to copy this document'); + } +} + +function getRoot(doc) { + assertdocExists(doc); + if (doc.ancestors && doc.ancestors.length && doc.ancestors[0]) { return fetchDocByRef(doc.ancestors[0]); } else { return doc; @@ -74,11 +111,22 @@ function getRoot(doc){ * * Warning: the doc and userId must be set by a trusted source */ -export function assertDocEditPermission(doc, userId){ +export function assertDocEditPermission(doc, userId) { let root = getRoot(doc); assertEditPermission(root, userId); } +/** + * Assert that the user can copy a descendant document whose root ancestor + * implements sharing permissions. + * + * Warning: the doc and userId must be set by a trusted source + */ +export function assertDocCopyPermission(doc, userId) { + let root = getRoot(doc); + assertCopyPermission(root, userId); +} + export function assertViewPermission(doc, userId) { assertdocExists(doc); if (doc.public) return true; @@ -88,17 +136,17 @@ export function assertViewPermission(doc, userId) { doc.owner === userId || _.contains(doc.readers, userId) || _.contains(doc.writers, userId) - ){ + ) { return true; } else { - + // Admin override const user = Meteor.users.findOne(userId, { fields: { 'roles': 1, } }); - if (user.roles && user.roles.includes('admin')){ + if (user.roles && user.roles.includes('admin')) { return true; } @@ -113,20 +161,20 @@ export function assertViewPermission(doc, userId) { * * Warning: the doc and userId must be set by a trusted source */ -export function assertDocViewPermission(doc, userId){ +export function assertDocViewPermission(doc, userId) { let root = getRoot(doc); assertViewPermission(root, userId); } -export function assertAdmin(userId){ +export function assertAdmin(userId) { assertIdValid(userId); - let user = Meteor.users.findOne(userId, {fields: {roles: 1}}); - if (!user){ + let user = Meteor.users.findOne(userId, { fields: { roles: 1 } }); + if (!user) { throw new Meteor.Error('Permission denied', 'UserId does not match any existing user'); } let isAdmin = user.roles && user.roles.includes('admin') - if (!isAdmin){ + if (!isAdmin) { throw new Meteor.Error('Permission denied', 'User does not have the admin role'); } diff --git a/app/imports/ui/components/ColorPicker.vue b/app/imports/ui/components/ColorPicker.vue index bb1115f6..cc1423bd 100644 --- a/app/imports/ui/components/ColorPicker.vue +++ b/app/imports/ui/components/ColorPicker.vue @@ -10,6 +10,7 @@ :outlined="!!label" :icon="!label" :min-width="label && 108" + :disabled="context.editPermission === false" v-on="on" > {{ label }} @@ -124,6 +125,9 @@ } export default { + inject: { + context: { default: {} } + }, props: { //hex string value: { diff --git a/app/imports/ui/components/propertyToolbar.vue b/app/imports/ui/components/propertyToolbar.vue index 28b454d7..af6ba016 100644 --- a/app/imports/ui/components/propertyToolbar.vue +++ b/app/imports/ui/components/propertyToolbar.vue @@ -69,6 +69,7 @@ @@ -80,8 +81,23 @@ mdi-content-copy + + + + Copy To + + + + mdi-content-duplicate + + @@ -95,6 +111,7 @@ @@ -157,6 +174,9 @@ export default { PropertyIcon, ColorPicker, }, + inject: { + context: { default: {} } + }, props: { model: { type: Object, diff --git a/app/imports/ui/creature/character/characterSheetTabs/StatsTab.vue b/app/imports/ui/creature/character/characterSheetTabs/StatsTab.vue index e8122e32..a201c69c 100644 --- a/app/imports/ui/creature/character/characterSheetTabs/StatsTab.vue +++ b/app/imports/ui/creature/character/characterSheetTabs/StatsTab.vue @@ -365,6 +365,10 @@ const getProperties = function (creature, filter, options = { filter.removed = { $ne: true }; filter.inactive = { $ne: true }; filter.overridden = { $ne: true }; + filter.$nor = [ + { hideWhenTotalZero: true, total: 0 }, + { hideWhenValueZero: true, value: 0 }, + ]; return CreatureProperties.find(filter, options); }; diff --git a/app/imports/ui/library/LibraryNodeDialog.vue b/app/imports/ui/library/LibraryNodeDialog.vue index e67c0cc2..977f3906 100644 --- a/app/imports/ui/library/LibraryNodeDialog.vue +++ b/app/imports/ui/library/LibraryNodeDialog.vue @@ -8,6 +8,7 @@ :embedded="embedded" @duplicate="duplicate" @move="move" + @copy="copy" @remove="remove" @toggle-editing="editing = !editing" @color-changed="value => change({path: ['color'], value})" @@ -95,10 +96,13 @@ import propertyFormIndex from '/imports/ui/properties/forms/shared/propertyFormIndex.js'; import propertyViewerIndex from '/imports/ui/properties/viewers/shared/propertyViewerIndex.js'; import { get } from 'lodash'; - import { assertDocEditPermission } from '/imports/api/sharing/sharingPermissions.js'; + import { + assertDocEditPermission, assertDocCopyPermission + } from '/imports/api/sharing/sharingPermissions.js'; import { organizeDoc } from '/imports/api/parenting/organizeMethods.js'; import { snackbar } from '/imports/ui/components/snackbars/SnackbarQueue.js'; import getPropertyTitle from '/imports/ui/properties/shared/getPropertyTitle.js'; + import copyLibraryNodeTo from '/imports/api/library/methods/copyLibraryNodeTo.js'; let formIndex = {}; for (let key in propertyFormIndex){ @@ -126,7 +130,7 @@ }, reactiveProvide: { name: 'context', - include: ['editPermission', 'isLibraryForm'], + include: ['editPermission', 'copyPermission', 'isLibraryForm'], }, data(){return { editing: !!this.startInEditTab, @@ -162,6 +166,14 @@ return false; } }, + copyPermission(){ + try { + assertDocCopyPermission(this.model, Meteor.userId()); + return true; + } catch (e) { + return false; + } + }, }, methods: { getPropertyName, @@ -200,6 +212,37 @@ } }); }, + copy(){ + const thisId = this._id; + this.$store.commit('pushDialogStack', { + component: 'move-library-node-dialog', + elementId: 'property-toolbar-menu-button', + data: { + action: 'Copy', + }, + callback(parentId){ + if (!parentId) return; + copyLibraryNodeTo.call({ + _id: thisId, + parent: { + collection: 'libraryNodes', + id: parentId + }, + }, (error) => { + if (error) { + console.error(error); + snackbar({ + text: error.reason || error.message || error.toString(), + }); + } else { + snackbar({ + text: 'Copied successfully', + }); + } + }); + } + }); + }, change({path, value, ack}){ updateLibraryNode.call({_id: this.currentId, path, value}, (error) =>{ if (ack){ diff --git a/app/imports/ui/library/MoveLibraryNodeDialog.vue b/app/imports/ui/library/MoveLibraryNodeDialog.vue index ce412813..f0d22101 100644 --- a/app/imports/ui/library/MoveLibraryNodeDialog.vue +++ b/app/imports/ui/library/MoveLibraryNodeDialog.vue @@ -16,7 +16,7 @@ color="primary" @click="$store.dispatch('popDialogStack', node._id)" > - Move + {{ action || 'Move' }} @@ -30,6 +30,12 @@ export default { DialogBase, LibraryAndNode, }, + props: { + action: { + type: String, + default: undefined, + }, + }, data() { return { node: undefined, diff --git a/app/imports/ui/properties/components/attributes/HealthBarCardContainer.vue b/app/imports/ui/properties/components/attributes/HealthBarCardContainer.vue index 639beee0..db3b9c66 100644 --- a/app/imports/ui/properties/components/attributes/HealthBarCardContainer.vue +++ b/app/imports/ui/properties/components/attributes/HealthBarCardContainer.vue @@ -42,6 +42,10 @@ export default { removed: { $ne: true }, inactive: { $ne: true }, overridden: { $ne: true }, + $nor: [ + { hideWhenTotalZero: true, total: 0 }, + { hideWhenValueZero: true, value: 0 }, + ], }; if (creature.settings.hideUnusedStats) { filter.hide = { $ne: true }; diff --git a/app/imports/ui/properties/forms/AttributeForm.vue b/app/imports/ui/properties/forms/AttributeForm.vue index 42814d86..1d76e1a0 100644 --- a/app/imports/ui/properties/forms/AttributeForm.vue +++ b/app/imports/ui/properties/forms/AttributeForm.vue @@ -106,10 +106,17 @@ /> + + -
- - - + + + + + + + + + + + + + + + + +
+ @@ -126,6 +137,7 @@